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') })