import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' import { run as createMerchant } from './merchant-qualification-fixture.mjs' 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) { return value == null ? '' : String(value) } 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 } } function transferDeliveries(data) { return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] } function deliveryNumbers(data) { return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort() } 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)) } export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { const codes = new Set(inboundCodes) const candidates = new Map() for (const inboundCode of codes) { const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, { customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`) if (!current) return null for (const balance of current) { const owned = id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId const quantities = BALANCE_BUCKETS.every(field => balance[field] !== null && balance[field] !== undefined && balance[field] !== '' && Number.isSafeInteger(Number(balance[field])) && Number(balance[field]) >= 0) const previous = candidates.get(id(balance.id)) const stable = !previous || JSON.stringify(inventoryQuantitySnapshot([previous])) === JSON.stringify(inventoryQuantitySnapshot([balance])) context.assert(MODULE, `${name}余额客户仓库数量及重复读取一致`, null, owned && quantities && stable, `balanceId=${id(balance.id)}, owned=${owned}, quantities=${quantities}, stable=${stable}`, OPTIONS) if (!owned || !quantities || !stable) return null candidates.set(id(balance.id), balance) } } const snapshot = [] for (const balance of candidates.values()) { const current = await platform(context, 'GET', `${name}读取余额实际全部位置`, query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/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) === id(balance.id) && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && nonBlank(row.inboundCode) && POSITION_BUCKETS.every(field => row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0)) context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid, `balanceId=${id(balance.id)}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) if (!valid) return null 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=${id(balance.id)}, balanceTotal=${balance.totalPackages}`, OPTIONS) if (!reconciled) return null // Balance DTO need not expose inboundCode. Text-filtered child tickets cannot count as this exact source. const exact = positions.filter(row => codes.has(row.inboundCode)) if (!exact.length) continue snapshot.push({ id: id(balance.id), 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() }) } return snapshot.sort((a, b) => a.id.localeCompare(b.id)) } 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) return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode, palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages), beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) .sort((a, b) => a.id.localeCompare(b.id)) } const quantityTotal = rows => (rows || []).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) // 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 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 stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存') const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo) if (!stockBefore || !ledgerBefore) 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 = Number(order.version) let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' if (!continuing) { const startBody = { expectedVersion: Number(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 = Number(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 (!Number.isInteger(processingVersion) || processingVersion < 0) { 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], '退回实收失败后库存') 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], '退回实收后库存') 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 && 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], '退回重放后库存') 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, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response, final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo && final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId) && final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId) && id(row.inventoryPositionId) === id(source.inventoryPositionId)) && delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo, 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 version = Number(before.data?.version) context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response, Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) if (!success(before.response) || !Number.isInteger(version) || version < 0) 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, 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, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)), 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 && Number(savedReplay.data?.version) === Number(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 } async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) { blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据') return } const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照', `${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`, { ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') }) const outboundNo = transferred.data?.outboundNo if (!success(transferred.response) || !nonBlank(outboundNo)) return const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 })) context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response, success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo && detail.data?.outbound?.dispatchNo === relabel.dispatchNo && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) }) } 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 !== 'PENDING_DEPARTURE' || !nonBlank(dispatch?.dispatchNo)) return null if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED' || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null return { dispatch, receiving } } export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const items = Array.isArray(detail.data?.items) ? detail.data.items : [] if (!success(detail.response) || items.length === 0) { blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response) return false } for (const [itemIndex, item] of items.entries()) { let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0) const positions = Array.isArray(item.positions) ? item.positions : [] for (const [positionIndex, position] of positions.entries()) { if (remaining <= 0) break const available = Number(position.availablePackages || 0) if (available <= 0) continue const packages = Math.min(remaining, available) const body = { positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`), remark: `${context.marker}-phase5-dispatch-pick`, } const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`, `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) if (!success(picked.response)) return false const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`, `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response, success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages), trace(replay.response), OPTIONS) remaining -= packages } if (remaining !== 0) { blocker(context, `PDA托盘${itemIndex + 1}足量库存`, `scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response) return false } } const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') } const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response, completed.data?.status === 'READY_TO_DEPART' && Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages) && Number(completed.data?.remainingPackages) === 0, trace(completed.response), OPTIONS) if (!success(completed.response)) return false const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response, replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS) return success(replay.response) } 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 const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return const updateBody = { vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), driverName: `五期验收司机-${context.marker.slice(-6)}`, driverPhone: '19900000000', carrierName: '五期开发验收车队', estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(), } const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody) context.assert(MODULE, '排车编辑字段完整回显', updated.response, updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate && updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route, trace(updated.response), OPTIONS) if (!success(updated.response)) return 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 transferBody = { idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName, driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], } await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody) const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) const outboundNo = id(transferred.data?.outboundNo) const deliveries = transferDeliveries(transferred.data) const deliveryNos = deliveryNumbers(transferred.data) const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) const deliveryNo = deliveryNos[0] || '' context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length && new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank) && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' && deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY') && deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount) && Number(transferred.data?.packageCount) > 0, `outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS) if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos }) const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response, adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) const lockedItem = updated.data?.items?.[0] if (lockedItem?.id) { const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, Number(stable.data?.packageCount) === Number(updated.data?.packageCount) && JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS) } else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID') const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {}) context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo && transferConflict.body?.data?.replayed === true && JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS) const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo })) const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : [] context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response, deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number && id(value?.dispatchNo) === dispatchNo)), `records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS) const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response, id(merchantDetail.data?.order?.deliveryNo) === deliveryNo && id(merchantDetail.data?.order?.dispatchNo) === dispatchNo && nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode || merchantDetail.data?.order?.destinationWarehouseId)) && Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0 && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), `skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`, OPTIONS) const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] if (dispatchInboundCodes.length === 0) { blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') await disablePicker(context, picker, 5) return } const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存') // Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks. const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水') try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存') const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水') const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id)) const expectedPackages = Number(transferred.data.packageCount) context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages && balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(stockAfterPick) === expectedPackages && expectedPackages > 0 && shippedRows?.length > 0 && -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages && shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages), `before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS) const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response, ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS) if (!success(ready.response) || ready.data?.canDepart !== true) return const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, trace(departed.response), OPTIONS) if (!success(departed.response)) return const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response, departReplay.data?.replayed === true && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存') const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水') context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) context.createdResources.phase5OverseasFulfillment.inventoryProof = { inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), afterPackages: quantityTotal(stockAfterDepart), expectedPackages, shipmentLedgerIds: (shippedRows || []).map(row => row.id), canDepart: ready.data?.canDepart === true, } } finally { await disablePicker(context, picker, 5) if (picker) picker.token = null } const outbound = await platform(context, 'GET', '回查发车后出库单状态', query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 })) context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response, outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', trace(outbound.response), OPTIONS) for (const [index, delivery] of deliveries.entries()) { const currentNo = id(delivery.deliveryNo) const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`, query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId })) const deliveryVersion = Number(beforeStatus.data?.order?.version) context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response, Number.isInteger(deliveryVersion) && deliveryVersion >= 0, `version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS) if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`, query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }), { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` }) context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response, delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) if (!success(delivering.response)) return } const completedDeliveries = [] for (const [index, delivery] of deliveries.entries()) { const completed = await saveReceiptAndComplete(context, 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 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 } const domesticOrder = await poll(async () => context.request('GET', query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })), (response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED') context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态', `/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`) context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response, customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED' && id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId, trace(customerCompletedOrder.response), OPTIONS) const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken || await createSignedDocumentIsolationCustomer(context) await verifyCustomerSignedDocuments(context, merchantToken, context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken) 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) 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) if (manualException.response.http === 0) { 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 returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) const returnInbound = Array.isArray(returnOptions.data) ? returnOptions.data.find((value) => Array.isArray(value?.returnSources) && value.returnSources.some((source) => id(source?.outboundPalletItemId) && id(source?.inventoryPositionId) && Number(source?.maxReturnPackages) > 0)) : null context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, id(returnInbound?.inboundOrderId) && returnInbound?.deliveryNo === deliveryNo && returnInbound?.outboundNo === outboundNo, `candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS) if (!returnInbound) return const returnSource = Array.isArray(returnInbound.returnSources) ? returnInbound.returnSources.find((value) => id(value?.outboundPalletItemId) && id(value?.inventoryPositionId) && Number(value?.maxReturnPackages) > 0) : null context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response, Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS) if (!returnSource) return const returnBody = { inboundOrderId: id(returnInbound.inboundOrderId), 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) 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.finalStatus = returnReceipt ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' }