813 lines
65 KiB
JavaScript
813 lines
65 KiB
JavaScript
import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs'
|
|
import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
|
import { redactEvidence } from './fba-core-regression-test-env.mjs'
|
|
import { createTestEvidenceReaders } from './fba-core-regression-evidence.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 { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs'
|
|
import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs'
|
|
import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs'
|
|
import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs'
|
|
import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs'
|
|
import { verifyFleetReturnRedispatch } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs'
|
|
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
|
|
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const MODULE = 'fleet-specialized-acceptance'
|
|
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
|
const id = value => value == null ? '' : String(value)
|
|
const success = response => response?.http === 200 && id(response?.body?.code) === '200'
|
|
const key = () => `fleet-${randomUUID()}`
|
|
|
|
/** Install before login/prerequisites. A caught unknown write must not permit another request. */
|
|
export function guardFleetWrites(context, checkpoint, { guardFetch = false } = {}) {
|
|
let stopped = null, sequence = 0
|
|
const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch
|
|
const direct = { async requestWithBody(method, path, body, token, headers, request) {
|
|
const nativeResponse = await originalFetch(request), copy = nativeResponse.clone()
|
|
const bytes = new Uint8Array(await copy.arrayBuffer()), contentType = nativeResponse.headers.get('content-type') || ''
|
|
const content = /json|text|xml|html/i.test(contentType) ? new TextDecoder().decode(bytes) : ''
|
|
let payload = null
|
|
try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic only. */ }
|
|
return { nativeResponse, http: nativeResponse.status, body: payload, bytes, contentType, raw: payload || !content ? null : content.slice(0, 500),
|
|
requestId: payload?.requestId || nativeResponse.headers.get('x-request-id'),
|
|
traceId: payload?.traceId || nativeResponse.headers.get('x-trace-id') }
|
|
} }
|
|
const targets = ['request', 'requestWithBody', 'requestBinary'].map(name => [context, name])
|
|
if (guardFetch) targets.push([direct, 'requestWithBody'])
|
|
for (const [owner, name] of targets) {
|
|
const original = owner[name]
|
|
if (typeof original !== 'function') continue
|
|
owner[name] = async function (method, path, body, token, headers) {
|
|
if (stopped) throw stopped
|
|
if (['GET', 'HEAD', 'OPTIONS'].includes(String(method).toUpperCase()))
|
|
return scope.run(true, () => original.apply(this, arguments))
|
|
const entry = { sequence: ++sequence, method, path, status: 'SENDING', startedAt: new Date().toISOString(),
|
|
headers, body }
|
|
let session = owner === direct ? String(headers?.authorization || '').replace(/^Bearer\s+/i, '')
|
|
: token === undefined ? this.token : token
|
|
if (owner !== direct && this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) session = this.pdaToken
|
|
entry.actor = session && session === context.pdaToken ? { kind: 'PDA', source: 'CONTEXT_PDA_SESSION' }
|
|
: session && session === context.token && context.options?.username ? { kind: 'PLATFORM', source: 'CONTEXT_ADMIN_SESSION' }
|
|
: { kind: 'UNKNOWN', source: session ? 'UNRESOLVED_SESSION' : 'NO_SESSION' }
|
|
// Multipart delegates to requestWithBody, so it is journaled exactly once, after construction.
|
|
if (body instanceof FormData) {
|
|
entry.body = { multipart: [] }
|
|
for (const [field, value] of body.entries()) entry.body.multipart.push(typeof value === 'string'
|
|
? { field, value } : { field, name: value.name, type: value.type,
|
|
base64: Buffer.from(await value.arrayBuffer()).toString('base64') })
|
|
}
|
|
try { await checkpoint(entry) } catch (error) { stopped = error; throw error }
|
|
try {
|
|
const response = await scope.run(true, () => original.apply(this, arguments))
|
|
const http = response?.http, payload = response?.body, code = id(payload?.code)
|
|
const expectsPdf = name === 'requestBinary' || owner === direct && /(?:^|,)\s*application\/pdf(?:\s*[;,]|$)/i.test(headers?.accept || '')
|
|
const binary = (name === 'requestBinary' || owner === direct) && response?.bytes instanceof Uint8Array
|
|
const pdf = expectsPdf && binary && http === 200 && /^application\/pdf(?:\s*;|$)/i.test(response.contentType || '')
|
|
&& response.bytes.length > 4 && Buffer.from(response.bytes.subarray(0, 5)).toString('ascii') === '%PDF-'
|
|
const definite = Number.isInteger(http) && http >= 200 && http < 500
|
|
&& response.requestId && response.traceId
|
|
&& (pdf || payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
&& (!expectsPdf && http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400)))
|
|
Object.assign(entry, { status: definite ? 'RESPONDED' : 'UNKNOWN_RESULT', http, code,
|
|
requestId: response?.requestId, traceId: response?.traceId, raw: boundedFleetDiagnostic(response?.raw),
|
|
responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() })
|
|
if (binary) entry.binary = { contentType: response.contentType, bytes: response.bytes.length,
|
|
sha256: createHash('sha256').update(response.bytes).digest('hex'), validPdf: Boolean(pdf) }
|
|
if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`)
|
|
await checkpoint(entry)
|
|
if (stopped) throw stopped
|
|
return response
|
|
} catch (error) {
|
|
stopped ||= Error(boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) || 'Fleet write failed')
|
|
if (entry.status === 'SENDING') {
|
|
entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString()
|
|
entry.raw = boundedFleetDiagnostic(error instanceof Error ? error.message : String(error))
|
|
await checkpoint(entry)
|
|
}
|
|
throw stopped
|
|
}
|
|
}
|
|
}
|
|
if (guardFetch) globalThis.fetch = async (input, init) => {
|
|
if (scope.getStore()) return originalFetch(input, init)
|
|
if (stopped) throw stopped
|
|
const request = new Request(input, init), url = new URL(request.url)
|
|
if (url.origin !== new URL(context.options.gateway).origin) return originalFetch(input, init)
|
|
if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return originalFetch(input, init)
|
|
const type = request.headers.get('content-type') || ''
|
|
let body
|
|
if (request.body) body = type.includes('multipart/form-data') ? await request.clone().formData()
|
|
: type.includes('application/json') ? await request.clone().json() : await request.clone().text()
|
|
const result = await direct.requestWithBody(request.method, url.pathname + url.search, body, undefined,
|
|
Object.fromEntries(request.headers), request)
|
|
return result.nativeResponse
|
|
}
|
|
return () => { if (guardFetch) globalThis.fetch = originalFetch }
|
|
}
|
|
|
|
function boundedFleetDiagnostic(value) {
|
|
return typeof value === 'string' ? value.replace(/Bearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]')
|
|
.replace(/((?:password|accessToken|refreshToken|authorization)["']?\s*[=:]\s*["']?)[^\s,;"'<>]+/gi, '$1[REDACTED]')
|
|
.replace(/https?:\/\/[^\s"'<>]+/gi, '[REDACTED_URL]').slice(0, 500) : null
|
|
}
|
|
|
|
export function redactFleetWrite(value, secrets = []) {
|
|
const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|api.?key|secret|token$|^(?:loginId|username)$/i.test(name)
|
|
if (Array.isArray(value)) return value.map(item => redactFleetWrite(item, secrets))
|
|
if (value && typeof value === 'object' && typeof value.field === 'string' && sensitive(value.field))
|
|
return { field: value.field, value: '[REDACTED]' }
|
|
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, item]) => [name,
|
|
sensitive(name)
|
|
? '[REDACTED]' : redactFleetWrite(item, secrets)]))
|
|
if (typeof value === 'string') return secrets.filter(secret => typeof secret === 'string' && secret.length)
|
|
.reduce((text, secret) => text.split(secret).join('[REDACTED]'), value)
|
|
return value
|
|
}
|
|
|
|
export function parseFleetArguments(args) {
|
|
const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'handover' }
|
|
let action = null
|
|
const seen = new Set()
|
|
for (let index = 0; index < args.length; index++) {
|
|
const name = args[index]
|
|
if (['--plan', '--execute'].includes(name)) {
|
|
if (action) throw Error('Choose exactly one plan or execute action')
|
|
action = name; config.execute = name === '--execute'; continue
|
|
}
|
|
if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--resume-sha256', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument')
|
|
if (seen.has(name)) throw Error('Duplicate argument')
|
|
seen.add(name)
|
|
const value = args[++index]
|
|
if (name === '--environment') config.environment = value
|
|
else if (name === '--run-id') config.runId = value
|
|
else if (name === '--output') config.outputDir = value
|
|
else if (name === '--resume-report') config.resumeReport = value
|
|
else if (name === '--resume-sha256') config.resumeSha256 = value
|
|
else if (name === '--scenario') config.scenario = value
|
|
else config.resumeStage = value
|
|
}
|
|
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')
|
|
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'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
|
|
if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256)
|
|
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 (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport))
|
|
throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint')
|
|
return config
|
|
}
|
|
export function fleetExecutionPlan(config) {
|
|
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',
|
|
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',
|
|
resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null,
|
|
stages: config.resumeStage === 'DEPARTED_BEFORE_COSTS'
|
|
? ['Verify reviewed report SHA256', 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries',
|
|
'Create fresh bounded cost/PDA sessions', 'Original trip operating costs with actual allocation availability',
|
|
'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',
|
|
'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking',
|
|
'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint',
|
|
'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'],
|
|
boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.',
|
|
limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' }
|
|
}
|
|
|
|
export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
|
|
const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0]
|
|
const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo)
|
|
if (stage === 'DEPARTED_BEFORE_COSTS') {
|
|
const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false)
|
|
if (report?.success !== false || proof?.stage !== 'TRIP_OPERATING_COSTS' || proof.status !== 'CHECKING'
|
|
|| proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo
|
|
|| !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash
|
|
|| costs.status !== 'FAILED' || !Array.isArray(costs.costs) || costs.costs.length || !costs.requests?.length
|
|
|| costs.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200')
|
|
|| trip.handover || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch
|
|
|| resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId
|
|
|| failures?.length !== 2 || !failures.some(row => row.name === '独立预期覆盖全部实物且没有重叠托盘')
|
|
|| failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].includes(row.name)))
|
|
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 }
|
|
}
|
|
if (stage === 'TERMINAL_READONLY') {
|
|
const failures = report?.steps?.filter(row => row.passed === false)
|
|
if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE'
|
|
|| trip?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|
|
|| proof.closure?.status !== trip.closure.status || trip.externalReceipt?.status !== 'VERIFIED'
|
|
|| trip.externalDispatch?.status !== 'VERIFIED' || trip.reassignment?.status !== 'VERIFIED'
|
|
|| !proof.receipts?.length || proof.finance?.status !== 'CONSUMER_PERSISTED'
|
|
|| !failures?.length || failures.some(row => !['本次车次货物结案及车辆司机释放完成', '模块执行'].includes(row.name)))
|
|
throw Error('Terminal reconciliation requires completed original business proofs and only the known aggregate assertion failure')
|
|
return { resources, marker: proof.marker, terminal: true }
|
|
}
|
|
if (stage === 'EXTERNAL_PALLETIZED' || stage === 'EXTERNAL_RECEIPT_CONFIRMED') {
|
|
const confirmed = stage === 'EXTERNAL_RECEIPT_CONFIRMED'
|
|
const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL'), receipt = trip?.externalReceipt
|
|
if (report?.success !== false || proof?.stage !== 'EXTERNAL_RECEIPT' || proof.status !== 'CHECKING'
|
|
|| full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo
|
|
|| trip.reservation?.status !== 'VERIFIED' || receipt?.status !== 'CHECKING' || trip.externalDispatch
|
|
|| (confirmed ? (!receipt.receiptRequest?.idempotencyKey || !receipt.requests?.some(row => row.label === '运营按实际1托位和新单价30确认实收' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) : receipt.receiptRequest)
|
|
|| !receipt.palletizeRequest?.idempotencyKey || !receipt.warehousePalletId || !proof.picker?.membershipId || !proof.pickerMarker
|
|
|| !receipt.requests?.some(row => row.label === 'PDA正式完成外接收货及库存入账' && row.method === 'POST' && row.http === 200 && String(row.code) === '200'))
|
|
throw Error('Palletized checkpoint requires the exact successful PDA command and no subsequent receipt confirmation')
|
|
return { resources, marker: proof.marker, receiptReady: true, palletized: !confirmed, receiptConfirmed: confirmed }
|
|
}
|
|
if (stage === 'EXTERNAL_RECEIPT_READY') {
|
|
const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL')
|
|
if (report?.success !== false || proof?.stage !== 'RESERVATIONS' || proof.status !== 'CHECKING'
|
|
|| full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo
|
|
|| trip.reservation?.status !== 'VERIFIED' || trip.externalReceipt || trip.externalDispatch || proof.picker
|
|
|| !report.steps?.some(row => row.name === '创建海外PDA仓库操作员71' && row.code === 'A0409' && row.message === '手机号已存在'))
|
|
throw Error('Receipt-ready checkpoint requires verified coverage/reservations and an explicitly rejected operator creation with no receipt writes')
|
|
return { resources, marker: proof.marker, receiptReady: true }
|
|
}
|
|
if (report?.success !== false || proof?.stage !== 'FULL_COVERAGE' || proof.status !== 'CHECKING' || full?.mode !== 'FULL'
|
|
|| full.marker !== proof.marker || full.dispatchNo !== trip?.dispatchNo || full.proof?.status !== 'CHECKING'
|
|
|| !full.proof.requests?.length
|
|
|| resources.fleetFeeCoverageCases.length !== 1 || !proof.originalDispatch || proof.originalDispatch.dispatchNo !== full.dispatchNo)
|
|
throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation')
|
|
const persisted = stage === 'FULL_COVERAGE_PERSISTED'
|
|
if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey
|
|
|| !full.proof.requests.some(row => row.label === '正式保存本次原费用服务覆盖并转派送' && row.method === 'POST' && row.http === 200 && String(row.code) === '200'))
|
|
: (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo))
|
|
throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation')
|
|
return { resources, marker: proof.marker, persisted }
|
|
}
|
|
|
|
export async function verifyFleetTerminalState(context) {
|
|
const proof = context.createdResources.fleetAcceptance
|
|
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
|
|
const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
|
|
const evidence = proof.terminalReadback = { status: 'CHECKING', requests: [], deliveries: [], dispatches: [] }
|
|
function check(label, passed, response) {
|
|
context.assert(MODULE, label, response, Boolean(passed), '', OPTIONS)
|
|
if (!passed) throw Error(label)
|
|
}
|
|
async function get(path) {
|
|
const result = await context.request('GET', path)
|
|
evidence.requests.push({ path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId })
|
|
check('终态只读回查成功', success(result) && result.requestId && result.traceId, result)
|
|
return result.body.data
|
|
}
|
|
check('结案助手已完成货物与资源核对', trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
|
const current = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`)
|
|
check('当前车次已完成且两项资源结束事实与原证明一致', current.trip?.status === 'COMPLETED'
|
|
&& current.trip.completedAt === trip.closure.finalTrip.completedAt
|
|
&& id(current.trip.recordVersion) === id(trip.closure.finalTrip.recordVersion)
|
|
&& JSON.stringify(current.trip.vehicleRelease) === JSON.stringify(trip.closure.finalTrip.vehicleRelease)
|
|
&& JSON.stringify(current.trip.driverRelease) === JSON.stringify(trip.closure.finalTrip.driverRelease)
|
|
&& current.trip.vehicleRelease && current.trip.driverRelease)
|
|
evidence.trip = current.trip
|
|
const expectedDispatches = [partial.dispatchNo, trip.externalDispatch.dispatchNo].sort()
|
|
check('终态车次仍关联全部原执行单', JSON.stringify(current.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify(expectedDispatches))
|
|
for (const number of expectedDispatches) {
|
|
const dispatch = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`)
|
|
check('原执行单已发车且拣货实发数量守恒', dispatch.dispatchNo === number && dispatch.status === 'DEPARTED'
|
|
&& Number(dispatch.packageCount) === Number(dispatch.shippedPackageCount)
|
|
&& Number(dispatch.pickedPackageCount) === Number(dispatch.shippedPackageCount))
|
|
evidence.dispatches.push(dispatch)
|
|
}
|
|
check('整车最终箱数与原一次发车事实一致', evidence.dispatches.reduce((n, row) => n + Number(row.shippedPackageCount), 0) === Number(proof.departure.shippedPackages))
|
|
const expectedDeliveries = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo].sort()
|
|
check('签收证明覆盖全部目的地派送', JSON.stringify(proof.receipts.map(row => row.deliveryNo).sort()) === JSON.stringify(expectedDeliveries))
|
|
for (const number of expectedDeliveries) {
|
|
const delivery = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`)
|
|
check('每张原派送保留完成及签收时间', delivery.order?.deliveryNo === number && delivery.order.status === 'DELIVERED'
|
|
&& delivery.order.signedAt === proof.receipts.find(row => row.deliveryNo === number).signedAt)
|
|
evidence.deliveries.push(delivery.order)
|
|
}
|
|
for (const probe of trip.closure.probes) {
|
|
const detail = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(probe.tripNo)}`)
|
|
check('资源占用测试计划均已正式取消', detail.trip?.status === 'CANCELLED')
|
|
}
|
|
const pool = await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(proof.customerId)}&direction=RECEIVABLE`)
|
|
const fees = pool.filter(row => id(row.sourceId) === id(proof.finance.source.sourceFeeId) && Number(row.sourceVersion) === Number(proof.finance.source.sourceVersion))
|
|
check('原外接应收仍唯一且金额正确', fees.length === 1 && fees[0].amount === 30 && fees[0].currency === 'CNY'
|
|
&& id(fees[0].customerId) === id(proof.customerId) && fees[0].sourceType === 'OVERSEAS_DELIVERY_FEE')
|
|
evidence.fees = fees; evidence.status = 'VERIFIED'
|
|
proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; proof.stage = 'FINISHED'
|
|
return proof
|
|
}
|
|
|
|
/** All business checks happen before creating replacement test sessions or issuing a business command. */
|
|
export async function verifyDepartedRecovery(context) {
|
|
const proof = context.createdResources.fleetAcceptance
|
|
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
|
|
const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad
|
|
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`
|
|
const evidence = { status: 'CHECKING', tripNo: trip.tripNo, requests: [], dispatchDetails: [], deliveries: [] }
|
|
context.createdResources.fleetDepartureRecoveryVerification = evidence
|
|
const check = (ok, message) => { if (!ok) throw Error(`Departed recovery refused: ${message}`) }
|
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
async function get(path) {
|
|
const r = await context.request('GET', path)
|
|
evidence.requests.push({ path, http: r.http, code: r.body?.code, requestId: r.requestId, traceId: r.traceId })
|
|
check(success(r) && r.requestId && r.traceId, 'read-only response not definitive')
|
|
return r.body.data
|
|
}
|
|
const current = await get(base)
|
|
check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(snapshot.trip.recordVersion)
|
|
&& current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId)
|
|
&& id(current.trip.vehicleId) === id(snapshot.trip.vehicleId) && id(current.trip.driverId) === id(snapshot.trip.driverId)
|
|
&& !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed')
|
|
const history = await get(`${base}/history?page=1&size=100`)
|
|
const departures = history.records?.filter(row => row.action === 'DEPART')
|
|
check(Array.isArray(history.records) && Number(history.total) === history.records.length
|
|
&& departures?.length === 1 && same(departures[0], original)
|
|
&& history.records.every(row => BigInt(row.recordVersion) <= BigInt(original.recordVersion)), 'immutable DEPART/history changed')
|
|
const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`)
|
|
check(costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0
|
|
&& costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed')
|
|
check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists')
|
|
check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort())
|
|
&& current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed')
|
|
for (const number of proof.dispatchNos) {
|
|
const detail = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`)
|
|
const cargo = load.cargo.filter(row => row.dispatchNo === number)
|
|
const facts = rows => rows.map(row => [id(row.id ?? row.dispatchItemId), id(row.warehousePalletId), Number(row.packageCount ?? row.packages)])
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
check(detail.dispatchNo === number && detail.status === 'DEPARTED' && id(detail.overseasWarehouseId) === id(proof.warehouseId)
|
|
&& Number(detail.packageCount) === Number(detail.shippedPackageCount) && Number(detail.pickedPackageCount) === Number(detail.shippedPackageCount)
|
|
&& same(facts(detail.items), facts(cargo)), 'dispatch shipped cargo changed')
|
|
evidence.dispatchDetails.push(detail)
|
|
}
|
|
const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
|
|
const deliveryNos = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo]
|
|
check(new Set(deliveryNos).size === deliveryNos.length, 'duplicate delivery identities')
|
|
for (const number of deliveryNos) {
|
|
const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`)
|
|
const order = detail.order
|
|
check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo)
|
|
&& order.status === 'WAIT_DELIVERY' && id(order.version) === '1' && !order.signedAt && !Number(order.signedPackages || 0)
|
|
&& !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed')
|
|
evidence.deliveries.push(order)
|
|
}
|
|
check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch')
|
|
Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash,
|
|
departure: departures[0], costSummary: costs.summary })
|
|
await context.checkpoint()
|
|
return { trip, dispatchDetails: evidence.dispatchDetails }
|
|
}
|
|
|
|
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) {
|
|
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails)
|
|
const run = context.createdResources.fleetAcceptance
|
|
if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo
|
|
|| run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser
|
|
|| trip.costAcceptance?.status !== 'VERIFIED' || trip.costAcceptance.marker !== context.marker)
|
|
throw Error('Only the current verified DEPART and handover can publish a shared fixture')
|
|
const fixture = { marker: context.marker, runId: context.context?.fleetRunId, tripNo: trip.tripNo, warehouseId: trip.warehouseId, dispatchNos: state.dispatchNos,
|
|
departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo,
|
|
originalResources: state.previous, replacementResources: state.replacement, browser: state.browser,
|
|
costs: trip.costAcceptance,
|
|
allocationStatus: trip.costAcceptance.expected?.allocationStatus,
|
|
allocationBlockReason: trip.costAcceptance.expected?.allocationBlockReason,
|
|
status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount }
|
|
context.context?.saveFleetHandoverFixture?.(fixture)
|
|
if (onReady) await onReady(fixture)
|
|
return fixture
|
|
}
|
|
|
|
export function fleetCostShares(trip, dispatchDetails) {
|
|
const external = trip.externalReceipt, externalDispatch = trip.externalDispatch
|
|
if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId || !externalDispatch?.dispatchNo)
|
|
throw Error('Cost expectations require this run\'s verified external receipt')
|
|
const shares = dispatchDetails.map(row => {
|
|
const pallets = [...new Set((row.items || []).map(item => id(item.warehousePalletId)))]
|
|
if (!row.dispatchNo || !pallets.length || pallets.some(value => !value)) throw Error('Cost expectations require actual dispatch pallets')
|
|
const isExternal = row.dispatchNo === externalDispatch.dispatchNo
|
|
if (isExternal && (pallets.length !== 1 || pallets[0] !== id(external.warehousePalletId))) throw Error('External cost source does not match actual receipt')
|
|
// The fixture's explicit measurement rule is one space per physical pallet; never derive this from cost totals.
|
|
return { businessNo: isExternal ? external.bookingNo : row.dispatchNo, sourceType: isExternal ? 'EXTERNAL' : 'OWN',
|
|
dispatchNo: row.dispatchNo, warehousePalletIds: pallets, palletSpaces: pallets.length }
|
|
})
|
|
const pallets = shares.flatMap(row => row.warehousePalletIds)
|
|
if (shares.filter(row => row.sourceType === 'EXTERNAL').length !== 1 || !shares.some(row => row.sourceType === 'OWN')
|
|
|| new Set(pallets).size !== pallets.length) throw Error('Cost fixture requires distinct own and external physical pallets')
|
|
return shares
|
|
}
|
|
|
|
export async function prepareFleetCostActors(context) {
|
|
if (!context.marker || context.createdResources.fleetCostActors || typeof context.checkpoint !== 'function')
|
|
throw Error('Cost actors require fresh current-run ownership and durable checkpoints')
|
|
const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CREATING', actors: [], requests: [] }
|
|
const base = '/api/platform/v1/iam', sessions = []
|
|
async function request(method, path, body, token) {
|
|
const entry = { method, path, body: redactEvidence(body), status: 'SENDING' }
|
|
proof.requests.push(entry); await context.checkpoint()
|
|
try {
|
|
const response = await context.request(method, path, body, token)
|
|
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code,
|
|
requestId: response?.requestId, traceId: response?.traceId, result: redactEvidence(response?.body?.data) })
|
|
await context.checkpoint()
|
|
if (!success(response) || !response.requestId || !response.traceId) throw Error(`Cost actor request failed: ${method} ${path}`)
|
|
return response.body.data
|
|
} catch (error) {
|
|
if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() }
|
|
throw error
|
|
}
|
|
}
|
|
async function close() {
|
|
const errors = []
|
|
for (const actor of proof.actors) {
|
|
try {
|
|
if (actor.membershipId && !actor.userDisabled) {
|
|
const result = await request('PUT', `${base}/users/${actor.membershipId}/status`, { status: 'DISABLED' })
|
|
if (result?.status !== 'DISABLED') throw Error('Cost test account was not disabled')
|
|
actor.userDisabled = true
|
|
}
|
|
if (actor.roleId && !actor.roleDisabled) {
|
|
const result = await request('PUT', `${base}/roles/${actor.roleId}/status`, { status: 'DISABLED' })
|
|
if (result?.status !== 'DISABLED') throw Error('Cost test role was not disabled')
|
|
actor.roleDisabled = true
|
|
}
|
|
} catch (error) { errors.push(error) }
|
|
}
|
|
proof.status = errors.length ? 'CLEANUP_FAILED' : 'DISABLED'; await context.checkpoint()
|
|
if (errors.length) throw new AggregateError(errors, 'Cost actor cleanup requires review; do not recreate accounts')
|
|
}
|
|
try {
|
|
const permissions = await request('GET', `${base}/permissions`)
|
|
const list = permissions.filter(row => row.permissionCode === 'platform:fleet:trip:list' && row.status === 'ACTIVE')
|
|
if (list.length !== 1 || !list[0].id) throw Error('Active trip list permission missing or ambiguous')
|
|
for (const kind of ['reader', 'denied']) {
|
|
const suffix = randomUUID().replaceAll('-', '').slice(0, 16), username = `fleet_${kind}_${suffix}`
|
|
const actor = { kind, username }; proof.actors.push(actor)
|
|
const roleCode = `FLEET_${kind.toUpperCase()}_${suffix.toUpperCase()}`
|
|
const role = await request('POST', `${base}/roles`, { roleCode, roleName: `${context.marker}-${kind}`, description: context.marker })
|
|
if (!id(role?.id) || role.roleCode !== roleCode || role.roleType !== 'CUSTOM') throw Error('Fresh cost role ownership mismatch')
|
|
actor.roleId = id(role.id)
|
|
await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] })
|
|
const password = `Fleet${randomUUID().replaceAll('-', '')}A9`
|
|
const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`,
|
|
phone: await context.findAvailablePhone(MODULE), email: `${username}@example.com`, tenantId: '0',
|
|
roleIds: [actor.roleId], password, status: 'ACTIVE' })
|
|
if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0'
|
|
|| user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch')
|
|
actor.membershipId = id(user.membershipId)
|
|
const login = await request('POST', '/api/auth/login', { loginId: username, password }, '')
|
|
if (!login?.accessToken || login.accessToken === context.token || sessions.includes(login.accessToken)) throw Error('Independent cost session missing')
|
|
sessions.push(login.accessToken)
|
|
const actual = await request('GET', `${base}/authorization/permissions`, undefined, login.accessToken)
|
|
const expected = kind === 'reader' ? ['platform:fleet:trip:list'] : []
|
|
if (!Array.isArray(actual) || JSON.stringify([...actual].sort()) !== JSON.stringify(expected)) throw Error('Cost actor effective permissions differ from exact scope')
|
|
}
|
|
proof.status = 'READY'; await context.checkpoint()
|
|
return { marker: context.marker, listOnlyToken: sessions[0], deniedToken: sessions[1], close }
|
|
} catch (error) {
|
|
proof.status = 'FAILED'; await context.checkpoint()
|
|
try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Cost actor creation and cleanup failed') }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady,
|
|
costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) {
|
|
if (terminalReadOnly) return verifyFleetTerminalState(context)
|
|
if (costActors?.marker !== context.marker || !costActors.listOnlyToken || !costActors.deniedToken
|
|
|| costActors.listOnlyToken === costActors.deniedToken || [costActors.listOnlyToken, costActors.deniedToken].includes(context.token))
|
|
throw Error('Prepare two independent current-run platform cost permission sessions before starting the fleet scenario')
|
|
const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance
|
|
: context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] }
|
|
function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) }
|
|
function check(label, value, response) { context.assert(MODULE, label, response, Boolean(value), '', OPTIONS); if (!value) throw Error(label) }
|
|
async function request(method, path, body, label, token) {
|
|
const response = await context.request(method, path, body, token)
|
|
check(label, success(response), response); return response.body.data
|
|
}
|
|
const source = context.createdResources.phase4OverseasDispatch, receiving = context.createdResources.phase4OverseasPda
|
|
check('专项只承接本轮实际已收货及预排资源', source?.marker === context.marker && source.dispatchNo
|
|
&& receiving?.marker === context.marker && receiving.finalStatus === 'COMPLETED' && receiving.customerId && receiving.overseasWarehouseId)
|
|
const customerId = id(receiving.customerId), warehouseId = id(receiving.overseasWarehouseId)
|
|
proof.customerId = customerId; proof.warehouseId = warehouseId
|
|
const dispatchBase = '/api/platform/v1/last-leg/dispatches'
|
|
let trip, partial
|
|
if (resumeReceiptReady) {
|
|
trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
|
|
partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
|
|
const current = await request('GET', `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`, undefined, '恢复实收前核对原车次分配与客户执行单')
|
|
const dispatch = await request('GET', `${dispatchBase}/${encodeURIComponent(partial.dispatchNo)}`, undefined, '恢复实收前核对原排车尚未拣货或发车')
|
|
check('仍是原已核对车辆司机与未执行客户排车', current.trip?.status === 'PLANNED'
|
|
&& id(current.trip.vehicleId) === id(trip.vehicleId) && id(current.trip.driverId) === id(trip.driverId)
|
|
&& current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === partial.dispatchNo
|
|
&& id(current.dispatches[0].customerId) === customerId && dispatch.dispatchNo === partial.dispatchNo
|
|
&& Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
|
|
} else {
|
|
const original = await request('GET', `${dispatchBase}/${encodeURIComponent(source.dispatchNo)}`, undefined, '读取本轮原始预排实物')
|
|
if (resumeFull) check('原样本恢复前排车货物未变且未开始作业', JSON.stringify(original.items) === JSON.stringify(proof.originalDispatch.items)
|
|
&& Number(original.pickedPackageCount) === 0 && Number(original.shippedPackageCount) === 0)
|
|
proof.originalDispatch = original
|
|
trip = resumeFull ? context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
|
|
: await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId)
|
|
proof.tripNo = trip.tripNo
|
|
const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`
|
|
function transferBody(vehicle) { return { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19),
|
|
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托',
|
|
vehiclePlate: vehicle.vehiclePlate, driverName: vehicle.driverName, driverPhone: vehicle.driverPhone, appointmentAccount: 'SELF',
|
|
remark: `${context.marker} 原费用覆盖专用验证`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖货物派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } }
|
|
let detail = await request('GET', tripPath, undefined, '读取当前派车事实供转派送使用')
|
|
check('当前车次资源及原客户排车关联仍一致', id(detail.trip.vehicleId) === id(trip.vehicleId)
|
|
&& id(detail.trip.driverId) === id(trip.driverId) && detail.dispatches.some(row => row.dispatchNo === source.dispatchNo && id(row.customerId) === customerId))
|
|
if (!resumeFull) context.createdResources.fleetFeeCoverageCases = []
|
|
const full = resumeFull ? context.createdResources.fleetFeeCoverageCases[0]
|
|
: { marker: context.marker, mode: 'FULL', dispatchNo: source.dispatchNo, customerId, transferBody: transferBody(detail.trip) }
|
|
if (resumeFull && !resumePersisted) {
|
|
full.previousReadOnlyAttempts ||= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof
|
|
} else if (!resumeFull) context.createdResources.fleetFeeCoverageCases.push(full)
|
|
stage('FULL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted })
|
|
stage('CANCEL_FULL_AND_REARRANGE')
|
|
detail = await request('GET', tripPath, undefined, '读取取消未执行全覆盖用例前车次版本')
|
|
await request('POST', `${tripPath}/dispatches/unbind`, { recordVersion: detail.trip.recordVersion, dispatchNo: full.dispatchNo,
|
|
reason: '全覆盖用例验证完毕,正式取消未执行发运并以新排车验证部分覆盖' }, '先解除未执行全覆盖排车关联')
|
|
await request('POST', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(full.proof.outboundNo)}/cancel`,
|
|
{ reason: '本轮全覆盖验证完毕,正式取消未执行出库后重新安排' }, '取消未执行全覆盖出库')
|
|
await request('DELETE', `${dispatchBase}/${encodeURIComponent(full.dispatchNo)}`, undefined, '正式取消原排车释放其托盘锁')
|
|
const draftToken = key(), planPalletIds = original.items.map(row => id(row.planPalletId))
|
|
check('原排车保留可追溯计划托盘', planPalletIds.length > 0 && planPalletIds.every(value => /^[1-9]\d*$/.test(value)))
|
|
proof.rearrange = { originalDispatchNo: full.dispatchNo, draftToken, planPalletIds }
|
|
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 }, '建立同批当前库存的新排车草稿')
|
|
for (const planPalletId of planPalletIds) await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId, draftToken, draftContainerId: id(container.id) }, '锁定已正式释放的本轮托盘')
|
|
const arranged = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds, draftToken }, '以新排车重新安排本轮同批货物')
|
|
check('取消重排产生新排车且货量不变', arranged.dispatchNo && arranged.dispatchNo !== full.dispatchNo
|
|
&& Number(arranged.packageCount) === Number(original.packageCount))
|
|
proof.rearrange.dispatchNo = arranged.dispatchNo
|
|
detail = await request('GET', tripPath, undefined, '读取原车次当前可关联版本')
|
|
detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, dispatchNo: arranged.dispatchNo,
|
|
reason: '本轮取消后新排车重新关联原计划车次' }, '将新排车正式关联原车次')
|
|
trip.replacedDispatchNo = trip.dispatchNo; trip.dispatchNo = arranged.dispatchNo
|
|
partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: arranged.dispatchNo, customerId, transferBody: transferBody(detail.trip) }
|
|
context.createdResources.fleetFeeCoverageCases.push(partial)
|
|
stage('PARTIAL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, partial)
|
|
stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip, controlledPalletMeasurements)
|
|
}
|
|
const access = await resolvePickerAccess(context)
|
|
let picker = null
|
|
try {
|
|
if (resumePalletized || resumeReceiptConfirmed) {
|
|
const suffix = proof.pickerMarker.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase()
|
|
const username = `pda_overseas_${suffix.toLowerCase()}_71`
|
|
check('仅复用本轮创建的临时PDA身份', proof.picker.username === username && access?.role?.id)
|
|
const users = await request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '200', keyword: username })}`, undefined, '核对原临时PDA账号绑定及角色')
|
|
const user = users?.records?.find(row => id(row.membershipId) === id(proof.picker.membershipId) && row.username === username)
|
|
check('原PDA仍仅有本仓作业员权限且不是受保护账号', user && id(user.tenantId) === '0' && !user.protectedAccount
|
|
&& id(user.overseasWarehouseId) === warehouseId && user.roleIds?.length === 1 && id(user.roleIds[0]) === id(access.role.id))
|
|
picker = { membershipId: user.membershipId, username, token: null }
|
|
const active = await request('PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, { status: 'ACTIVE' }, '为核对原PDA完成请求恢复本轮临时作业员')
|
|
check('原临时PDA已恢复启用', active?.status === 'ACTIVE')
|
|
const loggedIn = await request('POST', '/api/auth/login', { loginId: username, password: `Pda${suffix}A719` }, '以原生成凭据登录本轮临时PDA', '')
|
|
picker.token = loggedIn?.accessToken
|
|
} else {
|
|
const pickerContext = Object.create(context)
|
|
proof.pickerMarker ||= `${context.marker}-FPDA-${randomUUID().slice(0, 8)}`
|
|
pickerContext.marker = proof.pickerMarker
|
|
picker = access ? await createPicker(pickerContext, 71, warehouseId, access) : null
|
|
}
|
|
check('专项使用本轮绑定起运仓的PDA账号', picker?.token && picker.membershipId)
|
|
proof.picker = { membershipId: picker.membershipId, username: picker.username }
|
|
stage('EXTERNAL_RECEIPT'); await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, { reconcilePalletized: resumePalletized, reconcileConfirmed: resumeReceiptConfirmed })
|
|
stage('EXTERNAL_DISPATCH'); await verifyFleetExternalDispatch(context, MODULE, trip)
|
|
const external = trip.externalDispatch
|
|
stage('FINANCE_CONSUMER')
|
|
const sources = await request('GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId, businessType: 'DELIVERY_ORDER', businessNo: external.deliveryNo })}`,
|
|
undefined, '读取外接应收稳定来源编号和版本')
|
|
const target = sources?.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE')
|
|
check('外接应收来源只属于本次客户派送及实收', target?.length === 1 && target[0].businessNo === external.deliveryNo
|
|
&& id(target[0].customerId) === customerId && Number(target[0].originalAmount) === 30 && target[0].originalCurrency === 'CNY')
|
|
let matched = []
|
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
const pool = await request('GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`, undefined, '等待Finance消费本次外接费用')
|
|
check('Finance费用池为当前客户应收列表', Array.isArray(pool) && pool.every(row => id(row.customerId) === customerId && row.direction === 'RECEIVABLE'))
|
|
matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && Number(row.sourceVersion) === Number(target[0].sourceVersion))
|
|
if (matched.length) break
|
|
await wait(1000)
|
|
}
|
|
check('Finance真实消费且仅一笔30CNY外接应收', matched.length === 1 && matched[0].sourceType === 'OVERSEAS_DELIVERY_FEE'
|
|
&& Number(matched[0].amount) === 30 && matched[0].currency === 'CNY')
|
|
proof.finance = { status: 'CONSUMER_PERSISTED', source: target[0], pool: matched, producerAck: 'NOT_VERIFIED' }
|
|
external.financeAcknowledgement = 'CONSUMER_PERSISTED_PRODUCER_ACK_NOT_VERIFIED'
|
|
stage('PICK_EACH_DISPATCH')
|
|
const dispatchNos = [partial.dispatchNo, external.dispatchNo]
|
|
proof.dispatchNos = dispatchNos
|
|
const dispatchDetails = []
|
|
for (const [index, dispatchNo] of dispatchNos.entries()) {
|
|
const pickingContext = Object.create(context); pickingContext.marker = `${context.marker}-FLEET-PICK-${index}`
|
|
check('逐客户正式PDA拣货及完成成功', await pickAll(pickingContext, picker.token, dispatchNo))
|
|
dispatchDetails.push(await request('GET', `${dispatchBase}/${encodeURIComponent(dispatchNo)}`, undefined, '回查逐单拣货数量及未发车事实'))
|
|
}
|
|
check('每张客户排车实际拣足但尚未发车', dispatchDetails.every(row => row.status === 'READY_TO_DEPART'
|
|
&& Number(row.pickedPackageCount) === Number(row.packageCount) && Number(row.shippedPackageCount) === 0))
|
|
stage('WHOLE_TRIP_DEPARTURE')
|
|
const recordVersion = await confirmFleetLoading(context, MODULE, trip)
|
|
proof.departureRequest = { recordVersion, idempotencyKey: key(), remark: '本轮自有货与真实外接货统一发车' }
|
|
const departPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(partial.dispatchNo)}/depart`
|
|
const departed = await request('POST', departPath, proof.departureRequest, 'PDA一次命令发出全车两张客户执行单', picker.token)
|
|
check('整车发车包含所有排车且箱数守恒', departed?.tripNo === trip.tripNo && departed.status === 'IN_TRANSIT'
|
|
&& JSON.stringify(departed.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify([...dispatchNos].sort())
|
|
&& Number(departed.shippedPackages) === dispatchDetails.reduce((n, row) => n + Number(row.packageCount), 0))
|
|
const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token)
|
|
check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages))
|
|
proof.departure = departed
|
|
await completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady })
|
|
} finally { await disablePicker(context, picker, 71) }
|
|
return proof
|
|
}
|
|
|
|
export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady,
|
|
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
|
|
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
|
|
const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId
|
|
const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
|
|
const external = trip.externalDispatch, dispatchNos = proof.dispatchNos
|
|
const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) }
|
|
const check = (label, ok) => { if (!ok) throw Error(label) }
|
|
async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data }
|
|
stage('TRIP_OPERATING_COSTS')
|
|
await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
|
|
stage('IN_TRANSIT_HANDOVER')
|
|
await handover(context, trip, picker.token, dispatchDetails, {
|
|
onReady: onHandoverReady ? async fixture => {
|
|
stage('PDA_UI_CHECKPOINT')
|
|
await onHandoverReady({ fixture, picker })
|
|
} : undefined,
|
|
})
|
|
stage('RECEIPTS_AND_RESOURCE_RELEASE')
|
|
proof.receipts = []
|
|
if (context.context.fleetScenario === 'return-redispatch') {
|
|
stage('RETURN_REDISPATCH')
|
|
const returned = await verifyFleetReturnRedispatch(context, MODULE, trip, { customerId, warehouseId, pickerToken: picker.token,
|
|
dispatchNos, deliveryNos: [...partial.proof.deliveryNos, external.deliveryNo] })
|
|
proof.receipts = returned.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt }))
|
|
context.context.saveFleetReturnRedispatchFixture(returned)
|
|
} else {
|
|
for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) {
|
|
const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`,
|
|
undefined, '签收前读取本次实际派送')
|
|
const completed = await completeDelivery(context, delivery.order, customerId, index + 71)
|
|
check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt)
|
|
proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt })
|
|
}
|
|
}
|
|
proof.closure = await closeTrip(context, MODULE, trip)
|
|
check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
|
stage('HANDOVER_RESOURCE_CLEANUP')
|
|
await cleanup(context, MODULE, trip)
|
|
proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED')
|
|
return proof
|
|
}
|
|
|
|
export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors,
|
|
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
|
|
complete = completeDepartedFleetScenario } = {}) {
|
|
const verified = await verify(context)
|
|
const proof = context.createdResources.fleetAcceptance, trip = verified.trip
|
|
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
|
|
trip.previousReadOnlyCostAttempts = [trip.costAcceptance]
|
|
proof.previousPicker = proof.picker
|
|
delete context.createdResources.fleetCostActors
|
|
delete trip.costAcceptance
|
|
delete proof.picker
|
|
proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}`
|
|
await context.checkpoint()
|
|
let actors, picker
|
|
try {
|
|
actors = await prepareActors(context)
|
|
if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken
|
|
|| actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token))
|
|
throw Error('Recovery requires fresh independent cost sessions')
|
|
const pickerContext = Object.create(context); pickerContext.marker = proof.pickerMarker
|
|
const access = await resolveAccess(context)
|
|
picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null
|
|
if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session')
|
|
proof.picker = { membershipId: picker.membershipId, username: picker.username }
|
|
await context.checkpoint()
|
|
return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors })
|
|
} finally {
|
|
try { await closePicker(context, picker, 71) } finally { await actors?.close() }
|
|
}
|
|
}
|
|
|
|
export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) {
|
|
if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test'
|
|
|| basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required')
|
|
if (scenario === runFleetScenario && config.resumeStage !== 'TERMINAL_READONLY' && typeof prepareCostActors !== 'function')
|
|
throw Error('Current-run cost actor preparation and lifecycle must be provided before any environment requests')
|
|
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
|
|
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash
|
|
|| /(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname)) throw Error('Invalid TEST gateway')
|
|
for (const name of ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) if (!env[name]) throw Error(`Missing ${name}`)
|
|
if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Report scope invalid')
|
|
const output = resolve(root, config.outputDir || `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output)
|
|
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid')
|
|
mkdirSync(resolve(root, 'reports'), { recursive: true })
|
|
const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx')
|
|
let context, initialized = false, report, restoreFetch
|
|
const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(name)).map(([,value]) => value)
|
|
const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, [...secrets,
|
|
context?.token, context?.pdaToken, context?.refreshToken, context?.context?.merchantQualificationFixture?.password]), null, 2) + '\n')
|
|
try {
|
|
writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId, startedAt: new Date().toISOString() }))
|
|
mkdirSync(output, { recursive: false }); initialized = true; mkdirSync(join(output, 'evidence'))
|
|
save('manifest.json', fleetExecutionPlan(config))
|
|
let resumed
|
|
if (config.resumeReport) {
|
|
const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous)
|
|
if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports')
|
|
const bytes = readFileSync(previous)
|
|
if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256)
|
|
throw Error('Reviewed departure report SHA256 mismatch; no environment request sent')
|
|
resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage)
|
|
save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage })
|
|
} else {
|
|
readers ||= createTestEvidenceReaders(env)
|
|
await readers.preflight()
|
|
}
|
|
const options = validateExecutionOptions({ ...parseArguments(['--modules', 'phase4-overseas-dispatch', '--quiet', 'true']),
|
|
gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
|
|
pdaUsername: env.ADMIN_PDA_USERNAME, pdaPassword: env.ADMIN_PDA_PASSWORD, merchantUsername: '', merchantPassword: '',
|
|
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
|
|
context = createContext ? createContext(options) : new AcceptanceRun(options)
|
|
let writeCheckpoint = 0
|
|
restoreFetch = guardFleetWrites(context, entry => {
|
|
const payload = JSON.stringify(redactFleetWrite({ marker: context.marker, runId: config.runId, request: entry },
|
|
[...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n'
|
|
const descriptor = openSync(join(output, 'evidence', `write-${String(++writeCheckpoint).padStart(6, '0')}.json`), 'wx')
|
|
try { writeFileSync(descriptor, payload); fsyncSync(descriptor) }
|
|
finally { closeSync(descriptor) }
|
|
}, { guardFetch: true })
|
|
context.context.fleetRunId = config.runId
|
|
context.context.fleetScenario = config.scenario || 'handover'
|
|
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
|
|
context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture)
|
|
let handoverCheckpoint = 0
|
|
context.context.checkpointFleetHandover = proof => {
|
|
const payload = JSON.stringify(redactEvidence({ marker: context.marker, runId: config.runId,
|
|
proof, createdResources: context.createdResources }, [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n'
|
|
// Never truncate the prior checkpoint: interruption or disk failure leaves the earlier request recoverable.
|
|
const descriptor = openSync(join(output, 'evidence', `handover-${String(++handoverCheckpoint).padStart(6, '0')}.json`), 'wx')
|
|
try { writeFileSync(descriptor, payload); fsyncSync(descriptor) }
|
|
finally { closeSync(descriptor) }
|
|
}
|
|
context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance)
|
|
const fleetScenario = async (ctx, options = {}) => {
|
|
if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors })
|
|
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
|
|
try { return await scenario(ctx, { ...options, costActors }) }
|
|
finally { await costActors?.close() }
|
|
}
|
|
let evidenceNumber = 0
|
|
context.context.phase4ReceivingEvidenceReader = async scope => {
|
|
const evidence = await readers.readReceiving(scope)
|
|
save(`evidence/receiving-${++evidenceNumber}.json`, { scope, evidence }); return evidence
|
|
}
|
|
const executeModule = context.executeModule.bind(context)
|
|
context.executeModule = async (name, handler) => {
|
|
await executeModule(name, handler)
|
|
save('progress.json', { stage: name, steps: context.steps, modules: context.modules, createdResources: context.createdResources })
|
|
if (context.steps.some(step => step.passed === false) || context.modules.at(-1)?.success === false) throw Error(`Producer stopped: ${name}`)
|
|
if (name === 'phase4-overseas-dispatch') {
|
|
await executeModule(MODULE, () => fleetScenario(context))
|
|
if (context.modules.at(-1)?.success === false) throw Error('Fleet case stopped at original fixture')
|
|
}
|
|
}
|
|
if (resumed) {
|
|
context.createdResources = resumed.resources; context.marker = resumed.marker
|
|
const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
|
|
if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed')
|
|
context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || ''
|
|
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
|
|
resumeDeparted: resumed.departed,
|
|
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
|
|
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
|
|
report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules,
|
|
createdResources: context.createdResources, resumedFrom: config.resumeReport }
|
|
} else report = await context.run()
|
|
report.fleetExecution = fleetExecutionPlan(config)
|
|
// Keep the unresolved producer ACK explicit; a green consumer row cannot close the complete fleet goal.
|
|
report.fleetGoalComplete = false
|
|
} catch (error) {
|
|
report = { success: false, fatal: error instanceof Error ? error.message : 'FLEET_ACCEPTANCE_FAILED',
|
|
summary: context ? buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) : { failed: 1 },
|
|
steps: context?.steps || [], modules: context?.modules || [], createdResources: context?.createdResources || {} }
|
|
} finally {
|
|
restoreFetch?.()
|
|
if (initialized) save('report.json', report || { success: false, fatal: 'RUN_INTERRUPTED' })
|
|
closeSync(lock); unlinkSync(lockPath)
|
|
}
|
|
return { success: report?.success === true, report: join(output, 'report.json'), summary: report?.summary,
|
|
fleet: report?.createdResources?.fleetAcceptance?.status, fatal: report?.fatal }
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
try { const config = parseFleetArguments(process.argv.slice(2)); const result = config.execute ? await runFleetAcceptance(config) : fleetExecutionPlan(config)
|
|
console.log(JSON.stringify(result, null, 2)); if (config.execute && !result.success) process.exitCode = 1
|
|
} catch (error) { console.error(error instanceof Error ? error.message : 'Fleet acceptance failed'); process.exitCode = 1 }
|
|
}
|