Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs
T
2026-10-22 11:56:47 +08:00

279 lines
17 KiB
JavaScript

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.request(method, path, body, undefined, 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
}
export function selectCurrentInboundPlan(plans, pda, marker) {
if (!pda || pda.marker !== marker || !longId(pda.customerId)) return null
const ids = new Set(rows(pda.generatedPlanIds).map(id))
return rows(plans).find(item => ids.has(id(item?.planId || item?.id))
&& id(item?.customerId) === id(pda.customerId) && item?.planNo) || 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,
id(business?.purchase?.customerId), '海外采购')
const truck = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/us-truck-orders', business?.truck,
id(business?.truck?.customerId), '美国卡车')
const looseboard = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard,
id(business?.looseboard?.customerId), '本土散板')
const pda = context.createdResources?.phase4OverseasPda
const inboundCustomerId = id(pda?.customerId)
let inbound = null
if (longId(inboundCustomerId)) {
let listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
let plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
const total = Number(listed.data?.total || 0)
const pageSize = Number(listed.data?.size || 200)
for (let page = 2; !plan && (page - 1) * pageSize < total; page += 1) {
listed = await overseas(context, 'GET', '分页查询本轮入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=${page}&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
if (!rows(listed.data).length) break
}
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.request('POST', LASTLEG_BASE, { ...payload, amount: payload.amount + 1 }, undefined, { '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.request('POST', LASTLEG_BASE, wrongPayload, undefined, { 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) })
context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS)
}
}
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}费用经业务Outbox直接投递进入 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 verifyInternalBoundary(context) {
const response = await context.request('GET', '/internal/finance/event-operations/outbox')
context.assert(MODULE, '统一Gateway不公开内部事件运维接口', response,
[401, 403, 404].includes(response.http), trace(response), OPTIONS)
}
export async function run(context) {
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: '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 },
]
if (fixtures.procurement) {
const definition = { label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE' }
const denied = await context.request('POST', LASTLEG_BASE, feePayload(context,definition,fixtures.procurement,0), undefined,
{ 'Idempotency-Key': `${context.marker}-procurement-bypass-denied`.slice(0,128) })
context.assert(MODULE, '采购费用禁止绕过履约实扣创建通用来源', denied, conflict(denied), trace(denied), OPTIONS)
}
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 verifyInternalBoundary(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 })
}