This commit is contained in:
2026-10-08 17:07:57 +08:00
parent 16d2d9111a
commit c1ddb658bd
111 changed files with 2914 additions and 941 deletions
+7 -4
View File
@@ -70,7 +70,7 @@
对应本地数据库事务测试为 Firstleg `CarrierScheduleExceptionPersistenceTest`,使用真实 Service/Repository/MyBatis 和 H2,验证四类异常及解除的同事件重放、异摘要冲突、正常实际/预计节点整行不变,以及异常历史写入失败时事务回滚。它属于本地持久化验证,不代替 TEST MySQL 或真实供应商调度链验收。
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
`phase5-root-fulfillment.mjs` 是 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;TEST 与显式 `--environment gray` 的环境参数、授权和排除范围见脚本根 README。模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。海外 PDA 必须按本轮客户、柜次、来源入仓 ID 和入仓码确定实际唯一接柜仓,再核对 Warehouse 主档;不得按仓列表顺序选择另一仓。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
默认税制为 `BUY_DECLARATION`;编程调用显式设置 `AcceptanceRun.context.f40TaxMode='TAX_REFUND'` 可执行退税链。内部模块 `phase5-root-tax-customs.mjs` 在每个实装柜走客户上传、平台回执、客户确认、发送执行、人工正式申报与放行、完成文件,再进入尾程;订单最终完成后回查本客户资料并校验原件、回执、完成文件的实际下载字节和 SHA-256。该模块不能独立执行,不用 SQL 修改状态,不借历史资料;依赖相同 TEST 授权、当轮客户与只读取证器,失败及重跑保留各轮原始报告。直接合同为 `phase5-root-tax-customs-contract.test.mjs`,离线通过不代替实际 HTTP 或页面验收。
@@ -78,7 +78,7 @@
会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。
配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
配置仅来自显式指定的目标环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
@@ -263,12 +263,13 @@ export async function run(context) {
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
| 首页实时指标 | `dashboard.mjs` | `dashboard-contract.test.mjs`;默认只读核对实时柜量/清关指标、今日新运单及启用客户的服务端分页总数 |
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs`、`pda-picker-fixture-contract.test.mjs`(长场景名保留完整运行标识,避免造数账号跨轮重复) |
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
@@ -288,7 +289,7 @@ export async function run(context) {
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、下载中心 XLSX 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
@@ -426,3 +427,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { run, validOperationalMetrics } from './dashboard.mjs'
import { readFileSync } from 'node:fs'
const metrics = () => ({ inTransitContainerCount: '3', inTransitTeu: '4.5', unknownContainerTypeCount: '1', pendingCustomsOrderCount: '2', asOf: '2026-10-06T01:00:00' })
test('实时指标拒绝缺失、负数、非整数计数及未知箱型超出总柜数', () => {
assert.equal(validOperationalMetrics(metrics()), true)
for (const field of ['inTransitContainerCount', 'unknownContainerTypeCount', 'pendingCustomsOrderCount', 'inTransitTeu', 'asOf']) {
for (const value of [null, undefined, '', -1, 'invalid']) assert.equal(validOperationalMetrics({ ...metrics(), [field]: value }), false)
}
assert.equal(validOperationalMetrics({ ...metrics(), pendingCustomsOrderCount: 1.5 }), false)
assert.equal(validOperationalMetrics({ ...metrics(), unknownContainerTypeCount: 4 }), false)
})
const fake = () => {
const calls = []
return { calls, createdResources: {}, assert(_m, _n, _r, passed) { assert.equal(passed, true) },
apiSuccess(_m, _n, response) { return response.body.data },
async request(method, path) { calls.push({ method, path }); return { http: 200, body: { code: '200', data: path.includes('/operational-metrics') ? metrics() : { total: '9', records: [{}] } } } } }
}
test('默认编排实时首页三次只读查询,今日范围和启用客户筛选与页面一致', async () => {
const context = fake(); await run(context)
assert.equal(context.createdResources.dashboard.status, 'VERIFIED')
assert.equal(context.calls.length, 3)
assert.ok(context.calls.every(row => row.method === 'GET'))
const query = new URL(context.calls[1].path, 'https://offline.invalid').searchParams
assert.equal(query.get('createdFrom'), context.createdResources.dashboard.businessDate + 'T00:00:00.000')
assert.equal(query.get('createdTo'), context.createdResources.dashboard.businessDate + 'T23:59:59.999')
assert.equal(query.get('size'), '1')
assert.ok(context.calls[2].path.endsWith('status=ACTIVE'))
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'dashboard'/)
assert.doesNotMatch(entry, /首页为静态演示数据/)
})
test('非成功响应和缺失服务端总数不得被记为看板通过', async () => {
for (const response of [{ http: 200, body: { code: 'S0500', data: metrics() } }, { http: 200, body: { code: '200', data: { records: [] } } }]) {
const context = fake(); context.request = async () => response
await assert.rejects(run(context)); assert.equal(context.createdResources.dashboard.status, 'CHECKING')
}
})
@@ -0,0 +1,44 @@
const MODULE = 'dashboard'
const OPTIONS = { scenario: 'platform-live-dashboard', deploymentBlocker: true }
const integer = value => (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0
const decimal = value => (typeof value === 'number' || (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value)))
&& Number.isFinite(Number(value)) && Number(value) >= 0
export function validOperationalMetrics(value) {
return Boolean(value && ['inTransitContainerCount', 'unknownContainerTypeCount', 'pendingCustomsOrderCount']
.every(key => integer(value[key])) && decimal(value.inTransitTeu)
&& Number(value.unknownContainerTypeCount) <= Number(value.inTransitContainerCount)
&& typeof value.asOf === 'string' && Number.isFinite(Date.parse(value.asOf)))
}
export async function run(context) {
const proof = context.createdResources.dashboard = { status: 'CHECKING', readOnly: true }
const check = (label, response, condition) => {
context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS)
if (!condition) throw Error(label)
}
const read = async (path, label) => {
const response = await context.request('GET', path)
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
check(`${label}正式HTTP成功`, response, response.http === 200 && String(response.body?.code) === '200' && data != null)
return { response, data }
}
const metrics = await read('/api/platform/v1/first-leg/dashboard/operational-metrics', '读取首页实时运营指标')
check('在途柜量与待清关指标完整且非负,不以缺失值冒充零', metrics.response, validOperationalMetrics(metrics.data))
proof.operationalMetrics = metrics.data
// The deployed TEST services and the desktop page use the Shanghai business day.
const date = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10)
const query = new URLSearchParams({ createdFrom: `${date}T00:00:00.000`, createdTo: `${date}T23:59:59.999`, page: '1', size: '1' })
for (const [key, path, label] of [
['todayNewOrderCount', `/api/platform/v1/orders?${query}`, '读取今日新运单总数'],
['activeCustomerCount', '/api/platform/v1/iam/tenants?page=1&size=1&status=ACTIVE', '读取启用客户总数'],
]) {
const { response, data } = await read(path, label)
check(`${label}使用服务端分页总数`, response, integer(data.total) && Array.isArray(data.records)
&& data.records.length <= 1 && Number(data.total) >= data.records.length)
proof[key] = data.total
}
proof.businessDate = date
proof.status = 'VERIFIED'
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { verifyDownloadCenterExport } from './download-center.mjs'
import { exportWorkbook } from './download-center-fixture.mjs'
function fixture(status = 'SUCCEEDED', values = ['LM-1']) {
const calls = []
return {
marker: 'export-test', calls,
async request(method, path, body) {
calls.push({ method, path, body })
return { http: 200, body: { code: '200', data: { id: 'EXP-1', status: method === 'POST' ? 'PENDING' : status } } }
},
apiSuccess(_module, _name, response) { return response.body.data },
assert(_module, name, _response, passed) { assert.ok(passed, name) },
async requestBinary(method, path) {
calls.push({ method, path })
return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: exportWorkbook(['尾程单号'], values) }
},
}
}
test('creates, polls and validates the actual XLSX through File', async () => {
const context = fixture()
await verifyDownloadCenterExport(context, 'orders', 'LAST_MILE_ORDER_LIST', { orderNo: 'LM-1' }, ['尾程单号'], ['LM-1'])
assert.deepEqual(context.calls[0].body.parameters, { orderNo: 'LM-1' })
assert.equal(context.calls[0].body.format, 'XLSX')
assert.ok(context.calls.at(-1).path.endsWith('/EXP-1/download'))
})
test('failed generation cannot be reported as a successful download', async () => {
const context = fixture('FAILED')
await assert.rejects(verifyDownloadCenterExport(context, 'orders', 'LAST_MILE_ORDER_LIST', {}, ['尾程单号']))
assert.equal(context.calls.some(call => call.path.endsWith('/download')), false)
})
test('a file missing this run business data fails acceptance', async () => {
await assert.rejects(verifyDownloadCenterExport(fixture('SUCCEEDED', ['OTHER']), 'orders', 'LAST_MILE_ORDER_LIST', {}, ['尾程单号'], ['LM-1']))
})
@@ -0,0 +1,18 @@
// Offline XLSX fixture used only by download-center contract tests.
export function exportWorkbook(headers, values = []) {
const escape = value => String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const rows = [headers, values].map((row, index) => `<row r="${index + 1}">${row.map((value, col) => {
const letter = String.fromCharCode(65 + col)
return `<c r="${letter}${index + 1}" t="inlineStr"><is><t>${escape(value)}</t></is></c>`
}).join('')}</row>`).join('')
const name = Buffer.from('xl/worksheets/sheet1.xml')
const body = Buffer.from(`<worksheet><sheetData>${rows}</sheetData></worksheet>`)
const local = Buffer.alloc(30)
local.writeUInt32LE(0x04034b50); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(body.length, 22); local.writeUInt16LE(name.length, 26)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50); central.writeUInt32LE(body.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(name.length, 28)
const end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10)
end.writeUInt32LE(central.length + name.length, 12); end.writeUInt32LE(local.length + name.length + body.length, 16)
return Buffer.concat([local, name, body, central, name, end])
}
@@ -0,0 +1,31 @@
import { parseXlsx } from './finance-downloads.mjs'
// Internal acceptance helper; callers retain their own fixture and deployment authorization.
export async function verifyDownloadCenterExport(context, module, exportType, parameters, headers, tokens = [], expectedStatus = 'SUCCEEDED') {
const options = { scenario: 'download-center-export', deploymentBlocker: true }
const base = '/api/platform/v1/files/export-tasks'
const created = await context.request('POST', base, { exportType, format: 'XLSX', taskName: `${context.marker}-${exportType}`, parameters })
const task = context.apiSuccess(module, `${exportType}创建下载任务`, created, '', options)
if (!task?.id) throw new Error('下载任务创建失败')
let state = task
for (let attempt = 0; attempt < 45 && !['SUCCEEDED', 'FAILED', 'EXPIRED'].includes(state.status); attempt++) {
if (attempt) await new Promise(resolve => setTimeout(resolve, 2000))
const response = await context.request('GET', `${base}/${encodeURIComponent(String(task.id))}`)
state = context.apiSuccess(module, `${exportType}查询下载任务`, response, '', options)
if (!state) throw new Error('下载任务查询失败')
}
context.assert(module, `${exportType}后台生成完成`, created, state.status === expectedStatus, state.failureMessage || state.status, options)
if (expectedStatus === 'FAILED' && state.status === 'FAILED') {
context.assert(module, `${exportType}失败原因可见`, created, Boolean(state.failureMessage), state.failureMessage || '', options)
return created
}
if (state.status !== 'SUCCEEDED') throw new Error(`导出未完成:${state.status}`)
const response = await context.requestBinary('GET', `${base}/${encodeURIComponent(String(task.id))}/download`)
context.assert(module, `${exportType}下载XLSX`, response, response.http === 200 && String(response.contentType).includes('spreadsheetml'), `bytes=${response.bytes?.length || 0}`, options)
const workbook = parseXlsx(response.bytes)
const content = workbook.rows.flat().join('|')
const valid = headers.every(header => workbook.headers.includes(header)) && tokens.every(token => content.includes(String(token)))
context.assert(module, `${exportType}表头与marker内容`, response, valid, `headers=${workbook.headers.join('|')}`, options)
if (!valid) throw new Error('导出文件缺少预期字段或本轮业务数据')
return response
}
@@ -1,6 +1,8 @@
import { exportWorkbook } from './download-center-fixture.mjs'
import { parseXlsx } from './finance-downloads.mjs'
import assert from 'node:assert/strict'
import test from 'node:test'
import { run, runQuoteSession, verifyOperations, requireCurrentFixture, readCsv, fixtureContract } from './fleet-quotes-operations.mjs'
import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureContract } from './fleet-quotes-operations.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const copy = value => structuredClone(value)
@@ -10,6 +12,7 @@ function harness(fault) {
let next = 0, supplierStatus = 'ACTIVE'
let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] } }
const measuredHistory = []
const exportTasks = new Map()
const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] }
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' })
function snapshot(row, action) { histories.get(row.quoteNo).unshift({ action, recordVersion: row.recordVersion, snapshot: copy(row) }) }
@@ -31,6 +34,16 @@ function harness(fault) {
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
async request(method, path, body) {
calls.push({ method, path, body: copy(body) }); const u = new URL(path, 'http://offline.invalid'), p = u.pathname
if (p === '/api/platform/v1/files/export-tasks' && method === 'POST') {
const taskId = String(exportTasks.size + 1)
exportTasks.set(taskId, body)
return response({ id: taskId, status: 'PENDING' })
}
if (p.startsWith('/api/platform/v1/files/export-tasks/')) {
const task = exportTasks.get(p.split('/').at(-1))
const failed = task.parameters.kind === 'INVALID' || (task.parameters.from && Date.parse(task.parameters.from) > Date.parse(task.parameters.to))
return response({ id: p.split('/').at(-1), status: failed ? 'FAILED' : 'SUCCEEDED', failureMessage: failed ? '筛选参数无效' : null })
}
if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } })
if (p === '/api/platform/v1/suppliers' && method === 'POST') {
for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field)
@@ -90,11 +103,11 @@ function harness(fault) {
},
async requestBinary(method, path) {
calls.push({ method, path }); const u = new URL(path, 'http://offline.invalid')
const error = response(null, 'A0400')
if (u.pathname.includes('/quotes/') && u.searchParams.get('size') === '0') { const invalid = response(null, 'A0422'); return { ...invalid, contentType: 'application/json', bytes: Buffer.from(JSON.stringify(invalid.body)) } }
if (fault === 'json export masquerades as csv' || (u.searchParams.has('from') && Date.parse(u.searchParams.get('from')) > Date.parse(u.searchParams.get('to')))) return { ...error, contentType: fault ? 'text/csv' : 'application/json', bytes: Buffer.from(JSON.stringify(error.body)) }
const text = u.pathname.includes('/quotes/') ? `\uFEFF报价号,金额\r\n${[...rows.keys()].join('\r\n')}` : `\uFEFF车次号,实际里程(mile)\r\n${tripNo},25.5`
return { http: 200, body: null, bytes: Buffer.from(text), contentType: 'text/csv;charset=UTF-8', requestId: 'offline-request', traceId: 'offline-trace' }
assert.match(u.pathname, /files\/export-tasks\/[^/]+\/download$/)
if (fault === 'json export masquerades as csv') return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: Buffer.from('{"code":"A0400"}') }
const task = exportTasks.get(u.pathname.split('/').at(-2))
const bytes = task.exportType === 'FLEET_QUOTE_LIST' ? exportWorkbook(['报价号'], [[...rows.keys()].join(' / ')]) : exportWorkbook(['车次号', '实际里程(mile)'], [tripNo, '25.5'])
return { http: 200, body: null, bytes, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'offline-request', traceId: 'offline-trace' }
},
}
return { context, rows, calls, supplierStatus: () => supplierStatus }
@@ -127,7 +140,7 @@ test('missing or stale fixture fails before any HTTP and cannot be treated as sk
test('proof cannot be overwritten on rerun and CSV detector rejects misleading success content', async () => {
const { context } = harness(); await run(context); const proof = context.createdResources.fleetQuoteAcceptance
await assert.rejects(run(context), /Existing proof/); assert.equal(context.createdResources.fleetQuoteAcceptance, proof)
assert.throws(() => readCsv({ http: 200, contentType: 'text/csv', bytes: Buffer.from('{"code":"A0403"}') }, '报价号'), /not a valid CSV/)
assert.throws(() => parseXlsx(Buffer.from('{"code":"A0403"}')), /ZIP/)
assert.throws(() => requireCurrentFixture({ marker: 'x' }), /current-run/)
})
test('unknown expected money is rejected before HTTP instead of comparing as zero', async () => {
@@ -141,10 +154,10 @@ test('quote acceptance is independent of DEPART and never requests operations, i
assert.equal(context.createdResources.fleetQuoteAcceptance.operationsStatus, 'NOT_TESTED')
assert.ok(calls.every(call => !/operations|inventory|finance|\/trips\//.test(call.path)))
})
test('operations defaults to read-only and never creates customer, supplier or quote records', async () => {
test('operations only reads business data and creates download tasks, never customer, supplier or quote records', async () => {
const { context, calls, rows } = harness(); delete context.createdResources.merchantQualificationFixture
await verifyOperations(context)
assert.ok(calls.every(call => call.method === 'GET')); assert.equal(rows.size, 0)
assert.ok(calls.every(call => call.method === 'GET' || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks' && call.body.exportType === 'TRIP_OPERATIONS_LIST'))); assert.equal(rows.size, 0)
assert.equal(context.createdResources.fleetOperationsAcceptance.measuredStatus, 'NOT_TESTED')
})
test('measured writes require an explicit independent idempotency namespace', async () => {
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
import { createHash } from 'node:crypto'
import { expectedAllocations } from './fleet-trip-cost-acceptance.mjs'
@@ -116,14 +117,6 @@ function requireQuoteCustomer(context) {
return customer
}
export function readCsv(response, requiredHeader) {
const text = new TextDecoder().decode(response?.bytes || new Uint8Array()).replace(/^\uFEFF/, '')
if (response?.http !== 200 || /json/i.test(response?.contentType || '') || /^\s*[{[]/.test(text)
|| !/^text\/csv(?:;|$)/i.test(response?.contentType || '') || !text.split(/\r?\n/, 1)[0].includes(requiredHeader)) {
throw new Error(`Export is not a valid CSV: HTTP=${response?.http}, code=${response?.body?.code || 'n/a'}, requestId=${response?.requestId || 'n/a'}, traceId=${response?.traceId || 'n/a'}`)
}
return text
}
const currency = value => { if (!['CNY', 'USD'].includes(value)) throw new Error('Unsupported or missing currency fact'); return value }
const amounts = rows => rows.map(row => [currency(row.currency), number(row.amount)]).sort((a, b) => a[0].localeCompare(b[0]))
const costs = rows => rows.map(row => [currency(row.currency), number(row.directCost), row.ownCost == null ? null : number(row.ownCost), row.externalCost == null ? null : number(row.externalCost)]).sort((a, b) => a[0].localeCompare(b[0]))
@@ -197,6 +190,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
check(label, response, response.http === 200 && id(response.body?.code) === expectedCode && response.requestId && response.traceId)
}
async function binary(path) { const response = await context.requestBinary('GET', path); recordResponse('GET', path, response); return response }
const exportContext = Object.assign(Object.create(context), { request, requestBinary: (_method, path) => binary(path) })
function retain(row) {
check('报价返回稳定业务号', null, typeof row?.quoteNo === 'string' && row.quoteNo.length > 0)
if (!proof.quotes.includes(row.quoteNo)) proof.quotes.push(row.quoteNo)
@@ -281,13 +275,8 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
check('用途独立状态未串改', null, stillPurchase.status === 'ACTIVE' && id(stillPurchase.recordVersion) === id(purchase.recordVersion))
const history = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}/history`, undefined, '读取报价不可变版本历史')
check('历史保留原始零价与更正版本', null, history.length === 3 && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 12.5) && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 15))
const quoteCsv = await binary(`${QUOTES}/export?${query({ keyword: details.originCity })}`)
const quoteText = readCsv(quoteCsv, '报价号')
check('报价CSV为真实文件并包含本轮业务号', quoteCsv, quoteText.includes(sale.quoteNo) && quoteText.includes(purchase.quoteNo))
const error = await binary(`${QUOTES}/export?size=0`)
check('报价导出参数错误保留业务码', error, error.http === 200 && id(error.body?.code) === 'A0422')
let rejected = false; try { readCsv(error, '报价号') } catch { rejected = true }
check('报价下载拒绝JSON错误文件', error, rejected)
await verifyDownloadCenterExport(exportContext, MODULE, 'FLEET_QUOTE_LIST', { keyword: details.originCity }, ['报价号'], [sale.quoteNo, purchase.quoteNo])
await verifyDownloadCenterExport(exportContext, MODULE, 'FLEET_QUOTE_LIST', { kind: 'INVALID' }, [], [], 'FAILED')
proof.quoteStatus = 'VERIFIED'
}
if (scope === 'OPERATIONS_ONLY') {
@@ -308,14 +297,8 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
}
const finalReport = await report()
if (writeMeasured) check('运营列表批量回显最新实测记录', null, Number(finalReport.page.records[0].measured?.actualDistanceMiles) === 25.5 && Number(finalReport.page.records[0].measured?.actualFuelLitres) === 8.2)
const exported = await binary(`${OPERATIONS}/export?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo })}`)
const operationsCsv = readCsv(exported, '实际里程(mile)')
check('运营CSV包含本轮车次及实际里程字段', exported, operationsCsv.includes(fixture.tripNo) && (!writeMeasured || operationsCsv.includes('25.5')))
const exportError = await binary(`${OPERATIONS}/export?${query({ from: to, to: from })}`)
check('错误导出保留标准业务错误而非CSV成功', exportError, exportError.http === 200 && id(exportError.body?.code) === 'A0400')
let rejected = false
try { readCsv(exportError, '车次号') } catch { rejected = true }
check('下载检查器拒绝JSON错误响应', exportError, rejected)
await verifyDownloadCenterExport(exportContext, MODULE, 'TRIP_OPERATIONS_LIST', { from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo }, ['车次号', '实际里程(mile)'], [fixture.tripNo, ...(writeMeasured ? ['25.5'] : [])])
await verifyDownloadCenterExport(exportContext, MODULE, 'TRIP_OPERATIONS_LIST', { from: to, to: from }, [], [], 'FAILED')
proof.operationsStatus = 'VERIFIED'
}
proof.status = 'VERIFIED'
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { moneyCents, validateTripFinance, verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
const scope = { tripNo: 'TR-CURRENT', dispatchNos: ['DS-CURRENT'], receiptsVisible: true,
businesses: [{ customerId: '9007199254740993', deliveryNo: 'DL-CURRENT' }] }
test('default fulfillment runs finance verification using its own departed trip and delivery numbers', () => {
const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8')
assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/)
assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/)
assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/)
})
function fixture(status = 'AVAILABLE', received = null) {
return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true,
attributionStatus: 'ATTRIBUTED', attributionReason: '唯一发车来源', finance: {
status: 'AVAILABLE', reason: '有效财务来源',
sources: [{ customerId: scope.businesses[0].customerId, businessNo: 'DL-CURRENT', sourceNo: 'FS-CURRENT',
sourceVersion: 1, status, currency: 'USD', amount: '100.03' }],
totals: [{ currency: 'USD', receivableAmount: '100.03', receivedAmount: received }],
documents: received === null ? [] : [{ billNo: 'BILL-CURRENT', currency: 'USD', receivedAmount: received,
allocationStatus: 'ATTRIBUTED', reason: '已确认收款分配' }],
} }
}
test('decimal amounts use integer cents and refuse unknown or unsafe precision', () => {
assert.equal(moneyCents('100.03'), 10003n)
assert.equal(moneyCents('9007199254740993.00'), 900719925474099300n)
for (const value of [null, undefined, '', -1, NaN, Infinity, '1.005', '1e3', 9007199254740993]) {
assert.throws(() => moneyCents(value))
}
})
test('available, billed and settled sources survive partial and full payment readback', () => {
for (const [state, paid] of [['AVAILABLE', null], ['BILLED', '30.01'], ['SETTLED', '100.03']]) {
assert.equal(validateTripFinance(fixture(state, paid), scope), true)
}
})
test('currencies are reconciled separately without floating point addition', () => {
const value = fixture()
value.finance.sources.push({ ...value.finance.sources[0], sourceNo: 'FS-CNY', currency: 'CNY', amount: '0.20' })
value.finance.totals.push({ currency: 'CNY', receivableAmount: '0.20', receivedAmount: null })
assert.equal(validateTripFinance(value, scope), true)
value.finance.totals[0].receivableAmount = '100.23'
assert.throws(() => validateTripFinance(value, scope), /原币/)
})
test('pending allocation is explicitly unknown and never promoted to complete or zero', () => {
const value = fixture('BILLED')
value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null,
allocationStatus: 'PENDING_ALLOCATION', reason: '账单含本车次以外来源' }]
assert.equal(validateTripFinance(value, scope), true)
value.finance.totals[0].receivedAmount = 0
assert.throws(() => validateTripFinance(value, scope), /完整实收/)
const empty = fixture()
Object.assign(empty.finance, { sources: [], totals: [], documents: [], status: 'PENDING_ALLOCATION' })
assert.equal(validateTripFinance(empty, scope), true)
empty.finance.status = 'AVAILABLE'
assert.throws(() => validateTripFinance(empty, scope))
})
test('receipt restricted projection cannot leak totals or documents', () => {
const value = fixture('BILLED'); value.receiptsVisible = false
const restricted = { ...scope, receiptsVisible: false }
assert.equal(validateTripFinance(value, restricted), true)
value.finance.totals[0].receivedAmount = '30.01'
assert.throws(() => validateTripFinance(value, restricted), /泄露金额/)
value.finance.totals[0].receivedAmount = null
value.finance.documents = fixture('BILLED', '30.01').finance.documents
assert.throws(() => validateTripFinance(value, restricted), /泄露收款/)
})
test('NON_NULL省略未知实收仍保持未知,明确归属的实收和应收不得缺失', () => {
const withoutNulls = value => JSON.parse(JSON.stringify(value, (_key, item) => item === null ? undefined : item))
assert.equal(validateTripFinance(withoutNulls(fixture()), scope), true)
const restricted = withoutNulls(fixture()); restricted.receiptsVisible = false
assert.equal(validateTripFinance(restricted, { ...scope, receiptsVisible: false }), true)
const pending = fixture('BILLED')
pending.finance.documents = [{ billNo: 'BILL-MIXED', currency: 'USD', receivedAmount: null, allocationStatus: 'PENDING_ALLOCATION', reason: '归属未完成' }]
assert.equal(validateTripFinance(withoutNulls(pending), scope), true)
const missingPaid = fixture('BILLED', '10.00'); delete missingPaid.finance.documents[0].receivedAmount
assert.throws(() => validateTripFinance(missingPaid, scope))
const missingAmount = fixture(); delete missingAmount.finance.sources[0].amount
assert.throws(() => validateTripFinance(missingAmount, scope))
})
test('wrong customer, trip, duplicate sources and invented receipts all reject', () => {
const mutations = [v => { v.tripNo = 'TR-FOREIGN' }, v => { v.dispatchNos = ['DS-FOREIGN'] },
v => { v.finance.sources[0].customerId = '9007199254740994' },
v => { v.finance.sources[0].businessNo = 'DL-FOREIGN' },
v => { v.finance.sources.push({ ...v.finance.sources[0] }) },
v => { v.finance.sources[0].status = 'VOIDED' }, v => { v.finance.sources[0].sourceVersion = 0 },
v => { v.finance.totals[0].receivedAmount = 25 },
v => { v.finance.totals.push({ ...v.finance.totals[0] }) }]
for (const mutate of mutations) { const value = fixture(); mutate(value); assert.throws(() => validateTripFinance(value, scope)) }
})
test('read-only orchestration retains proof and detects execution state mutation or HTTP failure', async () => {
for (const fault of ['', 'mutation', 'http']) {
const calls = [], checks = []; let reads = 0
const context = { marker: 'CURRENT', createdResources: {},
assert: (_m, _n, _r, passed) => checks.push(passed), apiSuccess: (_m, _n, r) => r.body.data,
request: async (method, path) => {
calls.push({ method, path })
if (path.endsWith('/finance')) return { http: fault === 'http' ? 503 : 200, body: { code: '200', data: fixture() } }
return { http: 200, body: { code: '200', data: { trip: { tripNo: scope.tripNo,
departedAt: '2026-10-05T00:00:00Z', recordVersion: ++reads === 2 && fault === 'mutation' ? 2 : 1 } } } }
} }
if (fault) await assert.rejects(verifyFleetTripFinance(context, 'offline', scope))
else assert.equal((await verifyFleetTripFinance(context, 'offline', scope)).status, 'READ_ONLY_ATTRIBUTION_VERIFIED')
assert.ok(calls.every(row => row.method === 'GET'))
assert.equal(context.createdResources.fleetTripFinance[0].status,
fault ? 'CHECKING' : 'READ_ONLY_ATTRIBUTION_VERIFIED')
}
})
@@ -0,0 +1,102 @@
const BASE = '/api/platform/v1/last-leg/fleet'
const OPTIONS = { scenario: 'fleet-trip-finance-attribution', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const sameSet = (a, b) => a.length === new Set(a).size && b.length === new Set(b).size
&& JSON.stringify([...a].sort()) === JSON.stringify([...b].sort())
export function moneyCents(value) {
if (!['number', 'string'].includes(typeof value)) throw Error('金额必须是明确数值')
const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(String(value))
if (!match || (typeof value === 'number' && !Number.isSafeInteger(value * 100)
&& Math.abs(value * 100 - Math.round(value * 100)) > 0.000001)) throw Error('金额格式或精度无效')
const cents = BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
if (typeof value === 'number' && cents > BigInt(Number.MAX_SAFE_INTEGER)) throw Error('金额超过安全精度')
return cents
}
// A pending amount stays unknown. This does not use agreed booking prices as paid amounts.
export function validateTripFinance(value, scope) {
const fail = message => { throw Error(`车次财务证据:${message}`) }
if (value?.tripNo !== scope.tripNo || value.receiptsVisible !== scope.receiptsVisible
|| !Array.isArray(value.dispatchNos) || !sameSet(value.dispatchNos, scope.dispatchNos)) fail('车次或权限范围漂移')
if (!['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(value.attributionStatus)
|| typeof value.attributionReason !== 'string' || !value.attributionReason.trim()) fail('缺少业务归属判定')
const finance = value.finance
if (!['AVAILABLE', 'PENDING_ALLOCATION'].includes(finance?.status)
|| !Array.isArray(finance.sources) || !Array.isArray(finance.totals) || !Array.isArray(finance.documents)
|| typeof finance.reason !== 'string' || !finance.reason.trim()) fail('财务结果不完整')
const sourceKeys = new Set(), amounts = new Map(), covered = new Set()
for (const source of finance.sources) {
const business = `${id(source.customerId)}:${source.businessNo}`
if (!scope.businesses.some(row => `${row.customerId}:${row.deliveryNo}` === business)) fail('费用混入其他客户或派送')
const key = `${business}:${source.sourceNo}`
if (typeof source.sourceNo !== 'string' || !source.sourceNo.trim() || typeof source.customerId !== 'string'
|| !/^[1-9]\d*$/.test(source.customerId) || sourceKeys.has(key) || !Number.isSafeInteger(source.sourceVersion) || source.sourceVersion < 1
|| !['AVAILABLE', 'BILLED', 'SETTLED'].includes(source.status)) fail('费用重复、版本或生命周期无效')
if (!/^[A-Z]{3}$/.test(source.currency)) fail('费用币种无效')
sourceKeys.add(key); covered.add(business)
amounts.set(source.currency, (amounts.get(source.currency) || 0n) + moneyCents(source.amount))
}
if (finance.status === 'AVAILABLE' && scope.businesses.some(row => !covered.has(`${row.customerId}:${row.deliveryNo}`))) fail('缺失来源却声称应收完整')
if (!finance.sources.length && finance.status !== 'PENDING_ALLOCATION') fail('空来源不能冒充完整金额')
const totals = new Map()
for (const total of finance.totals) {
if (totals.has(total.currency) || !amounts.has(total.currency)
|| moneyCents(total.receivableAmount) !== amounts.get(total.currency)) fail('应收未按原币来源核平')
if (!scope.receiptsVisible && total.receivedAmount != null) fail('无实收权限泄露金额')
if (total.receivedAmount != null) moneyCents(total.receivedAmount)
totals.set(total.currency, total)
}
if (totals.size !== amounts.size) fail('币种汇总缺失')
if (!scope.receiptsVisible && finance.documents.length) fail('无实收权限泄露收款来源')
const documents = new Map(), paid = new Map(), pending = new Set()
for (const document of finance.documents) {
if (!document.billNo || documents.has(document.billNo) || !totals.has(document.currency)
|| !['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(document.allocationStatus)) fail('收款单据归属或去重无效')
documents.set(document.billNo, document)
if (document.allocationStatus === 'PENDING_ALLOCATION') {
if (document.receivedAmount != null || !document.reason?.trim()) fail('未分配金额必须保留未知值及原因')
pending.add(document.currency)
} else {
paid.set(document.currency, (paid.get(document.currency) || 0n) + moneyCents(document.receivedAmount))
}
}
for (const [currency, total] of totals) {
if (pending.has(currency) && total.receivedAmount != null) fail('混合未分配来源却给出完整实收')
if (total.receivedAmount != null && (!paid.has(currency) || moneyCents(total.receivedAmount) !== paid.get(currency))) fail('实收与明确归属单据不一致')
}
return true
}
export async function verifyFleetTripFinance(context, module, scope) {
if (!scope?.tripNo || !scope.dispatchNos?.length || !scope.businesses?.length
|| scope.dispatchNos.some(no => !no) || scope.businesses.some(row => !/^[1-9]\d*$/.test(row.customerId) || !row.deliveryNo)) {
throw Error('车次财务检查缺少本轮实际发车及派送来源')
}
const proof = { marker: context.marker, tripNo: scope.tripNo, status: 'CHECKING', receiptsVisible: scope.receiptsVisible,
boundaries: '只读归属和原币核平;无账单/收款样本时不声称已验证部分及全额收款生命周期' }
context.createdResources.fleetTripFinance ??= []
context.createdResources.fleetTripFinance.push(proof)
const read = async (path, label) => {
const response = await context.request('GET', path)
const value = context.apiSuccess(module, label, response, '', OPTIONS)
if (response?.http !== 200 || String(response.body?.code) !== '200' || !value) throw Error(`${label}失败`)
return { response, value }
}
const path = `${BASE}/trips/${encodeURIComponent(scope.tripNo)}`
const before = await read(path, '财务查询前冻结本轮车次执行状态')
if (before.value.trip?.tripNo !== scope.tripNo || !before.value.trip.departedAt) throw Error('本轮车次尚无实际发车事实')
const result = await read(`${BASE}/operations/${encodeURIComponent(scope.tripNo)}/finance`, '按本轮实际车次查询Finance应收与实收')
proof.result = result.value
try { validateTripFinance(result.value, scope) }
catch (error) { context.assert(module, '车次财务来源、权限及金额严格核平', result.response, false, error.message, OPTIONS); throw error }
context.assert(module, '车次财务来源、权限及金额严格核平', result.response, true,
`sources=${result.value.finance.sources.length}, currencies=${result.value.finance.totals.length}, attribution=${result.value.attributionStatus}`, OPTIONS)
const after = await read(path, '财务查询后回查车次零写入')
const unchanged = JSON.stringify(before.value) === JSON.stringify(after.value)
context.assert(module, '只读财务查询不改变车次、资源与审核', after.response, unchanged, `tripNo=${scope.tripNo}`, OPTIONS)
if (!unchanged) throw Error('只读财务查询改变车次事实')
proof.result = result.value
proof.status = 'READ_ONLY_ATTRIBUTION_VERIFIED'
return proof
}
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`,
overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n }))
const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] }))
const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId,
dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1,
packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0,
items: [{ ...candidates[n], id: `7${n}` }] } }))
test('registered dispatch flow consumes current-run selected candidates before its main draft', () => {
const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8')
const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)')
assert.ok(call > source.indexOf('const selectedCandidates'))
assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`'))
})
test('two loading groups preserve distinct dispatches and exact physical sources', () => {
assert.equal(groupedDispatchMatches(results(), groups, candidates), true)
assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true)
for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo },
r => { r[1].draftContainerId = r[0].draftContainerId },
r => { r[0].dispatch.items = r[1].dispatch.items },
r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' },
r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) {
const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false)
}
})
function server(fault = '') {
let draft, next = 0, arranged = false
const calls = [], checks = [], orders = new Map()
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' })
const context = { marker: 'CURRENT', createdResources: {},
assert: (_m, label, _r, passed) => checks.push({ label, passed }),
async request(method, path, body) {
calls.push({ method, path, body: structuredClone(body) })
if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) }
if (path.endsWith('/drafts/containers')) {
assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity')
const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity }
draft.containers.push(container); return response(container)
}
if (path.endsWith('/draft-locks/lock')) {
draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null)
}
if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : []))
if (path.endsWith('/drafts/pre-arrange')) {
if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) {
if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = []
return response(null, 'A0409')
}
if (fault === 'unknown-post') return { http: 0, body: null }
if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true }
const result = groups.map(group => ({ draftContainerId: group.draftContainerId,
dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) }))
if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo
return response(result)
}
if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) }
if (path.includes('/candidates?')) return response(candidates)
const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/)
const order = orders.get(match?.[1]); assert.ok(order, path)
if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) }
return response(structuredClone(order))
} }
return { context, calls, checks }
}
test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => {
const { context, calls, checks } = server()
const proof = await verifyGroupedDispatch(context, 'offline', candidates)
assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED')
assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED')
assert.equal(proof.results.length, 2)
assert.ok(checks.every(row => row.passed))
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3)
assert.equal(calls.filter(row => row.method === 'DELETE').length, 2)
assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path)))
})
test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => {
const { context, calls } = server('reject-mutates-draft')
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/)
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1)
assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED')
})
test('unknown successful-write transport result is not automatically retried or deleted', async () => {
const { context, calls } = server('unknown-post')
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/)
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2)
assert.equal(calls.filter(row => row.method === 'DELETE').length, 0)
assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0)
assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW')
})
@@ -0,0 +1,124 @@
const BASE = '/api/platform/v1/last-leg/dispatches'
const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const ok = response => response?.http === 200 && String(response.body?.code) === '200'
const sorted = values => [...values].map(id).sort()
export function groupedDispatchMatches(result, groups, candidates) {
if (!Array.isArray(result) || result.length !== groups.length) return false
const dispatchNos = new Set(), seen = new Set()
return result.every(row => {
const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId))
const dispatch = row.dispatch
if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo)
|| dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false
seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo)
const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId)))
if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds))
|| dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length
|| Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false
return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId)
&& id(candidate.warehousePalletId) === id(item.warehousePalletId)
&& id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId)
&& candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount)))
})
}
// Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence.
export async function verifyGroupedDispatch(context, module, candidates) {
if (!Array.isArray(candidates) || candidates.length < 2
|| new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length
|| new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length
|| candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId']
.some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key]))
|| typeof row.inboundCode !== 'string' || !row.inboundCode.trim()
|| !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) {
throw Error('分组预排缺少本轮两个独立实物托盘')
}
const draftToken = `${context.marker}-GROUPED`.slice(0, 64)
const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' }
context.createdResources.groupedDispatch = proof
const check = (label, response, passed) => {
context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS)
if (!passed) throw Error(`分组预排:${label}`)
}
const request = async (method, path, body) => {
let response
try { response = await context.request(method, path, body) }
catch (error) {
if (!['GET', 'HEAD'].includes(method)) {
proof.outcomeUnknown = true
proof.cleanup = 'PRESERVED_FOR_REVIEW'
}
throw error
}
if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) {
proof.outcomeUnknown = true
proof.cleanup = 'PRESERVED_FOR_REVIEW'
}
return response
}
const command = async (method, path, body, label) => {
const response = await request(method, path, body)
check(label, response, ok(response)); return response.body.data
}
const ownDraft = async () => {
const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域')
if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效')
return rows.filter(row => row.draftToken === draftToken)
}
try {
await command('POST', `${BASE}/drafts/snapshot`, { draftToken,
planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿')
for (const candidate of candidates) {
const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组')
if (!container?.id) throw Error('分组缺少服务端柜ID')
const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] }
proof.groups.push(group)
await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId,
planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组')
}
const before = JSON.stringify(await ownDraft())
const swapped = proof.groups.map((group, index) => ({ ...group,
planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds }))
const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped })
check('来源与分组不一致明确拒绝A0409', rejected,
[200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409')
check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before)
const body = { draftToken, groups: proof.groups }
const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车')
if (Array.isArray(result)) proof.results = result
check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates))
const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放')
check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result))
proof.status = 'GROUPED_PREARRANGE_CHECKED'
} finally {
// Only release resources whose successful response identified an owned dispatch; never retry an unknown POST.
if (!proof.outcomeUnknown) {
for (const row of proof.results) {
const dispatchNo = row.dispatch?.dispatchNo
if (!dispatchNo) continue
const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货')
check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE'
&& groupedDispatchMatches([{ ...row, dispatch: detail }],
proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates)
&& detail.items.every(item => /^[1-9]\d*$/.test(id(item.id)))
&& Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0)
for (const item of detail.items) {
await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`,
undefined, '移除分组测试明细释放正式托盘锁')
}
const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零')
check('分组对照排车释放后数量归零', null,
empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0)
}
await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿')
const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排')
check('分组验收来源回到正式候选', null, Array.isArray(available)
&& candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId))))
proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED'
}
}
proof.status = 'GROUPED_PREARRANGE_VERIFIED'
return proof
}
@@ -111,6 +111,8 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem
const matrix = { pages: {
inbounds: { requiredStatuses: ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: {
WAIT_MEASURE: { id: '1' },
FEE_CONFIRMING: { id: '5' },
WAIT_PRICING: { id: '6' },
} },
unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: {
WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' },
@@ -121,9 +123,11 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem
assert.deepEqual(matrix.pages.inbounds.supportedStatuses,
['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'])
assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE'])
assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING'])
assert.equal(matrix.pages.inbounds.coverageGaps.FEE_CONFIRMING, undefined)
assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'])
assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable')
assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING, undefined)
assert.equal(matrix.pages.inbounds.coverageGaps.INBOUNDING.category, 'fixture-unavailable')
assert.equal(matrix.pages.unifiedOrders.coverageGaps, undefined)
assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:DELIVERED'])
assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/)
@@ -10,9 +10,7 @@ const GROUPS = [
const FIXTURE_UNAVAILABLE = {
inbounds: {
MEASURE_REJECTED: '当前稳定 Admin 契约未开放测量审核驳回接口,无法稳定构造该状态',
WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING',
FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路',
INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API',
INBOUNDING: '当前公开接口没有可独立保留的 INBOUNDING 生成或暂停链路;不通过写业务SQL伪造中间状态',
VOIDED: '当前 Controller/Service 没有作废入库单的公开 API',
},
}
@@ -3,7 +3,92 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createCompletedOrder, transition } from './order-inbound-status-fixtures.mjs'
import { createCompletedOrder, transition, retainFeeConfirmingFixture, retainWaitPricingFixture } from './order-inbound-status-fixtures.mjs'
test('待计费样本通过同维度真实渠道变更生成且退出独立会话', async () => {
const execute = async (override = {}) => {
const calls = []
let detailReads = 0
const order = { id: '9', version: 2, status: 'APPROVED', receivingWarehouseId: '10',
destinationWarehouseId: '11', amazonWarehouseCode: 'ABE4', destinationWarehouseName: 'ABE4',
destinationType: 'FBA', pricingTaxMode: 'TAX_INCLUDED', channelCode: 'OLD', customerRemark: 'current-run' }
const context = { marker: 'current-run', context: { merchantQualificationFixture: { username: 'fixture', password: 'fixture' } },
async request(method, url, body, token) {
calls.push({ method, url, body, token })
let data
if (url === '/api/platform/inbounds/8?tenantId=7') {
detailReads += 1
data = { summary: { id: '8', tenantId: '7', orderId: '9', inboundCode: 'IN-8',
processStatus: detailReads === 1 ? 'FEE_CONFIRMING' : 'WAIT_PRICING',
channelCode: 'NEW', feeStatus: 'UNPRICED', ...(detailReads === 1 ? override.before : override.after) } }
} else if (url.includes('/orders/first-leg/9')) data = { order: { ...order, ...override.order }, cargoes: [{ itemNo: 1 }] }
else if (url === '/api/auth/login') data = { accessToken: 'test-session', refreshToken: 'test-refresh' }
else if (url.includes('/tenant/profile')) data = { id: override.customerId || '7' }
else if (url.includes('/pricing/channels')) data = override.channels ?? [
{ channelCode: 'OLD', pricingTaxModes: ['TAX_INCLUDED'], priceBookVersionId: '12' },
{ channelCode: 'NEW', channelName: '真实新渠道', pricingTaxModes: ['TAX_INCLUDED'], priceBookVersionId: '13' } ]
else if (url.includes('/pricing/estimate')) data = { channelCode: 'NEW', pricingTaxMode: 'TAX_INCLUDED', priceBookVersionId: '13' }
else if (url.includes('/declaration')) data = { version: 3, channelChanged: true, ...override.changed }
else if (url.includes('processStatus=WAIT_PRICING')) data = { records: override.rows ?? [{ id: '8', processStatus: 'WAIT_PRICING' }] }
return { http: 200, body: { code: '200', data } }
}, apiSuccess: (_m, _n, r) => r.body.data, assert() {} }
try { await retainWaitPricingFixture(context, { inboundId: '8', tenantId: '7', orderId: '9' }) }
finally {
if (calls.some(row => row.url === '/api/auth/login')) assert.equal(calls.at(-1).url, '/api/auth/logout')
}
return calls
}
const calls = await execute()
const mutation = calls.find(row => row.method === 'PUT')
assert.equal(mutation.body.expectedVersion, 2)
assert.equal(mutation.body.channelCode, 'NEW')
assert.equal(calls.filter(row => row.method === 'PUT').length, 1)
assert.equal(calls.find(row => row.url.includes('/pricing/estimate')).body.originWarehouseId, '10')
for (const override of [{ before: { orderId: 'other' } }, { order: { status: 'PROCESSING' } },
{ customerId: 'other' }, { channels: [] }, { changed: { version: 9 } },
{ after: { tenantId: 'other' } }, { rows: [] }]) {
await assert.rejects(execute(override), /WAIT_PRICING/)
}
})
test('待费用确认样本必须经真实试算并独立回查费用、详情和筛选', async () => {
const execute = async (override = {}) => {
const calls = []
let reads = 0
const fee = { feeId: '90', feeVersion: 1, feeStatus: 'CALCULATED', nextProcessStatus: 'FEE_CONFIRMING' }
const context = { marker: 'current-run',
async request(method, url, body, token, headers) {
calls.push({ method, url, body, headers })
let data
if (url === '/api/platform/inbounds/8?tenantId=7') {
reads += 1
data = { summary: { id: '8', tenantId: '7', inboundCode: 'IN-TEST-8',
processStatus: reads === 1 ? 'MEASURE_PENDING' : 'FEE_CONFIRMING',
measureReviewStatus: 'PENDING', ...(reads === 1 ? override.before : override.after) } }
} else if (url.includes('/fees/calculate')) data = { ...fee, ...override.calculated }
else if (url.includes('/fees/latest')) data = { ...fee, ...override.latest }
else data = { records: override.rows ?? [{ id: '8', processStatus: 'FEE_CONFIRMING' }] }
return { http: 200, body: { code: '200', data } }
}, apiSuccess: (_m, _n, response) => response.body.data,
assert() {} }
const result = await retainFeeConfirmingFixture(context, { inboundId: '8', tenantId: '7' })
return { calls, result }
}
const { calls, result } = await execute()
assert.deepEqual(result, { feeId: '90', feeVersion: 1 })
assert.equal(calls.length, 5)
const writes = calls.filter(row => row.method !== 'GET')
assert.equal(writes.length, 1)
assert.deepEqual(writes[0].body, { weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
assert.ok(writes[0].headers['Idempotency-Key'].startsWith('current-run-'))
assert.match(calls.at(-1).url, /inboundCode=IN-TEST-8&processStatus=FEE_CONFIRMING/)
for (const override of [
{ before: { tenantId: 'other' } }, { before: { processStatus: 'WAIT_SPLIT' } },
{ calculated: { nextProcessStatus: 'WAIT_SPLIT' } },
{ after: { processStatus: 'MEASURE_PENDING' } }, { after: { measureReviewStatus: 'APPROVED' } },
{ latest: { feeId: '91' } }, { latest: { feeStatus: 'SENT' } }, { rows: [] },
]) await assert.rejects(execute(override), /FEE_CONFIRMING/)
})
test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{
const calls=[]
@@ -91,7 +176,7 @@ test('测量状态截停发生在正确的 HTTP 边界', () => {
assert.match(source, /await weightOcr\.run\(context\)/)
assert.match(source, /target !== 'MEASURING'/)
assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/)
assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/)
assert.match(source, /\['MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING'\]\.includes\(target\) && isSubmit/)
assert.doesNotMatch(source, /target === 'MEASURE_REJECTED' && isSubmit/)
})
@@ -99,8 +184,8 @@ test('入库模块只声明状态全集且不在后续专项生成前预写失
for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) {
assert.match(source, new RegExp(`'${status}'`))
}
assert.match(source, /processStatus=WAIT_SPLIT/)
assert.match(source, /查询存量 WAIT_SPLIT 样本/)
assert.match(source, /本轮 WAIT_SPLIT 独立样本缺失/)
assert.doesNotMatch(source, /查询存量 WAIT_SPLIT 样本|legacyInbound|legacyPage/)
assert.doesNotMatch(source, /覆盖阻断|fixture-unavailable|recordCoverageGap|addCoverageGap/)
})
@@ -1,4 +1,4 @@
import { findPricedOrderRouteAcrossOrigins, freezeOrderCommand } from './orders.mjs'
import { findPricedOrderRouteAcrossOrigins, findPricedOrderRoute, freezeOrderCommand } from './orders.mjs'
const MODULE = 'order-inbound-status-fixtures'
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
@@ -178,19 +178,143 @@ async function waitForInbound(context, fixture, orderId, allowCompletedOrder = f
return { response: null, summary: null }
}
export async function retainFeeConfirmingFixture(context, { inboundId, tenantId }) {
const suffix = `${query(inboundId)}?tenantId=${query(tenantId)}`
const detailPath = `/api/platform/inbounds/${suffix}`
const beforeResponse = await context.request('GET', detailPath)
const before = context.apiSuccess(MODULE, '试算前核对独立待审核测量样本', beforeResponse, '', OPTIONS)
if (id(before?.summary?.id) !== id(inboundId)
|| id(before?.summary?.tenantId) !== id(tenantId)
|| before?.summary?.processStatus !== 'MEASURE_PENDING' || !before?.summary?.inboundCode) {
throw new Error('FEE_CONFIRMING 必须从本轮独立 MEASURE_PENDING 入库单生成')
}
const calculatedResponse = await context.request('POST',
`/api/platform/v1/inbounds/${query(inboundId)}/fees/calculate?tenantId=${query(tenantId)}`,
{ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' }, undefined,
{ 'Idempotency-Key': key(context, `retain-fee-confirming-${inboundId}`) })
const calculated = context.apiSuccess(MODULE, '审核前真实试算并保留待费用确认样本', calculatedResponse, '', OPTIONS)
if (!calculated?.feeId || calculated?.feeStatus !== 'CALCULATED'
|| calculated?.nextProcessStatus !== 'FEE_CONFIRMING') {
throw new Error('真实费用试算未生成 FEE_CONFIRMING/CALCULATED')
}
const latestResponse = await context.request('GET',
`/api/platform/v1/inbounds/${query(inboundId)}/fees/latest?tenantId=${query(tenantId)}`)
const latest = context.apiSuccess(MODULE, '回读待确认费用的同一快照', latestResponse, '', OPTIONS)
const detailResponse = await context.request('GET', detailPath)
const detail = context.apiSuccess(MODULE, '回读待费用确认样本持久状态', detailResponse, '', OPTIONS)
const valid = id(detail?.summary?.id) === id(inboundId)
&& id(detail?.summary?.tenantId) === id(tenantId)
&& detail?.summary?.processStatus === 'FEE_CONFIRMING'
&& detail?.summary?.measureReviewStatus === 'PENDING'
&& id(latest?.feeId) === id(calculated.feeId)
&& latest?.feeVersion === calculated.feeVersion && latest?.feeStatus === 'CALCULATED'
context.assert(MODULE, '费用已持久化且测量尚未审核,保留 FEE_CONFIRMING', detailResponse, valid,
`status=${detail?.summary?.processStatus || 'missing'}, feeId=${id(latest?.feeId)}`, OPTIONS)
if (!valid) throw new Error('FEE_CONFIRMING 持久状态或费用快照不一致')
const listResponse = await context.request('GET',
`/api/platform/inbounds?tenantId=${query(tenantId)}&inboundCode=${query(before.summary.inboundCode)}&processStatus=FEE_CONFIRMING&page=1&size=20`)
const page = context.apiSuccess(MODULE, '按 FEE_CONFIRMING 查询保留样本', listResponse, '', OPTIONS)
const visible = rows(page).some(row => id(row.id) === id(inboundId) && row.processStatus === 'FEE_CONFIRMING')
context.assert(MODULE, '待费用确认筛选包含本轮真实样本', listResponse, visible,
`inboundId=${inboundId}`, OPTIONS)
if (!visible) throw new Error('FEE_CONFIRMING 列表未包含本轮样本')
return { feeId: id(latest.feeId), feeVersion: latest.feeVersion }
}
export async function retainWaitPricingFixture(context, { inboundId, tenantId, orderId }) {
const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`
const before = context.apiSuccess(MODULE, '渠道变更前核对独立费用确认样本',
await context.request('GET', detailPath), '', OPTIONS)
const orderDetail = context.apiSuccess(MODULE, '渠道变更前读取当前订单版本与申报',
await context.request('GET', `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(tenantId)}`), '', OPTIONS)
const order = orderDetail?.order
if (id(before?.summary?.id) !== id(inboundId) || id(before?.summary?.tenantId) !== id(tenantId)
|| id(before?.summary?.orderId) !== id(orderId) || before?.summary?.processStatus !== 'FEE_CONFIRMING'
|| id(order?.id) !== id(orderId) || order?.status !== 'APPROVED' || !orderDetail?.cargoes?.length) {
throw new Error('WAIT_PRICING 必须从本轮 APPROVED 订单及 FEE_CONFIRMING 入库单生成')
}
const fixture = context.context?.merchantQualificationFixture
const username = fixture?.username || context.options?.merchantUsername || process.env.MERCHANT_TEST_USERNAME
const password = fixture?.password || context.options?.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
const login = context.apiSuccess(MODULE, '渠道样本客户独立登录',
await context.request('POST', '/api/auth/login', { loginId: username, password }, ''), '', OPTIONS)
if (!login?.accessToken) throw new Error('WAIT_PRICING 客户登录缺少会话')
const token = login.accessToken
try {
const profile = context.apiSuccess(MODULE, '核对渠道目录所属客户',
await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, token), '', OPTIONS)
if (id(profile?.id) !== id(tenantId)) throw new Error('WAIT_PRICING 渠道目录客户身份不一致')
const channels = context.apiSuccess(MODULE, '查询同仓可替换报价渠道',
await context.request('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(order.receivingWarehouseId)}`,
undefined, token), '', OPTIONS)
const route = await findPricedOrderRoute({
receiving: { id: order.receivingWarehouseId },
destinations: [{ id: order.destinationWarehouseId, warehouseCode: order.amazonWarehouseCode,
warehouseName: order.destinationWarehouseName }],
channels: (Array.isArray(channels) ? channels : []).filter(channel => channel.channelCode !== order.channelCode
&& channel.pricingTaxModes?.includes(order.pricingTaxMode))
.map(channel => ({ ...channel, pricingTaxModes: [order.pricingTaxMode] })),
destinationType: order.destinationType, concurrency: 1,
estimate: body => context.request('POST', '/api/merchant/v1/first-leg/pricing/estimate', body, token),
})
if (!route?.priced?.channel) throw new Error('WAIT_PRICING 没有同仓同目的仓同税制的实际可计价替换渠道')
const channel = route.priced.channel
const changedResponse = await context.request('PUT',
`/api/platform/v1/orders/${query(orderId)}/declaration?tenantId=${query(tenantId)}`,
{ expectedVersion: Number(order.version), customerRemark: order.customerRemark,
expectedArrivalAt: order.expectedArrivalAt || null, estimatedVolumeCbm: order.estimatedVolumeCbm ?? null,
channelCode: channel.channelCode, channelName: channel.channelName,
cargoes: orderDetail.cargoes.map(cargo => Object.fromEntries([
'itemNo', 'cargoName', 'cargoNameEn', 'skuNo', 'model', 'packageCount', 'totalQuantity',
'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg',
'hsCode', 'customsHsCode', 'unitsPerPackage', 'unitDeclaredValue', 'declaredValue',
'currency', 'material', 'usage', 'brand', 'cargoRemark',
].map(field => [field, cargo[field] ?? (field === 'cargoRemark' ? cargo.remark : null) ?? null]))) })
const changed = context.apiSuccess(MODULE, '通过正式申报接口变更本轮未发布费用订单渠道', changedResponse, '', OPTIONS)
if (Number(changed?.version) !== Number(order.version) + 1 || changed?.channelChanged !== true) {
throw new Error('WAIT_PRICING 渠道变更未确认当前订单版本')
}
let detail
let response
for (let attempt = 0; attempt < 30; attempt += 1) {
response = await context.request('GET', detailPath)
detail = context.apiSuccess(MODULE, '等待渠道变更及费用失效事实收敛', response, '', OPTIONS)
if (detail?.summary?.processStatus === 'WAIT_PRICING'
&& detail?.summary?.channelCode === channel.channelCode) break
if (attempt < 29) await wait(500)
}
const valid = id(detail?.summary?.id) === id(inboundId) && id(detail?.summary?.tenantId) === id(tenantId)
&& detail?.summary?.processStatus === 'WAIT_PRICING' && detail?.summary?.feeStatus === 'UNPRICED'
&& detail?.summary?.channelCode === channel.channelCode
context.assert(MODULE, '真实渠道变更保留 WAIT_PRICING 且旧费用失效', response, valid,
`status=${detail?.summary?.processStatus}, feeStatus=${detail?.summary?.feeStatus}`, OPTIONS)
if (!valid) throw new Error('WAIT_PRICING 渠道变更事实未收敛')
const listResponse = await context.request('GET',
`/api/platform/inbounds?tenantId=${query(tenantId)}&inboundCode=${query(before.summary.inboundCode)}&processStatus=WAIT_PRICING&page=1&size=20`)
const page = context.apiSuccess(MODULE, '按 WAIT_PRICING 筛选本轮样本', listResponse, '', OPTIONS)
const visible = rows(page).some(row => id(row.id) === id(inboundId) && row.processStatus === 'WAIT_PRICING')
context.assert(MODULE, '待计费筛选包含本轮真实样本', listResponse, visible, `inboundId=${inboundId}`, OPTIONS)
if (!visible) throw new Error('WAIT_PRICING 列表未包含本轮样本')
} finally {
context.apiSuccess(MODULE, '退出渠道目录客户独立会话',
await context.request('POST', '/api/auth/logout', { refreshToken: login.refreshToken }, token), '', OPTIONS)
}
}
async function prepareInboundStatus(context, target) {
const originalMarker = context.marker
const originalRequest = context.request
const fixtureMarker = `${originalMarker}-IN-${target}`
const stop = Symbol(target)
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'orderStopAtApproved']
const createdSnapshot = new Map(createdKeys.map((name) => [name,
{ present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name] }]))
const contextSnapshot = new Map(contextKeys.map((name) => [name,
{ present: Object.hasOwn(context.context, name), value: context.context[name] }]))
try {
context.marker = fixtureMarker
if (target === 'WAIT_PRICING') context.context.orderStopAtApproved = true
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const order = { ...context.createdResources.orders }
@@ -206,7 +330,7 @@ async function prepareInboundStatus(context, target) {
context.request = async (method, path, body, token, headers) => {
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`)
if (target === 'MEASURE_PENDING' && isSubmit) {
if (['MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING'].includes(target) && isSubmit) {
const response = await originalRequest.call(context, method, path, body, token, headers)
if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`)
throw stop
@@ -224,6 +348,13 @@ async function prepareInboundStatus(context, target) {
}
}
if (['FEE_CONFIRMING', 'WAIT_PRICING'].includes(target)) {
await retainFeeConfirmingFixture(context, { inboundId, tenantId })
}
if (target === 'WAIT_PRICING') {
await retainWaitPricingFixture(context, { inboundId, tenantId, orderId: id(order.orderId) })
}
let response = null
let detail = null
for (let attempt = 1; attempt <= 8; attempt += 1) {
@@ -320,7 +451,7 @@ export async function run(context, matrix = context.createdResources.manualState
id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
}
for (const status of ['MEASURING', 'MEASURE_PENDING', 'WAIT_SPLIT']) {
for (const status of ['MEASURING', 'MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING', 'WAIT_SPLIT']) {
const sample = await prepareInboundStatus(context, status)
result.inbounds[status] = sample
matrix.pages.inbounds.fixtures[status] = {
@@ -335,22 +466,7 @@ export async function run(context, matrix = context.createdResources.manualState
}
if (!result.inbounds.WAIT_SPLIT) {
const legacyResponse = await context.request('GET',
`/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`)
const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS)
const legacyInbound = rows(legacyPage)[0]
if (legacyInbound?.id && legacyInbound?.orderId) {
const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId,
status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null }
result.inbounds.WAIT_SPLIT = sample
result.unifiedOrders.WAIT_SPLIT = sample
matrix.pages.inbounds.fixtures.WAIT_SPLIT = {
id: sample.inboundId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
}
matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = {
id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
}
}
throw new Error('本轮 WAIT_SPLIT 独立样本缺失,不允许借用历史数据')
}
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { pickerFixtureSuffix } from './phase4-overseas-pda.mjs'
test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => {
const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' }
const second = { marker: 'ADMIN-API-20261005170914227-COMPLETED-F40-PDA' }
const oldSuffix = value => value.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
assert.equal(oldSuffix(first), oldSuffix(second))
assert.notEqual(pickerFixtureSuffix(first), pickerFixtureSuffix(second))
assert.equal(pickerFixtureSuffix(first), pickerFixtureSuffix({ ...first }))
assert.match(pickerFixtureSuffix(first), /^[0-9A-F]{32}$/)
assert.notEqual(pickerFixtureSuffix(first), pickerFixtureSuffix({ ...first, pdaContainerSuffix: 'B' }))
assert.throws(() => pickerFixtureSuffix({ marker: '' }))
})
@@ -7,6 +7,13 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
test('discard proof follows reserved approval and keeps actual PDA confirmation separate', () => {
assert.match(source, /Approval retains the existing reservation without deducting stock/)
assert.match(source, /Actual PDA confirmation deducts the confirmed quantity/)
assert.doesNotMatch(source, /require approval-transaction deduction/)
assert.match(source, /completed-confirm recovery\/replay/)
})
test('client services module uses only stable merchant self-service paths', () => {
for (const path of [
'/api/merchant/v1/last-leg/orders',
@@ -77,14 +84,14 @@ test('focused client services keeps the marker-owned merchant fixture when reusa
assert.ok(fixture >= 0 && fixture < clientServices)
})
test('discard follows formal approval deduction and keeps physical ledger evidence pending', () => {
assert.match(source, /正式废弃合同要求审批事务扣减一箱/)
test('discard approval retains reservation and keeps actual confirmation evidence pending', () => {
assert.match(source, /批准只保留锁箱且库存数量流水不变/)
assert.match(source, /废弃拒绝释放原箱且全部库存数量流水不变/)
assert.match(source, /APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING/)
assert.doesNotMatch(source, /INSERT INTO|UPDATE ll_|fleet-trip|\/api\/pda\//)
})
for (const fault of ['', 'pending deducts', 'reject does not release', 'approve does not deduct', 'create conflict mutates']) {
for (const fault of ['', 'pending deducts', 'reject does not release', 'approve deducts', 'create conflict mutates']) {
test(`discard approval stock boundaries: ${fault || 'formal approval contract'}`, async () => {
let state = 'PENDING_REVIEW', approved = false, conflictSeen = false
const baseline = { balances: [{customerId:'123',overseasWarehouseId:'42',availablePackages:'4'}], ledger:[],
@@ -103,7 +110,7 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve
if(body.remark.endsWith('-DIFFERENT')) {conflictSeen=true;return {http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}}
approved=true;state='PENDING_REVIEW';data={summary:{discardNo:'NEW'}}
} else if(path.endsWith('/balances')) {
const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault!=='approve does not deduct') || (conflictSeen && fault==='create conflict mutates')
const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault==='approve deducts') || (conflictSeen && fault==='create conflict mutates')
data={total:'1',records:[{...baseline.balances[0],availablePackages:changed?'3':'4'}]}
} else if(path.endsWith('/ledger')) data={total:'0',records:[]}
else if(path.endsWith('/inventory-candidates')) data=[{...baseline.source,availablePackages:state==='REJECTED' && fault!=='reject does not release'?'4':'3'}]
@@ -111,7 +118,7 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve
return {http:200,body:{code:'200',data}}
}}
await assert.rejects(verifyDiscardApproval(context,'merchant',{idempotencyKey:'key',remark:'fixture'},'OLD',proof),
fault ? /仅锁定|释放原箱|审批事务扣减|异载荷拒绝后/ : /CHECKPOINT_PASSED/)
fault ? /仅锁定|释放原箱|只保留锁箱|异载荷拒绝后/ : /CHECKPOINT_PASSED/)
assert.ok(!calls.some(call=>call.raw.includes('/pda/')))
})
}
@@ -249,7 +249,7 @@ export async function verifyDiscard(context, token, markerOrders) {
sourceOrderId: id(candidate.orderId), customerId: id(context.context.merchantQualificationFixture.tenantId),
warehouseId: candidate.overseasWarehouseId, inboundCode: candidate.inboundCode,
skuCode: sku.skuCode, snapshots: {}, status: 'RUNNING',
contract: 'Current stage-five and service AGENTS require approval-transaction deduction. Review has no idempotencyKey. An approval that only enables PDA must fail this contract.',
contract: 'Approval retains the existing reservation without deducting stock. Actual PDA confirmation deducts the confirmed quantity; completed confirmation replay must not write again. Review has no idempotencyKey.',
}
const before = await discardSnapshot(context, token, proof)
proof.snapshots.before = before
@@ -358,7 +358,7 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro
require('批准后反向审核拒绝且事实不变', conflict(reverse) && nonBlank(reverse.requestId) && nonBlank(reverse.traceId)
&& stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved))
proof.status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
proof.remainingEvidence = 'll_dropship_carton, ll_dropship_sku_inventory, ll_dropship_carton_ledger require independent read-only evidence; public ledger is not carton ledger'
proof.remainingEvidence = 'Approval reservation only. Actual PDA confirm, completed-confirm recovery/replay and independent ll_dropship_carton/ll_dropship_sku_inventory/ll_dropship_carton_ledger evidence remain required; public ledger is not carton ledger.'
}
@@ -1,5 +1,7 @@
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
const MODULE = 'phase4-overseas-dispatch'
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -308,6 +310,7 @@ export async function run(context) {
context.assert(MODULE, '正式锁释放后候选恢复', freed.response,
Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS)
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
await verifyGroupedDispatch(context, MODULE, selectedCandidates)
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
const snapshotBody = { draftToken, planIds, containersJson }
@@ -1,3 +1,4 @@
import { exportWorkbook } from './download-center-fixture.mjs'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
@@ -104,12 +105,20 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
},
async request(...args) {
if (args[1].startsWith('/api/platform/v1/files/export-tasks')) {
calls.push({ gateway: 'domestic', method: args[0], path: args[1], body: args[2] })
return ok({ id: args[2]?.exportType || args[1].split('/').at(-1), status: args[0] === 'POST' ? 'PENDING' : 'SUCCEEDED' })
}
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
const handler = route ? async function(method,path,body,token = 'admin-overseas-token',extraHeaders = {}) {
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
assert.notEqual(method, 'DELETE')
if (token === 'deny-overseas-token') return forbidden()
if (path.includes('/fleet/vehicles/')) {
if (method === 'POST' && path.endsWith('/records/finance-status')) {
return ok(body.recordNos.map(recordNo => ({ recordNo, status: processingMarksPaid ? 'PAID' : 'UNLINKED',
sourceVersion: null, submittedRecordVersion: null, finance: null })))
}
const url = new URL(path, 'https://fixture.invalid'), parts = url.pathname.split('/records'), owner = parts[0].split('/').at(-1)
const suffix = parts[1], record = careRows.get(suffix.split('/')[1])
if (record && owner !== record.vehicleId && !careLeak) return { http: 200, body: { code: 'A0404' } }
@@ -117,7 +126,7 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
const prior = [...careRows.values()].find(value => value.key === body.idempotencyKey && value.vehicleId === owner)
if (prior) return ok(structuredClone(prior))
const created = { recordNo: `VR-${careRows.size + 1}`, vehicleId: owner, details: structuredClone(body.details), files: [], recordVersion: 0, key: body.idempotencyKey,
paymentStatus: ['REPAIR', 'RESCUE'].includes(body.details.recordType) ? 'UNLINKED' : null }
paymentStatus: null }
careRows.set(created.recordNo, created); careHistory.set(created.recordNo, [{ snapshot: structuredClone(created) }]); return ok(structuredClone(created))
}
if (method === 'GET' && !suffix) {
@@ -130,7 +139,6 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
if (method === 'PUT') {
if (Number(body.recordVersion) !== record.recordVersion) return conflict()
record.details = structuredClone(body.details); record.recordVersion++
if (processingMarksPaid && record.details.incident?.processingStatus === 'COMPLETED') record.paymentStatus = 'PAID'
careHistory.get(record.recordNo).unshift({ reason: body.reason, snapshot: structuredClone(record) }); return ok(structuredClone(record))
}
}
@@ -286,13 +294,20 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
calls.push({ gateway: 'overseas-binary', method, path })
assert.equal(method, 'GET')
if (path.includes('/fleet/vehicles/')) return { http: 200, contentType: 'image/png', bytes: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') }
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
let csv = ''
if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n`
else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n`
else csv = `车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},${state.vehicle.maxPalletSpaces},${state.vehicle.maxLoadKg},${state.vehicle.maxVolumeCbm},2026-08-21\r\n`
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`),
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
assert.match(path, /files\/export-tasks\/[^/]+\/download$/)
let headers, values
if (path.includes('OVERSEAS_QUOTE_LIST')) {
headers = ['报价名称', '合作仓编码', '合作仓名称', '报价方']
values = [state.quote.summary.name, state.quote.summary.warehouseCode, state.quote.summary.warehouseName, state.quote.summary.providerName]
} else if (path.includes('MONTHLY_EXCHANGE_RATE_LIST')) {
headers = ['月份', '月平均汇率', '状态']
values = [state.exchange.month, state.exchange.usdToCnyAverage, state.exchange.status]
} else {
headers = ['车牌号', '最大托位', '最大载重(kg)', '可用容积(m³)', '状态']
values = [state.vehicle.plateNumber, state.vehicle.maxPalletSpaces, state.vehicle.maxLoadKg, state.vehicle.maxVolumeCbm, state.vehicle.status]
}
return { method, path, http: 200, bytes: exportWorkbook(headers, values),
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
},
}
return { context, state }
@@ -304,20 +319,20 @@ test('静态锁定统一Gateway主数据生命周期、CSV与安全边界', () =
assert.match(source, /context\.request\('POST'/)
assert.match(source, /context\.request\('PUT'/)
assert.match(source, /context\.request\('PATCH'/)
assert.match(source, /context\.requestBinary\('GET'/)
assert.match(source, /verifyDownloadCenterExport\(context/)
assert.match(source, /context\.expectConflict/)
assert.match(source, /warehouse-options\?refresh=true/)
assert.match(source, /空候选不负缓存30分钟/)
assert.match(source, /30分钟正缓存与刷新快照一致/)
assert.match(source, /无quote:list权限访问海外报价被拒绝/)
assert.match(source, /表头与marker内容/)
assert.match(source, /verifyDownloadCenterExport/)
assert.match(source, /finally\s*\{/)
assert.match(source, /context\.createdResources\.phase4OverseasMasterData/)
assert.doesNotMatch(source, /context\.addCoverageGap/)
assert.match(source, /2090 \+ Math\.floor/)
assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/)
for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export',
'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint))
for (const endpoint of ['overseas-quotes/warehouse-options', 'OVERSEAS_QUOTE_LIST',
'MONTHLY_EXCHANGE_RATE_LIST', 'VEHICLE_LIST']) assert.ok(source.includes(endpoint))
assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/)
assert.doesNotMatch(source, /Idempotency-Key/)
})
@@ -334,7 +349,7 @@ test('API dropping updated vehicle capacity fails even with a correct CSV and cl
}
await run(context)
assert.equal(context.steps.find(step => step.name === '车辆编辑保持三项实际容量')?.passed, false)
assert.equal(context.steps.find(step => step.name === '导出marker车辆CSV表头与marker内容')?.passed, true)
assert.equal(context.steps.find(step => step.name === 'VEHICLE_LIST表头与marker内容')?.passed, true)
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE')
})
@@ -358,7 +373,7 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全
assert.equal(state.role.status, 'DISABLED')
assert.equal(state.user.status, 'DISABLED')
assert.equal(state.optionReads, 4)
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export?')).length, 3)
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export-tasks/') && call.path.endsWith('/download')).length, 3)
assert.equal(resource.vehicle.care.status, 'VERIFIED')
assert.equal(state.spareVehicle.status, 'INACTIVE')
assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0)
@@ -395,7 +410,22 @@ test('事件处理完成被错误映射为已付款时,真实验收编排必
const { context } = fakeContext({ softAssertions: true, processingMarksPaid: true })
await run(context)
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING')
assert.ok(context.steps.some(step => step.name === '处理完成保持独立的费用及付款事实' && !step.passed && step.deploymentBlocker))
assert.ok(context.steps.some(step => step.name === '处理完成未自动提交财务或标记付款' && !step.passed && step.deploymentBlocker))
})
test('UNLINKED付款响应夹带财务单据或提交版本仍阻断,不忽略独立查询证据', async () => {
for (const facts of [{ submittedRecordVersion: 1 }, { finance: { costNo: 'COST-UNEXPECTED' } }]) {
const { context } = fakeContext({ softAssertions: true })
const request = context.request.bind(context)
context.request = async (...args) => {
const response = await request(...args)
if (args[1].endsWith('/records/finance-status')) Object.assign(response.body.data[0], facts)
return response
}
await run(context)
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING')
assert.ok(context.steps.some(step => step.name === '处理完成未自动提交财务或标记付款' && !step.passed && step.deploymentBlocker))
}
})
test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => {
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
import { verifyVehicleCare } from './vehicle-care.mjs'
const MODULE = 'phase4-overseas-master-data'
@@ -16,19 +17,6 @@ function requireData(context, name, response) {
return value
}
function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) {
const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : ''
const header = text.split(/\r?\n/, 1)[0]
context.assert(MODULE, name, response,
response?.http === 200
&& String(response.contentType || '').toLowerCase().startsWith('text/csv')
&& response.bytes instanceof Uint8Array && response.bytes.length > 0,
`HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`,
BLOCKER)
context.assert(MODULE, `${name}表头与marker内容`, response,
header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))),
`header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER)
}
const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : []
@@ -218,10 +206,7 @@ async function quoteLifecycle(context, resource) {
context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse,
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
assertBinaryCsv(context, '导出海外报价CSV', csv,
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
[id, context.marker])
await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_QUOTE_LIST', { keyword: context.marker }, ['报价名称', '合作仓编码', '合作仓名称', '报价方'], [context.marker])
return true
}
@@ -288,10 +273,7 @@ async function exchangeLifecycle(context, resource) {
Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)),
`month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER)
const csv = await context.requestBinary('GET',
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
await verifyDownloadCenterExport(context, MODULE, 'MONTHLY_EXCHANGE_RATE_LIST', { monthFrom: month, monthTo: month }, ['月份', '月平均汇率', '状态'], [month, 'INACTIVE'])
}
function vehiclePayload(context, edited = false) {
@@ -378,10 +360,7 @@ async function vehicleLifecycle(context, resource) {
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
assertBinaryCsv(context, '导出marker车辆CSV', csv,
'车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间',
[createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)])
await verifyDownloadCenterExport(context, MODULE, 'VEHICLE_LIST', { keyword: createPayload.plateNumber }, ['车牌号', '最大托位', '最大载重(kg)', '可用容积(m³)'], [createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)])
resource.vehicle.care = {}
await verifyVehicleCare(context, MODULE, vehicle, resource.vehicle.care)
}
@@ -1,4 +1,5 @@
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
import { createHash } from 'node:crypto'
const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-container-receiving'
@@ -47,6 +48,10 @@ function forbidden(response) { return [401, 403].includes(response?.http) || (re
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
export function pickerFixtureSuffix(context) {
if (typeof context.marker !== 'string' || !context.marker.trim()) throw Error('PDA账号缺少本轮运行标识')
return createHash('sha256').update(JSON.stringify([context.marker, context.pdaContainerSuffix || ''])).digest('hex').slice(0, 32).toUpperCase()
}
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function receivingSnapshot(data) {
return JSON.stringify({
@@ -313,7 +318,7 @@ export async function resolvePickerAccess(context) {
export async function createPicker(context, index, overseasWarehouseId, access) {
const role = access.role
const suffix = markerSuffix(context)
const suffix = pickerFixtureSuffix(context)
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
const phone = await context.findAvailablePhone(MODULE)
@@ -1,3 +1,4 @@
import { exportWorkbook } from './download-center-fixture.mjs'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
@@ -17,6 +18,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
const context = {
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
async request(...args) {
if (args[1].startsWith('/api/platform/v1/files/export-tasks')) {
calls.push({ channel: 'domestic', method: args[0], path: args[1], body: args[2] })
return response({ id: args[2]?.exportType || args[1].split('/').at(-1), status: args[0] === 'POST' ? 'PENDING' : 'SUCCEEDED' })
}
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
const handler = route ? async function(method,requestPath,body) {
calls.push({ channel: 'overseas', method, path: requestPath, body })
@@ -58,7 +63,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
return handler.apply(this,args);
},
async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: exportWorkbook(['入库单号', 'SKU', '可用箱数', '发生时间', '业务单号', '变动箱数']), contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'request-binary', traceId: 'trace-binary' } },
apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null },
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
@@ -70,7 +75,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
test('模块只依赖注入请求,不读取环境或自行联网', () => {
assert.match(source, /export async function run\(context\)/)
assert.match(source, /context\.request\('GET'/)
assert.match(source, /context\.requestBinary\('GET'/)
assert.match(source, /verifyDownloadCenterExport\(context/)
assert.doesNotMatch(source, /https?:\/\//)
assert.doesNotMatch(source, /process\.env|fetch\(/)
})
@@ -80,7 +85,7 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => {
return run(context).then(() => {
assert.ok(context.calls.length >= 12)
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method)))
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3)
assert.equal(context.calls.filter((call) => call.channel === 'domestic' && !call.path.includes('/files/export-tasks')).length, 3)
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?')))
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?')))
assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false)
@@ -89,7 +94,7 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => {
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets')))
assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false)
assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/')))
assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/') || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks')))
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
const step = context.steps.find((item) => item.name === name)
assert.ok(step?.passed, name)
@@ -113,7 +118,7 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) {
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
}
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) {
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?'))) {
const params = new URL(`http://acceptance${call.path}`).searchParams
assert.equal(params.has('page'), false, call.path)
assert.equal(params.has('size'), false, call.path)
@@ -141,11 +146,11 @@ test('缺少客户时明确失败,缺少仓库时通过正式接口创建并
})
test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => {
assert.match(source, /inventory\/balances\/export/)
assert.match(source, /inventory\/ledger\/export/)
assert.match(source, /OVERSEAS_INVENTORY_BALANCE/)
assert.match(source, /OVERSEAS_INVENTORY_LEDGER/)
assert.match(source, /inventory\/summary[^\n]+\{ customerId \}/)
assert.match(source, /inventory\/balances\/export[^\n]+\{ customerId \}/)
assert.match(source, /inventory\/ledger\/export[^\n]+\{ customerId \}/)
assert.match(source, /OVERSEAS_INVENTORY_BALANCE[^\n]+\{ customerId \}/)
assert.match(source, /OVERSEAS_INVENTORY_LEDGER[^\n]+\{ customerId \}/)
for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /requestId=/)
assert.match(source, /traceId=/)
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
const MODULE = 'phase4-overseas-warehouse'
const SCENARIO = 'phase4-overseas-warehouse'
const OPTIONS = { scenario: SCENARIO }
@@ -47,13 +48,6 @@ async function domesticMutation(context, method, name, path, body) {
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body?.data : null }
}
async function overseasCsv(context, name, path) {
const response = await context.requestBinary('GET', path)
context.addStep(MODULE, name, response,
response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'),
`bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS)
return response
}
function assertPage(context, name, result) {
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
@@ -151,8 +145,8 @@ export async function run(context) {
&& Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages))
&& Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt),
`id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS)
await overseasCsv(context, '下载SKU库存余额CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId }))
await overseasCsv(context, '下载进销存流水CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId }))
await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_INVENTORY_BALANCE', { customerId }, ['入库单号', 'SKU', '可用箱数'])
await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_INVENTORY_LEDGER', { customerId }, ['发生时间', '业务单号', '变动箱数'])
let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
let ownedWarehouse = false
@@ -25,7 +25,7 @@ test('tail order controls execute after current producer and before overseas PDA
})
test('module covers the public tail-order write surface before PDA receiving', () => {
for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export',
for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', 'LAST_MILE_ORDER_LIST',
'/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) {
assert.ok(source.includes(path), `missing ${path}`)
}
@@ -33,7 +33,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
'接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409',
'整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓',
'单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组',
'尾程费用详情可追溯', '导出本轮尾程订单CSV',
'尾程费用详情可追溯', 'LAST_MILE_ORDER_LIST',
'暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划',
'解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放',
'人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点',
@@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
assert.match(source, /creationMode === 'AUTO'/)
assert.match(source, /requestBinary\('GET'/)
assert.match(source, /verifyDownloadCenterExport\(context/)
assert.match(source, /palletMergeBatchId/)
assert.match(source, /feeItemCode/)
assert.match(source, /feeItemName/)
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
const MODULE = 'phase4-tail-order-controls'
const SCENARIO = 'phase4-tail-order-controls-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -207,14 +208,9 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, {
const exported = await verifyDownloadCenterExport(context, MODULE, 'LAST_MILE_ORDER_LIST', {
customerId: id(representative.customerId), containerNo: producer.containerNo,
}))
const csv = new TextDecoder().decode(exported.bytes || new Uint8Array())
context.addStep(MODULE, '导出本轮尾程订单CSV', exported,
exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv')
&& csv.includes(producer.containerNo) && csv.includes('尾程单号'),
`bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS)
}, ['尾程单号', '集装箱号'], [producer.containerNo])
return success(batchCleared.response) && success(fee.response) && exported?.http === 200
}
function manualPlanBody(order, version, marker) {
@@ -1,6 +1,6 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
@@ -10,6 +10,23 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => {
const expected = { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', inboundCode: 'IN-1', evidenceFileId: '91' }
const detail = { order: { ...expected, status: 'COMPLETED', packageCount: 1, evidenceFileIds: ['91'] },
returnSources: [], returnReceipts: [{ receiptNo: 'RSR-1', receivedPackages: '1', receivedAt: '2026-10-06T01:00:00' }] }
const delivery = { order: { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', packageCount: 4, status: 'RETURN_PENDING' } }
assert.equal(returnedExceptionFactsMatch(detail, delivery, expected), true)
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, packageCount: 1, status: 'RETURNED' } }, expected), true)
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, status: 'RETURNED' } }, expected), false)
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, customerId: 'OTHER' } }, expected), false)
for (const mutate of [v => { v.returnReceipts = [] }, v => { v.returnReceipts[0].receivedPackages = 2 },
v => { v.order.outboundNo = 'OB-OTHER' }, v => { v.order.evidenceFileIds = [] },
v => { v.returnSources = [{ maxReturnPackages: 1 }] }, v => { v.returnReceipts.push({ ...v.returnReceipts[0] }) }]) {
const changed = structuredClone(detail); mutate(changed)
assert.equal(returnedExceptionFactsMatch(changed, delivery, expected), false)
}
})
test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{
const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']}
const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'},
@@ -20,6 +37,7 @@ test('route equals actual item destinations and frozen selected candidates, then
for(const mutate of [
value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER',
value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER',
value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED',
value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10',
value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(),
value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}),
@@ -218,9 +236,10 @@ test('same dispatch-return exception reaches actual restored inventory and retri
const replayed = completed; completed = true
return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed })
}
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } })
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1 } })
return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN',
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] })
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1, version, evidenceFileIds: ['91'] },
returnSources: completed ? [] : [source], returnReceipts: completed ? [{ receiptNo: 'RSR-CURRENT', receivedPackages: 1, receivedAt: '2026-10-06T00:00:00' }] : [] })
})
context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
@@ -1,6 +1,7 @@
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
const MODULE = 'phase5-overseas-fulfillment'
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
@@ -68,6 +69,8 @@ export function dispatchDestinationConservation(detail, fixture, deliveries = nu
const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id)
if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length
|| new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false
if (items.some(row => nonBlank(row.fbaWarehouseCode) && nonBlank(row.destinationWarehouseCode)
&& id(row.fbaWarehouseCode).trim().toUpperCase() !== id(row.destinationWarehouseCode).trim().toUpperCase())) return false
const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode))
const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(','))
if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false
@@ -444,6 +447,26 @@ export async function verifyPartialOrderCompletion(context, { orderId, customerI
return sameOrder && blocked
}
export function returnedExceptionFactsMatch(detail, delivery, expected) {
const order = detail?.order, shipment = delivery?.order
const receipts = detail?.returnReceipts
if (order?.status !== 'COMPLETED' || order.outboundNo !== expected.outboundNo
|| order.deliveryNo !== expected.deliveryNo || order.inboundCode !== expected.inboundCode
|| id(order.customerId) !== expected.customerId
|| !order.evidenceFileIds?.some(fileId => id(fileId) === expected.evidenceFileId)
|| !Array.isArray(detail.returnSources) || detail.returnSources.length !== 0
|| !Array.isArray(receipts) || receipts.length === 0
|| new Set(receipts.map(row => row.receiptNo)).size !== receipts.length
|| receipts.some(row => !nonBlank(row.receiptNo) || !nonBlank(row.receivedAt)
|| !Number.isSafeInteger(Number(row.receivedPackages)) || Number(row.receivedPackages) <= 0)) return false
const received = receipts.reduce((sum, row) => sum + Number(row.receivedPackages), 0)
const sent = Number(shipment?.packageCount)
return received === Number(order.packageCount) && Number.isSafeInteger(sent) && sent >= received
&& shipment.outboundNo === expected.outboundNo && shipment.deliveryNo === expected.deliveryNo
&& id(shipment.customerId) === expected.customerId
&& shipment.status === (received === sent ? 'RETURNED' : 'RETURN_PENDING')
}
export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) {
const owned = context.createdResources?.phase5OverseasFulfillment
if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) {
@@ -566,13 +589,10 @@ export async function completeReturnedException(context, { customerId, warehouse
const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath)
const delivery = await platform(context, 'GET', '回查退回实收后的原派送单',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId }))
context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response,
final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo
&& final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId)
&& final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId)
&& id(row.inventoryPositionId) === id(source.inventoryPositionId))
&& delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo,
trace(final.response), OPTIONS)
context.assert(MODULE, '退回实收保留原出库和收货证据,派送状态按实际退回数量推进', final.response,
returnedExceptionFactsMatch(final.data, delivery.data, { customerId, outboundNo,
deliveryNo: order.deliveryNo, inboundCode: order.inboundCode, evidenceFileId }),
`approved=${final.data?.order?.packageCount}, sent=${delivery.data?.order?.packageCount}, status=${delivery.data?.order?.status}; ${trace(final.response)}`, OPTIONS)
return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId,
inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages),
targetPositionIds: targetPositions.map(row => id(row.id)) }
@@ -1232,6 +1252,8 @@ export async function run(context) {
exceptionNo: returnExceptionNo, outboundNo })
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
businesses: deliveryNos.map(deliveryNo => ({ customerId, deliveryNo })), receiptsVisible: true })
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT'
}
@@ -2,16 +2,29 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials,
completionCounts, exactCompletionOutboxProof, rootCompletionTimeMatches, resolveF40MerchantCredentials,
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder,
validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder,
verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs'
verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence,
resolveF40ReceivingWarehouse, run } from './phase5-root-fulfillment.mjs'
import { loadOneFragment } from './phase3-order-splitting.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
resolveSharedWarehouse } from './phase4-overseas-pda.mjs'
const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8')
test('根完成UTC时刻与上海本地签收最大值一致,拒绝时区偏移、较早来源和缺失证据', () => {
const times = ['2026-10-05T21:55:00.000', '2026-10-05T21:56:07.922']
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', times), true)
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922Z', [...times].reverse()), true)
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922Z', ['2026-10-05T21:56:07.922+08:00']), true)
for (const value of ['2026-10-05T21:56:07.922', '2026-10-05T13:55:00', null]) {
assert.equal(rootCompletionTimeMatches(value, times), false)
}
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', []), false)
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', ['invalid']), false)
})
const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action}))
const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2,
currentRemainderPackageCount:0,children:[
@@ -285,6 +298,85 @@ test('exported receiving warehouse resolver continues to choose only configured
]}}})
assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'})
})
function warehouseSelectionFixture() {
const producers = [1, 2].map(n => ({ tenantId: '90', containerNo: `CONTAINER-${n}`,
containerBookingId: `BOOKING-${n}`, sourceInboundId: `INBOUND-${n}`, inboundCode: `CODE-${n}` }))
const orders = producers.map((p, n) => ({ id: `LASTLEG-${n}`, customerId: p.tenantId,
sourceContainerBookingId: p.containerBookingId, sourceInboundId: p.sourceInboundId,
inboundCode: p.inboundCode, containerNo: p.containerNo, overseasWarehouseId: 'actual-warehouse',
overseasWarehouseCode: 'ACTUAL' }))
const warehouses = [{ id: 'other-warehouse', warehouseName: 'other', warehouseCode: 'OTHER',
warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'actual-warehouse', warehouseName: 'actual', warehouseCode: 'ACTUAL',
warehouseType: 'OVERSEAS', status: 'ACTIVE' }]
const calls = [], context = fixture()
context.request = async (method, path) => {
calls.push({ method, path })
const rows = path.includes('/warehouse-admin?') ? warehouses
: orders.filter(row => row.containerNo === new URL(path, 'https://offline.invalid').searchParams.get('containerNo'))
return { http: 200, body: { code: 200, data: { records: rows, total: rows.length } } }
}
return { producers, orders, warehouses, calls, context }
}
test('F40 selects actual source warehouse irrespective of authority list order and only performs GET', async () => {
for (const reverse of [false, true]) {
const f = warehouseSelectionFixture()
if (reverse) { f.warehouses.reverse(); f.orders.reverse(); f.producers.reverse() }
const warehouse = await resolveF40ReceivingWarehouse(f.context, f.producers)
assert.equal(warehouse.id, 'actual-warehouse')
assert.equal(warehouse.code, 'ACTUAL')
assert.equal(warehouse.sourceOrders.length, 2)
assert.ok(f.calls.every(row => row.method === 'GET'))
}
assert.ok(source.indexOf('await awaitProducerConsumption(scope,producers)')
< source.indexOf('await resolveF40ReceivingWarehouse(scope,producers)'))
assert.ok(source.indexOf('await resolveF40ReceivingWarehouse(scope,producers)')
< source.indexOf('await createPicker(scope, 7'))
assert.doesNotMatch(source, /resolveSharedWarehouse|19300000000020000[34]/)
})
test('F40 warehouse resolution rejects wrong customer, booking, inbound identity, duplicates and missing assignment', async () => {
for (const mutate of [
f => { f.orders[0].customerId = 'foreign' },
f => { f.orders[0].sourceContainerBookingId = 'foreign' },
f => { f.orders[0].sourceInboundId = 'foreign' },
f => { f.orders[0].inboundCode = 'foreign' },
f => { f.orders[0].containerNo = 'foreign' },
f => { f.orders[0].overseasWarehouseId = '' },
f => { f.orders.push({ ...f.orders[0] }) },
]) {
const f = warehouseSelectionFixture(); mutate(f)
await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /来源柜客户入仓身份/)
assert.ok(f.calls.every(row => !row.path.includes('/warehouse-admin?')))
}
})
test('F40 warehouse resolution blocks different receiving warehouses and invalid or ambiguous official master data', async () => {
const mixed = warehouseSelectionFixture(); mixed.orders[1].overseasWarehouseId = 'other-warehouse'
await assert.rejects(resolveF40ReceivingWarehouse(mixed.context, mixed.producers), /同一实际接柜仓/)
for (const mutate of [
f => { f.warehouses.pop() },
f => { f.warehouses[1].status = 'DISABLED' },
f => { f.warehouses[1].warehouseType = 'DOMESTIC' },
f => { f.warehouses[1].warehouseCode = 'WRONG' },
f => { f.warehouses.push({ ...f.warehouses[1] }) },
]) {
const f = warehouseSelectionFixture(); mutate(f)
await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /唯一启用海外收货仓/)
assert.ok(f.calls.every(row => row.method === 'GET'))
}
})
test('F40 warehouse resolution rejects empty or duplicate producers before HTTP', async () => {
for (const select of [() => [], f => [f.producers[0], f.producers[0]],
f => [{ ...f.producers[0], sourceInboundId: '' }]]) {
const f = warehouseSelectionFixture()
await assert.rejects(resolveF40ReceivingWarehouse(f.context, select(f)), /唯.*本轮实装来源/)
assert.equal(f.calls.length, 0)
}
})
test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback or SQL',() => {
assert.equal(typeof run,'function')
for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch',
@@ -2,7 +2,7 @@ import { loadOneFragment } from './phase3-order-splitting.mjs'
import { freezeOrderCommand } from './orders.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { stableLoadedCartonMarks } from './phase3-loading.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse,
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
import { isDeepStrictEqual } from 'node:util'
@@ -19,6 +19,47 @@ const id = value => value == null ? '' : String(value)
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
/** Resolve only this run's consumed physical sources, never a fixed warehouse ID or list position. */
export async function resolveF40ReceivingWarehouse(context, producers) {
check(context, 'F40 选仓仅接受唯一的本轮实装来源', Array.isArray(producers) && producers.length > 0
&& producers.every(row => row?.containerNo && row?.inboundCode && id(row.tenantId)
&& id(row.containerBookingId) && id(row.sourceInboundId))
&& new Set(producers.map(row => id(row.containerBookingId))).size === producers.length
&& new Set(producers.map(row => id(row.tenantId))).size === 1, 'exact-producer-identities')
const sources = []
for (const producer of producers) {
const page = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓',
query('/api/platform/v1/last-leg/orders', { page: 1, size: 200, containerNo: producer.containerNo }))
const rows = Array.isArray(page?.records) ? page.records : []
const matching = rows.filter(row => row.containerNo === producer.containerNo
&& id(row.sourceContainerBookingId) === id(producer.containerBookingId)
&& id(row.customerId) === id(producer.tenantId)
&& id(row.sourceInboundId) === id(producer.sourceInboundId) && row.inboundCode === producer.inboundCode)
check(context, 'F40 尾程来源柜客户入仓身份唯一且接柜仓明确', matching.length === 1
&& (page?.total == null || Number(page.total) <= rows.length)
&& Boolean(id(matching[0]?.id) && id(matching[0]?.overseasWarehouseId)),
`container=${producer.containerNo}, exactSources=${matching.length}`)
sources.push(matching[0])
}
const warehouseId = id(sources[0].overseasWarehouseId)
check(context, 'F40 全部实装来源属于同一实际接柜仓',
sources.every(row => id(row.overseasWarehouseId) === warehouseId), 'unique-source-receiving-warehouse')
const page = await api(context, 'GET', 'F40 按来源仓精确核验Warehouse权威收货主档',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = Array.isArray(page?.records) ? page.records : []
const matching = rows.filter(row => id(row.id) === warehouseId)
const warehouse = matching[0]
check(context, 'F40 实际接柜仓为唯一启用海外收货仓且编码一致', matching.length === 1
&& warehouse.warehouseType === 'OVERSEAS' && warehouse.status === 'ACTIVE'
&& sources.every(row => !row.overseasWarehouseCode || row.overseasWarehouseCode === warehouse.warehouseCode),
`warehouseId=${warehouseId}`)
return { id: warehouseId, name: warehouse.warehouseName || warehouse.name,
code: warehouse.warehouseCode, sourceOrders: sources.map(row => ({ orderId: id(row.id),
customerId: id(row.customerId), sourceContainerBookingId: id(row.sourceContainerBookingId),
sourceInboundId: id(row.sourceInboundId), inboundCode: row.inboundCode, containerNo: row.containerNo,
overseasWarehouseId: id(row.overseasWarehouseId) })) }
}
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
export function preserveReceivingEvidence(proof, scope) {
proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {}
@@ -128,6 +169,19 @@ export function validSignatureGate(snapshot, firstSource, allSigned = false) {
&& snapshot?.rootInbound?.processStatus === 'DELIVERED'
&& snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED'
}
// TEST Lastleg runs with TZ=Asia/Shanghai. Its public signedAt is local;
// the completion event and Order projection preserve the same instant in UTC.
export function rootCompletionTimeMatches(completedAt, signedTimes) {
const instant = (value, offset) => {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)) return NaN
return Date.parse(value + (/(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? '' : offset))
}
if (!Array.isArray(signedTimes) || signedTimes.length === 0) return false
const signatures = signedTimes.map(value => instant(value, '+08:00'))
return signatures.every(Number.isFinite) && Number.isFinite(instant(completedAt, 'Z'))
&& instant(completedAt, 'Z') === Math.max(...signatures)
}
export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, sourceOrderIds) {
const expected = [...new Set(sourceOrderIds.map(id))].sort()
const rows = Array.isArray(evidence?.orders) ? evidence.orders : []
@@ -613,8 +667,13 @@ async function runRootFulfillment(context) {
&& id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED'
&& before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures')
proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before })
const producers = cancelRemainder ? [first] : [first,second]
proof.currentStage = 'OVERSEAS_SOURCE_WAREHOUSE'
check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`)
const warehouse = await resolveF40ReceivingWarehouse(scope,producers)
proof.receivingWarehouseResolution = warehouse
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope)
const access = await resolvePickerAccess(scope)
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
scope.marker = `${scope.context.f40BaseMarker}-PDA`
const picker = await createPicker(scope, 7, warehouse.id, access)
@@ -622,7 +681,6 @@ async function runRootFulfillment(context) {
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
picker.warehouseId = warehouse.id
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
const producers = cancelRemainder ? [first] : [first,second]
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers }
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
@@ -630,7 +688,6 @@ async function runRootFulfillment(context) {
receivingCompletionEvidence:{} }
preserveReceivingEvidence(proof,scope)
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`)
check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`)
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers
for (const [index, producer] of signatureSources.entries()) {
@@ -663,7 +720,7 @@ async function runRootFulfillment(context) {
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable)
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
check(scope,'F40 根完成时间为实际有效来源签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
check(scope,'F40 根完成时间为实际有效来源签收时间最大值', rootCompletionTimeMatches(stable.root.rawOrder?.completedAt, proof.signatures.map(row => row.signedAt)),
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
const reader = context.context?.f40ReadOnlyOutboxEvidence
const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId],
@@ -80,7 +80,9 @@ test('container API uses one stable long string identity', () => {
assert.match(controller, /convertContainer/)
assert.match(adminApi, /sourceContainerBookingId: string/)
assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/)
assert.match(adminView, /const commandSnapshot = preview\.resourceRetry \?\? preview/)
assert.match(adminView, /containerConversionIdempotencyKey = preview\.resourceRetry\.idempotencyKey/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: commandSnapshot\.snapshotVersion,\s*snapshotHash: commandSnapshot\.snapshotHash,/)
})
test('backend batch conversion is one transaction and never merges same-customer orders', () => {
@@ -65,7 +65,15 @@ export async function verifyVehicleCare(context, module, vehicle, proof) {
incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成',
details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`)
check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED'
&& (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost && incident.paymentStatus === 'UNLINKED')))
&& incident.paymentStatus == null
&& (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost
&& incident.details.incident.currency === details.incident.currency)))
if (priced) {
const financial = await command('POST', `${base}/finance-status`, { recordNos: [incident.recordNo] }, '独立批量查询本轮处理记录付款事实')
check('处理完成未自动提交财务或标记付款', null, Array.isArray(financial) && financial.length === 1
&& financial[0].recordNo === incident.recordNo && financial[0].status === 'UNLINKED'
&& financial[0].sourceVersion == null && financial[0].submittedRecordVersion == null && financial[0].finance == null)
}
const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`)
check('处理记录重新读取保持全部现场字段', null, (refreshed.details.incident.item ?? null) === details.incident.item
&& (refreshed.details.incident.place ?? null) === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人')