1
This commit is contained in:
@@ -55,7 +55,7 @@ export async function run(context) {
|
||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
|
||||
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` |
|
||||
| 四期海外 PDA 柜号收货 HTTP 生命周期 | `phase4-overseas-pda.mjs`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` |
|
||||
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
@@ -64,7 +64,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 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。
|
||||
四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
|
||||
|
||||
@@ -491,6 +491,9 @@ export async function run(context) {
|
||||
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
|
||||
processStatus: finalMeasurement?.processStatus || null,
|
||||
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
|
||||
destinationType: context.createdResources?.orders?.destinationType || null,
|
||||
destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null,
|
||||
destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null,
|
||||
}
|
||||
finishScenario(context, start)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite
|
||||
function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' }
|
||||
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
|
||||
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
|
||||
export function requestedOrderDestinationType(context) {
|
||||
return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS'
|
||||
? 'OVERSEAS' : 'FBA'
|
||||
}
|
||||
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
|
||||
const summary = orderDetail?.summary
|
||||
const task = success(taskResponse) ? taskResponse.body?.data : null
|
||||
@@ -33,12 +37,12 @@ function tomorrow(days = 30) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) {
|
||||
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType = 'FBA') {
|
||||
return {
|
||||
originWarehouseId: receiving.id,
|
||||
channelCode: channel.channelCode,
|
||||
destinationCode: destination.warehouseCode,
|
||||
destinationType: 'FBA',
|
||||
destinationType,
|
||||
destinationWarehouseId: destination.id,
|
||||
destinationName: destination.warehouseName,
|
||||
pricingTaxMode,
|
||||
@@ -56,7 +60,7 @@ function estimateMatches(response, candidate) {
|
||||
}
|
||||
|
||||
export async function findPricedOrderRoute({ receiving, destinations, channels, estimate,
|
||||
concurrency = PRICING_PROBE_CONCURRENCY }) {
|
||||
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
|
||||
const candidates = []
|
||||
for (const channel of channels) {
|
||||
if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue
|
||||
@@ -65,7 +69,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels,
|
||||
for (const destination of destinations) {
|
||||
if (!destination?.id || !destination?.warehouseCode) continue
|
||||
candidates.push({ channel, pricingTaxMode, destination,
|
||||
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) })
|
||||
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +102,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels,
|
||||
}
|
||||
|
||||
export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate,
|
||||
concurrency = PRICING_PROBE_CONCURRENCY }) {
|
||||
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
|
||||
const originFailures = []
|
||||
let attempts = 0
|
||||
let response = null
|
||||
@@ -115,7 +119,8 @@ export async function findPricedOrderRouteAcrossOrigins({ receivings, destinatio
|
||||
traceId: channelResponse?.traceId ?? 'missing', count: channels.length })
|
||||
continue
|
||||
}
|
||||
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, estimate, concurrency })
|
||||
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels,
|
||||
estimate, concurrency, destinationType })
|
||||
attempts += result.attempts
|
||||
response = result.response || response
|
||||
if (result.priced) return { receiving, ...result, originFailures }
|
||||
@@ -196,19 +201,21 @@ async function assertProductImageDownload(context, merchantToken, fileId) {
|
||||
}
|
||||
|
||||
async function loadMasterData(context, merchantToken) {
|
||||
const destinationType = requestedOrderDestinationType(context)
|
||||
const domesticResponse = await request(context, 'GET',
|
||||
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken)
|
||||
const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse)
|
||||
context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0,
|
||||
`count=${records(domesticPage).length}`)
|
||||
|
||||
const fbaResponse = await request(context, 'GET',
|
||||
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', undefined, merchantToken)
|
||||
const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse)
|
||||
context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0,
|
||||
`count=${records(fbaPage).length}`)
|
||||
const destinationResponse = await request(context, 'GET',
|
||||
`/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
|
||||
undefined, merchantToken)
|
||||
const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse)
|
||||
context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse,
|
||||
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
|
||||
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
const destinations = records(fbaPage)
|
||||
const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
|
||||
if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓')
|
||||
|
||||
const pricingResult = await findPricedOrderRouteAcrossOrigins({
|
||||
@@ -217,6 +224,7 @@ async function loadMasterData(context, merchantToken) {
|
||||
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
|
||||
undefined, merchantToken),
|
||||
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
|
||||
destinationType,
|
||||
})
|
||||
const priced = pricingResult.priced
|
||||
const receiving = pricingResult.receiving
|
||||
@@ -225,7 +233,7 @@ async function loadMasterData(context, merchantToken) {
|
||||
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}`
|
||||
: originPricingFailureEvidence(pricingResult),
|
||||
CONTRACT_OPTIONS)
|
||||
if (!priced) throw new Error('当前 ACTIVE 国内收货仓、渠道、报价税制和FBA目的仓没有可计价组合')
|
||||
if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`)
|
||||
|
||||
const partnerResponse = await request(context, 'GET',
|
||||
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
|
||||
@@ -234,7 +242,7 @@ async function loadMasterData(context, merchantToken) {
|
||||
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
|
||||
: null
|
||||
return { receiving, destination: priced.destination, channel: priced.channel,
|
||||
pricingTaxMode: priced.pricingTaxMode, partner }
|
||||
pricingTaxMode: priced.pricingTaxMode, destinationType, partner }
|
||||
}
|
||||
|
||||
function buildPayload(context, profile, master, productImageFileId) {
|
||||
@@ -254,9 +262,10 @@ function buildPayload(context, profile, master, productImageFileId) {
|
||||
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
|
||||
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
||||
pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL',
|
||||
customsInspection: false, destinationType: 'FBA',
|
||||
amazonWarehouseCode: master.destination.warehouseCode,
|
||||
shipmentId, referenceId,
|
||||
customsInspection: false, destinationType: master.destinationType,
|
||||
amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null,
|
||||
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
|
||||
referenceId: master.destinationType === 'FBA' ? referenceId : null,
|
||||
sender: {
|
||||
companyName, contactName: '订单验收联系人', phone: '13800138000',
|
||||
email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省',
|
||||
@@ -279,7 +288,9 @@ function buildPayload(context, profile, master, productImageFileId) {
|
||||
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
|
||||
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
|
||||
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
|
||||
shipmentId, referenceId, upcNo: `${Date.now()}${index}`.slice(-12),
|
||||
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
|
||||
referenceId: master.destinationType === 'FBA' ? referenceId : null,
|
||||
upcNo: `${Date.now()}${index}`.slice(-12),
|
||||
productImageFileId, dangerousGoods: false,
|
||||
cargoRemark: `${context.marker} boxType=${box.boxType}`,
|
||||
}
|
||||
@@ -387,6 +398,9 @@ export async function run(context) {
|
||||
marker: context.marker, tenantId, orderId, productImageFileId,
|
||||
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
|
||||
receivingWarehouseId: id(master.receiving.id),
|
||||
destinationType: master.destinationType,
|
||||
destinationWarehouseId: id(master.destination.id),
|
||||
destinationWarehouseCode: master.destination.warehouseCode,
|
||||
}
|
||||
|
||||
const replayResponse = await request(context, 'POST', createPath, payload, merchantToken,
|
||||
|
||||
@@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3OutboundDispatch
|
||||
if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的国内出仓门禁前置', null, false,
|
||||
`inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '订舱/拖车/报关/PDA/五类证据统一硬门禁',
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
|
||||
@@ -14,6 +14,9 @@ export async function run(context) {
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
if (!tenantId || !inboundId || !taskId || !loadingPlanId || !containerBookingId || !warehouseId
|
||||
|| fixture.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的五证据门禁前置', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '五类证据多图追加、作废审计和有效证据门禁',
|
||||
'本轮未取得带 taskId/inboundId/loadingPlanId/containerBookingId 的 phase3-loading marker 隔离证据 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
@@ -44,6 +47,8 @@ export async function run(context) {
|
||||
evidenceReadable,
|
||||
`processStatus=${inbound?.summary?.processStatus || 'missing'}, taskWorkflow=${task?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
if (!evidenceReadable) {
|
||||
context.addStep(MODULE, '装柜证据读取前置必须由服务端进入 LOADING', inboundResponse, false,
|
||||
`processStatus=${inbound?.summary?.processStatus || 'missing'};不能以 AWAITING_REVIEW 或 coverageGap 代替装柜阶段事实`, OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '服务端装柜阶段前不请求证据',
|
||||
`taskId=${taskId}, inboundId=${inboundId}, loadingPlanId=${loadingPlanId}, containerBookingId=${containerBookingId}, processStatus=${inbound?.summary?.processStatus || 'missing'};AWAITING_REVIEW 不作为证据开放条件`, 'deployment-blocker')
|
||||
return
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
|
||||
test('phase3 downstream modules turn missing current-marker prerequisites into deployment blockers', () => {
|
||||
const expectations = new Map([
|
||||
['phase3-loading-model-bridge.mjs', '取得同 marker 的预装与柜次桥接前置'],
|
||||
['phase3-pda-convergence.mjs', '取得同 marker 的 PDA 收敛前置'],
|
||||
['phase3-evidence-gate.mjs', '取得同 marker 的五证据门禁前置'],
|
||||
['phase3-outbound-dispatch.mjs', '取得同 marker 的 LOADED 国内出仓前置'],
|
||||
['phase3-outbound-aggregate.mjs', '取得同 marker 的出仓主聚合前置'],
|
||||
['phase3-dispatch-gate.mjs', '取得同 marker 的国内出仓门禁前置'],
|
||||
['phase3-waiting-for-loading.mjs', '取得同 marker 的 WAITING_FOR_LOADING 前置'],
|
||||
])
|
||||
for (const [file, step] of expectations) {
|
||||
const source = read(file)
|
||||
assert.match(source, /deploymentBlocker: true/, file)
|
||||
assert.match(source, new RegExp(`addStep\\(MODULE, '${step}'[\\s\\S]{0,500}false`), file)
|
||||
assert.match(source, /fixtureMarker=.*runMarker=/, file)
|
||||
}
|
||||
})
|
||||
|
||||
test('phase3 inventory mutation and downstream gates cannot pass as coverage gaps', () => {
|
||||
const loading = read('phase3-loading.mjs')
|
||||
const convergence = read('phase3-pda-convergence.mjs')
|
||||
const dispatch = read('phase3-outbound-dispatch.mjs')
|
||||
const aggregate = read('phase3-outbound-aggregate.mjs')
|
||||
assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/)
|
||||
assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/)
|
||||
assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/)
|
||||
assert.match(aggregate, /统一时间线必须包含报关与三期船期历史[\s\S]{0,300}false/)
|
||||
})
|
||||
|
||||
test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => {
|
||||
const source = read('phase3-outbound-dispatch.mjs')
|
||||
for (const step of [
|
||||
'confirm-dispatch 幂等重放',
|
||||
'相同 Idempotency-Key 不同请求拒绝',
|
||||
'reverse-dispatch 幂等重放',
|
||||
'reverse-dispatch 同键异参返回 A0409',
|
||||
'撤销后旧版本再次确认返回 A0409',
|
||||
'负向与幂等验证后回查出仓聚合',
|
||||
'出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史',
|
||||
]) assert.match(source, new RegExp(step))
|
||||
})
|
||||
@@ -79,7 +79,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi
|
||||
})
|
||||
|
||||
test('downstream finalizer uses the frozen truck-coupled complete contract', () => {
|
||||
assert.match(convergence, /summary\?\.status === 'LOADING'/)
|
||||
assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/)
|
||||
assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/)
|
||||
assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/)
|
||||
assert.match(customs, /ensureReleasedOceanBooking/)
|
||||
|
||||
@@ -27,6 +27,35 @@ export function buildFinalizeResource(fixture, finalData, completeResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
export function collectPhase4InboundProducerFixture(previous, producer) {
|
||||
const existing = previous?.marker === producer?.marker && Array.isArray(previous?.containers)
|
||||
? previous.containers : []
|
||||
const containers = [...existing.filter((item) => item.eventKey !== producer.eventKey), producer]
|
||||
const byDestinationType = Object.fromEntries(['FBA', 'OVERSEAS'].map((destinationType) => [
|
||||
destinationType,
|
||||
containers.filter((item) => item.destinationType === destinationType),
|
||||
]))
|
||||
const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size
|
||||
return {
|
||||
marker: producer.marker,
|
||||
containers,
|
||||
byDestinationType,
|
||||
readiness: {
|
||||
fbaContainerCount: byDestinationType.FBA.length,
|
||||
overseasContainerCount: byDestinationType.OVERSEAS.length,
|
||||
distinctContainerCount,
|
||||
readyForPhase4Pda: byDestinationType.FBA.length >= 1
|
||||
&& byDestinationType.OVERSEAS.length >= 1
|
||||
&& distinctContainerCount >= 3,
|
||||
},
|
||||
requiredEntryOrchestration: [
|
||||
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
|
||||
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
|
||||
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
@@ -323,4 +352,27 @@ export async function run(context) {
|
||||
settlementMode,
|
||||
settledOrderId: id(loadedSlice?.childOrderId) || orderId,
|
||||
}
|
||||
const producer = {
|
||||
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
|
||||
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
|
||||
eventVersion: 2,
|
||||
eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`,
|
||||
resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`,
|
||||
tenantId,
|
||||
aggregateType: 'LOADING_PLAN',
|
||||
aggregateId: loadingPlanId,
|
||||
loadingPlanId,
|
||||
containerBookingId,
|
||||
sourceOrderId: orderId,
|
||||
sourceInboundId: inboundId,
|
||||
destinationType: fixture.destinationType || null,
|
||||
destinationWarehouseId: id(fixture.destinationWarehouseId),
|
||||
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
|
||||
containerNo: fixture.containerNo || null,
|
||||
sourceStatus: 'LOADED',
|
||||
verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP',
|
||||
}
|
||||
context.createdResources.phase3LoadingCompletedProducer = producer
|
||||
context.createdResources.phase4InboundProducerFixtures = collectPhase4InboundProducerFixture(
|
||||
context.createdResources.phase4InboundProducerFixtures, producer)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export async function run(context) {
|
||||
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
|
||||
const loadingPlanId = id(fixture?.loadingPlanId)
|
||||
if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的预装与柜次桥接前置', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联',
|
||||
'本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture',
|
||||
{ activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } })
|
||||
|
||||
@@ -101,9 +101,10 @@ export async function run(context) {
|
||||
const source = fixture(context)
|
||||
const tenantId = id(source?.tenantId)
|
||||
const inboundId = id(source?.inboundId)
|
||||
if (!tenantId || !inboundId) {
|
||||
if (!tenantId || !inboundId || source?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false,
|
||||
'未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单', OPTIONS)
|
||||
`未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单;fixtureMarker=${source?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -697,6 +698,8 @@ export async function run(context) {
|
||||
effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken }
|
||||
}
|
||||
} else {
|
||||
context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false,
|
||||
`本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
|
||||
`inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`,
|
||||
'fixture')
|
||||
@@ -1159,6 +1162,9 @@ export async function run(context) {
|
||||
|
||||
context.createdResources.phase3Loading = {
|
||||
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
|
||||
destinationType: initial?.summary?.destinationType || source?.destinationType || null,
|
||||
destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId),
|
||||
destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null,
|
||||
evidenceIds: evidenceItems.map((item) => id(item.id)),
|
||||
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
|
||||
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
|
||||
|
||||
@@ -16,6 +16,9 @@ function ordered(items) {
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3OutboundDispatch
|
||||
if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的出仓主聚合前置', null, false,
|
||||
`inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '出仓单/柜次主聚合与整体时间线',
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
@@ -49,4 +52,6 @@ export async function run(context) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线',
|
||||
'当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影',
|
||||
'contract-gap')
|
||||
context.addStep(MODULE, '统一时间线必须包含报关与三期船期历史', response, false,
|
||||
'当前服务端聚合未公开 CUSTOMS 与 SAILING_SCHEDULE 时间线,不能以 coverageGap 计为自动验收通过', OPTIONS)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ export async function run(context) {
|
||||
const containerBookingId = id(fixture?.containerBookingId)
|
||||
if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus)
|
||||
|| fixture?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的 LOADED 国内出仓前置', null, false,
|
||||
`inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, finalStatus=${fixture?.finalStatus || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
|
||||
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
@@ -85,6 +88,9 @@ export async function run(context) {
|
||||
{ 'Idempotency-Key': confirmKey })
|
||||
context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked),
|
||||
`HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS)
|
||||
context.addStep(MODULE, '国内出仓八项门禁必须在同轮全部通过', gateResponse, false,
|
||||
`failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`,
|
||||
OPTIONS)
|
||||
for (const item of gateItems.filter((value) => !value.passed)) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, item.name || item.code,
|
||||
`${item.code}: actual=${item.actual || 'missing'}, requirement=${item.requirement || 'missing'}`,
|
||||
@@ -124,6 +130,37 @@ export async function run(context) {
|
||||
reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'),
|
||||
`dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS)
|
||||
const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
|
||||
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
|
||||
}, { 'Idempotency-Key': reverseKey })
|
||||
const reverseReplay = context.apiSuccess(MODULE, 'reverse-dispatch 幂等重放', reverseReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '撤销重放保持 READY_FOR_DISPATCH 与同一版本', reverseReplayResponse,
|
||||
reverseReplay?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||
&& Number(reverseReplay?.aggregate?.version) === Number(reversed?.aggregate?.version),
|
||||
`dispatch=${reverseReplay?.aggregate?.dispatchStatus || 'missing'}, version=${reverseReplay?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const reverseHashConflict = await request(context, 'POST', `${BASE}/reverse-dispatch`, {
|
||||
inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: 'forged',
|
||||
}, { 'Idempotency-Key': reverseKey })
|
||||
context.addStep(MODULE, 'reverse-dispatch 同键异参返回 A0409', reverseHashConflict,
|
||||
reverseHashConflict.http === 200 && codeOf(reverseHashConflict) === 'A0409',
|
||||
`HTTP=${reverseHashConflict.http}, code=${codeOf(reverseHashConflict)}`, OPTIONS)
|
||||
|
||||
const staleConfirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody,
|
||||
{ 'Idempotency-Key': `${context.marker}-outbound-confirm-stale`.slice(0, 128) })
|
||||
context.addStep(MODULE, '撤销后旧版本再次确认返回 A0409', staleConfirmResponse,
|
||||
staleConfirmResponse.http === 200 && codeOf(staleConfirmResponse) === 'A0409',
|
||||
`HTTP=${staleConfirmResponse.http}, code=${codeOf(staleConfirmResponse)}`, OPTIONS)
|
||||
|
||||
const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`)
|
||||
const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', finalDetailResponse,
|
||||
finalDetail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||
&& finalDetail?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& Number(finalDetail?.aggregate?.version) === Number(reversed?.aggregate?.version)
|
||||
&& finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH')
|
||||
&& finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'),
|
||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
if (reversed?.aggregate) {
|
||||
context.createdResources.phase3OutboundDispatch = {
|
||||
marker: context.marker, inboundId, containerBookingId,
|
||||
|
||||
@@ -27,6 +27,9 @@ export async function run(context) {
|
||||
const warehouseId = id(fixture?.warehouseId); const loadingPlanId = id(fixture?.loadingPlanId)
|
||||
if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || !loadingPlanId
|
||||
|| fixture?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的 PDA 收敛前置', null, false,
|
||||
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛',
|
||||
'本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture')
|
||||
return
|
||||
@@ -76,6 +79,10 @@ export async function run(context) {
|
||||
const operationsResponse = await request(context, 'GET',
|
||||
`${taskPath}/operations`)
|
||||
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
|
||||
const batchOperations = Array.isArray(operations)
|
||||
? operations.filter((item) => item.actionType === 'BATCH'
|
||||
&& Boolean(item.operatorId) && Boolean(item.createdAt)) : []
|
||||
const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1
|
||||
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
|
||||
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
|
||||
&& Boolean(item.operatorId) && Boolean(item.createdAt))
|
||||
@@ -84,9 +91,9 @@ export async function run(context) {
|
||||
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
|
||||
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
|
||||
Array.isArray(operations) && operations.filter((item) => item.actionType === 'BATCH'
|
||||
&& Boolean(item.operatorId) && Boolean(item.createdAt)).length >= 1,
|
||||
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
|
||||
batchOperations.length === expectedBatchCount
|
||||
&& new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount,
|
||||
`expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS)
|
||||
const forged = await request(context, 'POST',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' },
|
||||
@@ -95,21 +102,31 @@ export async function run(context) {
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
|
||||
if (fixture?.atomicBatchVerified !== true) {
|
||||
context.addStep(MODULE, 'PDA 原子批次成功且同键重放零新增', null, false,
|
||||
'phase3-loading 未发布 atomicBatchVerified=true;不能以 coverageGap 代替真实批次与幂等断言', OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 原子批量成功与批次幂等闭环',
|
||||
'原子 batch 端点已部署,但本轮合法整批提交或同键重放未通过真实 HTTP 断言;保留真实失败证据',
|
||||
'deployment-blocker')
|
||||
}
|
||||
if (fixture?.inventorySnapshotVerified !== true) {
|
||||
context.addStep(MODULE, 'PDA 实时库存快照身份与合法 token 提交', null, false,
|
||||
'phase3-loading 未发布 inventorySnapshotVerified=true;不能以 coverageGap 代替真实库存快照断言', OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存快照身份、稳定性与合法 token 提交',
|
||||
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
|
||||
'deployment-blocker')
|
||||
}
|
||||
if (fixture?.inventoryMutationVerified !== true
|
||||
&& !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) {
|
||||
context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false,
|
||||
'本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
|
||||
'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化',
|
||||
'fixture')
|
||||
}
|
||||
if (fixture?.dynamicIncrementVerified !== true) {
|
||||
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
|
||||
'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS)
|
||||
}
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环',
|
||||
'原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收',
|
||||
'device')
|
||||
|
||||
@@ -25,6 +25,9 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat
|
||||
assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/)
|
||||
assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/)
|
||||
assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/)
|
||||
assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/)
|
||||
assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/)
|
||||
assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/)
|
||||
assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { requestedOrderDestinationType } from './orders.mjs'
|
||||
import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
|
||||
test('order fixture defaults to FBA and accepts only the explicit OVERSEAS variant', () => {
|
||||
assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'FBA')
|
||||
const orders = read('orders.mjs')
|
||||
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
|
||||
assert.match(orders, /destinationType: master\.destinationType/)
|
||||
assert.match(orders, /destinationType,\s*\n\s*\}\)/)
|
||||
assert.doesNotMatch(orders, /const fbaResponse =/)
|
||||
})
|
||||
|
||||
test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => {
|
||||
const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' }
|
||||
const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' }
|
||||
const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' }
|
||||
const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' }
|
||||
const one = collectPhase4InboundProducerFixture(null, fba)
|
||||
const two = collectPhase4InboundProducerFixture(one, overseasPrimary)
|
||||
const ready = collectPhase4InboundProducerFixture(two, secondary)
|
||||
assert.equal(one.readiness.readyForPhase4Pda, false)
|
||||
assert.equal(two.readiness.readyForPhase4Pda, false)
|
||||
assert.equal(ready.readiness.readyForPhase4Pda, true)
|
||||
assert.equal(ready.readiness.fbaContainerCount, 2)
|
||||
assert.equal(ready.readiness.overseasContainerCount, 1)
|
||||
assert.equal(ready.readiness.distinctContainerCount, 3)
|
||||
assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2')
|
||||
})
|
||||
|
||||
test('same event key is idempotently replaced and another marker starts a new matrix', () => {
|
||||
const first = collectPhase4InboundProducerFixture(null,
|
||||
{ marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' })
|
||||
const replay = collectPhase4InboundProducerFixture(first,
|
||||
{ marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' })
|
||||
assert.equal(replay.containers.length, 1)
|
||||
const nextRun = collectPhase4InboundProducerFixture(replay,
|
||||
{ marker: 'RUN-2', eventKey: 'EVENT-2', destinationType: 'OVERSEAS', containerNo: 'OS-002' })
|
||||
assert.equal(nextRun.containers.length, 1)
|
||||
assert.equal(nextRun.marker, 'RUN-2')
|
||||
assert.deepEqual(nextRun.requiredEntryOrchestration, [
|
||||
'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
|
||||
'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
|
||||
'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize',
|
||||
])
|
||||
})
|
||||
|
||||
test('measurement and loading pass destination identity into the LOADED producer key', () => {
|
||||
const measurement = read('inbound-measurement.mjs')
|
||||
const loading = read('phase3-loading.mjs')
|
||||
const finalize = read('phase3-loading-finalize.mjs')
|
||||
assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/)
|
||||
assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/)
|
||||
assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/)
|
||||
assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/)
|
||||
assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/)
|
||||
})
|
||||
@@ -627,3 +627,14 @@ test('loaded boundary acceptance proves evidence and transport immutability with
|
||||
assert.match(finalize, /afterHistorySignature === historySignature/)
|
||||
assert.match(finalize, /afterPlanSignature === immutablePlanSignature/)
|
||||
})
|
||||
|
||||
test('finalize publishes a current-marker producer correlation for cross-region verification', () => {
|
||||
const finalize = source('./phase3-loading-finalize.mjs')
|
||||
assert.match(finalize, /const producer = \{/)
|
||||
assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/)
|
||||
assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/)
|
||||
assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/)
|
||||
assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:2`/)
|
||||
assert.match(finalize, /sourceStatus: 'LOADED'/)
|
||||
assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches'
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3OutboundDispatch
|
||||
if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) {
|
||||
context.addStep(MODULE, '取得同 marker 的 WAITING_FOR_LOADING 前置', null, false,
|
||||
`inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
|
||||
OPTIONS)
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'WAITING_FOR_LOADING 状态与历史',
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
|
||||
@@ -4,10 +4,16 @@ import test from 'node:test'
|
||||
import { run } from './phase4-overseas-master-data.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8')
|
||||
const controllerSource = [
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java',
|
||||
'../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java',
|
||||
].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n')
|
||||
const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin'
|
||||
const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data'
|
||||
const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' })
|
||||
const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' })
|
||||
const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' })
|
||||
|
||||
function detailQuote(state) {
|
||||
return {
|
||||
@@ -22,21 +28,25 @@ function vehicleWire(state) {
|
||||
return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) }
|
||||
}
|
||||
|
||||
function fakeContext() {
|
||||
function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
const calls = []
|
||||
const coverageGaps = []
|
||||
const steps = []
|
||||
const marker = 'ADMIN-API-20260821123456789'
|
||||
const state = { warehouse: null, quote: null, exchange: null, vehicle: null }
|
||||
const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null }
|
||||
const context = {
|
||||
marker, calls, coverageGaps, createdResources: {},
|
||||
marker, calls, coverageGaps, steps, createdResources: {},
|
||||
async findAvailablePhone() { return '13800138000' },
|
||||
apiSuccess(_module, name, response) {
|
||||
assert.equal(response?.http, 200, `${name}: HTTP`)
|
||||
assert.equal(String(response?.body?.code), '200', `${name}: code`)
|
||||
return response.body.data
|
||||
},
|
||||
assert(_module, name, _response, condition, evidence) {
|
||||
assert.equal(Boolean(condition), true, `${name}: ${evidence}`)
|
||||
return true
|
||||
assert(_module, name, _response, condition, evidence, options = {}) {
|
||||
const passed = Boolean(condition)
|
||||
steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence })
|
||||
if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`)
|
||||
return passed
|
||||
},
|
||||
expectConflict(_module, name, response) {
|
||||
assert.equal(response.http, 200, `${name}: HTTP`)
|
||||
@@ -64,12 +74,40 @@ function fakeContext() {
|
||||
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) {
|
||||
calls.push({ gateway: 'overseas', method, path, body })
|
||||
async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') {
|
||||
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()
|
||||
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) {
|
||||
state.optionReads += 1
|
||||
if (!state.warehouse) return ok([])
|
||||
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
|
||||
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
|
||||
}
|
||||
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) {
|
||||
state.optionReads += 1
|
||||
if (refreshEmpty) return ok([])
|
||||
return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode,
|
||||
warehouseName: state.warehouse.warehouseName, countryCode: 'US' }])
|
||||
}
|
||||
@@ -81,6 +119,10 @@ function fakeContext() {
|
||||
}
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) {
|
||||
return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20,
|
||||
total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 })
|
||||
}
|
||||
if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) {
|
||||
if (method === 'GET') return ok(detailQuote(state.quote))
|
||||
if (method === 'PUT') {
|
||||
@@ -100,7 +142,10 @@ function fakeContext() {
|
||||
return ok(detailQuote(state.quote))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) {
|
||||
return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`)
|
||||
&& (!path.includes('status=') || path.includes(`status=${state.exchange.status}`))
|
||||
return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20,
|
||||
total: matches ? 1 : 0, pages: matches ? 1 : 0 })
|
||||
}
|
||||
if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) {
|
||||
state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 }
|
||||
@@ -139,13 +184,21 @@ function fakeContext() {
|
||||
state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 }
|
||||
return ok(vehicleWire(state))
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) {
|
||||
return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20,
|
||||
total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 })
|
||||
}
|
||||
assert.fail(`unexpected overseas call ${method} ${path}`)
|
||||
},
|
||||
async requestOverseasBinary(method, path) {
|
||||
calls.push({ gateway: 'overseas-binary', method, path })
|
||||
assert.equal(method, 'GET')
|
||||
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
|
||||
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'),
|
||||
let csv = ''
|
||||
if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n`
|
||||
else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n`
|
||||
else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n`
|
||||
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`),
|
||||
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
|
||||
},
|
||||
}
|
||||
@@ -160,13 +213,20 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
assert.match(source, /context\.requestOverseas\('PATCH'/)
|
||||
assert.match(source, /context\.requestOverseasBinary\('GET'/)
|
||||
assert.match(source, /context\.expectConflict/)
|
||||
assert.match(source, /warehouse-options\?refresh=true/)
|
||||
assert.match(source, /空候选不负缓存30分钟/)
|
||||
assert.match(source, /30分钟正缓存与刷新快照一致/)
|
||||
assert.match(source, /无quote:list权限访问海外报价被拒绝/)
|
||||
assert.match(source, /表头与marker内容/)
|
||||
assert.match(source, /finally\s*\{/)
|
||||
assert.match(source, /context\.createdResources\.phase4OverseasMasterData/)
|
||||
assert.match(source, /context\.addCoverageGap/)
|
||||
assert.doesNotMatch(source, /context\.addCoverageGap/)
|
||||
assert.match(source, /2090 \+ Math\.floor/)
|
||||
assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/)
|
||||
for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export',
|
||||
'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint))
|
||||
assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/)
|
||||
assert.doesNotMatch(source, /Idempotency-Key/)
|
||||
})
|
||||
|
||||
test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => {
|
||||
@@ -186,30 +246,39 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全
|
||||
assert.equal(state.quote.summary.status, 'INACTIVE')
|
||||
assert.equal(state.exchange.status, 'INACTIVE')
|
||||
assert.equal(state.vehicle.status, 'INACTIVE')
|
||||
assert.equal(state.role.status, 'DISABLED')
|
||||
assert.equal(state.user.status, 'DISABLED')
|
||||
assert.equal(state.optionReads, 4)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9)
|
||||
assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1)
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
})
|
||||
|
||||
test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => {
|
||||
const { context, state } = fakeContext()
|
||||
const original = context.requestOverseas
|
||||
context.requestOverseas = async (method, path, body) => {
|
||||
if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([])
|
||||
return original(method, path, body)
|
||||
}
|
||||
test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => {
|
||||
const { context } = fakeContext()
|
||||
await run(context)
|
||||
const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options'))
|
||||
assert.equal(optionCalls.length, 4)
|
||||
assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true)
|
||||
assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true)
|
||||
assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true)
|
||||
assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001')
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
})
|
||||
|
||||
test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => {
|
||||
const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true })
|
||||
await run(context)
|
||||
assert.equal(state.warehouse.status, 'INACTIVE')
|
||||
assert.equal(state.quote, null)
|
||||
assert.equal(state.warehouse.status, 'INACTIVE')
|
||||
assert.equal(state.exchange.status, 'INACTIVE')
|
||||
assert.equal(state.vehicle.status, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE')
|
||||
assert.equal(context.coverageGaps.length, 1)
|
||||
assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期')
|
||||
assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite')
|
||||
assert.match(context.coverageGaps[0].reason, /30分钟空缓存/)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2)
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见'
|
||||
&& !step.passed && step.deploymentBlocker), true)
|
||||
assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行'
|
||||
&& !step.passed && step.deploymentBlocker), true)
|
||||
})
|
||||
|
||||
@@ -14,13 +14,26 @@ function requireData(context, name, response) {
|
||||
return value
|
||||
}
|
||||
|
||||
function assertBinaryCsv(context, name, response) {
|
||||
function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) {
|
||||
const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : ''
|
||||
const header = text.split(/\r?\n/, 1)[0]
|
||||
context.assert(MODULE, name, response,
|
||||
response?.http === 200
|
||||
&& String(response.contentType || '').toLowerCase().startsWith('text/csv')
|
||||
&& response.bytes instanceof Uint8Array && response.bytes.length > 0,
|
||||
`HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`,
|
||||
BLOCKER)
|
||||
context.assert(MODULE, `${name}表头与marker内容`, response,
|
||||
header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))),
|
||||
`header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER)
|
||||
}
|
||||
|
||||
const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : []
|
||||
|
||||
function assertForbidden(context, name, response) {
|
||||
context.assert(MODULE, name, response,
|
||||
response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))),
|
||||
`HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER)
|
||||
}
|
||||
|
||||
async function safeCleanup(context, name, action) {
|
||||
@@ -97,6 +110,16 @@ async function createAndVerifyPartnerWarehouse(context, resource) {
|
||||
BLOCKER)
|
||||
}
|
||||
|
||||
async function captureWarehouseOptionsBeforeCreate(context, resource) {
|
||||
const response = await context.requestOverseas('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)
|
||||
&& item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)),
|
||||
`count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER)
|
||||
resource.partnerWarehouseOptionsBefore = optionIds(options)
|
||||
}
|
||||
|
||||
function quoteCreatePayload(context, warehouseId) {
|
||||
return {
|
||||
name: `${context.marker} 海外报价`, warehouseId,
|
||||
@@ -127,27 +150,25 @@ async function quoteLifecycle(context, resource) {
|
||||
Array.isArray(options) && options.every((item) => asId(item.warehouseId)
|
||||
&& item.warehouseCode && item.warehouseName && item.countryCode),
|
||||
`count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER)
|
||||
if (Array.isArray(options) && options.length === 0) {
|
||||
resource.quote = {
|
||||
skipped: true,
|
||||
reason: 'warehouse-options-empty-cache',
|
||||
finalStatus: null,
|
||||
}
|
||||
context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期',
|
||||
'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。',
|
||||
'deployment-prerequisite', {
|
||||
marker: context.marker,
|
||||
partnerWarehouseId: resource.partnerWarehouse?.id || null,
|
||||
http: optionsResponse?.http ?? null,
|
||||
code: optionsResponse?.body?.code ?? null,
|
||||
requestId: optionsResponse?.requestId ?? null,
|
||||
traceId: optionsResponse?.traceId ?? null,
|
||||
})
|
||||
return false
|
||||
const createdId = resource.partnerWarehouse?.id
|
||||
if (resource.partnerWarehouseOptionsBefore?.length === 0) {
|
||||
context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse,
|
||||
optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER)
|
||||
}
|
||||
const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id)
|
||||
const option = createdOption || options[0]
|
||||
if (!option) throw new Error('海外报价接口未返回可用合作仓候选')
|
||||
|
||||
const refreshResponse = await context.requestOverseas('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 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)
|
||||
@@ -164,6 +185,13 @@ async function quoteLifecycle(context, resource) {
|
||||
&& 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 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 staleVersion = Number(detail.summary.recordVersion)
|
||||
const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion)
|
||||
const updateResponse = await context.requestOverseas('PUT', path, updatePayload)
|
||||
@@ -191,7 +219,9 @@ async function quoteLifecycle(context, resource) {
|
||||
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv)
|
||||
assertBinaryCsv(context, '导出海外报价CSV', csv,
|
||||
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
|
||||
[id, context.marker])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -252,9 +282,17 @@ 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 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',
|
||||
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv)
|
||||
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
|
||||
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
|
||||
}
|
||||
|
||||
function vehiclePayload(context, edited = false) {
|
||||
@@ -316,8 +354,48 @@ 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 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)}`)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv,
|
||||
'车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间',
|
||||
[id, createPayload.plateNumber, 'MAINTENANCE'])
|
||||
}
|
||||
|
||||
async function verifyCanonicalPermissionDenial(context, resource) {
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
||||
const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', {
|
||||
roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker,
|
||||
})
|
||||
const role = requireData(context, '创建无主数据权限角色', roleResponse)
|
||||
const roleId = asId(role?.id)
|
||||
if (!roleId) throw new Error('无主数据权限角色未返回ID')
|
||||
resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null }
|
||||
|
||||
const phone = await context.findAvailablePhone(MODULE)
|
||||
const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase()
|
||||
const password = `P4md${suffix.slice(-8)}A1!`
|
||||
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
|
||||
username, displayName: `四期主数据拒绝-${suffix}`, phone,
|
||||
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE',
|
||||
})
|
||||
const user = requireData(context, '创建无主数据权限用户', userResponse)
|
||||
const membershipId = asId(user?.membershipId)
|
||||
if (!membershipId) throw new Error('无主数据权限用户未返回membershipId')
|
||||
resource.permissionProbe.membershipId = membershipId
|
||||
resource.permissionProbe.userStatus = 'ACTIVE'
|
||||
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', { 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)
|
||||
assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied)
|
||||
}
|
||||
|
||||
async function cleanupPartnerWarehouse(context, resource) {
|
||||
@@ -355,16 +433,33 @@ async function cleanupVersioned(context, resource, key, path, extract) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupPermissionProbe(context, resource) {
|
||||
const probe = resource.permissionProbe
|
||||
if (!probe) return
|
||||
if (probe.membershipId && probe.userStatus !== 'DISABLED') {
|
||||
const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' })
|
||||
context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER)
|
||||
if (success(response)) probe.userStatus = 'DISABLED'
|
||||
}
|
||||
if (probe.roleId && probe.roleStatus !== 'DISABLED') {
|
||||
const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' })
|
||||
context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER)
|
||||
if (success(response)) probe.roleStatus = 'DISABLED'
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const resource = { marker: context.marker }
|
||||
context.createdResources.phase4OverseasMasterData = resource
|
||||
try {
|
||||
await continueLifecycle(context, '海外报价', async () => {
|
||||
await captureWarehouseOptionsBeforeCreate(context, resource)
|
||||
await createAndVerifyPartnerWarehouse(context, resource)
|
||||
await quoteLifecycle(context, resource)
|
||||
})
|
||||
await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource))
|
||||
await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource))
|
||||
await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource))
|
||||
} finally {
|
||||
await safeCleanup(context, '海外报价', async () => {
|
||||
if (!resource.quote?.id) return
|
||||
@@ -382,5 +477,6 @@ export async function run(context) {
|
||||
await cleanupVersioned(context, resource, 'vehicle', path, (value) => value)
|
||||
})
|
||||
await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource))
|
||||
await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,41 +9,79 @@ import { run } from './phase4-overseas-pda.mjs'
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
|
||||
|
||||
test('PDA module uses only injected development acceptance requests', () => {
|
||||
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, /context\.requestOverseas\(/)
|
||||
assert.match(source, /context\.requestOverseasMultipart\(/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
assert.doesNotMatch(source, /process\.env|fetch\(/)
|
||||
})
|
||||
|
||||
test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => {
|
||||
for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/',
|
||||
'/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) {
|
||||
assert.ok(source.includes(token), token)
|
||||
}
|
||||
for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden)
|
||||
assert.match(source, /requestOverseas\(/)
|
||||
assert.match(source, /requestOverseasMultipart\(/)
|
||||
assert.match(source, /requestId=/)
|
||||
assert.match(source, /traceId=/)
|
||||
assert.match(source, /PENDING_ASSIGNMENT/)
|
||||
assert.match(source, /nonBlank\(item\?\.containerNo\)/)
|
||||
assert.match(source, /expectedCartons/)
|
||||
assert.doesNotMatch(source, /https?:\/\//)
|
||||
assert.doesNotMatch(source, /fetch\(|process\.env/)
|
||||
assert.doesNotMatch(source, /planNo|allocationId/)
|
||||
})
|
||||
|
||||
test('missing source order is reported as a fixture prerequisite without business writes', async () => {
|
||||
test('PDA module covers the container-number receiving contract and excludes outbound domains', () => {
|
||||
for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign',
|
||||
'/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve',
|
||||
'/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token)
|
||||
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.ok(source.includes(role), role)
|
||||
for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden)
|
||||
assert.match(source, /同键同载荷重放/)
|
||||
assert.match(source, /同键异载荷冲突/)
|
||||
assert.match(source, /跨柜切换锁返回409/)
|
||||
assert.match(source, /超收被拒绝/)
|
||||
assert.match(source, /重复箱新键被拒绝/)
|
||||
assert.match(source, /模式硬拒绝/)
|
||||
assert.match(source, /current返回404正常空态/)
|
||||
assert.match(source, /安全扫描被拒绝/)
|
||||
assert.match(source, /retained as acceptance evidence/)
|
||||
})
|
||||
|
||||
test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => {
|
||||
const calls = []; const gaps = []; const steps = []
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', createdResources: {},
|
||||
async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } },
|
||||
async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') },
|
||||
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
|
||||
async request(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||
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') },
|
||||
addStep(...args) { steps.push(args) },
|
||||
assert(...args) { steps.push(args) },
|
||||
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(gaps.length, 1)
|
||||
assert.match(gaps[0][3], /三期装柜完成事件链/)
|
||||
assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null)
|
||||
assert.equal(gaps[0][5].deploymentBlocker, true)
|
||||
assert.equal(steps.length, 1)
|
||||
assert.equal(steps[0].passed, false)
|
||||
assert.equal(steps[0].options.deploymentBlocker, true)
|
||||
})
|
||||
|
||||
test('empty candidate response cannot become a zero-business-request pass', async () => {
|
||||
const calls = []; const gaps = []; const steps = []
|
||||
const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200,
|
||||
body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' })
|
||||
const context = {
|
||||
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {},
|
||||
context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } },
|
||||
async request(method, requestPath) {
|
||||
calls.push({ channel: 'domestic', method, requestPath })
|
||||
return response({ overseasAccessToken: 'pda-token' }, requestPath, method)
|
||||
},
|
||||
async requestOverseas(method, requestPath) {
|
||||
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)
|
||||
},
|
||||
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 }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
await run(context)
|
||||
assert.ok(calls.some((call) => call.requestPath.endsWith('/candidates')))
|
||||
assert.ok(steps.some((step) => step.name.includes('正常空态') && step.passed))
|
||||
assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker))
|
||||
assert.equal(gaps.length, 1)
|
||||
assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null)
|
||||
})
|
||||
|
||||
@@ -1,212 +1,270 @@
|
||||
const MODULE = 'phase4-overseas-pda'
|
||||
const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle'
|
||||
const SCENARIO = 'phase4-overseas-pda-container-receiving'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']
|
||||
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
function query(path, params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
||||
return `${path}?${search}`
|
||||
}
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function code(response) { return String(response?.body?.code ?? '') }
|
||||
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
||||
function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) }
|
||||
function conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) }
|
||||
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
|
||||
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
|
||||
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
|
||||
function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
|
||||
function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
|
||||
|
||||
async function domesticGet(context, name, path) {
|
||||
const response = await context.request('GET', path)
|
||||
const passed = success(response)
|
||||
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
||||
return passed ? response.body.data : null
|
||||
}
|
||||
async function overseas(context, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(method, path, body)
|
||||
const passed = success(response)
|
||||
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
|
||||
return { response, data: passed ? response.body.data : null }
|
||||
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
|
||||
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
|
||||
context.assert(MODULE, `${name}覆盖前置`, response, false, reason, OPTIONS)
|
||||
}
|
||||
|
||||
async function findUnplannedOrder(context) {
|
||||
const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE'))
|
||||
for (const tenant of tenants) {
|
||||
const customerId = id(tenant?.id)
|
||||
if (!longId(customerId)) continue
|
||||
const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 }))
|
||||
if (!success(plans.response)) continue
|
||||
const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean))
|
||||
const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 }))
|
||||
if (!success(orders.response)) continue
|
||||
const order = records(orders.data).find((item) => longId(item?.id) && nonBlank(item?.containerNo)
|
||||
&& !planned.has(id(item.id)))
|
||||
if (order) return { customerId, order }
|
||||
async function pda(context, token, method, name, path, body = undefined) {
|
||||
const response = await context.requestOverseas(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)
|
||||
context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS)
|
||||
return response
|
||||
}
|
||||
|
||||
async function loginPda(context) {
|
||||
const fixture = context.context?.merchantQualificationFixture
|
||||
if (!fixture?.username || !fixture?.password) {
|
||||
blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块')
|
||||
return null
|
||||
}
|
||||
return null
|
||||
const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.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
|
||||
}
|
||||
|
||||
async function createWarehouse(context, customerId) {
|
||||
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
|
||||
const warehouseCode = `PDA-${suffix}`.slice(0, 50)
|
||||
const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', {
|
||||
customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS',
|
||||
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA',
|
||||
contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`,
|
||||
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
|
||||
district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null,
|
||||
latitude: null, longitude: null },
|
||||
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null,
|
||||
description: `${context.marker} PDA inbound acceptance` }],
|
||||
async function createPallet(context, inbound, index) {
|
||||
const warehouseId = id(inbound?.destinationWarehouseId)
|
||||
if (!longId(warehouseId)) return null
|
||||
const suffix = markerSuffix(context)
|
||||
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64),
|
||||
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
|
||||
})
|
||||
const warehouseId = id(created.data)
|
||||
context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId),
|
||||
`warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS)
|
||||
return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` }
|
||||
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS)
|
||||
const locationId = id(location?.body?.data)
|
||||
if (!longId(locationId)) return null
|
||||
context.createdResources.phase4OverseasPda.locationIds.push(locationId)
|
||||
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128)
|
||||
const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId,
|
||||
remark: `${context.marker}-receiving-retained`,
|
||||
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
||||
})
|
||||
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
|
||||
const palletId = id(pallet?.body?.data)
|
||||
return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null
|
||||
}
|
||||
|
||||
async function createResource(context, warehouseId, index, fbaWarehouseCode) {
|
||||
const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase()
|
||||
const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`,
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64),
|
||||
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker,
|
||||
})
|
||||
const locationId = id(location.data)
|
||||
context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId),
|
||||
`locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS)
|
||||
const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`,
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, {
|
||||
palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2,
|
||||
unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined,
|
||||
remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null,
|
||||
})
|
||||
const palletId = id(pallet.data)
|
||||
context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId),
|
||||
`palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS)
|
||||
return { locationId, palletId }
|
||||
}
|
||||
|
||||
async function operateExpectedCartons(context, customerId, planNo, task) {
|
||||
for (const [index, carton] of (task.expectedCartons || []).entries()) {
|
||||
if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue
|
||||
const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`,
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), {
|
||||
cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId),
|
||||
idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
||||
})
|
||||
context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response,
|
||||
scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark,
|
||||
`cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS)
|
||||
async function uploadPhotos(context, token) {
|
||||
const malicious = await context.requestOverseasMultipart('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, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && 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', {
|
||||
name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG,
|
||||
}, token)
|
||||
const fileId = id(response?.body?.data?.fileId)
|
||||
context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId),
|
||||
`role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS)
|
||||
if (!longId(fileId)) return null
|
||||
photos.push({ role, fileId })
|
||||
}
|
||||
return photos
|
||||
}
|
||||
|
||||
async function operateLegacyAllocations(context, customerId, planNo, task) {
|
||||
for (const [index, allocation] of (task.allocations || []).entries()) {
|
||||
const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0)
|
||||
if (remaining <= 0) continue
|
||||
await overseas(context, 'POST', `清点入仓分配${index + 1}`,
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), {
|
||||
packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)),
|
||||
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
||||
})
|
||||
await overseas(context, 'POST', `实收入仓分配${index + 1}`,
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), {
|
||||
packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)),
|
||||
operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
||||
})
|
||||
function skuFixtures(context) {
|
||||
const boxes = context.createdResources?.orders?.boxTypes
|
||||
const orchestration = context.createdResources?.orchestrationFixtures || {}
|
||||
const markers = [context.marker, orchestration.phase3Loading?.sourceMarker,
|
||||
orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank)
|
||||
return Array.isArray(boxes)
|
||||
? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))]
|
||||
: []
|
||||
}
|
||||
|
||||
async function receiveFba(context, token, inbound, pallet) {
|
||||
const skus = skuFixtures(context)
|
||||
if (!skus.length) {
|
||||
blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU')
|
||||
return false
|
||||
}
|
||||
let accepted = 0
|
||||
for (const [index, skuCode] of skus.entries()) {
|
||||
const resolvedResponse = await context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token)
|
||||
const resolvable = success(resolvedResponse) || notFound(resolvedResponse)
|
||||
context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS)
|
||||
const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null }
|
||||
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue
|
||||
const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1
|
||||
const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode,
|
||||
packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) }
|
||||
const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
||||
if (!success(first.response)) return false
|
||||
accepted += Number(first.data?.acceptedPackages || 0)
|
||||
const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
|
||||
context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response,
|
||||
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
|
||||
{ ...body, packageCount: packageCount + 1 }, conflict)
|
||||
await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
|
||||
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict)
|
||||
}
|
||||
if (accepted <= 0) {
|
||||
blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function receiveOverseas(context, token, inbound, pallet) {
|
||||
const cartonMark = context.createdResources?.inboundMeasurement?.boxCode
|
||||
if (!nonBlank(cartonMark)) {
|
||||
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号')
|
||||
return false
|
||||
}
|
||||
const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark })
|
||||
if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) {
|
||||
blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response)
|
||||
return false
|
||||
}
|
||||
const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') }
|
||||
const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
|
||||
if (!success(first.response)) return false
|
||||
const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body)
|
||||
context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response,
|
||||
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
|
||||
await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
|
||||
{ ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict)
|
||||
await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton',
|
||||
{ ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null,
|
||||
planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true }
|
||||
const source = await findUnplannedOrder(context)
|
||||
if (!source) {
|
||||
context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单',
|
||||
'所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable')
|
||||
context.createdResources.phase4OverseasPda = {
|
||||
marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null,
|
||||
palletIds: [], locationIds: [], finalStatus: null,
|
||||
cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||
}
|
||||
const token = await loginPda(context)
|
||||
if (!token) return
|
||||
if (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)
|
||||
}
|
||||
const before = await context.requestOverseas('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)
|
||||
if (success(before)) {
|
||||
blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before)
|
||||
return
|
||||
}
|
||||
const { customerId, order } = source
|
||||
const warehouse = await createWarehouse(context, customerId)
|
||||
if (!longId(warehouse.warehouseId)) return
|
||||
Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId })
|
||||
|
||||
const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓',
|
||||
query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), {
|
||||
warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode,
|
||||
warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`,
|
||||
})
|
||||
if (!success(transferred.response)) return
|
||||
const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', {
|
||||
orderId: id(order.id), customerId,
|
||||
})
|
||||
const plan = converted.data
|
||||
const planNo = plan?.planNo || ''
|
||||
context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response,
|
||||
Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0,
|
||||
`planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS)
|
||||
if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return
|
||||
context.createdResources.phase4OverseasPda.planNo = planNo
|
||||
|
||||
for (const [index, planPallet] of plan.pallets.entries()) {
|
||||
const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode)
|
||||
if (!longId(resource.locationId) || !longId(resource.palletId)) return
|
||||
const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`,
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), {
|
||||
warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId,
|
||||
idempotencyKey: key(context, 'assign', String(index + 1)),
|
||||
})
|
||||
if (!success(assigned.response)) return
|
||||
const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates')
|
||||
if (!Array.isArray(candidates.data) || candidates.data.length === 0) {
|
||||
blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response)
|
||||
return
|
||||
}
|
||||
|
||||
const pushed = await overseas(context, 'POST', '推送PDA入仓任务',
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), {
|
||||
idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
||||
})
|
||||
const task = pushed.data
|
||||
context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response,
|
||||
longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length
|
||||
&& Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons),
|
||||
`taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS)
|
||||
if (!longId(task?.taskId)) return
|
||||
context.createdResources.phase4OverseasPda.taskId = id(task.taskId)
|
||||
|
||||
if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task)
|
||||
else await operateLegacyAllocations(context, customerId, planNo, task)
|
||||
|
||||
const photo = await context.requestOverseasMultipart('POST',
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), {
|
||||
name: `${context.marker}-unload.png`, type: 'image/png',
|
||||
content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')),
|
||||
})
|
||||
context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS)
|
||||
const fileId = id(photo?.body?.data?.fileId)
|
||||
context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS)
|
||||
if (!longId(fileId)) return
|
||||
const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间',
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), {
|
||||
fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收',
|
||||
})
|
||||
context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response,
|
||||
bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt),
|
||||
`fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS)
|
||||
|
||||
const completed = await overseas(context, 'POST', '完成PDA入仓并写库存',
|
||||
query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId }))
|
||||
context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response,
|
||||
completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt),
|
||||
`status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS)
|
||||
const preferredContainer = context.createdResources?.phase3Loading?.containerNo
|
||||
const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0]
|
||||
const containerNo = id(candidate?.containerNo)
|
||||
context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response,
|
||||
nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0,
|
||||
`containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS)
|
||||
if (!containerNo) return
|
||||
context.createdResources.phase4OverseasPda.containerNo = containerNo
|
||||
const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`)
|
||||
if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return
|
||||
const prepareBody = { idempotencyKey: key(context, 'prepare') }
|
||||
const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话',
|
||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
|
||||
if (!success(prepared.response)) return
|
||||
const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放',
|
||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
|
||||
context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response,
|
||||
success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS)
|
||||
const other = candidates.data.find((item) => item.containerNo !== containerNo)
|
||||
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
|
||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict)
|
||||
else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁')
|
||||
const photos = await uploadPhotos(context, token)
|
||||
if (!photos) return
|
||||
const signBody = { photos, idempotencyKey: key(context, 'sign') }
|
||||
const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
|
||||
if (!success(signed.response)) return
|
||||
const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody)
|
||||
context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response,
|
||||
success(signReplay.response) && signReplay.data?.status === 'SIGNED'
|
||||
&& PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS)
|
||||
await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign',
|
||||
{ ...signBody, photos: [...photos].reverse() }, conflict)
|
||||
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
|
||||
if (!success(current.response)) return
|
||||
const pallets = new Map()
|
||||
for (const [index, inbound] of current.data.inbounds.entries()) {
|
||||
const pallet = await createPallet(context, inbound, index)
|
||||
if (!pallet) {
|
||||
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
|
||||
`无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource')
|
||||
continue
|
||||
}
|
||||
pallets.set(inbound.inboundCode, pallet)
|
||||
context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
|
||||
const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`,
|
||||
'/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode })
|
||||
context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response,
|
||||
success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS)
|
||||
}
|
||||
const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA')
|
||||
const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS')
|
||||
if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
|
||||
const firstFba = fbaInbounds[0]
|
||||
await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
|
||||
cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA',
|
||||
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
|
||||
}, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i))
|
||||
for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode))
|
||||
} else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码')
|
||||
if (overseasInbound && pallets.has(overseasInbound.inboundCode)) {
|
||||
const pallet = pallets.get(overseasInbound.inboundCode)
|
||||
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
|
||||
inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode,
|
||||
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
|
||||
}, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i))
|
||||
await receiveOverseas(context, token, overseasInbound, pallet)
|
||||
} else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
|
||||
const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current')
|
||||
if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) {
|
||||
blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)
|
||||
return
|
||||
}
|
||||
const completeBody = { idempotencyKey: key(context, 'complete') }
|
||||
const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
|
||||
if (!success(completed.response)) return
|
||||
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)
|
||||
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
||||
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
||||
|
||||
const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态',
|
||||
query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId }))
|
||||
context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED',
|
||||
`planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS)
|
||||
const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存',
|
||||
query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 }))
|
||||
context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response,
|
||||
records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId),
|
||||
`balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user