feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix
公开契约族化(前端只需知道款号/族): - GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后); 无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计) - GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集, 尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404 - priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级 标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签 - 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复) - family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化 - admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配, 改价 payload 带 printCount) - 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
This commit is contained in:
@@ -73,9 +73,10 @@ export function linkDims(name: string | null | undefined): LinkDims {
|
||||
const logistics =
|
||||
openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null;
|
||||
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
|
||||
const printCount = name.includes('双面印花')
|
||||
// 与 api 端一致:裸「双面/单面」也归入印花数量(如「直喷双面」)
|
||||
const printCount = name.includes('双面')
|
||||
? '双面印花'
|
||||
: name.includes('单面印花')
|
||||
: name.includes('单面')
|
||||
? '单面印花'
|
||||
: null;
|
||||
return {
|
||||
@@ -87,14 +88,14 @@ export function linkDims(name: string | null | undefined): LinkDims {
|
||||
|
||||
/**
|
||||
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
|
||||
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
|
||||
* 印花数量:双面 优先于 单面(含「直喷双面」这类工艺段写法);工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
|
||||
* 物流:不包邮 优先于 包邮。
|
||||
*/
|
||||
export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
if (!name) return [];
|
||||
const names: string[] = [];
|
||||
if (name.includes('双面印花')) names.push('双面印花');
|
||||
else if (name.includes('单面印花')) names.push('单面印花');
|
||||
if (name.includes('双面')) names.push('双面印花');
|
||||
else if (name.includes('单面')) names.push('单面印花');
|
||||
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
|
||||
if (craftHits.length > 0) names.push(...craftHits);
|
||||
else names.push('烫画');
|
||||
|
||||
@@ -133,6 +133,7 @@ interface MemberPriceCell {
|
||||
colorId: string
|
||||
sizeName: string | null
|
||||
colorName: string | null
|
||||
printCount: string
|
||||
craft: string
|
||||
logistics: string
|
||||
price: string
|
||||
@@ -216,9 +217,24 @@ function memberVariants(id: string) {
|
||||
return memberDetail(id)?.variants ?? []
|
||||
}
|
||||
|
||||
// 矩阵三个归因维度的合法取值(与 api 端 auto-tag-rules 一致)
|
||||
const PRINT_COUNT_VALUES = ['单面印花', '双面印花']
|
||||
const CRAFT_VALUES = ['烫画', '直喷', '不打印']
|
||||
const LOGISTICS_VALUES = ['包邮', '不包邮']
|
||||
|
||||
/** 成员的矩阵归因维度:优先取有效标签(人工接管后仍准确),回退按名称解析 */
|
||||
function memberDims(id: string) {
|
||||
const row = editFamilyMembers.value.find((m) => m.id === id)
|
||||
return linkDims(row?.goodName ?? null)
|
||||
const fallback = linkDims(row?.goodName ?? null)
|
||||
const tagNames = ((row as any)?.originGoodTags ?? [])
|
||||
.map((r: any) => r.tag?.tagName)
|
||||
.filter(Boolean) as string[]
|
||||
const crafts = CRAFT_VALUES.filter((v) => tagNames.includes(v))
|
||||
return {
|
||||
logistics: LOGISTICS_VALUES.find((v) => tagNames.includes(v)) ?? fallback.logistics,
|
||||
crafts: crafts.length ? crafts : fallback.crafts,
|
||||
printCount: PRINT_COUNT_VALUES.find((v) => tagNames.includes(v)) ?? fallback.printCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) {
|
||||
@@ -234,21 +250,25 @@ async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) {
|
||||
} finally { detailSyncing.value = '' }
|
||||
}
|
||||
|
||||
/** 该链接在族价格矩阵中的格子(craft/logistics 即该链接的标签维度) */
|
||||
/** 该链接在族价格矩阵中的格子(印花数量/工艺/物流 三维即该链接的归因标签) */
|
||||
function loadMemberPrices(ogId: string) {
|
||||
const row = editFamilyMembers.value.find((m) => m.id === ogId)
|
||||
if (!row?.craftLabel || !row.logisticsLabel) {
|
||||
const dims = memberDims(ogId)
|
||||
if (!dims.printCount || !dims.crafts.length || !dims.logistics) {
|
||||
memberPrices.value[ogId] = []
|
||||
return
|
||||
}
|
||||
const matrixRows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
|
||||
memberPrices.value[ogId] = matrixRows
|
||||
.filter((r: any) => r.craft === row.craftLabel && r.logistics === row.logisticsLabel)
|
||||
.filter((r: any) =>
|
||||
r.printCount === dims.printCount &&
|
||||
dims.crafts.includes(r.craft) &&
|
||||
r.logistics === dims.logistics)
|
||||
.map((r: any) => ({
|
||||
sizeId: String(r.sizeId),
|
||||
colorId: String(r.colorId),
|
||||
sizeName: r.sizeName ?? null,
|
||||
colorName: r.colorName ?? null,
|
||||
printCount: r.printCount,
|
||||
craft: r.craft,
|
||||
logistics: r.logistics,
|
||||
price: String(r.price ?? ''),
|
||||
@@ -263,7 +283,7 @@ async function saveMemberPrices(row: { id: string }) {
|
||||
memberPriceSaving.value = row.id
|
||||
try {
|
||||
await productFamiliesApi.putOverrides(editFamilyId.value, cells.map((c) => ({
|
||||
sizeId: c.sizeId, colorId: c.colorId, craft: c.craft, logistics: c.logistics, price: c.editPrice,
|
||||
sizeId: c.sizeId, colorId: c.colorId, printCount: c.printCount, craft: c.craft, logistics: c.logistics, price: c.editPrice,
|
||||
})))
|
||||
ElMessage.success('价格已保存')
|
||||
await loadEditFamily()
|
||||
@@ -803,7 +823,7 @@ async function handleDeleteGood() {
|
||||
<template #default="{ row }">{{ row.craft || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="印花数量" width="100">
|
||||
<template #default="{ row }">{{ row.craft?.includes('双面印花') ? '双面印花' : row.craft?.includes('单面印花') ? '单面印花' : '-' }}</template>
|
||||
<template #default="{ row }">{{ row.printCount || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sizeName" label="尺码" width="90" />
|
||||
<el-table-column prop="colorName" label="颜色" min-width="110" />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 价格矩阵五维化:覆盖表补 印花数量 维度(表当前 0 行,无需数据回填)
|
||||
ALTER TABLE "family_price_overrides" ADD COLUMN "print_count" TEXT NOT NULL DEFAULT '单面印花';
|
||||
|
||||
DROP INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key";
|
||||
CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_print_count_craft_log_key"
|
||||
ON "family_price_overrides"("family_id", "size_id", "color_id", "print_count", "craft", "logistics");
|
||||
@@ -328,20 +328,21 @@ model ProductFamily {
|
||||
// ---------- Family Price Overrides (manual per-cell price) ----------
|
||||
// 独立于推导矩阵:重算只重建推导部分,本表永不被动覆盖。
|
||||
model FamilyPriceOverride {
|
||||
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
|
||||
familyId BigInt @map("family_id")
|
||||
sizeId String @map("size_id")
|
||||
colorId String @map("color_id")
|
||||
craft String
|
||||
logistics String
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
note String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
|
||||
familyId BigInt @map("family_id")
|
||||
sizeId String @map("size_id")
|
||||
colorId String @map("color_id")
|
||||
printCount String @default("单面印花") @map("print_count")
|
||||
craft String
|
||||
logistics String
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
note String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@unique([familyId, sizeId, colorId, craft, logistics])
|
||||
@@unique([familyId, sizeId, colorId, printCount, craft, logistics])
|
||||
@@map("family_price_overrides")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ describe('auto-tag-rules / deriveLinkTagNames', () => {
|
||||
).toEqual(['双面印花', '烫画', '不包邮']);
|
||||
});
|
||||
|
||||
it('工艺段写法「直喷双面/直喷单面」→ 裸 双面/单面 也归入印花数量', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷双面'),
|
||||
).toEqual(['双面印花', '直喷', '包邮']);
|
||||
expect(
|
||||
deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷单面'),
|
||||
).toEqual(['单面印花', '直喷', '包邮']);
|
||||
});
|
||||
|
||||
it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => {
|
||||
expect(
|
||||
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
|
||||
|
||||
@@ -47,8 +47,10 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] {
|
||||
if (!name) return [];
|
||||
const names: string[] = [];
|
||||
|
||||
if (name.includes('双面印花')) names.push('双面印花');
|
||||
else if (name.includes('单面印花')) names.push('单面印花');
|
||||
// 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」;
|
||||
// 双面优先判断,避免「单面」误吞
|
||||
if (name.includes('双面')) names.push('双面印花');
|
||||
else if (name.includes('单面')) names.push('单面印花');
|
||||
|
||||
const craftTags = new Set<string>();
|
||||
for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
|
||||
|
||||
@@ -210,7 +210,12 @@ export class PriceOverrideItemDto {
|
||||
@IsNotEmpty()
|
||||
colorId!: string;
|
||||
|
||||
@ApiProperty({ example: '单面印花' })
|
||||
@ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
printCount!: string;
|
||||
|
||||
@ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
craft!: string;
|
||||
@@ -250,7 +255,12 @@ export class PriceOverrideCellDto {
|
||||
@IsNotEmpty()
|
||||
colorId!: string;
|
||||
|
||||
@ApiProperty({ example: '单面印花' })
|
||||
@ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
printCount!: string;
|
||||
|
||||
@ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
craft!: string;
|
||||
|
||||
@@ -14,8 +14,11 @@ describe('FamilyRecomputeService', () => {
|
||||
const stamp = Date.now();
|
||||
const createdOriginGoodIds: bigint[] = [];
|
||||
const createdFamilyIds: bigint[] = [];
|
||||
const createdTagIds: bigint[] = [];
|
||||
const createdTagGroupIds: bigint[] = [];
|
||||
|
||||
const mkOriginGood = async (over: {
|
||||
goodName?: string;
|
||||
craftLabel?: string | null;
|
||||
logisticsLabel?: string | null;
|
||||
variants?: Array<{
|
||||
@@ -34,7 +37,7 @@ describe('FamilyRecomputeService', () => {
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
goodName: `测试链接-${stamp}`,
|
||||
goodName: over.goodName ?? `测试链接-${stamp}`,
|
||||
source: 'CUSTOM',
|
||||
craftLabel: over.craftLabel ?? null,
|
||||
logisticsLabel: over.logisticsLabel ?? null,
|
||||
@@ -102,6 +105,8 @@ describe('FamilyRecomputeService', () => {
|
||||
afterAll(async () => {
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: createdTagIds } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: createdTagGroupIds } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -136,10 +141,9 @@ describe('FamilyRecomputeService', () => {
|
||||
expect(after.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('价格矩阵:同格子取最低价并累积来源;缺失归因维度/停用变体不参与', async () => {
|
||||
it('价格矩阵:五维格子取最低价并累积来源;维度取链接标签、名称回退;停用变体不参与', async () => {
|
||||
const a = await mkOriginGood({
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
goodName: '美国(包邮)测试A-PA1-单面印花',
|
||||
variants: [
|
||||
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||
{ sdsVariantId: 'v2', sku: 'A-XL-WHT', sizeId: 'size_XL', sizeName: 'XL', colorId: 'color_wht', colorName: '白色', price: 27 },
|
||||
@@ -147,25 +151,22 @@ describe('FamilyRecomputeService', () => {
|
||||
],
|
||||
});
|
||||
const b = await mkOriginGood({
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮', // 同格子(不同仓库)
|
||||
goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库)
|
||||
variants: [
|
||||
{ sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 },
|
||||
{ sdsVariantId: 'v5', sku: 'B-XXXL-BLK', sizeId: 'size_XXXL', sizeName: 'XXXL', colorId: 'color_blk', colorName: '黑色', price: 29 },
|
||||
],
|
||||
});
|
||||
const d = await mkOriginGood({
|
||||
craftLabel: '双面印花',
|
||||
logisticsLabel: '专线',
|
||||
goodName: '美国(包邮)测试D-PD1-直喷双面',
|
||||
variants: [
|
||||
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
|
||||
],
|
||||
});
|
||||
const e = await mkOriginGood({
|
||||
craftLabel: null, // 缺工艺 → 不参与矩阵
|
||||
logisticsLabel: '包邮',
|
||||
goodName: '美国(不包邮)测试E-PE1-光板', // 无标签行 → 名称回退:不打印 + 单面印花默认
|
||||
variants: [
|
||||
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 1 },
|
||||
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 },
|
||||
],
|
||||
});
|
||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
|
||||
@@ -174,21 +175,64 @@ describe('FamilyRecomputeService', () => {
|
||||
|
||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
const matrix = after.priceMatrix as any;
|
||||
const cell = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.craft === '单面印花' && r.logistics === '包邮');
|
||||
const cell = matrix.rows.find(
|
||||
(r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk'
|
||||
&& r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
|
||||
);
|
||||
expect(cell.price).toBe('24.5');
|
||||
expect(cell.manual).toBe(false);
|
||||
expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除
|
||||
expect(matrix.rows.find((r: any) => r.craft === '双面印花' && r.price === '30')).toBeTruthy();
|
||||
expect(matrix.rows.some((r: any) => Number(r.price) === 1)).toBe(false); // e 缺归因被排除
|
||||
expect(matrix.crafts.sort()).toEqual(['单面印花', '双面印花']);
|
||||
expect(matrix.logistics.sort()).toEqual(['专线', '包邮']);
|
||||
// 直喷双面 → 印花数量/工艺两维正确拆分
|
||||
const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30');
|
||||
expect(dg).toBeTruthy();
|
||||
// 光板(无标签)→ 工艺=不打印、印花数量回退单面、物流=不包邮
|
||||
const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮');
|
||||
expect(blank).toBeTruthy();
|
||||
expect(blank.printCount).toBe('单面印花');
|
||||
expect(matrix.printCounts.sort()).toEqual(['单面印花', '双面印花']);
|
||||
expect(matrix.crafts.sort()).toEqual(['不打印', '烫画', '直喷']);
|
||||
expect(matrix.logistics.sort()).toEqual(['不包邮', '包邮']);
|
||||
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
|
||||
});
|
||||
|
||||
it('价格矩阵:人工接管标签优先于名称派生', async () => {
|
||||
const a = await mkOriginGood({
|
||||
goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮
|
||||
variants: [
|
||||
{ sdsVariantId: 'v1', sku: 'M-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||
],
|
||||
});
|
||||
// 人工把该链接标签改成 双面印花/直喷/不包邮(手工造标签行,模拟 updateTags 结果)
|
||||
const tagIds: bigint[] = [];
|
||||
for (const groupName of ['印花数量', '印刷工艺', '物流渠道']) {
|
||||
const group = await prisma.tagGroup.findFirst({ where: { groupName } });
|
||||
const g = group ?? (await prisma.tagGroup.create({ data: { groupName } }));
|
||||
if (!group) createdTagGroupIds.push(g.id);
|
||||
for (const tagName of groupName === '印花数量' ? ['双面印花'] : groupName === '印刷工艺' ? ['直喷'] : ['不包邮']) {
|
||||
const existing = await prisma.tag.findFirst({ where: { tagName } });
|
||||
const t = existing ?? (await prisma.tag.create({ data: { tagName, tagGroupId: g.id } }));
|
||||
if (!existing) createdTagIds.push(t.id);
|
||||
tagIds.push(t.id);
|
||||
}
|
||||
}
|
||||
await prisma.originGoodTag.createMany({
|
||||
data: tagIds.map((tagId) => ({ originGoodId: a.id, tagId, manual: true })),
|
||||
});
|
||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
|
||||
|
||||
await service.recomputeFamily(family.id);
|
||||
|
||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
const matrix = after.priceMatrix as any;
|
||||
expect(matrix.rows).toHaveLength(1);
|
||||
expect(matrix.rows[0].printCount).toBe('双面印花');
|
||||
expect(matrix.rows[0].craft).toBe('直喷');
|
||||
expect(matrix.rows[0].logistics).toBe('不包邮');
|
||||
});
|
||||
|
||||
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
|
||||
const a = await mkOriginGood({
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
goodName: '美国(包邮)测试O-PO1-单面印花',
|
||||
variants: [
|
||||
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
|
||||
],
|
||||
@@ -196,8 +240,8 @@ describe('FamilyRecomputeService', () => {
|
||||
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
|
||||
await prisma.familyPriceOverride.createMany({
|
||||
data: [
|
||||
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: new Prisma.Decimal(23) },
|
||||
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', craft: '三面印花', logistics: '海运', price: new Prisma.Decimal(40) },
|
||||
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: new Prisma.Decimal(23) },
|
||||
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', printCount: '四面印花', craft: '丝印', logistics: '海运', price: new Prisma.Decimal(40) },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -205,14 +249,18 @@ describe('FamilyRecomputeService', () => {
|
||||
|
||||
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||
const matrix = after.priceMatrix as any;
|
||||
const hit = matrix.rows.find((r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk');
|
||||
const hit = matrix.rows.find(
|
||||
(r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
|
||||
);
|
||||
expect(hit.price).toBe('23');
|
||||
expect(hit.manual).toBe(true);
|
||||
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '三面印花');
|
||||
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '丝印');
|
||||
expect(added).toBeTruthy();
|
||||
expect(added.manual).toBe(true);
|
||||
expect(added.printCount).toBe('四面印花');
|
||||
expect(added.sources).toEqual([]);
|
||||
expect(matrix.crafts).toContain('三面印花');
|
||||
expect(matrix.crafts).toContain('丝印');
|
||||
expect(matrix.printCounts).toContain('四面印花');
|
||||
expect(matrix.logistics).toContain('海运');
|
||||
expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface PriceMatrixRow {
|
||||
sizeName: string | null;
|
||||
colorId: string;
|
||||
colorName: string | null;
|
||||
/** 印花数量维:单面印花 / 双面印花 */
|
||||
printCount: string;
|
||||
/** 工艺维:烫画 / 直喷 / 不打印 */
|
||||
craft: string;
|
||||
logistics: string;
|
||||
price: string;
|
||||
@@ -38,6 +41,7 @@ export interface PriceMatrixRow {
|
||||
export interface PriceMatrix {
|
||||
sizes: Array<{ key: string; name: string | null }>;
|
||||
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
|
||||
printCounts: string[];
|
||||
crafts: string[];
|
||||
logistics: string[];
|
||||
rows: PriceMatrixRow[];
|
||||
@@ -55,10 +59,72 @@ export interface ChartLike {
|
||||
}
|
||||
|
||||
type Member = Prisma.OriginGoodGetPayload<{
|
||||
include: { detail: true; variants: true };
|
||||
include: {
|
||||
detail: true;
|
||||
variants: true;
|
||||
originGoodTags: { select: { tag: { select: { tagName: true } } } };
|
||||
};
|
||||
}>;
|
||||
type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
|
||||
|
||||
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
|
||||
const DIM_VALUES = {
|
||||
printCount: ['单面印花', '双面印花'],
|
||||
craft: ['烫画', '直喷', '不打印'],
|
||||
logistics: ['包邮', '不包邮'],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
type DimKey = keyof typeof DIM_VALUES;
|
||||
export interface MatrixCombo {
|
||||
printCount: string;
|
||||
craft: string;
|
||||
logistics: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员在矩阵中的归因维度组合(纯函数),取值优先级:
|
||||
* 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签);
|
||||
* 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本);
|
||||
* 3. 链接名称派生(deriveLinkTagNames);
|
||||
* 4. 组内默认值(单面印花 / 烫画 / 包邮)。
|
||||
* 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。
|
||||
*/
|
||||
export function memberMatrixCombos(input: {
|
||||
goodName: string | null;
|
||||
tagNames: string[];
|
||||
customLabels?: { craft?: string | null; logistics?: string | null };
|
||||
}): MatrixCombo[] {
|
||||
const derived = deriveLinkTagNames(input.goodName);
|
||||
const values = (dim: DimKey): string[] => {
|
||||
const fromTags = DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
||||
if (fromTags.length) return [...fromTags];
|
||||
const label =
|
||||
dim === 'craft' ? input.customLabels?.craft : input.customLabels?.logistics;
|
||||
if (dim !== 'printCount' && label) return [label];
|
||||
const fromName = derived.filter((n) =>
|
||||
(DIM_VALUES[dim] as readonly string[]).includes(n),
|
||||
);
|
||||
if (fromName.length) return fromName;
|
||||
if (dim === 'printCount') {
|
||||
const craftLabel = input.customLabels?.craft ?? '';
|
||||
return [craftLabel.includes('双面') ? '双面印花' : DIM_VALUES[dim][0]];
|
||||
}
|
||||
return [DIM_VALUES[dim][0]];
|
||||
};
|
||||
const printCounts = values('printCount');
|
||||
const crafts = values('craft');
|
||||
const logistics = values('logistics');
|
||||
const combos: MatrixCombo[] = [];
|
||||
for (const printCount of printCounts) {
|
||||
for (const craft of crafts) {
|
||||
for (const logistic of logistics) {
|
||||
combos.push({ printCount, craft, logistics: logistic });
|
||||
}
|
||||
}
|
||||
}
|
||||
return combos;
|
||||
}
|
||||
|
||||
function chartRowKey(row: ChartSizeRow): string {
|
||||
return String(row.sizeName ?? row.sizeId ?? '');
|
||||
}
|
||||
@@ -89,8 +155,9 @@ function firstColumns(charts: Array<ChartLike | null | undefined>): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* 推导五维价格矩阵:(sizeKey, colorKey, craft, logistics) → 价格。
|
||||
* 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName。
|
||||
* 推导五维价格矩阵:(sizeKey, colorKey, printCount, craft, logistics) → 价格。
|
||||
* 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName;
|
||||
* 归因维度取成员链接标签(见 memberMatrixCombos)。
|
||||
* 同格子多来源取最低价,sources 全保留;随后合并人工覆盖(manual=true)。
|
||||
*/
|
||||
export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): PriceMatrix {
|
||||
@@ -98,65 +165,89 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
const colors = new Map<string, { name: string | null; hex: string | null; imageUrl: string | null }>();
|
||||
const cellMap = new Map<string, PriceMatrixRow>();
|
||||
|
||||
for (const member of members) {
|
||||
if (!member.craftLabel || !member.logisticsLabel) continue;
|
||||
for (const variant of member.variants) {
|
||||
if (variant.price === null) continue;
|
||||
const sizeKey = String(variant.sizeId ?? variant.sizeName ?? '');
|
||||
const colorKey = String(variant.colorId ?? variant.colorName ?? '');
|
||||
if (!sizeKey && !colorKey) continue;
|
||||
if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName);
|
||||
if (colorKey) {
|
||||
const prev = colors.get(colorKey);
|
||||
colors.set(colorKey, {
|
||||
name: prev?.name ?? variant.colorName,
|
||||
hex: prev?.hex ?? variant.colorHex,
|
||||
imageUrl: prev?.imageUrl ?? variant.imageUrl,
|
||||
});
|
||||
}
|
||||
const key = `${sizeKey}|${colorKey}|${member.craftLabel}|${member.logisticsLabel}`;
|
||||
const priceStr = variant.price.toString();
|
||||
const source: PriceMatrixSource = {
|
||||
sdsGoodId: member.sdsGoodId,
|
||||
sdsVariantId: variant.sdsVariantId,
|
||||
price: priceStr,
|
||||
};
|
||||
const existing = cellMap.get(key);
|
||||
if (!existing) {
|
||||
cellMap.set(key, {
|
||||
sizeId: sizeKey,
|
||||
sizeName: variant.sizeName,
|
||||
colorId: colorKey,
|
||||
colorName: variant.colorName,
|
||||
craft: member.craftLabel,
|
||||
logistics: member.logisticsLabel,
|
||||
const memberCombos = members.map((member) => ({
|
||||
member,
|
||||
combos: memberMatrixCombos({
|
||||
goodName: member.goodName,
|
||||
tagNames: member.originGoodTags.map((r) => r.tag.tagName),
|
||||
// CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源
|
||||
customLabels:
|
||||
member.source === 'CUSTOM'
|
||||
? { craft: member.craftLabel, logistics: member.logisticsLabel }
|
||||
: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const noteVariant = (variant: Member['variants'][number]) => {
|
||||
const sizeKey = String(variant.sizeId ?? variant.sizeName ?? '');
|
||||
const colorKey = String(variant.colorId ?? variant.colorName ?? '');
|
||||
if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName);
|
||||
if (colorKey) {
|
||||
const prev = colors.get(colorKey);
|
||||
colors.set(colorKey, {
|
||||
name: prev?.name ?? variant.colorName,
|
||||
hex: prev?.hex ?? variant.colorHex,
|
||||
imageUrl: prev?.imageUrl ?? variant.imageUrl,
|
||||
});
|
||||
}
|
||||
return { sizeKey, colorKey };
|
||||
};
|
||||
|
||||
for (const { member, combos } of memberCombos) {
|
||||
for (const combo of combos) {
|
||||
for (const variant of member.variants) {
|
||||
if (variant.price === null) continue;
|
||||
const { sizeKey, colorKey } = noteVariant(variant);
|
||||
if (!sizeKey && !colorKey) continue;
|
||||
const key = `${sizeKey}|${colorKey}|${combo.printCount}|${combo.craft}|${combo.logistics}`;
|
||||
const priceStr = variant.price.toString();
|
||||
const source: PriceMatrixSource = {
|
||||
sdsGoodId: member.sdsGoodId,
|
||||
sdsVariantId: variant.sdsVariantId,
|
||||
price: priceStr,
|
||||
manual: false,
|
||||
sources: [source],
|
||||
});
|
||||
} else {
|
||||
existing.sources.push(source);
|
||||
if (Number(variant.price) < Number(existing.price)) existing.price = priceStr;
|
||||
};
|
||||
const existing = cellMap.get(key);
|
||||
if (!existing) {
|
||||
cellMap.set(key, {
|
||||
sizeId: sizeKey,
|
||||
sizeName: variant.sizeName,
|
||||
colorId: colorKey,
|
||||
colorName: variant.colorName,
|
||||
printCount: combo.printCount,
|
||||
craft: combo.craft,
|
||||
logistics: combo.logistics,
|
||||
price: priceStr,
|
||||
manual: false,
|
||||
sources: [source],
|
||||
});
|
||||
} else {
|
||||
existing.sources.push(source);
|
||||
if (Number(variant.price) < Number(existing.price)) existing.price = priceStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crafts = [...new Set(members.map((m) => m.craftLabel).filter((c): c is string => !!c))];
|
||||
const printCounts = [
|
||||
...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.printCount))),
|
||||
];
|
||||
const crafts = [...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.craft)))];
|
||||
const logisticsOptions = [
|
||||
...new Set(members.map((m) => m.logisticsLabel).filter((l): l is string => !!l)),
|
||||
...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.logistics))),
|
||||
];
|
||||
|
||||
const rows = [...cellMap.values()];
|
||||
|
||||
// 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值
|
||||
for (const override of overrides) {
|
||||
if (!printCounts.includes(override.printCount)) printCounts.push(override.printCount);
|
||||
if (!crafts.includes(override.craft)) crafts.push(override.craft);
|
||||
if (!logisticsOptions.includes(override.logistics)) logisticsOptions.push(override.logistics);
|
||||
if (override.sizeId && !sizes.has(override.sizeId)) sizes.set(override.sizeId, null);
|
||||
if (override.colorId && !colors.has(override.colorId)) {
|
||||
colors.set(override.colorId, { name: null, hex: null, imageUrl: null });
|
||||
}
|
||||
const key = `${override.sizeId}|${override.colorId}|${override.craft}|${override.logistics}`;
|
||||
const key = `${override.sizeId}|${override.colorId}|${override.printCount}|${override.craft}|${override.logistics}`;
|
||||
const row = cellMap.get(key);
|
||||
if (row) {
|
||||
row.price = override.price.toString();
|
||||
@@ -167,6 +258,7 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
sizeName: sizes.get(override.sizeId) ?? null,
|
||||
colorId: override.colorId,
|
||||
colorName: colors.get(override.colorId)?.name ?? null,
|
||||
printCount: override.printCount,
|
||||
craft: override.craft,
|
||||
logistics: override.logistics,
|
||||
price: override.price.toString(),
|
||||
@@ -181,6 +273,7 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
return {
|
||||
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
|
||||
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
|
||||
printCounts,
|
||||
crafts,
|
||||
logistics: logisticsOptions,
|
||||
rows,
|
||||
@@ -243,6 +336,7 @@ export class FamilyRecomputeService {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
|
||||
originGoodTags: { select: { tag: { select: { tagName: true } } } },
|
||||
},
|
||||
},
|
||||
priceOverrides: true,
|
||||
|
||||
@@ -239,12 +239,12 @@ describe('ProductFamiliesService', () => {
|
||||
|
||||
await expect(
|
||||
service.putPriceOverrides(fid, [
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', craft: '不存在的工艺', logistics: '包邮', price: 1 },
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '不存在的工艺', logistics: '包邮', price: 1 },
|
||||
]),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
|
||||
const result = (await service.putPriceOverrides(fid, [
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮', price: 23, note: '促销' },
|
||||
{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: 23, note: '促销' },
|
||||
])) as any;
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].derivedPrice).toBe('25');
|
||||
@@ -256,7 +256,7 @@ describe('ProductFamiliesService', () => {
|
||||
expect(row.manual).toBe(true);
|
||||
|
||||
const restored = (await service.deletePriceOverrides(fid, {
|
||||
cells: [{ sizeId: 'size_S', colorId: 'color_blk', craft: '单面印花', logistics: '包邮' }],
|
||||
cells: [{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮' }],
|
||||
})) as any;
|
||||
expect(restored.items).toHaveLength(0);
|
||||
const after2 = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
|
||||
|
||||
@@ -344,6 +344,7 @@ export class ProductFamiliesService {
|
||||
(r) =>
|
||||
r.sizeId === o.sizeId &&
|
||||
r.colorId === o.colorId &&
|
||||
r.printCount === o.printCount &&
|
||||
r.craft === o.craft &&
|
||||
r.logistics === o.logistics,
|
||||
);
|
||||
@@ -376,6 +377,7 @@ export class ProductFamiliesService {
|
||||
const allowed = {
|
||||
sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)),
|
||||
colors: new Set((matrix?.colors ?? []).map((c) => c.key)),
|
||||
printCounts: new Set(matrix?.printCounts ?? []),
|
||||
crafts: new Set(matrix?.crafts ?? []),
|
||||
logistics: new Set(matrix?.logistics ?? []),
|
||||
};
|
||||
@@ -383,6 +385,7 @@ export class ProductFamiliesService {
|
||||
(i) =>
|
||||
!allowed.sizes.has(i.sizeId) ||
|
||||
!allowed.colors.has(i.colorId) ||
|
||||
!allowed.printCounts.has(i.printCount) ||
|
||||
!allowed.crafts.has(i.craft) ||
|
||||
!allowed.logistics.has(i.logistics),
|
||||
);
|
||||
@@ -392,6 +395,7 @@ export class ProductFamiliesService {
|
||||
invalidCells: invalid.map((i) => ({
|
||||
sizeId: i.sizeId,
|
||||
colorId: i.colorId,
|
||||
printCount: i.printCount,
|
||||
craft: i.craft,
|
||||
logistics: i.logistics,
|
||||
})),
|
||||
@@ -401,10 +405,11 @@ export class ProductFamiliesService {
|
||||
for (const item of items) {
|
||||
await this.prisma.familyPriceOverride.upsert({
|
||||
where: {
|
||||
familyId_sizeId_colorId_craft_logistics: {
|
||||
familyId_sizeId_colorId_printCount_craft_logistics: {
|
||||
familyId: id,
|
||||
sizeId: item.sizeId,
|
||||
colorId: item.colorId,
|
||||
printCount: item.printCount,
|
||||
craft: item.craft,
|
||||
logistics: item.logistics,
|
||||
},
|
||||
@@ -413,6 +418,7 @@ export class ProductFamiliesService {
|
||||
familyId: id,
|
||||
sizeId: item.sizeId,
|
||||
colorId: item.colorId,
|
||||
printCount: item.printCount,
|
||||
craft: item.craft,
|
||||
logistics: item.logistics,
|
||||
price: new Prisma.Decimal(item.price),
|
||||
@@ -435,6 +441,7 @@ export class ProductFamiliesService {
|
||||
familyId: id,
|
||||
sizeId: cell.sizeId,
|
||||
colorId: cell.colorId,
|
||||
printCount: cell.printCount,
|
||||
craft: cell.craft,
|
||||
logistics: cell.logistics,
|
||||
},
|
||||
|
||||
@@ -65,7 +65,7 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
required: false,
|
||||
nullable: true,
|
||||
type: Object,
|
||||
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×工艺×物流)',
|
||||
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×印花数量×工艺×物流)',
|
||||
})
|
||||
family?: {
|
||||
familyId: string;
|
||||
@@ -73,6 +73,7 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
familyName: string;
|
||||
sizes: Array<{ key: string; name: string | null }>;
|
||||
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
|
||||
printCounts: string[];
|
||||
crafts: string[];
|
||||
logistics: string[];
|
||||
sizeChart: Record<string, unknown> | null;
|
||||
|
||||
@@ -94,8 +94,8 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
|
||||
it('默认(未设开关):输出族块', async () => {
|
||||
delete process.env.PUBLIC_DETAIL_FROM_FAMILY
|
||||
const detail = await service.getGood(sdsGoodId);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString());
|
||||
expect(detail.family).toBeTruthy();
|
||||
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
|
||||
expect(detail.family!.minPrice).toBe('25');
|
||||
@@ -105,12 +105,12 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
|
||||
it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
|
||||
const detail = await service.getGood(sdsGoodId);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString());
|
||||
expect('family' in detail).toBe(false);
|
||||
});
|
||||
|
||||
it('族变体并集:任何族成员链接的 sdsGoodId 均命中同一商品且变体含全体成员', async () => {
|
||||
it('族变体并集:族ID 命中商品且变体含全体成员;成员 sdsGoodId 不再可寻址', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||
// 再加一个同族成员(不同仓库段)
|
||||
const og2 = await prisma.originGood.create({
|
||||
@@ -136,18 +136,20 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 用成员链接(非主链接)的 sdsGoodId 访问 → 命中同一商品(响应 goodId 仍为主链接)
|
||||
const detail = await service.getGood(`pubfam-m-${stamp}`);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
// 族 ID 是唯一公开键:成员变体并入同一详情
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString());
|
||||
const skus = detail.variants.map((v) => v.sku);
|
||||
expect(skus).toContain(`PF-${stamp}-S`);
|
||||
expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集
|
||||
// 成员链接的 sdsGoodId 不再可寻址
|
||||
await expect(service.getGood(`pubfam-m-${stamp}`)).rejects.toThrow();
|
||||
// 清理:把成员移出族避免影响其他用例
|
||||
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } });
|
||||
await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } });
|
||||
});
|
||||
|
||||
it('无族商品:开关开启也不含 family 键', async () => {
|
||||
it('无族商品:公开端点不可见(列表不含 / 详情 404)', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||
const og2 = await prisma.originGood.create({
|
||||
data: {
|
||||
@@ -166,8 +168,13 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
},
|
||||
});
|
||||
createdGoodIds.push(good2.id);
|
||||
const detail = await service.getGood(`pubfam-2-${stamp}`);
|
||||
expect('family' in detail).toBe(false);
|
||||
const list = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: createdCountryIds[0].toString(),
|
||||
});
|
||||
expect(list.items.map((i) => i.goodName)).not.toContain(`无族商品-${stamp}`);
|
||||
await expect(service.getGood(`pubfam-2-${stamp}`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,50 +13,50 @@ import {
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicService } from './public.service';
|
||||
import {
|
||||
PublicCountryQueryDto,
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
|
||||
@ApiTags('public')
|
||||
@ApiExtraModels(PublicTagFilterDto)
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||
return this.service.getCategoriesTree(query.countryId);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
@Get('tag-groups')
|
||||
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||
return this.service.getTagGroups(query.countryId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: '分页获取商品' })
|
||||
@ApiOperation({ summary: '分页获取商品(族化契约:一族一条,goodId=族ID;无族商品不返回)' })
|
||||
@ApiQuery({
|
||||
name: 'tags',
|
||||
required: false,
|
||||
@@ -80,8 +80,8 @@ export class PublicController {
|
||||
}
|
||||
|
||||
@Get('goods/:goodId')
|
||||
@ApiOperation({ summary: '获取商品完整详情' })
|
||||
@ApiParam({ name: 'goodId', type: String, example: '168746' })
|
||||
@ApiOperation({ summary: '获取商品完整详情(goodId = 族 ID)' })
|
||||
@ApiParam({ name: 'goodId', type: String, example: '758', description: '产品族 ID' })
|
||||
@ApiOkResponse({ type: PublicGoodDetailDto })
|
||||
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
||||
return this.service.getGood(goodId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PublicService } from './public.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PublicService', () => {
|
||||
@@ -15,6 +16,7 @@ describe('PublicService', () => {
|
||||
let filterGroupIds: bigint[] = [];
|
||||
let filterTagIds: bigint[] = [];
|
||||
let originGoodId: bigint;
|
||||
let familyId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -57,6 +59,16 @@ describe('PublicService', () => {
|
||||
});
|
||||
originGoodId = og.id;
|
||||
|
||||
// 公开契约族化:商品必须挂族才对外可见
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `Pub Family ${stamp}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
familyId = family.id;
|
||||
await prisma.originGood.update({
|
||||
where: { id: og.id },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
|
||||
// Seed 3 goods:
|
||||
// high priority + position.indexVal=1
|
||||
// mid priority + position.indexVal=5
|
||||
@@ -72,6 +84,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub High ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
@@ -82,6 +95,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
@@ -92,6 +106,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
@@ -143,6 +158,8 @@ describe('PublicService', () => {
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
// 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算)
|
||||
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
@@ -150,6 +167,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId: otherCategoryId,
|
||||
goodPriority: 1,
|
||||
@@ -162,6 +180,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub ChildGood ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId: childCategoryId,
|
||||
goodPriority: 0,
|
||||
@@ -176,6 +195,8 @@ describe('PublicService', () => {
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Pub ` } },
|
||||
});
|
||||
// Good.familyId / OriginGood.familyId 均为 SetNull,先删商品再删族
|
||||
await prisma.productFamily.deleteMany({ where: { id: familyId } });
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
@@ -224,7 +245,7 @@ describe('PublicService', () => {
|
||||
categoryId: categoryId.toString(), // includes child
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
@@ -254,9 +275,8 @@ describe('PublicService', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
||||
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
||||
);
|
||||
// 族化后命中族内多条 Good 仍只出代表行(High 优先级最高)
|
||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]);
|
||||
|
||||
const acrossGroups = await service.getGoods({
|
||||
page: 1,
|
||||
@@ -293,20 +313,22 @@ describe('PublicService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
keyword: `Pub `,
|
||||
});
|
||||
|
||||
// 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).toBe(familyId.toString());
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条
|
||||
});
|
||||
|
||||
it('returns custom goods through the same public product contract', async () => {
|
||||
it('custom goods (无族) are not visible on public endpoints', async () => {
|
||||
const customPublicId = `custom-public-${stamp}`;
|
||||
const origin = await prisma.originGood.create({
|
||||
data: {
|
||||
@@ -326,17 +348,22 @@ describe('PublicService', () => {
|
||||
},
|
||||
});
|
||||
try {
|
||||
const detail = await service.getGood(customPublicId);
|
||||
expect(detail.goodId).toBe(customPublicId);
|
||||
expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
|
||||
expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
|
||||
const list = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub Custom`,
|
||||
});
|
||||
expect(list.items).toHaveLength(0);
|
||||
// sdsGoodId 不再是公开寻址键:非数字直接 404
|
||||
await expect(service.getGood(customPublicId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
} finally {
|
||||
await prisma.good.delete({ where: { id: good.id } });
|
||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
it('getGood returns family detail by family id and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
@@ -344,14 +371,17 @@ describe('PublicService', () => {
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
expect(detail.details.productionProcess).toBe('白墨烫画');
|
||||
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
||||
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
expect(detail.family?.familyId).toBe(familyId.toString());
|
||||
|
||||
// 旧 sdsGoodId 寻址不再可达(族 ID 是唯一公开键)
|
||||
await expect(service.getGood(`pub-sds-${stamp}`)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
@@ -382,7 +412,7 @@ describe('PublicService', () => {
|
||||
});
|
||||
|
||||
describe('merged secondary origin goods', () => {
|
||||
it('resolves a good by secondary sdsGoodId with merged variants', async () => {
|
||||
it('family members union variants and media (secondary link joins the family)', async () => {
|
||||
const secondary = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` },
|
||||
});
|
||||
@@ -396,20 +426,24 @@ describe('PublicService', () => {
|
||||
imageUrl: 'http://img/black-sec',
|
||||
},
|
||||
});
|
||||
// Attach as secondary source of the highest-priority fixture good.
|
||||
await prisma.goodOriginGood.create({
|
||||
data: { goodId: goodIds[0], originGoodId: secondary.id },
|
||||
// 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联)
|
||||
await prisma.originGood.update({
|
||||
where: { id: secondary.id },
|
||||
data: { familyId },
|
||||
});
|
||||
|
||||
try {
|
||||
const detail = await service.getGood(`pub-secondary-${stamp}`);
|
||||
expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString()); // 对外 goodId 恒为族ID
|
||||
expect(detail.variants.length).toBeGreaterThanOrEqual(2);
|
||||
const black = detail.mediaByColor.find((g) => g.colorName === '黑色');
|
||||
expect(black).toBeTruthy();
|
||||
expect(black!.images).toContain('http://img/black-sec');
|
||||
// sdsGoodId 不是公开键:副链 ID 无法寻址
|
||||
await expect(service.getGood(`pub-secondary-${stamp}`)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
} finally {
|
||||
await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } });
|
||||
await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined);
|
||||
await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined);
|
||||
}
|
||||
@@ -456,20 +490,26 @@ describe('PublicService', () => {
|
||||
media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i9', url: 'http://img/sec-x', sortOrder: 0 }], primaryImageUrl: 'http://img/pri-a' },
|
||||
},
|
||||
});
|
||||
// 主副链同族 + 一条官网 Good(族化契约:Good 挂族才公开)
|
||||
const mergeFamily = await prisma.productFamily.create({
|
||||
data: { familyName: `Pub Merge Family ${stamp2}`, primaryOriginGoodId: primaryOg.id },
|
||||
});
|
||||
await prisma.originGood.updateMany({
|
||||
where: { id: { in: [primaryOg.id, secondaryOg.id] } },
|
||||
data: { familyId: mergeFamily.id },
|
||||
});
|
||||
const mergedGood = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Merged ${stamp2}`,
|
||||
originGoodId: primaryOg.id,
|
||||
familyId: mergeFamily.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
await prisma.goodOriginGood.create({
|
||||
data: { goodId: mergedGood.id, originGoodId: secondaryOg.id },
|
||||
});
|
||||
|
||||
try {
|
||||
const detail = await service.getGood(`pub-pri-${stamp2}`);
|
||||
const detail = await service.getGood(mergeFamily.id.toString());
|
||||
// Variants: 3 unique color+size combos (case-insensitive); duplicate
|
||||
// Black|S from the secondary deduped.
|
||||
expect(
|
||||
@@ -496,8 +536,8 @@ describe('PublicService', () => {
|
||||
]);
|
||||
expect(media.primaryImageUrl).toBe('http://img/pri-a');
|
||||
} finally {
|
||||
await prisma.goodOriginGood.deleteMany({ where: { goodId: mergedGood.id } });
|
||||
await prisma.good.delete({ where: { id: mergedGood.id } });
|
||||
await prisma.productFamily.delete({ where: { id: mergeFamily.id } });
|
||||
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
|
||||
await prisma.originGoodDetail.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
|
||||
await prisma.originGood.delete({ where: { id: primaryOg.id } });
|
||||
|
||||
@@ -66,6 +66,7 @@ export class PublicService {
|
||||
|
||||
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
||||
const goodsWhere: Prisma.GoodWhereInput = {
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
@@ -109,7 +110,7 @@ export class PublicService {
|
||||
|
||||
async getCountries(): Promise<PublicCountryDto[]> {
|
||||
const rows = await this.prisma.country.findMany({
|
||||
where: { goods: { some: { originGood: { delisted: false } } } },
|
||||
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.map(PublicCountryDto.from);
|
||||
@@ -117,7 +118,11 @@ export class PublicService {
|
||||
|
||||
async getTags(): Promise<PublicTagDto[]> {
|
||||
const rows = await this.prisma.tag.findMany({
|
||||
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
||||
where: {
|
||||
goodTags: {
|
||||
some: { good: { familyId: { not: null }, originGood: { delisted: false } } },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ tagGroup: { sortOrder: 'asc' } },
|
||||
{ sortOrder: 'asc' },
|
||||
@@ -130,6 +135,7 @@ export class PublicService {
|
||||
|
||||
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
||||
const goodWhere: Prisma.GoodWhereInput = {
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
@@ -160,7 +166,11 @@ export class PublicService {
|
||||
}
|
||||
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
|
||||
// 无族商品(自定义)不进公开列表:只认族
|
||||
const where: Prisma.GoodWhereInput = {
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
};
|
||||
if (query.countryId) where.countryId = BigInt(query.countryId);
|
||||
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
if (query.categoryId) {
|
||||
@@ -199,65 +209,107 @@ export class PublicService {
|
||||
{ id: 'asc' },
|
||||
];
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
||||
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
||||
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
|
||||
const rows = await this.prisma.good.findMany({
|
||||
where,
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy,
|
||||
});
|
||||
const grouped = new Map<string, PublicGoodRow[]>();
|
||||
for (const good of rows) {
|
||||
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||
const bucket = grouped.get(key);
|
||||
if (bucket) bucket.push(good);
|
||||
else grouped.set(key, [good]);
|
||||
}
|
||||
let items = [...grouped.values()].map((goods) => {
|
||||
const rep = goods[0];
|
||||
const dto = this.toPublicGood(rep);
|
||||
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
||||
const familyMin = this.familyMinPrice(rep);
|
||||
if (rep.familyId && familyMin !== null) dto.price = familyMin;
|
||||
return dto;
|
||||
});
|
||||
if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') {
|
||||
// 分组后的最终价(族最低价)重排,null 价沉底
|
||||
const num = (v: string | null) => (v === null ? Number.POSITIVE_INFINITY : Number(v));
|
||||
items = items.sort((a, b) =>
|
||||
query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
|
||||
);
|
||||
}
|
||||
const total = items.length;
|
||||
const start = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
items: rows.map((good) => this.toPublicGood(good)),
|
||||
items: items.slice(start, start + query.pageSize),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** 族物化矩阵的最低价;无族/无矩阵返回 null */
|
||||
private familyMinPrice(good: PublicGoodRow): string | null {
|
||||
const matrix = good.originGood.family?.priceMatrix as
|
||||
| { rows?: Array<{ price: string }> }
|
||||
| null
|
||||
| undefined;
|
||||
const prices = (matrix?.rows ?? []).map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
|
||||
return prices.length ? String(Math.min(...prices)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。
|
||||
* 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。
|
||||
*/
|
||||
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||
const good = await this.prisma.good.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ originGood: { sdsGoodId: goodId, delisted: false } },
|
||||
// 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义)
|
||||
{ family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } },
|
||||
// 历史副源关联(good_origin_goods)只读保留,仍可命中
|
||||
{
|
||||
mergedOriginGoods: {
|
||||
some: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||
},
|
||||
},
|
||||
],
|
||||
const notFound = () =>
|
||||
new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
if (!/^\d+$/.test(goodId)) throw notFound();
|
||||
const detail = await this.getGoodByFamilyId(BigInt(goodId));
|
||||
if (!detail) throw notFound();
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
|
||||
private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
|
||||
const [family, goods] = await Promise.all([
|
||||
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
|
||||
this.prisma.good.findMany({
|
||||
where: { familyId, originGood: { delisted: false } },
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
// 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 走回退路径
|
||||
if (!family || goods.length === 0) return null;
|
||||
const rep = goods[0];
|
||||
|
||||
const members = await this.prisma.originGood.findMany({
|
||||
where: { familyId, delisted: false },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
const familyVariants = members.flatMap((m) =>
|
||||
m.variants.map((variant) => ({ originGoodId: m.id, variant })),
|
||||
);
|
||||
const familyDetails = members
|
||||
.filter((m) => m.id !== rep.originGoodId)
|
||||
.map((m) => m.detail);
|
||||
|
||||
const dto = this.toPublicGoodDetail(rep, familyVariants, familyDetails);
|
||||
// 尺码表/包装规格以族物化并集为准(款级公共数据),空并集回退主链接合并结果
|
||||
if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'false') {
|
||||
dto.sizeChart = (family.sizeChart as PublicGoodDetailDto['sizeChart']) ?? dto.sizeChart;
|
||||
dto.packageSpecs =
|
||||
(family.packageSpecs as PublicGoodDetailDto['packageSpecs']) ?? dto.packageSpecs;
|
||||
}
|
||||
// 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重
|
||||
let familyVariants: Array<{
|
||||
originGoodId: bigint;
|
||||
variant: PublicGoodRow['originGood']['variants'][number];
|
||||
}> = [];
|
||||
const familyId = good.originGood.family?.id;
|
||||
if (familyId) {
|
||||
const members = await this.prisma.originGood.findMany({
|
||||
where: { familyId, delisted: false },
|
||||
select: {
|
||||
id: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
},
|
||||
});
|
||||
familyVariants = members
|
||||
.filter((m) => m.id !== good.originGoodId)
|
||||
.flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant })));
|
||||
}
|
||||
const dto = this.toPublicGoodDetail(good, familyVariants);
|
||||
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
||||
dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -265,6 +317,7 @@ export class PublicService {
|
||||
const rows = await this.prisma.good.findMany({
|
||||
where: {
|
||||
positionId: { not: null },
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||
},
|
||||
@@ -276,7 +329,19 @@ export class PublicService {
|
||||
],
|
||||
take: query.limit,
|
||||
});
|
||||
return rows.map((good) => this.toPublicGood(good));
|
||||
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
|
||||
const seen = new Set<string>();
|
||||
const items: PublicGoodDto[] = [];
|
||||
for (const good of rows) {
|
||||
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const dto = this.toPublicGood(good);
|
||||
const familyMin = this.familyMinPrice(good);
|
||||
if (good.familyId && familyMin !== null) dto.price = familyMin;
|
||||
items.push(dto);
|
||||
}
|
||||
return items.slice(0, query.limit);
|
||||
}
|
||||
|
||||
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
|
||||
@@ -285,7 +350,8 @@ export class PublicService {
|
||||
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||
: null;
|
||||
return {
|
||||
goodId: good.originGood.sdsGoodId,
|
||||
// 有族 → 族ID(对外契约);无族(自定义商品)→ sdsGoodId
|
||||
goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId,
|
||||
goodName: good.goodName,
|
||||
goodPriority: good.goodPriority,
|
||||
country: {
|
||||
@@ -389,14 +455,17 @@ export class PublicService {
|
||||
originGoodId: bigint;
|
||||
variant: PublicGoodRow['originGood']['variants'][number];
|
||||
}> = [],
|
||||
/** 族成员 detail(款级公共规格并集来源之一;族视角详情传入,链接视角为空) */
|
||||
familyDetails: Array<PublicGoodRow['originGood']['detail']> = [],
|
||||
): PublicGoodDetailDto {
|
||||
const base = this.toPublicGood(good);
|
||||
const detail = good.originGood.detail;
|
||||
// Detail specs filled from secondaries for sizes/options the primary
|
||||
// does not have.
|
||||
const secondaryDetails = good.mergedOriginGoods
|
||||
.map((m) => m.originGood.detail)
|
||||
.filter((d): d is NonNullable<typeof d> => Boolean(d));
|
||||
// Detail specs filled from secondaries (族成员 + 旧副源) for sizes/options
|
||||
// the primary does not have.
|
||||
const secondaryDetails = [
|
||||
...familyDetails,
|
||||
...good.mergedOriginGoods.map((m) => m.originGood.detail),
|
||||
].filter((d): d is NonNullable<typeof d> => Boolean(d));
|
||||
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
|
||||
// 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重,
|
||||
// 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。
|
||||
@@ -479,6 +548,7 @@ export class PublicService {
|
||||
const matrix = family.priceMatrix as {
|
||||
sizes: Array<{ key: string; name: string | null }>;
|
||||
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
|
||||
printCounts: string[];
|
||||
crafts: string[];
|
||||
logistics: string[];
|
||||
rows: Array<{ price: string }>;
|
||||
@@ -491,6 +561,7 @@ export class PublicService {
|
||||
familyName: family.familyName,
|
||||
sizes: matrix.sizes,
|
||||
colors: matrix.colors,
|
||||
printCounts: matrix.printCounts ?? [],
|
||||
crafts: matrix.crafts,
|
||||
logistics: matrix.logistics,
|
||||
sizeChart: (family.sizeChart as Record<string, unknown> | null) ?? null,
|
||||
|
||||
Reference in New Issue
Block a user