v3.5.4版本提交
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-21 15:48:15 +08:00
parent 26334ed072
commit a01217539c
32 changed files with 2278 additions and 147 deletions
@@ -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,
};
}