完善财务全量验收链路
This commit is contained in:
+9
-2
@@ -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<OrderVolume> 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<OrderVolume> 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)
|
||||
|
||||
+15
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 内部快照服务部署\/契约/)
|
||||
})
|
||||
|
||||
@@ -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/金额')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user