173 lines
10 KiB
JavaScript
173 lines
10 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
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')
|
|
|
|
test('module covers the frozen Admin inbound-plan write surface', () => {
|
|
for (const value of ['/container-preview', '/convert-container', '/pallets/supplement', '/fba-appointments/batch',
|
|
'/fba-appointments/${encodeURIComponent(appointmentNo)}']) assert.ok(source.includes(value), value)
|
|
assert.equal(source.includes("`${PLAN_BASE}/convert`"), false)
|
|
assert.match(source, /snapshotVersion/)
|
|
assert.match(source, /snapshotHash/)
|
|
assert.match(source, /'Idempotency-Key'/)
|
|
assert.match(source, /重复转换返回同一入仓计划/)
|
|
assert.match(source, /FBA预约同单号异载荷返回A0409/)
|
|
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', () => {
|
|
for (const endpoint of ['/hold', '/release', '/adjust', '/ledger']) assert.ok(source.includes(endpoint), endpoint)
|
|
assert.match(source, /PDA完成柜形成的库存/)
|
|
assert.match(source, /库存冻结同键异载荷返回A0409/)
|
|
assert.match(source, /盘点调整同键异载荷返回A0409/)
|
|
assert.match(source, /库存数量恢复基线/)
|
|
assert.match(source, /types\.filter\(\(type\) => type === 'ADJUST'\)\.length === 2/)
|
|
})
|
|
|
|
test('module stays inside inbound and inventory scope', () => {
|
|
for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/dispatch', '/ship', '/pick']) {
|
|
assert.equal(source.toLowerCase().includes(forbidden), false, forbidden)
|
|
}
|
|
assert.doesNotMatch(source, /\bfetch\s*\(|process\.env|https?:\/\//)
|
|
})
|