fix(fleet): reuse reviewed handover vehicle and check formal responses
This commit is contained in:
@@ -42,6 +42,8 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_
|
|||||||
|
|
||||||
经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。
|
经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。
|
||||||
|
|
||||||
|
`DEPARTED_VEHICLE_CREATED` 是独立的显式恢复阶段:只接受已验证成本、唯一接替车辆创建 POST 明确成功、随后仅因车牌大小写断言停止的报告。执行前核对精确报告 SHA、原成功响应、车辆完整当前快照,并通过 Jenkins 一致性只读查询确认该车辆在 `ll_fleet_resource_assignment` 中没有占用;缺少证据、车辆变更或占用均停止。已创建车辆显式传给交接助手,跳过原车辆创建 POST,从新司机及证照继续;原成本和 DEPART 保留。该成功写入不能借用只读失败恢复阶段忽略,也不得自动重发。
|
||||||
|
|
||||||
`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。
|
`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。
|
||||||
|
|
||||||
`fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 提供,数据库凭据只在执行节点内存读取。
|
`fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 提供,数据库凭据只在执行节点内存读取。
|
||||||
|
|||||||
@@ -14,13 +14,23 @@ function fixture(fault = '') {
|
|||||||
const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
|
const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
|
||||||
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
|
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
|
||||||
items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] }))
|
items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] }))
|
||||||
const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 }))
|
if (fault === 'r5-layout') {
|
||||||
const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' }))
|
Object.assign(dispatchDetails[0], { packageCount: 5, pickedPackageCount: 5, items: [
|
||||||
|
{ id: 'ITEM-A1', warehousePalletId: 'PALLET-1', packageCount: 1 }, { id: 'ITEM-A2', warehousePalletId: 'PALLET-2', packageCount: 4 }] })
|
||||||
|
Object.assign(dispatchDetails[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] })
|
||||||
|
}
|
||||||
|
const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: row.packageCount }))
|
||||||
|
const cargo = dispatchDetails.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: row.dispatchNo,
|
||||||
|
dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount,
|
||||||
|
destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' })))
|
||||||
|
const pallets = [...new Set(cargo.map(row => row.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1 }))
|
||||||
const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo,
|
const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo,
|
||||||
pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } }
|
pallets } } }
|
||||||
const resources = new Map([['VEHICLE:11', 'TR-ONE'], ['DRIVER:21', 'TR-ONE']])
|
const resources = new Map([['VEHICLE:11', 'TR-ONE'], ['DRIVER:21', 'TR-ONE']])
|
||||||
const vehicles = new Map(), drivers = new Map(), probes = new Map(), calls = []
|
const vehicles = new Map(), drivers = new Map(), probes = new Map(), calls = []
|
||||||
let createdVehicles = 0, handover = null, preview = null
|
let createdVehicles = fault === 'reviewed-vehicle' ? 1 : 0, handover = null, preview = null
|
||||||
|
if (fault === 'reviewed-vehicle') vehicles.set('12', { id: '12', plateNumber: 'HO-24255909-3BC', recordVersion: '0',
|
||||||
|
status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 })
|
||||||
const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
||||||
const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
|
const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
|
||||||
const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) }
|
const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) }
|
||||||
@@ -30,11 +40,12 @@ function fixture(fault = '') {
|
|||||||
const context = {
|
const context = {
|
||||||
context: { async checkpointFleetHandover() {} },
|
context: { async checkpointFleetHandover() {} },
|
||||||
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
|
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
|
||||||
departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: 20, dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: 10 })) } } },
|
departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: dispatchDetails.reduce((n,row)=>n+row.packageCount,0), dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: row.packageCount })) } } },
|
||||||
assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) },
|
assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) },
|
||||||
async requestMultipart(method, path, file) {
|
async requestMultipart(method, path, file) {
|
||||||
calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0)
|
calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0)
|
||||||
drivers.get('22').recordVersion = '1'; return ok({ id: '22', recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' })
|
Object.assign(drivers.get('22'), { recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' });
|
||||||
|
return ok(fault === 'upload-identity' ? { ...drivers.get('22'), id: 'OTHER' } : drivers.get('22'))
|
||||||
},
|
},
|
||||||
async request(method, path, body, token) {
|
async request(method, path, body, token) {
|
||||||
calls.push({ method, path, body: copy(body), token })
|
calls.push({ method, path, body: copy(body), token })
|
||||||
@@ -47,12 +58,13 @@ function fixture(fault = '') {
|
|||||||
const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1)
|
const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1)
|
||||||
const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport
|
const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport
|
||||||
return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport,
|
return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport,
|
||||||
deliveries: [{ deliveryNo: `DL-${customer}`, status: 'WAIT_DELIVERY', packages: 10, terminalAt: null,
|
deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', packages: row.packages, terminalAt: null,
|
||||||
transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } }],
|
transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } })),
|
||||||
latestHandover: handover ? { handoverNo: handover.handoverNo } : null })
|
latestHandover: handover ? { handoverNo: handover.handoverNo } : null })
|
||||||
}
|
}
|
||||||
if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1)))
|
if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1)))
|
||||||
if (path.endsWith('/master-data/vehicles') && method === 'POST') {
|
if (path.endsWith('/master-data/vehicles') && method === 'POST') {
|
||||||
|
assert.equal(body.plateNumber, body.plateNumber.toUpperCase(), 'request must use the formal uppercase vehicle plate')
|
||||||
const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle)
|
const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle)
|
||||||
}
|
}
|
||||||
if (path.includes('/master-data/vehicles/')) {
|
if (path.includes('/master-data/vehicles/')) {
|
||||||
@@ -61,13 +73,18 @@ function fixture(fault = '') {
|
|||||||
return ok(vehicle)
|
return ok(vehicle)
|
||||||
}
|
}
|
||||||
if (path.endsWith('/fleet/drivers') && method === 'POST') {
|
if (path.endsWith('/fleet/drivers') && method === 'POST') {
|
||||||
const driver = { id: '22', recordVersion: '0', details: body, active: true }; drivers.set(driver.id, driver); return ok(driver)
|
const profile = fault === 'driver-default-null' ? { warehouseId: null, vehicleModels: null, languagesAndArea: null,
|
||||||
|
medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } : {}
|
||||||
|
const driver = { id: '22', driverNo: 'DR-TEST', recordVersion: '0', details: { ...body, profile,
|
||||||
|
...(fault === 'driver-default-null' ? { licenseIssuedOn: null } : {}) }, active: true, reviewStatus: 'PENDING' }; drivers.set(driver.id, driver);
|
||||||
|
if (fault === 'driver-default-nonempty') driver.details.profile.warehouseId = 'OTHER'
|
||||||
|
return ok(fault === 'driver-identity' ? { ...driver, recordVersion: '3' } : driver)
|
||||||
}
|
}
|
||||||
if (path.includes('/fleet/drivers/')) {
|
if (path.includes('/fleet/drivers/')) {
|
||||||
const driver = drivers.get('22')
|
const driver = drivers.get('22')
|
||||||
if (path.endsWith('/review')) { driver.reviewStatus = 'APPROVED'; driver.recordVersion = '2' }
|
if (path.endsWith('/review')) { driver.reviewStatus = 'APPROVED'; driver.recordVersion = '2' }
|
||||||
if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' }
|
if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' }
|
||||||
return ok(driver)
|
return ok(fault === 'review-version' && path.endsWith('/review') ? { ...driver, recordVersion: '1' } : driver)
|
||||||
}
|
}
|
||||||
if (path.endsWith('/fleet/trips') && method === 'POST') {
|
if (path.endsWith('/fleet/trips') && method === 'POST') {
|
||||||
const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] })
|
const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] })
|
||||||
@@ -93,9 +110,13 @@ function fixture(fault = '') {
|
|||||||
return ok({ records, total: records.length })
|
return ok({ records, total: records.length })
|
||||||
}
|
}
|
||||||
if (path.endsWith('/handovers/preview')) {
|
if (path.endsWith('/handovers/preview')) {
|
||||||
preview = { cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
|
preview = { tripNo: trip.tripNo, recordVersion: trip.recordVersion,
|
||||||
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: ['PALLET-INTERNAL'], occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 } } }
|
previous: { vehicleId: '11', driverId: '21', ...originalTransport }, replacement: { vehicleId: '12', driverId: '22' },
|
||||||
|
capacity: { palletSpaces: 30, weightKg: 18000, volumeCbm: 90 }, cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
|
||||||
|
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: pallets.map(row=>row.warehousePalletId), occupied: { palletSpaces: pallets.length, weightKg: pallets.length*100, volumeCbm: pallets.length } } }
|
||||||
if (fault === 'wrong-customer') preview.cargo.remainingCargo[0].customerId = 'OTHER'
|
if (fault === 'wrong-customer') preview.cargo.remainingCargo[0].customerId = 'OTHER'
|
||||||
|
if (fault === 'preview-identity') preview.replacement.driverId = 'OTHER'
|
||||||
|
if (fault === 'preview-capacity') preview.capacity.palletSpaces = 29
|
||||||
if (fault === 'double-count-pallet') preview.cargo.occupied.palletSpaces = 2
|
if (fault === 'double-count-pallet') preview.cargo.occupied.palletSpaces = 2
|
||||||
return ok(preview)
|
return ok(preview)
|
||||||
}
|
}
|
||||||
@@ -120,8 +141,10 @@ function fixture(fault = '') {
|
|||||||
return ok(handover)
|
return ok(handover)
|
||||||
}
|
}
|
||||||
assert.equal(String(body.recordVersion), handover.recordVersion)
|
assert.equal(String(body.recordVersion), handover.recordVersion)
|
||||||
resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location }
|
resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location,
|
||||||
handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump(); return ok(handover)
|
idempotencyKey: body.idempotencyKey, vehicleStatus: body.vehicleStatus }
|
||||||
|
handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump();
|
||||||
|
return ok(fault === 'release-version' && vehicle ? { ...handover, recordVersion: '99' } : handover)
|
||||||
}
|
}
|
||||||
if (path.endsWith('/trips/TR-ONE') && method === 'GET') return ok({ trip, dispatches: bindings })
|
if (path.endsWith('/trips/TR-ONE') && method === 'GET') return ok({ trip, dispatches: bindings })
|
||||||
throw new Error(`Unexpected test route ${method} ${path}`)
|
throw new Error(`Unexpected test route ${method} ${path}`)
|
||||||
@@ -210,7 +233,9 @@ test('every HTTP call observes a completed pre-send checkpoint; post-response di
|
|||||||
assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING')
|
assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING')
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const [fault, message] of Object.entries({ 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
|
for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接替司机返回身份和版本', 'driver-identity': '接替司机返回身份和版本', 'upload-identity': '测试证照有正式文件归属',
|
||||||
|
'review-version': '接替司机已正式审核', 'preview-identity': '交接预览绑定原车次', 'preview-capacity': '交接预览容量', 'release-version': '原车辆已结束',
|
||||||
|
'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
|
||||||
'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用',
|
'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用',
|
||||||
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) {
|
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) {
|
||||||
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
|
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
|
||||||
@@ -247,8 +272,35 @@ test('explicit closure cleanup cancels only test probes and disables newly creat
|
|||||||
assert.ok(!f.calls.some(row => row.method === 'DELETE'))
|
assert.ok(!f.calls.some(row => row.method === 'DELETE'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('formal driver response accepts empty profile with omitted or NULL optional fields', async () => {
|
||||||
|
const f = fixture('driver-default-null')
|
||||||
|
assert.equal((await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)).status, 'VERIFIED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('r5 two dispatches, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => {
|
||||||
|
const f = fixture('r5-layout')
|
||||||
|
const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
|
||||||
|
assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaAfter.flatMap(row=>row.deliveries).length, 3)
|
||||||
|
assert.equal(result.preview.cargo.remainingPalletIds.length, 3)
|
||||||
|
f.proof.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' }
|
||||||
|
f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() }
|
||||||
|
f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22')
|
||||||
|
assert.equal((await cleanupFleetHandoverResources(f.context, 'test', f.proof)).status, 'COMPLETED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit reviewed vehicle reuse skips the original POST and proceeds from driver creation', async () => {
|
||||||
|
const f = fixture('reviewed-vehicle'), snapshot = copy(f.vehicles.get('12'))
|
||||||
|
const reviewedVehicle = { snapshot, occupation: { vehicleId: '12', assignmentCount: 0 },
|
||||||
|
creationRequest: { method: 'POST', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't',
|
||||||
|
body: { plateNumber: 'HO-24255909-3bc' }, result: snapshot } }
|
||||||
|
const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails, { reviewedVehicle })
|
||||||
|
assert.equal(result.status, 'VERIFIED'); assert.equal(result.replacement.vehicleId, '12')
|
||||||
|
const created = f.calls.filter(row=>row.method==='POST' && row.path?.endsWith('/master-data/vehicles'))
|
||||||
|
assert.equal(created.length, 1); assert.match(created[0].body.plateNumber, /^HO-PROBE-/)
|
||||||
|
})
|
||||||
|
|
||||||
test('internal helper has no environment loader, SQL or standalone execution path', async () => {
|
test('internal helper has no environment loader, SQL or standalone execution path', async () => {
|
||||||
const source = await readFile(new URL('./fleet-handover-acceptance.mjs', import.meta.url), 'utf8')
|
const source = await readFile(new URL('./fleet-handover-acceptance.mjs', import.meta.url), 'utf8')
|
||||||
assert.doesNotMatch(source, /process\.env|process\.argv|fetch\(|execFile|INSERT INTO|UPDATE ll_|DELETE FROM/)
|
assert.doesNotMatch(source, /process\.env|process\.argv|fetch\(|execFile|INSERT INTO|UPDATE ll_|DELETE FROM/)
|
||||||
assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails\)/)
|
assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails, \{ reviewedVehicle \} = \{\}\)/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function thousandths(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Internal fresh-run helper, called only after the caller's real departure + replay and before signing. */
|
/** Internal fresh-run helper, called only after the caller's real departure + replay and before signing. */
|
||||||
export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails) {
|
export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails, { reviewedVehicle } = {}) {
|
||||||
const run = requireRun(context, tripProof)
|
const run = requireRun(context, tripProof)
|
||||||
if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length)
|
if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length)
|
||||||
throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑')
|
throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑')
|
||||||
@@ -135,28 +135,53 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
|||||||
const capacity = { maxPalletSpaces: detail.trip.maxPalletSpaces, maxLoadKg: detail.trip.maxLoadKg, maxVolumeCbm: detail.trip.maxVolumeCbm }
|
const capacity = { maxPalletSpaces: detail.trip.maxPalletSpaces, maxLoadKg: detail.trip.maxLoadKg, maxVolumeCbm: detail.trip.maxVolumeCbm }
|
||||||
check('使用正式原车辆容量及有效任务结束时间', Object.values(capacity).every(value => Number(value) > 0 && Number.isFinite(Number(value)))
|
check('使用正式原车辆容量及有效任务结束时间', Object.values(capacity).every(value => Number(value) > 0 && Number.isFinite(Number(value)))
|
||||||
&& Date.parse(detail.trip.expectedDriverEndAt) > Date.now())
|
&& Date.parse(detail.trip.expectedDriverEndAt) > Date.now())
|
||||||
const marker = `HO-${randomUUID().slice(0, 12)}`
|
const marker = reviewedVehicle ? reviewedVehicle.snapshot.plateNumber : `HO-${randomUUID().slice(0, 12).toUpperCase()}`
|
||||||
const vehicleBody = { plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
const vehicleBody = { plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
||||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', ...capacity, remark: `${context.marker} controlled TEST handover vehicle` }
|
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', ...capacity, remark: `${context.marker} controlled TEST handover vehicle` }
|
||||||
proof.replacement = { marker, vehicleRequest: vehicleBody }
|
proof.replacement = { marker, vehicleRequest: vehicleBody }
|
||||||
const vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆')
|
let vehicle
|
||||||
|
if (reviewedVehicle) {
|
||||||
|
check('显式沿用车辆具有已审阅成功请求和无占用证明', reviewedVehicle.creationRequest?.status === 'RESPONDED'
|
||||||
|
&& reviewedVehicle.creationRequest.http === 200 && text(reviewedVehicle.creationRequest.code) === '200'
|
||||||
|
&& reviewedVehicle.creationRequest.requestId && reviewedVehicle.creationRequest.traceId
|
||||||
|
&& text(reviewedVehicle.occupation?.vehicleId) === text(reviewedVehicle.snapshot.id) && reviewedVehicle.occupation.assignmentCount === 0)
|
||||||
|
vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(reviewedVehicle.snapshot.id)}`, undefined, '再次核对已创建接替车辆完整快照')
|
||||||
|
check('已创建车辆未发生身份版本容量或状态变更', same(vehicle, reviewedVehicle.snapshot)
|
||||||
|
&& text(vehicle.recordVersion) === '0' && vehicle.status === 'IN_SERVICE'
|
||||||
|
&& vehicle.region === 'OVERSEAS' && vehicle.ownership === 'OWNED'
|
||||||
|
&& Number(vehicle.maxPalletSpaces) === capacity.maxPalletSpaces && Number(vehicle.maxLoadKg) === capacity.maxLoadKg && Number(vehicle.maxVolumeCbm) === capacity.maxVolumeCbm)
|
||||||
|
proof.replacement.vehicleReuse = clone(reviewedVehicle)
|
||||||
|
} else vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆')
|
||||||
check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker)
|
check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker)
|
||||||
proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber
|
proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber
|
||||||
const driver = await request('POST', `${FLEET}/drivers`, { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
const driverBody = { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
||||||
licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
||||||
remark: `${context.marker} TEST credential fixture; not a real driving licence` }, '建立本轮接替司机档案')
|
remark: `${context.marker} TEST credential fixture; not a real driving licence` }
|
||||||
check('接替司机返回身份和版本', driver?.id && validVersion(driver.recordVersion))
|
const driver = await request('POST', `${FLEET}/drivers`, driverBody, '建立本轮接替司机档案')
|
||||||
|
const profileFields = ['warehouseId', 'vehicleModels', 'languagesAndArea', 'medicalExpiresOn', 'trainingExpiresOn', 'insuranceExpiresOn']
|
||||||
|
check('接替司机返回身份和版本', driver?.id && text(driver.recordVersion) === '0' && driver.active === true
|
||||||
|
&& Object.entries(driverBody).every(([field, value]) => driver.details?.[field] === value)
|
||||||
|
&& driver.details.licenseIssuedOn == null && driver.details.profile && !Array.isArray(driver.details.profile)
|
||||||
|
&& profileFields.every(field => driver.details.profile[field] == null))
|
||||||
proof.replacement.driverId = text(driver.id)
|
proof.replacement.driverId = text(driver.id)
|
||||||
const file = { name: `${marker}-TEST-NOT-REAL-LICENSE.png`, type: 'image/png', content: certificate }
|
const file = { name: `${marker}-TEST-NOT-REAL-LICENSE.png`, type: 'image/png', content: certificate }
|
||||||
proof.replacement.certificate = { name: file.name, type: file.type, contentBase64: certificate.toString('base64'), sha256: createHash('sha256').update(certificate).digest('hex'), synthetic: true }
|
proof.replacement.certificate = { name: file.name, type: file.type, contentBase64: certificate.toString('base64'), sha256: createHash('sha256').update(certificate).digest('hex'), synthetic: true }
|
||||||
const uploaded = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/license?recordVersion=${driver.recordVersion}`, undefined, '正式上传非真实测试证照', { file })
|
const uploaded = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/license?recordVersion=${driver.recordVersion}`, undefined, '正式上传非真实测试证照', { file })
|
||||||
check('测试证照有正式文件归属', uploaded?.licenseFileId && validVersion(uploaded.recordVersion))
|
check('测试证照有正式文件归属', text(uploaded?.id) === text(driver.id) && uploaded?.licenseFileId
|
||||||
|
&& text(uploaded.recordVersion) === String(BigInt(driver.recordVersion) + 1n))
|
||||||
proof.replacement.licenseFileId = text(uploaded.licenseFileId)
|
proof.replacement.licenseFileId = text(uploaded.licenseFileId)
|
||||||
const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机')
|
const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机')
|
||||||
check('接替司机已正式审核', reviewed?.reviewStatus === 'APPROVED')
|
check('接替司机已正式审核', text(reviewed?.id) === text(driver.id) && reviewed.reviewStatus === 'APPROVED'
|
||||||
|
&& text(reviewed.recordVersion) === String(BigInt(uploaded.recordVersion) + 1n) && reviewed.licenseFileId === uploaded.licenseFileId)
|
||||||
proof.selection = { recordVersion: detail.trip.recordVersion, vehicleId: vehicle.id, driverId: driver.id,
|
proof.selection = { recordVersion: detail.trip.recordVersion, vehicleId: vehicle.id, driverId: driver.id,
|
||||||
expectedDriverEndAt: detail.trip.expectedDriverEndAt, handedOverAt: new Date().toISOString() }
|
expectedDriverEndAt: detail.trip.expectedDriverEndAt, handedOverAt: new Date().toISOString() }
|
||||||
const preview = await request('POST', `${base}/handovers/preview`, proof.selection, '预览真实剩余货物及资源资格')
|
const preview = await request('POST', `${base}/handovers/preview`, proof.selection, '预览真实剩余货物及资源资格')
|
||||||
|
check('交接预览绑定原车次版本和确切新旧身份', preview?.tripNo === tripProof.tripNo
|
||||||
|
&& text(preview.recordVersion) === text(proof.selection.recordVersion)
|
||||||
|
&& text(preview.previous?.vehicleId) === proof.previous.vehicleId && text(preview.previous?.driverId) === proof.previous.driverId
|
||||||
|
&& text(preview.replacement?.vehicleId) === text(vehicle.id) && text(preview.replacement?.driverId) === text(driver.id))
|
||||||
|
check('交接预览容量与已核对车辆一致', Number(preview.capacity?.palletSpaces) === capacity.maxPalletSpaces
|
||||||
|
&& Number(preview.capacity?.weightKg) === capacity.maxLoadKg && Number(preview.capacity?.volumeCbm) === capacity.maxVolumeCbm)
|
||||||
const expectedCargo = original.confirmedLoad.cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
|
const expectedCargo = original.confirmedLoad.cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
|
||||||
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages }))
|
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages }))
|
||||||
check('预览精确包含全部未签收客户货物且资格有效', /^[0-9a-f]{64}$/.test(text(preview?.cargoHash)) && preview.driverQualification?.canDispatch === true
|
check('预览精确包含全部未签收客户货物且资格有效', /^[0-9a-f]{64}$/.test(text(preview?.cargoHash)) && preview.driverQualification?.canDispatch === true
|
||||||
@@ -171,7 +196,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
|||||||
proof.preview = preview
|
proof.preview = preview
|
||||||
proof.confirmRequest = { selection: proof.selection, cargoHash: preview.cargoHash, location: `${context.marker}受控交接点`, reason: '本轮真实发运后整车资源交接', idempotencyKey: key() }
|
proof.confirmRequest = { selection: proof.selection, cargoHash: preview.cargoHash, location: `${context.marker}受控交接点`, reason: '本轮真实发运后整车资源交接', idempotencyKey: key() }
|
||||||
const handover = await request('POST', `${base}/handovers`, proof.confirmRequest, '确认实际在途交接')
|
const handover = await request('POST', `${base}/handovers`, proof.confirmRequest, '确认实际在途交接')
|
||||||
check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && validVersion(handover.recordVersion)
|
check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && text(handover.recordVersion) === '0'
|
||||||
&& text(handover.snapshot?.previous?.vehicleId) === proof.previous.vehicleId && text(handover.snapshot.previous.driverId) === proof.previous.driverId
|
&& text(handover.snapshot?.previous?.vehicleId) === proof.previous.vehicleId && text(handover.snapshot.previous.driverId) === proof.previous.driverId
|
||||||
&& text(handover.snapshot.replacement.vehicleId) === text(vehicle.id) && text(handover.snapshot.replacement.driverId) === text(driver.id)
|
&& text(handover.snapshot.replacement.vehicleId) === text(vehicle.id) && text(handover.snapshot.replacement.driverId) === text(driver.id)
|
||||||
&& same(inspectionFacts(handover.snapshot.cargo), inspectionFacts(preview.cargo)) && handover.vehicleRelease == null && handover.driverRelease == null)
|
&& same(inspectionFacts(handover.snapshot.cargo), inspectionFacts(preview.cargo)) && handover.vehicleRelease == null && handover.driverRelease == null)
|
||||||
@@ -194,11 +219,21 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
|||||||
const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release`
|
const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release`
|
||||||
proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() }
|
proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() }
|
||||||
const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务')
|
const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务')
|
||||||
check('原车辆已结束而原司机仍在任务中', vehicleReleased.vehicleRelease?.actualEndAt && vehicleReleased.driverRelease == null)
|
check('原车辆已结束而原司机仍在任务中', vehicleReleased.handoverNo === handover.handoverNo && vehicleReleased.tripNo === tripProof.tripNo
|
||||||
|
&& text(vehicleReleased.recordVersion) === String(BigInt(proof.vehicleReleaseRequest.recordVersion) + 1n)
|
||||||
|
&& Date.parse(vehicleReleased.vehicleRelease?.actualEndAt) === Date.parse(proof.vehicleReleaseRequest.actualEndAt)
|
||||||
|
&& vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey
|
||||||
|
&& vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE'
|
||||||
|
&& vehicleReleased.driverRelease == null)
|
||||||
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
|
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
|
||||||
proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() }
|
proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() }
|
||||||
const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务')
|
const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务')
|
||||||
check('原车辆司机分别保留结束事实', released.vehicleRelease?.actualEndAt && released.driverRelease?.actualEndAt)
|
check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo
|
||||||
|
&& text(released.recordVersion) === String(BigInt(proof.driverReleaseRequest.recordVersion) + 1n)
|
||||||
|
&& same(released.vehicleRelease, vehicleReleased.vehicleRelease)
|
||||||
|
&& Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt)
|
||||||
|
&& released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey
|
||||||
|
&& released.driverRelease?.location === proof.driverReleaseRequest.location)
|
||||||
const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划')
|
const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划')
|
||||||
firstProbe.recordVersion = assigned.trip.recordVersion
|
firstProbe.recordVersion = assigned.trip.recordVersion
|
||||||
check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId)
|
check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId)
|
||||||
@@ -208,7 +243,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
|||||||
check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot))
|
check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot))
|
||||||
const secondProbe = await createProbe(2)
|
const secondProbe = await createProbe(2)
|
||||||
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
|
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
|
||||||
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10)}` }
|
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` }
|
||||||
const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆')
|
const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆')
|
||||||
check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id)
|
check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id)
|
||||||
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })
|
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|||||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
||||||
import { redactEvidence } from './fba-core-regression-test-env.mjs'
|
import { redactEvidence } from './fba-core-regression-test-env.mjs'
|
||||||
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
|
import { createTestEvidenceReaders, buildEvidenceRemoteScript, validateEvidenceConfig } from './fba-core-regression-evidence.mjs'
|
||||||
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
|
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
|
||||||
import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs'
|
import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs'
|
||||||
import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs'
|
import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs'
|
||||||
@@ -154,7 +154,7 @@ export function parseFleetArguments(args) {
|
|||||||
}
|
}
|
||||||
if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
|
if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
|
||||||
|| (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required')
|
|| (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required')
|
||||||
if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
|
if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
|
||||||
if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256)
|
if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256)
|
||||||
throw Error('Departed recovery requires the exact reviewed report SHA256')
|
throw Error('Departed recovery requires the exact reviewed report SHA256')
|
||||||
if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports')
|
if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports')
|
||||||
@@ -163,17 +163,19 @@ export function parseFleetArguments(args) {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
export function fleetExecutionPlan(config) {
|
export function fleetExecutionPlan(config) {
|
||||||
|
const afterCosts = ['DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage)
|
||||||
return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover',
|
return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover',
|
||||||
returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full',
|
returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full',
|
||||||
stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null,
|
stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null,
|
||||||
outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites',
|
outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites',
|
||||||
resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null,
|
resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null,
|
||||||
stages: config.resumeStage?.startsWith('DEPARTED_')
|
stages: config.resumeStage?.startsWith('DEPARTED_')
|
||||||
? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
|
? ['Verify reviewed report SHA256', afterCosts
|
||||||
? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries'
|
? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries'
|
||||||
: 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries',
|
: 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries',
|
||||||
config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions',
|
...(config.resumeStage === 'DEPARTED_VEHICLE_CREATED' ? ['Verify exact successful vehicle creation, complete current vehicle snapshot and zero resource assignments', 'Explicitly reuse the created vehicle; skip its creation POST and continue from new driver'] : []),
|
||||||
config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability',
|
afterCosts ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions',
|
||||||
|
afterCosts ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability',
|
||||||
'Retain original DEPART; no picking or departure commands',
|
'Retain original DEPART; no picking or departure commands',
|
||||||
'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions']
|
'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions']
|
||||||
: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle',
|
: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle',
|
||||||
@@ -187,11 +189,20 @@ export function fleetExecutionPlan(config) {
|
|||||||
export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
|
export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
|
||||||
const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0]
|
const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0]
|
||||||
const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo)
|
const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo)
|
||||||
if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER') {
|
if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') {
|
||||||
const afterCosts = stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
|
const vehicleCreated = stage === 'DEPARTED_VEHICLE_CREATED'
|
||||||
|
const afterCosts = stage !== 'DEPARTED_BEFORE_COSTS'
|
||||||
const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false)
|
const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false)
|
||||||
const failureName = afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘'
|
const failureName = vehicleCreated ? '接替车辆返回真实身份及车牌' : afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘'
|
||||||
const handover = trip?.handover
|
const handover = trip?.handover
|
||||||
|
const creation = vehicleCreated ? handover?.requests?.filter(row => row.method !== 'GET') : null
|
||||||
|
if (vehicleCreated && (creation?.length !== 1 || creation[0].method !== 'POST'
|
||||||
|
|| creation[0].path !== '/api/platform/v1/last-leg/master-data/vehicles' || creation[0].status !== 'RESPONDED'
|
||||||
|
|| creation[0].http !== 200 || id(creation[0].code) !== '200' || !creation[0].requestId || !creation[0].traceId
|
||||||
|
|| !/^[1-9]\d*$/.test(id(creation[0].result?.id)) || id(creation[0].result.recordVersion) !== '0'
|
||||||
|
|| creation[0].result.plateNumber !== creation[0].body?.plateNumber?.toUpperCase()
|
||||||
|
|| creation[0].result.status !== 'IN_SERVICE' || handover.replacement?.driverId))
|
||||||
|
throw Error('Created vehicle recovery requires the exact definitive successful creation and no later handover write')
|
||||||
if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING'
|
if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING'
|
||||||
|| proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo
|
|| proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo
|
||||||
|| !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash
|
|| !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash
|
||||||
@@ -199,14 +210,15 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
|
|||||||
|| costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId
|
|| costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId
|
||||||
|| (afterCosts ? ![200, 403].includes(row.http) || !['200', 'A0403', 'A0409'].includes(id(row.code)) : row.method !== 'GET' || row.http !== 200 || id(row.code) !== '200'))
|
|| (afterCosts ? ![200, 403].includes(row.http) || !['200', 'A0403', 'A0409'].includes(id(row.code)) : row.method !== 'GET' || row.http !== 200 || id(row.code) !== '200'))
|
||||||
|| (afterCosts ? handover?.status !== 'FAILED' || !handover.requests?.length || handover.confirmRequest || handover.selection
|
|| (afterCosts ? handover?.status !== 'FAILED' || !handover.requests?.length || handover.confirmRequest || handover.selection
|
||||||
|| handover.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200')
|
|| handover.requests.some(row => (row.method !== 'GET' && row !== creation?.[0]) || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200')
|
||||||
|| !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover)
|
|| !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover)
|
||||||
|| trip.closure || proof.receipts || proof.closure || trip.returnRedispatch
|
|| trip.closure || proof.receipts || proof.closure || trip.returnRedispatch
|
||||||
|| resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId
|
|| resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId
|
||||||
|| failures?.length !== 2 || !failures.some(row => row.name === failureName)
|
|| failures?.length !== 2 || !failures.some(row => row.name === failureName)
|
||||||
|| failures.some(row => ![failureName, '模块执行'].includes(row.name)))
|
|| failures.some(row => ![failureName, '模块执行'].includes(row.name)))
|
||||||
throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes')
|
throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes')
|
||||||
return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts }
|
return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts,
|
||||||
|
createdVehicle: vehicleCreated ? { snapshot: creation[0].result, creationRequest: creation[0] } : null }
|
||||||
}
|
}
|
||||||
if (stage === 'TERMINAL_READONLY') {
|
if (stage === 'TERMINAL_READONLY') {
|
||||||
const failures = report?.steps?.filter(row => row.passed === false)
|
const failures = report?.steps?.filter(row => row.passed === false)
|
||||||
@@ -309,7 +321,7 @@ export async function verifyFleetTerminalState(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** All business checks happen before creating replacement test sessions or issuing a business command. */
|
/** All business checks happen before creating replacement test sessions or issuing a business command. */
|
||||||
export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false } = {}) {
|
export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle } = {}) {
|
||||||
const proof = context.createdResources.fleetAcceptance
|
const proof = context.createdResources.fleetAcceptance
|
||||||
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
|
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
|
||||||
const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad
|
const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad
|
||||||
@@ -363,14 +375,42 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f
|
|||||||
evidence.deliveries.push(order)
|
evidence.deliveries.push(order)
|
||||||
}
|
}
|
||||||
check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch')
|
check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch')
|
||||||
|
if (createdVehicle) {
|
||||||
|
const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`)
|
||||||
|
const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
|
||||||
|
check(same(canonical(vehicle), canonical(createdVehicle.snapshot)), 'created vehicle snapshot changed')
|
||||||
|
check(typeof context.context?.readReviewedVehicleOccupation === 'function', 'vehicle occupancy reader missing')
|
||||||
|
const occupation = await context.context.readReviewedVehicleOccupation(vehicle.id)
|
||||||
|
check(id(occupation?.vehicleId) === id(vehicle.id) && occupation.assignmentCount === 0, 'created vehicle is occupied')
|
||||||
|
evidence.reviewedVehicle = { ...createdVehicle, snapshot: vehicle, occupation }
|
||||||
|
}
|
||||||
Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash,
|
Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash,
|
||||||
departure: departures[0], costSummary: costs.summary })
|
departure: departures[0], costSummary: costs.summary })
|
||||||
await context.checkpoint()
|
await context.checkpoint()
|
||||||
return { trip, dispatchDetails: evidence.dispatchDetails }
|
return { trip, dispatchDetails: evidence.dispatchDetails, reviewedVehicle: evidence.reviewedVehicle }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) {
|
export async function readReviewedVehicleOccupation(env, vehicleId, fetcher = globalThis.fetch) {
|
||||||
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails)
|
validateEvidenceConfig(env)
|
||||||
|
if (!/^[1-9]\d*$/.test(id(vehicleId))) throw Error('Exact numeric reviewed vehicle ID required')
|
||||||
|
const sql = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;',
|
||||||
|
'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;',
|
||||||
|
`SELECT JSON_OBJECT('vehicleId','${vehicleId}','assignmentCount',COUNT(*)) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=${vehicleId};`, 'COMMIT;'].join('\n')
|
||||||
|
const script = buildEvidenceRemoteScript('lastleg', sql, env.FBA_CORE_JENKINS_AGENT)
|
||||||
|
const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST',
|
||||||
|
headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) })
|
||||||
|
if (!response.ok) throw Error('Reviewed vehicle occupancy HTTP failure')
|
||||||
|
const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON='))
|
||||||
|
if (lines.length !== 1) throw Error('Reviewed vehicle occupancy response ambiguous')
|
||||||
|
const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length))
|
||||||
|
if (result.ok !== true || result.rows?.length !== 1) throw Error('Reviewed vehicle occupancy query failed')
|
||||||
|
return result.rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) {
|
||||||
|
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle })
|
||||||
const run = context.createdResources.fleetAcceptance
|
const run = context.createdResources.fleetAcceptance
|
||||||
if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo
|
if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo
|
||||||
|| run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser
|
|| run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser
|
||||||
@@ -650,7 +690,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady,
|
export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady,
|
||||||
costsAlreadyVerified = false,
|
costsAlreadyVerified = false, reviewedVehicle,
|
||||||
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
|
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
|
||||||
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
|
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
|
||||||
const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId
|
const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId
|
||||||
@@ -665,6 +705,7 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa
|
|||||||
} else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED')
|
} else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED')
|
||||||
stage('IN_TRANSIT_HANDOVER')
|
stage('IN_TRANSIT_HANDOVER')
|
||||||
await handover(context, trip, picker.token, dispatchDetails, {
|
await handover(context, trip, picker.token, dispatchDetails, {
|
||||||
|
reviewedVehicle,
|
||||||
onReady: onHandoverReady ? async fixture => {
|
onReady: onHandoverReady ? async fixture => {
|
||||||
stage('PDA_UI_CHECKPOINT')
|
stage('PDA_UI_CHECKPOINT')
|
||||||
await onHandoverReady({ fixture, picker })
|
await onHandoverReady({ fixture, picker })
|
||||||
@@ -696,10 +737,10 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors,
|
export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors,
|
||||||
costsAlreadyVerified = false,
|
costsAlreadyVerified = false, createdVehicle,
|
||||||
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
|
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
|
||||||
complete = completeDepartedFleetScenario } = {}) {
|
complete = completeDepartedFleetScenario } = {}) {
|
||||||
const verified = await verify(context, { costsAlreadyVerified })
|
const verified = await verify(context, { costsAlreadyVerified, createdVehicle })
|
||||||
const proof = context.createdResources.fleetAcceptance, trip = verified.trip
|
const proof = context.createdResources.fleetAcceptance, trip = verified.trip
|
||||||
if (!costsAlreadyVerified) {
|
if (!costsAlreadyVerified) {
|
||||||
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
|
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
|
||||||
@@ -728,7 +769,7 @@ export async function continueReviewedDeparture(context, { verify = verifyDepart
|
|||||||
if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session')
|
if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session')
|
||||||
proof.picker = { membershipId: picker.membershipId, username: picker.username }
|
proof.picker = { membershipId: picker.membershipId, username: picker.username }
|
||||||
await context.checkpoint()
|
await context.checkpoint()
|
||||||
return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified })
|
return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified, reviewedVehicle: verified.reviewedVehicle })
|
||||||
} finally {
|
} finally {
|
||||||
try { await closePicker(context, picker, 71) } finally { await actors?.close() }
|
try { await closePicker(context, picker, 71) } finally { await actors?.close() }
|
||||||
}
|
}
|
||||||
@@ -784,6 +825,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
|||||||
}, { guardFetch: true })
|
}, { guardFetch: true })
|
||||||
context.context.fleetRunId = config.runId
|
context.context.fleetRunId = config.runId
|
||||||
context.context.fleetScenario = config.scenario || 'handover'
|
context.context.fleetScenario = config.scenario || 'handover'
|
||||||
|
context.context.readReviewedVehicleOccupation = vehicleId => readReviewedVehicleOccupation(env, vehicleId)
|
||||||
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
|
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
|
||||||
context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture)
|
context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture)
|
||||||
let handoverCheckpoint = 0
|
let handoverCheckpoint = 0
|
||||||
@@ -797,7 +839,8 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
|||||||
}
|
}
|
||||||
context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance)
|
context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance)
|
||||||
const fleetScenario = async (ctx, options = {}) => {
|
const fleetScenario = async (ctx, options = {}) => {
|
||||||
if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, costsAlreadyVerified: options.costsAlreadyVerified })
|
if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors,
|
||||||
|
costsAlreadyVerified: options.costsAlreadyVerified, createdVehicle: options.createdVehicle })
|
||||||
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
|
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
|
||||||
try { return await scenario(ctx, { ...options, costActors }) }
|
try { return await scenario(ctx, { ...options, costActors }) }
|
||||||
finally { await costActors?.close() }
|
finally { await costActors?.close() }
|
||||||
@@ -825,6 +868,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
|
|||||||
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
|
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
|
||||||
resumeDeparted: resumed.departed,
|
resumeDeparted: resumed.departed,
|
||||||
costsAlreadyVerified: resumed.costsAlreadyVerified,
|
costsAlreadyVerified: resumed.costsAlreadyVerified,
|
||||||
|
createdVehicle: resumed.createdVehicle,
|
||||||
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
|
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
|
||||||
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
|
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
|
||||||
report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules,
|
report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules,
|
||||||
|
|||||||
@@ -38,6 +38,51 @@ function departedRecoveryFixture() {
|
|||||||
return { report, ctx, calls, responses, base, trip }
|
return { report, ctx, calls, responses, base, trip }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createdVehicleRecoveryFixture() {
|
||||||
|
const f = afterCostsRecoveryFixture(), snapshot = { id: '2106360046045675522', plateNumber: 'HO-24255909-3BC', recordVersion: '0',
|
||||||
|
status: 'IN_SERVICE', ownership: 'OWNED', region: 'OVERSEAS', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
|
||||||
|
f.report.steps[0].name = '接替车辆返回真实身份及车牌'
|
||||||
|
const request = { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', body: { plateNumber: 'HO-24255909-3bc' },
|
||||||
|
status: 'RESPONDED', http: 200, code: '200', requestId: 'created-r', traceId: 'created-t', result: snapshot }
|
||||||
|
f.trip.handover.requests.push(request)
|
||||||
|
f.responses.set('/api/platform/v1/last-leg/master-data/vehicles/' + snapshot.id, structuredClone(snapshot))
|
||||||
|
f.ctx.context.readReviewedVehicleOccupation = async vehicleId => ({ vehicleId, assignmentCount: 0 })
|
||||||
|
return { ...f, snapshot, request }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('created-vehicle checkpoint requires exact successful uppercase response and no subsequent writes', () => {
|
||||||
|
const { report } = createdVehicleRecoveryFixture()
|
||||||
|
const accepted = reviewedFullCheckpoint(report, 'DEPARTED_VEHICLE_CREATED')
|
||||||
|
assert.equal(accepted.createdVehicle.snapshot.plateNumber, 'HO-24255909-3BC')
|
||||||
|
assert.equal(accepted.createdVehicle.creationRequest.body.plateNumber, 'HO-24255909-3bc')
|
||||||
|
for (const mutation of [f => f.request.http = 0, f => f.request.status = 'UNKNOWN_RESULT', f => f.request.code = 'A0409',
|
||||||
|
f => f.request.traceId = null, f => f.trip.handover.requests.push({ method: 'POST', path: '/drivers' })]) {
|
||||||
|
const f = createdVehicleRecoveryFixture(); mutation(f)
|
||||||
|
assert.throws(() => reviewedFullCheckpoint(f.report, 'DEPARTED_VEHICLE_CREATED'), /Created vehicle recovery/)
|
||||||
|
}
|
||||||
|
assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const fault of ['none', 'changed', 'occupied', 'missing-reader']) test(`created vehicle exact snapshot and no occupation mandatory: ${fault}`, async () => {
|
||||||
|
const f = createdVehicleRecoveryFixture(), accepted = reviewedFullCheckpoint(f.report, 'DEPARTED_VEHICLE_CREATED')
|
||||||
|
if (fault === 'changed') f.responses.get('/api/platform/v1/last-leg/master-data/vehicles/' + f.snapshot.id).recordVersion = '1'
|
||||||
|
if (fault === 'occupied') f.ctx.context.readReviewedVehicleOccupation = async vehicleId => ({ vehicleId, assignmentCount: 1 })
|
||||||
|
if (fault === 'missing-reader') delete f.ctx.context.readReviewedVehicleOccupation
|
||||||
|
const options = { costsAlreadyVerified: true, createdVehicle: accepted.createdVehicle }
|
||||||
|
if (fault === 'none') { const r = await verifyDepartedRecovery(f.ctx, options); assert.equal(r.reviewedVehicle.snapshot.id, f.snapshot.id) }
|
||||||
|
else await assert.rejects(continueReviewedDeparture(f.ctx, { ...options, makePicker: () => assert.fail('no session before verification') }), /recovery refused/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('occupancy reader is scoped to exact numeric vehicle and readonly evidence transport', async () => {
|
||||||
|
const env = { JENKINS_URL: 'https://test.invalid/', JENKINS_USER: 'reader', JENKINS_API_TOKEN: 'secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' }
|
||||||
|
const result = await readReviewedVehicleOccupation(env, '123', async (url, init) => {
|
||||||
|
assert.equal(url.pathname, '/scriptText'); assert.equal(init.method, 'POST'); assert.ok(init.body.get('script').includes('executeGroovy'))
|
||||||
|
return new Response('FBA_CORE_JSON={"ok":true,"rows":[{"vehicleId":"123","assignmentCount":0}]}')
|
||||||
|
})
|
||||||
|
assert.deepEqual(result, { vehicleId: '123', assignmentCount: 0 })
|
||||||
|
await assert.rejects(readReviewedVehicleOccupation(env, '123; DELETE', () => assert.fail('invalid identity')), /numeric/)
|
||||||
|
})
|
||||||
|
|
||||||
function afterCostsRecoveryFixture() {
|
function afterCostsRecoveryFixture() {
|
||||||
const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture
|
const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture
|
||||||
report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER'
|
report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER'
|
||||||
@@ -156,7 +201,7 @@ test('changed source report SHA blocks recovery before creating context or loggi
|
|||||||
rmSync(temporary, { recursive: true, force: true })
|
rmSync(temporary, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs'
|
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation } from './fleet-test-env-acceptance.mjs'
|
||||||
|
|
||||||
for (const response of [
|
for (const response of [
|
||||||
{ http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' },
|
{ http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' },
|
||||||
|
|||||||
Reference in New Issue
Block a user