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:
@@ -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 }
|
||||
|
||||
+163
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user