Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 |
|
||||
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
|
||||
| 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 |
|
||||
| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 |
|
||||
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
|
||||
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
|
||||
| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
@@ -71,14 +71,16 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['phase3-order-splitting', ['phase3-loading-finalize']],
|
||||
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']],
|
||||
['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']],
|
||||
['phase4-overseas-pda', ['phase4-carrier-pickup-ready']],
|
||||
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
|
||||
['phase4-overseas-pda', ['phase4-tail-order-controls']],
|
||||
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
|
||||
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
|
||||
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']],
|
||||
['phase5-terminal-drayage', ['phase4-overseas-pda']],
|
||||
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']],
|
||||
['phase4-carrier-nodes', ['phase4-overseas-pda-operations']],
|
||||
['phase4-carrier-nodes', ['phase4-overseas-pda-operations', 'phase5-terminal-drayage']],
|
||||
['phase4-overseas-business', ['merchant-qualification-fixture']],
|
||||
['firstleg-container-fee-allocation', []],
|
||||
['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']],
|
||||
@@ -328,7 +330,14 @@ export function parseArguments(argv) {
|
||||
const modules = normalizeModuleOrder(requestedModules)
|
||||
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
|
||||
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
|
||||
if (merchantUsername && merchantPassword && !modules.includes('phase4-overseas-business')) {
|
||||
const requiresMarkerOwnedMerchantFixture = [
|
||||
'phase4-overseas-business',
|
||||
'phase4-overseas-client-services',
|
||||
'phase5-overseas-fulfillment',
|
||||
'phase4-overseas-finance-cross-region',
|
||||
'phase4-overseas-wallet-and-pallet-cost',
|
||||
].some((module) => modules.includes(module))
|
||||
if (merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) {
|
||||
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
|
||||
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
|
||||
}
|
||||
@@ -1491,13 +1500,16 @@ export class AcceptanceRun {
|
||||
const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this))
|
||||
const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey
|
||||
const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => {
|
||||
const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null,
|
||||
carrierLatestActualAt = null) => {
|
||||
const matrix = this.createdResources.phase4InboundProducerFixtures
|
||||
const records = [matrix?.containers, matrix?.byDestinationType?.FBA,
|
||||
matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray)
|
||||
for (const values of records) {
|
||||
const target = values.find((item) => item?.resourceKey === producerKey)
|
||||
if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId })
|
||||
if (target) Object.assign(target, {
|
||||
shippingReady, carrierScheduleQueryId, carrierLatestActualAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (producerKey) writeShippingEvidence(false)
|
||||
@@ -1523,7 +1535,8 @@ export class AcceptanceRun {
|
||||
const pickup = this.createdResources.phase4CarrierPickupReady
|
||||
const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true
|
||||
&& Boolean(pickup?.queryId)
|
||||
writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null)
|
||||
writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null,
|
||||
shippingReady ? pickup.latestActualAt : null)
|
||||
this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null,
|
||||
shippingReady,
|
||||
`resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`,
|
||||
|
||||
@@ -86,6 +86,7 @@ export async function run(context) {
|
||||
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
|
||||
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
|
||||
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
|
||||
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
@@ -93,7 +94,7 @@ export async function run(context) {
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
|
||||
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 海外仓业务船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜后复用 `ACTIVE + TRUCKING` 主数据,覆盖选车队、预约提柜、实际提柜、实际还柜及四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
|
||||
@@ -103,6 +104,12 @@ export async function run(context) {
|
||||
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
|
||||
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再对目标柜全部尾程订单生成人工计划;PDA 卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
|
||||
|
||||
```powershell
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
|
||||
```
|
||||
|
||||
海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。
|
||||
|
||||
FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。
|
||||
|
||||
@@ -123,6 +123,8 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
|
||||
assert.match(entry, /for \(const key of createdKeys\) delete this\.createdResources\[key\]/)
|
||||
assert.match(pickupChain, /shippingReady/)
|
||||
assert.match(entry, /carrierScheduleQueryId/)
|
||||
assert.match(entry, /carrierLatestActualAt/)
|
||||
assert.match(pickupChain, /pickup\.latestActualAt/)
|
||||
assert.match(pickupChain, /pickup\?\.marker === variantMarker/)
|
||||
assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/)
|
||||
assert.match(entry, /dropshipReady\?\.shippingReady === true/)
|
||||
|
||||
@@ -5,7 +5,7 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES,
|
||||
manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs'
|
||||
manualNodePayload, markerOwnedCarrierQuery, run, runUntilPickupReady } from './phase4-carrier-nodes.mjs'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -32,22 +32,25 @@ test('focused carrier node execution injects the complete outbound-confirm produ
|
||||
'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules',
|
||||
'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch',
|
||||
'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready',
|
||||
'phase4-overseas-pda', 'phase4-carrier-nodes']
|
||||
'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase5-terminal-drayage',
|
||||
'phase4-carrier-nodes']
|
||||
for (const module of required) assert.ok(modules.includes(module), module)
|
||||
for (let index = 1; index < required.length; index += 1) {
|
||||
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
|
||||
}
|
||||
})
|
||||
|
||||
test('real lifecycle opens the shipping gate before PDA and records pickup and empty return afterwards', () => {
|
||||
test('real lifecycle opens the shipping gate before PDA and delegates pickup and empty return to terminal drayage', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes'])
|
||||
const pickup = modules.indexOf('phase4-carrier-pickup-ready')
|
||||
const pda = modules.indexOf('phase4-overseas-pda')
|
||||
const drayage = modules.indexOf('phase5-terminal-drayage')
|
||||
const completion = modules.indexOf('phase4-carrier-nodes')
|
||||
assert.ok(pickup >= 0 && pickup < pda)
|
||||
assert.ok(pda < completion)
|
||||
assert.ok(pda < drayage && drayage < completion)
|
||||
assert.match(source, /phase4CarrierPickupReady/)
|
||||
assert.match(source, /STANDARD_NODES\.slice\(STANDARD_NODES\.indexOf\('PICKED_UP'\)\)/)
|
||||
assert.match(source, /phase5TerminalDrayage/)
|
||||
assert.doesNotMatch(source, /PDA完成后补录实际节点/)
|
||||
})
|
||||
|
||||
test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => {
|
||||
@@ -100,16 +103,11 @@ test('ownership requires current marker, long query id and outbound-confirm sour
|
||||
} }), null)
|
||||
})
|
||||
|
||||
test('missing phase3 outbound query is an explicit deployment blocker and performs no write', async () => {
|
||||
test('missing terminal drayage completion is an explicit deployment blocker and performs no write', async () => {
|
||||
const calls = []; const steps = []
|
||||
const context = {
|
||||
marker: MARKER, token: 'token', createdResources: {}, calls,
|
||||
request: async (method, requestPath) => {
|
||||
calls.push({ method, path: requestPath })
|
||||
if (requestPath === `${BASE}/carriers`) return ok([])
|
||||
if (requestPath === `${BASE}?page=1&size=20`) return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
throw new Error(`unexpected path ${requestPath}`)
|
||||
},
|
||||
request: async (method, requestPath) => { calls.push({ method, path: requestPath }); throw new Error('unexpected request') },
|
||||
apiSuccess: (_module, _name, response) => response.body.data,
|
||||
assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`),
|
||||
addStep: (_module, name, _response, passed, evidence, options) => steps.push({ name, passed, evidence, options }),
|
||||
@@ -117,11 +115,11 @@ test('missing phase3 outbound query is an explicit deployment blocker and perfor
|
||||
|
||||
await run(context)
|
||||
|
||||
assert.deepEqual(calls.map((item) => item.method), ['GET', 'GET'])
|
||||
assert.deepEqual(calls, [])
|
||||
assert.equal(steps.length, 1)
|
||||
assert.equal(steps[0].passed, false)
|
||||
assert.equal(steps[0].options.deploymentBlocker, true)
|
||||
assert.match(steps[0].name, /禁止借历史任务写节点/)
|
||||
assert.match(steps[0].name, /取得本轮码头拖车还柜终态/)
|
||||
})
|
||||
|
||||
function markerOwnedContext() {
|
||||
@@ -225,13 +223,13 @@ function markerOwnedContext() {
|
||||
return { context, task, nodes, exceptions, histories, steps, assertions }
|
||||
}
|
||||
|
||||
test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => {
|
||||
test('marker-owned pre-PDA chain covers idempotency conflicts through pickup-ready', async () => {
|
||||
const fixture = markerOwnedContext()
|
||||
|
||||
await run(fixture.context)
|
||||
await runUntilPickupReady(fixture.context)
|
||||
|
||||
const posts = fixture.context.calls.filter((item) => item.method === 'POST')
|
||||
assert.equal(posts.length, 17)
|
||||
assert.equal(posts.length, 15)
|
||||
assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`))
|
||||
assert.ok(posts.every((item) => item.headers['Idempotency-Key']))
|
||||
assert.ok(posts.every((item) => Object.keys(item.body).length === 8))
|
||||
@@ -240,11 +238,11 @@ test('marker-owned chain covers idempotency conflicts, all actual nodes, excepti
|
||||
assert.ok(fixture.assertions.some((item) => /更早实际节点A0409后task、node、history零漂移/.test(item.name)))
|
||||
assert.ok(fixture.steps.some((item) => /同级actual失败请求同键重试.*operation claim零残留/.test(item.name)))
|
||||
assert.ok(fixture.steps.some((item) => /更早actual失败请求同键重试.*operation claim零残留/.test(item.name)))
|
||||
assert.equal(fixture.task.status, 'STOPPED')
|
||||
assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED')
|
||||
assert.equal(fixture.task.stopReason, '已还柜')
|
||||
assert.equal(fixture.task.status, 'READY')
|
||||
assert.equal(fixture.task.latestActualNode, 'PICKUP_READY')
|
||||
assert.equal(fixture.task.stopReason, null)
|
||||
const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode))
|
||||
assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES].sort())
|
||||
assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES.slice(0, 8)].sort())
|
||||
assert.equal(fixture.exceptions[0].exceptionCode, 'CUSTOMS_HOLD')
|
||||
assert.ok(fixture.nodes.every((item) => /^\d{16,}$/.test(item.id)))
|
||||
assert.equal(fixture.context.calls.some((item) => /attempts|scheduler|internal|\/stop/.test(item.path)), false)
|
||||
|
||||
@@ -312,63 +312,32 @@ export async function runUntilPickupReady(context) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
let pickup = context.createdResources?.phase4CarrierPickupReady
|
||||
if (pickup?.marker !== context.marker || pickup?.ready !== true) {
|
||||
await runUntilPickupReady(context)
|
||||
pickup = context.createdResources?.phase4CarrierPickupReady
|
||||
}
|
||||
if (!pickup?.queryId) return
|
||||
|
||||
const queryId = id(pickup.queryId)
|
||||
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
let detail = context.apiSuccess(MODULE, 'PDA收货完成后恢复本轮船节点任务', detailResponse,
|
||||
`queryId=${queryId}`, OPTIONS)
|
||||
assertDetail(context, detailResponse, detail, '恢复任务详情DTO完整')
|
||||
const pickupTask = detail?.query?.query
|
||||
if (pickupTask?.latestActualNode !== 'PICKUP_READY' || pickupTask?.status === 'STOPPED') {
|
||||
blocker(context, 'PDA收货后船节点未停留在PICKUP_READY,禁止伪造后续节点',
|
||||
`latest=${pickupTask?.latestActualNode || 'missing'}, status=${pickupTask?.status || 'missing'}`, detailResponse)
|
||||
const pickup = context.createdResources?.phase4CarrierPickupReady
|
||||
const drayage = context.createdResources?.phase5TerminalDrayage
|
||||
if (pickup?.marker !== context.marker || pickup?.ready !== true || !pickup?.queryId
|
||||
|| drayage?.marker !== context.marker || drayage?.finalStatus !== 'EMPTY_RETURNED') {
|
||||
blocker(context, '取得本轮码头拖车还柜终态',
|
||||
`pickup=${pickup?.ready === true}, drayageMarker=${drayage?.marker || 'missing'}, drayageStatus=${drayage?.finalStatus || 'missing'}`)
|
||||
return
|
||||
}
|
||||
|
||||
let version = Number(pickupTask.version)
|
||||
const frozenLatestActualMs = Date.parse(`${pickupTask.latestActualAt}Z`)
|
||||
const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000
|
||||
const remainingNodes = STANDARD_NODES.slice(STANDARD_NODES.indexOf('PICKED_UP'))
|
||||
for (let index = 0; index < remainingNodes.length; index += 1) {
|
||||
const standardNode = remainingNodes[index]
|
||||
const actualAt = localIso(new Date(baseActual + index * 60_000))
|
||||
const body = manualNodePayload({ expectedVersion: version, standardNode,
|
||||
observedAt: localIso(new Date()), actualAt, marker: context.marker,
|
||||
index: STANDARD_NODES.indexOf(standardNode) + 1 })
|
||||
const key = `${context.marker}-carrier-actual-${String(STANDARD_NODES.indexOf(standardNode) + 1).padStart(2, '0')}`.slice(0, 128)
|
||||
const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body,
|
||||
context.token, { 'Idempotency-Key': key })
|
||||
const updated = context.apiSuccess(MODULE, `PDA完成后补录实际节点${standardNode}`, response,
|
||||
`queryId=${queryId}`, OPTIONS)
|
||||
assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`)
|
||||
const updatedTask = updated?.query?.query
|
||||
const isEmptyReturned = standardNode === 'EMPTY_RETURNED'
|
||||
context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response,
|
||||
updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt
|
||||
&& (isEmptyReturned
|
||||
? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null
|
||||
: updatedTask.status !== 'STOPPED'),
|
||||
`status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS)
|
||||
version = Number(updatedTask.version)
|
||||
detail = updated
|
||||
}
|
||||
const queryId = id(pickup.queryId)
|
||||
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '码头拖车完成后回查本轮船节点任务', detailResponse,
|
||||
`queryId=${queryId}`, OPTIONS)
|
||||
assertDetail(context, detailResponse, detail, '码头拖车回写后任务详情DTO完整')
|
||||
|
||||
const finalTask = detail.query.query
|
||||
const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode))
|
||||
context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse,
|
||||
context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse,
|
||||
STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED'
|
||||
&& finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜',
|
||||
&& finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜'
|
||||
&& id(finalTask.containerBookingId) === id(drayage.containerBookingId),
|
||||
`actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS)
|
||||
|
||||
const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse,
|
||||
context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory)
|
||||
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length,
|
||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
@@ -30,6 +31,8 @@ test('module never borrows historical order or inventory candidates', () => {
|
||||
|
||||
test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => {
|
||||
assert.match(source, /chargeAmount: 0\.01/)
|
||||
assert.match(source, /cartonMark:/)
|
||||
assert.match(source, /fbaCartonMark:/)
|
||||
for (const token of [
|
||||
'客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409',
|
||||
'客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING',
|
||||
@@ -55,3 +58,14 @@ test('module records marker-owned resources without forbidden fulfillment cleanu
|
||||
assert.match(source, /discardNo = discardNo/)
|
||||
assert.match(source, /no outbound, dispatch, PDA or delivery action is invoked/)
|
||||
})
|
||||
|
||||
test('focused client services keeps the marker-owned merchant fixture when reusable credentials exist', () => {
|
||||
const parsed = parseArguments([
|
||||
'--modules', 'phase4-overseas-client-services',
|
||||
'--merchant-username', 'existing-merchant',
|
||||
'--merchant-password', 'existing-password',
|
||||
])
|
||||
const fixture = parsed.modules.indexOf('merchant-qualification-fixture')
|
||||
const clientServices = parsed.modules.indexOf('phase4-overseas-client-services')
|
||||
assert.ok(fixture >= 0 && fixture < clientServices)
|
||||
})
|
||||
|
||||
@@ -101,6 +101,8 @@ async function verifyRelabel(context, token, order) {
|
||||
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
|
||||
newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker,
|
||||
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
|
||||
cartonMark: `CM-${String(context.marker).slice(-32)}`,
|
||||
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
|
||||
}],
|
||||
}
|
||||
const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body)
|
||||
|
||||
@@ -5,7 +5,7 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run } from './phase4-overseas-pda-operations.mjs'
|
||||
import { auxiliaryPalletFbaWarehouseCode, run } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
|
||||
@@ -46,7 +46,8 @@ 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', 'APPROVE', 'COMPLETED',
|
||||
'chargeAmount: 0.01', 'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) {
|
||||
'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:',
|
||||
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) {
|
||||
assert.ok(source.includes(marker), marker)
|
||||
}
|
||||
for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) {
|
||||
@@ -57,6 +58,16 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
assert.match(source, /traceId=/)
|
||||
})
|
||||
|
||||
test('auxiliary pallets use the authoritative inbound FBA code and never the business destination fallback', () => {
|
||||
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ' ABE3 '), 'ABE3')
|
||||
assert.equal(auxiliaryPalletFbaWarehouseCode('OVERSEAS', 'ABE3'), null)
|
||||
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ''), null)
|
||||
assert.match(receivingSource,
|
||||
/destinationType: inbound\.destinationType,[\s\S]*fbaWarehouseCode: inbound\.destinationType === 'FBA' \? inbound\.fbaWarehouseCode : null/)
|
||||
assert.match(source, /fbaWarehouseCode,\s*\n\s*remark:/)
|
||||
assert.doesNotMatch(source, /createEmptyPallet\([^\n]*main\.destinationWarehouseCode/)
|
||||
})
|
||||
|
||||
test('fixture failure still disables both picker accounts and removes all transient tokens', async () => {
|
||||
const cleanups = []
|
||||
const gaps = []
|
||||
|
||||
@@ -95,13 +95,26 @@ async function createLocation(context, warehouseId, suffix) {
|
||||
return success(response) ? { locationId, locationCode } : null
|
||||
}
|
||||
|
||||
async function createEmptyPallet(context, token, fixture, suffix, destinationWarehouseCode) {
|
||||
export function auxiliaryPalletFbaWarehouseCode(destinationType, fbaWarehouseCode) {
|
||||
if (destinationType === 'OVERSEAS') return null
|
||||
if (destinationType === 'FBA' && nonBlank(fbaWarehouseCode)) return fbaWarehouseCode.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
async function createEmptyPallet(context, token, fixture, suffix) {
|
||||
const fbaWarehouseCode = auxiliaryPalletFbaWarehouseCode(
|
||||
fixture.destinationType, fixture.fbaWarehouseCode)
|
||||
if (fixture.destinationType === 'FBA' && !fbaWarehouseCode) {
|
||||
blocker(context, `${suffix}辅助空托盘`,
|
||||
`当前FBA入仓码 ${fixture.inboundCode || 'missing'} 缺少权威fbaWarehouseCode`)
|
||||
return null
|
||||
}
|
||||
const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120)
|
||||
const response = await context.requestOverseas('POST',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE',
|
||||
locationId: fixture.location.locationId,
|
||||
fbaWarehouseCode: destinationWarehouseCode || null,
|
||||
fbaWarehouseCode,
|
||||
remark: `${context.marker}-pda-operations-${suffix}`,
|
||||
})
|
||||
context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS)
|
||||
@@ -234,9 +247,9 @@ async function verifyPalletOperations(context, token, fixture) {
|
||||
'主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱')
|
||||
return false
|
||||
}
|
||||
const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP', main.destinationWarehouseCode)
|
||||
const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT', main.destinationWarehouseCode)
|
||||
const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND', main.destinationWarehouseCode)
|
||||
const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP')
|
||||
const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT')
|
||||
const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND')
|
||||
if (!swapTarget || !splitTarget || !unbindTarget) {
|
||||
blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘')
|
||||
return false
|
||||
@@ -333,7 +346,9 @@ async function verifyRelabel(context, token, fixture) {
|
||||
idempotencyKey: key(context, 'RELABEL-CREATE'),
|
||||
items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品',
|
||||
totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128),
|
||||
newSkuCode: newSku, labelRequirement: context.marker }],
|
||||
newSkuCode: newSku, labelRequirement: context.marker,
|
||||
cartonMark: `CM-${String(context.marker).slice(-32)}`,
|
||||
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}` }],
|
||||
}
|
||||
const createdResponse = await context.requestOverseas('POST',
|
||||
query(relabelBase, { customerId }), createBody)
|
||||
|
||||
@@ -669,7 +669,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
pallets.get(inbound.inboundCode), inbound.destinationType)
|
||||
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
|
||||
context.context.phase4OverseasPdaOperations.completedFixtures.push({
|
||||
containerNo, order, ...warehouseFacts,
|
||||
containerNo, order, destinationType: inbound.destinationType,
|
||||
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
||||
...warehouseFacts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -30,6 +30,8 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
|
||||
"terminalStatus === 'COMPLETED'",
|
||||
"'CANCELLED'",
|
||||
'relabelPayload(context, warehouseId, suffix, 0.01)',
|
||||
'cartonMark:',
|
||||
'fbaCartonMark:',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/)
|
||||
assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/)
|
||||
|
||||
@@ -178,7 +178,9 @@ function relabelPayload(context, warehouseId, suffix, chargeAmount) {
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255),
|
||||
items: [{ skuCode: `OLD-${value}-${suffix}`.slice(0, 128), productNameZh: `${context.marker} 换标商品`,
|
||||
totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${value}-${suffix}`.slice(0, 128),
|
||||
newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker }],
|
||||
newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker,
|
||||
cartonMark: `CM-${value}-${suffix}`.slice(0, 255),
|
||||
fbaCartonMark: `FBA-${value}-${suffix}`.slice(0, 255) }],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase4-tail-order-controls.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('tail order controls execute after current producer and before overseas PDA', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-overseas-pda'])
|
||||
const controls = modules.indexOf('phase4-tail-order-controls')
|
||||
assert.ok(controls > modules.indexOf('phase4-carrier-pickup-ready'))
|
||||
assert.ok(controls < modules.indexOf('phase4-overseas-pda'))
|
||||
})
|
||||
|
||||
test('module covers the public tail-order write surface before PDA receiving', () => {
|
||||
for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export',
|
||||
'/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) {
|
||||
assert.ok(source.includes(path), `missing ${path}`)
|
||||
}
|
||||
for (const evidence of [
|
||||
'接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409',
|
||||
'整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓',
|
||||
'单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组',
|
||||
'尾程费用详情可追溯', '导出本轮尾程订单CSV',
|
||||
'暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划',
|
||||
'解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放',
|
||||
'人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点',
|
||||
'人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', '人工计划同键异载荷返回A0409',
|
||||
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
|
||||
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
|
||||
assert.match(source, /creationMode === 'MANUAL'/)
|
||||
assert.match(source, /requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /palletMergeBatchId/)
|
||||
})
|
||||
|
||||
test('receiving warehouse verification reuses public master data and restores the original assignment', () => {
|
||||
assert.match(source, /tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/)
|
||||
assert.match(source, /id\(row\.id\) !== originalWarehouseId/)
|
||||
assert.match(source, /overseasWarehouseId: originalWarehouseId, expectedVersion: Number\(changed\.data\.assignmentVersion\)/)
|
||||
assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
})
|
||||
|
||||
test('module is marker-owned and never borrows historical tail orders', () => {
|
||||
assert.match(source, /matrix\?\.marker !== context\.marker/)
|
||||
assert.match(source, /containerNo: producer\.containerNo/)
|
||||
assert.match(source, /customerId\) === id\(producer\.tenantId\)/)
|
||||
assert.match(source, /禁止借历史尾程订单/)
|
||||
assert.doesNotMatch(source, /records\([^)]*\)\[0\]/)
|
||||
})
|
||||
|
||||
test('manual plans preserve long identifiers and are retained for PDA consumption', () => {
|
||||
assert.match(source, /orderId: id\(order\.id\)/)
|
||||
assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/)
|
||||
assert.match(source, /!order\.overseasWarehouseId/)
|
||||
assert.match(source, /planNos: plans/)
|
||||
assert.match(source, /MANUAL_PLANS_READY/)
|
||||
assert.match(source, /remain as the marker-owned prerequisites consumed by overseas PDA receiving/)
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
const MODULE = 'phase4-tail-order-controls'
|
||||
const SCENARIO = 'phase4-tail-order-controls-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const ORDERS = '/api/platform/v1/last-leg/orders'
|
||||
const PLANS = '/api/platform/v1/last-leg/inbound-plans'
|
||||
const NODE_CODES = Object.freeze([
|
||||
'DOMESTIC_CUSTOMS_COMPLETED', 'ESTIMATED_DEPARTURE', 'ACTUAL_DEPARTURE', 'ESTIMATED_ARRIVAL',
|
||||
'OVERSEAS_CUSTOMS_COMPLETED', 'ACTUAL_ARRIVAL_WAITING_UNLOAD', 'PICKUP_APPOINTED', 'UNLOADED',
|
||||
])
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
function query(path, values) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(values || {})) {
|
||||
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value))
|
||||
}
|
||||
return params.size ? `${path}?${params}` : path
|
||||
}
|
||||
async function api(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers, context.overseasToken)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
async function domesticApi(context, name, path) {
|
||||
const response = await context.request('GET', path)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
function blocker(context, name, evidence, response = null) {
|
||||
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
|
||||
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
|
||||
}
|
||||
function targetProducer(context) {
|
||||
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
||||
if (matrix?.marker !== context.marker) return null
|
||||
return matrix.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
|
||||
|| matrix.byDestinationType?.FBA?.[0]
|
||||
|| matrix.byDestinationType?.OVERSEAS?.[0]
|
||||
|| null
|
||||
}
|
||||
async function markerOrders(context, producer) {
|
||||
let last = null
|
||||
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
||||
last = await api(context, 'GET', `等待本轮尾程订单落地(${attempt}/60)`,
|
||||
query(ORDERS, { page: 1, size: 200, containerNo: producer.containerNo }))
|
||||
const matched = records(last.data).filter((row) => id(row?.containerNo) === id(producer.containerNo)
|
||||
&& id(row?.customerId) === id(producer.tenantId) && id(row?.id))
|
||||
if (matched.length > 0) return { rows: matched, response: last.response }
|
||||
if (attempt < 60) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
}
|
||||
return { rows: [], response: last?.response || null }
|
||||
}
|
||||
async function detail(context, row, name = '读取尾程订单即时详情') {
|
||||
return api(context, 'GET', name,
|
||||
query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) }))
|
||||
}
|
||||
async function verifyReceivingWarehouseChange(context, producer, details) {
|
||||
const bookingId = id(producer.containerBookingId)
|
||||
if (!bookingId) {
|
||||
blocker(context, '取得本轮来源柜预订ID', `containerNo=${producer.containerNo}`)
|
||||
return false
|
||||
}
|
||||
const progressPath = `${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}`
|
||||
const current = await api(context, 'GET', '读取本轮柜级入仓计划进度', progressPath)
|
||||
const summary = current.data?.summary
|
||||
const originalWarehouseId = id(summary?.overseasWarehouseId)
|
||||
if (!success(current.response) || !originalWarehouseId || summary?.canChangeReceivingWarehouse !== true) {
|
||||
blocker(context, '柜级接柜仓可变更前置',
|
||||
`bookingId=${bookingId}, warehouseId=${originalWarehouseId || 'missing'}, canChange=${summary?.canChangeReceivingWarehouse}`, current.response)
|
||||
return false
|
||||
}
|
||||
const warehouses = await domesticApi(context, '查询可用公共海外接柜仓',
|
||||
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
||||
const alternate = records(warehouses.data).find((row) => id(row?.id) && id(row.id) !== originalWarehouseId
|
||||
&& row?.warehouseType === 'OVERSEAS' && row?.status === 'ACTIVE')
|
||||
if (!alternate) {
|
||||
blocker(context, '柜级接柜仓真实切换候选',
|
||||
`originalWarehouseId=${originalWarehouseId}; 至少需要2个ACTIVE公共OVERSEAS接柜仓`, warehouses.response)
|
||||
return false
|
||||
}
|
||||
const changePath = `${progressPath}/receiving-warehouse`
|
||||
const changeKey = `${context.marker}-receiving-warehouse-change`.slice(0, 128)
|
||||
const changeBody = { overseasWarehouseId: id(alternate.id), expectedVersion: Number(summary.assignmentVersion), remark: context.marker }
|
||||
const changed = await api(context, 'PUT', '整柜切换到另一公共接柜仓', changePath, changeBody,
|
||||
{ 'Idempotency-Key': changeKey })
|
||||
context.assert(MODULE, '接柜仓变更原子推进版本并保持柜内订单数', changed.response,
|
||||
id(changed.data?.overseasWarehouseId) === id(alternate.id)
|
||||
&& Number(changed.data?.assignmentVersion) === Number(summary.assignmentVersion) + 1
|
||||
&& Number(changed.data?.totalOrderCount) === details.length,
|
||||
trace(changed.response), OPTIONS)
|
||||
if (!success(changed.response)) return false
|
||||
const replay = await api(context, 'PUT', '接柜仓变更同键同载荷重放', changePath, changeBody,
|
||||
{ 'Idempotency-Key': changeKey })
|
||||
context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response,
|
||||
Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion)
|
||||
&& id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` },
|
||||
{ 'Idempotency-Key': changeKey }, context.overseasToken)
|
||||
context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', changePath,
|
||||
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken)
|
||||
context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS)
|
||||
const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath,
|
||||
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` },
|
||||
{ 'Idempotency-Key': `${context.marker}-receiving-warehouse-restore`.slice(0, 128) })
|
||||
context.assert(MODULE, '接柜仓恢复后供后续PDA消费', restored.response,
|
||||
id(restored.data?.overseasWarehouseId) === originalWarehouseId
|
||||
&& Number(restored.data?.assignmentVersion) === Number(changed.data.assignmentVersion) + 1,
|
||||
trace(restored.response), OPTIONS)
|
||||
return success(restored.response)
|
||||
}
|
||||
|
||||
async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
const fbaResult = await domesticApi(context, '查询公共ACTIVE FBA目的仓',
|
||||
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&warehouseType=FBA&status=ACTIVE')
|
||||
const fba = records(fbaResult.data).find((row) => id(row?.id) && nonBlank(row?.warehouseCode)
|
||||
&& nonBlank(row?.warehouseName) && row?.warehouseType === 'FBA' && row?.status === 'ACTIVE')
|
||||
if (!fba) {
|
||||
blocker(context, '尾程目的仓变更权威主档', '国内Warehouse未返回公共ACTIVE FBA仓', fbaResult.response)
|
||||
return false
|
||||
}
|
||||
const destination = { warehouseId: id(fba.id), warehouseCode: fba.warehouseCode,
|
||||
warehouseName: fba.warehouseName, remark: context.marker }
|
||||
const representative = details[0]
|
||||
const one = await api(context, 'PUT', '单票变更FBA目的仓',
|
||||
query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/destination`, { customerId: id(representative.customerId) }), destination)
|
||||
context.assert(MODULE, '单票目的仓变更返回1', one.response, Number(one.data) === 1, trace(one.response), OPTIONS)
|
||||
if (!success(one.response)) return false
|
||||
const batchOrders = details.slice(1).length > 0 ? details.slice(1) : [representative]
|
||||
const batch = await api(context, 'PUT', '批量变更FBA目的仓',
|
||||
query(`${ORDERS}/destination`, { customerId: id(representative.customerId) }),
|
||||
{ ...destination, orderIds: batchOrders.map((row) => id(row.id)) })
|
||||
context.assert(MODULE, '批量目的仓变更数量精确', batch.response,
|
||||
Number(batch.data) === batchOrders.length, trace(batch.response), OPTIONS)
|
||||
if (!success(batch.response)) return false
|
||||
|
||||
const mergePath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/pallet-merge-required`, {
|
||||
customerId: id(representative.customerId),
|
||||
})
|
||||
const marked = await api(context, 'PUT', '单票开启PDA合托提示', mergePath, { palletMergeRequired: true })
|
||||
context.assert(MODULE, '单票合托标记写入成功', marked.response, marked.data == null, trace(marked.response), OPTIONS)
|
||||
const markedRead = await detail(context, representative, '回查单票合托标记')
|
||||
context.assert(MODULE, '单票合托标记为REQUIRED', markedRead.response,
|
||||
markedRead.data?.order?.palletMergeFlag === 'REQUIRED', trace(markedRead.response), OPTIONS)
|
||||
const unmarked = await api(context, 'PUT', '单票取消PDA合托提示', mergePath, { palletMergeRequired: false })
|
||||
context.assert(MODULE, '单票合托标记可取消', unmarked.response, unmarked.data == null, trace(unmarked.response), OPTIONS)
|
||||
|
||||
const allIds = details.map((row) => id(row.id))
|
||||
const batchMergePath = query(`${ORDERS}/pallet-merge-required/batch`, { customerId: id(representative.customerId) })
|
||||
const batchMarked = await api(context, 'PUT', '批量开启PDA合托分组', batchMergePath,
|
||||
{ orderIds: allIds, palletMergeRequired: true })
|
||||
context.assert(MODULE, '批量合托分组写入成功', batchMarked.response, batchMarked.data == null, trace(batchMarked.response), OPTIONS)
|
||||
const groupRead = await detail(context, representative, '回查批量合托关系')
|
||||
context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response,
|
||||
groupRead.data?.order?.palletMergeFlag === 'REQUIRED'
|
||||
&& nonBlank(groupRead.data?.order?.palletMergeBatchId), trace(groupRead.response), OPTIONS)
|
||||
const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath,
|
||||
{ orderIds: allIds, palletMergeRequired: false })
|
||||
context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS)
|
||||
|
||||
const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128)
|
||||
const fee = await api(context, 'POST', '新增本轮尾程订单费用',
|
||||
query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }),
|
||||
{ feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker })
|
||||
context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response,
|
||||
id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem
|
||||
&& fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW',
|
||||
trace(fee.response), OPTIONS)
|
||||
const feeRead = await detail(context, representative, '回查尾程订单费用明细')
|
||||
context.assert(MODULE, '尾程费用详情可追溯', feeRead.response,
|
||||
feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS)
|
||||
|
||||
const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, {
|
||||
customerId: id(representative.customerId), containerNo: producer.containerNo,
|
||||
}))
|
||||
const csv = new TextDecoder().decode(exported.bytes || new Uint8Array())
|
||||
context.addStep(MODULE, '导出本轮尾程订单CSV', exported,
|
||||
exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv')
|
||||
&& csv.includes(producer.containerNo) && csv.includes('尾程单号'),
|
||||
`bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS)
|
||||
return success(batchCleared.response) && success(fee.response) && exported?.http === 200
|
||||
}
|
||||
function manualPlanBody(order, version, marker) {
|
||||
return {
|
||||
customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version),
|
||||
plannedArrivalDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
|
||||
inboundCode: order.inboundCode,
|
||||
palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)),
|
||||
palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true,
|
||||
remark: marker,
|
||||
}
|
||||
}
|
||||
function completeManualNodes(marker) {
|
||||
const base = Date.now() - 8 * 60 * 60_000
|
||||
return NODE_CODES.map((nodeCode, index) => ({
|
||||
nodeCode, nodeAt: localIso(new Date(base + index * 60 * 60_000)),
|
||||
})).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4TailOrderControls = {
|
||||
marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null,
|
||||
finalStatus: null,
|
||||
cleanup: 'Manual inbound plans remain as the marker-owned prerequisites consumed by overseas PDA receiving',
|
||||
}
|
||||
const producer = targetProducer(context)
|
||||
if (!producer?.containerNo || !producer?.tenantId) {
|
||||
blocker(context, '取得本轮尾程控制目标柜', '缺少本轮phase4入仓生产者,禁止借历史尾程订单')
|
||||
return
|
||||
}
|
||||
const listed = await markerOrders(context, producer)
|
||||
if (listed.rows.length === 0) {
|
||||
blocker(context, '取得本轮尾程订单', `containerNo=${producer.containerNo}, customerId=${producer.tenantId}`, listed.response)
|
||||
return
|
||||
}
|
||||
const details = []
|
||||
for (const row of listed.rows) {
|
||||
const current = await detail(context, row)
|
||||
if (success(current.response) && id(current.data?.order?.id) === id(row.id)) details.push(current.data.order)
|
||||
}
|
||||
if (details.length !== listed.rows.length || details.some((order) => !order.inboundCode
|
||||
|| !order.overseasWarehouseId)) {
|
||||
blocker(context, '尾程订单人工计划冻结事实完整', `listed=${listed.rows.length}, detailed=${details.length}`)
|
||||
return
|
||||
}
|
||||
if (!await verifyReceivingWarehouseChange(context, producer, details)) return
|
||||
if (!await verifyDestinationMergeFeeAndExport(context, details, producer)) return
|
||||
const refreshedDetails = []
|
||||
for (const original of details) {
|
||||
const refreshed = await detail(context, original, `管理命令后刷新订单${id(original.id)}版本`)
|
||||
if (!success(refreshed.response) || !refreshed.data?.order) return
|
||||
refreshedDetails.push(refreshed.data.order)
|
||||
}
|
||||
const representative = refreshedDetails[0]
|
||||
const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, {
|
||||
customerId: id(representative.customerId),
|
||||
})
|
||||
const holdKey = `${context.marker}-tail-hold`.slice(0, 128)
|
||||
const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) }
|
||||
const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
||||
context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response,
|
||||
held.data?.held === true && held.data?.chargeable === true
|
||||
&& id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1,
|
||||
trace(held.response), OPTIONS)
|
||||
if (!success(held.response)) return
|
||||
const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
||||
context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response,
|
||||
JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS)
|
||||
const heldConflict = await context.requestOverseas('PUT', orderPath,
|
||||
{ ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken)
|
||||
context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS)
|
||||
|
||||
const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker)
|
||||
const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody,
|
||||
{ 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken)
|
||||
context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS)
|
||||
|
||||
const releaseKey = `${context.marker}-tail-release`.slice(0, 128)
|
||||
const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) }
|
||||
const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey })
|
||||
context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response,
|
||||
released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null
|
||||
&& Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS)
|
||||
if (!success(released.response)) return
|
||||
|
||||
const nodesPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/manual-nodes`, {
|
||||
customerId: id(representative.customerId),
|
||||
})
|
||||
const nodesKey = `${context.marker}-tail-manual-nodes`.slice(0, 128)
|
||||
const nodesBody = { expectedVersion: Number(released.data.version), nodes: completeManualNodes(context.marker) }
|
||||
const nodes = await api(context, 'PUT', '保存尾程八个人工节点快照', nodesPath, nodesBody,
|
||||
{ 'Idempotency-Key': nodesKey })
|
||||
context.assert(MODULE, '人工节点永久切换MANUAL且完整返回八行', nodes.response,
|
||||
nodes.data?.manualOverrideEnabled === true && nodes.data?.nodes?.length === 8
|
||||
&& nodes.data.nodes.every((node) => node?.source === 'MANUAL')
|
||||
&& Number(nodes.data?.version) === Number(released.data.version) + 1,
|
||||
`nodes=${nodes.data?.nodes?.length ?? 'missing'}; ${trace(nodes.response)}`, OPTIONS)
|
||||
if (!success(nodes.response)) return
|
||||
const nodesReplay = await api(context, 'PUT', '人工节点同键同载荷重放', nodesPath, nodesBody,
|
||||
{ 'Idempotency-Key': nodesKey })
|
||||
context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response,
|
||||
JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS)
|
||||
const nodesConflict = await context.requestOverseas('PUT', nodesPath,
|
||||
{ ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) },
|
||||
{ 'Idempotency-Key': nodesKey }, context.overseasToken)
|
||||
context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS)
|
||||
const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath)
|
||||
context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response,
|
||||
nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8
|
||||
&& nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS)
|
||||
|
||||
const plans = []
|
||||
for (const original of refreshedDetails) {
|
||||
const refreshed = await detail(context, original, `创建人工计划前刷新订单${id(original.id)}版本`)
|
||||
const order = refreshed.data?.order
|
||||
if (!success(refreshed.response) || !order) return
|
||||
const body = manualPlanBody(order, order.version, context.marker)
|
||||
const key = `${context.marker}-manual-plan-${id(order.id)}`.slice(0, 128)
|
||||
const created = await api(context, 'POST', `为本轮尾程订单${id(order.id)}创建人工计划`, `${PLANS}/manual`, body,
|
||||
{ 'Idempotency-Key': key })
|
||||
context.assert(MODULE, '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', created.response,
|
||||
Boolean(created.data?.planNo) && created.data?.creationMode === 'MANUAL'
|
||||
&& Boolean(created.data?.overseasWarehouseCode) && Boolean(created.data?.overseasWarehouseName)
|
||||
&& created.data?.plannedArrivalDate === body.plannedArrivalDate
|
||||
&& created.data?.palletType === body.palletType && created.data?.urgent === true
|
||||
&& Number(created.data?.palletCount) === body.palletCount
|
||||
&& created.data?.lines?.some((line) => id(line?.orderId) === id(order.id) && line?.inboundCode === order.inboundCode),
|
||||
`orderId=${id(order.id)}, planNo=${created.data?.planNo || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!success(created.response) || !created.data?.planNo) return
|
||||
const replay = await api(context, 'POST', `人工计划${created.data.planNo}幂等重放`, `${PLANS}/manual`, body,
|
||||
{ 'Idempotency-Key': key })
|
||||
context.assert(MODULE, '人工计划重放复用同一计划且零新增', replay.response,
|
||||
replay.data?.planNo === created.data.planNo, trace(replay.response), OPTIONS)
|
||||
if (plans.length === 0) {
|
||||
const hashConflict = await context.requestOverseas('POST', `${PLANS}/manual`,
|
||||
{ ...body, remark: `${context.marker}-DIFFERENT` }, { 'Idempotency-Key': key }, context.overseasToken)
|
||||
context.addStep(MODULE, '人工计划同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
}
|
||||
plans.push(created.data.planNo)
|
||||
}
|
||||
context.createdResources.phase4TailOrderControls = {
|
||||
...context.createdResources.phase4TailOrderControls,
|
||||
containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)),
|
||||
planNos: plans, representativeOrderId: id(representative.id), finalStatus: 'MANUAL_PLANS_READY',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
|
||||
|
||||
test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => {
|
||||
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
|
||||
const pickup = modules.indexOf('phase4-carrier-pickup-ready')
|
||||
const pda = modules.indexOf('phase4-overseas-pda')
|
||||
const drayage = modules.indexOf('phase5-terminal-drayage')
|
||||
const finalCarrier = modules.indexOf('phase4-carrier-nodes')
|
||||
assert.ok(pickup >= 0 && pickup < pda)
|
||||
assert.ok(pda < drayage && drayage < finalCarrier)
|
||||
})
|
||||
|
||||
test('module uses only public business endpoints and reuses public trucking master data', () => {
|
||||
for (const path of [
|
||||
'/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history',
|
||||
'category=TRUCKING',
|
||||
]) assert.ok(source.includes(path), `missing ${path}`)
|
||||
assert.doesNotMatch(source, /\/internal\//)
|
||||
assert.doesNotMatch(source, /POST[^\n]*\/api\/platform\/v1\/suppliers/)
|
||||
assert.match(source, /脚本不临时创建主数据/)
|
||||
})
|
||||
|
||||
test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => {
|
||||
for (const token of [
|
||||
'选择车队', '预约提柜', '确认实际提柜', '确认实际还柜',
|
||||
'同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED',
|
||||
'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED',
|
||||
]) assert.ok(source.includes(token), `missing ${token}`)
|
||||
assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/)
|
||||
})
|
||||
|
||||
test('target is strictly current-marker pickup and completed-PDA container', () => {
|
||||
assert.match(source, /matrix\?\.marker !== context\.marker/)
|
||||
assert.match(source, /pda\?\.marker !== context\.marker/)
|
||||
assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/)
|
||||
assert.match(source, /completedContainerFacts/)
|
||||
assert.match(source, /item\?\.shippingReady === true/)
|
||||
assert.match(source, /carrierScheduleQueryId/)
|
||||
assert.match(source, /carrierLatestActualAt/)
|
||||
assert.match(source, /phase4CarrierPickupReady =/)
|
||||
assert.match(source, /禁止借历史货柜/)
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
const MODULE = 'phase5-terminal-drayage'
|
||||
const SCENARIO = 'phase5-terminal-drayage-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/terminal-drayages'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
async function api(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.request(method, path, body, context.token, headers)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
function blocker(context, name, evidence, response = null) {
|
||||
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
|
||||
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
|
||||
}
|
||||
function fixture(context) {
|
||||
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
||||
const pda = context.createdResources?.phase4OverseasPda
|
||||
if (matrix?.marker !== context.marker || pda?.marker !== context.marker
|
||||
|| pda?.finalStatus !== 'COMPLETED') return null
|
||||
const producers = Array.isArray(matrix.containers) ? matrix.containers : []
|
||||
for (const completed of pda.completedContainerFacts || []) {
|
||||
const producer = producers.find((item) => id(item?.containerBookingId) === id(completed?.containerBookingId)
|
||||
&& id(item?.containerNo) === id(completed?.containerNo)
|
||||
&& item?.shippingReady === true && id(item?.carrierScheduleQueryId)
|
||||
&& item?.carrierLatestActualAt)
|
||||
if (producer) return {
|
||||
matrix, producer, pda, completed, containerBookingId: id(producer.containerBookingId),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
async function command(context, target, action, path, body) {
|
||||
const key = `${context.marker}-terminal-${action}`.slice(0, 128)
|
||||
const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`,
|
||||
body, { 'Idempotency-Key': key })
|
||||
if (!success(first.response)) return first
|
||||
const replay = await api(context, 'POST', `目的港码头拖车${action}同键重放`,
|
||||
`${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key })
|
||||
context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response,
|
||||
JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
const changed = path === 'fleet' ? { ...body, reason: `${body.reason}-DIFFERENT` }
|
||||
: { ...body, reason: `${body.reason}-DIFFERENT` }
|
||||
const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`,
|
||||
changed, context.token, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict,
|
||||
conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
return first
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase5TerminalDrayage = {
|
||||
marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null,
|
||||
cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence',
|
||||
}
|
||||
const target = fixture(context)
|
||||
if (!target) {
|
||||
blocker(context, '取得本轮PICKUP_READY且已卸柜货柜',
|
||||
`matrix=${context.createdResources?.phase4InboundProducerFixtures?.marker || 'missing'}, shippingReady=${context.createdResources?.phase4InboundProducerFixtures?.containers?.filter((item) => item?.shippingReady === true).length ?? 0}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`)
|
||||
return
|
||||
}
|
||||
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
|
||||
const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path)
|
||||
context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response,
|
||||
id(initial.data?.containerBookingId) === target.containerBookingId
|
||||
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
|
||||
&& Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'),
|
||||
`status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
|
||||
if (!success(initial.response)) return
|
||||
|
||||
const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商',
|
||||
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100')
|
||||
const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE')
|
||||
if (!fleet) {
|
||||
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response)
|
||||
return
|
||||
}
|
||||
const selected = await command(context, target, '选择车队', 'fleet', {
|
||||
expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id),
|
||||
fleetSupplierName: fleet.name, reason: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response,
|
||||
id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name
|
||||
&& selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS)
|
||||
if (!success(selected.response)) return
|
||||
|
||||
const latestActual = Date.parse(`${target.producer.carrierLatestActualAt}Z`)
|
||||
const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000))
|
||||
const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', {
|
||||
expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '预约提柜时间和版本持久化', scheduled.response,
|
||||
scheduled.data?.pickupAppointmentAt === appointmentAt
|
||||
&& Number(scheduled.data?.version) === Number(selected.data.version) + 1,
|
||||
trace(scheduled.response), OPTIONS)
|
||||
if (!success(scheduled.response)) return
|
||||
|
||||
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
|
||||
const picked = await command(context, target, '确认实际提柜', 'pickup', {
|
||||
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, reason: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response,
|
||||
picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt
|
||||
&& picked.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), trace(picked.response), OPTIONS)
|
||||
if (!success(picked.response)) return
|
||||
|
||||
const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`)
|
||||
const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
|
||||
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
|
||||
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
|
||||
expectedVersion: Number(picked.data.version), actualAt: returnedAt, reason: context.marker,
|
||||
})
|
||||
context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response,
|
||||
returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt
|
||||
&& Array.isArray(returned.data?.allowedActions) && returned.data.allowedActions.length === 0
|
||||
&& returned.data?.emptyReturnWarning?.status === 'RESOLVED', trace(returned.response), OPTIONS)
|
||||
if (!success(returned.response)) return
|
||||
|
||||
const list = await api(context, 'GET', '批量回读本轮码头拖车聚合',
|
||||
`${BASE}?containerBookingIds=${encodeURIComponent(target.containerBookingId)}`)
|
||||
context.assert(MODULE, '批量读取只命中本轮货柜终态', list.response,
|
||||
Array.isArray(list.data) && list.data.length === 1
|
||||
&& id(list.data[0]?.containerBookingId) === target.containerBookingId
|
||||
&& list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS)
|
||||
const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`)
|
||||
const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action))
|
||||
context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response,
|
||||
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
|
||||
&& history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1),
|
||||
`actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS)
|
||||
context.createdResources.phase5TerminalDrayage = {
|
||||
...context.createdResources.phase5TerminalDrayage,
|
||||
containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo,
|
||||
finalStatus: 'EMPTY_RETURNED', version: Number(returned.data.version),
|
||||
}
|
||||
context.createdResources.phase4CarrierPickupReady = {
|
||||
marker: context.marker, queryId: id(target.producer.carrierScheduleQueryId),
|
||||
latestActualAt: target.producer.carrierLatestActualAt, ready: true,
|
||||
}
|
||||
}
|
||||
@@ -96,8 +96,8 @@ test('Admin consumes a server-paged container summary and never derives 15 from
|
||||
assert.match(adminApi, /listContainerPlanProgress/)
|
||||
assert.match(adminApi, /container-summaries/)
|
||||
assert.match(adminView, /listContainerPlanProgress\(/)
|
||||
assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/)
|
||||
assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/)
|
||||
assert.match(adminView, /containerRows\.value\s*=\s*result\.records/)
|
||||
assert.match(adminView, /containerTotal\.value\s*=\s*result\.total/)
|
||||
assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/)
|
||||
assert.match(adminView, /if \(request !== containerOptionRequest\) return/)
|
||||
assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/)
|
||||
|
||||
@@ -126,9 +126,14 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
|
||||
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
|
||||
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
|
||||
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
|
||||
Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项。' '测试登记清空状态'
|
||||
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
|
||||
Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控'
|
||||
Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜后端 |' '测试环境海外接柜后端专项'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜Admin前端 |' '测试环境海外接柜 Admin 专项'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜Client前端 |' '测试环境海外接柜 Client 专项'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜PDA前端 |' '测试环境海外接柜 PDA 专项'
|
||||
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
|
||||
Assert-NotContains $testTaskRegistry '01a05b10-a5fc-7d51-8203-c671f3a823ad' '测试登记开发总控'
|
||||
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
|
||||
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
|
||||
Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目'
|
||||
@@ -136,6 +141,38 @@ Assert-Contains $devTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global-
|
||||
Assert-Contains $devTaskRegistry '`01a06c0b-0515-7c60-b329-77342b54e666`' '开发环境全流程总控'
|
||||
Assert-NotContains $devTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '开发登记测试总控'
|
||||
Assert-NotContains $devTaskRegistry 'c64729ff-6710-43c6-8ef2-d84e9666cc1e' '开发登记测试 projectId'
|
||||
|
||||
# 业务文档已由开发批次目录收敛为稳定业务域,根快速路由不得继续引用已删除阶段目录。
|
||||
foreach ($businessDocsEntry in @(
|
||||
'docs/订单与客户/README.md',
|
||||
'docs/入库测量与计费/README.md',
|
||||
'docs/国内货柜履约/README.md',
|
||||
'docs/海外仓业务/README.md'
|
||||
)) {
|
||||
Assert-Contains $rootAgents $businessDocsEntry "根业务域入口 $businessDocsEntry"
|
||||
}
|
||||
foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) {
|
||||
Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry"
|
||||
}
|
||||
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
|
||||
|
||||
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
|
||||
Assert-Contains $rootAgents '入库扣货、解除与凭证' '根入库扣货路由'
|
||||
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
|
||||
Assert-Contains $backendAgents '入库扣货、解除、履约门禁与凭证' '后端入库扣货路由'
|
||||
Assert-Contains $rootAgents '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
|
||||
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
|
||||
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由'
|
||||
Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
|
||||
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
|
||||
Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由'
|
||||
Assert-Contains $rootAgents '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
|
||||
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
|
||||
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
|
||||
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
|
||||
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
|
||||
Assert-Contains $backendAgents '尾程订单控制与柜级入仓计划转换' '后端尾程订单控制路由'
|
||||
$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
|
||||
$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
|
||||
foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) {
|
||||
@@ -144,6 +181,8 @@ foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains
|
||||
}
|
||||
Assert-Contains $scriptsAgents 'client-quick-order-test-env-acceptance.mjs' '脚本指引 Client 快速下单入口'
|
||||
Assert-Contains $scriptsAgents 'admin-test-env-modules/service-center-flow.mjs' '脚本指引服务中心验收模块'
|
||||
Assert-Contains $scriptsAgents 'admin-test-env-modules/phase4-tail-order-controls.mjs' '脚本指引尾程单控制模块'
|
||||
Assert-Contains $scriptsAgents 'admin-test-env-modules/phase5-terminal-drayage.mjs' '脚本指引目的港码头拖车模块'
|
||||
Assert-Contains $scriptsAgents 'script-documentation-contract.test.mjs' '脚本说明防漏门禁'
|
||||
Assert-Contains $scriptsAgents '不得散落到 `src/test/scripts/`' '服务局部脚本目录边界'
|
||||
Assert-Path 'baiyue-firstleg/scripts/AGENTS.md'
|
||||
@@ -152,6 +191,7 @@ Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs'
|
||||
Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同'
|
||||
Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同'
|
||||
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
|
||||
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
|
||||
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
|
||||
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
|
||||
Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
|
||||
@@ -171,6 +211,9 @@ $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md')
|
||||
Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码'
|
||||
Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口'
|
||||
Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口'
|
||||
Assert-Contains $lastlegAgents 'LastlegOrderCommandService.java' 'Lastleg 尾程订单命令入口'
|
||||
Assert-Contains $lastlegAgents '固定八行完整快照' 'Lastleg 人工节点快照边界'
|
||||
Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-container' 'Lastleg 整柜转换正式入口'
|
||||
|
||||
# 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。
|
||||
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
|
||||
@@ -319,6 +362,11 @@ Assert-Contains $qualificationOpenApi 'BUSINESS_LICENSE 不允许走此入口' '
|
||||
Assert-Contains $clientCompanyProfile '法人身份证已保存,尚未提交审核' 'Client 法人身份证单独上传反馈'
|
||||
Assert-NotContains $clientCompanyProfile '当前文件服务尚未开放法人身份证单独上传或替换' 'Client 已移除法人身份证旧阻断'
|
||||
$truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml')
|
||||
$terminalDrayageOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml')
|
||||
$terminalDrayageService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java')
|
||||
$lastlegOrderCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java')
|
||||
$adminTerminalDrayages = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/terminalDrayages.ts')
|
||||
$adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
|
||||
$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
|
||||
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
|
||||
$cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java')
|
||||
@@ -338,6 +386,20 @@ Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划
|
||||
Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性'
|
||||
Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦'
|
||||
Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
|
||||
Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口'
|
||||
Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径'
|
||||
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
|
||||
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI'
|
||||
Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现'
|
||||
Assert-Contains $terminalDrayageService 'context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现'
|
||||
Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费'
|
||||
Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现'
|
||||
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口'
|
||||
Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口'
|
||||
Assert-Contains $adminAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界'
|
||||
Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界'
|
||||
Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界'
|
||||
Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁'
|
||||
Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段'
|
||||
Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程'
|
||||
@@ -621,7 +683,7 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/contai
|
||||
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
|
||||
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $rootAgents '柜级入仓计划进度与转换' '根柜级计划入口'
|
||||
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
|
||||
Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选'
|
||||
Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选'
|
||||
Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选'
|
||||
@@ -655,7 +717,7 @@ $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/m
|
||||
$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java')
|
||||
Assert-Contains $rootAgents '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
|
||||
Assert-Contains $rootAgents '海外业务费用来源与客户财务' '根海外费用快速路由'
|
||||
Assert-Contains $adminAgents '财务与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
|
||||
Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
|
||||
Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由'
|
||||
Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由'
|
||||
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
|
||||
|
||||
@@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary
|
||||
const summary = await buildQualitySummary(repositoryRoot)
|
||||
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
|
||||
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
|
||||
assert.equal(summary.openapi.documents, 39)
|
||||
assert.equal(summary.openapi.documents, 40)
|
||||
assert.equal(summary.openapi.coveredServices.length, 8)
|
||||
assert.equal(summary.openapi.codeFirstServices.length, 7)
|
||||
assert.equal(baseline.mode, 'report-only')
|
||||
|
||||
Reference in New Issue
Block a user