From 6e35993e61ccadcd74776be7b3b59dc38ba6bc74 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 12:07:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=B4=BE=E9=80=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=AE=8C=E6=88=90=E6=B5=8B=E8=AF=95=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/README.md | 6 +- ...se5-overseas-fulfillment-contract.test.mjs | 20 +- .../phase5-overseas-fulfillment.mjs | 207 ++++++++++++++---- 3 files changed, 179 insertions(+), 54 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index f89e321df..86bbe7c79 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -43,6 +43,8 @@ export async function run(context) { - 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。 +- `orders.mjs` 创建草稿前读取正式 `inbound-code-policy`:自动模式不传入仓码,手工模式按本轮 marker 生成短且稳定的测试入仓码,确保箱唛二维码长度有效;策略读取失败或矛盾立即停止,不修改系统全局配置、不重用历史订单。 + - `dropship-outbound-edge-cases.mjs` 是 `dropship-pallet-matrix` 内部探针,不单独运行:使用本轮库存验证超量、错误 SKU、不存在/重复箱唛、箱数不符、同键异载荷、未拣齐完成、并发同键创建、未拣取消和重放。单票单托额外先拣一箱再拣剩余箱;部分拣货被拒时仍保留失败及部署阻断,只有回查冻结事实无变化才继续整条明细拣货以采集后续证据,不能将此计为分批通过。多票共托使用同一 SKU,来源区分必须依赖入仓码。成功数据保留,未拣并发探针出库单正常取消。本扩展不代表跨客户、跨仓、多 SKU 混箱、逐箱不等件数、跨区故障注入均已覆盖,结论以当轮真实报告为准。 - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 @@ -142,7 +144,7 @@ export async function run(context) { | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | -| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | +| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 @@ -167,7 +169,7 @@ node scripts/admin-test-env-acceptance.mjs --gateway --ove 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 -海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为 `WAIT_OUTBOUND + WAIT_DELIVERY`,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后覆盖派送完成、回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 +海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 40a0dbe67..369f3757c 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -25,7 +25,7 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick', '/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}', - '/delivery-orders/${encodeURIComponent(deliveryNo)}/status', + '/delivery-orders/${encodeURIComponent(currentNo)}/status', ]) assert.ok(source.includes(token), token) assert.match(source, /WAIT_OUTBOUND/) assert.match(source, /WAIT_DELIVERY/) @@ -33,7 +33,9 @@ test('phase-five module covers dispatch edit, transfer, PDA pick/depart and deli assert.match(source, /OUTBOUNDED/) assert.match(source, /DELIVERING/) assert.match(source, /expectedVersion: deliveryVersion/) - assert.match(source, /status: 'DELIVERED', expectedVersion: completeVersion/) + assert.match(source, /transferDeliveries\(transferred\.data\)/) + assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/) + assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/) }) test('phase-five module proves the client delivery create, list and nested detail contract', () => { @@ -53,9 +55,10 @@ test('phase-five module proves the client delivery create, list and nested detai assert.match(source, /merchantDetail\.data\?\.operations/) }) -test('phase-five module proves completion, customer visit, manual exception and delivery return', () => { +test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { for (const token of [ - '/delivery-orders/${encodeURIComponent(deliveryNo)}/complete', + '/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'", + 'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE', '/customer-visits/${encodeURIComponent(visitId)}', "visitResult: 'SATISFIED'", '/exception-orders', '/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'", @@ -64,6 +67,15 @@ test('phase-five module proves completion, customer visit, manual exception and "idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放', 'RETURN_PENDING_WITH_COMPLETED_VISIT', ]) assert.ok(source.includes(token), token) + assert.match(source, /saved\.data\?\.categories\?\.some/) + assert.match(source, /completed\.data\?\.order\?\.status === 'DELIVERED'/) + assert.match(source, /人工确认派送完成接口已移除/) + assert.match(source, /query\('\/api\/platform\/inbounds'/) + assert.match(source, /inboundCode: inbound\.inboundCode/) + assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/) + assert.match(source, /mainStatus === 'COMPLETED'/) + assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/) assert.match(source, /manualExceptionNo/) assert.match(source, /returnExceptionNo/) }) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 4f03048f4..7df885a99 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -48,6 +48,73 @@ async function merchant(context, token, method, name, path, body = undefined) { 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 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.requestOverseasMultipart('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 + + 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 +} + export function relabelOutboundMarksMatch(detail, relabel) { const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') .split(',').map(mark => mark.trim()).filter(Boolean)) @@ -74,7 +141,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { 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, deliveryNo: transferred.data?.deliveryNo }) + Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) }) } async function loginMerchant(context, customerId) { @@ -170,7 +237,7 @@ async function pickAll(context, token, dispatchNo) { export async function run(context) { context.createdResources.phase5OverseasFulfillment = { - marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, + 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', } @@ -220,18 +287,25 @@ export async function run(context) { const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) const outboundNo = id(transferred.data?.outboundNo) - const deliveryNo = id(transferred.data?.deliveryNo) - context.assert(MODULE, '转派送同时生成WAIT_OUTBOUND与WAIT_DELIVERY单据', transferred.response, - nonBlank(outboundNo) && nonBlank(deliveryNo) && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' - && transferred.data?.deliveryStatus === 'WAIT_DELIVERY' && Number(transferred.data?.packageCount) > 0, - `outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS) - if (!success(transferred.response) || !outboundNo || !deliveryNo) return - Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo }) + 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 - && id(adminReplay.data?.deliveryNo) === deliveryNo, trace(adminReplay.response), OPTIONS) + && 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.requestOverseas('DELETE', @@ -246,24 +320,28 @@ export async function run(context) { query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo - && id(transferReplay.data?.deliveryNo) === deliveryNo, trace(transferReplay.response), OPTIONS) + && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) const transferConflict = await context.requestOverseas('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, trace(transferConflict), OPTIONS) + && 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, '客户派送列表包含本轮单据且不依赖customerId参数', merchantPage.response, - merchantRows.some((value) => id(value?.deliveryNo) === deliveryNo && id(value?.dispatchNo) === dispatchNo), + 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)}`, @@ -301,42 +379,52 @@ export async function run(context) { outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', trace(outbound.response), OPTIONS) - const deliveryBeforeStatus = await platform(context, 'GET', '读取派送状态更新即时版本', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) - const deliveryVersion = Number(deliveryBeforeStatus.data?.order?.version) - context.assert(MODULE, '派送详情提供状态乐观锁版本', deliveryBeforeStatus.response, - Number.isInteger(deliveryVersion) && deliveryVersion >= 0, - `version=${deliveryBeforeStatus.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeStatus.response)}`, OPTIONS) - if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return - const delivering = await platform(context, 'PUT', '派送单推进配送中', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, { customerId }), - { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering` }) - context.assert(MODULE, '派送状态进入DELIVERING', delivering.response, - delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) - if (!success(delivering.response)) return - const deliveryBeforeComplete = await platform(context, 'GET', '读取派送完成即时版本', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) - const completeVersion = Number(deliveryBeforeComplete.data?.order?.version) - context.assert(MODULE, '配送中详情版本已递增', deliveryBeforeComplete.response, - Number.isInteger(completeVersion) && completeVersion > deliveryVersion, - `before=${deliveryVersion}, after=${deliveryBeforeComplete.data?.order?.version ?? 'missing'}; ${trace(deliveryBeforeComplete.response)}`, - OPTIONS) - if (!Number.isInteger(completeVersion) || completeVersion <= deliveryVersion) return - const completed = await platform(context, 'POST', '确认派送完成并生成客户回访', + 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.requestOverseas('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), - { status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` }) - const visitId = id(completed.data?.visitId) - context.assert(MODULE, '派送完成生成PENDING回访记录', completed.response, - completed.data?.deliveryStatus === 'DELIVERED' && completed.data?.visitStatus === 'PENDING' - && /^\d+$/.test(visitId), `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(completed.response)}`, OPTIONS) - if (!success(completed.response) || !visitId) return + { 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 completeReplay = await platform(context, 'POST', '确认派送完成重复调用', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), - { status: 'DELIVERED', expectedVersion: completeVersion, remark: `${context.marker}-delivered` }) - context.assert(MODULE, '派送完成重放复用同一回访', completeReplay.response, - completeReplay.data?.replayed === true && id(completeReplay.data?.visitId) === visitId, - trace(completeReplay.response), OPTIONS) const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId })) @@ -345,6 +433,29 @@ export async function run(context) { 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) + const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED',