Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-09 12:19:17 +08:00
6 changed files with 668 additions and 4 deletions
+1
View File
@@ -15,6 +15,7 @@
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
| 源 Excel 订单造数 | `real-order-xlsx-test-env.mjs`、`real-order-xlsx-import.md` | 按源入仓码归并及严格主数据匹配,分别支持预检、真实字段草稿和显式模拟测量到待分柜;仅单个已配置客户,私人地址及一件代发工作托盘未获确认前阻断;批次检查点防重复,清库后用新批次重跑 |
| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 |
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
+1
View File
@@ -69,6 +69,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
| 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建缺失字段留空的草稿,`wait-split` 显式允许测试占位后走正式审核测量。只复用两个原有托盘,不新增主档或库位。参数、限制及重跑说明见 `real-order-xlsx-import.md` |
| 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 |
| 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 |
| 测试环境 HTTP | `phase2-test-env-acceptance.ps1` | 入库测量与计费测试环境 API 验收,会写业务数据 |
+60
View File
@@ -0,0 +1,60 @@
# 源订单 Excel 测试导入
入口 `real-order-xlsx-test-env.mjs`,离线合同 `real-order-xlsx-test-env.test.mjs`。仅限当前授权的测试环境,不清库、不部署、不修改环境配置、不写数据库。
## 配置与命令
凭据仍只维护在用户目录的 `chongchong-global-test-acceptance.env`:`ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。不复制到源码或报告。Node 支持 `--env-file`,Python 需已有 `openpyxl`,可用桌面提供的工作区依赖路径。脚本不安装或链接依赖。
从后端根执行,按本机实际位置设置参数:
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/real-order-xlsx-test-env.mjs --input '<源XLSX绝对路径>' --python '<Python绝对路径>' --output-dir '<仓库外的本批次专用目录>' --batch-id trial-001 --expected-host '<测试网关主机名>' --stage preflight
```
- `--sheet` 默认 `D1512开始`。
- `--customer-name` 默认 `IT测试`,必须和登录企业一致;`--origin-warehouse-name` 默认 `义乌仓`,必须唯一存在。
- `--expected-host` 必须与配置完全相同且以 `test-` 或 `test.` 开头,禁止生产环境。
- `--stage preflight` 登录并查询目录、托盘和编号策略,不创建业务数据。
- `--stage create --limit 1` 按源实际箱数、重量、体积尝试创建一票草稿,不伪造图片、尺寸或报关字段,不提交订单。按用户确认规则,源表未注明木制品时 `woodenProduct=false`;其他缺失必填项保留服务端错误,不保证所有源行能创建。
- `--stage wait-split --allow-test-placeholders true --limit 1` 创建完整测试订单并提交、审核、测量,回查 `WAIT_SPLIT`、审核状态、箱数和货重。只有明确接受下述模拟规则时才能使用。
- `--limit` 是本次新增尝试数,默认 1,串行执行。前三票连续失败且没有成功时停止扩大写入。
## 源数据与模拟规则
同入仓码多行按实际箱数/毛重/体积求和,分柜明细还须与唯一预报箱数相符;同码客户、渠道、目的仓或报关方式冲突则阻断。缺失或零实际值不以预报替换。无有效入仓码的行排除,源文件保持原样。
报关方式“买单改退税”取最终退税,“退税改买单”取最终买单;空白或未识别文本阻断,不默认买单。
试价计费方式取“计费数据”后的第一个“单位”列:KGS/KG 映射 KG,CBM 保留。包税按服务端契约固定 KG;不包税单位缺失或分柜行冲突则阻断。
本脚本是单个已认证客户的造数入口,不能替不同客户下单。源客户及源入仓码保留在订单备注;新入仓码由系统 AUTO 生成,不改变编号策略。原船期、装柜、船节点、价格和历史费用不作为当前流程已完成事实导入,当前计价由测试库有效报价决定。
渠道仅去掉包税字样和快递括号后匹配同名渠道,保留原渠道文本到备注。税制必须存在。目的仓按同类型的精确编码/名称匹配,不模糊替代。私人地址缺少结构化映射、海外仓名称不能精确匹配、ZIM 等目录缺失渠道均记录为未匹配;不新增仓库或地址。多 SKU 明细无法从自由文本可靠还原,当前按一条汇总货物导入,不宣称保留逐 SKU 分布。
源表没有箱规和图片。按用户确认,总箱数、总货重、总体积为权威目标,长宽高允许推算。读取当前客户四档箱规,按 ADD 加边规则反算基数,使用正式人工外箱尺寸字段提交,不修改客户配置。考虑服务端逐箱四位小数向上取整,将总体积按 0.0001 m³ 整数单位分配为最多两组,每组以一个箱码代表相应箱数。草稿保存后和最终待分柜均核对总箱数、总体积,体积不一致直接失败;源体积超过服务端精度或当前规则无可行尺寸时写订单前阻断,不放宽误差掩盖差异。
每 30 箱一张称重图,末托为余数。源毛重解释为含包装货重,按箱数分摊后加服务端托盘皮重作为秤重,使服务端净货重与源毛重一致。不得将秤重总值和源货重混为一谈。推算箱规不是原始实测尺寸。
源体积可用三位小数表达时,优先以 0.001 m³ 分组,使各明细体积重和汇总体积重避免逐行向上取整差异;否则按四位精度分组。提交前同时检查明细体积重合计与批次一致,不一致记录服务端取整阻断,不提交审核或伪造总量。
照片、缺失产品数量/单价/HS/材质/危险品属性等为明确标记的测试占位,不能用于真实报关、计费准确性或实物测量验收。木制品未注明时按用户确认默认非木制品。多行或超长货件标识不截取为另一个真实 ID,模拟模式用 TEST 标识、草稿模式留空,完整原值仍在源文件中。`create` 模式不添加这些占位值。
托盘只允许 `TP-001` 和 `YW-TP-002`,任一缺失则停止;不调用托盘/库位新增接口。普通位与公共位 A0 由正式测量链分配。一件代发后续需要新建工作托盘,与当前禁止新增约束有待确认,因此先记录阻断,不降为普通单或擅自创建工作托盘。
## 报告与重跑
必须保留批次目录:`preflight.json` 为全部解析/主数据匹配结果;`state.json` 为源文件哈希、批次身份、源行号到新订单/入仓码的对应、每次写入前后的检查点、错误码和 requestId/traceId。该目录包含业务数据,仅在本机仓库外保存,不提交或公开分享。原始 Excel 自身是完整源数据依据。
同目录重新运行会跳过所有已经尝试过的票(包括成功、失败和中断票),不会自动重新创建。`limit` 每次从尚未尝试的票继续;不是完整失败恢复器。特别注意:`create` 成功的草稿不会因切换 `wait-split` 自动补齐和推进;需单独选择尚未尝试票做完整链试跑。失败/中断保留服务端部分数据,先核对检查点,不删单或自动补偿。
源文件、客户、网关或批次 ID 改变时拒绝复用目录。并发进程通过 `run.lock` 拒绝;异常终止残留锁,人工确认旧进程已结束后才可移除精确锁文件。
用户清库并重新初始化客户、报价、仓库和原有托盘后,指定**新的 batch-id 和全新输出目录**重新运行同一脚本。不能复用旧状态,否则已尝试票会跳过。清库不由本脚本执行。
## 本地检查
```powershell
node --check scripts/real-order-xlsx-test-env.mjs
node --test scripts/real-order-xlsx-test-env.test.mjs scripts/script-documentation-contract.test.mjs
```
+479
View File
@@ -0,0 +1,479 @@
#!/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 })
}
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDraft, buildPayload,
requireSuccess, executeCandidates, totalPreservingShapes } from './real-order-xlsx-test-env.mjs'
const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号']
const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1']
const order = () => normalizeRows([header, row]).orders[0]
const catalog = { receiving: { id: '1', warehouseName: 'Origin' },
channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }],
destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] }
test('source counts remain actual, including after forecast differs', () => {
const modified = [...row]; modified[7] = 99
assert.equal(normalizeRows([header, modified]).orders[0].packageCount, 31)
})
test('missing and zero actual data do not fall back to forecast', () => {
for (const missing of [null, '', 0]) {
const modified = [...row]; modified[4] = missing
assert.ok(normalizeRows([header, modified]).orders[0].problems.includes('MISSING_实际件数'))
}
})
test('invalid source codes are excluded, not converted into orders', () => {
const modified = [...row]; modified[0] = 'container subtotal'
const actual = normalizeRows([header, modified])
assert.equal(actual.orders.length, 0); assert.equal(actual.excluded.length, 1)
})
test('explicit declaration changes use the stated final mode', () => {
for (const [source, target] of [['买单改退税', 'TAX_REFUND'], ['退税改买单', 'BUY_DECLARATION']]) {
const modified = [...row]; modified[8] = source
const result = normalizeRows([header, modified]).orders[0]
assert.equal(result.taxMode, target); assert.deepEqual(result.problems, [])
}
})
test('split rows aggregate actual counts once and reconcile forecast', () => {
const a = [...row], b = [...row]; a[4] = 15; b[4] = 16
const result = normalizeRows([header, a, b]).orders[0]
assert.equal(result.packageCount, 31); assert.deepEqual(result.rows, [2, 3]); assert.deepEqual(result.problems, [])
b[4] = 15
assert.ok(normalizeRows([header, a, b]).orders[0].problems.includes('SPLIT_COUNT_MISMATCH'))
})
test('conflicting channels on same inbound code block import', () => {
const b = [...row]; b[2] = '其他包税'
assert.ok(normalizeRows([header, row, b]).orders[0].problems.includes('CONFLICT_渠道'))
})
test('channel suffix normalization never substitutes another base channel', () => {
assert.equal(channelName('合德海派包税\n(FedEx)'), '合德海派')
assert.equal(channelName('合德海卡不包税'), '合德海卡')
assert.throws(() => matchRoute({ ...order(), channelName: 'Unknown' }, catalog))
})
test('excluded-tax estimation uses source billing unit and never guesses missing units', () => {
const modified = [...row]; modified[2] = '合德海卡不包税'
const columns = [...header, '单位']
const result = normalizeRows([columns, [...modified, 'CBM']]).orders[0]
assert.equal(result.chargeMode, 'CBM'); assert.deepEqual(result.problems, [])
assert.ok(normalizeRows([columns, [...modified, '']]).orders[0].problems.includes('UNKNOWN_OR_CONFLICTING_CHARGE_MODE'))
})
test('ambiguous destinations and unavailable tax modes fail closed', () => {
assert.throws(() => matchRoute(order(), { ...catalog, destinations: [...catalog.destinations, ...catalog.destinations] }))
assert.throws(() => matchRoute({ ...order(), pricingTaxMode: 'TAX_EXCLUDED' }, catalog))
})
test('private addresses and dropship are explicit gaps, not random replacements', () => {
assert.throws(() => matchRoute({ ...order(), destinationType: 'PRIVATE' }, catalog), /PRIVATE_ADDRESS/)
assert.throws(() => matchRoute({ ...order(), fulfillmentType: 'ONE_CLICK_DROPSHIP' }, catalog), /WORK_PALLET/)
})
test('logical pallet count uses ceiling of boxes divided by 30', () => {
for (const [boxes, expected] of [[1, 1], [30, 1], [31, 2], [300, 10], [1000, 34]]) {
assert.equal(measurementShape({ ...order(), packageCount: boxes }).palletCount, expected)
}
})
test('inferred groups conserve volume after ADD and server per-carton rounding', () => {
const rules = [[30, 20, 20], [40, 30, 30], [50, 40, 40], [60, 50, 50]].map((dims, index) =>
({ boxType: String(index), innerLengthCm: dims[0], innerWidthCm: dims[1], innerHeightCm: dims[2], edgeAdditionCm: 2 }))
for (const [packageCount, volumeCbm] of [[8, 0.403], [17, 1.545], [7, 0.626], [31, 2], [300, 23.457], [1, 0.1], [1000, 98.7654]]) {
const plan = totalPreservingShapes({ ...order(), packageCount, volumeCbm }, rules)
assert.ok(plan.length <= 2)
assert.equal(plan.reduce((n, g) => n + g.count, 0), packageCount)
const ticks = plan.reduce((n, g) => {
const raw = Number(((g.length + 2 * g.edgeAdditionCm) ** 3 / 1e6).toFixed(6))
return n + Math.ceil(raw * 10000 - 1e-8) * g.count
}, 0)
assert.equal(ticks, Math.round(volumeCbm * 10000))
if (Number.isInteger(Math.round(volumeCbm * 1e6) / 1000)) {
const detailWeight = plan.reduce((sum, group) => sum + Math.ceil(group.unitVolumeCbm * group.count * 167 * 1000 - 1e-8), 0)
assert.equal(detailWeight, Math.ceil(volumeCbm * 167 * 1000 - 1e-8))
}
}
assert.throws(() => totalPreservingShapes({ ...order(), volumeCbm: 0.123456 }, rules), /PRECISION/)
})
test('source-only draft does not invent measurement or product evidence', () => {
const payload = buildSourceDraft(order(), matchRoute(order(), catalog), 'Example', 'MARKER')
assert.equal(payload.cargoes[0].packageCount, 31)
assert.equal(payload.cargoes[0].grossWeightKg, 100)
assert.equal(payload.cargoes[0].volumeCbm, 2)
assert.equal(payload.cargoes[0].woodenProduct, false)
for (const key of ['lengthCm', 'productImageFileId', 'hsCode', 'dangerousGoods']) assert.equal(key in payload.cargoes[0], false)
})
test('simulated payload is labeled, preserving source measures without randomization', () => {
const payload = buildPayload(order(), matchRoute(order(), catalog), 'Example', 'image', 'MARKER')
assert.match(payload.customerRemark, /非真实测量证据/)
assert.equal(payload.cargoes[0].volumeCbm, 2)
assert.match(payload.cargoes[0].cargoRemark, /禁止用于真实报关/)
})
test('HTTP business errors preserve diagnostic evidence', () => {
assert.throws(() => requireSuccess({ http: 200, path: '/example', body: { code: 'A0409' }, requestId: 'req' }),
(error) => error.evidence.code === 'A0409' && error.evidence.requestId === 'req')
})
test('same batch never recreates an attempted order, even when interrupted', async () => {
const value = order(), state = { identity: { batchId: 'test' }, orders: { [value.key]: { status: 'STARTED' } } }
await executeCandidates({ candidates: [{ order: value, route: catalog }], limit: 1, state, save: async () => {},
api: async () => { throw Error('must not call HTTP') } })
assert.equal(state.lastRun.attempted, 0); assert.equal(state.lastRun.skipped, 1)
})