feat(api): add mini program catalog endpoints and product details
This commit is contained in:
@@ -33,4 +33,23 @@ describe('SdsClientService', () => {
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProductDetail', () => {
|
||||
it('requests /products/{goodId} and validates the returned id', async () => {
|
||||
http.get.mockReturnValue(of({ data: { id: 168746, sku: 'OZ10827003' } }));
|
||||
|
||||
const result = await service.fetchProductDetail('168746');
|
||||
|
||||
expect(result.sku).toBe('OZ10827003');
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'https://mapi.sdspod.com/products/168746',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a mismatched product response', async () => {
|
||||
http.get.mockReturnValue(of({ data: { id: 1 } }));
|
||||
await expect(service.fetchProductDetail('168746')).rejects.toThrow(/id mismatch/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,86 @@ export interface SdsProductsPage {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductVariant extends Record<string, unknown> {
|
||||
id?: number | string;
|
||||
sku?: string;
|
||||
size?: string;
|
||||
sizeId?: number | string;
|
||||
sizeDto?: { id?: number | string; sizeName?: string };
|
||||
colorId?: number | string;
|
||||
color_name?: string;
|
||||
color?: {
|
||||
colorId?: number | string;
|
||||
color?: string;
|
||||
color_name?: string;
|
||||
chineseName?: string;
|
||||
};
|
||||
currentPrice?: number | string;
|
||||
originalPrice?: number | string;
|
||||
unit_price?: number | string;
|
||||
min_price?: number | string;
|
||||
weight?: number | string;
|
||||
box_length?: number | string;
|
||||
box_width?: number | string;
|
||||
box_height?: number | string;
|
||||
status?: number | string;
|
||||
delFlag?: number | string;
|
||||
size_sort?: number | string;
|
||||
attribute_sort?: string;
|
||||
psd_img_url?: string;
|
||||
img_url?: string;
|
||||
blankDesignUrl?: string;
|
||||
designPrototype?: {
|
||||
detailImgUrls?: Array<{ imageUrl?: string }>;
|
||||
prototypeResultGroups?: Array<{ resultImage?: string }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SdsProductDetail extends Record<string, unknown> {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
sku?: string;
|
||||
english_name?: string;
|
||||
blankDesignUrl?: string;
|
||||
detailsPageVideoUrl?: string;
|
||||
productionCycle?: number | string;
|
||||
minWeight?: number | string;
|
||||
min_price?: number | string;
|
||||
updateTime?: number | string;
|
||||
psd_img_url?: string;
|
||||
img_url?: string;
|
||||
texture?: { name?: string };
|
||||
product_details?: {
|
||||
reminder?: string;
|
||||
production_process?: string;
|
||||
material_description?: string;
|
||||
product_performance?: string;
|
||||
applicable_scenarios?: string;
|
||||
washing_instructions?: string;
|
||||
special_description?: string;
|
||||
design_explanation?: string;
|
||||
design_area?: string;
|
||||
picture_request?: string;
|
||||
product_size?: string;
|
||||
packaging_specification?: string;
|
||||
};
|
||||
subproducts?: {
|
||||
attributers?: Array<{
|
||||
size?: string;
|
||||
sizeId?: number | string;
|
||||
colors?: Array<{
|
||||
colorId?: number | string;
|
||||
color?: string;
|
||||
color_name?: string;
|
||||
chineseName?: string;
|
||||
colorSort?: number | string;
|
||||
}>;
|
||||
}>;
|
||||
items?: SdsProductVariant[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||
* `SyncService` consumes.
|
||||
@@ -137,4 +217,17 @@ export class SdsClientService {
|
||||
}
|
||||
return data as SdsProductsPage;
|
||||
}
|
||||
|
||||
async fetchProductDetail(goodId: string | number): Promise<SdsProductDetail> {
|
||||
const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`;
|
||||
const data = await this.request<unknown>('get', url);
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error(`SDS product detail returned ${typeof data}, expected object`);
|
||||
}
|
||||
const detail = data as SdsProductDetail;
|
||||
if (String(detail.id) !== String(goodId)) {
|
||||
throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
normalizeProductDetail,
|
||||
parsePackageSpecs,
|
||||
parseSizeChart,
|
||||
} from './sds-product-detail.mapper';
|
||||
|
||||
const sizeTable = JSON.stringify([
|
||||
['尺码', '衣长(cm/in)', '胸围(cm/in)', '肩宽(cm/in)', '袖长(cm/in)'].map((content) => ({ content, remark: '' })),
|
||||
['S', '71', '92', '43', '22'].map((content) => ({ content, remark: '' })),
|
||||
['M', '74', '102', '45', '22'].map((content) => ({ content, remark: '' })),
|
||||
['L', '76', '112', '48', '23'].map((content) => ({ content, remark: '' })),
|
||||
['XL', '79', '122', '51', '23'].map((content) => ({ content, remark: '' })),
|
||||
['2XL', '82', '132', '53', '25'].map((content) => ({ content, remark: '' })),
|
||||
['3XL', '84', '142', '56', '25'].map((content) => ({ content, remark: '' })),
|
||||
]);
|
||||
|
||||
const packageTable = JSON.stringify([
|
||||
['尺码', '包装尺寸(cm)', '包装尺寸(in)', '包装体积(cm³)', '包装体积(in³)', '含包装重量(g)', '含包装重量(lb)'].map((content) => ({ content })),
|
||||
['S', '36.0*26.0*1.0\t', '14.17*10.24*0.39\t', '936.00', '57.12', '208.00', '0.46'].map((content) => ({ content })),
|
||||
['M', '36.0*26.0*1.0', '14.17*10.24*0.39', '936.00', '57.12', '218.00', '0.48'].map((content) => ({ content })),
|
||||
]);
|
||||
|
||||
describe('SDS product detail mapper', () => {
|
||||
it('parses the product_detail.txt size table into structured rows', () => {
|
||||
const chart = parseSizeChart(sizeTable) as any;
|
||||
expect(chart.columns.map((column: any) => column.key)).toEqual([
|
||||
'bodyLength',
|
||||
'chest',
|
||||
'shoulder',
|
||||
'sleeveLength',
|
||||
]);
|
||||
expect(chart.rows).toHaveLength(6);
|
||||
expect(chart.rows[0].measurements[0]).toEqual({ key: 'bodyLength', cm: '71', in: '27.95' });
|
||||
});
|
||||
|
||||
it('parses packaging dimensions and weights', () => {
|
||||
const specs = parsePackageSpecs(packageTable) as any;
|
||||
expect(specs.rows).toHaveLength(2);
|
||||
expect(specs.rows[0].dimensionsCm).toEqual({ length: '36.0', width: '26.0', height: '1.0' });
|
||||
expect(specs.rows[0].grossWeightG).toBe('208.00');
|
||||
});
|
||||
|
||||
it('normalizes detail text, options and variants', () => {
|
||||
const normalized = normalizeProductDetail({
|
||||
id: 168746,
|
||||
sku: 'OZ10827003',
|
||||
english_name: 't-shirt',
|
||||
productionCycle: 24,
|
||||
minWeight: 250,
|
||||
product_details: {
|
||||
production_process: '白墨烫画',
|
||||
material_description: '100%纯棉',
|
||||
product_size: sizeTable,
|
||||
packaging_specification: packageTable,
|
||||
},
|
||||
subproducts: {
|
||||
attributers: [{
|
||||
size: 'S',
|
||||
sizeId: 1922304,
|
||||
colors: [{ colorId: 1139383, color: '#000300', color_name: 'black', colorSort: 1 }],
|
||||
}],
|
||||
items: [{
|
||||
id: 168747,
|
||||
sku: 'OZ10827003001',
|
||||
size: 'S',
|
||||
sizeId: 1922304,
|
||||
colorId: 1139383,
|
||||
color: { colorId: 1139383, color: '#000300', color_name: 'black' },
|
||||
currentPrice: 38,
|
||||
originalPrice: 38,
|
||||
weight: 250,
|
||||
box_length: 30,
|
||||
box_width: 20,
|
||||
box_height: 5,
|
||||
status: 1,
|
||||
delFlag: '0',
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.productCode).toBe('OZ10827003');
|
||||
expect(normalized.productionProcess).toBe('白墨烫画');
|
||||
expect(normalized.variants[0].sku).toBe('OZ10827003001');
|
||||
expect(normalized.variants[0].price?.toString()).toBe('38');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ describe('SyncService', () => {
|
||||
const sdsMock: Partial<SdsClientService> = {
|
||||
fetchCategoryTree: jest.fn(),
|
||||
fetchProductsPage: jest.fn(),
|
||||
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
@@ -29,6 +30,9 @@ describe('SyncService', () => {
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SyncService);
|
||||
jest
|
||||
.spyOn(service, 'syncConfiguredProductDetails')
|
||||
.mockResolvedValue({ synced: 0, failed: 0 });
|
||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
|
||||
import {
|
||||
SdsClientService,
|
||||
SdsCategoryTreeNode,
|
||||
SdsProduct,
|
||||
SdsProductDetail,
|
||||
} from './sds-client.service';
|
||||
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
||||
|
||||
export interface CategorySyncResult {
|
||||
inserted: number;
|
||||
@@ -17,6 +23,8 @@ export interface ProductSyncResult {
|
||||
total: number;
|
||||
leafCategories: number;
|
||||
delisted: number;
|
||||
detailsSynced: number;
|
||||
detailFailures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,15 +347,28 @@ export class SyncService {
|
||||
);
|
||||
}
|
||||
|
||||
// Only hydrate full details for products selected in the website catalog.
|
||||
// This keeps the hourly sync bounded and preserves the existing Good/tag
|
||||
// merchandising model. A failed detail request never erases cached data.
|
||||
const detailResult = await this.syncConfiguredProductDetails();
|
||||
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
|
||||
},
|
||||
});
|
||||
return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount };
|
||||
return {
|
||||
inserted,
|
||||
updated,
|
||||
total,
|
||||
leafCategories: leafRows.length,
|
||||
delisted: delistedCount,
|
||||
detailsSynced: detailResult.synced,
|
||||
detailFailures: detailResult.failed,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.prisma.syncLog.update({
|
||||
@@ -371,6 +392,120 @@ export class SyncService {
|
||||
});
|
||||
}
|
||||
|
||||
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||
const configured = await this.prisma.originGood.findMany({
|
||||
where: { delisted: false, goods: { some: {} } },
|
||||
select: { id: true, sdsGoodId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
for (const originGood of configured) {
|
||||
try {
|
||||
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
synced++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
||||
}
|
||||
}
|
||||
return { synced, failed };
|
||||
}
|
||||
|
||||
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
||||
goodId: string;
|
||||
variants: number;
|
||||
sizeRows: number;
|
||||
packageRows: number;
|
||||
configuredGoods: number;
|
||||
}> {
|
||||
const goodId = String(upstream.id);
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
const originGood = await this.prisma.originGood.upsert({
|
||||
where: { sdsGoodId: goodId },
|
||||
create: {
|
||||
sdsGoodId: goodId,
|
||||
goodName: String(upstream.name ?? goodId),
|
||||
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null,
|
||||
goodPrice:
|
||||
upstream.min_price === undefined || upstream.min_price === null
|
||||
? null
|
||||
: new Prisma.Decimal(Number(upstream.min_price)),
|
||||
},
|
||||
update: {
|
||||
goodName: upstream.name ? String(upstream.name) : undefined,
|
||||
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined,
|
||||
goodPrice:
|
||||
upstream.min_price === undefined || upstream.min_price === null
|
||||
? undefined
|
||||
: new Prisma.Decimal(Number(upstream.min_price)),
|
||||
},
|
||||
});
|
||||
await this.persistProductDetail(originGood.id, upstream);
|
||||
const configuredGoods = await this.prisma.good.count({
|
||||
where: { originGoodId: originGood.id },
|
||||
});
|
||||
const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null;
|
||||
const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null;
|
||||
return {
|
||||
goodId,
|
||||
variants: normalized.variants.length,
|
||||
sizeRows: sizeChart?.rows?.length ?? 0,
|
||||
packageRows: packageSpecs?.rows?.length ?? 0,
|
||||
configuredGoods,
|
||||
};
|
||||
}
|
||||
|
||||
private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise<void> {
|
||||
const normalized = normalizeProductDetail(upstream);
|
||||
const { variants, ...detail } = normalized;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
|
||||
await tx.originGoodDetail.upsert({
|
||||
where: { originGoodId },
|
||||
create: {
|
||||
originGoodId,
|
||||
...detail,
|
||||
sizeChart: json(detail.sizeChart),
|
||||
packageSpecs: json(detail.packageSpecs),
|
||||
options: json(detail.options),
|
||||
media: json(detail.media),
|
||||
},
|
||||
update: {
|
||||
...detail,
|
||||
sizeChart: json(detail.sizeChart),
|
||||
packageSpecs: json(detail.packageSpecs),
|
||||
options: json(detail.options),
|
||||
media: json(detail.media),
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const seenVariantIds: string[] = [];
|
||||
for (const variant of variants) {
|
||||
seenVariantIds.push(variant.sdsVariantId);
|
||||
const { designData, ...data } = variant;
|
||||
await tx.originGoodVariant.upsert({
|
||||
where: {
|
||||
originGoodId_sdsVariantId: {
|
||||
originGoodId,
|
||||
sdsVariantId: variant.sdsVariantId,
|
||||
},
|
||||
},
|
||||
create: { originGoodId, ...data, designData: json(designData) },
|
||||
update: { ...data, designData: json(designData) },
|
||||
});
|
||||
}
|
||||
await tx.originGoodVariant.deleteMany({
|
||||
where: {
|
||||
originGoodId,
|
||||
...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user