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 & { 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(VOC_PARSE_CLASSES.product, { workspaceId, role: 'own' }), client.findAll(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, isCurrent: true }), ]); const sourceBySku = new Map; sku: Record }>(); 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 { // 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>('/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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } 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[], 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 ''; }