diff --git a/scripts/README.md b/scripts/README.md index d984c0701..a3e6cbca3 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d3c9d31b5..08ba8bb13 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 6de52ac9c..2285bebff 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -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 : [] diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs new file mode 100644 index 000000000..48f55f897 --- /dev/null +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -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 })) +}