import assert from 'node:assert/strict' import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.mjs' import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs' import { nativeRelabelCreateBody } from '../overseas-relabel-real-acceptance.mjs' import { finishFleetTrip } from './fleet-trip-fulfillment.mjs' const ROOT = '/api/platform/v1/last-leg', MODULE = 'dispatch-workflow-extensions' const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const clone = value => structuredClone(value) const id = value => String(value ?? '') export const extensionMatrix = Object.freeze([ { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' }, { scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' }, { scenario: 'relabel', scope: 'Fresh three-box customer receipt, confirmed zero-scan cancellation, two applications sharing one pallet and native rearrangement', readiness: 'READY_FORMAL_THREE_BOX_RECEIPT' }, { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, { scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' }, ]) export function assertDualDestinationFixture(fixture) { assert.equal(fixture.members?.length, 2) assert.equal(new Set(fixture.members.map(row => id(row.customerId))).size, 2) assert.equal(new Set(fixture.members.map(row => row.destination?.key)).size, 2) assert(fixture.members.every(row => row.destination?.key && row.receiptVerified === true)) assert(fixture.tripProof?.stops?.length >= 3) for (const member of fixture.members) assert(fixture.tripProof.stops.some(stop => stop.destinationKey === member.destination.key)) } export function feeCents(value) { assert(['string', 'number'].includes(typeof value) && /^(0|[1-9]\d*)(\.\d{1,2})?$/.test(String(value)), 'Non-canonical fee amount') const [whole, fraction = ''] = String(value).split('.') const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')) assert(Number.isSafeInteger(cents)); return cents } export function applyIndependentFees(body, groups, members, marker) { assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2) assert.equal(new Set(groups.map(row => row.groupKey)).size, 2) assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2) const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775])) const expected = {} for (const group of body.groups) { const matches = groups.filter(row => row.groupKey === group.groupKey) assert.equal(matches.length, 1) const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId) assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required') assert(!expected[customerId], 'Duplicated customer fee group') for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` } expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) })) } return expected } /** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */ export async function discoverSecondDestination(context, primaryId) { const get = async path => { const response = await context.request('GET', path) assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return response.body.data } const primary = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(primaryId)}?tenantId=0`) const country = primary.addresses?.find(row => row.addressLine1)?.countryCode assert(country && primary.warehouse?.warehouseType === 'FBA' && primary.warehouse.status === 'ACTIVE') let total, scanned = 0 const seen = new Set() for (let page = 1; page <= 100; page++) { const data = await get(`/api/platform/v1/warehouse-admin?${new URLSearchParams({ tenantId: '0', page: String(page), size: '200', warehouseType: 'FBA', status: 'ACTIVE', countryCode: country })}`) assert(Array.isArray(data.records)); assert.equal(Number(data.page), page); assert.equal(Number(data.size), 200) assert(Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0) total ??= Number(data.total); assert.equal(Number(data.total), total) assert(data.records.length > 0 || scanned === total) for (const row of data.records) { assert(row.id && !seen.has(id(row.id))); seen.add(id(row.id)); scanned++ if (id(row.id) === id(primaryId)) continue const candidate = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(row.id)}?tenantId=0`) if (candidate.warehouse?.warehouseType !== 'FBA' || candidate.warehouse.status !== 'ACTIVE' || id(candidate.warehouse.tenantId) !== '0' || candidate.warehouse.warehouseCode === primary.warehouse.warehouseCode) continue if (candidate.addresses?.some(address => address.addressLine1 && address.countryCode === country)) return id(row.id) } if (scanned === total) break } throw Error('No second distinct public active FBA destination with a real same-country address; no fixture created') } /** Reuse the formal full-chain verifier; only explicitly supplied synthetic fee amounts differ by customer. */ export async function verifyDualDestination(context, fixture, options) { if (!options.bol) assertDualDestinationFixture(fixture) else { assert.equal(fixture.members.length, 2); assert.equal(new Set(fixture.members.map(row => row.customerId)).size, 2) assert.equal(new Set(fixture.members.map(row => row.destination.key)).size, 1) assert(fixture.members.every(row => row.receiptVerified === true)) } const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false, hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] } const original = context.request let arrangement, deferredFailure, raceDone = false, firstRaceDone = false, unarrangedDone = false const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}` context.request = async function (method, path, body, ...rest) { if (deferredFailure) throw deferredFailure if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) { proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker) proof.arrangeRequest = clone(body); await context.checkpoint() } let response if (options.firstCompetition && !firstRaceDone && method === 'POST' && path === `${pdaPath}/loading-complete` && fixture.tripProof.loadingCommand?.body?.idempotencyKey === body?.idempotencyKey) { firstRaceDone = true const outcome = await executeFirstLoadingRace(context, fixture, options, original.bind(this), body) response = outcome.pdaResponse if (outcome.error) deferredFailure = outcome.error } else response = await original.call(this, method, path, body, ...rest) if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement` && response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data) // 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。 if (!deferredFailure && response.http === 200 && String(response.body?.code) === '200') { try { if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone && response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') { raceDone = true await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data) } if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone && response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) { unarrangedDone = true await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data) } if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) { const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true } for (let attempt = 1; attempt <= 60; attempt++) { const current = await original.call(this, 'GET', mainPath) assert.equal(current.http, 200); assert.equal(String(current.body?.code), '200') assert.equal(current.body.data?.dispatchNo, fixture.dispatchNo) fence.attempts.push({ attempt, stage: current.body.data.stage, recordVersion: current.body.data.recordVersion, requestId: current.requestId, traceId: current.traceId }) if (current.body.data.stage === 'DELIVERED') { fence.status = 'DELIVERED'; break } assert.equal(current.body.data.stage, 'DELIVERING') await context.checkpoint(); await new Promise(resolve => setTimeout(resolve, 500)) } assert.equal(fence.status, 'DELIVERED', 'Automatic completion did not settle within 30 seconds') await context.checkpoint() } } catch (error) { deferredFailure = error } } return response } let verified try { verified = await verifyFleetCrossCustomer(context, fixture, options) } finally { context.request = original } assert.equal(verified.status, 'VERIFIED') const final = verified.snapshots.FINAL assert.equal(new Set(final.dispatch.documents.deliveries.map(row => row.deliveryNo)).size, 2) assert.equal(new Set(final.dispatch.documents.outbounds.map(row => row.outboundNo)).size, 2) const feeIds = new Set() for (const [index, member] of fixture.members.entries()) { const delivery = final.members[index].delivery assert.equal(id(delivery.order.customerId), member.customerId) assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1) const expected = proof.expectedFees[member.customerId] assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length) for (const fee of delivery.fees) { assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id)) assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE') } const other = fixture.members[1 - index] const path = `${ROOT}/delivery-orders/${encodeURIComponent(delivery.order.deliveryNo)}?customerId=${encodeURIComponent(other.customerId)}` const response = await context.request('GET', path) const denied = [200, 403, 404].includes(response.http) && ['A0403', 'A0404'].includes(String(response.body?.code)) context.assert(MODULE, 'Foreign customer cannot read this delivery or its independent fees', response, denied, '', OPTIONS) assert(denied) proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) } assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId]) proof.status = 'VERIFIED'; await context.checkpoint(); return verified } const receipt = response => ({ http: response.http, code: response.body?.code, message: response.body?.message, requestId: response.requestId, traceId: response.traceId }) async function competitionSnapshot(context, fixture, { readInventory }, send) { const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const state = {} for (const [name, target] of [['main', path], ['items', `${path}/items`], ['loading', `${path}/loading`], ['trip', `${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`]]) { const response = await send('GET', target) assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); state[name] = response.body.data } state.stock = [] for (const member of fixture.members) state.stock.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })) return state } /** 仅两个请求同时发送;使用已成功装车的原键,明确区别首次执行竞争。 */ export async function verifyLoadingReplayRace(context, fixture, options, send, body, acknowledged) { const proof = context.createdResources.dispatchWorkflowCompetition assert(proof && proof.status === 'CHECKING') const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete` const pda = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete` const race = proof.loadingReplayRace = { status: 'INTENT', scope: 'ACKNOWLEDGED_COMMAND_TWO_ACTOR_REPLAY', firstExecutionRace: 'NOT_TESTED', body: clone(body), before: await competitionSnapshot(context, fixture, options, send) } await context.checkpoint() const results = await Promise.allSettled([send('POST', pda, clone(body), fixture.pdaToken), send('POST', path, clone(body))]) race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' }) race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT' await context.checkpoint() for (const result of results) if (result.status === 'rejected') throw result.reason const [pdaResult, adminResult] = results.map(result => result.value) assert.equal(String(pdaResult.body?.code), '200'); assert.equal(pdaResult.body?.data?.replayed, true) assert.deepEqual(pdaResult.body.data.result, acknowledged.result) assert.equal(id(pdaResult.body.data.recordVersion), id(acknowledged.recordVersion)) assert.equal(String(adminResult.body?.code), 'A0403'); assert.match(adminResult.body?.message || '', /操作员/) race.after = await competitionSnapshot(context, fixture, options, send) assert.deepEqual(race.after, race.before) context.assert(MODULE, 'Concurrent Admin/PDA original-key replays preserve the one loading result and complete inventories', pdaResult, true, '', OPTIONS) race.status = 'VERIFIED'; await context.checkpoint() } /** 首次命令使用两个不同键和同一真实版本;胜者必须唯一,真实PDA回执原样交还调用者。 */ export async function executeFirstLoadingRace(context, fixture, options, send, body) { const proof = context.createdResources.dispatchWorkflowCompetition assert(proof && proof.status === 'CHECKING') const adminBody = { ...clone(body), idempotencyKey: `${context.marker}-EXT-FIRST-ADMIN-LOAD` } const race = proof.firstLoadingRace = { status: 'INTENT', pdaBody: clone(body), adminBody, before: await competitionSnapshot(context, fixture, options, send) } assert.equal(race.before.main.stage, 'LOADING'); assert.equal(Number(race.before.main.counts.pickedPackageCount), 2) assert.equal(id(race.before.main.recordVersion), id(body.recordVersion)); await context.checkpoint() const results = await Promise.allSettled([ send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`, clone(body), fixture.pdaToken), send('POST', `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete`, adminBody), ]) race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' }) race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT'; await context.checkpoint() for (const result of results) if (result.status === 'rejected') throw result.reason const responses = results.map(result => result.value) try { const winnerIndexes = responses.map((response, index) => String(response.body?.code) === '200' ? index : -1).filter(index => index >= 0) assert.equal(winnerIndexes.length, 1) const winnerIndex = winnerIndexes[0], winner = responses[winnerIndex], loser = responses[1 - winnerIndex] assert.equal(String(loser.body?.code), 'A0409') assert.equal(winner.body.data.stage, 'AFTER_LOADING') assert.equal(winner.body.data.result?.confirmedLoad?.manifestHash, body.manifestHash) assert.equal(BigInt(winner.body.data.recordVersion), BigInt(body.recordVersion) + 1n) race.after = await competitionSnapshot(context, fixture, options, send) assert.equal(race.after.main.stage, 'AFTER_LOADING') assert.equal(id(race.after.main.recordVersion), id(winner.body.data.recordVersion)) assert.deepEqual(race.after.stock, race.before.stock) assert.equal(Number(race.after.main.counts.packageCount), 2) assert.equal(Number(race.after.main.counts.pickedPackageCount), 2) race.winner = winnerIndex === 0 ? 'PDA' : 'ADMIN'; race.winnerResult = clone(winner.body.data) race.status = 'VERIFIED'; proof.firstExecutionRace = 'VERIFIED' context.assert(MODULE, 'First valid Admin/PDA loading race commits exactly one version and preserves complete inventories', winner, true, '', OPTIONS) await context.checkpoint() if (winnerIndex !== 0) return { pdaResponse: responses[0], error: Error('Admin won the first loading race; original PDA rejection retained, no fulfillment replay') } return { pdaResponse: responses[0] } } catch (error) { race.verificationError = error.message; await context.checkpoint() return { pdaResponse: responses[0], error } } } async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) { const proof = context.createdResources.dispatchWorkflowCompetition const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send), body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } } assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0) await context.checkpoint() const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken) state.response = receipt(response); await context.checkpoint() assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/) state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before) context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS) state.status = 'VERIFIED'; await context.checkpoint() } export async function verifyCompetitionExtension(context, fixture, options) { const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' } const before = await competitionSnapshot(context, fixture, options, context.request.bind(context)) const body = { idempotencyKey: `${context.marker}-EXT-OCCUPIED-RESOURCE`, warehouseId: fixture.warehouseId, vehicle: { mode: 'MASTER', vehicleId: fixture.tripProof.vehicleId }, driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } }, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, remark: `${context.marker} TEST已占用资源不能分配另一空车`, cargo: [], batchAllocations: [] } proof.resourceConflict = { status: 'INTENT', body: clone(body), before }; await context.checkpoint() const response = await context.request('POST', `${ROOT}/dispatches`, body) proof.resourceConflict.response = receipt(response); await context.checkpoint() assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /车辆.*占用|司机.*占用/) proof.resourceConflict.after = await competitionSnapshot(context, fixture, options, context.request.bind(context)) assert.deepEqual(proof.resourceConflict.after, before) context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS) proof.resourceConflict.status = 'VERIFIED' const verified = await verifyDualDestination(context, fixture, { ...options, competition: true }) assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED') if (options.firstCompetition) assert.equal(proof.firstLoadingRace?.status, 'VERIFIED') proof.status = options.firstCompetition ? 'VERIFIED_FIRST_EXECUTION_AND_REPLAY' : 'VERIFIED_BOUNDED_SCOPE' await context.checkpoint(); return verified } export const verifyFirstCompetitionExtension = (context, fixture, options) => verifyCompetitionExtension(context, fixture, { ...options, firstCompetition: true }) /** 仅使用本轮PDA实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */ export async function prepareBolAppointments(context, members, scenario, warehouseId) { assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario)) const proof = context.createdResources.dispatchWorkflowBolAppointments = { scenario, status: 'PREPARING', appointments: [], allocations: [] } if (scenario === 'bol-empty') { proof.status = 'NO_APPOINTMENT'; return [] } const groups = scenario === 'bol-same' ? [members] : members.map(member => [member]) for (const [index, group] of groups.entries()) { for (const member of group) { const response = await context.request('GET', `${ROOT}/warehouse-transfers/${encodeURIComponent(member.incomingNo)}?customerId=${encodeURIComponent(member.customerId)}`) assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200') const transfer = response.body.data assert.equal(transfer.transferNo, member.incomingNo); assert.equal(transfer.inboundCode, member.incomingNo) assert.equal(id(transfer.inboundOrderId), id(member.receipt.balance.inboundOrderId)) assert.equal(id(transfer.operatingWarehouseId), warehouseId) assert(transfer.lastlegOrderId && member.selection.planPalletId && member.receiptVerified) member.appointmentSourceKey = `ORDER:${transfer.lastlegOrderId}` } const refs = group.map(member => { return { customerId: member.customerId, sourceKey: member.appointmentSourceKey, destinationKey: member.destination.key } }) const confirmationNo = `TEST-ISA-${context.marker.slice(-12)}-${index + 1}` const body = { sources: refs, originWarehouseId: warehouseId, fbaWarehouseCode: group[0].destination.warehouseCode, details: { appointmentStatus: 'SUCCESS', confirmationNo, appointmentAt: new Date(Date.now() + 48 * 3600000).toISOString().slice(0, 19), appointmentEndAt: new Date(Date.now() + 50 * 3600000).toISOString().slice(0, 19), appointmentRemark: `${context.marker} TEST合成预约确认资料` }, idempotencyKey: `${context.marker}-BOL-AP-${index + 1}` } const record = { status: 'INTENT', body: clone(body) }; proof.appointments.push(record); await context.checkpoint() const response = await context.request('POST', `${ROOT}/appointment-planning/appointments`, body) record.response = receipt(response); record.status = 'RESPONDED'; await context.checkpoint() const data = context.apiSuccess(MODULE, 'Create own formal FBA appointment for exact received quantities', response, '', OPTIONS) assert.equal(String(response.body?.code), '200'); assert(data?.batchKey && data.appointmentNo) assert.equal(data.confirmationNo, confirmationNo); assert.equal(data.appointmentStatus, 'SUCCESS') assert.equal(data.items.length, group.length); assert(Number.isSafeInteger(data.version) && data.version >= 0) record.data = clone(data) for (const member of group) { const matches = data.items.filter(item => id(item.customerId) === member.customerId && item.sourceKey === member.appointmentSourceKey) assert.equal(matches.length, 1); assert.equal(Number(matches[0].packageCount), 1) assert.equal(matches[0].destinationKey, member.destination.key) proof.allocations.push({ planPalletId: member.selection.planPalletId, batchKey: data.batchKey, customerId: member.customerId, sourceKey: matches[0].sourceKey, destinationKey: matches[0].destinationKey, packageCount: 1, recordVersion: data.version }) } record.status = 'VERIFIED'; await context.checkpoint() } proof.status = 'VERIFIED'; return clone(proof.allocations) } export function assertBolPages(pages, fixture, appointments, scenario) { const expected = scenario === 'bol-different' ? 2 : 1 assert.equal(pages.length, expected) const counts = [], isas = [], flattened = pages.map(page => page.replace(/\s/g, '')) for (const page of pages) { assert.match(page, /BILL OF LADING/) const total = page.match(/Total\s+CTNS:\s*(\d+)/) assert(total, 'Printed total box count missing'); counts.push(Number(total[1])) const isaLine = page.split(/\r?\n/).find(line => line.includes('ISA #:')) assert(isaLine !== undefined); isas.push(isaLine.split('ISA #:')[1].trim()) } assert.equal(counts.reduce((sum, count) => sum + count, 0), 2) // 现行窄托盘列会省略长托盘码;用完整入仓码核对原实收来源,每个来源在本夹具中严格对应一箱一实托。 for (const member of fixture.members) { assert(member.incomingNo && member.palletNo) assert.equal(flattened.reduce((sum, page) => sum + page.split(member.incomingNo).length - 1, 0), 1) } const expectedIsas = appointments.appointments.map(row => row.data.confirmationNo) assert.deepEqual([...isas].sort(), scenario === 'bol-empty' ? [''] : [...expectedIsas].sort()) if (scenario !== 'bol-different') assert.deepEqual(counts, [2]) else assert.deepEqual(counts, [1, 1]) return { pageCount: pages.length, totalPackages: 2, packagesPerBol: counts, confirmationNos: isas, eachSourcePrintedOnce: true, physicalPalletMapping: 'ONE_BOX_ONE_PALLET_PER_SOURCE', palletColumn: 'LONG_CODES_TRUNCATED_BY_EXISTING_TEMPLATE' } } export async function verifyBolExtension(context, fixture, options) { assert(typeof options.inspectBol === 'function') const proof = context.createdResources.dispatchWorkflowBol = { scenario: options.bol, status: 'CHECKING', physicalMeasurementVerified: false, hardwareVerified: false } proof.before = await competitionSnapshot(context, fixture, options, context.request.bind(context)) const pages = await options.inspectBol(context, fixture) proof.printed = assertBolPages(pages, fixture, context.createdResources.dispatchWorkflowBolAppointments, options.bol) proof.after = await competitionSnapshot(context, fixture, options, context.request.bind(context)) assert.deepEqual(proof.after, proof.before) context.assert(MODULE, 'Actual BOL PDF preserves appointment shares, cross-customer grouping and complete inventory facts', { http: 200 }, true, '', OPTIONS) proof.status = 'VERIFIED'; await context.checkpoint() return verifyDualDestination(context, fixture, options) } /** Exercise edits only on this fresh zero-picked main, then leave a new fully approved round for the formal chain. */ export async function exerciseReviewLifecycle(context, fixture, { readInventory }) { const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const proof = context.createdResources.dispatchWorkflowReviews = { marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', commands: [], rejections: [], snapshots: {}, syntheticTestMaterials: true } const request = async (method, target, body, label, reject = false) => { if (method !== 'GET') { proof.commands.push({ method, path: target, body: clone(body) }); await context.checkpoint() } const response = await context.request(method, target, body) if (reject) { const denied = [200, 409].includes(response.http) && ['A0409', '409'].includes(String(response.body?.code)) context.assert(MODULE, label, response, denied, '', OPTIONS); assert(denied) proof.rejections.push({ label, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) return response } const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data } const get = (target, label) => request('GET', target, undefined, label) const snapshot = async label => { const data = { main: await get(path, label), items: await get(`${path}/items`, label), route: await get(`${path}/route`, label), stock: [] } for (const member of fixture.members) data.stock.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })) proof.snapshots[label] = data; await context.checkpoint(); return data } const initial = await snapshot('INITIAL_APPROVED_ZERO_PICK') assert.equal(initial.main.stage, 'LOADING'); assert.equal(initial.main.reviewState, 'APPROVED') assert.equal(Number(initial.main.counts.pickedPackageCount), 0); assert.equal(Number(initial.main.counts.shippedPackageCount), 0) const key = label => `${context.marker}-REV-${label}` let result = await request('POST', `${path}/return-to-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('RETURN'), reason: 'TEST零扫码显式退回改货改车' }, 'Zero-pick explicit return') assert.equal(result.stage, 'BEFORE_LOADING') let before = await snapshot('RETURNED') assert.equal(before.main.reviewState, 'DRAFT') await request('POST', `${path}/submit-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('STALE'), remark: context.marker }, 'Stale main version is rejected', true) assert.deepEqual(await snapshot('AFTER_STALE'), before) const submit = { recordVersion: before.main.recordVersion, idempotencyKey: key('SUBMIT-REJECT-ROUND'), remark: 'TEST第一重提轮次' } result = await request('POST', `${path}/submit-review`, submit, 'Submit dispatch rejection round') before = await snapshot('SUBMITTED') const replay = await request('POST', `${path}/submit-review`, submit, 'Original submit key replays') assert.equal(replay.replayed, true); assert.equal(String(replay.recordVersion), String(result.recordVersion)) await request('POST', `${path}/submit-review`, { ...submit, remark: 'TEST同键异载荷不得复用' }, 'Changed payload with original key is rejected', true) await request('POST', `${path}/submit-review`, { ...submit, recordVersion: before.main.recordVersion, idempotencyKey: key('DUPLICATE-SUBMIT') }, 'New-key duplicate submit is rejected', true) assert.deepEqual(await snapshot('AFTER_DUPLICATE_REJECTIONS'), before) result = await request('POST', `${path}/dispatch-review`, { recordVersion: before.main.recordVersion, idempotencyKey: key('DISPATCH-REJECT'), decision: 'REJECTED', reason: 'TEST调度驳回' }, 'Dispatch reviewer rejects') let detail = await get(path, 'Read dispatch rejection') assert.equal(detail.reviewState, 'DISPATCH_REJECTED'); assert.equal(detail.reviews.dispatchReview.status, 'REJECTED') result = await request('POST', `${path}/submit-review`, { recordVersion: detail.recordVersion, idempotencyKey: key('SUBMIT-LOADING-REJECT'), remark: 'TEST驳回重提' }, 'Resubmit after dispatch rejection') result = await request('POST', `${path}/dispatch-review`, { recordVersion: result.recordVersion, idempotencyKey: key('DISPATCH-APPROVE'), decision: 'APPROVED', reason: 'TEST调度通过' }, 'Dispatch approves new round') result = await request('POST', `${path}/loading-review`, { recordVersion: result.recordVersion, idempotencyKey: key('LOADING-REJECT'), decision: 'REJECTED', reason: 'TEST理货驳回' }, 'Loading reviewer rejects') detail = await get(path, 'Read loading rejection') assert.equal(detail.reviewState, 'LOADING_REJECTED'); assert.equal(detail.reviews.loadingReview.status, 'REJECTED') const second = fixture.members[1] result = await request('POST', `${path}/items/${encodeURIComponent(second.dispatchItemId)}/remove`, { recordVersion: detail.recordVersion, idempotencyKey: key('REMOVE-SECOND'), reason: 'TEST零扫码先移出一票再真实重选' }, 'Remove only unpicked second member') const removed = await snapshot('SECOND_REMOVED') assert.equal(Number(removed.main.counts.packageCount), 1); assert.equal(removed.items.items.length, 1) assert(removed.route.stops.every(stop => stop.destinationKey !== second.destination.key)) const candidates = await get(`${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: second.incomingNo })}`, 'Read freshly released source candidate') const choices = candidates.candidates?.filter(row => id(row.customerId) === second.customerId && id(row.selection?.warehousePalletId) === second.palletId && row.palletNo === second.palletNo) assert.equal(choices?.length, 1); const choice = choices[0] assert(choice.selection && choice.sourceVersion && choice.blockReasons?.length === 0 && Number(choice.availablePackages) === 1) result = await request('POST', `${path}/items`, { recordVersion: result.recordVersion, idempotencyKey: key('ADD-SECOND'), cargo: [{ ...choice.selection, warehousePalletId: second.palletId, sourceVersion: choice.sourceVersion }], batchAllocations: [] }, 'Re-add exact released current source') const added = await snapshot('SECOND_RESTORED') assert.equal(added.items.items.length, 2); assert.equal(Number(added.main.counts.packageCount), 2) const replacement = added.items.items.filter(row => id(row.customerId) === second.customerId && id(row.warehousePalletId) === second.palletId) assert.equal(replacement.length, 1); second.dispatchItemId = id(replacement[0].dispatchItemId) const nextPlate = `FT-${context.marker.slice(-12)}-R2` const vehicle = await request('POST', `${ROOT}/master-data/vehicles`, { plateNumber: nextPlate, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} TEST零扫码改派自有车辆` }, 'Create owned replacement TEST vehicle') const update = { recordVersion: result.recordVersion, idempotencyKey: key('CHANGE-VEHICLE'), vehicle: { mode: 'MASTER', vehicleId: id(vehicle.id) }, driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } }, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, remark: `${context.marker} TEST零扫码改车需两级重审` } result = await request('PUT', path, update, 'Change resources before re-review') detail = await get(path, 'Resource edit invalidates old approvals') assert.equal(detail.reviewState, 'DRAFT'); assert.equal(id(detail.vehicleInfo.vehicle.vehicleId), id(vehicle.id)) assert.notEqual(id(vehicle.id), fixture.tripProof.vehicleId) for (const [action, suffix] of [['submit-review', 'FINAL-SUBMIT'], ['dispatch-review', 'FINAL-DISPATCH'], ['loading-review', 'FINAL-LOADING']]) { result = await request('POST', `${path}/${action}`, { recordVersion: result.recordVersion, idempotencyKey: key(suffix), ...(action === 'submit-review' ? { remark: 'TEST变更后重审' } : { decision: 'APPROVED', reason: 'TEST新依据重审通过' }) }, `Final new round ${action}`) } const final = await snapshot('REAPPROVED_ZERO_PICK') assert.equal(final.main.stage, 'LOADING'); assert.equal(final.main.reviewState, 'APPROVED') assert(Number(final.main.reviews.dispatchReview.reviewRound) > Number(initial.main.reviews.dispatchReview.reviewRound)) assert.equal(final.main.reviews.dispatchReview.reviewRound, final.main.reviews.loadingReview.reviewRound) assert.deepEqual(final.stock, initial.stock, 'All zero-pick review and cargo/resource edits preserve complete inventory') const fleet = await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`, 'Read same main updated trip resources and route') assert.equal(fleet.trip.tripNo, fixture.tripNo); assert.equal(id(fleet.trip.vehicleId), id(vehicle.id)) assert.equal(fleet.dispatches.length, 1); assert.equal(fleet.dispatches[0].dispatchNo, fixture.dispatchNo) Object.assign(fixture.tripProof, { marker: nextPlate, vehicleId: id(vehicle.id), vehicle: final.main.vehicleInfo.vehicle, vehicleInfo: final.main.vehicleInfo, driver: final.main.vehicleInfo.driver, updateRequest: update, trip: fleet.trip, stops: fleet.trip.stops, fleetRecordVersion: fleet.trip.recordVersion, recordVersion: final.main.recordVersion }) proof.membersAfterEdit = clone(fixture.members); proof.finalVehicleInfo = clone(final.main.vehicleInfo) proof.status = 'VERIFIED'; await context.checkpoint(); return proof } export async function verifyReviewExtension(context, fixture, options) { await exerciseReviewLifecycle(context, fixture, options) return verifyDualDestination(context, fixture, options) } export function assertConfirmedRelabelCancellation(before, after, outbound, stockBefore, stockAfter) { assert.equal(before.application.status, 'READY_FOR_OUTBOUND') assert(before.application.customerConfirmedAt && before.application.outboundOrderNo) assert.equal(after.application.relabelNo, before.application.relabelNo) assert.equal(after.application.customerConfirmedAt, before.application.customerConfirmedAt) assert.equal(after.application.status, 'READY_FOR_OUTBOUND') assert.equal(after.application.deliveryMethod, before.application.deliveryMethod) assert.equal(after.application.outboundOrderNo ?? null, null) assert.equal(after.application.fulfillmentStatus, 'WAIT_OUTBOUND_CREATION') assert.deepEqual(after.items, before.items) assert.equal(outbound.outbound.status, 'CANCELLED') assert.equal(outbound.outbound.outboundNo, before.application.outboundOrderNo) assert.deepEqual(stockAfter, stockBefore, 'Zero-scan cancellation preserves all completed relabel stock and immutable ledger rows') } /** 独立第三客户三箱正式实收;不改变正常链两个客户的一箱库存基线。 */ export async function exerciseRelabelExtensions(context, fixture, { readInventory }) { const proof = context.createdResources.dispatchWorkflowRelabel = { marker: context.marker, status: 'PREPARING', syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false, scenarios: [], sharedDispatches: [], nonTruckRearrangement: 'NOT_TESTED_NO_FORMAL_PUBLIC_ENTRY' } const key = label => `${context.marker}-RL-${label}` const api = async (method, path, body, token) => { const response = await context.request(method, path, body, token) const data = context.apiSuccess(MODULE, 'Formal relabel extension HTTP', response, '', OPTIONS) assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return data } const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') const upload = async (path, name) => { const response = await context.requestMultipart('POST', path, { name, type: 'image/png', content: png }, fixture.pdaToken) const data = context.apiSuccess(MODULE, 'Upload explicitly synthetic TEST relabel evidence', response, '', OPTIONS) assert.equal(String(response.body?.code), '200'); assert(data?.fileId); return id(data.fileId) } const master = await api('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.members[0].destination.warehouseId)}?tenantId=0`) const address = master.addresses?.find(row => row.addressLine1) assert(address && master.warehouse?.status === 'ACTIVE') const child = Object.create(context) child.marker = `${context.marker}-RELABEL`; child.options = { ...context.options, customerName: `TEST换标扩展-${context.marker.slice(-12)}` } child.context = {}; child.createdResources = {} child.request = async (...args) => context.request(...args) await createQualifiedMerchant(child, { publishEnvironment: false }) const credentials = child.context.merchantQualificationFixture assert(credentials?.tenantId && credentials.marker === child.marker) proof.customerId = id(credentials.tenantId); proof.qualification = clone(child.createdResources.merchantQualificationFixture) let session try { session = await api('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, '') } finally { delete child.context.merchantQualificationFixture } assert(session?.accessToken && session.refreshToken) const merchantToken = session.accessToken, customerId = proof.customerId const stock = () => readInventory({ marker: context.marker, customerId, warehouseId: fixture.warehouseId }) try { const profile = await api('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchantToken) assert.equal(id(profile.id), customerId) const skuCode = `RL-${context.marker.slice(-12)}` const merchantRoot = '/api/merchant/v1/last-leg/warehouse-transfers' const create = { sourceLastlegOrderIds: [], operatingWarehouseId: fixture.warehouseId, fbaWarehouseCode: master.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(master.warehouse.id), destinationWarehouseCode: master.warehouse.warehouseCode, destinationWarehouseName: master.warehouse.warehouseName, deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.addressLine1, address.postalCode].filter(Boolean).join(' '), packageCount: 3, volumeCbm: 3, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19), needRelabel: false, remark: `${context.marker} TEST三箱换标独立来货`, idempotencyKey: key('RECEIPT-CREATE'), items: [{ skuCode, productNameZh: 'TEST三箱换标货物', packageCount: 3, quantity: 3 }] } proof.receiptCreate = clone(create); await context.checkpoint() let incoming = await api('POST', merchantRoot, create, merchantToken) proof.incomingNo = incoming.transferNo; assert(proof.incomingNo) const merchantPath = `${merchantRoot}/${encodeURIComponent(proof.incomingNo)}` incoming = await api('POST', `${merchantPath}/submit`, undefined, merchantToken) incoming = await api('POST', `${ROOT}/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}/review?customerId=${customerId}`, { decision: 'APPROVE', remark: `${context.marker} TEST三箱独立实收` }) assert.equal(incoming.status, 'WAIT_PDA_RECEIVING') const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}` const task = await api('GET', pda, undefined, fixture.pdaToken) assert.equal(Number(task.packageCount), 3); assert.equal(task.items.length, 1); assert.equal(task.cartons.length, 3) assert.equal(task.items[0].skuCode, skuCode); assert.equal(Number(task.items[0].quantity), 3) assert.equal(new Set(task.cartons.map(row => row.cartonMark)).size, 3) const draft = await api('POST', `${pda}/pallets/drafts`, { idempotencyKey: key('DRAFT') }, fixture.pdaToken) assert.equal(draft.pallets.length, 1) const palletPath = `${pda}/pallets/${encodeURIComponent(draft.pallets[0].id)}` const generated = await api('POST', `${palletPath}/generate`, { idempotencyKey: key('GENERATE') }, fixture.pdaToken) const pallet = generated.pallets[0]; assert(pallet.warehousePalletId && pallet.palletNo) await api('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key('BIND') }, fixture.pdaToken) const counted = await api('POST', `${pda}/cartons/count`, { forecastPackageCount: 3, idempotencyKey: key('COUNT'), boxes: task.cartons.map(carton => ({ cartonMark: carton.cartonMark, transferItemId: id(task.items[0].id), transferPalletId: id(draft.pallets[0].id), length: 100, width: 100, height: 100, weight: 100, items: [{ skuCode, quantity: 1 }] })) }, fixture.pdaToken) assert.equal(counted.cartons.length, 3); assert(counted.cartons.every(row => row.status === 'COUNTED' && Number(row.quantity) === 1)) assert.equal(Number(counted.pallets[0].cartonCount), 3); assert.equal(Number(counted.pallets[0].grossWeightKg), 300) const photoId = await upload(`${pda}/measurement-photos/files`, `${context.marker}-TEST-three-boxes.png`) incoming = await api('POST', `${pda}/palletize`, { idempotencyKey: key('PALLETIZE'), photoFileIds: [photoId] }, fixture.pdaToken) assert.equal(incoming.status, 'WAIT_CUSTOMER_CONFIRMATION') incoming = await api('POST', `${merchantPath}/confirm-measurement`, { idempotencyKey: key('CONFIRM-RECEIPT'), remark: context.marker }, merchantToken) assert.equal(incoming.status, 'READY_FOR_DISPATCH') proof.receipt = await stock() assert.equal(proof.receipt.balances.length, 1); assert.equal(proof.receipt.positions.length, 1); assert.equal(proof.receipt.ledger.length, 1) assert.equal(Number(proof.receipt.balances[0].availablePackages), 3) assert.equal(id(proof.receipt.positions[0].palletId), id(pallet.warehousePalletId)) const candidates = await api('GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${fixture.warehouseId}`, undefined, merchantToken) const sources = candidates.filter(row => id(row.inventoryBalanceId) === id(proof.receipt.balances[0].id) && row.inboundCode === proof.incomingNo) assert.equal(sources.length, 1); const source = sources[0] const positions = source.positions.filter(row => id(row.positionId) === id(proof.receipt.positions[0].id)) assert.equal(positions.length, 1); assert.equal(Number(positions[0].availablePackages), 3) const resolve = code => api('POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code }, fixture.pdaToken) let sharedTarget for (const [index, method] of ['TRUCK', 'TRUCK', 'PICKUP'].entries()) { const name = `${method}-${index + 1}`, scenario = { name, operationType: 'CARTON_LABEL', deliveryMethod: method } proof.scenarios.push(scenario) const item = { sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(positions[0].positionId), sourceInboundOrderId: id(source.inboundOrderId), skuCode, productNameZh: 'TEST三箱换标货物', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `TEST-RL-${context.marker.slice(-12)}-${index + 1}`, cartonMark: task.cartons[index].cartonMark, fbaCartonMark: `TEST-NEW-${context.marker.slice(-12)}-${index + 1}`, destinationFbaWarehouseCode: master.warehouse.warehouseCode, operationType: 'CARTON_LABEL', skuItems: [], labelRequirement: `${context.marker} TEST换标成果` } scenario.createBody = { customerOrderNo: `TEST-${context.marker.slice(-12)}-${name}`, operationWarehouseId: fixture.warehouseId, labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), contactName: 'TEST换标验收', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: method, printShippingLabel: false, items: [item] } await context.checkpoint() const created = await api('POST', `${ROOT}/relabel-applications?customerId=${customerId}`, scenario.createBody) scenario.relabelNo = created.application.relabelNo; assert(scenario.relabelNo) assert.equal(created.application.status, 'PENDING_REVIEW') assert(created.application.lastlegOrderId) const approved = await api('PUT', `${ROOT}/orders/${encodeURIComponent(created.application.lastlegOrderId)}/relabel-review?customerId=${customerId}`, { status: 'PENDING_PROCESSING', remark: `${context.marker} TEST正式审核后允许PDA作业` }) assert.equal(approved.application.status, 'PENDING_PROCESSING'); scenario.approved = clone(approved) const taskRoot = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(scenario.relabelNo)}` let detail = await api('POST', `${taskRoot}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }, fixture.pdaToken) assert.equal(detail.items.length, 1) detail = await api('POST', `${taskRoot}/units`, { itemId: id(detail.items[0].itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark, skuVerifications: [], expectedVersion: detail.version, idempotencyKey: key(`${name}-SCAN`) }, fixture.pdaToken) let target if (index === 1) target = sharedTarget else target = await api('POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { relabelNo: scenario.relabelNo, idempotencyKey: key(`${name}-LABEL`) }, fixture.pdaToken) assert(target?.palletId && target.palletCode); if (index === 0) sharedTarget = target const from = await resolve(pallet.palletNo), to = await resolve(target.palletCode) await api('POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { relabelNo: scenario.relabelNo, idempotencyKey: key(`${name}-MOVE`), source: { palletCode: from.palletCode, expectedVersion: from.palletVersion }, target: { palletCode: to.palletCode, expectedVersion: to.palletVersion }, cartonMarks: [item.cartonMark], reason: `${context.marker} TEST实际箱移绑` }, fixture.pdaToken) scenario.targets = [clone(target)] const fileId = await upload(`${taskRoot}/photos/files`, `${context.marker}-${name}-TEST.png`) detail = await api('POST', `${taskRoot}/complete`, { photoFileIds: [fileId], expectedVersion: detail.version, idempotencyKey: key(`${name}-COMPLETE`), remark: context.marker }, fixture.pdaToken) assert.equal(detail.applicationStatus, 'CUSTOMER_CONFIRM_PENDING') const confirmed = await api('POST', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(scenario.relabelNo)}/confirm-completion`, { idempotencyKey: key(`${name}-CONFIRM`), remark: context.marker }, merchantToken) assert.equal(confirmed.application.status, 'READY_FOR_OUTBOUND'); assert(confirmed.application.customerConfirmedAt) scenario.confirmed = clone(confirmed); scenario.items = clone(confirmed.items); scenario.relabelVerified = true scenario.destinationFbaWarehouseCode = master.warehouse.warehouseCode await context.checkpoint() } proof.sharedPhysical = await resolve(sharedTarget.palletCode) assert.equal(Number(proof.sharedPhysical.totalPackages), 2) const pickup = proof.scenarios[2], relabelPath = `${ROOT}/relabel-applications/${encodeURIComponent(pickup.relabelNo)}` const before = await api('GET', relabelPath), stockBefore = await stock() assert.equal(before.application.fulfillmentStatus, 'OUTBOUND_CREATED'); assert(before.application.outboundOrderNo) const outboundPath = `${ROOT}/outbound-orders/${encodeURIComponent(before.application.outboundOrderNo)}` const outboundBefore = await api('GET', outboundPath) assert(['WAIT_PICK', 'WAIT_OUTBOUND'].includes(outboundBefore.outbound.status)) proof.cancel = { status: 'INTENT', before, stockBefore, outboundBefore, body: { reason: `${context.marker} TEST已确认换标零扫码撤销本次出库安排` } } await context.checkpoint() await api('POST', `${outboundPath}/cancel`, proof.cancel.body) const after = await api('GET', relabelPath), outboundAfter = await api('GET', outboundPath), stockAfter = await stock() Object.assign(proof.cancel, { after, outboundAfter, stockAfter }) assertConfirmedRelabelCancellation(before, after, outboundAfter, stockBefore, stockAfter) proof.cancel.status = 'VERIFIED'; await context.checkpoint() context.assert(MODULE, 'Confirmed relabel zero-scan outbound cancellation preserves completed items, customer confirmation and all raw stock', null, true, '', OPTIONS) await verifyRelabelSharedDispatches(context, fixture, { readInventory }, proof) return proof } finally { await api('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, merchantToken) proof.ownedMerchantSessionClosed = true; await context.checkpoint() } } export async function verifyRelabelSharedDispatches(context, fixture, { readInventory }, proof) { const customerId = proof.customerId, truck = proof.scenarios.slice(0, 2) const stock = () => readInventory({ marker: context.marker, customerId, warehouseId: fixture.warehouseId }) const api = async (method, path, body) => { const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, 'Exact owned relabel shared physical selection', response, '', OPTIONS) assert.equal(String(response.body?.code), '200'); return data } assert.equal(proof.cancel.status, 'VERIFIED'); assert.equal(truck.length, 2) assert(truck.every(row => row.relabelVerified && row.deliveryMethod === 'TRUCK')) proof.status = 'CHECKING_SHARED_NATIVE' for (const round of [1, 2]) { const byKey = new Map() for (const scenario of truck) { const page = await api('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, sourceStage: 'RELABEL', orderNo: scenario.relabelNo })}`) assert.equal(page.complete, true) for (const row of page.candidates) { assert(truck.some(item => item.relabelNo === row.selection?.relabelNo)) const prior = byKey.get(row.sourceKey) if (prior) assert.deepEqual(prior, row) byKey.set(row.sourceKey, row) } } const rows = [...byKey.values()] assert.equal(rows.length, 2); assert.equal(new Set(rows.map(row => id(row.selection.warehousePalletId))).size, 1) const createBody = nativeRelabelCreateBody(`${context.marker}-ROUND-${round}`, truck, rows, customerId, fixture.warehouseId) const run = { round, status: 'INTENT', createBody, stockBefore: await stock() }; proof.sharedDispatches.push(run); await context.checkpoint() run.result = await api('POST', `${ROOT}/dispatches`, createBody) assert(run.result.dispatchNo) const path = `${ROOT}/dispatches/${encodeURIComponent(run.result.dispatchNo)}` run.detail = await api('GET', path); run.items = await api('GET', `${path}/items`) assert.equal(Number(run.detail.counts.packageCount), 2); assert.equal(Number(run.detail.counts.palletCount), 1) assert.equal(Number(run.detail.counts.pickedPackageCount), 0); assert.equal(run.items.items.length, 2) assert.equal(new Set(run.items.items.map(row => id(row.warehousePalletId))).size, 1) assert(run.items.items.every(row => id(row.customerId) === customerId && Number(row.scheduledPackages) === 1)) run.cancelBody = { recordVersion: run.detail.recordVersion, idempotencyKey: `${context.marker}-RL-SHARED-${round}-CANCEL`, reason: `${context.marker} TEST零扫码验证后撤销安排保留换标成果` } await context.checkpoint(); run.cancelled = await api('POST', `${path}/cancel`, run.cancelBody) assert.equal(run.cancelled.status, 'CANCELLED'); run.stockAfter = await stock(); assert.deepEqual(run.stockAfter, run.stockBefore) for (const scenario of truck) { const current = await api('GET', `${ROOT}/relabel-applications/${encodeURIComponent(scenario.relabelNo)}`) assert.equal(current.application.status, 'READY_FOR_OUTBOUND'); assert.equal(current.application.customerConfirmedAt, scenario.confirmed.application.customerConfirmedAt) assert.deepEqual(current.items, scenario.confirmed.items) } run.status = 'VERIFIED'; await context.checkpoint() } assert.notEqual(proof.sharedDispatches[0].result.dispatchNo, proof.sharedDispatches[1].result.dispatchNo) context.assert(MODULE, 'Two confirmed applications share one actual pallet without double counting and can be natively rearranged after zero-pick cancellation', null, true, '', OPTIONS) proof.status = 'VERIFIED_WITH_NON_TRUCK_REARRANGEMENT_GAP'; await context.checkpoint(); return proof } export async function reconcileRelabelShared(context, fixture, options, prior) { const proof = context.createdResources.dispatchWorkflowRelabel = clone(prior.createdResources.dispatchWorkflowRelabel) assert.equal(proof.sharedDispatches.length, 0); assert.equal(proof.cancel.status, 'VERIFIED') assert(proof.scenarios.every(row => row.relabelVerified === true)) const read = async path => { const response = await context.request('GET', path) assert.equal(String(response.body?.code), '200'); return response.body.data } const main = await read(`${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`) const trip = await read(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`) assert.equal(main.stage, 'DELIVERED'); assert.equal(trip.trip.status, 'COMPLETED') assert(trip.trip.vehicleRelease && trip.trip.driverRelease) const currentStock = await options.readInventory({ marker: context.marker, customerId: proof.customerId, warehouseId: fixture.warehouseId }) assert.deepEqual(currentStock, proof.cancel.stockAfter) for (const scenario of proof.scenarios) { const current = await read(`${ROOT}/relabel-applications/${encodeURIComponent(scenario.relabelNo)}`) assert.equal(current.application.status, 'READY_FOR_OUTBOUND'); assert.equal(current.application.customerConfirmedAt, scenario.confirmed.application.customerConfirmedAt) assert.deepEqual(current.items, scenario.confirmed.items) } await verifyRelabelSharedDispatches(context, fixture, options, proof) context.createdResources.fleetCrossCustomer = clone(prior.createdResources.fleetCrossCustomer) return context.createdResources.fleetCrossCustomer } export async function verifyRelabelExtension(context, fixture, options) { const verified = await verifyDualDestination(context, fixture, options) try { await exerciseRelabelExtensions(context, fixture, options) } catch (error) { if (!context.createdResources.fleetWriteBoundary && !fixture.tripProof.closure) await finishFleetTrip(context, MODULE, fixture.tripProof) throw error } return verified } /** Read-only terminal reconciliation after the known sequential-read completion race; never replay fulfillment. */ export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) { assert.equal(prior.success, false) assert.equal(prior.error, 'Native main/items/PDA retain both true customers and physical pallets') assert.equal(prior.createdResources.dispatchWorkflowReviews.status, 'VERIFIED') assert.equal(prior.createdResources.fleetWriteBoundary, undefined) const old = prior.createdResources.fleetCrossCustomer, path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` assert.equal(old.dispatchNo, fixture.dispatchNo); assert.equal(old.marker, context.marker) assert(old.requests.filter(row => row.label === 'Attach receipt to independently complete this delivery').length === 2) assert(old.requests.every(row => row.status === 'RESPONDED')) const get = async target => { const response = await context.request('GET', target) const data = context.apiSuccess(MODULE, 'Explicit terminal reconciliation read only', response, '', OPTIONS) assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data } const current = { trip: await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`), dispatch: await get(path), items: await get(`${path}/items`), task: null, members: [] } const pda = await context.request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`, undefined, fixture.pdaToken) assert.equal(pda.http, 200); assert.equal(String(pda.body?.code), '200'); current.task = pda.body.data assert.equal(current.dispatch.stage, 'DELIVERED'); assert.equal(current.dispatch.status, 'ACTIVE') for (const value of [current.items, current.task]) { assert.equal(value.dispatchNo, fixture.dispatchNo); assert.equal(value.stage, 'DELIVERED') assert.equal(id(value.recordVersion), id(current.dispatch.recordVersion)); assert.equal(value.items.length, 2) } for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) assert.equal(Number(current.dispatch.counts[field]), 2) assert.equal(current.trip.trip.tripNo, fixture.tripNo) assert.equal(id(current.trip.trip.vehicleId), fixture.tripProof.vehicleId); assert.equal(id(current.trip.trip.driverId), fixture.tripProof.driverId) assert.equal(current.trip.dispatches.length, 1); assert.equal(current.trip.dispatches[0].dispatchNo, fixture.dispatchNo) const extension = context.createdResources.dispatchWorkflowExtensions = clone(prior.createdResources.dispatchWorkflowExtensions) extension.status = 'CHECKING'; extension.explicitTerminalContinuation = true; extension.priorFailurePreserved = true const allFeeIds = new Set() for (const [index, member] of fixture.members.entries()) { const oldMember = old.members[index] assert.equal(member.customerId, oldMember.customerId); assert.equal(member.dispatchItemId, oldMember.dispatchItemId) for (const rows of [current.items.items, current.task.items]) { const matches = rows.filter(row => id(row.dispatchItemId) === member.dispatchItemId) assert.equal(matches.length, 1); assert.equal(id(matches[0].customerId), member.customerId) assert.equal(matches[0].palletNo, member.palletNo); assert.equal(Number(matches[0].pickedPackages), 1) } const suffix = new URLSearchParams({ customerId: member.customerId }) const outbound = await get(`${ROOT}/outbound-orders/${encodeURIComponent(oldMember.outboundNo)}?${suffix}`) const delivery = await get(`${ROOT}/delivery-orders/${encodeURIComponent(oldMember.deliveryNo)}?${suffix}`) assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED') assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED') assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length) for (const fee of delivery.fees) { assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) } const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }) assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory') current.members.push({ outbound, delivery, stock }) } assert.deepEqual(await get(path), current.dispatch, 'Terminal state changed during reconciliation') const verified = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, members: clone(old.members), status: 'VERIFIED', snapshots: { FINAL: current }, continuation: 'TERMINAL_READONLY_AFTER_CONFIRMED_SIGNATURES', priorError: prior.error, gateProof: old.gateProof, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' } extension.status = 'VERIFIED'; await context.checkpoint(); return verified }