480 lines
32 KiB
JavaScript
480 lines
32 KiB
JavaScript
#!/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 } 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) => round(entries.reduce((total, { row }) => total + (Number(get(row, name)) || 0), 0))
|
|||
|
|
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',
|
|||
|
|
packageCount, weightKg: sum('实际毛重'), volumeCbm: sum('实际立方'),
|
|||
|
|
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', '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', 'wait-split'].includes(stage)) throw Error('stage 必须为 preflight/create/wait-split')
|
|||
|
|
if (stage === 'wait-split' && 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 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 lock = await open(resolve(directory, 'run.lock'), 'wx')
|
|||
|
|
const save = async () => {
|
|||
|
|
await writeFile(`${statePath}.tmp`, JSON.stringify(state, null, 2), 'utf8')
|
|||
|
|
for (let attempt = 0; ; attempt++) {
|
|||
|
|
try { await rename(`${statePath}.tmp`, statePath); break }
|
|||
|
|
catch (error) {
|
|||
|
|
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error.code) || attempt >= 5) throw error
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
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 = []
|
|||
|
|
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')
|
|||
|
|
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, state, save, api, ctx, merchant, profile, pallets, stage })
|
|||
|
|
} finally {
|
|||
|
|
await lock.close()
|
|||
|
|
const { unlink } = await import('node:fs/promises')
|
|||
|
|
await unlink(resolve(directory, 'run.lock'))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function executeCandidates({ candidates, limit, state, save, api, ctx, merchant, profile, pallets, stage }) {
|
|||
|
|
let attempted = 0, succeeded = 0, failed = 0, skipped = 0
|
|||
|
|
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.
|
|||
|
|
if (state.orders[order.key]) { skipped++; continue }
|
|||
|
|
if (attempted >= limit) break
|
|||
|
|
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() }
|
|||
|
|
state.orders[order.key] = entry
|
|||
|
|
await save()
|
|||
|
|
attempted++
|
|||
|
|
try {
|
|||
|
|
const 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)
|
|||
|
|
if (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 {
|
|||
|
|
const config = await api('GET', `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${profile.id}`)
|
|||
|
|
entry.dimensionPlan = totalPreservingShapes(order, config.rules)
|
|||
|
|
entry.stage = 'product-image'; await save()
|
|||
|
|
const image = requireSuccess(await ctx.requestMultipart('POST', '/api/merchant/v1/files/product-images',
|
|||
|
|
{ content: PNG, type: 'image/png', name: `${marker}-TEST.png` }, merchant))
|
|||
|
|
if (!image?.fileId) throw Error('PRODUCT_IMAGE_ID_MISSING')
|
|||
|
|
entry.productImageFileId = image.fileId
|
|||
|
|
payload = buildPayload(order, route, profile.tenantName, image.fileId, 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 (stage === 'wait-split') {
|
|||
|
|
entry.stage = 'submit'; await save()
|
|||
|
|
await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` })
|
|||
|
|
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++
|
|||
|
|
}
|
|||
|
|
entry.finishedAt = new Date().toISOString(); await save()
|
|||
|
|
console.log(JSON.stringify({ attempted, succeeded, failed, skipped, rows: entry.rows,
|
|||
|
|
stage: entry.stage, status: entry.status, orderNo: entry.orderNo, inboundCode: entry.inboundCode, error: entry.error, evidence: entry.evidence }))
|
|||
|
|
// A small pilot must expose a common deployment/data defect before multiplying it.
|
|||
|
|
if (failed >= 3 && succeeded === 0) break
|
|||
|
|
}
|
|||
|
|
state.lastRun = { attempted, succeeded, failed, skipped, stage, 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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 measured = await api('GET', measurementPath)
|
|||
|
|
if (measured.items?.length !== entry.dimensionPlan.length) throw Error('UNEXPECTED_MEASUREMENT_ITEMS')
|
|||
|
|
const draft = await api('PUT', `${base}/measurement/draft?${q}`, {
|
|||
|
|
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按总体积分组反算箱规,非实测` } }),
|
|||
|
|
})
|
|||
|
|
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 })
|
|||
|
|
}
|