diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts
index 6a169b5..935c7b1 100644
--- a/apps/admin/src/utils/origin-name.ts
+++ b/apps/admin/src/utils/origin-name.ts
@@ -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('烫画');
diff --git a/apps/admin/src/views/goods/components/GoodsEditDialog.vue b/apps/admin/src/views/goods/components/GoodsEditDialog.vue
index 1e15e98..21e06ca 100644
--- a/apps/admin/src/views/goods/components/GoodsEditDialog.vue
+++ b/apps/admin/src/views/goods/components/GoodsEditDialog.vue
@@ -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() {
{{ row.craft || '-' }}
- {{ row.craft?.includes('双面印花') ? '双面印花' : row.craft?.includes('单面印花') ? '单面印花' : '-' }}
+ {{ row.printCount || '-' }}
diff --git a/apps/api/prisma/migrations/20260828120000_override_print_count/migration.sql b/apps/api/prisma/migrations/20260828120000_override_print_count/migration.sql
new file mode 100644
index 0000000..d834d3b
--- /dev/null
+++ b/apps/api/prisma/migrations/20260828120000_override_print_count/migration.sql
@@ -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");
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 6d3036d..e964f93 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -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")
}
diff --git a/apps/api/src/product-families/auto-tag-rules.spec.ts b/apps/api/src/product-families/auto-tag-rules.spec.ts
index 16ad9d2..1bf09ce 100644
--- a/apps/api/src/product-families/auto-tag-rules.spec.ts
+++ b/apps/api/src/product-families/auto-tag-rules.spec.ts
@@ -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-不打印·美西洛杉矶二仓'),
diff --git a/apps/api/src/product-families/auto-tag-rules.ts b/apps/api/src/product-families/auto-tag-rules.ts
index fd2ae32..371e5b5 100644
--- a/apps/api/src/product-families/auto-tag-rules.ts
+++ b/apps/api/src/product-families/auto-tag-rules.ts
@@ -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();
for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
diff --git a/apps/api/src/product-families/dto/product-family.dto.ts b/apps/api/src/product-families/dto/product-family.dto.ts
index 6cd687e..a8a0c71 100644
--- a/apps/api/src/product-families/dto/product-family.dto.ts
+++ b/apps/api/src/product-families/dto/product-family.dto.ts
@@ -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;
diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts
index 26aa4d5..159110c 100644
--- a/apps/api/src/product-families/family-recompute.service.spec.ts
+++ b/apps/api/src/product-families/family-recompute.service.spec.ts
@@ -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);
});
diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts
index 6c89464..488c70e 100644
--- a/apps/api/src/product-families/family-recompute.service.ts
+++ b/apps/api/src/product-families/family-recompute.service.ts
@@ -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;
+
+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): 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();
const cellMap = new Map();
- 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,
diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts
index fbc560f..31a82f6 100644
--- a/apps/api/src/product-families/product-families.service.spec.ts
+++ b/apps/api/src/product-families/product-families.service.spec.ts
@@ -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 } });
diff --git a/apps/api/src/product-families/product-families.service.ts b/apps/api/src/product-families/product-families.service.ts
index 2b1f445..8708eba 100644
--- a/apps/api/src/product-families/product-families.service.ts
+++ b/apps/api/src/product-families/product-families.service.ts
@@ -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,
},
diff --git a/apps/api/src/public/dto/public-good-detail.dto.ts b/apps/api/src/public/dto/public-good-detail.dto.ts
index b9c764e..2a25840 100644
--- a/apps/api/src/public/dto/public-good-detail.dto.ts
+++ b/apps/api/src/public/dto/public-good-detail.dto.ts
@@ -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 | null;
diff --git a/apps/api/src/public/public-family-block.spec.ts b/apps/api/src/public/public-family-block.spec.ts
index 1c02eac..83796ba 100644
--- a/apps/api/src/public/public-family-block.spec.ts
+++ b/apps/api/src/public/public-family-block.spec.ts
@@ -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();
});
});
diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts
index 8487ffe..c7e6423 100644
--- a/apps/api/src/public/public.controller.ts
+++ b/apps/api/src/public/public.controller.ts
@@ -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 {
- 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 {
+ return this.service.getTags();
+ }
+
@Get('tag-groups')
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
getTagGroups(@Query() query: PublicCountryQueryDto): Promise {
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 {
return this.service.getGood(goodId);
diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts
index 5099ed9..535d528 100644
--- a/apps/api/src/public/public.service.spec.ts
+++ b/apps/api/src/public/public.service.spec.ts
@@ -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 } });
diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts
index 2aac1de..9c6a3c6 100644
--- a/apps/api/src/public/public.service.ts
+++ b/apps/api/src/public/public.service.ts
@@ -66,6 +66,7 @@ export class PublicService {
async getCategoriesTree(countryId?: string): Promise {
const goodsWhere: Prisma.GoodWhereInput = {
+ familyId: { not: null },
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
@@ -109,7 +110,7 @@ export class PublicService {
async getCountries(): Promise {
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 {
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 {
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 {
- 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();
+ 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 {
- 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 {
+ 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();
+ 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 = [],
): 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 => 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 => 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 | null) ?? null,
diff --git a/docs/references/product-center.md b/docs/references/product-center.md
index c21a490..7aa51e4 100644
--- a/docs/references/product-center.md
+++ b/docs/references/product-center.md
@@ -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` 转只读保留,观察期后另行删除。
**后台操作入口(族替代旧主源/副源,界面保持原有布局)**:
diff --git a/docs/references/structs.md b/docs/references/structs.md
index 870ecb7..63c7e1b 100644
--- a/docs/references/structs.md
+++ b/docs/references/structs.md
@@ -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 |
diff --git a/plans/feature/public-family-id-feature.md b/plans/feature/public-family-id-feature.md
new file mode 100644
index 0000000..20d7606
--- /dev/null
+++ b/plans/feature/public-family-id-feature.md
@@ -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 侧再切换