#!/usr/bin/env node // Explicitly authorized non-production HTTP import. Never resets a database. import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' const hash = (value) => createHash('sha256').update(value).digest('hex') const text = (value) => String(value ?? '').trim() const round = (value, digits = 6) => Number(value.toFixed(digits)) const positive = (value) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) > 0 const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') const PALLETS = ['TP-001', 'YW-TP-002'] const declarationMode = (value) => ({ 买单: 'BUY_DECLARATION', 退税: 'TAX_REFUND', 买单改退税: 'TAX_REFUND', 退税改买单: 'BUY_DECLARATION' })[text(value)] || null export function channelName(value) { return text(value).replace(/\s/g, '').replace(/[((](?:FEDEX|UPS)[))]/gi, '').replace(/不包税|包税/g, '') } export function normalizeRows(rows) { const header = rows[0].map((value) => text(value).replace(/\s/g, '')) const required = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数'] for (const name of required) if (!header.includes(name)) throw Error(`缺少源表列:${name}`) const get = (row, name) => row[header.indexOf(name)] const groups = new Map(), excluded = [] rows.slice(1).forEach((row, offset) => { if (row.every((value) => value === null || value === '')) return const code = text(get(row, '进仓编号')).match(/^[A-Z]{2,8}\d{8,16}(?=\s|$)/)?.[0] if (!code) { excluded.push({ rows: [offset + 2], reason: 'INVALID_SOURCE_CODE' }); return } const group = groups.get(code) || [] group.push({ row, rowNumber: offset + 2 }); groups.set(code, group) }) const orders = [...groups].map(([sourceCode, entries]) => { const first = entries[0].row const distinct = (name) => [...new Set(entries.map(({ row }) => text(get(row, name))))] const problems = [] for (const name of ['客户', '渠道', '亚马逊仓']) { if (distinct(name).length !== 1) problems.push(`CONFLICT_${name}`) } if (new Set(entries.map(({ row }) => declarationMode(get(row, '买单/退税')))).size !== 1) problems.push('CONFLICT_买单/退税') // Missing/zero actuals do not silently become forecast facts. for (const name of ['实际件数', '实际毛重', '实际立方']) { if (entries.some(({ row }) => !positive(get(row, name)))) problems.push(`MISSING_${name}`) } const sum = (name, digits = 6) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0), digits) const packageCount = sum('实际件数') if (!Number.isInteger(packageCount) || packageCount < 1) problems.push('INVALID_PACKAGE_COUNT') if (entries.length > 1) { const forecasts = distinct('预报件数').map((value) => Number(value.match(/^\d+/)?.[0])) if (forecasts.length !== 1 || forecasts[0] !== packageCount) problems.push('SPLIT_COUNT_MISMATCH') } const sourceChannel = text(get(first, '渠道')) const destination = text(get(first, '亚马逊仓')) const taxMode = declarationMode(get(first, '买单/退税')) if (!taxMode) problems.push('UNKNOWN_DECLARATION_MODE') const pricingTaxMode = sourceChannel.includes('不包税') ? 'TAX_EXCLUDED' : sourceChannel.includes('包税') ? 'TAX_INCLUDED' : null if (!pricingTaxMode) problems.push('UNKNOWN_PRICING_TAX_MODE') const chargeModes = entries.map(({ row }) => ({ KGS: 'KG', KG: 'KG', CBM: 'CBM' })[text(get(row, '单位')).toUpperCase()]) const chargeMode = pricingTaxMode === 'TAX_INCLUDED' ? 'KG' : chargeModes[0] if (pricingTaxMode === 'TAX_EXCLUDED' && (!chargeMode || chargeModes.some((mode) => mode !== chargeMode))) problems.push('UNKNOWN_OR_CONFLICTING_CHARGE_MODE') const destinationType = destination.includes('私人') ? 'PRIVATE' : /^[A-Z]{3}\d$/.test(destination) ? 'FBA' : 'OVERSEAS' return { key: hash(sourceCode).slice(0, 24), sourceCode, rows: entries.map((entry) => entry.rowNumber), sourceCustomer: text(get(first, '客户')), sourceChannel, channelName: channelName(sourceChannel), destination, destinationType, pricingTaxMode, taxMode, chargeMode, fulfillmentType: destination.includes('一件代发') ? 'ONE_CLICK_DROPSHIP' : 'STANDARD', // Match the persisted order schema: kg has 3 decimals and CBM has 4. // Split source rows can contain longer allocation decimals whose exact sum // cannot be represented by the order table. packageCount, weightKg: sum('实际毛重', 3), volumeCbm: sum('实际立方', 4), cargoName: distinct('品名').filter(Boolean).join(' / '), sku: distinct('SKU').filter(Boolean).join('\n'), shipmentId: distinct('FBA货件编号').filter(Boolean).join('\n'), referenceId: distinct('跟踪编号').filter(Boolean).join('\n'), problems } }) return { orders, excluded } } export function matchRoute(order, catalog) { if (order.problems.length) throw Error(order.problems.join('|')) if (order.destinationType === 'PRIVATE') throw Error('PRIVATE_ADDRESS_REQUIRES_EXPLICIT_MAPPING') // Do not create work pallets to bypass the user's no-new-pallet constraint. if (order.fulfillmentType === 'ONE_CLICK_DROPSHIP') throw Error('DROPSHIP_REQUIRES_WORK_PALLET_WORKFLOW') const channels = catalog.channels.filter((item) => item.channelName === order.channelName && item.pricingTaxModes?.includes(order.pricingTaxMode)) if (channels.length !== 1) throw Error('CHANNEL_OR_TAX_MODE_NOT_UNIQUE') const destinations = catalog.destinations.filter((item) => item.warehouseType === order.destinationType && (item.warehouseCode === order.destination || item.warehouseName === order.destination)) if (destinations.length !== 1) throw Error('DESTINATION_NOT_UNIQUE') return { channel: channels[0], destination: destinations[0], receiving: catalog.receiving } } export function measurementShape(order) { if (!positive(order.volumeCbm) || !positive(order.weightKg) || !Number.isInteger(order.packageCount) || order.packageCount < 1) throw Error('INVALID_MEASUREMENT_FACTS') const edge = round(Math.cbrt(order.volumeCbm * 1e6 / order.packageCount), 3) return { length: edge, width: edge, height: edge, palletCount: Math.ceil(order.packageCount / 30) } } export function totalPreservingShapes(order, rules) { measurementShape(order) if (!Array.isArray(rules) || rules.length !== 4 || rules.some((r) => ![r.innerLengthCm, r.innerWidthCm, r.innerHeightCm, r.edgeAdditionCm].every(positive))) throw Error('INVALID_DIMENSION_RULES') const sorted = [...rules].sort((a, b) => a.innerLengthCm * a.innerWidthCm * a.innerHeightCm - b.innerLengthCm * b.innerWidthCm * b.innerHeightCm) const choose = (edge) => sorted.find((r) => Math.min(r.innerLengthCm, r.innerWidthCm, r.innerHeightCm) >= edge) || sorted.at(-1) // Manual outer dimensions use server CEILING(4) per carton. Distribute the // integer 0.0001 m3 remainder across two groups, not independently rounded boxes. const scale = Math.abs(Math.round(order.volumeCbm * 1000) / 1000 - order.volumeCbm) < 0.0000001 ? 1000 : 10000 const ticks = Math.round(order.volumeCbm * scale) if (Math.abs(ticks / scale - order.volumeCbm) > 0.0000001) throw Error('SOURCE_VOLUME_EXCEEDS_SERVER_PRECISION') const lower = Math.floor(ticks / order.packageCount), extra = ticks % order.packageCount const groups = [{ count: order.packageCount - extra, ticks: lower }, { count: extra, ticks: lower + 1 }].filter((g) => g.count) return groups.map((group) => { if (group.ticks < 1) throw Error('VOLUME_TOO_SMALL_FOR_CARTON_COUNT') const unitVolumeCbm = group.ticks / scale for (const rule of sorted) { const addition = 2 * Number(rule.edgeAdditionCm) const edge = round(Math.cbrt((unitVolumeCbm - 0.00005) * 1e6) - addition, 3) const raw = round((edge + addition) ** 3 / 1e6, 6) const serverTicks = Math.ceil(raw * 10000 - 1e-8) if (edge > 0 && choose(edge) === rule && serverTicks === Math.round(unitVolumeCbm * 10000)) { return { length: edge, width: edge, height: edge, count: group.count, unitVolumeCbm, edgeAdditionCm: Number(rule.edgeAdditionCm), boxType: rule.boxType } } } throw Error('NO_TOTAL_PRESERVING_DIMENSIONS_FOR_CURRENT_RULES') }) } function oneValue(value, fallback, max) { // Multiline/oversized identifiers are not silently truncated into a different real ID. return value && !/[\r\n]/.test(value) && value.length <= max ? value : fallback } export function buildPayload(order, route, customer, imageId, marker) { const shape = measurementShape(order) const shipmentId = oneValue(order.shipmentId, `TEST-SHIP-${marker}`, 64) const referenceId = oneValue(order.referenceId, `TEST-REF-${marker}`, 64) const fba = order.destinationType === 'FBA' return { customerName: customer, transportMode: 'SEA', tradeTerm: 'FOB', fulfillmentType: 'STANDARD', pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: route.receiving.id, receivingWarehouseName: route.receiving.warehouseName, channelCode: route.channel.channelCode, channelName: route.channel.channelName, pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, containerMode: 'LCL', customsInspection: false, destinationType: order.destinationType, destinationWarehouseId: route.destination.id, destinationWarehouseName: route.destination.warehouseName, destinationCountryCode: route.destination.countryCode, amazonWarehouseCode: fba ? route.destination.warehouseCode : null, shipmentId: fba ? shipmentId : null, referenceId: fba ? referenceId : null, customerRemark: `${marker} 测试导入,源入仓码=${order.sourceCode};源客户=${order.sourceCustomer};源渠道=${order.sourceChannel};箱数/重量/体积取源实际值;箱规为等体积推算,照片及缺失产品字段为测试占位;非真实测量证据`.slice(0, 1000), cargoes: [{ cargoName: oneValue(order.cargoName, '测试导入汇总货物(详见源表)', 128), cargoNameEn: 'TEST imported goods', skuNo: oneValue(order.sku, `TEST-SKU-${marker}`, 64), packageType: 'CARTON', packageCount: order.packageCount, grossWeightKg: order.weightKg, volumeCbm: order.volumeCbm, lengthCm: shape.length, widthCm: shape.width, heightCm: shape.height, unitsPerPackage: 1, totalQuantity: order.packageCount, netWeightKg: order.weightKg, hsCode: '0000000000', customsHsCode: '0000000000', unitDeclaredValue: 1, declaredValue: order.packageCount, currency: 'USD', material: 'TEST_UNKNOWN', usage: '测试流程', model: 'TEST', brand: 'TEST', productLengthCm: 1, productWidthCm: 1, productHeightCm: 1, shipmentId: fba ? shipmentId : null, referenceId: fba ? referenceId : null, upcNo: '000000000000', productImageFileId: imageId, dangerousGoods: false, woodenProduct: false, cargoRemark: `${marker} 聚合源表行${order.rows.join(',')};商品数量/申报价/材质/HS/属性为测试占位,禁止用于真实报关`, }], } } export function buildSourceDraft(order, route, customer, marker) { // Drafts deliberately retain missing fields. No inferred dimensions, fake photo, // customs declaration values or dangerous-goods assertions are submitted here. // User-confirmed import rule: absent wooden-product information means false. return { customerName: customer, transportMode: 'SEA', fulfillmentType: order.fulfillmentType, pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: route.receiving.id, receivingWarehouseName: route.receiving.warehouseName, channelCode: route.channel.channelCode, channelName: route.channel.channelName, pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, destinationType: order.destinationType, destinationWarehouseId: route.destination.id, destinationWarehouseName: route.destination.warehouseName, destinationCountryCode: route.destination.countryCode, amazonWarehouseCode: order.destinationType === 'FBA' ? route.destination.warehouseCode : null, customerRemark: `${marker} 源数据草稿;源入仓码=${order.sourceCode};源客户=${order.sourceCustomer};箱数/重量/体积取源实际值。缺失产品字段和实测证据待补充。`.slice(0, 1000), cargoes: [{ cargoName: oneValue(order.cargoName, '', 128), skuNo: oneValue(order.sku, null, 64), packageType: 'CARTON', woodenProduct: false, packageCount: order.packageCount, grossWeightKg: order.weightKg, volumeCbm: order.volumeCbm, shipmentId: oneValue(order.shipmentId, null, 64), referenceId: oneValue(order.referenceId, null, 64), cargoRemark: `${marker} 源表行${order.rows.join(',')};多货物原文请核对源表` }], } } export function requireSuccess(response) { if (response?.http !== 200 || String(response?.body?.code) !== '200') { const error = Error(`HTTP_${response?.http}_CODE_${response?.body?.code || 'MISSING'}`) error.evidence = { method: response?.method, path: response?.path, http: response?.http, code: response?.body?.code, message: response?.body?.message, requestId: response?.requestId, traceId: response?.traceId } throw error } return response.body.data } async function main() { const args = new Map() const allowed = new Set(['input', 'sheet', 'python', 'output-dir', 'batch-id', 'stage', 'limit', 'concurrency', 'customer-name', 'origin-warehouse-name', 'allow-test-placeholders', 'expected-host']) for (let i = 2; i < process.argv.length; i += 2) { const name = process.argv[i].replace(/^--/, ''), value = process.argv[i + 1] if (!allowed.has(name) || !value || value.startsWith('--')) throw Error(`无效参数:${name}`) args.set(name, value) } for (const key of ['input', 'python', 'output-dir', 'batch-id', 'expected-host']) if (!args.get(key)) throw Error(`缺少 --${key}`) const stage = args.get('stage') || 'preflight' if (!['preflight', 'create', 'pending-review', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/pending-review/wait-split') if (['pending-review', 'wait-split'].includes(stage) && args.get('allow-test-placeholders') !== 'true') { throw Error('此源表缺少提交必填字段,须显式允许测试占位') } const limit = Number(args.get('limit') || 1) if (!Number.isInteger(limit) || limit < 1) throw Error('limit 必须为正整数') const concurrency = Number(args.get('concurrency') || 1) if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) throw Error('concurrency 必须是1到10的整数') const options = validateExecutionOptions(parseArguments(['--modules', 'orders,inbound-measurement', '--quiet', 'true'])) if (new URL(options.gateway).hostname !== args.get('expected-host') || !/^test[-.]/.test(args.get('expected-host'))) throw Error('只允许明确匹配的测试域名') const input = resolve(args.get('input')) const bytes = await readFile(input) const rows = JSON.parse(execFileSync(args.get('python'), ['-c', 'import openpyxl,json,sys; w=openpyxl.load_workbook(sys.argv[1],read_only=True,data_only=True); print(json.dumps(list(w[sys.argv[2]].values),ensure_ascii=False,default=str)); w.close()', input, args.get('sheet') || 'D1512开始'], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } })) const normalized = normalizeRows(rows) const directory = resolve(args.get('output-dir')) await mkdir(directory, { recursive: true }) const statePath = resolve(directory, 'state.json') const identity = { sourceHash: hash(bytes), batchId: args.get('batch-id'), gateway: hash(options.gateway), customer: args.get('customer-name') || 'IT测试', origin: args.get('origin-warehouse-name') || '义乌仓' } let state try { state = JSON.parse(await readFile(statePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error } if (state && JSON.stringify(state.identity) !== JSON.stringify(identity)) throw Error('批次/源文件/目标发生变化,请使用独立输出目录') state ||= { identity, orders: {}, startedAt: new Date().toISOString() } const lockPath = resolve(directory, 'run.lock') const lock = await acquireRunLock(lockPath) let saveQueue = Promise.resolve() const save = () => { saveQueue = saveQueue.then(async () => { const temporaryPath = `${statePath}.${process.pid}.tmp` await writeFile(temporaryPath, JSON.stringify(state, null, 2), 'utf8') for (let attempt = 0; ; attempt++) { try { await rename(temporaryPath, statePath); break } catch (error) { if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) } } }) return saveQueue } const ctx = new AcceptanceRun(options) const api = async (method, path, body, token = ctx.token, headers = {}) => requireSuccess(await ctx.request(method, path, body, token, headers)) try { ctx.token = (await api('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')).accessToken const merchant = (await api('POST', '/api/auth/login', { loginId: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, '')).accessToken if (!ctx.token || !merchant) throw Error('LOGIN_TOKEN_MISSING') const profile = await api('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant) if (profile.tenantName !== identity.customer || profile.status !== 'ACTIVE') throw Error('CUSTOMER_MISMATCH_OR_INACTIVE') const list = async (type) => { const result = [] for (let page = 1; ; page++) { const data = await api('GET', `/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`, undefined, merchant) if (!Array.isArray(data.records) || !Number.isFinite(Number(data.total))) throw Error('WAREHOUSE_PAGE_INVALID') result.push(...data.records) if (result.length >= Number(data.total)) return result if (!data.records.length || page >= 100) throw Error('WAREHOUSE_PAGE_INCOMPLETE') } } const origins = (await list('DOMESTIC')).filter((item) => item.warehouseName === identity.origin) if (origins.length !== 1) throw Error('ORIGIN_NOT_UNIQUE') const receiving = origins[0] const channels = await api('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${receiving.id}`, undefined, merchant) const catalog = { receiving, channels, destinations: [...await list('FBA'), ...await list('OVERSEAS')] } const pallets = [] if (stage === 'wait-split') { for (const code of PALLETS) { const page = await api('GET', `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${receiving.id}&palletCode=${encodeURIComponent(code)}`) const found = page.records?.filter((item) => item.palletCode === code && String(item.warehouseId) === String(receiving.id)) if (found?.length !== 1) throw Error(`REQUIRED_PALLET_MISSING_${code}`) pallets.push(found[0]) } } const policy = await api('GET', '/api/merchant/v1/orders/first-leg/inbound-code-policy', undefined, merchant) if (policy.mode !== 'AUTO') throw Error('INBOUND_CODE_POLICY_NOT_AUTO') let sharedResources = null if (['pending-review', 'wait-split'].includes(stage)) { let dimensionRules = null if (stage === 'wait-split') { const dimensionConfig = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`) totalPreservingShapes(normalized.orders.find((order) => order.problems.length === 0), dimensionConfig.rules) dimensionRules = dimensionConfig.rules } let productImageFileId = state.sharedResources?.productImageFileId if (!productImageFileId) { const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images', { content: PNG, type: 'image/png', name: `TEST-XLSX-${state.identity.batchId}.png` }, merchant)) productImageFileId = image?.fileId if (!productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_ID_MISSING') } state.sharedResources = { productImageFileId, ...(dimensionRules ? { dimensionRules } : {}) } sharedResources = state.sharedResources await save() } const candidates = [], rejected = [...normalized.excluded] for (const order of normalized.orders) { try { candidates.push({ order, route: matchRoute(order, catalog) }) } catch (error) { rejected.push({ key: order.key, rows: order.rows, reason: error.message }) } } const preflight = { sourceRows: rows.length - 1, orders: normalized.orders.length, matched: candidates.length, rejected, channelCount: channels.length, palletCodes: PALLETS, note: '匹配仅验证目录,写入前还会用实际重量体积试价。私人仓、需工作托盘的一件代发及歧义源记录不擅自替代。' } await writeFile(resolve(directory, 'preflight.json'), JSON.stringify(preflight, null, 2), 'utf8') console.log(JSON.stringify({ stage, sourceRows: preflight.sourceRows, orders: preflight.orders, matched: candidates.length, rejected: rejected.length })) await save() if (stage === 'preflight') return await executeCandidates({ candidates, limit, concurrency, state, save, api, ctx, merchant, profile, pallets, sharedResources, stage }) } finally { await saveQueue.catch(() => {}) await lock.close() await unlink(lockPath) } } async function acquireRunLock(lockPath) { try { const handle = await open(lockPath, 'wx') await handle.writeFile(String(process.pid), 'utf8') return handle } catch (error) { if (error.code !== 'EEXIST') throw error const existingPid = Number(await readFile(lockPath, 'utf8').catch(() => '')) let alive = false if (Number.isInteger(existingPid) && existingPid > 0) { try { process.kill(existingPid, 0); alive = true } catch { /* Stale PID. */ } } if (alive) throw Error(`批次正在运行,pid=${existingPid}`) await unlink(lockPath) const handle = await open(lockPath, 'wx') await handle.writeFile(String(process.pid), 'utf8') return handle } } export async function executeCandidates({ candidates, limit, concurrency = 1, state, save, api, ctx, merchant, profile, pallets, sharedResources, stage }) { let attempted = 0, completed = 0, succeeded = 0, failed = 0, skipped = 0, stopReason = null const pending = [] for (const { order, route } of candidates) { // An interrupted write is not permission to create another order. Keep its last // checkpoint for inspection. A reset database is always a NEW batch/directory. const previous = state.orders[order.key] const retryablePricingWarning = stage === 'pending-review' && previous?.status === 'FAILED' && previous.stage === 'estimate' && !previous.orderId && ['A0404', 'A0422'].includes(previous.evidence?.code) if (previous && !retryablePricingWarning) { skipped++; continue } if (pending.length >= limit) break pending.push({ order, route, previous }) } for (let offset = 0; offset < pending.length; offset += concurrency) { const batch = pending.slice(offset, offset + concurrency) await Promise.all(batch.map(async ({ order, route, previous }) => { const marker = `XLSX-${hash(`${state.identity.batchId}:${order.key}`).slice(0, 24)}` const entry = { rows: order.rows, marker, status: 'STARTED', stage: 'estimate', startedAt: new Date().toISOString(), ...(previous ? { priorAttempts: [...(previous.priorAttempts || []), { status: previous.status, stage: previous.stage, error: previous.error, evidence: previous.evidence, finishedAt: previous.finishedAt }] } : {}) } state.orders[order.key] = entry await save() attempted++ try { let estimate = null try { estimate = await api('POST', '/api/merchant/v1/first-leg/pricing/estimate', { originWarehouseId: route.receiving.id, channelCode: route.channel.channelCode, destinationCode: route.destination.warehouseCode, destinationType: order.destinationType, destinationWarehouseId: route.destination.id, destinationName: route.destination.warehouseName, pricingTaxMode: order.pricingTaxMode, chargeMode: order.chargeMode, actualWeightKg: order.weightKg, actualVolumeCbm: order.volumeCbm, }, merchant) } catch (error) { if (stage !== 'pending-review' || !['A0404', 'A0422'].includes(error.evidence?.code)) throw error entry.pricingWarning = { code: error.evidence.code, message: error.evidence.message, requestId: error.evidence.requestId, traceId: error.evidence.traceId } } if (estimate && (estimate.channelCode !== route.channel.channelCode || estimate.pricingTaxMode !== order.pricingTaxMode)) { throw Error('ESTIMATE_ROUTE_MISMATCH') } let payload if (stage === 'create') { payload = buildSourceDraft(order, route, profile.tenantName, marker) } else { if (!sharedResources?.productImageFileId) throw Error('SHARED_PRODUCT_IMAGE_MISSING') if (stage === 'wait-split') { if (!Array.isArray(sharedResources.dimensionRules)) throw Error('SHARED_DIMENSION_RULES_MISSING') entry.dimensionPlan = totalPreservingShapes(order, sharedResources.dimensionRules) } entry.productImageFileId = sharedResources.productImageFileId payload = buildPayload(order, route, profile.tenantName, sharedResources.productImageFileId, marker) } entry.stage = 'create'; entry.payloadHash = hash(JSON.stringify(payload)); await save() entry.orderId = String(await api('POST', '/api/merchant/v1/orders/first-leg', payload, merchant, { 'Idempotency-Key': `${marker}-create` })) if (!entry.orderId || entry.orderId === 'null' || entry.orderId === 'undefined') throw Error('ORDER_ID_MISSING') entry.stage = 'draft-created'; await save() const clientPath = `/api/merchant/v1/orders/first-leg/${entry.orderId}` const draft = await api('GET', clientPath, undefined, merchant) if (String(draft.order?.id) !== entry.orderId || !draft.order?.customerRemark?.includes(marker)) throw Error('ORDER_PROVENANCE_MISMATCH') const cargo = draft.cargoes?.[0] if (draft.cargoes?.length !== 1 || Number(cargo.packageCount) !== order.packageCount || Math.abs(Number(cargo.grossWeightKg) - order.weightKg) > 0.001 || Math.abs(Number(cargo.volumeCbm) - order.volumeCbm) > 0.00001) throw Error('DRAFT_SOURCE_FACTS_MISMATCH') entry.orderNo = draft.order.orderNo entry.declarationVerified = true if (['pending-review', 'wait-split'].includes(stage)) { if (!Number.isSafeInteger(draft.order.version) || draft.order.version < 0) throw Error('SUBMIT_VERSION_MISSING') entry.submitCommand = { body: { expectedVersion: draft.order.version }, headers: { 'Idempotency-Key': `${marker}-submit` } } entry.stage = 'submit'; await save() await api('POST', `${clientPath}/submit`, entry.submitCommand.body, merchant, entry.submitCommand.headers) } if (stage === 'pending-review') { const submitted = await api('GET', clientPath, undefined, merchant) const unified = await api('GET', `/api/merchant/v1/orders/${entry.orderId}`, undefined, merchant) if (submitted.order?.status !== 'SUBMITTED' || unified.summary?.mainStatus !== 'PENDING_REVIEW') { const error = Error('PENDING_REVIEW_NOT_CONFIRMED') error.evidence = { clientStatus: submitted.order?.status, mainStatus: unified.summary?.mainStatus } throw error } entry.stage = 'PENDING_REVIEW' entry.verification = { clientStatus: submitted.order.status, mainStatus: unified.summary.mainStatus, expectedPackages: order.packageCount, actualPackages: Number(submitted.cargoes?.[0]?.packageCount), expectedCargoWeightKg: order.weightKg, actualCargoWeightKg: Number(submitted.cargoes?.[0]?.grossWeightKg), expectedVolumeCbm: order.volumeCbm, actualVolumeCbm: Number(submitted.cargoes?.[0]?.volumeCbm) } } if (stage === 'wait-split') { entry.stage = 'approve'; await save() const platformPath = `/api/platform/v1/orders/first-leg/${entry.orderId}` const q = `tenantId=${profile.id}` await api('POST', `${platformPath}/approve?${q}`) const unified = await poll(async () => api('GET', `/api/platform/v1/orders/${entry.orderId}?${q}`), (data) => data.summary?.inboundId && ['WAIT_MEASURE', 'MEASURING'].includes(data.summary?.mainStatus)) entry.inboundId = String(unified.summary.inboundId) entry.stage = 'inbound-created'; await save() await api('POST', `${platformPath}/accept?${q}`) await api('POST', `${platformPath}/start?${q}`) await measureOrder({ order, entry, ctx, api, pallets, tenantId: String(profile.id), save }) } entry.status = 'SUCCEEDED'; succeeded++ } catch (error) { entry.status = 'FAILED'; entry.error = error.message; entry.evidence = error.evidence; failed++ if (error.evidence?.code === 'A0401') stopReason ||= 'AUTHENTICATION_EXPIRED' } completed++ entry.finishedAt = new Date().toISOString(); await save() if (entry.status === 'FAILED' || completed % 100 === 0 || completed === pending.length) { console.log(JSON.stringify({ attempted, completed, succeeded, failed, skipped, rows: entry.rows, stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence })) } })) if (stopReason) break // A small concurrent pilot must expose a common deployment/data defect before multiplying it. if (failed >= 3 && succeeded === 0) break } state.lastRun = { attempted, completed, succeeded, failed, skipped, concurrency, stage, stopReason, finishedAt: new Date().toISOString() } await save() if (failed) process.exitCode = 1 } async function poll(read, predicate) { let data for (let i = 0; i < 30; i++) { data = await read() if (predicate(data)) return data if (i < 29) await new Promise((resolve) => setTimeout(resolve, 1000)) } const error = Error('PROJECTION_NOT_READY') error.evidence = { processStatus: data?.processStatus || data?.summary?.mainStatus, measurementStatus: data?.measurementStatus, measureReviewStatus: data?.measureReviewStatus } throw error } export async function saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, attempts = 3, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), onRetry }) { for (let attempt = 1; attempt <= attempts; attempt++) { const measured = await api('GET', measurementPath) if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS') const payload = { measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: oneValue(order.cargoName, '测试导入汇总货物', 128), items: entry.dimensionPlan.map((group) => { const item = measured.items.find((value) => String(value.id) === group.itemId) if (!item) throw Error('MEASUREMENT_GROUP_MISSING') return { inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, packageCount: group.count, lengthCm: group.length, widthCm: group.width, heightCm: group.height, intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, outerLengthCm: group.length, outerWidthCm: group.width, outerHeightCm: group.height, coefficientMode: 'ADD', remark: `${marker} TEST按总体积分组反算箱规,非实测` } }), } try { return await api('PUT', `${base}/measurement/draft?${q}`, payload) } catch (error) { if (error.evidence?.code !== 'S0500' || attempt === attempts) throw error await onRetry?.({ attempt, code: error.evidence.code, requestId: error.evidence.requestId, traceId: error.evidence.traceId }) await wait(250 * attempt) } } throw Error('MEASUREMENT_DRAFT_RETRY_EXHAUSTED') } async function measureOrder({ order, entry, ctx, api, pallets, tenantId, save }) { const inboundId = entry.inboundId, q = `tenantId=${tenantId}`, marker = entry.marker const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` const base = `/api/platform/v1/first-leg/inbounds/${inboundId}` const measurementPath = `${base}/measurement?${q}` const inbound = await api('GET', inboundPath) if (String(inbound.summary?.tenantId) !== tenantId || String(inbound.summary?.id) !== inboundId) throw Error('INBOUND_PROVENANCE_MISMATCH') entry.inboundCode = inbound.summary.inboundCode entry.stage = 'carton-labels'; await save() const pdf = await ctx.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`) if (pdf.http !== 200 || !pdf.contentType?.includes('pdf') || Buffer.from(pdf.bytes || []).subarray(0, 4).toString() !== '%PDF') throw Error('LABEL_GENERATION_FAILED') const shape = measurementShape(order) entry.stage = 'carton-scan'; await save() let cartonIndex = 1 for (const group of entry.dimensionPlan) { const form = new FormData() form.append('data', new Blob([JSON.stringify({ boxCode: `${entry.inboundCode}#${order.packageCount}-${cartonIndex}`, length: group.length, width: group.width, height: group.height, machineNo: 'TEST-XLSX-SIMULATED', measuredAt: new Date().toISOString().slice(0, 19) })], { type: 'application/json' })) form.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png') const scan = requireSuccess(await ctx.requestMultipart('POST', `${base}/measurement/carton-scans?${q}`, form)) if (!scan?.itemId || !scan?.measurementId) throw Error('SCAN_IDS_MISSING') entry.measurementId = scan.measurementId; await save() await api('PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`, { measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: group.count }) group.itemId = String(scan.itemId) cartonIndex += group.count } const draft = await saveMeasurementDraft({ api, measurementPath, base, q, order, entry, marker, onRetry: async (retry) => { entry.retries ||= []; entry.retries.push({ stage: 'measurement-draft', ...retry }); await save() } }) const draftCheck = await api('GET', measurementPath) if (Math.abs(Number(draftCheck.actualVolumeCbm) - order.volumeCbm) > 0.0000001 || Number(draftCheck.actualPackageCount) !== order.packageCount) throw Error('DRAFT_VOLUME_OR_COUNT_MISMATCH') const itemVolumeWeight = draftCheck.items.reduce((sum, value) => sum + Number(value.volumeWeightKg), 0) if (Math.abs(itemVolumeWeight - Number(draftCheck.volumeWeightKg)) > 0.0000001) throw Error('SERVER_ITEM_TOTAL_VOLUME_WEIGHT_ROUNDING_MISMATCH') entry.stage = 'weights'; entry.weights = []; await save() let netSoFar = 0 for (let index = 0; index < shape.palletCount; index++) { const pallet = pallets[Number.parseInt(hash(`${marker}:${index}`).slice(0, 8), 16) % pallets.length] const upload = requireSuccess(await ctx.requestMultipart('POST', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { content: PNG, type: 'image/png', name: `TEST-weight-${index + 1}.png` })) const created = await api('POST', `${base}/weight-results?${q}`, { fileId: upload.fileId, palletId: pallet.id, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null }) const tare = Number(created.palletTareWeightKg) if (created.palletTareWeightKg == null || !Number.isFinite(tare) || tare < 0) throw Error('PALLET_TARE_SNAPSHOT_MISSING') // Source gross is packed cargo weight, NOT scale weight including the reusable pallet. const boxes = Math.min(30, order.packageCount - index * 30) const net = index === shape.palletCount - 1 ? round(order.weightKg - netSoFar, 3) : round(order.weightKg * boxes / order.packageCount, 3) const confirmed = await api('POST', `${base}/weight-results/${created.id}/confirm?${q}`, { version: Number(created.version), adjustedWeightValue: round(net + tare, 3), adjustedWeightUnit: 'KG', adjustmentReason: `${marker} TEST模拟称重:源货重按箱分摊,再加托盘皮重` }) if (Math.abs(Number(confirmed.netWeightKg) - net) > 0.001) throw Error('CONFIRMED_NET_WEIGHT_MISMATCH') netSoFar += net entry.weights.push({ id: created.id, palletCode: pallet.palletCode, netWeightKg: net, tareWeightKg: tare }); await save() } entry.stage = 'measurement-submit'; await save() await api('POST', `${base}/measurement/submit?${q}`, { measurementId: draft.measurementId, version: Number(draft.version) }) const pending = await api('GET', inboundPath) entry.stage = 'measurement-approve'; await save() await api('POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`, { measurementId: draft.measurementId, expectedVersion: Number(pending.summary?.version), reason: 'TEST源数据流程试跑' }, ctx.token, { 'Idempotency-Key': `${marker}-measurement-approve` }) const final = await poll(() => api('GET', measurementPath), (data) => data.processStatus === 'WAIT_SPLIT') entry.verification = { processStatus: final.processStatus, measurementStatus: final.measurementStatus, reviewStatus: final.measureReviewStatus, expectedPackages: order.packageCount, actualPackages: Number(final.actualPackageCount), expectedCargoWeightKg: order.weightKg, actualNetWeightKg: Number(final.actualNetWeightKg), sourceVolumeCbm: order.volumeCbm, measuredVolumeCbm: Number(final.actualVolumeCbm), volumeDifferenceCbm: round(Number(final.actualVolumeCbm) - order.volumeCbm), sourceVolumeMatched: Math.abs(Number(final.actualVolumeCbm) - order.volumeCbm) <= 0.0000001, simulatedDimensions: entry.dimensionPlan, confirmedWeightImages: entry.weights.length } if (final.measureReviewStatus !== 'APPROVED' || final.measurementStatus !== 'APPROVED' || Number(final.actualPackageCount) !== order.packageCount || Math.abs(Number(final.actualNetWeightKg) - order.weightKg) > 0.001 || !entry.verification.sourceVolumeMatched) throw Error('FINAL_SOURCE_FACTS_MISMATCH') entry.stage = 'WAIT_SPLIT' } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main().catch((error) => { console.error(JSON.stringify({ error: error.message, evidence: error.evidence })); process.exitCode = 1 }) }