优化
This commit is contained in:
@@ -642,7 +642,7 @@ export class AcceptanceRun {
|
||||
const fixtureMarker = `${originalMarker}-LOAD`
|
||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound',
|
||||
'phase3LoadingRequireSharedOverflow']
|
||||
'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount']
|
||||
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
|
||||
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
|
||||
}]))
|
||||
@@ -674,9 +674,25 @@ export class AcceptanceRun {
|
||||
orderId: fixture.orderId || this.createdResources.orders?.orderId,
|
||||
inboundId: fixture.inboundId,
|
||||
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
|
||||
const incrementalMarker = `${originalMarker}-LOAD-INC`
|
||||
this.marker = incrementalMarker
|
||||
this.context.phase3LoadingRequireSharedOverflow = false
|
||||
this.context.phase3LoadingIncrementalPackageCount = 2
|
||||
await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this))
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this))
|
||||
const incrementalSource = this.createdResources.inboundMeasurement || this.createdResources.orders
|
||||
const incrementalFixture = incrementalSource ? {
|
||||
marker: originalMarker,
|
||||
sourceMarker: incrementalMarker,
|
||||
tenantId: incrementalSource.tenantId,
|
||||
orderId: incrementalSource.orderId,
|
||||
inboundId: incrementalSource.inboundId,
|
||||
} : { marker: originalMarker, failed: true }
|
||||
this.createdResources.orchestrationFixtures = {
|
||||
...(this.createdResources.orchestrationFixtures || {}),
|
||||
phase3Loading: loadingFixture,
|
||||
phase3LoadingIncremental: incrementalFixture,
|
||||
inboundMeasurement: loadingFixture,
|
||||
}
|
||||
this.marker = originalMarker
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function run(context) {
|
||||
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` |
|
||||
@@ -62,9 +62,11 @@ export async function run(context) {
|
||||
|
||||
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
|
||||
|
||||
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货/PDA complete -> 五类证据 -> 报关及下游门禁 -> loading complete -> LOADED`。
|
||||
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> 拖车与报关清关门禁 -> PDA task complete 同事务完成 task/plan/inbound -> LOADED`。
|
||||
|
||||
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
|
||||
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
|
||||
|
||||
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:服务端预检同柜 `RELEASED` 订舱、`COMPLETED` 拖车、五类 ACTIVE 证据和 bridge,缺项返回 A0409 且 task/plan/inbound 零写入;全部满足后同一事务推进 `LOADED`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
|
||||
|
||||
部署后需要定向验证该完整链时,模块顺序固定为:
|
||||
|
||||
|
||||
@@ -87,10 +87,16 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
||||
return ok(permissions)
|
||||
}
|
||||
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
|
||||
return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] })
|
||||
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
|
||||
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
|
||||
pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE',
|
||||
lastChangedAt: '2026-08-10T09:00:00' }] })
|
||||
}
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
|
||||
return ok({ records: [{ customerId: '11', statementId: '1', remark: marker }] })
|
||||
return ok({ records: [{ customerId: '11', statementId: '1', ledgerId: '1',
|
||||
occurredAt: '2026-08-10T09:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL',
|
||||
documentId: '1', documentNo: 'REC-1', receivableChange: 100, payableChange: 0,
|
||||
outstandingReceivableAfter: 100, operatorName: 'operator-7001', remark: marker }] })
|
||||
}
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) {
|
||||
return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] })
|
||||
@@ -225,6 +231,10 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
|
||||
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
|
||||
assert.match(source, /balance.*recharge.*prepayment/)
|
||||
assert.match(source, /accountReadback/)
|
||||
assert.match(source, /accountInformationComplete/)
|
||||
assert.match(source, /ledgerInformationComplete/)
|
||||
assert.match(source, /客户账户基本信息、四项金额和最近变动时间完整/)
|
||||
assert.match(source, /客户账户流水时间、业务类型、关联单据、金额和操作人完整/)
|
||||
for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) {
|
||||
assert.match(source, new RegExp(`['"]${action}['"]`))
|
||||
}
|
||||
|
||||
@@ -129,6 +129,18 @@ function accountCustomerId(value) { return id(value?.customerId ?? value?.tenant
|
||||
function hasWalletField(value) {
|
||||
return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field))
|
||||
}
|
||||
function accountInformationComplete(value) {
|
||||
return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt']
|
||||
.every((field) => present(value?.[field]))
|
||||
&& ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable']
|
||||
.every((field) => Number.isFinite(Number(value?.[field])))
|
||||
}
|
||||
function ledgerInformationComplete(value) {
|
||||
return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName']
|
||||
.every((field) => present(value?.[field]))
|
||||
&& ['receivableChange', 'payableChange', 'outstandingReceivableAfter']
|
||||
.every((field) => Number.isFinite(Number(value?.[field])))
|
||||
}
|
||||
async function verifyCustomerAccountAndLedger(context, fixture) {
|
||||
const customerId = id(fixture.customerId)
|
||||
const accountResponse = await context.request('GET',
|
||||
@@ -138,6 +150,9 @@ async function verifyCustomerAccountAndLedger(context, fixture) {
|
||||
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
|
||||
Boolean(account) && !accounts.some(hasWalletField),
|
||||
`customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS)
|
||||
context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse,
|
||||
accountInformationComplete(account),
|
||||
`customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS)
|
||||
const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`)
|
||||
const ledger = accountRows(ledgerResponse)
|
||||
const rowsForCustomer = ledger.filter((item) => {
|
||||
@@ -155,6 +170,10 @@ async function verifyCustomerAccountAndLedger(context, fixture) {
|
||||
context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse,
|
||||
rowsForCustomer.length === ledger.length && containsRoundTrip,
|
||||
`customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS)
|
||||
const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item))
|
||||
context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse,
|
||||
rowsForCustomer.length > 0 && incompleteLedger.length === 0,
|
||||
`customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS)
|
||||
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
|
||||
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ export async function run(context) {
|
||||
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
|
||||
if (!frozenTransportMatches) return
|
||||
const allocatedContainerBookingId = ''
|
||||
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
||||
if (['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)) {
|
||||
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
|
||||
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
|
||||
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -266,14 +266,16 @@ function buildPayload(context, profile, master, productImageFileId) {
|
||||
cargoes: BOXES.map((box, index) => {
|
||||
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
|
||||
const unitsPerPackage = 10 + index
|
||||
const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1
|
||||
return {
|
||||
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
|
||||
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
|
||||
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
|
||||
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
|
||||
unitsPerPackage, packageCount: 1, grossWeightKg: 10 + index,
|
||||
volumeCbm, totalQuantity: unitsPerPackage, netWeightKg: 9 + index,
|
||||
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * 8.5, currency: 'USD',
|
||||
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
|
||||
volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
|
||||
netWeightKg: (9 + index) * packageCount,
|
||||
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
|
||||
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
|
||||
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
|
||||
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
|
||||
@@ -294,7 +296,7 @@ function assertCargoContract(context, response, cargoes, payload) {
|
||||
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
|
||||
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
|
||||
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
|
||||
&& Number(actual?.packageCount) === 1 && Number(actual?.totalQuantity) === expected.totalQuantity
|
||||
&& Number(actual?.packageCount) === expected.packageCount && Number(actual?.totalQuantity) === expected.totalQuantity
|
||||
&& Number(actual?.netWeightKg) === expected.netWeightKg
|
||||
&& Number(actual?.unitDeclaredValue) === expected.unitDeclaredValue
|
||||
&& actual?.material === expected.material && actual?.productUsage === expected.usage
|
||||
|
||||
@@ -130,7 +130,9 @@ export async function run(context) {
|
||||
const containerBookingId = id(container?.containerBookingId)
|
||||
const loadingPlanId = id(container?.loadingPlanId)
|
||||
if (!tenantId || !parentOrderId || !inboundId || !containerBookingId
|
||||
|| !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) {
|
||||
|| !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus)
|
||||
|| (container?.finalStatus === 'AWAITING_REVIEW'
|
||||
&& (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) {
|
||||
context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (path) => readFileSync(resolve(here, path), 'utf8')
|
||||
const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
|
||||
const pdaOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml')
|
||||
const entry = read('../admin-test-env-acceptance.mjs')
|
||||
const loading = read('./phase3-loading.mjs')
|
||||
const convergence = read('./phase3-pda-convergence.mjs')
|
||||
const customs = read('./phase3-customs.mjs')
|
||||
const finalize = read('./phase3-loading-finalize.mjs')
|
||||
|
||||
function ordered(source, fragments) {
|
||||
let cursor = -1
|
||||
for (const fragment of fragments) {
|
||||
const next = source.indexOf(fragment, cursor + 1)
|
||||
assert.ok(next > cursor, `${fragment} must appear in frozen order`)
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
test('OpenAPI freezes LOADING incremental plan capabilities and optimistic versions', () => {
|
||||
for (const endpoint of [
|
||||
'/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:',
|
||||
'/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:',
|
||||
]) assert.ok(firstlegOpenApi.includes(endpoint), `${endpoint} missing`)
|
||||
for (const field of [
|
||||
'inboundId', 'inboundDetailId', 'packageCount', 'expectedVersion', 'operationId',
|
||||
'expectedPlanVersion', 'expectedItemVersion', 'canAssign', 'canEdit', 'blockedReason',
|
||||
'finalCompletionStatus', 'pdaTaskId', 'pdaTaskStatus', 'pdaTaskVersion',
|
||||
'pickedPackageCount', 'minPackageCount', 'canAdjust', 'canRemove',
|
||||
]) assert.match(firstlegOpenApi, new RegExp(`\\b${field}\\b`))
|
||||
assert.match(firstlegOpenApi, /enum: \[NOT_STARTED, IN_PROGRESS, COMPLETED\]/)
|
||||
})
|
||||
|
||||
test('default fixture publishes a second marker-owned inbound for the same-plan increment', () => {
|
||||
assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD-INC`/)
|
||||
assert.match(entry, /orders-phase3-loading-incremental-fixture/)
|
||||
assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/)
|
||||
assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/)
|
||||
assert.match(entry, /phase3LoadingIncremental: incrementalFixture/)
|
||||
assert.match(entry, /marker: originalMarker/)
|
||||
assert.doesNotMatch(entry, /phase3LoadingIncremental[\s\S]{0,300}(history|fallback|latest)/i)
|
||||
})
|
||||
|
||||
test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => {
|
||||
ordered(loading, [
|
||||
'PDA 原子批次完成拣货并等待人工确认',
|
||||
'五类装柜证据完整且铅封号持久化',
|
||||
'LOADING 阶段向同一 PDA task 增加入库单',
|
||||
'增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁',
|
||||
'增量后旧 inventory token 明确 A0409 并要求刷新',
|
||||
'增量旧 token 冲突后 plan/task/items 零写入',
|
||||
'未拣动态增量明细允许删除',
|
||||
'未拣动态增量明细允许调整数量',
|
||||
'调整数量递增 plan/task/item 版本并保持稳定任务',
|
||||
'重新增量后刷新 PDA task detail',
|
||||
'重新增量后刷新 inventory snapshot',
|
||||
'动态新增项拣完后同一任务重新进入 AWAITING_REVIEW',
|
||||
'已拣项减量低于 minPackageCount 返回 A0409',
|
||||
'已拣项删除返回 A0409',
|
||||
'已拣项删除和减量失败后 plan/task/items 零写入',
|
||||
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
|
||||
])
|
||||
assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/)
|
||||
assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/)
|
||||
assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/)
|
||||
assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/)
|
||||
assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/)
|
||||
})
|
||||
|
||||
test('downstream modules keep the task pending until the transactional PDA final complete', () => {
|
||||
assert.match(convergence, /summary\?\.status === 'LOADING'/)
|
||||
assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/)
|
||||
assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/)
|
||||
ordered(finalize, [
|
||||
'最终完成前拖车与报关清关门禁属于同一稳定柜次',
|
||||
'最终完成前四元身份稳定且任务等待人工确认',
|
||||
'读取最终完成并发前计划即时版本',
|
||||
'Admin 增量与 PDA final complete 并发恰好一个事务成功',
|
||||
'订舱拖车报关齐备后完成装柜',
|
||||
'统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移',
|
||||
'入库单终态 LOADED、版本递增且审计历史存在',
|
||||
'统一硬门禁通过后五类有效证据仍完整',
|
||||
'COMPLETED 后新增计划明细被拒绝',
|
||||
'COMPLETED 后调整计划明细被拒绝',
|
||||
'COMPLETED 后删除计划明细被拒绝',
|
||||
])
|
||||
assert.match(finalize, /`\$\{taskPath\}\/complete`/)
|
||||
assert.match(finalize, /await Promise\.all\(/)
|
||||
assert.match(finalize, /completeWon !== adjustWon/)
|
||||
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
|
||||
})
|
||||
|
||||
test('PDA final completion OpenAPI freezes zero-write gate semantics', () => {
|
||||
assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/)
|
||||
assert.match(pdaOpenApi, /先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据/)
|
||||
assert.match(pdaOpenApi, /任何阻断返回A0409且task、plan、inbound仍保持LOADING/)
|
||||
assert.match(pdaOpenApi, /全部满足后在同一事务内结算资源、完成task\/plan并推进inbound为LOADED/)
|
||||
})
|
||||
@@ -50,8 +50,11 @@ export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
|
||||
const orderId = id(fixture?.orderId)
|
||||
const taskId = id(fixture?.canonicalPdaTaskId); const loadingPlanId = id(fixture?.loadingPlanId)
|
||||
const containerBookingId = id(fixture?.containerBookingId)
|
||||
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|
||||
|| fixture?.loadingPlanStatus !== 'COMPLETED') {
|
||||
|| !taskId || !loadingPlanId || !containerBookingId
|
||||
|| fixture?.loadingPlanStatus !== 'LOADING' || fixture?.finalStatus !== 'AWAITING_REVIEW') {
|
||||
context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS)
|
||||
return
|
||||
@@ -66,30 +69,93 @@ export async function run(context) {
|
||||
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
|
||||
|
||||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||||
const completeResponse = await request(context, 'POST',
|
||||
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: version, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
|
||||
const truck = context.createdResources?.serviceCenterFlow
|
||||
const customs = context.createdResources?.phase3Customs
|
||||
context.assert(MODULE, '最终完成前拖车与报关清关门禁属于同一稳定柜次', beforeResponse,
|
||||
truck?.marker === context.marker && truck?.finalStatus === 'COMPLETED'
|
||||
&& id(truck?.containerBookingId) === containerBookingId
|
||||
&& customs?.marker === context.marker && customs?.finalStatus === 'CLEARANCE_COMPLETED'
|
||||
&& id(customs?.containerBookingId) === containerBookingId,
|
||||
`truck=${truck?.finalStatus || 'missing'}, customs=${customs?.finalStatus || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
|
||||
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
|
||||
const taskBeforeResponse = await context.request('GET', taskPath)
|
||||
const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse,
|
||||
id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId
|
||||
&& id(taskBefore?.loadingPlanId) === loadingPlanId
|
||||
&& id(taskBefore?.containerBookingId) === containerBookingId
|
||||
&& taskBefore?.taskStatus === 'LOADING' && taskBefore?.workflowStatus === 'AWAITING_REVIEW',
|
||||
`taskId=${id(taskBefore?.id) || 'missing'}, workflow=${taskBefore?.workflowStatus || 'missing'}, version=${taskBefore?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(Number(taskBefore?.version))) return
|
||||
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`
|
||||
const planBeforeRaceResponse = await context.request('GET', planPath)
|
||||
const planBeforeRace = context.apiSuccess(MODULE, '读取最终完成并发前计划即时版本', planBeforeRaceResponse, '', OPTIONS)
|
||||
const raceItem = planBeforeRace?.items?.find?.((item) => Number(item?.pickedPackageCount) > 0)
|
||||
context.assert(MODULE, '最终完成并发使用同一已拣计划明细与即时版本', planBeforeRaceResponse,
|
||||
Boolean(raceItem?.id) && Number.isInteger(Number(planBeforeRace?.version))
|
||||
&& Number.isInteger(Number(raceItem?.version)),
|
||||
`planVersion=${planBeforeRace?.version ?? 'missing'}, itemId=${id(raceItem?.id) || 'missing'}, itemVersion=${raceItem?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!raceItem?.id || !Number.isInteger(Number(planBeforeRace?.version))) return
|
||||
let completeBody = {
|
||||
expectedTaskVersion: Number(taskBefore.version),
|
||||
operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker,
|
||||
}
|
||||
let completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128)
|
||||
const concurrentAdjustBody = {
|
||||
packageCount: Number(raceItem.packageCount), expectedPlanVersion: Number(planBeforeRace.version),
|
||||
expectedItemVersion: Number(raceItem.version), operationId: `${context.marker}-race-adjust`.slice(0, 128),
|
||||
}
|
||||
const [initialCompleteResponse, concurrentAdjustResponse] = await Promise.all([
|
||||
request(context, 'POST', `${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey }),
|
||||
request(context, 'PATCH',
|
||||
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(raceItem.id))}`,
|
||||
concurrentAdjustBody),
|
||||
])
|
||||
const completeWon = initialCompleteResponse.http === 200 && codeOf(initialCompleteResponse) === '200'
|
||||
const adjustWon = concurrentAdjustResponse.http === 200 && codeOf(concurrentAdjustResponse) === '200'
|
||||
context.assert(MODULE, 'Admin 增量与 PDA final complete 并发恰好一个事务成功', initialCompleteResponse,
|
||||
completeWon !== adjustWon
|
||||
&& (completeWon ? codeOf(concurrentAdjustResponse) === 'A0409' : codeOf(initialCompleteResponse) === 'A0409'),
|
||||
`completeCode=${codeOf(initialCompleteResponse)}, adjustCode=${codeOf(concurrentAdjustResponse)}, completeRequestId=${initialCompleteResponse.requestId || 'missing'}, adjustRequestId=${concurrentAdjustResponse.requestId || 'missing'}`, OPTIONS)
|
||||
|
||||
let completeResponse = initialCompleteResponse
|
||||
if (adjustWon) {
|
||||
const taskAfterRaceResponse = await context.request('GET', taskPath)
|
||||
const taskAfterRace = context.apiSuccess(MODULE, '增量赢得并发后刷新 PDA 任务版本', taskAfterRaceResponse, '', OPTIONS)
|
||||
const planAfterRaceResponse = await context.request('GET', planPath)
|
||||
const planAfterRace = context.apiSuccess(MODULE, '增量赢得并发后回查唯一计划事实', planAfterRaceResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'final complete 失败方零写入且增量版本成为唯一事实', taskAfterRaceResponse,
|
||||
taskAfterRace?.taskStatus === 'LOADING' && taskAfterRace?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& Number(taskAfterRace?.version) > Number(taskBefore?.version)
|
||||
&& Number(planAfterRace?.version) > Number(planBeforeRace?.version),
|
||||
`taskVersion=${taskAfterRace?.version ?? 'missing'}, planVersion=${planAfterRace?.version ?? 'missing'}`, OPTIONS)
|
||||
completeBody = {
|
||||
expectedTaskVersion: Number(taskAfterRace?.version),
|
||||
operationId: `${context.marker}-pda-final-complete-retry`.slice(0, 128), remark: context.marker,
|
||||
}
|
||||
completeKey = `${context.marker}-p3-complete-after-race-refresh`.slice(0, 128)
|
||||
completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody,
|
||||
{ 'Idempotency-Key': completeKey })
|
||||
}
|
||||
const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS)
|
||||
const evidences = completed?.evidences?.evidences || []
|
||||
context.assert(MODULE, '统一硬门禁通过后推进 LOADED 且五类有效证据仍完整', completeResponse,
|
||||
completed?.mainStatus === 'LOADED' && fixture.evidenceTypes?.every?.((type) =>
|
||||
evidences.some((item) => item.evidenceType === type && item.status !== 'VOID')),
|
||||
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${evidences.length}`, OPTIONS)
|
||||
context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse,
|
||||
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
|
||||
&& id(completed?.id) === taskId && id(completed?.loadingPlanId) === loadingPlanId
|
||||
&& id(completed?.containerBookingId) === containerBookingId && id(completed?.inboundId) === inboundId,
|
||||
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const replayResponse = await request(context, 'POST',
|
||||
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: version, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
|
||||
`${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey })
|
||||
context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse,
|
||||
replayResponse.http === 200 && String(replayResponse.body?.code) === '200'
|
||||
&& replayResponse.body?.data?.mainStatus === 'LOADED',
|
||||
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.mainStatus || 'missing'}`, OPTIONS)
|
||||
&& replayResponse.body?.data?.taskStatus === 'COMPLETED'
|
||||
&& id(replayResponse.body?.data?.id) === taskId,
|
||||
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const finalResponse = await context.request('GET', detailPath)
|
||||
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
|
||||
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === version + 1
|
||||
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) > version
|
||||
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING'
|
||||
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
|
||||
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
|
||||
@@ -114,7 +180,10 @@ export async function run(context) {
|
||||
const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const immutableEvidenceResponse = await context.request('GET', evidencePath)
|
||||
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
|
||||
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(id(fixture.loadingPlanId))}`
|
||||
context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse,
|
||||
fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) =>
|
||||
item.evidenceType === type && item.status !== 'VOID')),
|
||||
`evidences=${immutableEvidence?.evidences?.length ?? 'missing'}`, OPTIONS)
|
||||
const immutablePlanResponse = await context.request('GET', planPath)
|
||||
const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS)
|
||||
const finalVersion = Number(finalData?.summary?.version)
|
||||
@@ -130,18 +199,19 @@ export async function run(context) {
|
||||
})
|
||||
|
||||
const completeNewKey = await request(context, 'POST',
|
||||
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: finalVersion, remark: `${context.marker}-loaded-new-key` },
|
||||
`${taskPath}/complete`,
|
||||
{ expectedTaskVersion: Number(completed?.version),
|
||||
operationId: `${context.marker}-pda-final-complete-new-key`.slice(0, 128), remark: `${context.marker}-loaded-new-key` },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-loaded-new-key`.slice(0, 128) })
|
||||
context.assert(MODULE, 'LOADED 后使用新键重复 complete 幂等保持终态', completeNewKey,
|
||||
completeNewKey.http === 200 && codeOf(completeNewKey) === '200'
|
||||
&& completeNewKey.body?.data?.mainStatus === 'LOADED',
|
||||
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.mainStatus || 'missing'}`, OPTIONS)
|
||||
&& completeNewKey.body?.data?.taskStatus === 'COMPLETED',
|
||||
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const completeSameKeyDifferentBody = await request(context, 'POST',
|
||||
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: version, remark: `${context.marker}-different-complete-body` },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
|
||||
`${taskPath}/complete`,
|
||||
{ ...completeBody, remark: `${context.marker}-different-complete-body` },
|
||||
{ 'Idempotency-Key': completeKey })
|
||||
context.addStep(MODULE, 'LOADED complete 同键异参返回冲突', completeSameKeyDifferentBody,
|
||||
completeSameKeyDifferentBody.http === 200 && codeOf(completeSameKeyDifferentBody) === 'A0409'
|
||||
&& Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId),
|
||||
@@ -175,6 +245,34 @@ export async function run(context) {
|
||||
&& Boolean(immutableTransport.requestId) && Boolean(immutableTransport.traceId),
|
||||
`HTTP=${immutableTransport.http}, code=${codeOf(immutableTransport)}, requestId=${immutableTransport.requestId || 'missing'}, traceId=${immutableTransport.traceId || 'missing'}`, OPTIONS)
|
||||
|
||||
const completedPlanItem = immutablePlan?.items?.[0]
|
||||
const assignmentAfterCompleted = await request(context, 'POST',
|
||||
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/assignments`, {
|
||||
inboundId: fixture.incrementalInboundId || inboundId,
|
||||
inboundDetailId: id(completedPlanItem?.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(immutablePlan?.version),
|
||||
operationId: `${context.marker}-assign-after-completed`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, 'COMPLETED 后新增计划明细被拒绝', assignmentAfterCompleted,
|
||||
assignmentAfterCompleted.http === 200 && codeOf(assignmentAfterCompleted) === 'A0409',
|
||||
`HTTP=${assignmentAfterCompleted.http}, code=${codeOf(assignmentAfterCompleted)}`, OPTIONS)
|
||||
const adjustAfterCompleted = await request(context, 'PATCH',
|
||||
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
|
||||
packageCount: Number(completedPlanItem?.packageCount || 1), expectedPlanVersion: Number(immutablePlan?.version),
|
||||
expectedItemVersion: Number(completedPlanItem?.version), operationId: `${context.marker}-adjust-after-completed`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, 'COMPLETED 后调整计划明细被拒绝', adjustAfterCompleted,
|
||||
adjustAfterCompleted.http === 200 && codeOf(adjustAfterCompleted) === 'A0409',
|
||||
`HTTP=${adjustAfterCompleted.http}, code=${codeOf(adjustAfterCompleted)}`, OPTIONS)
|
||||
const deleteAfterCompleted = await request(context, 'DELETE',
|
||||
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
|
||||
expectedPlanVersion: Number(immutablePlan?.version), expectedItemVersion: Number(completedPlanItem?.version),
|
||||
operationId: `${context.marker}-delete-after-completed`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, 'COMPLETED 后删除计划明细被拒绝', deleteAfterCompleted,
|
||||
deleteAfterCompleted.http === 200 && codeOf(deleteAfterCompleted) === 'A0409',
|
||||
`HTTP=${deleteAfterCompleted.http}, code=${codeOf(deleteAfterCompleted)}`, OPTIONS)
|
||||
|
||||
const afterBoundaryResponse = await context.request('GET', detailPath)
|
||||
const afterBoundary = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的入库快照', afterBoundaryResponse, '', OPTIONS)
|
||||
const afterEvidenceResponse = await context.request('GET', evidencePath)
|
||||
|
||||
@@ -652,39 +652,28 @@ export async function run(context) {
|
||||
atomicBatchReplayVerified,
|
||||
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const completeResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
|
||||
expectedTaskVersion: Number(pickedCanonical?.version),
|
||||
operationId: `${context.marker}-bridge-complete`.slice(0, 128), remark: context.marker,
|
||||
})
|
||||
const completedCanonical = context.apiSuccess(MODULE, '人工确认柜次 PDA 装柜完成', completeResponse, '', OPTIONS)
|
||||
const directPdaCompletion = completedCanonical?.taskStatus === 'COMPLETED'
|
||||
&& completedCanonical?.workflowStatus === 'COMPLETED'
|
||||
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
|
||||
directPdaCompletion && completedCanonical?.items?.every?.((item) => item.itemStatus === 'PICKED'),
|
||||
`taskStatus=${completedCanonical?.taskStatus || 'missing'}, workflow=${completedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
||||
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', completionOperationsResponse,
|
||||
Array.isArray(completionOperations)
|
||||
&& completionOperations.some((item) => item.actionType === 'CLAIM'
|
||||
const preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
|
||||
const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前原子 Batch 审计', preFinalOperationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', preFinalOperationsResponse,
|
||||
Array.isArray(preFinalOperations)
|
||||
&& preFinalOperations.some((item) => item.actionType === 'CLAIM'
|
||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||
&& completionOperations.some((item) => item.actionType === 'BATCH'
|
||||
&& preFinalOperations.some((item) => item.actionType === 'BATCH'
|
||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||
&& completionOperations.some((item) => item.actionType === 'COMPLETE_LOADING'
|
||||
&& id(item.operatorId) && Boolean(item.createdAt))
|
||||
&& !completionOperations.some((item) => item.actionType === 'REVIEW'),
|
||||
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
&& !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING')
|
||||
&& !preFinalOperations.some((item) => item.actionType === 'REVIEW'),
|
||||
`actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
|
||||
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse,
|
||||
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
|
||||
context.assert(MODULE, '下游门禁前柜次计划保持 LOADING 且等待最终完成', pickedPlanResponse,
|
||||
pickedPlan?.status === 'LOADING' && pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'
|
||||
&& id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING',
|
||||
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
|
||||
context.assert(MODULE, 'PDA 完成后预装清单仍为 CONFIRMED 且归属稳定', preload,
|
||||
context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload,
|
||||
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
|
||||
&& preloadData?.status === 'CONFIRMED',
|
||||
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
|
||||
@@ -697,18 +686,6 @@ export async function run(context) {
|
||||
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(loadingVersion)) return
|
||||
|
||||
// COMPLETE_LOADING is the only positive completion action; REVIEW is a
|
||||
// negative compatibility assertion after the task is already complete.
|
||||
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
|
||||
expectedTaskVersion: Number(completedCanonical?.version),
|
||||
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
|
||||
})
|
||||
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
|
||||
directPdaCompletion
|
||||
? canonicalReview.http === 200 && codeOf(canonicalReview) === 'A0409'
|
||||
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
|
||||
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
|
||||
|
||||
context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
|
||||
'本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
|
||||
|
||||
@@ -759,8 +736,9 @@ export async function run(context) {
|
||||
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
|
||||
|
||||
if (evidenceType === 'PHOTO_STEP_4') {
|
||||
const missingSeal = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: 'must require seal' },
|
||||
const missingSeal = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
|
||||
{ expectedTaskVersion: Number(pickedCanonical?.version),
|
||||
operationId: `${context.marker}-complete-before-seal`.slice(0, 128), remark: 'must require seal' },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-seal`.slice(0, 128) })
|
||||
context.addStep(MODULE, '缺铅封照片和锁码时拒绝完成装柜', missingSeal, failure(missingSeal),
|
||||
`HTTP=${missingSeal.http}, code=${codeOf(missingSeal)}`, OPTIONS)
|
||||
@@ -864,8 +842,10 @@ export async function run(context) {
|
||||
duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409',
|
||||
`HTTP=${duplicateVoid.http}, code=${codeOf(duplicateVoid)}`, OPTIONS)
|
||||
|
||||
const missingTypeComplete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: `${context.marker}-missing-photo-step-4` },
|
||||
const missingTypeComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
|
||||
{ expectedTaskVersion: Number(pickedCanonical?.version),
|
||||
operationId: `${context.marker}-complete-missing-photo-step-4`.slice(0, 128),
|
||||
remark: `${context.marker}-missing-photo-step-4` },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-missing-photo-step-4`.slice(0, 128) })
|
||||
context.addStep(MODULE, '作废唯一证据类型后 LOADED 门禁拒绝', missingTypeComplete,
|
||||
missingTypeComplete.http === 200 && codeOf(missingTypeComplete) === 'A0409',
|
||||
@@ -916,6 +896,178 @@ export async function run(context) {
|
||||
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
|
||||
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental
|
||||
const incrementalInboundId = id(incrementalFixture?.inboundId)
|
||||
let dynamicIncrementVerified = false
|
||||
if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
|
||||
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
|
||||
`inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS)
|
||||
} else {
|
||||
const dynamicCandidatesResponse = await context.request('GET',
|
||||
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
|
||||
const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) =>
|
||||
id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null
|
||||
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
|
||||
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
|
||||
&& id(dynamicCandidate?.warehouseId) === warehouseId
|
||||
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
|
||||
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (dynamicCandidate?.inboundDetailId) {
|
||||
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
|
||||
const taskBeforeIncrementResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const taskBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前任务', taskBeforeIncrementResponse, '', OPTIONS)
|
||||
const evidenceBeforeIncrement = JSON.stringify(evidenceListData?.evidences || [])
|
||||
const incrementResponse = await request(context, 'POST',
|
||||
`${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
|
||||
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(planBeforeIncrement?.version),
|
||||
operationId: `${context.marker}-dynamic-assign`.slice(0, 128),
|
||||
})
|
||||
const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS)
|
||||
const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
|
||||
const incrementedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
|
||||
const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
|
||||
id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id))
|
||||
const evidenceAfterIncrementResponse = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse,
|
||||
id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING'
|
||||
&& incrementedPlan?.finalCompletionStatus === 'IN_PROGRESS'
|
||||
&& id(incrementedPlan?.pdaTaskId) === canonicalTaskId && incrementedPlan?.pdaTaskStatus === 'LOADING'
|
||||
&& Number(incrementedPlan?.version) > Number(planBeforeIncrement?.version)
|
||||
&& Number(incrementedPlan?.pdaTaskVersion) > Number(taskBeforeIncrement?.version)
|
||||
&& Number(incrementalPlanItem?.version) === 0 && Number(incrementalPlanItem?.pickedPackageCount) === 0
|
||||
&& Number(incrementalPlanItem?.minPackageCount) === 1
|
||||
&& incrementalPlanItem?.canAdjust === true && incrementalPlanItem?.canRemove === true
|
||||
&& id(incrementedTask?.loadingPlanId) === loadingPlanId
|
||||
&& id(incrementedTask?.containerBookingId) === containerBookingId
|
||||
&& incrementedTask?.workflowStatus === 'IN_PROGRESS'
|
||||
&& JSON.stringify(evidenceAfterIncrement?.evidences || []) === evidenceBeforeIncrement,
|
||||
`planVersion=${incrementedPlan?.version ?? 'missing'}, taskVersion=${incrementedTask?.version ?? 'missing'}, workflow=${incrementedTask?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
if (incrementalTaskItem?.id) {
|
||||
const staleIncrementBatch = await request(context, 'POST', batchPath, {
|
||||
warehouseId, loadingPlanId, containerBookingId,
|
||||
expectedVersion: Number(incrementedTask?.version),
|
||||
inventorySnapshotToken: effectiveBatchPayload.inventorySnapshotToken,
|
||||
items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(incrementalTaskItem.version) }],
|
||||
}, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) })
|
||||
context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch,
|
||||
staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409'
|
||||
&& String(staleIncrementBatch.body?.message || '').includes('库存已变化,请刷新后重试'),
|
||||
responseEvidence(staleIncrementBatch), OPTIONS)
|
||||
const taskAfterStaleResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const taskAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量任务', taskAfterStaleResponse, '', OPTIONS)
|
||||
const planAfterStaleResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const planAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量计划', planAfterStaleResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '增量旧 token 冲突后 plan/task/items 零写入', taskAfterStaleResponse,
|
||||
Number(taskAfterStale?.version) === Number(incrementedTask?.version)
|
||||
&& taskItemsSignature(taskAfterStale) === taskItemsSignature(incrementedTask)
|
||||
&& Number(planAfterStale?.version) === Number(incrementedPlan?.version),
|
||||
`taskVersion=${taskAfterStale?.version ?? 'missing'}, planVersion=${planAfterStale?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const removeResponse = await request(context, 'DELETE',
|
||||
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(incrementalPlanItem.id))}`, {
|
||||
expectedPlanVersion: Number(incrementedPlan.version), expectedItemVersion: Number(incrementalPlanItem.version),
|
||||
operationId: `${context.marker}-dynamic-remove-unpicked`.slice(0, 128),
|
||||
})
|
||||
const removedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许删除', removeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '删除未拣项递增计划和任务版本且稳定身份不变', removeResponse,
|
||||
!removedPlan?.items?.some?.((item) => id(item?.id) === id(incrementalPlanItem.id))
|
||||
&& Number(removedPlan?.version) > Number(incrementedPlan?.version)
|
||||
&& Number(removedPlan?.pdaTaskVersion) > Number(incrementedPlan?.pdaTaskVersion)
|
||||
&& id(removedPlan?.pdaTaskId) === canonicalTaskId && removedPlan?.status === 'LOADING',
|
||||
`planVersion=${removedPlan?.version ?? 'missing'}, taskVersion=${removedPlan?.pdaTaskVersion ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const reassignResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
|
||||
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(removedPlan?.version), operationId: `${context.marker}-dynamic-reassign`.slice(0, 128),
|
||||
})
|
||||
const reassignedPlan = context.apiSuccess(MODULE, '删除后重新加入同一动态入库单', reassignResponse, '', OPTIONS)
|
||||
let effectiveDynamicPlan = reassignedPlan
|
||||
let reassignedPlanItem = reassignedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
|
||||
context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse,
|
||||
Number(dynamicCandidate?.availablePackageCount) >= 2,
|
||||
`availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (Number(dynamicCandidate?.availablePackageCount) < 2) return
|
||||
if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) {
|
||||
const adjustResponse = await request(context, 'PATCH',
|
||||
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
|
||||
packageCount: 2, expectedPlanVersion: Number(reassignedPlan?.version),
|
||||
expectedItemVersion: Number(reassignedPlanItem.version),
|
||||
operationId: `${context.marker}-dynamic-adjust`.slice(0, 128),
|
||||
})
|
||||
const adjustedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许调整数量', adjustResponse, '', OPTIONS)
|
||||
const adjustedItem = adjustedPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem.id))
|
||||
context.assert(MODULE, '调整数量递增 plan/task/item 版本并保持稳定任务', adjustResponse,
|
||||
Number(adjustedPlan?.version) > Number(reassignedPlan?.version)
|
||||
&& Number(adjustedPlan?.pdaTaskVersion) > Number(reassignedPlan?.pdaTaskVersion)
|
||||
&& Number(adjustedItem?.version) > Number(reassignedPlanItem?.version)
|
||||
&& Number(adjustedItem?.packageCount) === 2 && id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
|
||||
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
|
||||
effectiveDynamicPlan = adjustedPlan
|
||||
reassignedPlanItem = adjustedItem
|
||||
}
|
||||
const refreshedDynamicTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS)
|
||||
const refreshedDynamicTaskItem = refreshedDynamicTask?.items?.find?.((item) =>
|
||||
id(item?.loadingPlanItemId) === id(reassignedPlanItem?.id))
|
||||
const refreshedDynamicInventoryResponse = await context.request('GET',
|
||||
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const refreshedDynamicInventory = context.apiSuccess(MODULE, '重新增量后刷新 inventory snapshot', refreshedDynamicInventoryResponse, '', OPTIONS)
|
||||
const dynamicBatchResponse = await request(context, 'POST', batchPath, {
|
||||
warehouseId, loadingPlanId, containerBookingId,
|
||||
expectedVersion: Number(refreshedDynamicTask?.version),
|
||||
inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken,
|
||||
items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount),
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }],
|
||||
}, { 'Idempotency-Key': `${context.marker}-dynamic-pick`.slice(0, 128) })
|
||||
const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse,
|
||||
id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& dynamicPickedTask?.taskStatus === 'LOADING'
|
||||
&& dynamicPickedTask?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
||||
`taskId=${id(dynamicPickedTask?.id) || 'missing'}, workflow=${dynamicPickedTask?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
const pickedDynamicPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const pickedDynamicPlan = context.apiSuccess(MODULE, '动态新增项拣完后回查 minPackageCount', pickedDynamicPlanResponse, '', OPTIONS)
|
||||
const pickedDynamicPlanItem = pickedDynamicPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem?.id))
|
||||
const beforePickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS)
|
||||
const reducePickedResponse = await request(context, 'PATCH',
|
||||
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
|
||||
packageCount: Math.max(1, Number(pickedDynamicPlanItem?.minPackageCount) - 1),
|
||||
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
|
||||
operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, '已拣项减量低于 minPackageCount 返回 A0409', reducePickedResponse,
|
||||
reducePickedResponse.http === 200 && codeOf(reducePickedResponse) === 'A0409', responseEvidence(reducePickedResponse), OPTIONS)
|
||||
const deletePickedResponse = await request(context, 'DELETE',
|
||||
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
|
||||
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
|
||||
operationId: `${context.marker}-dynamic-delete-picked`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, '已拣项删除返回 A0409', deletePickedResponse,
|
||||
deletePickedResponse.http === 200 && codeOf(deletePickedResponse) === 'A0409', responseEvidence(deletePickedResponse), OPTIONS)
|
||||
const afterPickedMutationPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const afterPickedMutationPlan = context.apiSuccess(MODULE, '已拣项负向后回查计划', afterPickedMutationPlanResponse, '', OPTIONS)
|
||||
const afterPickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const afterPickedMutationTask = context.apiSuccess(MODULE, '已拣项负向后回查任务', afterPickedMutationTaskResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '已拣项删除和减量失败后 plan/task/items 零写入', afterPickedMutationPlanResponse,
|
||||
Number(afterPickedMutationPlan?.version) === Number(pickedDynamicPlan?.version)
|
||||
&& Number(afterPickedMutationTask?.version) === Number(beforePickedMutationTask?.version)
|
||||
&& taskItemsSignature(afterPickedMutationTask) === taskItemsSignature(beforePickedMutationTask),
|
||||
`planVersion=${afterPickedMutationPlan?.version ?? 'missing'}, taskVersion=${afterPickedMutationTask?.version ?? 'missing'}`, OPTIONS)
|
||||
dynamicIncrementVerified = dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
|
||||
&& Number(pickedDynamicPlanItem?.minPackageCount) === Number(reassignedPlanItem?.packageCount)
|
||||
&& Number(effectiveDynamicPlan?.version) <= Number(pickedDynamicPlan?.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const beforeHardGateResponse = await context.request('GET', detailPath)
|
||||
const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS)
|
||||
const beforeHardGateVersion = Number(beforeHardGate?.summary?.version)
|
||||
@@ -923,18 +1075,31 @@ export async function run(context) {
|
||||
beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion),
|
||||
`status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(beforeHardGateVersion)) return
|
||||
const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: beforeHardGateVersion, remark: `${context.marker}-before-downstream` },
|
||||
const beforeBlockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const beforeBlockedTask = context.apiSuccess(MODULE, '读取下游门禁拒绝前 PDA 任务快照', beforeBlockedTaskResponse, '', OPTIONS)
|
||||
const beforeBlockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const beforeBlockedPlan = context.apiSuccess(MODULE, '读取下游门禁拒绝前计划快照', beforeBlockedPlanResponse, '', OPTIONS)
|
||||
const blockedBeforeDownstream = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
|
||||
{ expectedTaskVersion: Number(beforeBlockedTask?.version),
|
||||
operationId: `${context.marker}-complete-before-downstream`.slice(0, 128), remark: `${context.marker}-before-downstream` },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
|
||||
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
|
||||
failure(blockedBeforeDownstream),
|
||||
`HTTP=${blockedBeforeDownstream.http}, code=${codeOf(blockedBeforeDownstream)}`, OPTIONS)
|
||||
blockedBeforeDownstream.http === 200 && codeOf(blockedBeforeDownstream) === 'A0409',
|
||||
responseEvidence(blockedBeforeDownstream), OPTIONS)
|
||||
const blockedDetailResponse = await context.request('GET', detailPath)
|
||||
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
|
||||
const blockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
|
||||
const blockedTask = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的 PDA 任务', blockedTaskResponse, '', OPTIONS)
|
||||
const blockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
|
||||
const blockedPlan = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的计划', blockedPlanResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'LOADED 硬门禁拒绝后 task/plan/inbound 零写入', blockedDetailResponse,
|
||||
blockedDetail?.summary?.processStatus === 'LOADING'
|
||||
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion,
|
||||
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion
|
||||
&& Number(blockedTask?.version) === Number(beforeBlockedTask?.version)
|
||||
&& taskItemsSignature(blockedTask) === taskItemsSignature(beforeBlockedTask)
|
||||
&& Number(blockedPlan?.version) === Number(beforeBlockedPlan?.version)
|
||||
&& blockedPlan?.status === beforeBlockedPlan?.status,
|
||||
`inboundStatus=${blockedDetail?.summary?.processStatus || 'missing'}, inboundVersion=${blockedDetail?.summary?.version ?? 'missing'}, taskVersion=${blockedTask?.version ?? 'missing'}, planVersion=${blockedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
context.createdResources.phase3Loading = {
|
||||
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
|
||||
@@ -946,9 +1111,11 @@ export async function run(context) {
|
||||
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
|
||||
canonicalPdaTaskId: canonicalTaskId,
|
||||
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
||||
atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
|
||||
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
|
||||
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
|
||||
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
|
||||
inventoryMutationVerified, inventoryMutationCleanup,
|
||||
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
|
||||
dynamicIncrementVerified, incrementalInboundId,
|
||||
pdaTaskVersion: Number(blockedTask?.version),
|
||||
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,26 +28,26 @@ export async function run(context) {
|
||||
}
|
||||
const loadingListResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/first-leg/loading-lists/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 完成后的装柜清单', loadingListResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜清单保留 PDA 完成终态和操作者审计', loadingListResponse,
|
||||
id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'COMPLETED'
|
||||
&& loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'
|
||||
&& Boolean(item.operatorName) && Boolean(item.occurredAt)),
|
||||
const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 待最终完成的装柜清单', loadingListResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜清单保持 LOADING 且尚无最终完成审计', loadingListResponse,
|
||||
id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'LOADING'
|
||||
&& !loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'),
|
||||
`planId=${id(loadingList?.summary?.planId) || 'missing'}, status=${loadingList?.summary?.status || 'missing'}`, OPTIONS)
|
||||
const projectionResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '入库投影与柜次事实数量和终态一致', projectionResponse,
|
||||
projection?.status === 'COMPLETED' && Number(projection?.expectedCount) > 0
|
||||
context.assert(MODULE, '入库投影数量已拣齐但仍等待最终门禁', projectionResponse,
|
||||
['AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0
|
||||
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
|
||||
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS)
|
||||
const operationsResponse = await request(context, 'GET',
|
||||
`/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '直接完成契约保留领取操作且不伪造复核历史', operationsResponse,
|
||||
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
|
||||
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
|
||||
&& Boolean(item.operatorId) && Boolean(item.createdAt))
|
||||
&& !operations.some((item) => item.actionType === 'REVIEW'),
|
||||
&& !operations.some((item) => item.actionType === 'REVIEW')
|
||||
&& !operations.some((item) => item.actionType === 'COMPLETE_LOADING'),
|
||||
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
|
||||
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
|
||||
|
||||
@@ -101,7 +101,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
|
||||
'读取柜次级 PDA 唯一事实任务',
|
||||
"claimedPlan?.status === 'LOADING'",
|
||||
"const canonicalPick = await request",
|
||||
"pickedPlan?.status === 'COMPLETED'",
|
||||
"pickedPlan?.status === 'LOADING'",
|
||||
"pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'",
|
||||
])
|
||||
assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/)
|
||||
assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/)
|
||||
@@ -225,7 +226,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
|
||||
const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch ='))
|
||||
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
|
||||
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
|
||||
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
|
||||
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified/)
|
||||
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
|
||||
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
|
||||
|
||||
@@ -427,7 +428,7 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera
|
||||
assert.equal((loading.match(/tasks\?loadingPlanId=/g) || []).length >= 7, true)
|
||||
})
|
||||
|
||||
test('phase3 loading proves stale inventory zero-write for all items and completion audit', () => {
|
||||
test('phase3 loading proves stale inventory zero-write and defers final completion audit', () => {
|
||||
const loading = source('./phase3-loading.mjs')
|
||||
assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/)
|
||||
assertOrder(loading, [
|
||||
@@ -435,9 +436,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet
|
||||
'库存变化后刷新 PDA 实时库存快照',
|
||||
'原子提交柜次 PDA 整批拣货',
|
||||
'PDA 原子批次同参同键重放保持待人工确认',
|
||||
'人工确认柜次 PDA 装柜完成',
|
||||
'回查原子 Batch 完成审计',
|
||||
'人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计',
|
||||
'回查下游门禁前原子 Batch 审计',
|
||||
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
|
||||
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
|
||||
'LOADED 硬门禁拒绝后 task/plan/inbound 零写入',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -540,7 +542,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
|
||||
assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
|
||||
assert.match(entry, /marker: originalMarker/)
|
||||
assert.match(entry, /sourceMarker: fixtureMarker/)
|
||||
assert.match(entry, /phase3Loading: loadingFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
|
||||
assert.match(entry, /phase3Loading: loadingFixture,\s*\n\s*phase3LoadingIncremental: incrementalFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
|
||||
assert.equal(run.marker.includes('-LOAD'), false)
|
||||
})
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function run(context) {
|
||||
Boolean(customerId) && Boolean(customerName),
|
||||
`customerId=${customerId || 'missing'}, customerName=${customerName || 'missing'}`, OPTIONS)
|
||||
if (!customerId || !customerName) return
|
||||
const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED')
|
||||
const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)
|
||||
&& id(containerFlow?.tenantId) === customerId
|
||||
&& Boolean(containerFlow?.containerBookingId)
|
||||
&& Boolean(containerFlow?.loadingPlanId)
|
||||
@@ -104,12 +104,6 @@ export async function run(context) {
|
||||
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
|
||||
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!appointmentId) return
|
||||
context.createdResources.serviceCenterFlow = {
|
||||
appointmentId, oceanBookingId: ocean.id,
|
||||
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
|
||||
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
|
||||
marker: context.marker, finalStatus: 'COMPLETED',
|
||||
}
|
||||
const path = `${TRUCK}/${appointmentId}`
|
||||
let version = Number(appointment.version)
|
||||
for (const [name, endpoint, status] of [
|
||||
@@ -146,4 +140,12 @@ export async function run(context) {
|
||||
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
|
||||
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
|
||||
OPTIONS)
|
||||
if (final?.status === 'COMPLETED') {
|
||||
context.createdResources.serviceCenterFlow = {
|
||||
appointmentId, oceanBookingId: ocean.id,
|
||||
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
|
||||
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
|
||||
marker: context.marker, finalStatus: 'COMPLETED',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildOrderPayload, parseArguments, validateCount } from './create-pending-review-orders.mjs'
|
||||
import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, validateCount } from './create-pending-review-orders.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -10,6 +10,13 @@ test('uses the fixed merchant account and stops at pending review', () => {
|
||||
assert.ok(source.includes("USERNAME: '17512095155'"), 'fixed username is missing')
|
||||
assert.ok(/PASSWORD:\s*'[^']*'/.test(source), 'CONFIG.PASSWORD is missing')
|
||||
assert.ok(/COUNT:\s*\d+/.test(source), 'CONFIG.COUNT is missing')
|
||||
assert.match(source, /COUNT:\s*50/)
|
||||
assert.match(source, /randomInt\(1, 31\)/)
|
||||
assert.match(source, /loadPricedRoutes/)
|
||||
assert.match(source, /destinationWarehouseCode/)
|
||||
assert.match(source, /channelCode/)
|
||||
assert.match(source, /pricingTaxMode/)
|
||||
assert.equal(PRODUCT_CATALOG.length, 8)
|
||||
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/\$\{encodeURIComponent\(orderId\)\}\/submit/)
|
||||
assert.match(source, /detail\?\.order\?\.status !== 'SUBMITTED'/)
|
||||
assert.match(source, /\/api\/merchant\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/)
|
||||
@@ -41,6 +48,8 @@ test('builds an order payload owned by the fixed profile', () => {
|
||||
channel: { channelCode: 'SEA-US', channelName: '美西海运' },
|
||||
pricingTaxMode: 'TAX_INCLUDED',
|
||||
},
|
||||
product: PRODUCT_CATALOG[0],
|
||||
packageCount: 7,
|
||||
productImageFileId: '33',
|
||||
})
|
||||
assert.equal(payload.receivingWarehouseId, '11')
|
||||
@@ -49,4 +58,25 @@ test('builds an order payload owned by the fixed profile', () => {
|
||||
assert.equal(payload.customerName, '固定测试客户')
|
||||
assert.equal(payload.cargoes.length, 1)
|
||||
assert.equal(payload.cargoes[0].productImageFileId, '33')
|
||||
assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName)
|
||||
assert.equal(payload.cargoes[0].packageCount, 7)
|
||||
assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7)
|
||||
assert.equal(payload.cargoes[0].grossWeightKg, Number((PRODUCT_CATALOG[0].cartonGrossWeightKg * 7).toFixed(3)))
|
||||
assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7)
|
||||
})
|
||||
|
||||
test('interleaves different logistics combinations before repeating one', () => {
|
||||
const route = (receivingId, channelCode, pricingTaxMode, destinationCode) => ({
|
||||
receiving: { id: receivingId }, channel: { channelCode }, pricingTaxMode,
|
||||
destination: { warehouseCode: destinationCode },
|
||||
})
|
||||
const diversified = diversifyRoutes([
|
||||
route('1', 'SEA-A', 'TAX_INCLUDED', 'A1'),
|
||||
route('1', 'SEA-A', 'TAX_INCLUDED', 'A2'),
|
||||
route('1', 'SEA-B', 'TAX_EXCLUDED', 'B1'),
|
||||
route('1', 'SEA-B', 'TAX_EXCLUDED', 'B2'),
|
||||
])
|
||||
const firstKey = `${diversified[0].channel.channelCode}|${diversified[0].pricingTaxMode}`
|
||||
const secondKey = `${diversified[1].channel.channelCode}|${diversified[1].pricingTaxMode}`
|
||||
assert.notEqual(firstKey, secondKey)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const MAX_COUNT = 100
|
||||
const MAX_ROUTE_POOL_SIZE = 40
|
||||
const MAX_DESTINATIONS_PER_LOGISTICS = 3
|
||||
|
||||
export const PRODUCT_CATALOG = [
|
||||
{ cargoName: '蓝牙音箱', cargoNameEn: 'Bluetooth speaker', hsCode: '8518210000', material: '塑料和电子元件 / Plastic and electronic components', usage: '音频播放 / Audio playback', brand: 'SOUNDTEST', modelPrefix: 'SPK', lengthCm: 42, widthCm: 32, heightCm: 28, unitsPerPackage: 8, cartonGrossWeightKg: 12.8, unitDeclaredValue: 18.5, productLengthCm: 18, productWidthCm: 10, productHeightCm: 8 },
|
||||
{ cargoName: '手机保护壳', cargoNameEn: 'Mobile phone case', hsCode: '3926909090', material: '热塑性聚氨酯 / TPU', usage: '手机保护 / Mobile phone protection', brand: 'CASETEST', modelPrefix: 'CASE', lengthCm: 48, widthCm: 38, heightCm: 32, unitsPerPackage: 100, cartonGrossWeightKg: 16.5, unitDeclaredValue: 2.2, productLengthCm: 17, productWidthCm: 8, productHeightCm: 1.5 },
|
||||
{ cargoName: '不锈钢保温杯', cargoNameEn: 'Stainless steel vacuum bottle', hsCode: '9617009000', material: '不锈钢 / Stainless steel', usage: '饮品保温 / Beverage insulation', brand: 'CUPTEST', modelPrefix: 'CUP', lengthCm: 50, widthCm: 40, heightCm: 32, unitsPerPackage: 24, cartonGrossWeightKg: 15.2, unitDeclaredValue: 9.8, productLengthCm: 8, productWidthCm: 8, productHeightCm: 25 },
|
||||
{ cargoName: '棉质短袖T恤', cargoNameEn: 'Cotton short sleeve T-shirt', hsCode: '6109100021', material: '棉 / Cotton', usage: '日常穿着 / Daily wear', brand: 'SHIRTTEST', modelPrefix: 'TS', lengthCm: 55, widthCm: 42, heightCm: 35, unitsPerPackage: 60, cartonGrossWeightKg: 18.6, unitDeclaredValue: 6.5, productLengthCm: 30, productWidthCm: 25, productHeightCm: 2 },
|
||||
{ cargoName: '瑜伽垫', cargoNameEn: 'Yoga mat', hsCode: '9506919000', material: '热塑性弹性体 / TPE', usage: '健身运动 / Fitness exercise', brand: 'YOGATEST', modelPrefix: 'YOGA', lengthCm: 65, widthCm: 45, heightCm: 45, unitsPerPackage: 10, cartonGrossWeightKg: 13.5, unitDeclaredValue: 12, productLengthCm: 61, productWidthCm: 18, productHeightCm: 18 },
|
||||
{ cargoName: '塑料收纳盒', cargoNameEn: 'Plastic storage box', hsCode: '3924900000', material: '聚丙烯 / Polypropylene', usage: '家居收纳 / Household storage', brand: 'BOXTEST', modelPrefix: 'BOX', lengthCm: 62, widthCm: 48, heightCm: 40, unitsPerPackage: 12, cartonGrossWeightKg: 17.8, unitDeclaredValue: 7.2, productLengthCm: 38, productWidthCm: 28, productHeightCm: 20 },
|
||||
{ cargoName: 'LED台灯', cargoNameEn: 'LED desk lamp', hsCode: '9405210090', material: '铝和塑料 / Aluminum and plastic', usage: '桌面照明 / Desk lighting', brand: 'LAMPTEST', modelPrefix: 'LAMP', lengthCm: 58, widthCm: 42, heightCm: 36, unitsPerPackage: 12, cartonGrossWeightKg: 14.4, unitDeclaredValue: 15.6, productLengthCm: 35, productWidthCm: 15, productHeightCm: 40 },
|
||||
{ cargoName: '宠物橡胶玩具', cargoNameEn: 'Pet rubber toy', hsCode: '4016999090', material: '天然橡胶 / Natural rubber', usage: '宠物玩耍 / Pet play', brand: 'PETTEST', modelPrefix: 'PET', lengthCm: 46, widthCm: 36, heightCm: 34, unitsPerPackage: 48, cartonGrossWeightKg: 15.6, unitDeclaredValue: 3.4, productLengthCm: 12, productWidthCm: 7, productHeightCm: 7 },
|
||||
]
|
||||
|
||||
// Local test-environment settings. Keep PASSWORD empty in committed code.
|
||||
const CONFIG = {
|
||||
GATEWAY: 'http://8.163.30.128:9000',
|
||||
USERNAME: '17512095155',
|
||||
PASSWORD: 'd6KW2ZC4vv44WJ4a',
|
||||
COUNT: 10,
|
||||
COUNT: 50,
|
||||
}
|
||||
|
||||
export function parseArguments(values) {
|
||||
@@ -63,6 +76,40 @@ function items(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function shuffled(values) {
|
||||
const result = [...values]
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const target = randomInt(index + 1)
|
||||
;[result[index], result[target]] = [result[target], result[index]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function round(value, scale) {
|
||||
return Number(value.toFixed(scale))
|
||||
}
|
||||
|
||||
function logisticsKey(route) {
|
||||
return `${route.receiving.id}|${route.channel.channelCode}|${route.pricingTaxMode}`
|
||||
}
|
||||
|
||||
export function diversifyRoutes(routes) {
|
||||
const grouped = new Map()
|
||||
for (const route of routes) {
|
||||
const key = logisticsKey(route)
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key).push(route)
|
||||
}
|
||||
const queues = shuffled([...grouped.values()]).map((group) => shuffled(group))
|
||||
const result = []
|
||||
while (queues.some((queue) => queue.length > 0)) {
|
||||
for (const queue of queues) {
|
||||
if (queue.length > 0) result.push(queue.shift())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function requireSuccess(response, action) {
|
||||
if (response.http !== 200 || String(response.body?.code) !== '200') {
|
||||
throw new Error(`${action} failed: http=${response.http}, code=${response.body?.code ?? 'missing'}, message=${response.body?.message ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'}`)
|
||||
@@ -101,7 +148,7 @@ async function request(gateway, timeoutMs, method, path, { token, body, headers
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMasterData(gateway, timeoutMs, token) {
|
||||
async function loadPricedRoutes(gateway, timeoutMs, token) {
|
||||
const domesticResponse = await request(gateway, timeoutMs, 'GET',
|
||||
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', { token })
|
||||
const domestic = records(requireSuccess(domesticResponse, 'Query active domestic warehouses'))
|
||||
@@ -112,14 +159,16 @@ async function loadMasterData(gateway, timeoutMs, token) {
|
||||
throw new Error('No active domestic receiving warehouse or FBA destination warehouse is available')
|
||||
}
|
||||
|
||||
for (const receiving of domestic) {
|
||||
const routes = []
|
||||
receivingLoop: for (const receiving of shuffled(domestic)) {
|
||||
const channelResponse = await request(gateway, timeoutMs, 'GET',
|
||||
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, { token })
|
||||
const channels = items(requireSuccess(channelResponse, 'Query active pricing channels'))
|
||||
.filter((channel) => channel?.channelCode && items(channel.pricingTaxModes).length > 0)
|
||||
for (const channel of channels) {
|
||||
for (const pricingTaxMode of channel.pricingTaxModes) {
|
||||
for (const destination of destinations) {
|
||||
for (const channel of shuffled(channels)) {
|
||||
for (const pricingTaxMode of shuffled(channel.pricingTaxModes)) {
|
||||
let logisticsDestinationCount = 0
|
||||
for (const destination of shuffled(destinations)) {
|
||||
const estimateResponse = await request(gateway, timeoutMs, 'POST',
|
||||
'/api/merchant/v1/first-leg/pricing/estimate', {
|
||||
token,
|
||||
@@ -136,13 +185,17 @@ async function loadMasterData(gateway, timeoutMs, token) {
|
||||
},
|
||||
})
|
||||
if (estimateResponse.http === 200 && String(estimateResponse.body?.code) === '200') {
|
||||
return { receiving, destination, channel, pricingTaxMode }
|
||||
routes.push({ receiving, destination, channel, pricingTaxMode })
|
||||
logisticsDestinationCount += 1
|
||||
if (routes.length >= MAX_ROUTE_POOL_SIZE) break receivingLoop
|
||||
if (logisticsDestinationCount >= MAX_DESTINATIONS_PER_LOGISTICS) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('No priced route is available for the fixed merchant account')
|
||||
if (routes.length === 0) throw new Error('No priced route is available for the fixed merchant account')
|
||||
return routes
|
||||
}
|
||||
|
||||
function testPng() {
|
||||
@@ -161,11 +214,16 @@ async function uploadProductImage(gateway, timeoutMs, token, marker) {
|
||||
return data.fileId
|
||||
}
|
||||
|
||||
export function buildOrderPayload({ marker, index, profile, master, productImageFileId }) {
|
||||
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, productImageFileId }) {
|
||||
const sequence = String(index).padStart(3, '0')
|
||||
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
|
||||
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
|
||||
const companyName = profile.tenantName || `固定客户-${marker}`
|
||||
const totalQuantity = product.unitsPerPackage * packageCount
|
||||
const grossWeightKg = round(product.cartonGrossWeightKg * packageCount, 3)
|
||||
const netWeightKg = round(grossWeightKg * 0.9, 3)
|
||||
const volumeCbm = round(product.lengthCm * product.widthCm * product.heightCm / 1_000_000 * packageCount, 6)
|
||||
const declaredValue = round(product.unitDeclaredValue * totalQuantity, 2)
|
||||
return {
|
||||
customerId: profile.id,
|
||||
customerName: companyName,
|
||||
@@ -206,30 +264,30 @@ export function buildOrderPayload({ marker, index, profile, master, productImage
|
||||
receiver: null,
|
||||
cargoes: [{
|
||||
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
|
||||
cargoName: '待审核测试产品',
|
||||
cargoNameEn: 'Pending review test product',
|
||||
hsCode: '8518210000',
|
||||
customsHsCode: '8518210000',
|
||||
cargoName: product.cargoName,
|
||||
cargoNameEn: product.cargoNameEn,
|
||||
hsCode: product.hsCode,
|
||||
customsHsCode: product.hsCode,
|
||||
packageType: 'CARTON',
|
||||
lengthCm: 30,
|
||||
widthCm: 20,
|
||||
heightCm: 20,
|
||||
unitsPerPackage: 12,
|
||||
packageCount: 2,
|
||||
grossWeightKg: 20.5,
|
||||
volumeCbm: 0.024,
|
||||
totalQuantity: 24,
|
||||
netWeightKg: 18,
|
||||
unitDeclaredValue: 8.5,
|
||||
declaredValue: 204,
|
||||
lengthCm: product.lengthCm,
|
||||
widthCm: product.widthCm,
|
||||
heightCm: product.heightCm,
|
||||
unitsPerPackage: product.unitsPerPackage,
|
||||
packageCount,
|
||||
grossWeightKg,
|
||||
volumeCbm,
|
||||
totalQuantity,
|
||||
netWeightKg,
|
||||
unitDeclaredValue: product.unitDeclaredValue,
|
||||
declaredValue,
|
||||
currency: 'USD',
|
||||
material: '塑料 / Plastic',
|
||||
usage: '接口测试 / API testing',
|
||||
model: `MODEL-${sequence}`,
|
||||
brand: 'TEST-NO-LOGO',
|
||||
productLengthCm: 12,
|
||||
productWidthCm: 8,
|
||||
productHeightCm: 6,
|
||||
material: product.material,
|
||||
usage: product.usage,
|
||||
model: `${product.modelPrefix}-${sequence}`,
|
||||
brand: product.brand,
|
||||
productLengthCm: product.productLengthCm,
|
||||
productWidthCm: product.productWidthCm,
|
||||
productHeightCm: product.productHeightCm,
|
||||
shipmentId,
|
||||
referenceId,
|
||||
upcNo: `${Date.now()}${sequence}`.slice(-12),
|
||||
@@ -289,11 +347,16 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
throw new Error(`Fixed merchant account is not ready: tenantId=${profile?.id ?? 'missing'}, status=${profile?.status ?? 'missing'}`)
|
||||
}
|
||||
|
||||
const master = await loadMasterData(gateway, timeoutMs, token)
|
||||
const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
|
||||
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
|
||||
const randomizedProducts = shuffled(PRODUCT_CATALOG)
|
||||
const randomizedRoutes = diversifyRoutes(routePool)
|
||||
|
||||
for (let index = 1; index <= count; index += 1) {
|
||||
const payload = buildOrderPayload({ marker, index, profile, master, productImageFileId })
|
||||
const master = randomizedRoutes[(index - 1) % randomizedRoutes.length]
|
||||
const product = randomizedProducts[(index - 1) % randomizedProducts.length]
|
||||
const packageCount = randomInt(1, 31)
|
||||
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, productImageFileId })
|
||||
const createResponse = await request(gateway, timeoutMs, 'POST',
|
||||
'/api/merchant/v1/orders/first-leg', {
|
||||
token,
|
||||
@@ -330,9 +393,19 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
orderNo,
|
||||
clientStatus: detail.order.status,
|
||||
mainStatus: unifiedOrder.summary.mainStatus,
|
||||
destinationWarehouseId: String(master.destination.id),
|
||||
destinationWarehouseCode: master.destination.warehouseCode,
|
||||
destinationWarehouseName: master.destination.warehouseName,
|
||||
receivingWarehouseId: String(master.receiving.id),
|
||||
receivingWarehouseName: master.receiving.warehouseName,
|
||||
channelCode: master.channel.channelCode,
|
||||
channelName: master.channel.channelName,
|
||||
pricingTaxMode: master.pricingTaxMode,
|
||||
cargoName: product.cargoName,
|
||||
packageCount,
|
||||
}
|
||||
orders.push(result)
|
||||
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} status=PENDING_REVIEW`)
|
||||
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} logistics=${result.channelCode}/${result.pricingTaxMode} destination=${result.destinationWarehouseCode} product=${result.cargoName} cartons=${result.packageCount} status=PENDING_REVIEW`)
|
||||
}
|
||||
|
||||
const report = {
|
||||
@@ -346,6 +419,9 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
productImageFileId: String(productImageFileId),
|
||||
pricedRoutePoolSize: routePool.length,
|
||||
logisticsPoolSize: new Set(routePool.map(logisticsKey)).size,
|
||||
productCatalogSize: PRODUCT_CATALOG.length,
|
||||
orders,
|
||||
securityNote: '报告不保存密码或访问令牌。',
|
||||
}
|
||||
|
||||
@@ -130,7 +130,8 @@ test('default Admin HTTP entry carries one marker-isolated loading chain through
|
||||
assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/)
|
||||
assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/)
|
||||
assert.match(finalize, /fixture\?\.marker !== context\.marker/)
|
||||
assert.match(finalize, /统一硬门禁通过后推进 LOADED 且五类有效证据仍完整/)
|
||||
assert.match(finalize, /统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移/)
|
||||
assert.match(finalize, /统一硬门禁通过后五类有效证据仍完整/)
|
||||
assert.match(finalize, /统一订单详情投影同一订单为 LOADED[\s\S]*统一订单列表投影同一订单为 LOADED/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user