491 lines
39 KiB
JavaScript
491 lines
39 KiB
JavaScript
import assert from 'node:assert/strict'
|
|||
|
|
import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.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: 'Confirmed relabel cancellation/rearrangement and multiple applications sharing one physical pallet', readiness: 'REQUIRES_FORMAL_RELABEL_FIXTURE' },
|
||
|
|
{ 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, 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()
|
||
|
|
}
|
||
|
|
const 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 (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()
|
||
|
|
}
|
||
|
|
|
||
|
|
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')
|
||
|
|
proof.status = 'VERIFIED_BOUNDED_SCOPE'; await context.checkpoint(); return verified
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 仅使用本轮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)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 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
|
||
|
|
}
|