更新派送自动完成测试验收

This commit is contained in:
2026-09-15 12:07:10 +08:00
parent c1f01e1e87
commit 6e35993e61
3 changed files with 179 additions and 54 deletions
+4 -2
View File
@@ -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 <development-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
@@ -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/)
})
@@ -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',