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");
|
||||
@@ -332,6 +332,7 @@ model FamilyPriceOverride {
|
||||
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)
|
||||
@@ -341,7 +342,7 @@ model FamilyPriceOverride {
|
||||
|
||||
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,13 +165,22 @@ 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 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 && !colorKey) continue;
|
||||
if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName);
|
||||
if (colorKey) {
|
||||
const prev = colors.get(colorKey);
|
||||
@@ -114,7 +190,16 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
imageUrl: prev?.imageUrl ?? variant.imageUrl,
|
||||
});
|
||||
}
|
||||
const key = `${sizeKey}|${colorKey}|${member.craftLabel}|${member.logisticsLabel}`;
|
||||
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,
|
||||
@@ -128,8 +213,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
sizeName: variant.sizeName,
|
||||
colorId: colorKey,
|
||||
colorName: variant.colorName,
|
||||
craft: member.craftLabel,
|
||||
logistics: member.logisticsLabel,
|
||||
printCount: combo.printCount,
|
||||
craft: combo.craft,
|
||||
logistics: combo.logistics,
|
||||
price: priceStr,
|
||||
manual: false,
|
||||
sources: [source],
|
||||
@@ -140,23 +226,28 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export class PublicController {
|
||||
}
|
||||
|
||||
@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({
|
||||
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
||||
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
||||
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
|
||||
const rows = await this.prisma.good.findMany({
|
||||
where,
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
/** 族物化矩阵的最低价;无族/无矩阵返回 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;
|
||||
}
|
||||
// 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重
|
||||
let familyVariants: Array<{
|
||||
originGoodId: bigint;
|
||||
variant: PublicGoodRow['originGood']['variants'][number];
|
||||
}> = [];
|
||||
const familyId = good.originGood.family?.id;
|
||||
if (familyId) {
|
||||
|
||||
/**
|
||||
* 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。
|
||||
* 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。
|
||||
*/
|
||||
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||
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,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
},
|
||||
});
|
||||
familyVariants = members
|
||||
.filter((m) => m.id !== good.originGoodId)
|
||||
.flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant })));
|
||||
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;
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -100,31 +100,42 @@ pnpm --filter @inkreach/api backfill:product-families
|
||||
# → [1/2] 解析全部链接名 [2/2] 自动建族 [3/3] 全量族重算(幂等,可随时重跑)
|
||||
```
|
||||
|
||||
**公开读路径(三期,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
||||
`GET /public/goods/:goodId` 行为:
|
||||
**公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
||||
公开端点**以款(族)为一等公民**:
|
||||
|
||||
- **变体并集(替代旧副源机制)**:`variants` = 主链接 ∪ 全体族成员 ∪ 旧副源(过渡期),
|
||||
按 `(链接, 变体)` 去重;`mediaByColor` 同源。任何族成员链接的 sdsGoodId 均命中同一商品。
|
||||
- **族块**(默认输出):`family` 含并集尺码表/包装 + 五维价格矩阵 + 族起价:
|
||||
- `GET /public/goods`:**一族一条**(族内多条 Good 去重,代表行=排序第一条),
|
||||
`goodId` = **族 ID**,`total` = 族数,分页作用于分组后;`price` = 族矩阵最低价(起价),
|
||||
价格排序按族起价重排;**无族商品(自定义)完全不返回**(列表/首页/分类树/标签统计同理)。
|
||||
- `GET /public/goods/:goodId`:`goodId` 即**族 ID**(唯一公开寻址键;旧 SDS 链接 ID 与
|
||||
自定义商品 sdsGoodId 均 404)。响应为款级聚合:
|
||||
|
||||
- 公共信息取代表 Good(名称/主图/国家/分类/标签)+ 主链接(详情文案);
|
||||
- `variants` = 全体族成员 ∪ 旧副源(过渡期),按 `(链接, 变体)` 再按 `颜色+尺码` 去重;
|
||||
`mediaByColor` 同源;`options/media` 并入族成员 detail;
|
||||
- `sizeChart` / `packageSpecs` = 族物化并集(空并集回退主链接合并结果);
|
||||
- **族块**(默认输出):`family` 含并集尺码表/包装 + **严格五维价格矩阵** + 族起价:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"family": {
|
||||
"familyId": "12", "familyCode": "DG015", "familyName": "DG015 180G纯棉T恤",
|
||||
"sizes": [...], "colors": [...], "crafts": ["单面印花", ...], "logistics": ["包邮", ...],
|
||||
"sizes": [...], "colors": [...],
|
||||
"printCounts": ["单面印花", "双面印花"], "crafts": ["烫画", "直喷", "不打印"], "logistics": ["包邮", "不包邮"],
|
||||
"sizeChart": { /* 并集 */ }, "packageSpecs": { /* 并集 */ },
|
||||
"priceMatrix": { /* 五维矩阵:rows 为 {sizeId, colorId, craft, logistics, price, manual, sources} */ },
|
||||
"priceMatrix": { /* rows 为 {sizeId, colorId, printCount, craft, logistics, price, manual, sources} */ },
|
||||
"minPrice": "29.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实测(DG015 族):4 工艺 × 2 物流 × 8 尺码 × 12 颜色 = 653 格矩阵,族起价 ¥29.5
|
||||
(主源单链接价为 ¥68.74——族视角展示了光板/单面的更低档价格)。前端本地按五维联动
|
||||
`priceMatrix` 即可实时算价,无需新增查价端点(设计 D4)。
|
||||
矩阵维度来源是**链接级标签**(印花数量/印刷工艺/物流渠道三组;人工接管后按人工标签),
|
||||
不再用原始链接名的第 3 段(`craftLabel` 含「直喷单面/光板不打印」等噪声)。名称派生规则已
|
||||
覆盖裸「单面/双面」写法(如「直喷双面」→ 双面印花 + 直喷)。光板/不打印链接无印花面概念,
|
||||
矩阵中印花数量维回退「单面印花」占位。前端本地按五维联动 `priceMatrix` 即可实时算价
|
||||
(设计 D4)。
|
||||
|
||||
**边界声明**:商品列表/筛选/排序仍基于主源 `goodPrice`(SQL 层无法廉价解析族矩阵 JSON,
|
||||
且避免展示价与筛选价不一致);`good_origin_goods` 转只读保留,观察期后另行删除。
|
||||
**边界声明**:价格区间筛选(minPrice/maxPrice)仍作用于链接 `goodPrice`(族内任一链接命中即
|
||||
返回该族;SQL 层无法廉价解析族矩阵 JSON);`good_origin_goods` 转只读保留,观察期后另行删除。
|
||||
|
||||
**后台操作入口(族替代旧主源/副源,界面保持原有布局)**:
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ apps/api/
|
||||
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
|
||||
| `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 |
|
||||
| `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 |
|
||||
| `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagIds(逗号分隔)/keyword/page/pageSize`,`tagIds` 为 AND 关系) | 公开 |
|
||||
| `/public/goods/:id` `GET` | 商品详情;默认输出 `family` 块(并集尺码表/包装 + 五维价格矩阵 + 族起价),`variants` = 主链接 ∪ 族成员 ∪ 旧副源(去重);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退旧行为 | 公开 |
|
||||
| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`) | 公开 |
|
||||
| `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 ∪ 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 |
|
||||
| `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT |
|
||||
| `/tags/sort` `PATCH` | 批量更新 tag 排序和分组归属 | JWT |
|
||||
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
|
||||
@@ -127,7 +127,7 @@ apps/api/
|
||||
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
|
||||
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
|
||||
| `/product-families/:id/members/custom` `POST` | 族内创建自定义成员(人工商品:物流/工艺归因必填 + 变体价格 + 可选尺码表/包装) | JWT |
|
||||
| `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;维度必须存在于族矩阵选项 | JWT |
|
||||
| `/product-families/:id/price-overrides` `GET/PUT/DELETE` | 人工改价:查(含推导价对照与差额)/ 批量 upsert / 按格删除恢复推导价;格子五键 `sizeId+colorId+printCount+craft+logistics` 必须存在于族矩阵选项 | JWT |
|
||||
| `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT |
|
||||
| `/sync/categories` `POST` | 手动触发分类同步 | JWT |
|
||||
| `/sync/products` `POST` | 手动触发商品同步 | JWT |
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 公开契约族化:goodId = 族ID + 五维价格矩阵
|
||||
|
||||
## 背景 / 动机
|
||||
|
||||
- 现状 `GET /public/goods/{goodId}` 以 SDS 链接 ID 寻址,`findFirst` 任选一条 Good;
|
||||
一个族在库内常配置多条 Good(92 个已配置族中 78 个是多条,如加拿大 CATM001 一族 4 条),
|
||||
列表因此出现同款重复,前端被迫理解"族背后有多少商品"。
|
||||
- 价格矩阵把 印花数量+工艺 混在一个 `craft` 维(原始链接名第 3 段,含
|
||||
`直喷单面`/`光板不打印`/解析噪声,且 47 个成员为 null),不是严格的五维。
|
||||
|
||||
目标契约(用户拍板):**前端只知道款号(族)**——
|
||||
|
||||
- `goodId` = 族 ID;前端不知道族背后有几条 Good / 几个链接
|
||||
- 详情 = 款的公共信息(名称、主图、国家、分类、商品详情文案、尺码表、包装规格)
|
||||
+ 价格矩阵
|
||||
- 价格严格五维:**尺码 × 颜色 × 物流 × 工艺 × 印花数量 → 价格**
|
||||
|
||||
## 事实核查(2026-08-28)
|
||||
|
||||
- 0 个族跨国家/跨分类 → 国家、分类可作为族的公共属性
|
||||
- `family_price_overrides` 0 行 → 加维度无数据回填负担
|
||||
- 无族 Good 6 条(source=CUSTOM)→ 必须保留 sdsGoodId 寻址回退
|
||||
- 链接级标签已全覆盖(536/536),三组派生标签是矩阵维度的可靠来源
|
||||
|
||||
## 方案
|
||||
|
||||
### A. priceMatrix 五维化(family-recompute.service.ts)
|
||||
|
||||
- `PriceMatrixRow` 增加 `printCount`;`PriceMatrix` 增加 `printCounts: string[]`
|
||||
- 成员维度来源改为**有效标签**(OriginGoodTag ∋ 物流渠道/印花数量/印刷工艺三组;
|
||||
人工接管时用人工标签),三组各自取值做笛卡尔(通常 1×1×1);任一组缺失则该成员不进矩阵
|
||||
- 弃用 `craftLabel` 作为维度(保留字段用于展示回退)
|
||||
- 同格子多来源取最低价、sources 全保留、人工覆盖并入 —— 规则不变
|
||||
|
||||
### B. FamilyPriceOverride 加 printCount(prisma migration)
|
||||
|
||||
- 新列 `print_count`(默认 `单面印花`,迁移时 0 行数据)
|
||||
- 唯一键扩展为 (familyId, sizeId, colorId, printCount, craft, logistics)
|
||||
|
||||
### C. product-families.service 覆盖价三处五键化
|
||||
|
||||
- `PriceOverrideItemDto` + `putPriceOverrides` 校验(allowed.printCounts)
|
||||
- `listPriceOverrides` 行匹配五键
|
||||
|
||||
### D. public 契约族化(public.service.ts)
|
||||
|
||||
1. `getGoods`:过滤条件不变(作用于 Good),**取全量匹配后内存按族分组**
|
||||
(规模 241 条 Good,注释说明;无族 Good 各自成组)。
|
||||
- 组代表 = 现有排序的第一条(priority desc → createdAt desc)
|
||||
- `goodId`:有族 → `familyId`;无族 → `sdsGoodId`(自定义商品)
|
||||
- `price`:族 minPrice(批量查 priceMatrix),回退 og price
|
||||
- `total` = 分组数,分页在分组后
|
||||
2. `getGood(id)`:**纯数字先按族 ID 解析**(族 + 其 Good + 成员变体并集),
|
||||
未命中再回退 sdsGoodId 旧路径(自定义商品/向后兼容)。
|
||||
族详情:公共字段取代表 Good;detail 文案取主链接;尺码表/包装规格用族并集;
|
||||
media/mediaByColor/variants 全成员并集;`family` 块 = 五维矩阵 + minPrice。
|
||||
族下无在售 Good → 404。`PUBLIC_DETAIL_FROM_FAMILY=false` 应急开关行为不变。
|
||||
3. `getHomeGoods`:按族去重(排序不变,同族保留最前一条)。
|
||||
|
||||
### E. admin 适配(GoodsEditDialog.vue)
|
||||
|
||||
- SKU 列:印花数量列直接读 `row.printCount`(不再从 craft 字符串猜测)
|
||||
- `loadMemberPrices`:按 printCount+craft+logistics 三键匹配该成员格子;
|
||||
维度优先取成员 `originGoodTags`(人工接管后仍准确),回退 `linkDims(goodName)`
|
||||
- 保存覆盖价 payload 带 printCount
|
||||
|
||||
### F. 存量族全量重算
|
||||
|
||||
一次性脚本:对所有 ProductFamily 逐个 `recomputeFamily`(矩阵物化 JSON 结构变更)。
|
||||
|
||||
### G. 测试与文档
|
||||
|
||||
- family-recompute spec:五维矩阵(含 人工标签 覆盖派生、缺组跳过、跨成员最低价)
|
||||
- product-families spec:覆盖价五键校验
|
||||
- public spec:列表按族去重(同族 N 条 → 1 条,goodId=familyId)、getGood(族ID)、
|
||||
自定义商品回退、home-goods 去重
|
||||
- public-family-block spec:矩阵 printCounts
|
||||
- admin:vue-tsc + 现有 22 测试
|
||||
- 文档:docs/references/product-center.md、structs.md
|
||||
|
||||
## 风险 / 边界
|
||||
|
||||
- 数字族 ID 与数字 sdsGoodId 理论碰撞:族 ID 优先(sdsGoodId 为 SDS 系统 6 位数,
|
||||
实际不碰撞),Swagger 注明
|
||||
- 列表内存分组在商品量级 10⁴ 前可接受,注释留优化方向(物化族表)
|
||||
- 旧前端(website)以 sdsGoodId 寻址仍可达(回退路径),后续 website 侧再切换
|
||||
Reference in New Issue
Block a user