| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- import 'dotenv/config';
- import { loadConfig } from '../src/config/env.js';
- import { ParseRestClient } from '../src/db/parse-rest.client.js';
- import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
- type Row = Record<string, unknown> & { objectId: string };
- const apply = process.argv.includes('--apply');
- const overwrite = process.argv.includes('--overwrite');
- const workspaceId = process.env.SAAS_DEFAULT_WORKSPACE_ID || 'demashi';
- const config = loadConfig();
- const client = new ParseRestClient({
- serverUrl: config.parse.serverUrl,
- appId: config.parse.appId,
- masterKey: config.parse.masterKey,
- timeoutMs: config.parse.timeoutMs,
- });
- const [products, sources] = await Promise.all([
- client.findAll<Row>(VOC_PARSE_CLASSES.product, { workspaceId, role: 'own' }),
- client.findAll<Row>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, isCurrent: true }),
- ]);
- const sourceBySku = new Map<string, { source: Row; payload: Record<string, unknown>; sku: Record<string, unknown> }>();
- for (const source of sources) {
- const payload = record(source.payload);
- for (const sku of list(payload.skus).map(record)) {
- const skuId = text(sku.skuId || sku.id);
- if (skuId) sourceBySku.set(skuId, { source, payload, sku });
- }
- }
- const updates = products.flatMap((product) => {
- if (!overwrite && product.rawPayload) return [];
- const match = sourceBySku.get(text(product.productId));
- if (!match) return [];
- const { source, payload, sku } = match;
- const imageUrls = list(payload.images).map(record).map((image) => text(image.url)).filter(Boolean);
- const attributes = list(sku.attributes).map(record);
- const saleAttrs = list(sku.saleAttrs).map(record);
- const dimensions = record(payload.dimensions);
- const marketing = record(payload.marketing);
- const sellingPoints = list(marketing.sellingPoints).map(text).filter(Boolean);
- const model = attributeValue(attributes, ['型号', 'model']) || text(product.model);
- const specification = saleAttrs.map((item) => text(item.value || item.attrValues || item.attrValueAlias)).filter(Boolean).join(' / ')
- || model;
- const rawPayload = {
- itemId: text(product.productId),
- skuId: text(product.productId),
- productId: text(product.productId),
- jdProductId: text(payload.productId || source.productId),
- itemName: text(sku.name || product.title || payload.title),
- brandName: text(payload.brand || product.brand),
- model,
- imageurl: imageUrls[0] || '',
- mainImages: imageUrls,
- specName: specification,
- color: attributeValue(attributes, ['颜色', 'color']),
- weight: text(dimensions.weight || dimensions.weightKg),
- length: text(dimensions.length),
- width: text(dimensions.width),
- height: text(dimensions.height),
- shopId: text(payload.shopId),
- skuStatus: text(sku.status || payload.itemStatus),
- sellPoint: sellingPoints.join(';'),
- price: number(sku.price),
- stock: number(sku.stock),
- sourceModifiedAt: text(payload.sourceModifiedAt),
- collectedAt: text(payload.syncedAt || source.updatedAt),
- source: 'jd-sp-api',
- };
- return [{
- objectId: product.objectId,
- body: {
- source: 'jd-sp-api',
- brand: text(payload.brand || product.brand),
- title: text(sku.name || product.title || payload.title),
- model,
- rawPayload,
- },
- }];
- });
- console.log(JSON.stringify({
- mode: apply ? 'apply' : 'dry-run',
- workspaceId,
- ownProducts: products.length,
- listingSources: sources.length,
- indexedSkus: sourceBySku.size,
- matchedUpdates: updates.length,
- unmatched: products.filter((product) => !sourceBySku.has(text(product.productId))).length,
- skippedExisting: products.filter((product) => Boolean(product.rawPayload) && sourceBySku.has(text(product.productId))).length,
- }, null, 2));
- if (apply) {
- for (let offset = 0; offset < updates.length; offset += 50) {
- const batch = updates.slice(offset, offset + 50);
- await writeFmodeBatch(batch.map((item) => ({
- method: 'PUT',
- path: `/classes/${VOC_PARSE_CLASSES.product}/${item.objectId}`,
- body: item.body,
- })));
- console.log(`[voc-product-backfill] ${Math.min(offset + batch.length, updates.length)}/${updates.length}`);
- }
- }
- async function writeFmodeBatch(requests: Array<{ method: 'PUT'; path: string; body: unknown }>): Promise<void> {
- // The managed Fmode mount exposes REST under /backend/{app}/data, while its
- // batch router expects nested paths relative to /data.
- const results = await client.request<Array<{ error?: { code?: number; error?: string } }>>('/batch', {
- method: 'POST',
- body: { requests: requests.map((request) => ({ ...request, path: `/data${request.path}` })) },
- });
- const failure = results.find((result) => result.error)?.error;
- if (failure) throw new Error(`voc_product_batch_${failure.code ?? 'unknown'}:${failure.error ?? 'failed'}`);
- }
- function record(value: unknown): Record<string, unknown> {
- return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
- }
- function list(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
- function text(value: unknown): string { return value === null || value === undefined ? '' : String(value).trim(); }
- function number(value: unknown): number | null { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }
- function attributeValue(attributes: Record<string, unknown>[], names: string[]): string {
- const expected = names.map((name) => name.toLowerCase());
- for (const attribute of attributes) {
- const name = text(attribute.name || attribute.attrName).toLowerCase();
- if (!expected.some((item) => name.includes(item))) continue;
- const values = list(attribute.values).map((value) => typeof value === 'object' ? text(record(value).attrValue || record(value).value) : text(value)).filter(Boolean);
- if (values.length) return values.join(' / ');
- }
- return '';
- }
|