From d2704f68a6d6e70c70f30657e240b145d4c8e062 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 13:24:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=A2=E5=8A=A1=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E9=AA=8C=E6=94=B6=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FinanceCostSnapshotService.java | 11 ++- .../FinanceCostSnapshotServiceTest.java | 15 ++++ scripts/admin-test-env-acceptance.mjs | 68 ++++++++++++++++++- .../finance-full-fixtures-contract.test.mjs | 12 ++++ .../finance-full-fixtures.mjs | 35 ++++++++-- ...ance-reconciliation-full-contract.test.mjs | 50 +++++++++++++- .../finance-reconciliation-full.mjs | 60 +++++++++++++--- .../inbound-measurement.mjs | 21 +++--- scripts/admin-test-env-modules/orders.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 34 ++++++---- .../phase3-producer-chain-contract.test.mjs | 32 ++++++++- 11 files changed, 295 insertions(+), 44 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java index 862a0d4b3..060fefeeb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java @@ -14,10 +14,17 @@ public class FinanceCostSnapshotService { public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = repository; } public ContainerSnapshot container(long customerId, long loadingPlanId) { - if (customerId <= 0 || loadingPlanId <= 0 || repository.plan(loadingPlanId) == null) { + if (customerId <= 0 || loadingPlanId <= 0) { throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); } - List orders = repository.items(loadingPlanId).stream() + // Older Finance deployments called this endpoint with the physical + // container booking ID. Resolve both identities during rollout while + // preserving the caller's ID in the snapshot contract. + var plan = repository.plan(loadingPlanId); + if (plan == null) plan = repository.planByBooking(loadingPlanId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); + long effectivePlanId = plan.getId() == null || plan.getId() <= 0 ? loadingPlanId : plan.getId(); + List orders = repository.items(effectivePlanId).stream() .filter(item -> item.getAccountId() != null && item.getAccountId() == customerId) .filter(item -> item.getOrderId() != null && item.getOrderId() > 0) .filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java index dc6f432c9..78a589326 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java @@ -57,6 +57,21 @@ class FinanceCostSnapshotServiceTest { .hasMessageContaining("柜次不存在"); } + @Test + void resolvesLegacyContainerBookingIdToLoadingPlan() { + var plan = new LoadingPlanEntity(); + plan.setId(88L); + plan.setContainerBookingId(99L); + when(repository.plan(99L)).thenReturn(null); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.items(88L)).thenReturn(List.of(item(10L, 101L, 1001L, "1.25"))); + + var snapshot = service.container(10L, 99L); + + assertThat(snapshot.containerId()).isEqualTo(99L); + assertThat(snapshot.orders()).hasSize(1); + } + private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) { var item = new LoadingPlanItemEntity(); item.setAccountId(customerId); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index cb648472d..52dd8c6cb 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -511,7 +511,8 @@ export class AcceptanceRun { const originalMarker = this.marker const fixtureMarker = `${originalMarker}-LOAD` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] - const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] + const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', + 'phase3LoadingRequireSharedOverflow'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) @@ -522,8 +523,19 @@ export class AcceptanceRun { this.marker = fixtureMarker const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this)) - const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) - await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) + const fixtureInbound = this.createdResources.orders + const warehouseId = String(fixtureInbound?.receivingWarehouseId || '') + this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, + Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`, + { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }) + const disabledLocations = await this.disableAvailableLocationsForA0(warehouseId) + this.context.phase3LoadingRequireSharedOverflow = true + try { + const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) + await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) + } finally { + await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) + } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders const loadingFixture = fixture ? { marker: originalMarker, @@ -552,6 +564,56 @@ export class AcceptanceRun { } } + async disableAvailableLocationsForA0(warehouseId) { + const module = 'phase3-loading-fixture' + const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } + if (!warehouseId) { + this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options) + return [] + } + const pageResponse = await this.request('GET', + `/api/platform/v1/locations?tenantId=0&page=1&size=200&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`) + const page = this.apiSuccess(module, '查询 A0 隔离前全部可用普通库位', pageResponse, '', options) + const records = Array.isArray(page?.records) ? page.records : [] + const locations = records.filter((item) => item.sharedOverflow !== true) + this.assert(module, 'A0 隔离库位分页完整', pageResponse, + Number(page?.total) === records.length && records.length < 200, + `total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options) + const disabled = [] + for (const location of locations) { + const response = await this.request('PATCH', + `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, + { status: 'DISABLED', version: Number(location.version) }) + const success = response?.http === 200 && String(response?.body?.code) === '200' + this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success, + `locationId=${location.id}, beforeVersion=${location.version}`, options) + if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 }) + } + const availableResponse = await this.request('GET', + `/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`) + const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options) + this.assert(module, '测量前 A0 溢出条件成立', availableResponse, + Array.isArray(available) && available.length === 0 && disabled.length === locations.length, + `disabled=${disabled.length}, available=${Array.isArray(available) ? available.length : 'missing'}`, options) + return disabled + } + + async restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) { + const module = 'phase3-loading-fixture' + const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } + for (const location of disabledLocations) { + const response = await this.request('PATCH', + `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, + { status: 'AVAILABLE', version: location.version }) + this.assert(module, `恢复 A0 隔离库位 ${location.code}`, response, + response?.http === 200 && String(response?.body?.code) === '200', + `locationId=${location.id}, restoreVersion=${location.version}`, options) + } + this.createdResources.phase3LoadingA0Isolation = { + warehouseId, restored: disabledLocations.map((item) => item.id), marker: this.marker, + } + } + async run() { const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 101a5e656..fd00a5d87 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -64,6 +64,7 @@ function hookSet({ missing = false } = {}) { loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE' }), loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }), uploadVoucher: async () => missing ? null : 'VOUCHER-1', + verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }), calculateAutomaticFee: async (context, resource) => { automaticIndex += 1 const weight = automaticIndex % 2 === 1 @@ -149,3 +150,14 @@ test('fixture creates four real receivable manual sources before payable source' assert.match(source, /'MANUAL_RECEIVABLE'/) assert.match(source, /dateOffset\(-1 - \(index % 2\)\)/) }) + +test('container cost uses loading plan identity and verifies snapshot fields before Finance', () => { + assert.match(source, /containerId: Number\(container\.loadingPlanId\)/) + assert.match(source, /smart-loading\/plans\/\$\{encodeURIComponent\(container\.loadingPlanId\)\}/) + assert.match(source, /id\(item\?\.accountId\) === id\(customerId\)/) + assert.match(source, /Number\(item\?\.orderId\) > 0/) + assert.match(source, /Number\(item\?\.volumeCbm\) > 0/) + assert.match(source, /snapshotInput\.valid \? await createContainerCost\(context, customerId, container, voucherFileId, snapshotInput\)/) + assert.match(source, /publicSnapshotValid=\$\{snapshotInput\?\.valid === true\}/) + assert.match(source, /Finance -> Firstleg 内部快照服务部署\/契约/) +}) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index e0c0e27e5..a97d00ce0 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -253,16 +253,40 @@ async function createMarkerContainer(context, resource) { } } -async function createContainerCost(context, customerId, container, voucherFileId) { - if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId) +async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { + if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) const body = { customerId: Number(customerId), costType: 'CONTAINER_SHARED', orderId: null, - containerId: Number(container.containerBookingId), expenseName: `${context.marker}-整柜成本`, amount: 300, + // Finance names this field containerId, but its Firstleg cost snapshot contract + // resolves it as the loading plan ID, not the physical container booking ID. + containerId: Number(container.loadingPlanId), expenseName: `${context.marker}-整柜成本`, amount: 300, currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker } const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body) + if (response?.body?.code === 'FIN_400') { + const first = snapshotInput?.items?.[0] + blocker(context, 'Finance 整柜成本请求关联真实装柜计划', + `message=${response.body.message || 'missing'}, loadingPlanId=${container.loadingPlanId || 'missing'}, containerBookingId=${container.containerBookingId || 'missing'}, voucherFileId=${voucherFileId || 'missing'}, publicSnapshotValid=${snapshotInput?.valid === true}, validItems=${snapshotInput?.items?.length || 0}, accountId=${first?.accountId ?? 'missing'}, orderId=${first?.orderId ?? 'missing'}, volumeCbm=${first?.volumeCbm ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'};若 publicSnapshotValid=true,说明 fixture ID 与公开装柜明细有效,阻断位于 Finance -> Firstleg 内部快照服务部署/契约`) + } const cost = context.apiSuccess(MODULE, '创建整柜成本并触发真实体积分摊', response, '', OPTIONS) return cost?.id ? { costId: id(cost.id), allocations: Array.isArray(cost.allocations) ? cost.allocations : null } : null } +async function verifyContainerCostSnapshotInput(context, customerId, container) { + if (context.financeFixtureHooks?.verifyContainerCostSnapshotInput) { + return context.financeFixtureHooks.verifyContainerCostSnapshotInput(context, customerId, container) + } + const path = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(container.loadingPlanId)}` + const response = await context.request('GET', path) + const plan = context.apiSuccess(MODULE, '回读 Finance 柜次体积快照输入', response, + `loadingPlanId=${container.loadingPlanId}`, OPTIONS) + const items = Array.isArray(plan?.items) ? plan.items : [] + const valid = items.filter((item) => id(item?.accountId) === id(customerId) + && Number(item?.orderId) > 0 && Number(item?.volumeCbm) > 0) + context.assert(MODULE, '装柜明细满足 Finance 内部快照筛选条件', response, + plan?.status === 'COMPLETED' && valid.length > 0, + `status=${plan?.status || 'missing'}, items=${items.length}, validItems=${valid.length}, accountId=${items[0]?.accountId ?? 'missing'}, orderId=${items[0]?.orderId ?? 'missing'}, volumeCbm=${items[0]?.volumeCbm ?? 'missing'}`, OPTIONS) + return { valid: plan?.status === 'COMPLETED' && valid.length > 0, items: valid } +} + export async function run(context) { const start = context.steps.length const pricingEvidence = await loadPricingEvidence(context) @@ -354,10 +378,11 @@ export async function run(context) { '未通过 /api/platform/finance/v1/vouchers multipart 上传取得真实 fileId;整柜成本不得模拟成功') let allocations = [] if (containerFixture?.feeSourceId && voucherFileId && supplier?.id) { - const cost = await createContainerCost(context, customerId, container, voucherFileId) + const snapshotInput = await verifyContainerCostSnapshotInput(context, customerId, container) + const cost = snapshotInput.valid ? await createContainerCost(context, customerId, container, voucherFileId, snapshotInput) : null if (Array.isArray(cost?.allocations)) allocations = cost.allocations else blocker(context, '回读整柜成本分摊结果', - `costId=${cost?.costId || 'missing'};Finance cost-entries 创建响应未返回逐订单/运单 allocations,禁止从金额和体积自行伪造持久化结果`) + `costId=${cost?.costId || 'missing'};Finance POST /api/platform/finance/v1/cost-entries 仅返回 ResourceResult,GET cost-entries 仅返回成本摘要,当前没有公开接口回读 fin_cost_allocation;禁止从金额和体积自行伪造持久化结果`) } if (allocations.length === 0) blocker(context, '输出真实订单/运单分摊', '缺少已完成柜次、凭证、供应商或 Finance 分摊响应;不伪造 allocation ID/金额') diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index aa3b68a07..784cf0c93 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -7,12 +7,18 @@ import { run } from './finance-reconciliation-full.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8') +const financePermissions = [ + 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', + 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', + 'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create', + 'platform:finance:settlement-ap:create', +] function ok(data) { return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' } } -function fakeContext(withFixture = true) { +function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false) { const marker = 'FIN-TEST-MARKER' const receivables = [ { feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' }, @@ -26,6 +32,9 @@ function fakeContext(withFixture = true) { { feeSourceId: null, sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', containerBookingId: 'CB-FAILED' }] + feeSources.push({ feeSourceId: '106', sourceType: 'MANUAL', direction: 'PAYABLE', currency: 'CNY', + businessDate: '2026-08-10', billingBasis: 'CONTAINER_SHARED', partnerId: '501', + containerBookingId: 'CB-COST-RESPONSE' }) const fixture = { marker, customerId: '11', orders: feeSources.filter((item) => item.orderId) .map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })), @@ -63,6 +72,13 @@ function fakeContext(withFixture = true) { calls.push({ method, path: requestPath, body, headers }) const idem = headers['Idempotency-Key'] if (idem && idempotency.has(idem)) return ok(structuredClone(idempotency.get(idem))) + if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') { + return ok(permissions) + } + if (denyFinanceWrites && method === 'POST' && requestPath.endsWith('/statements')) { + return { http: 200, body: { code: 'A0403', message: 'Access denied' }, + requestId: 'request-denied', traceId: 'trace-denied' } + } if (method === 'GET' && requestPath.includes('/fee-pool')) { return ok(receivables.map((item) => ({ ...item, reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE' }))) @@ -126,6 +142,10 @@ function fakeContext(withFixture = true) { test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输出', () => { assert.match(source, /generationMode === 'SELECTED'/) + assert.match(source, /platform:finance:reconciliation:create/) + assert.match(source, /\/api\/platform\/v1\/iam\/authorization\/permissions/) + assert.match(source, /rejectedWith\(currencyResponse, 'FIN_400'\)/) + assert.match(source, /stale-finance-token-authorities/) assert.match(source, /businessDates\.size < 2/) assert.match(source, /receivables\.length < 4/) assert.match(source, /const groups = \[cutoffSources/) @@ -156,9 +176,11 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张 assert.equal(fixture.payableSettlements[0].supplierId, '501') assert.equal(fixture.payableSettlements[0].partnerId, '501') assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner') - assert.equal(context.coverageGaps.some((gap) => gap.category === 'uncreated-fee-source-attempt' - && gap.reason.includes('containerBookingId=CB-FAILED')), true) + assert.equal(context.coverageGaps.some((gap) => gap.category === 'incomplete-container-cost-attempt' + && gap.reason.includes('containerBookingId=CB-FAILED') + && gap.reason.includes('containerBookingId=CB-COST-RESPONSE')), true) assert.equal(context.calls.some((call) => call.body?.feeSourceIds?.includes(null)), false) + assert.equal(context.calls.some((call) => call.body?.feeSourceIds?.includes('106')), false) assert.equal(context.calls.some((call) => call.path.includes('/fee-pool') || call.body?.generationMode === 'BY_CUTOFF'), false) assert.equal(context.steps.filter((step) => !step.passed).length, 0) @@ -181,3 +203,25 @@ test('PAYABLE 接受 partnerId 及名称快照,不依赖 SUPPLIER 类型', asy assert.equal(payableRequest.body.supplierId, '501') assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/) }) + +test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => { + const context = fakeContext(true, financePermissions.filter((item) => item !== 'platform:finance:reconciliation:create')) + await run(context) + assert.equal(context.calls.filter((call) => call.method !== 'GET').length, 0) + assert.equal(context.steps.some((step) => !step.passed && step.deploymentBlocker + && step.evidence.includes('platform:finance:reconciliation:create')), true) + assert.equal(context.coverageGaps.some((gap) => gap.category === 'missing-finance-write-permissions'), true) +}) + +test('权限快照已授权但 Finance 返回 A0403 时立即停止并保留诊断', async () => { + const context = fakeContext(true, financePermissions, true) + await run(context) + const writes = context.calls.filter((call) => call.method === 'POST' && call.path.endsWith('/statements')) + assert.equal(writes.length, 1) + assert.equal(writes[0].body.customerId, '11') + assert.equal(writes[0].body.currency, 'USD') + assert.equal(context.coverageGaps.some((gap) => gap.category === 'stale-finance-token-authorities' + && gap.reason.includes('requestId=request-denied') && gap.reason.includes('traceId=trace-denied')), true) + assert.equal(context.calls.some((call) => call.body?.generationMode === 'SELECTED' + && call.body?.currency === 'CNY'), false) +}) diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index de11d3b83..b908d52ae 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -2,6 +2,15 @@ const MODULE = 'finance-reconciliation-full' const SCENARIO = 'finance-full-reconciliation' const BASE = '/api/platform/finance/v1' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const REQUIRED_PERMISSIONS = [ + 'platform:finance:reconciliation:create', + 'platform:finance:reconciliation:review', + 'platform:finance:reconciliation:issue', + 'platform:finance:reconciliation:adjust', + 'platform:finance:reconciliation:void', + 'platform:finance:settlement-ar:create', + 'platform:finance:settlement-ap:create', +] function id(value) { return value == null ? '' : String(value) } function list(value) { return Array.isArray(value) ? value : [] } @@ -11,8 +20,15 @@ function settlementId(value) { return id(value?.settlementId ?? value?.id) } function sourceId(value) { return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id) } function sourceDate(value) { return value?.businessDate ?? value?.billingDate ?? value?.feeDate ?? '' } function sourceWaybill(value) { return id(value?.waybillId ?? value?.shipmentId ?? value?.orderId ?? value?.inboundId ?? value?.sourceNo) } +function isUnboundContainerCost(raw) { + return raw?.sourceType === 'MANUAL' && raw?.billingBasis === 'CONTAINER_SHARED' + && !sourceWaybill(raw) +} function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) } -function apiRejected(response) { return response?.http === 200 && response?.body && String(response.body.code) !== '200' } +function rejectedWith(response, code) { + return response?.http === 200 && String(response?.body?.code) === code +} +function accessDenied(response) { return rejectedWith(response, 'A0403') } function sameIds(actual, expected) { const left = [...new Set(actual)].sort() const right = [...new Set(expected)].sort() @@ -65,10 +81,11 @@ function normalizeFixture(context) { supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null, } }) + const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId || isUnboundContainerCost(source.raw)) return { root, customerId, - sources: attemptedSources.filter((source) => source.feeSourceId), - uncreatedSources: attemptedSources.filter((source) => !source.feeSourceId), + sources: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)), + uncreatedSources, } } @@ -182,14 +199,25 @@ export async function run(context) { finish(context, start) return } + const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions') + const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS) + const granted = new Set(list(permissions).map(String)) + const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission)) + if (missingPermissions.length > 0) { + blocker(context, 'Finance 写权限门禁', + `缺少权限=${missingPermissions.join(',')}; 对账接口将返回 A0403,禁止继续写入`, + 'missing-finance-write-permissions', permissionResponse) + finish(context, start) + return + } if (fixture.uncreatedSources.length > 0) { const evidence = fixture.uncreatedSources.map((source) => { const raw = source.raw - return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}` + return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}` }).join('; ') - context.addCoverageGap(MODULE, SCENARIO, '忽略未创建的费用来源尝试', - `Finance 未返回 feeSourceId,不得作为费用来源消费:${evidence}`, - 'uncreated-fee-source-attempt', { marker: context.marker, count: fixture.uncreatedSources.length }) + context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试', + `Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`, + 'incomplete-container-cost-attempt', { marker: context.marker, count: fixture.uncreatedSources.length }) } const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId || !source.businessDate || !source.waybillId || source.marker !== context.marker) @@ -219,7 +247,7 @@ export async function run(context) { feeSourceIds: [foreign.feeSourceId], periodStart: foreign.businessDate, periodEnd: foreign.businessDate, remark: `${context.marker}-foreign`, }, 'cross-customer-rejected') - context.assert(MODULE, 'SELECTED 拒绝混入其他客户来源', response, apiRejected(response), + context.assert(MODULE, 'SELECTED 拒绝混入其他客户来源', response, rejectedWith(response, 'FIN_409'), `code=${response.body?.code ?? 'missing'}`, OPTIONS) } else { context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation', @@ -231,7 +259,14 @@ export async function run(context) { feeSourceIds: receivables.map((source) => source.feeSourceId), periodStart: allPeriod.start, periodEnd: allPeriod.end, remark: `${context.marker}-usd`, }, 'currency-rejected') - context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, apiRejected(currencyResponse), + if (accessDenied(currencyResponse)) { + blocker(context, 'Finance 对账写权限与当前令牌不一致', + `POST ${BASE}/statements 返回 A0403 Access denied;customerId=${fixture.customerId}, currency=USD, requestId=${currencyResponse.requestId ?? 'null'}, traceId=${currencyResponse.traceId ?? 'null'};有效权限查询与 Finance JWT authority 不一致,禁止继续写入`, + 'stale-finance-token-authorities', currencyResponse) + finish(context, start) + return + } + context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, rejectedWith(currencyResponse, 'FIN_400'), `code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS) const cutoffDate = [...businessDates].sort()[0] @@ -259,6 +294,13 @@ export async function run(context) { for (let index = 0; index < groups.length; index += 1) { const created = await createStatement(context, groups[index], `selected-${index + 1}`, `创建第 ${index + 1} 张独立 SELECTED 对账单`) + if (accessDenied(created.response)) { + blocker(context, `第 ${index + 1} 张 SELECTED 写权限`, + `POST ${BASE}/statements 返回 A0403 Access denied;customerId=${created.body.customerId}, currency=${created.body.currency}, sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};请求符合 DTO,但当前令牌缺少 platform:finance:reconciliation:create authority`, + 'finance-reconciliation-create-forbidden', created.response) + finish(context, start) + return + } context.assert(MODULE, `第 ${index + 1} 张仅使用分组来源`, created.response, created.statement && sameIds(lineSourceIds(created.statement), groups[index].map((source) => source.feeSourceId)), `expected=${groups[index].length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 8648bce08..61d26bdcf 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -39,15 +39,18 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, to const palletId = asId(context.apiSuccess(MODULE, `创建测量占用托盘 ${index}/${palletCount}`, palletResponse, `warehouseId=${warehouseId}`, OPTIONS)) - const locationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', { - warehouseId, zoneCode: `E2E-${String(context.marker).slice(-8)}`, zoneName: '自动化验收区', - locationCode: `E2E-${resourceSuffix}`, locationName: `测量验收库位-${index}`, - locationType: 'PALLET', maxWeightKg: 1000, status: 'AVAILABLE', - remark: `${context.marker}-INBOUND-${inboundId}`, - }) - const locationId = asId(context.apiSuccess(MODULE, `创建测量占用库位 ${index}/${palletCount}`, - locationResponse, `warehouseId=${warehouseId}`, OPTIONS)) - if (!palletId || !locationId) return false + let locationId = null + if (context.context?.phase3LoadingRequireSharedOverflow !== true) { + const locationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', { + warehouseId, zoneCode: `E2E-${String(context.marker).slice(-8)}`, zoneName: '自动化验收区', + locationCode: `E2E-${resourceSuffix}`, locationName: `测量验收库位-${index}`, + locationType: 'PALLET', maxWeightKg: 1000, status: 'AVAILABLE', + remark: `${context.marker}-INBOUND-${inboundId}`, + }) + locationId = asId(context.apiSuccess(MODULE, `创建测量占用库位 ${index}/${palletCount}`, + locationResponse, `warehouseId=${warehouseId}`, OPTIONS)) + } + if (!palletId || (context.context?.phase3LoadingRequireSharedOverflow !== true && !locationId)) return false resources.push({ palletId, locationId }) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index d1faa2a1f..e735e6437 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -249,6 +249,7 @@ export async function run(context) { context.createdResources.orders = { marker: context.marker, tenantId, orderId, productImageFileId, boxTypes: BOXES.map((box) => box.boxType), inboundId: null, + receivingWarehouseId: id(master.receiving.id), } const replayResponse = await request(context, 'POST', createPath, payload, merchantToken, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 36b90b28b..587a65dc0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -85,6 +85,13 @@ function operationsSignature(operations) { }))) } +function taskItemsSignature(task) { + return JSON.stringify((task?.items || []).map((item) => ({ + id: id(item?.id), status: item?.itemStatus || null, + picked: Number(item?.pickedPackageCount), version: Number(item?.version), + }))) +} + export async function run(context) { const source = fixture(context) const tenantId = id(source?.tenantId) @@ -300,16 +307,12 @@ export async function run(context) { const afterInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) const afterInvalidOperations = context.apiSuccess(MODULE, '非法混合批次后回查 PDA 操作历史', afterInvalidOperationsResponse, '', OPTIONS) - const itemSignature = (task) => JSON.stringify((task?.items || []).map((item) => ({ - id: id(item?.id), status: item?.itemStatus || null, picked: Number(item?.pickedPackageCount), - version: Number(item?.version), - }))) context.assert(MODULE, '合法明细加非法明细失败后任务、全部明细和操作历史零写入', unchangedAfterInvalidResponse, unchangedAfterInvalid?.taskStatus === beforeInvalidTask?.taskStatus && Number(unchangedAfterInvalid?.version) === Number(beforeInvalidTask?.version) - && itemSignature(unchangedAfterInvalid) === itemSignature(beforeInvalidTask) + && taskItemsSignature(unchangedAfterInvalid) === taskItemsSignature(beforeInvalidTask) && operationsSignature(afterInvalidOperations) === operationsSignature(beforeInvalidOperations), - `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${itemSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) + `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) const merchantLogin = await context.request('POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, @@ -414,15 +417,11 @@ export async function run(context) { const afterStaleOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) const afterStaleOperations = context.apiSuccess(MODULE, '旧库存快照冲突后回查 PDA 操作历史', afterStaleOperationsResponse, '', OPTIONS) - const beforeItem = beforeMutationTask?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) - const afterItem = afterStaleTask?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) context.assert(MODULE, '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', afterStaleTaskResponse, Number(afterStaleTask?.version) === Number(beforeMutationTask?.version) - && afterItem?.itemStatus === beforeItem?.itemStatus - && Number(afterItem?.pickedPackageCount) === Number(beforeItem?.pickedPackageCount) - && Number(afterItem?.version) === Number(beforeItem?.version) + && taskItemsSignature(afterStaleTask) === taskItemsSignature(beforeMutationTask) && operationsSignature(afterStaleOperations) === operationsSignature(beforeMutationOperations), - `taskVersion=${afterStaleTask?.version ?? 'missing'}, itemStatus=${afterItem?.itemStatus || 'missing'}, picked=${afterItem?.pickedPackageCount ?? 'missing'}, operations=${afterStaleOperations?.length ?? 'missing'}`, OPTIONS) + `taskVersion=${afterStaleTask?.version ?? 'missing'}, items=${taskItemsSignature(afterStaleTask)}, operations=${afterStaleOperations?.length ?? 'missing'}`, OPTIONS) const refreshedInventoryResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) @@ -464,6 +463,17 @@ export async function run(context) { atomicBatchReplayVerified, `HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS) + const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS) + context.assert(MODULE, '合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW', completionOperationsResponse, + Array.isArray(completionOperations) + && completionOperations.some((item) => item.actionType === 'CLAIM' + && id(item.operatorId) && Boolean(item.createdAt)) + && completionOperations.some((item) => item.actionType === 'BATCH' + && id(item.operatorId) && Boolean(item.createdAt)) + && !completionOperations.some((item) => item.actionType === 'REVIEW'), + `actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) + const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS) context.assert(MODULE, '首笔真实拣货触发装柜状态并由单明细批次直接完成', pickedPlanResponse, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 91ad95d11..6c90a63a6 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -138,10 +138,40 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera '非法混合批次后回查 PDA 操作历史', '合法明细加非法明细失败后任务、全部明细和操作历史零写入', ]) - assert.match(loading, /itemSignature\(unchangedAfterInvalid\) === itemSignature\(beforeInvalidTask\)/) + assert.match(loading, /taskItemsSignature\(unchangedAfterInvalid\) === taskItemsSignature\(beforeInvalidTask\)/) assert.match(loading, /operationsSignature\(afterInvalidOperations\) === operationsSignature\(beforeInvalidOperations\)/) }) +test('phase3 loading proves stale inventory zero-write for all items and completion audit', () => { + const loading = source('./phase3-loading.mjs') + assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) + assertOrder(loading, [ + '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', + '库存变化后刷新 PDA 实时库存快照', + '原子提交柜次 PDA 整批拣货', + 'PDA 原子批次同参同键重放返回同一终态', + '回查原子 Batch 完成审计', + '合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW', + ]) +}) + +test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { + const entry = source('../admin-test-env-acceptance.mjs') + const measurement = source('./inbound-measurement.mjs') + const orders = source('./orders.mjs') + assertOrder(entry, [ + 'disableAvailableLocationsForA0(warehouseId)', + 'this.context.phase3LoadingRequireSharedOverflow = true', + "executeModule('inbound-measurement-phase3-loading-fixture'", + 'restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)', + ]) + assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/) + assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/) + assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/) + assert.match(measurement, /phase3LoadingRequireSharedOverflow !== true/) + assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/) +}) + test('customs provisions released booking and completed truck for the selected loading container', () => { const value = source('./phase3-customs.mjs') assertOrder(value, [