262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
import { Prisma } from '@prisma/client';
|
||
import { SdsProductDetail, SdsProductVariant } from './sds-client.service';
|
||
|
||
type TableCell = { content?: unknown; remark?: unknown };
|
||
type Table = TableCell[][];
|
||
|
||
export interface NormalizedVariant {
|
||
sdsVariantId: string;
|
||
sku: string;
|
||
sizeId: string | null;
|
||
sizeName: string | null;
|
||
colorId: string | null;
|
||
colorName: string | null;
|
||
colorHex: string | null;
|
||
imageUrl: string | null;
|
||
price: Prisma.Decimal | null;
|
||
originalPrice: Prisma.Decimal | null;
|
||
weightG: Prisma.Decimal | null;
|
||
boxLengthCm: Prisma.Decimal | null;
|
||
boxWidthCm: Prisma.Decimal | null;
|
||
boxHeightCm: Prisma.Decimal | null;
|
||
enabled: boolean;
|
||
sortOrder: number;
|
||
designData: Prisma.InputJsonValue | null;
|
||
}
|
||
|
||
export interface NormalizedProductDetail {
|
||
productCode: string | null;
|
||
englishName: string | null;
|
||
blankDesignUrl: string | null;
|
||
detailsPageVideoUrl: string | null;
|
||
textureName: string | null;
|
||
productionCycleHours: number | null;
|
||
minWeightG: Prisma.Decimal | null;
|
||
reminder: string | null;
|
||
productionProcess: string | null;
|
||
materialDescription: string | null;
|
||
productPerformance: string | null;
|
||
applicableScenarios: string | null;
|
||
washingInstructions: string | null;
|
||
specialDescription: string | null;
|
||
designExplanation: string | null;
|
||
designArea: string | null;
|
||
pictureRequest: string | null;
|
||
sizeChart: Prisma.InputJsonValue | null;
|
||
packageSpecs: Prisma.InputJsonValue | null;
|
||
options: Prisma.InputJsonValue | null;
|
||
media: Prisma.InputJsonValue | null;
|
||
upstreamUpdatedAt: Date | null;
|
||
variants: NormalizedVariant[];
|
||
}
|
||
|
||
const text = (value: unknown): string | null => {
|
||
if (value === undefined || value === null) return null;
|
||
const normalized = String(value).trim();
|
||
return normalized.length > 0 ? normalized : null;
|
||
};
|
||
|
||
const decimal = (value: unknown): Prisma.Decimal | null => {
|
||
if (value === undefined || value === null || value === '') return null;
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? new Prisma.Decimal(n) : null;
|
||
};
|
||
|
||
const integer = (value: unknown): number | null => {
|
||
const n = Number(value);
|
||
return Number.isInteger(n) ? n : null;
|
||
};
|
||
|
||
function parseTable(raw: unknown): Table | null {
|
||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||
try {
|
||
const parsed = JSON.parse(raw) as unknown;
|
||
if (!Array.isArray(parsed) || parsed.length < 2) return null;
|
||
const rows = parsed.filter(Array.isArray) as Table;
|
||
return rows.length >= 2 ? rows : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const cell = (row: TableCell[], index: number): string =>
|
||
text(row[index]?.content)?.replace(/\t/g, '').trim() ?? '';
|
||
|
||
const measurementKey = (header: string, index: number): string => {
|
||
if (header.includes('衣长')) return 'bodyLength';
|
||
if (header.includes('胸围')) return 'chest';
|
||
if (header.includes('肩宽')) return 'shoulder';
|
||
if (header.includes('袖长')) return 'sleeveLength';
|
||
return `measurement${index}`;
|
||
};
|
||
|
||
export function parseSizeChart(raw: unknown): Prisma.InputJsonValue | null {
|
||
const table = parseTable(raw);
|
||
if (!table) return null;
|
||
const [header, ...body] = table;
|
||
const columns = header.slice(1).map((item, index) => {
|
||
const name = text(item.content)?.replace(/\s*\(cm\/in\)\s*/i, '') ?? `规格${index + 1}`;
|
||
return { key: measurementKey(name, index + 1), name };
|
||
});
|
||
const rows = body
|
||
.map((row, rowIndex) => {
|
||
const sizeName = cell(row, 0);
|
||
if (!sizeName) return null;
|
||
return {
|
||
sizeId: `size_${rowIndex}`,
|
||
sizeName,
|
||
measurements: columns.map((column, index) => {
|
||
const cm = cell(row, index + 1);
|
||
const cmNumber = Number(cm);
|
||
return {
|
||
key: column.key,
|
||
cm: cm || null,
|
||
in: Number.isFinite(cmNumber) ? (cmNumber / 2.54).toFixed(2) : null,
|
||
};
|
||
}),
|
||
};
|
||
})
|
||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||
return { columns, rows } as Prisma.InputJsonValue;
|
||
}
|
||
|
||
function dimensions(value: string): { length: string; width: string; height: string } | null {
|
||
const parts = value
|
||
.replace(/[×x]/gi, '*')
|
||
.split('*')
|
||
.map((part) => part.trim());
|
||
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(Number(part)))) return null;
|
||
return { length: parts[0], width: parts[1], height: parts[2] };
|
||
}
|
||
|
||
export function parsePackageSpecs(raw: unknown): Prisma.InputJsonValue | null {
|
||
const table = parseTable(raw);
|
||
if (!table) return null;
|
||
const rows = table.slice(1)
|
||
.map((row, rowIndex) => {
|
||
const sizeName = cell(row, 0);
|
||
if (!sizeName) return null;
|
||
return {
|
||
sizeId: `size_${rowIndex}`,
|
||
sizeName,
|
||
dimensionsCm: dimensions(cell(row, 1)),
|
||
dimensionsIn: dimensions(cell(row, 2)),
|
||
volumeCm3: cell(row, 3) || null,
|
||
volumeIn3: cell(row, 4) || null,
|
||
grossWeightG: cell(row, 5) || null,
|
||
grossWeightLb: cell(row, 6) || null,
|
||
};
|
||
})
|
||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||
return { rows } as Prisma.InputJsonValue;
|
||
}
|
||
|
||
function normalizeOptions(detail: SdsProductDetail): Prisma.InputJsonValue | null {
|
||
const attributers = detail.subproducts?.attributers;
|
||
if (!Array.isArray(attributers)) return null;
|
||
const sizeMap = new Map<string, { id: string; name: string; sortOrder: number; enabled: boolean }>();
|
||
const colorMap = new Map<string, { id: string; name: string; hex: string | null; sortOrder: number; enabled: boolean }>();
|
||
attributers.forEach((attribute, sizeIndex) => {
|
||
const sizeName = text(attribute.size);
|
||
const sizeId = text(attribute.sizeId) ?? `size_${sizeIndex}`;
|
||
if (sizeName) sizeMap.set(sizeId, { id: sizeId, name: sizeName, sortOrder: sizeIndex, enabled: true });
|
||
if (Array.isArray(attribute.colors)) {
|
||
attribute.colors.forEach((color, colorIndex) => {
|
||
const colorId = text(color.colorId) ?? `color_${colorIndex}`;
|
||
if (!colorMap.has(colorId)) {
|
||
colorMap.set(colorId, {
|
||
id: colorId,
|
||
name: text(color.chineseName) ?? text(color.color_name) ?? colorId,
|
||
hex: text(color.color),
|
||
sortOrder: integer(color.colorSort) ?? colorIndex,
|
||
enabled: true,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
});
|
||
return { sizes: [...sizeMap.values()], colors: [...colorMap.values()] } as Prisma.InputJsonValue;
|
||
}
|
||
|
||
function normalizeMedia(detail: SdsProductDetail, variants: SdsProductVariant[]): Prisma.InputJsonValue | null {
|
||
const urls: string[] = [];
|
||
const add = (value: unknown) => {
|
||
const url = text(value);
|
||
if (url && !urls.includes(url)) urls.push(url);
|
||
};
|
||
add(detail.blankDesignUrl);
|
||
add(detail.psd_img_url);
|
||
add(detail.img_url);
|
||
for (const variant of variants) {
|
||
add(variant.psd_img_url);
|
||
add(variant.img_url);
|
||
add(variant.blankDesignUrl);
|
||
for (const image of variant.designPrototype?.detailImgUrls ?? []) add(image.imageUrl);
|
||
for (const image of variant.designPrototype?.prototypeResultGroups ?? []) add(image.resultImage);
|
||
}
|
||
if (urls.length === 0) return null;
|
||
return {
|
||
primaryImageUrl: urls[0],
|
||
images: urls.map((url, index) => ({ id: `image_${index}`, url, sortOrder: index })),
|
||
} as Prisma.InputJsonValue;
|
||
}
|
||
|
||
function normalizeVariant(variant: SdsProductVariant, index: number): NormalizedVariant | null {
|
||
const sdsVariantId = text(variant.id);
|
||
const sku = text(variant.sku);
|
||
if (!sdsVariantId || !sku) return null;
|
||
return {
|
||
sdsVariantId,
|
||
sku,
|
||
sizeId: text(variant.sizeId) ?? text(variant.sizeDto?.id),
|
||
sizeName: text(variant.size) ?? text(variant.sizeDto?.sizeName),
|
||
colorId: text(variant.colorId) ?? text(variant.color?.colorId),
|
||
colorName: text(variant.color?.chineseName) ?? text(variant.color_name) ?? text(variant.color?.color_name),
|
||
colorHex: text(variant.color?.color),
|
||
imageUrl: text(variant.psd_img_url) ?? text(variant.img_url) ?? text(variant.blankDesignUrl),
|
||
price: decimal(variant.currentPrice ?? variant.unit_price ?? variant.min_price),
|
||
originalPrice: decimal(variant.originalPrice),
|
||
weightG: decimal(variant.weight),
|
||
boxLengthCm: decimal(variant.box_length),
|
||
boxWidthCm: decimal(variant.box_width),
|
||
boxHeightCm: decimal(variant.box_height),
|
||
enabled: Number(variant.status ?? 1) === 1 && String(variant.delFlag ?? '0') === '0',
|
||
sortOrder: integer(variant.attribute_sort?.split('-')[0]) ?? integer(variant.size_sort) ?? index,
|
||
designData: variant.designPrototype ? (variant.designPrototype as Prisma.InputJsonValue) : null,
|
||
};
|
||
}
|
||
|
||
export function normalizeProductDetail(detail: SdsProductDetail): NormalizedProductDetail {
|
||
const productDetails = detail.product_details ?? {};
|
||
const sourceVariants = Array.isArray(detail.subproducts?.items) ? detail.subproducts!.items! : [];
|
||
const variants = sourceVariants
|
||
.map(normalizeVariant)
|
||
.filter((variant): variant is NormalizedVariant => variant !== null);
|
||
const updatedAt = Number(detail.updateTime);
|
||
return {
|
||
productCode: text(detail.sku),
|
||
englishName: text(detail.english_name),
|
||
blankDesignUrl: text(detail.blankDesignUrl),
|
||
detailsPageVideoUrl: text(detail.detailsPageVideoUrl),
|
||
textureName: text(detail.texture?.name),
|
||
productionCycleHours: integer(detail.productionCycle),
|
||
minWeightG: decimal(detail.minWeight),
|
||
reminder: text(productDetails.reminder),
|
||
productionProcess: text(productDetails.production_process),
|
||
materialDescription: text(productDetails.material_description),
|
||
productPerformance: text(productDetails.product_performance),
|
||
applicableScenarios: text(productDetails.applicable_scenarios),
|
||
washingInstructions: text(productDetails.washing_instructions),
|
||
specialDescription: text(productDetails.special_description),
|
||
designExplanation: text(productDetails.design_explanation),
|
||
designArea: text(productDetails.design_area),
|
||
pictureRequest: text(productDetails.picture_request),
|
||
sizeChart: parseSizeChart(productDetails.product_size),
|
||
packageSpecs: parsePackageSpecs(productDetails.packaging_specification),
|
||
options: normalizeOptions(detail),
|
||
media: normalizeMedia(detail, sourceVariants),
|
||
upstreamUpdatedAt: Number.isFinite(updatedAt) ? new Date(updatedAt) : null,
|
||
variants,
|
||
};
|
||
}
|