优化
This commit is contained in:
@@ -46,6 +46,7 @@ export async function run(context) {
|
||||
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test'
|
||||
|
||||
const supplierFixture = readFileSync(new URL('./suppliers.mjs', import.meta.url), 'utf8')
|
||||
const financeFixture = readFileSync(new URL('./finance-full-fixtures.mjs', import.meta.url), 'utf8')
|
||||
const oceanBookingFixture = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8')
|
||||
const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8')
|
||||
const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8')
|
||||
const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8')
|
||||
@@ -34,3 +35,10 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome
|
||||
assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/)
|
||||
assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s)
|
||||
})
|
||||
|
||||
test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a geographic scope', () => {
|
||||
assert.match(oceanBookingFixture, /category=BOOKING&status=ACTIVE/)
|
||||
assert.match(oceanBookingFixture, /bookingAgentId: bookingAgent\.supplierId/)
|
||||
assert.match(oceanBookingFixture, /订舱持久化启用订舱供应商快照/)
|
||||
assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const entry = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
|
||||
const moduleFile = new URL('./inbound-fees.mjs', import.meta.url)
|
||||
|
||||
test('admin full acceptance registers inbound fee lifecycle after measurement', async () => {
|
||||
const source = await readFile(entry, 'utf8')
|
||||
assert.match(source, /'inbound-measurement', 'inbound-fees', 'phase3-container-loading'/)
|
||||
assert.match(source, /normalized\.includes\('inbound-fees'\)/)
|
||||
assert.match(source, /\['merchant-qualification-fixture', 'orders', 'inbound-measurement'\]/)
|
||||
})
|
||||
|
||||
test('inbound fee lifecycle covers frozen source, version, publication and WAIT_SPLIT contracts', async () => {
|
||||
const source = await readFile(moduleFile, 'utf8')
|
||||
for (const token of [
|
||||
'/fees/latest', '/fees/published', '/fees/confirm', '/fees/send', '/fee-details',
|
||||
'FBA_PRICING', 'MANUAL', 'CALCULATED', 'CONFIRMED', 'SENT', 'PUBLISHED',
|
||||
'sourceFeeVersion', 'reviewStatus', 'publicationStatus', 'settlementIncluded', 'WAIT_SPLIT',
|
||||
]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
assert.match(source, /人工发布头程费用并触发 Finance Outbox/)
|
||||
assert.match(source, /对客合计仅含已发布头程与已审核其他应收且其他应付单列/)
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
const MODULE = 'inbound-fees'
|
||||
const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true }
|
||||
|
||||
function id(value) { return value === null || value === undefined ? '' : String(value) }
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function money(value) { return Number(value) }
|
||||
function requestHeaders(key) { return { 'Idempotency-Key': key.slice(0, 128) } }
|
||||
|
||||
function finish(context, start) {
|
||||
const steps = context.steps.slice(start)
|
||||
context.scenarios.push({
|
||||
name: OPTIONS.scenario,
|
||||
module: MODULE,
|
||||
success: steps.every((step) => step.passed),
|
||||
deploymentBlocked: steps.some((step) => step.deploymentBlocker),
|
||||
passed: steps.filter((step) => step.passed).length,
|
||||
failed: steps.filter((step) => !step.passed).length,
|
||||
})
|
||||
}
|
||||
|
||||
async function listDetails(context, tenantId, inboundId, name) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(tenantId)}`)
|
||||
const rows = context.apiSuccess(MODULE, name, response, '', OPTIONS)
|
||||
return { response, rows: Array.isArray(rows) ? rows : [] }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const start = context.steps.length
|
||||
const fixture = context.createdResources.inboundMeasurement
|
||||
const tenantId = id(fixture?.tenantId)
|
||||
const inboundId = id(fixture?.inboundId)
|
||||
context.assert(MODULE, '复用本轮审核通过且进入 WAIT_SPLIT 的入库单', null,
|
||||
Boolean(tenantId && inboundId && fixture?.finalStatus === 'APPROVED' && fixture?.processStatus === 'WAIT_SPLIT'),
|
||||
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, measurement=${fixture?.finalStatus || 'missing'}, process=${fixture?.processStatus || 'missing'}`, OPTIONS)
|
||||
if (!tenantId || !inboundId) { finish(context, start); return }
|
||||
|
||||
const latestPath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/latest?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const latestResponse = await context.request('GET', latestPath)
|
||||
const initial = context.apiSuccess(MODULE, '审核成功后读取自动初算费用', latestResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '自动初算保持 WAIT_SPLIT 且尚未发布', latestResponse,
|
||||
initial?.feeStatus === 'CALCULATED' && initial?.nextProcessStatus === 'WAIT_SPLIT'
|
||||
&& Number(initial?.feeVersion) >= 1 && money(initial?.totalAmount) >= 0,
|
||||
`feeId=${id(initial?.feeId) || 'missing'}, version=${initial?.feeVersion ?? 'missing'}, status=${initial?.feeStatus || 'missing'}, process=${initial?.nextProcessStatus || 'missing'}`, OPTIONS)
|
||||
if (!initial?.feeId || initial?.feeStatus !== 'CALCULATED') { finish(context, start); return }
|
||||
|
||||
const initialDetails = await listDetails(context, tenantId, inboundId, '查询自动初算派生费用明细')
|
||||
const initialDerived = initialDetails.rows.find((row) => row.sourceType === 'FBA_PRICING'
|
||||
&& id(row.sourceFeeId) === id(initial.feeId))
|
||||
context.assert(MODULE, 'FBA_PRICING 明细是初算版本的只读草稿派生', initialDetails.response,
|
||||
initialDerived?.sourceFeeVersion === initial.feeVersion && initialDerived?.reviewStatus === 'PENDING'
|
||||
&& initialDerived?.publicationStatus === 'DRAFT' && initialDerived?.direction === 'RECEIVABLE'
|
||||
&& money(initialDerived?.amount) === money(initial?.totalAmount),
|
||||
`sourceType=${initialDerived?.sourceType || 'missing'}, sourceVersion=${initialDerived?.sourceFeeVersion ?? 'missing'}, review=${initialDerived?.reviewStatus || 'missing'}, publication=${initialDerived?.publicationStatus || 'missing'}`, OPTIONS)
|
||||
if (initialDerived?.id) {
|
||||
const derivedUpdateResponse = await context.request('PUT',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${encodeURIComponent(initialDerived.id)}?accountId=${encodeURIComponent(tenantId)}`,
|
||||
{ feeItemId: initialDerived.feeItemId, direction: 'RECEIVABLE',
|
||||
businessDate: initialDerived.businessDate, unitPrice: initialDerived.unitPrice,
|
||||
currency: initialDerived.currency, amount: initialDerived.amount,
|
||||
settlementIncluded: initialDerived.settlementIncluded, remark: `${context.marker}-禁止修改派生` })
|
||||
context.assert(MODULE, 'FBA_PRICING 派生详情拒绝通用编辑', derivedUpdateResponse,
|
||||
derivedUpdateResponse.http === 200 && !success(derivedUpdateResponse)
|
||||
&& String(derivedUpdateResponse.body?.message || '').includes('只读派生'),
|
||||
`code=${derivedUpdateResponse.body?.code ?? 'missing'}, message=${derivedUpdateResponse.body?.message || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const adjustedQuantity = Math.max(0.0001, money(initial.chargeableQuantity) + 0.01)
|
||||
const adjustedUnitPrice = Math.max(0, money(initial.unitPrice))
|
||||
const adjustedAdditional = money(initial.additionalAmount) + 0.01
|
||||
const adjustResponse = await context.request('PUT',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedFeeVersion: Number(initial.feeVersion), chargeableQuantity: adjustedQuantity,
|
||||
unitPrice: adjustedUnitPrice, additionalAmount: adjustedAdditional,
|
||||
reason: `${context.marker}-自动费用版本调整` }, undefined,
|
||||
requestHeaders(`${context.marker}-inbound-fee-adjust`))
|
||||
const adjusted = context.apiSuccess(MODULE, '调整初算并生成新版本', adjustResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '调整版本仍为 CALCULATED 且不阻塞 WAIT_SPLIT', adjustResponse,
|
||||
Number(adjusted?.feeVersion) > Number(initial.feeVersion) && adjusted?.feeStatus === 'CALCULATED'
|
||||
&& adjusted?.nextProcessStatus === 'WAIT_SPLIT' && money(adjusted?.totalAmount) >= adjustedAdditional,
|
||||
`oldVersion=${initial.feeVersion}, newVersion=${adjusted?.feeVersion ?? 'missing'}, status=${adjusted?.feeStatus || 'missing'}, process=${adjusted?.nextProcessStatus || 'missing'}`, OPTIONS)
|
||||
if (!adjusted?.feeId) { finish(context, start); return }
|
||||
|
||||
const adjustedDetails = await listDetails(context, tenantId, inboundId, '回查调整后的派生详情版本')
|
||||
const currentDerived = adjustedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING'
|
||||
&& id(row.sourceFeeId) === id(adjusted.feeId))
|
||||
context.assert(MODULE, '派生详情切换到最新费用版本且旧版本不再当前发布', adjustedDetails.response,
|
||||
currentDerived?.sourceFeeVersion === adjusted.feeVersion && currentDerived?.publicationStatus === 'DRAFT'
|
||||
&& !adjustedDetails.rows.some((row) => row.sourceType === 'FBA_PRICING'
|
||||
&& id(row.sourceFeeId) !== id(adjusted.feeId) && row.publicationStatus === 'PUBLISHED'),
|
||||
`derivedFeeId=${id(currentDerived?.sourceFeeId) || 'missing'}, derivedVersion=${currentDerived?.sourceFeeVersion ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const confirmResponse = await context.request('POST',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/confirm?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-confirm`))
|
||||
const confirmed = context.apiSuccess(MODULE, '人工确认最新头程费用', confirmResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '人工确认只改变费用状态且保持 WAIT_SPLIT', confirmResponse,
|
||||
confirmed?.feeStatus === 'CONFIRMED' && confirmed?.feeVersion === adjusted.feeVersion
|
||||
&& confirmed?.nextProcessStatus === 'WAIT_SPLIT',
|
||||
`version=${confirmed?.feeVersion ?? 'missing'}, status=${confirmed?.feeStatus || 'missing'}, process=${confirmed?.nextProcessStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const sendResponse = await context.request('POST',
|
||||
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/send?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-send`))
|
||||
const sent = context.apiSuccess(MODULE, '人工发布头程费用并触发 Finance Outbox', sendResponse, '', OPTIONS)
|
||||
const publishedDetails = await listDetails(context, tenantId, inboundId, '发布后回查对客费用派生')
|
||||
const published = publishedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING'
|
||||
&& id(row.sourceFeeId) === id(sent?.feeId))
|
||||
context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse,
|
||||
sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT'
|
||||
&& published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED'
|
||||
&& published?.settlementIncluded === true,
|
||||
`status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE')
|
||||
const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS)
|
||||
const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records
|
||||
: Array.isArray(feeItemsPage) ? feeItemsPage : []
|
||||
const manualItem = feeItems.find((item) => item.status === 'ACTIVE' && item.warehouseReceivable === true
|
||||
&& item.feeCode !== 'FBA_HEAD_FREIGHT')
|
||||
context.assert(MODULE, '存在启用的非头程运费仓储应收项目', feeItemsResponse,
|
||||
Boolean(manualItem?.id), `feeItemId=${id(manualItem?.id) || 'missing'}, feeCode=${manualItem?.feeCode || 'missing'}`, OPTIONS)
|
||||
if (!manualItem?.id) { finish(context, start); return }
|
||||
|
||||
const detailsBase = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details`
|
||||
const manualPayload = { feeItemId: manualItem.id, direction: 'RECEIVABLE',
|
||||
businessDate: new Date().toISOString().slice(0, 10), unitPrice: 12.34, currency: 'CNY', amount: 12.34,
|
||||
settlementIncluded: true, remark: `${context.marker}-其他应收` }
|
||||
const manualCreateResponse = await context.request('POST', `${detailsBase}?accountId=${encodeURIComponent(tenantId)}`, manualPayload)
|
||||
const manual = context.apiSuccess(MODULE, '新增独立 MANUAL 其他应收', manualCreateResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'MANUAL 初始版本待审核且不冒充 FBA_PRICING', manualCreateResponse,
|
||||
manual?.sourceType === 'MANUAL' && manual?.sourceFeeVersion === 1
|
||||
&& manual?.reviewStatus === 'PENDING' && manual?.publicationStatus === 'DRAFT',
|
||||
`detailId=${id(manual?.id) || 'missing'}, sourceType=${manual?.sourceType || 'missing'}, sourceVersion=${manual?.sourceFeeVersion ?? 'missing'}`, OPTIONS)
|
||||
if (!manual?.id) { finish(context, start); return }
|
||||
|
||||
const approveResponse = await context.request('POST',
|
||||
`${detailsBase}/${encodeURIComponent(manual.id)}/approve?accountId=${encodeURIComponent(tenantId)}`)
|
||||
context.apiSuccess(MODULE, '审核通过 MANUAL 其他应收', approveResponse, '', OPTIONS)
|
||||
const afterApproval = await listDetails(context, tenantId, inboundId, '回查已审核 MANUAL 其他应收')
|
||||
const approved = afterApproval.rows.find((row) => id(row.id) === id(manual.id))
|
||||
context.assert(MODULE, '已审核 MANUAL 纳入对客应收但与头程费用独立', afterApproval.response,
|
||||
approved?.sourceType === 'MANUAL' && approved?.reviewStatus === 'APPROVED'
|
||||
&& approved?.settlementIncluded === true,
|
||||
`review=${approved?.reviewStatus || 'missing'}, settlementIncluded=${approved?.settlementIncluded ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const adjustedManualPayload = { ...manualPayload, unitPrice: 15.67, amount: 15.67,
|
||||
remark: `${context.marker}-其他应收版本调整` }
|
||||
const manualAdjustResponse = await context.request('POST',
|
||||
`${detailsBase}/${encodeURIComponent(manual.id)}/adjust?accountId=${encodeURIComponent(tenantId)}&expectedSourceVersion=1`,
|
||||
adjustedManualPayload)
|
||||
const manualV2 = context.apiSuccess(MODULE, '调整已审核 MANUAL 并生成新版本', manualAdjustResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'MANUAL V2 重新进入待审核且不影响头程 SENT', manualAdjustResponse,
|
||||
id(manualV2?.id) !== id(manual.id) && id(manualV2?.sourceFeeId) === id(manual.id)
|
||||
&& manualV2?.sourceType === 'MANUAL' && manualV2?.sourceFeeVersion === 2
|
||||
&& manualV2?.reviewStatus === 'PENDING' && manualV2?.publicationStatus === 'DRAFT'
|
||||
&& sent?.feeStatus === 'SENT',
|
||||
`sourceVersion=${manualV2?.sourceFeeVersion ?? 'missing'}, review=${manualV2?.reviewStatus || 'missing'}, freight=${sent?.feeStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const currentOnly = await listDetails(context, tenantId, inboundId, '默认列表隐藏 MANUAL 已替换版本')
|
||||
const historyResponse = await context.request('GET',
|
||||
`${detailsBase}?accountId=${encodeURIComponent(tenantId)}&includeHistory=true`)
|
||||
const historyRows = context.apiSuccess(MODULE, '显式查询费用历史版本', historyResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'includeHistory 仅扩展 SUPERSEDED 历史且当前版本不重复', historyResponse,
|
||||
!currentOnly.rows.some((row) => id(row.id) === id(manual.id))
|
||||
&& Array.isArray(historyRows) && historyRows.some((row) => id(row.id) === id(manual.id)
|
||||
&& row.publicationStatus === 'SUPERSEDED')
|
||||
&& historyRows.some((row) => id(row.id) === id(manualV2.id) && row.sourceFeeVersion === 2),
|
||||
`currentHasV1=${currentOnly.rows.some((row) => id(row.id) === id(manual.id))}, historyCount=${Array.isArray(historyRows) ? historyRows.length : 'n/a'}`, OPTIONS)
|
||||
|
||||
const approveV2Response = await context.request('POST',
|
||||
`${detailsBase}/${encodeURIComponent(manualV2.id)}/approve?accountId=${encodeURIComponent(tenantId)}`)
|
||||
context.apiSuccess(MODULE, '审核通过 MANUAL 新版本', approveV2Response, '', OPTIONS)
|
||||
const finalDetails = await listDetails(context, tenantId, inboundId, '汇总已发布头程与已审核其他应收')
|
||||
const receivableTotal = finalDetails.rows.filter((row) => row.direction === 'RECEIVABLE'
|
||||
&& ((row.sourceType === 'FBA_PRICING' && row.publicationStatus === 'PUBLISHED')
|
||||
|| (row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED')))
|
||||
.reduce((sum, row) => sum + money(row.amount), 0)
|
||||
const payableTotal = finalDetails.rows.filter((row) => row.direction === 'PAYABLE'
|
||||
&& row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED')
|
||||
.reduce((sum, row) => sum + money(row.amount), 0)
|
||||
context.assert(MODULE, '对客合计仅含已发布头程与已审核其他应收且其他应付单列', finalDetails.response,
|
||||
receivableTotal >= money(published?.amount) + money(manualV2?.amount) && payableTotal >= 0,
|
||||
`receivableTotal=${receivableTotal.toFixed(2)}, freight=${money(published?.amount).toFixed(2)}, manual=${money(manualV2?.amount).toFixed(2)}, payableTotal=${payableTotal.toFixed(2)}`, OPTIONS)
|
||||
|
||||
context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId,
|
||||
freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id),
|
||||
manualVersion: manualV2.sourceFeeVersion }
|
||||
finish(context, start)
|
||||
}
|
||||
@@ -16,13 +16,17 @@ async function request(context, method, path, body, headers = {}) {
|
||||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||||
}
|
||||
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
|
||||
async function createBooking(context, suffix, key, master) {
|
||||
async function createBooking(context, suffix, key, master, bookingAgent) {
|
||||
const containers = [
|
||||
{ containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
|
||||
{ containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
|
||||
]
|
||||
return request(context, 'POST', BASE,
|
||||
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { containers }) },
|
||||
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, {
|
||||
containers,
|
||||
bookingAgentId: bookingAgent.supplierId,
|
||||
bookingAgentName: bookingAgent.name,
|
||||
}) },
|
||||
{ 'Idempotency-Key': key })
|
||||
}
|
||||
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
|
||||
@@ -116,6 +120,15 @@ export async function run(context) {
|
||||
}
|
||||
const master = await resolveOceanBookingMaster(context, MODULE)
|
||||
if (!master) return
|
||||
const bookingAgentResponse = await request(context, 'GET',
|
||||
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
|
||||
const bookingAgentPage = context.apiSuccess(MODULE, '查询启用的订舱供应商', bookingAgentResponse, '', OPTIONS)
|
||||
const bookingAgent = bookingAgentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
|
||||
context.assert(MODULE, '订舱供应商不区分国内或国外', bookingAgentResponse,
|
||||
Boolean(bookingAgent?.id && bookingAgent?.name) && !Object.hasOwn(bookingAgent || {}, 'scope'),
|
||||
`supplierId=${id(bookingAgent?.id) || 'missing'}, scopeExposed=${Object.hasOwn(bookingAgent || {}, 'scope')}`, OPTIONS)
|
||||
if (!bookingAgent?.id || !bookingAgent?.name || Object.hasOwn(bookingAgent, 'scope')) return
|
||||
const bookingAgentSnapshot = { supplierId: id(bookingAgent.id), name: bookingAgent.name }
|
||||
const list = await request(context, 'GET', `${BASE}?page=1&size=5`)
|
||||
if (missing(list) || list.http === 0) {
|
||||
step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`)
|
||||
@@ -148,7 +161,7 @@ export async function run(context) {
|
||||
await createConfirmedUnassignedBooking(context, suffix, master)
|
||||
|
||||
const key = `${context.marker}-ocean-create`
|
||||
const createResponse = await createBooking(context, suffix, key, master)
|
||||
const createResponse = await createBooking(context, suffix, key, master, bookingAgentSnapshot)
|
||||
const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id)
|
||||
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
|
||||
context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse,
|
||||
@@ -160,6 +173,10 @@ export async function run(context) {
|
||||
context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse,
|
||||
id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName,
|
||||
`expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '订舱持久化启用订舱供应商快照', createResponse,
|
||||
id(detail?.oceanBooking?.bookingAgentId) === bookingAgentSnapshot.supplierId
|
||||
&& detail?.oceanBooking?.bookingAgentName === bookingAgentSnapshot.name,
|
||||
`expected=${bookingAgentSnapshot.supplierId}/${bookingAgentSnapshot.name}, actual=${id(detail?.oceanBooking?.bookingAgentId) || 'missing'}/${detail?.oceanBooking?.bookingAgentName || 'missing'}`, OPTIONS)
|
||||
const expectedContainerNos = [`TST-${suffix}-01`.slice(0, 64), `TST-${suffix}-02`.slice(0, 64)]
|
||||
context.assert(MODULE, '一单两柜逐柜原子生成独立需求分配和 ACTIVE 操作身份', createResponse,
|
||||
detail?.containers?.length === 2 && expectedContainerNos.every((containerNo) => {
|
||||
@@ -172,7 +189,7 @@ export async function run(context) {
|
||||
if (!bookingId) return
|
||||
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
|
||||
containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' }
|
||||
const replay = await createBooking(context, suffix, key, master)
|
||||
const replay = await createBooking(context, suffix, key, master, bookingAgentSnapshot)
|
||||
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
|
||||
const existingContainerNo = detail?.containers?.[0]?.containerNo
|
||||
const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{
|
||||
|
||||
@@ -40,6 +40,37 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) {
|
||||
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
|
||||
const form = new FormData()
|
||||
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`)
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method: 'POST', path, http: response.status, body: payload,
|
||||
raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) {
|
||||
return request(context, 'POST',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: 0, evidenceType, fileId, fileName: 'client-must-not-override.png',
|
||||
sealNo: evidenceType === 'SEAL' ? sealNo : null, remark: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) })
|
||||
}
|
||||
|
||||
export function selectContainerLoadingFixture(context) {
|
||||
const candidates = [
|
||||
context.createdResources?.orchestrationFixtures?.phase3Container,
|
||||
@@ -662,6 +693,52 @@ export async function run(context) {
|
||||
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
|
||||
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
|
||||
|
||||
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
||||
const sealNo = `SEAL-${suffix}`.slice(0, 64)
|
||||
for (const evidenceType of evidenceTypes) {
|
||||
const uploadResponse = await uploadLoadingEvidence(context, tenantId, inboundId, evidenceType)
|
||||
const uploaded = context.apiSuccess(MODULE, `上传柜次 ${evidenceType} 真实证据文件`, uploadResponse,
|
||||
responseEvidence(uploadResponse), OPTIONS)
|
||||
const fileId = id(uploaded?.fileId)
|
||||
context.assert(MODULE, `${evidenceType} 文件类型、归属和二进制元数据完整`, uploadResponse,
|
||||
Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}`
|
||||
&& uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0,
|
||||
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}, size=${uploaded?.sizeBytes ?? 'missing'}`, OPTIONS)
|
||||
if (!fileId) return
|
||||
|
||||
const bindResponse = await bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo)
|
||||
const bound = context.apiSuccess(MODULE, `绑定柜次 ${evidenceType} 装柜证据`, bindResponse,
|
||||
responseEvidence(bindResponse), OPTIONS)
|
||||
const boundItem = bound?.evidences?.evidences?.find?.((item) =>
|
||||
item.evidenceType === evidenceType && id(item.fileId) === fileId && item.status !== 'VOID')
|
||||
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, bindResponse,
|
||||
bound?.mainStatus === 'LOADING' && Boolean(boundItem?.id)
|
||||
&& boundItem?.remark === context.marker
|
||||
&& (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo),
|
||||
`mainStatus=${bound?.mainStatus || 'missing'}, evidenceId=${id(boundItem?.id) || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
|
||||
if (!boundItem?.id) return
|
||||
|
||||
const downloadResponse = await context.requestBinary('GET',
|
||||
`/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
|
||||
context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse,
|
||||
downloadResponse.http === 200 && downloadResponse.bytes.length > 0
|
||||
&& downloadResponse.contentType.toLowerCase().includes('image/png'),
|
||||
`bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
const evidenceListResponse = await context.request('GET',
|
||||
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse,
|
||||
'', OPTIONS)
|
||||
const activeEvidence = Array.isArray(evidenceList?.evidences)
|
||||
? evidenceList.evidences.filter((item) => item.status !== 'VOID') : []
|
||||
context.assert(MODULE, '五类有效照片和 SEAL 锁码齐全后才调用 PDA complete', evidenceListResponse,
|
||||
evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type))
|
||||
&& activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo),
|
||||
`types=${[...new Set(activeEvidence.map((item) => item.evidenceType))].join(',') || 'missing'}, sealNo=${sealNo}`, OPTIONS)
|
||||
if (!evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type))
|
||||
|| !activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo)) return
|
||||
|
||||
const completePath = `${PDA}/tasks/${taskId}/complete`
|
||||
const completePayload = {
|
||||
expectedTaskVersion: Number(picked?.version),
|
||||
|
||||
@@ -405,6 +405,18 @@ test('preload and loading versions use immediate operation baselines and exact p
|
||||
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
||||
const container = source('./phase3-container-loading.mjs')
|
||||
const convergence = source('./phase3-pda-convergence.mjs')
|
||||
assertOrder(container, [
|
||||
'最后一箱拣货后 PDA 任务等待人工确认装柜完成',
|
||||
'上传柜次 ${evidenceType} 真实证据文件',
|
||||
'绑定柜次 ${evidenceType} 装柜证据',
|
||||
'PDA 完成前回查五类有效装柜证据与锁码',
|
||||
'PDA 人工确认装柜完成',
|
||||
])
|
||||
assert.match(container, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/)
|
||||
assert.match(container, /businessType === `LOADING_\$\{evidenceType\}`/)
|
||||
assert.match(container, /item\.evidenceType === 'SEAL' && item\.sealNo === sealNo/)
|
||||
assert.match(container, /if \(!evidenceTypes\.every[\s\S]*\) return/)
|
||||
assert.match(container, /requestBinary\('GET',[\s\S]*files\/loading-evidence/)
|
||||
assert.match(container, /回查完成后的 PDA 操作审计/)
|
||||
assert.match(container, /item\.actionType === 'BATCH'/)
|
||||
assert.match(container, /item\.actionType === 'COMPLETE_LOADING'/)
|
||||
|
||||
Reference in New Issue
Block a user