test: use active suppliers for finance fixtures
This commit is contained in:
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { buildContainerCostBody, normalizeFinanceFixtureSources, run } from './finance-full-fixtures.mjs'
|
import { buildContainerCostBody, normalizeFinanceFixtureSources, run,
|
||||||
|
supplierCategoryForFeeItem } from './finance-full-fixtures.mjs'
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
|
||||||
@@ -30,10 +31,13 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs
|
|||||||
assert.match(source, /chargeUnit === 'CBM'/)
|
assert.match(source, /chargeUnit === 'CBM'/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PAYABLE fee selection uses the formal warehousePayable flag without name heuristics', () => {
|
test('PAYABLE fee selection requires the formal flag and a Finance-supported supplier category', () => {
|
||||||
const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee'))
|
const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee'))
|
||||||
assert.match(selector, /item\.warehousePayable === true/)
|
assert.match(selector, /item\.warehousePayable === true/)
|
||||||
assert.doesNotMatch(selector, /BOOKING\|FREIGHT|订舱|海运|运费/)
|
assert.match(selector, /supplierCategoryForFeeItem\(item\)/)
|
||||||
|
assert.equal(supplierCategoryForFeeItem({ feeCode: 'FBA_HEAD_FREIGHT', feeName: '头程运费' }), 'BOOKING')
|
||||||
|
assert.equal(supplierCategoryForFeeItem({ code: 'CUSTOMS_CLEARANCE', name: '报关费' }), 'CUSTOMS')
|
||||||
|
assert.equal(supplierCategoryForFeeItem({ feeCode: 'UNSUPPORTED', feeName: '操作费' }), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('automatic fee fixture completes the reviewed pricing workflow without recalculating', () => {
|
test('automatic fee fixture completes the reviewed pricing workflow without recalculating', () => {
|
||||||
@@ -98,7 +102,8 @@ function hookSet({ missing = false } = {}) {
|
|||||||
createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701',
|
createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701',
|
||||||
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
|
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
|
||||||
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
|
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
|
||||||
loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE' }),
|
loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE',
|
||||||
|
feeCode: direction === 'PAYABLE' ? 'FBA_HEAD_FREIGHT' : 'RECEIVABLE_SERVICE' }),
|
||||||
loadSupplier: async () => missing ? null : ({ id: 'SUPPLIER-1', name: '订舱供应商一', category: 'BOOKING', status: 'ACTIVE' }),
|
loadSupplier: async () => missing ? null : ({ id: 'SUPPLIER-1', name: '订舱供应商一', category: 'BOOKING', status: 'ACTIVE' }),
|
||||||
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
|
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
|
||||||
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
|
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
|
||||||
@@ -134,12 +139,15 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
|
|||||||
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
|
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
|
||||||
assert.equal(fixture.supplier.supplierId, 'SUPPLIER-1')
|
assert.equal(fixture.supplier.supplierId, 'SUPPLIER-1')
|
||||||
assert.equal(fixture.supplier.supplierName, '订舱供应商一')
|
assert.equal(fixture.supplier.supplierName, '订舱供应商一')
|
||||||
|
assert.equal(fixture.supplier.category, 'BOOKING')
|
||||||
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
|
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
|
||||||
assert.ok(Array.isArray(fixture.allocations))
|
assert.ok(Array.isArray(fixture.allocations))
|
||||||
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
|
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
|
||||||
assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2)
|
assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2)
|
||||||
assert.equal(fixture.feeSources.every((item) => item.feeSourceId && item.customerId === fixture.customerId
|
assert.equal(fixture.feeSources.every((item) => item.feeSourceId && item.customerId === fixture.customerId
|
||||||
&& item.businessDate && (item.waybillId || item.shipmentId || item.orderNo) && item.marker === context.marker), true)
|
&& item.businessDate && (item.waybillId || item.shipmentId || item.orderNo) && item.marker === context.marker), true)
|
||||||
|
assert.equal(fixture.feeSources.filter((item) => item.direction === 'PAYABLE').every((item) => item.supplierId
|
||||||
|
&& item.supplierName && item.supplierCategory === fixture.supplier.category), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('run preserves real price-row profiles when creating automatic orders', async () => {
|
test('run preserves real price-row profiles when creating automatic orders', async () => {
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ export function normalizeFinanceFixtureSources(sources, orders, allocations, cus
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supplierCategoryForFeeItem(item) {
|
||||||
|
const value = `${item?.feeCode ?? item?.code ?? ''} ${item?.feeName ?? item?.name ?? ''}`.toUpperCase()
|
||||||
|
if (['TRUCK', 'TRAILER', 'DRAYAGE', '拖车', '卡车'].some((candidate) => value.includes(candidate))) return 'TRUCKING'
|
||||||
|
if (['CUSTOMS', 'CLEARANCE', 'DECLARATION', '报关', '清关'].some((candidate) => value.includes(candidate))) return 'CUSTOMS'
|
||||||
|
if (['CERTIFICATE', 'CERTIFICATION', 'CERT_', '认证', '证书'].some((candidate) => value.includes(candidate))) return 'CERTIFICATE'
|
||||||
|
if (['BOOKING', 'FREIGHT', 'OCEAN', 'SHIPPING', '订舱', '海运', '运费'].some((candidate) => value.includes(candidate))) return 'BOOKING'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function createOrder(context, suffix, pricingProfile = null) {
|
async function createOrder(context, suffix, pricingProfile = null) {
|
||||||
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile)
|
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile)
|
||||||
const originalMarker = context.marker
|
const originalMarker = context.marker
|
||||||
@@ -124,7 +133,7 @@ async function loadFeeItem(context, direction) {
|
|||||||
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
|
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
|
||||||
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
|
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
|
||||||
? item.warehouseReceivable === true
|
? item.warehouseReceivable === true
|
||||||
: item.warehousePayable === true)) || null
|
: item.warehousePayable === true && Boolean(supplierCategoryForFeeItem(item)))) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) {
|
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) {
|
||||||
@@ -218,11 +227,13 @@ async function loadPublishedAutomaticFee(context, resource) {
|
|||||||
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSupplier(context) {
|
async function loadSupplier(context, category) {
|
||||||
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context)
|
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
|
||||||
const response = await context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING')
|
if (!category) return null
|
||||||
const page = context.apiSuccess(MODULE, '查询 Warehouse ACTIVE 订舱供应商', response, 'category=BOOKING', OPTIONS)
|
const path = `/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=${encodeURIComponent(category)}`
|
||||||
return rows(page).find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING') || null
|
const response = await context.request('GET', path)
|
||||||
|
const page = context.apiSuccess(MODULE, '查询 Warehouse ACTIVE 费用类别供应商', response, `category=${category}`, OPTIONS)
|
||||||
|
return rows(page).find((item) => item?.status === 'ACTIVE' && item?.category === category && item?.id && item?.name) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadVoucher(context, customerId) {
|
async function uploadVoucher(context, customerId) {
|
||||||
@@ -370,8 +381,10 @@ export async function run(context) {
|
|||||||
RECEIVABLE: await loadFeeItem(context, 'RECEIVABLE'),
|
RECEIVABLE: await loadFeeItem(context, 'RECEIVABLE'),
|
||||||
PAYABLE: await loadFeeItem(context, 'PAYABLE'),
|
PAYABLE: await loadFeeItem(context, 'PAYABLE'),
|
||||||
}
|
}
|
||||||
const supplier = await loadSupplier(context)
|
const payableSupplierCategory = supplierCategoryForFeeItem(feeItems.PAYABLE)
|
||||||
if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商', '未找到 ACTIVE BOOKING 供应商;不能伪造 supplierId/partnerId')
|
const supplier = await loadSupplier(context, payableSupplierCategory)
|
||||||
|
if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商',
|
||||||
|
`未找到 ACTIVE ${payableSupplierCategory || 'SUPPORTED'} 供应商或匹配正式类别的 PAYABLE 费用项;不能伪造 supplierId/partnerId`)
|
||||||
for (const direction of ['RECEIVABLE', 'PAYABLE']) {
|
for (const direction of ['RECEIVABLE', 'PAYABLE']) {
|
||||||
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
|
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
|
||||||
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
|
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
|
||||||
@@ -400,7 +413,8 @@ export async function run(context) {
|
|||||||
for (const [index, manualResource] of manualResources.entries()) {
|
for (const [index, manualResource] of manualResources.entries()) {
|
||||||
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
|
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
|
||||||
'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier)
|
'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier)
|
||||||
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null })
|
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id),
|
||||||
|
supplierName: supplier.name || null, supplierCategory: supplier.category || payableSupplierCategory })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +440,7 @@ export async function run(context) {
|
|||||||
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
|
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
|
||||||
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
|
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
|
||||||
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null,
|
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null,
|
||||||
|
supplierName: supplier?.name || null, supplierCategory: supplier?.category || payableSupplierCategory,
|
||||||
containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null,
|
containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null,
|
||||||
loadingPlanId: id(container.loadingPlanId) }
|
loadingPlanId: id(container.loadingPlanId) }
|
||||||
} else {
|
} else {
|
||||||
@@ -463,7 +478,9 @@ export async function run(context) {
|
|||||||
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null }))
|
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null }))
|
||||||
const fixtureSources = normalizeFinanceFixtureSources(fixtures, fixtureOrders, allocations, customerId, context.marker)
|
const fixtureSources = normalizeFinanceFixtureSources(fixtures, fixtureOrders, allocations, customerId, context.marker)
|
||||||
const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId
|
const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId
|
||||||
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker)
|
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker
|
||||||
|
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName
|
||||||
|
|| source.supplierCategory !== payableSupplierCategory)))
|
||||||
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
|
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
|
||||||
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
|
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
|
||||||
context.createdResources.financeFullFixtures = {
|
context.createdResources.financeFullFixtures = {
|
||||||
@@ -480,7 +497,8 @@ export async function run(context) {
|
|||||||
feeSources: fixtureSources,
|
feeSources: fixtureSources,
|
||||||
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
|
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
|
||||||
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
|
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
|
||||||
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null } : null,
|
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id),
|
||||||
|
supplierName: supplier.name || null, category: supplier.category || payableSupplierCategory } : null,
|
||||||
voucherFileId, pricingEvidence,
|
voucherFileId, pricingEvidence,
|
||||||
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
|
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { run } from './finance-reconciliation-full.mjs'
|
|||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8')
|
||||||
|
|
||||||
test('Finance pool keeps formal MANUAL_COST and maps container cost by stable container identity', () => {
|
test('Finance AP consumes only published fin_fee_source rows and never substitutes MANUAL_COST IDs', () => {
|
||||||
assert.doesNotMatch(source, /sourceType \?\? ''\) !== 'MANUAL_COST'/)
|
assert.doesNotMatch(source, /sourceType \?\? ''\) !== 'MANUAL_COST'/)
|
||||||
assert.match(source, /\[raw\.containerBookingId, raw\.loadingPlanId\]\.map\(id\)\.includes\(id\(item\?\.containerId\)\)/)
|
assert.doesNotMatch(source, /\[raw\.containerBookingId, raw\.loadingPlanId\]\.map\(id\)\.includes\(id\(item\?\.containerId\)\)/)
|
||||||
|
assert.match(source, /const financeSource = poolBySource\.get\(sourceId\(raw\)\)/)
|
||||||
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
|
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
|
||||||
})
|
})
|
||||||
const financePermissions = [
|
const financePermissions = [
|
||||||
@@ -26,7 +27,8 @@ function ok(data) {
|
|||||||
return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' }
|
return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false, missingSubject = false) {
|
function fakeContext(withFixture = true, permissions = financePermissions, denyFinanceWrites = false,
|
||||||
|
missingSubject = false, omitPayableSource = false) {
|
||||||
const marker = 'FIN-TEST-MARKER'
|
const marker = 'FIN-TEST-MARKER'
|
||||||
const receivables = [
|
const receivables = [
|
||||||
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
|
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
|
||||||
@@ -84,7 +86,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
|||||||
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
|
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
|
||||||
return ok(permissions)
|
return ok(permissions)
|
||||||
}
|
}
|
||||||
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20') {
|
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
|
||||||
return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] })
|
return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] })
|
||||||
}
|
}
|
||||||
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
|
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
|
||||||
@@ -98,7 +100,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
|||||||
requestId: 'request-denied', traceId: 'trace-denied' }
|
requestId: 'request-denied', traceId: 'trace-denied' }
|
||||||
}
|
}
|
||||||
if (method === 'GET' && requestPath.includes('/fee-pool')) {
|
if (method === 'GET' && requestPath.includes('/fee-pool')) {
|
||||||
return ok(feeSources.filter((item) => item.feeSourceId).map((item, index) => {
|
return ok(feeSources.filter((item) => item.feeSourceId && (!omitPayableSource || item.feeSourceId !== '105')).map((item, index) => {
|
||||||
const { feeSourceId, ...rest } = item
|
const { feeSourceId, ...rest } = item
|
||||||
return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId,
|
return { ...rest, poolItemId: `${item.direction}:${1001 + index}`, sourceId: feeSourceId,
|
||||||
reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE' }
|
reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE' }
|
||||||
@@ -219,7 +221,7 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
|
|||||||
assert.match(source, /platform:finance:settlement-ar:list/)
|
assert.match(source, /platform:finance:settlement-ar:list/)
|
||||||
assert.match(source, /platform:finance:settlement-ap:list/)
|
assert.match(source, /platform:finance:settlement-ap:list/)
|
||||||
assert.doesNotMatch(source, /platform:finance:settlement-(ar|ap):view/)
|
assert.doesNotMatch(source, /platform:finance:settlement-(ar|ap):view/)
|
||||||
assert.match(source, /\$\{BASE\}\/accounts\?page=1&pageSize=20/)
|
assert.match(source, /accounts\?page=1&pageSize=20&customerId=/)
|
||||||
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
|
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
|
||||||
assert.match(source, /balance.*recharge.*prepayment/)
|
assert.match(source, /balance.*recharge.*prepayment/)
|
||||||
assert.match(source, /accountReadback/)
|
assert.match(source, /accountReadback/)
|
||||||
@@ -300,6 +302,17 @@ test('来源缺失逐条 customerId 时不得由 fixture 根字段静默补齐',
|
|||||||
assert.equal(context.calls.some((call) => call.method !== 'GET'), false)
|
assert.equal(context.calls.some((call) => call.method !== 'GET'), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('marker-owned PAYABLE 来源未入池时 fail-closed 且不使用 MANUAL_COST 替代', async () => {
|
||||||
|
const context = fakeContext(true, financePermissions, false, false, true)
|
||||||
|
|
||||||
|
await run(context)
|
||||||
|
|
||||||
|
assert.equal(context.steps.some((step) => step.name === '本轮费用来源进入 Finance 池'
|
||||||
|
&& !step.passed && step.deploymentBlocker), true)
|
||||||
|
assert.equal(context.coverageGaps.some((gap) => gap.category === 'unpublished-marker-owned-finance-source'), true)
|
||||||
|
assert.equal(context.calls.some((call) => call.method !== 'GET'), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => {
|
test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => {
|
||||||
const context = fakeContext(true, financePermissions.filter((item) => item !== 'platform:finance:reconciliation:create'))
|
const context = fakeContext(true, financePermissions.filter((item) => item !== 'platform:finance:reconciliation:create'))
|
||||||
await run(context)
|
await run(context)
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ function hasWalletField(value) {
|
|||||||
}
|
}
|
||||||
async function verifyCustomerAccountAndLedger(context, fixture) {
|
async function verifyCustomerAccountAndLedger(context, fixture) {
|
||||||
const customerId = id(fixture.customerId)
|
const customerId = id(fixture.customerId)
|
||||||
const accountResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`)
|
const accountResponse = await context.request('GET',
|
||||||
|
`${BASE}/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`)
|
||||||
const accounts = accountRows(accountResponse)
|
const accounts = accountRows(accountResponse)
|
||||||
const account = accounts.find((item) => accountCustomerId(item) === customerId)
|
const account = accounts.find((item) => accountCustomerId(item) === customerId)
|
||||||
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
|
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
|
||||||
@@ -182,8 +183,6 @@ async function normalizeFixture(context) {
|
|||||||
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
|
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
|
||||||
const attemptedSources = root.feeSources.map((raw) => {
|
const attemptedSources = root.feeSources.map((raw) => {
|
||||||
const financeSource = poolBySource.get(sourceId(raw))
|
const financeSource = poolBySource.get(sourceId(raw))
|
||||||
|| pool.find((item) => String(item?.direction || '').toUpperCase() === 'PAYABLE'
|
|
||||||
&& [raw.containerBookingId, raw.loadingPlanId].map(id).includes(id(item?.containerId)))
|
|
||||||
return {
|
return {
|
||||||
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
|
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
|
||||||
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
||||||
@@ -346,22 +345,32 @@ export async function run(context) {
|
|||||||
finish(context, start)
|
finish(context, start)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (fixture.uncreatedSources.length > 0) {
|
const incompleteContainerSources = fixture.uncreatedSources.filter((source) => isUnboundContainerCost(source.raw))
|
||||||
const evidence = fixture.uncreatedSources.map((source) => {
|
if (incompleteContainerSources.length > 0) {
|
||||||
|
const evidence = incompleteContainerSources.map((source) => {
|
||||||
const raw = source.raw
|
const raw = source.raw
|
||||||
return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || '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('; ')
|
}).join('; ')
|
||||||
context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试',
|
context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试',
|
||||||
`Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`,
|
`Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`,
|
||||||
'incomplete-container-cost-attempt', { marker: context.marker, count: fixture.uncreatedSources.length })
|
'incomplete-container-cost-attempt', { marker: context.marker, count: incompleteContainerSources.length })
|
||||||
|
}
|
||||||
|
const unpublishedSources = fixture.uncreatedSources.filter((source) => !isUnboundContainerCost(source.raw))
|
||||||
|
if (unpublishedSources.length > 0) {
|
||||||
|
blocker(context, '本轮费用来源进入 Finance 池',
|
||||||
|
`等待 Finance fee-pool 后仍有 ${unpublishedSources.length} 条 marker-owned 来源不可见;禁止忽略来源或改用 MANUAL_COST ID`,
|
||||||
|
'unpublished-marker-owned-finance-source')
|
||||||
|
finish(context, start)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|
||||||
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
|
|| !source.businessDate || !source.waybillId || source.marker !== context.marker
|
||||||
|
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName)))
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
|
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
|
||||||
invalidSourceCount: invalid.length }
|
invalidSourceCount: invalid.length }
|
||||||
blocker(context, 'Fixture 来源契约完整',
|
blocker(context, 'Fixture 来源契约完整',
|
||||||
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
|
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker/应付供应商快照`,
|
||||||
'invalid-finance-fixture-contract')
|
'invalid-finance-fixture-contract')
|
||||||
finish(context, start)
|
finish(context, start)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user