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 }) }