diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 60e026f90..0ea05d28c 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -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'"); } diff --git a/scripts/README.md b/scripts/README.md index 64b712a82..f6baba0f7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index c296f7a69..24410adf1 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 68876aa97..2118c41b3 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 9a4e4ac68..2544c6b94 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -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) } diff --git a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs index 9e4c975a3..09175f3fb 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs @@ -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 () => { diff --git a/scripts/admin-test-env-modules/finance-settlement-full.mjs b/scripts/admin-test-env-modules/finance-settlement-full.mjs index dc887cae9..c73ff788e 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full.mjs @@ -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] diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs new file mode 100644 index 000000000..2105b8c87 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -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) +} diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index 49dbbb562..30e640204 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -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)) { diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs new file mode 100644 index 000000000..882134ce2 --- /dev/null +++ b/scripts/full-regression-entry-contract.test.mjs @@ -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') + } +}) diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 4b1f9fde1..8bee9dc85 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -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.')