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