Files
baiyue-firstleg/scripts/local-transfer-pickup-acceptance.mjs
T
2026-10-19 10:23:09 +08:00

242 lines
27 KiB
JavaScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
// 本入口只访问显式指定的本机统一 Gateway;所有业务数据均经正式 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('缺少本机平台与PDA验收凭据')
const priorReport = process.env.PICKUP_ACCEPTANCE_RESUME ? JSON.parse(await readFile(resolve(process.env.PICKUP_ACCEPTANCE_RESUME), 'utf8')) : null
if (priorReport?.passed) throw new Error('本批次已验收完成,不重复执行业务;请查看原报告或创建新批次')
const marker = priorReport?.marker || `PICKUP-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`
const output = resolve(process.env.PICKUP_ACCEPTANCE_RESUME || process.env.PICKUP_ACCEPTANCE_OUTPUT || `reports/${marker}.json`)
const report = priorReport || { marker, environment: 'LOCAL', evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_PICKUP', startedAt: new Date().toISOString(), passed: false, orders: [], trips: [], checks: [] }
const transferBase = '/api/platform/v1/last-leg/warehouse-transfers', pickupBase = '/api/platform/v1/last-leg/fleet/pickups', tripBase = '/api/platform/v1/last-leg/fleet/trips'
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64')
const attemptStart = report.checks.length
report.attempts ||= []
const attempt = { startedAt: new Date().toISOString(), checkOffset: attemptStart, passed: false }
report.attempts.push(attempt)
let admin, pda
/** 每个业务请求保留真实错误码和诊断,不保存会话、密码和完整客户资料。 */
async function api(token, method, path, body, expectedCode) {
const form = body instanceof FormData
// 写入前留脱敏意图,网络结果未知时可用原请求键定位,不能当作未提交再次造单。
if (method !== 'GET' && !path.startsWith('/api/auth/')) {
report.intents ||= []
report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, payloadSha256: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex'), at: new Date().toISOString() })
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 && (expectedCode ? code === expectedCode : code === '200')
report.checks.push({ method, path, code, http: response.status, expectedCode, passed, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId, message: code === '200' ? undefined : value?.message })
await save()
if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`)
return expectedCode ? value : value.data
}
/** 断言必须对应服务端读回结果。 */
function check(condition, label) { report.checks.push({ label, passed: Boolean(condition) }); if (!condition) throw new Error(label) }
/** 每个创建阶段立即落检查点,失败不删除本轮已成功单据。 */
async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) }
/** 本地模拟资料仅验证文件归属、签名和上传链路,不冒充实际照片或驾驶证。 */
function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body }
/** 所有执行操作使用当前车次版本和全新命令标识。 */
const command = (trip, reason) => ({ recordVersion: trip.trip.recordVersion, idempotencyKey: randomUUID(), reason })
/** 以既有有效客户创建本轮新业务单,不读取或推进历史订单。 */
async function order(customer, warehouse, index, method = 'WAREHOUSE_PICKUP', review = true) {
if (priorReport?.orders[index - 1]) return priorReport.orders[index - 1]
const products = ['手机硅胶保护壳', '折叠收纳盒', 'USB-C充电线', '宠物饮水器', '不锈钢保温杯', '桌面理线器']
const counts = index % 3 === 0 ? [3, 2, 1] : index % 2 === 0 ? [4, 2] : [3]
const payload = { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '取货验收收件人', privateDestinationContactPhone: '0000000000',
deliveryAddress: `${1580 + index} TEST Pickup Ave, Ontario, CA 91761 (${marker})`, pickupContactName: `取货验收员${index}`, pickupContactPhone: '0000000000',
packageCount: counts.reduce((sum, value) => sum + value, 0), volumeCbm: 0, deliveryMethod: method, estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), remark: `${marker} 场景${index} 本地业务接口验收单`, needRelabel: false, idempotencyKey: `${marker}-${index}`,
items: counts.map((packageCount, j) => ({ skuCode: `${marker}-${index}-${j + 1}`, productNameZh: products[(index + j) % products.length], quantity: packageCount * (8 + j * 4), packageCount, shippingMark: `${marker}-CUSTOMER-${index}-${j + 1}` })) }
let detail
if (index === 1) {
const token = await merchant(customer.id)
detail = await api(token, 'POST', '/api/merchant/v1/last-leg/warehouse-transfers', payload)
detail = await api(token, 'POST', `/api/merchant/v1/last-leg/warehouse-transfers/${detail.transferNo}/submit`)
check(detail.status === 'PENDING_REVIEW', '客户正式下单提交到待审核')
} else detail = await api(admin, 'POST', `${transferBase}?customerId=${customer.id}`, payload)
if (review) detail = await api(admin, 'POST', `${transferBase}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: `${marker} 本地取货验收` })
const row = { customerId: customer.id, transferNo: detail.transferNo, lastlegOrderNo: detail.lastlegOrderNo, warehouseId: warehouse.id, method, packageCount: detail.packageCount, status: detail.status, scenario: `场景${index}`, cartonMarks: detail.cartons.map(box => box.cartonMark), declaredQuantity: detail.items.reduce((sum, item) => sum + Number(item.quantity || 0), 0) }
report.orders.push(row); await save(); return row
}
/** 创建取货车次后保留单号,便于页面人工验收。 */
async function plan(sources, scenario) {
const existing = report.trips.find(row => row.scenario === scenario)
if (existing) return api(admin, 'GET', `${tripBase}/${existing.tripNo}`)
const body = { sources: sources.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(Date.now() - 300000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), remark: `${marker} ${scenario}`, idempotencyKey: randomUUID() }
const detail = await api(admin, 'POST', pickupBase, body)
report.trips.push({ tripNo: detail.trip.tripNo, scenario, sources: body.sources, status: detail.trip.status }); await save()
const replay = await api(admin, 'POST', pickupBase, body); check(replay.trip.tripNo === detail.trip.tripNo, '安排重试保持原车次')
return detail
}
/** 创建本轮专用司机和车辆,避免占用既有业务资源。 */
async function resources(warehouse, index) {
if (report.resources?.[index - 1]) { const row = report.resources[index - 1]; return { vehicleId: row.vehicleId, driverId: row.driverId } }
const driverBase = '/api/platform/v1/last-leg/fleet/drivers'
let driver = await api(admin, 'POST', driverBase, { name: `${marker}取货司机${index}`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: `${marker}-${index}`, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${marker} TEST licence image, local fixture only`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario local TEST route' } })
driver = await api(admin, 'POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image(`driver-${index}`))
driver = await api(admin, 'POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: `${marker} 本地模拟资质审核` })
const vehicle = await api(admin, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-PU-${marker.slice(-8)}-${index}`, model: '26ft Box Truck', fuelConsumptionPer100Km: 22, mileageKm: 18000 + index * 700, driverOrDispatchCompany: `${marker}本地验收车队`, phone: '0000000000', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: `${marker} 上门取货验收专用`, maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45 })
report.resources ||= []; report.resources.push({ driverId: driver.id, driverNo: driver.driverNo, vehicleId: vehicle.id, plateNumber: vehicle.plateNumber }); await save()
return { vehicleId: vehicle.id, driverId: driver.id }
}
/** 分配资源沿原接口,取货车与派送车共用占用规则。 */
async function assign(trip, resource) { if (trip.trip.vehicleId === resource.vehicleId && trip.trip.driverId === resource.driverId) return trip; return api(admin, 'POST', `${tripBase}/${trip.trip.tripNo}/assignment`, { recordVersion: trip.trip.recordVersion, ...resource, reason: `${marker} 分配取货车与司机` }) }
/** 发车使用取货链,不拣货、不生成出库单。 */
async function depart(trip) { if (trip.trip.status !== 'PLANNED') return trip; return api(admin, 'POST', `${pickupBase}/${trip.trip.tripNo}/depart`, command(trip, `${marker} 车辆已出发取货`)) }
/** 逐票取货并验证同键重放,不重复扣减或改写客户预报。 */
async function picked(trip, source, actual = source.packageCount) {
const existing = (await api(admin, 'GET', `${pickupBase}/${trip.trip.tripNo}/tasks`)).find(row => row.customerId === source.customerId && row.transferNo === source.transferNo)
if (existing && ['PICKED', 'ARRIVED'].includes(existing.status)) return { trip, file: existing.evidence[0] }
const path = `${pickupBase}/${trip.trip.tripNo}/tasks/${source.customerId}/${source.transferNo}`
const file = await api(admin, 'POST', `${path}/files`, image(source.transferNo))
const body = { recordVersion: trip.trip.recordVersion, idempotencyKey: randomUUID(), actualPackages: actual, evidenceFileIds: [file.fileId], remark: actual === source.packageCount ? '取货现场箱数已核对' : '实提与预报不同,等待仓库独立清点' }
await api(admin, 'POST', `${path}/picked`, body); await api(admin, 'POST', `${path}/picked`, body)
await api(admin, 'POST', `${path}/picked`, { ...body, actualPackages: actual + 1 }, 'A0409')
const detail = await api(admin, 'GET', `${transferBase}/${source.transferNo}?customerId=${source.customerId}`)
check(detail.packageCount === source.packageCount && detail.status === 'WAIT_PDA_RECEIVING', '实提不覆盖预报箱数或完成测量')
check(JSON.stringify(detail.cartons.map(box => box.cartonMark)) === JSON.stringify(source.cartonMarks), '取货运输保留原预报箱唛')
return { trip: await api(admin, 'GET', `${tripBase}/${trip.trip.tripNo}`), file }
}
/** 到仓确认后才允许仓库作业。 */
async function arrive(trip, source) {
if ((await api(admin, 'GET', `${pickupBase}/${trip.trip.tripNo}/tasks`)).some(row => row.customerId === source.customerId && row.transferNo === source.transferNo && row.status === 'ARRIVED')) return trip
await api(admin, 'POST', `${pickupBase}/${trip.trip.tripNo}/tasks/${source.customerId}/${source.transferNo}/arrive`, command(trip, `${marker} 货已送达入仓月台`))
return api(admin, 'GET', `${tripBase}/${trip.trip.tripNo}`)
}
/** 以平台正式代登录接口取得客户会话,仅用于本轮读回与越权测试。 */
async function merchant(customerId) {
const ticket = await api(admin, 'POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 验证客户本票取货回显与隔离` })
return (await api('', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })).accessToken
}
/** 送仓后走原 PDA 托盘、逐箱清点、照片及差异确认完整链,运输不代替仓内事实。 */
async function measure(source) {
const path = `/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}`
const current = await api(pda, 'GET', path)
if (current.status !== 'WAIT_PDA_RECEIVING') { check(current.status === 'WAIT_CUSTOMER_CONFIRMATION', '续验测量完成状态'); return }
let detail = current.pallets.length ? current : 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 declared = detail.items.flatMap(item => Array.from({ length: item.packageCount }, () => ({ skuCode: item.skuCode, quantity: Number(item.quantity) / item.packageCount })))
check(declared.length === detail.cartons.length, '预报SKU箱数与固定箱唛总数一致')
const boxes = detail.cartons.map((box, index) => ({ cartonMark: box.cartonMark, transferPalletId: pallet.id, length: 40 + index, width: 30, height: 25, weight: 4 + index / 10, items: [{ ...declared[index], quantity: declared[index].quantity - (index === 0 ? 1 : 0) }] }))
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('measurement'))
await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: randomUUID(), photoFileIds: [photo.fileId] }, 'A0400')
detail = await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: randomUUID(), photoFileIds: [photo.fileId], discrepancyReason: '本地验收:首箱SKU实点短收1件,已复点并拍照留存' })
check(detail.status === 'WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus === 'INBOUND_COMPLETED', '取货送仓后PDA完整测量成功,进入客户确认')
check(JSON.stringify(detail.cartons.map(box => box.cartonMark)) === JSON.stringify(source.cartonMarks), '测量完成保留客户预报箱唛身份')
check(detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) === source.declaredQuantity, '测量不覆盖客户SKU预报件数')
source.palletNo = pallet.palletNo
source.scenario = '取货送仓后已完成PDA测量,SKU短收1件且已填写原因'
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
// 本机旧权限目录可能未初始化取消/结案/释放资源;只补正式权限资源并授权本轮专用账号。
let permissions = await api(admin, 'GET', '/api/platform/v1/iam/permissions')
for (const [suffix, name] of [['cancel', '取消车次'], ['complete', '车次货物结案'], ['release', '结束车辆司机任务']]) {
const code = `platform:fleet:trip:${suffix}`
if (!permissions.some(row => row.permissionCode === code)) {
const row = await api(admin, 'POST', '/api/platform/v1/iam/permissions', { type: 'BUTTON', code, name, parentId: permissions.find(item => item.permissionCode === 'platform:fleet:trip:list').id, httpMethod: 'POST' })
permissions.push(row)
}
}
if (!report.operatorUsername) {
const existingRole = (await api(admin, 'GET', '/api/platform/v1/iam/roles')).find(row => row.roleCode === `${marker}_OPERATOR`)
const role = existingRole || await api(admin, 'POST', '/api/platform/v1/iam/roles', { roleCode: `${marker}_OPERATOR`, roleName: `${marker}本地取货验收角色`, description: '沿当前验收账号权限增加正式车次取消、结案和释放,仅供本轮本机验收' })
const allowed = new Set([...login.authorities, 'platform:fleet:trip:cancel', 'platform:fleet:trip:complete', 'platform:fleet:trip:release'])
await api(admin, 'PUT', '/api/platform/v1/iam/roles/permissions', { roleId: role.id, permissionIds: permissions.filter(row => allowed.has(row.permissionCode)).map(row => row.id) })
const username = `pickup_op_${marker.replace(/\D/g, '')}`
await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}本地取货验收`, phone: `18${String(Date.now()).slice(-9)}`, tenantId: '0', roleIds: [role.id], status: 'ACTIVE' })
report.operatorUsername = username; await save()
}
admin = (await api('', 'POST', '/api/auth/login', { loginId: report.operatorUsername, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken
const tenants = await api(admin, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200')
const customers = tenants.records.filter(row => row.status === 'ACTIVE').sort((a, b) => Number(!String(a.tenantName || a.name).includes('测试')) - Number(!String(b.tenantName || b.name).includes('测试')))
check(customers.length >= 2, '本地至少两名有效客户用于共车隔离验收')
const warehouses = (await api(admin, '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], otherWarehouse = warehouses.find(row => row.id !== warehouse?.id)
check(warehouse && otherWarehouse, '本地至少两个有效海外仓用于异仓拒绝验收')
report.warehouseId = warehouse.id
const roles = await api(admin, 'GET', '/api/platform/v1/iam/roles'), role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE')
check(role, '存在海外PDA作业角色')
const username = `pda_pickup_${marker.replace(/\D/g, '')}`
if (!report.pdaUsername) await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}取货验收PDA`, phone: `18${String(Date.now()).slice(-9)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status: 'ACTIVE' })
pda = (await api('', 'POST', '/api/auth/login', { loginId: username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken
report.pdaUsername = username
const orders = []
for (let i = 1; i <= 15; i++) orders.push(await order(customers[i % 2], warehouse, i, i >= 13 && i <= 14 ? 'CUSTOMER_DELIVERY' : 'WAREHOUSE_PICKUP', i !== 15))
const differentWarehouse = await order(customers[0], otherWarehouse, 16)
const waiting = orders[0], sharedA = orders[1], sharedB = orders[2]
waiting.scenario = '待取货,供页面手工选单'; orders[12].scenario = '客户自送,不进入取货池'; orders[13].scenario = '客户自送,不进入取货池'; orders[14].scenario = '未审核,不进入取货池'
// 候选关键字查地址,取货地址中使用本轮标识;按单号定向复验不依赖全局数据量。
const single = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${waiting.transferNo}`)
check(single.records.some(row => row.transferNo === waiting.transferNo), '已审上门取货单自动进入候选')
for (const excluded of [orders[12], orders[13], orders[14]]) {
const result = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${excluded.transferNo}`)
check(result.total === 0 || Number(result.total) === 0, '自送或未审核单不进入待取货池')
}
const invalid = { sources: [waiting, differentWarehouse].map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date().toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), idempotencyKey: randomUUID() }
await api(admin, 'POST', pickupBase, invalid, 'A0400')
await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${waiting.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() }, 'A0409')
let shared = await plan([sharedA, sharedB], '两客户多点共车取货,全流程验收')
await api(admin, 'POST', pickupBase, { ...invalid, sources: [{ customerId: sharedA.customerId, transferNo: sharedA.transferNo }], idempotencyKey: randomUUID() }, 'A0409')
const resource = await resources(warehouse, 1); shared = await assign(shared, resource)
let cancelTrip = await plan([orders[3]], '取消后原单回到取货池')
await api(admin, 'POST', `${tripBase}/${cancelTrip.trip.tripNo}/assignment`, { recordVersion: cancelTrip.trip.recordVersion, ...resource, reason: '验收同一资源不能同时占用两车' }, 'A0409')
if (cancelTrip.trip.status !== 'CANCELLED') cancelTrip = await api(admin, 'POST', `${tripBase}/${cancelTrip.trip.tripNo}/cancel`, { recordVersion: cancelTrip.trip.recordVersion, reason: `${marker} 改派验收` })
check((await api(admin, 'GET', `${pickupBase}?keyword=${orders[3].transferNo}`)).records.some(row => row.transferNo === orders[3].transferNo), '取消车次后原单可重新安排')
const replanned = await plan([orders[3], orders[4]], '取消后的重新安排,保留计划中供页面验收'); orders[3].scenario = '已改派重新安排'; orders[4].scenario = '已排车未分配车辆'
shared = await depart(shared)
if (!(await api(admin, 'GET', `${pickupBase}/${shared.trip.tripNo}/tasks`)).some(row => row.status !== 'ASSIGNED')) await api(admin, 'POST', `${pickupBase}/${shared.trip.tripNo}/complete`, command(shared, '验收未取货不能结案'), 'A0409')
await api(admin, 'POST', `${tripBase}/${shared.trip.tripNo}/cancel`, { recordVersion: shared.trip.recordVersion, reason: '验收已发车不可取消' }, 'A0409')
const pickedA = await picked(shared, sharedA, sharedA.packageCount - 1); shared = pickedA.trip
const pathB = `${pickupBase}/${shared.trip.tripNo}/tasks/${sharedB.customerId}/${sharedB.transferNo}`
if ((await api(admin, 'GET', `${pickupBase}/${shared.trip.tripNo}/tasks`)).some(row => row.transferNo === sharedB.transferNo && row.status === 'ASSIGNED')) await api(admin, 'POST', `${pathB}/picked`, { recordVersion: shared.trip.recordVersion, idempotencyKey: randomUUID(), actualPackages: sharedB.packageCount, evidenceFileIds: [pickedA.file.fileId] }, 'A0400')
const pickedB = await picked(shared, sharedB); shared = pickedB.trip
shared = await arrive(shared, sharedA); shared = await arrive(shared, sharedB)
const firstMerchant = await merchant(sharedA.customerId), secondMerchant = await merchant(sharedB.customerId)
const progress = await api(firstMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-progress`)
check(progress.status === 'ARRIVED' && progress.evidence.length === 1 && progress.tripNo === shared.trip.tripNo, '客户详情回显本票取货车次、进度和凭证')
await api(secondMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-progress`, undefined, 'A0404')
await api(secondMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedB.transferNo}/pickup-evidence/${pickedA.file.fileId}`, undefined, 'A0404')
const binary = await fetch(`${gateway}/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-evidence/${pickedA.file.fileId}`, { headers: { Authorization: `Bearer ${firstMerchant}` } })
check(binary.status === 200 && Buffer.from(await binary.arrayBuffer()).equals(png), '本票取货凭证真实文件可下载')
await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() })
check((await api(admin, 'GET', `${transferBase}/${sharedA.transferNo}?customerId=${sharedA.customerId}`)).status === 'WAIT_PDA_RECEIVING', '送仓后PDA可开始,运输不冒充测量完成')
if (shared.trip.status !== 'COMPLETED') shared = await api(admin, 'POST', `${pickupBase}/${shared.trip.tripNo}/complete`, command(shared, '全车已送仓,结束取货运输'))
for (const resourceKind of ['VEHICLE', 'DRIVER']) if (!shared.trip[resourceKind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) shared = await api(admin, 'POST', `${tripBase}/${shared.trip.tripNo}/resources/release`, { ...command(shared, '实际车辆司机任务结束'), resourceKind, actualEndAt: new Date().toISOString(), endLocation: warehouse.warehouseName || '本地测试海外仓', vehicleStatus: resourceKind === 'VEHICLE' ? 'IN_SERVICE' : null })
sharedA.scenario = '已送仓并已开始PDA测量,司机实提少一箱'; sharedB.scenario = '已送仓,取货运输已结案,仓库尚未清点'
await measure(sharedB)
// 保留三类不同履约阶段,方便用户进入页面继续验收。
let onRoad = await plan([orders[5], orders[6]], '已取货未送仓供页面验收'); onRoad = await assign(onRoad, resource); onRoad = await depart(onRoad); onRoad = (await picked(onRoad, orders[5])).trip; onRoad = (await picked(onRoad, orders[6])).trip
orders[5].scenario = orders[6].scenario = '已取货未送仓'
const secondResource = await resources(warehouse, 2)
let assigned = await plan([orders[7], orders[8]], '已分配车辆司机尚未发车供页面验收'); assigned = await assign(assigned, secondResource)
orders[7].scenario = orders[8].scenario = '已分配车辆司机,尚未发车'
for (const entry of report.trips) {
const detail = await api(admin, 'GET', `${tripBase}/${entry.tripNo}`); entry.status = detail.trip.status; entry.vehiclePlate = detail.trip.vehiclePlate
}
for (const entry of report.orders) {
const detail = await api(admin, 'GET', `${transferBase}/${entry.transferNo}?customerId=${entry.customerId}`); entry.status = detail.status
const progress = await api(admin, 'GET', `${transferBase}/${entry.transferNo}/pickup-progress?customerId=${entry.customerId}`); entry.pickupStatus = progress?.status || null; entry.tripNo = progress?.tripNo || null
}
check(replanned.trip.tripNo && assigned.trip.vehicleId, '改派与已分配阶段单据持久保留')
check(report.checks.slice(attemptStart).every(row => row.passed), '本次执行所有断言通过;前次失败保留审计记录')
attempt.passed = true; attempt.completedAt = new Date().toISOString();
report.passed = true; delete report.error; report.completedAt = new Date().toISOString(); await save()
console.log(JSON.stringify({ passed: true, marker, orders: report.orders.length, trips: report.trips.length, checks: report.checks.length, report: output }))
} catch (error) { attempt.error = error.message; report.error = error.message; await save(); console.error(JSON.stringify({ passed: false, marker, error: error.message, report: output })); process.exitCode = 1 }