merge: bring in public media/variant dedup from origin/develop (local only)

This commit is contained in:
yeuimu
2026-08-28 15:15:17 +08:00
2 changed files with 226 additions and 6 deletions
+136 -6
View File
@@ -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<typeof d> => Boolean(d));
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
// 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重
// 同一链接可能既是族成员又挂旧副源,按 `${originGoodId}:${sdsVariantId}` 去重
// 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。
const seen = new Set<string>(['']);
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<string, unknown> | null) ?? null,
media: this.mergeMedia(detail, secondaryDetails),
mediaByColor: this.groupImagesByColor(allVariants),
options: (detail?.options as Record<string, unknown> | null) ?? null,
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | 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<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(
selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> {