优化
This commit is contained in:
@@ -1,177 +1,83 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
|
||||
import { saveRetainedReport, validateRetainedScope, validateNativeRetainedDispatch,
|
||||
installRetainedJournal, retainedApi, runRetainedNativeDispatch } from './mixed-container-outbound-test-env.mjs'
|
||||
|
||||
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
||||
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
||||
const sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE
|
||||
const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO
|
||||
const directRelabelNo = process.env.OVERSEAS_RELABEL_NO
|
||||
const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json'
|
||||
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID
|
||||
const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN
|
||||
const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN
|
||||
const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN
|
||||
const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
||||
if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token')
|
||||
if (!directMerchantToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing merchant token or platform credentials')
|
||||
if (!directPdaToken && (!process.env.PDA_TEST_USERNAME || !process.env.PDA_TEST_PASSWORD)) throw new Error('Missing PDA credentials or token')
|
||||
if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id')
|
||||
const id = value => {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision')
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
const requireFact = (condition, message) => { if (!condition) throw Error(message) }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function key(value) { return `${marker}-${value}`.slice(0, 200) }
|
||||
function assert(value, message) { if (!value) throw new Error(message) }
|
||||
async function api(base, token, method, path, body) {
|
||||
const response = await fetch(`${base}${path}`, { method,
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) })
|
||||
const text = await response.text(); let value
|
||||
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
||||
if (!response.ok || String(value?.code) !== '200') {
|
||||
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
||||
}
|
||||
return value.data
|
||||
/** A dispatch number or legacy pre-arrange report cannot establish current source ownership. */
|
||||
export function retainedRelabelSource(source, customerId, dispatchNo) {
|
||||
const scenario = source?.scenarios?.find(value => value.name === 'SPLIT-TWO-TARGETS')
|
||||
const native = scenario?.nativeDispatch
|
||||
requireFact(source?.environment === 'TEST' && source.marker && native?.marker === source.marker
|
||||
&& scenario.relabelNo && native.customerId === id(customerId)
|
||||
&& (!dispatchNo || native.dispatchNo === dispatchNo), 'Relabel continuation requires this run native source report/customer/dispatch; old reports or a direct number cannot resume')
|
||||
const inboundCodes = [...new Set((native.detail?.orderDetails || []).map(row => row.inboundCode).filter(Boolean))]
|
||||
const expectedPackages = Number(native.detail?.counts?.packageCount)
|
||||
requireFact(inboundCodes.length > 0 && Number.isSafeInteger(expectedPackages) && expectedPackages > 0
|
||||
&& native.createBody?.cargo?.every(row => row.sourceType === 'RELABEL' && row.relabelNo === scenario.relabelNo),
|
||||
'Relabel native cargo does not belong to the original changed-carton sources')
|
||||
const newMarks = scenario.relabelCartonMarks, oldMarks = scenario.sourceCartonMarks
|
||||
requireFact(Array.isArray(newMarks) && newMarks.length === expectedPackages && newMarks.every(mark => typeof mark === 'string' && mark.trim())
|
||||
&& new Set(newMarks).size === newMarks.length && Array.isArray(oldMarks) && oldMarks.length > 0
|
||||
&& oldMarks.every(mark => typeof mark === 'string' && mark.trim()) && oldMarks.every(mark => !newMarks.includes(mark)),
|
||||
'Relabel source must retain original/new unique carton mark evidence; cannot infer marks from live final inventory')
|
||||
const scope = { marker: native.marker, customerId: id(customerId), warehouseId: id(native.createBody.warehouseId),
|
||||
inboundCodes, expectedPackages, expectedPalletItems: scenario.partialRelabel ? 1 : 2, relabelCartonMarks: newMarks, sourceCartonMarks: oldMarks }
|
||||
validateNativeRetainedDispatch(native, scope)
|
||||
return { scenario, native, scope }
|
||||
}
|
||||
|
||||
const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null
|
||||
const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS')
|
||||
|| { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' }
|
||||
assert(scenario?.dispatchNo, 'Dispatch not found in source report')
|
||||
const dispatchNo = scenario.dispatchNo
|
||||
const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD })
|
||||
const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', {
|
||||
loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD })
|
||||
const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST',
|
||||
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` })
|
||||
const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
||||
const platformToken = directPlatformToken || platformLogin.overseasAccessToken
|
||||
const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken
|
||||
const pdaToken = directPdaToken || pdaLogin.overseasAccessToken
|
||||
|
||||
let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
|
||||
let alreadyDeparted = task.status === 'DEPARTED'
|
||||
assert(alreadyDeparted || task.tripNo, 'Prepare and bind a qualified fleet trip before running retained dispatch fulfillment')
|
||||
const existingPage = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`)
|
||||
const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo)
|
||||
const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean))
|
||||
assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders')
|
||||
const transfer = existingDeliveries.length > 0 ? {
|
||||
outboundNo: [...existingOutboundNos][0],
|
||||
deliveries: existingDeliveries.map((value) => ({
|
||||
deliveryNo: value.deliveryNo, deliveryStatus: value.status,
|
||||
destinationWarehouseId: value.destinationWarehouseId,
|
||||
destinationWarehouseCode: value.destinationWarehouseCode,
|
||||
destinationWarehouseName: value.destinationWarehouseName,
|
||||
palletCount: value.palletCount, packageCount: value.packageCount,
|
||||
})),
|
||||
replayed: true,
|
||||
} : await api(overseas, merchantToken, 'POST',
|
||||
`/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, {
|
||||
idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队',
|
||||
pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托',
|
||||
appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费',
|
||||
direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }],
|
||||
})
|
||||
const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : []
|
||||
assert(transfer.outboundNo && transferDeliveries.length > 0,
|
||||
'Transfer did not create outbound and delivery')
|
||||
assert(transferDeliveries.length === 1,
|
||||
`Expected one destination delivery, got ${transferDeliveries.length}`)
|
||||
const deliveryNo = transferDeliveries[0].deliveryNo
|
||||
|
||||
const expectedPdaItems = scenario.partialRelabel ? 1 : 2
|
||||
assert(Array.isArray(task.items) && task.items.length === expectedPdaItems,
|
||||
`Expected ${expectedPdaItems} PDA pallet items`)
|
||||
if (!alreadyDeparted) {
|
||||
for (const [itemIndex, item] of task.items.entries()) {
|
||||
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
|
||||
for (const [positionIndex, position] of item.positions.entries()) {
|
||||
if (remaining <= 0) break
|
||||
const packages = Math.min(remaining, Number(position.availablePackages))
|
||||
if (packages <= 0) continue
|
||||
await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, {
|
||||
positionId: id(position.id), packages, productQuantity: packages,
|
||||
idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker,
|
||||
})
|
||||
remaining -= packages
|
||||
export async function mainRelabel(env = process.env) {
|
||||
const sourcePath = env.OVERSEAS_ACCEPTANCE_SOURCE
|
||||
requireFact(sourcePath && env.OVERSEAS_TEST_CUSTOMER_ID, 'Explicit native source report and customer are required; dispatch number alone is insufficient')
|
||||
const sourceBytes = readFileSync(resolve(sourcePath)), source = JSON.parse(sourceBytes)
|
||||
const { scenario, native, scope } = retainedRelabelSource(source, env.OVERSEAS_TEST_CUSTOMER_ID, env.OVERSEAS_DISPATCH_NO)
|
||||
if (env.OVERSEAS_RELABEL_NO) requireFact(env.OVERSEAS_RELABEL_NO === scenario.relabelNo, 'Explicit relabel number differs from original source')
|
||||
scope.sourceSha256 = createHash('sha256').update(sourceBytes).digest('hex')
|
||||
const output = resolve(env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json')
|
||||
const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST',
|
||||
...scope, sourceReport: resolve(sourcePath), relabelNo: scenario.relabelNo, nativeDispatch: native, requests: [] }
|
||||
validateRetainedScope(result, scope)
|
||||
const options = parseArguments([]); options.modules = []; options.output = output; validateExecutionOptions(options)
|
||||
requireFact(options.environment === 'test', 'Relabel native continuation only supports explicit TEST')
|
||||
const context = installRetainedJournal(new AcceptanceRun(options), result, () => saveRetainedReport(output, result))
|
||||
context.marker = scope.marker
|
||||
const api = (method, path, body, token) => retainedApi(context, method, path, body, token)
|
||||
try {
|
||||
const platform = await api('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
|
||||
requireFact(platform.accessToken, 'Unified platform accessToken is missing')
|
||||
context.token = platform.accessToken
|
||||
requireFact(env.PDA_TEST_ACCESS_TOKEN || options.pdaUsername && options.pdaPassword, 'Explicit current PDA token or complete credentials are required')
|
||||
const pdaToken = env.PDA_TEST_ACCESS_TOKEN || (await api('POST', '/api/auth/login', {
|
||||
loginId: options.pdaUsername, password: options.pdaPassword }, '')).accessToken
|
||||
requireFact(pdaToken, 'Unified PDA credentials or explicit current token are required')
|
||||
await runRetainedNativeDispatch(context, result, scope, pdaToken)
|
||||
result.visits = []
|
||||
for (const deliveryNo of result.deliveryNos) {
|
||||
const query = new URLSearchParams({ page: '1', size: '200', customerId: scope.customerId, keyword: deliveryNo })
|
||||
const visits = await api('GET', `/api/platform/v1/last-leg/customer-visits?${query}`)
|
||||
const visit = visits.records?.find(row => row.deliveryNo === deliveryNo)
|
||||
requireFact(visit?.status === 'PENDING' && id(visit.customerId) === scope.customerId, 'Real signature did not create the original customer visit')
|
||||
result.visits.push({ id: id(visit.id), deliveryNo, status: visit.status })
|
||||
}
|
||||
assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`)
|
||||
result.passed = true; result.scope = 'OVERSEAS_LASTLEG_ONLY'
|
||||
} catch (error) {
|
||||
result.passed = false; result.finalStatus = 'BLOCKED'; result.failedStage = 'NATIVE_RELABEL_FULFILLMENT'; process.exitCode = 1
|
||||
} finally {
|
||||
result.checks = context.steps
|
||||
if (context.steps.some(step => step.passed === false)) { result.passed = false; result.finalStatus = 'CHECK_FAILED'; process.exitCode = 1 }
|
||||
await context.checkpoint()
|
||||
console.log(JSON.stringify({ marker: result.marker, dispatchNo: result.dispatchNo, outboundNo: result.outboundNo,
|
||||
deliveryNos: result.deliveryNos, passed: result.passed === true, finalStatus: result.finalStatus, report: output }))
|
||||
}
|
||||
task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
|
||||
alreadyDeparted = task.status === 'DEPARTED'
|
||||
return result
|
||||
}
|
||||
if (!alreadyDeparted && task.status !== 'COMPLETED') {
|
||||
const pickingCompleted = await api(overseas, pdaToken, 'POST',
|
||||
`/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') })
|
||||
assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART')
|
||||
task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`)
|
||||
}
|
||||
assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking')
|
||||
if (!alreadyDeparted) {
|
||||
const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(task.tripNo)}`
|
||||
const trip = await api(overseas, platformToken, 'GET', tripPath)
|
||||
const loading = await api(overseas, platformToken, 'GET', `${tripPath}/loading`)
|
||||
assert(!loading.blockReason, `Trip not ready: ${loading.blockReason}`)
|
||||
const confirmed = await api(overseas, platformToken, 'POST', `${tripPath}/loading`, {
|
||||
recordVersion: loading.recordVersion, manifestHash: loading.manifestHash,
|
||||
pallets: controlledPalletMeasurements(loading.cargo, trip.trip.stops), reason: '受控测试测量,每实托100kg/1m³/1托位',
|
||||
})
|
||||
const departed = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`,
|
||||
{ recordVersion: confirmed.recordVersion, idempotencyKey: key('DEPART').slice(0,128), remark: marker })
|
||||
assert(departed.status === 'IN_TRANSIT' && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), 'Whole-trip departure missing dispatch')
|
||||
}
|
||||
const departedDispatch = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`)
|
||||
assert(departedDispatch.status === 'DEPARTED', 'Dispatch did not reach DEPARTED')
|
||||
|
||||
const outbound = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`)
|
||||
assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED')
|
||||
let delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
if (delivery.order.status === 'WAIT_DELIVERY') {
|
||||
const delivering = await api(overseas, platformToken, 'PUT',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, {
|
||||
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
|
||||
})
|
||||
assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING')
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
}
|
||||
if (delivery.order.status !== 'DELIVERED') {
|
||||
assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`)
|
||||
const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}`
|
||||
const documents = await api(overseas, platformToken, 'GET', documentsPath)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }),
|
||||
`${marker}-${deliveryNo}-receipt.txt`)
|
||||
const receipt = await api(overseas, platformToken, 'POST',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form)
|
||||
const existingBindings = (documents.categories || []).map((category) => ({
|
||||
category: category.category,
|
||||
fileIds: (category.files || []).map((file) => id(file.fileId)),
|
||||
})).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
||||
await api(overseas, platformToken, 'PUT', documentsPath, {
|
||||
expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'),
|
||||
bolNo: documents.bolNo || null,
|
||||
documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }],
|
||||
})
|
||||
delivery = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
|
||||
}
|
||||
assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery')
|
||||
const visits = await api(overseas, platformToken, 'GET',
|
||||
`/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`)
|
||||
const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo)
|
||||
assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit')
|
||||
|
||||
const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo,
|
||||
dispatchNo, dispatchStatus: departedDispatch.status, outboundNo: transfer.outboundNo,
|
||||
outboundStatus: outbound.outbound.status, deliveryNo,
|
||||
deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departedDispatch.shippedPackageCount,
|
||||
passed: true, finishedAt: new Date().toISOString() }
|
||||
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabel()
|
||||
|
||||
Reference in New Issue
Block a user