This commit is contained in:
2026-08-13 14:37:51 +08:00
parent bc62650ca9
commit 65cd7e0a39
63 changed files with 380 additions and 226 deletions
+29 -4
View File
@@ -1,4 +1,29 @@
# 后端全量回归
# 全量 HTTP 验收脚本索引
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|---|---|---|---|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库和权限数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务和客户侧权限链路 |
| Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 |
执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。
推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。
## 本地质量检查(非全量验收)
以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用:
| 检查 | 入口 |
|---|---|
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
## 后端全量回归
## 入口
@@ -21,7 +46,7 @@
脚本会检查 `pnpm` 和目标应用的 `package.json`;任一命令失败时立即透传退出码,不能同时跳过 Admin 和 Client。
脚本固定运行:
后端脚本固定运行:
```text
mvn -f <仓库根>/pom.xml -pl order,firstleg,iam,gateway,file,warehouse -am test
@@ -65,8 +90,8 @@ Dry-run 只检查根 `pom.xml` 和关键回归测试文件并打印 Maven 命令
正式入口是 `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
.\local-db-full-acceptance.ps1
.\scripts\local-db-full-acceptance.ps1 -DryRun
.\scripts\local-db-full-acceptance.ps1
```
Dry-run 只校验 SQL 输入并打印空库、复跑、两类 picking 旧结构、WAIT_SPLIT/IAM、海外仓旧结构、中断恢复和最终结构的执行顺序,不启动 MySQL、不写库。真实运行每阶段写入 `.codex-tmp/p23-04-acceptance/acceptance-run-*.log`,不使用 `--force`,任一 SQL 错误立即停止并透传退出码,最后执行 `verify-final.sql`。脚本不保存或传入数据库密码,使用本机默认认证环境。
+8 -2
View File
@@ -510,7 +510,10 @@ export class AcceptanceRun {
async preparePhase3ContainerFixture() {
const originalMarker = this.marker
const fixtureMarker = `${originalMarker}-CAB`
// Keep the shared orchestration marker stable. Downstream loading,
// overview and finance modules select fixtures by context.marker; using a
// private -CAB suffix here made the producer invisible to those modules.
const fixtureMarker = originalMarker
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
@@ -525,7 +528,10 @@ export class AcceptanceRun {
await this.executeModule('orders-phase3-container-fixture', () => orders.run(this))
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this))
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
const fixture = this.createdResources.inboundMeasurement
if (!fixture?.inboundId || fixture.finalStatus !== 'APPROVED' || fixture.processStatus !== 'WAIT_SPLIT') {
throw new Error(`独立柜次测量 fixture 未进入 WAIT_SPLIT,禁止回退使用 WAIT_PRICING 订单: processStatus=${fixture?.processStatus || 'missing'}, measurementStatus=${fixture?.finalStatus || 'missing'}`)
}
this.createdResources.orchestrationFixtures = {
...(this.createdResources.orchestrationFixtures || {}),
phase3Container: fixture ? {
@@ -403,6 +403,14 @@ export async function run(context) {
'使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS)
context.createdResources.financeFullFixtures = {
marker: context.marker, customerId,
prerequisites: {
container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0
? { ready: true, category: null }
: { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' },
financeSources: fixtures.length > 0
? { ready: true, category: null }
: { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生可消费 Finance 费用来源' },
},
orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
shipmentId: item.shipmentId ? id(item.shipmentId) : null,
waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId),
@@ -323,20 +323,25 @@ export async function run(context) {
finish(context, start)
return
}
fixture.root.reconciliationPrerequisite = null
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)) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
blocker(context, '客户对账单旧 view 权限兼容阻断',
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据`,
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
'legacy-customer-statement-view-permission', permissionResponse)
finish(context, start)
return
}
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
if (missingPermissions.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
required: missingPermissions, legacy: [] }
blocker(context, 'Finance 写权限门禁',
`缺少权限=${missingPermissions.join(',')}; 对账接口将返回 A0403,禁止继续写入`,
`缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
'missing-finance-write-permissions', permissionResponse)
finish(context, start)
return
@@ -353,6 +358,8 @@ export async function run(context) {
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
if (invalid.length > 0) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
invalidSourceCount: invalid.length }
blocker(context, 'Fixture 来源契约完整',
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
'invalid-finance-fixture-contract')
@@ -364,6 +371,8 @@ export async function run(context) {
&& source.direction === 'RECEIVABLE' && source.currency === 'CNY')
const businessDates = new Set(receivables.map((source) => source.businessDate))
if (receivables.length < 4 || businessDates.size < 2) {
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
receivableCount: receivables.length, businessDateCount: businessDates.size }
blocker(context, '三张 AR 与日期范围 Fixture 门禁',
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
'insufficient-receivable-date-fixture')
@@ -125,6 +125,9 @@ test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', as
assert.equal(empty.gaps.length, 1)
assert.equal(empty.steps[0].passed, false)
assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/)
assert.match(empty.steps[0].evidence, /上游 Finance fixture 前置条件未满足,非结算服务故障/)
assert.match(empty.gaps[0][4], /prerequisite/)
assert.equal(empty.calls.some((call) => call.method === 'POST'), false)
const partial = fakeContext({ receivableSettlements: [
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
@@ -133,4 +136,6 @@ test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', as
assert.equal(partial.calls.length, 0)
assert.equal(partial.gaps.length, 1)
assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/)
assert.match(partial.steps[0].evidence, /receivable-settlement-prerequisite/)
assert.equal(partial.calls.some((call) => call.method === 'POST'), false)
})
@@ -128,9 +128,17 @@ export async function run(context) {
const arSettlementIds = new Set(validReceivables.map(settlementId))
const arStatementIds = new Set(validReceivables.map(statementId))
if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) {
const upstream = fixtures.reconciliationPrerequisite?.ready === false
? fixtures.reconciliationPrerequisite
: Object.values(fixtures.prerequisites || {}).find((item) => item?.ready === false)
const missing = []
if (statements.filter((item) => item.status === 'ISSUED').length < 3) missing.push('reconciliation-issued-statement-prerequisite')
if (validReceivables.length !== 3) missing.push('receivable-settlement-prerequisite')
if (validPayables.length !== 1) missing.push('payable-settlement-prerequisite')
const category = upstream?.category || (missing.length === 1 ? missing[0] : 'upstream-finance-fixture-prerequisite')
gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements',
`需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}`,
'incomplete-settlement-fixture')
`上游 Finance fixture 前置条件未满足,非结算服务故障;需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}; prerequisite=${category}; missing=${missing.join(',') || 'unknown'}`,
category)
return
}
@@ -24,6 +24,19 @@ function validPdf(response) {
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
}
async function waitForCompletedMeasurement(context, measurementPath, attempts = 30) {
let response = null
let measurement = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', measurementPath)
measurement = response?.http === 200 && String(response?.body?.code) === '200'
? response.body.data : null
if (measurement?.processStatus === 'WAIT_SPLIT') return { response, measurement, attempts: attempt }
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response, measurement, attempts }
}
async function provisionWarehouseResources(context, { inboundId, warehouseId, totalVolumeCbm }) {
const palletCount = Math.ceil(Number(totalVolumeCbm) / DEFAULT_PALLET_CAPACITY_CBM)
if (!warehouseId || !Number.isFinite(palletCount) || palletCount < 1) return false
@@ -394,8 +407,13 @@ export async function run(context) {
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
const finalResponse = await context.request('GET', measurementPath)
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse, '', OPTIONS)
// Measurement approval may enqueue pricing/inbound completion. Poll the
// public projection instead of treating the transient WAIT_PRICING state as
// a smart-loading candidate or manufacturing fixture state in the script.
const completed = await waitForCompletedMeasurement(context, measurementPath)
const finalResponse = completed.response
const finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse,
`pollAttempts=${completed.attempts}`, OPTIONS)
context.assert(MODULE, '审核后入库与测量状态闭环且结果未变化', finalResponse,
finalMeasurement?.processStatus === 'WAIT_SPLIT' && finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED'
@@ -407,6 +425,7 @@ export async function run(context) {
context.createdResources.inboundMeasurement = {
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
}
finishScenario(context, start)
}
@@ -34,14 +34,20 @@ async function request(context, method, path, body, headers = {}) {
}
}
function fixture(context) {
return context.createdResources?.orchestrationFixtures?.phase3Container
|| context.createdResources?.inboundMeasurement
|| context.createdResources?.orders
export function selectContainerLoadingFixture(context) {
const candidates = [
context.createdResources?.orchestrationFixtures?.phase3Container,
context.createdResources?.inboundMeasurement,
context.createdResources?.orders,
].filter(Boolean)
// Finance and downstream modules temporarily switch context.marker for
// their own isolated runs. Never select the older CAB fixture merely because
// it is first in the object; ownership is the marker contract.
return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0]
}
export async function run(context) {
const source = fixture(context)
const source = selectContainerLoadingFixture(context)
const tenantId = id(source?.tenantId)
const inboundId = id(source?.inboundId)
if (!tenantId || !inboundId || source?.marker !== context.marker) {
@@ -4,6 +4,7 @@ import test from 'node:test'
import { selectCustomsFlow } from './phase3-customs.mjs'
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
function source(name) {
@@ -126,6 +127,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
test('container loading creates and confirms booking before scoped candidates', () => {
const value = source('./phase3-container-loading.mjs')
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
assertOrder(value, [
"const bookingCreate = await request",
"const submit = await request",
@@ -147,6 +149,33 @@ test('container loading creates and confirms booking before scoped candidates',
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
})
test('container loading selects the current marker instead of a stale CAB fixture', () => {
const selected = selectContainerLoadingFixture({
marker: 'ADMIN-RUN-AUTO1',
createdResources: {
orchestrationFixtures: {
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
},
inboundMeasurement: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-INBOUND' },
orders: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-ORDER-INBOUND' },
},
})
assert.equal(selected.inboundId, 'FINANCE-INBOUND')
assert.equal(selected.marker, 'ADMIN-RUN-AUTO1')
})
test('container loading keeps an unmatched candidate so marker failure stays observable', () => {
const selected = selectContainerLoadingFixture({
marker: 'ADMIN-RUN-AUTO1',
createdResources: {
orchestrationFixtures: {
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
},
},
})
assert.equal(selected.marker, 'ADMIN-RUN-CAB')
})
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
const value = source('./phase3-container-loading.mjs')
assertOrder(value, [
@@ -269,6 +298,20 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
assert.equal(run.marker.includes('-LOAD'), false)
})
test('container fixture keeps the run marker and does not publish incomplete WAIT_PRICING resources', () => {
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /const fixtureMarker = originalMarker/)
assert.match(entry, /fixture\.finalStatus !== 'APPROVED'/)
assert.match(entry, /禁止回退使用 WAIT_PRICING 订单/)
})
test('measurement fixture polls asynchronous completion before exporting loading identity', () => {
const measurement = source('./inbound-measurement.mjs')
assert.match(measurement, /waitForCompletedMeasurement/)
assert.match(measurement, /measurement\?\.processStatus === 'WAIT_SPLIT'/)
assert.match(measurement, /setTimeout\(resolve, 1_000\)/)
})
test('customs and finalize consume the same loading inbound identity', () => {
const loadingFixture = { marker: 'RUN-1', tenantId: 10001, orderId: 101, inboundId: 201 }
const loading = {