diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d47384b3e..a5ceb318e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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,不能按空场景通过;船节点禁止手工创建任务。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs index 1d11cd6e0..c84d709e3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs @@ -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', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index 76fb84043..88540b3c0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -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({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, } } diff --git a/scripts/appointment-work-test-env.mjs b/scripts/appointment-work-test-env.mjs index 3efca7f69..d5085d3ea 100644 --- a/scripts/appointment-work-test-env.mjs +++ b/scripts/appointment-work-test-env.mjs @@ -15,6 +15,11 @@ const ok = r => r?.http === 200 && id(r.body?.code) === '200' const refused = (r, status) => (r?.http === status || r?.http === 200) && id(r?.body?.code) === `A0${status}` && r.body?.data == null const sort = rows => rows.map(row => JSON.stringify(row)).sort() const source = row => ({ customerId: id(row.customerId), sourceKey: row.sourceKey, destinationKey: row.destinationKey, packageCount: Number(row.packageCount) }) +const count = value => { + if (typeof value !== 'number' && !(typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value))) return NaN + const n = Number(value) + return Number.isSafeInteger(n) && n >= 0 ? n : NaN +} export function plan() { return { environment: 'TEST', execute: false, entry: 'scripts/appointment-work-test-env.mjs', @@ -30,16 +35,16 @@ export function plan() { cases: [{ name: 'customer', containerBookingId: '', type: 'DELIVERY', flow: 'TRANSFER', requirementKey: 'DISPATCH:', batchKey: 'DISPATCH:', batchNo: '', expected: { executionStatus: 'PENDING', planStatus: 'UNPLANNED', dateBasis: 'NONE', calendarDate: null }, containerIds: [''] }], batches: [{ case: 'customer', kind: 'CUSTOMER', dispatchNo: '', packageCount: 1, pallets: [{ planPalletId: '', packageCount: 1 }] }], - sharedTrips: [], retired: [], workflow: { case: 'customer', ownerId: '', followupDate: '', noBooking: true } }, + sharedTrips: [], retired: [], workflow: { case: 'customer', ownerId: '', followupDate: '', noBooking: true } }, fixtureNotes: ['FBA batch entries instead use kind=FBA, appointmentNo, sources:[{customerId,sourceKey,destinationKey,packageCount}], minimumReadyCandidates (0 allowed).', 'sharedTrips:[{case,tripNo,tripId,containerIds,dispatches:[{dispatchNo,customerId,packageCount}]}]. The case must select TRANSPORT.', 'retired:[{workItemKey,containerBookingId,replacementCases:[name]}] checks absence from active container work and exact named replacements in old detail.', 'For COMPLETED cases provide expected.actual and calendarDate/dateBasis=ACTUAL; completed rows are loaded with their date filter.', 'workflow requires one pending item exposing SAVE_FOLLOWUP; noBooking additionally requires editable CUSTOMER DELIVERY and no original appointment plan.'], - minimumProducer: { entry:'scripts/admin-test-env-acceptance.mjs', arguments:'--environment test --modules phase4-carrier-pickup-ready --wait-split-count 1 --wait-split-package-count 1 --output reports//producer.json', + minimumProducer: { entry:'scripts/admin-test-env-acceptance.mjs', arguments:'--environment test --modules phase4-carrier-pickup-ready --wait-split-count 1 --wait-split-package-count 4 --output reports//producer.json', notes:'Uses its existing ADMIN_TEST_* and ADMIN_PDA_* configuration. Dependency closure creates a new customer/order, measurement/loading/customs/outbound and formal ship-node evidence. Select only the current report createdResources.phase3CarrierScheduleQuery.containerBookingId. First acceptance can target its UNKNOWN ROUTE work with workflow.noBooking=false; this does not prove stable batches.', batchPrerequisites:'Then use current formal route, cargo-verification/FBA appointment or PRIVATE receiving/pre-arrange APIs to prepare new stable batches; shared trip and completion stages need actual original dispatch/fleet/PDA execution before a new read report.', - existingProducerBlocker:'phase4-overseas-inbound-operations.mjs currently cancels FBA by PUT details.appointmentStatus=CANCELLED; the current service rejects this and requires the dedicated cancellation operation. Do not use that whole module as a verified batch producer until its owner fixes the contract.' }, + batchProducer:'Select phase4-overseas-inbound-operations with the same four-box arguments to exercise independent cargo verification, dedicated DELETE cancellation, split/merge conservation, immutable batch identity and replacement history. It also runs prerequisite PDA and reversible inventory writes; offline checks do not establish HTTP readiness. Retain samples and obtain the corresponding environment/cancellation authorization before execution.' }, remaining: ['This package does not create sailed containers, appointments, dispatches, stock or fleet trips, cancel original businesses, pick/depart/sign, or run internal synchronization.', 'Prepare/advance those samples with existing formal producers; run a new read report after each state transition. This is not end-to-end execution proof.', 'PRIVATE per-order pick quantity conservation and DIRECT receiving-write rejection require the original PDA/dispatch producer; this reader checks public batch identity/pallet quantities and completed facts only.', @@ -97,16 +102,17 @@ export function validateFixture(f, config, now = Date.now()) { } export function validatePage(page, number, size = 100) { - requireFact(page && Number.isSafeInteger(page.total) && page.total >= 0 && page.page === number && page.size === size && page.pages === Math.ceil(page.total / size) - && Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, page.total - (number - 1) * size)), 'Incomplete work page') + const total = count(page?.total) + requireFact(page && Number.isSafeInteger(total) && count(page.page) === number && count(page.size) === size && count(page.pages) === Math.ceil(total / size) + && Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, total - (number - 1) * size)), 'Incomplete work page') return page.records } export function assertItem(item, c) { requireFact(item?.workItemKey && item.type === c.type && item.flow === c.flow, `Case ${c.name}: wrong work identity`) for (const key of ['requirementKey','batchKey','batchNo']) if (Object.hasOwn(c, key)) assert.equal(item[key], c[key], `Case ${c.name}: ${key}`) assert.deepEqual(item.containers.map(r => id(r.containerBookingId)).sort(), [...c.containerIds].sort(), `Case ${c.name}: current container scope`) - for (const [key, value] of Object.entries(c.expected)) { requireFact(['executionStatus','planStatus','dateBasis','calendarDate','actual','reviewRequired','timezone'].includes(key), 'Unsupported expectation field'); assert.deepEqual(item[key], value, `Case ${c.name}: ${key}`) } - if (item.executionStatus !== 'COMPLETED') assert.equal(item.actual, null, 'Unfinished work must not invent completion') + for (const [key, value] of Object.entries(c.expected)) { requireFact(['executionStatus','planStatus','dateBasis','calendarDate','actual','reviewRequired','timezone'].includes(key), 'Unsupported expectation field'); assert.deepEqual(['calendarDate','actual','timezone'].includes(key) ? item[key] ?? null : item[key], value, `Case ${c.name}: ${key}`) } + if (item.executionStatus !== 'COMPLETED') assert.equal(item.actual ?? null, null, 'Unfinished work must not invent completion') if (c.flow === 'DIRECT') requireFact(item.type !== 'WAREHOUSE_RECEIPT', 'DIRECT cannot create warehouse receipt work') return item } @@ -125,8 +131,8 @@ export async function verify(context, fixture, config) { for (let page = 1; page <= 100; page++) { const query = new URLSearchParams({ view: 'ALL', page: id(page), size: '100', containerBookingId: container, ...(date ? { date } : {}) }) const response = await data('GET', `${WORK}/items?${query}`), next = validatePage(response, page) - requireFact(total == null || total === response.total, 'Work scope changed during pagination'); total = response.total - result.push(...next); if (page >= response.pages) { requireFact(result.length === total && new Set(result.map(i => i.workItemKey)).size === total, 'Duplicate or missing work rows'); return result } + requireFact(total == null || total === count(response.total), 'Work scope changed during pagination'); total = count(response.total) + result.push(...next); if (page >= count(response.pages)) { requireFact(result.length === total && new Set(result.map(i => i.workItemKey)).size === total, 'Duplicate or missing work rows'); return result } } throw Error('Work pagination limit exceeded') } @@ -134,7 +140,7 @@ export async function verify(context, fixture, config) { check('anonymous cannot read work', [401,403].includes(anonymous?.http) && anonymous.body?.data == null) check('customer cannot read platform work', refused(await context.request('merchant', 'GET', `${WORK}/summary`), 403)) const summary = await data('GET', `${WORK}/summary`) - check('summary contains complete initialized counters', ['all','followToday','overdue','undated','review'].every(k=>Number.isSafeInteger(summary[k])&&summary[k]>=0)) + check('summary contains complete initialized counters', ['all','followToday','overdue','undated','review'].every(k=>Number.isSafeInteger(count(summary[k])))) for (const c of fixture.cases) { const current = await rows(c.containerBookingId, c.expected.executionStatus === 'COMPLETED' ? c.expected.calendarDate : null) if (c.flow === 'DIRECT') check(`${c.name}: no active warehouse receiving branch`, !current.some(i => i.type === 'WAREHOUSE_RECEIPT')) @@ -188,7 +194,7 @@ export async function verify(context, fixture, config) { check('list-only platform cannot write followup', refused(await context.request('reader','POST',`${path}/commands`,command),403)) assert.deepEqual(await data('GET',path),before,'Denied write changed original work') const result = await data('POST',`${path}/commands`,command), saved = await data('GET',path) - check('followup resolves the active IAM owner', result.item.owner?.id === w.ownerId && typeof result.item.owner.name === 'string' && result.item.owner.name.length > 0 && result.item.followupDate === w.followupDate) + check('followup resolves the active platform Auth subject owner', result.item.owner?.id === w.ownerId && typeof result.item.owner.name === 'string' && result.item.owner.name.length > 0 && result.item.followupDate === w.followupDate) assert.deepEqual(result.item.actual,before.item.actual,'Followup invented execution'); assert.equal(result.item.executionStatus,before.item.executionStatus) check('one command creates one history entry',saved.history.length===before.history.length+1) assert.deepEqual(await data('POST',`${path}/commands`,command),result,'Idempotent replay changed response') @@ -199,7 +205,7 @@ export async function verify(context, fixture, config) { if(w.noBooking) { check('explicit customer requirement is editable',before.item.type==='DELIVERY' && saved.requirement && saved.item.allowedActions.some(a=>a.code==='SAVE_REQUIREMENT')) const updated=await data('POST',`${path}/commands`,{command:'SAVE_REQUIREMENT',expectedVersion:saved.item.version,idempotencyKey:`AW-${config.runId}-NOBOOK`,payload:{target:null,bookingRequired:false,receivingRequirements:'本轮测试客户仓按到货接收,无需外部预约',reason:`明确收货要求 ${config.runId}`}}) - check('no booking does not mean executed',updated.item.planStatus==='NOT_REQUIRED' && updated.item.executionStatus===before.item.executionStatus && updated.item.actual===null) + check('no booking does not mean executed',updated.item.planStatus==='NOT_REQUIRED' && updated.item.executionStatus===before.item.executionStatus && updated.item.actual==null) } } return { checks, resolved: Object.fromEntries([...resolved].map(([name,d])=>[name,{workItemKey:d.item.workItemKey,version:d.item.version,batchKey:d.item.batchKey,batchNo:d.item.batchNo,executionStatus:d.item.executionStatus}])) } diff --git a/scripts/appointment-work-test-env.test.mjs b/scripts/appointment-work-test-env.test.mjs index 4e176cd1a..28079f048 100644 --- a/scripts/appointment-work-test-env.test.mjs +++ b/scripts/appointment-work-test-env.test.mjs @@ -25,8 +25,8 @@ function harness(fixture=sample()) { requests.push({actor,method,path,body:clone(body)}) if(actor==='anonymous')return response(null,401,'A0401') if(actor==='merchant'||actor==='reader'&&method==='POST')return response(null,403,'A0403') - if(path===`${WORK}/summary`)return response({all:1,followToday:0,overdue:0,undated:1,review:0}) - if(path.startsWith(`${WORK}/items?`))return response({records:[clone(detail.item)],page:1,size:100,total:1,pages:1}) + if(path===`${WORK}/summary`)return response({all:'1',followToday:'0',overdue:'0',undated:'1',review:'0'}) + if(path.startsWith(`${WORK}/items?`))return response({records:[clone(detail.item)],page:'1',size:'100',total:'1',pages:'1'}) if(path===`${WORK}/items/WORK-1`&&method==='GET')return response(clone(detail)) if(path===`${API}/dispatches/D-20`)return response({id:'20',dispatchNo:'D-20',packageCount:4,items:[{planPalletId:'10',packageCount:4}]}) if(path===`${WORK}/items/WORK-1/commands`) { @@ -71,6 +71,22 @@ test('pagination and actual evidence cannot silently truncate or invent completi assert.throws(()=>assertItem({...item,actual:{localOccurredAt:'2026-10-15T11:00:00',occurredAt:null,timezone:null}},c)) assert.throws(()=>assertItem({...item,containers:[{containerBookingId:'foreign'}]},c)) }) + +test('Long wire counters preserve exact pagination and reject missing or unsafe counts',()=>{ + const page={records:[{workItemKey:'WORK-1'}],page:'1',size:'100',total:'1',pages:'1'} + assert.equal(validatePage(page,1).length,1) + for(const total of [null,undefined,'',true,'1.5','01','9007199254740993',-1])assert.throws(()=>validatePage({...page,total},1)) + assert.throws(()=>validatePage({...page,page:null},1)) + assert.throws(()=>validatePage({...page,pages:'2'},1)) +}) + +test('omitted nullable wire fields remain unknown and never imply completion',()=>{ + const c=sample().cases[0], item=workItem(c) + delete item.actual; delete item.calendarDate + assert.equal(assertItem(item,c),item) + assert.throws(()=>assertItem({...item,actual:{localOccurredAt:'2026-10-15T10:00:00'}},c)) + assert.throws(()=>assertItem({...item,executionStatus:undefined},c)) +}) test('workflow runs denial/replay/stale/no-booking checks without original-business writes',async()=>{ const h=harness(),result=await verify(h,sample(),{mode:'workflow',runId:'aw-test'}) assert.ok(result.checks.includes('no booking does not mean executed'))