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' // 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() if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, { idempotencyKey: `${context.marker}-DEPART`, remark: `${context.marker}-test-only` }, picker.token) 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 { await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) result.checks = context.steps save() console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error, dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo, deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output })) }