优化
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
固定客户车队履约通过显式环境文件的 `ADMIN_TEST_FLEET_RESOURCES` JSON 数组配置已有资源,每项为 `vehicleId`、`driverId`(十进制字符串)。助手只读核验海外自有在役车辆、已审核启用司机及截至预期任务结束的派车资格,不创建车辆/司机、不上传或重审证照。当前 runner 中尚未完成资源释放的组合不得重复分配;全部占用或缺配置直接阻断,失败资源保持占用待排查。复用车辆按审核后真实车牌查询,并以本轮排车号核对唯一 Fleet 绑定,不能把历史同车次记录当作当前证据。跨 runner 仍须串行,真实占用、仓与容量由正式分配接口最终校验。本规则不减少必要的业务排车与履约证据,也不覆盖车辆或司机主档生命周期。
|
||||
|
||||
国内整柜权限拒绝探针通过显式环境文件的 `ADMIN_TEST_DOMESTIC_DENY_ACTOR` JSON 对象配置 `membershipId`、`username`、`password`。固定客户模式在业务写入前核对既有 ACTIVE 平台账号及实际 A0403 拒绝响应,结束退出本轮会话;缺配置或权限不符直接阻断,不创建角色、账号或重置密码。实际凭据不得写入仓库或报告。
|
||||
|
||||
固定 PDA 账号取得会话后即占用;权限核验失败也检查退出结果。退出被拒绝或请求异常会记录失败并保留占用,禁止本轮再次分配该账号,同时继续尝试退出其他账号。不将退出未知结果视为清理成功。
|
||||
|
||||
固定海外 PDA 账号池通过显式环境文件的 `ADMIN_TEST_OVERSEAS_PDA_ACTORS` JSON 数组配置,每项为 `membershipId`、`warehouseId`、`username`、`password`,不得将实际值写入仓库或报告。同仓双操作员场景需要两个不同账号;当前 runner 按 membershipId 占用并在退出会话成功后释放。使用前核对 ACTIVE、平台身份、PDA 角色及仓绑定;缺失或不匹配直接阻断,不创建或修复账号。复用账号只注销本轮会话,不停用、不改绑、不清空角色;仓绑定撤销生命周期明确列为未验收。该占用只在当前 runner 内有效,仍须遵守共享环境造数串行规则。
|
||||
|
||||
固定客户下,报关、装柜、订舱级拖车和车队报价复用启用供应商;按 CUSTOMS/BOOKING/TRUCKING 类别、MATSON 船司及国内/海外拖车范围核验列表和详情。缺少匹配供应商时阻断,不自动创建或重新启用;车队报价结束只停用本轮报价,不停用复用供应商。
|
||||
|
||||
固定客户模式下,PDA 换标只核验现有可用余额至少 0.01 元(本场景换标费用),不自动补款;采购样本专项要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不自动充值。海外采购合作仓从正式候选复用,退出时不得停用复用仓。固定客户及本轮入仓计划查询覆盖后续分页;混托专项缺第二固定客户时,在首次下单和测量前阻断。以上前置检查不替代服务端执行时的实时余额、资格与版本校验。
|
||||
|
||||
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
|
||||
|
||||
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
|
||||
@@ -78,7 +90,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
|
||||
|
||||
`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;五期与F40通过正式API维护本轮车辆、司机及证照,提交并通过调度/理货两级审核,再读取该排车唯一自动生成的Fleet资源记录。PDA拣货核平后,显式确认装车完成,再由平台安排全车客户目的地派送,最后使用当前主单版本发车;不再独立创建、绑定或审核Fleet车次。实载托位、重量、体积及卸货站须有本轮真实来源证据,不能使用计费量或固定值;ID和版本无损保留,Long箱数字符串仅在安全数量边界归一化。资源及文件ID留在报告,失败保留原命令。直接调用者也须同步消费原生DTO及该顺序;合同通过不代表HTTP或PDA硬件验收。
|
||||
`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;通过正式 API 准备资源与两级审核,读取唯一自动生成的 Fleet 记录。PDA 拣货及来源核验通过后,读取本车五个装车照片框,复用已保存文件,仅为缺失框上传标明 TEST 的合成 PNG,再绑定、原键重放并回读;上传前保存检查点与文件摘要,未知结果停写,不自动重新上传。照片在 LOADING 保存不得提前发车;AFTER_LOADING 最后一个缺失框保存后须进入 DELIVERING。资料齐全后重新读取主单资格与实载清单,再确认装车并核对自动生成的派送,不再调用手工安排派送或发车。实载数据由服务端返回,重量和体积缺失保持未知,不以请求中的测量值或计费量替代。PDA 上传及绑定始终保留原账号会话;ID 和版本无损保留,资源及文件留在报告。直接调用者须消费当前 DTO;离线合同通过不代表真实 HTTP、实物照片或 PDA 硬件验收。
|
||||
|
||||
`prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。
|
||||
|
||||
@@ -257,7 +269,7 @@ export async function run(context) {
|
||||
| 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 |
|
||||
| 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 |
|
||||
| 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 |
|
||||
| 排车编辑、BOL、后台转派送 | `phase5-overseas-fulfillment` | `phase4-overseas-dispatch` 生成的主链排车 | 复用车辆编辑和客户转派送;新增 BOL content-type 与 %PDF- 签名检查;后台同载荷转派送复用同一出库/派送业务号;生成出库后移除托盘 A0409 且数量和身份不漂移;原 PDA 拣货/发车、派送、异常和回访继续执行 |
|
||||
| 排车编辑、BOL、自动派送 | `phase5-overseas-fulfillment` | 本轮主链排车 | 核对车辆编辑、BOL 文件、PDA 拣货、五框资料及装车自动生成出库/派送;双端回读阶段版本、客户归属和数量,库存与 SHIP 零重复扣减;继续逐单签收、异常和回访 |
|
||||
| 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 |
|
||||
| PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture |
|
||||
|
||||
@@ -320,7 +332,7 @@ export async function run(context) {
|
||||
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`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` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(本轮已收货托盘和排车,PDA 拣货、资料齐全后装车自动发车、只读单据守恒、签收与回推;固定 PDA 账号退出会话) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
|
||||
`phase4-overseas-inbound-operations.mjs` 的内部 `prepareAppointmentFixture` 可由已核对的本轮最小正式 API 流程复用:必须传入真实转换所得的入仓计划和同仓明细,至少两箱才执行拆分合并;两箱验证 `2 → 1 + 1 → 2`,不补造货量。完整 Admin 模块仍按上表四箱参数及统一依赖运行,不能为缩减造数而伪造生产者矩阵或跳过前置事实。
|
||||
@@ -347,7 +359,7 @@ node scripts/admin-test-env-acceptance.mjs --gateway <test-gateway> --modules ph
|
||||
|
||||
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
|
||||
|
||||
海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。
|
||||
海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`,只消费本轮国内装柜、海外接柜与 PDA 实收形成的排车和库存。正式资源及两级审核就绪后,由绑定同仓的 PDA 逐来源拣货,补齐五框装车资料,再确认装车;服务端自动生成客户出库与派送并发车。五期、F40、运力池和保留样本专项统一使用 `readAutomaticDispatchDelivery`、`verifyAutomaticDeparture` 只读核对单据、双端阶段/版本及客户数量,禁止重复调用手工转派送或发车。装车重放由共享助手核验,之后独立回查库存和 SHIP 不重复扣减,再逐单签收并验证头程回推。固定账号只退出本轮会话;非固定专项账号按原生命周期清理。前置不足明确失败,不能借历史单据冒充本轮自动发车。
|
||||
|
||||
该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。
|
||||
|
||||
|
||||
@@ -3,6 +3,30 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { verifyPermissionDenial } from './domestic-container.mjs'
|
||||
|
||||
test('fixed denial actor never creates, resets or disables accounts and always logs out', async () => {
|
||||
for (const deniedCode of ['A0403', '200']) {
|
||||
const calls = []
|
||||
const actor = { membershipId: '123', username: 'deny-fixture', password: 'fixture-password' }
|
||||
const context = { options: { fixedCustomerId: '456', fixedDomesticDenyActor: actor },
|
||||
async request(method, path) {
|
||||
calls.push({ method, path })
|
||||
const data = path.includes('/iam/users?') ? { total: 1, records: [{ ...actor, tenantId: '0', status: 'ACTIVE' }] }
|
||||
: path === '/api/auth/login' ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } : {}
|
||||
return { http: 200, body: { code: path.includes('/inventories?') ? deniedCode : '200', data }, requestId: 'test', traceId: 'test' }
|
||||
}, apiSuccess(_module, _label, response) { return response.body.data }, assert() {},
|
||||
}
|
||||
if (deniedCode === 'A0403') await verifyPermissionDenial(context, 'RUN', '789', 'CONTAINER')
|
||||
else await assert.rejects(verifyPermissionDenial(context, 'RUN', '789', 'CONTAINER'), /实际权限不符/)
|
||||
assert.equal(calls.at(-1).path, '/api/auth/logout')
|
||||
assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
|
||||
}
|
||||
})
|
||||
|
||||
test('missing fixed denial account blocks without requesting a phone or making HTTP calls', async () => {
|
||||
await assert.rejects(verifyPermissionDenial({ options: { fixedCustomerId: '123' } }, 'RUN', '789', 'CONTAINER'), /禁止自动创建/)
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const moduleSource = fs.readFileSync(path.join(here, 'domestic-container.mjs'), 'utf8')
|
||||
|
||||
@@ -131,6 +131,7 @@ export async function run(context) {
|
||||
if (!warehouse) throw new Error('缺少启用的平台国内仓,整柜验收无法继续')
|
||||
const warehouseId = id(warehouse.id)
|
||||
const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase()
|
||||
if (context.options?.fixedCustomerId) await verifyPermissionDenial(context, marker, warehouseId, containerNo)
|
||||
const now = new Date().toISOString().slice(0, 19)
|
||||
|
||||
await verifyValidationBoundaries(context, warehouseId, marker, now)
|
||||
@@ -222,11 +223,38 @@ export async function run(context) {
|
||||
|
||||
await verifyExtendedInventoryScenarios(context, warehouseId, marker, now)
|
||||
|
||||
await verifyPermissionDenial(context, marker, warehouseId, containerNo)
|
||||
if (!context.options?.fixedCustomerId) await verifyPermissionDenial(context, marker, warehouseId, containerNo)
|
||||
context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS }
|
||||
}
|
||||
|
||||
async function verifyPermissionDenial(context, marker, warehouseId, containerNo) {
|
||||
export async function verifyPermissionDenial(context, marker, warehouseId, containerNo) {
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const actor = context.options.fixedDomesticDenyActor
|
||||
if (!actor?.username || !actor.password || !/^[1-9]\d*$/.test(id(actor.membershipId))) {
|
||||
throw Error('缺少固定整柜权限拒绝账号,禁止自动创建用户或角色')
|
||||
}
|
||||
let user
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`)
|
||||
const result = context.apiSuccess(MODULE, '查询固定权限拒绝账号', response)
|
||||
user = result?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username)
|
||||
if (user || !result?.records?.length || page * Number(result.size || 100) >= Number(result.total || 0)) break
|
||||
}
|
||||
if (user?.status !== 'ACTIVE' || id(user.tenantId) !== '0') throw Error('固定权限拒绝账号必须为启用的平台账号')
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '')
|
||||
const session = context.apiSuccess(MODULE, '登录固定权限拒绝账号', login)
|
||||
if (!session?.accessToken || !session.refreshToken) throw Error('固定权限拒绝账号未返回完整会话')
|
||||
try {
|
||||
const denied = await context.request('GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`, undefined, session.accessToken)
|
||||
expectCode(context, '固定无整柜权限用户被拒绝', denied, 'A0403')
|
||||
if (denied.http !== 200 || String(denied.body?.code) !== 'A0403') throw Error('固定探针账号实际权限不符,停止整柜造数')
|
||||
} finally {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken)
|
||||
context.apiSuccess(MODULE, '退出固定权限拒绝账号会话,不停用账号或角色', logout)
|
||||
if (!succeeded(logout)) throw Error('固定权限拒绝账号退出失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
const phone = await context.findAvailablePhone(MODULE)
|
||||
const roleCode = `DC_DENY_${marker}`.slice(0, 64)
|
||||
const username = `dc_deny_${marker}`.slice(0, 64).toLowerCase()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { run } from './dropship-mixed-palletization.mjs'
|
||||
|
||||
test('single fixed customer blocks before first order or measurement', async () => {
|
||||
await assert.rejects(run({ options: { fixedCustomerId: '123' } }), /停止下单和测量造数/)
|
||||
})
|
||||
|
||||
const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8')
|
||||
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -8,6 +8,7 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
|
||||
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
|
||||
export async function run(context) {
|
||||
if (context.options?.fixedCustomerId) throw new Error('混托需要第二个固定客户,停止下单和测量造数')
|
||||
const originalMarker = context.marker
|
||||
const savedContext = { ...context.context }
|
||||
const originalManualCount = context.options.prepareDropshipManualCount
|
||||
|
||||
@@ -14,8 +14,8 @@ test('fixed customer supplier selection paginates and never creates a fallback',
|
||||
async request(method, path) {
|
||||
calls.push({ method, path })
|
||||
const data = path.includes('/123?') ? supplier : path.includes('page=2')
|
||||
? { records: [supplier], total: 101 }
|
||||
: { records: Array.from({ length: 100 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), total: 101 }
|
||||
? { records: [supplier], size: 20, total: 21 }
|
||||
: { records: Array.from({ length: 20 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), size: 20, total: 21 }
|
||||
return { body: { data } }
|
||||
},
|
||||
apiSuccess(_module, _name, response) { return response.body.data },
|
||||
@@ -30,6 +30,22 @@ test('fixed customer supplier selection paginates and never creates a fallback',
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
test('reused suppliers must match carrier and trucking scope in both list and detail', async () => {
|
||||
for (const criteria of [{ carrierCode: 'MATSON' }, { truckingScope: 'OVERSEAS' }]) {
|
||||
const good = { id: '123', category: 'BOOKING', status: 'ACTIVE', carrierCodes: ['MATSON'], truckingScope: 'OVERSEAS' }
|
||||
let detail = good
|
||||
const context = { options: { fixedCustomerId: '456' }, marker: 'REUSE',
|
||||
async request(method, path) {
|
||||
assert.equal(method, 'GET')
|
||||
return { body: { data: path.includes('/123?') ? detail : { records: [
|
||||
{ ...good, id: '999', carrierCodes: [], truckingScope: 'DOMESTIC' }, good], total: 2 } } }
|
||||
}, apiSuccess(_module, _name, response) { return response.body.data }, addStep() {},
|
||||
}
|
||||
assert.equal((await loadSupplier(context, 'BOOKING', criteria)).id, '123')
|
||||
detail = { ...good, carrierCodes: [], truckingScope: 'DOMESTIC' }
|
||||
assert.equal(await loadSupplier(context, 'BOOKING', criteria), null)
|
||||
}
|
||||
})
|
||||
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
|
||||
|
||||
test('finance fixture module is independently exported and marker isolated', () => {
|
||||
|
||||
@@ -258,23 +258,25 @@ async function loadPublishedAutomaticFee(context, resource) {
|
||||
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
||||
}
|
||||
|
||||
export async function loadSupplier(context, category) {
|
||||
export async function loadSupplier(context, category, { carrierCode, truckingScope } = {}) {
|
||||
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)
|
||||
&& (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope)
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
|
||||
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
|
||||
if (!data) return null
|
||||
const records = rows(data)
|
||||
const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category))
|
||||
const candidate = records.find(matches)
|
||||
if (candidate) {
|
||||
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
|
||||
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
|
||||
if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail
|
||||
if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail
|
||||
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
|
||||
return null
|
||||
}
|
||||
if (!records.length || page * 100 >= Number(data.total ?? records.length)) break
|
||||
if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break
|
||||
}
|
||||
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
|
||||
return null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
@@ -49,6 +50,7 @@ function model(fault = '') {
|
||||
}
|
||||
return response({...prior.result,replayed:true})
|
||||
}
|
||||
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()})
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -86,6 +87,7 @@ function fixture(fault = '', nativeMode = false) {
|
||||
if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) })
|
||||
if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo })
|
||||
if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops })
|
||||
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
|
||||
if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1,
|
||||
evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) })
|
||||
if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) }
|
||||
@@ -95,7 +97,7 @@ function fixture(fault = '', nativeMode = false) {
|
||||
if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) }
|
||||
if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) }
|
||||
if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) }
|
||||
if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: items.map((item, index) => ({ warehousePalletId: item.warehousePalletId, palletSpaces: 1, weightKg: index === 2 ? 37 : 10, volumeCbm: index === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || index === 2 ? 1 : index + 1 })) } })) }
|
||||
throw Error(`Unexpected ${method} ${raw}`)
|
||||
} }
|
||||
const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'}
|
||||
|
||||
@@ -6,6 +6,21 @@ import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureC
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const copy = value => structuredClone(value)
|
||||
test('fixed quote supplier is reused without creation or deactivation', async () => {
|
||||
const { context } = harness(), request = context.request.bind(context), writes = []
|
||||
context.options = { fixedCustomerId: '101' }
|
||||
const supplier = { id: '901', name: 'Existing', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (path.includes('/suppliers')) {
|
||||
if (method !== 'GET') writes.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: path.includes('/901') ? supplier : { records: [supplier], total: 1 } } }
|
||||
}
|
||||
return request(method, path, ...args)
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(context.createdResources.fleetQuoteAcceptance.supplierReused, true)
|
||||
assert.deepEqual(writes, [])
|
||||
})
|
||||
function harness(fault) {
|
||||
const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z'
|
||||
const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = []
|
||||
|
||||
@@ -227,10 +227,14 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
|
||||
}
|
||||
if (scope === 'QUOTES_ONLY') {
|
||||
const customer = requireQuoteCustomer(context)
|
||||
const supplier = await command('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
const supplier = context.options?.fixedCustomerId
|
||||
? await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' })
|
||||
: await command('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker,
|
||||
region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30',
|
||||
}, '新建本轮海外车队供应商')
|
||||
if (!supplier) throw new Error('缺少现有海外拖车供应商,不自动创建')
|
||||
proof.supplierReused = Boolean(context.options?.fixedCustomerId)
|
||||
proof.supplierId = id(supplier.id ?? supplier)
|
||||
check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId))
|
||||
const day = new Date().toISOString().slice(0, 10)
|
||||
@@ -311,7 +315,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
|
||||
if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史')
|
||||
} catch (error) { cleanupErrors.push(error.message) }
|
||||
}
|
||||
if (proof.supplierId && !context.quoteRequestOutcomeUnknown) {
|
||||
if (proof.supplierId && !proof.supplierReused && !context.quoteRequestOutcomeUnknown) {
|
||||
try {
|
||||
const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}`
|
||||
const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' })
|
||||
|
||||
+15
-5
@@ -1,3 +1,4 @@
|
||||
import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||
@@ -33,7 +34,7 @@ function fixture(fault = '') {
|
||||
const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage,
|
||||
documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] },
|
||||
reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked,
|
||||
vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] })
|
||||
vehicleInfo: { vehicle: { vehicleId: '601', plate: vehiclePlate }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] })
|
||||
const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 })
|
||||
const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 })
|
||||
const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false })
|
||||
@@ -113,8 +114,9 @@ function fixture(fault = '') {
|
||||
if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] })
|
||||
if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops })
|
||||
if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] })
|
||||
if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
|
||||
if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2,
|
||||
...(fault === 'missing-measurements' ? {} : { palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, deliveryIndex: 1, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] })
|
||||
deliveryIndex: 1, ...(fault === 'missing-measurements' ? {} : { palletSpaces: 1, weightKg: 37, volumeCbm: 0.7, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] })
|
||||
if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] })
|
||||
if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next))
|
||||
assert.ok(created, `${method} ${raw}`)
|
||||
@@ -124,7 +126,7 @@ function fixture(fault = '') {
|
||||
assert.equal(body.recordVersion, recordVersion, raw)
|
||||
if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) }
|
||||
if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) }
|
||||
if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: [{ warehousePalletId: '100', palletSpaces: 1, weightKg: fault === 'missing-measurements' ? null : 37, volumeCbm: fault === 'missing-measurements' ? null : 0.7, deliveryIndex: 1 }] } })) }
|
||||
throw Error(`Unexpected ${method} ${raw}`)
|
||||
} }
|
||||
const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } }
|
||||
@@ -133,6 +135,15 @@ function fixture(fault = '') {
|
||||
}
|
||||
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
|
||||
|
||||
test('server-owned loading permits absent optional weight and volume without inventing measurements', async () => {
|
||||
const f = fixture('missing-measurements')
|
||||
await run(f)
|
||||
const request = f.requests.find(item => item.path.endsWith('/loading-complete'))
|
||||
assert.ok(request)
|
||||
assert.equal(request.body?.measurements, undefined)
|
||||
assert.equal(f.trip.returnRedispatch.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
|
||||
})
|
||||
|
||||
test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => {
|
||||
const f = fixture(), result = await run(f)
|
||||
assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3)
|
||||
@@ -172,10 +183,9 @@ for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'fore
|
||||
for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => {
|
||||
const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET'))
|
||||
})
|
||||
for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'missing-measurements', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => {
|
||||
for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => {
|
||||
const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED')
|
||||
if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET'))
|
||||
if (fault === 'missing-measurements') assert.equal(f.requests.some(r => r.path.endsWith('/loading-complete') || r.path.endsWith('/transfer-delivery')), false)
|
||||
})
|
||||
test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => {
|
||||
const f = fixture('shared-root'), proof = await run(f)
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') }
|
||||
proof.requests.push(entry); await context.checkpoint()
|
||||
try {
|
||||
const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options)
|
||||
const response = file ? await context.requestMultipart(method, path, file, token) : await context.request(method, path, body, token, options)
|
||||
if (!definiteResponse(response)) {
|
||||
stopped = true; proof.outcomeUnknown = true
|
||||
Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code,
|
||||
@@ -77,7 +77,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
} catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error }
|
||||
}
|
||||
io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options)
|
||||
io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file)
|
||||
io.requestMultipart = (method, path, file, token) => invoke(method, path, undefined, token, file)
|
||||
async function request(method, path, body, token) {
|
||||
const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOADING_PHOTO_SLOTS, ensureFleetLoadingDocuments, reuseFleetResources } from './fleet-trip-fulfillment.mjs'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
@@ -17,6 +18,69 @@ const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo:
|
||||
{ warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] },
|
||||
] })
|
||||
|
||||
for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents fill missing slots without replacing existing files from ${initialStage}`, async () => {
|
||||
const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [], replays = new Map()
|
||||
const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', stage: initialStage,
|
||||
complete: false, editable: true, photos: [{ slot: LOADING_PHOTO_SLOTS[0], fileId: 'existing' }] }
|
||||
let saved = false
|
||||
const context = { async checkpoint() { saved = true },
|
||||
assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response.body.data },
|
||||
async requestMultipart(method, path, file, token) {
|
||||
assert.ok(saved); saved = false; assert.equal(token, 'PDA-TEST'); assert.equal(method, 'POST')
|
||||
const slot = path.split('/').at(-2)
|
||||
assert.match(path, /idempotencyKey=[a-f0-9]{64}$/); assert.equal(file.type, 'image/png')
|
||||
assert.equal(file.content.subarray(1, 4).toString(), 'PNG'); calls.push(slot)
|
||||
assert.equal(proof.loadingDocuments.uploads.at(-1).status, 'PREPARED')
|
||||
return ok({ slot, fileId: `new-${slot}` })
|
||||
},
|
||||
async request(method, path, body, token) {
|
||||
assert.equal(token, 'PDA-TEST')
|
||||
if (method === 'GET') return ok(state)
|
||||
assert.equal(method, 'PUT')
|
||||
if (replays.has(body.idempotencyKey)) return ok(replays.get(body.idempotencyKey))
|
||||
assert.equal(body.recordVersion, state.recordVersion)
|
||||
state.photos.push({ slot: path.split('/').at(-1), fileId: body.fileId })
|
||||
state.recordVersion = String(BigInt(state.recordVersion) + 1n)
|
||||
state.complete = state.photos.length === 5
|
||||
if (state.complete && initialStage === 'AFTER_LOADING') state.stage = 'DELIVERING'
|
||||
replays.set(body.idempotencyKey, structuredClone(state)); return ok(state)
|
||||
} }
|
||||
await ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST')
|
||||
assert.deepEqual(calls, LOADING_PHOTO_SLOTS.slice(1))
|
||||
assert.equal(state.photos[0].fileId, 'existing')
|
||||
assert.equal(state.stage, initialStage === 'LOADING' ? 'LOADING' : 'DELIVERING')
|
||||
assert.equal(proof.loadingDocuments.result.complete, true)
|
||||
})
|
||||
|
||||
for (const fault of ['checkpoint', 'upload-unknown', 'wrong-slot', 'replay-drift', 'early-departure']) test(`loading document ${fault} stops before loading confirmation`, async () => {
|
||||
const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = []
|
||||
const state = { dispatchNo: 'DP-A', stage: 'LOADING', recordVersion: '1', complete: false, editable: true, photos: [] }
|
||||
const context = { async checkpoint() { if (fault === 'checkpoint') throw Error('checkpoint failed') },
|
||||
assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response?.body?.data },
|
||||
async requestMultipart(_method, path) {
|
||||
calls.push(path)
|
||||
if (fault === 'upload-unknown') throw Error('unknown upload')
|
||||
return ok({ slot: fault === 'wrong-slot' ? 'RECEIPT' : 'LOADING_SCENE', fileId: 'new-file' })
|
||||
},
|
||||
async request(method, path) {
|
||||
calls.push(path)
|
||||
if (method === 'GET') return ok(state)
|
||||
if (state.photos.length && fault === 'replay-drift') return ok({ ...state, recordVersion: '3' })
|
||||
state.photos = [{ slot: 'LOADING_SCENE', fileId: 'new-file' }]
|
||||
if (fault === 'early-departure') state.stage = 'DELIVERING'
|
||||
return ok(state)
|
||||
} }
|
||||
await assert.rejects(ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST'))
|
||||
assert.equal(calls.some(path => path.endsWith('/loading-complete')), false)
|
||||
if (fault === 'checkpoint') assert.equal(calls.length, 1)
|
||||
if (fault === 'upload-unknown') {
|
||||
assert.equal(proof.loadingDocuments.uploads[0].status, 'UNKNOWN')
|
||||
const sent = calls.filter(path => path.includes('/uploads?')).length
|
||||
await assert.rejects(ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST'), /不自动重发/)
|
||||
assert.equal(calls.filter(path => path.includes('/uploads?')).length, sent)
|
||||
}
|
||||
})
|
||||
|
||||
test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => {
|
||||
const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets)
|
||||
for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) {
|
||||
@@ -37,7 +101,7 @@ function server(fault = '') {
|
||||
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false,
|
||||
dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} }
|
||||
const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops }
|
||||
let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail
|
||||
let driverVersion = 0, uploadComplete = false, driverReviewed = fault === 'fixed-master', nextPlate = fault === 'fixed-master' ? 'TEST-EXISTING' : '', checkpointError = false, nextTrip, nextDetail
|
||||
let mutatedItems = false, mutatedLoading = false
|
||||
const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']])
|
||||
const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n)
|
||||
@@ -57,6 +121,9 @@ function server(fault = '') {
|
||||
},
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' })
|
||||
if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
|
||||
if (method === 'GET' && path.endsWith(`/master-data/vehicles/${VID}`)) return ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
if (method === 'GET' && path.includes(`/fleet/drivers/${DRIVER}?`)) return ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
|
||||
if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown')
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase()
|
||||
@@ -232,6 +299,48 @@ test('native preparation edits existing master then submits both reviews, discov
|
||||
assert.ok(s.assertions.every(a => a.passed))
|
||||
})
|
||||
|
||||
test('fixed customer prepares a real trip without creating vehicles, drivers or uploading licenses', async () => {
|
||||
const s = server('fixed-master')
|
||||
s.context.options = { fixedCustomerId: '123', fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] }
|
||||
const proof = await prepare(s)
|
||||
assert.equal(proof.reusedMasterData, true)
|
||||
assert.equal(proof.preparation, 'VERIFIED')
|
||||
assert.equal(proof.vehicleId, VID)
|
||||
assert.equal(proof.driverId, DRIVER)
|
||||
assert.equal(s.calls.some(call => call.file), false)
|
||||
assert.ok(s.calls.filter(call => call.method !== 'GET').every(call => call.path.startsWith(D)))
|
||||
})
|
||||
|
||||
test('fixed fleet resources block missing, locally occupied and ineligible pairs without master writes', async () => {
|
||||
const pair = { vehicleId: VID, driverId: DRIVER }
|
||||
for (const fault of ['missing', 'occupied', 'vehicle-status', 'driver-qualification']) {
|
||||
const proof = {}, calls = []
|
||||
const context = { options: { fixedFleetResources: fault === 'missing' ? [] : [pair] },
|
||||
createdResources: { fleetTrips: fault === 'occupied' ? [{ ...pair }] : [proof] },
|
||||
async request(method, path) {
|
||||
calls.push(path); assert.equal(method, 'GET')
|
||||
return path.includes('/vehicles/') ? ok({ id: VID, status: fault === 'vehicle-status' ? 'DISPATCHED' : 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: fault !== 'driver-qualification', reason: 'ELIGIBLE' } })
|
||||
}, apiSuccess(_m, _l, response) { return response.body.data } }
|
||||
await assert.rejects(reuseFleetResources(context, 'test', proof), /固定车辆司机/)
|
||||
if (['missing', 'occupied'].includes(fault)) assert.equal(calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('verified resource closure makes a fixed pair available again', async () => {
|
||||
const proof = {}
|
||||
const context = { options: { fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] },
|
||||
createdResources: { fleetTrips: [{ vehicleId: VID, driverId: DRIVER, closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, proof] },
|
||||
async request(method, path) {
|
||||
assert.equal(method, 'GET')
|
||||
return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
|
||||
: ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
|
||||
}, apiSuccess(_m, _l, response) { return response.body.data } }
|
||||
await reuseFleetResources(context, 'test', proof)
|
||||
assert.equal(proof.vehicleId, VID)
|
||||
assert.equal(proof.driverId, DRIVER)
|
||||
})
|
||||
|
||||
test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => {
|
||||
const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED'
|
||||
const proof = await prepare(s)
|
||||
@@ -253,7 +362,7 @@ test('authoritative uppercase vehicle plate discovers both the prepared and subs
|
||||
assert.equal(proof.tripNo, 'TR-A')
|
||||
s.trip.status = 'IN_TRANSIT'
|
||||
const closure = await finishFleetTrip(s.context, 'native', proof)
|
||||
assert.equal(closure.nextDispatch.marker, marker); assert.equal(closure.nextDispatch.vehicle, undefined)
|
||||
assert.equal(closure.nextDispatch.marker, marker); assert.deepEqual(closure.nextDispatch.vehicle, proof.vehicle)
|
||||
assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED')
|
||||
const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?'))
|
||||
assert.equal(queries.length, 2)
|
||||
|
||||
@@ -43,14 +43,14 @@ async function command(context, module, method, path, body, label, token, expect
|
||||
return data
|
||||
}
|
||||
|
||||
// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。
|
||||
// 按审核后权威车辆快照分页,再核对真实资源及本轮排车唯一绑定。
|
||||
async function discoverTrip(context, module, proof) {
|
||||
// 正式车辆主档将车牌转为大写;保留原marker及命令键,仅按该权威规则生成精确查询身份。
|
||||
const vehiclePlate = proof.marker.toUpperCase()
|
||||
const vehiclePlate = proof.vehicle?.plate
|
||||
if (typeof vehiclePlate !== 'string' || !vehiclePlate.trim()) throw Error('审核后的车辆快照缺少真实车牌')
|
||||
const rows = [], seen = new Set(); let total
|
||||
for (let page = 1; ; page++) {
|
||||
const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId })
|
||||
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录')
|
||||
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按实际车牌读取自动资源记录')
|
||||
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|
||||
|| Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联')
|
||||
total ??= Number(data.total)
|
||||
@@ -79,7 +79,25 @@ async function discoverTrip(context, module, proof) {
|
||||
Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion })
|
||||
}
|
||||
|
||||
/** 本轮TEST主档经正式API建立;主单创建时已自动建立Fleet资源记录。 */
|
||||
export async function reuseFleetResources(context, module, proof, arrival) {
|
||||
const pairs = context.options.fixedFleetResources
|
||||
const busy = (context.createdResources.fleetTrips || []).filter(item => item !== proof
|
||||
&& item.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
const pair = Array.isArray(pairs) && pairs.find(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId))
|
||||
&& /^[1-9][0-9]*$/.test(id(item?.driverId))
|
||||
&& !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId)))
|
||||
if (!pair) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照')
|
||||
proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
|
||||
const vehicle = await command(context, module, 'GET', `/api/platform/v1/last-leg/master-data/vehicles/${id(pair.vehicleId)}`, undefined, '核对固定车辆状态')
|
||||
const driver = await command(context, module, 'GET', `${BASE}/drivers/${id(pair.driverId)}?expectedEnd=${encodeURIComponent(proof.expectedDriverEndAt)}`, undefined, '核对固定司机至任务结束的资格')
|
||||
if (id(vehicle.id) !== id(pair.vehicleId) || vehicle.status !== 'IN_SERVICE' || vehicle.region !== 'OVERSEAS' || vehicle.ownership !== 'OWNED'
|
||||
|| id(driver.id) !== id(pair.driverId) || driver.active !== true || driver.reviewStatus !== 'APPROVED'
|
||||
|| driver.details?.ownership !== 'OWNED' || driver.qualification?.canDispatch !== true || driver.qualification?.reason !== 'ELIGIBLE')
|
||||
throw Error('固定车辆司机状态或资格不符;不创建、启用或修复主档')
|
||||
Object.assign(proof, { vehicleId: id(vehicle.id), driverId: id(driver.id), reusedMasterData: true })
|
||||
}
|
||||
|
||||
/** 固定客户复用已配置主档;非固定生命周期场景才建立专用主档。 */
|
||||
export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) {
|
||||
if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓')
|
||||
const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`,
|
||||
@@ -90,6 +108,9 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
|
||||
version(before.recordVersion)
|
||||
if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0
|
||||
|| safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核')
|
||||
const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
|
||||
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
|
||||
if (context.options?.fixedCustomerId) await reuseFleetResources(context, module, proof, arrival)
|
||||
if (before.stage === 'LOADING') {
|
||||
proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' }
|
||||
await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前')
|
||||
@@ -97,6 +118,7 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
|
||||
}
|
||||
if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState))
|
||||
throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派')
|
||||
if (!context.options?.fixedCustomerId) {
|
||||
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
|
||||
@@ -104,9 +126,8 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
|
||||
}, '建立本轮TEST车辆主档及容量')
|
||||
proof.vehicleId = id(vehicle.id)
|
||||
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
|
||||
const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
|
||||
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
|
||||
await prepareQualifiedDriver(context, module, proof, arrival)
|
||||
}
|
||||
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
|
||||
publishLooseboardDemand: before.publishLooseboardDemand,
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
|
||||
@@ -222,17 +243,69 @@ async function verifyPreDepartureReassignment(context, module, proof) {
|
||||
state.status = 'REJECTED_WITHOUT_MUTATION'
|
||||
}
|
||||
|
||||
export const LOADING_PHOTO_SLOTS = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
|
||||
|
||||
export async function ensureFleetLoadingDocuments(context, module, proof, token) {
|
||||
const path = `${token ? PDA : DISPATCH}/${encodeURIComponent(proof.dispatchNo)}/loading-documents`
|
||||
const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token)
|
||||
let documents = await read()
|
||||
const initialStage = documents.stage
|
||||
const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId)))
|
||||
const validate = value => {
|
||||
if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage)
|
||||
|| value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配')
|
||||
}
|
||||
validate(documents)
|
||||
proof.loadingDocuments ??= { syntheticTestMaterials: true, uploads: [] }
|
||||
for (const slot of LOADING_PHOTO_SLOTS) {
|
||||
if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue
|
||||
if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力')
|
||||
const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`,
|
||||
name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' }
|
||||
if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发')
|
||||
proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.()
|
||||
let response
|
||||
try { response = await context.requestMultipart('POST', upload.path, { name: upload.name, type: 'image/png', content }, token) }
|
||||
catch (error) { upload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
|
||||
Object.assign(upload, { status: response?.http === 200 && response?.body?.code ? 'RESPONDED' : 'UNKNOWN',
|
||||
http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId })
|
||||
await context.checkpoint?.()
|
||||
const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS)
|
||||
if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车')
|
||||
const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) }
|
||||
const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token)
|
||||
validate(bound)
|
||||
check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
|
||||
const replay = await command(context, module, 'PUT', `${path}/${slot}`, body, '原键重放装车照片绑定', token)
|
||||
check(context, module, '照片绑定重放恢复原回执', JSON.stringify(replay) === JSON.stringify(bound))
|
||||
documents = await read(); validate(documents)
|
||||
check(context, module, '装车资料未齐不得提前发车', documents.stage === (initialStage === 'AFTER_LOADING' && documents.complete ? 'DELIVERING' : initialStage))
|
||||
check(context, module, '装车照片已持久化', documents.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
|
||||
}
|
||||
check(context, module, '五个装车照片框均已保存', documents.complete && complete(documents))
|
||||
proof.loadingDocuments.result = documents
|
||||
await context.checkpoint?.()
|
||||
return documents
|
||||
}
|
||||
|
||||
export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) {
|
||||
if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明')
|
||||
if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试')
|
||||
const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}`
|
||||
const preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken)
|
||||
let preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken)
|
||||
if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true
|
||||
|| preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`)
|
||||
if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker
|
||||
|| actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车')
|
||||
const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex }))
|
||||
await verifyPreDepartureReassignment(context, module, proof)
|
||||
await ensureFleetLoadingDocuments(context, module, proof, pdaToken)
|
||||
const refreshed = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '照片保存后回查装车资格', pdaToken)
|
||||
check(context, module, '资料保存不提前装车或改变实载清单', refreshed.dispatchNo === proof.dispatchNo
|
||||
&& refreshed.status === 'ACTIVE' && refreshed.stage === 'LOADING' && refreshed.pickingReconciled === true
|
||||
&& refreshed.loadingComplete?.allowed === true && refreshed.manifestHash === preview.manifestHash)
|
||||
preview = refreshed
|
||||
proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements
|
||||
proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion,
|
||||
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
|
||||
@@ -310,7 +383,7 @@ export async function finishFleetTrip(context, module, proof) {
|
||||
state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源')
|
||||
if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号')
|
||||
state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId,
|
||||
marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId }
|
||||
marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId, vehicle: proof.vehicle }
|
||||
await discoverTrip(context, module, state.nextDispatch)
|
||||
if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录')
|
||||
await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo)
|
||||
|
||||
@@ -16,7 +16,7 @@ test('manual loading-complete modules are optional and require a source report',
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module,
|
||||
'--manual-loading-complete-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }),
|
||||
/manual-loading-complete-source-report/)
|
||||
|
||||
@@ -34,7 +34,7 @@ test('fixture defaults to six orders while verifier requires an explicit report
|
||||
'--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json',
|
||||
'--manual-split-plan-id', '123',
|
||||
])
|
||||
assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify'])
|
||||
assert.deepEqual(verifyOptions.modules, ['merchant-qualification-fixture', 'manual-order-splitting-verify'])
|
||||
assert.equal(validateExecutionOptions(verifyOptions), verifyOptions)
|
||||
assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }),
|
||||
/manual-split-plan-id/)
|
||||
|
||||
@@ -15,7 +15,7 @@ test('manual PDA batch modules are optional and require a source report', () =>
|
||||
for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }),
|
||||
/manual-pda-batch-source-report/)
|
||||
|
||||
@@ -15,7 +15,7 @@ test('manual PDA-ready modules are optional and require a source report', () =>
|
||||
for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }),
|
||||
/manual-pda-source-report/)
|
||||
|
||||
@@ -15,7 +15,7 @@ test('manual preload modules are optional and require an explicit source report'
|
||||
for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) {
|
||||
const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin',
|
||||
'--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json'])
|
||||
assert.deepEqual(options.modules, [module])
|
||||
assert.deepEqual(options.modules, ['merchant-qualification-fixture', module])
|
||||
assert.equal(validateExecutionOptions(options), options)
|
||||
assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }),
|
||||
/manual-preload-source-report/)
|
||||
|
||||
@@ -86,7 +86,7 @@ test('fixed resource runner excludes lifecycle handlers without marking them pas
|
||||
test('both request entrypoints reject customer creation before network access', async () => {
|
||||
const runner = new AcceptanceRun({ environment: 'test' })
|
||||
for (const method of ['request', 'requestWithBody']) {
|
||||
for (const path of ['/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) {
|
||||
for (const path of ['/api/auth/register', '/api/auth/register/?source=test', '/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) {
|
||||
await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,12 +45,13 @@ test('draft and removal checks use actual IDs plus readback rather than snapshot
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => {
|
||||
test('fulfillment checks PDF signature, automatic documents and immutable stock around loading replay', async () => {
|
||||
const source = await read('phase5-overseas-fulfillment.mjs')
|
||||
for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
'replay.data?.replayed', 'JSON.stringify(replay.data.result) === JSON.stringify(result)',
|
||||
for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", 'await readAutomaticDispatchDelivery(',
|
||||
'value?.documents?.deliveries', 'value?.documents?.outbounds', 'await verifyAutomaticDeparture(',
|
||||
'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)'])
|
||||
assert.ok(source.includes(value), value)
|
||||
assert.doesNotMatch(source, /\/transfer-delivery|\/delivery-arrangement|\/depart`/)
|
||||
const helper = await read('fleet-trip-fulfillment.mjs')
|
||||
for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)',
|
||||
'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command',
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ test('offline orchestration model distinguishes processing replay recovery and c
|
||||
|
||||
test('POST complete is reliable acceptance only and returns PROCESSING/COMPLETING', () => {
|
||||
const start = loadingPickService.indexOf('public Task complete(')
|
||||
const end = loadingPickService.indexOf('/** Called only by the completion worker', start)
|
||||
const end = loadingPickService.indexOf('public void processCompletion(', start)
|
||||
assert.ok(start >= 0 && end > start)
|
||||
const block = loadingPickService.slice(start, end)
|
||||
assert.match(block, /LoadingCompletionCommandEntity/)
|
||||
assert.match(block, /acceptCompletion/)
|
||||
|
||||
@@ -1,6 +1,88 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { pickerFixtureSuffix } from './phase4-overseas-pda.mjs'
|
||||
import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff } from './phase4-overseas-pda.mjs'
|
||||
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
|
||||
|
||||
test('fixed PDA actors are leased separately and only logged out, never modified', async () => {
|
||||
const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' }))
|
||||
const calls = [], gaps = []
|
||||
const context = { options: { fixedCustomerId: '101', fixedPdaActors: actors },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path })
|
||||
let data = {}
|
||||
if (path.includes('/iam/users?')) data = { total: 2, records: actors.map(actor => ({ ...actor,
|
||||
overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] })) }
|
||||
if (path === '/api/auth/login') data = { accessToken: body.loginId, refreshToken: `refresh-${body.loginId}` }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
apiSuccess(_module, _name, result) { return result.body.data }, addStep() {},
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
const primary = await createPicker(context, 1, '91', { role: { id: '7' } })
|
||||
const secondary = await createPicker(context, 2, '91', { role: { id: '7' } })
|
||||
assert.notEqual(primary.membershipId, secondary.membershipId)
|
||||
assert.equal(await createPicker(context, 3, '91', { role: { id: '7' } }), null)
|
||||
const handoff = pickerSessionHandoff(primary)
|
||||
assert.equal(handoff.reused, true)
|
||||
assert.equal(handoff.password, undefined)
|
||||
await verifyPdaWarehouseRevocation(context, handoff)
|
||||
assert.equal(gaps.length, 1)
|
||||
await disablePicker(context, handoff, 1)
|
||||
await disablePicker(context, primary, 1)
|
||||
await disablePicker(context, secondary, 2)
|
||||
assert.equal(context.fixedPdaLeases.size, 0)
|
||||
assert.equal(calls.filter(call => call.path === '/api/auth/logout').length, 2)
|
||||
assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path)))
|
||||
})
|
||||
|
||||
test('missing fixed PDA pool stops before network or phone allocation', async () => {
|
||||
const context = { options: { fixedCustomerId: '101' }, addStep() {},
|
||||
request() { throw Error('unexpected HTTP') }, findAvailablePhone() { throw Error('unexpected allocation') } }
|
||||
assert.equal(await createPicker(context, 1, '91', { role: { id: '7' } }), null)
|
||||
})
|
||||
|
||||
test('failed PDA projection checks logout and retains leases on rejected or uncertain logout', async () => {
|
||||
for (const logout of ['success', 'rejected', 'throws']) {
|
||||
const actor = { membershipId: '11', warehouseId: '91', username: 'fixture-actor', password: 'fixture-only' }
|
||||
const steps = [], calls = []
|
||||
const context = { options: { fixedCustomerId: '101', fixedPdaActors: [actor] },
|
||||
async request(method, path) {
|
||||
calls.push(path)
|
||||
if (path.includes('/iam/users?')) return { body: { data: { records: [{ ...actor,
|
||||
overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] }] } } }
|
||||
if (path === '/api/auth/login') return { body: { data: { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } } }
|
||||
if (path === '/api/auth/logout' && logout === 'throws') throw Error('network failure')
|
||||
return { http: 200, body: { code: path === '/api/auth/logout' && logout === 'success' ? '200' : 'A0403' } }
|
||||
},
|
||||
apiSuccess(_module, _name, response) { return response.body.data },
|
||||
addStep(...args) { steps.push(args) },
|
||||
}
|
||||
assert.equal(await createPicker(context, 1, '91', { role: { id: '7' } }), null)
|
||||
assert.equal(calls.filter(path => path === '/api/auth/logout').length, 1)
|
||||
assert.equal(context.fixedPdaLeases.has('11'), logout !== 'success')
|
||||
assert.ok(steps.some(step => step[1].includes('核验失败') && step[3] === false))
|
||||
if (logout !== 'success') {
|
||||
assert.ok(steps.some(step => step[1].includes('退出固定PDA') && step[3] === false))
|
||||
const before = calls.length
|
||||
assert.equal(await createPicker(context, 2, '91', { role: { id: '7' } }), null)
|
||||
assert.equal(calls.length, before)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('logout exception does not prevent remaining fixed actors from being cleaned up', async () => {
|
||||
const calls = []
|
||||
const context = { fixedPdaLeases: new Set(['11', '12']), addStep() {},
|
||||
async request(_method, _path, body) {
|
||||
calls.push(body.refreshToken)
|
||||
if (body.refreshToken === 'first') throw Error('network failure')
|
||||
return { http: 200, body: { code: '200' } }
|
||||
} }
|
||||
await disablePicker(context, { membershipId: '11', refreshToken: 'first', reused: true }, 1)
|
||||
await disablePicker(context, { membershipId: '12', refreshToken: 'second', reused: true }, 2)
|
||||
assert.deepEqual(calls, ['first', 'second'])
|
||||
assert.deepEqual([...context.fixedPdaLeases], ['11'])
|
||||
})
|
||||
|
||||
test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => {
|
||||
const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' }
|
||||
|
||||
@@ -367,6 +367,13 @@ export async function run(context) {
|
||||
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
|
||||
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
let customsAgentId
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const existing = await loadSupplier(context, 'CUSTOMS')
|
||||
if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商')
|
||||
customsAgentId = id(existing.id)
|
||||
} else {
|
||||
const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳',
|
||||
legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000',
|
||||
@@ -374,15 +381,16 @@ export async function run(context) {
|
||||
businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null,
|
||||
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
|
||||
})
|
||||
const customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
|
||||
customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
|
||||
supplierCreateResponse, `marker=${context.marker}`, OPTIONS))
|
||||
}
|
||||
if (!customsAgentId) return
|
||||
const supplierDetailResponse = await context.request('GET',
|
||||
`/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`)
|
||||
const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse,
|
||||
id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE'
|
||||
&& customsAgent?.category === 'CUSTOMS' && customsAgent?.remark === context.marker,
|
||||
&& customsAgent?.category === 'CUSTOMS' && (context.options?.fixedCustomerId || customsAgent?.remark === context.marker),
|
||||
`customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS)
|
||||
if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE'
|
||||
|| customsAgent?.category !== 'CUSTOMS') return
|
||||
|
||||
@@ -42,6 +42,12 @@ function failure(response) {
|
||||
function containerRecord(value) { return value?.container }
|
||||
|
||||
async function ensureMatsonSupplier(context, records) {
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' })
|
||||
if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档')
|
||||
return supplier
|
||||
}
|
||||
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
|
||||
if (existing?.status === 'ACTIVE') return existing
|
||||
if (existing?.id) {
|
||||
|
||||
@@ -3,10 +3,40 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { run } from './phase4-overseas-business.mjs'
|
||||
import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
|
||||
|
||||
test('fixed partner warehouse is reused and never retired', async () => {
|
||||
const calls = []
|
||||
const context = { options: { fixedCustomerId: '123' }, marker: 'RUN', createdResources: {},
|
||||
async request(method, path) { calls.push({ method, path }); return { body: { data: [{ warehouseId: '456' }] } } },
|
||||
apiSuccess(_module, _name, response) { return response.body.data },
|
||||
}
|
||||
const warehouse = await createPartnerWarehouse(context)
|
||||
assert.equal(warehouse.id, '456')
|
||||
await retirePartnerWarehouse(context, warehouse)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
|
||||
|
||||
test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => {
|
||||
const context = fixture(), request = context.request.bind(context), pages = []
|
||||
context.options = { fixedCustomerId: '10001' }
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (path.startsWith('/api/platform/v1/iam/tenants?')) {
|
||||
pages.push(path)
|
||||
if (path.includes('page=1&')) return response({ ...page([{ id: '9999', status: 'ACTIVE' }]), size: 200, total: 201, pages: 2 })
|
||||
return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }]))
|
||||
}
|
||||
return request(method, path, ...args)
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(pages.length, 2)
|
||||
assert.ok(context.createdResources.phase4OverseasBusiness.purchase)
|
||||
assert.ok(context.calls.filter(call => call.path.includes('/warehouse-admin')).every(call => call.method === 'GET'))
|
||||
})
|
||||
function response(data, code = '200') {
|
||||
return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data },
|
||||
requestId: `request-${code}`, traceId: `trace-${code}` }
|
||||
|
||||
@@ -64,7 +64,15 @@ function partnerWarehousePayload(context) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createPartnerWarehouse(context) {
|
||||
export async function createPartnerWarehouse(context) {
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const result = await overseas(context, 'GET', '查询可复用采购合作仓', `${BASE}/overseas-procurements/warehouse-options`)
|
||||
const candidate = Array.isArray(result.data) ? result.data.find(item => longId(item?.warehouseId)) : null
|
||||
if (!candidate) return blocker(context, '缺少可复用采购合作仓', result.response, '不自动创建仓库')
|
||||
const resource = { marker: context.marker, id: id(candidate.warehouseId), reused: true, finalStatus: 'ACTIVE', retained: true }
|
||||
ownedRegistry(context).partnerWarehouse = resource
|
||||
return resource
|
||||
}
|
||||
const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', partnerWarehousePayload(context))
|
||||
const warehouseId = id(created.data)
|
||||
const valid = longId(warehouseId)
|
||||
@@ -140,7 +148,14 @@ async function prerequisites(context, session) {
|
||||
if (!activeCustomers.length) return blocker(context, '海外业务写验收 ACTIVE 客户前置', tenants.response,
|
||||
'IAM 未返回真实 ACTIVE 客户,禁止跳过完整生命周期')
|
||||
const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId)
|
||||
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
|
||||
let activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
|
||||
const customerPageSize = Number(tenants.data?.size || 200)
|
||||
for (let page = 2; !activeCustomer && (page - 1) * customerPageSize < Number(tenants.data?.total); page += 1) {
|
||||
const next = await domesticGet(context, '分页查询固定客户', `/api/platform/v1/iam/tenants?page=${page}&size=200&status=ACTIVE`)
|
||||
assertPage(context, '固定客户候选', next)
|
||||
activeCustomer = records(next.data).find(item => id(item?.id) === fixtureCustomerId && item.status === 'ACTIVE')
|
||||
if (!records(next.data).length) break
|
||||
}
|
||||
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
|
||||
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
|
||||
const token = await loginMerchant(context, activeCustomer, session)
|
||||
@@ -490,8 +505,8 @@ async function looseboardFlow(context, customerId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function retirePartnerWarehouse(context, resource) {
|
||||
if (!resource?.id) return
|
||||
export async function retirePartnerWarehouse(context, resource) {
|
||||
if (!resource?.id || resource.reused) return
|
||||
const retired = await context.request('PATCH',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(resource.id)}/status?tenantId=0&status=INACTIVE`)
|
||||
const passed = retired?.http === 200 && codeOf(retired) === '200'
|
||||
|
||||
@@ -7,6 +7,21 @@ import { run, selectCurrentInboundPlan } from './phase4-overseas-finance-cross-r
|
||||
|
||||
const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('current plan on second page is used instead of historical first-page plans', async () => {
|
||||
const context = fixture(), request = context.request.bind(context), pages = []
|
||||
context.request = async (method, path, ...args) => {
|
||||
if (path.startsWith('/api/platform/v1/last-leg/inbound-plans?')) {
|
||||
pages.push(path)
|
||||
if (path.includes('page=1&')) return response({ records: [{ planId: '9999', planNo: 'OLD', customerId: '2001' }], size: 200, total: 201 })
|
||||
}
|
||||
return request(method, path, ...args)
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(pages.length, 2)
|
||||
assert.ok(context.steps.find(step => step.name === '海外入仓费用关联真实计划')?.passed)
|
||||
assert.equal(context.calls.some(call => call.method === 'POST' && call.body?.businessId === '9999'), false)
|
||||
})
|
||||
|
||||
test('inbound fees cannot select historical plans or another customer under a fixed account', () => {
|
||||
const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' }
|
||||
const old = { planId: '3999', planNo: 'OLD', customerId: '2001' }
|
||||
|
||||
@@ -81,9 +81,17 @@ async function resolveFixtures(context) {
|
||||
const inboundCustomerId = id(pda?.customerId)
|
||||
let inbound = null
|
||||
if (longId(inboundCustomerId)) {
|
||||
const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
|
||||
let listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
|
||||
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
|
||||
const plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
|
||||
let plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
|
||||
const total = Number(listed.data?.total || 0)
|
||||
const pageSize = Number(listed.data?.size || 200)
|
||||
for (let page = 2; !plan && (page - 1) * pageSize < total; page += 1) {
|
||||
listed = await overseas(context, 'GET', '分页查询本轮入仓计划',
|
||||
`/api/platform/v1/last-leg/inbound-plans?page=${page}&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
|
||||
plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
|
||||
if (!rows(listed.data).length) break
|
||||
}
|
||||
context.assert(MODULE, '海外入仓费用关联真实计划', listed.response,
|
||||
Boolean(plan) && longId(plan?.planId || plan?.id),
|
||||
`planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
|
||||
|
||||
@@ -5,7 +5,24 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs'
|
||||
import { run, remainingRelabelItem, selectPhysicalRelabelSources, fundRelabelWallet, RELABEL_CHARGE_AMOUNT } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
test('fixed customer relabel wallet only reads existing available funds', async () => {
|
||||
assert.equal(RELABEL_CHARGE_AMOUNT, 0.01)
|
||||
for (const availableBalance of ['0.01', '0.00', undefined]) {
|
||||
const calls = [], steps = []
|
||||
const context = { options: { fixedCustomerId: '123' },
|
||||
async request(method, path) {
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: [{ customerId: '123', accountVersion: 1, actualBalance: 100, availableBalance }] } }
|
||||
},
|
||||
addStep(...args) { steps.push(args) },
|
||||
}
|
||||
assert.equal(await fundRelabelWallet(context, '123'), availableBalance === '0.01')
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
}
|
||||
})
|
||||
|
||||
test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => {
|
||||
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [
|
||||
@@ -69,7 +86,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
]) assert.ok(source.includes(endpoint), endpoint)
|
||||
for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409',
|
||||
'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED',
|
||||
'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:',
|
||||
'chargeAmount: RELABEL_CHARGE_AMOUNT', 'cartonMark:', 'fbaCartonMark:',
|
||||
'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:',
|
||||
'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map',
|
||||
'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
|
||||
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-pda-operations'
|
||||
export const RELABEL_CHARGE_AMOUNT = 0.01
|
||||
const SCENARIO = 'phase4-overseas-pda-p2-p5'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
||||
@@ -63,7 +64,7 @@ async function poll(action, predicate, attempts = 60) {
|
||||
return { result, attempts }
|
||||
}
|
||||
|
||||
async function fundRelabelWallet(context, customerId) {
|
||||
export async function fundRelabelWallet(context, customerId) {
|
||||
const accountResponse = await context.request('GET',
|
||||
`/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`)
|
||||
const account = success(accountResponse)
|
||||
@@ -71,6 +72,13 @@ async function fundRelabelWallet(context, customerId) {
|
||||
context.addStep(MODULE, 'P5读取本轮客户钱包版本', accountResponse,
|
||||
Boolean(account) && Number.isInteger(Number(account.accountVersion)), trace(accountResponse), OPTIONS)
|
||||
if (!account) return false
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const sufficient = account.availableBalance != null && Number.isFinite(Number(account.availableBalance))
|
||||
&& Number(account.availableBalance) >= RELABEL_CHARGE_AMOUNT
|
||||
context.addStep(MODULE, '复用已有可用余额;不足时禁止自动充值', accountResponse,
|
||||
sufficient, `availableBalance=${account.availableBalance ?? 'missing'}`, OPTIONS)
|
||||
return sufficient
|
||||
}
|
||||
const recharge = await context.request('POST',
|
||||
`/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/adjustments`, {
|
||||
type: 'RECHARGE', amount: 1, occurredDate: new Date().toISOString().slice(0, 10),
|
||||
@@ -415,7 +423,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const createBody = {
|
||||
customerOrderNo: String(customerOrderNo).slice(0, 128), operationWarehouseId: id(fixture.overseasWarehouseId),
|
||||
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
|
||||
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker,
|
||||
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: RELABEL_CHARGE_AMOUNT, remark: context.marker,
|
||||
idempotencyKey: key(context, 'RELABEL-CREATE'),
|
||||
deliveryMethod: 'TRUCK', printShippingLabel: false,
|
||||
items: sourceLines.map(({ source, position, packageCount }, index) => {
|
||||
|
||||
@@ -316,7 +316,47 @@ export async function resolvePickerAccess(context) {
|
||||
return { role, operate }
|
||||
}
|
||||
|
||||
async function reusePicker(context, index, overseasWarehouseId, access) {
|
||||
const actors = context.options.fixedPdaActors
|
||||
context.fixedPdaLeases ??= new Set()
|
||||
const actor = Array.isArray(actors) && actors.find(item => id(item?.warehouseId) === id(overseasWarehouseId)
|
||||
&& longId(item.membershipId) && nonBlank(item.username) && nonBlank(item.password)
|
||||
&& !context.fixedPdaLeases.has(id(item.membershipId)))
|
||||
if (!actor) {
|
||||
context.addStep(MODULE, `固定PDA操作员${index}前置`, null, false, '缺少该仓未占用的固定账号,禁止自动创建', OPTIONS)
|
||||
return null
|
||||
}
|
||||
let token, refreshToken
|
||||
try {
|
||||
let user
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`)
|
||||
const data = context.apiSuccess(MODULE, '核对固定PDA账号', result, '', OPTIONS)
|
||||
user = data?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username)
|
||||
if (user || !data?.records?.length || page * Number(data.size || 100) >= Number(data.total || 0)) break
|
||||
}
|
||||
if (user?.status !== 'ACTIVE' || id(user.tenantId) !== '0'
|
||||
|| id(user.overseasWarehouseId) !== id(overseasWarehouseId)
|
||||
|| !user.roleIds?.map(id).includes(id(access.role.id))) throw Error('固定PDA账号状态、角色或仓绑定不匹配')
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '')
|
||||
const session = context.apiSuccess(MODULE, '登录固定PDA账号', login, '', OPTIONS)
|
||||
token = session?.accessToken; refreshToken = session?.refreshToken
|
||||
if (token || refreshToken) context.fixedPdaLeases.add(id(actor.membershipId))
|
||||
if (!token || !refreshToken) throw Error('固定PDA登录未返回完整会话')
|
||||
const projection = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
if (!success(projection) && !notFound(projection)) throw Error('固定PDA作业权限检查失败')
|
||||
return { membershipId: id(actor.membershipId), username: actor.username, token, refreshToken, reused: true }
|
||||
} catch (error) {
|
||||
if (token && refreshToken) await disablePicker(context, {
|
||||
membershipId: id(actor.membershipId), token, refreshToken, reused: true,
|
||||
}, index)
|
||||
context.addStep(MODULE, `固定PDA操作员${index}核验失败`, null, false, error.message, OPTIONS)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPicker(context, index, overseasWarehouseId, access) {
|
||||
if (context.options?.fixedCustomerId) return reusePicker(context, index, overseasWarehouseId, access)
|
||||
const role = access.role
|
||||
const suffix = pickerFixtureSuffix(context)
|
||||
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
|
||||
@@ -359,13 +399,26 @@ export async function createPicker(context, index, overseasWarehouseId, access)
|
||||
|
||||
export function pickerSessionHandoff(picker) {
|
||||
if (!picker) return null
|
||||
const { membershipId, username, token, refreshToken, password, userUpdate } = picker
|
||||
return { membershipId, username, token, refreshToken, password,
|
||||
const { membershipId, username, token, refreshToken, password, userUpdate, reused } = picker
|
||||
return { membershipId, username, token, refreshToken, password, reused,
|
||||
userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined }
|
||||
}
|
||||
|
||||
export async function disablePicker(context, picker, index) {
|
||||
if (!longId(picker?.membershipId)) return
|
||||
if (picker.reused) {
|
||||
if (!context.fixedPdaLeases?.has(id(picker.membershipId))) return
|
||||
let response
|
||||
try {
|
||||
response = await context.request('POST', '/api/auth/logout', { refreshToken: picker.refreshToken }, picker.token)
|
||||
} catch {
|
||||
context.addStep(MODULE, `退出固定PDA操作员${index}会话失败`, null, false, '退出请求异常;保留账号占用,继续清理其他会话', OPTIONS)
|
||||
return
|
||||
}
|
||||
context.addStep(MODULE, `退出固定PDA操作员${index}会话,不停用账号`, response, success(response), trace(response), OPTIONS)
|
||||
if (success(response)) context.fixedPdaLeases.delete(id(picker.membershipId))
|
||||
return
|
||||
}
|
||||
const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' })
|
||||
const disabled = success(response) && response.body?.data?.status === 'DISABLED'
|
||||
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
||||
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
|
||||
dispatchDestinationConservation, pickAll, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -64,21 +64,20 @@ test('phase-five fulfillment is a registered default HTTP module after the real
|
||||
|
||||
test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => {
|
||||
for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
|
||||
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", '/transfer-delivery', '/delivery-arrangement',
|
||||
'cargoHash: value.cargoHash', 'loadingCompletionHash: value.loadingCompletionHash',
|
||||
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'value?.documents?.deliveries', 'value?.documents?.outbounds',
|
||||
'recordVersion: version(detail.data.recordVersion)', 'positionId: id',
|
||||
'departed.data.result?.shippedPackages', 'outboundRef.outboundNo']) {
|
||||
'ready.data.counts?.shippedPackageCount', 'outboundRef.outboundNo']) {
|
||||
if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/)
|
||||
else assert.ok(source.includes(token), token)
|
||||
}
|
||||
assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/)
|
||||
assert.match(source, /data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'/)
|
||||
assert.match(source, /departed.data.stage === 'DELIVERING'/)
|
||||
assert.match(source, /data.status === 'ACTIVE' && data.stage === 'DELIVERING'/)
|
||||
assert.doesNotMatch(source, /\/transfer-delivery|\/delivery-arrangement|\/depart`/)
|
||||
const run = source.slice(source.indexOf('export async function run'))
|
||||
assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll('))
|
||||
assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading('))
|
||||
assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await arrangeDispatchDelivery('))
|
||||
assert.ok(run.indexOf('await arrangeDispatchDelivery(') < run.indexOf('await verifyDepartureReadiness('))
|
||||
assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await readAutomaticDispatchDelivery('))
|
||||
assert.ok(run.indexOf('await readAutomaticDispatchDelivery(') < run.indexOf('await verifyAutomaticDeparture('))
|
||||
assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip('))
|
||||
assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest'))
|
||||
})
|
||||
@@ -133,9 +132,8 @@ test('phase-five module uses marker-owned fixtures and always disables its tempo
|
||||
})
|
||||
|
||||
test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => {
|
||||
assert.match(source, /idempotencyKey: key\(context, 'TRANSFER', dispatchNo\)/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART', dispatchNo\)/)
|
||||
assert.doesNotMatch(source, /DISPATCH-DEPART/)
|
||||
assert.match(source, /addCoverageGap\(MODULE, SCENARIO/)
|
||||
assert.match(source, /deploymentBlocker: true/)
|
||||
})
|
||||
@@ -497,13 +495,13 @@ test('inventory evidence captures zero drift independently for every balance buc
|
||||
})
|
||||
|
||||
test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => {
|
||||
const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993',
|
||||
pickingReconciled: true, actions: { DEPART: { allowed: true } } }
|
||||
for (const change of [null, { stage: 'LOADING' }, { stage: 'DELIVERING' }, { status: 'CANCELLED' },
|
||||
const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254740993',
|
||||
pickingReconciled: true, counts: { packageCount: 2, shippedPackageCount: 2 } }
|
||||
for (const change of [null, { stage: 'LOADING' }, { stage: 'AFTER_LOADING' }, { status: 'CANCELLED' },
|
||||
{ dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false },
|
||||
{ actions: { DEPART: { allowed: false } } }]) {
|
||||
{ counts: { packageCount: 2, shippedPackageCount: 1 } }]) {
|
||||
const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change }))
|
||||
assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), change == null)
|
||||
assert.equal(Boolean(await verifyAutomaticDeparture(context, 'PDA', 'DS-1')), change == null)
|
||||
assert.equal(context.calls.length, 2)
|
||||
assert.equal(context.calls.some(call => call.method !== 'GET'), false)
|
||||
}
|
||||
@@ -787,50 +785,56 @@ test('native PDA scans use exact current versions including zero, immutable repl
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
})
|
||||
|
||||
function nativeArrangementPreview(recordVersion = '0') {
|
||||
return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'CARGO',
|
||||
loadingCompletionHash: 'LOAD', arrange: { allowed: true }, groups: ['100', '200'].map(customerId => ({
|
||||
groupKey: `GROUP-${customerId}`, customerId, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' },
|
||||
groupCargoHash: `GROUP-HASH-${customerId}`, externalFreightHash: 'FREIGHT', feeCoverageCargoHash: 'COVERAGE',
|
||||
requiredServices: ['DELIVERY_FEE'], feeSources: [], cargo: [{ customerId, packages: 1 }], blockReasons: [] })) }
|
||||
function automaticDispatch(recordVersion = '0') {
|
||||
return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true,
|
||||
counts: { packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 2 },
|
||||
documents: { outbounds: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, status: 'OUTBOUNDED', packageCount: 1 })),
|
||||
deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, deliveryNo: `DL-${customerId}`,
|
||||
destination: { key: 'FBA:ONT8' }, status: 'DELIVERING', packageCount: 1 })) } }
|
||||
}
|
||||
|
||||
test('native arrangement carries frozen group and cost hashes, preserves all customer outbounds, and replays the exact original command', async () => {
|
||||
test('automatic delivery reader preserves customer grouping and performs no writes', async () => {
|
||||
for (const recordVersion of ['0', '9007199254740993']) {
|
||||
const preview = nativeArrangementPreview(recordVersion)
|
||||
let original, result
|
||||
const context = contextFor((method, path, body) => {
|
||||
if (method === 'GET') { assert.match(path, /delivery-arrangement$/); return ok(preview) }
|
||||
assert.match(path, /transfer-delivery$/)
|
||||
if (original) { assert.deepEqual(body, original); return ok({ ...result, replayed: true }) }
|
||||
original = structuredClone(body)
|
||||
assert.equal(body.recordVersion, recordVersion)
|
||||
assert.equal(body.cargoHash, 'CARGO'); assert.equal(body.loadingCompletionHash, 'LOAD')
|
||||
assert.deepEqual(body.groups.map(row => row.groupKey), ['GROUP-100', 'GROUP-200'])
|
||||
assert.ok(body.groups.every(row => row.externalFreightHash === 'FREIGHT' && row.feeCoverageCargoHash === 'COVERAGE'))
|
||||
result = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740994',
|
||||
result: { dispatchNo: 'DS-1', cargoHash: 'CARGO', outbounds: ['100', '200'].map(customerId => ({
|
||||
customerId, outboundNo: `OB-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 1 })),
|
||||
deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`,
|
||||
deliveryNo: `DL-${customerId}`, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, status: 'WAIT_DELIVERY', packageCount: 1 })) } }
|
||||
return ok(result)
|
||||
})
|
||||
const arranged = await arrangeDispatchDelivery(context, 'DS-1')
|
||||
assert.deepEqual(arranged.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200'])
|
||||
assert.equal(context.calls.filter(call => call.method === 'POST').length, 2)
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
const current = automaticDispatch(recordVersion)
|
||||
const context = contextFor((method, path) => { assert.equal(method, 'GET'); assert.ok(path.endsWith('/DS-1')); return ok(current) })
|
||||
const result = await readAutomaticDispatchDelivery(context, 'DS-1')
|
||||
assert.equal(result.data.recordVersion, recordVersion)
|
||||
assert.deepEqual(result.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200'])
|
||||
assert.equal(context.calls.length, 1)
|
||||
}
|
||||
})
|
||||
test('automatic delivery reader rejects incomplete departure and customer or quantity drift', async () => {
|
||||
for (const mutate of [v => { v.stage = 'AFTER_LOADING' }, v => { v.status = 'CANCELLED' },
|
||||
v => { v.documents.deliveries[0].customerId = '999' }, v => { v.documents.outbounds[0].packageCount = 2 },
|
||||
v => { v.documents.deliveries = [] }, v => { v.documents.deliveries[1].deliveryNo = 'DL-100' },
|
||||
v => { v.counts.shippedPackageCount = 1 }, v => { v.documents.outbounds[0].status = 'WAIT_OUTBOUND' }]) {
|
||||
const current = automaticDispatch(); mutate(current)
|
||||
const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) })
|
||||
assert.equal(await readAutomaticDispatchDelivery(context, 'DS-1'), null)
|
||||
assert.ok(context.assertions.some(row => !row.passed))
|
||||
}
|
||||
})
|
||||
|
||||
test('arrangement cannot run before loading or with missing hashes, groups or server gate', async () => {
|
||||
for (const mutate of [value => { value.stage = 'LOADING' }, value => { value.status = 'CANCELLED' },
|
||||
value => { value.cargoHash = '' }, value => { value.loadingCompletionHash = '' },
|
||||
value => { value.groups = [] }, value => { value.arrange.allowed = false },
|
||||
value => { value.groups[0].blockReasons = ['MISSING_FEE'] }]) {
|
||||
const preview = nativeArrangementPreview(); mutate(preview)
|
||||
const context = contextFor((method) => { assert.equal(method, 'GET'); return ok(preview) })
|
||||
assert.equal(await arrangeDispatchDelivery(context, 'DS-1'), null)
|
||||
assert.ok(context.assertions.some(row => !row.passed))
|
||||
test('automatic delivery cannot hide duplicated customer or destination groups behind conserved totals', async () => {
|
||||
for (const sameOutbound of [false, true]) {
|
||||
const current = automaticDispatch()
|
||||
current.documents.outbounds[1].customerId = '100'
|
||||
current.documents.deliveries[1].customerId = '100'
|
||||
if (sameOutbound) {
|
||||
current.documents.outbounds.pop()
|
||||
current.documents.outbounds[0].packageCount = 2
|
||||
current.documents.deliveries[1].outboundNo = 'OB-100'
|
||||
} else current.documents.deliveries[1].destination.key = 'FBA:ONT9'
|
||||
const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) })
|
||||
assert.equal(await readAutomaticDispatchDelivery(context, 'DS-1'), null)
|
||||
}
|
||||
})
|
||||
|
||||
test('automatic delivery accepts canonical Long quantities and rejects coercible non-quantities', async () => {
|
||||
for (const quantity of ['1', true, '01', '1e0', ' 1 ', '9007199254740993']) {
|
||||
const current = automaticDispatch()
|
||||
current.documents.deliveries[0].packageCount = quantity
|
||||
const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) })
|
||||
assert.equal(Boolean(await readAutomaticDispatchDelivery(context, 'DS-1')), quantity === '1')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -133,14 +133,6 @@ async function merchant(context, token, method, name, path, body = undefined) {
|
||||
return { response, data }
|
||||
}
|
||||
|
||||
function transferDeliveries(data) {
|
||||
return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
|
||||
}
|
||||
|
||||
function deliveryNumbers(data) {
|
||||
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
|
||||
}
|
||||
|
||||
export function dispatchDestinationConservation(detail, fixture, deliveries = null) {
|
||||
const codes = values => values.map(value => id(value).trim().toUpperCase())
|
||||
const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank)
|
||||
@@ -858,7 +850,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI
|
||||
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
|
||||
if (!await pickAll(context, token, relabel.dispatchNo)) return
|
||||
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
|
||||
const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo)
|
||||
const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo)
|
||||
if (!transferred) return
|
||||
const customerId = id(relabel.customerId)
|
||||
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
|
||||
@@ -869,7 +861,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI
|
||||
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, deliveryNos: deliveryNumbers(transferred.data) })
|
||||
Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
|
||||
}
|
||||
|
||||
async function loginMerchant(context, customerId) {
|
||||
@@ -909,17 +901,18 @@ function fixture(context) {
|
||||
return { dispatch, receiving }
|
||||
}
|
||||
|
||||
export async function verifyDepartureReadiness(context, token, dispatchNo) {
|
||||
const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁',
|
||||
export async function verifyAutomaticDeparture(context, token, dispatchNo) {
|
||||
const ready = await pda(context, token, 'GET', '回查PDA自动发车结果',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段',
|
||||
const dispatch = await platform(context, 'GET', '独立回查平台自动发车结果',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const passed = success(ready.response) && success(dispatch.response)
|
||||
&& [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo
|
||||
&& data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'
|
||||
&& data.pickingReconciled === true && data.actions?.DEPART?.allowed === true)
|
||||
&& data.status === 'ACTIVE' && data.stage === 'DELIVERING'
|
||||
&& data.pickingReconciled === true && Number(data.counts?.packageCount) > 0
|
||||
&& Number(data.counts?.shippedPackageCount) === Number(data.counts?.packageCount))
|
||||
&& String(ready.data.recordVersion) === String(dispatch.data.recordVersion)
|
||||
context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response,
|
||||
context.assert(MODULE, '双端自动发车阶段与版本一致', ready.response,
|
||||
passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS)
|
||||
if (passed) version(ready.data.recordVersion)
|
||||
return passed ? ready : null
|
||||
@@ -983,56 +976,30 @@ export async function pickAll(context, token, dispatchNo) {
|
||||
return passed
|
||||
}
|
||||
|
||||
export async function arrangeDispatchDelivery(context, dispatchNo) {
|
||||
const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`)
|
||||
const value = preview.data
|
||||
if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE'
|
||||
|| value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true
|
||||
|| !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash)
|
||||
|| !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey)
|
||||
|| !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key)
|
||||
|| !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) {
|
||||
blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response)
|
||||
return null
|
||||
}
|
||||
const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo),
|
||||
cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash,
|
||||
groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash,
|
||||
plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64),
|
||||
carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
|
||||
vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null,
|
||||
remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash,
|
||||
feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [],
|
||||
fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service,
|
||||
direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) }
|
||||
const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body)
|
||||
if (!success(transferred.response)) return null
|
||||
const result = transferred.data?.result
|
||||
const deliveries = transferDeliveries(transferred.data)
|
||||
const outbounds = result?.outbounds
|
||||
const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort()
|
||||
const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort()
|
||||
const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE'
|
||||
&& transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo
|
||||
&& result.cargoHash === body.cargoHash && deliveries.length === value.groups.length
|
||||
export async function readAutomaticDispatchDelivery(context, dispatchNo) {
|
||||
const current = await platform(context, 'GET', '回查装车后自动派送单据', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const value = current.data, deliveries = value?.documents?.deliveries, outbounds = value?.documents?.outbounds
|
||||
const positive = count => (typeof count === 'number' || typeof count === 'string')
|
||||
&& /^[1-9][0-9]*$/.test(String(count)) && Number.isSafeInteger(Number(count))
|
||||
const passed = success(current.response) && value?.dispatchNo === dispatchNo && value.status === 'ACTIVE'
|
||||
&& value.stage === 'DELIVERING' && value.pickingReconciled === true && positive(value.counts?.packageCount)
|
||||
&& positive(value.counts.pickedPackageCount) && positive(value.counts.shippedPackageCount)
|
||||
&& Number(value.counts.pickedPackageCount) === Number(value.counts.packageCount)
|
||||
&& Number(value.counts.shippedPackageCount) === Number(value.counts.packageCount)
|
||||
&& Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0
|
||||
&& new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length
|
||||
&& JSON.stringify(customerGroups) === JSON.stringify(actualGroups)
|
||||
&& Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size
|
||||
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND'
|
||||
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId))
|
||||
&& new Set(outbounds.map(row => row.outboundNo)).size === outbounds.length
|
||||
&& new Set(outbounds.map(row => id(row.customerId))).size === outbounds.length
|
||||
&& new Set(deliveries.map(row => JSON.stringify([id(row.customerId), row.destination?.key]))).size === deliveries.length
|
||||
&& deliveries.every(row => nonBlank(row.deliveryNo) && nonBlank(id(row.customerId)) && nonBlank(row.destination?.key)
|
||||
&& row.status === 'DELIVERING' && positive(row.packageCount)
|
||||
&& outbounds.some(outbound => outbound.outboundNo === row.outboundNo && id(outbound.customerId) === id(row.customerId)))
|
||||
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'OUTBOUNDED' && positive(row.packageCount)
|
||||
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount))
|
||||
&& deliveries.every(row => row.status === 'WAIT_DELIVERY')
|
||||
context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS)
|
||||
&& deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(value.counts.packageCount)
|
||||
context.assert(MODULE, '自动派送客户归属、单据状态和实发数量守恒', current.response, passed, trace(current.response), OPTIONS)
|
||||
if (!passed) return null
|
||||
const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body)
|
||||
const stable = success(replay.response) && replay.data?.replayed === true
|
||||
&& String(replay.data.recordVersion) === String(transferred.data.recordVersion)
|
||||
&& JSON.stringify(replay.data.result) === JSON.stringify(result)
|
||||
context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS)
|
||||
if (!stable) return null
|
||||
return { ...transferred, preview: value, outbounds, deliveries }
|
||||
return { ...current, outbounds, deliveries }
|
||||
}
|
||||
|
||||
/** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */
|
||||
@@ -1146,40 +1113,26 @@ export async function run(context) {
|
||||
const primaryInventory = proofs.find(proof => proof.customerId === customerId)
|
||||
if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation }
|
||||
await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements)
|
||||
transferred = await arrangeDispatchDelivery(context, dispatchNo)
|
||||
transferred = await readAutomaticDispatchDelivery(context, dispatchNo)
|
||||
if (!transferred) return
|
||||
deliveries = transferred.deliveries; outbounds = transferred.outbounds
|
||||
routeProof.deliveries = deliveries
|
||||
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
|
||||
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
|
||||
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
|
||||
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
|
||||
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
|
||||
if (!ready) return
|
||||
const departBody = { recordVersion: version(ready.data.recordVersion),
|
||||
idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` }
|
||||
const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
||||
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
|
||||
const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo
|
||||
&& departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING'
|
||||
&& Number(departed.data.result?.shippedPackages) === expectedPackages
|
||||
&& JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort())
|
||||
context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS)
|
||||
if (!departedCorrectly) return
|
||||
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
||||
const replayStable = success(departReplay.response) && departReplay.data?.replayed === true
|
||||
&& String(departReplay.data.recordVersion) === String(departed.data.recordVersion)
|
||||
&& JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result)
|
||||
context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS)
|
||||
if (!replayStable) return
|
||||
if (Number(ready.data.counts?.shippedPackageCount) !== expectedPackages) {
|
||||
blocker(context, '自动发车数量', '实发数量与本轮扫码来源不一致', ready.response); return
|
||||
}
|
||||
for (const proof of proofs) {
|
||||
proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes,
|
||||
`客户${proof.customerId}发车后库存`, proof.sourceBalanceIds)
|
||||
proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo)
|
||||
const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances)
|
||||
&& sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)
|
||||
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS)
|
||||
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS)
|
||||
if (!unchanged) return
|
||||
}
|
||||
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。
|
||||
|
||||
@@ -621,11 +621,11 @@ function nativeFragmentFixture() {
|
||||
async confirmFleetLoading(current, module, proof, token, actual) {
|
||||
events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement)
|
||||
if (!actual) throw new Error('实际测量缺少有效palletSpaces')
|
||||
return '9007199254740998'
|
||||
departed = true; proof.loadingCommand = { replay: { replayed: true } }; return '9007199254740998'
|
||||
},
|
||||
async arrangeDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery],
|
||||
outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { result: { deliveries: [delivery] } } } },
|
||||
async verifyDepartureReadiness() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999' } } },
|
||||
async readAutomaticDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery],
|
||||
outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { documents: { deliveries: [delivery] } } } },
|
||||
async verifyAutomaticDeparture() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999', stage: 'DELIVERING', counts: { shippedPackageCount: 1 } } } },
|
||||
async saveReceiptAndComplete(current, actual, customerId, index) {
|
||||
events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0)
|
||||
signed = true
|
||||
@@ -642,11 +642,11 @@ test('F40 fragment uses native creation, reviews, real PDA scan, explicit loadin
|
||||
assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003')
|
||||
assert.equal(result.signedAt, '2026-10-19T01:00:00')
|
||||
assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick',
|
||||
'loading-complete', 'arrange', 'read-current-depart-version', 'depart', 'depart', 'receipt', 'normal-completed', 'finish'])
|
||||
'loading-complete', 'arrange', 'read-current-depart-version', 'receipt', 'normal-completed', 'finish'])
|
||||
assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false)
|
||||
assert.equal(f.calls.some(call => call.method === 'PUT'), false)
|
||||
assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED')
|
||||
assert.equal(f.rootProof.dispatches[0].departureReplay.replayed, true)
|
||||
assert.equal(f.rootProof.dispatches[0].loadingReplay.replayed, true)
|
||||
assert.equal(f.context.steps.every(step => step.passed), true)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
|
||||
import { stableLoadedCartonMarks } from './phase3-loading.mjs'
|
||||
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
|
||||
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
|
||||
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs'
|
||||
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
||||
@@ -484,11 +484,11 @@ async function fragment(context, source, rootOrderId, sequence) {
|
||||
shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt,
|
||||
palletLabelManifest, rootOrderId }
|
||||
}
|
||||
const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery,
|
||||
verifyDepartureReadiness, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext }
|
||||
const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery,
|
||||
verifyAutomaticDeparture, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext }
|
||||
|
||||
export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) {
|
||||
const { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness,
|
||||
const { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, verifyAutomaticDeparture,
|
||||
saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides }
|
||||
const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId)
|
||||
context.marker = `${context.context.f40BaseMarker}-SEND-${index}`
|
||||
@@ -529,8 +529,8 @@ export async function sendFragment(context, producer, picker, merchantToken, ind
|
||||
dispatchProof.action = 'LOADING_COMPLETE'
|
||||
const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId)
|
||||
await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements)
|
||||
dispatchProof.action = 'ARRANGE_DELIVERY'
|
||||
const transfer = await arrangeDispatchDelivery(context, dispatchNo)
|
||||
dispatchProof.action = 'READ_AUTOMATIC_DELIVERY'
|
||||
const transfer = await readAutomaticDispatchDelivery(context, dispatchNo)
|
||||
check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`)
|
||||
const deliveries = transfer.deliveries, outbounds = transfer.outbounds
|
||||
check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1
|
||||
@@ -538,27 +538,18 @@ export async function sendFragment(context, producer, picker, merchantToken, ind
|
||||
&& deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1
|
||||
&& deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination')
|
||||
const outboundNo = outbounds[0].outboundNo
|
||||
dispatchProof.arrangement = transfer.data?.result; dispatchProof.outboundNo = outboundNo
|
||||
dispatchProof.arrangement = transfer.data?.documents; dispatchProof.outboundNo = outboundNo
|
||||
dispatchProof.deliveryNo = deliveries[0].deliveryNo
|
||||
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
|
||||
const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath)
|
||||
check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId
|
||||
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode)
|
||||
&& detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`)
|
||||
dispatchProof.action = 'PDA_DEPART'
|
||||
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
|
||||
dispatchProof.action = 'VERIFY_AUTOMATIC_DEPARTURE'
|
||||
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
|
||||
check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`)
|
||||
const departBody = { recordVersion: wireVersion(ready.data.recordVersion),
|
||||
idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }
|
||||
const departed = await api(context, 'POST', 'F40 正式 PDA 实际发车', `${PDA}/${dispatchNo}/depart`, departBody, picker.token)
|
||||
check(context, 'F40 本片原生发车进入DELIVERING并且真实出库一箱', departed.dispatchNo === dispatchNo
|
||||
&& departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 1
|
||||
&& departed.result?.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === deliveries[0].deliveryNo, `dispatch=${dispatchNo}`)
|
||||
const departureReplay = await api(context, 'POST', 'F40 实际发车原命令重放', `${PDA}/${dispatchNo}/depart`, departBody, picker.token)
|
||||
check(context, 'F40 发车重放保留原公共版本及全部发运结果', departureReplay.replayed === true
|
||||
&& String(departureReplay.recordVersion) === String(departed.recordVersion)
|
||||
&& JSON.stringify(departureReplay.result) === JSON.stringify(departed.result), 'immutable-departure-result')
|
||||
dispatchProof.departure = departed; dispatchProof.departureReplay = departureReplay; dispatchProof.stage = 'DELIVERING'
|
||||
check(context, 'F40 自动发车真实出库一箱', Number(ready.data.counts?.shippedPackageCount) === 1, `dispatch=${dispatchNo}`)
|
||||
dispatchProof.departure = ready.data; dispatchProof.loadingReplay = tripProof.loadingCommand?.replay; dispatchProof.stage = 'DELIVERING'
|
||||
const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`)
|
||||
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId
|
||||
&& outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const MODULE = 'procurement-sample-fulfillment'
|
||||
// Peak cash requirement: 1.00 purchase settled plus 2.00 freight frozen.
|
||||
export const PROCUREMENT_REQUIRED_AVAILABLE_BALANCE = '3.00'
|
||||
const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements'
|
||||
const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const clone = value => value == null ? value : structuredClone(value)
|
||||
@@ -67,7 +69,7 @@ export async function verifyProcurementSample(context, inputs) {
|
||||
}
|
||||
try {
|
||||
proof.walletBefore = clone(await inputs.readWallet())
|
||||
await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= 500n)
|
||||
await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= cents(PROCUREMENT_REQUIRED_AVAILABLE_BALANCE))
|
||||
const recipient = { name: '采购样品验收', phone: '13800000002', address: '广东省深圳市南山区科技园测试路1号' }
|
||||
const create = { customerName: inputs.customerName, purchaseCategory: 'SAMPLE', productName: `${context.marker} sample`, productSpecification: 'one-spec', purchaseQuantity: 2, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: recipient.address, contactName: recipient.name, contactPhone: recipient.phone, idempotencyKey: key('create') }
|
||||
const created = await request('POST', MERCHANT, create, '客户创建样品订单', inputs.customerToken)
|
||||
|
||||
@@ -6,9 +6,9 @@ export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({
|
||||
})
|
||||
|
||||
export const TAILLEG_ORDER_NUMBER = Object.freeze({
|
||||
pattern: /^FL-WH-\d{8}-\d{6}$/,
|
||||
timeZone: 'server-local',
|
||||
sharedBy: 'containerNo',
|
||||
pattern: /^WC\d{8}(?!0000)\d{4}$/,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
sharedBy: 'sourceContainerBookingId',
|
||||
})
|
||||
|
||||
export function validOwnerSnapshots(orders, expectedCount = orders?.length) {
|
||||
@@ -25,10 +25,10 @@ export function validOwnerSnapshots(orders, expectedCount = orders?.length) {
|
||||
|
||||
export function validContainerOrderGroup(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return false
|
||||
const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim()))
|
||||
const sourceBookings = new Set(rows.map((row) => String(row?.sourceContainerBookingId ?? '').trim()))
|
||||
const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim()))
|
||||
const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim())
|
||||
return containerNos.size === 1 && !containerNos.has('')
|
||||
return sourceBookings.size === 1 && !sourceBookings.has('')
|
||||
&& orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0])
|
||||
&& sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length
|
||||
}
|
||||
|
||||
@@ -62,37 +62,42 @@ test('Lastleg query DTO uses the persisted frozen owner name', () => {
|
||||
assert.match(orderService, /o\.getCustomerName\(\)/)
|
||||
})
|
||||
|
||||
test('head-leg sourced tail orders preserve the existing container grouping number', () => {
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), true)
|
||||
test('head-leg sourced tail orders use the registered WC daily sequence', () => {
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('WC202610200042'), true)
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false)
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'containerNo')
|
||||
assert.match(generator, /FL-WH-/)
|
||||
assert.match(generator, /BASIC_ISO_DATE/)
|
||||
assert.match(generator, /SHA-256/)
|
||||
assert.match(generatorTests, /actualContainerNumberRemainsStableAndShared/)
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'sourceContainerBookingId')
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai')
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('WC202610200000'), false)
|
||||
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), false)
|
||||
assert.match(generator, /case "LASTLEG" -> "WC"/)
|
||||
assert.match(generator, /return documents\.next\(prefix\)/)
|
||||
assert.match(generatorTests, /routesEveryOperationalTypeToItsDailySequence/)
|
||||
})
|
||||
|
||||
test('one container with 15 inbound rows shares one stable container order number', () => {
|
||||
const rows = Array.from({ length: 15 }, (_, index) => ({
|
||||
containerNo: 'CONT-15', orderNo: 'FL-WH-20261020-000042', sourceInboundId: `inbound-${index + 1}`,
|
||||
sourceContainerBookingId: 'booking-15', containerNo: 'CONT-15', orderNo: 'WC202610200042', sourceInboundId: `inbound-${index + 1}`,
|
||||
}))
|
||||
assert.equal(validContainerOrderGroup(rows), true)
|
||||
assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14
|
||||
? { ...row, orderNo: 'FL-WH-20261020-000043' } : row)), false)
|
||||
assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.forContainerNo\(payload\.containerNo\(\)\);[\s\S]*for \(int index/)
|
||||
? { ...row, orderNo: 'WC202610200043' } : row)), false)
|
||||
assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14
|
||||
? { ...row, sourceContainerBookingId: 'another-booking' } : row)), false)
|
||||
assert.equal(validContainerOrderGroup(rows.map((row) => ({ ...row, sourceContainerBookingId: '' }))), false)
|
||||
assert.match(consumer, /String orderNo\s*=\s*orderNumbers\.findContainerOrderNo\(sourceContainerBookingId\);\s*if \(orderNo == null\) orderNo = orderNoGenerator\.next\("LASTLEG"\);[\s\S]*for \(int index/)
|
||||
assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/)
|
||||
assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/)
|
||||
})
|
||||
|
||||
test('same event replay adds nothing and preserves the allocated number', () => {
|
||||
const before = { orderCount: 15, orderNo: 'FL-WH-20261020-000042', detailCount: 30, historyCount: 15 }
|
||||
const before = { orderCount: 15, orderNo: 'WC202610200042', detailCount: 30, historyCount: 15 }
|
||||
assert.equal(validEventReplay(before, { ...before }), true)
|
||||
assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false)
|
||||
const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox='))
|
||||
assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
|
||||
assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/)
|
||||
assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/)
|
||||
assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/)
|
||||
assert.doesNotMatch(duplicateBranch, /createOrder|orderNoGenerator|nextValue/)
|
||||
const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope'))
|
||||
assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
|
||||
assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/)
|
||||
|
||||
@@ -79,6 +79,13 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
|
||||
}
|
||||
|
||||
async function activeSupplier(context, category, suffix) {
|
||||
if (context.options?.fixedCustomerId) {
|
||||
const { loadSupplier } = await import('./finance-full-fixtures.mjs')
|
||||
const supplier = await loadSupplier(context, category, category === 'BOOKING'
|
||||
? { carrierCode: 'MATSON' } : { truckingScope: 'DOMESTIC' })
|
||||
if (!supplier) throw new Error(`缺少现有 ${category} 供应商,禁止自动创建`)
|
||||
return { supplierId: id(supplier.id), supplierName: supplier.name }
|
||||
}
|
||||
const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : category === 'TRUCKING' ? '&truckingScope=DOMESTIC' : ''
|
||||
const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`)
|
||||
const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE'
|
||||
|
||||
@@ -18,6 +18,11 @@ export function warehouseClaim(token) {
|
||||
// Called only after the owned actor's inventory operations finish. Credentials
|
||||
// stay in the private handoff context and are never added to createdResources.
|
||||
export async function verifyPdaWarehouseRevocation(context, picker) {
|
||||
if (picker?.reused) {
|
||||
context.addCoverageGap(MODULE, 'fixed-pda-revocation', '固定PDA账号不执行改绑和降权生命周期',
|
||||
'保留当前账号仓绑定与角色;本轮仅退出测试会话,撤销生命周期未验收', 'not-implemented', { blocking: false })
|
||||
return
|
||||
}
|
||||
if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料')
|
||||
const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200')
|
||||
const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS)
|
||||
|
||||
Reference in New Issue
Block a user