优化
This commit is contained in:
+29
-4
@@ -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`。脚本不保存或传入数据库密码,使用本机默认认证环境。
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user