import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' import { run as createMerchant } from './merchant-qualification-fixture.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs' import { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs' import { mkdir, writeFile, rename, readFile } from 'node:fs/promises' import { resolve, dirname } from 'node:path' import { createHash } from 'node:crypto' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches' const LASTLEG_BASE = '/api/platform/v1/last-leg' const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches' const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders' function id(value) { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('业务ID精度无效') return value == null ? '' : String(value) } function version(value) { if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw new Error('服务端recordVersion缺失或精度无效') return value } // 发送前保存原命令;未知写结果只保留证据,禁止本轮继续推进。 export async function fulfillmentEvidenceContext(context) { const marker = String(context.marker) if (!/^[A-Za-z0-9_-]+$/.test(marker)) throw new Error('验收marker不能用于独占检查点') const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase5-checkpoints', `${marker}.json`) try { await readFile(path); throw new Error(`已存在本轮履约检查点,必须核对原命令结果后续验:${path}`) } catch (error) { if (error.code !== 'ENOENT') throw error } const redact = value => { if (Array.isArray(value)) return value.map(redact) if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) .filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name)) .map(([name, nested]) => [name, redact(nested)])) return value } const evidence = { marker, status: 'PREPARED', requests: [] } const scoped = Object.create(context) let stopped = false scoped.checkpoint = async () => { await mkdir(dirname(path), { recursive: true }) await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence, fulfillment: context.createdResources.phase5OverseasFulfillment, looseboard: context.createdResources.phase4OverseasBusiness?.looseboard, clientRelabel: context.createdResources.phase4OverseasClientServices?.relabelFulfillment, pdaRelabel: context.createdResources.phase4OverseasRelabel, fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8') await rename(`${path}.tmp`, path) } scoped.request = async (method, requestPath, body, ...rest) => { if (method === 'GET' || /\/auth\//.test(requestPath)) return context.request(method, requestPath, body, ...rest) const identityCleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(requestPath) && body?.status === 'DISABLED' if (stopped && !identityCleanup) throw new Error('存在未知写结果,保留原命令并停止后续写入') const entry = { method, path: requestPath, body: redact(body), status: 'SENDING' } evidence.requests.push(entry) await scoped.checkpoint() let response try { response = await context.request(method, requestPath, body, ...rest) } catch (error) { stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error } const nativeCommand = /^\/api\/(?:platform|pda)\/v1\/last-leg\/dispatches(?:\/|$)/.test(requestPath) const knownResponse = response?.http > 0 && response.http < 500 && response.body?.code != null && !(nativeCommand && code(response) === '200' && response.body.data == null) Object.assign(entry, { status: knownResponse ? 'RESPONDED' : 'UNKNOWN', http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId, result: redact(response?.body?.data) }) if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' } await scoped.checkpoint() if (stopped && !identityCleanup) throw new Error(`履约写入结果未知,停止并保留原命令;${trace(response)}`) return response } scoped.requestMultipart = async (method, requestPath, file, ...rest) => { const bytes = file?.content == null ? null : Buffer.from(file.content) const metadata = { name: file?.name, type: file?.type, size: bytes?.length, sha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null } // multipart也必须在发送前落盘;证据只含文件摘要而不含文件字节。 const entry = { method, path: requestPath, file: metadata, status: 'SENDING' } if (stopped) throw new Error('未知写结果后禁止继续上传') evidence.requests.push(entry); await scoped.checkpoint() let response try { response = await context.requestMultipart(method, requestPath, file, ...rest) } catch (error) { stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error } Object.assign(entry, { status: response?.http > 0 && response.http < 500 ? 'RESPONDED' : 'UNKNOWN', http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId, result: redact(response?.body?.data) }) if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' } await scoped.checkpoint() if (stopped) throw new Error(`上传结果未知,保留原文件摘要并停写;${trace(response)}`) return response } context.createdResources.phase5OverseasFulfillment.checkpointPath = path await scoped.checkpoint() return scoped } function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response) { return String(response?.body?.code ?? '') } function conflict(response) { return response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))) } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function query(path, values) { const params = new URLSearchParams() for (const [name, value] of Object.entries(values || {})) { if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) } return params.size ? `${path}?${params}` : path } function key(context, action, suffix = '') { return `${context.marker}-P5-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 200) } function blocker(context, name, reason, response = null) { context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true }) context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) } async function platform(context, method, name, path, body = undefined) { const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } async function pda(context, token, method, name, path, body = undefined) { const response = await context.request(method, path, body, token, {}) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return { response, data: success(response) ? response.body?.data : null } } async function merchant(context, token, method, name, path, body = undefined) { const response = await context.request(method, path, body, token, {}) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } export function dispatchDestinationConservation(detail, fixture, deliveries = null) { const codes = values => values.map(value => id(value).trim().toUpperCase()) const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) && right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort()) const items = detail?.orderDetails if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.sourceKeys) || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.dispatchInfo?.route)) return false const actualIds = items.map(row => id(row.sourceKey)), expectedIds=fixture.sourceKeys.map(id) if (!actualIds.every(nonBlank) || !sameSet(actualIds,expectedIds)) return false const actual = codes(items.map(row => row.destinationFbaWarehouse)) const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.dispatchInfo.route.split(',')) if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false if (deliveries == null) return true if (!Array.isArray(deliveries) || !deliveries.length) return false const delivered=codes(deliveries.map(row => row.destination?.warehouseCode)) const groups = deliveries.map(row => `${id(row.customerId)}:${id(row.destination?.key)}`) return groups.every(nonBlank) && new Set(groups).size === groups.length && sameSet(delivered,actual) } async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { let result = null for (let attempt = 1; attempt <= attempts; attempt += 1) { result = await action() if (predicate(result)) return { result, attempts: attempt } if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, intervalMs)) } return { result, attempts } } async function allPages(context, path, values, name) { const rows = [] for (let page = 1; page <= 50; page += 1) { const current = await platform(context, 'GET', `${name}第${page}页`, query(path, { ...values, page, size: 200 })) if (!success(current.response) || !Array.isArray(current.data?.records)) return null rows.push(...current.data.records) const total = Number(current.data.total) if (Number.isInteger(total) && rows.length >= total) { const complete = rows.length === total && rows.every(row => nonBlank(id(row.id))) && new Set(rows.map(row => id(row.id))).size === rows.length context.assert(MODULE, `${name}无分页遗漏或重复`, current.response, complete, `rows=${rows.length}, total=${total}`, OPTIONS) return complete ? rows : null } if (current.data.records.length === 0) break } blocker(context, name, '分页总数缺失或未读完,不将截断记录当作完整库存证据') return null } const POSITION_BUCKETS = ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] const BALANCE_BUCKETS = [...POSITION_BUCKETS, 'inTransitPackages', 'procurementPackages'] const INVENTORY_IDENTITIES = ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', 'palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode'] export function inventoryQuantitySnapshot(rows) { return (rows || []).map(row => Object.fromEntries([ ...INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, id(row[field])]), ...BALANCE_BUCKETS.filter(field => row[field] !== undefined).map(field => [field, Number(row[field])]), ])).sort((a, b) => a.id.localeCompare(b.id)) } const balanceDimension = row => JSON.stringify([id(row.customerId), id(row.overseasWarehouseId), id(row.destinationWarehouseId), id(row.destinationWarehouseCode), id(row.skuCode)]) const validBuckets = (row, fields) => fields.every(field => row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0) export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name, sourceBalanceIds = []) { const codes = new Set(inboundCodes) const sourceIds = [...new Set(sourceBalanceIds.map(id))].sort() if (!sourceIds.length || sourceIds.some(value => !/^[1-9][0-9]*$/.test(value))) { blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源') return null } const inventoryEvidence = context.createdResources?.phase5OverseasFulfillment if (inventoryEvidence) { inventoryEvidence.inventoryReadEvidence ||= {} inventoryEvidence.inventoryReadEvidence[name] = { customerId, warehouseId, inboundCodes: [...codes], sourceBalanceIds: sourceIds, complete: false, globalGroups: null, sourceBalances: [] } } const readEvidence = inventoryEvidence?.inventoryReadEvidence?.[name] const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, { customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`) if (!groups) return null const groupValid = groups.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && validBuckets(row, BALANCE_BUCKETS)) && new Set(groups.map(balanceDimension)).size === groups.length context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS) if (!groupValid) return null if (readEvidence) readEvidence.globalGroups = inventoryQuantitySnapshot(groups) context.context ||= {} context.context.phase5InventorySources ||= {} const frozen = context.context.phase5InventorySources const snapshot = [] for (const balanceId of sourceIds) { const sourceKey = `${customerId}:${warehouseId}:${balanceId}` const detailResponse = await context.request('GET', query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}`, { customerId })) const missingZero = detailResponse.http === 404 || (detailResponse.http === 200 && code(detailResponse) === 'A0404') if (!success(detailResponse) && !missingZero) { context.apiSuccess(MODULE, `${name}读取冻结真实余额`, detailResponse, trace(detailResponse), OPTIONS) return null } const current = await platform(context, 'GET', `${name}读取冻结余额实际全部位置`, query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { customerId })) const positions = current.data const valid = success(current.response) && Array.isArray(positions) && new Set(positions.map(row => id(row.id))).size === positions.length && positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === balanceId && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && nonBlank(row.inboundCode) && validBuckets(row, POSITION_BUCKETS)) context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid, `balanceId=${balanceId}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) if (!valid) return null let balance = detailResponse.body?.data if (missingZero) { const prior = frozen[sourceKey] const currentGroup = prior && groups.find(row => balanceDimension(row) === balanceDimension(prior.balance)) const zeroProof = prior && positions.every(row => POSITION_BUCKETS.every(field => Number(row[field]) === 0)) && ['inTransitPackages', 'procurementPackages'].every(field => Number(prior.balance[field]) === 0) && BALANCE_BUCKETS.every(field => Number(currentGroup?.[field] || 0) <= Number(prior.group?.[field] || 0) - Number(prior.balance[field])) context.assert(MODULE, `${name}零余额排除由原ID位置及完整分组共同证实`, detailResponse, Boolean(zeroProof), `balanceId=${balanceId}, retainedSource=${Boolean(prior)}, zeroPositions=${positions.length}`, OPTIONS) if (!zeroProof) return null balance = { ...prior.balance, ...Object.fromEntries(BALANCE_BUCKETS.map(field => [field, 0])) } } else { const owned = id(balance?.id) === balanceId && id(balance?.customerId) === customerId && id(balance?.overseasWarehouseId) === warehouseId && validBuckets(balance, BALANCE_BUCKETS) context.assert(MODULE, `${name}真实余额客户仓及完整数量有效`, detailResponse, owned, `balanceId=${balanceId}`, OPTIONS) if (!owned) return null const group = groups.find(row => balanceDimension(row) === balanceDimension(balance)) const included = Boolean(group) && BALANCE_BUCKETS.every(field => Number(group[field]) >= Number(balance[field])) context.assert(MODULE, `${name}真实非零余额包含于独立完整分组`, detailResponse, included, `balanceId=${balanceId}`, OPTIONS) if (!included) return null frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] } } if (readEvidence) readEvidence.sourceBalances.push({ balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions) }) const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field])) context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled, `balanceId=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS) if (!reconciled) return null const exact = positions.filter(row => codes.has(row.inboundCode)) snapshot.push({ id: balanceId, customerId, overseasWarehouseId: warehouseId, inboundCodes: [...new Set(exact.map(row => row.inboundCode))].sort(), ...Object.fromEntries(POSITION_BUCKETS.map(field => [field, exact.reduce((sum, row) => sum + Number(row[field]), 0)])), balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions), matchingPositionIds: exact.map(row => id(row.id)).sort(), globalGroups: inventoryQuantitySnapshot(groups) }) } const jointlyIncluded = groups.every(group => BALANCE_BUCKETS.every(field => snapshot .filter(row => balanceDimension(row.balance) === balanceDimension(group)) .reduce((sum, row) => sum + Number(row.balance[field]), 0) <= Number(group[field]))) context.assert(MODULE, `${name}固定原始余额合计不超过独立分组`, null, jointlyIncluded, `rawBalances=${snapshot.length}, groups=${groups.length}`, OPTIONS) if (readEvidence) readEvidence.complete = jointlyIncluded return jointlyIncluded ? snapshot : null } export function dispatchInventorySources(task, dispatchNo, warehouseId) { if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId || !Array.isArray(task.items) || !task.items.length) return null const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : []) if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.positionId)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null const references = new Map() for (const row of positions) { const previous = references.get(id(row.positionId)) if (previous && previous !== id(row.balanceId)) return null references.set(id(row.positionId), id(row.balanceId)) } return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(), sourcePositionIds: [...references.keys()].sort(), sourcePositionBalances: [...references].map(([positionId, balanceId]) => ({ positionId, balanceId })) .sort((left, right) => left.positionId.localeCompare(right.positionId)) } } export function inventorySourcesPresent(snapshot, references) { return Array.isArray(snapshot) && references.every(reference => snapshot .find(row => row.id === reference.balanceId)?.positions.some(position => position.id === reference.positionId && position.balanceId === reference.balanceId)) } async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { const rows = await allPages(context, `${LASTLEG_BASE}/inventory/ledger`, { customerId, outboundOrderNo: outboundNo, businessType, businessOrderNo }, name) if (!rows) return null const exact = rows.filter(row => id(row.customerId) === customerId && row.outboundOrderNo === outboundNo && row.businessType === businessType && (!businessOrderNo || row.businessOrderNo === businessOrderNo)) context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length, `rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS) const quantities = rows.map(row => ({ id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId), inboundOrderId: id(row.inboundOrderId), inboundOrderNo: id(row.inboundOrderNo), inboundCode: id(row.inboundCode), palletNo: id(row.palletNo), batchNo: id(row.batchNo), skuCode: id(row.skuCode), businessType: id(row.businessType), businessOrderNo: id(row.businessOrderNo), outboundOrderNo: id(row.outboundOrderNo), deltaPackages: Number(row.deltaPackages), beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) .sort((a, b) => a.id.localeCompare(b.id)) const owned = context.createdResources?.phase5OverseasFulfillment if (owned) { owned.ledgerReadEvidence ||= {} owned.ledgerReadEvidence[name] = { customerId, outboundNo, businessType, businessOrderNo: businessOrderNo || null, complete: exact.length === rows.length, rows: quantities } } return exact.length === rows.length ? quantities : null } const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0) const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) export function dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, sourcePositionBalances, stockBefore, stockAfter, ledgerBefore, ledgerAfter }) { const positions = snapshot => (snapshot || []).flatMap(row => row.positions || []) const referenceKeys = new Set(sourcePositionBalances.map(row => `${row.balanceId}:${row.positionId}`)) const selected = snapshot => positions(snapshot).filter(row => referenceKeys.has(`${row.balanceId}:${row.id}`)) const positionBefore = selected(stockBefore) const positionAfter = selected(stockAfter) const newLedger = ledgerAfter?.filter(row => !ledgerBefore?.some(before => before.id === row.id)) || [] const rowChecks = newLedger.map(row => ({ id: row.id, identity: row.customerId === customerId && row.overseasWarehouseId === warehouseId && row.businessType === 'SHIP' && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo && positionBefore.some(position => position.palletNo === row.palletNo), quantities: [row.beforePackages, row.deltaPackages, row.afterPackages].every(Number.isSafeInteger) && row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages })) const identity = row => Object.fromEntries(INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, row[field]])) const sourcesStable = positionBefore.every(row => { const after = positionAfter.find(value => value.id === row.id && value.balanceId === row.balanceId) return after && JSON.stringify(identity(row)) === JSON.stringify(identity(after)) }) const unselectedBefore = positions(stockBefore).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) const unselectedAfter = positions(stockAfter).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) const totals = { positionBefore: quantityTotal(positionBefore), positionAfter: quantityTotal(positionAfter), rawBefore: balanceQuantityTotal(stockBefore), rawAfter: balanceQuantityTotal(stockAfter), groupBefore: groupQuantityTotal(stockBefore), groupAfter: groupQuantityTotal(stockAfter), ledgerShipped: -newLedger.reduce((sum, row) => sum + row.deltaPackages, 0), expectedPackages } const checks = { fixedSourcesPresent: inventorySourcesPresent(stockBefore, sourcePositionBalances) && inventorySourcesPresent(stockAfter, sourcePositionBalances), sourceIdentitiesStable: sourcesStable, unselectedPositionsUnchanged: sameSnapshot(inventoryQuantitySnapshot(unselectedBefore), inventoryQuantitySnapshot(unselectedAfter)), positionConservation: expectedPackages > 0 && totals.positionBefore - totals.positionAfter === expectedPackages, rawConservation: stockBefore !== null && stockAfter !== null && totals.rawBefore - totals.rawAfter === expectedPackages, groupConservation: stockBefore !== null && stockAfter !== null && totals.groupBefore - totals.groupAfter === expectedPackages, ledgerSourceIdentities: ledgerBefore !== null && ledgerAfter !== null && newLedger.length > 0 && new Set(newLedger.map(row => row.id)).size === newLedger.length && rowChecks.every(row => row.identity), ledgerQuantities: newLedger.length > 0 && totals.ledgerShipped === expectedPackages && rowChecks.every(row => row.quantities), } const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks, snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } } if (context.createdResources?.phase5OverseasFulfillment) { Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof ||= {}, proof) } for (const [name, passed] of Object.entries(checks)) { context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS) } const passed = Object.values(checks).every(Boolean) context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, passed, JSON.stringify(totals), OPTIONS) return { passed, proof, newLedger } } // Completion follows the formal root and every real child; a signed shipment alone is not the aggregate. export async function verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken, pollAttempts = 45, pollIntervalMs = 1_000 }) { const rawPath = orderId => query(`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { tenantId: customerId }) const source = await platform(context, 'GET', '读取签收来源原始订单履约归属', rawPath(sourceOrderId)) const sourceOrder = source.data?.order const rootOrderId = sourceOrder?.orderRole === 'MASTER' ? id(sourceOrder.id) : id(sourceOrder?.rootOrderId) const sourceOwned = success(source.response) && id(sourceOrder?.id) === sourceOrderId && id(sourceOrder?.tenantId) === customerId && id(sourceOrder?.customerId) === customerId && /^[1-9][0-9]*$/.test(rootOrderId) && (sourceOrder.orderRole === 'MASTER' || (sourceOrder.orderRole === 'CHILD' && id(sourceOrder.parentOrderId) === rootOrderId)) context.assert(MODULE, '签收来源订单属于本企业及唯一正式履约根', source.response, sourceOwned, `sourceOrderId=${sourceOrderId}, rootOrderId=${rootOrderId}`, OPTIONS) if (!sourceOwned) return null let root = rootOrderId === sourceOrderId ? source : await platform(context, 'GET', '读取原始履约根订单', rawPath(rootOrderId)) const treeResult = await platform(context, 'GET', '读取签收后的完整正式履约树', query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}/fulfillment-tree`, { tenantId: customerId })) const tree = treeResult.data const rows = Array.isArray(tree?.children) ? tree.children : [] const treeOrderIds = new Set([rootOrderId, ...rows.map(row => id(row.orderId))]) const consumed = new Set(rows.filter(row => row.splitRole === 'LOADED').map(row => id(row.sourceOrderId))) const loaded = rows.filter(row => row.splitRole === 'LOADED').reduce((sum, row) => sum + Number(row.packageCount), 0) const remaining = rows.filter(row => row.splitRole === 'REMAINDER' && !consumed.has(id(row.orderId))) .reduce((sum, row) => sum + Number(row.packageCount), 0) const treeComplete = success(root.response) && success(treeResult.response) && id(root.data?.order?.id) === rootOrderId && root.data.order.orderRole === 'MASTER' && id(root.data.order.tenantId) === customerId && id(root.data.order.customerId) === customerId && Array.isArray(root.data.statusHistory) && id(tree?.parentOrderId) === rootOrderId && tree.parentOrderNo === root.data.order.orderNo && Array.isArray(tree.children) && ['originalPackageCount', 'cumulativeLoadedPackageCount', 'currentRemainderPackageCount'].every(field => tree[field] !== null && tree[field] !== undefined && Number.isSafeInteger(Number(tree[field])) && Number(tree[field]) >= 0) && loaded === Number(tree.cumulativeLoadedPackageCount) && remaining === Number(tree.currentRemainderPackageCount) && loaded + remaining === Number(tree.originalPackageCount) && rows.every(row => /^[1-9][0-9]*$/.test(id(row.orderId)) && /^[1-9][0-9]*$/.test(id(row.sourceOrderId)) && treeOrderIds.has(id(row.sourceOrderId)) && nonBlank(row.orderNo) && ['LOADED', 'REMAINDER'].includes(row.splitRole) && (row.splitRole === 'LOADED' ? id(row.orderId) === id(row.sourceOrderId) : id(row.orderId) !== id(row.sourceOrderId)) && Number.isSafeInteger(Number(row.packageCount)) && Number(row.packageCount) > 0) && (sourceOrderId === rootOrderId || rows.some(row => id(row.orderId) === sourceOrderId)) context.assert(MODULE, '履约根及全部分片身份和树数量完整守恒', treeResult.response, treeComplete, `rootOrderId=${rootOrderId}, loaded=${loaded}, remaining=${remaining}, original=${tree?.originalPackageCount ?? 'missing'}`, OPTIONS) if (!treeComplete) return null const children = [] const statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'] for (const childId of [...new Set(rows.map(row => id(row.orderId)).filter(value => value !== rootOrderId))]) { const current = await platform(context, 'GET', '逐项读取真实履约子单完成事实', rawPath(childId)) const child = current.data?.order const owned = success(current.response) && id(child?.id) === childId && id(child?.tenantId) === customerId && id(child?.customerId) === customerId && child?.orderRole === 'CHILD' && id(child?.rootOrderId) === rootOrderId && id(child?.parentOrderId) === rootOrderId && rows.filter(row => id(row.orderId) === childId).every(row => row.orderNo === child.orderNo) && statuses.includes(child?.status) context.assert(MODULE, '真实履约子单存在且客户根身份和状态有效', current.response, owned, `childOrderId=${childId}`, OPTIONS) if (!owned) return null children.push({ orderId: childId, orderNo: child.orderNo, status: child.status, rootOrderId }) } const pendingChildren = children.filter(child => !['COMPLETED', 'CANCELLED'].includes(child.status)) const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true const expectedCompleted = rootDelivered && pendingChildren.length === 0 const owned = context.createdResources.phase5OverseasFulfillment if (owned) owned.orderCompletionProof = { customerId, sourceOrderId, rootOrderId, tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount), cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining, children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId), splitRole: row.splitRole, packageCount: Number(row.packageCount) })) }, children, rootDelivered, expectedCompleted, pendingChildOrderIds: pendingChildren.map(child => child.orderId) } const domestic = await poll(() => context.request('GET', query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}`, { tenantId: customerId })), response => success(response) && id(response.body?.data?.summary?.orderId) === rootOrderId && (expectedCompleted ? response.body.data.summary.mainStatus === 'COMPLETED' : rootDelivered ? response.body.data.summary.mainStatus === 'DELIVERED' : nonBlank(response.body.data.summary.mainStatus) && response.body.data.summary.mainStatus !== 'COMPLETED'), pollAttempts, pollIntervalMs) if (expectedCompleted) root = await platform(context, 'GET', '全部有效分片结束后回查原始履约根', rawPath(rootOrderId)) const rawPassed = success(root.response) && (expectedCompleted ? root.data?.order?.status === 'COMPLETED' && root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED') : statuses.includes(root.data?.order?.status) && root.data.order.status !== 'COMPLETED' && !root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED')) const platformPassed = success(domestic.result) && id(domestic.result?.body?.data?.summary?.orderId) === rootOrderId && (expectedCompleted ? domestic.result.body.data.summary.mainStatus === 'COMPLETED' : rootDelivered ? domestic.result.body.data.summary.mainStatus === 'DELIVERED' : nonBlank(domestic.result.body.data.summary.mainStatus) && domestic.result.body.data.summary.mainStatus !== 'COMPLETED') context.assert(MODULE, expectedCompleted ? '全部有效子单签收后原始履约根COMPLETED' : '有效余量或根票未签收时原始履约根不得提前COMPLETED', root.response, rawPassed, `rootOrderId=${rootOrderId}, pendingChildren=${pendingChildren.map(row => row.orderNo).join(',')}`, OPTIONS) context.addStep(MODULE, expectedCompleted ? '同订单全部有效履约分片签收后统一订单进入COMPLETED' : '本票签收但有效余量未完时统一订单保留未完成状态', domestic.result, platformPassed, `rootOrderId=${rootOrderId}, pollRequests=${domestic.attempts}, mainStatus=${domestic.result?.body?.data?.summary?.mainStatus || 'missing'}`, OPTIONS) const customer = await merchant(context, merchantToken, 'GET', '客户读取本企业履约根真实完成状态', `/api/merchant/v1/orders/${encodeURIComponent(rootOrderId)}`) const customerPassed = success(customer.response) && id(customer.data?.summary?.orderId) === rootOrderId && customer.data?.summary?.mainStatus === domestic.result?.body?.data?.summary?.mainStatus && platformPassed context.assert(MODULE, expectedCompleted ? '客户本企业全部履约完成后订单最终状态为COMPLETED' : '客户本企业仍有有效未完成履约时不得展示COMPLETED', customer.response, customerPassed, `rootOrderId=${rootOrderId}, mainStatus=${customer.data?.summary?.mainStatus || 'missing'}`, OPTIONS) if (owned) Object.assign(owned.orderCompletionProof, { rawRootStatus: root.data?.order?.status, platformMainStatus: domestic.result?.body?.data?.summary?.mainStatus, customerMainStatus: customer.data?.summary?.mainStatus, rawPassed: Boolean(rawPassed), platformPassed, customerPassed }) return { rootOrderId, expectedCompleted, pendingChildren, passed: Boolean(rawPassed && platformPassed && customerPassed) } } // A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) || !Array.isArray(pendingInboundCodes) || pendingInboundCodes.length === 0 || pendingInboundCodes.includes(deliveredInboundCode)) { blocker(context, '同Order多有效入仓分片', '缺少同一订单一片已签收及另一有效片未签收的本轮资源') return false } const rows = [] for (const inboundCode of [deliveredInboundCode, ...pendingInboundCodes]) { const current = await platform(context, 'GET', `核对同Order分片${inboundCode}`, query('/api/platform/inbounds', { tenantId: customerId, inboundCode, page: 1, size: 200 })) rows.push(current.data?.records?.find(row => row.inboundCode === inboundCode)) } const sameOrder = rows.every(row => row && id(row.orderId) === id(orderId)) const validPartialCompletion = sameOrder && rows[0]?.processStatus === 'DELIVERED' && rows.slice(1).every(row => nonBlank(row.processStatus) && !['DELIVERED', 'CANCELLED'].includes(row.processStatus)) context.assert(MODULE, '多分片完成门禁使用同一Order且各入仓码有效', null, validPartialCompletion, `codes=${[deliveredInboundCode, ...pendingInboundCodes].join(',')}`, OPTIONS) if (!validPartialCompletion) return false const current = await platform(context, 'GET', '未全签收时回查订单完成门禁', query(`/api/platform/v1/orders/${encodeURIComponent(id(orderId))}`, { tenantId: customerId })) const blocked = nonBlank(current.data?.summary?.mainStatus) && current.data.summary.mainStatus !== 'COMPLETED' context.assert(MODULE, '同一Order仍有有效未签收分片不得提前COMPLETED', current.response, blocked, `mainStatus=${current.data?.summary?.mainStatus || 'missing'}`, OPTIONS) return sameOrder && blocked } export function returnedExceptionFactsMatch(detail, delivery, expected) { const order = detail?.order, shipment = delivery?.order const receipts = detail?.returnReceipts if (order?.status !== 'COMPLETED' || order.outboundNo !== expected.outboundNo || order.deliveryNo !== expected.deliveryNo || order.inboundCode !== expected.inboundCode || id(order.customerId) !== expected.customerId || !order.evidenceFileIds?.some(fileId => id(fileId) === expected.evidenceFileId) || !Array.isArray(detail.returnSources) || detail.returnSources.length !== 0 || !Array.isArray(receipts) || receipts.length === 0 || new Set(receipts.map(row => row.receiptNo)).size !== receipts.length || receipts.some(row => !nonBlank(row.receiptNo) || !nonBlank(row.receivedAt) || !Number.isSafeInteger(Number(row.receivedPackages)) || Number(row.receivedPackages) <= 0)) return false const received = receipts.reduce((sum, row) => sum + Number(row.receivedPackages), 0) const sent = Number(shipment?.packageCount) return received === Number(order.packageCount) && Number.isSafeInteger(sent) && sent >= received && shipment.outboundNo === expected.outboundNo && shipment.deliveryNo === expected.deliveryNo && id(shipment.customerId) === expected.customerId && shipment.status === (received === sent ? 'RETURNED' : 'RETURN_PENDING') } export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) { const owned = context.createdResources?.phase5OverseasFulfillment if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) { blocker(context, '同派送退回异常实收归属', '必须复用本轮createdResources内的原派送退回异常和原出库号') return null } const exceptionPath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}`, { customerId }) const before = await platform(context, 'GET', '读取本轮派送退回异常冻结来源', exceptionPath) const order = before.data?.order const sources = before.data?.returnSources if (!success(before.response) || !['PENDING', 'PROCESSING'].includes(order?.status) || order?.exceptionType !== 'RETURN' || order?.outboundNo !== outboundNo || !Array.isArray(sources) || sources.length !== 1) { blocker(context, '同派送退回异常实收', '需要本轮PENDING/PROCESSING退回异常及唯一原出库来源,不能借普通退货单替代', before.response) return null } const continuing = order.status === 'PROCESSING' const previousAttempt = resume || owned.returnReceiptAttempt if (continuing && (!/^\d+$/.test(id(previousAttempt?.targetPalletId)) || !nonBlank(previousAttempt?.targetPalletNo) || !/^\d+$/.test(id(previousAttempt?.evidenceFileId)) || (previousAttempt.exceptionNo && previousAttempt.exceptionNo !== exceptionNo) || (previousAttempt.outboundNo && previousAttempt.outboundNo !== outboundNo))) { blocker(context, '同派送退回实收续验资料', 'PROCESSING续验必须保留原目标托盘与已上传证据文件ID,不重复开始或重新建资源', before.response) return null } const source = sources[0] const sourceBalanceIds = owned.inventoryProof?.sourceBalanceIds || [] const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存', sourceBalanceIds) const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo) if (!stockBefore || !ledgerBefore) return null if (!stockBefore.some(balance => balance.positions.some(position => position.id === id(source.inventoryPositionId)))) { blocker(context, '退回原位置冻结归属', '原returnSource位置必须存在于正式排车冻结真实余额中') return null } const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}` const palletCode = continuing ? previousAttempt.targetPalletNo : `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) let targetPalletId = continuing ? id(previousAttempt.targetPalletId) : '' if (!continuing) { const created = await platform(context, 'POST', '创建本轮派送退回实收目标托盘', `${resourceBase}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: `${context.marker}-delivery-return` }) targetPalletId = id(created.data) if (!success(created.response) || !/^\d+$/.test(targetPalletId)) return null } const pallet = await platform(context, 'GET', '回查退回目标托盘实际库位', `${resourceBase}/pallets/${targetPalletId}`) const locations = await allPages(context, `${resourceBase}/locations`, {}, '读取退回目标托盘库位') const location = locations?.find(row => id(row.id) === id(pallet.data?.locationId)) context.assert(MODULE, '退回实收目标托盘和库位属于同一海外仓', pallet.response, id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode && Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS) if (!location?.locationCode) return null let processingVersion = version(order.version) let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' if (!continuing) { const startBody = { expectedVersion: version(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } const started = await platform(context, 'POST', '开始处理同一派送退回异常', query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody) if (!success(started.response) || started.data?.status !== 'PROCESSING') return null processingVersion = version(started.data.version) const evidence = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }), { name: `${context.marker}-return-receipt.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} synthetic return receipt evidence ${exceptionNo}`) }) const uploaded = context.apiSuccess(MODULE, '上传同派送退回异常实收证据', evidence, trace(evidence), OPTIONS) if (!success(evidence) || !/^\d+$/.test(id(uploaded?.fileId))) return null evidenceFileId = id(uploaded.fileId) } const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey) ? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE') if (processingVersion == null || !/^(0|[1-9]\d*)$/.test(String(processingVersion))) { blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response) return null } owned.returnReceiptAttempt = { exceptionNo, outboundNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId, completeIdempotencyKey } const completeBody = { expectedVersion: processingVersion, idempotencyKey: completeIdempotencyKey, handlingResult: `${context.marker}-实际退回一箱并恢复来源库存`, evidenceFileIds: [evidenceFileId], receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: id(source.inventoryPositionId), receivedPackages: 1, targetPalletId, targetPalletNo: palletCode, targetLocationId: id(location.id), targetLocationCode: location.locationCode }] } const completePath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/complete`, { customerId }) const completed = await platform(context, 'POST', '同派送退回异常完成实收及恢复库存', completePath, completeBody) context.assert(MODULE, '同派送退回异常完成实收恰好恢复一箱', completed.response, completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1 && /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS) if (!success(completed.response)) { const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存', sourceBalanceIds) const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo) context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response, sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS) return null } const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存', sourceBalanceIds) const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo) const targetPositions = [] for (const balance of stockAfter || []) { const positions = await platform(context, 'GET', '回查退回目标托盘实际库存位置', query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balance.id)}/positions`, { customerId, palletNo: palletCode })) if (Array.isArray(positions.data)) targetPositions.push(...positions.data.filter(row => row.palletNo === palletCode)) } context.assert(MODULE, '退回一箱实际落到新目标托盘和原票库存位置', null, targetPositions.length === 1 && targetPositions[0].inboundCode === order.inboundCode && id(targetPositions[0].palletId) === targetPalletId && id(targetPositions[0].warehouseLocationId) === id(location.id) && id(targetPositions[0].customerId) === customerId && id(targetPositions[0].overseasWarehouseId) === warehouseId && Number(targetPositions[0].availablePackages) === 1 && Number(targetPositions[0].totalPackages) === 1, `targetPositionCount=${targetPositions.length}, targetPallet=${palletCode}`, OPTIONS) const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id)) context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 && balanceQuantityTotal(stockAfter) - balanceQuantityTotal(stockBefore) === 1 && groupQuantityTotal(stockAfter) - groupQuantityTotal(stockBefore) === 1 && newRows?.length === 1 && newRows[0].deltaPackages === 1 && newRows[0].afterPackages - newRows[0].beforePackages === 1, `before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS) const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody) const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存', sourceBalanceIds) const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo) context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response, replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId) && sameSnapshot(stockAfter, stockReplay) && sameSnapshot(ledgerAfter, ledgerReplay), trace(replay.response), OPTIONS) const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath) const delivery = await platform(context, 'GET', '回查退回实收后的原派送单', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId })) context.assert(MODULE, '退回实收保留原出库和收货证据,派送状态按实际退回数量推进', final.response, returnedExceptionFactsMatch(final.data, delivery.data, { customerId, outboundNo, deliveryNo: order.deliveryNo, inboundCode: order.inboundCode, evidenceFileId }), `approved=${final.data?.order?.packageCount}, sent=${delivery.data?.order?.packageCount}, status=${delivery.data?.order?.status}; ${trace(final.response)}`, OPTIONS) return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId, inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages), targetPositionIds: targetPositions.map(row => id(row.id)) } } export async function saveReceiptAndComplete(context, delivery, customerId, index) { const deliveryNo = id(delivery.deliveryNo) const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath) const documentVersion = before.data?.version const validVersion = documentVersion != null && /^(0|[1-9]\d*)$/.test(String(documentVersion)) && (typeof documentVersion !== 'number' || Number.isSafeInteger(documentVersion)) context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response, validVersion, `version=${documentVersion ?? 'missing'}; ${trace(before.response)}`, OPTIONS) if (!success(before.response) || !validVersion) return null const uploadResponse = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`), }) const uploaded = context.apiSuccess(MODULE, `上传第${index + 1}张派送单签收文件`, uploadResponse, trace(uploadResponse), OPTIONS) const fileId = id(uploaded?.fileId) context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse, /^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS) if (!success(uploadResponse) || !fileId) return null if (context.createdResources.phase5OverseasFulfillment) { const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= [] receipts.push({ deliveryNo, customerId, fileId, content: `${context.marker} receipt acceptance for ${deliveryNo}` }) } const existing = (before.data?.categories || []).map((category) => ({ category: category.category, fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)), })).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0) const saveBody = { expectedVersion: version(documentVersion), idempotencyKey: key(context, 'SAVE-RECEIPT', deliveryNo), bolNo: before.data?.bolNo || null, documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }], } const saved = await platform(context, 'PUT', `保存第${index + 1}张派送单签收资料并自动完成`, documentsPath, saveBody) context.assert(MODULE, `第${index + 1}张派送单签收资料保存后已上传`, saved.response, saved.data?.categories?.some((category) => category?.category === 'RECEIPT' && category?.status === 'UPLOADED' && Number(category?.fileCount) === 1), trace(saved.response), OPTIONS) if (!success(saved.response)) return null const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody) context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response, savedReplay.data?.replayed === true && String(savedReplay.data?.version) === String(saved.data?.version), trace(savedReplay.response), OPTIONS) const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`, query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) context.assert(MODULE, `第${index + 1}张派送单签收保存即进入DELIVERED`, completed.response, completed.data?.order?.status === 'DELIVERED' && completed.data?.operations?.some((operation) => operation?.operationNode === 'RECEIPT_AUTO_COMPLETE'), trace(completed.response), OPTIONS) return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null } async function receiptBinary(context, token, path) { try { const response = await fetch(`${context.options.gateway}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: '*/*' }, signal: AbortSignal.timeout(120_000), }) const bytes = new Uint8Array(await response.arrayBuffer()) const contentType = response.headers.get('content-type') || '' let body = null if (/json/i.test(contentType)) { try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* keep non-JSON failure evidence */ } } return { http: response.status, body, bytes, contentType, requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } } catch (error) { return { http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error) } } } const hiddenOrForbidden = response => [403, 404].includes(response?.http) || (response?.http === 200 && ['403', '404', 'A0403', 'A0404'].includes(code(response))) export async function createSignedDocumentIsolationCustomer(context, { create = createMerchant } = {}) { // Own options, credentials and resource storage prevent the secondary fixture from changing the main customer. const secondary = Object.create(context) secondary.marker = `${context.marker}-SIGNED-B` secondary.options = { ...context.options, customerName: '' } secondary.context = { ...context.context } secondary.createdResources = {} await create(secondary, { publishEnvironment: false }) const fixture = secondary.context?.merchantQualificationFixture const safe = secondary.createdResources?.merchantQualificationFixture const metadata = { tenantId: id(safe?.tenantId), tenantName: safe?.tenantName, username: safe?.username, marker: safe?.marker } const ready = fixture?.marker === secondary.marker && metadata.marker === secondary.marker && /^\d+$/.test(metadata.tenantId) && metadata.tenantId === id(fixture?.tenantId) && nonBlank(fixture?.username) && nonBlank(fixture?.password) context.assert(MODULE, '签收资料跨企业验收具有独立SIGNED-B本轮客户', null, ready, `fixtureMarker=${metadata.marker || 'missing'}, customerIdPresent=${Boolean(metadata.tenantId)}`, OPTIONS) if (!ready) { blocker(context, 'SIGNED-B客户会话', '独立隔离客户前置创建不完整,不使用主客户替代') return null } context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer = metadata const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const auth = context.apiSuccess(MODULE, '独立SIGNED-B客户登录用于签收资料隔离', login, trace(login), OPTIONS) if (!success(login) || !nonBlank(auth?.accessToken)) { blocker(context, 'SIGNED-B客户会话', '独立客户登录未返回有效会话', login) return null } context.context.phase5TailVerification = { ...context.context.phase5TailVerification, foreignMerchantToken: auth.accessToken } return auth.accessToken } export async function verifyCustomerSignedDocuments(context, merchantToken, receiptFiles, foreignToken = null) { if (!nonBlank(merchantToken) || !Array.isArray(receiptFiles) || receiptFiles.length === 0) { blocker(context, '客户完成后签收资料', '缺少本轮真实已绑定签收文件与客户会话') return false } let verified = true if (foreignToken) { const own = await merchant(context, merchantToken, 'GET', '签收文件隔离核对本客户会话身份', '/api/merchant/v1/iam/tenant/profile') const foreign = await merchant(context, foreignToken, 'GET', '签收文件隔离核对另一客户会话身份', '/api/merchant/v1/iam/tenant/profile') const distinct = /^\d+$/.test(id(own.data?.id)) && /^\d+$/.test(id(foreign.data?.id)) && id(own.data.id) !== id(foreign.data.id) context.assert(MODULE, '签收文件隔离必须使用两个不同企业的真实客户会话', foreign.response, distinct, `distinctCustomerIdentity=${distinct}`, OPTIONS) if (!distinct) return false } for (const receipt of receiptFiles) { const base = `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(receipt.deliveryNo)}/documents/receipt` const detail = await merchant(context, merchantToken, 'GET', `客户读取已完成派送${receipt.deliveryNo}签收资料`, base) const bound = detail.data?.deliveryNo === receipt.deliveryNo && detail.data?.files?.some(file => id(file.fileId) === id(receipt.fileId)) context.assert(MODULE, '客户签收只读资料返回本单已绑定文件', detail.response, bound, trace(detail.response), OPTIONS) const downloadPath = `${base}/files/${encodeURIComponent(id(receipt.fileId))}/download` const download = await receiptBinary(context, merchantToken, downloadPath) const exactBytes = download.http === 200 && /application\/octet-stream/i.test(download.contentType) && new TextDecoder().decode(download.bytes) === receipt.content context.addStep(MODULE, '客户下载自身签收文件字节等于本轮上传内容', download, exactBytes, trace(download), OPTIONS) verified &&= Boolean(bound && exactBytes) const otherReceipt = receiptFiles.find(other => other.deliveryNo !== receipt.deliveryNo) if (otherReceipt) { const wrongFile = await receiptBinary(context, merchantToken, `${base}/files/${encodeURIComponent(id(otherReceipt.fileId))}/download`) context.addStep(MODULE, '客户不得将另一派送单签收文件挂到本单读取', wrongFile, hiddenOrForbidden(wrongFile), trace(wrongFile), OPTIONS) verified &&= hiddenOrForbidden(wrongFile) } if (foreignToken) { const deniedDetail = await context.request('GET', base, undefined, foreignToken, {}) context.addStep(MODULE, '另一客户不得读取本企业派送签收资料', deniedDetail, hiddenOrForbidden(deniedDetail), trace(deniedDetail), OPTIONS) const deniedFile = await receiptBinary(context, foreignToken, downloadPath) context.addStep(MODULE, '另一客户不得下载本企业派送签收文件', deniedFile, hiddenOrForbidden(deniedFile), trace(deniedFile), OPTIONS) verified &&= hiddenOrForbidden(deniedDetail) && hiddenOrForbidden(deniedFile) } } if (!foreignToken) { blocker(context, '客户签收文件跨企业隔离', '本轮缺少独立另一客户会话,自己的下载成功不能替代跨客户拒绝') return false } return verified } export function relabelOutboundMarksMatch(detail, relabel) { const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') .split(',').map(mark => mark.trim()).filter(Boolean)) const expected = relabel?.relabelCartonMarks || [] return expected.length > 0 && marks.length === expected.length && new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark)) && (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark)) && Number(detail?.outbound?.packageCount) === expected.length } // 客户原贴标费用只读等待结算回执,不以派送成功推定扣费成功。 export async function verifyClientRelabelSettlement(context, relabel, delay = () => new Promise(resolve => setTimeout(resolve, 1000))) { if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment !== relabel || relabel?.marker !== context.marker || !nonBlank(relabel.sourceOrderNo) || id(relabel.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId) || relabel.deliveryExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || !nonBlank(relabel.outboundNo)) throw Error('贴标结算缺少本轮原申请已交付证据') const evidence = relabel.settlement = { status: 'POLLING', observations: [] } try { for (let attempt = 0; attempt < 31; attempt++) { await context.checkpoint() const result = await platform(context, 'GET', '客户原贴标申请结算回查', `${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`) const application = result.data?.application const same = success(result.response) && application?.relabelNo === relabel.relabelNo && application.customerOrderNo === relabel.sourceOrderNo && id(application.overseasWarehouseId) === id(relabel.warehouseId) && Number(application.chargeAmount) === 0.01 && nonBlank(application.chargeCurrency) && Boolean(application.customerConfirmedAt) context.assert(MODULE, '结算回执属于原贴标申请及原费用', result.response, same, `relabelNo=${relabel.relabelNo}`, OPTIONS) if (!same) throw Error('原贴标申请结算身份或金额不一致') evidence.observations.push({ walletStatus: application.walletStatus, chargeAmount: application.chargeAmount, chargeCurrency: application.chargeCurrency, requestId: result.response.requestId, traceId: result.response.traceId }) await context.checkpoint() if (application.walletStatus === 'SETTLED') { evidence.status = 'SETTLED_HTTP_VERIFIED'; await context.checkpoint(); return evidence } if (!['FROZEN', 'SETTLE_PENDING'].includes(application.walletStatus)) throw Error(`贴标结算异常状态: ${application.walletStatus}`) if (attempt < 30) await delay() } throw Error('原贴标费用结算回执等待超时') } catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await context.checkpoint(); throw error } } // 原换标申请逐车完成,必须等前车释放复用资源,不能只出库不签收。 export async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId, helpers = { prepare: prepareFleetTrip, pick: pickAll, load: confirmFleetLoading, read: readAutomaticDispatchDelivery, sign: saveReceiptAndComplete, finish: finishFleetTrip, settle: verifyClientRelabelSettlement, }) { if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo) || !token || relabel.deliveryExecution || ![context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment].includes(relabel) || id(relabel.warehouseId ?? relabel.overseasWarehouseId) !== id(warehouseId)) throw Error('缺少本轮原换标排车、同仓PDA或履约已执行') const evidence = relabel.deliveryExecution = { status: 'RUNNING', marker: context.marker } const require = (name, passed) => { context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabel.relabelNo}`, OPTIONS) if (!passed) throw Error(name) } try { const proof = await helpers.prepare(context, MODULE, relabel.dispatchNo, warehouseId) evidence.tripNo = proof.tripNo require('原换标排车实际扫码完成', await helpers.pick(context, token, relabel.dispatchNo)) await helpers.load(context, MODULE, proof, token, relabel.loadingMeasurements) const transferred = await helpers.read(context, relabel.dispatchNo) const customerId = id(relabel.customerId) require('原换标排车生成唯一客户出库且派送无外来客户', transferred?.outbounds?.length === 1 && id(transferred.outbounds[0].customerId) === customerId && nonBlank(transferred.outbounds[0].outboundNo) && transferred.deliveries?.length > 0 && new Set(transferred.deliveries.map(row => row.deliveryNo)).size === transferred.deliveries.length && transferred.deliveries.every(row => id(row.customerId) === customerId && row.outboundNo === transferred.outbounds[0].outboundNo) && transferred.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === relabel.relabelCartonMarks.length) const outboundNo = transferred.outbounds[0].outboundNo const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`) require('排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo && dispatchOutboundMatches(detail.data, transferred.outbounds[0], relabel.dispatchNo, transferred.deliveries) && relabelOutboundMarksMatch(detail.data, relabel)) Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() }) for (const [index, delivery] of transferred.deliveries.entries()) { const signed = await helpers.sign(context, delivery, customerId, index) require('原换标货物经正式签收完成派送', signed?.order?.status === 'DELIVERED' && signed.order.deliveryNo === delivery.deliveryNo && id(signed.order.customerId) === customerId && signed.order.outboundNo === outboundNo) } evidence.closure = await helpers.finish(context, MODULE, proof) require('原换标车签收后车辆司机均已释放', evidence.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment === relabel) { const settled = await helpers.settle(context, relabel) require('客户原贴标申请费用已结算', settled?.status === 'SETTLED_HTTP_VERIFIED') } evidence.status = 'DELIVERED_HTTP_VERIFIED' await context.checkpoint() return evidence } catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message await context.checkpoint(); throw error } } // 主车结案后串行复用同仓PDA及车辆池,客户原申请与旧换标样本分别核验。 export async function finishRelabelDeliveries(context, mainTrip, warehouseId) { if (mainTrip.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('主车尚未释放,不能启动换标履约') const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 95, warehouseId, access) : null try { if (!picker?.token) throw Error('换标完整履约缺少可复用同仓PDA') for (const original of [context.createdResources.phase4OverseasRelabel, context.createdResources.phase4OverseasClientServices?.relabelFulfillment]) { await verifyRelabelDispatchOutbound(context, original, picker.token, warehouseId) } } finally { await disablePicker(context, picker, 95) if (picker) picker.token = null await context.checkpoint() } } async function loginMerchant(context, customerId) { const merchantFixture = context.context?.merchantQualificationFixture const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password) ? { username: merchantFixture.username, password: merchantFixture.password } : { username: context.options?.merchantUsername, password: context.options?.merchantPassword } if (!nonBlank(credentials.username) || !nonBlank(credentials.password)) { blocker(context, '取得本轮客户工作台账号', '既无本轮动态客户凭据,也未配置开发验收客户凭据') return null } const response = await context.request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password, }, '') const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS) context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken), `tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS) if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.accessToken)) return null const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken) const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse, trace(profileResponse), OPTIONS) context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse, id(profile?.id) === customerId, `merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS) return id(profile?.id) === customerId ? auth.accessToken : null } function fixture(context) { const dispatch = context.createdResources?.phase4OverseasDispatch const receiving = context.createdResources?.phase4OverseasPda if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'ACTIVE' || dispatch?.stage !== 'BEFORE_LOADING' || !nonBlank(dispatch?.dispatchNo)) return null if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED' || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId) || id(dispatch.warehouseId) !== id(receiving.overseasWarehouseId)) return null return { dispatch, receiving } } export async function verifyAutomaticDeparture(context, token, dispatchNo) { const ready = await pda(context, token, 'GET', '回查PDA自动发车结果', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const dispatch = await platform(context, 'GET', '独立回查平台自动发车结果', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const passed = success(ready.response) && success(dispatch.response) && [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo && data.status === 'ACTIVE' && data.stage === 'DELIVERING' && data.pickingReconciled === true && Number(data.counts?.packageCount) > 0 && Number(data.counts?.shippedPackageCount) === Number(data.counts?.packageCount)) && String(ready.data.recordVersion) === String(dispatch.data.recordVersion) context.assert(MODULE, '双端自动发车阶段与版本一致', ready.response, passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS) if (passed) version(ready.data.recordVersion) return passed ? ready : null } export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(detail.response) || detail.data?.status !== 'ACTIVE' || detail.data?.stage !== 'LOADING' || !Array.isArray(detail.data.items) || !detail.data.items.length) { blocker(context, 'PDA排车拣货任务', '本轮排车尚未通过两级审核进入LOADING', detail.response) return false } const items = detail.data.items for (const [itemIndex, original] of items.entries()) { // 每条真实命令前重新读取当前公共版本和可用来源,不从上次响应推算版本。 detail = await pda(context, token, 'GET', '扫码前刷新公共版本及真实来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) let item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) if (!success(detail.response) || !item) return false if (Number(item.scheduledPackages) === Number(item.pickedPackages)) continue let sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position })) if (item.sourceBusinessType === 'RELABEL_ORDER') { const originalRelabel = [context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment] .find(row => row?.marker === context.marker && row.relabelNo === item.sourceBusinessNo && id(row.customerId) === id(item.customerId)) if (!originalRelabel) throw Error('换标拣货缺少本轮原申请身份,不能猜测SKU件数') const relabel = await platform(context, 'GET', '扫码前读取原换标逐箱SKU与实际位置', `${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(item.sourceBusinessNo)}`) const value = relabel.data if (!success(relabel.response) || value?.application?.relabelNo !== item.sourceBusinessNo || id(value.application.overseasWarehouseId) !== id(originalRelabel.warehouseId ?? originalRelabel.overseasWarehouseId) || !Array.isArray(value.items)) throw Error('换标逐箱来源或仓库身份不一致') sources = value.items.filter(row => (item.positions || []).some(position => id(position.positionId) === id(row.sourceInventoryPositionId) && Number(position.availablePackages) > 0)).map(row => { if (!row.cartonMark || row.notifiedPackageCount !== 1 || !['CARTON_AND_SKU', 'SKU_LABEL'].includes(row.operationType) || !row.skuItems?.length || row.skuItems.some(sku => !sku.skuCode || !sku.newSkuCode || !Number.isSafeInteger(Number(sku.quantity)) || Number(sku.quantity) <= 0)) throw Error('换标原箱缺少明确逐SKU核验件数,不按箱数推算') return { position: item.positions.find(position => id(position.positionId) === id(row.sourceInventoryPositionId)), carton: { cartonMark: row.cartonMark, productQuantity: row.skuItems.reduce((sum, sku) => sum + Number(sku.quantity), 0) } } }) if (new Set(sources.map(row => row.carton.cartonMark)).size !== sources.length) throw Error('换标逐箱来源重复') } for (const [sourceIndex, source] of sources.entries()) { const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) if (remaining <= 0) break const available = source.carton ? 1 : Number(source.position.availablePackages) if (!Number.isSafeInteger(available) || available <= 0) continue const packages = Math.min(remaining, available) const body = { recordVersion: version(detail.data.recordVersion), positionId: source.position ? id(source.position.positionId) : null, cartonMark: source.carton?.cartonMark || null, packages, productQuantity: source.carton ? Number(source.carton.productQuantity) : packages, idempotencyKey: key(context, 'DISPATCH-PICK', `${dispatchNo}-${itemIndex}-${sourceIndex}`), remark: `${context.marker}-phase5-dispatch-pick` } const path = `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick` const picked = await pda(context, token, 'POST', `PDA真实扫码托盘${itemIndex + 1}来源${sourceIndex + 1}`, path, body) if (!success(picked.response)) return false const replay = await pda(context, token, 'POST', 'PDA扫码原命令重放', path, body) const stable = success(replay.response) && replay.data?.replayed === true && String(replay.data.recordVersion) === String(picked.data.recordVersion) && JSON.stringify(replay.data.result) === JSON.stringify(picked.data.result) && JSON.stringify(replay.data.counts) === JSON.stringify(picked.data.counts) context.assert(MODULE, 'PDA扫码重放保留原结果及版本数量', replay.response, stable, trace(replay.response), OPTIONS) if (!stable) return false if (itemIndex === 0 && sourceIndex === 0) { const stale = await context.request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-STALE` }, token, {}) context.addStep(MODULE, '旧公共版本扫码冲突且不得再扣库存', stale, conflict(stale), trace(stale), OPTIONS) if (!conflict(stale)) return false } detail = await pda(context, token, 'GET', '扫码后重新读取公共版本与数量', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) if (!success(detail.response) || !item) return false } if (Number(item.scheduledPackages) !== Number(item.pickedPackages)) { blocker(context, `PDA托盘${itemIndex + 1}足量库存`, '实际来源不足,保留本轮已扫证据', detail.response) return false } } const passed = detail.data?.stage === 'LOADING' && detail.data?.pickingReconciled === true && Number(detail.data?.counts?.packageCount) > 0 && Number(detail.data.counts.packageCount) === Number(detail.data.counts.pickedPackageCount) context.assert(MODULE, '真实扫码核平仍停留LOADING等待显式装车完成', detail.response, passed, trace(detail.response), OPTIONS) return passed } export async function readAutomaticDispatchDelivery(context, dispatchNo) { const current = await platform(context, 'GET', '回查装车后自动派送单据', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const value = current.data, deliveries = value?.documents?.deliveries, outbounds = value?.documents?.outbounds const positive = count => (typeof count === 'number' || typeof count === 'string') && /^[1-9][0-9]*$/.test(String(count)) && Number.isSafeInteger(Number(count)) const passed = success(current.response) && value?.dispatchNo === dispatchNo && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && value.pickingReconciled === true && positive(value.counts?.packageCount) && positive(value.counts.pickedPackageCount) && positive(value.counts.shippedPackageCount) && Number(value.counts.pickedPackageCount) === Number(value.counts.packageCount) && Number(value.counts.shippedPackageCount) === Number(value.counts.packageCount) && Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0 && new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length && new Set(outbounds.map(row => row.outboundNo)).size === outbounds.length && new Set(outbounds.map(row => id(row.customerId))).size === outbounds.length && new Set(deliveries.map(row => JSON.stringify([id(row.customerId), row.destination?.key]))).size === deliveries.length && deliveries.every(row => nonBlank(row.deliveryNo) && nonBlank(id(row.customerId)) && nonBlank(row.destination?.key) && row.status === 'DELIVERING' && positive(row.packageCount) && outbounds.some(outbound => outbound.outboundNo === row.outboundNo && id(outbound.customerId) === id(row.customerId))) && outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'OUTBOUNDED' && positive(row.packageCount) && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount)) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(value.counts.packageCount) context.assert(MODULE, '自动派送客户归属、单据状态和实发数量守恒', current.response, passed, trace(current.response), OPTIONS) if (!passed) return null return { ...current, outbounds, deliveries } } /** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */ export async function createDeliveryReturn(context, { customerId, warehouseId, deliveryNo, outboundNo }) { const eligibleSource = source => /^[1-9][0-9]*$/.test(id(source?.outboundPalletItemId)) && /^[1-9][0-9]*$/.test(id(source?.inventoryPositionId)) && source?.returnCartonId == null && Number.isSafeInteger(Number(source?.maxReturnPackages)) && Number(source.maxReturnPackages) > 0 const returnOptions = await platform(context, 'GET', '查询派送退回可选主单', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) const returnCandidate = Array.isArray(returnOptions.data) ? returnOptions.data.find((value) => /^[1-9][0-9]*$/.test(id(value?.lastMileOrderId)) && id(value?.customerId) === customerId && id(value?.overseasWarehouseId) === warehouseId && value?.deliveryNo === deliveryNo && value?.outboundNo === outboundNo && Array.isArray(value?.returnSources) && value.returnSources.some(eligibleSource)) : null context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, id(returnCandidate?.lastMileOrderId) && returnCandidate?.deliveryNo === deliveryNo && returnCandidate?.outboundNo === outboundNo, `candidate=${Boolean(returnCandidate)}; ${trace(returnOptions.response)}`, OPTIONS) if (!returnCandidate) return null const returnSource = Array.isArray(returnCandidate.returnSources) ? returnCandidate.returnSources.find(eligibleSource) : null context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response, Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS) if (!returnSource) return null const returnBody = { lastMileOrderId: id(returnCandidate.lastMileOrderId), reason: `${context.marker}-delivery-return`, returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128), items: [{ outboundPalletItemId: id(returnSource.outboundPalletItemId), inventoryPositionId: id(returnSource.inventoryPositionId), packageCount: 1, }], idempotencyKey: key(context, 'DELIVERY-RETURN'), } const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody) return { returned, returnBody } } /** 出库详情的客户归属来自关联派送明细,OutboundRow本身不声明customerId。 */ export function dispatchOutboundMatches(detail, reference, dispatchNo, deliveries) { const outbound = detail?.outbound const expected = Array.isArray(deliveries) ? deliveries.filter(row => row.outboundNo === reference?.outboundNo) : [] const actual = detail?.deliveries return Boolean(reference?.customerId && reference.outboundNo && dispatchNo && expected.length && outbound?.outboundNo === reference.outboundNo && outbound.dispatchNo === dispatchNo && outbound.status === 'OUTBOUNDED' && Number(outbound.packageCount) === Number(reference.packageCount) && Array.isArray(actual) && actual.length === expected.length && new Set(actual.map(row => row.deliveryNo)).size === actual.length && expected.every(row => id(row.customerId) === id(reference.customerId) && actual.some(value => value.deliveryNo === row.deliveryNo && value.outboundNo === reference.outboundNo && value.dispatchNo === dispatchNo && id(value.customerId) === id(reference.customerId) && Number.isSafeInteger(Number(value.packageCount)) && Number(value.packageCount) > 0 && Number(value.packageCount) === Number(row.packageCount))) && actual.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(outbound.packageCount)) } export async function run(context) { context.createdResources.phase5OverseasFulfillment = { marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [], visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null, cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence', } const source = fixture(context) if (!source) { blocker(context, '取得本轮四期已收货排车前置', `dispatchMarker=${context.createdResources?.phase4OverseasDispatch?.marker || 'missing'}, dispatchStatus=${context.createdResources?.phase4OverseasDispatch?.finalStatus || 'missing'}, receivingStatus=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) return } const dispatchNo = source.dispatch.dispatchNo const customerId = id(source.receiving.customerId) const warehouseId = id(source.receiving.overseasWarehouseId) context.createdResources.phase5OverseasFulfillment.dispatchNo = dispatchNo const merchantToken = await loginMerchant(context, customerId) if (!merchantToken) return context = await fulfillmentEvidenceContext(context) let beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) let fulfillmentDispatch = source.dispatch if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = { sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes, beforeEdit: beforeEdit.data } context.assert(MODULE, '排车来源和目的地与本轮生产者守恒', beforeEdit.response, dispatchDestinationConservation(beforeEdit.data,source.dispatch), trace(beforeEdit.response), OPTIONS) if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null let deliveries, outbounds, transferred try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } const looseboard = await attachLooseboardToTrip(context, tripProof, picker.token) fulfillmentDispatch = { ...source.dispatch, sourceKeys: [...source.dispatch.sourceKeys, ...looseboard.proof.sourceKeys] } beforeEdit = { ...beforeEdit, data: looseboard.detail } if (!dispatchDestinationConservation(beforeEdit.data, fulfillmentDispatch)) throw new Error('散板加货后原车来源或目的地不守恒') Object.assign(routeProof, { sourceKeys: fulfillmentDispatch.sourceKeys, beforeEdit: beforeEdit.data, looseboardIncomingNo: looseboard.proof.incomingNo }) const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return const customerIds = [...new Set(sourceTask.data.items.map(item => id(item.customerId)))] const detailCustomers = [...new Set(beforeEdit.data.orderDetails.map(row => id(row.customerId)))].sort() if (!customerIds.every(nonBlank) || JSON.stringify([...customerIds].sort()) !== JSON.stringify(detailCustomers)) { blocker(context, '整车真实客户集合', 'PDA客户与本轮原生详情不一致'); return } const proofs = context.createdResources.phase5OverseasFulfillment.inventoryByCustomer = [] for (const currentCustomer of customerIds) { const task = { ...sourceTask.data, items: sourceTask.data.items.filter(item => id(item.customerId) === currentCustomer) } const sources = dispatchInventorySources(task, dispatchNo, warehouseId) const inboundCodes = [...new Set(beforeEdit.data.orderDetails.filter(row => id(row.customerId) === currentCustomer) .map(row => row.inboundCode).filter(nonBlank))] if (!sources || !inboundCodes.length) { blocker(context, '整车客户库存生产证据', '缺少当前客户真实库存位置或入仓码'); return } const stockBefore = await inventorySnapshot(context, currentCustomer, warehouseId, inboundCodes, `客户${currentCustomer}扫码前库存`, sources.sourceBalanceIds) const ledgerBefore = await shipmentLedger(context, currentCustomer, dispatchNo, `客户${currentCustomer}扫码前SHIP`, 'SHIP', dispatchNo) if (!stockBefore || !ledgerBefore || !inventorySourcesPresent(stockBefore, sources.sourcePositionBalances)) return proofs.push({ customerId: currentCustomer, inboundCodes, ...sources, stockBefore, ledgerBefore, expectedPackages: task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) }) } await context.checkpoint() if (!await pickAll(context, picker.token, dispatchNo)) return for (const proof of proofs) { proof.stockAfter = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, `客户${proof.customerId}扫码后库存`, proof.sourceBalanceIds) proof.ledgerAfter = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}扫码后SHIP`, 'SHIP', dispatchNo) const conservation = dispatchStockConservation(context, { ...proof, warehouseId, dispatchNo }) proof.conservation = conservation.proof if (!conservation.passed) return } const primaryInventory = proofs.find(proof => proof.customerId === customerId) if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation } await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements) transferred = await readAutomaticDispatchDelivery(context, dispatchNo) if (!transferred) return deliveries = transferred.deliveries; outbounds = transferred.outbounds routeProof.deliveries = deliveries context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response, dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries), trace(transferred.response), OPTIONS) if (!dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries)) return const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo) if (!ready) return const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0) if (Number(ready.data.counts?.shippedPackageCount) !== expectedPackages) { blocker(context, '自动发车数量', '实发数量与本轮扫码来源不一致', ready.response); return } for (const proof of proofs) { proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, `客户${proof.customerId}发车后库存`, proof.sourceBalanceIds) proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo) const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances) && sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart) context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS) if (!unchanged) return } } finally { try { await disablePicker(context, picker, 5) } finally { if (picker) picker.token = null; await context.checkpoint() } } return continueDepartedFulfillment(context, { customerId, warehouseId, dispatchNo, tripProof, deliveries, outbounds, merchantToken }) } /** 已发车原单的签收、回访及结案阶段;不重新准备订单、扫码或发车。 */ export async function continueDepartedFulfillment(context, { customerId, warehouseId, dispatchNo, tripProof, deliveries, outbounds, merchantToken }) { const proof = context.createdResources?.phase5OverseasFulfillment if (!merchantToken || proof?.marker !== context.marker || proof.dispatchNo !== dispatchNo || tripProof?.dispatchNo !== dispatchNo || id(tripProof.warehouseId) !== warehouseId || !Array.isArray(deliveries) || !deliveries.length || !Array.isArray(outbounds) || !outbounds.length || proof.finalStatus || proof.receiptFiles?.length || tripProof.closure) throw Error('签收阶段必须使用本轮尚未签收的原发车证明') const departed = await readAutomaticDispatchDelivery(context, dispatchNo) if (!departed || JSON.stringify(departed.deliveries) !== JSON.stringify(deliveries) || JSON.stringify(departed.outbounds) !== JSON.stringify(outbounds)) throw Error('原发车单据与当前客户来源或数量不一致,停止签收') const primaryDelivery = deliveries.find(row => id(row.customerId) === customerId) if (!primaryDelivery) { blocker(context, '客户签收资料前置', '本轮主客户不在实际派送结果中'); return } const deliveryNo = primaryDelivery.deliveryNo, outboundNo = primaryDelivery.outboundNo const deliveryNos = deliveries.map(row => row.deliveryNo).sort() Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos, outbounds, deliveries }) for (const outboundRef of outbounds) { const outbound = await platform(context, 'GET', '逐客户回查发车后真实出库状态', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundRef.outboundNo)}`) const passed = dispatchOutboundMatches(outbound.data, outboundRef, dispatchNo, deliveries) context.assert(MODULE, '每客户出库单独立OUTBOUNDED且归属不漂移', outbound.response, passed, trace(outbound.response), OPTIONS) if (!passed) return } const customerDeliveries = deliveries.filter(row => id(row.customerId) === customerId) const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', query(MERCHANT_DELIVERY_BASE, { page: 1, size: 200, dispatchNo })) const merchantRows = merchantPage.data?.records const merchantScoped = Array.isArray(merchantRows) && customerDeliveries.every(delivery => merchantRows.some(row => row.deliveryNo === delivery.deliveryNo)) && merchantRows.every(row => id(row.customerId) === customerId) context.assert(MODULE, '客户只看本车自己的派送单', merchantPage.response, merchantScoped, trace(merchantPage.response), OPTIONS) if (!merchantScoped) return const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) context.assert(MODULE, '客户派送详情保留订单货物费用操作真实结构', merchantDetail.response, merchantDetail.data?.order?.deliveryNo === deliveryNo && Array.isArray(merchantDetail.data?.skus) && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), trace(merchantDetail.response), OPTIONS) for (const delivery of deliveries) { const current = await platform(context, 'GET', '逐客户核对发车后真实派送状态', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, { customerId: id(delivery.customerId) })) const passed = current.data?.order?.status === 'DELIVERING' && id(current.data.order.customerId) === id(delivery.customerId) && current.data.order.outboundNo === delivery.outboundNo context.assert(MODULE, '派送由实际发车进入DELIVERING并保留客户出库关联', current.response, passed, trace(current.response), OPTIONS) if (!passed) return } const completedDeliveries = [] for (const [index, delivery] of deliveries.entries()) { const completed = await saveReceiptAndComplete(context, delivery, id(delivery.customerId), index) if (!completed) return completedDeliveries.push(completed) } context.assert(MODULE, '每个目的FBA仓派送单均由各自签收文件自动完成', null, completedDeliveries.length === deliveries.length && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) const normalCompletion = await poll(() => context.request('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`), response => success(response) && response.body?.data?.status === 'ACTIVE' && response.body?.data?.stage === 'DELIVERED') context.assert(MODULE, '全部客户正常签收汇总整车DELIVERED', normalCompletion.result, success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS) if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data await verifyLooseboardDelivered(context, tripProof, deliveries) const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, removedComplete?.http === 404 || removedComplete?.http === 405 || (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))), trace(removedComplete), OPTIONS) const visits = await platform(context, 'GET', '按派送单回查签收自动生成的客户回访', query(`${LASTLEG_BASE}/customer-visits`, { page: 1, size: 200, customerId, keyword: deliveryNo })) const visitRow = Array.isArray(visits.data?.records) ? visits.data.records.find((value) => id(value?.deliveryNo) === deliveryNo) : null const visitId = id(visitRow?.id) context.assert(MODULE, '签收自动完成生成PENDING回访记录', visits.response, /^\d+$/.test(visitId) && visitRow?.status === 'PENDING', `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(visits.response)}`, OPTIONS) if (!visitId) return context.createdResources.phase5OverseasFulfillment.visitId = visitId const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId })) const inbound = Array.isArray(visitDetail.data?.inboundOrders) ? visitDetail.data.inboundOrders.find((value) => id(value?.inboundOrderId) && nonBlank(value?.lastMileOrderNo)) : null context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response, Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS) if (!inbound) return const domesticInbound = await poll(async () => context.request('GET', query('/api/platform/inbounds', { tenantId: customerId, inboundCode: inbound.inboundCode, page: 1, size: 20, })), (response) => success(response) && Array.isArray(response.body?.data?.records) && response.body.data.records.some((value) => value?.inboundCode === inbound.inboundCode && value?.processStatus === 'DELIVERED')) const inboundSummary = domesticInbound.result?.body?.data?.records?.find( (value) => value?.inboundCode === inbound.inboundCode) context.addStep(MODULE, '海外签收完成事件回推头程入仓单DELIVERED', domesticInbound.result, success(domesticInbound.result) && inboundSummary?.processStatus === 'DELIVERED', `inboundCode=${inbound.inboundCode}, pollRequests=${domesticInbound.attempts}, processStatus=${inboundSummary?.processStatus || 'missing'}; ${trace(domesticInbound.result)}`, OPTIONS) const sourceOrderId = id(inboundSummary?.orderId) if (!sourceOrderId) { blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result) return } context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId const rootCompletion = await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) if (!rootCompletion?.passed) return const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken || await createSignedDocumentIsolationCustomer(context) if (!await verifyCustomerSignedDocuments(context, merchantToken, context.createdResources.phase5OverseasFulfillment.receiptFiles?.filter(receipt => receipt.customerId === customerId), foreignMerchantToken)) return const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED', customerFeedback: `${context.marker}-customer-feedback`, remark: `${context.marker}-visit-completed`, attachments: [], }) context.assert(MODULE, '客户回访完成并冻结所选业务入仓码', savedVisit.response, savedVisit.data?.visit?.status === 'COMPLETED' && savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode && savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS) if (!success(savedVisit.response) || savedVisit.data?.visit?.status !== 'COMPLETED' || savedVisit.data.visit.selectedInboundCode !== inbound.inboundCode || savedVisit.data.visit.visitResult !== 'SATISFIED') return // 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。 if (context.context?.phase5TailVerification?.runReturnScenario !== true) { context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) await finishRelabelDeliveries(context, tripProof, warehouseId) await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true }) context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT' await context.checkpoint() return } const manualExceptionBody = { customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null, } let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) const manualExceptionNo = id(manualException.data?.exceptionNo) context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response, nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null const returnRequest = await createDeliveryReturn(context, { customerId, warehouseId, deliveryNo, outboundNo }) if (!returnRequest) return const { returned, returnBody } = returnRequest const returnExceptionNo = id(returned.data?.exceptionNo) context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response, nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING' && returnExceptionNo !== manualExceptionNo, trace(returned.response), OPTIONS) context.createdResources.phase5OverseasFulfillment.returnExceptionNo = returnExceptionNo || null const returnReplay = await platform(context, 'POST', '派送退回同键同载荷重放', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody) context.assert(MODULE, '派送退回重放复用同一异常单', returnReplay.response, returnReplay.data?.replayed === true && id(returnReplay.data?.exceptionNo) === returnExceptionNo, trace(returnReplay.response), OPTIONS) const delivery = await platform(context, 'GET', '回查派送退回终态', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) context.assert(MODULE, '派送退回后单据状态为RETURN_PENDING且保留出库关联', delivery.response, delivery.data?.order?.status === 'RETURN_PENDING' && delivery.data?.order?.outboundNo === outboundNo, trace(delivery.response), OPTIONS) const exceptionPage = await platform(context, 'GET', '分页回查本轮两类异常', query(`${LASTLEG_BASE}/exception-orders`, { page: 1, size: 20, customerId, keyword: context.marker })) const exceptionRows = Array.isArray(exceptionPage.data?.records) ? exceptionPage.data.records : [] context.assert(MODULE, '异常列表同时包含人工异常与派送退回异常', exceptionPage.response, exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo) && exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo), `records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS) const returnReceipt = await completeReturnedException(context, { customerId, warehouseId, exceptionNo: returnExceptionNo, outboundNo }) context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) await finishRelabelDeliveries(context, tripProof, warehouseId) await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true }) context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' }