This commit is contained in:
2026-08-13 14:03:54 +08:00
parent 18cebd3fdd
commit bc62650ca9
11 changed files with 349 additions and 18 deletions
@@ -43,7 +43,7 @@ class AdminPrototypeMenuPermissionSqlContractTest {
.contains("'platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete'");
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
assertThat(unified).contains("<> 2")
assertThat(unified).contains("<> 4")
.contains("platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create'");
}
+4 -3
View File
@@ -24,14 +24,15 @@
脚本固定运行:
```text
mvn -f <仓库根>/pom.xml -pl order,firstleg -am test
mvn -f <仓库根>/pom.xml -pl order,firstleg,iam,gateway,file,warehouse -am test
```
这会先构建公共依赖,再运行 `order` 与 `firstleg` 的全部 Maven 测试。覆盖包括:
这会先构建公共依赖,再运行 `order`、`firstleg`、`iam`、`gateway`、`file` 与 `warehouse` 的全部 Maven 测试。覆盖包括:
- 三期 Ocean Booking Controller/Service/Mapper/SQL 契约;
- 三期 Truck Appointment Controller/Service/Mapper 契约、状态机、乐观锁和幂等操作;
- 二期 `WAIT_SPLIT` 的 firstleg 查询、order 统一查询及筛选语义;
- Warehouse 海外仓阶梯费用 SQL 与 Controller 权限合同;
- 拣货/预装/装柜模型映射与既有 Smart Loading/Split 契约。
## Dry-run
@@ -61,7 +62,7 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
### 本地 MySQL 全量验收
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `7F61C3BAB0D4D49525A30C1E5030DEA4B798F38F6A0287901E6D1D676967DFFA` 和 13712 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
正式入口是 `local-db-full-acceptance.ps1`,只允许连接本机 `127.0.0.1` 或 `localhost`,默认端口 `3306`;测试库 `8.163.30.128` 会立即以退出码 2 拒绝。脚本固定校验统一 SQL SHA-256 `BE7C5C98712D5202F5D187EA9A37C64EAB9F6AA9AE772C89A51F186507B1AE53` 和 14706 行,并静态确认中文注释升级采用校验入队、每表一次 ALTER、结构签名复核、九库覆盖断言及唯一最终 `PASS`。
```powershell
.\local-db-full-acceptance.ps1 -DryRun
+1 -1
View File
@@ -12,7 +12,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -8,11 +8,12 @@ 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:customer-account:list', 'platform:finance:customer-statement:list',
'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-ar:view', 'platform:finance:settlement-ap:create',
'platform:finance:settlement-ap:view',
'platform:finance:settlement-ar:list', 'platform:finance:settlement-ap:create',
'platform:finance:settlement-ap:list',
]
function ok(data) {
@@ -76,6 +77,12 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
if (method === 'GET' && requestPath === '/api/platform/v1/iam/authorization/permissions') {
return ok(permissions)
}
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20') {
return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] })
}
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
return ok({ records: [{ customerId: '11', statementId: '1', remark: marker }] })
}
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) {
return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] })
}
@@ -100,7 +107,8 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
: feeSources.filter((item, index) => body.feeSourceIds.includes(String(1001 + index)))
if (selected.some((item) => item.direction !== 'RECEIVABLE')) return { http: 200, body: { code: 'FIN_409' } }
const statementId = String(++statementSequence)
const statement = { statementId, status: 'DRAFT', version: 0, revisions: [],
const statement = { statementId, customerId: '11', status: 'DRAFT', version: 0, revisions: [],
createdBy: '7001', createdAt: '2026-08-10T09:00:00', updatedBy: '7001', updatedAt: '2026-08-10T09:00:00',
lines: selected.map((item, index) => ({ statementLineId: `${statementId}${index + 1}`,
feeSourceId: String(1001 + feeSources.indexOf(item)), statementAmount: 100 + index, adjustmentReason: null })) }
statements.set(statementId, statement)
@@ -137,6 +145,14 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/payable-settlements?')) {
return ok([...settlements.values()].filter((item) => item.direction === 'PAYABLE'))
}
if (method === 'GET' && requestPath === '/api/platform/finance/v1/statements?customerId=11') {
return ok([...statements.values()].map((item) => ({
statementId: item.statementId, customerId: item.customerId, status: item.status,
createdBy: item.createdBy, createdAt: item.createdAt, updatedBy: item.updatedBy, updatedAt: item.updatedAt,
})))
}
const detailMatch = requestPath.match(/\/reconciliation-statements\/(\d+)$/)
if (method === 'GET' && detailMatch) return ok(responseForStatement(statements.get(detailMatch[1])))
throw new Error(`Unexpected request: ${method} ${requestPath}`)
},
addStep(module, name, response, passed, evidence = '', options = {}) {
@@ -170,6 +186,16 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
assert.match(source, /const groups = \[cutoffSources/)
assert.match(source, /\/payable-settlements/)
assert.match(source, /findSettlementInOfficialList/)
assert.match(source, /verifyIssuedStatementReadback/)
assert.match(source, /\/statements\?customerId=/)
assert.match(source, /\/reconciliation-statements\/\$\{encodeURIComponent\(targetId\)\}/)
assert.match(source, /statementRows\.every/)
assert.match(source, /Array\.isArray\(detail\?\.lines\)/)
assert.match(source, /Array\.isArray\(detail\?\.revisions\)/)
assert.match(source, /\['createdBy', 'createdAt', 'updatedBy', 'updatedAt'\]/)
assert.match(source, /STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'/)
assert.match(source, /LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'/)
assert.match(source, /legacy-customer-statement-view-permission/)
assert.match(source, /`正式\$\{label\}列表包含同一 settlementId`/)
assert.match(source, /findSettlementInOfficialList\(context, 'RECEIVABLE'/)
assert.match(source, /findSettlementInOfficialList\(context, 'PAYABLE'/)
@@ -181,6 +207,15 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
assert.match(source, /http: response\?\.http \?\? null/)
assert.match(source, /requestId: response\?\.requestId \?\? null/)
assert.match(source, /traceId: response\?\.traceId \?\? null/)
assert.match(source, /platform:finance:customer-account:list/)
assert.match(source, /platform:finance:customer-statement:list/)
assert.match(source, /platform:finance:settlement-ar:list/)
assert.match(source, /platform:finance:settlement-ap:list/)
assert.doesNotMatch(source, /platform:finance:settlement-(ar|ap):view/)
assert.match(source, /\$\{BASE\}\/accounts\?page=1&pageSize=20/)
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
assert.match(source, /balance.*recharge.*prepayment/)
assert.match(source, /accountReadback/)
for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) {
assert.match(source, new RegExp(`['"]${action}['"]`))
}
@@ -210,11 +245,24 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张
&& call.path.includes('/receivable-settlements?customerId=11')).length, 3)
assert.equal(context.calls.filter((call) => call.method === 'GET'
&& call.path.includes('/payable-settlements?customerId=11')).length, 1)
assert.equal(context.calls.filter((call) => call.method === 'GET'
&& call.path === '/api/platform/finance/v1/statements?customerId=11').length, 3)
assert.equal(context.calls.filter((call) => call.method === 'GET'
&& /\/reconciliation-statements\/\d+$/.test(call.path)).length, 3)
assert.equal(context.calls.some((call) => call.body?.generationMode === 'BY_CUTOFF'), false)
const failedSteps = context.steps.filter((step) => !step.passed)
assert.equal(failedSteps.length, 0, JSON.stringify(failedSteps, null, 2))
})
test('旧客户对账单 view 权限只能形成兼容 blocker,不能作为列表/详情验收通过依据', async () => {
const context = fakeContext(true, [...financePermissions, 'platform:finance:bill:view'])
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:bill:view')), true)
assert.equal(context.coverageGaps.some((gap) => gap.category === 'legacy-customer-statement-view-permission'), true)
})
test('run 在 fixture 缺失时记录 blocker 且不发送 HTTP 请求', async () => {
const context = fakeContext(false)
await run(context)
@@ -2,16 +2,20 @@ const MODULE = 'finance-reconciliation-full'
const SCENARIO = 'finance-full-reconciliation'
const BASE = '/api/platform/finance/v1'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
const REQUIRED_PERMISSIONS = [
'platform:finance:customer-account:list',
STATEMENT_LIST_PERMISSION,
'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-ar:view',
'platform:finance:settlement-ar:list',
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ap:view',
'platform:finance:settlement-ap:list',
]
function id(value) { return value == null ? '' : String(value) }
@@ -46,6 +50,41 @@ function sameIds(actual, expected) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function present(value) { return value !== undefined && value !== null && String(value).trim() !== '' }
async function verifyIssuedStatementReadback(context, fixture, statement, label) {
const customerId = id(fixture.customerId)
const targetId = statementId(statement)
if (!customerId || !targetId) {
context.addCoverageGap(MODULE, SCENARIO, `${label} 对账单列表/详情查回`,
`必须使用本轮 fixture.customerId 和刚创建且已出账的 statementId;customerId=${customerId || 'missing'}, statementId=${targetId || 'missing'}`,
'missing-issued-statement-readback-fixture', { marker: context.marker, customerId, statementId: targetId })
return
}
const listResponse = await context.request('GET',
`${BASE}/statements?customerId=${encodeURIComponent(customerId)}`)
const summaries = context.apiSuccess(MODULE, `${label} 从正式对账单列表查回`, listResponse,
`customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS)
const statementRows = rows(summaries)
const found = statementRows.find((item) => statementId(item) === targetId)
context.assert(MODULE, `${label} 正式对账单列表仅返回本轮客户且包含同一 statementId`, listResponse,
statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId) && Boolean(found),
`customerId=${customerId}, statementId=${targetId}, rows=${statementRows.length}, found=${statementId(found) || 'missing'}`, OPTIONS)
const detailResponse = await context.request('GET',
`${BASE}/reconciliation-statements/${encodeURIComponent(targetId)}`)
const detail = context.apiSuccess(MODULE, `${label} 从正式对账单详情查回`, detailResponse,
`customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS)
const auditShape = ['createdBy', 'createdAt', 'updatedBy', 'updatedAt']
.every((field) => Object.hasOwn(detail ?? {}, field) && present(detail?.[field]))
context.assert(MODULE, `${label} 对账单详情归属、明细、revision 与审计结构完整`, detailResponse,
id(detail?.customerId) === customerId && statementId(detail) === targetId
&& Array.isArray(detail?.lines) && detail.lines.length > 0
&& Array.isArray(detail?.revisions) && detail.revisions.length > 0
&& auditShape,
`customerId=${id(detail?.customerId) || 'missing'}, statementId=${statementId(detail) || 'missing'}, lines=${list(detail?.lines).length}, revisions=${list(detail?.revisions).length}, auditShape=${auditShape}`, OPTIONS)
}
async function findSettlementInOfficialList(context, direction, customerId, targetId, label) {
const segment = direction === 'RECEIVABLE' ? 'receivable-settlements' : 'payable-settlements'
const response = await context.request('GET',
@@ -82,6 +121,42 @@ function diagnostic(response) {
code: response?.body?.code == null ? null : String(response.body.code),
requestId: response?.requestId ?? null, traceId: response?.traceId ?? null }
}
function accountRows(value) {
const data = value?.body?.data
return rows(data).concat(rows(data?.items))
}
function accountCustomerId(value) { return id(value?.customerId ?? value?.tenantId ?? value?.accountId ?? value?.id) }
function hasWalletField(value) {
return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field))
}
async function verifyCustomerAccountAndLedger(context, fixture) {
const customerId = id(fixture.customerId)
const accountResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`)
const accounts = accountRows(accountResponse)
const account = accounts.find((item) => accountCustomerId(item) === customerId)
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
Boolean(account) && !accounts.some(hasWalletField),
`customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS)
const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`)
const ledger = accountRows(ledgerResponse)
const rowsForCustomer = ledger.filter((item) => {
const itemCustomer = accountCustomerId(item)
return !itemCustomer || itemCustomer === customerId
})
const expectedIds = new Set([
...(fixture.statements || []).map((item) => statementId(item)),
...(fixture.receivableSettlements || []).map((item) => settlementId(item)),
...(fixture.payableSettlements || []).map((item) => settlementId(item)),
].filter(Boolean))
const containsRoundTrip = rowsForCustomer.some((item) => expectedIds.has(statementId(item) || settlementId(item)
|| id(item?.documentId ?? item?.businessId ?? item?.sourceId ?? item?.referenceId))
|| String(item?.remark ?? '').includes(context.marker))
context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse,
rowsForCustomer.length === ledger.length && containsRoundTrip,
`customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS)
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
}
function blocker(context, name, evidence, category, response = null) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, name, evidence, category,
@@ -251,6 +326,13 @@ export async function run(context) {
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))
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
blocker(context, '客户对账单旧 view 权限兼容阻断',
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据`,
'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start)
return
}
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) {
blocker(context, 'Finance 写权限门禁',
@@ -374,6 +456,7 @@ export async function run(context) {
finish(context, start)
return
}
await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`)
issuedStatements.push(issued)
if (index === 0) {
context.assert(MODULE, 'SELECTED 日期范围只包含指定真实日期来源', created.response,
@@ -455,5 +538,6 @@ export async function run(context) {
feeSourceIds: payables.map((source) => source.feeSourceId),
}] : []
fixture.root.sourceMappings = fixture.sourceMappings
await verifyCustomerAccountAndLedger(context, fixture)
finish(context, start)
}
@@ -6,6 +6,10 @@ const response = (data, http = 200, code = '200') => ({ http, body: { code, data
function fakeContext(overrides = {}) {
const balances = new Map([['101', 100], ['102', 100], ['103', 100], ['201', 100]])
const paidAmounts = new Map([['101', 0], ['102', 0], ['103', 0], ['201', 0]])
const statuses = new Map([['101', 'ISSUED'], ['102', 'ISSUED'], ['103', 'ISSUED'], ['201', 'ISSUED']])
const versions = new Map([['101', 1], ['102', 1], ['103', 1], ['201', 1]])
const actionIdempotency = new Map()
const settlementToBill = new Map([['101', '11'], ['102', '12'], ['103', '13']])
const billToSettlement = new Map([...settlementToBill].map(([s, b]) => [b, s]))
const fixture = { marker: 'T1', customerId: '9', voucherFileId: '77',
@@ -33,11 +37,21 @@ function fakeContext(overrides = {}) {
async request(method, path, body, token, headers) {
calls.push({ method, path, body, headers })
const match = path.match(/\/settlements\/(\d+)/)
if (method === 'GET' && match) return response({ settlementId: match[1], outstandingAmount: balances.get(match[1]), status: balances.get(match[1]) === 0 ? 'PAID' : 'PARTIALLY_PAID', version: calls.length })
if (path.endsWith('/receipts')) { for (const allocation of body.allocations) balances.set(billToSettlement.get(String(allocation.billId)), balances.get(billToSettlement.get(String(allocation.billId))) - allocation.amount); return response({ receiptId: calls.length, allocations: body.allocations }) }
if (method === 'GET' && match) return response({ settlementId: match[1], outstandingAmount: balances.get(match[1]), paidAmount: paidAmounts.get(match[1]), status: statuses.get(match[1]), version: versions.get(match[1]) })
if (match && path.endsWith('/actions')) {
const targetId = match[1]; const idem = headers['Idempotency-Key']; const fingerprint = JSON.stringify(body)
const prior = actionIdempotency.get(idem)
if (prior) return prior.fingerprint === fingerprint ? prior.response : response(null, 409, 'FIN_IDEMPOTENCY_CONFLICT')
if (body.action === 'VOID' && paidAmounts.get(targetId) > 0) return response(null, 409, 'FIN_SETTLEMENT_ALREADY_PAID')
if (body.expectedVersion !== versions.get(targetId)) return response(null, 409, 'FIN_VERSION_CONFLICT')
const nextStatus = body.action === 'VOID' ? 'VOIDED' : 'ISSUED'; statuses.set(targetId, nextStatus); versions.set(targetId, versions.get(targetId) + 1)
const result = response({ settlementId: targetId, outstandingAmount: balances.get(targetId), paidAmount: paidAmounts.get(targetId), status: nextStatus, version: versions.get(targetId) })
actionIdempotency.set(idem, { fingerprint, response: result }); return result
}
if (path.endsWith('/receipts')) { for (const allocation of body.allocations) { const targetId = billToSettlement.get(String(allocation.billId)); balances.set(targetId, balances.get(targetId) - allocation.amount); paidAmounts.set(targetId, paidAmounts.get(targetId) + allocation.amount); statuses.set(targetId, balances.get(targetId) === 0 ? 'PAID' : 'PARTIALLY_PAID'); versions.set(targetId, versions.get(targetId) + 1) } return response({ receiptId: calls.length, allocations: body.allocations }) }
if (match && path.endsWith('/transactions')) {
const current = balances.get(match[1]); if (body.amount > current || current === 0) return response(null, 409, 'FIN_409')
balances.set(match[1], current - body.amount); return response({ settlementId: match[1], outstandingAmount: current - body.amount, status: current - body.amount === 0 ? 'PAID' : 'PARTIALLY_PAID', version: calls.length })
balances.set(match[1], current - body.amount); paidAmounts.set(match[1], paidAmounts.get(match[1]) + body.amount); statuses.set(match[1], current - body.amount === 0 ? 'PAID' : 'PARTIALLY_PAID'); versions.set(match[1], versions.get(match[1]) + 1); return response({ settlementId: match[1], outstandingAmount: current - body.amount, paidAmount: paidAmounts.get(match[1]), status: statuses.get(match[1]), version: versions.get(match[1]) })
}
throw new Error(`unexpected ${method} ${path}`)
} }
@@ -64,6 +78,24 @@ test('run 使用 receipts 完成应收部分、全额及多单分配,使用 tr
assert.ok(context.createdResources.financeFullFixtures.payments.final)
assert.equal(context.createdResources.financeFullFixtures.settlementFinalStates.receivable.outstandingAmount, 0)
assert.equal(context.createdResources.financeFullFixtures.settlementFinalStates.payable.outstandingAmount, 0)
const actions = context.calls.filter((call) => call.path.endsWith('/actions'))
assert.deepEqual(actions.slice(0, 4).map((call) => call.body.action), ['VOID', 'VOID', 'REOPEN', 'REOPEN'])
assert.equal(actions[0].headers['Idempotency-Key'], actions[1].headers['Idempotency-Key'])
assert.equal(actions[0].headers['Idempotency-Key'], actions[2].headers['Idempotency-Key'])
assert.notEqual(actions[2].body.reason, actions[0].body.reason)
assert.notEqual(actions[3].headers['Idempotency-Key'], actions[0].headers['Idempotency-Key'])
assert.ok(context.assertions.find((item) => item.name === '已收付款结算单拒绝作废且状态金额不变')?.condition)
})
test('正式结算动作仅使用本轮 fixture,覆盖 VOID/REOPEN、重放、异参冲突和已付款拒绝', async () => {
const context = fakeContext()
await run(context)
const actions = context.calls.filter((call) => call.path.match(/\/settlements\/(101|102|103|201)\/actions$/))
assert.equal(actions.length, 5)
assert.ok(actions.every((call) => ['101', '102', '103', '201'].includes(call.path.match(/\/settlements\/(\d+)\/actions$/)[1])))
for (const name of ['未收付款结算单作废', '结算单作废同键同参幂等重放', '结算单动作同键异参冲突', '已作废结算单重开', '已收付款结算单拒绝作废且状态金额不变']) {
assert.ok(context.assertions.find((item) => item.name === name)?.condition, name)
}
})
test('冻结共享输入使用 4 条 statements、3 条 AR 和 payableSettlements 数组', async () => {
@@ -9,7 +9,9 @@ const write = (context, method, path, body, operation) => context.request(method
const settlementId = (value) => id(value?.settlementId ?? value?.summary?.settlementId ?? value?.id)
const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id)
const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0)
const paid = (value) => Number(value?.paidAmount ?? value?.summary?.paidAmount ?? 0)
const version = (value) => Number(value?.version ?? value?.summary?.version ?? 0)
const status = (value) => id(value?.status ?? value?.summary?.status ?? value?.paymentStatus)
export function values(value) {
if (Array.isArray(value)) return value.filter(Boolean)
@@ -34,6 +36,61 @@ async function detail(context, targetId) {
return { response, value: data(response) }
}
async function verifyVoidReopen(context, item) {
const targetId = settlementId(item)
const before = await detail(context, targetId)
if (!ok(before.response) || status(before.value) !== 'ISSUED' || outstanding(before.value) <= 0 || paid(before.value) !== 0) {
gap(context, '取得未收付款 ISSUED 结算单', 'settlement VOID/REOPEN',
`本轮 settlementId=${targetId || 'missing'} 不满足 ISSUED 且 paidAmount=0;status=${status(before.value)}, paid=${paid(before.value)}`,
'settlement-action-fixture-unavailable')
return null
}
const path = `${BASE}/settlements/${targetId}/actions`
const voidBody = { action: 'VOID', reason: context.marker, expectedVersion: version(before.value) }
const voidedResponse = await write(context, 'POST', path, voidBody, 'settlement-void')
const voided = data(voidedResponse)
context.assert(MODULE, '未收付款结算单作废', voidedResponse, ok(voidedResponse)
&& status(voided) === 'VOIDED' && version(voided) === version(before.value) + 1,
`settlementId=${targetId}, status=${status(voided)}, version=${version(voided)}`, OPTIONS)
const replayResponse = await write(context, 'POST', path, voidBody, 'settlement-void')
const replayed = data(replayResponse)
context.assert(MODULE, '结算单作废同键同参幂等重放', replayResponse, ok(replayResponse)
&& status(replayed) === 'VOIDED' && version(replayed) === version(voided),
`settlementId=${targetId}, version=${version(replayed)}`, OPTIONS)
const conflictResponse = await write(context, 'POST', path,
{ action: 'REOPEN', reason: `${context.marker}-conflict`, expectedVersion: version(voided) }, 'settlement-void')
context.assert(MODULE, '结算单动作同键异参冲突', conflictResponse, !ok(conflictResponse),
`HTTP=${conflictResponse.http}, code=${id(conflictResponse.body?.code)}`, OPTIONS)
const reopenBody = { action: 'REOPEN', reason: context.marker, expectedVersion: version(voided) }
const reopenedResponse = await write(context, 'POST', path, reopenBody, 'settlement-reopen')
const reopened = data(reopenedResponse)
context.assert(MODULE, '已作废结算单重开', reopenedResponse, ok(reopenedResponse)
&& status(reopened) === 'ISSUED' && version(reopened) === version(voided) + 1,
`settlementId=${targetId}, status=${status(reopened)}, version=${version(reopened)}`, OPTIONS)
return reopened
}
async function verifyPaidVoidRejected(context, item) {
const targetId = settlementId(item)
const before = await detail(context, targetId)
if (!ok(before.response) || paid(before.value) <= 0) {
gap(context, '取得已发生收付款结算单', 'paid settlement VOID rejection',
`本轮 settlementId=${targetId || 'missing'} 没有有效收付款;paid=${paid(before.value)}`,
'paid-settlement-fixture-unavailable')
return
}
const response = await write(context, 'POST', `${BASE}/settlements/${targetId}/actions`,
{ action: 'VOID', reason: context.marker, expectedVersion: version(before.value) }, 'paid-settlement-void')
const after = await detail(context, targetId)
context.assert(MODULE, '已收付款结算单拒绝作废且状态金额不变', response, !ok(response) && ok(after.response)
&& status(after.value) === status(before.value) && version(after.value) === version(before.value)
&& paid(after.value) === paid(before.value) && outstanding(after.value) === outstanding(before.value),
`settlementId=${targetId}, status=${status(after.value)}, paid=${paid(after.value)}, outstanding=${outstanding(after.value)}, version=${version(after.value)}`, OPTIONS)
}
async function receive(context, fixtures, item, requestedAmount, operation, label) {
const targetId = settlementId(item)
const billId = statementId(item) || id(item.reconciliationStatementId)
@@ -87,6 +144,11 @@ export async function run(context) {
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)
const actionTarget = validReceivables.find((item) => item.status === 'ISSUED')
?? validPayables.find((item) => item.status === 'ISSUED')
if (!actionTarget) gap(context, '取得结算单动作 Fixture', 'settlement VOID/REOPEN', '本轮没有未收付款且状态 ISSUED 的 AR/AP,禁止借历史单据')
else await verifyVoidReopen(context, actionTarget)
const multiTargets = validReceivables
if (multiTargets.length < 3) gap(context, '取得三张应收结算单', 'multi receipt allocation', '本轮没有三张独占应收单,禁止借历史单据')
else {
@@ -111,6 +173,7 @@ export async function run(context) {
const final = await receive(context, fixtures, primary, outstanding(partial.settlement), 'ar-final-receipt', '应收全额收款完结')
fixtures.receipts = { ...(fixtures.receipts || {}), partial: partial.receipt, final: final.receipt }
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), receivable: final.settlement }
await verifyPaidVoidRejected(context, primary)
}
const payable = validPayables[0]
@@ -0,0 +1,63 @@
const MODULE = 'phase3-container-overview'
const OPTIONS = { scenario: 'phase3-container-overview-read-only', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
function id(value) { return value == null ? '' : String(value) }
export async function run(context) {
const fixture = context.createdResources?.phase3ContainerLoading
const containerId = id(fixture?.containerBookingId)
const planId = id(fixture?.loadingPlanId)
const warehouseId = id(fixture?.warehouseId)
if (!containerId || !planId || !warehouseId || fixture?.marker !== context.marker) {
context.addStep(MODULE, '复用智能装柜产生的货柜 overview fixture', null, false,
`containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
return
}
const overviewResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/overview`)
const overview = context.apiSuccess(MODULE, '查询货柜 overview 聚合详情', overviewResponse,
`containerBookingId=${containerId}`, OPTIONS)
context.assert(MODULE, 'overview 返回货柜身份、版本和时间线', overviewResponse,
id(overview?.container?.id) === containerId
&& Number.isFinite(Number(overview?.container?.version))
&& Array.isArray(overview?.documents)
&& Array.isArray(overview?.exceptionHistory)
&& Array.isArray(overview?.timeline),
`documents=${overview?.documents?.length ?? 'missing'}, timeline=${overview?.timeline?.length ?? 'missing'}`, OPTIONS)
const timeline = Array.isArray(overview?.timeline) ? overview.timeline : []
context.assert(MODULE, 'overview timeline 按 occurredAt 升序返回', overviewResponse,
timeline.every((item, index) => index === 0 || new Date(timeline[index - 1]?.occurredAt) <= new Date(item?.occurredAt)),
`events=${timeline.length}`, OPTIONS)
if (overview?.pda) {
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
.every((field) => overview.pda[field] !== undefined)
&& Array.isArray(overview.pda.photoCounts)
&& Array.isArray(overview.pda.photos)
&& Array.isArray(overview.pda.exceptionReasons),
`taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS)
for (const [index, photo] of overview.pda.photos.entries()) {
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined),
`fileId=${id(photo.fileId)}`, OPTIONS)
}
}
for (const documentType of ['DELIVERY_ORDER', 'PICKUP_ORDER']) {
const response = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/documents/${documentType}`)
const data = context.apiSuccess(MODULE, `查询 ${documentType} 单证`, response, `documentType=${documentType}`, OPTIONS)
context.assert(MODULE, `${documentType} 单证归属货柜`, response,
data == null || id(data.containerId || containerId) === containerId,
`containerBookingId=${containerId}`, OPTIONS)
}
const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`)
context.apiSuccess(MODULE, '查询目的港清关资料', clearance, `containerBookingId=${containerId}`, OPTIONS)
const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`)
context.apiSuccess(MODULE, '查询出口报关资料', customs, `loadingPlanId=${planId}, warehouseId=${warehouseId}`, OPTIONS)
context.addStep(MODULE, '异常标记/取消、单证上传和完成锁定保持写入阻断', null, true,
'默认全量脚本只读;写场景需显式独立授权和一次性测试柜次', OPTIONS)
}
+13 -3
View File
@@ -23,12 +23,22 @@ $requiredTests = @(
"iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java",
"iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java",
"iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java",
"gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java"
"gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java",
"file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java",
"file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java",
"file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java",
"firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java",
"firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java",
"firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java",
"firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java",
"firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java",
"warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java",
"warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java"
)
$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway", "-am", "test")
$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway,file,warehouse", "-am", "test")
Write-Host "Backend regression root: $repoRoot"
Write-Host "Scope: order + firstleg + iam + gateway Maven reactor (includes all module tests)"
Write-Host "Scope: order + firstleg + iam + gateway + file + warehouse Maven reactor (includes all module tests)"
Write-Host "Database/API access: none"
if (-not (Test-Path -LiteralPath $pom)) {
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const scriptsDir = dirname(fileURLToPath(import.meta.url))
const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8')
const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8')
test('backend full regression includes file reactor and critical file/container coverage', () => {
assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/)
for (const required of [
'file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java',
'file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java',
'file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java',
'firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java',
'firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java',
'firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java',
'firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java',
'firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java',
]) assert.match(backendScript, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
})
test('test-environment full acceptance registers container overview module', () => {
const modulePath = resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-overview.mjs')
if (!adminAcceptance.includes('phase3-container-overview') || !existsSync(modulePath)) {
assert.fail('default acceptance module phase3-container-overview is not registered yet')
}
})
+2 -2
View File
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
$ExpectedSha256 = 'CA250057CAB91D248E31BCC5A66CADE72F81EB9906B19B8E3CB09BD60B112F29'
$ExpectedLines = 14708
$ExpectedSha256 = 'BE7C5C98712D5202F5D187EA9A37C64EAB9F6AA9AE772C89A51F186507B1AE53'
$ExpectedLines = 14706
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')