diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index f9b0caf..1272ff6 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -413,5 +413,84 @@ describe('PublicService', () => { 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); + } + }); }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 37e74ba..68599d9 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -39,6 +39,7 @@ const PUBLIC_GOOD_INCLUDE = { include: { originGood: { include: { + detail: true, variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, }, @@ -355,11 +356,17 @@ export class PublicService { private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto { const base = this.toPublicGood(good); const detail = good.originGood.detail; - // Merge primary and secondary origin good variants (dedup identical URLs). - const allVariants = [ + // Merge primary and secondary origin goods: variants deduped by + // 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 => Boolean(d)); + const secondaryVariantLists = good.mergedOriginGoods.map((m) => m.originGood.variants); + const allVariants = this.dedupeVariants([ ...good.originGood.variants, - ...good.mergedOriginGoods.flatMap((m) => m.originGood.variants), - ]; + ...secondaryVariantLists.flat(), + ]); return { ...base, productCode: detail?.productCode ?? null, @@ -380,9 +387,9 @@ export class PublicService { }, media: (detail?.media as Record | null) ?? null, mediaByColor: this.groupImagesByColor(allVariants), - options: (detail?.options as Record | null) ?? null, - sizeChart: (detail?.sizeChart as Record | null) ?? null, - packageSpecs: (detail?.packageSpecs as Record | null) ?? null, + options: this.mergeOptions(detail, secondaryDetails), + sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'), + packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'), variants: allVariants.map((variant) => ({ id: variant.sdsVariantId, 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(); + 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[], + field: 'sizeChart' | 'packageSpecs', + ): Record | null { + const primaryRows = (primary?.[field] as { rows?: Array> } | null)?.rows; + if (!Array.isArray(primaryRows) && secondaries.length === 0) return null; + const rows: Array> = 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> } | 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 | 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[], + ): Record | null { + const primaryOptions = primary?.options as + | { sizes?: Array>; colors?: Array> } + | null; + if (!primaryOptions && secondaries.length === 0) return null; + const merged: Record = { ...(primaryOptions ?? {}) }; + for (const listKey of ['sizes', 'colors'] as const) { + const base = Array.isArray(primaryOptions?.[listKey]) + ? [...(primaryOptions![listKey] as Array>)] + : 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>; colors?: Array> } + | 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( selectedGroups: PublicTagFilterDto[], ): Promise {