Files
baiyue-firstleg/scripts/mixed-container-outbound-test-env.mjs
T

175 lines
12 KiB
JavaScript

import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
// Explicit retained fixture continuation, not a new fixture generator.
const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2)
if (!sourcePath || !containerNo || !['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) || !outputPath) {
throw new Error('Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit test authorization required')
}
const options = parseArguments([])
validateExecutionOptions(options)
const context = new AcceptanceRun(options)
const source = JSON.parse(readFileSync(resolve(sourcePath), 'utf8'))
const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label)
if (!record || record.overseas?.plan?.containerNo !== containerNo || !record.overseas?.receiving?.receivingVerified) {
throw new Error('Fixture must be the explicitly selected, completed mixed-container receipt')
}
const inboundCode = record.source.inboundCode
const customerId = record.overseas.plan.customerId
const output = resolve(outputPath)
const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : {
marker: context.marker, containerNo, inboundCode, label, source: resolve(sourcePath), events: [],
}
if (result.inboundCode !== inboundCode || result.containerNo !== containerNo || result.label !== label) throw new Error('Checkpoint scope mismatch')
context.marker = result.marker
const save = () => writeFileSync(output, JSON.stringify({ ...result, updatedAt: new Date().toISOString() }, null, 2))
const base = '/api/platform/v1/last-leg'
async function api(method, path, body, token) {
const response = await context.requestOverseas(method, path, body, {}, token || context.overseasToken)
result.events.push({ method, path, http: response.http, code: response.body?.code,
message: response.body?.message, requestId: response.requestId, traceId: response.traceId })
save()
if (response.http !== 200 || String(response.body?.code) !== '200') throw new Error(`${method} ${path}: ${response.body?.message}`)
return response.body.data
}
let picker
try {
const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
if (!login.body?.data?.overseasAccessToken) throw new Error('Platform login failed')
context.token = login.body.data.accessToken
context.overseasToken = login.body.data.overseasAccessToken
if (label === 'MIXED-DROPSHIP') {
const candidatePath = `${base}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}`
if (!result.task) {
const candidates = await api('GET', candidatePath)
const selected = candidates.filter(row => row.inboundCode === inboundCode)
const inputs = selected.map(row => {
const pallet = record.task.pallets.find(p => p.palletNo === row.palletNo)
const cartonMarks = (pallet?.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId)).map(c => c.boxCode)
if (cartonMarks.length !== Number(row.availablePackages) || row.customerId !== customerId) throw new Error('Dropship pallet scope mismatch')
return { inventoryPositionId: row.inventoryPositionId, packages: cartonMarks.length, sku: row.skuCode, cartonMarks }
})
if (inputs.length !== 2 || inputs.reduce((n, i) => n + i.packages, 0) !== 4) throw new Error('Expected exactly two pallets and four cartons')
result.selected = selected
result.inputs = inputs
result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` }
save()
result.task = await api('POST', `${base}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody)
save()
}
const before = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`)
if (before.outbound.status !== 'OUTBOUNDED') {
const access = await resolvePickerAccess(context)
picker = access ? await createPicker(context, 9, record.overseas.warehouseId, access) : null
if (!picker?.token) throw new Error('PDA operator unavailable')
result.pickerMembershipId = picker.membershipId
save()
const path = `/api/pda/v1/last-leg/outbound-tasks/${result.task.taskNo}`
const detail = await api('GET', path, undefined, picker.token)
for (const [index, candidate] of result.selected.entries()) {
const item = detail.items.find(i => i.palletNo === candidate.palletNo && i.inboundCode === inboundCode && i.skuCode === candidate.skuCode)
if (!item || !item.positions.some(p => String(p.inventoryPositionId) === String(candidate.inventoryPositionId))) throw new Error('PDA source position mismatch')
const body = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId,
packages: result.inputs[index].packages, cartonMarks: result.inputs[index].cartonMarks, idempotencyKey: `${context.marker}-PICK-${index}` }
await api('POST', `${path}/pick`, body, picker.token)
await api('POST', `${path}/pick`, body, picker.token)
}
result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token)
}
result.outbound = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`)
result.remainingCandidates = (await api('GET', candidatePath)).filter(row => row.inboundCode === inboundCode)
result.finalStatus = result.outbound.outbound.status === 'OUTBOUNDED' && result.remainingCandidates.length === 0
? 'OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED' : 'OUTBOUND_FAILED'
} else {
const candidates = await api('GET', `${base}/dispatches/candidates?sourceStage=RECEIVED&orderNo=${encodeURIComponent(record.overseas.summary.orderNo)}`)
const selected = candidates.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo)
if (!result.dispatchNo) {
if (selected.reduce((sum, row) => sum + Number(row.packageCount), 0) !== 4 || selected.some(row => row.fbaWarehouseCode !== 'ABE4')) throw new Error('Live FBA candidate quantity or destination changed')
// The caller must retain the checkpoint after every mutation; do not retry creation without checking live candidates.
const dispatch = await api('POST', `${base}/dispatches/pre-arrange`, { planPalletIds: selected.map(row => row.planPalletId) })
result.dispatchNo = dispatch.dispatchNo
save()
}
const dispatchPath = `${base}/dispatches/${encodeURIComponent(result.dispatchNo)}`
let dispatch = await api('GET', dispatchPath)
if (!result.transfer) {
await api('PUT', dispatchPath, { vehiclePlate: `TEST-${context.marker.slice(-8)}`, driverName: 'HTTP测试司机',
driverPhone: '19900000000', carrierName: 'HTTP测试车队', estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString() })
result.transfer = await api('POST', `${dispatchPath}/transfer-delivery`, {
idempotencyKey: `${context.marker}-TRANSFER`, plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', carrierName: 'HTTP测试车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: 'HTTP测试司机', driverPhone: '19900000000',
vehiclePlate: `TEST-${context.marker.slice(-8)}`, appointmentAccount: 'SELF', remark: `${context.marker}-test-only`,
fees: [{ feeType: 'DELIVERY_FEE', feeName: '测试派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 1, remark: `${context.marker}-test-only` }],
})
save()
}
dispatch = await api('GET', dispatchPath)
if (dispatch.status !== 'DEPARTED') {
const access = await resolvePickerAccess(context)
picker = access ? await createPicker(context, 8, record.overseas.warehouseId, access) : null
if (!picker?.token) throw new Error('PDA operator unavailable')
result.pickerMembershipId = picker.membershipId
save()
const tripTask = await api('GET', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}`, undefined, picker.token)
if (!tripTask.tripNo) throw new Error('Retained dispatch must be assigned to a qualified fleet trip before picking; prepare through the current fleet API')
const trip = await api('GET', `${base}/fleet/trips/${encodeURIComponent(tripTask.tripNo)}`)
if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed')
const tripVersion = await confirmFleetLoading(context, 'mixed-container-outbound', { tripNo: tripTask.tripNo, stops: trip.trip.stops })
result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`,
{ recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: `${context.marker}-test-only` }, picker.token)
if (result.departure.status !== 'IN_TRANSIT' || !result.departure.dispatches?.some(item => item.dispatchNo === result.dispatchNo)) throw new Error('Whole-trip departure result missing retained dispatch')
save()
}
const deliveries = result.transfer.deliveries
if (!Array.isArray(deliveries) || deliveries.length !== 1 || Number(result.transfer.packageCount) !== 4) throw new Error('Unexpected transfer quantities')
result.deliveries = []
for (const [index, delivery] of deliveries.entries()) {
const path = `${base}/delivery-orders/${delivery.deliveryNo}?customerId=${customerId}`
const detail = await api('GET', path)
if (detail.order.status !== 'DELIVERED') {
if (detail.order.status !== 'DELIVERING') await api('PUT', `${base}/delivery-orders/${delivery.deliveryNo}/status?customerId=${customerId}`,
{ status: 'DELIVERING', expectedVersion: detail.order.version, remark: `${context.marker}-test-only` })
if (!await saveReceiptAndComplete(context, delivery, customerId, index)) throw new Error('Receipt signature failed')
}
result.deliveries.push(await api('GET', path))
save()
}
result.outbound = await api('GET', `${base}/outbound-orders/${result.transfer.outboundNo}`)
for (let attempt = 0; attempt < 30; attempt++) {
const response = await context.request('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${inboundCode}&page=1&size=20`)
if (String(response.body?.code) !== '200') throw new Error('Domestic inbound query failed')
result.domesticInbound = response.body.data.records.find(row => row.inboundCode === inboundCode)
if (result.domesticInbound?.processStatus === 'DELIVERED') break
await new Promise(resolve => setTimeout(resolve, 1000))
}
if (!result.domesticInbound?.orderId) throw new Error('Missing domestic source order')
const order = await context.request('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`)
result.domesticOrder = order.body?.data?.summary
result.finalStatus = result.domesticInbound?.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED'
&& result.outbound?.outbound?.status === 'OUTBOUNDED' ? 'PASSED' : 'STATE_CALLBACK_FAILED'
}
} catch (error) {
result.error = error.message
result.finalStatus = 'BLOCKED'
process.exitCode = 1
} finally {
try {
await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8)
} catch (error) {
result.cleanupError = error.message
result.finalStatus = 'CLEANUP_FAILED'
}
result.checks = context.steps
if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED'
if (result.finalStatus !== 'PASSED') process.exitCode = 1
save()
console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error,
dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo || result.task?.outboundNo,
deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output }))
}