feat(public): dedupe merged variants and merge specs/options from secondary origins

This commit is contained in:
yeuimu
2026-08-28 11:05:34 +08:00
parent 8233dda07a
commit 457792b729
2 changed files with 177 additions and 7 deletions
@@ -413,5 +413,84 @@ describe('PublicService', () => {
await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined);
} }
}); });
it('dedupes variants by color+size (primary wins) and merges specs/options', async () => {
const stamp2 = `${stamp}-merge2`;
const primaryOg = await prisma.originGood.create({
data: { sdsGoodId: `pub-pri-${stamp2}`, goodName: `Pub Pri ${stamp2}` },
});
const secondaryOg = await prisma.originGood.create({
data: { sdsGoodId: `pub-sec-${stamp2}`, goodName: `Pub Sec ${stamp2}` },
});
// Primary: Black|S and Black|M, size chart S/M, package S, options S/M.
await prisma.originGoodVariant.createMany({
data: [
{ originGoodId: primaryOg.id, sdsVariantId: `v1-${stamp2}`, sku: `SK1-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 16 },
{ originGoodId: primaryOg.id, sdsVariantId: `v2-${stamp2}`, sku: `SK2-${stamp2}`, colorName: 'black', sizeName: 'M', price: 16 },
],
});
await prisma.originGoodDetail.create({
data: {
originGoodId: primaryOg.id,
sizeChart: { rows: [{ sizeName: 'S', measurements: [{ key: 'chest', cm: '94' }] }, { sizeName: 'M', measurements: [{ key: 'chest', cm: '100' }] }] },
packageSpecs: { rows: [{ sizeName: 'S' }] },
options: { sizes: [{ name: 'S' }, { name: 'M' }] },
},
});
// Secondary: duplicate Black|S with a DIFFERENT price (must be dropped,
// primary wins), plus a unique color Kelly|S; specs add XXXL rows.
await prisma.originGoodVariant.createMany({
data: [
{ originGoodId: secondaryOg.id, sdsVariantId: `v3-${stamp2}`, sku: `SK3-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 20 },
{ originGoodId: secondaryOg.id, sdsVariantId: `v4-${stamp2}`, sku: `SK4-${stamp2}`, colorName: 'Kelly', sizeName: 'S', price: 22 },
],
});
await prisma.originGoodDetail.create({
data: {
originGoodId: secondaryOg.id,
sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] },
packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] },
options: { sizes: [{ name: 'XXXL' }] },
},
});
const mergedGood = await prisma.good.create({
data: {
goodName: `Pub Merged ${stamp2}`,
originGoodId: primaryOg.id,
countryId,
categoryId,
},
});
await prisma.goodOriginGood.create({
data: { goodId: mergedGood.id, originGoodId: secondaryOg.id },
});
try {
const detail = await service.getGood(`pub-pri-${stamp2}`);
// Variants: 3 unique color+size combos (case-insensitive); duplicate
// Black|S from the secondary deduped.
expect(
detail.variants.map((v) => `${v.colorName}/${v.sizeName}`.toLowerCase()).sort(),
).toEqual(['black/m', 'black/s', 'kelly/s']);
const blackS = detail.variants.find((v) => v.colorName === 'Black' && v.sizeName === 'S');
expect(blackS!.price).toBe('16'); // primary price wins over secondary 20
// Size chart: S/M from primary, XXXL appended from secondary.
const chartSizes = (detail.sizeChart as any).rows.map((r: any) => r.sizeName).sort();
expect(chartSizes).toEqual(['M', 'S', 'XXXL']);
// Package specs: S from primary, M/XXXL appended from secondary.
const pkgSizes = (detail.packageSpecs as any).rows.map((r: any) => r.sizeName).sort();
expect(pkgSizes).toEqual(['M', 'S', 'XXXL']);
// Options: sizes unioned S/M + XXXL.
const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort();
expect(optSizes).toEqual(['M', 'S', 'XXXL']);
} finally {
await prisma.goodOriginGood.deleteMany({ where: { goodId: mergedGood.id } });
await prisma.good.delete({ where: { id: mergedGood.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 } });
await prisma.originGood.delete({ where: { id: secondaryOg.id } }).catch(() => undefined);
}
});
}); });
}); });
+98 -7
View File
@@ -39,6 +39,7 @@ const PUBLIC_GOOD_INCLUDE = {
include: { include: {
originGood: { originGood: {
include: { include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
}, },
}, },
@@ -355,11 +356,17 @@ export class PublicService {
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
const base = this.toPublicGood(good); const base = this.toPublicGood(good);
const detail = good.originGood.detail; const detail = good.originGood.detail;
// Merge primary and secondary origin good variants (dedup identical URLs). // Merge primary and secondary origin goods: variants deduped by
const allVariants = [ // color+size (primary wins), detail specs filled from secondaries
// for sizes/options the primary does not have.
const secondaryDetails = good.mergedOriginGoods
.map((m) => m.originGood.detail)
.filter((d): d is NonNullable<typeof d> => Boolean(d));
const secondaryVariantLists = good.mergedOriginGoods.map((m) => m.originGood.variants);
const allVariants = this.dedupeVariants([
...good.originGood.variants, ...good.originGood.variants,
...good.mergedOriginGoods.flatMap((m) => m.originGood.variants), ...secondaryVariantLists.flat(),
]; ]);
return { return {
...base, ...base,
productCode: detail?.productCode ?? null, productCode: detail?.productCode ?? null,
@@ -380,9 +387,9 @@ export class PublicService {
}, },
media: (detail?.media as Record<string, unknown> | null) ?? null, media: (detail?.media as Record<string, unknown> | null) ?? null,
mediaByColor: this.groupImagesByColor(allVariants), mediaByColor: this.groupImagesByColor(allVariants),
options: (detail?.options as Record<string, unknown> | null) ?? null, options: this.mergeOptions(detail, secondaryDetails),
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null, sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'),
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null, packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'),
variants: allVariants.map((variant) => ({ variants: allVariants.map((variant) => ({
id: variant.sdsVariantId, id: variant.sdsVariantId,
sku: variant.sku, sku: variant.sku,
@@ -405,6 +412,90 @@ export class PublicService {
}; };
} }
/** Norm key for variant/dedupe matching: case-insensitive, trimmed. */
private static normName(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase();
}
/** Dedupe variants by color+size (case-insensitive); first (primary) wins. */
private dedupeVariants(variants: PublicGoodRow['originGood']['variants']) {
const seen = new Set<string>();
const result: PublicGoodRow['originGood']['variants'] = [];
for (const variant of variants) {
const key = `${PublicService.normName(variant.colorName)}|${PublicService.normName(variant.sizeName)}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(variant);
}
return result;
}
/**
* Merge a `{ rows: [...] }` spec (sizeChart / packageSpecs) across primary
* and secondary details: primary rows win, rows for sizes the primary
* lacks are appended from secondaries in order.
*/
private mergeRowsByKey(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
field: 'sizeChart' | 'packageSpecs',
): Record<string, unknown> | null {
const primaryRows = (primary?.[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(primaryRows) && secondaries.length === 0) return null;
const rows: Array<Record<string, unknown>> = Array.isArray(primaryRows) ? [...primaryRows] : [];
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.sizeName ?? ''))));
for (const sec of secondaries) {
const secRows = (sec[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(secRows)) continue;
for (const row of secRows) {
const key = PublicService.normName(String(row?.sizeName ?? ''));
if (seen.has(key)) continue;
seen.add(key);
rows.push(row);
}
}
if (rows.length === 0) return null;
return { ...(primary?.[field] as Record<string, unknown> | null ?? {}), rows };
}
/**
* Merge options across details: sizes/colors are unioned by name,
* primary entries win and secondary-only ones are appended.
*/
private mergeOptions(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | null {
const primaryOptions = primary?.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
if (!primaryOptions && secondaries.length === 0) return null;
const merged: Record<string, unknown> = { ...(primaryOptions ?? {}) };
for (const listKey of ['sizes', 'colors'] as const) {
const base = Array.isArray(primaryOptions?.[listKey])
? [...(primaryOptions![listKey] as Array<Record<string, unknown>>)]
: null;
if (!base && secondaries.length === 0) continue;
const rows = base ?? [];
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.name ?? ''))));
for (const sec of secondaries) {
const secOptions = sec.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
const secRows = secOptions?.[listKey];
if (!Array.isArray(secRows)) continue;
for (const row of secRows) {
const key = PublicService.normName(String(row?.name ?? ''));
if (seen.has(key)) continue;
seen.add(key);
rows.push(row);
}
}
merged[listKey] = rows;
}
return merged;
}
private async buildTagGroupFilters( private async buildTagGroupFilters(
selectedGroups: PublicTagFilterDto[], selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> { ): Promise<Prisma.GoodWhereInput[]> {