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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user