This commit is contained in:
2026-09-14 12:00:48 +08:00
parent fb66d9200a
commit e55ab73e61
11 changed files with 288 additions and 9 deletions
+27
View File
@@ -62,6 +62,33 @@ export async function run(context) {
## 近期业务模块与静态合同快速索引
### 合并后的海外增量覆盖
本节核对 `d4fec829` 首父至 `fb66d920` 的海外后端差异及当前 Admin 调用端。
复用已注册默认模块,不新增独立造数入口;定向执行仍由总入口展开本轮客户、国内履约和海外 PDA 前置。
离线合同 `merged-overseas-coverage-contract.test.mjs` 锁定默认注册、定向依赖、陈旧 fixture 拒绝及新增断言。
离线通过只证明脚本合同,不证明部署环境已通过。
| 新增功能 | 全量模块 | 本轮 fixture / 依赖 | 正常与失败断言 |
|---|---|---|---|
| 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 |
| 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 |
| 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 |
| 排车编辑、BOL、后台转派送 | `phase5-overseas-fulfillment` | `phase4-overseas-dispatch` 生成的主链排车 | 复用车辆编辑和客户转派送;新增 BOL content-type 与 %PDF- 签名检查;后台同载荷转派送复用同一出库/派送业务号;生成出库后移除托盘 A0409 且数量和身份不漂移;原 PDA 拣货/发车、派送、异常和回访继续执行 |
| 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 |
| PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture |
明确缺口及待验收(不能计作已覆盖通过):
- 转运 `Review.remark` 仅有长度限制,`WarehouseTransferService.review` 未校验空驳回原因,也未将审核原因保存到详情或历史。新增空原因负向 HTTP 断言会在当前实现失败,须业务实现专项修复;原 `Detail.remark` 是申请备注,不能冒充审核原因。
- 已核对专用 `WAREHOUSE_TRANSFER_APPROVE` Controller 注解;本轮 HTTP 增量覆盖客户 Token 拒绝,但“平台仅列表/仅管理无审核权限”的独立角色矩阵仍缺 fixture。不能用管理员成功替代最小权限验证。
- 新到仓转运本轮只验证审核落到 PDA 前置状态;`PdaWarehouseTransferController` 托盘化、客户确认、可选换标与后续预排完整新链尚未编排。不得声称 WAIT_PDA_RECEIVING 就是 PDA 已收货。
- 合并新增退货单换标候选及创建换标接口、换标申请编辑能力,尚需各自独立回归。退货出库需另补当前退货箱 fixture 到 RETURN_OUTBOUND 的完整链,不由普通派送退回覆盖。退货模块本轮已将旧 `sourceBatchNo=MERGED` 断言改为实际扣减来源批次去重、真实来源退货单,并补客户箱号分页与标签 SVG 条码;条码实际扫码可读性仍由人工验收。
- 智能预排跨客户/跨仓候选混选、不同平台操作员锁隔离与容量边界尚缺独立反向 fixture;本次同操作员不同草稿锁冲突不能替代它们。
- UI 专项由人工验收:审核弹窗驳回原因必填、按钮权限、列表状态刷新;排车编辑和移除交互、智能预排拖放/草稿恢复、BOL 排版与打印、请求数量。PDF 签名验证不证明打印样式正确。
所有真实 HTTP 场景待当前轮另行授权后运行;不读取真实客户 Excel、不造历史样本、不执行 SQL 或部署。
| 业务链路 | HTTP 模块/共享前置 | 离线静态合同 |
|---|---|---|
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { parseArguments } from '../admin-test-env-acceptance.mjs'
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
const read = name => readFile(new URL(name, import.meta.url), 'utf8')
test('merged scenarios remain in default and focused dependency chains', () => {
const defaults = parseArguments([]).modules
for (const module of ['phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment']) {
assert.ok(defaults.includes(module))
const focused = parseArguments(['--modules', module]).modules
assert.ok(focused.indexOf('merchant-qualification-fixture') >= 0)
assert.ok(focused.indexOf('phase4-overseas-pda-operations') < focused.indexOf(module))
}
})
test('review helper refuses stale fixture before any request', async () => {
const steps = []
await verifyWarehouseTransferReview({ marker: 'current', context: { merchantQualificationFixture: { marker: 'old' } },
addStep: (...args) => steps.push(args), requestOverseas: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1')
assert.equal(steps.length, 1)
assert.equal(steps[0][3], false)
assert.equal(steps[0][5].deploymentBlocker, true)
})
test('transfer review integration covers both source types and known blank-reason failure', async () => {
const source = await read('warehouse-transfer-review.mjs')
const caller = await read('phase4-overseas-client-services.mjs')
assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/)
for (const value of ['WAIT_PDA_RECEIVING', 'PENDING_ASSIGNMENT', 'WAIT_DELIVERY', 'CANCELLED',
'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)',
'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"])
assert.ok(source.includes(value), value)
assert.doesNotMatch(source, /fetch\(|mysql|process\.env|\/internal\//)
const controller = await read('../../lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java')
assert.match(controller, /WAREHOUSE_TRANSFER_APPROVE/)
})
test('draft and removal checks use actual IDs plus readback rather than snapshot-only success', async () => {
const source = await read('phase4-overseas-dispatch.mjs')
for (const value of ['drafts/containers', 'draftContainerId: containerId', 'container.palletIds',
'draft-locks/unlock', 'deletedDrafts', 'removableItem.id', 'removedDetail.data?.packageCount', 'freed.data.some'])
assert.ok(source.includes(value), value)
})
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => {
const source = await read('phase5-overseas-fulfillment.mjs')
for (const value of ['requestOverseasBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map'])
assert.ok(source.includes(value), value)
})
test('return merge sources are actual consumed batches, not a synthetic MERGED identifier', async () => {
const source = await read('phase4-overseas-return-receipt.mjs')
assert.doesNotMatch(source, /sourceBatchNo === 'MERGED'/)
for (const value of ['consumedBatches', 'new Set(actualBatches)', 'sourceReturnNo === returnNo',
'客户箱号管理按实物箱分页', '标签包含SVG条码和当前箱号']) assert.ok(source.includes(value), value)
})
@@ -1,3 +1,5 @@
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
const MODULE = 'phase4-overseas-client-services'
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -196,7 +198,7 @@ async function verifyTransfer(context, token, orders) {
&& detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0,
`status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.transferNo = transferNo
return true
return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)
}
async function verifyDiscard(context, token, markerOrders) {
@@ -244,7 +246,7 @@ export async function run(context) {
context.createdResources.phase4OverseasClientServices = {
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
finalStatuses: { relabel: null, transfer: null, discard: null },
cleanup: 'Merchant applications remain in their legal initial review/processing states; no outbound, dispatch, PDA or delivery action is invoked',
cleanup: 'Relabel/discard remain pending; transfers are reviewed with separate incoming approval/rejection fixtures; no outbound, dispatch, PDA or delivery action is invoked',
}
const token = await loginMerchant(context)
if (!token) return
@@ -269,7 +271,7 @@ export async function run(context) {
const discardDone = await verifyDiscard(context, token, orders)
context.createdResources.phase4OverseasClientServices.finalStatuses = {
relabel: relabelDone ? 'PENDING_PROCESSING' : null,
transfer: transferDone ? 'PENDING_REVIEW' : null,
transfer: transferDone ? 'APPROVED' : null,
discard: discardDone ? 'PENDING_REVIEW' : null,
}
}
@@ -124,6 +124,30 @@ export async function run(context) {
const planId = id(candidate.planId)
const planPalletId = id(candidate.planPalletId)
// Exercise removal on a disposable dispatch, then reuse the released current-run pallet.
const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, {
planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`,
})
const removableNo = id(removable.data?.dispatchNo)
const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId)
if (!success(removable.response) || !removableNo || !removableItem?.id) {
blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response)
return
}
const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}`
const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath)
const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`)
context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response,
success(removed.response) && success(removedDetail.response)
&& Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0
&& removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS)
const removedAgain = await context.requestOverseas('DELETE', removePath)
context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain,
removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS)
const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
context.assert(MODULE, '正式锁释放后候选恢复', freed.response,
Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS)
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
const snapshotBody = { draftToken, planIds: [planId], containersJson }
@@ -145,7 +169,28 @@ export async function run(context) {
&& matchingDrafts[0].containersJson === containersJson,
`matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS)
const lockBody = { planPalletId, draftToken }
const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 })
const containerId = id(disposableContainer.data?.id)
if (!success(disposableContainer.response) || !containerId) return
const boundBody = { planPalletId, draftToken, draftContainerId: containerId }
const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody)
if (!success(bound.response)) return
const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`)
context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response,
boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container =>
id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS)
await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody)
const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
context.assert(MODULE, '草稿解锁恢复候选', unlocked.response,
unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS)
await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody)
await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`)
context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response,
Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS)
const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 })
if (!success(finalContainer.response) || !finalContainer.data?.id) return
const lockBody = { planPalletId, draftToken, draftContainerId: id(finalContainer.data.id) }
const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody)
if (!success(locked.response)) return
const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody)
@@ -290,10 +290,33 @@ export async function run(context) {
if (success(merged.response)) {
context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response,
nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2
&& merged.data?.label?.sourceBatchNo === 'MERGED'
&& nonBlank(merged.data?.label?.sourceBatchNo) && merged.data?.label?.sourceBatchNo !== 'MERGED'
&& merged.data?.label?.sourceReturnNo === returnNo
&& Number(merged.data?.label?.items?.length) === 2
&& nonBlank(merged.data?.label?.printableHtml),
`cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS)
const consumedBatches = []
for (const [mark, batch, before] of [[cartonA, firstBatchNo, 2], [cartonB, secondBatchNo, 3]]) {
const source = await overseas(context, merchantToken, 'GET', '合箱后回查来源箱实际扣减',
`${RETURN_INVENTORY}/cartons/${encodeURIComponent(mark)}`)
if (success(source.response) && Number(source.data?.totalQuantity) < before) consumedBatches.push(batch)
}
const actualBatches = String(merged.data?.label?.sourceBatchNo || '').split(',')
context.assert(MODULE, '合箱来源批次精确等于实际消耗来源且去重', merged.response,
consumedBatches.length > 0 && new Set(actualBatches).size === actualBatches.length
&& JSON.stringify([...consumedBatches].sort()) === JSON.stringify([...actualBatches].sort()),
`expected=${consumedBatches.join(',')}, actual=${actualBatches.join(',')}`, OPTIONS)
const cartonPage = await overseas(context, merchantToken, 'GET', '客户箱号管理按实物箱分页',
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 20, returnNo }))
context.assert(MODULE, '箱号分页包含本轮合箱且来源退货单保持真实业务号', cartonPage.response,
records(cartonPage.data).some(row => row.cartonMark === merged.data.cartonMark
&& row.sourceReturnNo === returnNo && Number(row.totalQuantity) === 2), trace(cartonPage.response), OPTIONS)
const label = await overseas(context, merchantToken, 'GET', '客户读取本轮合箱条码标签',
`${RETURN_INVENTORY}/cartons/${encodeURIComponent(merged.data.cartonMark)}/label`)
context.assert(MODULE, '标签包含SVG条码和当前箱号', label.response,
label.data?.cartonMark === merged.data.cartonMark
&& /<svg\b/.test(label.data?.printableHtml || '')
&& label.data?.printableHtml?.includes(merged.data.cartonMark), trace(label.response), OPTIONS)
const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放',
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
@@ -172,6 +172,11 @@ export async function run(context) {
trace(updated.response), OPTIONS)
if (!success(updated.response)) return
const bol = await context.requestOverseasBinary('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),
@@ -192,6 +197,21 @@ export async function run(context) {
`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 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)
const lockedItem = updated.data?.items?.[0]
if (lockedItem?.id) {
const deniedRemove = await context.requestOverseas('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,
@@ -0,0 +1,83 @@
const PLATFORM = '/api/platform/v1/last-leg/warehouse-transfers'
const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers'
const MODULE = 'phase4-overseas-client-services'
const OPTIONS = { scenario: 'warehouse-transfer-review', deploymentBlocker: true }
const ok = r => r?.http === 200 && String(r.body?.code) === '200'
const rejected = (r, status) => r?.http === status || (r?.http === 200 && String(r.body?.code) === `A0${status}`)
const evidence = r => `HTTP=${r?.http}, code=${r?.body?.code}, requestId=${r?.requestId}, traceId=${r?.traceId}`
// Called by the registered client-services module, never a standalone fixture runner.
export async function verifyWarehouseTransferReview(context, token, customerId, sourceBody, transferNo) {
if (!customerId || String(customerId) === '0'
|| context.context?.merchantQualificationFixture?.marker !== context.marker) {
context.addStep(MODULE, '转运审核缺少本轮客户作用域', null, false, 'No historical fallback', OPTIONS)
return false
}
const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}`
const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}`
async function call(method, path, body, merchantToken) {
return context.requestOverseas(method, path, body, {}, merchantToken)
}
const initial = await call('GET', scoped(transferNo))
context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial,
ok(initial) && initial.body.data?.status === 'PENDING_REVIEW', evidence(initial), OPTIONS)
if (!ok(initial)) return false
const denied = await call('POST', reviewPath(transferNo), { decision: 'APPROVE' }, token)
context.addStep(MODULE, '客户Token没有平台专用转运审核权限', denied, rejected(denied, 403), evidence(denied), OPTIONS)
const missingScope = await call('POST', `${PLATFORM}/${encodeURIComponent(transferNo)}/review`, { decision: 'APPROVE' })
context.addStep(MODULE, '平台转运审核缺客户作用域拒绝', missingScope, rejected(missingScope, 400), evidence(missingScope), OPTIONS)
const invalid = await call('POST', reviewPath(transferNo), { decision: 'INVALID' })
context.addStep(MODULE, '非法审核决定拒绝', invalid, rejected(invalid, 400), evidence(invalid), OPTIONS)
const unchanged = await call('GET', scoped(transferNo))
context.addStep(MODULE, '审核负向后状态和分配零漂移', unchanged,
ok(unchanged) && unchanged.body.data?.status === 'PENDING_REVIEW'
&& JSON.stringify(unchanged.body.data?.allocations) === JSON.stringify(initial.body.data?.allocations), evidence(unchanged), OPTIONS)
// Existing-stock approval must not be confused with inbound-transit PDA receipt.
const approved = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker })
context.addStep(MODULE, '已有库存转运通过为APPROVED且主单待分配', approved,
ok(approved) && approved.body.data?.status === 'APPROVED'
&& approved.body.data?.orderStatus === 'PENDING_ASSIGNMENT', evidence(approved), OPTIONS)
if (!ok(approved)) return false
const replay = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker })
context.addStep(MODULE, '同决定重放不推进状态', replay,
ok(replay) && replay.body.data?.status === 'APPROVED' && replay.body.data?.transferNo === transferNo, evidence(replay), OPTIONS)
const reverse = await call('POST', reviewPath(transferNo), { decision: 'REJECT', remark: '反向审核应拒绝' })
context.addStep(MODULE, '已通过转运不能反向驳回', reverse, rejected(reverse, 409), evidence(reverse), OPTIONS)
const readback = await call('GET', `${MERCHANT}/${encodeURIComponent(transferNo)}`, undefined, token)
context.addStep(MODULE, '客户回查审核通过且未产生出库', readback,
ok(readback) && readback.body.data?.status === 'APPROVED' && readback.body.data?.actualShipAt == null
&& Array.isArray(readback.body.data?.allocations) && readback.body.data.allocations.length === 0, evidence(readback), OPTIONS)
for (const decision of ['APPROVE', 'REJECT']) {
const body = { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [],
idempotencyKey: `${context.marker}-TRANSIT-${decision}`.slice(0, 255) }
const created = await call('POST', MERCHANT, body, token)
const no = created.body?.data?.transferNo
context.addStep(MODULE, `创建本轮新到仓转运${decision}对照`, created,
ok(created) && Boolean(no) && created.body.data?.status === 'PENDING_REVIEW', evidence(created), OPTIONS)
if (!ok(created) || !no) return false
const result = await call('POST', reviewPath(no), { decision, remark: `${context.marker}-审核原因` })
const expected = decision === 'APPROVE' ? 'WAIT_PDA_RECEIVING' : 'REJECTED'
const expectedOrder = decision === 'APPROVE' ? 'WAIT_DELIVERY' : 'CANCELLED'
const detail = await call('GET', `${MERCHANT}/${encodeURIComponent(no)}`, undefined, token)
context.addStep(MODULE, `新到仓转运${decision}回查${expected}`, detail,
ok(result) && ok(detail) && detail.body.data?.status === expected
&& detail.body.data?.orderType === 'WAREHOUSE_TRANSFER'
&& detail.body.data?.orderStatus === expectedOrder
&& (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS)
context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status }
}
const reasonFixture = await call('POST', MERCHANT, { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [],
idempotencyKey: `${context.marker}-TRANSIT-REASON`.slice(0, 255) }, token)
const reasonNo = reasonFixture.body?.data?.transferNo
context.addStep(MODULE, '创建空驳回原因独立反向票', reasonFixture, ok(reasonFixture) && Boolean(reasonNo), evidence(reasonFixture), OPTIONS)
if (!ok(reasonFixture) || !reasonNo) return false
const emptyReason = await call('POST', reviewPath(reasonNo), { decision: 'REJECT', remark: '' })
context.addStep(MODULE, '空驳回原因应拒绝(当前服务端缺口)', emptyReason, rejected(emptyReason, 400), evidence(emptyReason), OPTIONS)
const reasonReadback = await call('GET', scoped(reasonNo))
context.addStep(MODULE, '空原因拒绝后仍待审核', reasonReadback,
ok(reasonReadback) && reasonReadback.body.data?.status === 'PENDING_REVIEW', evidence(reasonReadback), OPTIONS)
context.createdResources.phase4OverseasClientServices.reasonValidationTransferNo = reasonNo
return true
}