merge: bring in public media/variant dedup from origin/develop (local only)
This commit is contained in:
@@ -413,5 +413,95 @@ 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' }] },
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,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 }] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -391,8 +392,14 @@ export class PublicService {
|
|||||||
): PublicGoodDetailDto {
|
): PublicGoodDetailDto {
|
||||||
const base = this.toPublicGood(good);
|
const base = this.toPublicGood(good);
|
||||||
const detail = good.originGood.detail;
|
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<typeof d> => Boolean(d));
|
||||||
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
|
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
|
||||||
// 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重。
|
// 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重,
|
||||||
|
// 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。
|
||||||
const seen = new Set<string>(['']);
|
const seen = new Set<string>(['']);
|
||||||
const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => {
|
const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => {
|
||||||
const key = `${originGoodId}:${variant.sdsVariantId}`;
|
const key = `${originGoodId}:${variant.sdsVariantId}`;
|
||||||
@@ -401,7 +408,7 @@ export class PublicService {
|
|||||||
return variant;
|
return variant;
|
||||||
};
|
};
|
||||||
good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v));
|
good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v));
|
||||||
const allVariants = [
|
const unionVariants = [
|
||||||
...good.originGood.variants,
|
...good.originGood.variants,
|
||||||
...familyVariants
|
...familyVariants
|
||||||
.map(({ originGoodId, variant }) => dedupe(originGoodId, variant))
|
.map(({ originGoodId, variant }) => dedupe(originGoodId, variant))
|
||||||
@@ -412,6 +419,7 @@ export class PublicService {
|
|||||||
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
|
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
const allVariants = this.dedupeVariants(unionVariants);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
productCode: detail?.productCode ?? null,
|
productCode: detail?.productCode ?? null,
|
||||||
@@ -430,11 +438,11 @@ export class PublicService {
|
|||||||
designArea: detail?.designArea ?? null,
|
designArea: detail?.designArea ?? null,
|
||||||
pictureRequest: detail?.pictureRequest ?? null,
|
pictureRequest: detail?.pictureRequest ?? null,
|
||||||
},
|
},
|
||||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
media: this.mergeMedia(detail, secondaryDetails),
|
||||||
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,
|
||||||
@@ -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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<PublicGoodRow['originGood']['detail']>[],
|
||||||
|
): Record<string, unknown> | 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<string, unknown> | 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(
|
private async buildTagGroupFilters(
|
||||||
selectedGroups: PublicTagFilterDto[],
|
selectedGroups: PublicTagFilterDto[],
|
||||||
): Promise<Prisma.GoodWhereInput[]> {
|
): Promise<Prisma.GoodWhereInput[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user