Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。
|
||||
|
||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||
|
||||
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
|
||||
@@ -37,11 +39,27 @@ export async function run(context) {
|
||||
|
||||
## 可用上下文
|
||||
|
||||
混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。
|
||||
|
||||
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
|
||||
|
||||
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
|
||||
|
||||
一件代发矩阵在每个剩余实物托盘收货前核对待收箱数,并以 `allowDifference=false` 尝试正常完成:必须返回 A0409,回查会话事实不变后才继续收货。这不覆盖显式差异完成,不得把正常完成门禁解读成系统禁止一切差异结案。
|
||||
|
||||
每托首次收货及重复扫描后,矩阵按本轮已提交的来源托盘、箱唛和入仓码独立计算期望进度,回查柜级及每票的总箱数、实收、待收三项。柜级总数正确但串票、重复行、缺票或逐票待收未刷新仍判失败。离线验证器包含三票交替进度样本,但该样本不等于三类混柜真实 HTTP 验收。
|
||||
|
||||
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
|
||||
|
||||
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
|
||||
|
||||
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
|
||||
|
||||
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
|
||||
|
||||
- `orders.mjs` 创建草稿前读取正式 `inbound-code-policy`:自动模式不传入仓码,手工模式按本轮 marker 生成短且稳定的测试入仓码,确保箱唛二维码长度有效;策略读取失败或矛盾立即停止,不修改系统全局配置、不重用历史订单。
|
||||
|
||||
- `dropship-outbound-edge-cases.mjs` 是 `dropship-pallet-matrix` 内部探针,不单独运行:使用本轮库存验证超量、错误 SKU、不存在/重复箱唛、箱数不符、同键异载荷、未拣齐完成、并发同键创建、未拣取消和重放。单票单托额外先拣一箱再拣剩余箱;部分拣货被拒时仍保留失败及部署阻断,只有回查冻结事实无变化才继续整条明细拣货以采集后续证据,不能将此计为分批通过。多票共托使用同一 SKU,来源区分必须依赖入仓码。成功数据保留,未拣并发探针出库单正常取消。本扩展不代表跨客户、跨仓、多 SKU 混箱、逐箱不等件数、跨区故障注入均已覆盖,结论以当轮真实报告为准。
|
||||
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
|
||||
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
|
||||
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
|
||||
@@ -140,7 +158,7 @@ export async function run(context) {
|
||||
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
@@ -165,7 +183,7 @@ node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --ove
|
||||
|
||||
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
|
||||
|
||||
海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为 `WAIT_OUTBOUND + WAIT_DELIVERY`,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后覆盖派送完成、回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。
|
||||
海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。
|
||||
|
||||
```powershell
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment
|
||||
|
||||
@@ -93,8 +93,8 @@ async function createSecondGroup(context, third, fourth) {
|
||||
|
||||
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
|
||||
evidence(simulationResponse), OPTIONS)
|
||||
@@ -119,8 +119,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
|
||||
|
||||
async function createNormalPushedPlan(context, fixture, suffix) {
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
|
||||
evidence(simulationResponse), OPTIONS)
|
||||
@@ -174,8 +174,8 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
|
||||
const draft = oceanBookingDraft(context, suffix, master, {
|
||||
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
|
||||
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length,
|
||||
capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: chains.length,
|
||||
capacityCbm: 68, maxWeightKg: 19500 }],
|
||||
})
|
||||
const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: draft }, undefined,
|
||||
@@ -205,7 +205,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
|
||||
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) })
|
||||
const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse,
|
||||
evidence(releaseResponse), OPTIONS)
|
||||
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HC')
|
||||
const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HQ')
|
||||
context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse,
|
||||
released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length,
|
||||
`status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS)
|
||||
@@ -510,10 +510,10 @@ export async function run(context) {
|
||||
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
|
||||
|
||||
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP',
|
||||
capacityCbm: 33, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40GP',
|
||||
capacityCbm: 58, maxWeightKg: 19500,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
|
||||
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用40GP模拟柜', simulationResponse,
|
||||
evidence(simulationResponse), OPTIONS)
|
||||
let bookingId = id(simulation?.id)
|
||||
if (!bookingId) return
|
||||
@@ -573,7 +573,7 @@ export async function run(context) {
|
||||
context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
|
||||
candidateGroupIds.includes(groupId)
|
||||
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
|
||||
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
|
||||
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=58cbm/19500kg`, OPTIONS)
|
||||
const allCandidateVolume = isolatedCandidates
|
||||
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
|
||||
context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse,
|
||||
@@ -601,10 +601,10 @@ export async function run(context) {
|
||||
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
|
||||
|
||||
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
|
||||
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HQ模拟柜',
|
||||
recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS)
|
||||
const recommendBookingId = id(recommendSimulation?.id)
|
||||
if (!recommendBookingId) return
|
||||
@@ -647,10 +647,10 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
|
||||
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HQ模拟柜', workingSimulationResponse,
|
||||
evidence(workingSimulationResponse), OPTIONS)
|
||||
bookingId = id(workingSimulation?.id)
|
||||
if (!bookingId) return
|
||||
@@ -966,8 +966,8 @@ export async function run(context) {
|
||||
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
|
||||
evidence(uiGroupResponse), OPTIONS)
|
||||
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
|
||||
evidence(uiSimulationResponse), OPTIONS)
|
||||
|
||||
@@ -21,15 +21,15 @@ test('HTTP fixtures use the canonical primary and mismatch container specificati
|
||||
const primarySources = productionFixtures.map(source).join('\n')
|
||||
const containerFlow = source('phase3-container-loading.mjs')
|
||||
|
||||
assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/)
|
||||
assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/)
|
||||
assert.match(primarySources, /containerType: '40HQ'[\s\S]*?capacityCbm: 68[\s\S]*?maxWeightKg: 19500/)
|
||||
assert.match(containerFlow, /containerType: '40GP'[\s\S]*?capacityCbm: 58[\s\S]*?maxWeightKg: 19500/)
|
||||
})
|
||||
|
||||
test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => {
|
||||
for (const name of productionFixtures) {
|
||||
const fixture = source(name)
|
||||
assert.doesNotMatch(fixture, /40HQ|45HQ/, name)
|
||||
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name)
|
||||
assert.doesNotMatch(fixture, /20GP|40HC|40NR|45HC/, name)
|
||||
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|76|67|33|28)(?:\D|$)/, name)
|
||||
assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name)
|
||||
}
|
||||
})
|
||||
@@ -37,13 +37,11 @@ test('HTTP fixtures do not reintroduce retired aliases or arbitrary specificatio
|
||||
test('the frozen catalog is complete and unambiguous', () => {
|
||||
// Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg.
|
||||
const catalog = Object.freeze({
|
||||
'20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }),
|
||||
'40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
|
||||
'40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }),
|
||||
'40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
|
||||
'45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }),
|
||||
'40GP': Object.freeze({ capacityCbm: 58, maxWeightKg: 19500 }),
|
||||
'40HQ': Object.freeze({ capacityCbm: 68, maxWeightKg: 19500 }),
|
||||
'45HQ': Object.freeze({ capacityCbm: 86, maxWeightKg: 19500 }),
|
||||
})
|
||||
|
||||
assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC'])
|
||||
assert.equal(new Set(Object.keys(catalog)).size, 5)
|
||||
assert.deepEqual(Object.keys(catalog), ['40GP', '40HQ', '45HQ'])
|
||||
assert.equal(new Set(Object.keys(catalog)).size, 3)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Explicit UI handoff: creates one fresh multi-pallet order and stops before overseas PDA writes.
|
||||
export { prepareBrowserFixture as run } from './dropship-pallet-matrix.mjs'
|
||||
@@ -0,0 +1,170 @@
|
||||
// Internal probes invoked only by the registered dropship matrix and its fresh fixtures.
|
||||
// A failed expectation remains a deployment blocker. Continue other probes only when
|
||||
// authoritative task/inventory snapshots prove the failed request changed no state.
|
||||
const MODULE = 'dropship-pallet-matrix'
|
||||
const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/last-leg/outbound-orders'
|
||||
|
||||
// Expected progress comes from submitted fixture operations, never the server response.
|
||||
export function receivingProgressMatches(snapshot, expected) {
|
||||
const count = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
|
||||
if (!Array.isArray(snapshot?.inbounds) || !Array.isArray(expected) || !expected.length) return false
|
||||
if (new Set(expected.map(row => row.inboundCode)).size !== expected.length
|
||||
|| snapshot.inbounds.length !== expected.length
|
||||
|| new Set(snapshot.inbounds.map(row => row.inboundCode)).size !== expected.length) return false
|
||||
let total = 0
|
||||
let received = 0
|
||||
for (const row of expected) {
|
||||
if (!Number.isInteger(row.total) || !Number.isInteger(row.received)
|
||||
|| row.total <= 0 || row.received < 0 || row.received > row.total) return false
|
||||
const actual = snapshot.inbounds.find(item => item.inboundCode === row.inboundCode)
|
||||
if (!actual || count(actual.totalPackages) !== row.total
|
||||
|| count(actual.receivedPackages) !== row.received
|
||||
|| count(actual.pendingPackages) !== row.total - row.received) return false
|
||||
total += row.total
|
||||
received += row.received
|
||||
}
|
||||
return count(snapshot.totalPackages) === total && count(snapshot.receivedPackages) === received
|
||||
&& count(snapshot.pendingPackages) === total - received
|
||||
}
|
||||
|
||||
function record(edge, name, response, passed, evidence = '') {
|
||||
edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS)
|
||||
}
|
||||
|
||||
async function request(edge, method, path, body, pda = false) {
|
||||
return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken)
|
||||
}
|
||||
|
||||
async function candidates(edge) {
|
||||
const rows = await edge.call('边界测试回查本轮库存候选', 'GET', edge.candidatePath)
|
||||
return rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode))
|
||||
.map(row => ({ position: row.inventoryPositionId, sku: row.skuCode,
|
||||
inbound: row.inboundCode, packages: row.availablePackages }))
|
||||
.sort((a, b) => `${a.position}:${a.sku}`.localeCompare(`${b.position}:${b.sku}`))
|
||||
}
|
||||
|
||||
async function taskSnapshot(edge) {
|
||||
return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`)
|
||||
}
|
||||
|
||||
export async function outboundCandidateGuards(edge) {
|
||||
const warehouse = encodeURIComponent(edge.warehouseId)
|
||||
const customer = encodeURIComponent(edge.customerId)
|
||||
const paths = [
|
||||
['平台首次查询不传客户', `${BASE}/one-click-dropship/candidates?warehouseId=${warehouse}`],
|
||||
['平台指定客户查询', `${BASE}/one-click-dropship/candidates?customerId=${customer}&warehouseId=${warehouse}`],
|
||||
]
|
||||
const snapshots = []
|
||||
for (const [name, path] of paths) {
|
||||
const rows = await edge.call(name, 'GET', path)
|
||||
edge.check(`${edge.label}${name}返回候选数组`, null, Array.isArray(rows))
|
||||
edge.check(`${edge.label}${name}仓库隔离`, null,
|
||||
rows.every(row => String(row.overseasWarehouseId) === String(edge.warehouseId)))
|
||||
if (name === '平台指定客户查询') {
|
||||
edge.check(`${edge.label}指定客户不返回其他客户库存`, null,
|
||||
rows.every(row => String(row.customerId) === String(edge.customerId)))
|
||||
}
|
||||
const own = rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode))
|
||||
for (const record of edge.group) {
|
||||
const selected = own.filter(row => row.inboundCode === record.source.inboundCode)
|
||||
const expectedPallets = [...new Set(record.task.pallets.map(p => p.palletNo))].sort()
|
||||
const actualPallets = [...new Set(selected.map(row => row.palletNo))].sort()
|
||||
edge.check(`${record.label}${name}全部托盘与箱数完整`, null,
|
||||
JSON.stringify(expectedPallets) === JSON.stringify(actualPallets)
|
||||
&& selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === record.expectedCartonMarks.length
|
||||
&& selected.every(row => String(row.customerId) === String(edge.customerId)))
|
||||
}
|
||||
snapshots.push(own.map(row => ({ inbound: row.inboundCode, pallet: row.palletNo,
|
||||
position: String(row.inventoryPositionId), sku: row.skuCode, packages: Number(row.availablePackages) }))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))
|
||||
}
|
||||
edge.check(`${edge.label}客户筛选前后本轮库存事实一致`, null,
|
||||
JSON.stringify(snapshots[0]) === JSON.stringify(snapshots[1]))
|
||||
}
|
||||
|
||||
export async function outboundCreationGuards(edge) {
|
||||
const before = await candidates(edge)
|
||||
const first = edge.body.items[0]
|
||||
const cases = [
|
||||
['超量创建', { ...first, packages: first.packages + 100, cartonMarks: [] }],
|
||||
['错误SKU创建', { ...first, sku: `WRONG-${edge.context.marker}` }],
|
||||
['不存在箱唛创建', { ...first, packages: 1, cartonMarks: [`MISSING-${edge.context.marker}`] }],
|
||||
['重复箱唛创建', { ...first, packages: 2, cartonMarks: [first.cartonMarks[0], first.cartonMarks[0]] }],
|
||||
['箱数与箱唛不符创建', { ...first, packages: first.packages + 1 }],
|
||||
]
|
||||
for (const [index, [name, input]] of cases.entries()) {
|
||||
const response = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`,
|
||||
{ ...edge.body, items: [input], idempotencyKey: `${edge.body.idempotencyKey}-invalid-${index}` })
|
||||
record(edge, `${name}返回A0409`, response, response.body?.code === 'A0409')
|
||||
const after = await candidates(edge)
|
||||
edge.check(`${edge.label}${name}后库存候选零漂移`, null, JSON.stringify(before) === JSON.stringify(after))
|
||||
}
|
||||
// Two real concurrent requests, same fixture and same key; no parallel fixture workers.
|
||||
const body = { ...edge.body, idempotencyKey: `${edge.body.idempotencyKey}-race-cancel` }
|
||||
const create = () => request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body)
|
||||
const responses = await Promise.all([create(), create()])
|
||||
const tasks = responses.filter(r => r.body?.code === '200').map(r => r.body.data)
|
||||
record(edge, '并发同键创建均成功且只生成一个任务', responses[1],
|
||||
tasks.length === 2 && tasks[0].taskNo === tasks[1].taskNo && tasks[0].outboundNo === tasks[1].outboundNo)
|
||||
edge.check(`${edge.label}并发创建至少一个可回查结果`, null, tasks.length > 0)
|
||||
for (const outboundNo of new Set(tasks.map(t => t.outboundNo))) {
|
||||
for (let replay = 0; replay < 2; replay++) {
|
||||
const cancelled = await request(edge, 'POST', `${BASE}/${outboundNo}/cancel`, { reason: '本轮未拣取消及重放验收' })
|
||||
edge.check(`${edge.label}未拣取消及重复取消成功`, cancelled, cancelled.body?.code === '200')
|
||||
}
|
||||
const detail = await edge.call('回查取消任务状态', 'GET', `${BASE}/${outboundNo}`)
|
||||
edge.check(`${edge.label}取消后明细全部取消`, null, detail.outbound?.status === 'CANCELLED'
|
||||
&& detail.pallets.every(p => p.status === 'CANCELLED' && Number(p.pickedPackageCount) === 0))
|
||||
}
|
||||
edge.check(`${edge.label}取消后逐票SKU可用箱数恢复`, null,
|
||||
JSON.stringify(before) === JSON.stringify(await candidates(edge)))
|
||||
}
|
||||
|
||||
export async function outboundTaskGuards(edge) {
|
||||
const before = await taskSnapshot(edge)
|
||||
const first = edge.body.items[0]
|
||||
const changedBodies = [
|
||||
['收件人', { ...edge.body, recipient: `${edge.body.recipient}-changed` }],
|
||||
['备注', { ...edge.body, remark: `${edge.body.remark || ''}-changed` }],
|
||||
['明细', { ...edge.body, items: [{ ...first, packages: first.packages + 1 }, ...edge.body.items.slice(1)] }],
|
||||
['箱唛', { ...edge.body, items: [{ ...first, cartonMarks: [`CHANGED-${edge.context.marker}`, ...first.cartonMarks.slice(1)] }, ...edge.body.items.slice(1)] }],
|
||||
]
|
||||
for (const [field, body] of changedBodies) {
|
||||
const changed = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body)
|
||||
record(edge, `出库创建同键异载荷返回A0409:${field}`, changed, changed.body?.code === 'A0409')
|
||||
edge.check(`${edge.label}同键修改${field}后出库事实零漂移`, null,
|
||||
JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge)))
|
||||
}
|
||||
const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}`
|
||||
const incomplete = await request(edge, 'POST', `${path}/complete`,
|
||||
{ idempotencyKey: `${edge.body.idempotencyKey}-incomplete` }, true)
|
||||
record(edge, '未拣齐禁止完成出库', incomplete, incomplete.body?.code === 'A0409')
|
||||
edge.check(`${edge.label}未拣齐完成失败后零漂移`, null,
|
||||
JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge)))
|
||||
}
|
||||
|
||||
export async function probePartialPick(edge) {
|
||||
const before = await taskSnapshot(edge)
|
||||
const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}/pick`
|
||||
const partial = { ...edge.pick, packages: 1, cartonMarks: edge.pick.cartonMarks.slice(0, 1),
|
||||
idempotencyKey: `${edge.pick.idempotencyKey}-partial` }
|
||||
const result = await request(edge, 'POST', path, partial, true)
|
||||
record(edge, '四箱任务先拣一箱成功', result, result.body?.code === '200')
|
||||
const after = await taskSnapshot(edge)
|
||||
if (result.body?.code !== '200') {
|
||||
edge.check(`${edge.label}分批拣货失败后无库存明细变化`, null, JSON.stringify(before) === JSON.stringify(after))
|
||||
// Keep the failed assertion; whole-item picking below only continues later coverage.
|
||||
return 0
|
||||
}
|
||||
edge.check(`${edge.label}分批首箱只累计一箱`, null,
|
||||
after.pallets.reduce((sum, p) => sum + Number(p.pickedPackageCount), 0) === 1)
|
||||
const replay = await request(edge, 'POST', path, partial, true)
|
||||
record(edge, '分批首箱同键重放成功', replay, replay.body?.code === '200')
|
||||
edge.check(`${edge.label}分批重放不重复扣减`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge)))
|
||||
const cancel = await request(edge, 'POST', `${BASE}/${edge.task.outboundNo}/cancel`, { reason: '已拣取消拦截验收' })
|
||||
record(edge, '部分已拣禁止取消', cancel, cancel.body?.code === 'A0409')
|
||||
edge.check(`${edge.label}已拣取消失败后零漂移`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge)))
|
||||
return 1
|
||||
}
|
||||
@@ -3,6 +3,107 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
|
||||
import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
|
||||
|
||||
test('receiving verifier follows three independent inbound codes through interleaved progress', () => {
|
||||
const expected = [{ inboundCode: 'FBA', total: 5, received: 0 },
|
||||
{ inboundCode: 'DROPSHIP', total: 4, received: 0 }, { inboundCode: 'OVERSEAS', total: 3, received: 0 }]
|
||||
for (const [index, count] of [[0, 2], [1, 1], [2, 3], [0, 3], [1, 3]]) {
|
||||
expected[index].received += count
|
||||
const snapshot = { totalPackages: 12, receivedPackages: expected.reduce((sum, x) => sum + x.received, 0),
|
||||
pendingPackages: expected.reduce((sum, x) => sum + x.total - x.received, 0),
|
||||
inbounds: expected.map(x => ({ inboundCode: x.inboundCode, totalPackages: x.total,
|
||||
receivedPackages: x.received, pendingPackages: x.total - x.received })) }
|
||||
assert.equal(receivingProgressMatches(snapshot, expected), true)
|
||||
const stale = structuredClone(snapshot)
|
||||
stale.inbounds[index].pendingPackages += 1
|
||||
assert.equal(receivingProgressMatches(stale, expected), false)
|
||||
const swapped = structuredClone(snapshot)
|
||||
swapped.inbounds[0].receivedPackages += 1
|
||||
swapped.inbounds[1].receivedPackages -= 1
|
||||
assert.equal(receivingProgressMatches(swapped, expected), false, 'correct container total cannot conceal wrong ownership')
|
||||
const duplicate = structuredClone(snapshot)
|
||||
duplicate.inbounds[2] = duplicate.inbounds[0]
|
||||
assert.equal(receivingProgressMatches(duplicate, expected), false)
|
||||
}
|
||||
assert.equal(receivingProgressMatches({ inbounds: [] }, []), false)
|
||||
})
|
||||
|
||||
test('receiving verifier does not interpret absent or malformed progress as zero', () => {
|
||||
const expected = [{ inboundCode: 'IN', total: 4, received: 0 }]
|
||||
const good = { totalPackages: 4, receivedPackages: 0, pendingPackages: 4,
|
||||
inbounds: [{ inboundCode: 'IN', totalPackages: 4, receivedPackages: 0, pendingPackages: 4 }] }
|
||||
for (const value of [null, undefined, '', false, -1, 0.5]) {
|
||||
const snapshot = structuredClone(good)
|
||||
snapshot.inbounds[0].receivedPackages = value
|
||||
assert.equal(receivingProgressMatches(snapshot, expected), false)
|
||||
}
|
||||
assert.equal(receivingProgressMatches(good, expected), true)
|
||||
})
|
||||
|
||||
function candidateProbe(transform = rows => rows) {
|
||||
const rows = ['P1', 'P2'].map((palletNo, index) => ({ palletNo, inventoryPositionId: index + 1,
|
||||
inboundCode: 'IN-1', skuCode: 'SKU', availablePackages: 2, customerId: 'C1', overseasWarehouseId: 'W1' }))
|
||||
const paths = []
|
||||
return { paths, edge: { label: 'MULTI', customerId: 'C1', warehouseId: 'W1',
|
||||
group: [{ label: 'MULTI', source: { inboundCode: 'IN-1' },
|
||||
task: { pallets: [{ palletNo: 'P1' }, { palletNo: 'P2' }] }, expectedCartonMarks: ['a', 'b', 'c', 'd'] }],
|
||||
check: (name, _response, passed) => assert.ok(passed, name),
|
||||
call: async (_name, method, path) => {
|
||||
assert.equal(method, 'GET'); paths.push(path)
|
||||
return transform(structuredClone(rows), path)
|
||||
},
|
||||
} }
|
||||
}
|
||||
|
||||
test('candidate probe sends the actual platform query without customer and checks both pallets', async () => {
|
||||
const { edge, paths } = candidateProbe()
|
||||
await outboundCandidateGuards(edge)
|
||||
assert.equal(new URL(paths[0], 'https://example.invalid').searchParams.has('customerId'), false)
|
||||
assert.equal(new URL(paths[1], 'https://example.invalid').searchParams.get('customerId'), 'C1')
|
||||
})
|
||||
|
||||
for (const [name, transform] of [
|
||||
['missing second pallet', rows => rows.slice(0, 1)],
|
||||
['wrong warehouse', rows => rows.map(row => ({ ...row, overseasWarehouseId: 'W2' }))],
|
||||
['wrong customer', rows => rows.map(row => ({ ...row, customerId: 'C2' }))],
|
||||
['bad request response', () => ({ code: 'A0400', message: 'Bad request' })],
|
||||
['candidate identity drift after filtering', (rows, path) => path.includes('customerId=')
|
||||
? rows.map(row => ({ ...row, inventoryPositionId: row.inventoryPositionId + 10 })) : rows],
|
||||
]) {
|
||||
test(`candidate probe fails closed on ${name}`, async () => {
|
||||
await assert.rejects(outboundCandidateGuards(candidateProbe(transform).edge))
|
||||
})
|
||||
}
|
||||
|
||||
test('browser fixture is explicit and stops before overseas receipt or outbound', () => {
|
||||
assert.deepEqual(normalizeModuleOrder(['dropship-browser-fixture']), ['merchant-qualification-fixture', 'dropship-browser-fixture'])
|
||||
const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
|
||||
const branch = source.slice(source.indexOf('if (stopBeforeOverseasPda)'), source.indexOf("await verify(await prepare('SINGLE-MULTI'"))
|
||||
assert.match(branch, /prepare\('BROWSER-MULTI', 2\)/)
|
||||
assert.match(branch, /convertOverseas\(\[record\]\)/)
|
||||
assert.match(branch, /READY_FOR_BROWSER_RECEIVING'[\s\S]*return/)
|
||||
assert.doesNotMatch(branch, /receiveOverseas|outboundOverseas|createPicker/)
|
||||
})
|
||||
|
||||
test('partial-pick rejection stays failed; a changed snapshot aborts further picking', async () => {
|
||||
const failures = []
|
||||
const snapshot = { pallets: [{ pickedPackageCount: 0 }] }
|
||||
const edge = {
|
||||
label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' },
|
||||
pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' },
|
||||
context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) },
|
||||
requestOverseas: async () => ({ body: { code: 'A0409' } }) },
|
||||
check: (_name, _response, passed) => assert.ok(passed),
|
||||
call: async () => structuredClone(snapshot),
|
||||
}
|
||||
assert.equal(await probePartialPick(edge), 0)
|
||||
assert.equal(failures.length, 1)
|
||||
assert.match(failures[0], /四箱任务先拣一箱成功/)
|
||||
let calls = 0
|
||||
edge.call = async () => ({ pallets: [{ pickedPackageCount: calls++ }] })
|
||||
await assert.rejects(probePartialPick(edge))
|
||||
})
|
||||
|
||||
test('outbound replay preserves business identity and database millisecond timestamps', () => {
|
||||
const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' }
|
||||
@@ -15,6 +116,49 @@ test('outbound replay preserves business identity and database millisecond times
|
||||
})
|
||||
|
||||
const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
|
||||
const edges = readFileSync(new URL('./dropship-outbound-edge-cases.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('matrix runs candidate probes before selecting inventory or creating outbound', () => {
|
||||
const probe = source.indexOf('await outboundCandidateGuards(')
|
||||
assert.ok(probe > 0)
|
||||
assert.ok(probe < source.indexOf("const all = await call('读取专用出库可用库存'"))
|
||||
})
|
||||
|
||||
test('each remaining pallet tests normal completion rejection and zero drift before receiving', () => {
|
||||
const loop = source.slice(source.indexOf('for (const [index, sourcePalletNo] of sourceNos.entries())'),
|
||||
source.indexOf("check(`${label}整柜实收总箱数守恒`"))
|
||||
assert.match(loop, /allowDifference: false/)
|
||||
assert.match(loop, /premature.body\?\.code === 'A0409'/)
|
||||
assert.match(loop, /JSON.stringify\(unchanged\) === JSON.stringify\(before\)/)
|
||||
assert.ok(loop.indexOf('JSON.stringify(unchanged)') < loop.indexOf("const received = await call"))
|
||||
})
|
||||
|
||||
test('outbound edge probes preserve failures and require zero drift before continuing', () => {
|
||||
assert.match(edges, /deploymentBlocker: true/)
|
||||
assert.match(edges, /四箱任务先拣一箱成功/)
|
||||
assert.match(edges, /分批拣货失败后无库存明细变化/)
|
||||
assert.match(edges, /JSON.stringify\(before\) === JSON.stringify\(after\)/)
|
||||
assert.match(source, /await probePartialPick/)
|
||||
assert.match(source, /pick.packages -= count/)
|
||||
assert.match(source, /pick.cartonMarks = pick.cartonMarks.slice\(count\)/)
|
||||
})
|
||||
|
||||
test('outbound creation probes cover concurrent idempotency, invalid cartons and cancellation', () => {
|
||||
assert.match(edges, /Promise.all\(\[create\(\), create\(\)\]\)/)
|
||||
for (const scenario of ['超量创建', '错误SKU创建', '不存在箱唛创建', '重复箱唛创建',
|
||||
'箱数与箱唛不符创建', '出库创建同键异载荷返回A0409', '未拣齐禁止完成出库', '部分已拣禁止取消']) {
|
||||
assert.ok(edges.includes(scenario))
|
||||
}
|
||||
assert.match(edges, /取消后逐票SKU可用箱数恢复/)
|
||||
assert.match(source, /await outboundCreationGuards\(edge\)/)
|
||||
assert.match(source, /await outboundTaskGuards\(\{ ...edge, task \}\)/)
|
||||
})
|
||||
|
||||
test('two inbound orders explicitly share a SKU without losing their separate identities', () => {
|
||||
assert.match(source, /label.startsWith\('MULTI-MERGE-'\)/)
|
||||
assert.match(source, /skuCodes = \[`SKU-SHARED-\$\{marker\}`/)
|
||||
assert.match(source, /row.inboundCode === candidate.inboundCode/)
|
||||
})
|
||||
|
||||
test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => {
|
||||
assert.match(source, /await outboundOverseas\(group, picker.token\)/)
|
||||
@@ -53,6 +197,9 @@ test('allocation checks both physical summaries and uncollapsed per-inbound quan
|
||||
})
|
||||
|
||||
test('each scenario reaches physical loading through released booking and asynchronous completion', () => {
|
||||
assert.match(source, /装柜前保存柜级预计到仓时间/)
|
||||
assert.match(source, /capacityCbm: 68, maxWeightKg: 19500, estimatedWarehouseArrivalAt/)
|
||||
assert.ok(source.indexOf('装柜前保存柜级预计到仓时间') < source.indexOf('读取自动生成装柜计划'))
|
||||
assert.match(source, /ocean-booking-link/)
|
||||
assert.match(source, /readiness\?\.canConfirm === true/)
|
||||
assert.match(source, /action: 'PICK_PALLET'/)
|
||||
|
||||
@@ -11,6 +11,8 @@ import { run as completeCustoms } from './phase3-customs.mjs'
|
||||
import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
|
||||
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs'
|
||||
import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
|
||||
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
|
||||
|
||||
const MODULE = 'dropship-pallet-matrix'
|
||||
const BASE = '/api/platform/v1/first-leg/pda-dropship'
|
||||
@@ -29,6 +31,18 @@ export function sameOutboundTask(left, right) {
|
||||
// Domestic producer stage: these retained loaded fixtures must subsequently be verified
|
||||
// through overseas receipt and dedicated dropship outbound, not generic dispatch.
|
||||
export async function run(context) {
|
||||
return runWorkflow(context, false)
|
||||
}
|
||||
|
||||
export async function prepareBrowserFixture(context) {
|
||||
return runWorkflow(context, true)
|
||||
}
|
||||
|
||||
export async function runMixedContainer(context) {
|
||||
return runWorkflow(context, false, true)
|
||||
}
|
||||
|
||||
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) {
|
||||
const marker = context.marker
|
||||
const savedOptions = context.options.prepareDropshipManualCount
|
||||
const savedContext = { ...context.context }
|
||||
@@ -39,22 +53,40 @@ export async function run(context) {
|
||||
context.assert(MODULE, name, response, passed, details, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
async function prepare(label, palletCount, mode = {}) {
|
||||
async function prepare(label, palletCount, mode = {}, destinationType = null) {
|
||||
context.marker = `${marker}-${label}`
|
||||
for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) {
|
||||
delete context.createdResources[key]
|
||||
}
|
||||
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
||||
context.context.orderDestinationType = 'OVERSEAS'
|
||||
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
|
||||
context.context.orderDestinationType = destinationType || 'OVERSEAS'
|
||||
context.context.orderFulfillmentType = destinationType ? 'STANDARD' : 'ONE_CLICK_DROPSHIP'
|
||||
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
|
||||
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount }
|
||||
context.options.prepareDropshipManualCount = true
|
||||
if (label.startsWith('MULTI-MERGE-')) {
|
||||
context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)]
|
||||
}
|
||||
context.options.prepareDropshipManualCount = !destinationType
|
||||
const start = context.steps.length
|
||||
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
|
||||
await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context))
|
||||
check(`${label}本轮订单和测量前置全部通过`, null,
|
||||
!context.steps.slice(start).some((step) => !step.passed))
|
||||
if (destinationType) {
|
||||
const measurement = { ...context.createdResources.inboundMeasurement }
|
||||
const order = { ...context.createdResources.orders }
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/inbounds/${measurement.inboundId}?tenantId=${measurement.tenantId}`)
|
||||
const detail = context.apiSuccess(MODULE, `${label}普通订单待分柜快照`, response, '', OPTIONS)
|
||||
check(`${label}普通订单四箱待分柜且类型未漂移`, response,
|
||||
detail?.summary?.processStatus === 'WAIT_SPLIT' && measurement.actualPackageCount === 4
|
||||
&& order.fulfillmentType === 'STANDARD' && order.destinationType === destinationType)
|
||||
const record = { label, order, measurement, summary: detail.summary, task: null,
|
||||
source: { inboundId: String(measurement.inboundId), inboundCode: detail.summary.inboundCode },
|
||||
expectedCartonMarks: Array.from({ length: 4 }, (_, index) => `${detail.summary.inboundCode}#4-${index + 1}`) }
|
||||
records.push(record)
|
||||
return record
|
||||
}
|
||||
const source = context.createdResources.dropshipManualCount
|
||||
check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId))
|
||||
const task = await completeDropshipPalletization(context, {
|
||||
@@ -100,19 +132,29 @@ export async function run(context) {
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value))
|
||||
return value
|
||||
}
|
||||
const pallets = [...new Map(group.flatMap((item) => item.task.pallets)
|
||||
const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || [])
|
||||
.map((pallet) => [String(pallet.palletId), pallet])).values()]
|
||||
for (const record of group) {
|
||||
const fee = await ensureInitialCalculated(context, { ...record.measurement,
|
||||
tenantId: String(record.task.tenantId), inboundId: record.source.inboundId,
|
||||
tenantId: String(record.order.tenantId), inboundId: record.source.inboundId,
|
||||
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
|
||||
check(`${record.label}初始费用已计算`, null, Boolean(fee))
|
||||
}
|
||||
const summary = group[0].summary
|
||||
const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', {
|
||||
let cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId),
|
||||
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
// Arrival is frozen by the loading event; prepare it while the cabinet is editable.
|
||||
const estimatedWarehouseArrivalAt = `${new Date(Date.now() + 86400000).toISOString().slice(0, 10)}T10:00:00`
|
||||
cabinet = await command('装柜前保存柜级预计到仓时间', 'PUT', `/api/platform/v1/containers/${cabinet.id}`, {
|
||||
version: Number(cabinet.version), data: {
|
||||
warehouseId: String(summary.warehouseId), containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500, estimatedWarehouseArrivalAt,
|
||||
},
|
||||
})
|
||||
check(`${label}预计到仓时间已写入正式柜资料`, null,
|
||||
cabinet.estimatedWarehouseArrivalAt?.slice(0, 16) === estimatedWarehouseArrivalAt.slice(0, 16))
|
||||
let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`)
|
||||
const path = `${smart}/plans/${plan.id}`
|
||||
async function conserve(name) {
|
||||
@@ -124,14 +166,14 @@ export async function run(context) {
|
||||
check(`${label}${name}没有混入其他订单`, null,
|
||||
detail.items?.every((item) => expectedIds.has(String(item.inboundId))))
|
||||
check(`${label}${name}整托汇总不重复计算箱数`, null,
|
||||
plan.items?.length === pallets.length
|
||||
(mixedContainer || plan.items?.length === pallets.length)
|
||||
&& plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0)
|
||||
=== group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0))
|
||||
for (const record of group) {
|
||||
const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId)
|
||||
check(`${record.label}${name}分配箱数与托盘身份正确`, null,
|
||||
actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length
|
||||
&& actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))
|
||||
&& (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))))
|
||||
}
|
||||
}
|
||||
for (const pallet of pallets) {
|
||||
@@ -140,7 +182,16 @@ export async function run(context) {
|
||||
operationId: `${context.marker}-add-${pallet.palletId}`,
|
||||
})
|
||||
}
|
||||
if (mixedContainer) {
|
||||
for (const record of group.filter(item => !item.task)) {
|
||||
plan = await command(`${record.label}普通订单按整票箱数加入`, 'POST', `${path}/inbound-assignments`, {
|
||||
inboundId: record.source.inboundId, packageCount: record.expectedCartonMarks.length,
|
||||
expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${record.label}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
await conserve('手工加入后')
|
||||
if (!mixedContainer) {
|
||||
for (const pallet of pallets) {
|
||||
plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
|
||||
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`,
|
||||
@@ -152,13 +203,14 @@ export async function run(context) {
|
||||
customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode,
|
||||
})
|
||||
await conserve('智能推荐后')
|
||||
}
|
||||
for (const record of group) record.loading = { cabinet, plan, allocationVerified: true }
|
||||
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
||||
const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
|
||||
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
|
||||
const suffix = markerSuffix(context.marker)
|
||||
const draft = oceanBookingDraft(context, suffix, {
|
||||
customerId: String(group[0].task.tenantId), customerName: summary.customerName,
|
||||
customerId: String(group[0].order.tenantId), customerName: summary.customerName,
|
||||
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
|
||||
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
|
||||
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
|
||||
@@ -199,10 +251,14 @@ export async function run(context) {
|
||||
warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id),
|
||||
expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken,
|
||||
items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version),
|
||||
action: 'PICK_PALLET', palletNo: item.palletNo })),
|
||||
...(item.unitType === 'SEALED_PALLET'
|
||||
? { action: 'PICK_PALLET', palletNo: item.palletNo }
|
||||
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })),
|
||||
}
|
||||
check(`${label}PDA全部任务均为封托单元`, null,
|
||||
claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))
|
||||
claimed.items.length > 0 && (mixedContainer
|
||||
? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET')
|
||||
: claimed.items.every((item) => item.unitType === 'SEALED_PALLET')))
|
||||
const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
|
||||
{ 'Idempotency-Key': `${context.marker}-pick` })
|
||||
check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW')
|
||||
@@ -224,7 +280,7 @@ export async function run(context) {
|
||||
`status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`)
|
||||
for (const record of group) {
|
||||
const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET',
|
||||
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`)
|
||||
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
|
||||
check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED')
|
||||
record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released,
|
||||
allocationVerified: true, loadingVerified: true, containerNo }
|
||||
@@ -233,10 +289,10 @@ export async function run(context) {
|
||||
async function progressShipping(group) {
|
||||
const record = group[0]
|
||||
context.marker = `${marker}-${record.label}-SHIPPING`
|
||||
context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId),
|
||||
context.createdResources.orders = { ...record.order, tenantId: String(record.order.tenantId),
|
||||
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) }
|
||||
context.createdResources.phase3Loading = {
|
||||
marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId,
|
||||
marker: context.marker, tenantId: String(record.order.tenantId), orderId: record.order.orderId,
|
||||
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId),
|
||||
containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id),
|
||||
oceanBookingId: String(record.loading.booking.oceanBooking.id),
|
||||
@@ -258,7 +314,7 @@ export async function run(context) {
|
||||
String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED'))
|
||||
&& detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH'))
|
||||
context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId,
|
||||
tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus }
|
||||
tenantId: String(record.order.tenantId), confirmedStatus: aggregate.dispatchStatus }
|
||||
}
|
||||
for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture],
|
||||
['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) {
|
||||
@@ -288,16 +344,19 @@ export async function run(context) {
|
||||
return data
|
||||
}
|
||||
let summary
|
||||
let summaryResponse
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`)
|
||||
summaryResponse = response
|
||||
summary = response?.body?.code === '200' ? response.body.data : null
|
||||
if (summary?.orders?.length === group.length) break
|
||||
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
check(`${label}跨区事件逐票建单完整`, null,
|
||||
check(`${label}跨区事件逐票建单完整`, summaryResponse,
|
||||
summary?.orders?.length === group.length && group.every((record) =>
|
||||
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
|
||||
&& String(order.customerId) === String(record.task.tenantId))))
|
||||
&& String(order.customerId) === String(record.order.tenantId))),
|
||||
`containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`)
|
||||
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
|
||||
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
|
||||
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
|
||||
@@ -309,10 +368,10 @@ export async function run(context) {
|
||||
const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode))
|
||||
check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1)
|
||||
const target = matching[0]
|
||||
const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort()
|
||||
const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort()
|
||||
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
|
||||
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
|
||||
JSON.stringify(expectedNos) === JSON.stringify(actualNos)
|
||||
(expectedNos ? JSON.stringify(expectedNos) === JSON.stringify(actualNos) : actualNos.length > 0)
|
||||
&& target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId))
|
||||
record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId),
|
||||
summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode),
|
||||
@@ -361,9 +420,35 @@ export async function run(context) {
|
||||
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
|
||||
{ photos, idempotencyKey: `${context.marker}-sign` })
|
||||
check(`${label}签收成功`, null, signed.status === 'SIGNED')
|
||||
if (mixedContainer) {
|
||||
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
|
||||
request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) })
|
||||
for (const item of group) {
|
||||
const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST',
|
||||
'/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode })
|
||||
check(`${item.label}库存归属和真实托盘绑定正确且箱数核平`, null,
|
||||
stock.queryCode === item.source.inboundCode && Array.isArray(stock.items) && stock.items.length > 0
|
||||
&& stock.items.every(row => row.inboundCode === item.source.inboundCode
|
||||
&& String(row.customerId) === String(item.order.tenantId)
|
||||
&& row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId)))
|
||||
&& stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length)
|
||||
item.overseas.receiving = { completed, stock, receivingVerified: true }
|
||||
}
|
||||
return
|
||||
}
|
||||
const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))]
|
||||
const receivedPallets = new Set()
|
||||
let accepted = 0
|
||||
for (const [index, sourcePalletNo] of sourceNos.entries()) {
|
||||
const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`)
|
||||
check(`${label}剩余托盘待收箱数准确`, null,
|
||||
Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted)
|
||||
const premature = await context.requestOverseas('POST', `${base}/current/complete`,
|
||||
{ idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token)
|
||||
check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409')
|
||||
const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`)
|
||||
check(`${label}未齐完成拒绝后事实不变且可继续收货`, null,
|
||||
JSON.stringify(unchanged) === JSON.stringify(before))
|
||||
const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` }
|
||||
const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body)
|
||||
accepted += Number(received.acceptedPackages)
|
||||
@@ -373,6 +458,14 @@ export async function run(context) {
|
||||
{ sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` })
|
||||
check(`${label}同托重复扫描不重复收货`, null,
|
||||
duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0)
|
||||
receivedPallets.add(sourcePalletNo)
|
||||
const progress = await call('每托收货及重扫后回查逐票进度', 'GET', `${base}/current`)
|
||||
const expected = group.map(record => ({ inboundCode: record.source.inboundCode,
|
||||
total: record.expectedCartonMarks.length,
|
||||
received: new Set(record.task.pallets.filter(p => receivedPallets.has(p.palletNo))
|
||||
.flatMap(p => p.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId))
|
||||
.map(c => c.boxCode)).size }))
|
||||
check(`${label}每托实收不串票且柜级与逐票剩余同步`, null, receivingProgressMatches(progress, expected))
|
||||
}
|
||||
check(`${label}整柜实收总箱数守恒`, null,
|
||||
accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0))
|
||||
@@ -404,6 +497,7 @@ export async function run(context) {
|
||||
return value
|
||||
}
|
||||
const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}`
|
||||
await outboundCandidateGuards({ context, group, customerId, warehouseId, label, check, call })
|
||||
const all = await call('读取专用出库可用库存', 'GET', candidatePath)
|
||||
const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))
|
||||
const inputs = selected.map((candidate) => {
|
||||
@@ -422,10 +516,13 @@ export async function run(context) {
|
||||
.reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length))
|
||||
const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs,
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` }
|
||||
const edge = { context, group, token, customerId, warehouseId, candidatePath, body, label, check, call }
|
||||
await outboundCreationGuards(edge)
|
||||
const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
|
||||
for (const record of group) record.outbound = { task, selected, completed: false }
|
||||
const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
|
||||
check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task))
|
||||
await outboundTaskGuards({ ...edge, task })
|
||||
const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}`
|
||||
const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true)
|
||||
check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length)
|
||||
@@ -438,6 +535,11 @@ export async function run(context) {
|
||||
const pick = { outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages,
|
||||
cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` }
|
||||
if (label === 'SINGLE-SINGLE' && index === 0) {
|
||||
const count = await probePartialPick({ ...edge, task, pick })
|
||||
pick.packages -= count
|
||||
pick.cartonMarks = pick.cartonMarks.slice(count)
|
||||
}
|
||||
const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true)
|
||||
const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true)
|
||||
check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked))
|
||||
@@ -460,6 +562,33 @@ export async function run(context) {
|
||||
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
|
||||
}
|
||||
try {
|
||||
if (mixedContainer) {
|
||||
const dropship = await prepare('MIXED-DROPSHIP', 2)
|
||||
await verify(dropship, 2)
|
||||
context.context.waitSplitRoute = { originWarehouseName: dropship.order.receivingWarehouseName,
|
||||
channelCode: dropship.order.channelCode }
|
||||
const fba = await prepare('MIXED-FBA', 1, {}, 'FBA')
|
||||
const overseas = await prepare('MIXED-OVERSEAS', 1, {}, 'OVERSEAS')
|
||||
const group = [dropship, fba, overseas]
|
||||
check('混柜三票属于同一起运仓和渠道组', null, group.every(r =>
|
||||
String(r.summary.warehouseId) === String(dropship.summary.warehouseId)
|
||||
&& channelGroup(r.summary.channelCode) === channelGroup(dropship.summary.channelCode)))
|
||||
await allocate(group)
|
||||
await progressShipping(group)
|
||||
await convertOverseas(group)
|
||||
await receiveOverseas(group)
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
|
||||
return
|
||||
}
|
||||
if (stopBeforeOverseasPda) {
|
||||
const record = await prepare('BROWSER-MULTI', 2)
|
||||
await verify(record, 2)
|
||||
await allocate([record])
|
||||
await progressShipping([record])
|
||||
await convertOverseas([record])
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'READY_FOR_BROWSER_RECEIVING'
|
||||
return
|
||||
}
|
||||
await verify(await prepare('SINGLE-MULTI', 2), 2)
|
||||
await verify(await prepare('SINGLE-SINGLE', 1), 1)
|
||||
await verify(await prepare('SINGLE-MERGE', 2, { mergeToFirstPallet: true }), 1)
|
||||
|
||||
@@ -142,7 +142,7 @@ async function provisionCrossCustomerPlan(context) {
|
||||
|| first.channelCode !== second.channelCode) return null
|
||||
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId),
|
||||
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
|
||||
evidence(containerResponse), OPTIONS)
|
||||
|
||||
@@ -82,8 +82,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
|
||||
|
||||
test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
|
||||
const samples = [
|
||||
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
|
||||
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
|
||||
[containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'],
|
||||
[preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HQ', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'],
|
||||
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
|
||||
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
|
||||
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
|
||||
|
||||
@@ -159,6 +159,12 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影
|
||||
assert.equal(inline.replace(/\r\n/g, '\n').trim(), lastlegBaseline.replace(/\r\n/g, '\n').trim())
|
||||
})
|
||||
|
||||
test('当前海外基线不从废弃申请回填历史尾程订单', () => {
|
||||
for (const source of [lastlegBaseline, overseasSql]) {
|
||||
assert.doesNotMatch(source, /INSERT IGNORE INTO ll_order\s*\([\s\S]*?SELECT a\.id,a\.discard_no/)
|
||||
}
|
||||
})
|
||||
|
||||
test('国内统一初始化补齐入库费用人工调整基线字段', () => {
|
||||
assert.match(sql, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/)
|
||||
assert.match(sql, /SET baseline_chargeable_quantity = chargeable_quantity[\s\S]*?WHERE baseline_chargeable_quantity IS NULL/)
|
||||
|
||||
@@ -44,7 +44,7 @@ test('backend never appends a generic STATUS_UPDATE and current operation replay
|
||||
assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/)
|
||||
assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/)
|
||||
assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/)
|
||||
assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/)
|
||||
assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateRemarks/)
|
||||
})
|
||||
|
||||
test('operationId is projected end-to-end for identity-based frontend deduplication', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml
|
||||
const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
|
||||
const outbox = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java')
|
||||
const event = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java')
|
||||
const eventV3 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java')
|
||||
const admin = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue')
|
||||
|
||||
test('fixture locks customer and latest internal note sources without cargoRemark leakage', () => {
|
||||
@@ -34,16 +35,18 @@ test('warehouse remark PATCH covers save clear stale version and source preserva
|
||||
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
|
||||
})
|
||||
|
||||
test('completed event V2 carries all three remarks with stable event payload', () => {
|
||||
test('completed event V3 retains V2 order remarks with stable event payload', () => {
|
||||
assert.match(outbox, /FirstLegLoadingPlanCompletedV2/)
|
||||
assert.match(outbox, /FirstLegLoadingPlanCompletedV3/)
|
||||
assert.match(eventV3, /List<FirstLegLoadingPlanCompletedV2\.Order> orders/)
|
||||
for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field))
|
||||
assert.match(outbox, /row\.getCustomerRemark\(\)/)
|
||||
assert.match(outbox, /row\.getInternalRemark\(\)/)
|
||||
assert.match(outbox, /row\.getWarehouseRemark\(\)/)
|
||||
assert.match(outbox, /HANDOVER_VERSION = 2L/)
|
||||
assert.match(outbox, /HANDOVER_VERSION = 3L/)
|
||||
assert.match(outbox, /EventPayloadCanonicalizer\.canonicalize\(payloadNode\)/)
|
||||
assert.match(outbox, /FIRSTLEG_LOADING_COMPLETED:[^\n]*HANDOVER_VERSION/)
|
||||
assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 2/)
|
||||
assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 3/)
|
||||
})
|
||||
|
||||
test('Admin exposes only warehouse remark editing and keeps completed rows read-only', () => {
|
||||
|
||||
@@ -118,18 +118,18 @@ async function createConfirmedContainer(context, source, label) {
|
||||
bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
|
||||
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
placeOfDelivery: 'LAX',
|
||||
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
|
||||
remark: `${context.marker}-${label}`,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
|
||||
maxWeightKg: 28000 }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68,
|
||||
maxWeightKg: 19500 }],
|
||||
}
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||
const containerId = id(simulation?.id)
|
||||
@@ -157,7 +157,7 @@ async function createConfirmedContainer(context, source, label) {
|
||||
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
|
||||
confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0),
|
||||
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
|
||||
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
|
||||
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`)
|
||||
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
|
||||
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo,
|
||||
|
||||
@@ -14,10 +14,17 @@ test('manual matrix retains only DRAFT and RELEASED ocean booking states', () =>
|
||||
|
||||
test('truck matrix remains independent while retaining its own lifecycle', () => {
|
||||
for (const status of ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS',
|
||||
'ARRIVED_WAREHOUSE', 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) {
|
||||
'ARRIVED_WAREHOUSE', 'LOADING', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) {
|
||||
assert.match(source, new RegExp(`['\"]${status}['\"]`))
|
||||
}
|
||||
assert.match(source, /page=1&size=100&status=/)
|
||||
assert.match(source, /category=TRUCKING&status=ACTIVE/)
|
||||
assert.match(source, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
|
||||
const completion = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentCompletionService.java', import.meta.url), 'utf8')
|
||||
const statuses = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/domain/TruckAppointmentStatus.java', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(statuses, /\bDEPARTED\b/)
|
||||
assert.match(statuses, /\bCOMPLETED\b/)
|
||||
assert.match(completion, /if \(!allDeparted\(rows\)\) return/)
|
||||
assert.match(completion, /history\.setAction\("AUTO_COMPLETE"\)/)
|
||||
assert.match(completion, /history\.setToStatus\("COMPLETED"\)/)
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ function oceanPayload(context, template, index) {
|
||||
bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null,
|
||||
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
|
||||
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
|
||||
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm,
|
||||
placeOfDelivery: source.placeOfDelivery,
|
||||
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00',
|
||||
remark: `${context.marker} 人工列表状态 ${index}`,
|
||||
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs'
|
||||
|
||||
function fixtures() {
|
||||
const record = (type, fulfillmentType = 'STANDARD') => ({ label: type,
|
||||
order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type },
|
||||
expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`),
|
||||
overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } })
|
||||
const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP')
|
||||
d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] },
|
||||
{ palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] }
|
||||
return [d, record('FBA'), record('OVERSEAS')]
|
||||
}
|
||||
|
||||
test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => {
|
||||
assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving'])
|
||||
const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
|
||||
const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'")
|
||||
const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start))
|
||||
for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'",
|
||||
'allocate(group)', 'progressShipping(group)', 'convertOverseas(group)', 'receiveOverseas(group)',
|
||||
'MIXED_CONTAINER_INBOUND_COMPLETED']) assert.ok(branch.includes(entry), entry)
|
||||
assert.doesNotMatch(branch, /outboundOverseas|warehouse-transfers/)
|
||||
assert.match(source, /混柜完成后按入仓码查询库存/)
|
||||
assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/)
|
||||
assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/)
|
||||
})
|
||||
|
||||
test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => {
|
||||
const steps = mixedReceivingSchedule(fixtures())
|
||||
assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet'])
|
||||
assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]])
|
||||
assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12)
|
||||
assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2)))
|
||||
})
|
||||
|
||||
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false,
|
||||
changedPayloadAccepted = false } = {}) {
|
||||
const group = fixtures()
|
||||
const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
|
||||
const schedule = mixedReceivingSchedule(group)
|
||||
const seen = new Map()
|
||||
let index = 0
|
||||
let complete = false
|
||||
const snapshot = () => ({ status: complete ? 'COMPLETED' : 'SIGNED', totalPackages: 12,
|
||||
receivedPackages: [...expected.values()].reduce((a, b) => a + b, 0),
|
||||
pendingPackages: 12 - [...expected.values()].reduce((a, b) => a + b, 0),
|
||||
inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4,
|
||||
receivedPackages, pendingPackages: 4 - receivedPackages })) })
|
||||
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
|
||||
request: async (_method, path, body) => {
|
||||
if (body.idempotencyKey.endsWith('-duplicate')) {
|
||||
return { body: path.endsWith('/carton') ? { code: 'A0409' }
|
||||
: { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } }
|
||||
}
|
||||
if (seen.has(body.idempotencyKey)) return { body: { code: changedPayloadAccepted ? '200' : 'A0409' } }
|
||||
return { body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }
|
||||
},
|
||||
call: async (_name, method, path, body) => {
|
||||
if (method === 'GET') return snapshot()
|
||||
if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey))
|
||||
if (path.endsWith('/complete')) {
|
||||
complete = true
|
||||
const result = snapshot(); seen.set(body.idempotencyKey, result); return result
|
||||
}
|
||||
const step = schedule[index++]
|
||||
assert.ok(path.endsWith(`/receipts/${step.path}`))
|
||||
const inbound = drift ? 'FBA' : step.record.source.inboundCode
|
||||
expected.set(inbound, expected.get(inbound) + step.count)
|
||||
const result = { acceptedPackages: step.count }
|
||||
seen.set(body.idempotencyKey, result)
|
||||
return result
|
||||
} }
|
||||
}
|
||||
|
||||
test('mixed receiving executes the whole schedule and completes only after all three rows balance', async () => {
|
||||
const result = await receiveMixedContainer(driver())
|
||||
assert.equal(result.status, 'COMPLETED')
|
||||
assert.equal(result.receivedPackages, 12)
|
||||
})
|
||||
test('mixed receiving fails on early-completion acceptance', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true })))
|
||||
})
|
||||
test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true })))
|
||||
})
|
||||
test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ drift: true })))
|
||||
})
|
||||
test('mixed receiving rejects a new-key rescan that accepts another box', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ repeatIncrement: true })))
|
||||
})
|
||||
test('mixed receiving rejects same-key different-payload success', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true })))
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { receivingProgressMatches } from './dropship-outbound-edge-cases.mjs'
|
||||
|
||||
export async function run(context) {
|
||||
const { runMixedContainer } = await import('./dropship-pallet-matrix.mjs')
|
||||
return runMixedContainer(context)
|
||||
}
|
||||
|
||||
export function mixedReceivingSchedule(group) {
|
||||
const fba = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'FBA')
|
||||
const overseas = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'OVERSEAS')
|
||||
const dropship = group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
if (group.length !== 3 || fba.length !== 1 || overseas.length !== 1 || dropship.length !== 1
|
||||
|| dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票')
|
||||
const [f, o, d] = [fba[0], overseas[0], dropship[0]]
|
||||
const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo
|
||||
const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo
|
||||
if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数')
|
||||
const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c =>
|
||||
String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } })
|
||||
return [
|
||||
{ record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } },
|
||||
palletStep(d.task.pallets[0]),
|
||||
...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })),
|
||||
{ record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1,
|
||||
body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } },
|
||||
palletStep(d.task.pallets[1]),
|
||||
]
|
||||
}
|
||||
|
||||
export async function receiveMixedContainer({ group, call, check, request, marker }) {
|
||||
const base = '/api/pda/v1/last-leg/receiving-containers/current'
|
||||
const steps = mixedReceivingSchedule(group)
|
||||
const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 }))
|
||||
for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) {
|
||||
const before = await call('混柜错误收货模式前回查', 'GET', base)
|
||||
const invalid = await request('POST', `${base}/receipts/bulk`, {
|
||||
inboundCode: record.source.inboundCode, palletCode: record.overseas.plan.pallets[0].palletNo,
|
||||
packageCount: 1, idempotencyKey: `${marker}-forbid-bulk-${record.label}`,
|
||||
})
|
||||
check('普通海外仓和一件代发均不能绕过各自规则使用FBA批量收货', invalid, invalid.body?.code === 'A0409')
|
||||
const after = await call('混柜错误收货模式拒绝后回查', 'GET', base)
|
||||
check('混柜错误收货模式拒绝后零漂移', null, JSON.stringify(before) === JSON.stringify(after))
|
||||
}
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const before = await call('混柜交替收货前进度', 'GET', base)
|
||||
check('混柜每步前逐票和柜级进度准确', null, receivingProgressMatches(before, expected))
|
||||
const premature = await request('POST', `${base}/complete`,
|
||||
{ allowDifference: false, idempotencyKey: `${marker}-early-${index}` })
|
||||
check('混柜任一票未齐不得正常完成', premature, premature.body?.code === 'A0409')
|
||||
const afterRejected = await call('混柜提前完成拒绝后回查', 'GET', base)
|
||||
check('混柜提前完成拒绝后零漂移', null, JSON.stringify(afterRejected) === JSON.stringify(before))
|
||||
const path = `${base}/receipts/${step.path}`
|
||||
const body = { ...step.body, idempotencyKey: `${marker}-mixed-receive-${index}` }
|
||||
const received = await call(`${step.record.label}交替收货`, 'POST', path, body)
|
||||
check('混柜本次只收当前操作对应箱数', null, Number(received.acceptedPackages) === step.count)
|
||||
const replay = await call('混柜收货同键重放', 'POST', path, body)
|
||||
check('混柜同键重放结果稳定', null, JSON.stringify(replay) === JSON.stringify(received))
|
||||
expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count
|
||||
const after = await call('混柜每步后逐票回查', 'GET', base)
|
||||
check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected))
|
||||
const changedBody = step.path === 'pallet' ? { ...body, sourcePalletNo: `${body.sourcePalletNo}-CHANGED` }
|
||||
: step.path === 'bulk' ? { ...body, packageCount: body.packageCount + 1 }
|
||||
: { ...body, palletCode: `${body.palletCode}-CHANGED` }
|
||||
const changed = await request('POST', path, changedBody)
|
||||
check('混柜收货同键异载荷必须冲突', changed, changed.body?.code === 'A0409')
|
||||
const duplicate = await request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-duplicate` })
|
||||
check('混柜同货物新键重复操作不重复收货', duplicate, step.path === 'carton'
|
||||
? duplicate.body?.code === 'A0409'
|
||||
: duplicate.body?.code === '200' && duplicate.body?.data?.result === 'ALREADY_RECEIVED'
|
||||
&& Number(duplicate.body?.data?.acceptedPackages) === 0)
|
||||
const afterRetries = await call('混柜重复和冲突请求后回查全部票', 'GET', base)
|
||||
check('混柜冲突及新键重复后全柜事实不变', null, JSON.stringify(afterRetries) === JSON.stringify(after))
|
||||
}
|
||||
const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` }
|
||||
const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body)
|
||||
check('混柜三票全收且普通完成', null,
|
||||
completed.status === 'COMPLETED' && receivingProgressMatches(completed, expected))
|
||||
const replay = await call('混柜入库完成重放', 'POST', `${base}/complete`, body)
|
||||
check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
|
||||
return completed
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import test from 'node:test'
|
||||
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingAgent, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
|
||||
|
||||
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' }
|
||||
const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', warehouseId: '41', warehouseName: '深圳仓' }
|
||||
|
||||
test('fixture resolves current-marker customer channel and warehouse without inventing data', async () => {
|
||||
const steps = []
|
||||
@@ -22,12 +22,13 @@ test('fixture resolves current-marker customer channel and warehouse without inv
|
||||
test('shared DRAFT uses requirements and never registers a real container number', () => {
|
||||
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master)
|
||||
assert.equal(draft.channelName, undefined)
|
||||
assert.equal(draft.channelCode, 'MATSON_CLX')
|
||||
assert.equal(draft.warehouseName, undefined)
|
||||
assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }])
|
||||
assert.deepEqual(draft.containerRequirements, [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }])
|
||||
assert.equal(Object.hasOwn(draft, 'containers'), false)
|
||||
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
|
||||
assert.equal(draft.serviceTerm, 'CY-CY')
|
||||
assert.equal(draft.freightTerm, 'NINGBO')
|
||||
assert.equal('serviceTerm' in draft, false)
|
||||
assert.equal('freightTerm' in draft, false)
|
||||
assert.equal(draft.carrierCode, 'OTHER')
|
||||
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field])
|
||||
})
|
||||
@@ -41,8 +42,8 @@ test('shared DRAFT permits an omitted customer without inventing placeholders',
|
||||
|
||||
test('explicit requirement rows preserve quantity and fixed catalog facts', () => {
|
||||
const requirements = [
|
||||
{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 },
|
||||
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
|
||||
{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 },
|
||||
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
|
||||
]
|
||||
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements })
|
||||
assert.deepEqual(draft.containerRequirements, requirements)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
function text(value) { return value == null ? '' : String(value).trim() }
|
||||
const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels'
|
||||
|
||||
@@ -69,10 +71,11 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
|
||||
const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master
|
||||
const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING'
|
||||
const containerRequirements = labels.containerRequirements || [{
|
||||
containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000,
|
||||
containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500,
|
||||
}]
|
||||
return {
|
||||
...writableMaster,
|
||||
channelCode: channelGroup(writableMaster.channelCode),
|
||||
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
|
||||
carrierCode: labels.carrierCode || 'OTHER',
|
||||
shippingCompanyId: labels.shippingCompanyId ?? null,
|
||||
@@ -81,7 +84,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
|
||||
bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null,
|
||||
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`,
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
||||
estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00',
|
||||
estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00',
|
||||
remark: context.marker,
|
||||
|
||||
@@ -27,8 +27,8 @@ function fullDraft(context, suffix, master, labels) {
|
||||
return oceanBookingDraft(context, suffix, master, {
|
||||
...labels,
|
||||
containerRequirements: labels.containerRequirements || [
|
||||
{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 },
|
||||
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
|
||||
{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 },
|
||||
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,24 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs'
|
||||
import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs'
|
||||
|
||||
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('fresh fixture obeys the authoritative inbound-code policy without changing global settings', () => {
|
||||
assert.deepEqual(fixtureInboundCode({ mode: 'AUTO', manualEntryRequired: false }, 'batch-A'), {})
|
||||
const policy = { mode: 'LEGACY_MANUAL', manualEntryRequired: true }
|
||||
const first = fixtureInboundCode(policy, 'batch-A')
|
||||
assert.deepEqual(first, fixtureInboundCode(policy, 'batch-A'))
|
||||
assert.notEqual(first.inboundCode, fixtureInboundCode(policy, 'batch-B').inboundCode)
|
||||
assert.match(first.inboundCode, /^AT[A-F0-9]{20}$/)
|
||||
assert.ok(`${first.inboundCode}#9999-9999`.length <= 34)
|
||||
for (const invalid of [null, {}, { mode: 'AUTO', manualEntryRequired: true }, { mode: 'UNKNOWN', manualEntryRequired: false }]) {
|
||||
assert.throws(() => fixtureInboundCode(invalid, 'batch-A'), /入仓码策略/)
|
||||
}
|
||||
assert.match(source, /GET', `\$\{createPath\}\/inbound-code-policy`/)
|
||||
})
|
||||
|
||||
test('order demo payload covers multi-box multi-SKU images and both destination families', () => {
|
||||
for (const boxType of ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']) {
|
||||
assert.match(source, new RegExp(`boxType: '${boxType}'`))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'orders'
|
||||
const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
||||
@@ -25,6 +27,14 @@ function rotate(values, offset) {
|
||||
return [...values.slice(shift), ...values.slice(0, shift)]
|
||||
}
|
||||
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
|
||||
export function fixtureInboundCode(policy, marker) {
|
||||
if (policy?.mode === 'AUTO' && policy.manualEntryRequired === false) return {}
|
||||
if (policy?.mode !== 'LEGACY_MANUAL' || policy.manualEntryRequired !== true || !marker) {
|
||||
throw new Error('入仓码策略缺失或不一致,停止创建测试订单')
|
||||
}
|
||||
// Short enough for the 34-character carton QR limit; stable within this fresh fixture.
|
||||
return { inboundCode: `AT${createHash('sha256').update(marker).digest('hex').slice(0, 20).toUpperCase()}` }
|
||||
}
|
||||
function declarationCargo(source, index) {
|
||||
const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0
|
||||
const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value)
|
||||
@@ -449,7 +459,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
|
||||
const carton = dropship ? selectedBoxes[0] : box
|
||||
const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
|
||||
return {
|
||||
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
|
||||
skuNo: context.context?.waitSplitVariant?.skuCodes?.[index]
|
||||
|| `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
|
||||
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
|
||||
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
|
||||
packingGroupNo: dropship ? mixedPackingGroupNo : null,
|
||||
@@ -573,8 +584,11 @@ export async function run(context) {
|
||||
privateDestinationAddressId: master.destinationType === 'PRIVATE'
|
||||
? await ensureCustomerAddress(context, merchantToken, profile, 'PRIVATE_DESTINATION') : null,
|
||||
}
|
||||
const payload = buildPayload(context, profile, master, productImageFileId, customerAddresses)
|
||||
const createPath = '/api/merchant/v1/orders/first-leg'
|
||||
const policyResponse = await request(context, 'GET', `${createPath}/inbound-code-policy`, undefined, merchantToken)
|
||||
const policy = context.apiSuccess(MODULE, 'Client 读取入仓码录入策略', policyResponse, '', CONTRACT_OPTIONS)
|
||||
const payload = { ...buildPayload(context, profile, master, productImageFileId, customerAddresses),
|
||||
...fixtureInboundCode(policy, context.marker) }
|
||||
const createResponse = await request(context, 'POST', createPath, payload, merchantToken,
|
||||
{ 'Idempotency-Key': key(context, 'client-create') })
|
||||
const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse,
|
||||
|
||||
@@ -173,7 +173,7 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
if (!selectedChannel) return
|
||||
const channelName = selectedChannel.channelName
|
||||
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
|
||||
const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500 }
|
||||
const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
|
||||
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
|
||||
const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
|
||||
@@ -188,14 +188,14 @@ export async function run(context) {
|
||||
if (!shippingCarrier?.id) return
|
||||
const bookingDraft = {
|
||||
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
|
||||
channelCode, warehouseId,
|
||||
channelCode: channelGroup(channelCode), warehouseId,
|
||||
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
|
||||
shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON',
|
||||
bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name,
|
||||
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
||||
serviceTerm: 'CY-CY', freightTerm: 'NINGBO', estimatedSailingAt: '2026-09-01T08:00:00',
|
||||
estimatedSailingAt: '2026-09-01T08:00:00',
|
||||
estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
||||
@@ -203,7 +203,7 @@ export async function run(context) {
|
||||
containerRequirements: [
|
||||
{ containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1,
|
||||
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg },
|
||||
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
|
||||
{ containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
|
||||
],
|
||||
}
|
||||
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
|
||||
|
||||
@@ -558,8 +558,8 @@ export async function run(context) {
|
||||
const producer = {
|
||||
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
|
||||
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
|
||||
eventVersion: 2,
|
||||
eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`,
|
||||
eventVersion: 3,
|
||||
eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:3`,
|
||||
resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`,
|
||||
tenantId,
|
||||
aggregateType: 'LOADING_PLAN',
|
||||
|
||||
@@ -197,24 +197,24 @@ export async function run(context) {
|
||||
const bookingDraft = {
|
||||
customerId: tenantId, customerName: initial?.summary?.customerName,
|
||||
warehouseId,
|
||||
channelCode: initial?.summary?.channelCode,
|
||||
channelCode: channelGroup(initial?.summary?.channelCode),
|
||||
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
|
||||
shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON',
|
||||
bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
|
||||
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
||||
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
|
||||
remark: context.marker,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
|
||||
maxWeightKg: 28000 }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68,
|
||||
maxWeightKg: 19500 }],
|
||||
}
|
||||
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC',
|
||||
capacityCbm: 76, maxWeightKg: 28000,
|
||||
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
|
||||
const containerBookingId = id(simulation?.id)
|
||||
@@ -618,7 +618,7 @@ export async function run(context) {
|
||||
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
|
||||
const frozenTransportMatches = frozenTransportValid
|
||||
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse,
|
||||
Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches
|
||||
&& Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
|
||||
|
||||
@@ -102,8 +102,8 @@ test('booking freezes the source customer channel and warehouse scope', () => {
|
||||
assert.match(source, /customerId: source\.tenantId/)
|
||||
assert.match(source, /channelCode: source\.channelCode/)
|
||||
assert.match(source, /warehouseId: source\.warehouseId/)
|
||||
assert.match(source, /containerType === '40HC'/)
|
||||
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
|
||||
assert.match(source, /containerType === '40HQ'/)
|
||||
assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500/)
|
||||
})
|
||||
|
||||
test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
|
||||
|
||||
@@ -188,7 +188,7 @@ async function loadOneFragment(context, state) {
|
||||
if (!carrier?.id) return null
|
||||
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelGroupCode: channelGroup(state.channelCode), warehouseId,
|
||||
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||
const containerId = id(simulation?.id)
|
||||
@@ -221,7 +221,7 @@ async function loadOneFragment(context, state) {
|
||||
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
|
||||
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
|
||||
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS)
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`)
|
||||
if (!requirement?.id) return null
|
||||
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
|
||||
|
||||
@@ -46,7 +46,7 @@ function oceanDraft(context, suffix, master) {
|
||||
...oceanBookingDraft(context, suffix, master),
|
||||
departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
placeOfDelivery: 'LAX',
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
|
||||
@@ -813,7 +813,7 @@ test('finalize publishes a current-marker producer correlation for cross-region
|
||||
assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/)
|
||||
assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/)
|
||||
assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/)
|
||||
assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:2`/)
|
||||
assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:3`/)
|
||||
assert.match(finalize, /sourceStatus: 'LOADED'/)
|
||||
assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/)
|
||||
})
|
||||
|
||||
@@ -316,7 +316,7 @@ function looseboardPayload(context) {
|
||||
return {
|
||||
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
|
||||
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`,
|
||||
destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80,
|
||||
destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80,
|
||||
totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
|
||||
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
|
||||
|
||||
@@ -28,6 +28,14 @@ test('module uses marker-owned completed PDA pallets and never borrows historica
|
||||
assert.doesNotMatch(source, /candidates\[0\]/)
|
||||
})
|
||||
|
||||
test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => {
|
||||
assert.match(source, /const candidatesByDestination = new Map\(\)/)
|
||||
assert.match(source, /destinationWarehouseCode \|\| value\?\.fbaWarehouseCode/)
|
||||
assert.match(source, /selectedCandidates\.length >= 2/)
|
||||
assert.match(source, /planPalletIds, route: `\$\{context\.marker\}-ROUTE`/)
|
||||
assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/)
|
||||
})
|
||||
|
||||
test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => {
|
||||
assert.match(source, /相同草稿快照重复保存/)
|
||||
assert.match(source, /同草稿重复锁定托盘幂等成功/)
|
||||
|
||||
@@ -95,7 +95,7 @@ async function runRelabelDispatch(context, relabel) {
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch = {
|
||||
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
|
||||
planPalletId: null, finalStatus: null,
|
||||
planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], finalStatus: null,
|
||||
cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded',
|
||||
}
|
||||
|
||||
@@ -122,16 +122,26 @@ export async function run(context) {
|
||||
const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选',
|
||||
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : []
|
||||
const candidate = candidates.find((value) => fixture.palletIds.has(id(value?.warehousePalletId))
|
||||
const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId))
|
||||
&& id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId)
|
||||
&& id(value?.planId) && id(value?.planPalletId))
|
||||
context.assert(MODULE, '候选严格命中本轮PDA库存托盘与实际海外仓', candidateResult.response,
|
||||
Boolean(candidate) && candidate.sourceStage === 'RECEIVED' && Number(candidate.packageCount) > 0,
|
||||
`candidate=${Boolean(candidate)}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS)
|
||||
if (!candidate) return
|
||||
&& id(value?.planId) && id(value?.planPalletId)
|
||||
&& value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0)
|
||||
const candidatesByDestination = new Map()
|
||||
for (const value of markerCandidates) {
|
||||
const destination = String(value?.destinationWarehouseCode || value?.fbaWarehouseCode || '').trim().toUpperCase()
|
||||
if (destination && !candidatesByDestination.has(destination)) candidatesByDestination.set(destination, value)
|
||||
}
|
||||
const selectedCandidates = [...candidatesByDestination.values()]
|
||||
const candidate = selectedCandidates[0]
|
||||
context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response,
|
||||
selectedCandidates.length >= 2,
|
||||
`candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS)
|
||||
if (selectedCandidates.length < 2) return
|
||||
|
||||
const planId = id(candidate.planId)
|
||||
const planPalletId = id(candidate.planPalletId)
|
||||
const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))]
|
||||
const planPalletIds = selectedCandidates.map(value => id(value.planPalletId))
|
||||
// Exercise removal on a disposable dispatch, then reuse the released current-run pallet.
|
||||
const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`,
|
||||
@@ -158,9 +168,11 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
|
||||
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
|
||||
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
|
||||
const snapshotBody = { draftToken, planIds: [planId], containersJson }
|
||||
const snapshotBody = { draftToken, planIds, containersJson }
|
||||
context.createdResources.phase4OverseasDispatch.draftToken = draftToken
|
||||
context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId
|
||||
context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds
|
||||
context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()]
|
||||
|
||||
const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody)
|
||||
context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response,
|
||||
@@ -173,11 +185,11 @@ export async function run(context) {
|
||||
const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : []
|
||||
const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken)
|
||||
context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response,
|
||||
matchingDrafts.length === 1 && String(matchingDrafts[0].planIdsJson || '').split(',').includes(planId)
|
||||
matchingDrafts.length === 1 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value))
|
||||
&& matchingDrafts[0].containersJson === containersJson,
|
||||
`matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS)
|
||||
|
||||
const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 })
|
||||
const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 })
|
||||
const containerId = id(disposableContainer.data?.id)
|
||||
if (!success(disposableContainer.response) || !containerId) return
|
||||
const boundBody = { planPalletId, draftToken, draftContainerId: containerId }
|
||||
@@ -196,26 +208,31 @@ export async function run(context) {
|
||||
const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`)
|
||||
context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response,
|
||||
Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS)
|
||||
const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 })
|
||||
const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 })
|
||||
if (!success(finalContainer.response) || !finalContainer.data?.id) return
|
||||
const lockBody = { planPalletId, draftToken, draftContainerId: id(finalContainer.data.id) }
|
||||
const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody)
|
||||
if (!success(locked.response)) return
|
||||
const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) }))
|
||||
for (const [index, body] of lockBodies.entries()) {
|
||||
const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body)
|
||||
if (!success(locked.response)) return
|
||||
}
|
||||
const lockBody = lockBodies[0]
|
||||
const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody)
|
||||
context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response,
|
||||
success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS)
|
||||
|
||||
const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选',
|
||||
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken }))
|
||||
const visible = Array.isArray(visibleInDraft.data) && visibleInDraft.data.some((value) => id(value?.planPalletId) === planPalletId)
|
||||
const visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId)))
|
||||
const visible = planPalletIds.every(value => visibleIds.has(value))
|
||||
context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible,
|
||||
`planPalletId=${planPalletId}; ${trace(visibleInDraft.response)}`, OPTIONS)
|
||||
`planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS)
|
||||
|
||||
const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘',
|
||||
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
const hidden = Array.isArray(hiddenResult.data) && !hiddenResult.data.some((value) => id(value?.planPalletId) === planPalletId)
|
||||
const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId)))
|
||||
const hidden = planPalletIds.every(value => !hiddenIds.has(value))
|
||||
context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden,
|
||||
`planPalletId=${planPalletId}; ${trace(hiddenResult.response)}`, OPTIONS)
|
||||
`planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS)
|
||||
|
||||
const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64)
|
||||
const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, {
|
||||
@@ -224,7 +241,7 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
|
||||
|
||||
const preArrangeBody = {
|
||||
planPalletIds: [planPalletId], route: `${context.marker}-ROUTE`, fbaAppointmentNo: null,
|
||||
planPalletIds, route: `${context.marker}-ROUTE`, fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken,
|
||||
}
|
||||
const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
@@ -232,9 +249,9 @@ export async function run(context) {
|
||||
context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response,
|
||||
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
|
||||
&& Array.isArray(arranged.data?.items)
|
||||
&& arranged.data.items.some((item) => id(item?.planPalletId) === planPalletId)
|
||||
&& Number(arranged.data?.palletCount) === 1,
|
||||
`dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}; ${trace(arranged.response)}`, OPTIONS)
|
||||
&& planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value))
|
||||
&& Number(arranged.data?.palletCount) === planPalletIds.length,
|
||||
`dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${trace(arranged.response)}`, OPTIONS)
|
||||
if (!dispatchNo) return
|
||||
context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo
|
||||
context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id)
|
||||
|
||||
@@ -64,8 +64,10 @@ test('current loading event binds each pallet carton group to an explicit source
|
||||
const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8')
|
||||
assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/)
|
||||
assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/)
|
||||
assert.match(producer, /SkuPackageSnapshot\(detail\.detailId\(\)\.toString\(\), detail\.skuCode\(\)/)
|
||||
assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/)
|
||||
assert.match(producer, /groupingBy\(PalletRow::getSourceInboundDetailId/)
|
||||
assert.match(producer, /map\(PalletRow::getActualSkuQuantity\)/)
|
||||
assert.match(producer, /cartonQuantities\(detailRows\.getValue\(\), marks\)/)
|
||||
const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8')
|
||||
const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8')
|
||||
const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8')
|
||||
@@ -137,9 +139,10 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /rememberGeneratedPlans\(context, plans\)/)
|
||||
assert.match(source, /resolveGeneratedPallet\(context, inbound\)/)
|
||||
assert.match(source, /generatedPalletsByInbound/)
|
||||
assert.match(source, /standardFba = matrix\?\.byDestinationType\?\.FBA\?\.find/)
|
||||
assert.match(source, /standardFbaProducers = \(matrix\?\.byDestinationType\?\.FBA \|\| \[\]\)/)
|
||||
assert.match(source, /item\?\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'/)
|
||||
assert.match(source, /const producers = \[dropship[\s\S]*?standardFba,/)
|
||||
assert.match(source, /\.slice\(0, 2\)/)
|
||||
assert.match(source, /const producers = \[dropship, \.\.\.standardFbaProducers,/)
|
||||
assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/)
|
||||
assert.match(source, /retained as acceptance evidence/)
|
||||
assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/)
|
||||
|
||||
@@ -898,10 +898,13 @@ export async function run(context) {
|
||||
}
|
||||
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
||||
const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||
const standardFbaProducers = (matrix?.byDestinationType?.FBA || [])
|
||||
.filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||
.slice(0, 2)
|
||||
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
||||
// fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks.
|
||||
const producers = [dropship, standardFba,
|
||||
// fixture supplies multiple cartons for reversible operations. The second standard FBA
|
||||
// fixture remains available for the multi-destination dispatch and delivery split proof.
|
||||
const producers = [dropship, ...standardFbaProducers,
|
||||
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||
.filter(uniqueContainerProducer)
|
||||
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
||||
|
||||
@@ -223,7 +223,6 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
function manualPlanBody(order, version, marker) {
|
||||
return {
|
||||
customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version),
|
||||
plannedArrivalDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
|
||||
inboundCode: order.inboundCode,
|
||||
palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)),
|
||||
palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true,
|
||||
|
||||
@@ -25,7 +25,7 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli
|
||||
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'",
|
||||
'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick',
|
||||
'/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}',
|
||||
'/delivery-orders/${encodeURIComponent(deliveryNo)}/status',
|
||||
'/delivery-orders/${encodeURIComponent(currentNo)}/status',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(source, /WAIT_OUTBOUND/)
|
||||
assert.match(source, /WAIT_DELIVERY/)
|
||||
@@ -33,7 +33,9 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli
|
||||
assert.match(source, /OUTBOUNDED/)
|
||||
assert.match(source, /DELIVERING/)
|
||||
assert.match(source, /expectedVersion: deliveryVersion/)
|
||||
assert.match(source, /status: 'DELIVERED', expectedVersion: completeVersion/)
|
||||
assert.match(source, /transferDeliveries\(transferred\.data\)/)
|
||||
assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/)
|
||||
assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/)
|
||||
})
|
||||
|
||||
test('phase-five module proves the client delivery create, list and nested detail contract', () => {
|
||||
@@ -53,9 +55,10 @@ test('phase-five module proves the client delivery create, list and nested detai
|
||||
assert.match(source, /merchantDetail\.data\?\.operations/)
|
||||
})
|
||||
|
||||
test('phase-five module proves completion, customer visit, manual exception and delivery return', () => {
|
||||
test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => {
|
||||
for (const token of [
|
||||
'/delivery-orders/${encodeURIComponent(deliveryNo)}/complete',
|
||||
'/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'",
|
||||
'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE',
|
||||
'/customer-visits/${encodeURIComponent(visitId)}',
|
||||
"visitResult: 'SATISFIED'", '/exception-orders',
|
||||
'/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'",
|
||||
@@ -64,6 +67,15 @@ test('phase-five module proves completion, customer visit, manual exception and
|
||||
"idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放',
|
||||
'RETURN_PENDING_WITH_COMPLETED_VISIT',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(source, /saved\.data\?\.categories\?\.some/)
|
||||
assert.match(source, /completed\.data\?\.order\?\.status === 'DELIVERED'/)
|
||||
assert.match(source, /人工确认派送完成接口已移除/)
|
||||
assert.match(source, /query\('\/api\/platform\/inbounds'/)
|
||||
assert.match(source, /inboundCode: inbound\.inboundCode/)
|
||||
assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/)
|
||||
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/)
|
||||
assert.match(source, /mainStatus === 'COMPLETED'/)
|
||||
assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/)
|
||||
assert.match(source, /manualExceptionNo/)
|
||||
assert.match(source, /returnExceptionNo/)
|
||||
})
|
||||
|
||||
@@ -48,6 +48,73 @@ async function merchant(context, token, method, name, path, body = undefined) {
|
||||
return { response, data }
|
||||
}
|
||||
|
||||
function transferDeliveries(data) {
|
||||
return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
|
||||
}
|
||||
|
||||
function deliveryNumbers(data) {
|
||||
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
|
||||
}
|
||||
|
||||
async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
|
||||
let result = null
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
result = await action()
|
||||
if (predicate(result)) return { result, attempts: attempt }
|
||||
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||
}
|
||||
return { result, attempts }
|
||||
}
|
||||
|
||||
export async function saveReceiptAndComplete(context, delivery, customerId, index) {
|
||||
const deliveryNo = id(delivery.deliveryNo)
|
||||
const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId })
|
||||
const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath)
|
||||
const version = Number(before.data?.version)
|
||||
context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response,
|
||||
Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS)
|
||||
if (!success(before.response) || !Number.isInteger(version) || version < 0) return null
|
||||
|
||||
const uploadResponse = await context.requestOverseasMultipart('POST',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), {
|
||||
name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain',
|
||||
content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`),
|
||||
})
|
||||
const uploaded = context.apiSuccess(MODULE, `上传第${index + 1}张派送单签收文件`, uploadResponse,
|
||||
trace(uploadResponse), OPTIONS)
|
||||
const fileId = id(uploaded?.fileId)
|
||||
context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse,
|
||||
/^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS)
|
||||
if (!success(uploadResponse) || !fileId) return null
|
||||
|
||||
const existing = (before.data?.categories || []).map((category) => ({
|
||||
category: category.category,
|
||||
fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)),
|
||||
})).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
||||
const saveBody = {
|
||||
expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)),
|
||||
bolNo: before.data?.bolNo || null,
|
||||
documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }],
|
||||
}
|
||||
const saved = await platform(context, 'PUT', `保存第${index + 1}张派送单签收资料并自动完成`, documentsPath, saveBody)
|
||||
context.assert(MODULE, `第${index + 1}张派送单签收资料保存后已上传`, saved.response,
|
||||
saved.data?.categories?.some((category) => category?.category === 'RECEIPT'
|
||||
&& category?.status === 'UPLOADED' && Number(category?.fileCount) === 1), trace(saved.response), OPTIONS)
|
||||
if (!success(saved.response)) return null
|
||||
|
||||
const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody)
|
||||
context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response,
|
||||
savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version),
|
||||
trace(savedReplay.response), OPTIONS)
|
||||
const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`,
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
|
||||
context.assert(MODULE, `第${index + 1}张派送单签收保存即进入DELIVERED`, completed.response,
|
||||
completed.data?.order?.status === 'DELIVERED'
|
||||
&& completed.data?.operations?.some((operation) => operation?.operationNode === 'RECEIPT_AUTO_COMPLETE'),
|
||||
trace(completed.response), OPTIONS)
|
||||
return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null
|
||||
}
|
||||
|
||||
export function relabelOutboundMarksMatch(detail, relabel) {
|
||||
const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '')
|
||||
.split(',').map(mark => mark.trim()).filter(Boolean))
|
||||
@@ -74,7 +141,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, transferBody) {
|
||||
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
|
||||
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
|
||||
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
|
||||
Object.assign(relabel, { outboundNo, deliveryNo: transferred.data?.deliveryNo })
|
||||
Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) })
|
||||
}
|
||||
|
||||
async function loginMerchant(context, customerId) {
|
||||
@@ -113,7 +180,7 @@ function fixture(context) {
|
||||
return { dispatch, receiving }
|
||||
}
|
||||
|
||||
async function pickAll(context, token, dispatchNo) {
|
||||
export async function pickAll(context, token, dispatchNo) {
|
||||
let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const items = Array.isArray(detail.data?.items) ? detail.data.items : []
|
||||
@@ -170,7 +237,7 @@ async function pickAll(context, token, dispatchNo) {
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase5OverseasFulfillment = {
|
||||
marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null,
|
||||
marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [],
|
||||
visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null,
|
||||
cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence',
|
||||
}
|
||||
@@ -210,7 +277,7 @@ export async function run(context) {
|
||||
idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64),
|
||||
carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
|
||||
vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', driverName: updateBody.driverName,
|
||||
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName,
|
||||
driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate,
|
||||
appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
|
||||
@@ -220,18 +287,25 @@ export async function run(context) {
|
||||
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
const outboundNo = id(transferred.data?.outboundNo)
|
||||
const deliveryNo = id(transferred.data?.deliveryNo)
|
||||
context.assert(MODULE, '转派送同时生成WAIT_OUTBOUND与WAIT_DELIVERY单据', transferred.response,
|
||||
nonBlank(outboundNo) && nonBlank(deliveryNo) && transferred.data?.outboundStatus === 'WAIT_OUTBOUND'
|
||||
&& transferred.data?.deliveryStatus === 'WAIT_DELIVERY' && Number(transferred.data?.packageCount) > 0,
|
||||
`outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS)
|
||||
if (!success(transferred.response) || !outboundNo || !deliveryNo) return
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo })
|
||||
const deliveries = transferDeliveries(transferred.data)
|
||||
const deliveryNos = deliveryNumbers(transferred.data)
|
||||
const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId))
|
||||
const deliveryNo = deliveryNos[0] || ''
|
||||
context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response,
|
||||
nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length
|
||||
&& new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank)
|
||||
&& transferred.data?.outboundStatus === 'WAIT_OUTBOUND'
|
||||
&& deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY')
|
||||
&& deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount)
|
||||
&& Number(transferred.data?.packageCount) > 0,
|
||||
`outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS)
|
||||
if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos })
|
||||
const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody)
|
||||
context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response,
|
||||
adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo
|
||||
&& id(adminReplay.data?.deliveryNo) === deliveryNo, trace(adminReplay.response), OPTIONS)
|
||||
&& JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS)
|
||||
const lockedItem = updated.data?.items?.[0]
|
||||
if (lockedItem?.id) {
|
||||
const deniedRemove = await context.requestOverseas('DELETE',
|
||||
@@ -246,24 +320,28 @@ export async function run(context) {
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response,
|
||||
transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo
|
||||
&& id(transferReplay.data?.deliveryNo) === deliveryNo, trace(transferReplay.response), OPTIONS)
|
||||
&& JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS)
|
||||
const transferConflict = await context.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }),
|
||||
{ ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken)
|
||||
context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict,
|
||||
success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo
|
||||
&& transferConflict.body?.data?.replayed === true, trace(transferConflict), OPTIONS)
|
||||
&& transferConflict.body?.data?.replayed === true
|
||||
&& JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS)
|
||||
|
||||
const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单',
|
||||
query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo }))
|
||||
const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : []
|
||||
context.assert(MODULE, '客户派送列表包含本轮单据且不依赖customerId参数', merchantPage.response,
|
||||
merchantRows.some((value) => id(value?.deliveryNo) === deliveryNo && id(value?.dispatchNo) === dispatchNo),
|
||||
context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response,
|
||||
deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number
|
||||
&& id(value?.dispatchNo) === dispatchNo)),
|
||||
`records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS)
|
||||
const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情',
|
||||
`${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`)
|
||||
context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response,
|
||||
id(merchantDetail.data?.order?.deliveryNo) === deliveryNo
|
||||
&& id(merchantDetail.data?.order?.dispatchNo) === dispatchNo
|
||||
&& nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode
|
||||
|| merchantDetail.data?.order?.destinationWarehouseId))
|
||||
&& Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0
|
||||
&& Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations),
|
||||
`skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`,
|
||||
@@ -301,42 +379,52 @@ export async function run(context) {
|
||||
outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED',
|
||||
trace(outbound.response), OPTIONS)
|
||||
|
||||
const deliveryBeforeStatus = await platform(context, 'GET', '读取派送状态更新即时版本',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
|
||||
const deliveryVersion = Number(deliveryBeforeStatus.data?.order?.version)
|
||||
context.assert(MODULE, '派送详情提供状态乐观锁版本', deliveryBeforeStatus.response,
|
||||
Number.isInteger(deliveryVersion) && deliveryVersion >= 0,
|
||||
`version=${deliveryBeforeStatus.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeStatus.response)}`, OPTIONS)
|
||||
if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return
|
||||
const delivering = await platform(context, 'PUT', '派送单推进配送中',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, { customerId }),
|
||||
{ status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering` })
|
||||
context.assert(MODULE, '派送状态进入DELIVERING', delivering.response,
|
||||
delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS)
|
||||
if (!success(delivering.response)) return
|
||||
const deliveryBeforeComplete = await platform(context, 'GET', '读取派送完成即时版本',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
|
||||
const completeVersion = Number(deliveryBeforeComplete.data?.order?.version)
|
||||
context.assert(MODULE, '配送中详情版本已递增', deliveryBeforeComplete.response,
|
||||
Number.isInteger(completeVersion) && completeVersion > deliveryVersion,
|
||||
`before=${deliveryVersion}, after=${deliveryBeforeComplete.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeComplete.response)}`,
|
||||
OPTIONS)
|
||||
if (!Number.isInteger(completeVersion) || completeVersion <= deliveryVersion) return
|
||||
const completed = await platform(context, 'POST', '确认派送完成并生成客户回访',
|
||||
for (const [index, delivery] of deliveries.entries()) {
|
||||
const currentNo = id(delivery.deliveryNo)
|
||||
const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`,
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId }))
|
||||
const deliveryVersion = Number(beforeStatus.data?.order?.version)
|
||||
context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response,
|
||||
Number.isInteger(deliveryVersion) && deliveryVersion >= 0,
|
||||
`version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS)
|
||||
if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return
|
||||
const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`,
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }),
|
||||
{ status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` })
|
||||
context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response,
|
||||
delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS)
|
||||
if (!success(delivering.response)) return
|
||||
}
|
||||
|
||||
const completedDeliveries = []
|
||||
for (const [index, delivery] of deliveries.entries()) {
|
||||
const completed = await saveReceiptAndComplete(context, delivery, customerId, index)
|
||||
if (!completed) return
|
||||
completedDeliveries.push(completed)
|
||||
}
|
||||
context.assert(MODULE, '每个目的FBA仓派送单均由各自签收文件自动完成', null,
|
||||
completedDeliveries.length === deliveries.length
|
||||
&& completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'),
|
||||
`deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS)
|
||||
|
||||
const removedComplete = await context.requestOverseas('POST',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }),
|
||||
{ status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` })
|
||||
const visitId = id(completed.data?.visitId)
|
||||
context.assert(MODULE, '派送完成生成PENDING回访记录', completed.response,
|
||||
completed.data?.deliveryStatus === 'DELIVERED' && completed.data?.visitStatus === 'PENDING'
|
||||
&& /^\d+$/.test(visitId), `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(completed.response)}`, OPTIONS)
|
||||
if (!success(completed.response) || !visitId) return
|
||||
{ status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
|
||||
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
|
||||
removedComplete?.http === 404 || removedComplete?.http === 405
|
||||
|| (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))),
|
||||
trace(removedComplete), OPTIONS)
|
||||
|
||||
const visits = await platform(context, 'GET', '按派送单回查签收自动生成的客户回访',
|
||||
query(`${LASTLEG_BASE}/customer-visits`, { page: 1, size: 200, customerId, keyword: deliveryNo }))
|
||||
const visitRow = Array.isArray(visits.data?.records)
|
||||
? visits.data.records.find((value) => id(value?.deliveryNo) === deliveryNo) : null
|
||||
const visitId = id(visitRow?.id)
|
||||
context.assert(MODULE, '签收自动完成生成PENDING回访记录', visits.response,
|
||||
/^\d+$/.test(visitId) && visitRow?.status === 'PENDING',
|
||||
`visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(visits.response)}`, OPTIONS)
|
||||
if (!visitId) return
|
||||
context.createdResources.phase5OverseasFulfillment.visitId = visitId
|
||||
const completeReplay = await platform(context, 'POST', '确认派送完成重复调用',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }),
|
||||
{ status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` })
|
||||
context.assert(MODULE, '派送完成重放复用同一回访', completeReplay.response,
|
||||
completeReplay.data?.replayed === true && id(completeReplay.data?.visitId) === visitId,
|
||||
trace(completeReplay.response), OPTIONS)
|
||||
|
||||
const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情',
|
||||
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }))
|
||||
@@ -345,6 +433,29 @@ export async function run(context) {
|
||||
context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response,
|
||||
Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS)
|
||||
if (!inbound) return
|
||||
|
||||
const domesticInbound = await poll(async () => context.request('GET', query('/api/platform/inbounds', {
|
||||
tenantId: customerId, inboundCode: inbound.inboundCode, page: 1, size: 20,
|
||||
})), (response) => success(response) && Array.isArray(response.body?.data?.records)
|
||||
&& response.body.data.records.some((value) => value?.inboundCode === inbound.inboundCode
|
||||
&& value?.processStatus === 'DELIVERED'))
|
||||
const inboundSummary = domesticInbound.result?.body?.data?.records?.find(
|
||||
(value) => value?.inboundCode === inbound.inboundCode)
|
||||
context.addStep(MODULE, '海外签收完成事件回推头程入仓单DELIVERED', domesticInbound.result,
|
||||
success(domesticInbound.result) && inboundSummary?.processStatus === 'DELIVERED',
|
||||
`inboundCode=${inbound.inboundCode}, pollRequests=${domesticInbound.attempts}, processStatus=${inboundSummary?.processStatus || 'missing'}; ${trace(domesticInbound.result)}`, OPTIONS)
|
||||
const sourceOrderId = id(inboundSummary?.orderId)
|
||||
if (!sourceOrderId) {
|
||||
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
|
||||
return
|
||||
}
|
||||
const domesticOrder = await poll(async () => context.request('GET',
|
||||
query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })),
|
||||
(response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED')
|
||||
context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result,
|
||||
success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED',
|
||||
`orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS)
|
||||
|
||||
const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果',
|
||||
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), {
|
||||
selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED',
|
||||
|
||||
@@ -7,12 +7,12 @@ import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('four real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
|
||||
test('three real command payloads mutate contract fields and preserve remarks and state on conflict', async () => {
|
||||
const marker = 'offline-terminal'
|
||||
const containerBookingId = '9223372036854775806'
|
||||
const containerNo = 'TEST0000001'
|
||||
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN']
|
||||
const paths = ['fleet', 'pickup-appointment', 'pickup', 'empty-return']
|
||||
const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN']
|
||||
const paths = ['fleet', 'pickup-appointment', 'empty-return']
|
||||
let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] }
|
||||
const history = []
|
||||
const commands = new Map()
|
||||
@@ -59,20 +59,19 @@ test('four real command payloads mutate contract fields and preserve remarks and
|
||||
state = { ...state, version: state.version + 1 }
|
||||
if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] })
|
||||
if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt
|
||||
if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] })
|
||||
if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
|
||||
if (index === 2) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
|
||||
history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] })
|
||||
commands.set(key, { body: structuredClone(body), state: structuredClone(state) })
|
||||
return response(state)
|
||||
},
|
||||
}
|
||||
await prepareTerminalDrayage(context, producer)
|
||||
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
|
||||
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', actualPickupAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
|
||||
await run(context)
|
||||
assert.equal(commands.size, 4)
|
||||
assert.equal(replays, 4)
|
||||
assert.equal(conflicts, 4)
|
||||
assert.equal(state.version, 4)
|
||||
assert.equal(commands.size, 3)
|
||||
assert.equal(replays, 3)
|
||||
assert.equal(conflicts, 3)
|
||||
assert.equal(state.version, 3)
|
||||
assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED')
|
||||
})
|
||||
|
||||
@@ -90,7 +89,7 @@ test('terminal drayage prepares pickup before unloading and finalizes return bef
|
||||
|
||||
test('module uses only public business endpoints and reuses public trucking master data', () => {
|
||||
for (const path of [
|
||||
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history',
|
||||
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'empty-return'", '/history',
|
||||
'category=TRUCKING',
|
||||
]) assert.ok(source.includes(path), `missing ${path}`)
|
||||
assert.doesNotMatch(source, /\/internal\//)
|
||||
@@ -98,11 +97,11 @@ test('module uses only public business endpoints and reuses public trucking mast
|
||||
assert.match(source, /脚本不临时创建主数据/)
|
||||
})
|
||||
|
||||
test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => {
|
||||
test('all three commands cover idempotent replay hash conflict and lifecycle semantics', () => {
|
||||
for (const token of [
|
||||
'选择车队', '预约提柜', '确认实际提柜', '确认实际还柜',
|
||||
'同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED',
|
||||
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
|
||||
'选择车队', '预约提柜', '确认实际还柜',
|
||||
'同键重放', '同键异载荷返回A0409', 'UNLOADED', 'EMPTY_RETURNED',
|
||||
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
|
||||
]) assert.ok(source.includes(token), `missing ${token}`)
|
||||
assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/)
|
||||
assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//)
|
||||
|
||||
@@ -39,7 +39,7 @@ async function command(context, target, action, path, body) {
|
||||
`${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key })
|
||||
context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response,
|
||||
JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
const changed = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` }
|
||||
const changed = path === 'empty-return' ? { ...body, remark: `${body.remark}-DIFFERENT` }
|
||||
: { ...body, reason: `${body.reason}-DIFFERENT` }
|
||||
const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`,
|
||||
changed, context.token, { 'Idempotency-Key': key })
|
||||
@@ -103,15 +103,7 @@ export async function prepareTerminalDrayage(context, producer) {
|
||||
trace(scheduled.response), OPTIONS)
|
||||
if (!success(scheduled.response)) return null
|
||||
|
||||
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
|
||||
const picked = await command(context, target, '确认实际提柜', 'pickup', {
|
||||
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response,
|
||||
picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt,
|
||||
trace(picked.response), OPTIONS)
|
||||
if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null
|
||||
resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) }
|
||||
resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) }
|
||||
resource.containerBookingId = target.containerBookingId
|
||||
resource.containerNo = target.containerNo
|
||||
return resource.prepared
|
||||
@@ -149,15 +141,15 @@ export async function run(context) {
|
||||
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
|
||||
id(initial.data?.containerBookingId) === target.containerBookingId
|
||||
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
|
||||
&& Number(initial.data?.version) === Number(target.pickedVersion)
|
||||
&& Number(initial.data?.version) === Number(target.scheduledVersion)
|
||||
&& initial.data?.status === 'UNLOADED'
|
||||
&& initial.data?.actualPickupAt === target.pickupAt
|
||||
&& initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt
|
||||
&& initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'),
|
||||
`pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
|
||||
if (!success(initial.response)) return
|
||||
|
||||
const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`)
|
||||
const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
|
||||
const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt
|
||||
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
|
||||
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
|
||||
expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker,
|
||||
@@ -176,13 +168,12 @@ export async function run(context) {
|
||||
&& list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS)
|
||||
const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`)
|
||||
const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action))
|
||||
context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response,
|
||||
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
|
||||
context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response,
|
||||
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
|
||||
&& history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1),
|
||||
`actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS)
|
||||
context.assert(MODULE, '提柜和还柜备注写入不可变历史', history.response,
|
||||
['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) =>
|
||||
history.data?.some((item) => item.action === action && item.reason === context.marker)),
|
||||
context.assert(MODULE, '还柜备注写入不可变历史', history.response,
|
||||
history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker),
|
||||
trace(history.response), OPTIONS)
|
||||
context.createdResources.phase5TerminalDrayage = {
|
||||
...context.createdResources.phase5TerminalDrayage,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export function channelGroup(channelCode) {
|
||||
const value = String(channelCode || '').trim().toUpperCase()
|
||||
if (['MATSON_CLX', 'MATSON_MAX', 'EXX', 'HEDE', 'OA_LA', 'OA_CHICAGO',
|
||||
'OA_HOUSTON', 'OA_SAVANNAH', 'OA_NEW_YORK_TRUCK'].includes(value)) return value
|
||||
if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX'
|
||||
if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX'
|
||||
if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX'
|
||||
|
||||
@@ -144,7 +144,7 @@ async function fixture(context) {
|
||||
const draft = oceanBookingDraft(context, suffix, master, {
|
||||
bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName,
|
||||
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
||||
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }],
|
||||
})
|
||||
const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft },
|
||||
{ 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) })
|
||||
|
||||
Reference in New Issue
Block a user