diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 76995bfb6..5c7a348f2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -28,6 +28,14 @@ test('module uses marker-owned completed PDA pallets and never borrows historica assert.doesNotMatch(source, /candidates\[0\]/) }) +test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => { + assert.match(source, /const candidatesByDestination = new Map\(\)/) + assert.match(source, /destinationWarehouseCode \|\| value\?\.fbaWarehouseCode/) + assert.match(source, /selectedCandidates\.length >= 2/) + assert.match(source, /planPalletIds, route: `\$\{context\.marker\}-ROUTE`/) + assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/) +}) + test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { assert.match(source, /相同草稿快照重复保存/) assert.match(source, /同草稿重复锁定托盘幂等成功/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 138dc59fe..5f5337eb9 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -95,7 +95,7 @@ async function runRelabelDispatch(context, relabel) { export async function run(context) { context.createdResources.phase4OverseasDispatch = { marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, - planPalletId: null, finalStatus: null, + planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], finalStatus: null, cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded', } @@ -122,16 +122,26 @@ export async function run(context) { const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] - const candidate = candidates.find((value) => fixture.palletIds.has(id(value?.warehousePalletId)) + const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId)) && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) - && id(value?.planId) && id(value?.planPalletId)) - context.assert(MODULE, '候选严格命中本轮PDA库存托盘与实际海外仓', candidateResult.response, - Boolean(candidate) && candidate.sourceStage === 'RECEIVED' && Number(candidate.packageCount) > 0, - `candidate=${Boolean(candidate)}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) - if (!candidate) return + && id(value?.planId) && id(value?.planPalletId) + && value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0) + const candidatesByDestination = new Map() + for (const value of markerCandidates) { + const destination = String(value?.destinationWarehouseCode || value?.fbaWarehouseCode || '').trim().toUpperCase() + if (destination && !candidatesByDestination.has(destination)) candidatesByDestination.set(destination, value) + } + const selectedCandidates = [...candidatesByDestination.values()] + const candidate = selectedCandidates[0] + context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response, + selectedCandidates.length >= 2, + `candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) + if (selectedCandidates.length < 2) return const planId = id(candidate.planId) const planPalletId = id(candidate.planPalletId) + const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))] + const planPalletIds = selectedCandidates.map(value => id(value.planPalletId)) // Exercise removal on a disposable dispatch, then reuse the released current-run pallet. const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, { planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`, @@ -158,9 +168,11 @@ export async function run(context) { context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) - const snapshotBody = { draftToken, planIds: [planId], containersJson } + const snapshotBody = { draftToken, planIds, containersJson } context.createdResources.phase4OverseasDispatch.draftToken = draftToken context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId + context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds + context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()] const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody) context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response, @@ -173,7 +185,7 @@ export async function run(context) { const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : [] const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken) context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response, - matchingDrafts.length === 1 && String(matchingDrafts[0].planIdsJson || '').split(',').includes(planId) + matchingDrafts.length === 1 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value)) && matchingDrafts[0].containersJson === containersJson, `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) @@ -198,24 +210,29 @@ export async function run(context) { Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS) const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) if (!success(finalContainer.response) || !finalContainer.data?.id) return - const lockBody = { planPalletId, draftToken, draftContainerId: id(finalContainer.data.id) } - const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody) - if (!success(locked.response)) return + const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) })) + for (const [index, body] of lockBodies.entries()) { + const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body) + if (!success(locked.response)) return + } + const lockBody = lockBodies[0] const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response, success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS) const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken })) - const visible = Array.isArray(visibleInDraft.data) && visibleInDraft.data.some((value) => id(value?.planPalletId) === planPalletId) + const visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId))) + const visible = planPalletIds.every(value => visibleIds.has(value)) context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible, - `planPalletId=${planPalletId}; ${trace(visibleInDraft.response)}`, OPTIONS) + `planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS) const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - const hidden = Array.isArray(hiddenResult.data) && !hiddenResult.data.some((value) => id(value?.planPalletId) === planPalletId) + const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId))) + const hidden = planPalletIds.every(value => !hiddenIds.has(value)) context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden, - `planPalletId=${planPalletId}; ${trace(hiddenResult.response)}`, OPTIONS) + `planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS) const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, { @@ -224,7 +241,7 @@ export async function run(context) { context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) const preArrangeBody = { - planPalletIds: [planPalletId], route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, + planPalletIds, route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, } const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) @@ -232,9 +249,9 @@ export async function run(context) { context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response, Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' && Array.isArray(arranged.data?.items) - && arranged.data.items.some((item) => id(item?.planPalletId) === planPalletId) - && Number(arranged.data?.palletCount) === 1, - `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}; ${trace(arranged.response)}`, OPTIONS) + && planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value)) + && Number(arranged.data?.palletCount) === planPalletIds.length, + `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${trace(arranged.response)}`, OPTIONS) if (!dispatchNo) return context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id)