diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index f9b0caf..e755447 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -413,5 +413,95 @@ 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' }] }, + 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, + // 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' }] }, + 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({ + 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']); + // Media gallery: primary images first, secondary-only URL appended, + // duplicate URL (pri-a) kept once. Entries keep their object shape. + const media = detail.media as any; + 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 } }); + 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 0e3898b..2aac1de 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -49,6 +49,7 @@ const PUBLIC_GOOD_INCLUDE = { include: { originGood: { include: { + detail: true, variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, }, }, @@ -391,8 +392,14 @@ export class PublicService { ): PublicGoodDetailDto { const base = this.toPublicGood(good); const detail = good.originGood.detail; + // 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)); // 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期); - // 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重。 + // 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重, + // 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。 const seen = new Set(['']); const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => { const key = `${originGoodId}:${variant.sdsVariantId}`; @@ -401,7 +408,7 @@ export class PublicService { return variant; }; good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v)); - const allVariants = [ + const unionVariants = [ ...good.originGood.variants, ...familyVariants .map(({ originGoodId, variant }) => dedupe(originGoodId, variant)) @@ -412,6 +419,7 @@ export class PublicService { .filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null), ), ]; + const allVariants = this.dedupeVariants(unionVariants); return { ...base, productCode: detail?.productCode ?? null, @@ -430,11 +438,11 @@ 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: (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, @@ -493,6 +501,128 @@ 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; + } + + /** + * Merge the gallery `media` across details: primary images first, + * secondary-only image URLs appended (URL-deduped). `primaryImageUrl` + * 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 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; + 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) { + 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; + return { ...(primaryMedia ?? {}), images }; + } + private async buildTagGroupFilters( selectedGroups: PublicTagFilterDto[], ): Promise {