This commit is contained in:
2026-09-15 15:13:15 +08:00
parent 7edea222d8
commit 6d5cc03741
4 changed files with 166 additions and 2 deletions
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引
`mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜 FBA 票出库续跑入口:参数为 `source-report containerNo MIXED-FBA output-report`,凭据由显式 `node --env-file` 注入。它实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它只覆盖选定 FBA 分支,不代表普通海外仓目的地转换或一件代发专用出库验收通过。
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
进入本目录维护或运行脚本前先读 `scripts/AGENTS.md`;本文件是脚本入口、用途和副作用的唯一总索引,具体模块说明继续下沉到相邻 README。
+2
View File
@@ -1,5 +1,7 @@
# Admin 测试环境 HTTP 模块
`phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
@@ -66,7 +66,7 @@ async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
return { result, attempts }
}
async function saveReceiptAndComplete(context, delivery, customerId, index) {
export async function saveReceiptAndComplete(context, delivery, customerId, index) {
const deliveryNo = id(delivery.deliveryNo)
const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId })
const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath)
@@ -180,7 +180,7 @@ function fixture(context) {
return { dispatch, receiving }
}
async function pickAll(context, token, dispatchNo) {
export async function pickAll(context, token, dispatchNo) {
let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const items = Array.isArray(detail.data?.items) ? detail.data.items : []
@@ -0,0 +1,160 @@
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 }))
}