187 lines
17 KiB
JavaScript
187 lines
17 KiB
JavaScript
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 }
|
|
|