test(acceptance): reuse fixed customers and align native fulfillment flows
This commit is contained in:
@@ -1,20 +1,32 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`current-boxed-inventory.mjs` 是 PDA 与客户换标/转运共用的内部助手:仅读取本轮已收货入仓码,交叉核对正式箱候选、SKU 件数和箱所在托盘位置,不拼接 `CM-` 箱号或推算件数。共用的一件代发来源为五箱、两托,同一订单分别供应三箱 PDA 换标、一箱客户换标和一箱转运;废弃按正式候选提交具体 `cartonIds`。对应 `current-boxed-inventory-contract.test.mjs`。
|
||||
|
||||
预约取消保留原批次的逐票货物分配,脚本通过原预约更新接口恢复 PENDING,再核对分批与合并;取消不应被当成释放实物或另造新批次。客户后续服务完整读取尾程分页,包含一件代发来源;F40 排车候选同时限定本轮已转换计划和 RECEIVED 阶段,不以全仓默认二十条列表证明完整性。
|
||||
|
||||
第二柜先走原生报关、出仓及船期任务,保持尚不可接柜以供 PDA 拒绝用例;Admin 预约前由 `ensureAppointmentDeparture` 推进该柜实际船节点至开船并等待尾程消费,不用订单人工覆盖或 SQL 绕过货物核实门槛。对应 `appointment-departure-prerequisite-contract.test.mjs`。固定车辆司机选择前读取完整车次分页,已完成但未释放的资源仍视为占用;F40 来源核验读取完整分页后才证明唯一归属。
|
||||
|
||||
样品采购履约每次聚合回查均核对原客户、原采购单号和有效版本;创建重放及客户最终详情同样校验身份,串单立即停止后续写入。`procurement-sample-fulfillment-contract.test.mjs` 离线走完整个分次收货、双箱打包、两段交寄及 1 元货款、2 元运费冻结、1.5 元实际运费结算链,另以串单和重放重复扣款验证失败路径;这不代表真实钱包或部署已验收。
|
||||
|
||||
货物废弃的库存列表按客户、仓、目的仓和 SKU 聚合,不带入仓码;脚本按该 SKU 的代表余额 ID 回查原始详情,并要求客户、仓、入仓码及全部数量桶与聚合值一致,不能给聚合行补造来源,也不能让混合来源汇总冒充单一余额。散板余位、询价和实收的 Long 版本接受服务端规范十进制编码,仍拒绝空值、负值、前导零和不安全整数。
|
||||
|
||||
散板来货沿当前 PDA 接口创建空托盘草稿,生成并绑定业务托盘后,读取原预报箱号与 SKU,通过逐箱清点保存显式 TEST 尺寸、重量和件数,再打托及运营实收确认。两托三箱的受控输入逐箱登记,不把托盘草稿接口当作测量接口,也不从整托总数猜测箱规;这些合成材料不代表真机测量或照片效果验收。
|
||||
|
||||
货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。
|
||||
|
||||
本土卡车履约每次请求前后保存检查点:总编排提供 `checkpoint` 时沿用其证据持久化,否则独占创建 `reports/us-truck-checkpoints/<marker>.json`。同批次文件已存在即拒绝重跑,不覆盖旧证据;落盘失败不发送请求,发送后未知结果停止后续命令。记录原请求体、拒绝预期、诊断和最终状态,不记录会话凭据。对应磁盘失败、未知响应及新执行覆盖拒绝均有离线合同。
|
||||
|
||||
本土卡车默认履约在正式实收和分配前,各用原单增加一条无效命令:逐托箱数超过请求总箱数、重复选同一托盘。必须明确返回参数错误并带诊断标识,随后回查原单数量、状态、历史和版本完全不变,才继续正常链。两托原单仅分配首托时,还尝试进入待派车,必须返回 409/A0409 状态冲突并保留诊断标识,整个原单回查不变后才继续分配第二托;未知结果、无关拒绝或意外接受均停止。此边界不增加客户或业务单据,不替代超托审核。
|
||||
|
||||
客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运链存在源码待回归风险:PDA 完工调用 `settlePdaCompletion`,而 `completeWarehouseTransferFulfillment` 当前只接受 `FROZEN/NOT_REQUIRED`;脚本保留正式收货及结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。
|
||||
客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运关联换标的源码收货门槛接受 `FROZEN/NOT_REQUIRED/SETTLE_PENDING/SETTLED`,与 PDA 完工发起结算的时序衔接,结算中或已结算不重复发送费用与扣款命令;脚本仍保留正式收货及最终结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。
|
||||
|
||||
`warehouse-transfer-receipt.mjs` 在默认原转运出库后复用异仓既有公共空闲托盘,要求目的 FBA 一致、主档启用及库位可预留;不足时失败,不创建或修改公共资源。按原出库仓位正式收货,核对完成命令的权威目标、目标库存位置、唯一新 SKU 入库一箱流水、原键重放及客户详情,再有限轮询关联换标 `COMPLETED/SETTLED`。逐请求保存 `reports/warehouse-transfer-receipt-checkpoints/<marker>.json`;未知结果停写,不覆盖同批次,不直接修改资金状态。离线合同为 `warehouse-transfer-receipt-contract.test.mjs`;只补正常已有库存转运链,在途、多来源、部分收货与异常矩阵仍未全覆盖,真实 HTTP 待运行。
|
||||
|
||||
`warehouse-transfer-outbound.mjs` 由默认客户业务链在原转运关联换标确认后调用,仅接受登记的新 SKU 实托预留。出库前核对原单和零 SHIP 流水;出库后独立核对同客户、同仓、原入仓码、新 SKU、实际托盘号的唯一扣一箱流水,原键重放后单据及流水均不变,客户回查一致且关联换标同步 `OUTBOUNDED`。不另建转运单或客户;未知结果停写,证据保存于 `reports/warehouse-transfer-outbound-checkpoints/<marker>.json`,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-outbound-contract.test.mjs`;收货及关联换标结算由 warehouse-transfer-receipt.mjs 继续核验,尚未运行真实 HTTP。
|
||||
|
||||
已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。检查点独立保存 `reports/transfer-relabel-checkpoints/<marker>.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。
|
||||
固定客户全量在 DROPSHIP 来源造单前先只读筛选其他海外仓既有公共空闲托盘、目的 FBA、可预留库位和启用主档,再把来源线路限制到该目的仓并核验真实报价;收货前重新核对资源,不能把预检当预留,也不改写公共托盘目的仓或补造主档。
|
||||
|
||||
已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。拆合托后按当前库存版本查询源、目标非空托盘的尺寸重量;箱级事实不完整时阻断,不以整托输入绕过。无箱级实测的托盘通过正式 PDA 接口提交报告中明确标记的受控 TEST 尺寸重量,属于合成测试输入,不代表硬件测量。幂等键包含当前换标申请号,避免同一操作员执行客户贴标和转运贴标时串单。检查点独立保存 `reports/transfer-relabel-checkpoints/<marker>.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。
|
||||
|
||||
`warehouse-transfer-allocation.mjs` 由默认 `phase4-overseas-client-services.mjs` 在本轮已有库存转运审核通过后调用。仅分配原入仓单、同 SKU、同客户同仓的真实可用仓位,核对关联换标申请唯一、同键重放及客户详情一致,达到 `WAIT_RELABEL`。不另建客户或替代转运单;请求前后保存 `reports/warehouse-transfer-checkpoints/<marker>.json`,未知结果停写,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-allocation-contract.test.mjs`;关联换标完工与转运出库已衔接,收货已由后续助手衔接,尚未运行真实 HTTP。
|
||||
|
||||
@@ -192,7 +204,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
`phase4-overseas-return-followups.mjs` 已接入默认全量,在退货签收合箱后执行换标、客户确认及普通/换标后出库;显式只选签收模块仍不运行后续。前置必须是同批次、同客户、主链断言通过的退货资源,仓库严格沿用实际退货收货仓并核对当前启用状态,不回退其他仓。固定客户模式保留跨客户、跨仓和并发边界缺口,不创建第二客户;后续复用既有 PDA 池,两个模块均退出本轮客户会话。写请求网络结果未知不自动重试。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;当前只补脚本,真实 HTTP 及页面仍待执行。
|
||||
|
||||
退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。
|
||||
退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。换标来源明确返回 `palletCode=UNBOUND` 且 `palletId=0` 时,按未组托退货箱提交空来源托盘;实体来源仍先回查真实托盘版本,不能把任意解析失败当作未组托。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。
|
||||
|
||||
退货转换标请求按当前契约传 `items[{skuCode, quantity}]`、`fileScope` 和真实 FBA 目的仓 ID/编码,返回 `PENDING_REVIEW` 与 `skuCodes`。分页查找启用的 SCK8,缺失则阻断;总箱标文件回查 `cartonTotalFileId`。独立退货出库候选使用查询参数 `warehouseId`。同模块的 `completeReturnRelabel` 在独立出库之后继续审核本轮换标申请、PDA 认领与数量完成、来源托盘解析、正式生成目标托盘、组托及原键重放、上传合成完工图片、提交完工、客户确认及原键重放,最终回查原退货单仍为签收完成且换标关联一致。它不执行其他客户库存操作;目标托盘是本轮换标业务资源,不是国内基础托盘。客户确认后 `outboundRelabelledReturn` 逐箱核对本轮换标号、客户、仓库、来源单与数量,只为已完成换标的一件库存创建出库,检查创建原键重放、PDA 拣货/完成与平台、客户库存 `OUTBOUNDED` 终态;另存 `relabelOutboundNo`,不能用此前未冻结库存的独立出库结果替代。
|
||||
|
||||
@@ -228,6 +240,8 @@ PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据
|
||||
|
||||
`fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。
|
||||
|
||||
跨客户场景可显式配置 `ADMIN_TEST_SECONDARY_CUSTOMER_ID`、`ADMIN_TEST_SECONDARY_MERCHANT_USERNAME`、`ADMIN_TEST_SECONDARY_MERCHANT_PASSWORD`,仅复用第二个启用的既有客户。资质准备按调用场景选择主客户或第二客户,先验证账号真实归属与两个客户不同;混合报关、跨客户柜费、财务隔离消费同一配置,不创建替代客户。缺失、不完整或指定为同一客户时停止对应跨客户造数;主流程仍使用 `ADMIN_TEST_FIXED_CUSTOMER_ID`,临时切换客户上下文结束后恢复。
|
||||
|
||||
固定客户模式下 `fee-items` 只查询现有费用目录、核对112条正式基线及客户权限,不新增、编辑或删除费用主档,增删改生命周期单独记录为未覆盖。所有模式均不自动删除历史 `ACPT_` 项目;非固定模式的隔离写入仅清理本轮返回的项目和明细 ID。
|
||||
|
||||
`finance-full-fixtures` 在固定客户模式下只复用已有不包税计费规则:唯一规则或唯一默认规则必须属于当前客户并匹配所需计费方式;缺失、不匹配或默认规则有歧义即停止,不自动创建规则版本或切换默认规则。供应商同样复用既有启用记录;下述创建隔离供应商的说明仅适用于非固定客户模式。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ensureAppointmentDeparture } from './phase4-overseas-inbound-operations.mjs'
|
||||
|
||||
const producer = { marker: 'RUN', carrierScheduleQueryId: '10', tenantId: '20', containerBookingId: '30', containerNo: 'BOX' }
|
||||
function fixture(fault = '', departed = false) {
|
||||
const calls = [], task = { id: '10', customerId: fault === 'foreign-customer' ? 'OTHER' : '20',
|
||||
containerBookingId: '30', trackingNo: 'BOX', version: '0', latestActualNode: departed ? 'DEPARTED_ORIGIN_PORT' : null }
|
||||
const context = { marker: 'RUN', createdResources: {}, async checkpoint() {}, addStep() {},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
if (method === 'POST') {
|
||||
if (fault === 'unknown') throw Error('unknown write result')
|
||||
assert.equal(body.expectedVersion, Number(task.version))
|
||||
task.version = String(Number(task.version) + 1); task.latestActualNode = body.standardNode; task.latestActualAt = body.actualAt
|
||||
}
|
||||
const data = path.includes('/last-leg/orders/') ? { order: { customerId: '20', sourceContainerBookingId: '30',
|
||||
containerNode: task.latestActualNode, manualNodeOverride: false } } : { query: { query: structuredClone(task) } }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
return { context, calls }
|
||||
}
|
||||
test('appointment source advances native container shipping and proves Lastleg consumption before appointment writes', async () => {
|
||||
const { context, calls } = fixture()
|
||||
assert.equal((await ensureAppointmentDeparture(context, producer, '40')).status, 'CONSUMED')
|
||||
assert.deepEqual(calls.filter(call => call.method === 'POST').map(call => call.body.standardNode),
|
||||
['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT'])
|
||||
assert.ok(calls.filter(call => call.method === 'POST').every(call => call.path.startsWith('/api/platform/v1/carrier-schedule-queries/10/manual-nodes')))
|
||||
})
|
||||
test('already departed source is reused without adding shipping nodes', async () => {
|
||||
const { context, calls } = fixture('', true)
|
||||
await ensureAppointmentDeparture(context, producer, '40')
|
||||
assert.ok(calls.every(call => call.method === 'GET'))
|
||||
})
|
||||
test('foreign source and unknown write stop without further mutation', async () => {
|
||||
const foreign = fixture('foreign-customer')
|
||||
await assert.rejects(ensureAppointmentDeparture(foreign.context, producer, '40'), /身份/)
|
||||
assert.ok(foreign.calls.every(call => call.method === 'GET'))
|
||||
const unknown = fixture('unknown')
|
||||
await assert.rejects(ensureAppointmentDeparture(unknown.context, producer, '40'), /unknown/)
|
||||
assert.equal(unknown.calls.filter(call => call.method === 'POST').length, 1)
|
||||
assert.equal(unknown.context.createdResources.appointmentDeparturePrerequisite.commands[0].status, 'UNKNOWN_RESULT')
|
||||
})
|
||||
@@ -7,6 +7,8 @@ function harness(fault = '', transfer = false) {
|
||||
const proof = { marker: 'RELABEL-TEST', relabelNo: 'TB202610220001', customerId: '101', warehouseId: '201',
|
||||
sourcePositionId: '301', sourcePalletNo: 'SOURCE', sourceOrderNo: 'WC202610220001' }
|
||||
let status = 'PENDING_REVIEW', moved = false, scanned = false, version = 0, taskStatus = 'AVAILABLE'
|
||||
const palletMeasurements = ['SOURCE', 'TARGET'].map((palletCode, index) => ({ palletId: String(500 + index), palletCode,
|
||||
packages: index ? 1 : 2, stockVersion: `CURRENT-${palletCode}`, cartonAggregated: false }))
|
||||
const item = { itemId: '401', expectedPackages: 1, cartonMark: 'BOX-TEST', fbaCartonMark: 'FBA-TEST',
|
||||
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'SKU', newSkuCode: 'NEW', quantity: 1 }] }
|
||||
const detail = () => ({ application: { relabelNo: proof.relabelNo, customerOrderNo: proof.sourceOrderNo,
|
||||
@@ -35,6 +37,13 @@ function harness(fault = '', transfer = false) {
|
||||
if (fault === 'unknown scan') throw new Error('unknown scan response')
|
||||
scanned = true; if (!repeated) version++; data = task()
|
||||
if (fault === 'scan replay drift' && repeated) data.relabelledPackages = 2
|
||||
} else if (path.endsWith('/relabel-pallet-measurements')) data = structuredClone(palletMeasurements)
|
||||
else if (path.endsWith('/relabel-pallet-measure')) {
|
||||
const row = palletMeasurements.find(value => value.palletCode === body.palletCode)
|
||||
assert.equal(body.stockVersion, row.stockVersion)
|
||||
if (fault !== 'measurement not saved') Object.assign(row, { lengthCm: body.lengthCm, widthCm: body.widthCm,
|
||||
heightCm: body.heightCm, grossWeightKg: body.grossWeightKg, volumeCbm: 0.96 })
|
||||
data = structuredClone(palletMeasurements)
|
||||
} else if (path.endsWith('/relabel-pallet-label')) data = { palletId: '501', palletCode: 'TARGET' }
|
||||
else if (path.endsWith('/pallets/resolve')) {
|
||||
const source = body.code === 'SOURCE'
|
||||
@@ -79,6 +88,23 @@ test('original customer relabel reaches ready for dispatch through real command
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /不自动重跑/)
|
||||
})
|
||||
|
||||
test('same operator and batch can execute customer and transfer applications without sharing operation keys', async () => {
|
||||
const first = harness(), second = harness('', true)
|
||||
second.inputs.proof.relabelNo = 'TB202610220002'
|
||||
second.inputs.transferProof.relabelNo = second.inputs.proof.relabelNo
|
||||
await verifyClientRelabelFulfillment(first.context, first.inputs)
|
||||
const used = new Map(first.calls.filter(call => call.body?.idempotencyKey).map(call => [call.body.idempotencyKey, call.path]))
|
||||
const request = second.context.request
|
||||
second.context.request = async (method, path, body, token) => {
|
||||
if (body?.idempotencyKey && used.has(body.idempotencyKey) && used.get(body.idempotencyKey) !== path)
|
||||
return { http: 200, body: { code: 'A0404' } }
|
||||
return request(method, path, body, token)
|
||||
}
|
||||
const result = await verifyClientRelabelFulfillment(second.context, second.inputs)
|
||||
assert.equal(result.execution.status, 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND')
|
||||
assert.ok(second.calls.filter(call => call.body?.idempotencyKey).every(call => !used.has(call.body.idempotencyKey)))
|
||||
})
|
||||
|
||||
for (const fault of ['wrong customer', 'wrong warehouse', 'no funds', 'unknown scan', 'scan replay drift', 'no source deduction', 'disk']) {
|
||||
test(`relabel stops before completion on ${fault}`, async () => {
|
||||
const h = harness(fault)
|
||||
@@ -109,6 +135,13 @@ test('已有库存转运原关联换标完成后回到原转运新SKU预留', as
|
||||
assert.equal(h.inputs.transferProof.outboundAllocations[0].palletId, '501')
|
||||
})
|
||||
|
||||
test('转运拆合托补测未保存时禁止完工和客户确认', async () => {
|
||||
const h = harness('measurement not saved', true)
|
||||
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /补测回执/)
|
||||
assert.equal(h.inputs.proof.execution.status, 'FAILED')
|
||||
assert.equal(h.calls.some(call => call.path.endsWith('/complete') || call.path.endsWith('/confirm-completion')), false)
|
||||
})
|
||||
|
||||
for (const fault of ['wrong fulfillment', 'wrong target']) {
|
||||
test(`转运关联换标拒绝 ${fault}`, async () => {
|
||||
const h = harness(fault, true)
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function verifyClientRelabelFulfillment(context, { proof, transferP
|
||||
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'client-relabel-fulfillment', deploymentBlocker: true })
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const key = action => `${context.marker}-CLIENT-RELABEL-${action}`
|
||||
const key = action => `${context.marker}-CLIENT-RELABEL-${proof.relabelNo}-${action}`
|
||||
const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
|
||||
const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
|
||||
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(proof.relabelNo)}`
|
||||
@@ -120,6 +120,26 @@ export async function verifyClientRelabelFulfillment(context, { proof, transferP
|
||||
require('换标原箱源减一目标增一且组托重放复用操作', validId(bound.operationId) && bound.status === 'COMPLETED' && boundReplay.replayed === true
|
||||
&& String(bound.operationId) === String(boundReplay.operationId)
|
||||
&& Number(fromAfter.totalPackages) === Number(from.totalPackages) - 1 && Number(toAfter.totalPackages) === Number(to.totalPackages) + 1)
|
||||
if (transferProof) {
|
||||
// 明确的受控TEST补测输入;不按箱数、原托尺寸或计费重量推算,也不作为真实硬件实测证据。
|
||||
const input = { lengthCm: 120, widthCm: 100, heightCm: 80, grossWeightKg: 12.5 }
|
||||
proof.palletMeasurementInput = { syntheticTestMaterials: true, input }
|
||||
let measurements = await command('POST', `${PALLETS}/relabel-pallet-measurements`, { relabelNo: proof.relabelNo }, pdaToken)
|
||||
const codes = new Set([from.palletCode, target.palletCode])
|
||||
require('补测只接受本次源目标非空托盘及当前库存版本', Array.isArray(measurements) && measurements.length > 0
|
||||
&& measurements.every(row => validId(row.palletId) && codes.has(row.palletCode) && Number(row.packages) > 0 && row.stockVersion))
|
||||
for (const row of [...measurements]) {
|
||||
if (['grossWeightKg', 'volumeCbm'].every(field => Number(row[field]) > 0)
|
||||
&& (row.cartonAggregated || ['lengthCm', 'widthCm', 'heightCm'].every(field => Number(row[field]) > 0))) continue
|
||||
require('箱级实测不完整时不能用整托输入绕过', row.cartonAggregated !== true)
|
||||
measurements = await command('POST', `${PALLETS}/relabel-pallet-measure`, { relabelNo: proof.relabelNo,
|
||||
palletCode: row.palletCode, stockVersion: row.stockVersion, idempotencyKey: key(`measure-${row.palletId}`), ...input }, pdaToken)
|
||||
const measured = measurements.find(value => String(value.palletId) === String(row.palletId))
|
||||
require('补测回执保持本托库存版本和明确TEST输入', measured?.stockVersion === row.stockVersion
|
||||
&& Object.entries(input).every(([field, value]) => Number(measured[field]) === value) && Number(measured.volumeCbm) > 0)
|
||||
}
|
||||
proof.palletMeasurements = measurements
|
||||
}
|
||||
task = await command('GET', work, undefined, pdaToken)
|
||||
const photo = await command('POST', `${work}/photos/files`, { name: `${context.marker}-synthetic-relabel.png`, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') }, pdaToken, true)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const calls = []
|
||||
const context = { marker: 'RUN', createdResources: { phase4OverseasPda: {
|
||||
marker: fault === 'old-run' ? 'OLD' : 'RUN', finalStatus: 'COMPLETED', overseasWarehouseId: 'WH',
|
||||
generatedPalletsByInbound: { IN: [{}] },
|
||||
} }, apiSuccess(_m, _n, response) { return response.body.data },
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path }); assert.equal(method, 'GET'); assert.equal(token, 'merchant')
|
||||
let data
|
||||
if (path.includes('/cargo-discards/')) data = [{ inboundCode: 'IN', orderId: '100', overseasWarehouseId: 'WH',
|
||||
cartons: [{ cartonId: '200', cartonMark: 'BOX', palletNo: 'REAL' }] }]
|
||||
else if (path.includes('/inventory-source-cartons?')) {
|
||||
data = [{ cartonMark: 'BOX', items: [{ skuCode: 'SKU', quantity: fault === 'unknown-quantity' ? null : '10' }] }]
|
||||
if (fault === 'duplicate-carton') data.push(data[0])
|
||||
} else data = [{ sourceType: 'INBOUND', inboundCode: fault === 'foreign-inbound' ? 'OTHER' : 'IN',
|
||||
overseasWarehouseId: fault === 'foreign-warehouse' ? 'OTHER' : 'WH', inventoryBalanceId: '10', inboundOrderId: '100', skuCode: 'SKU',
|
||||
positions: [{ positionId: '20', palletNo: fault === 'wrong-pallet' ? 'UNBOUND' : 'REAL', availablePackages: '2' }] }]
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
return { context, calls }
|
||||
}
|
||||
|
||||
test('boxed source joins only current inbound, authoritative carton SKU quantities and real physical pallet', async () => {
|
||||
const { context, calls } = fixture()
|
||||
const [source] = await readCurrentBoxedInventory(context, 'merchant', 'WH', 'test')
|
||||
assert.equal(source.cartons[0].cartonMark, 'BOX')
|
||||
assert.equal(source.cartons[0].positionId, '20')
|
||||
assert.equal(source.cartons[0].items[0].quantity, '10')
|
||||
assert.ok(calls.every(call => !call.path.includes('OTHER')))
|
||||
})
|
||||
for (const fault of ['unknown-quantity', 'wrong-pallet', 'foreign-inbound', 'foreign-warehouse']) test(`boxed source rejects unsupported or foreign facts: ${fault}`, async () => {
|
||||
assert.deepEqual(await readCurrentBoxedInventory(fixture(fault).context, 'merchant', 'WH', 'test'), [])
|
||||
})
|
||||
test('old marker blocks before HTTP; duplicate authoritative carton marks stop instead of choosing first', async () => {
|
||||
const old = fixture('old-run')
|
||||
await assert.rejects(readCurrentBoxedInventory(old.context, 'merchant', 'WH', 'test'), /本轮/)
|
||||
assert.equal(old.calls.length, 0)
|
||||
await assert.rejects(readCurrentBoxedInventory(fixture('duplicate-carton').context, 'merchant', 'WH', 'test'), /重复/)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const positive = value => /^[1-9][0-9]*$/.test(id(value)) && Number.isSafeInteger(Number(value))
|
||||
|
||||
/** 仅组合本轮已收货入仓单的权威箱、SKU 和实际托盘位置,不生成箱号或推算每箱件数。 */
|
||||
export async function readCurrentBoxedInventory(context, token, warehouseId, module) {
|
||||
const pda = context.createdResources?.phase4OverseasPda
|
||||
if (pda?.marker !== context.marker || pda.finalStatus !== 'COMPLETED'
|
||||
|| id(pda.overseasWarehouseId) !== id(warehouseId)) throw Error('真实箱来源缺少本轮已收货仓库事实')
|
||||
const inbounds = Object.keys(pda.generatedPalletsByInbound || {})
|
||||
if (!inbounds.length) throw Error('真实箱来源缺少本轮入仓单')
|
||||
const get = async path => {
|
||||
const response = await context.request('GET', path, undefined, token, {})
|
||||
const data = context.apiSuccess(module, '核对本轮库存真实箱与SKU来源', response)
|
||||
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !Array.isArray(data))
|
||||
throw Error('真实箱来源接口未返回完整列表')
|
||||
return data
|
||||
}
|
||||
const physical = await get('/api/merchant/v1/last-leg/cargo-discards/inventory-candidates')
|
||||
const result = []
|
||||
for (const inboundCode of inbounds) {
|
||||
const scope = { operatingWarehouseId: id(warehouseId), inboundCode }
|
||||
const cartons = await get('/api/merchant/v1/last-leg/warehouse-transfers/inventory-source-cartons?' + new URLSearchParams(scope))
|
||||
if (!cartons.length) continue
|
||||
const inventory = await get('/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?'
|
||||
+ new URLSearchParams({ operationWarehouseId: id(warehouseId), inboundCode }))
|
||||
for (const source of inventory.filter(row => row.sourceType === 'INBOUND' && row.inboundCode === inboundCode
|
||||
&& id(row.overseasWarehouseId) === id(warehouseId) && id(row.inventoryBalanceId) && id(row.inboundOrderId))) {
|
||||
const boxes = physical.filter(row => row.inboundCode === inboundCode && id(row.overseasWarehouseId) === id(warehouseId)
|
||||
&& id(row.orderId) === id(source.inboundOrderId)).flatMap(row => row.cartons || [])
|
||||
const seen = new Set()
|
||||
const actual = []
|
||||
for (const carton of cartons) {
|
||||
if (!carton.cartonMark || seen.has(carton.cartonMark)) throw Error('权威来源箱号缺失或重复')
|
||||
seen.add(carton.cartonMark)
|
||||
const matches = boxes.filter(box => box.cartonMark === carton.cartonMark)
|
||||
if (matches.length !== 1) continue
|
||||
const box = matches[0]
|
||||
const positions = (source.positions || []).filter(position => position.palletNo === box.palletNo
|
||||
&& box.palletNo && box.palletNo.toUpperCase() !== 'UNBOUND' && Number(position.availablePackages) > 0)
|
||||
const items = carton.items || []
|
||||
if (positions.length !== 1 || !items.length || items.some(item => !item.skuCode || !positive(item.quantity))
|
||||
|| !items.some(item => item.skuCode === source.skuCode)) continue
|
||||
actual.push({ ...carton, cartonId: id(box.cartonId), positionId: id(positions[0].positionId), palletNo: box.palletNo })
|
||||
}
|
||||
if (actual.length) result.push({ ...source, cartons: actual })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -236,24 +236,27 @@ async function verifyFrozenSource(context, fixture) {
|
||||
}
|
||||
|
||||
async function createSecondCustomer(context) {
|
||||
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户财务写入')
|
||||
if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('缺少第二个固定客户,停止跨客户财务写入')
|
||||
const originalMarker = context.marker
|
||||
const originalContext = context.context?.merchantQualificationFixture
|
||||
const originalResource = context.createdResources?.merchantQualificationFixture
|
||||
const originalCustomerId = context.context?.customerId
|
||||
const originalUsername = process.env.MERCHANT_TEST_USERNAME
|
||||
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
|
||||
context.marker = `${originalMarker}-FIN-ISO`
|
||||
try {
|
||||
const module = await import(new URL('./merchant-qualification-fixture.mjs', import.meta.url))
|
||||
await module.run(context)
|
||||
await module.run(context, { publishEnvironment: false, requireDistinctCustomer: Boolean(context.options?.fixedCustomerId) })
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
if (!fixture?.tenantId || !fixture?.username || !fixture?.password || fixture.marker !== context.marker) return null
|
||||
const loginResponse = await context.request('POST', '/api/auth/login',
|
||||
{ loginId: fixture.username, password: fixture.password }, '')
|
||||
const login = context.apiSuccess(MODULE, '第二隔离客户登录', loginResponse, diagnostic(loginResponse), OPTIONS)
|
||||
return { customerId: id(fixture.tenantId), token: login?.accessToken }
|
||||
return { customerId: id(fixture.tenantId), token: login?.accessToken, refreshToken: login?.refreshToken }
|
||||
} finally {
|
||||
context.marker = originalMarker
|
||||
if (originalCustomerId === undefined) delete context.context.customerId
|
||||
else context.context.customerId = originalCustomerId
|
||||
if (originalContext) context.context.merchantQualificationFixture = originalContext
|
||||
else delete context.context.merchantQualificationFixture
|
||||
if (originalResource) context.createdResources.merchantQualificationFixture = originalResource
|
||||
@@ -267,32 +270,39 @@ async function createSecondCustomer(context) {
|
||||
|
||||
async function verifyCustomerIsolation(context, fixture) {
|
||||
const second = await createSecondCustomer(context)
|
||||
const target = rows(fixture.statements).find((item) => statementId(item))
|
||||
const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean)
|
||||
if (!second?.customerId || !second?.token || !target || !apSource) {
|
||||
blocker(context, '建立第二隔离客户与跨客户单据 Fixture',
|
||||
`customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`,
|
||||
null, 'second-customer-fixture-unavailable')
|
||||
return
|
||||
try {
|
||||
const target = rows(fixture.statements).find((item) => statementId(item))
|
||||
const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean)
|
||||
if (!second?.customerId || !second?.token || !target || !apSource) {
|
||||
blocker(context, '建立第二隔离客户与跨客户单据 Fixture',
|
||||
`customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`,
|
||||
null, 'second-customer-fixture-unavailable')
|
||||
return
|
||||
}
|
||||
const targetId = statementId(target)
|
||||
const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token)
|
||||
const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`,
|
||||
{ expectedVersion: Number(target.version ?? 0) }, second.token,
|
||||
{ 'Idempotency-Key': key(context, 'foreign-confirm') })
|
||||
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, {
|
||||
customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY',
|
||||
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker,
|
||||
allocations: [{ billId: targetId, amount: 0.01 }],
|
||||
}, 'foreign-receipt')
|
||||
const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, {
|
||||
customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId),
|
||||
feeSourceIds: [apSource],
|
||||
dueDate: today(), currency: 'CNY',
|
||||
}, 'foreign-payable')
|
||||
context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm,
|
||||
[read, confirm, receipt, payment].every(rejected),
|
||||
`read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS)
|
||||
} finally {
|
||||
if (second?.token) {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: second.refreshToken }, second.token)
|
||||
context.apiSuccess(MODULE, '退出第二固定客户隔离测试会话', logout, diagnostic(logout), OPTIONS)
|
||||
}
|
||||
}
|
||||
const targetId = statementId(target)
|
||||
const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token)
|
||||
const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`,
|
||||
{ expectedVersion: Number(target.version ?? 0) }, second.token,
|
||||
{ 'Idempotency-Key': key(context, 'foreign-confirm') })
|
||||
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, {
|
||||
customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY',
|
||||
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker,
|
||||
allocations: [{ billId: targetId, amount: 0.01 }],
|
||||
}, 'foreign-receipt')
|
||||
const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, {
|
||||
customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId),
|
||||
feeSourceIds: [apSource],
|
||||
dueDate: today(), currency: 'CNY',
|
||||
}, 'foreign-payable')
|
||||
context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm,
|
||||
[read, confirm, receipt, payment].every(rejected),
|
||||
`read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
|
||||
@@ -45,7 +45,7 @@ test('module freezes all required real HTTP invariants and provisions a formal c
|
||||
'非柜内客户不能读取柜次费用', 'requestId=', 'traceId=', 'deploymentBlocker: true',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
for (const token of [
|
||||
'createMerchant(context)', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan',
|
||||
'createMerchant(context, { publishEnvironment: false, requireDistinctCustomer:', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan',
|
||||
'originalUsername', 'originalPassword', 'delete process.env.MERCHANT_TEST_USERNAME',
|
||||
'delete process.env.MERCHANT_TEST_PASSWORD',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const MODULE = 'firstleg-container-fee-allocation'
|
||||
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
|
||||
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { run as createMerchant, requireFixedSecondaryCustomer } from './merchant-qualification-fixture.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
@@ -105,7 +105,7 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) {
|
||||
const runMarker = context.marker
|
||||
context.marker = `${runMarker}-${suffix}`
|
||||
if (fixedRoute) context.context.waitSplitRoute = fixedRoute
|
||||
await createMerchant(context)
|
||||
await createMerchant(context, { publishEnvironment: false, requireDistinctCustomer: suffix === 'ALLOC-B' })
|
||||
const orders = await import(new URL(`./orders.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url))
|
||||
const measurementModule = await import(new URL(`./inbound-measurement.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url))
|
||||
await orders.run(context)
|
||||
@@ -122,12 +122,14 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) {
|
||||
}
|
||||
|
||||
async function provisionCrossCustomerPlan(context) {
|
||||
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户柜费造数')
|
||||
if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('缺少第二个固定客户,停止跨客户柜费造数')
|
||||
if (context.options?.fixedCustomerId) requireFixedSecondaryCustomer(context.options)
|
||||
const originalMarker = context.marker
|
||||
const originalUsername = process.env.MERCHANT_TEST_USERNAME
|
||||
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
|
||||
const originalRoute = context.context.waitSplitRoute
|
||||
const originalMerchant = context.context.merchantQualificationFixture
|
||||
const originalCustomerId = context.context.customerId
|
||||
try {
|
||||
const first = await createCustomerWaitSplit(context, 'ALLOC-A')
|
||||
const route = {
|
||||
@@ -173,6 +175,8 @@ async function provisionCrossCustomerPlan(context) {
|
||||
context.marker = originalMarker
|
||||
context.context.waitSplitRoute = originalRoute
|
||||
context.context.merchantQualificationFixture = originalMerchant
|
||||
if (originalCustomerId === undefined) delete context.context.customerId
|
||||
else context.context.customerId = originalCustomerId
|
||||
if (originalUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME
|
||||
else process.env.MERCHANT_TEST_USERNAME = originalUsername
|
||||
if (originalPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD
|
||||
|
||||
@@ -41,6 +41,12 @@ function model(fault = '', countPatch = {}) {
|
||||
async requestMultipart(method, raw, file, token = this.token) {
|
||||
calls.push({ method, raw, token, multipart: true }); assert.ok(file.content.length > 0)
|
||||
if (raw.includes('/license?')) return response({ id: 'DRIVER', recordVersion: '1', licenseFileId: 'LICENSE' })
|
||||
const evidenceKind = raw.match(/^\/api\/platform\/v1\/last-leg\/dispatches\/DS-ONE\/evidence\/(VEHICLE|DRIVER_PHOTO|DRIVER)$/)?.[1]
|
||||
if (evidenceKind) {
|
||||
assert.equal(token, 'admin-secret')
|
||||
assert.equal(file.type, 'image/png')
|
||||
return response({ fileId: { VEHICLE: '901', DRIVER_PHOTO: '902', DRIVER: '903' }[evidenceKind] })
|
||||
}
|
||||
assert.match(raw, /\/warehouse-transfers\/IN[01]\/measurement-photos\/files$/); assert.equal(token, options.pdaToken)
|
||||
return response({ fileId: 'PHOTO', sizeBytes: file.content.length })
|
||||
},
|
||||
@@ -168,6 +174,9 @@ function model(fault = '', countPatch = {}) {
|
||||
if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) {
|
||||
if (method === 'PUT') {
|
||||
assert.equal(body.recordVersion, native.recordVersion)
|
||||
assert.deepEqual(body.vehicle.evidenceFileIds, ['901'])
|
||||
assert.deepEqual(body.driver.evidence.photoFileIds, ['902'])
|
||||
assert.deepEqual(body.driver.evidence.fileIds, ['903'])
|
||||
Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId })
|
||||
native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } }
|
||||
native.recordVersion++
|
||||
|
||||
@@ -141,6 +141,8 @@ function server(fault = '') {
|
||||
return ok({ id: DRIVER, reviewStatus: 'APPROVED', recordVersion: ++driverVersion })
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) {
|
||||
if (!new URL(path, 'https://unused.test').searchParams.has('keyword'))
|
||||
return ok({ records: [], page: 1, size: 200, total: 0 })
|
||||
const rows = fault === 'missing-trip' ? [] : [trip, ...(nextTrip ? [nextTrip] : [])]
|
||||
if (fault === 'duplicate-trip') rows.push({ ...trip, tripNo: 'TR-B' })
|
||||
const wrong = { 'wrong-plate': { vehiclePlate: `${trip.vehiclePlate}-OTHER` },
|
||||
@@ -321,6 +323,18 @@ test('fixed customer reuses master records and uploads only this dispatch review
|
||||
assert.ok(s.calls.filter(call => call.method !== 'GET').every(call => call.path.startsWith(D)))
|
||||
})
|
||||
|
||||
test('review preparation preserves existing maintenance and appointment numbers while filling missing TEST destination numbers', async () => {
|
||||
const s = server()
|
||||
s.detail.vehicleInfo.maintenance = { loadType: '托盘', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ORIGINAL' }] }
|
||||
s.detail.dispatchInfo.fbaAppointments = [{ destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', appointmentNo: 'ORIGINAL' },
|
||||
{ destinationKey: 'FBA:ABE3', destinationWarehouseCode: 'ABE3', status: 'PENDING' }]
|
||||
const proof = await prepare(s)
|
||||
assert.equal(proof.updateRequest.maintenance.loadType, '托盘')
|
||||
assert.equal(proof.updateRequest.maintenance.destinationAppointments[0].appointmentNo, 'ORIGINAL')
|
||||
assert.match(proof.updateRequest.maintenance.destinationAppointments[1].appointmentNo, /^TEST-/)
|
||||
assert.equal(proof.updateRequest.maintenance.destinationAppointments[1].destinationKey, 'FBA:ABE3')
|
||||
})
|
||||
|
||||
test('fixed fleet resources block missing, locally occupied and ineligible pairs without master writes', async () => {
|
||||
const pair = { vehicleId: VID, driverId: DRIVER }
|
||||
for (const fault of ['missing', 'occupied', 'vehicle-status', 'driver-qualification']) {
|
||||
@@ -329,6 +343,7 @@ test('fixed fleet resources block missing, locally occupied and ineligible pairs
|
||||
createdResources: { fleetTrips: fault === 'occupied' ? [{ ...pair }] : [proof] },
|
||||
async request(method, path) {
|
||||
calls.push(path); assert.equal(method, 'GET')
|
||||
if (path.includes('/fleet/trips?')) return ok({ records: [], page: 1, size: 200, total: 0 })
|
||||
return path.includes('/vehicles/') ? ok({ id: VID, status: fault === 'vehicle-status' ? 'DISPATCHED' : 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: fault !== 'driver-qualification', reason: 'ELIGIBLE' } })
|
||||
}, apiSuccess(_m, _l, response) { return response.body.data } }
|
||||
@@ -343,6 +358,7 @@ test('verified resource closure makes a fixed pair available again', async () =>
|
||||
createdResources: { fleetTrips: [{ vehicleId: VID, driverId: DRIVER, closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, proof] },
|
||||
async request(method, path) {
|
||||
assert.equal(method, 'GET')
|
||||
if (path.includes('/fleet/trips?')) return ok({ records: [], page: 1, size: 200, total: 0 })
|
||||
return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
|
||||
}, apiSuccess(_m, _l, response) { return response.body.data } }
|
||||
@@ -351,6 +367,25 @@ test('verified resource closure makes a fixed pair available again', async () =>
|
||||
assert.equal(proof.driverId, DRIVER)
|
||||
})
|
||||
|
||||
test('fixed pool skips externally occupied resources, even completed trips without release', async () => {
|
||||
for (const status of ['PLANNED', 'COMPLETED']) {
|
||||
const proof = {}, calls = []
|
||||
const context = { options: { fixedFleetResources: [{ vehicleId: '11', driverId: '12' }, { vehicleId: VID, driverId: DRIVER }] },
|
||||
createdResources: { fleetTrips: [proof] }, apiSuccess(_m, _l, response) { return response.body.data },
|
||||
async request(method, path) {
|
||||
calls.push(path); assert.equal(method, 'GET')
|
||||
if (path.includes('/fleet/trips?')) return ok({ records: [{ tripNo: 'FOREIGN', status, vehicleId: '11', driverId: '12' }], page: 1, size: 200, total: 1 })
|
||||
return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
|
||||
} }
|
||||
await reuseFleetResources(context, 'test', proof)
|
||||
assert.equal(proof.vehicleId, VID); assert.equal(proof.driverId, DRIVER)
|
||||
assert.ok(calls.every(path => !path.includes('/vehicles/11') && !path.includes('/drivers/12')))
|
||||
context.options.fixedFleetResources.pop()
|
||||
await assert.rejects(reuseFleetResources(context, 'test', {}), /固定车辆司机/)
|
||||
}
|
||||
})
|
||||
|
||||
test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => {
|
||||
const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED'
|
||||
const proof = await prepare(s)
|
||||
|
||||
@@ -51,7 +51,7 @@ async function discoverTrip(context, module, proof) {
|
||||
for (let page = 1; ; page++) {
|
||||
const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId })
|
||||
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按实际车牌读取自动资源记录')
|
||||
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|
||||
if (!Array.isArray(data.records) || data.total == null || data.total === '' || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|
||||
|| Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联')
|
||||
total ??= Number(data.total)
|
||||
if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && rows.length < total)) throw new Error('车次分页变化或缺页')
|
||||
@@ -83,9 +83,28 @@ export async function reuseFleetResources(context, module, proof, arrival) {
|
||||
const pairs = context.options.fixedFleetResources
|
||||
const busy = (context.createdResources.fleetTrips || []).filter(item => item !== proof
|
||||
&& item.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
const pair = Array.isArray(pairs) && pairs.find(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId))
|
||||
const localPairs = Array.isArray(pairs) ? pairs.filter(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId))
|
||||
&& /^[1-9][0-9]*$/.test(id(item?.driverId))
|
||||
&& !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId)))
|
||||
&& !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId))) : []
|
||||
if (!localPairs.length) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照')
|
||||
const trips = [], seen = new Set(); let total
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const data = await command(context, module, 'GET', `${BASE}/trips?page=${page}&size=200`, undefined, '核对共享环境车辆司机占用')
|
||||
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|
||||
|| Number(data.page) !== page || Number(data.size) !== 200) throw Error('固定车辆司机占用分页无效')
|
||||
total ??= Number(data.total)
|
||||
if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && trips.length < total))
|
||||
throw Error('固定车辆司机占用分页变化或缺页')
|
||||
for (const row of data.records) {
|
||||
if (!row.tripNo || seen.has(row.tripNo)) throw Error('固定车辆司机占用车次重复或缺少业务号')
|
||||
seen.add(row.tripNo); trips.push(row)
|
||||
}
|
||||
if (trips.length >= total) break
|
||||
}
|
||||
if (trips.length !== total) throw Error('固定车辆司机占用分页不完整')
|
||||
const pair = localPairs.find(item => !trips.some(trip => trip.status !== 'CANCELLED'
|
||||
&& ((id(trip.vehicleId) === id(item.vehicleId) && !trip.vehicleRelease?.actualEndAt)
|
||||
|| (id(trip.driverId) === id(item.driverId) && !trip.driverRelease?.actualEndAt))))
|
||||
if (!pair) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照')
|
||||
proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
|
||||
const vehicle = await command(context, module, 'GET', `/api/platform/v1/last-leg/master-data/vehicles/${id(pair.vehicleId)}`, undefined, '核对固定车辆状态')
|
||||
@@ -145,7 +164,17 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
|
||||
upload.fileId = id(file.fileId)
|
||||
await context.checkpoint?.()
|
||||
}
|
||||
const maintenance = structuredClone(before.vehicleInfo?.maintenance || {})
|
||||
const appointments = new Map((maintenance.destinationAppointments || []).map(row => [row.destinationKey, row]))
|
||||
for (const row of before.dispatchInfo?.fbaAppointments || []) {
|
||||
if (!row.destinationKey || !row.destinationWarehouseCode) continue
|
||||
const existing = row.appointmentNo || appointments.get(row.destinationKey)?.appointmentNo
|
||||
appointments.set(row.destinationKey, { destinationKey: row.destinationKey,
|
||||
appointmentNo: existing || `TEST-${proof.marker}-${row.destinationWarehouseCode}`.slice(0, 128) })
|
||||
}
|
||||
maintenance.destinationAppointments = [...appointments.values()]
|
||||
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
|
||||
maintenance,
|
||||
publishLooseboardDemand: before.publishLooseboardDemand,
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId, evidenceFileIds: [proof.reviewPhotos.VEHICLE.fileId] },
|
||||
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt,
|
||||
|
||||
@@ -20,11 +20,14 @@ export async function attachLooseboardToTrip(context, tripProof, pdaToken,
|
||||
&& Number.isSafeInteger(row.packageCount) && row.packageCount >= row.estimatedPalletCount)
|
||||
const destinationKey = tripProof.stops?.[1]?.destinationKey
|
||||
check('原车存在明确卸货目的地', typeof destinationKey === 'string' && destinationKey.length > 0)
|
||||
// 当前受控样本以每实托100×100×90cm、40kg执行,原单箱数均分且总数守恒。
|
||||
const measurements = Array.from({ length: row.estimatedPalletCount }, (_, index) => ({
|
||||
cartonCount: Math.floor(row.packageCount / row.estimatedPalletCount) + (index < row.packageCount % row.estimatedPalletCount ? 1 : 0),
|
||||
lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
|
||||
}))
|
||||
// 明确的两托三箱TEST样本,逐箱输入,不把整托数值或箱数推算成实际箱规。
|
||||
check('受控箱规仅适用于本轮两托三箱样本', row.estimatedPalletCount === 2 && row.packageCount === 3)
|
||||
const measurements = [
|
||||
{ cartonCount: 2, lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
|
||||
boxes: [{ length: 100, width: 100, height: 45, weight: 20, quantity: 1 }, { length: 100, width: 100, height: 45, weight: 20, quantity: 1 }] },
|
||||
{ cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
|
||||
boxes: [{ length: 100, width: 100, height: 90, weight: 40, quantity: 1 }] },
|
||||
]
|
||||
proof.measurements = { kind: 'CONTROLLED_TEST_MEASUREMENT', measurements }
|
||||
await helpers.prepare(context, { demand, tripProof, destinationKey, unitPrice: 25 })
|
||||
await helpers.receive(context, { demand, tripProof, pdaToken, measurements })
|
||||
|
||||
@@ -34,7 +34,7 @@ function fixture(fault = '') {
|
||||
} else if (method === 'GET' && path.endsWith('/OF-1')) data = { offer }
|
||||
else if (method === 'GET' && path.endsWith('/BK-1')) data = created.inquiry
|
||||
else if (method === 'POST' && path.endsWith('/OF-1/inquiries')) {
|
||||
assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(body.sourceOrderVersion, 2)
|
||||
assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(Number(body.sourceOrderVersion), 2)
|
||||
assert.equal(body.customerId, '101'); assert.equal(body.expectedPalletCount, 2)
|
||||
assert.equal(body.cargo.packageCount, 3)
|
||||
if (fault === 'network') return { http: 0 }
|
||||
@@ -68,6 +68,21 @@ test('pool binding preserves original demand and confirms only one commercial or
|
||||
assert.equal(f.writes(), 4)
|
||||
})
|
||||
|
||||
for (const native of [true, false]) test(`native Long inquiry versions remain bounded: ${native ? 'encoded strings' : 'invalid empty value'}`, async () => {
|
||||
const f = fixture()
|
||||
// 生产 fixture 已将版本规范为JS整数;HTTP的Long版本仍为规范十进制字符串。
|
||||
if (!native) f.args.demand.version = ''
|
||||
const request = f.context.request
|
||||
f.context.request = async (...args) => {
|
||||
const response = await request(...args)
|
||||
response.body.data = JSON.parse(JSON.stringify(response.body.data, (key, value) =>
|
||||
/^(version|recordVersion|tripRecordVersion|offerRecordVersion)$/.test(key) ? native ? String(value) : '' : value))
|
||||
return response
|
||||
}
|
||||
if (native) assert.equal((await bindLooseboardDemand(f.context, f.args)).status, 'CONFIRMED_ORIGINAL_ORDER')
|
||||
else { await assert.rejects(bindLooseboardDemand(f.context, f.args), /散板原单/); assert.equal(f.writes(), 0) }
|
||||
})
|
||||
|
||||
for (const [fault, maxWrites] of [['customer', 0], ['capacity', 0], ['network', 1], ['replay', 2], ['new order', 3], ['price', 3], ['binding', 4], ['disk', 0]]) {
|
||||
test(`pool binding stops on ${fault} without another order or automatic retry`, async () => {
|
||||
const f = fixture(fault)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = '/api/platform/v1/last-leg/fleet/capacity-offers'
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const OPTIONS = { scenario: 'looseboard-pool-binding', deploymentBlocker: true }
|
||||
const version = value => Number.isSafeInteger(value) && value >= 0
|
||||
const version = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value))
|
||||
|
||||
/** 将本轮散板原单绑定本轮已发布车次余位,禁止无来源另建商业订单。 */
|
||||
export async function bindLooseboardDemand(context, { demand, tripProof, offerNo, destinationKey, unitPrice }) {
|
||||
@@ -38,7 +38,8 @@ export async function bindLooseboardDemand(context, { demand, tripProof, offerNo
|
||||
const original = await request('GET', originalPath)
|
||||
const summary = original?.summary
|
||||
check('散板原单仍可接车且无已有承运', summary?.orderNo === demand.orderNo && String(summary.customerId) === String(demand.customerId)
|
||||
&& summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && summary.version === demand.version
|
||||
&& summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && version(demand.version)
|
||||
&& Number(summary.version) === Number(demand.version)
|
||||
&& !summary.fleetArrangement && original.remark?.startsWith(context.marker))
|
||||
const offer = (await request('GET', `${BASE}/${encodeURIComponent(offerNo)}`))?.offer
|
||||
const destination = offer?.destinations?.find(row => row.destinationKey === destinationKey)
|
||||
|
||||
@@ -62,6 +62,21 @@ for (const valid of [false, true]) test(`prepare owned pool and pass original de
|
||||
assert.equal(f.calls.some(c => /\/vehicles|\/drivers|\/iam/.test(c.path)), false)
|
||||
})
|
||||
|
||||
for (const encoded of ['0', '12', '', null, '01', '-1', '9007199254740992']) test(`pool source version uses bounded native Long encoding: ${String(encoded)}`, async () => {
|
||||
const f = fixture('', true)
|
||||
const request = f.context.request
|
||||
f.context.request = async (method, path, body) => {
|
||||
const response = await request(method, path, body)
|
||||
if (path.endsWith('/capacity-plan')) response.body.data.recordVersion = encoded
|
||||
return response
|
||||
}
|
||||
if (['0', '12'].includes(encoded)) await prepareLooseboardPool(f.context, f.args, f.bind)
|
||||
else {
|
||||
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind), /容量依据/)
|
||||
assert.equal(f.counts().creates, 0)
|
||||
}
|
||||
})
|
||||
|
||||
for (const fault of ['picked', 'driver', 'foreign cargo', 'loaded', 'network', 'draft replay', 'publish replay', 'disk']) {
|
||||
test(`pool preparation fails closed on ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
|
||||
const API = '/api/platform/v1/last-leg'
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const OPTIONS = { scenario: 'looseboard-pool-preparation', deploymentBlocker: true }
|
||||
const validVersion = value => Number.isSafeInteger(value) && value >= 0
|
||||
const validVersion = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value))
|
||||
|
||||
/** 复用本轮已审核且未扫码车次,发布可核验余位并衔接散板原单。 */
|
||||
export async function prepareLooseboardPool(context, { demand, tripProof, destinationKey, unitPrice }, bind = bindLooseboardDemand) {
|
||||
|
||||
@@ -9,6 +9,7 @@ function fixture(fault = '') {
|
||||
tripNo: 'TR-1', bookingNo: 'BK-1', status: 'CONFIRMED_ORIGINAL_ORDER', createRequest: { cargo, expectedPalletCount: 2 } } }
|
||||
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', status: 'CONFIRMED', recordVersion: 1, cargo }
|
||||
const pallets = [], calls = [], snapshots = []; let incomingWrites = 0, receiptWrites = 0, palletized = false
|
||||
const cartons = [1, 2, 3].map(index => ({ cartonMark: `BX-${index}`, transferItemId: '81', status: 'EXPECTED' }))
|
||||
const source = () => ({ incomingNo: 'IN-1', warehouseId: fault === 'warehouse' ? '99' : '42', destinationKey: cargo.destinationKey,
|
||||
status: palletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING', receiptHash: 'hash',
|
||||
receivedPackages: fault === 'quantity' ? 4 : 3,
|
||||
@@ -27,15 +28,30 @@ function fixture(fault = '') {
|
||||
if (fault === 'replay' && incomingWrites > 1) data.booking.incomingNo = 'IN-2'
|
||||
} else if (path.endsWith('/fulfillment')) data = { preview: { tripRecordVersion: 2, recordVersion: 2, incoming: source() } }
|
||||
else if (path.endsWith('/pallets/drafts')) {
|
||||
assert.equal(token, 'pda'); assert.equal(body.cartonCount, pallets.length ? 1 : 2)
|
||||
pallets.push({ id: String(pallets.length + 1), cartonCount: body.cartonCount, status: 'DRAFT' })
|
||||
assert.equal(token, 'pda'); assert.deepEqual(Object.keys(body), ['idempotencyKey'])
|
||||
pallets.push({ id: String(pallets.length + 1), cartonCount: 0, status: 'DRAFT' })
|
||||
data = { pallets }
|
||||
} else if (path.endsWith('/generate')) {
|
||||
const pallet = pallets.find(p => path.includes(`/pallets/${p.id}/`))
|
||||
Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)) })
|
||||
Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)), status: 'GENERATED' })
|
||||
data = { pallets }
|
||||
} else if (path.endsWith('/bind')) data = { pallets }
|
||||
} else if (path.endsWith('/bind')) { pallets.find(p => path.includes(`/pallets/${p.id}/`)).status = 'BOUND'; data = { pallets } }
|
||||
else if (path.endsWith('/cartons/count')) {
|
||||
assert.equal(token, 'pda'); assert.equal(body.forecastPackageCount, 3)
|
||||
assert.equal(body.boxes.length, 3)
|
||||
assert.ok(pallets.every(row => row.status === 'BOUND'))
|
||||
if (fault !== 'uncounted') {
|
||||
for (const input of body.boxes) Object.assign(cartons.find(box => box.cartonMark === input.cartonMark), input, { status: 'COUNTED' })
|
||||
for (const pallet of pallets) {
|
||||
const boxes = body.boxes.filter(box => box.transferPalletId === pallet.id)
|
||||
Object.assign(pallet, { cartonCount: boxes.length, volumeCbm: boxes.reduce((sum, box) => sum + box.length * box.width * box.height / 1000000, 0),
|
||||
grossWeightKg: boxes.reduce((sum, box) => sum + box.weight, 0) })
|
||||
}
|
||||
}
|
||||
data = { pallets, cartons }
|
||||
}
|
||||
else if (path.endsWith('/palletize')) {
|
||||
assert.ok(cartons.every(box => box.status === 'COUNTED'))
|
||||
assert.deepEqual(body.photoFileIds, ['9'])
|
||||
palletized = true; pallets.forEach(p => { p.status = 'COMPLETED' })
|
||||
data = { status: 'WAIT_CUSTOMER_CONFIRMATION', pallets }
|
||||
@@ -45,7 +61,9 @@ function fixture(fault = '') {
|
||||
assert.equal(body.unitPrice, 25)
|
||||
data = { tripRecordVersion: 3, booking: { ...booking, status: 'RECEIVED', incomingNo: 'IN-1',
|
||||
receipt: { source: { ...source(), status: 'READY_FOR_DISPATCH' } } }, replayed: receiptWrites > 1 }
|
||||
} else throw Error(`Unexpected ${method} ${path}`)
|
||||
} else if (method === 'GET' && path.includes('/warehouse-transfers/')) data = { operatingWarehouseId: '42', packageCount: 3,
|
||||
items: [{ id: '81', skuCode: 'DECLARED-SKU' }], cartons, pallets }
|
||||
else throw Error(`Unexpected ${method} ${path}`)
|
||||
if (path.includes('/warehouse-transfers/')) data.transferNo = fault === 'wrong incoming' ? 'OTHER' : 'IN-1'
|
||||
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
|
||||
},
|
||||
@@ -56,7 +74,9 @@ function fixture(fault = '') {
|
||||
} }
|
||||
return { context, calls, snapshots, counts: () => ({ incomingWrites, receiptWrites }),
|
||||
args: { demand, tripProof, pdaToken: 'pda', measurements: [2, 1].map(cartonCount => ({ cartonCount, lengthCm: 100, widthCm: 100,
|
||||
heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40 })) } }
|
||||
heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
|
||||
boxes: cartonCount === 2 ? [{ length: 100, width: 100, height: 45, weight: 20, quantity: 1 }, { length: 100, width: 100, height: 45, weight: 20, quantity: 1 }]
|
||||
: [{ length: 100, width: 100, height: 90, weight: 40, quantity: 1 }] })) } }
|
||||
}
|
||||
|
||||
test('original looseboard booking receives two pallets three boxes and confirms once with replay', async () => {
|
||||
@@ -69,7 +89,19 @@ test('original looseboard booking receives two pallets three boxes and confirms
|
||||
await assert.rejects(receiveLooseboardPool(f.context, f.args))
|
||||
})
|
||||
|
||||
for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming']) {
|
||||
for (const native of [true, false]) test(`native Long receipt versions remain bounded: ${native ? 'encoded strings' : 'invalid unsafe value'}`, async () => {
|
||||
const f = fixture(), request = f.context.request
|
||||
f.context.request = async (...args) => {
|
||||
const response = await request(...args)
|
||||
response.body.data = JSON.parse(JSON.stringify(response.body.data, (key, value) =>
|
||||
/^(recordVersion|tripRecordVersion)$/.test(key) ? native ? String(value) : '9007199254740992' : value))
|
||||
return response
|
||||
}
|
||||
if (native) assert.equal((await receiveLooseboardPool(f.context, f.args)).status, 'RECEIVED_READY_FOR_DISPATCH')
|
||||
else { await assert.rejects(receiveLooseboardPool(f.context, f.args), /来货前/); assert.equal(f.counts().incomingWrites, 0) }
|
||||
})
|
||||
|
||||
for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming', 'uncounted']) {
|
||||
test(`receipt stops on ${fault} before operational confirmation`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(receiveLooseboardPool(f.context, f.args))
|
||||
@@ -77,6 +109,7 @@ for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk
|
||||
assert.equal(f.counts().receiptWrites, 0)
|
||||
if (fault === 'network') assert.equal(f.counts().incomingWrites, 1)
|
||||
if (fault === 'disk') assert.equal(f.calls.length, 0)
|
||||
if (fault === 'uncounted') assert.equal(f.calls.some(call => call.path.endsWith('/palletize')), false)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const API = '/api/platform/v1/last-leg'
|
||||
const OPTIONS = { scenario: 'looseboard-pool-receipt', deploymentBlocker: true }
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
const validVersion = value => Number.isSafeInteger(value) && value >= 0
|
||||
const validVersion = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value))
|
||||
const id = value => String(value ?? '')
|
||||
|
||||
/** 原承运来货经PDA实收及运营确认;测量值由调用方明确给出,不借历史库存。 */
|
||||
@@ -13,6 +13,11 @@ export async function receiveLooseboardPool(context, { demand, tripProof, pdaTok
|
||||
|| typeof context.checkpoint !== 'function' || !Array.isArray(measurements) || !measurements.length
|
||||
|| measurements.length !== binding.createRequest.expectedPalletCount
|
||||
|| measurements.some(m => !Number.isSafeInteger(m.cartonCount) || m.cartonCount <= 0
|
||||
|| !Array.isArray(m.boxes) || m.boxes.length !== m.cartonCount
|
||||
|| m.boxes.some(box => ['length', 'width', 'height', 'weight'].some(field => !Number.isFinite(box[field]) || box[field] <= 0)
|
||||
|| !Number.isSafeInteger(box.quantity) || box.quantity <= 0)
|
||||
|| Math.abs(m.boxes.reduce((sum, box) => sum + box.length * box.width * box.height / 1000000, 0) - m.volumeCbm) > 0.000001
|
||||
|| m.boxes.reduce((sum, box) => sum + box.weight, 0) !== m.grossWeightKg
|
||||
|| ['lengthCm', 'widthCm', 'heightCm', 'volumeCbm', 'grossWeightKg'].some(k => !Number.isFinite(m[k]) || m[k] <= 0))
|
||||
|| measurements.reduce((sum, m) => sum + m.cartonCount, 0) !== cargo?.packageCount) {
|
||||
throw Error('散板实收缺少本轮原单承运、PDA身份或逐托测量与箱数依据')
|
||||
@@ -63,11 +68,17 @@ export async function receiveLooseboardPool(context, { demand, tripProof, pdaTok
|
||||
check('来货范围与初始实收状态正确', initial?.incomingNo === proof.incomingNo && id(initial.warehouseId) === id(tripProof.warehouseId)
|
||||
&& initial.destinationKey === cargo.destinationKey && initial.status === 'WAIT_PDA_RECEIVING' && initial.pallets?.length === 0)
|
||||
const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
|
||||
const forecast = await io('GET', pda, undefined, pdaToken)
|
||||
check('逐箱实收只使用原来货预报箱号和唯一预报SKU', forecast?.transferNo === proof.incomingNo
|
||||
&& id(forecast.operatingWarehouseId) === id(tripProof.warehouseId) && forecast.packageCount === cargo.packageCount
|
||||
&& forecast.items?.length === 1 && forecast.items[0].skuCode && forecast.cartons?.length === cargo.packageCount
|
||||
&& new Set(forecast.cartons.map(box => box.cartonMark)).size === cargo.packageCount
|
||||
&& forecast.cartons.every(box => box.cartonMark && box.status === 'EXPECTED' && id(box.transferItemId) === id(forecast.items[0].id)))
|
||||
for (const [index, measurement] of measurements.entries()) {
|
||||
const draft = await io('POST', `${pda}/pallets/drafts`, { ...measurement, idempotencyKey: key(`measure-${index}`) }, pdaToken)
|
||||
const draft = await io('POST', `${pda}/pallets/drafts`, { idempotencyKey: key(`measure-${index}`) }, pdaToken)
|
||||
const newPallets = draft?.pallets?.filter(row => !proof.pallets.some(p => id(p.id) === id(row.id))) || []
|
||||
check('逐托测量只新增本次一个草稿', draft?.transferNo === proof.incomingNo && draft?.pallets?.length === index + 1 && newPallets.length === 1
|
||||
&& newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === measurement.cartonCount
|
||||
&& newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === 0
|
||||
&& /^[1-9]\d*$/.test(id(newPallets[0].id)))
|
||||
const palletId = newPallets[0].id, path = `${pda}/pallets/${encodeURIComponent(palletId)}`
|
||||
const generated = await io('POST', `${path}/generate`, { idempotencyKey: key(`generate-${index}`) }, pdaToken)
|
||||
@@ -77,6 +88,21 @@ export async function receiveLooseboardPool(context, { demand, tripProof, pdaTok
|
||||
await io('POST', `${path}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`bind-${index}`) }, pdaToken)
|
||||
proof.pallets.push({ id: palletId, warehousePalletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo, packages: measurement.cartonCount })
|
||||
}
|
||||
let offset = 0
|
||||
const boxes = measurements.flatMap((measurement, index) => measurement.boxes.map(input => {
|
||||
const carton = forecast.cartons[offset++]
|
||||
return { cartonMark: carton.cartonMark, transferItemId: id(carton.transferItemId), transferPalletId: id(proof.pallets[index].id),
|
||||
length: input.length, width: input.width, height: input.height, weight: input.weight,
|
||||
items: [{ skuCode: forecast.items[0].skuCode, quantity: input.quantity }] }
|
||||
}))
|
||||
proof.controlledBoxInputs = { syntheticTestMaterials: true, boxes }
|
||||
const counted = await io('POST', `${pda}/cartons/count`, { idempotencyKey: key('count-boxes'), forecastPackageCount: forecast.packageCount, boxes }, pdaToken)
|
||||
check('逐箱尺寸重量SKU实点与实际托盘归属已保存', counted?.transferNo === proof.incomingNo && counted.cartons?.length === boxes.length
|
||||
&& boxes.every(input => counted.cartons.some(box => box.cartonMark === input.cartonMark && box.status === 'COUNTED'
|
||||
&& id(box.transferPalletId) === input.transferPalletId && ['length', 'width', 'height', 'weight'].every(field => Number(box[field]) === input[field])
|
||||
&& box.items?.length === 1 && box.items[0].skuCode === input.items[0].skuCode && Number(box.items[0].quantity) === input.items[0].quantity))
|
||||
&& proof.pallets.every((p, index) => counted.pallets?.some(row => id(row.id) === id(p.id) && row.cartonCount === p.packages
|
||||
&& Number(row.volumeCbm) === measurements[index].volumeCbm && Number(row.grossWeightKg) === measurements[index].grossWeightKg)))
|
||||
const uploaded = await io('POST', `${pda}/measurement-photos/files`, { name: `${context.marker}-TEST-looseboard.png`, type: 'image/png', content: PNG }, pdaToken, true)
|
||||
check('实收测试图片已正式上传', /^[1-9]\d*$/.test(id(uploaded?.fileId)))
|
||||
const received = await io('POST', `${pda}/palletize`, { idempotencyKey: key('palletize'), photoFileIds: [id(uploaded.fileId)] }, pdaToken)
|
||||
|
||||
@@ -10,8 +10,8 @@ function fixedContext(profileId = '2100000000000000001') {
|
||||
return {
|
||||
calls, marker: 'FIXED-CUSTOMER-TEST', context: {}, createdResources: {},
|
||||
options: { fixedCustomerId: 'TEN2100000000000000001', merchantUsername: 'fixture-user', merchantPassword: 'fixture-password' },
|
||||
async request(method, path) {
|
||||
calls.push({ method, path })
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
const data = path.endsWith('/login') ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' }
|
||||
: path.endsWith('/profile') ? { id: profileId, status: 'ACTIVE', tenantName: 'Fixture' }
|
||||
: { status: 'READY' }
|
||||
@@ -51,6 +51,31 @@ test('second customer fixture cannot silently reuse the first customer', async (
|
||||
assert.equal(context.calls.length, 0)
|
||||
})
|
||||
|
||||
test('second fixed customer validates its own credentials and profile without creating a customer or publishing primary credentials', async () => {
|
||||
const context = fixedContext('2100000000000000002')
|
||||
context.options.fixedSecondaryCustomer = { customerId: 'TEN2100000000000000002', username: 'second-test', password: 'second-password' }
|
||||
const primaryOptions = structuredClone(context.options)
|
||||
const before = { username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }
|
||||
await run(context, { publishEnvironment: false })
|
||||
assert.equal(context.context.merchantQualificationFixture.tenantId, '2100000000000000002')
|
||||
assert.equal(context.context.merchantQualificationFixture.username, 'second-test')
|
||||
assert.deepEqual(context.calls[0].body, { loginId: 'second-test', password: 'second-password' })
|
||||
assert.equal(context.createdResources.merchantQualificationFixture.password, undefined)
|
||||
assert.deepEqual(context.options, primaryOptions)
|
||||
assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, before)
|
||||
assert.deepEqual(context.calls.filter(call => call.method !== 'GET').map(call => call.path), ['/api/auth/login', '/api/auth/logout'])
|
||||
})
|
||||
|
||||
for (const fault of ['same customer', 'wrong profile', 'missing credentials']) test(`second fixed customer rejects ${fault}`, async () => {
|
||||
const context = fixedContext()
|
||||
context.options.fixedSecondaryCustomer = { customerId: fault === 'same customer' ? context.options.fixedCustomerId : '2100000000000000002',
|
||||
username: 'second-test', password: fault === 'missing credentials' ? '' : 'second-password' }
|
||||
await assert.rejects(run(context, { publishEnvironment: false }), /第二个客户|第二个固定客户|归属或状态不匹配/)
|
||||
assert.ok(context.calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
|
||||
if (fault !== 'wrong profile') assert.equal(context.calls.length, 0)
|
||||
else assert.equal(context.calls.at(-1).path, '/api/auth/logout')
|
||||
})
|
||||
|
||||
test('TEST fixture without fixed customer fails before any request', async () => {
|
||||
const context = fixedContext()
|
||||
context.options = { environment: 'test' }
|
||||
@@ -92,6 +117,18 @@ test('both request entrypoints reject customer creation before network access',
|
||||
}
|
||||
})
|
||||
|
||||
test('report security rejects the secondary password even under an ordinary evidence field', async () => {
|
||||
for (const leak of [false, true]) {
|
||||
const password = 'secondary-private-test-credential'
|
||||
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true, modules: [],
|
||||
fixedSecondaryCustomer: { customerId: '456', username: 'second-test', password } })
|
||||
runner.request = async () => ({ http: 200, body: { code: 'A0401' } })
|
||||
runner.createdResources.note = leak ? `unexpected detail: ${password}` : 'ordinary diagnostic evidence'
|
||||
const report = await runner.run()
|
||||
assert.equal(report.steps.find(step => step.module === 'report-security').passed, !leak)
|
||||
}
|
||||
})
|
||||
|
||||
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
|
||||
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -44,11 +44,20 @@ export function normalizeFixedCustomerId(value) {
|
||||
return text
|
||||
}
|
||||
|
||||
export function requireFixedSecondaryCustomer(options) {
|
||||
const secondary = options.fixedSecondaryCustomer
|
||||
if (!secondary?.customerId || !secondary.username || !secondary.password)
|
||||
throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证')
|
||||
if (normalizeFixedCustomerId(secondary.customerId) === normalizeFixedCustomerId(options.fixedCustomerId))
|
||||
throw new Error('第二个固定客户必须不同于主客户,禁止替代隔离验证')
|
||||
return secondary
|
||||
}
|
||||
|
||||
async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) {
|
||||
if (requireDistinctCustomer) throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证')
|
||||
const tenantId = normalizeFixedCustomerId(context.options.fixedCustomerId)
|
||||
const username = context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
|
||||
const password = context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
|
||||
const secondary = requireDistinctCustomer ? requireFixedSecondaryCustomer(context.options) : null
|
||||
const tenantId = normalizeFixedCustomerId(secondary?.customerId || context.options.fixedCustomerId)
|
||||
const username = secondary ? secondary.username : context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
|
||||
const password = secondary ? secondary.password : context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
|
||||
if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码')
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '')
|
||||
const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse)
|
||||
|
||||
@@ -2,10 +2,18 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { eligibleOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
|
||||
import { eligibleOrderDestinations, restrictOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
|
||||
|
||||
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('来源选线只能使用已有转运收货资源支持的目的仓,缺失时不得回退其他线路', () => {
|
||||
const destinations = [{ warehouseCode: 'ABE3' }, { warehouseCode: 'ABE4' }, { warehouseCode: 'ONT8' }]
|
||||
assert.deepEqual(restrictOrderDestinations(destinations, { includeDestinationCodes: [' abe4 '] }), [destinations[1]])
|
||||
assert.deepEqual(restrictOrderDestinations(destinations, { includeDestinationCodes: ['MISSING'] }), [])
|
||||
assert.deepEqual(restrictOrderDestinations(destinations, { includeDestinationCodes: ['ABE4'], excludeDestinationCodes: ['ABE4'] }), [])
|
||||
assert.deepEqual(restrictOrderDestinations(destinations, { excludeDestinationCodes: ['ABE3'] }), destinations.slice(1))
|
||||
})
|
||||
|
||||
test('orders fixture verifies the complete pricing tuple before selecting an FBA warehouse', () => {
|
||||
assert.match(source, /\/api\/merchant\/v1\/first-leg\/pricing\/estimate/)
|
||||
for (const field of ['originWarehouseId', 'channelCode', 'destinationCode', 'destinationType',
|
||||
|
||||
@@ -314,6 +314,14 @@ export function requestedOrderDestinationType(context) {
|
||||
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
|
||||
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
|
||||
}
|
||||
export function restrictOrderDestinations(destinations, route) {
|
||||
const codes = values => new Set((values || []).map(value => String(value).trim().toUpperCase()).filter(Boolean))
|
||||
const included = codes(route.includeDestinationCodes), excluded = codes(route.excludeDestinationCodes)
|
||||
return destinations.filter(value => {
|
||||
const code = String(value.warehouseCode || '').trim().toUpperCase()
|
||||
return (!included.size || included.has(code)) && !excluded.has(code)
|
||||
})
|
||||
}
|
||||
export function eligibleOrderDestinations(values, destinationType) {
|
||||
const active = (Array.isArray(values) ? values : []).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
return destinationType === 'FBA'
|
||||
@@ -633,8 +641,7 @@ async function loadMasterData(context, merchantToken) {
|
||||
// a producer intended for the second FBA destination can silently reuse ABE4.
|
||||
if (destinationType !== 'PRIVATE') {
|
||||
destinations = [...new Map(destinations.map(value => [String(value.warehouseCode).trim().toUpperCase(), value])).values()]
|
||||
const excluded = new Set((fixedRoute.excludeDestinationCodes || []).map(value => String(value).trim().toUpperCase()).filter(Boolean))
|
||||
if (excluded.size) destinations = destinations.filter(value => !excluded.has(String(value.warehouseCode).trim().toUpperCase()))
|
||||
destinations = restrictOrderDestinations(destinations, fixedRoute)
|
||||
}
|
||||
|
||||
let pricingResult
|
||||
|
||||
+10
-2
@@ -117,11 +117,19 @@ test('final acceptance reconciles terminal task and one completion audit after o
|
||||
assert.match(loadingPickService, /private void finalizeCompletion/)
|
||||
})
|
||||
|
||||
test('PDA treats accepted completion as processing, returns to list, and never claims completion early', () => {
|
||||
test('PDA observes authoritative completion and keeps processing distinct from success', () => {
|
||||
assert.match(pdaCompletionState, /PROCESSING_STATUSES = new Set\(\['COMPLETING', 'PROCESSING'\]\)/)
|
||||
assert.match(pdaCompletionTests, /accepted completion is processing/)
|
||||
assert.match(pdaPage, /loading-completion-state/)
|
||||
assert.match(pdaPage, /(?:PROCESSING|COMPLETING)[\s\S]{0,1200}navigateBackOr/)
|
||||
const method = pdaPage.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
|
||||
assert.ok(method)
|
||||
assert.match(method, /await observeLoadingCompletion\(/)
|
||||
const processing = method.match(/if \(phase === COMPLETION_PHASE\.PROCESSING\) \{([^}]+)\}/)?.[1]
|
||||
assert.ok(processing)
|
||||
assert.match(processing, /persistSession\(\)/)
|
||||
assert.match(processing, /return/)
|
||||
assert.doesNotMatch(processing, /navigateBackOr|['"]success['"]|装柜已完成/)
|
||||
assert.match(pdaCompletionTests, /page only announces success without persisting a replay request after authoritative completion/)
|
||||
assert.doesNotMatch(pdaPage, /completeContainerPickingTask[\s\S]{0,700}showToast\('已确认装柜完成'/,
|
||||
'accepted PROCESSING response must not show a completed success toast')
|
||||
assert.match(pdaPage, /shouldReconcileCompletion/)
|
||||
|
||||
@@ -260,7 +260,11 @@ export async function withPlatformBookingScope(context, runLoading) {
|
||||
}
|
||||
|
||||
export async function prepare(context) {
|
||||
if (context.options?.fixedCustomerId) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数')
|
||||
if (context.options?.fixedCustomerId && !context.options.fixedSecondaryCustomer) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数')
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const { requireFixedSecondaryCustomer } = await import('./merchant-qualification-fixture.mjs')
|
||||
requireFixedSecondaryCustomer(context.options)
|
||||
}
|
||||
const outerMarker = context.marker
|
||||
const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
|
||||
const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, {
|
||||
|
||||
@@ -6,10 +6,28 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { requestedOrderDestinationType } from './orders.mjs'
|
||||
import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
|
||||
test('转运异仓资源不足在造单前阻断,并恢复外层批次和路线', async () => {
|
||||
let created = false
|
||||
const originalRoute = { randomIndex: 3 }
|
||||
const run = { marker: 'MATRIX', phase4ProducerAttempts: new Map(),
|
||||
options: { fixedCustomerId: '101', fixedPdaActors: [{ warehouseId: '201' }], modules: ['phase4-overseas-client-services'] },
|
||||
context: { waitSplitRoute: originalRoute }, createdResources: { phase3Loading: { marker: 'OUTER' } },
|
||||
request: async method => { assert.equal(method, 'GET'); return { http: 200, body: { code: '200', data: { total: '0', records: [] } } } },
|
||||
apiSuccess: (_module, _name, response) => response.body.data,
|
||||
preparePhase3LoadingFixture: async () => { created = true; throw Error('unexpected order creation') },
|
||||
}
|
||||
await assert.rejects(AcceptanceRun.prototype.runPhase4ProducerVariant.call(run, 'DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true), /未创建来源订单/)
|
||||
assert.equal(created, false)
|
||||
assert.equal(run.marker, 'MATRIX')
|
||||
assert.equal(run.context.waitSplitRoute, originalRoute)
|
||||
assert.deepEqual(run.createdResources.phase3Loading, { marker: 'OUTER' })
|
||||
})
|
||||
|
||||
test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE routes', () => {
|
||||
assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS')
|
||||
@@ -95,7 +113,7 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(entry, /packageCounts: \[4\], palletCount: 2/)
|
||||
assert.match(entry, /packageCounts: \[5\], palletCount: 2/)
|
||||
assert.match(entry, /sealedSourcePalletNos: Array\.isArray\(fixture\.sealedSourcePalletNos\)/)
|
||||
assert.match(entry, /orderFulfillmentType/)
|
||||
assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/)
|
||||
@@ -125,7 +143,7 @@ test('measurement and loading pass destination identity into the LOADED producer
|
||||
assert.match(pda, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/)
|
||||
})
|
||||
|
||||
test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => {
|
||||
test('all receiving and appointment sources use native outbound; waiting source stays below pickup readiness', () => {
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
|
||||
@@ -138,7 +156,10 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
|
||||
assert.match(entry, /const hasSecondaryProducer =/)
|
||||
assert.match(entry, /if \(!hasSecondaryProducer\(\)\)/)
|
||||
assert.doesNotMatch(entry, /if \(Number\(readiness\(\)\.distinctContainerCount \|\| 0\) < 4\)/)
|
||||
const start = entry.indexOf('if (pickupRequired && producerKey)')
|
||||
assert.match(entry, /const shippingRequired = pickupRequired \|\| label === 'SECONDARY'/)
|
||||
assert.match(entry, /if \(!pickupRequired\)/)
|
||||
assert.match(entry, /writeShippingEvidence\(false, ready \? String\(source.queryId\) : null\)/)
|
||||
const start = entry.indexOf('if (shippingRequired && producerKey)')
|
||||
const end = entry.indexOf('} finally {', start)
|
||||
const pickupChain = entry.slice(start, end)
|
||||
const ordered = [
|
||||
|
||||
@@ -23,6 +23,18 @@ test('fixed partner warehouse is reused and never retired', async () => {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
|
||||
|
||||
test('卡车履约接受正式Long字符串版本并保持原单重放一致', async () => {
|
||||
const context = fixture(), request = context.request.bind(context)
|
||||
context.request = async (...args) => {
|
||||
const response = structuredClone(await request(...args))
|
||||
if (args[1].includes('/us-truck-orders/') && response.body?.data?.summary)
|
||||
response.body.data.summary.version = String(response.body.data.summary.version)
|
||||
return response
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'HTTP_VERIFIED')
|
||||
})
|
||||
|
||||
test('卡车请求先持久化,正常与失败响应均留有证据', async () => {
|
||||
const context = fixture(), original = context.request.bind(context)
|
||||
context.request = async (method, path, ...args) => {
|
||||
|
||||
@@ -2,11 +2,53 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { verifyDiscardApproval, submitClientTransfer } from './phase4-overseas-client-services.mjs'
|
||||
import { verifyDiscardApproval, submitClientTransfer, currentOrders, discardSnapshot } from './phase4-overseas-client-services.mjs'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
for (const fault of ['', 'foreign inbound', 'mixed aggregate']) test(`discard resolves source balance rather than inventing inbound on aggregate: ${fault || 'native grouped list'}`, async () => {
|
||||
const aggregate = { id: '901', customerId: '101', overseasWarehouseId: '201', skuCode: 'SKU', totalPackages: '5', availablePackages: '5',
|
||||
holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', inTransitPackages: '0', receivingPackages: '0', procurementPackages: '0' }
|
||||
const proof = { customerId: '101', warehouseId: '201', skuCode: 'SKU', inboundCode: 'RK', sourceOrderId: '301' }
|
||||
const calls = []
|
||||
const context = { token: 'admin', assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
apiSuccess(_m, _n, r) { assert.equal(r.body.code, '200'); return r.body.data },
|
||||
async request(method, path) {
|
||||
assert.equal(method, 'GET'); calls.push(path)
|
||||
const pathname = new URL(path, 'https://offline.invalid').pathname
|
||||
const data = pathname.endsWith('/balances') ? { total: '1', records: [aggregate] }
|
||||
: pathname.endsWith('/ledger') ? { total: '0', records: [] }
|
||||
: pathname.endsWith('/balances/901') ? { ...aggregate, inboundCode: fault === 'foreign inbound' ? 'OTHER' : 'RK', totalPackages: fault === 'mixed aggregate' ? '4' : '5' }
|
||||
: [{ orderId: '301', inboundCode: 'RK' }]
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
if (fault) await assert.rejects(discardSnapshot(context, 'merchant', proof), /来源余额身份与聚合数量一致/)
|
||||
else {
|
||||
const result = await discardSnapshot(context, 'merchant', proof)
|
||||
assert.equal(result.balances[0].inboundCode, 'RK')
|
||||
assert.equal(aggregate.inboundCode, undefined)
|
||||
assert.ok(calls.some(path => path.includes('/balances/901?customerId=101')))
|
||||
}
|
||||
})
|
||||
|
||||
for (const fault of ['', 'duplicate-page', 'missing-total']) test(`current customer orders preserve dropship and fully page before selection: ${fault || 'page two'}`, async () => {
|
||||
const calls = [], row = { id: 'DROP', containerNo: 'CURRENT', orderType: 'DROPSHIP' }
|
||||
const context = { createdResources: { phase4OverseasPda: { completedContainers: ['CURRENT'], orderId: 'DROP' } },
|
||||
assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _n, response) { return response.body.data },
|
||||
async request(method, path) {
|
||||
calls.push(path); assert.equal(method, 'GET')
|
||||
const url = new URL(path, 'https://offline.invalid'); assert.equal(url.searchParams.has('sailingStage'), false)
|
||||
const data = url.pathname.endsWith('/DROP') ? { order: row, cargo: [] }
|
||||
: { total: fault === 'missing-total' ? null : 201, records: url.searchParams.get('page') === '1' || fault === 'duplicate-page'
|
||||
? Array.from({ length: 200 }, (_, n) => ({ id: `OTHER-${n}`, containerNo: 'HISTORY' })) : [row] }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
if (fault) await assert.rejects(currentOrders(context, 'merchant'), /分页/)
|
||||
else assert.deepEqual((await currentOrders(context, 'merchant')).map(item => item.row.id), ['DROP'])
|
||||
assert.ok(calls.every(path => !path.endsWith('/OTHER')))
|
||||
})
|
||||
|
||||
for (const fault of ['', 'network', 'server', 'rejected', 'wrong order', 'wrong status', 'throws']) {
|
||||
test(`transfer submit sends once and stops on uncertain result: ${fault || 'success'}`, async () => {
|
||||
const calls = []
|
||||
@@ -73,7 +115,9 @@ test('relabel transfer and discard cover create replay conflict list detail and
|
||||
'operationWarehouseId:', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:',
|
||||
'destinationFbaWarehouseCode:',
|
||||
]) assert.match(source, new RegExp(field))
|
||||
assert.match(source, /query\(`\$\{RELABEL\}\/inventory-candidates`, \{/)
|
||||
assert.match(source, /await readCurrentBoxedInventory\(context, token,/)
|
||||
assert.match(source, /cartonMark: carton.cartonMark/)
|
||||
assert.match(source, /cartonIds: \[id\(carton.cartonId\)\]/)
|
||||
for (const token of [
|
||||
'客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409',
|
||||
'客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_REVIEW',
|
||||
@@ -124,9 +168,10 @@ test('discard approval retains reservation and keeps actual confirmation evidenc
|
||||
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:[],
|
||||
const baseline = { balances: [{id:'777',customerId:'123',overseasWarehouseId:'42',skuCode:'SKU',inboundCode:'IP',totalPackages:'4',availablePackages:'4',
|
||||
holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',inTransitPackages:'0',receivingPackages:'0',procurementPackages:'0'}], ledger:[],
|
||||
source:{orderId:'9',overseasWarehouseId:'42',inboundCode:'IP',availablePackages:'4'},detail:null }
|
||||
const proof = {sourceOrderId:'9',customerId:'123',warehouseId:'42',inboundCode:'IP',snapshots:{before:baseline}}
|
||||
const proof = {sourceOrderId:'9',customerId:'123',warehouseId:'42',inboundCode:'IP',skuCode:'SKU',snapshots:{before:baseline}}
|
||||
const calls=[]
|
||||
const context={marker:'fixture',token:'admin',addStep(){},
|
||||
assert(_m,name,_r,passed){ assert.ok(passed,name); if(name==='重复批准不会提前扣库存或新增历史') throw new Error('CHECKPOINT_PASSED') },
|
||||
@@ -139,9 +184,10 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve
|
||||
else if(method==='POST' && path.endsWith('/cargo-discards')) {
|
||||
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')) {
|
||||
} else if(path.endsWith('/balances') || path.endsWith('/balances/777')) {
|
||||
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'}]}
|
||||
const balance={...baseline.balances[0],availablePackages:changed?'3':'4'}
|
||||
data=path.endsWith('/777')?balance:{total:'1',records:[balance]}
|
||||
} 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'}]
|
||||
else data={summary:{discardNo:approved?'NEW':'OLD',status:state}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
|
||||
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
|
||||
import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs'
|
||||
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
|
||||
import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
|
||||
@@ -63,14 +64,28 @@ async function loginMerchant(context) {
|
||||
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
|
||||
}
|
||||
|
||||
async function currentOrders(context, token) {
|
||||
export async function currentOrders(context, token) {
|
||||
const pda = context.createdResources.phase4OverseasPda
|
||||
const containerNos = [...new Set([...(pda.completedContainers || []), pda.containerNo].map(id).filter(Boolean))]
|
||||
const result = []
|
||||
for (const containerNo of containerNos) {
|
||||
const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`,
|
||||
query(ORDERS, { page: 1, size: 200, containerNo }))
|
||||
for (const row of records(page.data)) {
|
||||
const all = [], seen = new Set(); let total
|
||||
for (let pageNo = 1; pageNo <= 100; pageNo++) {
|
||||
const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`,
|
||||
query(ORDERS, { page: pageNo, size: 200, containerNo }))
|
||||
const count = Number(page.data?.total)
|
||||
if (page.data?.total == null || !Number.isSafeInteger(count) || count < 0 || !Array.isArray(page.data?.records)
|
||||
|| (total !== undefined && count !== total) || (!page.data.records.length && all.length < count))
|
||||
throw Error('客户尾程订单分页不完整或总数变化')
|
||||
total ??= count
|
||||
for (const row of page.data.records) {
|
||||
if (!id(row.id) || seen.has(id(row.id))) throw Error('客户尾程订单分页身份重复或缺失')
|
||||
seen.add(id(row.id)); all.push(row)
|
||||
}
|
||||
if (all.length >= total) break
|
||||
}
|
||||
if (all.length !== total) throw Error('客户尾程订单分页未完整读取')
|
||||
for (const row of all) {
|
||||
if (id(row?.containerNo) !== containerNo || !id(row?.id)) continue
|
||||
const detail = await merchant(context, token, 'GET', `读取本轮尾程订单${id(row.id)}详情`,
|
||||
`${ORDERS}/${encodeURIComponent(id(row.id))}`)
|
||||
@@ -91,27 +106,21 @@ function usableCargo(order) {
|
||||
&& nonBlank(item?.productNameZh) && Number(item?.packageCount) > 0)
|
||||
}
|
||||
|
||||
async function verifyRelabel(context, token, order) {
|
||||
async function verifyRelabel(context, token, orders) {
|
||||
const boxed = await readCurrentBoxedInventory(context, token,
|
||||
context.createdResources.phase4OverseasPda.overseasWarehouseId, MODULE)
|
||||
const source = boxed.find(row => orders.some(order => order.row.inboundCode === row.inboundCode && usableCargo(order)?.skuCode === row.skuCode))
|
||||
const order = source && orders.find(order => order.row.inboundCode === source.inboundCode && usableCargo(order)?.skuCode === source.skuCode)
|
||||
if (!order) throw Error('客户换标缺少本轮未占用真实箱库存')
|
||||
const carton = source.cartons[0]
|
||||
const cargo = usableCargo(order)
|
||||
if (!cargo || !nonBlank(order.row?.overseasWarehouseId)) {
|
||||
blocker(context, '本轮贴标申请候选',
|
||||
`orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`)
|
||||
return false
|
||||
}
|
||||
const inventory = await merchant(context, token, 'GET', '客户查询贴标权威库存候选',
|
||||
query(`${RELABEL}/inventory-candidates`, {
|
||||
operationWarehouseId: order.row.overseasWarehouseId,
|
||||
inboundCode: order.row.inboundCode,
|
||||
}))
|
||||
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
|
||||
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
|
||||
&& item?.skuCode === cargo.skuCode && item.inboundCode === order.row.inboundCode
|
||||
&& id(item.overseasWarehouseId) === id(order.row.overseasWarehouseId)
|
||||
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0
|
||||
&& nonBlank(position.palletNo) && position.palletNo.toUpperCase() !== 'UNBOUND'))
|
||||
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0
|
||||
&& nonBlank(item.palletNo) && item.palletNo.toUpperCase() !== 'UNBOUND')
|
||||
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
|
||||
const position = source.positions.find(item => id(item.positionId) === carton.positionId)
|
||||
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', null,
|
||||
Boolean(source && position),
|
||||
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
|
||||
if (!source || !position) return false
|
||||
@@ -132,9 +141,9 @@ async function verifyRelabel(context, token, order) {
|
||||
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
|
||||
newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker,
|
||||
operationType: source.destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL',
|
||||
skuItems: [{ skuCode: cargo.skuCode, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, remark: context.marker }],
|
||||
skuItems: carton.items.map(item => ({ skuCode: item.skuCode, newSkuCode: `${item.skuCode}-NEW`.slice(0, 128), quantity: Number(item.quantity), remark: context.marker })),
|
||||
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
|
||||
cartonMark: `CM-${String(context.marker).slice(-32)}`,
|
||||
cartonMark: carton.cartonMark,
|
||||
fbaCartonMark: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null,
|
||||
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
|
||||
}],
|
||||
@@ -210,8 +219,12 @@ export async function submitClientTransfer(context, token, transferNo) {
|
||||
}
|
||||
|
||||
async function verifyTransfer(context, token, orders) {
|
||||
const order = orders.find((value) => nonBlank(value.row?.inboundCode)
|
||||
&& nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value))
|
||||
const boxed = await readCurrentBoxedInventory(context, token,
|
||||
context.createdResources.phase4OverseasPda.overseasWarehouseId, MODULE)
|
||||
const source = boxed.find(row => row.destinationFbaWarehouseCode && orders.some(order => order.row.inboundCode === row.inboundCode
|
||||
&& usableCargo(order)?.skuCode === row.skuCode))
|
||||
const order = source && orders.find(value => value.row.inboundCode === source.inboundCode && usableCargo(value)?.skuCode === source.skuCode)
|
||||
const carton = source?.cartons[0]
|
||||
const cargo = order ? usableCargo(order) : null
|
||||
if (!order || !cargo) {
|
||||
blocker(context, '本轮仓储转运申请候选',
|
||||
@@ -226,14 +239,14 @@ async function verifyTransfer(context, token, orders) {
|
||||
deliveryAddress: `${order.row.fbaWarehouseCode} ${context.marker}`.slice(0, 1000),
|
||||
packageCount: packages, volumeCbm: volume, deliveryMethod: 'TRUCK', estimatedArrivalAt: future(10),
|
||||
fbaShipmentNo: cargo.shipmentNo || `${context.marker}-TRANSFER-SHIP`, trackingNo: cargo.fbaTrackingNo || null,
|
||||
fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: true,
|
||||
fbaBoxNos: carton.cartonMark, remark: context.marker, needRelabel: true,
|
||||
labelDeadline: future(7), relabelContactName: '客户贴标验收联系人', relabelContactPhone: '13800000006',
|
||||
relabelChargeAmount: 0.01, relabelDeliveryMethod: 'TRUCK',
|
||||
idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255),
|
||||
items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh,
|
||||
packageCount: packages, shippingMark: cargo.shippingMark || null }],
|
||||
relabelItems: [{ operationType: 'CARTON_AND_SKU', customerCartonMarks: [cargo.shippingMark || `CM-${context.marker}`],
|
||||
originalSku: cargo.skuCode, newSku: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1,
|
||||
packageCount: packages, shippingMark: carton.cartonMark }],
|
||||
relabelItems: [{ operationType: 'CARTON_AND_SKU', customerCartonMarks: [carton.cartonMark],
|
||||
originalSku: cargo.skuCode, newSku: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: Number(carton.items[0].quantity),
|
||||
fbaCartonMark: `FBA-${context.marker}`.slice(0, 255), destinationWarehouseCode: order.row.fbaWarehouseCode,
|
||||
remark: context.marker }],
|
||||
}
|
||||
@@ -267,6 +280,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
const transferProof = context.createdResources.phase4OverseasClientServices.transferFulfillment = {
|
||||
marker: context.marker, customerId: id(order.row.customerId), warehouseId: id(order.row.overseasWarehouseId),
|
||||
transferNo, inboundCode: order.row.inboundCode, skuCode: cargo.skuCode, status: 'APPROVED',
|
||||
requiredSourcePositionId: carton.positionId, sourceCartonMark: carton.cartonMark,
|
||||
}
|
||||
await allocateWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
|
||||
const relabelProof = transferProof.relabelFulfillment = {
|
||||
@@ -294,15 +308,18 @@ export async function verifyDiscard(context, token, markerOrders) {
|
||||
const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS)
|
||||
const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id)))
|
||||
const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId))
|
||||
&& Array.isArray(value?.skus) && value.skus.some((sku) => nonBlank(sku?.skuCode) && Number(sku?.availablePackages) > 0)) : null
|
||||
&& value.cartonSelectionSupported === true && value.cartons?.some(box => id(box.cartonId)
|
||||
&& box.skus?.some(sku => nonBlank(sku.skuCode) && Number(sku.availablePackages) > 0))) : null
|
||||
if (!candidate) {
|
||||
blocker(context, '本轮货物废弃库存候选',
|
||||
`candidateOrders=${Array.isArray(candidates) ? candidates.map((value) => id(value?.orderId)).join(',') : 'missing'}, markerOrders=${[...markerOrderIds].join(',') || 'missing'}; 禁止借历史一件代发库存`, response)
|
||||
return false
|
||||
}
|
||||
const sku = candidate.skus.find((value) => nonBlank(value?.skuCode) && Number(value?.availablePackages) > 0)
|
||||
const carton = candidate.cartons.find(box => id(box.cartonId)
|
||||
&& box.skus?.some(sku => nonBlank(sku.skuCode) && Number(sku.availablePackages) > 0))
|
||||
const sku = carton.skus.find(value => nonBlank(value.skuCode) && Number(value.availablePackages) > 0)
|
||||
const body = {
|
||||
items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }],
|
||||
items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1, cartonIds: [id(carton.cartonId)] }],
|
||||
idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker,
|
||||
}
|
||||
const proof = context.createdResources.phase4OverseasClientServices.discardApproval = {
|
||||
@@ -369,7 +386,7 @@ async function discardCommand(context, token, method, name, path, body) {
|
||||
if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`)
|
||||
return result.data
|
||||
}
|
||||
async function discardSnapshot(context, token, proof, discardNo) {
|
||||
export async function discardSnapshot(context, token, proof, discardNo) {
|
||||
const all = async (kind) => {
|
||||
const rows = []
|
||||
let total
|
||||
@@ -388,6 +405,19 @@ async function discardSnapshot(context, token, proof, discardNo) {
|
||||
throw new Error('废弃库存分页超过上限')
|
||||
}
|
||||
const balances = await all('balances'), ledger = await all('ledger')
|
||||
// 列表按客户、仓、目的仓和SKU聚合,不携带入仓码;用代表余额ID回查真实来源并核对汇总数,不能给聚合行补造来源。
|
||||
for (let index = 0; index < balances.length; index++) {
|
||||
const aggregate = balances[index]
|
||||
if (aggregate.skuCode !== proof.skuCode) continue
|
||||
const raw = await discardCommand(context, context.token, 'GET', '废弃核对实际来源余额详情',
|
||||
query(`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(aggregate.id))}`, { customerId: proof.customerId }))
|
||||
discardRequire(context, '废弃来源余额身份与聚合数量一致', id(raw?.id) === id(aggregate.id)
|
||||
&& id(raw.customerId) === proof.customerId && raw.overseasWarehouseId === proof.warehouseId
|
||||
&& raw.inboundCode === proof.inboundCode && raw.skuCode === proof.skuCode
|
||||
&& ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
|
||||
.every(field => /^\d+$/.test(String(raw[field])) && Number(raw[field]) === Number(aggregate[field])))
|
||||
balances[index] = raw
|
||||
}
|
||||
const candidates = await discardCommand(context, token, 'GET', '废弃回查本轮客户真实候选', `${DISCARD}/inventory-candidates`)
|
||||
const source = candidates.find(row => id(row.orderId) === proof.sourceOrderId)
|
||||
const detail = discardNo ? await discardCommand(context, token, 'GET', '废弃回查申请真实状态', `${DISCARD}/${encodeURIComponent(discardNo)}`) : null
|
||||
@@ -469,7 +499,7 @@ async function runWithSession(context, token) {
|
||||
blocker(context, '本轮贴标订单明细', `orders=${orders.length}; 缺少真实SKU/品名/箱数或实际海外仓`)
|
||||
return
|
||||
}
|
||||
const relabelDone = await verifyRelabel(context, token, relabelOrder)
|
||||
const relabelDone = await verifyRelabel(context, token, orders)
|
||||
const transferDone = await verifyTransfer(context, token, orders)
|
||||
const discardDone = await verifyDiscard(context, token, orders)
|
||||
context.createdResources.phase4OverseasClientServices.finalStatuses = {
|
||||
|
||||
@@ -66,7 +66,7 @@ function authority({ failSave = false, mutateOrder = false, unknown = 0, drift =
|
||||
if (pathname.endsWith('/orders/order1')) return response(state.order)
|
||||
if (pathname.endsWith('/cargo-verification')) {
|
||||
if (method === 'GET') {
|
||||
const allocated = state.appointments.filter(a => a.appointmentStatus === 'PENDING' && !a.replacementBatchKeys.length)
|
||||
const allocated = state.appointments.filter(a => !a.replacementBatchKeys.length)
|
||||
.flatMap(a => a.items).reduce((n, i) => n + i.packageCount, 0)
|
||||
return response({ source: sourceFacts, verification: state.verification, allocatedPackages: String(allocated),
|
||||
availablePackages: String((state.verification?.packageCount ?? packages) - allocated), unknownAllocationCount: String(unknown), sourceChanged: drift })
|
||||
@@ -123,7 +123,7 @@ test('four real boxes retain original order facts through verification, cancel,
|
||||
assert.equal(state.order.order.packageCount, 4)
|
||||
assert.equal(state.order.order.version, 7)
|
||||
assert.deepEqual(state.verification, { version: 2, sourceVersion: 'source-v1', packageCount: 4 })
|
||||
assert.equal(state.appointments.length, 5)
|
||||
assert.equal(state.appointments.length, 4)
|
||||
assert.equal(state.history.size, 1)
|
||||
assert.equal(state.history.get(result.cancelledAppointmentNo).length, 1)
|
||||
assert.equal(result.replacedBatchKeys.length, 3)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { NODE_SEQUENCES, STANDARD_NODES, manualNodePayload } from './phase4-carrier-nodes.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-inbound-operations'
|
||||
const OPTIONS = { scenario: 'phase4-inbound-admin-and-inventory-commands', deploymentBlocker: true }
|
||||
const PLAN_BASE = '/api/platform/v1/last-leg/inbound-plans'
|
||||
@@ -112,12 +114,14 @@ export async function prepareAppointmentFixture(context, { customerId, plan, fba
|
||||
&& history.records[0].snapshot?.plannedDate === detail.plannedDate, appointmentNo)
|
||||
for (const v of verified) {
|
||||
const restored = await request(context, 'GET', '取消后逐票可分配量恢复', query(`${APPOINTMENT_BASE}/sources/${encodeURIComponent(v.sourceKey)}/cargo-verification`, { customerId, destinationKey: v.destinationKey }))
|
||||
requireProof(context, '取消未删除核实事实且恢复剩余需求', count(restored.allocatedPackages) === 0 && count(restored.availablePackages) === v.packageCount
|
||||
requireProof(context, '取消保留核实事实及原批次货物分配', count(restored.allocatedPackages) === v.packageCount && count(restored.availablePackages) === 0
|
||||
&& restored.verification?.version === v.cargoVerificationVersion && restored.sourceChanged === false, v.sourceKey)
|
||||
}
|
||||
const active = await request(context, 'POST', '为本轮后续验收重新建立有效批次', `${PLAN_BASE}/fba-appointments/batch`, { ...createBody, idempotencyKey: `${createBody.idempotencyKey}-active` })
|
||||
requireProof(context, '重新安排生成新批次且逐票数量不变', active.batchKey === `FBA_APPOINTMENT:${active.id}` && active.batchKey !== cancelled.batchKey
|
||||
&& active.appointmentNo !== appointmentNo && active.appointmentStatus === 'PENDING' && JSON.stringify(exactItems(active)) === JSON.stringify(exactItems(detail)), active?.appointmentNo || 'missing')
|
||||
const active = await request(context, 'PUT', '在原批次重新安排本轮有效预约', detailPath,
|
||||
{ version: cancelled.version, idempotencyKey: `${createBody.idempotencyKey}-active`, details: createBody.details })
|
||||
requireProof(context, '重新安排复用原批次且逐票数量不变', active.batchKey === cancelled.batchKey
|
||||
&& active.appointmentNo === appointmentNo && active.version === cancelled.version + 1
|
||||
&& active.appointmentStatus === 'PENDING' && JSON.stringify(exactItems(active)) === JSON.stringify(exactItems(detail)), active?.appointmentNo || 'missing')
|
||||
const activeContext = await request(context, 'GET', '回查保留的有效批次', `${APPOINTMENT_BASE}/batches/${encodeURIComponent(active.batchKey)}`)
|
||||
requireProof(context, '保留批次上下文完整可供后续HTTP核对', activeContext?.appointment?.batchKey === active.batchKey
|
||||
&& activeContext.appointment.appointmentStatus === 'PENDING', active.batchKey)
|
||||
@@ -242,6 +246,7 @@ async function inboundAdminLifecycle(context) {
|
||||
&& id(supplement?.destinationWarehouseId) === id(sample.destinationWarehouseId),
|
||||
`palletId=${id(supplement?.id) || 'missing'}, status=${supplement?.assignmentStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
await ensureAppointmentDeparture(context, producer, order.id)
|
||||
const appointmentProof = await prepareAppointmentFixture(context, { customerId, plan, fbaLines })
|
||||
context.createdResources.phase4OverseasInboundAdmin = {
|
||||
marker: context.marker, customerId, orderId: id(order.id), planNo,
|
||||
@@ -249,6 +254,54 @@ async function inboundAdminLifecycle(context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用本轮自动船期任务推进实际开船事实,再等待尾程消费,不能用人工订单节点绕过来源校验。 */
|
||||
export async function ensureAppointmentDeparture(context, producer, orderId) {
|
||||
if (producer?.marker !== context.marker || !id(producer.carrierScheduleQueryId)
|
||||
|| !id(producer.containerBookingId) || !id(producer.tenantId) || !id(orderId))
|
||||
throw Error('预约开船前置缺少本轮船期任务身份')
|
||||
const base = `/api/platform/v1/carrier-schedule-queries/${encodeURIComponent(id(producer.carrierScheduleQueryId))}`
|
||||
let detail = await request(context, 'GET', '核对预约来源的原生船期任务', base)
|
||||
const task = detail?.query?.query
|
||||
if (id(task?.id) !== id(producer.carrierScheduleQueryId) || id(task?.customerId) !== id(producer.tenantId)
|
||||
|| id(task?.containerBookingId) !== id(producer.containerBookingId) || task?.trackingNo !== producer.containerNo)
|
||||
throw Error('预约来源船期任务客户或柜身份不一致')
|
||||
const proof = context.createdResources.appointmentDeparturePrerequisite = {
|
||||
marker: context.marker, queryId: id(task.id), containerBookingId: id(producer.containerBookingId), commands: [], status: 'PREPARING',
|
||||
}
|
||||
for (const node of STANDARD_NODES.slice(0, 4)) {
|
||||
const current = detail.query.query
|
||||
if ((NODE_SEQUENCES[current.latestActualNode] || 0) >= NODE_SEQUENCES[node]) continue
|
||||
const now = new Date()
|
||||
const local = value => new Date(value.getTime() - value.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const priorAt = current.latestActualAt && Date.parse(current.latestActualAt)
|
||||
const actualAt = Number.isFinite(priorAt) ? new Date(priorAt + 60000) : new Date(now.getTime() - 10 * 60000)
|
||||
const body = manualNodePayload({ expectedVersion: Number(current.version), standardNode: node,
|
||||
observedAt: local(now), actualAt: local(actualAt), marker: context.marker })
|
||||
if (!Number.isSafeInteger(body.expectedVersion) || body.expectedVersion < 0) throw Error('预约来源船期版本无效')
|
||||
const headers = { 'Idempotency-Key': key(context, `appointment-departure-${node}`) }
|
||||
const command = { body, idempotencyKey: headers['Idempotency-Key'], status: 'PREPARED' }
|
||||
proof.commands.push(command); await context.checkpoint?.()
|
||||
command.status = 'UNKNOWN_RESULT'
|
||||
await request(context, 'POST', '补齐预约来源原生实际船节点', `${base}/manual-nodes`, body, headers)
|
||||
command.status = 'SUCCEEDED'
|
||||
detail = await request(context, 'GET', '回查原生实际船节点', base)
|
||||
if ((NODE_SEQUENCES[detail?.query?.query?.latestActualNode] || 0) < NODE_SEQUENCES[node])
|
||||
throw Error('实际船节点补录未推进原船期任务')
|
||||
}
|
||||
for (let attempt = 1; attempt <= 30; attempt++) {
|
||||
const response = await context.request('GET', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(orderId))}`,
|
||||
{ customerId: id(producer.tenantId) }))
|
||||
const order = response?.body?.data?.order
|
||||
if (ok(response) && id(order?.customerId) === id(producer.tenantId)
|
||||
&& id(order?.sourceContainerBookingId) === id(producer.containerBookingId)
|
||||
&& !order.manualNodeOverride && (NODE_SEQUENCES[order.containerNode] || 0) >= 40) {
|
||||
proof.status = 'CONSUMED'; return proof
|
||||
}
|
||||
if (attempt < 30) await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
throw Error('预约来源开船事实未被尾程消费,停止预约写入')
|
||||
}
|
||||
|
||||
async function inventoryCommandLifecycle(context) {
|
||||
const pda = context.createdResources?.phase4OverseasPda
|
||||
const customerId = id(pda?.customerId)
|
||||
|
||||
@@ -25,14 +25,16 @@ test('fixed customer relabel wallet only reads existing available funds', async
|
||||
})
|
||||
|
||||
test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => {
|
||||
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [
|
||||
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', cartons: [
|
||||
{ cartonMark: 'A1', positionId: '10' }, { cartonMark: 'A2', positionId: '10' }, { cartonMark: 'A3', positionId: '11' }], positions: [
|
||||
{ positionId: '10', palletNo: 'REAL-A', availablePackages: 2 },
|
||||
{ positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] }
|
||||
const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [
|
||||
const b = { inventoryBalanceId: 'B', inboundOrderId: '2', cartons: [{ cartonMark: 'B1', positionId: '20' }], positions: [
|
||||
{ positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] }
|
||||
const selected = selectPhysicalRelabelSources([b, a, a], 'A')
|
||||
assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B'])
|
||||
assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2)
|
||||
assert.equal(selectPhysicalRelabelSources([{ ...a, cartons: [] }], 'A').length, 0)
|
||||
})
|
||||
|
||||
test('exception uses the untouched third carton, independent of response ordering', () => {
|
||||
@@ -87,9 +89,9 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409',
|
||||
'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED',
|
||||
'chargeAmount: RELABEL_CHARGE_AMOUNT', 'cartonMark:', 'fbaCartonMark:',
|
||||
'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
|
||||
'readCurrentBoxedInventory', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
|
||||
'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map',
|
||||
'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark',
|
||||
'sourceLines.push({ source, position, carton, packageCount: 1 })', 'oldLabel: item.cartonMark',
|
||||
'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null',
|
||||
'newLabel: item.fbaCartonMark || item.newSkuCode',
|
||||
"operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
|
||||
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
|
||||
import { readCurrentBoxedInventory } from './current-boxed-inventory.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-pda-operations'
|
||||
export const RELABEL_CHARGE_AMOUNT = 0.01
|
||||
@@ -364,14 +365,17 @@ export function selectPhysicalRelabelSources(candidates, preferredBalanceId) {
|
||||
const seen = new Set()
|
||||
for (const source of ordered) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source.positions || []) {
|
||||
const usedByPosition = new Map()
|
||||
for (const carton of source.cartons || []) {
|
||||
const position = (source.positions || []).find(row => id(row.positionId) === id(carton.positionId))
|
||||
const positionId = id(position?.positionId)
|
||||
if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo)
|
||||
const cartonKey = `${id(source.inboundOrderId)}:${carton.cartonMark}`
|
||||
if (!carton.cartonMark || seen.has(cartonKey) || !positionId || !nonBlank(position?.palletNo)
|
||||
|| position.palletNo.toUpperCase() === 'UNBOUND') continue
|
||||
seen.add(positionId)
|
||||
const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages))
|
||||
if (!Number.isInteger(selected) || selected <= 0) continue
|
||||
for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 })
|
||||
const used = usedByPosition.get(positionId) || 0
|
||||
if (used >= Number(position.availablePackages)) continue
|
||||
seen.add(cartonKey); usedByPosition.set(positionId, used + 1)
|
||||
sourceLines.push({ source, position, carton, packageCount: 1 })
|
||||
if (sourceLines.length === 3) return sourceLines
|
||||
}
|
||||
}
|
||||
@@ -388,25 +392,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
return false
|
||||
}
|
||||
if (!await fundRelabelWallet(context, customerId)) return false
|
||||
// P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets,
|
||||
// and may combine only the current completed container's same-destination inbounds.
|
||||
const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || [])
|
||||
.filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType
|
||||
&& item.fbaWarehouseCode === fixture.fbaWarehouseCode)
|
||||
.map((item) => item.inboundCode).filter(nonBlank))]
|
||||
const inventoryCandidates = []
|
||||
let inventoryCandidatesResponse = null
|
||||
for (const inboundCode of inboundCodes) {
|
||||
inventoryCandidatesResponse = await context.request('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
|
||||
}), undefined, merchantToken, {})
|
||||
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
|
||||
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
|
||||
if (!success(inventoryCandidatesResponse)) return false
|
||||
inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) =>
|
||||
item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId)))
|
||||
}
|
||||
const boxed = await readCurrentBoxedInventory(context, merchantToken, fixture.overseasWarehouseId, MODULE)
|
||||
// 同一次收货产生的真实有箱库存;优先选一个具备三箱来源的入仓单,避免借用历史库存。
|
||||
const candidate = boxed.find(row => selectPhysicalRelabelSources([row]).length === 3)
|
||||
const inventoryCandidates = candidate ? [candidate] : []
|
||||
const inventoryCandidatesResponse = null
|
||||
const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId)
|
||||
const requiredPackages = 3 - sourceLines.length
|
||||
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
||||
@@ -426,7 +416,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: RELABEL_CHARGE_AMOUNT, remark: context.marker,
|
||||
idempotencyKey: key(context, 'RELABEL-CREATE'),
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
items: sourceLines.map(({ source, position, packageCount }, index) => {
|
||||
items: sourceLines.map(({ source, position, carton, packageCount }, index) => {
|
||||
const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128)
|
||||
const changesCartonLabel = nonBlank(source.destinationFbaWarehouseCode)
|
||||
return {
|
||||
@@ -437,11 +427,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128),
|
||||
newSkuCode,
|
||||
labelRequirement: context.marker,
|
||||
cartonMark: nonBlank(source.cartonMark) ? source.cartonMark : `CM-${String(context.marker).slice(-28)}-${index + 1}`,
|
||||
cartonMark: carton.cartonMark,
|
||||
fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null,
|
||||
destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null,
|
||||
operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL',
|
||||
skuItems: [{ skuCode: source.skuCode, newSkuCode, quantity: packageCount, remark: context.marker }],
|
||||
skuItems: carton.items.map(item => ({ skuCode: item.skuCode, newSkuCode, quantity: Number(item.quantity), remark: context.marker })),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -214,14 +214,30 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
}
|
||||
const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选',
|
||||
query(`${root}/return/candidates`, { customerId, warehouseId }))
|
||||
const completed = await command(pdaToken, 'GET', '读取本轮已完成换标任务的实际箱身份',
|
||||
`/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(relabelNo)}`)
|
||||
require('同一换标任务已完成且实际箱身份唯一', completed.relabelNo === relabelNo
|
||||
&& completed.taskStatus === 'COMPLETED' && completed.applicationStatus === 'COMPLETED'
|
||||
&& completed.items?.length === 1 && id(completed.items[0].returnCartonId)
|
||||
&& Number(completed.items[0].relabelledPackages) === 1)
|
||||
const cartonSources = await command(merchantToken, 'GET', '客户按原退货单核对已换标箱ID与箱号',
|
||||
query('/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: warehouseId, inboundCode: returnNo,
|
||||
}))
|
||||
const matched = (Array.isArray(cartonSources) ? cartonSources : []).filter(row =>
|
||||
id(row.returnCartonId) === id(completed.items[0].returnCartonId))
|
||||
require('任务实际箱唯一映射为客户库存箱号', matched.length === 1 && nonBlank(matched[0].cartonMark)
|
||||
&& id(matched[0].overseasWarehouseId) === warehouseId)
|
||||
const selected = []
|
||||
for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) {
|
||||
const detail = await command(merchantToken, 'GET', '客户核对出库候选的真实换标归属', `${inventory}/${encodeURIComponent(row.cartonMark)}`)
|
||||
if (detail.relabelNo !== relabelNo) continue
|
||||
if (detail.cartonMark !== matched[0].cartonMark) continue
|
||||
require('已换标候选归属数量与完成状态正确', detail.customerId === customerId
|
||||
&& detail.overseasWarehouseId === warehouseId && detail.sourceReturnNo === returnNo
|
||||
&& detail.status === 'AVAILABLE' && detail.relabelStatus === 'COMPLETED'
|
||||
&& Number(detail.totalQuantity) === 1 && detail.items?.length === 1)
|
||||
&& (!detail.relabelNo || detail.relabelNo === relabelNo)
|
||||
&& Number(detail.totalQuantity) === 1 && detail.items?.length === 1
|
||||
&& detail.items[0].skuCode === completed.items[0].skuCode)
|
||||
selected.push(detail)
|
||||
}
|
||||
require('只出库本轮实际换标的一件库存', selected.length === 1)
|
||||
@@ -402,13 +418,16 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
|
||||
const sources = await command(pdaToken, 'POST', '读取本轮换标实际来源托盘', `${pallets}/relabel-sources`, { relabelNo })
|
||||
require('退货换标具有明确来源托盘', Array.isArray(sources) && sources.length > 0)
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const unbound = source.palletCode === 'UNBOUND' && id(source.palletId) === '0'
|
||||
require('来源必须是实际托盘或明确未组托退货箱', unbound
|
||||
|| (nonBlank(source.palletCode) && source.palletCode !== 'UNBOUND' && /^[1-9]\d*$/.test(id(source.palletId))))
|
||||
const target = await command(pdaToken, 'POST', '生成本轮带目的仓换标托盘', `${pallets}/relabel-pallet-label`,
|
||||
{ relabelNo, idempotencyKey: key(context, `RETURN-PALLET-${index}`) })
|
||||
const from = await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/pallets/resolve`, { code: source.palletCode })
|
||||
const from = unbound ? null : await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/pallets/resolve`, { code: source.palletCode })
|
||||
const to = await command(pdaToken, 'POST', '回查目标托盘版本', `${pallets}/pallets/resolve`, { code: target.palletCode })
|
||||
require('组托仅使用本轮任务箱唛', source.cartonMarks?.length > 0
|
||||
&& source.cartonMarks.every(mark => task.items.some(item => item.cartonMark === mark)))
|
||||
const body = { relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
|
||||
const body = { relabelNo, source: from ? { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) } : null,
|
||||
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) },
|
||||
actualSource: { positionId: source.positionId, expectedPositionVersion: Number(source.positionVersion),
|
||||
packages: task.items.filter(item => source.cartonMarks.includes(item.cartonMark))
|
||||
|
||||
@@ -32,7 +32,7 @@ test('relabelled outbound refuses another relabel application before creating an
|
||||
return { http: 200, body: { code: '200', data: path.includes('/candidates')
|
||||
? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } }
|
||||
} }
|
||||
await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /只出库本轮/)
|
||||
await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /实际箱身份/)
|
||||
assert.ok(calls.every(call => call.method === 'GET'))
|
||||
})
|
||||
|
||||
@@ -58,6 +58,11 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true
|
||||
data = { status: 'IN_PROGRESS' }
|
||||
} else if (path.endsWith('/complete')) { completed = true; data = { status: 'COMPLETED' } }
|
||||
else if (path.endsWith('/candidates')) { reserved = false; data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] }
|
||||
else if (path.endsWith('/relabel-tasks/RL-1')) data = { relabelNo: fault === 'foreign task' ? 'OTHER' : 'RL-1',
|
||||
taskStatus: fault === 'pending task' ? 'IN_PROGRESS' : 'COMPLETED', applicationStatus: 'COMPLETED',
|
||||
items: [{ returnCartonId: '100', skuCode: 'SKU', relabelledPackages: 1 }] }
|
||||
else if (path.endsWith('/inventory-candidates')) data = [{ returnCartonId: fault === 'wrong source carton id' ? 'OTHER' : '100',
|
||||
cartonMark: 'BOX', overseasWarehouseId: '42' }]
|
||||
else if (path.endsWith('/outbound-orders/return')) { reserved = true; data = { taskNo: 'TASK', outboundNo: 'OUT' } }
|
||||
else if (path.endsWith('/return-inventory')) {
|
||||
assert.equal(url.searchParams.get('customerId'), '123'); assert.equal(url.searchParams.get('overseasWarehouseId'), '42')
|
||||
@@ -77,7 +82,8 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true
|
||||
if (picked && fault === 'duplicate movement') rows.push(movement)
|
||||
data = { records: rows.reverse(), total: rows.length }
|
||||
} else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123',
|
||||
overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED',
|
||||
overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: null, relabelStatus: 'COMPLETED',
|
||||
skuConversions: [],
|
||||
status: picked ? 'OUTBOUNDED' : reserved ? 'OUTBOUND_PENDING' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity,
|
||||
receivedAt: picked && fault === 'history time drift' ? '2026-10-02T17:54:38.305' : '2026-10-02T17:52:56.794',
|
||||
items: [{ skuCode: 'SKU', quantity: picked && fault === 'history erased' ? 0 : quantity }] }
|
||||
@@ -176,7 +182,13 @@ test('relabelled outbound shares accounting and strict PDA scan checks', async (
|
||||
assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo, 'OUT')
|
||||
})
|
||||
|
||||
test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => {
|
||||
for (const fault of ['foreign task', 'wrong source carton id', 'pending task']) test(`completed relabel outbound rejects invalid authoritative task mapping: ${fault}`, async () => {
|
||||
const { context, calls } = outboundHarness(fault, 1)
|
||||
await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /箱身份|库存箱号/)
|
||||
assert.ok(calls.every(call => call.method === 'GET'))
|
||||
})
|
||||
|
||||
for (const unbound of [false, true]) test(`return relabel lifecycle requires review, quantities, binding, photo and customer confirmation: unbound=${unbound}`, async () => {
|
||||
const calls = []
|
||||
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
|
||||
const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
@@ -189,13 +201,14 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
|
||||
assert.equal(body.cartonMark, 'BOX')
|
||||
assert.equal(body.skuCode, 'SKU-1')
|
||||
}
|
||||
if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletCode: 'OLD', cartonMarks: ['BOX'] }]
|
||||
if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletId: unbound ? '0' : '42', palletCode: unbound ? 'UNBOUND' : 'OLD', cartonMarks: ['BOX'] }]
|
||||
if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' }
|
||||
if (path.endsWith('/resolve')) {
|
||||
assert.equal(path, '/api/pda/v1/last-leg/pallet-operations/pallets/resolve')
|
||||
data = { palletCode: body.code, palletVersion: 1 }
|
||||
}
|
||||
if (path.endsWith('/relabel-disassemble')) {
|
||||
assert.deepEqual(body.source, unbound ? null : { palletCode: 'OLD', expectedVersion: 1 })
|
||||
assert.deepEqual(body.actualSource, { positionId: '12', expectedPositionVersion: 0, packages: 1 })
|
||||
data = { status: 'COMPLETED', operationId: '9', replayed: true }
|
||||
}
|
||||
@@ -218,6 +231,7 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
|
||||
assert.ok(index('/photos/files') < index('/RL-1/complete'))
|
||||
assert.ok(index('/RL-1/complete') < index('/confirm-completion'))
|
||||
assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant')
|
||||
assert.deepEqual(calls.filter(c => c.path.endsWith('/resolve')).map(c => c.body.code), unbound ? ['NEW'] : ['OLD', 'NEW'])
|
||||
})
|
||||
|
||||
for (const missing of ['cartonMark', 'skuCode', 'expectedPackages']) {
|
||||
|
||||
@@ -365,6 +365,24 @@ test('F40 selects actual source warehouse irrespective of authority list order a
|
||||
assert.doesNotMatch(source, /resolveSharedWarehouse|19300000000020000[34]/)
|
||||
})
|
||||
|
||||
for (const fault of ['', 'changed-total', 'repeated-page', 'missing-page']) test(`F40 fully pages sources before proving unique receiving warehouse: ${fault || 'target on page two'}`, async () => {
|
||||
const f = warehouseSelectionFixture(), original = f.context.request
|
||||
f.context.request = async (method, path) => {
|
||||
if (path.includes('/warehouse-admin?')) return original(method, path)
|
||||
f.calls.push({ method, path })
|
||||
const url = new URL(path, 'https://offline.invalid'), page = Number(url.searchParams.get('page'))
|
||||
const target = f.orders.filter(row => row.containerNo === url.searchParams.get('containerNo'))
|
||||
const unrelated = Array.from({ length: 200 }, (_, n) => ({ id: `OTHER-${n}`, customerId: 'OTHER' }))
|
||||
const records = page === 1 || fault === 'repeated-page' ? unrelated : fault === 'missing-page' ? [] : target
|
||||
return { http: 200, body: { code: 200, data: { records, total: fault === 'changed-total' && page === 2 ? 202 : 201 } } }
|
||||
}
|
||||
if (fault) await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /分页/)
|
||||
else {
|
||||
assert.equal((await resolveF40ReceivingWarehouse(f.context, f.producers)).id, 'actual-warehouse')
|
||||
assert.equal(f.calls.filter(row => row.path.includes('page=2')).length, 2)
|
||||
}
|
||||
})
|
||||
|
||||
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' },
|
||||
@@ -569,6 +587,7 @@ test('early F40 failures become FAILED with only a safe stage and preserve the o
|
||||
|
||||
function nativeFragmentFixture() {
|
||||
const context = fixture(), events = [], calls = []
|
||||
context.createdResources.phase4OverseasPda = { generatedPlanIds: ['81', '82'] }
|
||||
const producer = { tenantId: '90', inboundCode: 'IN-F40', sourceInboundId: '901', sourceOrderId: '100',
|
||||
rootOrderId: '100', containerBookingId: '902' }
|
||||
const rootProof = { fragments: [producer], currentStage: 'DISPATCH_AND_SIGNATURE_1' }
|
||||
@@ -584,7 +603,9 @@ function nativeFragmentFixture() {
|
||||
let data
|
||||
if (method === 'GET' && path.includes('/candidates?')) {
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('warehouseId'), '80')
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('sourceStage'), false)
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('sourceStage'), 'RECEIVED')
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('planIds'), '81,82')
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('size'), '50')
|
||||
data = { complete: true, candidates: [nativeF40Candidate()] }
|
||||
} else if (method === 'POST' && path.endsWith('/dispatches')) {
|
||||
events.push('create')
|
||||
|
||||
@@ -38,15 +38,28 @@ export async function resolveF40ReceivingWarehouse(context, producers) {
|
||||
&& 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 rows = [], seen = new Set(); let total
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const data = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓',
|
||||
query('/api/platform/v1/last-leg/orders', { page, size: 200, containerNo: producer.containerNo }))
|
||||
check(context, 'F40 来源柜客户入仓身份分页完整且总数稳定', Array.isArray(data?.records)
|
||||
&& Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0
|
||||
&& (total === undefined || total === Number(data.total)) && data.records.length <= 200
|
||||
&& (data.records.length > 0 || rows.length === Number(data.total)), `page=${page}`)
|
||||
total ??= Number(data.total)
|
||||
for (const row of data.records) {
|
||||
const key = `${id(row.customerId)}:${id(row.id)}`
|
||||
check(context, 'F40 来源柜客户入仓身份分页无重复或缺失', Boolean(id(row.id)) && !seen.has(key), `page=${page}`)
|
||||
seen.add(key); rows.push(row)
|
||||
}
|
||||
if (rows.length >= total) break
|
||||
}
|
||||
check(context, 'F40 来源柜客户入仓身份分页完整', rows.length === total, `rows=${rows.length}, total=${total}`)
|
||||
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])
|
||||
@@ -504,8 +517,12 @@ export async function sendFragment(context, producer, picker, merchantToken, ind
|
||||
context.createdResources.phase5OverseasFulfillment = dispatchProof
|
||||
context = await fulfillmentEvidenceContext(context)
|
||||
try {
|
||||
const planIds = context.createdResources?.phase4OverseasPda?.generatedPlanIds
|
||||
check(context, 'F40 正式候选限定本轮转换计划', Array.isArray(planIds) && planIds.length > 0
|
||||
&& planIds.every(value => /^[1-9][0-9]*$/.test(id(value))) && new Set(planIds.map(id)).size === planIds.length,
|
||||
'current-converted-plan-scope')
|
||||
const candidates = await api(context, 'GET', '读取该来源入仓码原生实收排车候选',
|
||||
query(`${DISPATCH}/candidates`, { warehouseId }))
|
||||
query(`${DISPATCH}/candidates`, { warehouseId, sourceStage: 'RECEIVED', planIds: planIds.map(id).join(','), page: 1, size: 50 }))
|
||||
check(context, 'F40 正式候选列表完整', candidates?.complete === true && Array.isArray(candidates.candidates), 'native-complete-candidate-list')
|
||||
const matches = candidates.candidates.filter(row => row.selection?.sourceType === 'INBOUND_PLAN'
|
||||
&& row.inboundCode === producer.inboundCode && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId)
|
||||
|
||||
@@ -131,6 +131,17 @@ test('sample procurement traverses receipt, packing, both shipments and exact wa
|
||||
assert.ok(proof.checks.every(check => check.passed))
|
||||
})
|
||||
|
||||
test('正式Long字符串版本贯穿采购正常履约', async () => {
|
||||
const h = completeFixture(), request = h.context.request.bind(h.context)
|
||||
h.context.request = async (...args) => {
|
||||
const response = await request(...args)
|
||||
const order = response.body?.data?.customerView?.order || response.body?.data?.order
|
||||
if (order) order.version = String(order.version)
|
||||
return response
|
||||
}
|
||||
assert.equal((await verifyProcurementSample(h.context, h.inputs)).status, 'PASSED')
|
||||
})
|
||||
|
||||
for (const fault of ['aggregate customer', 'aggregate order', 'aggregate version', 'create replay customer', 'customer final order', 'replay deducts']) {
|
||||
test(`procurement stops rather than passing ${fault}`, async () => {
|
||||
const h = completeFixture(fault)
|
||||
|
||||
@@ -4,6 +4,7 @@ export const PROCUREMENT_REQUIRED_AVAILABLE_BALANCE = '3.00'
|
||||
const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements'
|
||||
const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const clone = value => value == null ? value : structuredClone(value)
|
||||
const version = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN
|
||||
const cents = value => {
|
||||
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value))
|
||||
if (!match) throw new Error('钱包金额缺失或精度异常')
|
||||
@@ -48,11 +49,11 @@ export async function verifyProcurementSample(context, inputs) {
|
||||
detail = await request('GET', path(), undefined, '回查采购聚合详情')
|
||||
await check('采购聚合仍属于原客户原单且版本有效', detail?.customerView?.order?.orderNo === proof.orderNo
|
||||
&& detail.customerView.order.customerId === customerId
|
||||
&& Number.isSafeInteger(detail.customerView.order.version) && detail.customerView.order.version >= 0)
|
||||
&& Number.isSafeInteger(version(detail.customerView.order.version)))
|
||||
return detail
|
||||
}
|
||||
async function command(operation, values = {}, replay = false) {
|
||||
const body = { version: detail.customerView.order.version, idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values }
|
||||
const body = { version: version(detail.customerView.order.version), idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values }
|
||||
await request('POST', path(operation), body, operation)
|
||||
if (replay) await request('POST', path(operation), body, `${operation} 原请求重放`)
|
||||
await read(); return body
|
||||
@@ -84,7 +85,7 @@ export async function verifyProcurementSample(context, inputs) {
|
||||
const repeated = await request('POST', MERCHANT, create, '客户同键重放', inputs.customerToken)
|
||||
await check('重放没有新建采购单', repeated?.order?.orderNo === proof.orderNo && repeated.order.customerId === customerId)
|
||||
await read()
|
||||
await request('POST', path('review'), { version: detail.customerView.order.version, idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓')
|
||||
await request('POST', path('review'), { version: version(detail.customerView.order.version), idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓')
|
||||
await read()
|
||||
const purchaseFile = await inputs.upload(proof.orderNo, 'PURCHASE')
|
||||
const voucher = await inputs.upload(proof.orderNo, 'VOUCHER')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
const MODULE = 'phase4-overseas-business'
|
||||
const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true }
|
||||
const version = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN
|
||||
|
||||
/** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */
|
||||
export async function verifyUsTruckFulfillment(context, proof, { checkpoint = context.checkpoint } = {}) {
|
||||
@@ -47,7 +48,7 @@ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = co
|
||||
}
|
||||
const identity = detail => check('卡车原单客户和版本有效', detail?.summary?.orderNo === proof.orderNo
|
||||
&& String(detail.summary.customerId) === String(proof.customerId)
|
||||
&& Number.isSafeInteger(detail.summary.version) && detail.summary.version >= 0)
|
||||
&& Number.isSafeInteger(version(detail.summary.version)))
|
||||
try {
|
||||
let current = await request('GET', '')
|
||||
identity(current)
|
||||
@@ -68,12 +69,12 @@ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = co
|
||||
for (const [action, state, allocationIndex] of stages) {
|
||||
if (action === 'allocate-pallets' && allocationIndex === 1) {
|
||||
await request('POST', '/prepare-dispatch', {
|
||||
version: current.summary.version, idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker,
|
||||
version: version(current.summary.version), idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker,
|
||||
}, 409)
|
||||
const unchanged = await request('GET', '')
|
||||
check('卡车部分分配禁止待派车且原单完全不变', JSON.stringify(unchanged) === JSON.stringify(current))
|
||||
}
|
||||
const beforeVersion = current.summary.version
|
||||
const beforeVersion = version(current.summary.version)
|
||||
const body = { version: beforeVersion, idempotencyKey: `${context.marker}-truck-${action}`, remark: context.marker }
|
||||
if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: palletCount, packageCount: packages, palletPackageCounts: packageCounts })
|
||||
if (action === 'allocate-pallets') {
|
||||
@@ -90,7 +91,7 @@ export async function verifyUsTruckFulfillment(context, proof, { checkpoint = co
|
||||
}
|
||||
const next = await request('POST', `/${action}`, body)
|
||||
identity(next)
|
||||
check(`卡车${action}状态和版本正确`, next.summary.status === state && next.summary.version === beforeVersion + 1)
|
||||
check(`卡车${action}状态和版本正确`, next.summary.status === state && version(next.summary.version) === beforeVersion + 1)
|
||||
check('卡车原单实收托盘及箱数守恒', next.pallets?.length === palletCount && Number(next.summary.receivedPalletCount) === palletCount
|
||||
&& new Set(next.pallets.map(row => String(row.id))).size === palletCount
|
||||
&& next.pallets.every((row, index) => Number(row.packageCount) === packageCounts[index]
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function allocateWarehouseTransfer(context, { proof, merchantToken,
|
||||
const positions = await request('GET', `${PLATFORM}/inventory/balances/${encodeURIComponent(candidate.inventoryBalanceId)}/positions${scope}`)
|
||||
check('库存位置回查必须返回完整数组', Array.isArray(positions))
|
||||
position = positions.find(p => id(p.customerId) === proof.customerId && id(p.overseasWarehouseId) === proof.warehouseId
|
||||
&& (!proof.requiredSourcePositionId || id(p.id) === proof.requiredSourcePositionId)
|
||||
&& p.inboundCode === proof.inboundCode && id(p.inboundOrderId) === id(candidate.inboundOrderId)
|
||||
&& id(p.balanceId) === id(candidate.inventoryBalanceId)
|
||||
&& validId(p.id) && validId(p.palletId) && Number(p.availablePackages) >= 1
|
||||
|
||||
@@ -27,7 +27,7 @@ function fixture(fault = '') {
|
||||
data = { total: rows.length, records: rows }
|
||||
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'READY_FOR_OUTBOUND', fulfillmentStatus: fault === 'wrong relabel' ? 'COMPLETED' : 'OUTBOUNDED' } }
|
||||
else data = { transferNo: 'WT-1', status: shipped ? 'OUTBOUNDED' : 'ALLOCATED', relabelNo: 'RL-1', relabelApplicationId: '501', operatingWarehouseId: '201',
|
||||
packageCount: 1, fbaWarehouseCode: 'FBA-1', outboundAt: shipped ? '2026-10-22T12:00:00' : null,
|
||||
packageCount: 1, fbaWarehouseCode: 'FBA-1', actualShipAt: shipped && fault !== 'missing shipment time' ? '2026-10-22T12:00:00' : null,
|
||||
allocations: [{ ...allocation, status: shipped ? 'SHIPPED' : 'RESERVED' }] }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} }
|
||||
@@ -47,7 +47,7 @@ test('原转运出库验证同单新SKU扣库及重放唯一性', async () => {
|
||||
assert.equal(f.calls.length, count)
|
||||
})
|
||||
|
||||
for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'disk']) {
|
||||
for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'missing shipment time', 'disk']) {
|
||||
test(`原转运出库拒绝 ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function outboundWarehouseTransfer(context, { proof, merchantToken,
|
||||
check('原转运出库前没有出库流水', (await ledger()).length === 0)
|
||||
const body = { idempotencyKey: `${context.marker}-TRANSFER-OUTBOUND`, remark: context.marker }
|
||||
const shipped = await request('POST', `${base}/outbound${scope}`, body)
|
||||
check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.outboundAt))
|
||||
check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.actualShipAt))
|
||||
const movements = await ledger()
|
||||
check('原转运唯一SHIP流水按实际新SKU托盘扣除一箱', movements.length === 1 && movements.every(row => validId(row.id)
|
||||
&& row.businessType === 'SHIP' && row.businessOrderNo === proof.transferNo && row.inboundCode === proof.inboundCode
|
||||
@@ -72,7 +72,7 @@ export async function outboundWarehouseTransfer(context, { proof, merchantToken,
|
||||
const replay = await request('POST', `${base}/outbound${scope}`, body)
|
||||
check('出库重放保持原单和流水不变', stable(replay) === stable(shipped) && stable(await ledger()) === stable(movements))
|
||||
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
|
||||
check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.outboundAt === shipped.outboundAt)
|
||||
check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.actualShipAt === shipped.actualShipAt)
|
||||
const relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
|
||||
check('原关联换标同步出库且未提前完成', relabel?.application?.relabelNo === proof.relabelNo
|
||||
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.fulfillmentStatus === 'OUTBOUNDED'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
|
||||
import { findReusableTransferReceiptTarget, receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
|
||||
|
||||
// 收货替身使用独立目标仓资源,覆盖资源不足、串单、重复入库和结算未完成。
|
||||
function fixture(fault = '') {
|
||||
@@ -56,6 +56,23 @@ test('原转运复用异仓托盘收货并核对唯一入库和结算终态', as
|
||||
assert.equal(f.calls.length, count)
|
||||
})
|
||||
|
||||
test('造单前只读选取异仓目的仓,翻过同仓和未绑定目的仓的托盘且不改主档', async () => {
|
||||
const f = fixture()
|
||||
const request = async (method, path) => {
|
||||
assert.equal(method, 'GET')
|
||||
if (path.includes('/pallets?') && path.includes('page=1&')) return { total: '201', records: Array.from({ length: 200 }, (_, i) => ({
|
||||
id: String(1000 + i), customerId: '0', warehouseId: i % 2 ? '201' : '202', status: 'IDLE', locationId: '701',
|
||||
fbaWarehouseCode: i % 2 ? 'FBA-OTHER' : null })) }
|
||||
if (path.includes('/pallets?')) return { total: '201', records: [{ id: '601', customerId: '0', warehouseId: '202', status: 'IDLE', locationId: '701', fbaWarehouseCode: 'FBA-1' }] }
|
||||
return (await f.context.request(method, path)).body.data
|
||||
}
|
||||
const target = await findReusableTransferReceiptTarget({ sourceWarehouseId: '201', request,
|
||||
check: (name, ok) => assert.ok(ok, name) })
|
||||
assert.equal(target.pallet.fbaWarehouseCode, 'FBA-1')
|
||||
assert.equal(target.warehouse.id, '202')
|
||||
assert.equal(f.calls.some(c => c.method !== 'GET'), false)
|
||||
})
|
||||
|
||||
for (const fault of ['no resource', 'occupied', 'location blocked', 'network', 'target drift', 'foreign customer', 'double receipt', 'replay drift', 'unsettled', 'disk']) {
|
||||
test(`转运收货遇到 ${fault} 明确失败且不补造资源`, async () => {
|
||||
const f = fixture(fault)
|
||||
|
||||
@@ -6,6 +6,38 @@ const validId = value => /^[1-9]\d*$/.test(id(value))
|
||||
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
|
||||
|
||||
/** 只读筛选既有异仓空闲资源,供造单前选线和收货前重新核验共用。 */
|
||||
export async function findReusableTransferReceiptTarget({ sourceWarehouseId, fbaWarehouseCode = '', request, check }) {
|
||||
if (!validId(sourceWarehouseId)) throw Error('缺少转运来源作业仓')
|
||||
let target
|
||||
// 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。
|
||||
for (let page = 1; page <= 100 && !target; page++) {
|
||||
const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`)
|
||||
check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0)
|
||||
for (const candidate of pallets.records) {
|
||||
if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId)
|
||||
|| id(candidate.warehouseId) === sourceWarehouseId || (!candidate.fbaWarehouseCode || (fbaWarehouseCode && candidate.fbaWarehouseCode !== fbaWarehouseCode))
|
||||
|| !validId(candidate.id) || !validId(candidate.locationId)) continue
|
||||
const resource = `${RESOURCES}/${candidate.warehouseId}`
|
||||
const pallet = await request('GET', `${resource}/pallets/${candidate.id}`)
|
||||
if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0'
|
||||
|| id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId)
|
||||
|| pallet.fbaWarehouseCode !== candidate.fbaWarehouseCode || !pallet.palletCode) continue
|
||||
const location = await request('GET', `${resource}/locations/${pallet.locationId}`)
|
||||
if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId)
|
||||
|| id(location?.id) !== id(pallet.locationId) || !location.locationCode
|
||||
|| !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue
|
||||
const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`)
|
||||
const warehouse = master?.warehouse
|
||||
if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0'
|
||||
|| warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue
|
||||
target = { pallet, location, warehouse }; break
|
||||
}
|
||||
if (page * 200 >= Number(pallets.total)) break
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/** 转运收货复用其他海外仓的空闲匹配托盘,并验证原单入库及关联换标结算。 */
|
||||
export async function receiveWarehouseTransfer(context, { proof, merchantToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
|
||||
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|
||||
@@ -47,32 +79,8 @@ export async function receiveWarehouseTransfer(context, { proof, merchantToken,
|
||||
try {
|
||||
const original = await request('GET', base + scope)
|
||||
check('原转运出库来源不变', same(original, 'OUTBOUNDED') && Boolean(original.fbaWarehouseCode))
|
||||
let target
|
||||
// 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。
|
||||
for (let page = 1; page <= 100 && !target; page++) {
|
||||
const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`)
|
||||
check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0)
|
||||
for (const candidate of pallets.records) {
|
||||
if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId)
|
||||
|| id(candidate.warehouseId) === proof.warehouseId || candidate.fbaWarehouseCode !== original.fbaWarehouseCode
|
||||
|| !validId(candidate.id) || !validId(candidate.locationId)) continue
|
||||
const resource = `${RESOURCES}/${candidate.warehouseId}`
|
||||
const pallet = await request('GET', `${resource}/pallets/${candidate.id}`)
|
||||
if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0'
|
||||
|| id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId)
|
||||
|| pallet.fbaWarehouseCode !== original.fbaWarehouseCode || !pallet.palletCode) continue
|
||||
const location = await request('GET', `${resource}/locations/${pallet.locationId}`)
|
||||
if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId)
|
||||
|| id(location?.id) !== id(pallet.locationId) || !location.locationCode
|
||||
|| !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue
|
||||
const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`)
|
||||
const warehouse = master?.warehouse
|
||||
if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0'
|
||||
|| warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue
|
||||
target = { pallet, location, warehouse }; break
|
||||
}
|
||||
if (page * 200 >= Number(pallets.total)) break
|
||||
}
|
||||
const target = await findReusableTransferReceiptTarget({ sourceWarehouseId: proof.warehouseId,
|
||||
fbaWarehouseCode: original.fbaWarehouseCode, request, check })
|
||||
check('必须存在其他海外仓同目的仓的空闲托盘与可预留库位', Boolean(target))
|
||||
evidence.target = target
|
||||
const { pallet, location, warehouse } = target
|
||||
|
||||
Reference in New Issue
Block a user