fix: complete development acceptance paths

This commit is contained in:
2026-09-03 19:10:18 +08:00
parent 4df0ae4811
commit 2063d29d16
13 changed files with 203 additions and 79 deletions
@@ -29,6 +29,7 @@ test('成本与未结清结算单选择严格命中客户金额和可支付状
{ id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName },
], body)?.id, 2)
assert.equal(findOpenSettlement([
{ settlementId: 3, outstandingAmount: 0, status: 'ISSUED' },
{ settlementId: 1, outstandingAmount: 0, status: 'PAID' },
{ settlementId: 2, outstandingAmount: 9, status: 'ISSUED' },
])?.settlementId, 2)
@@ -40,6 +41,7 @@ test('HTTP 模块覆盖直接已付投影、AP 排除、幂等冲突、冻结和
'收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突',
'拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单',
'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs',
'multi-bill-receipt', 'ap-partial-payment', 'settlementKey',
]) assert.ok(source.includes(token), token)
assert.match(source, /fixture\.marker !== context\.marker/)
assert.match(source, /禁止借历史财务单据/)
@@ -22,6 +22,10 @@ function key(context, operation) {
return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function settlementKey(context, operation) {
return `${context.marker}-finance-settlement-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, operation, token = context.token, overseas = false) {
const headers = { 'Idempotency-Key': key(context, operation) }
return overseas
@@ -75,6 +79,7 @@ export function findDirectPaidCost(items, body) {
export function findOpenSettlement(items) {
return rows(items).find((item) => settlementId(item)
&& outstanding(item) > 0
&& !['VOIDED', 'PAID'].includes(id(item?.status))
&& !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null
}
@@ -134,8 +139,11 @@ async function verifyDirectPaidProjection(context, fixture, published) {
let source = null
let costs = []
let summary = null
for (let attempt = 0; attempt < 40; attempt += 1) {
const [sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
let sourcesResponse = null
let costsResponse = null
let summaryResponse = null
for (let attempt = 0; attempt < 120; attempt += 1) {
;[sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`),
context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`),
context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`),
@@ -151,7 +159,7 @@ async function verifyDirectPaidProjection(context, fixture, published) {
context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null,
Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount
&& Number(summary?.paidCost) >= published.body.amount,
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}`, OPTIONS)
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}; sources=${diagnostic(sourcesResponse)}; costs=${diagnostic(costsResponse)}; summary=${diagnostic(summaryResponse)}`, OPTIONS)
const apPoolResponse = await context.request('GET',
`${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
const apListResponse = await context.request('GET',
@@ -164,45 +172,43 @@ async function verifyDirectPaidProjection(context, fixture, published) {
}
async function verifyPaymentIdempotency(context, fixture) {
const ar = findOpenSettlement(fixture.receivableSettlements)
const ap = findOpenSettlement(fixture.payableSettlements)
if (!ar || !ap) {
blocker(context, '取得未结清 AR/AP 用于收付款幂等',
`AR=${settlementId(ar) || 'missing'}, AP=${settlementId(ap) || 'missing'};需在 finance-settlement-full 消费前执行本模块`, null,
'open-finance-settlement-unavailable')
const receivables = rows(fixture.receivableSettlements).filter((item) => settlementId(item) && statementId(item))
const ap = rows(fixture.payableSettlements).find((item) => settlementId(item))
const priorPayment = fixture.payments?.partial
const priorPaymentTransaction = rows(priorPayment?.transactions)[0]
if (receivables.length < 3 || !ap || !fixture.receipts?.multi || !priorPayment || !priorPaymentTransaction) {
blocker(context, '取得前序收付款成功结果用于幂等重放',
`AR=${receivables.length}, AP=${settlementId(ap) || 'missing'}, receipt=${id(fixture.receipts?.multi?.id) || 'missing'}, payment=${settlementId(priorPayment) || 'missing'};需在 finance-settlement-full 后执行本模块`, null,
'completed-finance-payment-fixture-unavailable')
return
}
const arDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ar))}`)
const arDetail = context.apiSuccess(MODULE, '读取未结清 AR 详情用于收款幂等', arDetailResponse,
diagnostic(arDetailResponse), OPTIONS)
const receiptAmount = Math.min(0.01, outstanding(arDetail))
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: receiptAmount,
const allocationAmount = Number(fixture.multiBillAllocationAmount || 1)
const allocations = receivables.slice(0, 3).map((item) => ({ billId: Number(statementId(item)), amount: allocationAmount }))
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: allocationAmount * allocations.length,
currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)],
remark: context.marker, allocations: [{ billId: statementId(ar), amount: receiptAmount }] }
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
const receiptReplay = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
const receiptConflict = await write(context, 'POST', `${FINANCE}/receipts`,
{ ...receiptBody, remark: `${context.marker}-different` }, 'receipt-replay')
remark: context.marker, allocations }
const receiptHeaders = { 'Idempotency-Key': settlementKey(context, 'multi-bill-receipt') }
const receiptReplay = await context.request('POST', `${FINANCE}/receipts`, receiptBody, context.token, receiptHeaders)
const receiptConflict = await context.request('POST', `${FINANCE}/receipts`,
{ ...receiptBody, remark: `${context.marker}-different` }, context.token, receiptHeaders)
context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay,
ok(receipt) && ok(receiptReplay)
&& id(data(receipt)?.receiptId ?? data(receipt)?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
ok(receiptReplay)
&& id(fixture.receipts.multi?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
&& rejected(receiptConflict),
`receipt=${id(data(receipt)?.receiptId ?? data(receipt)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
`receipt=${id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
const apDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}`)
const apDetail = data(apDetailResponse)
const paymentAmount = Math.min(0.01, outstanding(apDetail))
const paymentBody = { transactionDate: today(), amount: paymentAmount, currency: 'CNY', method: 'BANK_TRANSFER',
referenceNo: `${context.marker}-AP-IDEMPOTENT`, voucherFileIds: [id(fixture.voucherFileId)],
expectedVersion: version(apDetail), remark: context.marker }
const paymentBody = { transactionDate: id(priorPaymentTransaction.transactionDate) || today(),
amount: Number(priorPaymentTransaction.amount), currency: 'CNY', method: 'BANK_TRANSFER',
referenceNo: `${context.marker}-AP`, voucherFileIds: [id(fixture.voucherFileId)],
expectedVersion: Math.max(0, version(priorPayment) - 1), remark: context.marker }
const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions`
const payment = await write(context, 'POST', path, paymentBody, 'ap-replay')
const paymentReplay = await write(context, 'POST', path, paymentBody, 'ap-replay')
const paymentConflict = await write(context, 'POST', path,
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, 'ap-replay')
const paymentHeaders = { 'Idempotency-Key': settlementKey(context, 'ap-partial-payment') }
const paymentReplay = await context.request('POST', path, paymentBody, context.token, paymentHeaders)
const paymentConflict = await context.request('POST', path,
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, context.token, paymentHeaders)
context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay,
ok(payment) && ok(paymentReplay) && rejected(paymentConflict)
&& version(data(paymentReplay)) === version(data(payment)),
ok(paymentReplay) && rejected(paymentConflict)
&& version(data(paymentReplay)) >= version(priorPayment),
`settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS)
}
@@ -5,6 +5,26 @@ import { fileURLToPath } from 'node:url'
import { stableLinkSnapshot } from './phase3-loading-model-bridge.mjs'
const source = readFileSync(fileURLToPath(new URL('./phase3-loading-model-bridge.mjs', import.meta.url)), 'utf8')
const unifiedSql = readFileSync(fileURLToPath(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url)), 'utf8')
test('unified migration selects firstleg before creating the loading model bridge', () => {
assert.match(
unifiedSql,
/USE baiyue_firstleg;\s*\/\* Phase 3: link inbound preload plans to one or more container loading plans\. \*\/\s*CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link/,
)
})
test('unified migration converges declaration ledger table comments before final schema validation', () => {
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_event', '[^']*[一-龥][^']*'\)/)
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_snapshot', '[^']*[一-龥][^']*'\)/)
for (const column of ['tenant_id', 'event_id', 'order_id', 'order_version', 'request_hash', 'consumed_at']) {
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_event','${column}','[^']*[一-龥][^']*'\\)`))
}
for (const column of ['tenant_id', 'order_id', 'event_id', 'order_version', 'request_hash', 'customer_remark',
'expected_arrival_at', 'channel_code', 'channel_name', 'cargoes_json', 'changes_json', 'occurred_at', 'created_at', 'updated_at']) {
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_snapshot','${column}','[^']*[一-龥][^']*'\\)`))
}
})
test('bridge acceptance treats an existing pair as a read-only idempotent replay', () => {
assert.match(source, /已由推送建立的同一桥接 pair 重放返回原关联/)
@@ -13,6 +13,29 @@ const PHASE4_READ_CODES = [
'platform:overseas-business:looseboard:list',
]
const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review']
const PHASE5_OPERATOR_CODES = [
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:dispatch:edit',
'platform:overseas-warehouse:dispatch:cancel',
'platform:overseas-warehouse:dispatch:transfer-delivery',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:outbound:create',
'platform:overseas-warehouse:outbound:cancel',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit',
]
const PHASE5_TALLY_CODES = [
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:customer-visit:list',
]
function id(value) { return value == null ? '' : String(value) }
@@ -62,38 +85,41 @@ export async function run(context) {
`permissions=${[...overseasPdaCodes].sort().join(',')}`, OPTIONS)
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
context.assert(MODULE, '操作员具备国内入仓权限与四期十项只读权限', rolesResponse,
context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse,
operatorCodes.has('platform:firstleg:inbound:loading:view')
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
&& operatorCodes.size === 15
&& operatorCodes.size === 29
&& PHASE4_READ_CODES.every((code) => operatorCodes.has(code))
&& PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code))
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
&& !operatorCodes.has('platform:firstleg:pda:assign')
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm')
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse')
&& [...operatorCodes].filter((code) => code.startsWith('platform:overseas-'))
.every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))),
.every((code) => PHASE4_READ_CODES.includes(code) || PHASE5_OPERATOR_CODES.includes(code)),
`permissionCount=${operatorCodes.size}`, OPTIONS)
const tallyCodes = roleCodes('WAREHOUSE_TALLY')
context.assert(MODULE, '理货员可后台维护证据和复核但不能完成装柜', rolesResponse,
context.assert(MODULE, '理货员可维护证据、复核并只读查看海外履约', rolesResponse,
tallyCodes.has('platform:firstleg:inbound:loading:view')
&& tallyCodes.has('platform:firstleg:inbound:loading:edit')
&& tallyCodes.has('platform:firstleg:pda:review')
&& tallyCodes.size === 22
&& tallyCodes.size === 28
&& PHASE4_READ_CODES.every((code) => tallyCodes.has(code))
&& PHASE5_TALLY_CODES.every((code) => tallyCodes.has(code))
&& !tallyCodes.has('platform:firstleg:inbound:loading:complete')
&& !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm')
&& [...tallyCodes].filter((code) => code.startsWith('platform:overseas-'))
.every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))),
.every((code) => PHASE4_READ_CODES.includes(code) || PHASE5_TALLY_CODES.includes(code)),
`permissionCount=${tallyCodes.size}`, OPTIONS)
context.assert(MODULE, '四期list权限同时用于列表与详情且不隐含写权限', rolesResponse,
context.assert(MODULE, '四期只读与五期岗位权限集合严格匹配', rolesResponse,
PHASE4_READ_CODES.every((code) => code.endsWith(':list'))
&& [...operatorCodes, ...tallyCodes]
.filter((code) => code.startsWith('platform:overseas-'))
.every((code) => PHASE4_READ_CODES.includes(code)),
`phase4ReadCodes=${PHASE4_READ_CODES.length}`, OPTIONS)
&& PHASE5_TALLY_CODES.every((code) => code.endsWith(':list'))
&& PHASE5_OPERATOR_CODES.some((code) => PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix)))
&& PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code))
&& PHASE5_TALLY_CODES.every((code) => tallyCodes.has(code)),
`phase4ReadCodes=${PHASE4_READ_CODES.length}, phase5OperatorCodes=${PHASE5_OPERATOR_CODES.length}, phase5TallyCodes=${PHASE5_TALLY_CODES.length}`, OPTIONS)
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
[...operatorCodes, ...domesticPdaCodes, ...overseasPdaCodes, ...tallyCodes]
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8')
const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8')
const phase5Sql = readFileSync(new URL('../../iam/src/main/resources/db/phase5-delivery-permissions.sql', import.meta.url), 'utf8')
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const listCodes = [
@@ -19,19 +20,42 @@ const listCodes = [
'platform:overseas-business:looseboard:list',
]
test('warehouse operator and tally use the exact phase-four list/detail permission set', () => {
test('warehouse operator and tally use the exact phase-four and phase-five permission sets', () => {
for (const code of listCodes) {
assert.match(acceptance, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`))
assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`))
}
assert.match(acceptance, /operatorCodes\.size === 15/)
assert.match(acceptance, /tallyCodes\.size === 22/)
assert.match(acceptance, /operatorCodes\.size === 29/)
assert.match(acceptance, /tallyCodes\.size === 28/)
assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/)
assert.match(iamSql, /assert_phase4_warehouse_role_minimum/)
assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/)
})
test('warehouse roles include the exact phase-five fulfillment responsibilities', () => {
assert.match(acceptance, /PHASE5_OPERATOR_CODES/)
assert.match(acceptance, /PHASE5_TALLY_CODES/)
for (const code of [
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:dispatch:edit',
'platform:overseas-warehouse:dispatch:cancel',
'platform:overseas-warehouse:dispatch:transfer-delivery',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:outbound:create',
'platform:overseas-warehouse:outbound:cancel',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit'
]) {
assert.match(phase5Sql, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
}
})
test('domestic and overseas PDA operators use isolated authorities', () => {
assert.match(acceptance, /domesticPdaCodes\.size === 2/)
assert.match(acceptance, /overseasPdaCodes\.size === 1/)
@@ -24,9 +24,9 @@ function fixture() {
const fees = new Map()
let sequence = 7000
const businessRows = {
'/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1001' },
'/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1001' },
'/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1001' },
'/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1101' },
'/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1201' },
'/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1301' },
}
const context = {
marker,
@@ -36,9 +36,9 @@ function fixture() {
createdResources: {
merchantQualificationFixture: { tenantId: '1001' },
phase4OverseasBusiness: {
purchase: { orderNo: 'PO-1', customerId: '1001' },
truck: { orderNo: 'TK-1', customerId: '1001' },
looseboard: { orderNo: 'LB-1', customerId: '1001' },
purchase: { orderNo: 'PO-1', customerId: '1101' },
truck: { orderNo: 'TK-1', customerId: '1201' },
looseboard: { orderNo: 'LB-1', customerId: '1301' },
},
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
},
@@ -77,7 +77,9 @@ function fixture() {
const existing = commands.get(`CREATE:${key}`)
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
const expectedCustomer = body.businessId === '4001' ? '2001'
: ['3001', '3002', '3003'].includes(String(body.businessId)) ? '1001' : body.customerId
: body.businessId === '3001' ? '1101'
: body.businessId === '3002' ? '1201'
: body.businessId === '3003' ? '1301' : body.customerId
if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400')
const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1,
reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' }
@@ -61,11 +61,14 @@ async function resolveFixtures(context) {
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|| business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId)
const procurement = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/overseas-procurements', business?.purchase, merchantCustomerId, '海外采购')
'/api/platform/v1/last-leg/overseas-procurements', business?.purchase,
id(business?.purchase?.customerId), '海外采购')
const truck = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/us-truck-orders', business?.truck, merchantCustomerId, '美国卡车')
'/api/platform/v1/last-leg/us-truck-orders', business?.truck,
id(business?.truck?.customerId), '美国卡车')
const looseboard = await resolveListedBusiness(context,
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, merchantCustomerId, '本土散板')
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard,
id(business?.looseboard?.customerId), '本土散板')
const pda = context.createdResources?.phase4OverseasPda
const inboundCustomerId = id(pda?.customerId)