feat(api): public good detail family block behind gray-release flag
This commit is contained in:
@@ -122,11 +122,17 @@ export class GoodsService {
|
||||
);
|
||||
await this.ensureMergedOriginGoods(mergedIds);
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
// Good 的族是派生数据:主链接所属族
|
||||
const primary = await tx.originGood.findUnique({
|
||||
where: { id: BigInt(dto.originGoodId) },
|
||||
select: { familyId: true },
|
||||
});
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
familyId: primary?.familyId ?? null,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
@@ -217,6 +223,7 @@ export class GoodsService {
|
||||
const good = await tx.good.create({
|
||||
data: {
|
||||
originGoodId: originGood.id,
|
||||
familyId: family?.id ?? null,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId:
|
||||
@@ -445,6 +452,7 @@ export class GoodsService {
|
||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
originGoodId: og.id,
|
||||
familyId: og.familyId,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
|
||||
@@ -221,6 +221,10 @@ export class ProductFamiliesService {
|
||||
where: { id: { in: removeIds }, familyId: id },
|
||||
data: { familyId: null },
|
||||
});
|
||||
await this.prisma.good.updateMany({
|
||||
where: { originGoodId: { in: removeIds }, familyId: id },
|
||||
data: { familyId: null },
|
||||
});
|
||||
// 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员
|
||||
if (remaining > 0) {
|
||||
const stillPrimary = await this.prisma.originGood.count({
|
||||
@@ -248,10 +252,12 @@ export class ProductFamiliesService {
|
||||
}
|
||||
|
||||
if (dto.addOriginGoodIds?.length) {
|
||||
await this.attachMembers(
|
||||
id,
|
||||
dto.addOriginGoodIds.map((v) => BigInt(v)),
|
||||
);
|
||||
const addIds = dto.addOriginGoodIds.map((v) => BigInt(v));
|
||||
await this.attachMembers(id, addIds);
|
||||
await this.prisma.good.updateMany({
|
||||
where: { originGoodId: { in: addIds } },
|
||||
data: { familyId: id },
|
||||
});
|
||||
}
|
||||
|
||||
await this.recompute.recomputeFamily(id);
|
||||
|
||||
@@ -59,6 +59,27 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
detailSyncedAt!: string | null;
|
||||
|
||||
/** 灰度字段:PUBLIC_DETAIL_FROM_FAMILY=true 且主链接有所属族时输出,否则完全不含该键 */
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
type: Object,
|
||||
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×工艺×物流)',
|
||||
})
|
||||
family?: {
|
||||
familyId: string;
|
||||
familyCode: string | null;
|
||||
familyName: string;
|
||||
sizes: Array<{ key: string; name: string | null }>;
|
||||
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
|
||||
crafts: string[];
|
||||
logistics: string[];
|
||||
sizeChart: Record<string, unknown> | null;
|
||||
packageSpecs: Record<string, unknown> | null;
|
||||
priceMatrix: Record<string, unknown>;
|
||||
minPrice: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export class PublicTagGroupFilterDto {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PublicService } from './public.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
|
||||
/**
|
||||
* 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、
|
||||
* 族块字段内容、Good.familyId 派生与成员移动联动。
|
||||
*/
|
||||
describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
let service: PublicService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const createdOriginGoodIds: bigint[] = [];
|
||||
const createdFamilyIds: bigint[] = [];
|
||||
const createdGoodIds: bigint[] = [];
|
||||
const createdCountryIds: bigint[] = [];
|
||||
const createdCategoryIds: bigint[] = [];
|
||||
const prevFlag = process.env.PUBLIC_DETAIL_FROM_FAMILY;
|
||||
let sdsGoodId = '';
|
||||
let familyId = 0n;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PublicService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PublicService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `公开族国家-${stamp}` },
|
||||
});
|
||||
createdCountryIds.push(country.id);
|
||||
const category = await prisma.category.create({
|
||||
data: { categoryName: `公开族分类-${stamp}` },
|
||||
});
|
||||
createdCategoryIds.push(category.id);
|
||||
|
||||
// 族 + 主链接(带变体)+ Good
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `pubfam-${stamp}`,
|
||||
goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花`,
|
||||
goodPrice: new Prisma.Decimal(25),
|
||||
craftLabel: '单面印花',
|
||||
logisticsLabel: '包邮',
|
||||
},
|
||||
});
|
||||
sdsGoodId = og.sdsGoodId;
|
||||
createdOriginGoodIds.push(og.id);
|
||||
await prisma.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId: og.id,
|
||||
sdsVariantId: 'pf-v1',
|
||||
sku: `PF-${stamp}-S`,
|
||||
sizeId: 'size_S',
|
||||
sizeName: 'S',
|
||||
colorId: 'color_blk',
|
||||
colorName: '黑色',
|
||||
price: new Prisma.Decimal(25),
|
||||
},
|
||||
});
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `公开族-${stamp}`, familyCode: `PF${stamp}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
familyId = family.id;
|
||||
createdFamilyIds.push(family.id);
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
||||
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og.id,
|
||||
familyId: family.id,
|
||||
countryId: country.id,
|
||||
categoryId: category.id,
|
||||
goodName: `公开族商品-${stamp}`,
|
||||
},
|
||||
});
|
||||
createdGoodIds.push(good.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = prevFlag;
|
||||
await prisma.good.deleteMany({ where: { id: { in: createdGoodIds } } });
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
|
||||
await prisma.country.deleteMany({ where: { id: { in: createdCountryIds } } });
|
||||
await prisma.category.deleteMany({ where: { id: { in: createdCategoryIds } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('开关关闭:响应完全不含 family 键(与现状形状一致)', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
|
||||
const detail = await service.getGood(sdsGoodId);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
expect('family' in detail).toBe(false);
|
||||
});
|
||||
|
||||
it('开关开启:输出族块(物化矩阵 + 并集 + 起价)', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||
const detail = await service.getGood(sdsGoodId);
|
||||
expect(detail.family).not.toBeNull();
|
||||
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
|
||||
expect(detail.family!.familyId).toBe(familyId.toString());
|
||||
expect(detail.family!.crafts).toEqual(['单面印花']);
|
||||
expect(detail.family!.logistics).toEqual(['包邮']);
|
||||
expect(detail.family!.priceMatrix).toBeTruthy();
|
||||
expect(detail.family!.minPrice).toBe('25');
|
||||
// 旧字段保留(向后兼容)
|
||||
expect(detail.variants.length).toBe(1);
|
||||
expect(detail.sizeChart).toBeDefined();
|
||||
});
|
||||
|
||||
it('无族商品:开关开启也不含 family 键', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||
const og2 = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `pubfam-2-${stamp}`,
|
||||
goodName: `无族链接-${stamp}`,
|
||||
goodPrice: new Prisma.Decimal(10),
|
||||
},
|
||||
});
|
||||
createdOriginGoodIds.push(og2.id);
|
||||
const good2 = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og2.id,
|
||||
countryId: createdCountryIds[0],
|
||||
categoryId: createdCategoryIds[0],
|
||||
goodName: `无族商品-${stamp}`,
|
||||
},
|
||||
});
|
||||
createdGoodIds.push(good2.id);
|
||||
const detail = await service.getGood(`pubfam-2-${stamp}`);
|
||||
expect('family' in detail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Good familyId derivation', () => {
|
||||
let prisma: PrismaService;
|
||||
let familiesService: import('../product-families/product-families.service').ProductFamiliesService;
|
||||
const stamp = Date.now();
|
||||
const ids = { originGood: [] as bigint[], family: [] as bigint[], good: [] as bigint[], country: [] as bigint[], category: [] as bigint[] };
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaService],
|
||||
}).compile();
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
|
||||
prisma,
|
||||
new FamilyRecomputeService(prisma),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
|
||||
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
|
||||
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
||||
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
|
||||
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('updateMembers 移除/添加成员时,Good.familyId 联动', async () => {
|
||||
const country = await prisma.country.create({ data: { countryName: `联动国家-${stamp}` } });
|
||||
ids.country.push(country.id);
|
||||
const category = await prisma.category.create({ data: { categoryName: `联动分类-${stamp}` } });
|
||||
ids.category.push(category.id);
|
||||
const og = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `derive-${stamp}`, goodName: `联动链接-${stamp}` },
|
||||
});
|
||||
ids.originGood.push(og.id);
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `联动族-${stamp}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
ids.family.push(family.id);
|
||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
|
||||
const good = await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: og.id,
|
||||
familyId: family.id,
|
||||
countryId: country.id,
|
||||
categoryId: category.id,
|
||||
goodName: `联动商品-${stamp}`,
|
||||
},
|
||||
});
|
||||
ids.good.push(good.id);
|
||||
|
||||
// 移出族 → Good.familyId 置空
|
||||
await familiesService.updateMembers(family.id, { removeOriginGoodIds: [og.id.toString()] });
|
||||
expect(
|
||||
(await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId,
|
||||
).toBeNull();
|
||||
|
||||
// 重新挂回 → Good.familyId 回填
|
||||
await familiesService.updateMembers(family.id, { addOriginGoodIds: [og.id.toString()] });
|
||||
expect(
|
||||
(await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId,
|
||||
).toBe(family.id);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,16 @@ const PUBLIC_GOOD_INCLUDE = {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
family: {
|
||||
select: {
|
||||
id: true,
|
||||
familyCode: true,
|
||||
familyName: true,
|
||||
sizeChart: true,
|
||||
packageSpecs: true,
|
||||
priceMatrix: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mergedOriginGoods: {
|
||||
@@ -402,6 +412,42 @@ export class PublicService {
|
||||
sortOrder: variant.sortOrder,
|
||||
})),
|
||||
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
||||
...this.familyBlock(good),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 灰度族块(设计 D4:零新增公开端点):仅当 PUBLIC_DETAIL_FROM_FAMILY=true
|
||||
* 且主链接有所属族时输出;数据全部来自 ProductFamily 的物化 JSON,无额外查询。
|
||||
*/
|
||||
private familyBlock(
|
||||
good: PublicGoodRow,
|
||||
): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
|
||||
if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'true') return {};
|
||||
const family = good.originGood.family;
|
||||
if (!family || !family.priceMatrix) return {};
|
||||
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 }>;
|
||||
crafts: string[];
|
||||
logistics: string[];
|
||||
rows: Array<{ price: string }>;
|
||||
};
|
||||
const prices = matrix.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
|
||||
return {
|
||||
family: {
|
||||
familyId: family.id.toString(),
|
||||
familyCode: family.familyCode,
|
||||
familyName: family.familyName,
|
||||
sizes: matrix.sizes,
|
||||
colors: matrix.colors,
|
||||
crafts: matrix.crafts,
|
||||
logistics: matrix.logistics,
|
||||
sizeChart: (family.sizeChart as Record<string, unknown> | null) ?? null,
|
||||
packageSpecs: (family.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||
priceMatrix: matrix as unknown as Record<string, unknown>,
|
||||
minPrice: prices.length ? String(Math.min(...prices)) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -715,6 +715,11 @@ export class SyncService {
|
||||
where: { id: originGoodId },
|
||||
data: { familyId },
|
||||
});
|
||||
// Good 的族随主链接联动
|
||||
await this.prisma.good.updateMany({
|
||||
where: { originGoodId },
|
||||
data: { familyId },
|
||||
});
|
||||
this.familyRecompute.enqueue(familyId);
|
||||
} else {
|
||||
await this.prisma.productFamily.update({
|
||||
|
||||
Reference in New Issue
Block a user