94 lines
6.2 KiB
JavaScript
94 lines
6.2 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { readFileSync } from 'node:fs'
|
|
import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
|
|
|
|
const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`,
|
|
overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n }))
|
|
const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] }))
|
|
const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId,
|
|
dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1,
|
|
packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0,
|
|
items: [{ ...candidates[n], id: `7${n}` }] } }))
|
|
test('registered dispatch flow consumes current-run selected candidates before its main draft', () => {
|
|
const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8')
|
|
const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)')
|
|
assert.ok(call > source.indexOf('const selectedCandidates'))
|
|
assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`'))
|
|
})
|
|
test('two loading groups preserve distinct dispatches and exact physical sources', () => {
|
|
assert.equal(groupedDispatchMatches(results(), groups, candidates), true)
|
|
assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true)
|
|
for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo },
|
|
r => { r[1].draftContainerId = r[0].draftContainerId },
|
|
r => { r[0].dispatch.items = r[1].dispatch.items },
|
|
r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' },
|
|
r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) {
|
|
const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false)
|
|
}
|
|
})
|
|
function server(fault = '') {
|
|
let draft, next = 0, arranged = false
|
|
const calls = [], checks = [], orders = new Map()
|
|
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' })
|
|
const context = { marker: 'CURRENT', createdResources: {},
|
|
assert: (_m, label, _r, passed) => checks.push({ label, passed }),
|
|
async request(method, path, body) {
|
|
calls.push({ method, path, body: structuredClone(body) })
|
|
if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) }
|
|
if (path.endsWith('/drafts/containers')) {
|
|
assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity')
|
|
const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity }
|
|
draft.containers.push(container); return response(container)
|
|
}
|
|
if (path.endsWith('/draft-locks/lock')) {
|
|
draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null)
|
|
}
|
|
if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : []))
|
|
if (path.endsWith('/drafts/pre-arrange')) {
|
|
if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) {
|
|
if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = []
|
|
return response(null, 'A0409')
|
|
}
|
|
if (fault === 'unknown-post') return { http: 0, body: null }
|
|
if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true }
|
|
const result = groups.map(group => ({ draftContainerId: group.draftContainerId,
|
|
dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) }))
|
|
if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo
|
|
return response(result)
|
|
}
|
|
if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) }
|
|
if (path.includes('/candidates?')) return response(candidates)
|
|
const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/)
|
|
const order = orders.get(match?.[1]); assert.ok(order, path)
|
|
if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) }
|
|
return response(structuredClone(order))
|
|
} }
|
|
return { context, calls, checks }
|
|
}
|
|
test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => {
|
|
const { context, calls, checks } = server()
|
|
const proof = await verifyGroupedDispatch(context, 'offline', candidates)
|
|
assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED')
|
|
assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED')
|
|
assert.equal(proof.results.length, 2)
|
|
assert.ok(checks.every(row => row.passed))
|
|
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3)
|
|
assert.equal(calls.filter(row => row.method === 'DELETE').length, 2)
|
|
assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path)))
|
|
})
|
|
test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => {
|
|
const { context, calls } = server('reject-mutates-draft')
|
|
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/)
|
|
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1)
|
|
assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED')
|
|
})
|
|
test('unknown successful-write transport result is not automatically retried or deleted', async () => {
|
|
const { context, calls } = server('unknown-post')
|
|
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/)
|
|
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2)
|
|
assert.equal(calls.filter(row => row.method === 'DELETE').length, 0)
|
|
assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0)
|
|
assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW')
|
|
})
|