test(appointments): verify stable batches and current HTTP contracts

This commit is contained in:
2026-10-15 18:13:52 +08:00
parent 204ae10737
commit caff693ad8
5 changed files with 335 additions and 55 deletions
+3 -1
View File
@@ -276,7 +276,7 @@ export async function run(context) {
| 海外 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`、`pda-picker-fixture-contract.test.mjs`(长场景名保留完整运行标识,避免造数账号跨轮重复) |
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
| 尾程单公网控制与人工计划 | `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-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed。入仓扩展模块选择本轮未完成 PDA 收货的第二个 FBA 柜,按真实来源版本独立核实箱数,验证核实变化不覆盖原订单;通过 DELETE 取消及原版本重放核对唯一取消历史和释放数量,再建立有效批次并执行拆分、合并、数量守恒、陈旧版本拒绝、同键重放与异载荷拒绝,保留稳定批次及替代链。该模块同时对已收货首柜执行库存冻结、释放与正反盘点,不能视为只读或仅预约写入。最小运行由 Admin 总入口选择 `--modules phase4-overseas-inbound-operations --wait-split-count 1 --wait-split-package-count 4`,使用明确 TEST 环境文件、既有 Admin/PDA 配置及依赖展开;还需预约列表/编辑/取消、入仓预约和尾程订单读取权限。任何请求失败或来源、数量证明失败即停止后续步骤,保留本轮有效、取消和替代批次,不自动删除或重跑;结果写入总入口报告的 `phase4OverseasInboundAdmin`,取消行为须有对应本轮授权 |
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(统一平台会话、匿名拒绝、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询与诊断证据;重复 GET 仅验证读取稳定,缺真实投递重放证据仍阻断;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
@@ -287,6 +287,8 @@ export async function run(context) {
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
`phase4-overseas-inbound-operations.mjs` 的内部 `prepareAppointmentFixture` 可由已核对的本轮最小正式 API 流程复用:必须传入真实转换所得的入仓计划和同仓明细,至少两箱才执行拆分合并;两箱验证 `2 → 1 + 1 → 2`,不补造货量。完整 Admin 模块仍按上表四箱参数及统一依赖运行,不能为缩减造数而伪造生产者矩阵或跳过前置事实。
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
海外仓业务 HTTP 验收使用 Auth 统一会话,仅通过 `--gateway` 访问 TEST Gateway;拒绝旧 `--overseas-gateway`、`ADMIN_OVERSEAS_GATEWAY` 和非 test 环境参数。平台、客户与 PDA 各用自己的账号访问令牌,权限、客户隔离及仓绑定仍须验证。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 访问令牌,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
@@ -3,6 +3,7 @@ import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { prepareAppointmentFixture } from './phase4-overseas-inbound-operations.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-inbound-operations.mjs'), 'utf8')
@@ -16,7 +17,142 @@ test('module covers the frozen Admin inbound-plan write surface', () => {
assert.match(source, /'Idempotency-Key'/)
assert.match(source, /重复转换返回同一入仓计划/)
assert.match(source, /FBA预约同单号异载荷返回A0409/)
assert.match(source, /appointmentStatus: 'CANCELLED'/)
assert.match(source, /request\(context, 'DELETE'/)
assert.match(source, /cancelReason/)
assert.match(source, /cargo-verification/)
assert.match(source, /appointments\/repartition/)
assert.doesNotMatch(source, /appointmentStatus: 'CANCELLED'/)
})
// An independent HTTP authority model: records original facts separately from
// verification and active allocations, and rejects stale commands before mutation.
function authority({ failSave = false, mutateOrder = false, unknown = 0, drift = false, packages = 4 } = {}) {
const state = { calls: [], appointments: [], verification: null, order: { order: {
id: 'order1', customerId: 'customer1', packageCount: packages, receivedPackageCount: 0, version: 7,
sourceInboundId: 'inbound1' }, cargo: [{ packageCount: packages }] }, history: new Map() }
const commands = new Map()
const clone = value => structuredClone(value)
const response = (data, code = '200') => ({ http: code === '200' ? 200 : code === 'A0409' ? 409 : code === 'A0400' ? 400 : 503,
body: { code, data: clone(data) } })
const sourceFacts = { sourceKey: 'ORDER:order1', customerId: 'customer1', destinationKey: 'FBA:TEST1',
originWarehouseId: 'warehouse1', canFollowup: true, sourceVersion: 'source-v1', packages }
const fresh = allocations => {
const id = String(state.appointments.length + 1)
const value = { id, batchKey: `FBA_APPOINTMENT:${id}`, appointmentNo: `APPT-${id}`, version: 0,
appointmentStatus: 'PENDING', replacementBatchKeys: [], items: allocations.map(a => ({
customerId: a.customerId, sourceKey: a.sourceKey, destinationKey: sourceFacts.destinationKey,
packageCount: a.packageCount, sourceVersion: sourceFacts.sourceVersion,
cargoVerificationVersion: state.verification.version })) }
state.appointments.push(value)
return value
}
const context = { marker: 'offline-new-four-box',
assert(_module, name, _response, condition, evidence) { assert.ok(condition, `${name}: ${evidence}`) },
addStep() {},
async request(method, path, body) {
state.calls.push({ method, path, body: clone(body) })
const pathname = decodeURIComponent(path.split('?')[0])
const commandKey = body?.idempotencyKey && `${pathname}/${body.idempotencyKey}`
const fingerprint = JSON.stringify(body)
if (commandKey && commands.has(commandKey)) {
const old = commands.get(commandKey)
return old.fingerprint === fingerprint ? clone(old.response) : response(null, 'A0409')
}
const commit = data => {
const result = response(data)
if (commandKey) commands.set(commandKey, { fingerprint, response: clone(result) })
return result
}
if (pathname.endsWith('/orders/order1')) return response(state.order)
if (pathname.endsWith('/cargo-verification')) {
if (method === 'GET') {
const allocated = state.appointments.filter(a => a.appointmentStatus === 'PENDING' && !a.replacementBatchKeys.length)
.flatMap(a => a.items).reduce((n, i) => n + i.packageCount, 0)
return response({ source: sourceFacts, verification: state.verification, allocatedPackages: String(allocated),
availablePackages: String((state.verification?.packageCount ?? packages) - allocated), unknownAllocationCount: String(unknown), sourceChanged: drift })
}
if (body.expectedSourceVersion !== sourceFacts.sourceVersion || body.expectedVersion !== (state.verification?.version ?? 0)) return response(null, 'A0409')
if (failSave) return response(null, 'S0503')
state.verification = { version: body.expectedVersion + 1, sourceVersion: sourceFacts.sourceVersion, packageCount: body.packageCount }
if (mutateOrder) state.order.order.packageCount = body.packageCount
return commit(state.verification)
}
if (pathname.endsWith('/fba-appointments/batch')) {
const created = fresh([{ customerId: 'customer1', sourceKey: sourceFacts.sourceKey, packageCount: state.verification.packageCount }])
Object.assign(created, body.details)
return commit(created)
}
if (pathname.includes('/batches/')) return response({ appointment: state.appointments.find(a => a.batchKey === pathname.split('/').at(-1)) })
if (pathname.endsWith('/appointments/repartition')) {
const originals = body.originals.map(o => state.appointments.find(a => a.appointmentNo === o.appointmentNo))
if (originals.some((a, i) => !a || a.version !== body.originals[i].version || a.replacementBatchKeys.length)) return response(null, 'A0409')
const total = items => items.reduce((n, i) => n + i.packageCount, 0)
if (total(originals.flatMap(a => a.items)) !== total(body.batches.flatMap(b => b.allocations))) return response(null, 'A0400')
const appointments = body.batches.map(b => fresh(b.allocations))
for (const a of originals) { a.version++; a.replacementBatchKeys = appointments.map(a => a.batchKey) }
return commit({ appointments, replacedBatchKeys: originals.map(a => a.batchKey) })
}
const match = /\/fba-appointments\/(APPT-\d+)(\/cancellations)?$/.exec(pathname)
assert.ok(match, `Unexpected request ${method} ${path}`)
const appointment = state.appointments.find(a => a.appointmentNo === match[1])
if (match[2]) return response({ total: String((state.history.get(match[1]) || []).length), records: state.history.get(match[1]) || [] })
if (method === 'GET') return response(appointment)
if (method === 'PUT') {
if (body.version !== appointment.version) return response(null, 'A0409')
appointment.version++; Object.assign(appointment, body.details)
return commit(appointment)
}
assert.equal(method, 'DELETE')
const old = state.history.get(match[1])
if (old) return old[0].version === body.version + 1 && old[0].cancelReason === body.cancelReason ? response(null) : response(null, 'A0409')
if (body.version !== appointment.version) return response(null, 'A0409')
appointment.version++; appointment.appointmentStatus = 'CANCELLED'
state.history.set(match[1], [{ version: appointment.version, cancelReason: body.cancelReason, snapshot: clone(appointment) }])
return response(null)
} }
return { context, state }
}
const fixture = { customerId: 'customer1', plan: { planNo: 'PLAN-1', overseasWarehouseId: 'warehouse1' },
fbaLines: [{ id: 'line1', orderId: 'order1', fbaWarehouseCode: 'TEST1', destinationWarehouseId: 'destination1' }] }
test('four real boxes retain original order facts through verification, cancel, split and merge', async () => {
const { context, state } = authority()
const result = await prepareAppointmentFixture(context, fixture)
assert.equal(result.terminalStatus, 'PENDING')
assert.equal(state.order.order.packageCount, 4)
assert.equal(state.order.order.version, 7)
assert.deepEqual(state.verification, { version: 2, sourceVersion: 'source-v1', packageCount: 4 })
assert.equal(state.appointments.length, 5)
assert.equal(state.history.size, 1)
assert.equal(state.history.get(result.cancelledAppointmentNo).length, 1)
assert.equal(result.replacedBatchKeys.length, 3)
assert.equal(result.splitBatchKeys.length, 2)
const active = state.appointments.filter(a => a.appointmentStatus === 'PENDING' && !a.replacementBatchKeys.length)
assert.equal(active.length, 1)
assert.equal(active[0].batchKey, result.batchKey)
assert.equal(active[0].items.reduce((n, i) => n + i.packageCount, 0), 4)
})
for (const [name, mode, expected] of [
['unknown write outcome', { failSave: true }, /stop, retain/],
['original order changed by verification', { mutateOrder: true }, /不覆盖原订单/],
['unknown source allocations', { unknown: 1 }, /核实数量和版本/],
['source drift', { drift: true }, /核实数量和版本/],
]) test(`${name} stops before creating any appointment`, async () => {
const { context, state } = authority(mode)
await assert.rejects(() => prepareAppointmentFixture(context, fixture), expected)
assert.equal(state.appointments.length, 0)
assert.equal(state.calls.some(c => c.path.endsWith('/fba-appointments/batch')), false)
})
test('minimal two-box sample splits into one box each and merges without inflating source quantity', async () => {
const { context, state } = authority({ packages: 2 })
const result = await prepareAppointmentFixture(context, fixture)
const split = state.appointments.filter(a => result.splitBatchKeys.includes(a.batchKey))
assert.deepEqual(split.map(a => a.items.reduce((n, i) => n + i.packageCount, 0)), [1, 1])
assert.equal(state.appointments.find(a => a.batchKey === result.batchKey).items[0].packageCount, 2)
assert.equal(state.order.order.packageCount, 2)
})
test('module covers reversible inventory commands with idempotency and ledger proof', () => {
@@ -2,11 +2,16 @@ const MODULE = 'phase4-overseas-inbound-operations'
const OPTIONS = { scenario: 'phase4-inbound-admin-and-inventory-commands', deploymentBlocker: true }
const PLAN_BASE = '/api/platform/v1/last-leg/inbound-plans'
const INVENTORY_BASE = '/api/platform/v1/last-leg/inventory'
const APPOINTMENT_BASE = '/api/platform/v1/last-leg/appointment-planning'
function id(value) { return value == null ? '' : String(value) }
function count(value) {
const parsed = value == null || value === '' ? NaN : Number(value)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : NaN
}
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function rows(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function conflict(response) { return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) }
function conflict(response) { return [200, 409].includes(response?.http) && String(response?.body?.code) === 'A0409' && response.body?.data == null }
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function query(path, values) {
const params = new URLSearchParams()
@@ -18,7 +23,154 @@ function key(context, operation) { return `${context.marker}:P4-INBOUND:${operat
async function request(context, method, name, path, body, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS)
return ok(response) ? response.body?.data : null
if (!ok(response)) throw Error(`${name}: ${trace(response)}; stop, retain original request and sample`)
return response.body?.data
}
function requireProof(context, name, condition, evidence) {
context.assert(MODULE, name, null, condition, evidence, OPTIONS)
if (!condition) throw Error(`${name}: ${evidence}`)
}
/** Current-run original-domain producer: verifies actual sources, proves cancellation, retains a fresh active batch. */
export async function prepareAppointmentFixture(context, { customerId, plan, fbaLines }) {
const sample = fbaLines[0]
requireProof(context, '本轮预约只选择同接仓点同目的仓真实来源', Boolean(customerId && plan?.planNo && plan?.overseasWarehouseId && sample?.fbaWarehouseCode)
&& fbaLines.length > 0 && fbaLines.length <= 100 && fbaLines.every(l => l.orderId && l.id && l.fbaWarehouseCode === sample.fbaWarehouseCode
&& id(l.destinationWarehouseId) === id(sample.destinationWarehouseId)), `planNo=${plan?.planNo || 'missing'}`)
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const verified = []
for (const orderId of [...new Set(fbaLines.map(l => id(l.orderId)))]) {
const sourceKey = `ORDER:${orderId}`, destinationKey = `FBA:${sample.fbaWarehouseCode}`
const sourcePath = `${APPOINTMENT_BASE}/sources/${encodeURIComponent(sourceKey)}/cargo-verification`
const orderPath = query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`, { customerId })
const orderBefore = await request(context, 'GET', '核实前读取原订单货量', orderPath)
const orderFacts = value => ({ id: id(value?.order?.id), customerId: id(value?.order?.customerId), packageCount: value?.order?.packageCount,
receivedPackageCount: value?.order?.receivedPackageCount, sourceInboundId: value?.order?.sourceInboundId, version: value?.order?.version, cargo: value?.cargo })
requireProof(context, '核实前原订单身份和箱数真实可读', id(orderBefore?.order?.id) === orderId && id(orderBefore.order.customerId) === customerId
&& Number.isSafeInteger(orderBefore.order.packageCount) && orderBefore.order.packageCount > 0, sourceKey)
const before = await request(context, 'GET', '读取本轮逐票核实上下文', query(sourcePath, { customerId, destinationKey }))
requireProof(context, '核实数量和版本必须来自当前货源', before?.source?.sourceKey === sourceKey && id(before.source.customerId) === customerId
&& before.source.destinationKey === destinationKey && id(before.source.originWarehouseId) === id(plan.overseasWarehouseId)
&& before.source.canFollowup === true && typeof before.source.sourceVersion === 'string' && before.source.sourceVersion.length > 0
&& Number.isSafeInteger(before.source.packages) && before.source.packages > 0 && count(before.allocatedPackages) === 0 && count(before.unknownAllocationCount) === 0
&& before.sourceChanged === false,
`sourceKey=${sourceKey}, allocated=${before?.allocatedPackages ?? 'missing'}`)
const body = { destinationKey, expectedSourceVersion: before.source.sourceVersion, expectedVersion: before.verification?.version ?? 0,
packageCount: before.source.packages > 1 ? before.source.packages - 1 : before.source.packages, shipmentReferences: before.verification?.shipmentReferences ?? [],
remark: `${context.marker} PENDING预约仅核实真实箱数`, idempotencyKey: `verify-${suffix}-${verified.length}` }
const stale = await context.request('POST', query(sourcePath, { customerId }), { ...body,
expectedSourceVersion: `stale-${before.source.sourceVersion}`.slice(0, 160), idempotencyKey: `${body.idempotencyKey}-stale` })
context.addStep(MODULE, '陈旧货源版本拒绝保存核实资料', stale, conflict(stale), trace(stale), OPTIONS)
requireProof(context, '陈旧货源拒绝必须明确返回冲突', conflict(stale), trace(stale))
const unchanged = await request(context, 'GET', '拒绝后回查核实记录未变', query(sourcePath, { customerId, destinationKey }))
requireProof(context, '陈旧货源写入未生成核实记录', JSON.stringify(unchanged.verification) === JSON.stringify(before.verification)
&& unchanged.allocatedPackages === before.allocatedPackages, sourceKey)
let saved = await request(context, 'POST', '保存独立预约核实箱数和当前来源版本', query(sourcePath, { customerId }), body)
const replay = await request(context, 'POST', '逐票核实原键原载荷重放', query(sourcePath, { customerId }), body)
requireProof(context, '核实重放版本和事实零重复', JSON.stringify(saved) === JSON.stringify(replay)
&& saved.version === body.expectedVersion + 1 && saved.sourceVersion === body.expectedSourceVersion && saved.packageCount === body.packageCount, sourceKey)
const orderAfter = await request(context, 'GET', '核实箱数减少后原订单仍保持原事实', orderPath)
requireProof(context, '预约核实不覆盖原订单箱数版本及货物明细', JSON.stringify(orderFacts(orderAfter)) === JSON.stringify(orderFacts(orderBefore)), sourceKey)
if (body.packageCount !== before.source.packages) saved = await request(context, 'POST', '将预约核实恢复为本轮完整货量', query(sourcePath, { customerId }), {
...body, expectedVersion: saved.version, packageCount: before.source.packages, idempotencyKey: `${body.idempotencyKey}-full` })
const current = await request(context, 'GET', '回查当前核实及可分配箱数', query(sourcePath, { customerId, destinationKey }))
requireProof(context, '核实来源未漂移且箱数完整可分配', current.sourceChanged === false && current.verification?.version === saved.version
&& count(current.availablePackages) === before.source.packages && count(current.allocatedPackages) === 0, sourceKey)
verified.push({ customerId, sourceKey, destinationKey, packageCount: before.source.packages, sourceVersion: saved.sourceVersion, cargoVerificationVersion: saved.version })
}
const createBody = { originWarehouseId: id(plan.overseasWarehouseId), fbaWarehouseCode: sample.fbaWarehouseCode,
lineIds: fbaLines.map(l => id(l.id)), idempotencyKey: `fba-${suffix}`,
details: { plannedDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10), appointmentStatus: 'PENDING', appointmentRemark: context.marker } }
const appointment = await request(context, 'POST', '批量创建FBA预约', `${PLAN_BASE}/fba-appointments/batch`, createBody)
const appointmentNo = appointment?.appointmentNo
requireProof(context, '原预约必须返回不可变批次及业务号', Boolean(appointmentNo && appointment.id && appointment.batchKey === `FBA_APPOINTMENT:${appointment.id}`), 'stable batch identity')
const replay = await request(context, 'POST', 'FBA预约同单号同载荷重放', `${PLAN_BASE}/fba-appointments/batch`, createBody)
requireProof(context, 'FBA创建重放保留不可变批次和全部逐票分配', JSON.stringify(replay) === JSON.stringify(appointment), appointmentNo)
const changed = await context.request('POST', `${PLAN_BASE}/fba-appointments/batch`, { ...createBody, details: { ...createBody.details, appointmentRemark: `${context.marker}-different` } })
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', changed, conflict(changed), trace(changed), OPTIONS)
requireProof(context, 'FBA异载荷必须明确拒绝', conflict(changed), trace(changed))
const detailPath = `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`
const detail = await request(context, 'GET', '查询FBA预约详情', detailPath)
const exactItems = value => (value?.items || []).map(i => JSON.stringify({ customerId: id(i.customerId), sourceKey: i.sourceKey,
destinationKey: i.destinationKey, packageCount: i.packageCount, sourceVersion: i.sourceVersion, cargoVerificationVersion: i.cargoVerificationVersion })).sort()
requireProof(context, 'FBA预约逐票归属数量及核实版本冻结', detail.batchKey === appointment.batchKey && detail.appointmentStatus === 'PENDING'
&& JSON.stringify(exactItems(detail)) === JSON.stringify(verified.map(v => JSON.stringify(v)).sort()), appointmentNo)
const batch = await request(context, 'GET', '按不可变批次查询原预约上下文', `${APPOINTMENT_BASE}/batches/${encodeURIComponent(detail.batchKey)}`)
requireProof(context, '稳定批次上下文返回同一原预约', batch?.appointment?.batchKey === detail.batchKey && batch.appointment.appointmentNo === appointmentNo
&& JSON.stringify(exactItems(batch.appointment)) === JSON.stringify(exactItems(detail)), detail.batchKey)
const cancelBody = { version: detail.version, cancelReason: `${context.marker}-cancelled` }
await request(context, 'DELETE', '按正式取消操作取消本轮预约', detailPath, cancelBody)
await request(context, 'DELETE', '原取消版本和原因重放', detailPath, cancelBody)
const cancelled = await request(context, 'GET', '回查已取消预约的原批次与历史事实', detailPath)
requireProof(context, '取消保留原批次且仅推进一次版本', cancelled.appointmentStatus === 'CANCELLED' && cancelled.batchKey === detail.batchKey
&& cancelled.version === detail.version + 1 && JSON.stringify(exactItems(cancelled)) === JSON.stringify(exactItems(detail)), appointmentNo)
const history = await request(context, 'GET', '核对唯一取消历史和保留的原计划', `${detailPath}/cancellations?page=1&size=20`)
requireProof(context, '取消重放不重复历史且保留原计划日期', count(history?.total) === 1 && history.records?.length === 1
&& history.records[0].cancelReason === cancelBody.cancelReason && history.records[0].version === cancelled.version
&& history.records[0].snapshot?.appointmentStatus === 'CANCELLED'
&& history.records[0].snapshot?.plannedDate === detail.plannedDate, appointmentNo)
for (const v of verified) {
const restored = await request(context, 'GET', '取消后逐票可分配量恢复', query(`${APPOINTMENT_BASE}/sources/${encodeURIComponent(v.sourceKey)}/cargo-verification`, { customerId, destinationKey: v.destinationKey }))
requireProof(context, '取消未删除核实事实且恢复剩余需求', count(restored.allocatedPackages) === 0 && count(restored.availablePackages) === v.packageCount
&& restored.verification?.version === v.cargoVerificationVersion && restored.sourceChanged === false, v.sourceKey)
}
const active = await request(context, 'POST', '为本轮后续验收重新建立有效批次', `${PLAN_BASE}/fba-appointments/batch`, { ...createBody, idempotencyKey: `${createBody.idempotencyKey}-active` })
requireProof(context, '重新安排生成新批次且逐票数量不变', active.batchKey === `FBA_APPOINTMENT:${active.id}` && active.batchKey !== cancelled.batchKey
&& active.appointmentNo !== appointmentNo && active.appointmentStatus === 'PENDING' && JSON.stringify(exactItems(active)) === JSON.stringify(exactItems(detail)), active?.appointmentNo || 'missing')
const activeContext = await request(context, 'GET', '回查保留的有效批次', `${APPOINTMENT_BASE}/batches/${encodeURIComponent(active.batchKey)}`)
requireProof(context, '保留批次上下文完整可供后续HTTP核对', activeContext?.appointment?.batchKey === active.batchKey
&& activeContext.appointment.appointmentStatus === 'PENDING', active.batchKey)
const repartitioned = await verifyRepartition(context, active, verified, suffix, createBody.details.plannedDate)
return { appointmentNo: repartitioned.active.appointmentNo, batchKey: repartitioned.active.batchKey, terminalStatus: repartitioned.active.appointmentStatus,
cancelledAppointmentNo: appointmentNo, cancelledBatchKey: cancelled.batchKey, cancelledVersion: cancelled.version,
replacedBatchKeys: repartitioned.replacedBatchKeys, splitBatchKeys: repartitioned.splitBatchKeys,
verifiedSources: verified, plannedDate: createBody.details.plannedDate, timezone: repartitioned.active.timezone }
}
async function verifyRepartition(context, initial, verified, suffix, plannedDate) {
const total = verified.reduce((n,v)=>n+v.packageCount,0)
requireProof(context, '分批合并使用本轮至少两箱真实来源', total >= 2, `packages=${total}; use at least two actual packages`)
const detailPath = no => `${PLAN_BASE}/fba-appointments/${encodeURIComponent(no)}`
const active = await request(context,'PUT','推进原预约版本以验证陈旧版本拒绝',detailPath(initial.appointmentNo),{
version:initial.version,idempotencyKey:`version-${suffix}`,details:{plannedDate,appointmentStatus:'PENDING',appointmentRemark:`${context.marker}-version-proof`} })
const allocation = (v,count=v.packageCount) => ({customerId:v.customerId,sourceKey:v.sourceKey,packageCount:count,
expectedSourceVersion:v.sourceVersion,expectedVerificationVersion:v.cargoVerificationVersion})
let left=Math.floor(total/2);const first=[],second=[]
for(const v of verified){const count=Math.min(v.packageCount,left);if(count)first.push(allocation(v,count));if(count<v.packageCount)second.push(allocation(v,v.packageCount-count));left-=count}
const body={originals:[{appointmentNo:active.appointmentNo,version:active.version}],batches:[first,second].map(allocations=>({plannedDate,remark:context.marker,allocations})),reason:`${context.marker}-split`,idempotencyKey:`split-${suffix}`}
const endpoint=`${APPOINTMENT_BASE}/appointments/repartition`
for(const [name,negative,expected]of [
['旧预约版本分批拒绝',{...body,originals:[{appointmentNo:active.appointmentNo,version:initial.version}],idempotencyKey:`split-stale-${suffix}`},'A0409'],
['分批逐票总量不守恒拒绝',{...body,batches:[{...body.batches[0],allocations:body.batches[0].allocations.map((a,i)=>i? a:{...a,packageCount:a.packageCount+1})},body.batches[1]],idempotencyKey:`split-count-${suffix}`},'A0400']]){
const denied=await context.request('POST',endpoint,negative)
const rejected=[200,expected==='A0409'?409:400].includes(denied?.http)&&denied?.body?.code===expected&&denied.body.data==null
context.addStep(MODULE,name,denied,rejected,trace(denied),OPTIONS);requireProof(context,name,rejected,trace(denied))
const unchanged=await request(context,'GET','拒绝分批后原批次保持不变',detailPath(active.appointmentNo))
requireProof(context,'拒绝未改写原批次版本分配或替代链',JSON.stringify(unchanged)===JSON.stringify(active),active.appointmentNo)
}
const sumSources = batches => {
const result=new Map()
for(const b of batches)for(const i of b.items){const k=`${i.customerId}/${i.sourceKey}/${i.destinationKey}`;result.set(k,(result.get(k)||0)+i.packageCount)}
return JSON.stringify([...result].sort((a,b)=>a[0].localeCompare(b[0])))
}
const split=await request(context,'POST','原批次按真实货量分成两批',endpoint,body)
const splitReplay=await request(context,'POST','分批原键原载荷重放',endpoint,body)
requireProof(context,'分批重放只返回原两批且逐票总量守恒',JSON.stringify(split)===JSON.stringify(splitReplay)&&split.appointments?.length===2
&&new Set(split.appointments.map(a=>a.batchKey)).size===2&&split.appointments.every(a=>a.batchKey===`FBA_APPOINTMENT:${a.id}`&&a.batchKey!==active.batchKey)
&&sumSources(split.appointments)===sumSources([active]),active.batchKey)
const old=await request(context,'GET','旧批次保存明确分批替代链',detailPath(active.appointmentNo))
requireProof(context,'旧批次不可变键保留并指向真实新批次',old.batchKey===active.batchKey&&JSON.stringify([...old.replacementBatchKeys].sort())===JSON.stringify(split.appointments.map(a=>a.batchKey).sort()),active.batchKey)
const changed=await context.request('POST',endpoint,{...body,reason:`${body.reason}-different`})
requireProof(context,'分批原键异载荷拒绝',conflict(changed),trace(changed))
const mergeBody={originals:split.appointments.map(a=>({appointmentNo:a.appointmentNo,version:a.version})),batches:[{plannedDate,remark:context.marker,allocations:verified.map(v=>allocation(v))}],reason:`${context.marker}-merge`,idempotencyKey:`merge-${suffix}`}
const merged=await request(context,'POST','两批按原来源数量合并',endpoint,mergeBody)
const mergeReplay=await request(context,'POST','合并原键原载荷重放',endpoint,mergeBody)
requireProof(context,'合并新键唯一且逐票总量不变',JSON.stringify(merged)===JSON.stringify(mergeReplay)&&merged.appointments?.length===1
&&merged.appointments[0].batchKey===`FBA_APPOINTMENT:${merged.appointments[0].id}`&&!split.appointments.some(a=>a.batchKey===merged.appointments[0].batchKey)
&&sumSources(merged.appointments)===sumSources([active]),active.appointmentNo)
for(const child of split.appointments){const replaced=await request(context,'GET','分批历史指向合并后的稳定批次',detailPath(child.appointmentNo));requireProof(context,'合并保留旧批次及唯一替代指向',replaced.batchKey===child.batchKey&&JSON.stringify(replaced.replacementBatchKeys)===JSON.stringify([merged.appointments[0].batchKey]),child.appointmentNo)}
return {active:merged.appointments[0],replacedBatchKeys:[active.batchKey,...split.appointments.map(a=>a.batchKey)],splitBatchKeys:split.appointments.map(a=>a.batchKey)}
}
async function inboundAdminLifecycle(context) {
@@ -68,8 +220,10 @@ async function inboundAdminLifecycle(context) {
Boolean(plan?.planNo) && replay?.planNo === plan.planNo,
`first=${plan?.planNo || 'missing'}, replay=${replay?.planNo || 'missing'}`, OPTIONS)
const planNo = plan?.planNo
const fbaLines = (plan?.lines || []).filter((line) => line?.fbaWarehouseCode
const eligibleLines = (plan?.lines || []).filter((line) => line?.fbaWarehouseCode
&& line?.destinationWarehouseId)
const fbaLines = eligibleLines.filter(line => line.fbaWarehouseCode === eligibleLines[0]?.fbaWarehouseCode
&& id(line.destinationWarehouseId) === id(eligibleLines[0]?.destinationWarehouseId))
context.assert(MODULE, 'FBA入仓计划包含可预约明细', null,
Boolean(planNo) && fbaLines.length > 0,
`planNo=${planNo || 'missing'}, fbaLines=${fbaLines.length}`, OPTIONS)
@@ -88,44 +242,10 @@ async function inboundAdminLifecycle(context) {
&& id(supplement?.destinationWarehouseId) === id(sample.destinationWarehouseId),
`palletId=${id(supplement?.id) || 'missing'}, status=${supplement?.assignmentStatus || 'missing'}`, OPTIONS)
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const now = Date.now()
const appointmentBody = {
originWarehouseId: id(plan.overseasWarehouseId), fbaWarehouseCode: sample.fbaWarehouseCode,
lineIds: fbaLines.map((line) => id(line.id)), idempotencyKey: `fba-${suffix}`,
details: { plannedDate: new Date(now + 24 * 60 * 60_000).toISOString().slice(0, 10),
appointmentStatus: 'PENDING', appointmentRemark: context.marker },
}
const appointment = await request(context, 'POST', '批量创建FBA预约', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody)
const appointmentNo = appointment.appointmentNo
const expectedSources = new Set(fbaLines.map(line => id(line.orderId))).size
const appointmentReplay = await request(context, 'POST', 'FBA预约同单号同载荷重放', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody)
context.assert(MODULE, 'FBA预约重放返回同一聚合且明细一致', null,
id(appointmentReplay?.id) === id(appointment?.id)
&& appointmentReplay?.items?.length === expectedSources,
`appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS)
const appointmentConflict = await context.request('POST', `${PLAN_BASE}/fba-appointments/batch`, {
...appointmentBody, details: { ...appointmentBody.details, appointmentRemark: `${context.marker}-different` },
})
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict,
conflict(appointmentConflict), trace(appointmentConflict), OPTIONS)
const detail = await request(context, 'GET', '查询FBA预约详情',
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`)
context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null,
detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode
&& detail?.items?.length === expectedSources,
`appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS)
const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口',
`${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, {
idempotencyKey: `fba-cancel-${suffix}`, version: detail.version,
details: { appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled` },
})
context.assert(MODULE, 'FBA预约最终为CANCELLED', null,
cancelled?.appointmentStatus === 'CANCELLED', `status=${cancelled?.appointmentStatus || 'missing'}`, OPTIONS)
const appointmentProof = await prepareAppointmentFixture(context, { customerId, plan, fbaLines })
context.createdResources.phase4OverseasInboundAdmin = {
marker: context.marker, customerId, orderId: id(order.id), planNo,
supplementedPalletId: id(supplement?.id) || null, appointmentNo,
terminalStatus: cancelled?.appointmentStatus || null,
supplementedPalletId: id(supplement?.id) || null, ...appointmentProof,
}
}