backfill-voc-products-from-listing-sources.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import 'dotenv/config';
  2. import { loadConfig } from '../src/config/env.js';
  3. import { ParseRestClient } from '../src/db/parse-rest.client.js';
  4. import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
  5. type Row = Record<string, unknown> & { objectId: string };
  6. const apply = process.argv.includes('--apply');
  7. const overwrite = process.argv.includes('--overwrite');
  8. const workspaceId = process.env.SAAS_DEFAULT_WORKSPACE_ID || 'demashi';
  9. const config = loadConfig();
  10. const client = new ParseRestClient({
  11. serverUrl: config.parse.serverUrl,
  12. appId: config.parse.appId,
  13. masterKey: config.parse.masterKey,
  14. timeoutMs: config.parse.timeoutMs,
  15. });
  16. const [products, sources] = await Promise.all([
  17. client.findAll<Row>(VOC_PARSE_CLASSES.product, { workspaceId, role: 'own' }),
  18. client.findAll<Row>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, isCurrent: true }),
  19. ]);
  20. const sourceBySku = new Map<string, { source: Row; payload: Record<string, unknown>; sku: Record<string, unknown> }>();
  21. for (const source of sources) {
  22. const payload = record(source.payload);
  23. for (const sku of list(payload.skus).map(record)) {
  24. const skuId = text(sku.skuId || sku.id);
  25. if (skuId) sourceBySku.set(skuId, { source, payload, sku });
  26. }
  27. }
  28. const updates = products.flatMap((product) => {
  29. if (!overwrite && product.rawPayload) return [];
  30. const match = sourceBySku.get(text(product.productId));
  31. if (!match) return [];
  32. const { source, payload, sku } = match;
  33. const imageUrls = list(payload.images).map(record).map((image) => text(image.url)).filter(Boolean);
  34. const attributes = list(sku.attributes).map(record);
  35. const saleAttrs = list(sku.saleAttrs).map(record);
  36. const dimensions = record(payload.dimensions);
  37. const marketing = record(payload.marketing);
  38. const sellingPoints = list(marketing.sellingPoints).map(text).filter(Boolean);
  39. const model = attributeValue(attributes, ['型号', 'model']) || text(product.model);
  40. const specification = saleAttrs.map((item) => text(item.value || item.attrValues || item.attrValueAlias)).filter(Boolean).join(' / ')
  41. || model;
  42. const rawPayload = {
  43. itemId: text(product.productId),
  44. skuId: text(product.productId),
  45. productId: text(product.productId),
  46. jdProductId: text(payload.productId || source.productId),
  47. itemName: text(sku.name || product.title || payload.title),
  48. brandName: text(payload.brand || product.brand),
  49. model,
  50. imageurl: imageUrls[0] || '',
  51. mainImages: imageUrls,
  52. specName: specification,
  53. color: attributeValue(attributes, ['颜色', 'color']),
  54. weight: text(dimensions.weight || dimensions.weightKg),
  55. length: text(dimensions.length),
  56. width: text(dimensions.width),
  57. height: text(dimensions.height),
  58. shopId: text(payload.shopId),
  59. skuStatus: text(sku.status || payload.itemStatus),
  60. sellPoint: sellingPoints.join(';'),
  61. price: number(sku.price),
  62. stock: number(sku.stock),
  63. sourceModifiedAt: text(payload.sourceModifiedAt),
  64. collectedAt: text(payload.syncedAt || source.updatedAt),
  65. source: 'jd-sp-api',
  66. };
  67. return [{
  68. objectId: product.objectId,
  69. body: {
  70. source: 'jd-sp-api',
  71. brand: text(payload.brand || product.brand),
  72. title: text(sku.name || product.title || payload.title),
  73. model,
  74. rawPayload,
  75. },
  76. }];
  77. });
  78. console.log(JSON.stringify({
  79. mode: apply ? 'apply' : 'dry-run',
  80. workspaceId,
  81. ownProducts: products.length,
  82. listingSources: sources.length,
  83. indexedSkus: sourceBySku.size,
  84. matchedUpdates: updates.length,
  85. unmatched: products.filter((product) => !sourceBySku.has(text(product.productId))).length,
  86. skippedExisting: products.filter((product) => Boolean(product.rawPayload) && sourceBySku.has(text(product.productId))).length,
  87. }, null, 2));
  88. if (apply) {
  89. for (let offset = 0; offset < updates.length; offset += 50) {
  90. const batch = updates.slice(offset, offset + 50);
  91. await writeFmodeBatch(batch.map((item) => ({
  92. method: 'PUT',
  93. path: `/classes/${VOC_PARSE_CLASSES.product}/${item.objectId}`,
  94. body: item.body,
  95. })));
  96. console.log(`[voc-product-backfill] ${Math.min(offset + batch.length, updates.length)}/${updates.length}`);
  97. }
  98. }
  99. async function writeFmodeBatch(requests: Array<{ method: 'PUT'; path: string; body: unknown }>): Promise<void> {
  100. // The managed Fmode mount exposes REST under /backend/{app}/data, while its
  101. // batch router expects nested paths relative to /data.
  102. const results = await client.request<Array<{ error?: { code?: number; error?: string } }>>('/batch', {
  103. method: 'POST',
  104. body: { requests: requests.map((request) => ({ ...request, path: `/data${request.path}` })) },
  105. });
  106. const failure = results.find((result) => result.error)?.error;
  107. if (failure) throw new Error(`voc_product_batch_${failure.code ?? 'unknown'}:${failure.error ?? 'failed'}`);
  108. }
  109. function record(value: unknown): Record<string, unknown> {
  110. return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
  111. }
  112. function list(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
  113. function text(value: unknown): string { return value === null || value === undefined ? '' : String(value).trim(); }
  114. function number(value: unknown): number | null { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }
  115. function attributeValue(attributes: Record<string, unknown>[], names: string[]): string {
  116. const expected = names.map((name) => name.toLowerCase());
  117. for (const attribute of attributes) {
  118. const name = text(attribute.name || attribute.attrName).toLowerCase();
  119. if (!expected.some((item) => name.includes(item))) continue;
  120. const values = list(attribute.values).map((value) => typeof value === 'object' ? text(record(value).attrValue || record(value).value) : text(value)).filter(Boolean);
  121. if (values.length) return values.join(' / ');
  122. }
  123. return '';
  124. }