完成单机TEST统一入口与可靠投递回归收尾
This commit is contained in:
@@ -190,7 +190,7 @@ export async function run(context) {
|
||||
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
|
||||
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
|
||||
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(统一平台会话、匿名拒绝、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询与诊断证据;重复 GET 仅验证读取稳定,缺真实投递重放证据仍阻断;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
|
||||
@@ -202,7 +202,7 @@ export async function run(context) {
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
|
||||
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
海外仓业务 HTTP 验收使用 Auth 统一会话,仅通过 `--gateway` 访问 TEST Gateway;拒绝旧 `--overseas-gateway`、`ADMIN_OVERSEAS_GATEWAY` 和非 test 环境参数。平台、客户与 PDA 各用自己的账号访问令牌,权限、客户隔离及仓绑定仍须验证。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 访问令牌,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
|
||||
|
||||
@@ -217,7 +217,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
|
||||
FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <test-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes,phase4-overseas-wallet-and-pallet-cost
|
||||
```
|
||||
|
||||
只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。
|
||||
@@ -340,3 +340,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
|
||||
|
||||
|
||||
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
|
||||
|
||||
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
|
||||
|
||||
@@ -30,11 +30,11 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', ()
|
||||
assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s)
|
||||
})
|
||||
|
||||
test('system后刷新双Token且受限海外权限只接受403语义', () => {
|
||||
test('system后刷新统一Token且受限海外权限只接受403语义', () => {
|
||||
assert.match(acceptance, /module === 'system' && laterOverseasModule/)
|
||||
assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/)
|
||||
assert.match(acceptance, /system后新admin海外Token只读探针可用/)
|
||||
assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/)
|
||||
assert.match(acceptance, /system后新admin统一Token只读探针可用/)
|
||||
assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.expiresAt/)
|
||||
assert.match(system, /缺少canonical海外订单权限必须403/)
|
||||
assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/)
|
||||
assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/)
|
||||
|
||||
@@ -34,7 +34,7 @@ function record(edge, name, response, passed, evidence = '') {
|
||||
}
|
||||
|
||||
async function request(edge, method, path, body, pda = false) {
|
||||
return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken)
|
||||
return edge.context.request(method, path, body, pda ? edge.token : edge.context.token, {})
|
||||
}
|
||||
|
||||
async function candidates(edge) {
|
||||
|
||||
@@ -93,7 +93,7 @@ test('partial-pick rejection stays failed; a changed snapshot aborts further pic
|
||||
label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' },
|
||||
pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' },
|
||||
context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) },
|
||||
requestOverseas: async () => ({ body: { code: 'A0409' } }) },
|
||||
request: async () => ({ body: { code: 'A0409' } }) },
|
||||
check: (_name, _response, passed) => assert.ok(passed),
|
||||
call: async () => structuredClone(snapshot),
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
const bookingId = String(loading.cabinet.id)
|
||||
const base = '/api/platform/v1/last-leg'
|
||||
async function call(name, method, path, body, headers) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
|
||||
return data
|
||||
@@ -410,7 +410,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
let summary
|
||||
let summaryResponse
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`)
|
||||
const response = await context.request('GET', `${base}/orders/container-summaries/${bookingId}`)
|
||||
summaryResponse = response
|
||||
summary = response?.body?.code === '200' ? response.body.data : null
|
||||
if (summary?.orders?.length === group.length) break
|
||||
@@ -463,14 +463,14 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
try {
|
||||
check(`${label}海外PDA账号和安全投影可用`, null, Boolean(picker?.token))
|
||||
async function call(name, method, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, picker.token)
|
||||
const response = await context.request(method, path, body, picker.token, {})
|
||||
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
|
||||
return data
|
||||
}
|
||||
let candidate
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/candidates`, undefined, {}, picker.token)
|
||||
const response = await context.request('GET', `${base}/candidates`, undefined, picker.token, {})
|
||||
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo)
|
||||
if (candidate?.operable && candidate?.allPlansReady) break
|
||||
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
@@ -486,7 +486,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
check(`${label}签收成功`, null, signed.status === 'SIGNED')
|
||||
if (mixedContainer) {
|
||||
const completed = await receiveMixedContainer({ group, call, check, marker: context.marker,
|
||||
request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) })
|
||||
request: (method, path, body) => context.request(method, path, body, picker.token, {}) })
|
||||
for (const item of group) {
|
||||
const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST',
|
||||
'/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode })
|
||||
@@ -507,8 +507,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`)
|
||||
check(`${label}剩余托盘待收箱数准确`, null,
|
||||
Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted)
|
||||
const premature = await context.requestOverseas('POST', `${base}/current/complete`,
|
||||
{ idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token)
|
||||
const premature = await context.request('POST', `${base}/current/complete`, { idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, picker.token, {})
|
||||
check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409')
|
||||
const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`)
|
||||
check(`${label}未齐完成拒绝后事实不变且可继续收货`, null,
|
||||
@@ -555,7 +554,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
const customerId = String(group[0].task.tenantId)
|
||||
const warehouseId = group[0].overseas.warehouseId
|
||||
async function call(name, method, path, body, pda = false) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, pda ? token : context.overseasToken)
|
||||
const response = await context.request(method, path, body, pda ? token : context.token, {})
|
||||
const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && value != null)
|
||||
return value
|
||||
|
||||
@@ -1,49 +1,34 @@
|
||||
export const EVENT_BRIDGE_RECOVERY_FIXTURE = Object.freeze({
|
||||
customerCode: 'C10000000',
|
||||
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
|
||||
initialOutboxStatus: 'PENDING',
|
||||
initialRetryCount: 62,
|
||||
failedTarget: '172.31.198.8:9313',
|
||||
currentOutboxStatus: 'PENDING',
|
||||
currentRetryCount: 81,
|
||||
currentFailureCode: 'A0401',
|
||||
currentFailureMessage: '内部服务令牌无效',
|
||||
currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704',
|
||||
currentTraceId: 'e8e6d620c1cff40caa765e6968f89024',
|
||||
dockerDnsProven: true,
|
||||
// Pure evidence checks; no HTTP, SQL or historical incident data.
|
||||
export const TEST_DIRECT_DELIVERY_URLS = Object.freeze({
|
||||
LASTLEG_SERVICE_URL: 'http://lastleg-service:9303',
|
||||
FIRSTLEG_SERVICE_URL: 'http://firstleg:9202',
|
||||
FINANCE_SERVICE_URL: 'http://finance:9203',
|
||||
})
|
||||
|
||||
export const TEST_EVENT_BRIDGE_URLS = Object.freeze({
|
||||
LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312',
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312',
|
||||
})
|
||||
|
||||
export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze([
|
||||
'firstleg',
|
||||
'event-bridge-cn',
|
||||
'event-bridge-overseas',
|
||||
'lastleg-service',
|
||||
])
|
||||
|
||||
export function validTestEventBridgeEnvironment(values = {}) {
|
||||
return Object.entries(TEST_EVENT_BRIDGE_URLS)
|
||||
.every(([key, expected]) => values[key] === expected)
|
||||
&& Object.values(values).every((value) => !/^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::|\/|$)/.test(String(value)))
|
||||
export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze(['firstleg', 'lastleg-service', 'finance'])
|
||||
export function validDirectDeliveryEnvironment(values = {}) {
|
||||
return Object.entries(TEST_DIRECT_DELIVERY_URLS).every(([key, url]) => values[key] === url)
|
||||
}
|
||||
|
||||
// Callers must provide only a non-secret fingerprint/revision marker, never the token value.
|
||||
// Only provide non-secret credential revision/fingerprints, never tokens.
|
||||
export function validInternalTokenCohort(fingerprints = {}) {
|
||||
const values = INTERNAL_TOKEN_COHORT_SERVICES.map((service) => fingerprints[service])
|
||||
return values.every((value) => typeof value === 'string' && value.length > 0)
|
||||
&& new Set(values).size === 1
|
||||
const values = INTERNAL_TOKEN_COHORT_SERVICES.map(service => fingerprints[service])
|
||||
return values.every(value => typeof value === 'string' && value.trim().length > 0) && new Set(values).size === 1
|
||||
}
|
||||
|
||||
export function recoveredEventBridgeFacts(facts = {}) {
|
||||
return facts.firstlegOutboxStatus === 'SENT'
|
||||
&& Number(facts.overseasBridgeEventCount) === 1
|
||||
&& Number(facts.lastlegInboxEventCount) === 1
|
||||
&& Number(facts.lastlegOrderCount) === 1
|
||||
&& Number(facts.cnBridgeDuplicateCount) === 0
|
||||
&& Number(facts.manualReplayCount) === 0
|
||||
&& Number(facts.sqlMutationCount) === 0
|
||||
export function validDirectDeliveryEvidence(facts = {}) {
|
||||
const attempts = facts.attempts
|
||||
if (!facts.marker || !facts.eventId || !facts.payloadHash || !Array.isArray(attempts) || attempts.length < 1) return false
|
||||
if (!Number.isInteger(facts.maxAttempts) || facts.maxAttempts < 1 || facts.sqlMutationCount !== 0) return false
|
||||
if (!attempts.every(a => a.eventId === facts.eventId && a.payloadHash === facts.payloadHash
|
||||
&& Number.isFinite(Date.parse(a.at)) && a.requestId && a.traceId)) return false
|
||||
const failures = attempts.filter(a => ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT'].includes(a.failure))
|
||||
if (failures.length === 0 || attempts.some(a => a.failure && a.outcome)) return false
|
||||
if (failures.some(a => a.failure === 'TOKEN_REJECTED') && facts.status !== 'DEAD'
|
||||
&& facts.authorizedDeadRetry !== true) return false
|
||||
const successful = attempts.filter(a => ['APPLIED','DUPLICATE','STALE'].includes(a.outcome))
|
||||
if (facts.status === 'DEAD') return successful.length === 0 && (failures.length === facts.maxAttempts
|
||||
|| (failures.length === 1 && failures[0].failure === 'TOKEN_REJECTED'))
|
||||
&& facts.automaticRetryScheduled === false
|
||||
// A read-only snapshot alone can never prove a duplicate delivery happened.
|
||||
return facts.status === 'SENT' && failures.length < facts.maxAttempts
|
||||
&& successful.some(a => a.outcome === 'APPLIED' && a.ackLost === true) && successful.some(a => a.outcome === 'DUPLICATE')
|
||||
&& facts.inboxEventCount === 1 && facts.businessEffectCount === 1
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function settlementKey(context, operation) {
|
||||
function write(context, method, path, body, operation, token = context.token, overseas = false) {
|
||||
const headers = { 'Idempotency-Key': key(context, operation) }
|
||||
return overseas
|
||||
? context.requestOverseas(method, path, body, headers, token)
|
||||
? context.request(method, path, body, token, headers)
|
||||
: context.request(method, path, body, token, headers)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async function loadActiveSupplierAndWarehouse(context, fixture) {
|
||||
|
||||
async function publishDirectPaid(context, fixture, supplier, warehouse) {
|
||||
const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse)
|
||||
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
|
||||
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.token, true)
|
||||
const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse,
|
||||
diagnostic(createdResponse), OPTIONS)
|
||||
const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId })
|
||||
@@ -116,21 +116,19 @@ async function publishDirectPaid(context, fixture, supplier, warehouse) {
|
||||
`sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS)
|
||||
if (!targetId) return null
|
||||
|
||||
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
|
||||
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.token, true)
|
||||
const replay = data(replayResponse)
|
||||
context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse,
|
||||
ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId,
|
||||
`first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS)
|
||||
const conflictResponse = await write(context, 'POST', OVERSEAS_FEES,
|
||||
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true)
|
||||
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.token, true)
|
||||
context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse),
|
||||
diagnostic(conflictResponse), OPTIONS)
|
||||
|
||||
const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`,
|
||||
{ idempotencyKey: key(context, 'overseas-submit') })
|
||||
const submittedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/submit`, { idempotencyKey: key(context, 'overseas-submit') })
|
||||
context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS)
|
||||
const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`,
|
||||
{ decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
|
||||
const reviewedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
|
||||
context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS)
|
||||
return { sourceFeeId: targetId, body }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ test('merged scenarios remain in default and focused dependency chains', () => {
|
||||
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')
|
||||
addStep: (...args) => steps.push(args), request: () => { 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)
|
||||
@@ -46,7 +46,7 @@ test('draft and removal checks use actual IDs plus readback rather than snapshot
|
||||
|
||||
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',
|
||||
for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
@@ -158,7 +158,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
|
||||
assert.match(entry, /carrierLatestActualAt/)
|
||||
assert.match(pickupChain, /pickup\.latestActualAt/)
|
||||
assert.match(pickupChain, /pickup\?\.marker === variantMarker/)
|
||||
assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/)
|
||||
assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|request/)
|
||||
assert.match(entry, /dropshipReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /overseasReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /awaitingVariants\.length >= 1/)
|
||||
|
||||
@@ -36,8 +36,7 @@ function fixture(context) {
|
||||
}
|
||||
|
||||
async function listOrders(context, containerNo) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
return { response, rows: records(response?.body?.data).filter((row) => row?.containerNo === containerNo) }
|
||||
}
|
||||
|
||||
@@ -46,8 +45,7 @@ async function snapshot(context, source) {
|
||||
const plans = []
|
||||
for (const order of listed.rows) {
|
||||
if (!order?.inboundCode) continue
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`)
|
||||
plans.push(...records(response?.body?.data).map((row) => ({
|
||||
planId: id(row.planId), planNo: row.planNo, orderId: id(row.orderId), customerId: id(row.customerId), status: row.status,
|
||||
})))
|
||||
@@ -66,16 +64,14 @@ function convertBody(source) {
|
||||
}
|
||||
|
||||
async function convert(context, name, source, key) {
|
||||
const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT,
|
||||
convertBody(source), { 'Idempotency-Key': key }, context.overseasToken)
|
||||
const response = await context.request('POST', CONTAINER_CONVERT_ENDPOINT, convertBody(source), context.token, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
|
||||
return response
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const data = fixture(context)
|
||||
const summaryResponse = await context.requestOverseas('GET',
|
||||
`${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
const summaryResponse = await context.request('GET', `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
context.assert(MODULE, 'P1服务端柜级摘要跨订单分页仍返回15票', summaryResponse,
|
||||
success(summaryResponse) && validContainerSummaryPage(summaryResponse.body?.data, {
|
||||
sourceContainerBookingId: data.normal.sourceContainerBookingId,
|
||||
@@ -132,8 +128,7 @@ export async function run(context) {
|
||||
&& after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo),
|
||||
`planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS)
|
||||
|
||||
const readySummary = await context.requestOverseas('GET',
|
||||
`${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
const readySummary = await context.request('GET', `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
|
||||
context.assert(MODULE, 'P0全柜每票独立计划完成后allPlansReady', readySummary,
|
||||
success(readySummary) && validContainerSummaryPage(readySummary.body?.data, {
|
||||
sourceContainerBookingId: data.normal.sourceContainerBookingId,
|
||||
|
||||
@@ -22,7 +22,7 @@ function context(resources = {}) {
|
||||
const gaps = []
|
||||
const calls = []
|
||||
return {
|
||||
marker: 'MARKER-1', token: 'domestic-token', overseasToken: 'overseas-token',
|
||||
marker: 'MARKER-1', token: 'platform-token',
|
||||
createdResources: resources, steps, gaps, calls,
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata) {
|
||||
gaps.push({ module, scenario, name, reason, category, ...metadata })
|
||||
@@ -34,24 +34,28 @@ function context(resources = {}) {
|
||||
assert(module, name, value, passed, evidence, options) {
|
||||
return this.addStep(module, name, value, passed, evidence, options)
|
||||
},
|
||||
async requestOverseas(method, path, body, headers, token) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, token: token || this.overseasToken })
|
||||
if (token === this.token) return response(path, null, 403, 'A0403')
|
||||
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,path,body,token,headers) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, token: token || this.token })
|
||||
if (token === '') return response(path, null, 401, 'A0401')
|
||||
if (path.includes('/last-leg/orders/9001')) {
|
||||
return response(path, { order: { id: '9001', containerNo: 'MSCU1234567' } })
|
||||
}
|
||||
return response(path, { records: [{ id: '7001', containerNo: 'MSCU1234567' }] })
|
||||
},
|
||||
async request(method, path, body, token) {
|
||||
} : async function(method,path,body,token) {
|
||||
calls.push({ region: 'CN', method, path, token: token || this.token })
|
||||
if (token === this.overseasToken) return response(path, null, 403, 'A0403')
|
||||
if (token === '') return response(path, null, 401, 'A0401')
|
||||
if (path.includes('/overview')) return response(path, { container: {
|
||||
id: '3001', containerNo: 'MSCU1234567', unloadingCompletedAt: '2026-08-24T10:00:00Z',
|
||||
unloadingPhotoUploadedAt: '2026-08-24T09:00:00Z', estimatedEmptyReportedAt: '2026-08-27T10:00:00Z',
|
||||
actualEmptyReportedAt: null, emptyReportSourceVersion: 7,
|
||||
} })
|
||||
return response(path, { records: [{ container: { id: '3001', containerNo: 'MSCU1234567' } }] })
|
||||
},
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +114,11 @@ test('tail controls receive their own completed producer matrix before they cons
|
||||
assert.match(entry, /await this\.ensurePhase4PdaProducerMatrix\(\)/)
|
||||
})
|
||||
|
||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||
test('contract covers unified session, canonical 403, both event directions and diagnostics', () => {
|
||||
for (const token of [
|
||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||
'无会话不得访问Lastleg', '同一平台会话可访问国内授权详情', 'cn-overseas-event-not-delivered',
|
||||
'overseas-cn-event-not-delivered', 'requestId=', 'traceId=', 'pollRequests=',
|
||||
'装柜完成事件重试保持建单幂等', '海外柜完成事件重试保持回写幂等',
|
||||
'非事件重放证据', 'missing-direct-delivery-replay-evidence',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(system, /缺少canonical海外订单权限必须403/)
|
||||
assert.match(system, /restrictedOverseasToken/)
|
||||
@@ -127,13 +131,14 @@ test('full observable cross-region flow performs requests and passes', async ()
|
||||
phase4OverseasPda: { marker: 'MARKER-1', finalStatus: 'COMPLETED', customerId: '100', orderId: '9001' },
|
||||
})
|
||||
|
||||
await run(ctx)
|
||||
await assert.rejects(() => run(ctx), /missing|真实投递重试与同事件幂等证据/)
|
||||
|
||||
assert.ok(ctx.calls.length >= 8)
|
||||
assert.equal(ctx.steps.every((step) => step.passed), true)
|
||||
assert.equal(ctx.gaps.length, 0)
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'overseas-token'))
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'domestic-token'))
|
||||
assert.equal(ctx.steps.filter((step) => !step.name.includes('真实投递')).every((step) => step.passed), true)
|
||||
assert.equal(ctx.gaps.length, 1)
|
||||
assert.equal(ctx.gaps[0].category, 'missing-direct-delivery-replay-evidence')
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'CN' && call.token === 'platform-token'))
|
||||
assert.ok(ctx.calls.some((call) => call.region === 'OVERSEAS' && call.token === 'platform-token'))
|
||||
})
|
||||
|
||||
test('missing marker fixture is a blocking coverage gap after real security request', async () => {
|
||||
|
||||
@@ -32,16 +32,12 @@ function sameIds(left, right) {
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
if (!context.token || !context.overseasToken) {
|
||||
blockingGap(context, '双Token前置', '国内或海外访问令牌缺失,不能执行跨区安全门禁', 'missing-dual-token')
|
||||
}
|
||||
|
||||
const domesticTokenAgainstOverseas = await context.requestOverseas('GET',
|
||||
'/api/platform/v1/last-leg/orders?page=1&size=1', undefined, {}, context.token)
|
||||
context.assert(MODULE, '国内Token不能访问海外Audience', domesticTokenAgainstOverseas,
|
||||
denied(domesticTokenAgainstOverseas) && traced(domesticTokenAgainstOverseas),
|
||||
evidence(domesticTokenAgainstOverseas), OPTIONS)
|
||||
|
||||
if (!context.token) blockingGap(context, '统一会话前置', '平台访问令牌缺失', 'missing-session')
|
||||
const authorized = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
context.assert(MODULE, '统一平台会话可访问Lastleg授权列表', authorized,
|
||||
success(authorized) && traced(authorized), evidence(authorized), OPTIONS)
|
||||
const anonymous = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, '')
|
||||
context.assert(MODULE, '无会话不得访问Lastleg', anonymous, denied(anonymous) && traced(anonymous), evidence(anonymous), OPTIONS)
|
||||
const phase3 = context.createdResources?.phase3Loading
|
||||
if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded
|
||||
|| !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) {
|
||||
@@ -50,31 +46,29 @@ export async function run(context) {
|
||||
'missing-marker-loading-completed')
|
||||
}
|
||||
|
||||
const overseasTokenAgainstDomestic = await context.request('GET',
|
||||
`/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`,
|
||||
undefined, context.overseasToken)
|
||||
context.assert(MODULE, '海外Token不能访问国内Audience', overseasTokenAgainstDomestic,
|
||||
denied(overseasTokenAgainstDomestic) && traced(overseasTokenAgainstDomestic),
|
||||
evidence(overseasTokenAgainstDomestic), OPTIONS)
|
||||
const domestic = await context.request('GET',
|
||||
`/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`)
|
||||
context.assert(MODULE, '同一平台会话可访问国内授权详情', domestic,
|
||||
success(domestic) && traced(domestic), evidence(domestic), OPTIONS)
|
||||
|
||||
const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}`
|
||||
const delivered = await poll(
|
||||
() => context.requestOverseas('GET', taillegPath),
|
||||
() => context.request('GET', taillegPath),
|
||||
(response) => success(response) && records(response.body?.data)
|
||||
.some((item) => item?.containerNo === phase3.containerNo))
|
||||
const deliveredRows = records(delivered.response?.body?.data)
|
||||
.filter((item) => item?.containerNo === phase3.containerNo)
|
||||
context.assert(MODULE, '国内装柜完成经Bridge生成Lastleg订单', delivered.response,
|
||||
context.assert(MODULE, '国内装柜完成经Outbox直接投递生成Lastleg订单', delivered.response,
|
||||
deliveredRows.length > 0 && traced(delivered.response),
|
||||
evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS)
|
||||
if (deliveredRows.length === 0) {
|
||||
blockingGap(context, '国内到海外事件闭环',
|
||||
`本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查Bridge重试与下游诊断`,
|
||||
`本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查生产服务Outbox重试与下游诊断`,
|
||||
'cn-overseas-event-not-delivered', delivered.response)
|
||||
}
|
||||
|
||||
const deliveredAgain = await context.requestOverseas('GET', taillegPath)
|
||||
context.assert(MODULE, '装柜完成事件重试保持建单幂等', deliveredAgain,
|
||||
const deliveredAgain = await context.request('GET', taillegPath)
|
||||
context.assert(MODULE, '装柜结果重复读取稳定(非事件重放证据)', deliveredAgain,
|
||||
success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data)
|
||||
&& traced(deliveredAgain),
|
||||
evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS)
|
||||
@@ -88,7 +82,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}`
|
||||
const orderDetail = await context.requestOverseas('GET', orderDetailPath)
|
||||
const orderDetail = await context.request('GET', orderDetailPath)
|
||||
const order = orderDetail.body?.data?.order || orderDetail.body?.data
|
||||
const completedContainerNo = order?.containerNo || ''
|
||||
context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail,
|
||||
@@ -127,13 +121,13 @@ export async function run(context) {
|
||||
const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt)
|
||||
&& Number.isFinite(estimatedAt) && completedAt >= uploadedAt
|
||||
&& estimatedAt - completedAt === 72 * 60 * 60 * 1_000
|
||||
context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response,
|
||||
context.assert(MODULE, '海外柜完成经Outbox直接投递回写Firstleg时间事实', writtenBack.response,
|
||||
validTimeline && traced(writtenBack.response),
|
||||
evidence(writtenBack.response,
|
||||
`pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS)
|
||||
if (!validTimeline) {
|
||||
blockingGap(context, '海外到国内事件闭环',
|
||||
`柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查Bridge重试与诊断证据`,
|
||||
`柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查生产服务Outbox重试与诊断证据`,
|
||||
'overseas-cn-event-not-delivered', writtenBack.response)
|
||||
}
|
||||
|
||||
@@ -141,8 +135,11 @@ export async function run(context) {
|
||||
const replaySnapshot = writtenBackAgain.body?.data?.container || {}
|
||||
const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt',
|
||||
'actualEmptyReportedAt', 'emptyReportSourceVersion']
|
||||
context.assert(MODULE, '海外柜完成事件重试保持回写幂等', writtenBackAgain,
|
||||
context.assert(MODULE, '卸柜时间事实重复读取稳定(非事件重放证据)', writtenBackAgain,
|
||||
success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null))
|
||||
&& traced(writtenBackAgain),
|
||||
evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS)
|
||||
blockingGap(context, '真实投递重试与同事件幂等证据',
|
||||
'两次GET仅证明读取稳定;需按event-outbox-operations.mjs的受保护内部运维流程,在本轮事件上验证故障后有限重试/DEAD及同eventId+payloadHash重复消费,保存逐次尝试与业务计数证据。不得重放SENT、改表或伪造新事件。',
|
||||
'missing-direct-delivery-replay-evidence')
|
||||
}
|
||||
|
||||
@@ -41,17 +41,9 @@ function fixture(options = {}) {
|
||||
marker, calls, steps, gaps, blockers,
|
||||
context: { merchantQualificationFixture: credentials },
|
||||
createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } },
|
||||
async request(method, requestPath, body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
|
||||
if (requestPath === '/api/auth/login') return response({ overseasAccessToken: 'merchant-overseas-token' })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
|
||||
state.warehouseStatus = 'INACTIVE'; return response(null)
|
||||
}
|
||||
throw new Error(`unexpected domestic ${method} ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,requestPath,body,token,headers) {
|
||||
calls.push({ channel: 'overseas', method, path: requestPath, body, tokenPresent: Boolean(token) })
|
||||
|
||||
if (requestPath === '/api/merchant/v1/last-leg/overseas-procurements' && method === 'POST') {
|
||||
@@ -185,7 +177,19 @@ function fixture(options = {}) {
|
||||
return response(looseboardDetail())
|
||||
}
|
||||
throw new Error(`unexpected ${method} ${requestPath}`)
|
||||
},
|
||||
} : async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.includes('/iam/tenants?')) return response(page(activeCustomers))
|
||||
if (requestPath === '/api/auth/login') return response({ accessToken: 'merchant-overseas-token' })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) {
|
||||
state.warehouseStatus = 'INACTIVE'; return response(null)
|
||||
}
|
||||
throw new Error(`unexpected domestic ${method} ${requestPath}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
apiSuccess(module, name, result, evidence, stepOptions) {
|
||||
const passed = result.http === 200 && String(result.body?.code) === '200'
|
||||
this.addStep(module, name, result, passed, evidence, stepOptions)
|
||||
@@ -215,7 +219,7 @@ test('module is context injected, blocks missing prerequisites and excludes forb
|
||||
for (const forbidden of ['/outbounds', '/receive-pallets', '/outbound', '/delivery', '/dispatch', '/export']) {
|
||||
assert.equal(source.includes(forbidden), false, forbidden)
|
||||
}
|
||||
assert.doesNotMatch(source, /requestOverseas\('DELETE'/)
|
||||
assert.doesNotMatch(source, /request\('DELETE'/)
|
||||
})
|
||||
|
||||
test('complete procurement truck and looseboard lifecycles pass with legal marker terminals', async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ async function domestic(context, method, name, path, body) {
|
||||
}
|
||||
|
||||
async function overseas(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -96,17 +96,17 @@ async function loginMerchant(context, activeCustomer) {
|
||||
}
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||
const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS)
|
||||
const overseasToken = auth?.overseasAccessToken
|
||||
context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken),
|
||||
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(overseasToken)}; ${trace(login)}`, OPTIONS)
|
||||
return nonEmpty(overseasToken) ? overseasToken : null
|
||||
const token = auth?.accessToken
|
||||
context.assert(MODULE, '采购客户登录返回统一Token', login, nonEmpty(token),
|
||||
`customerId=${id(activeCustomer?.id) || 'missing'}, tokenPresent=${Boolean(token)}; ${trace(login)}`, OPTIONS)
|
||||
return nonEmpty(token) ? token : null
|
||||
}
|
||||
|
||||
async function createMerchantProcurement(context, activeCustomer, overseasToken) {
|
||||
async function createMerchantProcurement(context, activeCustomer, token) {
|
||||
const path = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const payload = procurementPayload(context, activeCustomer)
|
||||
let activeToken = overseasToken
|
||||
let response = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
||||
let activeToken = token
|
||||
let response = await context.request('POST', path, payload, activeToken, {})
|
||||
// A newly approved customer is projected to the overseas region asynchronously. The
|
||||
// domestic login can therefore succeed a few seconds before the overseas gateway has
|
||||
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
|
||||
@@ -117,7 +117,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
|
||||
const refreshedToken = await loginMerchant(context, activeCustomer)
|
||||
if (!refreshedToken) break
|
||||
activeToken = refreshedToken
|
||||
response = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
||||
response = await context.request('POST', path, payload, activeToken, {})
|
||||
}
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
|
||||
const value = summary(detail)
|
||||
@@ -132,15 +132,14 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
|
||||
`orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS)
|
||||
if (!valid) return null
|
||||
|
||||
const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken)
|
||||
const replayResponse = await context.request('POST', path, payload, activeToken, {})
|
||||
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
|
||||
const replay = summary(replayDetail)
|
||||
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
|
||||
replay?.orderNo === value.orderNo && Number(replay?.version) === Number(value.version),
|
||||
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
|
||||
|
||||
const mismatch = await context.requestOverseas('POST', path,
|
||||
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken)
|
||||
const mismatch = await context.request('POST', path, { ...payload, productName: `${payload.productName}-DIFFERENT` }, activeToken, {})
|
||||
expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch)
|
||||
|
||||
return { resource, payload }
|
||||
@@ -156,11 +155,11 @@ async function prerequisites(context) {
|
||||
const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId)
|
||||
if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response,
|
||||
`marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`)
|
||||
const overseasToken = await loginMerchant(context, activeCustomer)
|
||||
if (!overseasToken) return null
|
||||
const token = await loginMerchant(context, activeCustomer)
|
||||
if (!token) return null
|
||||
const partnerWarehouse = await createPartnerWarehouse(context)
|
||||
if (!partnerWarehouse) return null
|
||||
const purchase = await createMerchantProcurement(context, activeCustomer, overseasToken)
|
||||
const purchase = await createMerchantProcurement(context, activeCustomer, token)
|
||||
if (!purchase) return null
|
||||
|
||||
const customerOptions = await overseas(context, 'GET', '查询采购客户候选', `${BASE}/overseas-procurements/customer-options?limit=200`)
|
||||
@@ -181,7 +180,7 @@ async function prerequisites(context) {
|
||||
if (!warehouse) return blocker(context, '采购仓候选存在 ACTIVE 仓', warehouseOptions.response,
|
||||
'采购仓候选为空,禁止跳过采购三态清空')
|
||||
return { activeCustomer, procurementCustomer, warehouse, customerOptions, warehouseOptions,
|
||||
partnerWarehouse, purchase, overseasToken }
|
||||
partnerWarehouse, purchase, token }
|
||||
}
|
||||
|
||||
function assertProcurementHistory(context, result) {
|
||||
@@ -249,8 +248,7 @@ async function procurementFlow(context, prerequisite) {
|
||||
&& Number(clearedSummary?.version) === Number(omittedSummary?.version) + 1,
|
||||
`version=${clearedSummary?.version ?? 'missing'}, specification=${clearedSummary?.productSpecification ?? 'null'}, warehouseId=${clearedSummary?.purchaseWarehouseId ?? 'null'}; ${trace(clearedResult.response)}`, OPTIONS)
|
||||
|
||||
const stale = await context.requestOverseas('PATCH', path,
|
||||
{ version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
|
||||
const stale = await context.request('PATCH', path, { version: Number(setSummary?.version), productSpecification: 'STALE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '采购旧 version 返回 A0409', stale, true)
|
||||
const afterConflict = await overseas(context, 'GET', '采购冲突后零漂移回查', path)
|
||||
const afterConflictSummary = summary(afterConflict.data)
|
||||
@@ -278,13 +276,9 @@ async function procurementFlow(context, prerequisite) {
|
||||
summary(rejectedReplay.data)?.status === 'REJECTED'
|
||||
&& Number(summary(rejectedReplay.data)?.version) === Number(rejectedSummary?.version),
|
||||
trace(rejectedReplay.response), OPTIONS)
|
||||
const reviewHashConflict = await context.requestOverseas('POST',
|
||||
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
||||
{ ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
const reviewHashConflict = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict)
|
||||
const reverseReview = await context.requestOverseas('POST',
|
||||
query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }),
|
||||
{ version: Number(rejectedSummary?.version), decision: 'APPROVE',
|
||||
const reverseReview = await context.request('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), { version: Number(rejectedSummary?.version), decision: 'APPROVE',
|
||||
idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255),
|
||||
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview)
|
||||
@@ -333,8 +327,7 @@ async function assertCreateIdempotency(context, base, payload, customerId, label
|
||||
context.assert(MODULE, `${label}幂等重放复用订单且版本不漂移`, replay.response,
|
||||
replayValue?.orderNo === value.orderNo && Number(replayValue?.version) === Number(value.version),
|
||||
`orderNo=${id(replayValue?.orderNo) || 'missing'}, version=${replayValue?.version ?? 'missing'}; ${trace(replay.response)}`, OPTIONS)
|
||||
const mismatch = await context.requestOverseas('POST', path,
|
||||
{ ...payload, destination: `${payload.destination}-DIFFERENT` })
|
||||
const mismatch = await context.request('POST', path, { ...payload, destination: `${payload.destination}-DIFFERENT` })
|
||||
expectedConflict(context, `${label}创建同幂等键异载荷返回冲突`, mismatch)
|
||||
return { created, value, path }
|
||||
}
|
||||
@@ -406,8 +399,7 @@ async function truckFlow(context, customerId) {
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
||||
&& after?.status === 'PENDING_REVIEW',
|
||||
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PATCH', path,
|
||||
{ version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
const stale = await context.request('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true)
|
||||
const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path)
|
||||
const afterStaleSummary = summary(afterStale.data)
|
||||
@@ -430,11 +422,9 @@ async function truckFlow(context, customerId) {
|
||||
summary(reviewReplay.data)?.status === 'WAITING_INBOUND'
|
||||
&& Number(summary(reviewReplay.data)?.version) === Number(forwardedSummary?.version),
|
||||
trace(reviewReplay.response), OPTIONS)
|
||||
const reviewMismatch = await context.requestOverseas('POST', reviewPath,
|
||||
{ ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
const reviewMismatch = await context.request('POST', reviewPath, { ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '卡车审核同键异载荷返回冲突', reviewMismatch)
|
||||
const reversed = await context.requestOverseas('POST', reviewPath,
|
||||
{ version: Number(forwardedSummary?.version), decision: 'REJECT',
|
||||
const reversed = await context.request('POST', reviewPath, { version: Number(forwardedSummary?.version), decision: 'REJECT',
|
||||
idempotencyKey: `${context.marker}-truck-review-terminal-reverse`.slice(0, 255),
|
||||
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '卡车审核终态逆向返回冲突', reversed)
|
||||
@@ -483,8 +473,7 @@ async function looseboardFlow(context, customerId) {
|
||||
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
|
||||
&& after?.status === 'PENDING_ACCEPTANCE',
|
||||
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', path,
|
||||
{ ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
const stale = await context.request('PUT', path, { ...editable, version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '散板旧 version 编辑返回 A0409', stale, true)
|
||||
const afterStale = await overseas(context, 'GET', '散板旧 version 冲突后零漂移回查', path)
|
||||
const afterStaleSummary = summary(afterStale.data)
|
||||
@@ -511,11 +500,9 @@ async function looseboardFlow(context, customerId) {
|
||||
summary(forwardReplay.data)?.status === 'DISPATCHED'
|
||||
&& Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version),
|
||||
trace(forwardReplay.response), OPTIONS)
|
||||
const forwardMismatch = await context.requestOverseas('PATCH', statusPath,
|
||||
{ ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
const forwardMismatch = await context.request('PATCH', statusPath, { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch)
|
||||
const reversed = await context.requestOverseas('PATCH', statusPath,
|
||||
{ version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE',
|
||||
const reversed = await context.request('PATCH', statusPath, { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE',
|
||||
idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255),
|
||||
remark: 'REVERSE-DO-NOT-APPLY' })
|
||||
expectedConflict(context, '散板状态逆向回退返回冲突', reversed)
|
||||
|
||||
@@ -51,9 +51,9 @@ test('relabel transfer and discard cover create replay conflict list detail and
|
||||
|
||||
test('customer isolation is exercised with merchant token, forged customerId and platform denial', () => {
|
||||
assert.match(source, /merchantQualificationFixture/)
|
||||
assert.match(source, /overseasAccessToken/)
|
||||
assert.match(source, /accessToken/)
|
||||
assert.match(source, /customerId: 0/)
|
||||
assert.match(source, /平台海外Token不能冒充客户访问/)
|
||||
assert.match(source, /平台统一Token不能冒充客户访问/)
|
||||
assert.match(source, /response\?\.http === 403.*codeOf\(response\) === 'A0403'/s)
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function query(path, values) {
|
||||
function future(days) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19) }
|
||||
|
||||
async function merchant(context, token, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -51,9 +51,9 @@ async function loginMerchant(context) {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS)
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken),
|
||||
`tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(auth?.overseasAccessToken) ? auth.overseasAccessToken : null
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
|
||||
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(auth?.accessToken) ? auth.accessToken : null
|
||||
}
|
||||
|
||||
async function currentOrders(context, token) {
|
||||
@@ -136,7 +136,7 @@ async function verifyRelabel(context, token, order) {
|
||||
const replay = await merchant(context, token, 'POST', '客户贴标申请幂等重放', RELABEL, body)
|
||||
context.assert(MODULE, '贴标重放复用同一申请', replay.response,
|
||||
id(replay.data?.application?.relabelNo) === relabelNo, trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token)
|
||||
const hashConflict = await context.request('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, token, {})
|
||||
context.addStep(MODULE, '贴标同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const page = await merchant(context, token, 'GET', '客户查询自身贴标申请列表',
|
||||
query(RELABEL, { page: 1, size: 20, relabelNo, customerId: 0 }))
|
||||
@@ -189,8 +189,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
// reconcile from the authoritative detail before asserting the state.
|
||||
let submitted = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const response = await context.requestOverseas('POST',
|
||||
`${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, {}, token)
|
||||
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
|
||||
if (response?.http === 0 && attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 800 * attempt))
|
||||
continue
|
||||
@@ -214,7 +213,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body)
|
||||
context.assert(MODULE, '转运重放复用同一申请', replay.response,
|
||||
id(replay.data?.transferNo) === transferNo, trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token)
|
||||
const hashConflict = await context.request('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, token, {})
|
||||
context.addStep(MODULE, '转运同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const page = await merchant(context, token, 'GET', '客户查询自身仓储转运列表',
|
||||
query(TRANSFER, { page: 1, size: 20, transferNo, customerId: 0 }))
|
||||
@@ -231,7 +230,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
}
|
||||
|
||||
async function verifyDiscard(context, token, markerOrders) {
|
||||
const response = await context.requestOverseas('GET', `${DISCARD}/inventory-candidates`, undefined, {}, token)
|
||||
const response = await context.request('GET', `${DISCARD}/inventory-candidates`, undefined, token, {})
|
||||
const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS)
|
||||
const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id)))
|
||||
const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId))
|
||||
@@ -281,8 +280,8 @@ export async function run(context) {
|
||||
if (!token) return
|
||||
|
||||
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
|
||||
const denied = await context.requestOverseas('GET', query(path, { page: 1, size: 1 }))
|
||||
context.addStep(MODULE, `平台海外Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS)
|
||||
const denied = await context.request('GET', query(path, { page: 1, size: 1 }))
|
||||
context.addStep(MODULE, `平台统一Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
|
||||
const orders = await currentOrders(context, token)
|
||||
|
||||
@@ -32,7 +32,7 @@ function query(path, values) {
|
||||
}
|
||||
|
||||
async function api(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export async function run(context) {
|
||||
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)
|
||||
const removedAgain = await context.request('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' }))
|
||||
@@ -235,7 +235,7 @@ export async function run(context) {
|
||||
`planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS)
|
||||
|
||||
const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64)
|
||||
const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, {
|
||||
const foreignLock = await context.request('POST', `${BASE}/draft-locks/lock`, {
|
||||
planPalletId, draftToken: foreignToken,
|
||||
})
|
||||
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
|
||||
@@ -257,7 +257,7 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id)
|
||||
context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null
|
||||
|
||||
const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
const duplicateArrange = await context.request('POST', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange,
|
||||
success(duplicateArrange)
|
||||
&& dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data),
|
||||
|
||||
+30
-27
@@ -42,28 +42,11 @@ function fixture() {
|
||||
},
|
||||
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ region: 'CN', method, path, body })
|
||||
if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') {
|
||||
return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] })
|
||||
}
|
||||
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'CN')
|
||||
if (path.startsWith('/api/platform/finance/v1/fee-sources?')) {
|
||||
const query = new URLSearchParams(path.split('?')[1])
|
||||
const customerId = query.get('customerId')
|
||||
const keyword = query.get('keyword')
|
||||
const rows = [...fees.values()].filter((fee) => fee.customerId === customerId
|
||||
&& (fee.inboundCode || fee.businessNo) === keyword)
|
||||
.filter((fee) => fee.reviewStatus === 'APPROVED')
|
||||
.map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG',
|
||||
sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId }))
|
||||
return response(rows)
|
||||
}
|
||||
throw new Error(`unexpected CN request ${method} ${path}`)
|
||||
},
|
||||
async requestOverseas(method, path, body, headers = {}) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,path,body,_token,headers = {}) {
|
||||
calls.push({ region: 'OVERSEAS', method, path, body, headers })
|
||||
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'OVERSEAS')
|
||||
if (path === '/internal/finance/event-operations/outbox') return response(null, '404', 404, 'OVERSEAS')
|
||||
for (const [base, row] of Object.entries(businessRows)) {
|
||||
if (method === 'GET' && path.startsWith(`${base}?`)) return response({ records: [row] })
|
||||
}
|
||||
@@ -101,7 +84,28 @@ function fixture() {
|
||||
return response(fee)
|
||||
}
|
||||
throw new Error(`unexpected OVERSEAS request ${method} ${path}`)
|
||||
},
|
||||
} : async function(method,path,body) {
|
||||
calls.push({ region: 'CN', method, path, body })
|
||||
if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') {
|
||||
return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] })
|
||||
}
|
||||
if (path === '/internal/finance/event-operations/outbox') return response(null, '404', 404, 'CN')
|
||||
if (path.startsWith('/api/platform/finance/v1/fee-sources?')) {
|
||||
const query = new URLSearchParams(path.split('?')[1])
|
||||
const customerId = query.get('customerId')
|
||||
const keyword = query.get('keyword')
|
||||
const rows = [...fees.values()].filter((fee) => fee.customerId === customerId
|
||||
&& (fee.inboundCode || fee.businessNo) === keyword)
|
||||
.filter((fee) => fee.reviewStatus === 'APPROVED')
|
||||
.map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG',
|
||||
sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId }))
|
||||
return response(rows)
|
||||
}
|
||||
throw new Error(`unexpected CN request ${method} ${path}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
apiSuccess(module, name, result, evidence, options) {
|
||||
const passed = result.http === 200 && String(result.body?.code) === '200'
|
||||
this.addStep(module, name, result, passed, evidence, options)
|
||||
@@ -126,7 +130,7 @@ test('module uses only injected real HTTP clients and never fabricates database/
|
||||
'OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE',
|
||||
'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE',
|
||||
'/api/platform/v1/last-leg/finance-fee-sources', '/api/platform/finance/v1/fee-sources',
|
||||
'/internal/event-bridge/status',
|
||||
'/internal/finance/event-operations/outbox',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
})
|
||||
|
||||
@@ -163,10 +167,9 @@ test('five fee types traverse Lastleg lifecycle and reach Finance through real H
|
||||
assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed)
|
||||
assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed)
|
||||
assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed)
|
||||
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).length, 5)
|
||||
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).every((step) => step.passed))
|
||||
assert.ok(context.steps.find((step) => step.name === '国内 Event Bridge HTTP 能力状态可观察')?.passed)
|
||||
assert.ok(context.steps.find((step) => step.name === '海外 Event Bridge HTTP 能力状态可观察')?.passed)
|
||||
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).length, 5)
|
||||
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经业务Outbox直接投递进入 Finance')).every((step) => step.passed))
|
||||
assert.ok(context.steps.find((step) => step.name === '统一Gateway不公开内部事件运维接口')?.passed)
|
||||
|
||||
assert.deepEqual(context.steps.filter((step) => !step.passed), [])
|
||||
assert.equal(context.scenarios[0].deploymentBlocked, false)
|
||||
|
||||
@@ -20,7 +20,7 @@ function blocker(context, name, reason, response = null) {
|
||||
}
|
||||
|
||||
async function overseas(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -147,8 +147,7 @@ async function createApprove(context, definition, fixture, index) {
|
||||
id(replay.data?.sourceFeeId) === sourceFeeId && Number(replay.data?.sourceVersion) === 1,
|
||||
trace(replay.response), OPTIONS)
|
||||
if (index === 0) {
|
||||
const mismatch = await context.requestOverseas('POST', LASTLEG_BASE,
|
||||
{ ...payload, amount: payload.amount + 1 }, { 'Idempotency-Key': key })
|
||||
const mismatch = await context.request('POST', LASTLEG_BASE, { ...payload, amount: payload.amount + 1 }, undefined, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, '费用创建同键异载荷冲突', mismatch, conflict(mismatch), trace(mismatch), OPTIONS)
|
||||
}
|
||||
|
||||
@@ -189,8 +188,7 @@ async function verifyCustomerIsolation(context, fixtures, source) {
|
||||
`otherCustomerId=${id(other.id)}, leaked=${rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId)}; ${trace(wrongList.response)}`, OPTIONS)
|
||||
if (source.payload.businessId) {
|
||||
const wrongPayload = { ...source.payload, customerId: id(other.id), customerName: other.tenantName || other.name || 'other' }
|
||||
const wrong = await context.requestOverseas('POST', LASTLEG_BASE, wrongPayload,
|
||||
{ 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
|
||||
const wrong = await context.request('POST', LASTLEG_BASE, wrongPayload, undefined, { 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
|
||||
context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS)
|
||||
}
|
||||
}
|
||||
@@ -205,7 +203,7 @@ async function verifyFinanceDelivery(context, sources) {
|
||||
&& id(item?.sourceFeeId) === source.sourceFeeId)
|
||||
return { response, found }
|
||||
}, (value) => success(value?.response) && Boolean(value?.found))
|
||||
context.assert(MODULE, `${source.definition.label}费用经跨区链进入 Finance`, delivered.result?.response,
|
||||
context.assert(MODULE, `${source.definition.label}费用经业务Outbox直接投递进入 Finance`, delivered.result?.response,
|
||||
Boolean(delivered.result?.found)
|
||||
&& id(delivered.result.found.customerId) === id(source.customerId)
|
||||
&& delivered.result.found.sourceType === source.definition.sourceType,
|
||||
@@ -213,15 +211,10 @@ async function verifyFinanceDelivery(context, sources) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBridgeCapabilities(context) {
|
||||
const domestic = await context.request('GET', '/internal/event-bridge/status')
|
||||
context.addStep(MODULE, '国内 Event Bridge HTTP 能力状态可观察', domestic,
|
||||
(success(domestic) && String(domestic.body?.data?.region).toUpperCase() === 'CN') || domestic.http === 404,
|
||||
`${domestic.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(domestic)}`, OPTIONS)
|
||||
const overseas = await context.requestOverseas('GET', '/internal/event-bridge/status')
|
||||
context.addStep(MODULE, '海外 Event Bridge HTTP 能力状态可观察', overseas,
|
||||
(success(overseas) && String(overseas.body?.data?.region).toUpperCase() === 'OVERSEAS') || overseas.http === 404,
|
||||
`${overseas.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(overseas)}`, OPTIONS)
|
||||
async function verifyInternalBoundary(context) {
|
||||
const response = await context.request('GET', '/internal/finance/event-operations/outbox')
|
||||
context.assert(MODULE, '统一Gateway不公开内部事件运维接口', response,
|
||||
[401, 403, 404].includes(response.http), trace(response), OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
@@ -251,7 +244,7 @@ export async function run(context) {
|
||||
`sourceFeeId=${warehouseSource?.sourceFeeId || 'missing'}, warehouseId=${warehouseSource?.payload?.warehouseId || 'missing'}`, OPTIONS)
|
||||
await verifyCustomerIsolation(context, fixtures, sources.find((item) => item.payload.businessId))
|
||||
await verifyFinanceDelivery(context, sources)
|
||||
await verifyBridgeCapabilities(context)
|
||||
await verifyInternalBoundary(context)
|
||||
context.createdResources.phase4OverseasFinanceCrossRegion = {
|
||||
marker: context.marker,
|
||||
sources: sources.map((item) => ({ sourceFeeId: item.sourceFeeId, customerId: item.customerId,
|
||||
|
||||
@@ -16,7 +16,7 @@ function query(path, values) {
|
||||
function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) }
|
||||
|
||||
async function request(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS)
|
||||
return ok(response) ? response.body?.data : null
|
||||
}
|
||||
@@ -44,17 +44,12 @@ async function inboundAdminLifecycle(context) {
|
||||
let preview = await request(context, 'GET', '预览第二柜待生成入仓计划', query(`${PLAN_BASE}/container-preview`, {
|
||||
sourceContainerBookingId: bookingId,
|
||||
}))
|
||||
const arrivalQuery = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/orders/container-arrival-times/query',
|
||||
{ sourceContainerBookingIds: [bookingId] })
|
||||
const arrivalQuery = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] })
|
||||
const arrival = ok(arrivalQuery) && Array.isArray(arrivalQuery.body?.data)
|
||||
? arrivalQuery.body.data.find((item) => id(item?.sourceContainerBookingId) === bookingId) : null
|
||||
if (arrival && !arrival.estimatedWarehouseArrivalAt) {
|
||||
await context.requestOverseas('PUT',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`,
|
||||
{ estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19),
|
||||
expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` },
|
||||
{ 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) })
|
||||
await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 19),
|
||||
expectedVersion: Number(arrival.version), remark: `${context.marker}-inbound-plan-prerequisite` }, undefined, { 'Idempotency-Key': `${context.marker}-inbound-plan-arrival-${bookingId}`.slice(0, 128) })
|
||||
preview = await request(context, 'GET', '刷新补齐时间后的入仓计划预览', query(`${PLAN_BASE}/container-preview`, {
|
||||
sourceContainerBookingId: bookingId,
|
||||
}))
|
||||
@@ -108,7 +103,7 @@ async function inboundAdminLifecycle(context) {
|
||||
id(appointmentReplay?.id) === id(appointment?.id)
|
||||
&& appointmentReplay?.items?.length === fbaLines.length,
|
||||
`appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS)
|
||||
const appointmentConflict = await context.requestOverseas('POST', `${PLAN_BASE}/fba-appointments/batch`, {
|
||||
const appointmentConflict = await context.request('POST', `${PLAN_BASE}/fba-appointments/batch`, {
|
||||
...appointmentBody, appointmentRemark: `${context.marker}-different`,
|
||||
})
|
||||
context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict,
|
||||
@@ -171,7 +166,7 @@ async function inventoryCommandLifecycle(context) {
|
||||
context.assert(MODULE, '库存冻结重放不重复扣减', null,
|
||||
held?.operationType === 'HOLD' && heldReplay?.operationId === held?.operationId && heldReplay?.replayed === true,
|
||||
`operationId=${id(held?.operationId) || 'missing'}, replayed=${heldReplay?.replayed}`, OPTIONS)
|
||||
const holdConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/hold`, {
|
||||
const holdConflict = await context.request('POST', `${INVENTORY_BASE}/hold`, {
|
||||
...holdBody, remark: `${context.marker}-different`,
|
||||
})
|
||||
context.addStep(MODULE, '库存冻结同键异载荷返回A0409', holdConflict,
|
||||
@@ -194,7 +189,7 @@ async function inventoryCommandLifecycle(context) {
|
||||
increased?.operationType === 'ADJUST' && increaseReplay?.operationId === increased?.operationId
|
||||
&& increaseReplay?.replayed === true,
|
||||
`operationId=${id(increased?.operationId) || 'missing'}, replayed=${increaseReplay?.replayed}`, OPTIONS)
|
||||
const adjustConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/adjust`, {
|
||||
const adjustConflict = await context.request('POST', `${INVENTORY_BASE}/adjust`, {
|
||||
...increaseBody, reason: 'DIFFERENT_COUNT_REASON',
|
||||
})
|
||||
context.addStep(MODULE, '盘点调整同键异载荷返回A0409', adjustConflict,
|
||||
|
||||
@@ -56,46 +56,9 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
addCoverageGap(module, scenario, name, reason, category, metadata = {}) {
|
||||
coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ gateway: 'domestic', method, path, body })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (method === 'POST' && path === DOMESTIC_BASE) {
|
||||
state.warehouse = { ...body, id: '910000000000000001' }
|
||||
return ok(state.warehouse.id)
|
||||
}
|
||||
if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities })
|
||||
}
|
||||
if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
state.warehouse = { ...body, id: state.warehouse.id }
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) {
|
||||
state.warehouse.status = 'INACTIVE'
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/roles') {
|
||||
state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.role })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
|
||||
state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.user })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/auth/login') {
|
||||
return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 })
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') {
|
||||
state.user.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') {
|
||||
state.role.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
assert.fail(`unexpected domestic call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,path,body,token = 'admin-overseas-token',extraHeaders = {}) {
|
||||
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (token === 'deny-overseas-token') return forbidden()
|
||||
@@ -189,8 +152,49 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 })
|
||||
}
|
||||
assert.fail(`unexpected overseas call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseasBinary(method, path) {
|
||||
} : async function(method,path,body) {
|
||||
calls.push({ gateway: 'domestic', method, path, body })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (method === 'POST' && path === DOMESTIC_BASE) {
|
||||
state.warehouse = { ...body, id: '910000000000000001' }
|
||||
return ok(state.warehouse.id)
|
||||
}
|
||||
if (method === 'GET' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
return ok({ warehouse: { ...state.warehouse }, addresses: [state.warehouse.address], capabilities: state.warehouse.capabilities })
|
||||
}
|
||||
if (method === 'PUT' && path === `${DOMESTIC_BASE}/910000000000000001?tenantId=0`) {
|
||||
state.warehouse = { ...body, id: state.warehouse.id }
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PATCH' && path === `${DOMESTIC_BASE}/910000000000000001/status?tenantId=0&status=INACTIVE`) {
|
||||
state.warehouse.status = 'INACTIVE'
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/roles') {
|
||||
state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.role })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
|
||||
state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' }
|
||||
return ok({ ...state.user })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/auth/login') {
|
||||
return ok({ accessToken: 'deny-overseas-token', expiresIn: 900 })
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') {
|
||||
state.user.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') {
|
||||
state.role.status = body.status
|
||||
return ok(null)
|
||||
}
|
||||
assert.fail(`unexpected domestic call ${method} ${path}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestBinary(method, path) {
|
||||
calls.push({ gateway: 'overseas-binary', method, path })
|
||||
assert.equal(method, 'GET')
|
||||
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
|
||||
@@ -208,10 +212,10 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
assert.match(source, /context\.request\('POST', DOMESTIC_BASE/)
|
||||
assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/)
|
||||
assert.match(source, /context\.requestOverseas\('POST'/)
|
||||
assert.match(source, /context\.requestOverseas\('PUT'/)
|
||||
assert.match(source, /context\.requestOverseas\('PATCH'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.request\('POST'/)
|
||||
assert.match(source, /context\.request\('PUT'/)
|
||||
assert.match(source, /context\.request\('PATCH'/)
|
||||
assert.match(source, /context\.requestBinary\('GET'/)
|
||||
assert.match(source, /context\.expectConflict/)
|
||||
assert.match(source, /warehouse-options\?refresh=true/)
|
||||
assert.match(source, /空候选不负缓存30分钟/)
|
||||
|
||||
@@ -111,7 +111,7 @@ async function createAndVerifyPartnerWarehouse(context, resource) {
|
||||
}
|
||||
|
||||
async function captureWarehouseOptionsBeforeCreate(context, resource) {
|
||||
const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const response = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response)
|
||||
context.assert(MODULE, '合作仓候选基线为规范数组', response,
|
||||
Array.isArray(options) && options.every((item) => asId(item?.warehouseId)
|
||||
@@ -144,7 +144,7 @@ function quoteUpdatePayload(created, context, version) {
|
||||
}
|
||||
|
||||
async function quoteLifecycle(context, resource) {
|
||||
const optionsResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const optionsResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const options = requireData(context, '查询海外报价合作仓候选', optionsResponse)
|
||||
context.assert(MODULE, '报价候选只来自海外正式接口', optionsResponse,
|
||||
Array.isArray(options) && options.every((item) => asId(item.warehouseId)
|
||||
@@ -156,37 +156,35 @@ async function quoteLifecycle(context, resource) {
|
||||
optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER)
|
||||
}
|
||||
|
||||
const refreshResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`)
|
||||
const refreshResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`)
|
||||
const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse)
|
||||
const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null
|
||||
context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option),
|
||||
`createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER)
|
||||
if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`)
|
||||
|
||||
const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const cachedResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`)
|
||||
const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse)
|
||||
context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse,
|
||||
JSON.stringify(cached) === JSON.stringify(refreshed),
|
||||
`refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER)
|
||||
|
||||
const payload = quoteCreatePayload(context, asId(option.warehouseId))
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload)
|
||||
let detail = requireData(context, '新增海外报价', createResponse)
|
||||
const id = asId(detail?.summary?.id)
|
||||
if (!id || !recordVersion(detail?.summary?.recordVersion)) throw new Error('报价创建响应缺少字符串ID或recordVersion')
|
||||
resource.quote = { id, warehouseId: payload.warehouseId, recordVersion: Number(detail.summary.recordVersion), finalStatus: detail.summary.status }
|
||||
const path = `${OVERSEAS_BASE}/overseas-quotes/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
detail = requireData(context, '查询海外报价详情', detailResponse)
|
||||
context.assert(MODULE, '报价详情长ID、费用和版本', detailResponse,
|
||||
asId(detail?.summary?.id) === id && recordVersion(detail?.summary?.recordVersion)
|
||||
&& Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0,
|
||||
`id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`)
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`)
|
||||
const page = requireData(context, '分页查询marker海外报价', listResponse)
|
||||
context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
@@ -194,22 +192,22 @@ async function quoteLifecycle(context, resource) {
|
||||
|
||||
const staleVersion = Number(detail.summary.recordVersion)
|
||||
const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion)
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
const updateResponse = await context.request('PUT', path, updatePayload)
|
||||
detail = requireData(context, '编辑海外报价', updateResponse)
|
||||
resource.quote.recordVersion = Number(detail.summary.recordVersion)
|
||||
context.assert(MODULE, '报价编辑版本递增', updateResponse,
|
||||
detail.summary.name === updatePayload.name && resource.quote.recordVersion === staleVersion + 1,
|
||||
`before=${staleVersion}, after=${resource.quote.recordVersion}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, name: `${context.marker} stale` })
|
||||
const conflictResponse = await context.request('PUT', path, { ...updatePayload, name: `${context.marker} stale` })
|
||||
context.expectConflict(MODULE, '报价旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
const afterConflictResponse = await context.request('GET', path)
|
||||
detail = requireData(context, '报价冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '报价冲突未覆盖新值', afterConflictResponse,
|
||||
detail.summary.name === updatePayload.name && Number(detail.summary.recordVersion) === resource.quote.recordVersion,
|
||||
`name=${detail.summary.name}, version=${detail.summary.recordVersion}`, BLOCKER)
|
||||
|
||||
const activateResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const activateResponse = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'ACTIVE', recordVersion: resource.quote.recordVersion,
|
||||
})
|
||||
detail = requireData(context, '启用海外报价', activateResponse)
|
||||
@@ -218,7 +216,7 @@ async function quoteLifecycle(context, resource) {
|
||||
context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse,
|
||||
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv,
|
||||
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
|
||||
[id, context.marker])
|
||||
@@ -232,7 +230,7 @@ async function chooseSafeExchangeMonth(context) {
|
||||
const serial = ((seed + offset) % 120)
|
||||
const month = `${2090 + Math.floor(serial / 12)}-${String((serial % 12) + 1).padStart(2, '0')}`
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}`
|
||||
const response = await context.requestOverseas('GET', path)
|
||||
const response = await context.request('GET', path)
|
||||
const page = requireData(context, `探测安全汇率月份${month}`, response)
|
||||
if (Array.isArray(page?.records) && page.records.length === 0 && Number(page.total) === 0) return month
|
||||
}
|
||||
@@ -242,12 +240,12 @@ async function chooseSafeExchangeMonth(context) {
|
||||
async function exchangeLifecycle(context, resource) {
|
||||
const month = await chooseSafeExchangeMonth(context)
|
||||
const createPayload = { month, usdToCnyAverage: 7.123456, remark: `${context.marker} safe marker month` }
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/exchange-rates`, createPayload)
|
||||
let rate = requireData(context, '新增安全marker月汇率', createResponse)
|
||||
resource.exchangeRate = { month, recordVersion: Number(rate.recordVersion), finalStatus: rate.status }
|
||||
const path = `${OVERSEAS_BASE}/exchange-rates/${encodeURIComponent(month)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
rate = requireData(context, '查询marker月汇率详情', detailResponse)
|
||||
context.assert(MODULE, '汇率详情月份、decimal与版本', detailResponse,
|
||||
rate.month === month && Number(rate.usdToCnyAverage) > 0 && recordVersion(rate.recordVersion),
|
||||
@@ -255,7 +253,7 @@ async function exchangeLifecycle(context, resource) {
|
||||
|
||||
const staleVersion = Number(rate.recordVersion)
|
||||
const updatePayload = { recordVersion: staleVersion, usdToCnyAverage: 7.234567, remark: `${context.marker} overwritten once` }
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
const updateResponse = await context.request('PUT', path, updatePayload)
|
||||
rate = requireData(context, '覆盖维护marker月汇率', updateResponse)
|
||||
resource.exchangeRate.recordVersion = Number(rate.recordVersion)
|
||||
context.assert(MODULE, '汇率覆盖版本递增', updateResponse,
|
||||
@@ -263,16 +261,16 @@ async function exchangeLifecycle(context, resource) {
|
||||
&& resource.exchangeRate.recordVersion === staleVersion + 1,
|
||||
`before=${staleVersion}, after=${resource.exchangeRate.recordVersion}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 })
|
||||
const conflictResponse = await context.request('PUT', path, { ...updatePayload, usdToCnyAverage: 7.345678 })
|
||||
context.expectConflict(MODULE, '汇率旧recordVersion覆盖冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
const afterConflictResponse = await context.request('GET', path)
|
||||
rate = requireData(context, '汇率冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '汇率冲突未覆盖新值', afterConflictResponse,
|
||||
Number(rate.usdToCnyAverage) === updatePayload.usdToCnyAverage
|
||||
&& Number(rate.recordVersion) === resource.exchangeRate.recordVersion,
|
||||
`rate=${rate.usdToCnyAverage}, version=${rate.recordVersion}`, BLOCKER)
|
||||
|
||||
const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const statusResponse = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'INACTIVE', recordVersion: resource.exchangeRate.recordVersion,
|
||||
remark: `${context.marker} lifecycle complete`,
|
||||
})
|
||||
@@ -282,14 +280,13 @@ async function exchangeLifecycle(context, resource) {
|
||||
context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse,
|
||||
rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`)
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`)
|
||||
const page = requireData(context, '分页查询marker月汇率', listResponse)
|
||||
context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)),
|
||||
`month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET',
|
||||
const csv = await context.requestBinary('GET',
|
||||
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
|
||||
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
|
||||
@@ -311,14 +308,14 @@ function vehiclePayload(context, edited = false) {
|
||||
|
||||
async function vehicleLifecycle(context, resource) {
|
||||
const createPayload = vehiclePayload(context)
|
||||
const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/vehicles`, createPayload)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/vehicles`, createPayload)
|
||||
let vehicle = requireData(context, '新增marker车辆', createResponse)
|
||||
const id = asId(vehicle.id)
|
||||
if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion')
|
||||
resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status }
|
||||
const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
vehicle = requireData(context, '查询marker车辆详情', detailResponse)
|
||||
context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse,
|
||||
asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status)
|
||||
@@ -327,7 +324,7 @@ async function vehicleLifecycle(context, resource) {
|
||||
|
||||
const staleVersion = Number(vehicle.recordVersion)
|
||||
const updatePayload = { recordVersion: staleVersion, ...vehiclePayload(context, true) }
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
const updateResponse = await context.request('PUT', path, updatePayload)
|
||||
vehicle = requireData(context, '编辑marker车辆', updateResponse)
|
||||
const updatedVersion = Number(vehicle.recordVersion)
|
||||
const updatedMileage = Number(vehicle.mileageKm)
|
||||
@@ -337,15 +334,15 @@ async function vehicleLifecycle(context, resource) {
|
||||
&& updatedVersion === Number(staleVersion) + 1,
|
||||
`before=${staleVersion}, after=${updatedVersion}, mileage=${updatedMileage}`, BLOCKER)
|
||||
|
||||
const conflictResponse = await context.requestOverseas('PUT', path, { ...updatePayload, model: 'stale overwrite' })
|
||||
const conflictResponse = await context.request('PUT', path, { ...updatePayload, model: 'stale overwrite' })
|
||||
context.expectConflict(MODULE, '车辆旧recordVersion编辑冲突', conflictResponse, BLOCKER.scenario)
|
||||
const afterConflictResponse = await context.requestOverseas('GET', path)
|
||||
const afterConflictResponse = await context.request('GET', path)
|
||||
vehicle = requireData(context, '车辆冲突后重新查询', afterConflictResponse)
|
||||
context.assert(MODULE, '车辆冲突未覆盖新值', afterConflictResponse,
|
||||
vehicle.model === updatePayload.model && Number(vehicle.recordVersion) === resource.vehicle.recordVersion,
|
||||
`model=${vehicle.model}, version=${vehicle.recordVersion}`, BLOCKER)
|
||||
|
||||
const statusResponse = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const statusResponse = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'MAINTENANCE', recordVersion: resource.vehicle.recordVersion,
|
||||
})
|
||||
vehicle = requireData(context, '车辆切换安全维护状态', statusResponse)
|
||||
@@ -354,14 +351,13 @@ async function vehicleLifecycle(context, resource) {
|
||||
context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse,
|
||||
vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER)
|
||||
|
||||
const listResponse = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const page = requireData(context, '分页查询marker车辆', listResponse)
|
||||
context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv,
|
||||
'车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间',
|
||||
[id, createPayload.plateNumber, 'MAINTENANCE'])
|
||||
@@ -392,9 +388,8 @@ async function verifyCanonicalPermissionDenial(context, resource) {
|
||||
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const login = requireData(context, '无主数据权限用户登录', loginResponse)
|
||||
if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token')
|
||||
const denied = await context.requestOverseas('GET',
|
||||
`${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken)
|
||||
if (!login?.accessToken) throw new Error('无主数据权限用户未签发统一Token')
|
||||
const denied = await context.request('GET', `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, login.accessToken, {})
|
||||
assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied)
|
||||
}
|
||||
|
||||
@@ -409,7 +404,7 @@ async function cleanupPartnerWarehouse(context, resource) {
|
||||
async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
const current = resource[key]
|
||||
if (!current) return
|
||||
const detailResponse = await context.requestOverseas('GET', path)
|
||||
const detailResponse = await context.request('GET', path)
|
||||
const detail = data(detailResponse)
|
||||
const value = extract(detail)
|
||||
if (!success(detailResponse) || !value || !recordVersion(value.recordVersion)) {
|
||||
@@ -420,7 +415,7 @@ async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
current.recordVersion = Number(value.recordVersion)
|
||||
current.finalStatus = value.status
|
||||
if (value.status === 'INACTIVE') return
|
||||
const response = await context.requestOverseas('PATCH', `${path}/status`, {
|
||||
const response = await context.request('PATCH', `${path}/status`, {
|
||||
status: 'INACTIVE', recordVersion: current.recordVersion,
|
||||
...(key === 'exchangeRate' ? { remark: `${context.marker} finally cleanup` } : {}),
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ test('dropship original counted cartons must resolve exactly before any receivin
|
||||
for (const fault of [null, 'missing-carton', 'wrong-carton', 'wrong-inbound', 'wrong-destination']) {
|
||||
const calls = []; const checks = []
|
||||
const context = {
|
||||
async requestOverseas(method, requestPath, body) {
|
||||
async request(method,requestPath,body) {
|
||||
calls.push({ method, requestPath, body })
|
||||
const data = { cartonMark: body.cartonMark, inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS' }
|
||||
if (fault === 'wrong-carton') data.cartonMark = 'ANOTHER-CARTON'
|
||||
@@ -60,7 +60,7 @@ test('dropship original counted cartons must resolve exactly before any receivin
|
||||
for (const sealedCartonMarks of [undefined, [], ['REAL-INBOUND#2-1'], ['REAL-INBOUND#2-1', 'REAL-INBOUND#2-1']]) {
|
||||
const checks = []
|
||||
const result = await resolveCartonFixtures({ assert(m,n,r,passed) { checks.push(passed) },
|
||||
async requestOverseas() { assert.fail('incomplete physical fixture must not issue HTTP') } },
|
||||
async request() { assert.fail('incomplete physical fixture must not issue HTTP') } },
|
||||
'token', inbounds, producer.sealedCartonMarks, { ...producer, sealedCartonMarks })
|
||||
assert.equal(result, null)
|
||||
assert.deepEqual(checks, [false])
|
||||
@@ -91,8 +91,8 @@ test('current loading event binds each pallet carton group to an explicit source
|
||||
|
||||
test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => {
|
||||
assert.match(source, /export async function run\(context\)/)
|
||||
assert.match(source, /requestOverseas\(/)
|
||||
assert.match(source, /requestOverseasMultipart\(/)
|
||||
assert.match(source, /request\(/)
|
||||
assert.match(source, /requestMultipart\(/)
|
||||
assert.match(source, /requestId=/)
|
||||
assert.match(source, /traceId=/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
@@ -140,8 +140,8 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /安全扫描返回A0400/)
|
||||
assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/)
|
||||
assert.match(source, /193000000000200003.*193000000000200004/)
|
||||
assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.doesNotMatch(source, /request\('GET', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.match(source, /receiving-containers\/current\/pallets\/resolve/)
|
||||
assert.match(source, /overseasWarehouseId, password/)
|
||||
assert.match(source, /inbound\?\.overseasWarehouseId/)
|
||||
@@ -280,13 +280,17 @@ test('missing OVERSEAS_WAREHOUSE_PDA_OPERATOR overseas authority is a deployment
|
||||
const calls = []; const gaps = []; const steps = []
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
|
||||
async request(method, requestPath) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(...args) { calls.push(args); throw new Error('must not request without credentials') } : async function(method,requestPath) {
|
||||
calls.push({ method, requestPath })
|
||||
return { method, path: requestPath, http: 200, body: { code: '200', data: [] },
|
||||
requestId: 'request-contract-1', traceId: 'trace-contract-0001' }
|
||||
},
|
||||
async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
apiSuccess(module, name, response) { return response.body.data },
|
||||
addStep(...args) { steps.push(args) },
|
||||
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
|
||||
@@ -306,9 +310,16 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
let userSequence = 0
|
||||
const roleId = '4000005'; const permissionId = '5107015'
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {},
|
||||
marker: 'PDA-CONTRACT', token: '', createdResources: {}, context: {},
|
||||
async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` },
|
||||
async request(method, requestPath, body) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,requestPath,body,token,headers) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
} : async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'domestic', method, requestPath })
|
||||
if (method === 'GET' && requestPath.endsWith('/roles')) return response([
|
||||
{ id: roleId, roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', status: 'ACTIVE', permissionIds: [permissionId] },
|
||||
@@ -324,16 +335,13 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId],
|
||||
overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE',
|
||||
}, requestPath, method)
|
||||
if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method)
|
||||
if (method === 'POST' && requestPath === '/api/auth/login') return response({ accessToken: `pda-token-${userSequence}` }, requestPath, method)
|
||||
if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method)
|
||||
throw new Error(`unexpected domestic request ${method} ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
},
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
apiSuccess(module, name, result) { return result.body.data },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||
|
||||
@@ -103,7 +103,7 @@ test('PDA operations reuse received plan pallets and the public location without
|
||||
})
|
||||
|
||||
test('P5 follows warehouse review, customer confirmation and fulfillment completion in order', () => {
|
||||
assert.match(source, /request\(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK'/)
|
||||
assert.match(source, /request\(context, context.token, 'POST', 'P5平台复核退回REWORK'/)
|
||||
assert.doesNotMatch(source, /decision: 'APPROVE'/)
|
||||
assert.match(source, /manualCompletion: true/)
|
||||
assert.match(source, /P5返工重新确认箱标阶段/)
|
||||
|
||||
@@ -47,7 +47,7 @@ function blocker(context, name, reason, response = null) {
|
||||
}
|
||||
|
||||
async function request(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
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 }
|
||||
}
|
||||
@@ -85,7 +85,7 @@ async function fundRelabelWallet(context, customerId) {
|
||||
async function expectConflict(context, token, method, name, path, body) {
|
||||
let response = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
response = await context.requestOverseas(method, path, body, {}, token)
|
||||
response = await context.request(method, path, body, token, {})
|
||||
if (response?.http !== 0 || attempt === 3) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
||||
}
|
||||
@@ -99,8 +99,7 @@ async function resolvePallet(context, token, palletCode, name = '扫码解析托
|
||||
}
|
||||
|
||||
async function resolvePublicLocation(context, fixture, sourceLocationCode) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
|
||||
const response = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
|
||||
const locations = records(response?.body?.data)
|
||||
const location = locations.find((item) => item?.locationType === 'SHARED_OVERFLOW'
|
||||
&& item?.locationCode === 'A0' && item?.status === 'ACTIVE' && item.locationCode !== sourceLocationCode)
|
||||
@@ -389,10 +388,10 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const inventoryCandidates = []
|
||||
let inventoryCandidatesResponse = null
|
||||
for (const inboundCode of inboundCodes) {
|
||||
inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||
inventoryCandidatesResponse = await context.request('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
|
||||
}), undefined, {}, merchantToken)
|
||||
}), undefined, merchantToken, {})
|
||||
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
|
||||
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
|
||||
if (!success(inventoryCandidatesResponse)) return false
|
||||
@@ -437,8 +436,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
}
|
||||
}),
|
||||
}
|
||||
const createdResponse = await context.requestOverseas('POST',
|
||||
query(relabelBase, { customerId }), createBody)
|
||||
const createdResponse = await context.request('POST', query(relabelBase, { customerId }), createBody)
|
||||
const created = success(createdResponse) ? createdResponse.body?.data : null
|
||||
const relabelNo = id(created?.application?.relabelNo)
|
||||
context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse,
|
||||
@@ -449,11 +447,11 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse)
|
||||
return false
|
||||
}
|
||||
const reviewed = await request(context, context.overseasToken, 'PUT', 'P5尾程订单审核直接贴标申请',
|
||||
const reviewed = await request(context, context.token, 'PUT', 'P5尾程订单审核直接贴标申请',
|
||||
query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.application.lastlegOrderId))}/relabel-review`, { customerId }),
|
||||
{ status: 'PENDING_PROCESSING', remark: context.marker })
|
||||
if (!success(reviewed.response) || reviewed.data?.application?.status !== 'PENDING_PROCESSING') return false
|
||||
const funded = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
const funded = await poll(async () => context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
(response) => success(response) && response.body?.data?.application?.walletStatus === 'FROZEN')
|
||||
context.addStep(MODULE, 'P5换标费用冻结后开放PDA作业', funded.result,
|
||||
funded.result?.body?.data?.application?.walletStatus === 'FROZEN',
|
||||
@@ -537,7 +535,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
detail = exception.data
|
||||
if (!detail) return false
|
||||
|
||||
const photo = await context.requestOverseasMultipart('POST',
|
||||
const photo = await context.requestMultipart('POST',
|
||||
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
|
||||
name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG,
|
||||
}, token)
|
||||
@@ -557,9 +555,9 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const reviewPath = `${relabelBase}/${encodeURIComponent(relabelNo)}/rework`
|
||||
const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data.version),
|
||||
idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` }
|
||||
const reworked = await request(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody)
|
||||
const reworked = await request(context, context.token, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody)
|
||||
if (!success(reworked.response)) return false
|
||||
const reworkReplay = await request(context, context.overseasToken, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody)
|
||||
const reworkReplay = await request(context, context.token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody)
|
||||
context.assert(MODULE, 'P5 REWORK重放保持AVAILABLE等待重新认领', reworkReplay.response,
|
||||
reworkReplay.data?.taskStatus === 'AVAILABLE' && Number(reworkReplay.data.relabelledPackages) === 0,
|
||||
trace(reworkReplay.response), OPTIONS)
|
||||
@@ -588,7 +586,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
if (!success(redo.response)) return false
|
||||
detail = redo.data
|
||||
}
|
||||
const bindingResponse = await context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`)
|
||||
const bindingResponse = await context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`)
|
||||
const bindingDetail = success(bindingResponse) ? bindingResponse.body.data : null
|
||||
if (!Array.isArray(bindingDetail?.items) || bindingDetail.items.length !== 3) return false
|
||||
|
||||
@@ -681,7 +679,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
`targets=${new Set(movedFacts.map((fact) => fact.targetPalletId)).size}, targetBMoves=${movedFacts.filter((fact) => fact.targetPalletId === id(printedB.data.palletId)).length}`, OPTIONS)
|
||||
|
||||
for (const fact of movedFacts) {
|
||||
const positionsResponse = await context.requestOverseas('GET', query(
|
||||
const positionsResponse = await context.request('GET', query(
|
||||
`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(fact.balanceId)}/positions`,
|
||||
{ customerId }), undefined)
|
||||
const positions = success(positionsResponse) && Array.isArray(positionsResponse.body?.data)
|
||||
@@ -694,7 +692,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
&& targetPosition?.inboundCode === fact.inboundCode,
|
||||
`targetPallet=${fact.targetPalletCode}, inbound=${targetPosition?.inboundOrderNo || 'missing'}; ${trace(positionsResponse)}`, OPTIONS)
|
||||
}
|
||||
const ledgerResponse = await context.requestOverseas('GET', query(
|
||||
const ledgerResponse = await context.request('GET', query(
|
||||
'/api/platform/v1/last-leg/inventory/ledger', {
|
||||
customerId, page: 1, size: 100, businessType: 'RELABEL_PALLET_DISASSEMBLE',
|
||||
occurredFrom: new Date(Date.now() - 10 * 60_000).toISOString().slice(0, 19),
|
||||
@@ -721,8 +719,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
trace(finalReplay.response), OPTIONS)
|
||||
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
|
||||
remark: `${context.marker}-customer-confirmed` }
|
||||
const confirmedResponse = await context.requestOverseas('POST',
|
||||
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
|
||||
const confirmedResponse = await context.request('POST', `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, merchantToken, {})
|
||||
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
|
||||
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
|
||||
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
|
||||
@@ -741,13 +738,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
export async function settleRelabelFulfillment(context, relabel) {
|
||||
const relabelNo = relabel.relabelNo
|
||||
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
|
||||
const terminal = await context.requestOverseas('PUT',
|
||||
`${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
|
||||
const terminal = await context.request('PUT', `${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
|
||||
status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`,
|
||||
})
|
||||
context.addStep(MODULE, 'P5待出库换标单完成履约', terminal,
|
||||
success(terminal) && terminal.body?.data?.application?.status === 'COMPLETED', trace(terminal), OPTIONS)
|
||||
const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
const settled = await poll(async () => context.request('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
(response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED')
|
||||
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
|
||||
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
|
||||
|
||||
@@ -59,20 +59,19 @@ function blockerGap(context, name, reason, category = 'fixture-unavailable', res
|
||||
}
|
||||
|
||||
async function pda(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
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 expected(context, token, method, name, path, body, predicate) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS)
|
||||
return response
|
||||
}
|
||||
|
||||
async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const candidates = records(response?.body?.data)
|
||||
.filter((item) => id(item?.containerNo) === id(containerNo))
|
||||
const exactCustomer = expectedCustomerId == null ? candidates
|
||||
@@ -85,7 +84,7 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
|
||||
context.createdResources.phase4OverseasPda.orderId = id(order.id)
|
||||
context.createdResources.phase4OverseasPda.customerId = id(order.customerId)
|
||||
const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}`
|
||||
const detail = await context.requestOverseas('GET', detailPath)
|
||||
const detail = await context.request('GET', detailPath)
|
||||
context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail,
|
||||
success(detail) && id(detail.body?.data?.order?.id) === id(order.id)
|
||||
&& Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0,
|
||||
@@ -102,10 +101,10 @@ async function loginMerchantForNegativeProbe(context) {
|
||||
if (!username || !password) return null
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const data = success(response) ? response.body?.data : null
|
||||
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
|
||||
success(response) && nonBlank(data?.overseasAccessToken),
|
||||
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
||||
context.addStep(MODULE, '本轮客户登录取得访问令牌用于PDA越权反证', response,
|
||||
success(response) && nonBlank(data?.accessToken),
|
||||
`tokenPresent=${nonBlank(data?.accessToken)}; ${trace(response)}`, OPTIONS)
|
||||
return nonBlank(data?.accessToken) ? data.accessToken : null
|
||||
}
|
||||
|
||||
export async function resolvePickerAccess(context) {
|
||||
@@ -144,17 +143,16 @@ export async function createPicker(context, index, overseasWarehouseId, access)
|
||||
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || 'missing'}`, OPTIONS)
|
||||
if (!longId(membershipId)) return null
|
||||
const login = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
||||
const token = success(login) ? login.body?.data?.overseasAccessToken : null
|
||||
context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token),
|
||||
`overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||
const token = success(login) ? login.body?.data?.accessToken : null
|
||||
context.addStep(MODULE, `海外PDA操作员${index}登录取得统一访问令牌`, login, nonBlank(token),
|
||||
`tokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||
if (!nonBlank(token)) return { membershipId, username, token: null }
|
||||
|
||||
let projectionResponse = null
|
||||
let projectionReady = false
|
||||
let projectionAttempt = 0
|
||||
for (; projectionAttempt < 30; projectionAttempt += 1) {
|
||||
projectionResponse = await context.requestOverseas('GET',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
projectionResponse = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
projectionReady = success(projectionResponse) || notFound(projectionResponse)
|
||||
if (projectionReady || !forbidden(projectionResponse)) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
@@ -193,12 +191,10 @@ async function resolveGeneratedPallet(context, inbound) {
|
||||
const pallet = candidates[0]
|
||||
if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
|
||||
const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
|
||||
const listed = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
|
||||
const listed = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
|
||||
const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode)
|
||||
const locations = stored && longId(stored?.locationId)
|
||||
? await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
|
||||
? await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
|
||||
: null
|
||||
const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId))
|
||||
const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode)
|
||||
@@ -263,14 +259,14 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
|
||||
}
|
||||
|
||||
export async function uploadPhotos(context, token) {
|
||||
const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
const malicious = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
|
||||
}, token)
|
||||
context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious,
|
||||
malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS)
|
||||
const photos = []
|
||||
for (const role of PHOTO_ROLES) {
|
||||
const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
const response = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG,
|
||||
}, token)
|
||||
const fileId = id(response?.body?.data?.fileId)
|
||||
@@ -290,8 +286,7 @@ async function awaitProducerConsumption(context, producers) {
|
||||
for (let attempt = 1; attempt <= 90; attempt += 1) {
|
||||
const missing = expected.filter((containerNo) => !consumed.has(containerNo))
|
||||
const results = await Promise.all(missing.map(async (containerNo) => {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
|
||||
return { containerNo, response }
|
||||
}))
|
||||
for (const { containerNo, response } of results) {
|
||||
@@ -316,9 +311,7 @@ async function awaitProducerConsumption(context, producers) {
|
||||
async function convertReceivingContainers(context, producers) {
|
||||
for (const producer of producers) {
|
||||
const containerNo = id(producer?.containerNo)
|
||||
const summaryResponse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`,
|
||||
undefined, {}, context.overseasToken)
|
||||
const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {})
|
||||
const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records)
|
||||
? summaryResponse.body.data.records : []
|
||||
const summary = summaries.find((item) => id(item?.containerNo) === containerNo)
|
||||
@@ -336,9 +329,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
|
||||
if (!convertible) return false
|
||||
const bookingId = id(summary.sourceContainerBookingId)
|
||||
const arrivalQueryResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/orders/container-arrival-times/query',
|
||||
{ sourceContainerBookingIds: [bookingId] }, {}, context.overseasToken)
|
||||
const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {})
|
||||
const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data)
|
||||
? arrivalQueryResponse.body.data : []
|
||||
let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId)
|
||||
@@ -349,12 +340,8 @@ async function convertReceivingContainers(context, producers) {
|
||||
if (!arrivalTime) return false
|
||||
if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) {
|
||||
const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19)
|
||||
const arrivalUpdateResponse = await context.requestOverseas('PUT',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`,
|
||||
{ estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
|
||||
remark: `${context.marker}-overseas-inbound-plan-prerequisite` },
|
||||
{ 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) },
|
||||
context.overseasToken)
|
||||
const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
|
||||
remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, context.token, { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) })
|
||||
arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null
|
||||
context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse,
|
||||
id(arrivalTime?.sourceContainerBookingId) === bookingId
|
||||
@@ -364,8 +351,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false
|
||||
}
|
||||
const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
|
||||
const previewResponse = await context.requestOverseas('GET', previewPath,
|
||||
undefined, {}, context.overseasToken)
|
||||
const previewResponse = await context.request('GET', previewPath, undefined, context.token, {})
|
||||
const preview = success(previewResponse) ? previewResponse.body?.data : null
|
||||
context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
|
||||
preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
|
||||
@@ -377,9 +363,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
const body = { sourceContainerBookingId: bookingId,
|
||||
snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) }
|
||||
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
|
||||
const convertedResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const convertedResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key })
|
||||
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
|
||||
? convertedResponse.body.data : []
|
||||
const convertedInboundCodes = new Set(plans
|
||||
@@ -412,9 +396,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
OPTIONS)
|
||||
}
|
||||
rememberGeneratedPlans(context, plans)
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
{ 'Idempotency-Key': key }, context.overseasToken)
|
||||
const replayResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key })
|
||||
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
|
||||
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
|
||||
trace(replayResponse), OPTIONS)
|
||||
@@ -428,9 +410,7 @@ async function convertReceivingContainers(context, producers) {
|
||||
`plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
||||
OPTIONS)
|
||||
}
|
||||
const progressResponse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`,
|
||||
undefined, {}, context.overseasToken)
|
||||
const progressResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, undefined, context.token, {})
|
||||
const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null
|
||||
context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse,
|
||||
progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders
|
||||
@@ -452,8 +432,7 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer
|
||||
let response = null
|
||||
let candidatesByContainer = new Map()
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
response = await context.requestOverseas('GET',
|
||||
'/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token)
|
||||
response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {})
|
||||
const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
|
||||
candidatesByContainer = new Map(candidateRows
|
||||
.map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo)))
|
||||
@@ -499,8 +478,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa
|
||||
}
|
||||
const cartonFixtures = []
|
||||
for (const [index, cartonMark] of cartonCandidates.entries()) {
|
||||
const response = await context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token)
|
||||
const response = await context.request('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, token, {})
|
||||
if (!strictDropship && overseasCartons.includes(cartonMark)
|
||||
&& a0404Message(response, /^Carton mark was not found$/)) {
|
||||
context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true,
|
||||
@@ -669,15 +647,15 @@ async function receiveDropship(context, token, producer, inbounds) {
|
||||
}
|
||||
|
||||
async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
|
||||
if (runAuthorizationProbes && context.overseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
||||
context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
if (runAuthorizationProbes && context.token) {
|
||||
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {})
|
||||
context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
if (runAuthorizationProbes && merchantToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken)
|
||||
context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, merchantToken, {})
|
||||
context.addStep(MODULE, '客户访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
}
|
||||
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
const before = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
const noCurrent = notFound(before)
|
||||
context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before),
|
||||
`${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS)
|
||||
@@ -848,7 +826,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
||||
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
|
||||
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS)
|
||||
const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||
const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
|
||||
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
||||
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
||||
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
|
||||
|
||||
@@ -42,7 +42,7 @@ function add(context, name, response, passed, evidence = '') {
|
||||
}
|
||||
|
||||
async function overseas(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
add(context, name, response, success(response))
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
}
|
||||
@@ -70,7 +70,7 @@ async function loginMerchant(context) {
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.overseasAccessToken : null
|
||||
const token = success(response) ? response.body?.data?.accessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export async function run(context) {
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
|
||||
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
|
||||
const uploaded = await context.requestOverseasMultipart('POST',
|
||||
const uploaded = await context.requestMultipart('POST',
|
||||
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
|
||||
form, merchantToken)
|
||||
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
|
||||
@@ -150,7 +150,7 @@ export async function run(context) {
|
||||
}
|
||||
}
|
||||
|
||||
const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选',
|
||||
const outboundCandidates = await overseas(context, context.token, 'GET', '平台查询本轮退货出库候选',
|
||||
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
|
||||
customerId, warehouseId: warehouse.id,
|
||||
}))
|
||||
@@ -165,7 +165,7 @@ export async function run(context) {
|
||||
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
|
||||
idempotencyKey: key(context, 'OUTBOUND'),
|
||||
}
|
||||
const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单',
|
||||
const outbound = await overseas(context, context.token, 'POST', '平台创建独立退货出库单',
|
||||
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
|
||||
const taskNo = id(outbound.data?.taskNo)
|
||||
const outboundNo = id(outbound.data?.outboundNo)
|
||||
@@ -187,7 +187,7 @@ export async function run(context) {
|
||||
const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库',
|
||||
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`,
|
||||
{ idempotencyKey: key(context, 'OUTBOUND-COMPLETE') })
|
||||
const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态',
|
||||
const outboundDetail = await overseas(context, context.token, 'GET', '平台回查退货出库最终状态',
|
||||
query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }))
|
||||
context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response,
|
||||
completed.data?.status === 'COMPLETED'
|
||||
@@ -220,7 +220,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
context.assert(MODULE, name, null, Boolean(value), `relabelNo=${relabelNo}`, OPTIONS)
|
||||
if (!value) throw new Error(name)
|
||||
}
|
||||
const candidates = await command(context.overseasToken, 'GET', '查询换标完成后的退货出库候选',
|
||||
const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选',
|
||||
query(`${root}/return/candidates`, { customerId, warehouseId }))
|
||||
const selected = []
|
||||
for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) {
|
||||
@@ -236,13 +236,13 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
const mark = selected[0].cartonMark
|
||||
const body = { recipient: `${context.marker} RELABELLED RETURN`, overseasWarehouseId: warehouseId,
|
||||
returnNo, cartonMarks: [mark], remark: context.marker, idempotencyKey: key(context, 'RELABELLED-OUTBOUND') }
|
||||
const task = await command(context.overseasToken, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body)
|
||||
const replay = await command(context.overseasToken, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body)
|
||||
const task = await command(context.token, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body)
|
||||
const replay = await command(context.token, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body)
|
||||
require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo)
|
||||
&& replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo)
|
||||
const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}`
|
||||
const detail = await command(pdaToken, 'GET', 'PDA核对已换标库存出库任务', path)
|
||||
const frozen = await command(context.overseasToken, 'GET', '平台核对本轮冻结出库箱号',
|
||||
const frozen = await command(context.token, 'GET', '平台核对本轮冻结出库箱号',
|
||||
query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId }))
|
||||
const items = detail.items || []
|
||||
require('出库任务只包含本轮已换标箱', detail.task?.outboundNo === task.outboundNo
|
||||
@@ -255,7 +255,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken,
|
||||
cartonMarks: [mark], idempotencyKey: key(context, 'RELABELLED-PICK'),
|
||||
})
|
||||
const completed = await command(pdaToken, 'POST', 'PDA完成已换标库存出库', `${path}/complete`, { idempotencyKey: key(context, 'RELABELLED-COMPLETE') })
|
||||
const outbound = await command(context.overseasToken, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId }))
|
||||
const outbound = await command(context.token, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId }))
|
||||
const after = await command(merchantToken, 'GET', '客户回查已换标库存出库终态', `${inventory}/${encodeURIComponent(mark)}`)
|
||||
require('已换标库存出库完成且来源和换标关联不漂移', completed.status === 'COMPLETED'
|
||||
&& outbound.outbound?.status === 'OUTBOUNDED' && after.status === 'OUTBOUNDED'
|
||||
@@ -277,7 +277,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
|
||||
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`,
|
||||
const reviewed = await command(context.token, 'PUT', '审核本轮退货换标申请', `${platform}/status`,
|
||||
{ status: 'PENDING_PROCESSING', remark: context.marker })
|
||||
require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING')
|
||||
let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') })
|
||||
@@ -311,7 +311,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
|
||||
task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken)
|
||||
const photo = await context.requestMultipart('POST', `${work}/photos/files`, form, pdaToken)
|
||||
add(context, '上传合成换标完工照片', photo, success(photo))
|
||||
if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败')
|
||||
task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, {
|
||||
|
||||
@@ -10,9 +10,9 @@ const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.ur
|
||||
|
||||
test('relabelled outbound refuses another relabel application before creating an outbound', async () => {
|
||||
const calls = []
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
addStep() {}, assert() {},
|
||||
async requestOverseas(method, path) {
|
||||
async request(method,path) {
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: path.includes('/candidates')
|
||||
? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } }
|
||||
@@ -22,10 +22,10 @@ test('relabelled outbound refuses another relabel application before creating an
|
||||
})
|
||||
|
||||
test('return outbound binds platform frozen carton to PDA item without requiring ordinary inventory positions', async () => {
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {},
|
||||
assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
async requestOverseas(method, path, body) {
|
||||
async request(method,path,body) {
|
||||
let data
|
||||
if (path.includes('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }]
|
||||
else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123',
|
||||
@@ -47,9 +47,9 @@ test('return outbound binds platform frozen carton to PDA item without requiring
|
||||
test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => {
|
||||
const calls = []
|
||||
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
async requestOverseas(method, path, body, _headers, token) {
|
||||
async request(method,path,body,token,_headers) {
|
||||
calls.push({ method, path, body, token })
|
||||
let data = task
|
||||
if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
|
||||
@@ -68,7 +68,7 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
|
||||
if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestOverseasMultipart(method, path, form, token) {
|
||||
async requestMultipart(method, path, form, token) {
|
||||
assert.equal(token, 'pda'); assert.ok(form instanceof FormData)
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: { fileId: '7' } } }
|
||||
@@ -101,7 +101,7 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt
|
||||
assert.match(source, /palletCode: cartonA/)
|
||||
assert.match(source, /最后一批缺少打托照片拒绝且库存不变/)
|
||||
assert.match(source, /photos\/files/)
|
||||
assert.match(source, /requestOverseasMultipart\('POST',[^\n]+photoForm, picker\.token\)/)
|
||||
assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/)
|
||||
assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/)
|
||||
assert.match(source, /不存在SKU不是业务错误/)
|
||||
assert.match(source, /已完成退货单不再进入待签收流程/)
|
||||
|
||||
@@ -55,10 +55,10 @@ function add(context, name, response, passed, evidence = '') {
|
||||
}
|
||||
|
||||
async function overseas(context, token, method, name, path, body = undefined) {
|
||||
let response = await context.requestOverseas(method, path, body, {}, token)
|
||||
let response = await context.request(method, path, body, token, {})
|
||||
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
||||
response = await context.requestOverseas(method, path, body, {}, token)
|
||||
response = await context.request(method, path, body, token, {})
|
||||
}
|
||||
add(context, name, response, success(response))
|
||||
return { response, data: success(response) ? response.body?.data : null }
|
||||
@@ -87,7 +87,7 @@ async function loginMerchant(context) {
|
||||
const response = await context.request('POST', '/api/auth/login', {
|
||||
loginId: fixture.username, password: fixture.password,
|
||||
}, '')
|
||||
const token = success(response) ? response.body?.data?.overseasAccessToken : null
|
||||
const token = success(response) ? response.body?.data?.accessToken : null
|
||||
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
|
||||
return nonBlank(token) ? token : null
|
||||
}
|
||||
@@ -107,8 +107,7 @@ export async function approveReturnFixture(context, merchantToken, created) {
|
||||
if (!success(owned.response) || owned.data?.order?.returnNo !== order.returnNo
|
||||
|| owned.data?.order?.status !== 'PENDING_REVIEW') throw new Error('客户不可读取当前待审核退货单')
|
||||
const path = `/api/platform/v1/last-leg/return-orders/${encodeURIComponent(order.returnNo)}/review`
|
||||
const response = await context.requestOverseas('POST', path,
|
||||
{ decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, { 'X-Customer-Id': expectedCustomer })
|
||||
const response = await context.request('POST', path, { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, undefined, { 'X-Customer-Id': expectedCustomer })
|
||||
add(context, '平台审核本轮退货预报', response, success(response))
|
||||
if (!success(response)) throw new Error(`退货审核失败:${trace(response)}`)
|
||||
const detail = await overseas(context, merchantToken, 'GET', '客户回查退货审核状态',
|
||||
@@ -193,8 +192,7 @@ export async function run(context) {
|
||||
const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
|
||||
context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
|
||||
id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
|
||||
await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken))
|
||||
await reject('退货预报同键异载荷返回冲突', conflict, () => context.request('POST', RETURN_ORDERS, { ...createBody, remark: `${context.marker}-CHANGED` }, merchantToken, {}))
|
||||
|
||||
await approveReturnFixture(context, merchantToken, created.data)
|
||||
|
||||
@@ -205,8 +203,7 @@ export async function run(context) {
|
||||
['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]],
|
||||
['重复预报SKU', [createBody.items[0], createBody.items[0]]],
|
||||
]) {
|
||||
await reject(`拒绝${name}`, validationFailure, () => context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, {}, merchantToken))
|
||||
await reject(`拒绝${name}`, validationFailure, () => context.request('POST', RETURN_ORDERS, { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, merchantToken, {}))
|
||||
}
|
||||
const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量',
|
||||
query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA }))
|
||||
@@ -214,8 +211,7 @@ export async function run(context) {
|
||||
Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo
|
||||
&& records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS)
|
||||
|
||||
await reject('不存在退货单返回可诊断未找到', notFound, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`,
|
||||
{ returnNo: `MISSING-${suffix}` }, {}, picker.token))
|
||||
await reject('不存在退货单返回可诊断未找到', notFound, () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo: `MISSING-${suffix}` }, picker.token, {}))
|
||||
|
||||
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
|
||||
@@ -260,9 +256,9 @@ export async function run(context) {
|
||||
&& id(firstReplay.data?.receiptBatchNo) === firstBatchNo
|
||||
&& id(firstReplay.data?.palletCode) === cartonA,
|
||||
trace(firstReplay.response), OPTIONS)
|
||||
await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
|
||||
}, {}, picker.token))
|
||||
}, picker.token, {}))
|
||||
|
||||
await reject('超收被拒绝且不得生成费用批次', conflict, async () => {
|
||||
const body = {
|
||||
@@ -271,7 +267,7 @@ export async function run(context) {
|
||||
}
|
||||
let response = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, body, {}, picker.token)
|
||||
response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, body, picker.token, {})
|
||||
if (response?.http !== 0 || attempt === 3) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
||||
}
|
||||
@@ -285,13 +281,12 @@ export async function run(context) {
|
||||
['非法签收方式', { startedBy: 'INVALID' }, validationFailure],
|
||||
['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict],
|
||||
]) {
|
||||
await reject(`拒绝${name}`, expected, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`,
|
||||
{ ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, {}, picker.token))
|
||||
await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {}))
|
||||
}
|
||||
await reject('部分签收退货单禁止合箱', conflict, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
|
||||
await reject('部分签收退货单禁止合箱', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
|
||||
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10,
|
||||
weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'),
|
||||
}, {}, picker.token))
|
||||
}, picker.token, {}))
|
||||
|
||||
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
|
||||
@@ -310,7 +305,7 @@ export async function run(context) {
|
||||
await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => {
|
||||
let response = null
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token)
|
||||
response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, secondBody, picker.token, {})
|
||||
if (response?.http !== 0 || attempt === 3) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
||||
}
|
||||
@@ -319,7 +314,7 @@ export async function run(context) {
|
||||
const photoForm = new FormData()
|
||||
// Synthetic HTTP fixture, not a photograph of real warehouse activity.
|
||||
photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token)
|
||||
const photo = await context.requestMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token)
|
||||
add(context, '上传本轮合成打托测试照片', photo, success(photo))
|
||||
if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败')
|
||||
const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody)
|
||||
@@ -338,7 +333,7 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
|
||||
|
||||
await reject('已完成退货单不再进入待签收流程', notFound,
|
||||
() => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token))
|
||||
() => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo }, picker.token, {}))
|
||||
const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
|
||||
context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
|
||||
@@ -358,13 +353,11 @@ export async function run(context) {
|
||||
&& detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo),
|
||||
`receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS)
|
||||
|
||||
const merchantInventory = await context.requestOverseas('GET',
|
||||
query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }),
|
||||
undefined, {}, merchantToken)
|
||||
const merchantInventory = await context.request('GET', query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), undefined, merchantToken, {})
|
||||
add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory))
|
||||
|
||||
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
|
||||
const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存',
|
||||
const inventory = await overseas(context, context.token, 'GET', '平台按客户和退货单回查全部退货箱库存',
|
||||
query('/api/platform/v1/last-leg/return-inventory/cartons', {
|
||||
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo,
|
||||
}))
|
||||
@@ -376,7 +369,7 @@ export async function run(context) {
|
||||
&& sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo),
|
||||
`cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS)
|
||||
|
||||
const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存',
|
||||
const summary = await overseas(context, context.token, 'GET', '平台回查退货SKU汇总库存',
|
||||
query('/api/platform/v1/last-leg/return-inventory', {
|
||||
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id,
|
||||
}))
|
||||
@@ -389,14 +382,14 @@ export async function run(context) {
|
||||
|
||||
for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 3 },
|
||||
{ cartonMark: cartonB, batchNo: secondBatchNo, quantity: 2 }].entries()) {
|
||||
const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`,
|
||||
const carton = await overseas(context, context.token, 'GET', `平台回查第${index + 1}个退货箱明细`,
|
||||
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`)
|
||||
context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response,
|
||||
carton.data?.sourceBatchNo === expected.batchNo
|
||||
&& Number(carton.data?.totalQuantity) === expected.quantity
|
||||
&& Array.isArray(carton.data?.items) && carton.data.items.length > 0,
|
||||
`batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS)
|
||||
const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`,
|
||||
const label = await overseas(context, context.token, 'GET', `平台生成第${index + 1}个退货箱标签`,
|
||||
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`)
|
||||
context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response,
|
||||
label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
|
||||
@@ -454,9 +447,7 @@ export async function run(context) {
|
||||
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
|
||||
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
|
||||
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
|
||||
await reject('合箱同键异载荷返回冲突', conflict, () => context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, weight: 3 }, {}, picker.token))
|
||||
await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, picker.token, {}))
|
||||
|
||||
const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照',
|
||||
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo }))
|
||||
@@ -468,8 +459,7 @@ export async function run(context) {
|
||||
['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict],
|
||||
['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound],
|
||||
]) {
|
||||
await reject(`合箱拒绝${name}`, expected, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, {}, picker.token))
|
||||
await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {}))
|
||||
}
|
||||
await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
|
||||
|
||||
@@ -48,8 +48,8 @@ async function merchantLogin(context, fixture) {
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||
const auth = success(response) ? response.body.data : null
|
||||
assert(context, '客户账号同时取得国内和海外短时Token', response,
|
||||
Boolean(auth?.accessToken) && Boolean(auth?.overseasAccessToken),
|
||||
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.overseasAccessToken)}`)
|
||||
Boolean(auth?.accessToken) && Boolean(auth?.accessToken),
|
||||
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`)
|
||||
return auth
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ async function prepareWallet(context, customerId) {
|
||||
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
|
||||
}
|
||||
|
||||
async function procurementFlow(context, fixture, overseasToken) {
|
||||
async function procurementFlow(context, fixture, token) {
|
||||
const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const payload = {
|
||||
customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`,
|
||||
@@ -105,7 +105,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker,
|
||||
idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255),
|
||||
}
|
||||
const created = await context.requestOverseas('POST', merchantPath, payload, {}, overseasToken)
|
||||
const created = await context.request('POST', merchantPath, payload, token, {})
|
||||
const detail = created?.body?.data
|
||||
const orderNo = detail?.summary?.orderNo
|
||||
assert(context, '客户创建钱包采购单', created,
|
||||
@@ -117,7 +117,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
`orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`)
|
||||
|
||||
const customerId = id(fixture.tenantId)
|
||||
const approved = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
|
||||
const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
|
||||
version: Number(detail.summary.version), decision: 'APPROVE',
|
||||
idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker,
|
||||
})
|
||||
@@ -128,7 +128,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
|
||||
const files = []
|
||||
for (const category of ['VOUCHER', 'RECEIPT']) {
|
||||
const uploaded = await context.requestOverseasMultipart('POST',
|
||||
const uploaded = await context.requestMultipart('POST',
|
||||
query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
||||
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
||||
assert(context, `采购单上传${category}文件`, uploaded,
|
||||
@@ -138,7 +138,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
}
|
||||
if (files.length !== 2) return null
|
||||
|
||||
const fee = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
|
||||
const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
|
||||
feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01,
|
||||
voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker,
|
||||
})
|
||||
@@ -149,7 +149,7 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
if (!success(fee) || !feeNo) return null
|
||||
|
||||
const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId })
|
||||
const frozen = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const frozen = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN')
|
||||
const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
|
||||
assert(context, '采购费用跨区冻结到账', frozen.result,
|
||||
@@ -157,13 +157,13 @@ async function procurementFlow(context, fixture, overseasToken) {
|
||||
if (frozenFee?.walletStatus !== 'FROZEN') return null
|
||||
|
||||
const current = frozen.result.body.data.summary
|
||||
const settledOrder = await context.requestOverseas('PATCH', detailPath, {
|
||||
const settledOrder = await context.request('PATCH', detailPath, {
|
||||
version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity),
|
||||
})
|
||||
assert(context, '采购全量入库推进待出库并触发结算', settledOrder,
|
||||
success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND',
|
||||
`status=${settledOrder.body?.data?.summary?.status || 'missing'}`)
|
||||
const settled = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const settled = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED')
|
||||
const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
|
||||
assert(context, '采购费用跨区结算到账', settled.result,
|
||||
@@ -191,10 +191,9 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
|
||||
}
|
||||
}
|
||||
|
||||
async function relabelFlow(context, overseasToken, warehouseId, suffix, terminalStatus) {
|
||||
const candidatesResponse = await context.requestOverseas('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }),
|
||||
undefined, {}, overseasToken)
|
||||
async function relabelFlow(context, token, warehouseId, suffix, terminalStatus) {
|
||||
const candidatesResponse = await context.request('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), undefined, token, {})
|
||||
const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data)
|
||||
? candidatesResponse.body.data : []
|
||||
const currentPda = context.createdResources?.phase4OverseasPda
|
||||
@@ -229,7 +228,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
Boolean(source?.position), `warehouseId=${warehouseId}, candidates=${candidates.length}`)
|
||||
if (!source?.position) return null
|
||||
const payload = relabelPayload(context, warehouseId, source, suffix, 0.01)
|
||||
const created = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, {}, overseasToken)
|
||||
const created = await context.request('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, token, {})
|
||||
const relabelNo = created?.body?.data?.application?.relabelNo
|
||||
assert(context, `${suffix}换标单创建并携带收费金额`, created,
|
||||
success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01,
|
||||
@@ -237,7 +236,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
if (!success(created) || !relabelNo) return null
|
||||
|
||||
const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}`
|
||||
const frozen = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const frozen = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN')
|
||||
assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result,
|
||||
frozen.result?.body?.data?.application?.walletStatus === 'FROZEN',
|
||||
@@ -245,32 +244,29 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
|
||||
if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null
|
||||
|
||||
const reviewStatus = terminalStatus === 'COMPLETED' ? 'PENDING_PROCESSING' : 'CANCELLED'
|
||||
const review = await context.requestOverseas('PUT', query(
|
||||
const review = await context.request('PUT', query(
|
||||
`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.body.data.application.lastlegOrderId))}/relabel-review`,
|
||||
{ customerId: id(context.context.merchantQualificationFixture.tenantId) }),
|
||||
{ status: reviewStatus, remark: context.marker })
|
||||
{ customerId: id(context.context.merchantQualificationFixture.tenantId) }), { status: reviewStatus, remark: context.marker })
|
||||
assert(context, `${suffix}尾程订单审核直接贴标申请`, review,
|
||||
success(review) && review.body.data?.application?.status === reviewStatus)
|
||||
if (!success(review)) return null
|
||||
|
||||
if (terminalStatus === 'COMPLETED') {
|
||||
const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
|
||||
const processing = await context.request('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
|
||||
assert(context, '换标单冻结后才允许进入处理中', processing,
|
||||
success(processing) && processing.body.data?.application?.status === 'PROCESSING')
|
||||
const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`,
|
||||
{ status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
|
||||
const confirmPending = await context.request('PUT', `${detailPath}/status`, { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
|
||||
assert(context, '换标处理完成后进入客户确认', confirmPending,
|
||||
success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING')
|
||||
const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`,
|
||||
{ status: 'READY_FOR_OUTBOUND', remark: context.marker })
|
||||
const readyForOutbound = await context.request('PUT', `${detailPath}/status`, { status: 'READY_FOR_OUTBOUND', remark: context.marker })
|
||||
assert(context, '换标客户确认后进入待出库', readyForOutbound,
|
||||
success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND')
|
||||
}
|
||||
const terminal = terminalStatus === 'CANCELLED' ? review : await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
|
||||
const terminal = terminalStatus === 'CANCELLED' ? review : await context.request('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
|
||||
assert(context, `${suffix}换标单进入${terminalStatus}`, terminal,
|
||||
success(terminal) && terminal.body.data?.application?.status === terminalStatus)
|
||||
const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED'
|
||||
const wallet = await poll(async () => context.requestOverseas('GET', detailPath),
|
||||
const wallet = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal)
|
||||
assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result,
|
||||
wallet.result?.body?.data?.application?.walletStatus === walletTerminal,
|
||||
@@ -308,14 +304,13 @@ async function verifyPalletSummaries(context, warehouseId) {
|
||||
success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount'])
|
||||
&& Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount),
|
||||
`total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`)
|
||||
const overseas = await context.requestOverseas('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20')
|
||||
const overseas = await context.request('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20')
|
||||
const o = overseas?.body?.data
|
||||
assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas,
|
||||
success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])
|
||||
&& Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount),
|
||||
`total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`)
|
||||
const warehouse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`)
|
||||
const warehouse = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`)
|
||||
assert(context, '海外仓级托盘汇总可用', warehouse,
|
||||
success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']))
|
||||
}
|
||||
@@ -324,7 +319,7 @@ async function verifyDestuffCosts(context, completedFacts) {
|
||||
const evidence = []
|
||||
for (const fact of completedFacts) {
|
||||
const businessNo = `DESTUFF-${fact.containerBookingId}`
|
||||
const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', {
|
||||
const lastleg = await poll(async () => context.request('GET', query('/api/platform/v1/last-leg/finance-fee-sources', {
|
||||
businessType: 'OVERSEAS_CONTAINER', businessNo,
|
||||
})), (response) => success(response) && rows(response.body.data).length > 0, 75)
|
||||
const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : []
|
||||
@@ -363,9 +358,9 @@ export async function run(context) {
|
||||
|
||||
const auth = await merchantLogin(context, fixture)
|
||||
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
|
||||
const procurement = wallet ? await procurementFlow(context, fixture, auth.overseasAccessToken) : null
|
||||
const settledRelabel = procurement ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
||||
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
||||
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null
|
||||
const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
||||
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
||||
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
|
||||
const finalAccount = procurement && relabels.length === 2
|
||||
? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null
|
||||
|
||||
@@ -16,22 +16,15 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null
|
||||
const context = {
|
||||
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
|
||||
async request(method, requestPath, body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
||||
if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||
throw new Error(`Unexpected domestic path: ${requestPath}`)
|
||||
},
|
||||
async requestOverseas(method, requestPath, body) {
|
||||
async request(...args) {
|
||||
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
|
||||
const handler = route ? async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'overseas', method, path: requestPath, body })
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
|
||||
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
|
||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
||||
if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
|
||||
if (method === 'DELETE' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = null; return voidResponse() }
|
||||
if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||
if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) {
|
||||
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
||||
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse()
|
||||
}
|
||||
@@ -45,16 +38,27 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }]))
|
||||
if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }])
|
||||
if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) return createdLocation == null
|
||||
? response(undefined, 'A0404') : response(createdLocation)
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||
if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) return response(createdPallet)
|
||||
throw new Error(`Unexpected path: ${requestPath}`)
|
||||
},
|
||||
async requestOverseasBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
|
||||
} : async function(method,requestPath,body) {
|
||||
calls.push({ channel: 'domestic', method, path: requestPath, body })
|
||||
if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants))
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
|
||||
if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||
throw new Error(`Unexpected domestic path: ${requestPath}`)
|
||||
};
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
|
||||
apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null },
|
||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
|
||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
|
||||
@@ -65,10 +69,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
||||
|
||||
test('模块只依赖注入请求,不读取环境或自行联网', () => {
|
||||
assert.match(source, /export async function run\(context\)/)
|
||||
assert.match(source, /context\.requestOverseas\('GET'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.request\('GET'/)
|
||||
assert.match(source, /context\.requestBinary\('GET'/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/)
|
||||
assert.doesNotMatch(source, /process\.env|fetch\(/)
|
||||
})
|
||||
|
||||
test('既有仓库场景只写本轮库位与托盘生命周期', () => {
|
||||
@@ -83,9 +87,9 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => {
|
||||
assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/')))
|
||||
const writes = context.calls.filter((call) => call.method !== 'GET')
|
||||
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
|
||||
assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
||||
assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets')))
|
||||
assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false)
|
||||
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/')))
|
||||
assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/')))
|
||||
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
|
||||
const step = context.steps.find((item) => item.name === name)
|
||||
assert.ok(step?.passed, name)
|
||||
|
||||
@@ -31,12 +31,12 @@ async function domesticGet(context, name, path) {
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasGet(context, name, path) {
|
||||
const response = await context.requestOverseas('GET', path)
|
||||
const response = await context.request('GET', path)
|
||||
assertEnvelope(context, name, response)
|
||||
return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) }
|
||||
}
|
||||
async function overseasMutation(context, method, name, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const response = await context.request(method, path, body)
|
||||
const passed = response?.http === 200 && String(response?.body?.code) === '200'
|
||||
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
||||
return { response, data: passed ? response.body?.data : null }
|
||||
@@ -48,7 +48,7 @@ async function domesticMutation(context, method, name, path, body) {
|
||||
return { response, data: passed ? response.body?.data : null }
|
||||
}
|
||||
async function overseasCsv(context, name, path) {
|
||||
const response = await context.requestOverseasBinary('GET', path)
|
||||
const response = await context.requestBinary('GET', path)
|
||||
context.addStep(MODULE, name, response,
|
||||
response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'),
|
||||
`bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS)
|
||||
@@ -164,7 +164,7 @@ export async function run(context) {
|
||||
const warehouseId = id(warehouse.id)
|
||||
context.createdResources.phase4OverseasWarehouse.customerId = customerId
|
||||
context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId
|
||||
const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 }))
|
||||
const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 }))
|
||||
assertPage(context, '公共库位列表', locations)
|
||||
const location = records(locations.data)[0]
|
||||
if (location) {
|
||||
@@ -172,12 +172,12 @@ export async function run(context) {
|
||||
longId(location.id) && id(location.warehouseId) === warehouseId && id(location.customerId) === '0'
|
||||
&& nonBlank(location.locationCode) && nonBlank(location.status),
|
||||
`id=${id(location.id) || 'missing'}, customerId=${id(location.customerId) || 'missing'}, status=${location.status || 'missing'}; ${trace(locations.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`)
|
||||
const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`)
|
||||
context.assert(MODULE, '公共库位详情对应列表记录', detail.response,
|
||||
id(detail.data?.id) === id(location.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0',
|
||||
`listId=${id(location.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
}
|
||||
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
||||
const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 }))
|
||||
assertPage(context, '公共托盘列表', pallets)
|
||||
const pallet = records(pallets.data)[0]
|
||||
if (pallet) {
|
||||
@@ -185,7 +185,7 @@ export async function run(context) {
|
||||
longId(pallet.id) && id(pallet.warehouseId) === warehouseId && id(pallet.customerId) === '0'
|
||||
&& nonBlank(pallet.palletCode) && ['IDLE', 'OCCUPIED', 'SCRAPPED'].includes(pallet.status),
|
||||
`id=${id(pallet.id) || 'missing'}, customerId=${id(pallet.customerId) || 'missing'}, status=${pallet.status || 'missing'}; ${trace(pallets.response)}`, OPTIONS)
|
||||
const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`)
|
||||
const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`)
|
||||
context.assert(MODULE, '公共托盘详情对应列表记录', detail.response,
|
||||
id(detail.data?.id) === id(pallet.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0',
|
||||
`listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
@@ -209,7 +209,7 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
let locationId = ''
|
||||
try {
|
||||
const created = await overseasMutation(context, 'POST', '创建本轮公共库位',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
locationCode, locationType: 'STORAGE', maxLoadKg: 1000,
|
||||
status: 'ACTIVE', remark: context.marker,
|
||||
})
|
||||
@@ -218,7 +218,7 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId),
|
||||
`locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!locationId) return
|
||||
const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`
|
||||
const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`
|
||||
const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath)
|
||||
context.assert(MODULE, '本轮公共库位创建事实正确', detail.response,
|
||||
id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0'
|
||||
@@ -237,9 +237,8 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
||||
} finally {
|
||||
if (locationId) {
|
||||
const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deletedDetail = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deletedDetail = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||
const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail)
|
||||
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null
|
||||
context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted,
|
||||
@@ -287,7 +286,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
let palletId = ''
|
||||
try {
|
||||
const created = await overseasMutation(context, 'POST', '创建本轮公共托盘',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker,
|
||||
})
|
||||
palletId = id(created.data)
|
||||
@@ -296,7 +295,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
`palletId=${palletId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS)
|
||||
if (!palletId) return
|
||||
|
||||
const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`
|
||||
const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`
|
||||
const detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath)
|
||||
context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response,
|
||||
id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId
|
||||
@@ -314,7 +313,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5
|
||||
&& Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`,
|
||||
`capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', detailPath, {
|
||||
const stale = await context.request('PUT', detailPath, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE',
|
||||
remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version),
|
||||
})
|
||||
@@ -323,17 +322,17 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
||||
} finally {
|
||||
if (palletId) {
|
||||
const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED',
|
||||
remark: `${context.marker}-acceptance-retired`, version: Number((await overseasGet(context,
|
||||
'读取公共托盘报废前最新版本',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
||||
})
|
||||
context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null
|
||||
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired),
|
||||
`palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS)
|
||||
const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||
`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||
context.assert(MODULE, '本轮公共托盘终态为已报废', retiredDetail.response,
|
||||
id(retiredDetail.data?.id) === palletId && retiredDetail.data?.status === 'SCRAPPED',
|
||||
`palletId=${id(retiredDetail.data?.id) || 'missing'}, status=${retiredDetail.data?.status || 'missing'}; ${trace(retiredDetail.response)}`, OPTIONS)
|
||||
|
||||
@@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
|
||||
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
|
||||
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
|
||||
assert.match(source, /creationMode === 'AUTO'/)
|
||||
assert.match(source, /requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /requestBinary\('GET'/)
|
||||
assert.match(source, /palletMergeBatchId/)
|
||||
assert.match(source, /feeItemCode/)
|
||||
assert.match(source, /feeItemName/)
|
||||
|
||||
@@ -26,7 +26,7 @@ function query(path, values) {
|
||||
return params.size ? `${path}?${params}` : path
|
||||
}
|
||||
async function api(context, method, name, path, body, headers = {}) {
|
||||
const response = await context.requestOverseas(method, path, body, headers, context.overseasToken)
|
||||
const response = await context.request(method, path, body, context.token, headers)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -123,12 +123,9 @@ async function verifyReceivingWarehouseChange(context, producer, details) {
|
||||
context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response,
|
||||
Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion)
|
||||
&& id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS)
|
||||
const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` },
|
||||
{ 'Idempotency-Key': changeKey }, context.overseasToken)
|
||||
const hashConflict = await context.request('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, context.token, { 'Idempotency-Key': changeKey })
|
||||
context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
|
||||
const stale = await context.requestOverseas('PUT', changePath,
|
||||
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken)
|
||||
const stale = await context.request('PUT', changePath, { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) })
|
||||
context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS)
|
||||
const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath,
|
||||
{ overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` },
|
||||
@@ -210,7 +207,7 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
|
||||
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
|
||||
|
||||
const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, {
|
||||
const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, {
|
||||
customerId: id(representative.customerId), containerNo: producer.containerNo,
|
||||
}))
|
||||
const csv = new TextDecoder().decode(exported.bytes || new Uint8Array())
|
||||
@@ -309,7 +306,7 @@ async function convertContainerPlans(context, producer, details) {
|
||||
|
||||
for (const { plan, pallet } of pallets) {
|
||||
const line = plan.lines[0]
|
||||
const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
|
||||
const palletPath = query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
|
||||
page: 1, size: 10, keyword: pallet.palletNo,
|
||||
})
|
||||
const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath)
|
||||
@@ -357,13 +354,11 @@ export async function verifyReceivedOrderHold(context) {
|
||||
const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
||||
context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response,
|
||||
JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS)
|
||||
const heldConflict = await context.requestOverseas('PUT', orderPath,
|
||||
{ ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken)
|
||||
const heldConflict = await context.request('PUT', orderPath, { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, context.token, { 'Idempotency-Key': holdKey })
|
||||
context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS)
|
||||
|
||||
const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker)
|
||||
const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody,
|
||||
{ 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken)
|
||||
const blockedPlan = await context.request('POST', `${PLANS}/manual`, blockedPlanBody, context.token, { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) })
|
||||
context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS)
|
||||
|
||||
const releaseKey = `${context.marker}-tail-release`.slice(0, 128)
|
||||
@@ -412,10 +407,8 @@ export async function run(context) {
|
||||
refreshedDetails.push(refreshed.data.order)
|
||||
}
|
||||
const representative = refreshedDetails[0]
|
||||
const rejectedHold = await context.requestOverseas('PUT', query(
|
||||
`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }),
|
||||
{ held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) },
|
||||
{ 'Idempotency-Key': `${context.marker}-pre-receipt-hold` })
|
||||
const rejectedHold = await context.request('PUT', query(
|
||||
`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, undefined, { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` })
|
||||
context.addStep(MODULE, '入库前暂扣必须拒绝', rejectedHold, conflict(rejectedHold), trace(rejectedHold), OPTIONS)
|
||||
const afterHold = await detail(context, representative, '入库前暂扣拒绝后回查订单')
|
||||
const unchanged = success(afterHold.response) && JSON.stringify(afterHold.data?.order) === JSON.stringify(representative)
|
||||
@@ -440,9 +433,7 @@ export async function run(context) {
|
||||
{ 'Idempotency-Key': nodesKey })
|
||||
context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response,
|
||||
JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS)
|
||||
const nodesConflict = await context.requestOverseas('PUT', nodesPath,
|
||||
{ ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) },
|
||||
{ 'Idempotency-Key': nodesKey }, context.overseasToken)
|
||||
const nodesConflict = await context.request('PUT', nodesPath, { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, context.token, { 'Idempotency-Key': nodesKey })
|
||||
context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS)
|
||||
const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath)
|
||||
context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response,
|
||||
|
||||
@@ -57,7 +57,7 @@ test('phase-five module proves the client delivery create, list and nested detai
|
||||
|
||||
test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => {
|
||||
for (const token of [
|
||||
'/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'",
|
||||
'/documents/RECEIPT/files', 'requestMultipart', "category: 'RECEIPT'",
|
||||
'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE',
|
||||
'/customer-visits/${encodeURIComponent(visitId)}',
|
||||
"visitResult: 'SATISFIED'", '/exception-orders',
|
||||
|
||||
@@ -33,17 +33,17 @@ function blocker(context, name, reason, response = null) {
|
||||
context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS)
|
||||
}
|
||||
async function platform(context, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
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.requestOverseas(method, path, body, {}, token)
|
||||
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.requestOverseas(method, path, body, {}, token)
|
||||
const response = await context.request(method, path, body, token, {})
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
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',
|
||||
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}`),
|
||||
@@ -158,16 +158,16 @@ async function loginMerchant(context, customerId) {
|
||||
password: credentials.password,
|
||||
}, '')
|
||||
const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS)
|
||||
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken),
|
||||
`tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||
if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.overseasAccessToken)) return null
|
||||
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.overseasAccessToken : null
|
||||
return id(profile?.id) === customerId ? auth.accessToken : null
|
||||
}
|
||||
|
||||
function fixture(context) {
|
||||
@@ -270,7 +270,7 @@ 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`)
|
||||
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)
|
||||
@@ -310,8 +310,7 @@ export async function run(context) {
|
||||
&& 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',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(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,
|
||||
@@ -323,8 +322,7 @@ export async function run(context) {
|
||||
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.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }),
|
||||
{ ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken)
|
||||
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
|
||||
@@ -409,9 +407,7 @@ export async function run(context) {
|
||||
&& 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: 0, remark: `${context.marker}-removed-complete-probe` })
|
||||
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))),
|
||||
|
||||
@@ -138,7 +138,7 @@ test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async (
|
||||
assert.match(source, /'X-Customer-Id': customerId/)
|
||||
assert.match(source, /status !== 'PENDING_REVIEW'/)
|
||||
assert.match(source, /palletCode: order\.palletCode/)
|
||||
assert.match(source, /requestOverseasMultipart\('POST',[^\n]+form, operator\.token\)/)
|
||||
assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/)
|
||||
assert.match(source, /两个合法SKU同批签收仍整笔拒绝/)
|
||||
assert.match(source, /palletCode: nextPallet\.palletCode/)
|
||||
assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/)
|
||||
|
||||
@@ -61,10 +61,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS)
|
||||
}
|
||||
async function request(token, method, path, body) {
|
||||
let response = await context.requestOverseas(method, path, body, {}, token)
|
||||
let response = await context.request(method, path, body, token, {})
|
||||
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
||||
response = await context.requestOverseas(method, path, body, {}, token)
|
||||
response = await context.request(method, path, body, token, {})
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -87,8 +87,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
/^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo))
|
||||
const customerId = customerByToken.get(token)
|
||||
if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符')
|
||||
const reviewed = await context.requestOverseas('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`,
|
||||
{ decision: 'APPROVE', remark: context.marker }, { 'X-Customer-Id': customerId })
|
||||
const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId })
|
||||
check('边界退货单审核通过进入待签收', reviewed, ok(reviewed) && reviewed.body.data?.order?.status === 'PENDING_RECEIPT')
|
||||
if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败')
|
||||
const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker
|
||||
@@ -97,7 +96,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
if (!pallet.palletCode) throw new Error('退货托盘码缺失')
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token)
|
||||
const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token)
|
||||
check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId))
|
||||
if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败')
|
||||
return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode }
|
||||
@@ -160,9 +159,9 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
const fixtureB = secondary.context.merchantQualificationFixture
|
||||
resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture
|
||||
const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '')
|
||||
check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken))
|
||||
if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return
|
||||
const tokenB = loginB.body.data.overseasAccessToken
|
||||
check('第二隔离客户登录取得访问令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.accessToken))
|
||||
if (!ok(loginB) || !loginB.body?.data?.accessToken) return
|
||||
const tokenB = loginB.body.data.accessToken
|
||||
customerByToken.set(tokenB, String(fixtureB.tenantId))
|
||||
const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
||||
const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id))
|
||||
@@ -364,7 +363,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
|
||||
await scenario('身份入口权限', async () => {
|
||||
const order = await newOrder('权限边界', `${prefix}-AUTH`, 1)
|
||||
for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.overseasToken, [403]]]) {
|
||||
for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.token, [403]]]) {
|
||||
await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses)
|
||||
}
|
||||
await receive(order)
|
||||
@@ -383,7 +382,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
await scenario('签收流水与余额核对', async () => {
|
||||
const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3)
|
||||
const customerId = context.context.merchantQualificationFixture.tenantId
|
||||
const ledger = () => good(context.overseasToken, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, {
|
||||
const ledger = () => good(context.token, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, {
|
||||
customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 }))
|
||||
check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), []))
|
||||
const body = receiptBody(order, 2)
|
||||
|
||||
@@ -7,7 +7,7 @@ function fixture({ failedReview = false, wrongOwner = false, stale = false } = {
|
||||
const calls = []
|
||||
const context = { marker: 'FIXTURE', context: { merchantQualificationFixture: { tenantId: '123' } },
|
||||
addStep() {}, assert() {},
|
||||
async requestOverseas(method, path, body, headers, token) {
|
||||
async request(method,path,body,token,headers) {
|
||||
calls.push({ method, path, body, headers, token })
|
||||
if (method === 'POST') {
|
||||
assert.equal(headers['X-Customer-Id'], '123')
|
||||
|
||||
@@ -147,13 +147,12 @@ export async function run(context) {
|
||||
const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken)
|
||||
context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`)
|
||||
if (context.options.modules.includes('phase4-cross-region-events')) {
|
||||
const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || ''
|
||||
context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse,
|
||||
const restrictedOverseasToken = resetLoginResponse.body?.data?.accessToken || ''
|
||||
context.assert(module, '受限角色登录同时签发统一Token', resetLoginResponse,
|
||||
Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`,
|
||||
{ scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true })
|
||||
if (restrictedOverseasToken) {
|
||||
const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1',
|
||||
undefined, {}, restrictedOverseasToken)
|
||||
const denied = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, restrictedOverseasToken, {})
|
||||
context.assert(module, '缺少canonical海外订单权限必须403', denied,
|
||||
(denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code)))
|
||||
&& Boolean(denied.requestId) && Boolean(denied.traceId),
|
||||
|
||||
+49
-104
@@ -1,110 +1,55 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
EVENT_BRIDGE_RECOVERY_FIXTURE,
|
||||
INTERNAL_TOKEN_COHORT_SERVICES,
|
||||
recoveredEventBridgeFacts,
|
||||
TEST_EVENT_BRIDGE_URLS,
|
||||
validInternalTokenCohort,
|
||||
validTestEventBridgeEnvironment,
|
||||
} from './event-bridge-pending-recovery.mjs'
|
||||
|
||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||
const cnCompose = read('../../deploy/docker-compose.yml')
|
||||
const overseasCompose = read('../../deploy/docker-compose.overseas.yml')
|
||||
const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh')
|
||||
const deployServices = read('../../deploy/scripts/deploy-services.sh')
|
||||
const crossRegionHttp = read('phase4-cross-region-events.mjs')
|
||||
|
||||
test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => {
|
||||
assert.deepEqual(TEST_EVENT_BRIDGE_URLS, {
|
||||
LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312',
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312',
|
||||
})
|
||||
assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true)
|
||||
assert.equal(validTestEventBridgeEnvironment({
|
||||
...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313',
|
||||
}), false)
|
||||
import { TEST_DIRECT_DELIVERY_URLS, INTERNAL_TOKEN_COHORT_SERVICES, validDirectDeliveryEnvironment,
|
||||
validInternalTokenCohort, validDirectDeliveryEvidence } from './event-bridge-pending-recovery.mjs'
|
||||
const read=p=>readFileSync(new URL(p,import.meta.url),'utf8')
|
||||
const compose=read('../../deploy/docker-compose.yml')
|
||||
test('all three direct targets use Compose DNS and current internal ports',()=>{
|
||||
assert.equal(validDirectDeliveryEnvironment(TEST_DIRECT_DELIVERY_URLS),true)
|
||||
for(const [key,url] of Object.entries(TEST_DIRECT_DELIVERY_URLS)){
|
||||
assert.ok(compose.includes(`${key}: ${url}`))
|
||||
assert.equal(validDirectDeliveryEnvironment({...TEST_DIRECT_DELIVERY_URLS,[key]:'http://localhost:9312'}),false)
|
||||
}
|
||||
assert.doesNotMatch(compose,/EVENT_BRIDGE_URL|event-bridge-service:/)
|
||||
})
|
||||
test('the three producers and consumers share internal token revision without exposing secrets',()=>{
|
||||
assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES,['firstleg','lastleg-service','finance'])
|
||||
assert.match(compose,/INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r1'}),true)
|
||||
assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r2'}),false)
|
||||
assert.equal(validInternalTokenCohort({}),false)
|
||||
})
|
||||
const attempt=(extra={})=>({eventId:'event-fixture',payloadHash:'hash-fixture',at:'2026-09-29T00:00:00Z',requestId:'request-fixture',traceId:'trace-fixture',...extra})
|
||||
const facts=(attempts,extra={})=>({marker:'run-fixture',eventId:'event-fixture',payloadHash:'hash-fixture',maxAttempts:12,sqlMutationCount:0,status:'SENT',inboxEventCount:1,businessEffectCount:1,attempts,...extra})
|
||||
test('DNS, invalid token and timeout recovery require original event identity and actual duplicate ACK',()=>{
|
||||
for(const failure of ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT']){
|
||||
const evidence=facts([attempt({failure}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})],{authorizedDeadRetry:failure==='TOKEN_REJECTED'})
|
||||
assert.equal(validDirectDeliveryEvidence(evidence),true)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,businessEffectCount:2}),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,sqlMutationCount:1}),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({eventId:'new-id',outcome:'DUPLICATE'})]}),false)
|
||||
}
|
||||
assert.equal(validDirectDeliveryEvidence(facts([attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'APPLIED',ackLost:true})])),false)
|
||||
})
|
||||
test('retry budget exhaustion stays DEAD and never schedules an automatic retry',()=>{
|
||||
const evidence=facts(Array.from({length:12},()=>attempt({failure:'TIMEOUT'})),{status:'DEAD',automaticRetryScheduled:false})
|
||||
assert.equal(validDirectDeliveryEvidence(evidence),true)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,automaticRetryScheduled:true}),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({failure:'TIMEOUT'})]}),false)
|
||||
})
|
||||
test('GET stability does not claim real event replay coverage',()=>{
|
||||
const source=read('./phase4-cross-region-events.mjs')
|
||||
assert.match(source,/非事件重放证据/)
|
||||
assert.match(source,/missing-direct-delivery-replay-evidence/)
|
||||
assert.match(source,/event-outbox-operations\.mjs/)
|
||||
assert.doesNotMatch(source,/经Bridge|不能访问.*Audience/)
|
||||
})
|
||||
|
||||
test('Compose wires both producers to the stable service aliases used by test env', () => {
|
||||
assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/)
|
||||
assert.match(overseasCompose,
|
||||
/FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
|
||||
assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/)
|
||||
assert.match(cnCompose,
|
||||
/FINANCE_FEE_EVENT_CONSUMER_URL:\s*\$\{FINANCE_FEE_EVENT_CONSUMER_URL:-http:\/\/finance:9203\/internal\/finance\/v1\/business-fee-source-events\}/)
|
||||
assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/)
|
||||
for (const source of [cnCompose, overseasCompose]) {
|
||||
assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/)
|
||||
assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/)
|
||||
}
|
||||
})
|
||||
|
||||
test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => {
|
||||
assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [
|
||||
'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service',
|
||||
])
|
||||
assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.match(overseasCompose,
|
||||
/x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/)
|
||||
assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/)
|
||||
assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/)
|
||||
assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/)
|
||||
assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/)
|
||||
for (const source of [deployBuilt, deployServices]) {
|
||||
assert.match(source, /--env-file\s+"\$ENV_FILE"/)
|
||||
}
|
||||
assert.equal(validInternalTokenCohort({
|
||||
firstleg: 'revision-20260827',
|
||||
'event-bridge-cn': 'revision-20260827',
|
||||
'event-bridge-overseas': 'revision-20260827',
|
||||
'lastleg-service': 'revision-20260827',
|
||||
}), true)
|
||||
assert.equal(validInternalTokenCohort({
|
||||
firstleg: 'revision-20260827',
|
||||
'event-bridge-cn': 'revision-20260827',
|
||||
'event-bridge-overseas': 'stale-revision',
|
||||
'lastleg-service': 'revision-20260827',
|
||||
}), false)
|
||||
})
|
||||
|
||||
test('incident fixture records DNS recovery followed by retry81 token rejection', () => {
|
||||
assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, {
|
||||
customerCode: 'C10000000',
|
||||
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
|
||||
initialOutboxStatus: 'PENDING',
|
||||
initialRetryCount: 62,
|
||||
failedTarget: '172.31.198.8:9313',
|
||||
currentOutboxStatus: 'PENDING',
|
||||
currentRetryCount: 81,
|
||||
currentFailureCode: 'A0401',
|
||||
currentFailureMessage: '内部服务令牌无效',
|
||||
currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704',
|
||||
currentTraceId: 'e8e6d620c1cff40caa765e6968f89024',
|
||||
dockerDnsProven: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => {
|
||||
assert.equal(recoveredEventBridgeFacts({
|
||||
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
|
||||
lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0,
|
||||
manualReplayCount: 0, sqlMutationCount: 0,
|
||||
}), true)
|
||||
assert.equal(recoveredEventBridgeFacts({
|
||||
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
|
||||
lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0,
|
||||
manualReplayCount: 0, sqlMutationCount: 0,
|
||||
}), false)
|
||||
assert.equal(recoveredEventBridgeFacts({
|
||||
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
|
||||
lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0,
|
||||
manualReplayCount: 1, sqlMutationCount: 0,
|
||||
}), false)
|
||||
assert.match(crossRegionHttp, /const delivered = await poll/)
|
||||
assert.match(crossRegionHttp, /deliveredRows\.length > 0/)
|
||||
assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/)
|
||||
test('permanent token failure stops immediately and recovery needs authorized DEAD retry', () => {
|
||||
const stopped=facts([attempt({failure:'TOKEN_REJECTED'})],{status:'DEAD',automaticRetryScheduled:false})
|
||||
assert.equal(validDirectDeliveryEvidence(stopped),true)
|
||||
const recovered=facts([attempt({failure:'TOKEN_REJECTED'}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})])
|
||||
assert.equal(validDirectDeliveryEvidence(recovered),false)
|
||||
assert.equal(validDirectDeliveryEvidence({...recovered,authorizedDeadRetry:true}),true)
|
||||
})
|
||||
|
||||
@@ -92,7 +92,16 @@ test('same event replay adds nothing and preserves the allocated number', () =>
|
||||
const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 }
|
||||
assert.equal(validEventReplay(before, { ...before }), true)
|
||||
assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false)
|
||||
assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/)
|
||||
const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox='))
|
||||
assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
|
||||
assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/)
|
||||
assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/)
|
||||
assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/)
|
||||
const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope'))
|
||||
assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/)
|
||||
assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/)
|
||||
assert.match(racedBranch, /DeliveryAck\.Outcome\.DUPLICATE/)
|
||||
|
||||
assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/)
|
||||
assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function verifyWarehouseTransferReview(context, token, customerId,
|
||||
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)
|
||||
return context.request(method, path, body, merchantToken, {})
|
||||
}
|
||||
const initial = await call('GET', scoped(transferNo))
|
||||
context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial,
|
||||
|
||||
Reference in New Issue
Block a user