提交
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`local-looseboard-receiving-acceptance.mjs`:仅本机 Gateway 的散板两场景 HTTP 验收。显式通过 `node --env-file=<开发配置> --env-file=<PDA配置> scripts/local-looseboard-receiving-acceptance.mjs` 提供 `ADMIN_TEST_GATEWAY`、平台账号及 `PDA_USERNAME/PDA_PASSWORD`;复用有效客户和PDA,创建送货到仓、仓库上门取货各一单,审核、取货、PDA清点打托并加入同一散板需求排车,验证4箱2托、零拣货及菜单/按钮权限。会创建本地测试单据、车辆司机和合成照片,不代表现场物流或页面验收;不删除数据,报告默认 `reports/local-looseboard-receiving.json`(可用 `LOOSEBOARD_ACCEPTANCE_OUTPUT` 指定),失败后按已保存单号和幂等键继续,不重复造单。设置 `LOOSEBOARD_ACCEPTANCE_PLANNED=true` 并指定新的报告路径可验证审核后未收货先加货、空起运地送货到仓、物流号可空、取货凭证及实收后沿原预排交接。
|
||||
|
||||
Admin 全量可显式传 `--stop-file <本轮唯一停止文件>`。运行前文件必须不存在;需要中止时创建该文件,脚本等待当前模块或前置准备到达边界,退出后续模块,仍执行入口的故障配置恢复与健康核验并保存失败报告。中止结果不计为全量通过。不要强杀含故障注入的进程;同批次不得覆盖重跑,后继运行改用新报告及停止文件。
|
||||
|
||||
装柜事件 ACK 丢失代理保留同一 Lastleg 服务上的两类只读请求:`POST /internal/lastleg/v1/order-workflow-facts` 和 `GET /internal/lastleg/appointment-work/containers/{id}/route`。它们按原正文和服务身份透传,不进入事件选取、故障计数或事件证据;未列出的路径和方法继续拒绝,避免故障验收阻断正常尾程查询。对应离线行为由 `event-delivery-fault-proxy.test.mjs` 验证。
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
// 两种入仓方式均走正式 HTTP;照片和箱规是本地验收样本,不代表真实物流作业。
|
||||
const gateway = process.env.ADMIN_TEST_GATEWAY?.replace(/\/$/, '')
|
||||
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw Error('必须显式指定本机 Gateway')
|
||||
const planned = process.env.LOOSEBOARD_ACCEPTANCE_PLANNED === 'true'
|
||||
const output = resolve(process.env.LOOSEBOARD_ACCEPTANCE_OUTPUT || 'reports/local-looseboard-receiving.json')
|
||||
let report
|
||||
try { report = JSON.parse(await readFile(output, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
report ||= { marker: `LB-${Date.now()}`, environment: 'LOCAL', evidence: 'SYNTHETIC_TEST_MEASUREMENTS_AND_PHOTO', orders: [], requests: {}, checks: [] }
|
||||
if (report.passed) { console.log(JSON.stringify({ passed: true, orders: report.orders, dispatchNo: report.dispatchNo })); process.exit(0) }
|
||||
let admin, pda
|
||||
const base = '/api/platform/v1/last-leg', key = name => `${report.marker}-${name}`
|
||||
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) }
|
||||
function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw Error(label) }
|
||||
async function api(token, method, path, body) {
|
||||
const form = body instanceof FormData
|
||||
const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) })
|
||||
const value = await response.json(), passed = response.ok && String(value.code) === '200'
|
||||
report.checks.push({ method, path, http: response.status, code: value.code, passed, requestId: value.requestId || response.headers.get('x-request-id'), message: passed ? undefined : value.message }); await save()
|
||||
if (!passed) throw Error(`${method} ${path}: ${value.code} ${value.message}`)
|
||||
return value.data
|
||||
}
|
||||
const call = (method, path, body) => api(admin, method, path, body)
|
||||
function image(name) { const f = new FormData(); f.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')], { type: 'image/png' }), `${key(name)}-TEST.png`); return f }
|
||||
async function resource(warehouseId) {
|
||||
if (report.resources) return report.resources
|
||||
const drivers = `${base}/fleet/drivers`
|
||||
let driver
|
||||
if (report.driverId) driver = await call('GET', `${drivers}/${report.driverId}`)
|
||||
else {
|
||||
driver = await call('POST', drivers, { name: `${report.marker}验收司机`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: report.marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: '2028-12-31', remark: '本地模拟资质', profile: { warehouseId, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario TEST' } })
|
||||
report.driverId = driver.id; await save()
|
||||
}
|
||||
driver = await call('POST', `${drivers}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image('licence'))
|
||||
driver = await call('POST', `${drivers}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '本地模拟资质验收' })
|
||||
const vehicle = await call('POST', `${base}/master-data/vehicles`, { plateNumber: `LB-${report.marker.slice(-10)}`, model: '26ft Box Truck', fuelConsumptionPer100Km: 22, mileageKm: 18000, driverOrDispatchCompany: report.marker, phone: '0000000000', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: '本地两场景验收', maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45 })
|
||||
report.resources = { driverId: driver.id, vehicleId: vehicle.id }; await save(); return report.resources
|
||||
}
|
||||
async function pickup(order) {
|
||||
const path = `${base}/dispatches/transport-tasks`
|
||||
const read = () => call('GET', `${path}/${order.pickupDispatchNo}`)
|
||||
// 审核已经自动建立取货排车时复用原车,不能重复创建。
|
||||
if (!order.pickupDispatchNo) {
|
||||
const page = await call('GET', `${base}/dispatches?page=1&size=50&customerId=${report.customerId}&warehouseId=${report.warehouseId}`)
|
||||
for (const row of page.records.filter(r => r.taskType === 'PICKUP')) {
|
||||
const d = await call('GET', `${path}/${row.dispatchNo}`)
|
||||
if (d.pickups.some(r => r.transferNo === order.transferNo)) { order.pickupDispatchNo = row.dispatchNo; await save(); break }
|
||||
}
|
||||
}
|
||||
if (!order.pickupDispatchNo) {
|
||||
report.requests.pickup ||= { sources: [{ customerId: report.customerId, transferNo: order.transferNo }], plannedDepartureAt: new Date(Date.now() - 300000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), remark: report.marker, idempotencyKey: key('pickup') }; await save()
|
||||
const d = await call('POST', `${path}/pickup`, report.requests.pickup); order.pickupDispatchNo = d.dispatchNo; await save()
|
||||
}
|
||||
let d = await read()
|
||||
const act = async (suffix, extra = {}) => { d = await read(); return call('POST', `${path}/${order.pickupDispatchNo}/${suffix}`, { recordVersion: d.execution.trip.recordVersion, idempotencyKey: randomUUID(), reason: report.marker, ...extra }) }
|
||||
if (!d.execution.trip.vehicleId) {
|
||||
const r = await resource(report.warehouseId)
|
||||
const vehicle = await api(admin, 'POST', `${base}/dispatches/${order.pickupDispatchNo}/evidence/VEHICLE`, image('vehicle'))
|
||||
const driver = await api(admin, 'POST', `${base}/dispatches/${order.pickupDispatchNo}/evidence/DRIVER_PHOTO`, image('driver'))
|
||||
d = await read()
|
||||
await act('assign', { ...r, dispatchRecordVersion: d.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [vehicle.fileId], driverPhotoFileIds: [driver.fileId], licenseFileIds: [] } })
|
||||
}
|
||||
for (const [action, state] of [['submit-review', 'DRAFT'], ['dispatch-review', 'DISPATCH_PENDING'], ['loading-review', 'LOADING_PENDING']]) {
|
||||
const root = await call('GET', `${base}/dispatches/${order.pickupDispatchNo}`)
|
||||
if (root.reviewState === state) await call('POST', `${base}/dispatches/${order.pickupDispatchNo}/${action}`, { recordVersion: root.recordVersion, idempotencyKey: key(`pickup-${action}`), ...(action === 'submit-review' ? { remark: report.marker } : { decision: 'APPROVED', reason: report.marker }) })
|
||||
}
|
||||
d = await read()
|
||||
if (d.execution.trip.status === 'PLANNED') await act('pickup/depart')
|
||||
d = await read()
|
||||
let task = d.pickups.find(r => r.transferNo === order.transferNo)
|
||||
const suffix = `pickup/${report.customerId}/${order.transferNo}`
|
||||
if (task.status === 'ASSIGNED') {
|
||||
const f = await call('POST', `${path}/${order.pickupDispatchNo}/${suffix}/files`, image('pickup'))
|
||||
await act(`${suffix}/picked`, { actualPackages: 2, evidenceFileIds: [f.fileId] })
|
||||
}
|
||||
d = await read(); task = d.pickups.find(r => r.transferNo === order.transferNo)
|
||||
if (task.status === 'PICKED') {
|
||||
const f = await call('POST', `${path}/${order.pickupDispatchNo}/${suffix}/files`, image('arrival'))
|
||||
await act(`${suffix}/arrive`, { evidenceFileIds: [f.fileId] })
|
||||
}
|
||||
d = await read()
|
||||
if (d.execution.trip.status === 'IN_TRANSIT') await act('pickup/complete')
|
||||
for (const kind of ['VEHICLE', 'DRIVER']) {
|
||||
d = await read()
|
||||
if (!d.execution.trip[kind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) await act('release', { resourceKind: kind, actualEndAt: new Date().toISOString(), endLocation: '本地验收收货仓', vehicleStatus: kind === 'VEHICLE' ? 'IN_SERVICE' : null })
|
||||
}
|
||||
}
|
||||
async function receive(order) {
|
||||
const path = `/api/pda/v1/last-leg/warehouse-transfers/${order.transferNo}`
|
||||
let d = await api(pda, 'GET', path)
|
||||
if (d.status === 'READY_FOR_DISPATCH') return
|
||||
if (!d.pallets.length) d = await api(pda, 'POST', `${path}/pallets/drafts`, { idempotencyKey: key(`${order.mode}-draft`) })
|
||||
let p = d.pallets[0]
|
||||
if (p.status === 'DRAFT') d = await api(pda, 'POST', `${path}/pallets/${p.id}/generate`, { idempotencyKey: key(`${order.mode}-generate`) })
|
||||
p = d.pallets.find(r => r.id === p.id)
|
||||
if (p.status === 'GENERATED') d = await api(pda, 'POST', `${path}/pallets/${p.id}/bind`, { idempotencyKey: key(`${order.mode}-bind`), palletCode: p.palletNo })
|
||||
if (!d.cartons.some(r => r.countedAt)) d = await api(pda, 'POST', `${path}/cartons/count`, { idempotencyKey: key(`${order.mode}-count`), forecastPackageCount: d.packageCount, boxes: d.cartons.map(box => ({ cartonMark: box.cartonMark, transferPalletId: p.id, length: 40, width: 30, height: 25, weight: 4, items: [{ skuCode: d.items[0].skuCode, quantity: 1 }] })) })
|
||||
const f = await api(pda, 'POST', `${path}/measurement-photos/files`, image(order.mode))
|
||||
d = await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: key(`${order.mode}-palletize`), photoFileIds: [f.fileId] })
|
||||
check(d.status === 'READY_FOR_DISPATCH', `${order.mode}打托完成后可派车`)
|
||||
order.palletNo = p.palletNo; await save()
|
||||
}
|
||||
try {
|
||||
const login = await api('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }); admin = login.accessToken
|
||||
check(login.authorities?.includes('platform:fleet:capacity-offer:list'), '散板需求菜单权限已下发')
|
||||
check(login.authorities?.includes('platform:fleet:capacity-offer:cargo-add'), '加入散板货物按钮权限已下发')
|
||||
pda = (await api('', 'POST', '/api/auth/login', { loginId: process.env.PDA_USERNAME, password: process.env.PDA_PASSWORD })).accessToken
|
||||
const customers = (await call('GET', '/api/platform/v1/iam/tenants?page=1&size=200')).records
|
||||
report.customerId ||= customers.find(r => r.status === 'ACTIVE' && /测试/.test(r.tenantName || r.name))?.id || customers.find(r => r.status === 'ACTIVE')?.id
|
||||
const warehouses = (await call('GET', '/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
|
||||
report.warehouseId ||= warehouses.find(r => r.warehouseCode === 'US-LAX-01')?.id; await save()
|
||||
check(report.customerId && report.warehouseId, '有效测试客户及洛杉矶仓存在')
|
||||
const ticket = await call('POST', `/api/platform/v1/iam/tenants/${report.customerId}/impersonation`, { reason: `${report.marker}散板两场景验收` })
|
||||
const merchant = (await api('', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })).accessToken
|
||||
if (planned && !report.dispatchNo) {
|
||||
report.requests.dispatch ||= { idempotencyKey: key('delivery'), warehouseId: report.warehouseId, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: key('申报先排车') }; await save()
|
||||
report.dispatchNo = (await call('POST', base + '/dispatches', report.requests.dispatch)).dispatchNo; await save()
|
||||
}
|
||||
for (const mode of ['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP']) {
|
||||
let order = report.orders.find(r => r.mode === mode)
|
||||
if (!order) {
|
||||
report.requests[mode] ||= { operatingWarehouseId: report.warehouseId, destinationType: 'PRIVATE', destinationPostalCode: '91761', deliveryMode: mode, deliveryContactName: '验收仓库联系人', deliveryContactPhone: '0000000000', estimatedArrivalAt: '2026-11-03T09:00:00', trackingNo: key(mode), origin: 'Los Angeles', originAddress: '100 TEST Pickup Road', destination: 'Ontario', destinationAddress: '200 TEST Destination Road', productName: `验收散板-${mode}`, estimatedPalletCount: 1, packageCount: 2, totalWeightKg: 8, totalVolumeCbm: 0.06, budgetAmount: 25, currency: 'USD', originContactName: '验收取货员', originContactPhone: '0000000000', destinationContactName: '验收收件人', destinationContactPhone: '0000000000', pickupAt: '2026-11-03T09:00:00', deliveryAt: '2026-11-04T09:00:00', remark: report.marker, idempotencyKey: key(mode) }; await save()
|
||||
if (planned && mode === 'DELIVERY_TO_WAREHOUSE') for (const field of ['origin', 'originAddress', 'originContactName', 'originContactPhone', 'trackingNo', 'destinationType']) delete report.requests[mode][field]
|
||||
await save()
|
||||
const d = await api(merchant, 'POST', '/api/merchant/v1/last-leg/local-looseboard-orders', report.requests[mode])
|
||||
order = { mode, orderNo: d.summary.orderNo }; report.orders.push(order); await save()
|
||||
}
|
||||
const orderPath = `${base}/local-looseboard-orders/${order.orderNo}`, query = `?customerId=${report.customerId}`
|
||||
let d = await call('GET', orderPath + query)
|
||||
if (d.summary.status === 'PENDING_ACCEPTANCE') d = await call('POST', `${orderPath}/review${query}`, { version: d.summary.version, decision: 'APPROVE', idempotencyKey: key(`${mode}-review`), remark: report.marker })
|
||||
order.transferNo = d.summary.transferNo; await save(); check(order.transferNo, '审核生成唯一收货履约单')
|
||||
if (planned && !order.plannedAdded) {
|
||||
const path = base + '/dispatches/' + report.dispatchNo, cargoPath = base + '/dispatches/capacity-demands/' + report.dispatchNo + '/cargo'
|
||||
const root = await call('GET', path)
|
||||
// 失败后回查原排车,不重复创建或添加已存在的来源。
|
||||
const existing = (await call('GET', path + '/items')).items
|
||||
if (!existing.some(r => r.inboundCode === order.transferNo || r.orderNo === order.transferNo)) {
|
||||
const result = await call('GET', cargoPath + '?page=1&size=50')
|
||||
const selected = result.candidates.filter(r => r.inboundCode === order.transferNo || r.orderNo === order.transferNo)
|
||||
check(selected.length === 1 && selected.every(r => !r.selection.warehousePalletId && !r.blockReasons.length), mode + '审核后无实托也可预排')
|
||||
order.plannedPalletId = selected[0].selection.planPalletId
|
||||
const cargo = [...new Map(selected.flatMap(r => r.selectionGroup.sources).map(r => [r.sourceKey, r.selection])).values()]
|
||||
const request = { recordVersion: root.recordVersion, idempotencyKey: key(mode + '-planned-add'), cargo, batchAllocations: [] }
|
||||
await call('POST', cargoPath, request); await call('POST', cargoPath, request)
|
||||
}
|
||||
const pre = await call('GET', path), preItems = (await call('GET', path + '/items')).items
|
||||
check(preItems.some(r => r.planPalletId === order.plannedPalletId) && preItems.filter(r => r.planPalletId === order.plannedPalletId).every(r => !r.warehousePalletId) && Number(pre.counts.pickedPackageCount) === 0, '预排没有虚构实物和出库拣货')
|
||||
order.plannedAdded = true; await save()
|
||||
}
|
||||
if (mode === 'WAREHOUSE_PICKUP') await pickup(order)
|
||||
await receive(order)
|
||||
d = await call('GET', orderPath + query); check(d.summary.status === 'PENDING_DISPATCH', '原散板单已同步待派车')
|
||||
if (planned) {
|
||||
const root = await call('GET', base + '/dispatches/' + report.dispatchNo + '/items')
|
||||
const item = root.items.find(r => r.planPalletId === order.plannedPalletId)
|
||||
check(item && item.warehousePalletId && Number(item.scheduledPackages) === 2, mode + '实收沿原预排来源更新实托和箱数')
|
||||
}
|
||||
order.status = d.summary.status; await save()
|
||||
}
|
||||
if (!report.dispatchNo) {
|
||||
report.requests.dispatch ||= { idempotencyKey: key('delivery'), warehouseId: report.warehouseId, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), publishLooseboardDemand: true, cargo: [], batchAllocations: [], remark: `${report.marker}两场景散板需求车` }; await save()
|
||||
const d = await call('POST', `${base}/dispatches`, report.requests.dispatch); report.dispatchNo = d.dispatchNo; await save()
|
||||
}
|
||||
const dispatchPath = `${base}/dispatches/${report.dispatchNo}`, cargoPath = `${base}/dispatches/capacity-demands/${report.dispatchNo}/cargo`
|
||||
const pool = await call('GET', `${base}/dispatches/capacity-demands?page=1&size=100`)
|
||||
check(pool.records.some(r => r.dispatchNo === report.dispatchNo), '验收车辆出现在散板需求列表')
|
||||
let detail = await call('GET', dispatchPath)
|
||||
if (Number(detail.counts.packageCount) === 0) {
|
||||
const candidates = await call('GET', `${cargoPath}?page=1&size=50`)
|
||||
const selected = candidates.candidates.filter(r => report.orders.some(o => r.inboundCode === o.transferNo || r.orderNo === o.transferNo || r.palletNo === o.palletNo))
|
||||
check(selected.length === 2, '两个场景均出现在添加货物候选')
|
||||
check(selected.every(r => !r.blockReasons.length), '两票散板均具备可排车资格')
|
||||
const sources = new Map(selected.flatMap(r => r.selectionGroup.sources).map(r => [r.sourceKey, r.selection]))
|
||||
report.requests.add = { recordVersion: detail.recordVersion, idempotencyKey: key('add'), cargo: [...sources.values()], batchAllocations: [] }; await save()
|
||||
await call('POST', cargoPath, report.requests.add)
|
||||
await call('POST', cargoPath, report.requests.add)
|
||||
}
|
||||
detail = await call('GET', dispatchPath)
|
||||
check(Number(detail.counts.packageCount) === 4 && Number(detail.counts.palletCount) === 2 && Number(detail.counts.pickedPackageCount) === 0, '两票共四箱两托已加入原车,尚未进行出库拣货;重放未重复加入')
|
||||
report.finalCounts = detail.counts; report.passed = true; delete report.error; await save()
|
||||
console.log(JSON.stringify({ passed: true, orders: report.orders, dispatchNo: report.dispatchNo, counts: report.finalCounts }, null, 2))
|
||||
} catch (error) { report.error = error.message; await save(); console.error(error.message); process.exitCode = 1 }
|
||||
|
||||
Reference in New Issue
Block a user