From 457792b7292005ad31a790ad9cefdcf0ff35db3e Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 11:05:34 +0800 Subject: [PATCH 1/3] feat(public): dedupe merged variants and merge specs/options from secondary origins --- apps/api/src/public/public.service.spec.ts | 79 ++++++++++++++++ apps/api/src/public/public.service.ts | 105 +++++++++++++++++++-- 2 files changed, 177 insertions(+), 7 deletions(-) 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 { From d826c5bdd4c45fe4731b3531a74b73d5e9643a17 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 11:19:27 +0800 Subject: [PATCH 2/3] feat(public): merge secondary media gallery images with URL dedup --- apps/api/src/public/public.service.spec.ts | 7 +++++ apps/api/src/public/public.service.ts | 31 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 1272ff6..9a68d22 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -435,6 +435,7 @@ describe('PublicService', () => { 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' }] }, + media: { images: ['http://img/pri-a', 'http://img/pri-b'], primaryImageUrl: 'http://img/pri-a' }, }, }); // Secondary: duplicate Black|S with a DIFFERENT price (must be dropped, @@ -451,6 +452,7 @@ describe('PublicService', () => { sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] }, packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] }, options: { sizes: [{ name: 'XXXL' }] }, + media: { images: ['http://img/pri-a', 'http://img/sec-x'], primaryImageUrl: 'http://img/pri-a' }, }, }); const mergedGood = await prisma.good.create({ @@ -483,6 +485,11 @@ describe('PublicService', () => { // 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']); + // Media gallery: primary images first, secondary-only URL appended, + // duplicate URL (pri-a) kept once. + const media = detail.media as any; + expect(media.images).toEqual(['http://img/pri-a', 'http://img/pri-b', 'http://img/sec-x']); + 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 } }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 68599d9..24d226a 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -385,7 +385,7 @@ export class PublicService { designArea: detail?.designArea ?? null, pictureRequest: detail?.pictureRequest ?? null, }, - media: (detail?.media as Record | null) ?? null, + media: this.mergeMedia(detail, secondaryDetails), mediaByColor: this.groupImagesByColor(allVariants), options: this.mergeOptions(detail, secondaryDetails), sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'), @@ -496,6 +496,35 @@ export class PublicService { return merged; } + /** + * Merge the gallery `media` across details: primary images first, + * secondary-only image URLs appended (URL-deduped). `primaryImageUrl` + * stays the primary's. + */ + private mergeMedia( + primary: PublicGoodRow['originGood']['detail'], + secondaries: NonNullable[], + ): Record | null { + const toImages = (media: unknown): string[] => { + const images = (media as { images?: unknown } | null)?.images; + return Array.isArray(images) ? images.filter((u): u is string => typeof u === 'string') : []; + }; + const primaryMedia = (primary?.media as Record | null) ?? null; + if (!primaryMedia && secondaries.length === 0) return null; + const images = toImages(primaryMedia); + const seen = new Set(images); + for (const sec of secondaries) { + for (const url of toImages(sec.media)) { + if (!seen.has(url)) { + seen.add(url); + images.push(url); + } + } + } + if (images.length === 0) return null; + return { ...(primaryMedia ?? {}), images }; + } + private async buildTagGroupFilters( selectedGroups: PublicTagFilterDto[], ): Promise { From 270e516f9425918a78b93fb9249680aa2db4753d Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 11:22:53 +0800 Subject: [PATCH 3/3] fix(public): media images are objects ({id,url,sortOrder}) - handle both shapes in mergeMedia --- apps/api/src/public/public.service.spec.ts | 12 +++++--- apps/api/src/public/public.service.ts | 33 ++++++++++++++-------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 9a68d22..e755447 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -435,7 +435,7 @@ describe('PublicService', () => { 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' }] }, - media: { images: ['http://img/pri-a', 'http://img/pri-b'], primaryImageUrl: 'http://img/pri-a' }, + media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i2', url: 'http://img/pri-b', sortOrder: 1 }], primaryImageUrl: 'http://img/pri-a' }, }, }); // Secondary: duplicate Black|S with a DIFFERENT price (must be dropped, @@ -452,7 +452,7 @@ describe('PublicService', () => { sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] }, packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] }, options: { sizes: [{ name: 'XXXL' }] }, - media: { images: ['http://img/pri-a', 'http://img/sec-x'], primaryImageUrl: 'http://img/pri-a' }, + 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' }, }, }); const mergedGood = await prisma.good.create({ @@ -486,9 +486,13 @@ describe('PublicService', () => { const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort(); expect(optSizes).toEqual(['M', 'S', 'XXXL']); // Media gallery: primary images first, secondary-only URL appended, - // duplicate URL (pri-a) kept once. + // duplicate URL (pri-a) kept once. Entries keep their object shape. const media = detail.media as any; - expect(media.images).toEqual(['http://img/pri-a', 'http://img/pri-b', 'http://img/sec-x']); + expect(media.images.map((i: any) => i.url)).toEqual([ + 'http://img/pri-a', + 'http://img/pri-b', + 'http://img/sec-x', + ]); expect(media.primaryImageUrl).toBe('http://img/pri-a'); } finally { await prisma.goodOriginGood.deleteMany({ where: { goodId: mergedGood.id } }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 24d226a..d525bc1 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -499,26 +499,35 @@ export class PublicService { /** * Merge the gallery `media` across details: primary images first, * secondary-only image URLs appended (URL-deduped). `primaryImageUrl` - * stays the primary's. + * stays the primary's. Image entries keep their original shape + * (`{id,url,sortOrder}` objects or plain strings). */ private mergeMedia( primary: PublicGoodRow['originGood']['detail'], secondaries: NonNullable[], ): Record | null { - const toImages = (media: unknown): string[] => { - const images = (media as { images?: unknown } | null)?.images; - return Array.isArray(images) ? images.filter((u): u is string => typeof u === 'string') : []; + const imageUrl = (img: unknown): string | null => { + if (typeof img === 'string') return img; + if (img && typeof img === 'object' && typeof (img as { url?: unknown }).url === 'string') { + return (img as { url: string }).url; + } + return null; }; const primaryMedia = (primary?.media as Record | null) ?? null; - if (!primaryMedia && secondaries.length === 0) return null; - const images = toImages(primaryMedia); - const seen = new Set(images); + const primaryImages = Array.isArray(primaryMedia?.images) + ? (primaryMedia!.images as unknown[]) + : null; + if (!primaryImages && secondaries.length === 0) return null; + const images: unknown[] = primaryImages ? [...primaryImages] : []; + const seen = new Set(images.map(imageUrl).filter((u): u is string => Boolean(u))); for (const sec of secondaries) { - for (const url of toImages(sec.media)) { - if (!seen.has(url)) { - seen.add(url); - images.push(url); - } + const secMedia = sec.media as { images?: unknown } | null; + if (!Array.isArray(secMedia?.images)) continue; + for (const img of secMedia!.images as unknown[]) { + const url = imageUrl(img); + if (!url || seen.has(url)) continue; + seen.add(url); + images.push(img); } } if (images.length === 0) return null;