Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/admin-test-env-acceptance.mjs
#	scripts/agents-index-contract.ps1
#	scripts/full-regression-entry-contract.test.mjs
This commit is contained in:
2026-09-02 20:32:23 +08:00
28 changed files with 1412 additions and 58 deletions
+1
View File
@@ -77,6 +77,7 @@ export async function run(context) {
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
| 消息中心管理 | `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` |
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
@@ -24,7 +24,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'整组原子移除',
'容量竞争时智能推荐不因整组容量失败',
'容量竞争时同柜组只能整组全取或全不取',
'推荐候选仅含当前目标同柜组且成员身份精确',
'推荐候选包含当前目标同柜组且成员身份精确',
'ACTIVE同柜组整组取消成功',
'同柜组PDA原子批次记录短拣异常',
'同柜组任一成员短拣时完成被拒绝',
@@ -461,8 +461,8 @@ export async function run(context) {
`members=${JSON.stringify(crossDestinationFacts)}`, OPTIONS)
if (crossDestinationGroup?.groupId) {
const cleanupResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(crossDestinationGroup.groupId)}/cancel`, {
expectedVersion: Number(crossDestinationGroup.version), reason: '释放跨目的仓正向验收组成员',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fifth.inboundCode)}/cancel`, {
reason: '释放跨目的仓正向验收组成员',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cross-destination-cancel`.slice(0, 128) })
context.apiSuccess(MODULE, '跨目的仓正向组整组取消并释放成员', cleanupResponse,
evidence(cleanupResponse), OPTIONS)
@@ -532,8 +532,8 @@ export async function run(context) {
.map((item) => id(item?.coLoadGroupId)).filter(Boolean))]
const targetCandidateInbounds = isolatedCandidates.filter((item) => id(item?.coLoadGroupId) === groupId)
.map((item) => id(item?.inboundId)).sort()
context.assert(MODULE, '推荐候选仅含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.length === 1 && candidateGroupIds[0] === groupId
context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.includes(groupId)
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()),
`groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS)
const allCandidateVolume = isolatedCandidates
@@ -675,8 +675,8 @@ export async function run(context) {
`before=${beforeMutation}, after=${planSignature(afterMutation.data)}`, OPTIONS)
const cancelLockedResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(groupId)}/cancel`, {
expectedVersion: Number(group?.version) + 1, reason: '锁定组取消反向验收',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(first.inboundCode)}/cancel`, {
reason: '锁定组取消反向验收',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-cancel-locked`.slice(0, 128) })
conflict(context, '已锁定组不能取消', cancelLockedResponse)
@@ -729,8 +729,8 @@ export async function run(context) {
const cancelGroupId = id(cancelGroup?.groupId)
if (!cancelGroupId) return
const cancelResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(cancelGroupId)}/cancel`, {
expectedVersion: Number(cancelGroup?.version), reason: '验收ACTIVE整组取消',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(fifth.inboundCode)}/cancel`, {
reason: '验收ACTIVE整组取消',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-active-cancel`.slice(0, 128) })
const cancelled = context.apiSuccess(MODULE, 'ACTIVE同柜组整组取消成功', cancelResponse,
evidence(cancelResponse), OPTIONS)
@@ -917,8 +917,8 @@ export async function run(context) {
`treeStable=${splitTreeBeforeGroup === treeSignature(splitTreeAfter)}`, OPTIONS)
if (splitGroup?.groupId) {
const splitCancelResponse = await context.request('POST',
`/api/platform/v1/first-leg/coload-groups/${encodeURIComponent(splitGroup.groupId)}/cancel`, {
expectedVersion: Number(splitGroup.version), reason: '完成拆分余量正向验收',
`/api/platform/v1/first-leg/coload-groups/by-inbound-code/${encodeURIComponent(eighth.inboundCode)}/cancel`, {
reason: '完成拆分余量正向验收',
}, undefined, { 'Idempotency-Key': `${context.marker}-coload-split-cancel`.slice(0, 128) })
context.apiSuccess(MODULE, '拆分余量正向组整组取消', splitCancelResponse,
evidence(splitCancelResponse), OPTIONS)
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { buildDirectPaidBody, findDirectPaidCost, findOpenSettlement } from './finance-direct-paid-isolation.mjs'
const source = await readFile(new URL('./finance-direct-paid-isolation.mjs', import.meta.url), 'utf8')
test('DIRECT_PAID 请求只绑定客户、仓库、供应商且不伪造业务关联', () => {
const body = buildDirectPaidBody('RUN-001', { customerId: '101', customerName: '客户A' },
{ id: '201', name: '供应商A' }, { id: '301' })
assert.equal(body.businessType, 'WAREHOUSE_SERVICE')
assert.equal(body.sourceType, 'OVERSEAS_WAREHOUSE_FEE')
assert.equal(body.settlementMode, 'DIRECT_PAID')
assert.equal(body.direction, 'PAYABLE')
assert.equal(body.customerId, '101')
assert.equal(body.warehouseId, '301')
assert.equal(body.supplierId, '201')
assert.equal(body.orderId, null)
assert.equal(body.inboundId, null)
assert.equal(body.inboundCode, null)
assert.equal(body.containerId, null)
assert.match(body.paymentReferenceNo, /DIRECT-PAID/)
})
test('成本与未结清结算单选择严格命中客户金额和可支付状态', () => {
const body = { customerId: '101', amount: 17.31, feeItemName: '海外仓服务已付费用' }
assert.equal(findDirectPaidCost([
{ id: 1, customerId: '102', amount: 17.31, feeItemName: body.feeItemName },
{ id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName },
], body)?.id, 2)
assert.equal(findOpenSettlement([
{ settlementId: 1, outstandingAmount: 0, status: 'PAID' },
{ settlementId: 2, outstandingAmount: 9, status: 'ISSUED' },
])?.settlementId, 2)
})
test('HTTP 模块覆盖直接已付投影、AP 排除、幂等冲突、冻结和第二客户隔离', () => {
for (const token of [
'BUSINESS_DIRECT_PAID', 'customer-accounts/', 'cost-entries', 'fee-pool', 'payable-settlements',
'收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突',
'拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单',
'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs',
]) assert.ok(source.includes(token), token)
assert.match(source, /fixture\.marker !== context\.marker/)
assert.match(source, /禁止借历史财务单据/)
assert.doesNotMatch(source, /INSERT\s+INTO|UPDATE\s+fin_|DELETE\s+FROM/i)
})
test('敏感凭据不写入输出且临时客户上下文完整恢复', () => {
assert.match(source, /originalUsername/)
assert.match(source, /originalPassword/)
assert.match(source, /delete process\.env\.MERCHANT_TEST_PASSWORD/)
assert.doesNotMatch(source, /console\.log\([^)]*password/i)
assert.doesNotMatch(source, /MYSQL_PWD|Authorization:\s*`Bearer[^$]/)
})
@@ -0,0 +1,322 @@
const MODULE = 'finance-direct-paid-isolation'
const SCENARIO = 'finance-direct-paid-idempotency-freeze-isolation'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const FINANCE = '/api/platform/finance/v1'
const CLIENT = '/api/client/finance/v1'
const OVERSEAS_FEES = '/api/platform/v1/last-leg/finance-fee-sources'
const id = (value) => value == null ? '' : String(value)
const data = (response) => response?.body?.data ?? response?.body
const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
const ok = (response) => response?.http === 200 && String(response?.body?.code ?? '200') === '200'
const rejected = (response) => !ok(response)
const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id)
const settlementId = (value) => id(value?.settlementId ?? value?.id)
const sourceId = (value) => id(value?.sourceFeeId ?? value?.feeSourceId ?? value?.id)
const costId = (value) => id(value?.costId ?? value?.id)
const version = (value) => Number(value?.operationVersion ?? value?.version ?? value?.summary?.version ?? 0)
const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0)
const today = () => new Date().toISOString().slice(0, 10)
function key(context, operation) {
return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, operation, token = context.token, overseas = false) {
const headers = { 'Idempotency-Key': key(context, operation) }
return overseas
? context.requestOverseas(method, path, body, headers, token)
: context.request(method, path, body, token, headers)
}
function diagnostic(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${id(response?.body?.code) || 'missing'}, requestId=${response?.requestId ?? 'missing'}, traceId=${response?.traceId ?? 'missing'}`
}
function blocker(context, name, reason, response = null, category = 'finance-direct-paid-prerequisite') {
context.addStep(MODULE, name, response, false, `${reason}; ${diagnostic(response)}`, OPTIONS)
context.addCoverageGap?.(MODULE, SCENARIO, name, reason, category, {
marker: context.marker, requestId: response?.requestId ?? null, traceId: response?.traceId ?? null,
})
}
function finish(context, start) {
const steps = context.steps.slice(start)
context.scenarios.push({ name: 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 })
}
export function buildDirectPaidBody(marker, fixture, supplier, warehouse) {
const businessNo = `WHS-${String(marker).replace(/[^A-Za-z0-9]/g, '').slice(-24)}`
return {
customerId: id(fixture.customerId), customerName: fixture.customerName || null,
sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_SERVICE', businessId: null,
businessNo, orderId: null, inboundId: null, inboundCode: null,
containerId: null, containerBookingId: null, containerNo: null,
warehouseId: id(warehouse.id ?? warehouse.warehouseId),
feeItemCode: 'WAREHOUSE_SERVICE_DIRECT_PAID', feeItemName: `${marker} 海外仓服务已付费用`,
direction: 'PAYABLE', supplierId: id(supplier.id ?? supplier.supplierId),
supplierName: supplier.name ?? supplier.supplierName, supplierCategory: null,
settlementMode: 'DIRECT_PAID', paidAt: new Date().toISOString(), paymentMethod: 'BANK_TRANSFER',
paymentReferenceNo: `${marker}-DIRECT-PAID`, paymentVoucherFileIds: [],
businessDate: today(), currency: 'CNY', amount: 17.31,
}
}
export function findDirectPaidCost(items, body) {
return rows(items).find((item) => Number(item?.amount ?? item?.totalAmount ?? item?.costAmount ?? item?.payableAmount) === Number(body.amount)
&& id(item?.customerId ?? item?.tenantId) === id(body.customerId)
&& (item?.feeItemName === body.feeItemName
|| id(item?.costNo ?? item?.settlementNo).startsWith('DIRECT-')))
}
export function findOpenSettlement(items) {
return rows(items).find((item) => settlementId(item)
&& !['VOIDED', 'PAID'].includes(id(item?.status))
&& !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null
}
async function ensureFixture(context) {
const fixture = context.createdResources?.financeFullFixtures
if (!fixture || fixture.marker !== context.marker || !fixture.customerId || !fixture.voucherFileId) return null
return fixture
}
async function loadActiveSupplierAndWarehouse(context, fixture) {
const supplier = fixture.supplier || rows(fixture.suppliers)[0]
const warehouseResponse = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS')
const warehousePage = context.apiSuccess(MODULE, '查询海外自营仓用于无入仓码已付费用', warehouseResponse,
diagnostic(warehouseResponse), OPTIONS)
const warehouse = rows(warehousePage).find((item) => id(item?.id ?? item?.warehouseId))
if (!id(supplier?.id ?? supplier?.supplierId) || !(supplier?.name ?? supplier?.supplierName) || !warehouse) {
return { supplier, warehouse, response: warehouseResponse }
}
return { supplier, warehouse, response: warehouseResponse }
}
async function publishDirectPaid(context, fixture, supplier, warehouse) {
const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse)
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse,
diagnostic(createdResponse), OPTIONS)
const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId })
|| id(created?.sourceFeeId)
context.assert(MODULE, 'DIRECT_PAID 来源仅绑定客户、仓库与供应商', createdResponse,
Boolean(targetId) && created?.settlementMode === 'DIRECT_PAID' && created?.direction === 'PAYABLE'
&& !created?.orderId && !created?.inboundId && !created?.inboundCode && !created?.containerId,
`sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS)
if (!targetId) return null
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
const replay = data(replayResponse)
context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse,
ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId,
`first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS)
const conflictResponse = await write(context, 'POST', OVERSEAS_FEES,
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true)
context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse),
diagnostic(conflictResponse), OPTIONS)
const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`,
{ idempotencyKey: key(context, 'overseas-submit') })
context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS)
const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`,
{ decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS)
return { sourceFeeId: targetId, body }
}
async function verifyDirectPaidProjection(context, fixture, published) {
let source = null
let costs = []
let summary = null
for (let attempt = 0; attempt < 40; attempt += 1) {
const [sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`),
context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`),
context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`),
])
source = rows(data(sourcesResponse)).find((item) => id(item?.sourceFeeId) === published.sourceFeeId
&& item?.sourceSystem !== 'FIRSTLEG')
costs = rows(data(costsResponse))
summary = data(summaryResponse)
if (source && findDirectPaidCost(costs, published.body)) break
await new Promise((resolve) => setTimeout(resolve, 500))
}
const cost = findDirectPaidCost(costs, published.body)
context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null,
Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount
&& Number(summary?.paidCost) >= published.body.amount,
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}`, OPTIONS)
const apPoolResponse = await context.request('GET',
`${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
const apListResponse = await context.request('GET',
`${FINANCE}/payable-settlements?customerId=${encodeURIComponent(fixture.customerId)}`)
const inApPool = rows(data(apPoolResponse)).some((item) => id(item?.sourceFeeId) === published.sourceFeeId)
const inAp = rows(data(apListResponse)).some((item) => rows(item?.lines).some((line) => id(line?.sourceFeeId) === sourceId(source)))
context.assert(MODULE, 'DIRECT_PAID 不进入 AP 费用池或应付结算单', apPoolResponse,
!inApPool && !inAp, `inApPool=${inApPool}, inAp=${inAp}`, OPTIONS)
return { source, cost, summary }
}
async function verifyPaymentIdempotency(context, fixture) {
const ar = findOpenSettlement(fixture.receivableSettlements)
const ap = findOpenSettlement(fixture.payableSettlements)
if (!ar || !ap) {
blocker(context, '取得未结清 AR/AP 用于收付款幂等',
`AR=${settlementId(ar) || 'missing'}, AP=${settlementId(ap) || 'missing'};需在 finance-settlement-full 消费前执行本模块`, null,
'open-finance-settlement-unavailable')
return
}
const arDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ar))}`)
const arDetail = context.apiSuccess(MODULE, '读取未结清 AR 详情用于收款幂等', arDetailResponse,
diagnostic(arDetailResponse), OPTIONS)
const receiptAmount = Math.min(0.01, outstanding(arDetail))
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: receiptAmount,
currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)],
remark: context.marker, allocations: [{ billId: statementId(ar), amount: receiptAmount }] }
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
const receiptReplay = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
const receiptConflict = await write(context, 'POST', `${FINANCE}/receipts`,
{ ...receiptBody, remark: `${context.marker}-different` }, 'receipt-replay')
context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay,
ok(receipt) && ok(receiptReplay)
&& id(data(receipt)?.receiptId ?? data(receipt)?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
&& rejected(receiptConflict),
`receipt=${id(data(receipt)?.receiptId ?? data(receipt)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
const apDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}`)
const apDetail = data(apDetailResponse)
const paymentAmount = Math.min(0.01, outstanding(apDetail))
const paymentBody = { transactionDate: today(), amount: paymentAmount, currency: 'CNY', method: 'BANK_TRANSFER',
referenceNo: `${context.marker}-AP-IDEMPOTENT`, voucherFileIds: [id(fixture.voucherFileId)],
expectedVersion: version(apDetail), remark: context.marker }
const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions`
const payment = await write(context, 'POST', path, paymentBody, 'ap-replay')
const paymentReplay = await write(context, 'POST', path, paymentBody, 'ap-replay')
const paymentConflict = await write(context, 'POST', path,
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, 'ap-replay')
context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay,
ok(payment) && ok(paymentReplay) && rejected(paymentConflict)
&& version(data(paymentReplay)) === version(data(payment)),
`settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS)
}
async function verifyFrozenSource(context, fixture) {
const mapped = rows(fixture.statements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)
const targetId = mapped[0] || rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)[0]
if (!targetId) {
blocker(context, '取得已进入有效单据的来源', 'finance reconciliation/settlement fixture 未提供 feeSourceId', null,
'frozen-fee-source-unavailable')
return
}
const beforeResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`)
const before = data(beforeResponse)
const expectedVersion = version(before)
const edit = await write(context, 'PUT', `${FINANCE}/fee-sources/${targetId}`,
{ amount: Number(before?.amount || 1) + 1, reason: context.marker, expectedVersion }, 'frozen-edit')
const adjust = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/adjust`,
{ amount: Number(before?.amount || 1) + 2, reason: context.marker, expectedVersion }, 'frozen-adjust')
const voided = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/void`,
{ reason: context.marker, expectedVersion }, 'frozen-void')
const afterResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`)
const after = data(afterResponse)
context.assert(MODULE, '已进入有效账单或结算的来源拒绝编辑、调整和作废且零漂移', afterResponse,
[edit, adjust, voided].every(rejected) && version(after) === expectedVersion
&& Number(after?.amount) === Number(before?.amount) && after?.sourceStatus === before?.sourceStatus,
`source=${targetId}, beforeVersion=${expectedVersion}, afterVersion=${version(after)}; edit=${diagnostic(edit)}; adjust=${diagnostic(adjust)}; void=${diagnostic(voided)}`, OPTIONS)
}
async function createSecondCustomer(context) {
const originalMarker = context.marker
const originalContext = context.context?.merchantQualificationFixture
const originalResource = context.createdResources?.merchantQualificationFixture
const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
context.marker = `${originalMarker}-FIN-ISO`
try {
const module = await import(new URL('./merchant-qualification-fixture.mjs', import.meta.url))
await module.run(context)
const fixture = context.context?.merchantQualificationFixture
if (!fixture?.tenantId || !fixture?.username || !fixture?.password || fixture.marker !== context.marker) return null
const loginResponse = await context.request('POST', '/api/auth/login',
{ username: fixture.username, password: fixture.password }, '')
const login = context.apiSuccess(MODULE, '第二隔离客户登录', loginResponse, diagnostic(loginResponse), OPTIONS)
return { customerId: id(fixture.tenantId), token: login?.accessToken }
} finally {
context.marker = originalMarker
if (originalContext) context.context.merchantQualificationFixture = originalContext
else delete context.context.merchantQualificationFixture
if (originalResource) context.createdResources.merchantQualificationFixture = originalResource
else delete context.createdResources.merchantQualificationFixture
if (originalUsername == null) delete process.env.MERCHANT_TEST_USERNAME
else process.env.MERCHANT_TEST_USERNAME = originalUsername
if (originalPassword == null) delete process.env.MERCHANT_TEST_PASSWORD
else process.env.MERCHANT_TEST_PASSWORD = originalPassword
}
}
async function verifyCustomerIsolation(context, fixture) {
const second = await createSecondCustomer(context)
const target = rows(fixture.statements).find((item) => statementId(item))
const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean)
if (!second?.customerId || !second?.token || !target || !apSource) {
blocker(context, '建立第二隔离客户与跨客户单据 Fixture',
`customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`,
null, 'second-customer-fixture-unavailable')
return
}
const targetId = statementId(target)
const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token)
const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`,
{ expectedVersion: Number(target.version ?? 0) }, second.token,
{ 'Idempotency-Key': key(context, 'foreign-confirm') })
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, {
customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker,
allocations: [{ billId: targetId, amount: 0.01 }],
}, 'foreign-receipt')
const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, {
customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId),
feeSourceIds: [apSource],
dueDate: today(), currency: 'CNY',
}, 'foreign-payable')
context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm,
[read, confirm, receipt, payment].every(rejected),
`read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS)
}
export async function run(context) {
const start = context.steps.length
try {
const fixture = await ensureFixture(context)
if (!fixture) {
blocker(context, '取得本轮 Finance 全链 Fixture', '缺少同 marker financeFullFixtures,禁止借历史财务单据')
return
}
const input = await loadActiveSupplierAndWarehouse(context, fixture)
if (!id(input.supplier?.id ?? input.supplier?.supplierId)
|| !(input.supplier?.name ?? input.supplier?.supplierName) || !input.warehouse) {
blocker(context, '取得 DIRECT_PAID 供应商和海外仓',
`supplier=${id(input.supplier?.id ?? input.supplier?.supplierId) || 'missing'}, warehouse=${id(input.warehouse?.id ?? input.warehouse?.warehouseId) || 'missing'}`,
input.response, 'direct-paid-master-data-unavailable')
return
}
const published = await publishDirectPaid(context, fixture, input.supplier, input.warehouse)
if (!published) return
await verifyDirectPaidProjection(context, fixture, published)
await verifyPaymentIdempotency(context, fixture)
await verifyFrozenSource(context, fixture)
await verifyCustomerIsolation(context, fixture)
context.createdResources.financeDirectPaidIsolation = {
marker: context.marker, customerId: id(fixture.customerId), sourceFeeId: published.sourceFeeId,
businessNo: published.body.businessNo,
}
} finally {
finish(context, start)
}
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
candidateLoadingPlanIds,
ownershipAllocations,
withoutInbound,
} from './firstleg-container-fee-allocation.mjs'
const source = readFileSync(new URL('./firstleg-container-fee-allocation.mjs', import.meta.url), 'utf8')
test('candidate plans only come from resources created by the current acceptance run', () => {
assert.deepEqual(candidateLoadingPlanIds({
phase3ContainerLoading: { loadingPlanId: '101' },
coloadGroupAcceptance: { fullPlanId: '102', planId: '103' },
manualLoadingComplete: { loadingPlanId: '101' },
financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] },
}), ['101', '102', '103', '104'])
assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/)
})
test('allocation builder selects one real order per distinct customer and preserves exact cents', () => {
const allocations = ownershipAllocations([
{ accountId: '11', orderId: '21', orderNo: 'FL-21', inboundId: '31', inboundCode: 'IN-31' },
{ accountId: '11', orderId: '22', orderNo: 'FL-22', inboundId: '32', inboundCode: 'IN-32' },
{ accountId: '12', orderId: '23', orderNo: 'FL-23', inboundId: '33', inboundCode: 'IN-33' },
{ accountId: '13', orderId: '24', orderNo: 'FL-24', inboundId: '34', inboundCode: 'IN-34' },
], 100)
assert.equal(allocations.length, 3)
assert.equal(new Set(allocations.map((item) => item.customerId)).size, 3)
assert.equal(allocations.reduce((sum, item) => sum + Number(item.amount), 0), 100)
assert.deepEqual(withoutInbound(allocations[0]), {
customerId: '11', orderId: '21', orderNo: 'FL-21', amount: '33.33',
})
})
test('module freezes all required real HTTP invariants and provisions a formal cross-customer fixture', async () => {
for (const token of [
'/allocation-groups', '分摊金额合计不一致被拒绝', '分摊客户必填', '分摊订单必填',
'伪造非柜内订单归属被拒绝', '伪造非柜内入仓码被拒绝', '不填写入仓码原子创建跨客户分摊组',
'单行审核不能破坏分摊组一致性', '整组审核原子成功', '缺少组成员的调整被拒绝',
'整组调整原子成功', '整组作废原子成功', '已作废冻结分摊组拒绝继续调整',
'非柜内客户不能读取柜次费用', 'requestId=', 'traceId=', 'deploymentBlocker: true',
]) assert.ok(source.includes(token), token)
for (const token of [
'createMerchant(context)', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan',
'originalUsername', 'originalPassword', 'delete process.env.MERCHANT_TEST_USERNAME',
'delete process.env.MERCHANT_TEST_PASSWORD',
]) assert.ok(source.includes(token), token)
assert.doesNotMatch(source, /\bfetch\s*\(|\/internal\/|mysql|SELECT\s/i)
})
@@ -0,0 +1,326 @@
const MODULE = 'firstleg-container-fee-allocation'
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function code(response) { return String(response?.body?.code ?? response?.http ?? '') }
function evidence(response, extra = '') {
return [`HTTP=${response?.http ?? 'missing'}`, `code=${code(response) || 'missing'}`,
`requestId=${response?.requestId || response?.body?.requestId || 'missing'}`,
`traceId=${response?.traceId || response?.body?.traceId || 'missing'}`, extra].filter(Boolean).join(', ')
}
function operationKey(context, suffix) { return `${context.marker}-container-fee-${suffix}`.slice(0, 128) }
function post(context, path, body, suffix) {
return context.request('POST', path, body, context.token, { 'Idempotency-Key': operationKey(context, suffix) })
}
function blocker(context, name, detail) {
context.addStep(MODULE, name, null, false, detail, OPTIONS)
}
function expectBusinessFailure(context, name, response, expectedCodes = ['A0400', 'A0404', 'A0409', 'A0422']) {
context.addStep(MODULE, name, response,
response?.http >= 400 || (response?.http === 200 && expectedCodes.includes(code(response))),
evidence(response, `message=${response?.body?.message || 'missing'}`), OPTIONS)
}
function money(value) { return Number(Number(value || 0).toFixed(2)) }
export function candidateLoadingPlanIds(createdResources = {}) {
const candidates = [
createdResources.phase3ContainerLoading?.loadingPlanId,
createdResources.coloadGroupAcceptance?.fullPlanId,
createdResources.coloadGroupAcceptance?.planId,
createdResources.manualLoadingComplete?.loadingPlanId,
createdResources.phase3ContainerDocuments?.loadingPlanId,
...rows(createdResources.financeFullFixtures?.feeSources).map((item) => item?.loadingPlanId),
].map(id).filter((value) => /^[1-9]\d*$/.test(value))
return [...new Set(candidates)]
}
export function ownershipAllocations(planItems = [], totalAmount = 120) {
const unique = new Map()
for (const item of rows(planItems)) {
const customerId = id(item?.accountId ?? item?.customerId ?? item?.tenantId)
const orderId = id(item?.orderId)
const orderNo = String(item?.orderNo || '').trim()
const inboundId = id(item?.inboundId)
const inboundCode = String(item?.inboundCode || '').trim()
if (!customerId || !orderId || !orderNo || !inboundId || !inboundCode) continue
const key = `${customerId}|${orderId}|${inboundId}`
if (!unique.has(key)) unique.set(key, { customerId, orderId, orderNo, inboundId, inboundCode })
}
const byCustomer = new Map()
for (const allocation of unique.values()) if (!byCustomer.has(allocation.customerId)) byCustomer.set(allocation.customerId, allocation)
const selected = [...byCustomer.values()]
if (selected.length < 2) return []
const totalCents = Math.round(Number(totalAmount) * 100)
const base = Math.floor(totalCents / selected.length)
return selected.map((allocation, index) => ({ ...allocation,
amount: ((base + (index === selected.length - 1 ? totalCents - base * selected.length : 0)) / 100).toFixed(2) }))
}
export function withoutInbound(allocation) {
const { inboundId: _inboundId, inboundCode: _inboundCode, ...required } = allocation
return required
}
function groupRows(allRows, groupId) {
return allRows.filter((item) => id(item?.allocationGroupId) === id(groupId))
}
function signature(group) {
return group.map((item) => [id(item?.id), id(item?.tenantId), item?.reviewStatus,
item?.sourceStatus, Number(item?.sourceVersion), money(item?.amount)].join(':')).sort().join('|')
}
async function readOwnedRows(context, loadingPlanId, customerIds, label) {
const result = []
for (const customerId of customerIds) {
const response = await context.request('GET',
`/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details?accountId=${encodeURIComponent(customerId)}`)
const data = context.apiSuccess(MODULE, `${label}:客户 ${customerId} 回读费用`, response,
evidence(response, `loadingPlanId=${loadingPlanId}, customerId=${customerId}`), OPTIONS)
const owned = rows(data)
context.assert(MODULE, `${label}:客户费用隔离`, response,
owned.every((item) => id(item?.tenantId) === customerId),
`customerId=${customerId}, returnedTenants=${[...new Set(owned.map((item) => id(item?.tenantId)))].join('|') || 'none'}`, OPTIONS)
result.push(...owned)
}
return result
}
async function findCrossCustomerPlan(context) {
for (const loadingPlanId of candidateLoadingPlanIds(context.createdResources)) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`)
const plan = context.apiSuccess(MODULE, '回读本轮柜次费用分摊候选', response,
evidence(response, `loadingPlanId=${loadingPlanId}`), OPTIONS)
const allocations = ownershipAllocations(plan?.items, 120)
if (allocations.length >= 2) return { loadingPlanId, plan, allocations }
}
return null
}
async function createCustomerWaitSplit(context, suffix, fixedRoute = null) {
const runMarker = context.marker
context.marker = `${runMarker}-${suffix}`
if (fixedRoute) context.context.waitSplitRoute = fixedRoute
await createMerchant(context)
const orders = await import(new URL(`./orders.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url))
const measurementModule = await import(new URL(`./inbound-measurement.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url))
await orders.run(context)
await measurementModule.run(context)
const order = structuredClone(context.createdResources.orders || {})
const measurement = structuredClone(context.createdResources.inboundMeasurement || {})
context.assert(MODULE, `${suffix}客户产生真实WAIT_SPLIT入仓单`, null,
Boolean(order.tenantId && order.orderId && order.inboundId && order.inboundCode
&& measurement.processStatus === 'WAIT_SPLIT'),
`customerId=${order.tenantId || 'missing'}, inboundCode=${order.inboundCode || 'missing'}, status=${measurement.processStatus || 'missing'}`,
OPTIONS)
context.marker = runMarker
return { ...order, processStatus: measurement.processStatus }
}
async function provisionCrossCustomerPlan(context) {
const originalMarker = context.marker
const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
const originalRoute = context.context.waitSplitRoute
const originalMerchant = context.context.merchantQualificationFixture
try {
const first = await createCustomerWaitSplit(context, 'ALLOC-A')
const route = {
originWarehouseName: first.receivingWarehouseName,
channelCode: first.channelCode,
destinationType: first.destinationType,
destinationWarehouseId: first.destinationWarehouseId,
taxModeIndex: 0,
}
const second = await createCustomerWaitSplit(context, 'ALLOC-B', route)
if (id(first.tenantId) === id(second.tenantId)
|| id(first.receivingWarehouseId) !== id(second.receivingWarehouseId)
|| first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
evidence(containerResponse), OPTIONS)
const bookingId = id(container?.id)
if (!bookingId) return null
const planResponse = await context.request('GET',
`/api/platform/v1/first-leg/smart-loading/plans/by-booking/${encodeURIComponent(bookingId)}`)
let plan = context.apiSuccess(MODULE, '读取跨客户费用分摊专用计划', planResponse,
evidence(planResponse), OPTIONS)
const loadingPlanId = id(plan?.id)
if (!loadingPlanId) return null
for (const [index, item] of [first, second].entries()) {
const assigned = await context.request('POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/inbound-assignments`, {
inboundId: id(item.inboundId),
packageCount: rows(item.skuLines).reduce((sum, line) => sum + Number(line.packageCount || 0), 0),
expectedVersion: Number(plan?.version),
operationId: operationKey(context, `fixture-assign-${index + 1}`),
})
plan = context.apiSuccess(MODULE, `原子分配第${index + 1}个客户入仓单`, assigned,
evidence(assigned, `inboundCode=${item.inboundCode}`), OPTIONS)
if (!plan) return null
}
const allocations = ownershipAllocations(plan.items, 120)
return allocations.length >= 2 ? { loadingPlanId, plan, allocations } : null
} finally {
context.marker = originalMarker
context.context.waitSplitRoute = originalRoute
context.context.merchantQualificationFixture = originalMerchant
if (originalUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME
else process.env.MERCHANT_TEST_USERNAME = originalUsername
if (originalPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD
else process.env.MERCHANT_TEST_PASSWORD = originalPassword
}
}
export async function run(context) {
const start = context.steps.length
const fixture = await findCrossCustomerPlan(context) || await provisionCrossCustomerPlan(context)
if (!fixture) {
blocker(context, '取得本轮跨客户真实柜次',
`marker=${context.marker}, candidateLoadingPlanIds=${candidateLoadingPlanIds(context.createdResources).join('|') || 'none'};必须由本轮前置创建至少包含两个客户、真实订单和入仓码的柜次,禁止借用历史数据`)
context.createdResources.firstlegContainerFeeAllocation = { marker: context.marker, ready: false }
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: false,
deploymentBlocked: true, passed: context.steps.slice(start).filter((step) => step.passed).length,
failed: context.steps.slice(start).filter((step) => !step.passed).length })
return
}
const { loadingPlanId, allocations } = fixture
const customerIds = allocations.map((item) => item.customerId)
const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const feeItems = context.apiSuccess(MODULE, '查询柜次应收费用项目', feeItemsResponse,
evidence(feeItemsResponse), OPTIONS)
const feeItem = rows(feeItems).find((item) => item?.status === 'ACTIVE'
&& (item?.warehouseReceivable === true || item?.pickupReceivable === true))
if (!feeItem?.id) {
blocker(context, '取得可用应收费用项目', '没有 ACTIVE 且启用仓储/提货应收的费用项目,禁止猜测 feeItemId')
return
}
const base = `/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details`
const groupPath = `${base}/allocation-groups`
const fee = { feeItemId: id(feeItem.id), direction: 'RECEIVABLE',
businessDate: new Date().toISOString().slice(0, 10), amount: 120, currency: 'CNY',
remark: `${context.marker}-cross-customer-allocation` }
const initial = await readOwnedRows(context, loadingPlanId, customerIds, '创建前')
const invalidTotal = await post(context, groupPath,
{ fee, allocations: allocations.map((item, index) => ({ ...withoutInbound(item), amount: index === 0 ? '1.00' : item.amount })) }, 'invalid-total')
expectBusinessFailure(context, '分摊金额合计不一致被拒绝', invalidTotal, ['A0409'])
const missingCustomer = await post(context, groupPath,
{ fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), customerId: null } : withoutInbound(item)) }, 'missing-customer')
expectBusinessFailure(context, '分摊客户必填', missingCustomer, ['A0422'])
const missingOrder = await post(context, groupPath,
{ fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), orderId: null } : withoutInbound(item)) }, 'missing-order')
expectBusinessFailure(context, '分摊订单必填', missingOrder, ['A0422'])
const forgedOrder = allocations.map(withoutInbound)
forgedOrder[0] = { ...forgedOrder[0], orderId: '999999999999999999' }
const forgedOrderResponse = await post(context, groupPath, { fee, allocations: forgedOrder }, 'forged-order')
expectBusinessFailure(context, '伪造非柜内订单归属被拒绝', forgedOrderResponse, ['A0409'])
const forgedInbound = allocations.map((item) => ({ ...item }))
forgedInbound[0] = { ...forgedInbound[0], inboundCode: `${context.marker}-FORGED-INBOUND`.slice(0, 64) }
const forgedInboundResponse = await post(context, groupPath, { fee, allocations: forgedInbound }, 'forged-inbound')
expectBusinessFailure(context, '伪造非柜内入仓码被拒绝', forgedInboundResponse, ['A0409'])
const afterNegatives = await readOwnedRows(context, loadingPlanId, customerIds, '负向请求后')
context.assert(MODULE, '负向请求未产生部分分摊行', null,
signature(initial) === signature(afterNegatives),
`before=${signature(initial) || 'empty'}, after=${signature(afterNegatives) || 'empty'}`, OPTIONS)
const createResponse = await post(context, groupPath,
{ fee, allocations: allocations.map(withoutInbound) }, 'create')
const created = context.apiSuccess(MODULE, '不填写入仓码原子创建跨客户分摊组', createResponse,
evidence(createResponse, `loadingPlanId=${loadingPlanId}`), OPTIONS)
const createdRows = rows(created)
const groupId = id(createdRows[0]?.allocationGroupId)
context.assert(MODULE, '分摊组客户订单完整且未伪造入仓码', createResponse,
Boolean(groupId) && createdRows.length === allocations.length
&& new Set(createdRows.map((item) => id(item?.tenantId))).size >= 2
&& createdRows.every((item) => item?.allocationInboundId == null && !item?.allocationInboundCode)
&& money(createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120,
`groupId=${groupId || 'missing'}, rows=${createdRows.length}, customers=${new Set(createdRows.map((item) => id(item?.tenantId))).size}, total=${createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)}`, OPTIONS)
if (!groupId || createdRows.length < 2) return
const first = createdRows[0]
const singleApprove = await post(context,
`${base}/${encodeURIComponent(id(first.id))}/approve?accountId=${encodeURIComponent(id(first.tenantId))}`, null, 'single-approve')
expectBusinessFailure(context, '单行审核不能破坏分摊组一致性', singleApprove, ['A0409'])
const beforeApprove = await readOwnedRows(context, loadingPlanId, customerIds, '单行审核拒绝后')
context.assert(MODULE, '单行审核拒绝后整组仍为待审核', singleApprove,
groupRows(beforeApprove, groupId).length === createdRows.length
&& groupRows(beforeApprove, groupId).every((item) => item?.reviewStatus === 'PENDING'),
`group=${signature(groupRows(beforeApprove, groupId))}`, OPTIONS)
const approveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'approve')
const approved = rows(context.apiSuccess(MODULE, '整组审核原子成功', approveResponse,
evidence(approveResponse, `groupId=${groupId}`), OPTIONS))
context.assert(MODULE, '整组审核后全部行已审核且版本一致', approveResponse,
approved.length === createdRows.length && approved.every((item) => item?.reviewStatus === 'APPROVED'
&& item?.sourceStatus === 'ACTIVE' && Number(item?.sourceVersion) === 1),
`group=${signature(approved)}`, OPTIONS)
const partialAdjustBody = { expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120,
allocations: [{ feeId: id(approved[0]?.id), amount: 120 }] }
const partialAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, partialAdjustBody, 'partial-adjust')
expectBusinessFailure(context, '缺少组成员的调整被拒绝', partialAdjust, ['A0409'])
const afterPartialAdjust = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '部分调整拒绝后'), groupId)
context.assert(MODULE, '部分调整失败后整组零漂移', partialAdjust,
signature(afterPartialAdjust) === signature(approved),
`approved=${signature(approved)}, after=${signature(afterPartialAdjust)}`, OPTIONS)
const adjustedAmounts = approved.map((item, index) => ({ feeId: id(item.id),
amount: index === 0 ? '70.00' : index === 1 ? '50.00' : '0.01' }))
if (adjustedAmounts.length > 2) {
const overflow = (adjustedAmounts.length - 2) * 0.01
adjustedAmounts[1].amount = (50 - overflow).toFixed(2)
}
const adjustResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`,
{ expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'adjust')
const adjusted = rows(context.apiSuccess(MODULE, '整组调整原子成功', adjustResponse,
evidence(adjustResponse, `groupId=${groupId}`), OPTIONS))
context.assert(MODULE, '整组调整后全部行统一升版并待复核', adjustResponse,
adjusted.length === approved.length && adjusted.every((item) => Number(item?.sourceVersion) === 2
&& item?.reviewStatus === 'PENDING' && item?.sourceStatus === 'ACTIVE')
&& money(adjusted.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120,
`group=${signature(adjusted)}`, OPTIONS)
const reapproveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'reapprove')
const reapproved = rows(context.apiSuccess(MODULE, '调整后整组复核原子成功', reapproveResponse,
evidence(reapproveResponse, `groupId=${groupId}`), OPTIONS))
const voidResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/void`,
{ expectedSourceVersion: 2 }, 'void')
context.apiSuccess(MODULE, '整组作废原子成功', voidResponse, evidence(voidResponse, `groupId=${groupId}`), OPTIONS)
const voided = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '整组作废后'), groupId)
context.assert(MODULE, '整组作废后全部行冻结且无活动残留', voidResponse,
reapproved.length === createdRows.length && voided.length === createdRows.length
&& voided.every((item) => item?.sourceStatus === 'VOIDED' && Number(item?.sourceVersion) === 2),
`group=${signature(voided)}`, OPTIONS)
const frozenAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`,
{ expectedSourceVersion: 2, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'frozen-adjust')
expectBusinessFailure(context, '已作废冻结分摊组拒绝继续调整', frozenAdjust, ['A0409'])
const afterFrozen = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '冻结调整拒绝后'), groupId)
context.assert(MODULE, '冻结门禁拒绝后整组零漂移', frozenAdjust,
signature(afterFrozen) === signature(voided),
`voided=${signature(voided)}, after=${signature(afterFrozen)}`, OPTIONS)
const foreignResponse = await context.request('GET',
`${base}?accountId=999999999999999999`)
expectBusinessFailure(context, '非柜内客户不能读取柜次费用', foreignResponse, ['A0404'])
context.createdResources.firstlegContainerFeeAllocation = {
marker: context.marker, ready: true, loadingPlanId, groupId,
customerIds, feeIds: createdRows.map((item) => id(item.id)), finalStatus: 'VOIDED',
}
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 })
}
@@ -828,12 +828,16 @@ export async function run(context) {
if (refreshedInventory?.snapshotToken) {
effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken }
}
} else {
} else if (source?.requireInventoryMutation !== false) {
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')
} else {
context.addStep(MODULE, '非快照专项装柜链不重复制造 A0 库存变化', null, true,
'当前模块作为四期真实业务前置,仅验证正常装柜链;旧 snapshot token A0409 由独立 phase3-loading 专项覆盖',
{ scenario: OPTIONS.scenario })
}
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
@@ -1502,6 +1506,7 @@ export async function run(context) {
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
inventoryMutationVerified, inventoryMutationCleanup,
inventoryMutationRequired: source?.requireInventoryMutation !== false,
skipBatchVerified, partialBatchVerified, dynamicIncrementVerified,
expectedBatchOperationIds, incrementalInboundId,
pdaTaskVersion: Number(stagedTask?.version),
@@ -124,13 +124,17 @@ export async function run(context) {
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
'deployment-blocker')
}
if (fixture?.inventoryMutationVerified !== true
if (fixture?.inventoryMutationRequired !== false && 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')
} else if (fixture?.inventoryMutationRequired === false) {
context.addStep(MODULE, '依赖链不重复执行 A0 旧快照破坏性专项', null, true,
'正常库存快照、原子批次和幂等已验证;A0 换绑与旧 token A0409 仅在显式 phase3-loading 专项运行',
{ scenario: OPTIONS.scenario })
}
if (fixture?.dynamicIncrementVerified !== true) {
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
@@ -105,7 +105,20 @@ async function loginMerchant(context, activeCustomer) {
async function createMerchantProcurement(context, activeCustomer, overseasToken) {
const path = '/api/merchant/v1/last-leg/overseas-procurements'
const payload = procurementPayload(context, activeCustomer)
const response = await context.requestOverseas('POST', path, payload, {}, overseasToken)
let activeToken = overseasToken
let response = await context.requestOverseas('POST', path, payload, {}, activeToken)
// A newly approved customer is projected to the overseas region asynchronously. The
// domestic login can therefore succeed a few seconds before the overseas gateway has
// received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact
// security-projection conflict and always issue a fresh short-lived overseas token.
for (let attempt = 1; attempt <= 12
&& response?.http === 403 && codeOf(response) === 'A0403'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const refreshedToken = await loginMerchant(context, activeCustomer)
if (!refreshedToken) break
activeToken = refreshedToken
response = await context.requestOverseas('POST', path, payload, {}, activeToken)
}
const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS)
const value = summary(detail)
const resource = nonEmpty(value?.orderNo) && longId(value?.customerId)
@@ -119,7 +132,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
`orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS)
if (!valid) return null
const replayResponse = await context.requestOverseas('POST', path, payload, {}, overseasToken)
const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken)
const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS)
const replay = summary(replayDetail)
context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse,
@@ -127,7 +140,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken)
`orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS)
const mismatch = await context.requestOverseas('POST', path,
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, overseasToken)
{ ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken)
expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch)
return { resource, payload }
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { run } from './phase4-overseas-finance-cross-region.mjs'
const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8')
function response(data, code = '200', http = 200, region) {
return {
http,
body: { code, data: region ? { status: '运行中', region } : data },
requestId: `req-${code}-${http}`,
traceId: `trace-${code}-${http}`,
}
}
function fixture() {
const marker = 'ACC-P4-FIN-XREGION'
const calls = []
const steps = []
const commands = new Map()
const fees = new Map()
let sequence = 7000
const businessRows = {
'/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1001' },
'/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1001' },
'/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1001' },
}
const context = {
marker,
steps,
scenarios: [],
calls,
createdResources: {
merchantQualificationFixture: { tenantId: '1001' },
phase4OverseasBusiness: {
purchase: { orderNo: 'PO-1', customerId: '1001' },
truck: { orderNo: 'TK-1', customerId: '1001' },
looseboard: { orderNo: 'LB-1', customerId: '1001' },
},
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
},
async request(method, path, body) {
calls.push({ region: 'CN', method, path, body })
if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') {
return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] })
}
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'CN')
if (path.startsWith('/api/platform/finance/v1/fee-sources?')) {
const query = new URLSearchParams(path.split('?')[1])
const customerId = query.get('customerId')
const keyword = query.get('keyword')
const rows = [...fees.values()].filter((fee) => fee.customerId === customerId
&& (fee.inboundCode || fee.businessNo) === keyword)
.filter((fee) => fee.reviewStatus === 'APPROVED')
.map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG',
sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId }))
return response(rows)
}
throw new Error(`unexpected CN request ${method} ${path}`)
},
async requestOverseas(method, path, body, headers = {}) {
calls.push({ region: 'OVERSEAS', method, path, body, headers })
if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'OVERSEAS')
for (const [base, row] of Object.entries(businessRows)) {
if (method === 'GET' && path.startsWith(`${base}?`)) return response({ records: [row] })
}
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/inbound-plans?')) {
return response({ records: [{ planId: '4001', planNo: 'IP-1', customerId: '2001', inboundId: '4101', inboundCode: 'IN-1' }] })
}
if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/finance-fee-sources?')) return response([])
if (method === 'POST' && path === '/api/platform/v1/last-leg/finance-fee-sources') {
const key = headers['Idempotency-Key']
const hash = JSON.stringify(body)
const existing = commands.get(`CREATE:${key}`)
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
const expectedCustomer = body.businessId === '4001' ? '2001'
: ['3001', '3002', '3003'].includes(String(body.businessId)) ? '1001' : body.customerId
if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400')
const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1,
reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' }
commands.set(`CREATE:${key}`, { hash, fee })
fees.set(fee.sourceFeeId, fee)
return response(fee)
}
const action = path.match(/finance-fee-sources\/(\d+)\/(submit|review)$/)
if (method === 'POST' && action) {
const fee = fees.get(action[1])
const key = body.idempotencyKey
const scope = `${action[2].toUpperCase()}:${key}`
const hash = JSON.stringify(body)
const existing = commands.get(scope)
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
if (action[2] === 'submit') fee.reviewStatus = 'PENDING'
else { fee.reviewStatus = 'APPROVED'; fee.publicationStatus = 'PUBLISHED' }
commands.set(scope, { hash, fee: structuredClone(fee) })
return response(fee)
}
throw new Error(`unexpected OVERSEAS request ${method} ${path}`)
},
apiSuccess(module, name, result, evidence, options) {
const passed = result.http === 200 && String(result.body?.code) === '200'
this.addStep(module, name, result, passed, evidence, options)
return passed ? result.body.data : null
},
assert(module, name, result, passed, evidence, options) {
this.addStep(module, name, result, Boolean(passed), evidence, options)
},
addStep(module, name, result, passed, evidence, options = {}) {
steps.push({ module, name, result, passed: Boolean(passed), evidence,
deploymentBlocker: Boolean(options.deploymentBlocker) })
},
}
return context
}
test('module uses only injected real HTTP clients and never fabricates database/event success', () => {
assert.match(source, /export async function run\(context\)/)
assert.doesNotMatch(source, /process\.env|https?:\/\/|fetch\(|mysql|SELECT\s|INSERT\s|UPDATE\s/i)
assert.doesNotMatch(source, /admin-test-env-acceptance|README\.md/)
for (const token of [
'OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE',
'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE',
'/api/platform/v1/last-leg/finance-fee-sources', '/api/platform/finance/v1/fee-sources',
'/internal/event-bridge/status',
]) assert.ok(source.includes(token), token)
})
test('five fee types traverse Lastleg lifecycle and reach Finance through real HTTP', async () => {
const context = fixture()
await run(context)
const registry = context.createdResources.phase4OverseasFinanceCrossRegion
assert.equal(registry.sources.length, 5)
assert.deepEqual(new Set(registry.sources.map((item) => item.businessType)), new Set([
'OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE',
]))
const warehouseCreate = context.calls.find((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources'
&& call.body?.businessType === 'WAREHOUSE_SERVICE')
assert.ok(warehouseCreate)
for (const field of ['businessId', 'orderId', 'inboundId', 'inboundCode', 'containerId', 'containerBookingId', 'containerNo']) {
assert.equal(warehouseCreate.body[field], null, field)
}
assert.equal(warehouseCreate.body.customerId, '1001')
assert.equal(warehouseCreate.body.warehouseId, '9001')
const creates = context.calls.filter((call) => call.method === 'POST'
&& call.path === '/api/platform/v1/last-leg/finance-fee-sources')
assert.equal(creates.length, 12)
assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed)
assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed)
assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed)
assert.equal(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).length, 5)
assert.ok(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).every((step) => step.passed))
assert.ok(context.steps.find((step) => step.name === '国内 Event Bridge HTTP 能力状态可观察')?.passed)
assert.ok(context.steps.find((step) => step.name === '海外 Event Bridge HTTP 能力状态可观察')?.passed)
assert.deepEqual(context.steps.filter((step) => !step.passed), [])
assert.equal(context.scenarios[0].deploymentBlocked, false)
assert.equal(context.scenarios[0].success, true)
})
@@ -0,0 +1,262 @@
const MODULE = 'phase4-overseas-finance-cross-region'
const SCENARIO = 'phase4-overseas-finance-cross-region-http'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const LASTLEG_BASE = '/api/platform/v1/last-leg/finance-fee-sources'
const FINANCE_BASE = '/api/platform/finance/v1/fee-sources'
const id = (value) => value == null ? '' : String(value)
const codeOf = (response) => String(response?.body?.code ?? 'missing')
const success = (response) => response?.http === 200 && codeOf(response) === '200'
const conflict = (response) => response?.http === 409
|| (response?.http === 200 && ['A0409', '409', 'FIN-IDEM-409'].includes(codeOf(response)))
const rejected = (response) => [400, 403, 404, 409].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
const longId = (value) => /^[1-9]\d*$/.test(id(value))
const rows = (data) => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : []
const trace = (response, extra = '') => `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || response?.body?.requestId || 'missing'}, traceId=${response?.traceId || response?.body?.traceId || 'missing'}`
function blocker(context, name, reason, response = null) {
context.addStep(MODULE, name, response, false, `${reason}; ${trace(response)}`, OPTIONS)
}
async function overseas(context, method, name, path, body, headers = {}) {
const response = await context.requestOverseas(method, path, body, headers)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
async function domestic(context, method, name, path, body) {
const response = await context.request(method, path, body)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
async function poll(action, predicate, attempts = 30) {
let result = null
for (let index = 0; index < attempts; index += 1) {
result = await action()
if (predicate(result)) return { result, attempts: index + 1 }
if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { result, attempts }
}
async function resolveListedBusiness(context, base, resource, customerId, label) {
if (!resource?.orderNo || !customerId) {
blocker(context, `${label}费用业务前置`, `缺少本轮 ${label} orderNo/customerId,禁止借用历史业务`)
return null
}
const path = `${base}?page=1&size=200&customerId=${encodeURIComponent(customerId)}&orderNo=${encodeURIComponent(resource.orderNo)}`
const listed = await overseas(context, 'GET', `回查本轮${label}业务`, path)
const row = rows(listed.data).find((item) => id(item?.orderNo) === id(resource.orderNo)
&& id(item?.customerId) === id(customerId))
context.assert(MODULE, `${label}费用关联真实业务记录`, listed.response,
longId(row?.id) && Boolean(row?.orderNo),
`businessId=${id(row?.id) || 'missing'}, businessNo=${row?.orderNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null
}
async function resolveFixtures(context) {
const business = context.createdResources?.phase4OverseasBusiness
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|| business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId)
const procurement = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/overseas-procurements', business?.purchase, merchantCustomerId, '海外采购')
const truck = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/us-truck-orders', business?.truck, merchantCustomerId, '美国卡车')
const looseboard = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, merchantCustomerId, '本土散板')
const pda = context.createdResources?.phase4OverseasPda
const inboundCustomerId = id(pda?.customerId)
let inbound = null
if (longId(inboundCustomerId)) {
const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
const plan = rows(listed.data).find((item) => longId(item?.planId || item?.id) && item?.planNo)
context.assert(MODULE, '海外入仓费用关联真实计划', listed.response,
Boolean(plan) && longId(plan?.planId || plan?.id),
`planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
if (plan) inbound = {
businessId: id(plan.planId || plan.id), businessNo: plan.planNo, customerId: inboundCustomerId,
inboundId: longId(plan.inboundId) ? id(plan.inboundId) : null,
inboundCode: plan.inboundCode || null,
}
} else blocker(context, '海外入仓费用业务前置', '缺少本轮海外 PDA 客户身份,无法安全选择真实入仓计划')
const warehouseId = id(pda?.overseasWarehouseId || context.createdResources?.phase4OverseasWarehouse?.warehouseId)
const warehouse = longId(merchantCustomerId) && longId(warehouseId)
? { businessId: null, businessNo: `${context.marker}-WAREHOUSE`, customerId: merchantCustomerId, warehouseId }
: null
if (!warehouse) blocker(context, '海外仓服务费业务前置', '缺少本轮客户或真实海外仓 ID,禁止伪造仓库服务费')
return { merchantCustomerId, inbound, procurement, truck, looseboard, warehouse }
}
function feePayload(context, definition, fixture, index) {
return {
customerId: fixture.customerId,
customerName: `${context.marker}-客户`,
sourceType: definition.sourceType,
businessType: definition.businessType,
businessId: fixture.businessId,
businessNo: fixture.businessNo,
orderId: null,
inboundId: fixture.inboundId || null,
inboundCode: fixture.inboundCode || null,
containerId: null,
containerBookingId: null,
containerNo: null,
warehouseId: fixture.warehouseId || null,
feeItemCode: `P4-${definition.businessType}`.slice(0, 64),
feeItemName: `${definition.label}验收费用`,
direction: 'RECEIVABLE',
supplierId: null,
supplierName: null,
supplierCategory: null,
settlementMode: null,
paidAt: null,
paymentMethod: null,
paymentReferenceNo: null,
paymentVoucherFileIds: [],
businessDate: new Date().toISOString().slice(0, 10),
currency: 'CNY',
amount: Number((101 + index + 0.25).toFixed(2)),
}
}
async function createApprove(context, definition, fixture, index) {
if (!fixture) return null
const payload = feePayload(context, definition, fixture, index)
const key = `${context.marker}-${definition.businessType}-fee-create`.slice(0, 128)
const created = await overseas(context, 'POST', `创建${definition.label}费用草稿`, LASTLEG_BASE,
payload, { 'Idempotency-Key': key })
const sourceFeeId = id(created.data?.sourceFeeId)
context.assert(MODULE, `${definition.label}费用草稿字段完整`, created.response,
longId(sourceFeeId) && created.data?.reviewStatus === 'DRAFT'
&& id(created.data?.customerId) === id(fixture.customerId)
&& created.data?.businessType === definition.businessType,
`sourceFeeId=${sourceFeeId || 'missing'}, status=${created.data?.reviewStatus || 'missing'}; ${trace(created.response)}`, OPTIONS)
if (!longId(sourceFeeId)) return null
const replay = await overseas(context, 'POST', `${definition.label}费用创建同参重放`, LASTLEG_BASE,
payload, { 'Idempotency-Key': key })
context.assert(MODULE, `${definition.label}创建重放复用费用且版本不漂移`, replay.response,
id(replay.data?.sourceFeeId) === sourceFeeId && Number(replay.data?.sourceVersion) === 1,
trace(replay.response), OPTIONS)
if (index === 0) {
const mismatch = await context.requestOverseas('POST', LASTLEG_BASE,
{ ...payload, amount: payload.amount + 1 }, { 'Idempotency-Key': key })
context.addStep(MODULE, '费用创建同键异载荷冲突', mismatch, conflict(mismatch), trace(mismatch), OPTIONS)
}
const submitKey = `${context.marker}-${definition.businessType}-fee-submit`.slice(0, 128)
const submitted = await overseas(context, 'POST', `提交${definition.label}费用`,
`${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey: submitKey })
context.assert(MODULE, `${definition.label}费用进入待审核`, submitted.response,
submitted.data?.reviewStatus === 'PENDING', trace(submitted.response), OPTIONS)
const reviewBody = { decision: 'APPROVE', reason: context.marker,
idempotencyKey: `${context.marker}-${definition.businessType}-fee-review`.slice(0, 128) }
const approved = await overseas(context, 'POST', `审核通过${definition.label}费用`,
`${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/review`, reviewBody)
context.assert(MODULE, `${definition.label}费用已发布并触发 Outbox`, approved.response,
approved.data?.reviewStatus === 'APPROVED' && approved.data?.publicationStatus === 'PUBLISHED',
trace(approved.response), OPTIONS)
const reviewReplay = await overseas(context, 'POST', `${definition.label}审核同参重放`,
`${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/review`, reviewBody)
context.assert(MODULE, `${definition.label}审核重放保持发布版本`, reviewReplay.response,
id(reviewReplay.data?.sourceFeeId) === sourceFeeId
&& Number(reviewReplay.data?.sourceVersion) === Number(approved.data?.sourceVersion),
trace(reviewReplay.response), OPTIONS)
return { sourceFeeId, customerId: fixture.customerId, definition, payload }
}
async function verifyCustomerIsolation(context, fixtures, source) {
if (!source || !fixtures.merchantCustomerId) return
const tenants = await domestic(context, 'GET', '查询第二个 ACTIVE 客户用于隔离反证',
'/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
const other = rows(tenants.data).find((item) => longId(item?.id) && id(item.id) !== id(source.customerId))
if (!other) {
blocker(context, '费用客户隔离反证前置', '没有第二个 ACTIVE 客户,无法验证跨客户查询与业务归属拒绝', tenants.response)
return
}
const wrongList = await overseas(context, 'GET', '其他客户查询海外费用来源',
`${LASTLEG_BASE}?customerId=${encodeURIComponent(id(other.id))}&businessNo=${encodeURIComponent(source.payload.businessNo)}`)
context.assert(MODULE, '海外费用列表不会泄露其他客户来源', wrongList.response,
!rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId),
`otherCustomerId=${id(other.id)}, leaked=${rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId)}; ${trace(wrongList.response)}`, OPTIONS)
if (source.payload.businessId) {
const wrongPayload = { ...source.payload, customerId: id(other.id), customerName: other.tenantName || other.name || 'other' }
const wrong = await context.requestOverseas('POST', LASTLEG_BASE, wrongPayload,
{ 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS)
}
}
async function verifyFinanceDelivery(context, sources) {
for (const source of sources) {
const keyword = source.payload.inboundCode || source.payload.businessNo
const path = `${FINANCE_BASE}?page=1&pageSize=200&customerId=${encodeURIComponent(source.customerId)}&keyword=${encodeURIComponent(keyword)}`
const delivered = await poll(async () => {
const response = await context.request('GET', path)
const found = rows(response?.body?.data).find((item) => item?.sourceSystem === 'LASTLEG'
&& id(item?.sourceFeeId) === source.sourceFeeId)
return { response, found }
}, (value) => success(value?.response) && Boolean(value?.found))
context.assert(MODULE, `${source.definition.label}费用经跨区链进入 Finance`, delivered.result?.response,
Boolean(delivered.result?.found)
&& id(delivered.result.found.customerId) === id(source.customerId)
&& delivered.result.found.sourceType === source.definition.sourceType,
`pollRequests=${delivered.attempts}, sourceFeeId=${source.sourceFeeId}, financeFeeSourceId=${id(delivered.result?.found?.feeSourceId) || 'missing'}; ${trace(delivered.result?.response)}`, OPTIONS)
}
}
async function verifyBridgeCapabilities(context) {
const domestic = await context.request('GET', '/internal/event-bridge/status')
context.addStep(MODULE, '国内 Event Bridge HTTP 能力状态可观察', domestic,
(success(domestic) && String(domestic.body?.data?.region).toUpperCase() === 'CN') || domestic.http === 404,
`${domestic.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(domestic)}`, OPTIONS)
const overseas = await context.requestOverseas('GET', '/internal/event-bridge/status')
context.addStep(MODULE, '海外 Event Bridge HTTP 能力状态可观察', overseas,
(success(overseas) && String(overseas.body?.data?.region).toUpperCase() === 'OVERSEAS') || overseas.http === 404,
`${overseas.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(overseas)}`, OPTIONS)
}
export async function run(context) {
const start = context.steps.length
const fixtures = await resolveFixtures(context)
const definitions = [
{ label: '海外入仓', businessType: 'OVERSEAS_INBOUND', sourceType: 'OVERSEAS_INBOUND_FEE', fixture: fixtures.inbound },
{ label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', fixture: fixtures.procurement },
{ label: '美国卡车', businessType: 'US_TRUCK', sourceType: 'OVERSEAS_TRUCK_FEE', fixture: fixtures.truck },
{ label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard },
{ label: '海外仓服务', businessType: 'WAREHOUSE_SERVICE', sourceType: 'OVERSEAS_WAREHOUSE_FEE', fixture: fixtures.warehouse },
]
const sources = []
for (const [index, definition] of definitions.entries()) {
const source = await createApprove(context, definition, definition.fixture, index)
if (source) sources.push(source)
}
context.assert(MODULE, '五类海外费用均创建、提交并审核', null, sources.length === definitions.length,
`created=${sources.length}, expected=${definitions.length}`, OPTIONS)
const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE')
context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null,
Boolean(warehouseSource) && warehouseSource.payload.businessId == null
&& warehouseSource.payload.orderId == null && warehouseSource.payload.inboundId == null
&& warehouseSource.payload.inboundCode == null && warehouseSource.payload.containerId == null
&& warehouseSource.payload.containerBookingId == null && warehouseSource.payload.containerNo == null
&& longId(warehouseSource.payload.warehouseId),
`sourceFeeId=${warehouseSource?.sourceFeeId || 'missing'}, warehouseId=${warehouseSource?.payload?.warehouseId || 'missing'}`, OPTIONS)
await verifyCustomerIsolation(context, fixtures, sources.find((item) => item.payload.businessId))
await verifyFinanceDelivery(context, sources)
await verifyBridgeCapabilities(context)
context.createdResources.phase4OverseasFinanceCrossRegion = {
marker: context.marker,
sources: sources.map((item) => ({ sourceFeeId: item.sourceFeeId, customerId: item.customerId,
businessType: item.definition.businessType, businessNo: item.payload.businessNo })),
observability: 'BUSINESS_HTTP_VERIFIED_EVENT_PERSISTENCE_REQUIRES_AUTHORIZED_DB_EVIDENCE',
}
const steps = context.steps.slice(start)
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}