247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
import {
|
|
TestWhitelistService,
|
|
normalizeTestPhone,
|
|
} from '../../common/test-whitelist/test-whitelist.service';
|
|
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
|
import {
|
|
flattenSkuOntoProduct,
|
|
listMinOnSalePrice,
|
|
mapSkuDto,
|
|
mapSpecAttrsDto,
|
|
pickDisplaySku,
|
|
resolveOrderSale,
|
|
} from './product-sku.util';
|
|
|
|
export type CatalogViewer = {
|
|
/** C 端用户手机号;无则无法看到白名单商品 */
|
|
phone?: string | null;
|
|
/** 仅总部代下单等运营场景跳过白名单;合伙人端必须遵守白名单 */
|
|
bypassWhitelist?: boolean;
|
|
};
|
|
|
|
@Injectable()
|
|
export class CatalogService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly testWhitelist: TestWhitelistService,
|
|
) {}
|
|
|
|
private async whitelistPhoneSet(): Promise<Set<string>> {
|
|
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
|
select: { phone: true },
|
|
});
|
|
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
|
|
}
|
|
|
|
isVisibleToViewer(
|
|
product: { visibilityWhitelistEnabled: boolean },
|
|
viewer?: CatalogViewer,
|
|
whitelistPhones?: Set<string>,
|
|
): boolean {
|
|
if (viewer?.bypassWhitelist) return true;
|
|
if (!product.visibilityWhitelistEnabled) return true;
|
|
const phone = normalizeTestPhone(viewer?.phone);
|
|
if (!phone) return false;
|
|
if (whitelistPhones) return whitelistPhones.has(phone);
|
|
return false;
|
|
}
|
|
|
|
async listCities() {
|
|
const cities = await this.prisma.commonCity.findMany({
|
|
where: { status: 'ACTIVE' },
|
|
include: {
|
|
partnerAccounts: {
|
|
where: { isPrimary: 1, bindingStatus: 'ACTIVE' },
|
|
orderBy: { createdAt: 'asc' },
|
|
select: { id: true, companyName: true, scopeType: true },
|
|
},
|
|
},
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
return serializeBigInt(
|
|
cities.map((city) => ({
|
|
...city,
|
|
partnerBindingCount: city.partnerAccounts.length,
|
|
partnerBindings: city.partnerAccounts.map((bp) => ({
|
|
partnerAccountId: bp.id.toString(),
|
|
partnerId: bp.id.toString(),
|
|
companyName: bp.companyName,
|
|
scopeType: bp.scopeType,
|
|
})),
|
|
partnerAccounts: undefined,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async listProducts(aromaType?: string, cityCode?: string, viewer?: CatalogViewer) {
|
|
if (cityCode) {
|
|
const city = await this.prisma.commonCity.findFirst({
|
|
where: { code: cityCode, status: 'ACTIVE' },
|
|
});
|
|
if (!city) throw new BadRequestException('该城市暂未开城');
|
|
}
|
|
|
|
const products = await this.prisma.commonProductItem.findMany({
|
|
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
|
|
orderBy: { sortOrder: 'asc' },
|
|
include: {
|
|
coverResource: true,
|
|
skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
|
specAttrs: { select: { id: true } },
|
|
},
|
|
});
|
|
|
|
const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled)
|
|
? await this.whitelistPhoneSet()
|
|
: new Set<string>();
|
|
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones));
|
|
|
|
const productIds = visible.map((p) => p.id);
|
|
const resources = productIds.length
|
|
? await this.prisma.commonResource.findMany({
|
|
where: {
|
|
ownerType: 'PRODUCT',
|
|
ownerId: { in: productIds },
|
|
status: 'ACTIVE',
|
|
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
|
},
|
|
orderBy: { sortOrder: 'asc' },
|
|
})
|
|
: [];
|
|
const resourceMap = groupResourcesByProductId(resources);
|
|
|
|
return serializeBigInt(
|
|
visible.map((p) => {
|
|
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
|
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = p;
|
|
const display = pickDisplaySku(skus);
|
|
const flat = flattenSkuOntoProduct(p, display);
|
|
const minPrice = listMinOnSalePrice(skus);
|
|
const price = minPrice ?? flat.price;
|
|
const benefitAmount = flat.benefitAmount;
|
|
return {
|
|
...rest,
|
|
skuCode: flat.skuCode,
|
|
barcode69: undefined,
|
|
spec: flat.spec,
|
|
price,
|
|
benefitAmount,
|
|
benefitDisplay: benefitAmount,
|
|
allowOnSitePickup: flat.allowOnSitePickup,
|
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
|
saleUnit: flat.saleUnit,
|
|
...media,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
async getProduct(id: bigint, viewer?: CatalogViewer) {
|
|
const product = await this.prisma.commonProductItem.findUnique({
|
|
where: { id },
|
|
include: {
|
|
coverResource: true,
|
|
skus: {
|
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
|
include: { skuSpecs: { select: { valueId: true } } },
|
|
},
|
|
specAttrs: {
|
|
orderBy: { sortOrder: 'asc' },
|
|
include: { values: { orderBy: { sortOrder: 'asc' } } },
|
|
},
|
|
},
|
|
});
|
|
if (!product) return null;
|
|
const whitelistPhones = product.visibilityWhitelistEnabled
|
|
? await this.whitelistPhoneSet()
|
|
: new Set<string>();
|
|
if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) {
|
|
return null;
|
|
}
|
|
|
|
const resources = await this.prisma.commonResource.findMany({
|
|
where: {
|
|
ownerType: 'PRODUCT',
|
|
ownerId: id,
|
|
status: 'ACTIVE',
|
|
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
|
},
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
|
|
const media = mapProductMedia(product, resources);
|
|
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = product;
|
|
const display = pickDisplaySku(skus);
|
|
const flat = flattenSkuOntoProduct(product, display);
|
|
const defaultSku = skus.find((s) => s.isDefault) ?? display;
|
|
const cSkus = skus.map((s) => {
|
|
const dto = mapSkuDto(s);
|
|
const { skuCode: _c, barcode69: _b, sortOrder: _o, ...publicSku } = dto;
|
|
return publicSku;
|
|
});
|
|
|
|
return serializeBigInt({
|
|
...rest,
|
|
skuCode: flat.skuCode,
|
|
spec: flat.spec,
|
|
price: flat.price,
|
|
benefitAmount: flat.benefitAmount,
|
|
benefitDisplay: flat.benefitAmount,
|
|
allowOnSitePickup: flat.allowOnSitePickup,
|
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
|
saleUnit: flat.saleUnit,
|
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
|
specAttrs: mapSpecAttrsDto(specAttrs),
|
|
skus: cSkus,
|
|
defaultSkuId: defaultSku?.id.toString(),
|
|
...media,
|
|
});
|
|
}
|
|
|
|
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */
|
|
async assertPurchasable(
|
|
productId: bigint,
|
|
viewerPhone?: string | null,
|
|
skuId?: string | null,
|
|
options?: { bypassWhitelist?: boolean },
|
|
) {
|
|
const product = await this.prisma.commonProductItem.findUnique({
|
|
where: { id: productId },
|
|
include: { skus: true },
|
|
});
|
|
if (!product || product.status !== 'ON_SALE') {
|
|
throw new BadRequestException('商品不可购买');
|
|
}
|
|
if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) {
|
|
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
|
if (!ok) {
|
|
throw new BadRequestException('该商品暂不对当前账号开放');
|
|
}
|
|
}
|
|
const sale = resolveOrderSale(product, product.skus ?? [], skuId);
|
|
return { product, sale };
|
|
}
|
|
|
|
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { phone: true },
|
|
});
|
|
return user?.phone ?? null;
|
|
}
|
|
|
|
async listSkusForProduct(productId: bigint) {
|
|
return this.prisma.commonProductSku.findMany({
|
|
where: { productId },
|
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
|
include: { skuSpecs: { select: { valueId: true } } },
|
|
});
|
|
}
|
|
}
|