Files
baiyue-firstleg/scripts/real-order-xlsx-test-env.mjs

596 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 })
}