Files
baiyue-firstleg/scripts/local-dispatch-transport-acceptance.mjs
2026-10-27 17:44:41 +08:00

177 lines
18 KiB
JavaScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { randomUUID, createHash } from 'node:crypto'
// 仅针对显式指定的本机统一网关;业务事实全部使用正式 HTTP 创建。
const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '')
if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('必须显式指定本机 Gateway')
if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD || !process.env.LOCAL_TRANSFER_PDA_PASSWORD) throw new Error('缺少显式验收凭据')
const output = resolve(process.env.TRANSPORT_ACCEPTANCE_OUTPUT || 'reports/local-dispatch-transport.json')
const report = process.env.TRANSPORT_ACCEPTANCE_RESUME ? JSON.parse(await readFile(output, 'utf8')) : { marker: `DT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`, evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_TRANSPORT', checks: [], documents: {}, requests: {}, intents: [] }
if (report.passed) throw new Error('本批次已通过,请直接查看报告')
const marker = report.marker, base = '/api/platform/v1/last-leg/dispatches/transport-tasks', transfers = '/api/platform/v1/last-leg/warehouse-transfers', trucks = '/api/platform/v1/last-leg/us-truck-orders'
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
let admin, pda
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 new Error(label) }
function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body }
async function api(token, method, path, body, expected = '200') {
const form = body instanceof FormData
if (method !== 'GET' && !path.startsWith('/api/auth/')) { report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, hash: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex') }); await save() }
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().catch(() => null), code = String(value?.code)
const passed = response.status === 200 && code === expected
report.checks.push({ method, path, code, http: response.status, expected, passed, message: code === '200' ? undefined : value?.message, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }); await save()
if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`)
return expected === '200' ? value.data : value
}
const call = (method, path, body, expected) => api(admin, method, path, body, expected)
const request = (key, body) => report.requests[key] ||= { ...body, idempotencyKey: randomUUID() }
const read = number => call('GET', `${base}/${number}`)
const command = detail => ({ recordVersion: detail.execution.trip.recordVersion, idempotencyKey: randomUUID(), reason: `${marker} 本机运输流程验收` })
async function act(detail, suffix, extra = {}, expected) { return call('POST', `${base}/${detail.dispatchNo}/${suffix}`, { ...command(detail), ...extra }, expected) }
async function transfer(customer, warehouse, key) {
if (report.documents[key]) return report.documents[key]
const body = request(key, { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '本机验收收货员', privateDestinationContactPhone: '0000000000', deliveryAddress: `1580 TEST Pickup Ave, Ontario, CA (${marker}-${key})`, pickupContactName: '本机验收取货员', pickupContactPhone: '0000000000', packageCount: 3, volumeCbm: 0, deliveryMethod: 'WAREHOUSE_PICKUP', estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), estimatedShipAt: new Date(Date.now()+172800000).toISOString().slice(0,19), remark: marker, needRelabel: false, items: [{ skuCode: `${marker}-${key}`, productNameZh: '本机验收收纳盒', quantity: 24, packageCount: 3, shippingMark: `${marker}-${key}` }] })
await save()
let detail = await call('POST', `${transfers}?customerId=${customer.id}`, body)
detail = await call('POST', `${transfers}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: marker })
report.documents[key] = { customerId: customer.id, transferNo: detail.transferNo, packageCount: detail.packageCount, cartonMarks: detail.cartons.map(row=>row.cartonMark) }; await save(); return report.documents[key]
}
async function pickup(sources, key) {
if (report.documents[key]) return read(report.documents[key].dispatchNo)
const body = request(key, { sources: sources.map(({customerId,transferNo})=>({customerId,transferNo})), plannedDepartureAt: new Date(Date.now()-300000).toISOString(), expectedDriverEndAt: new Date(Date.now()+86400000).toISOString(), remark: marker })
await save(); const detail = await call('POST', `${base}/pickup`, body)
report.documents[key] = { dispatchNo: detail.dispatchNo, sources: body.sources }; await save()
const replay = await call('POST', `${base}/pickup`, body); check(replay.dispatchNo === detail.dispatchNo, '到仓取货重放返回同一排车号'); return detail
}
async function resources(warehouse) {
if (report.resources) return report.resources
const driverBase = '/api/platform/v1/last-leg/fleet/drivers'
let driver = await call('POST', driverBase, { name: `${marker}专用司机`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now()+365*86400000).toISOString().slice(0,10), remark: `${marker} TEST licence image`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario TEST' } })
driver = await call('POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image('licence'))
driver = await call('POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '本机模拟资质审核' })
const vehicle = await call('POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-DT-${marker.slice(-10)}`, model: '26ft Box Truck', fuelConsumptionPer100Km:22, mileageKm:18000, driverOrDispatchCompany:marker, phone:'0000000000', region:'OVERSEAS', ownership:'OWNED', status:'IN_SERVICE', remark:marker, maxPalletSpaces:12,maxLoadKg:8000,maxVolumeCbm:45 })
report.resources = { vehicleId:vehicle.id,driverId:driver.id }; await save(); return report.resources
}
async function assign(detail, resource) { if(detail.execution.trip.vehicleId) return detail; return call('POST', `${base}/${detail.dispatchNo}/assign`, { recordVersion:detail.execution.trip.recordVersion, ...resource, reason:marker }) }
async function release(detail) {
for(const kind of ['VEHICLE','DRIVER']) {
detail = await read(detail.dispatchNo)
if(detail.execution.trip[kind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) continue
await act(detail,'release',{resourceKind:kind,actualEndAt:new Date().toISOString(),endLocation:'Ontario TEST warehouse',vehicleStatus:kind==='VEHICLE'?'IN_SERVICE':null})
}
return read(detail.dispatchNo)
}
async function truck(customer,key) {
if(report.documents[key]) return call('GET',`${trucks}/${report.documents[key].orderNo}?customerId=${customer.id}`)
const body=request(key,{vehicleType:'PALLET_15',origin:'TEST取货地',originAddress:'100 TEST Origin Road, Ontario, CA',destination:'TEST送货地',destinationAddress:'200 TEST Destination Road, Ontario, CA',packageCount:4,totalWeightKg:40,totalVolumeCbm:1,budgetAmount:100,currency:'USD',originContactName:'本机验收员',originContactPhone:'0000000000',destinationContactName:'本机验收员',destinationContactPhone:'0000000000',pickupAt:new Date().toISOString().slice(0,19),deliveryAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:marker})
await save();let detail=await call('POST',`${trucks}?customerId=${customer.id}`,body)
report.documents[key]={customerId:customer.id,orderNo:detail.summary.orderNo};await save();return detail
}
async function truckReady(detail) {
const path=`${trucks}/${detail.summary.orderNo}`, query=`?customerId=${detail.summary.customerId}`
const step=async(suffix,body={})=>call('POST',`${path}/${suffix}${query}`,{version:detail.summary.version,idempotencyKey:randomUUID(),remark:marker,...body})
if(detail.summary.status==='PENDING_REVIEW')detail=await step('review',{decision:'APPROVE'})
if(detail.summary.status==='WAITING_INBOUND')detail=await step('receive-pallets',{actualPalletCount:1,packageCount:4,palletPackageCounts:[4]})
if(detail.summary.status==='WAITING_CONSOLIDATION')detail=await step('allocate-pallets',{palletIds:detail.pallets.map(row=>row.id)})
if(detail.summary.status==='ALLOCATED')detail=await step('prepare-dispatch')
check(detail.summary.status==='PENDING_DISPATCH','本土卡车原单审核、实收和分配后进入待派车');return detail
}
async function truckPlan(source,warehouse,key) {
if(report.documents[key])return read(report.documents[key].dispatchNo)
const body=request(key,{customerId:source.summary.customerId,orderNo:source.summary.orderNo,warehouseId:warehouse.id,plannedDepartureAt:new Date(Date.now()-300000).toISOString(),expectedDriverEndAt:new Date(Date.now()+86400000).toISOString()});await save()
const detail=await call('POST',`${base}/truck`,body);report.documents[key]={dispatchNo:detail.dispatchNo,orderNo:body.orderNo};await save()
check((await call('POST',`${base}/truck`,body)).dispatchNo===detail.dispatchNo,'本土卡车安排幂等重放');return detail
}
async function measure(source) {
const path=`/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}`
let detail=await api(pda,'GET',path)
if(detail.status==='WAIT_CUSTOMER_CONFIRMATION')return
if(!detail.pallets.length)detail=await api(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:randomUUID()})
let pallet=detail.pallets[0]
if(pallet.status==='DRAFT')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:randomUUID()})
pallet=detail.pallets.find(row=>row.id===pallet.id)
if(pallet.status!=='BOUND')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:randomUUID(),palletCode:pallet.palletNo})
const boxes=detail.cartons.map(box=>({cartonMark:box.cartonMark,transferPalletId:pallet.id,length:40,width:30,height:25,weight:4,items:[{skuCode:detail.items[0].skuCode,quantity:8}]}))
if(!detail.cartons.some(row=>row.countedAt))detail=await api(pda,'POST',`${path}/cartons/count`,{idempotencyKey:randomUUID(),forecastPackageCount:detail.packageCount,boxes})
const photo=await api(pda,'POST',`${path}/measurement-photos/files`,image('warehouse-count'))
detail=await api(pda,'POST',`${path}/palletize`,{idempotencyKey:randomUUID(),photoFileIds:[photo.fileId]})
check(detail.status==='WAIT_CUSTOMER_CONFIRMATION'&&detail.orderStatus==='INBOUND_COMPLETED','取货到仓后仓库独立清点、测量、拍照完成入库');source.palletNo=pallet.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
const required = ['list','create','edit','depart','cancel'].map(key => `platform:overseas-warehouse:dispatch:${key}`)
check(Array.isArray(login.authorities) && required.every(code => login.authorities.includes(code)), '固定平台账号缺少正式业务权限,不自动补权限或创建角色')
report.operatorUsername = process.env.ADMIN_TEST_USERNAME; await save()
const customers=(await call('GET','/api/platform/v1/iam/tenants?page=1&size=200')).records.filter(row=>row.status==='ACTIVE')
check(customers.length>=2,'有两个有效客户验证同仓跨客户取货')
const warehouses=(await call('GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records
const warehouse=warehouses.find(row=>row.warehouseCode==='US-LAX-01')||warehouses[0];report.warehouseId=warehouse.id
report.pdaUsername = process.env.LOCAL_TRANSFER_PDA_USERNAME
check(Boolean(report.pdaUsername), '必须配置既有 LOCAL_TRANSFER_PDA_USERNAME,不自动创建PDA账号')
pda=(await api('','POST','/api/auth/login',{loginId:report.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken
const a=await transfer(customers[0],warehouse,'pickupA'),b=await transfer(customers[1],warehouse,'pickupB'),c=await transfer(customers[0],warehouse,'pickupCancelSource')
let shared=await pickup([a,b],'pickupCompleted'),cancelled=await pickup([c],'pickupCancelled')
const resource=await resources(warehouse);shared=await assign(shared,resource)
if(cancelled.execution.trip.status==='PLANNED'){
await call('POST',`${base}/${cancelled.dispatchNo}/assign`,{recordVersion:cancelled.execution.trip.recordVersion,...resource,reason:marker},'A0409')
await act(cancelled,'cancel');cancelled=await read(cancelled.dispatchNo)
}
check(cancelled.execution.trip.status==='CANCELLED','取消取货排车生效')
const replanned=await pickup([c],'pickupPendingSample');check(replanned.dispatchNo!==cancelled.dispatchNo,'取货取消后可重新安排新排车,保留待操作样单')
if(shared.execution.trip.status==='PLANNED'){
await api(pda,'POST',`/api/pda/v1/last-leg/warehouse-transfers/${a.transferNo}/pallets/drafts`,{idempotencyKey:randomUUID()},'A0409')
await call('POST',`${base}/pickup`,{...report.requests.pickupCompleted,idempotencyKey:randomUUID()},'A0409')
await act(shared,'pickup/depart');shared=await read(shared.dispatchNo)
await act(shared,'pickup/complete',{},'A0409')
}
for(const source of [a,b]){
shared=await read(shared.dispatchNo);let task=shared.pickups.find(row=>row.transferNo===source.transferNo)
const suffix=`pickup/${source.customerId}/${source.transferNo}`
if(task.status==='ASSIGNED'){
if(source===b&&report.pickupEvidenceA)await act(shared,`${suffix}/picked`,{actualPackages:3,evidenceFileIds:[report.pickupEvidenceA]},'A0400')
const file=await call('POST',`${base}/${shared.dispatchNo}/${suffix}/files`,image(source.transferNo))
if(source===a){report.pickupEvidenceA=file.fileId;await save()}
const body={...command(shared),actualPackages:3,evidenceFileIds:[file.fileId],remark:marker}
await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body);await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body)
await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,{...body,actualPackages:4},'A0409')
const original=await call('GET',`${transfers}/${source.transferNo}?customerId=${source.customerId}`)
check(original.packageCount===3&&original.status==='WAIT_PDA_RECEIVING'&&JSON.stringify(original.cartons.map(row=>row.cartonMark))===JSON.stringify(source.cartonMarks),'取货事实不篡改预报箱数、箱唛及仓库收货状态')
}
shared=await read(shared.dispatchNo);task=shared.pickups.find(row=>row.transferNo===source.transferNo)
if(task.status==='PICKED')await act(shared,`${suffix}/arrive`)
}
shared=await read(shared.dispatchNo);if(shared.execution.trip.status==='IN_TRANSIT')await act(shared,'pickup/complete')
shared=await release(await read(shared.dispatchNo));check(shared.execution.trip.status==='COMPLETED','到仓取货排车完成并分别释放司机车辆')
await measure(a)
let source=await truck(customers[0],'truckOriginal')
if(!report.documents.truckCancelled)source=await truckReady(source)
let native=await truckPlan(source,warehouse,'truckCancelled')
if(native.execution.trip.status==='PLANNED'){await act(native,'cancel');native=await read(native.dispatchNo)}
source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`)
if(!report.documents.truckCompleted)check(source.summary.status==='PENDING_DISPATCH','取消本土卡车排车回退原单待派车')
native=await truckPlan(source,warehouse,'truckCompleted')
await call('POST',`${base}/truck`,{...report.requests.truckCompleted,plannedDepartureAt:new Date().toISOString()},'A0409')
if(native.execution.trip.status==='PLANNED'){
await call('POST',`${trucks}/${source.summary.orderNo}/start-transit?customerId=${source.summary.customerId}`,{version:source.summary.version,idempotencyKey:randomUUID(),remark:marker},'A0409')
native=await assign(native,resource)
await act(native,'truck/depart',{recordVersion:native.execution.trip.recordVersion-1},'A0409')
await act(native,'truck/depart');native=await read(native.dispatchNo)
}
if(native.execution.trip.status==='IN_TRANSIT'){
await act(native,'truck/complete',{evidenceFileIds:[]},'A0409')
const file=await call('POST',`${base}/${native.dispatchNo}/truck/files`,image('truck-completion'))
const body={...command(native),evidenceFileIds:[file.fileId]};await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body);await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body)
await call('POST',`${base}/${native.dispatchNo}/truck/complete`,{...body,evidenceFileIds:[]},'A0409')
}
native=await release(await read(native.dispatchNo))
source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`)
check(native.execution.trip.status==='COMPLETED'&&source.summary.status==='COMPLETED'&&native.completionEvidence.length>0,'本土卡车排车完成、凭证持久化、原单同步完成')
for(const doc of [shared,native,replanned]){
const root=await call('GET',`/api/platform/v1/last-leg/dispatches/${doc.dispatchNo}`)
check(root.taskType===doc.taskType,'同一个现有排车详情返回正确任务类型')
}
report.final={pickup:shared.dispatchNo,truck:native.dispatchNo,pending:replanned.dispatchNo,truckOriginal:source.summary.orderNo};report.passed=true;report.completedAt=new Date().toISOString();await save();console.log(JSON.stringify({passed:true,output,...report.final}))
} catch(error){report.passed=false;report.lastError=String(error);await save();console.error(String(error));process.exitCode=1}