@@ -6,6 +6,14 @@ import {
|
||||
normalizeTestPhone,
|
||||
} from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||
import {
|
||||
flattenSkuOntoProduct,
|
||||
listMinOnSalePrice,
|
||||
mapSkuDto,
|
||||
mapSpecAttrsDto,
|
||||
pickDisplaySku,
|
||||
resolveOrderSku,
|
||||
} from './product-sku.util';
|
||||
|
||||
export type CatalogViewer = {
|
||||
/** C 端用户手机号;无则无法看到白名单商品 */
|
||||
@@ -81,6 +89,8 @@ export class CatalogService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
coverResource: true,
|
||||
skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
specAttrs: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,12 +116,25 @@ export class CatalogService {
|
||||
return serializeBigInt(
|
||||
visible.map((p) => {
|
||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
|
||||
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,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
@@ -123,6 +146,14 @@ export class CatalogService {
|
||||
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;
|
||||
@@ -144,30 +175,57 @@ export class CatalogService {
|
||||
});
|
||||
|
||||
const media = mapProductMedia(product, resources);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
|
||||
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,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
|
||||
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
|
||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;返回 SPU + 解析后的 SKU */
|
||||
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 (product.visibilityWhitelistEnabled) {
|
||||
if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) {
|
||||
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
||||
if (!ok) {
|
||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||
}
|
||||
}
|
||||
return product;
|
||||
const sku = resolveOrderSku(product.skus, skuId);
|
||||
return { product, sku };
|
||||
}
|
||||
|
||||
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
||||
@@ -177,4 +235,12 @@ export class CatalogService {
|
||||
});
|
||||
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 } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { CommonProductItem, CommonProductSku, ProductSaleUnit } from '@prisma/client';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BOTTLES_PER_BOX } from '@dukang/domain';
|
||||
|
||||
export type SkuWithSpecs = CommonProductSku & {
|
||||
skuSpecs?: Array<{ valueId: bigint; value?: { id: bigint; name: string; attrId: bigint } }>;
|
||||
};
|
||||
|
||||
export type SpecAttrWithValues = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
values: Array<{ id: bigint; name: string; sortOrder: number }>;
|
||||
};
|
||||
|
||||
/** 可售 SKU */
|
||||
export function isSkuOnSale(sku: { status: string }): boolean {
|
||||
return sku.status === 'ON_SALE';
|
||||
}
|
||||
|
||||
export function buildSpecKey(valueIds: bigint[]): string {
|
||||
if (!valueIds.length) return '';
|
||||
return [...valueIds]
|
||||
.map((id) => id.toString())
|
||||
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
|
||||
.join('_');
|
||||
}
|
||||
|
||||
export function buildSpecText(
|
||||
valueIds: bigint[],
|
||||
valueNameById: Map<string, string>,
|
||||
): string {
|
||||
if (!valueIds.length) return '';
|
||||
return valueIds
|
||||
.map((id) => valueNameById.get(id.toString()) ?? '')
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析下单 SKU:显式 skuId,或单可售自动回落;多可售且未传则 400。
|
||||
*/
|
||||
export function resolveOrderSku(
|
||||
skus: CommonProductSku[],
|
||||
skuId?: string | null,
|
||||
): CommonProductSku {
|
||||
const onSale = skus.filter(isSkuOnSale);
|
||||
if (skuId) {
|
||||
const found = skus.find((s) => s.id.toString() === String(skuId));
|
||||
if (!found) throw new BadRequestException('规格不存在');
|
||||
if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买');
|
||||
return found;
|
||||
}
|
||||
if (onSale.length === 1) return onSale[0];
|
||||
if (onSale.length === 0) throw new BadRequestException('商品暂无可售规格');
|
||||
throw new BadRequestException('请选择规格');
|
||||
}
|
||||
|
||||
/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */
|
||||
export function pickDisplaySku(skus: CommonProductSku[]): CommonProductSku | null {
|
||||
if (!skus.length) return null;
|
||||
const onSale = skus.filter(isSkuOnSale);
|
||||
const pool = onSale.length ? onSale : skus;
|
||||
const def = pool.find((s) => s.isDefault);
|
||||
if (def) return def;
|
||||
return [...pool].sort((a, b) => Number(a.price) - Number(b.price) || a.sortOrder - b.sortOrder)[0];
|
||||
}
|
||||
|
||||
export function listMinOnSalePrice(skus: CommonProductSku[]): number | null {
|
||||
const onSale = skus.filter(isSkuOnSale);
|
||||
if (!onSale.length) return null;
|
||||
return Math.min(...onSale.map((s) => Number(s.price)));
|
||||
}
|
||||
|
||||
export function flattenSkuOntoProduct(
|
||||
product: CommonProductItem,
|
||||
sku: CommonProductSku | null,
|
||||
): {
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
saleUnit: ProductSaleUnit;
|
||||
bottlesPerUnit: number;
|
||||
} {
|
||||
if (!sku) {
|
||||
return {
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
spec: product.spec,
|
||||
price: Number(product.price),
|
||||
benefitAmount: Number(product.benefitAmount ?? product.price),
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
skuCode: sku.skuCode,
|
||||
barcode69: sku.barcode69,
|
||||
spec: sku.specText || product.spec,
|
||||
price: Number(sku.price),
|
||||
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||
allowOnSitePickup: sku.allowOnSitePickup,
|
||||
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||
saleUnit: sku.saleUnit,
|
||||
bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSkuDto(sku: SkuWithSpecs) {
|
||||
const specValueIds = (sku.skuSpecs ?? []).map((row) => row.valueId.toString());
|
||||
return {
|
||||
id: sku.id.toString(),
|
||||
specValueIds,
|
||||
specText: sku.specText,
|
||||
price: Number(sku.price),
|
||||
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||
status: sku.status,
|
||||
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||
allowOnSitePickup: sku.allowOnSitePickup,
|
||||
saleUnit: sku.saleUnit as 'BOTTLE' | 'BOX',
|
||||
bottlesPerUnit: sku.bottlesPerUnit,
|
||||
isDefault: sku.isDefault,
|
||||
skuCode: sku.skuCode,
|
||||
barcode69: sku.barcode69,
|
||||
sortOrder: sku.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSpecAttrsDto(attrs: SpecAttrWithValues[]) {
|
||||
return attrs
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((attr) => ({
|
||||
id: attr.id.toString(),
|
||||
name: attr.name,
|
||||
values: attr.values
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((v) => ({ id: v.id.toString(), name: v.name })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function syncDefaultSkuFieldsFromProduct(product: {
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
spec: string;
|
||||
price: unknown;
|
||||
benefitAmount: unknown;
|
||||
status: string;
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
}) {
|
||||
return {
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specText: product.spec,
|
||||
price: product.price as never,
|
||||
benefitAmount: product.benefitAmount as never,
|
||||
status: product.status as never,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user