优化
This commit is contained in:
+3
-1
@@ -51,7 +51,9 @@ node scripts/acceptance-overview.mjs --report reports/<明确的一次验收报
|
||||
|
||||
总览显式读取一份报告,不遍历历史目录挑选绿灯。`FAILED` 表示有失败、fatal 或阻断缺口;`INCOMPLETE` 表示有跳过、非阻断缺口、计划模块未出现或仅准备/局部模式;`RECORDED_CHECKS_PASSED` 只表示所记录检查通过,**不是当前版本全量通过**。旧报告没有执行计划时显示 `UNKNOWN_LEGACY_REPORT`,不反推范围。报告损坏或格式不支持时退出1,不自动重跑;正常分析退出0,即使报告失败也不作为 CI 放行命令。
|
||||
|
||||
新 Admin 报告的 `executionScope` 保存申请模块、依赖展开后的计划、当轮默认范围及模式限制;这里只记录计划,不以注册即视为已执行。总览将有请求失败与无请求失败分开统计,后者包含模块退出/前置不足,不能把失败条数直接当作独立 Bug 数。缺少模块断言也仅表示缺少证据,不猜测未执行原因。
|
||||
新 Admin 报告的 `executionScope` 保存申请模块、依赖展开后的计划、当轮默认范围、未选择的专项及模式限制,并分别列出 `completedModules`、`skippedModules`、`failedModules`、`unexecutedModules` 和 `unverifiedModules`。模块实际成功且有非零断言才计为完成;`planCompleted` 不代表全部业务覆盖。`fullCoverage` 仅在默认计划全部实际完成、结果成功、没有覆盖缺口/跳过且不是准备或局部模式时为 true;`coverageStatus` 区分 `FAILED`、`PARTIAL`、`FULL`,清理结束后重新计算。总览将有请求失败与无请求失败分开统计,后者包含模块退出/前置不足,不能把失败条数直接当作独立 Bug 数。
|
||||
|
||||
Admin 固定资源模式对客户、系统、公共主档、通知、价格簿及海外主档执行 `READ_ONLY` 查询核验,写入生命周期仍保留 coverage gap;只有纯写入边界专项整体跳过。Client 补查固定客户企业资料、资质、地址及既有角色、成员和权限,原写入/隔离缺口仍明确跳过。两端均不因只读通过宣称完整生命周期已验收。默认 Admin 计划还包含 `mixed-container-receiving`,放在主业务与财务链之后,以独立上下文和资源区执行混柜、私人地址全收及短少确认,避免覆盖主链订单证据;灰度范围限制不变。
|
||||
|
||||
最新部署与已验/未验状态仍只维护在外层 `docs/测试环境单机部署与配置契约.md`;本索引不复制每日进度。总览不校验实际部署版本、不读取环境文件,执行前仍须经正式入口校验凭据和环境。直接离线测试为 `acceptance-overview.test.mjs`。
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
|
||||
export function describeHomepageBusinessCoverage() {
|
||||
const scope = describeAcceptanceScope()
|
||||
return [
|
||||
{ title: '头程下单', route: '/orders/first-leg/create', modules: ['orders', 'phase5-overseas-fulfillment'],
|
||||
{ title: '头程下单', route: '/orders/first-leg/create', modules: ['orders', 'phase5-overseas-fulfillment', 'mixed-container-receiving'],
|
||||
additionalModules: ['phase5-root-fulfillment', 'dropship-pallet-matrix'],
|
||||
covered: '默认头程至海外履约主链;拆分根单、一件代发矩阵为可选专项',
|
||||
remaining: '各订单类型、税制及根/余量分支未由默认链全部执行' },
|
||||
covered: '默认头程至海外履约主链,以及混柜收货、私人地址全收与短少确认;拆分根单和其他一件代发矩阵为可选专项',
|
||||
remaining: '全部税制、根/余量及一件代发出库矩阵未由默认链全部执行' },
|
||||
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
|
||||
covered: '退货预报、审核、签收、合箱、换标、客户确认及换标后出库',
|
||||
remaining: '换标、客户确认及换标后出库已接入默认后续模块;固定客户跨客户边界仍缺资源,真实HTTP待验' },
|
||||
@@ -53,16 +53,21 @@ export function summarizeReport(report) {
|
||||
// 历史报告不包含计划信息,不能从断言数量或模块名字推定完整执行。
|
||||
const scopeKnown = Array.isArray(scope?.plannedModules) && scope.plannedModules.length > 0
|
||||
&& Array.isArray(scope.defaultModules) && scope.defaultModules.length > 0
|
||||
const observedModules = new Set(steps.map((step) => step.module))
|
||||
const observedModules = new Set(Array.isArray(report.modules)
|
||||
? report.modules.filter(module => !module.skipped && module.success === true && Number(module.passed) > 0).map(module => module.name)
|
||||
: steps.map((step) => step.module))
|
||||
const missingModules = scopeKnown ? scope.plannedModules.filter((name) => !observedModules.has(name)) : []
|
||||
const limited = Boolean(scope?.preparationOnly || scope?.returnMainChainOnly || report.fullAcceptance === false)
|
||||
const failedResult = Boolean(report.fatal) || report.success === false || failed.length > 0 || blockers.length > 0
|
||||
|| Boolean(scope?.stoppedByRequest) || (report.deploymentBlockers || []).length > 0
|
||||
|| (report.modules || []).some(module => !module.skipped && (module.success === false || module.fatal))
|
||||
return {
|
||||
result: failedResult ? 'FAILED' : skipped.length || skippedModules || gaps.length || missingModules.length || limited ? 'INCOMPLETE' : 'RECORDED_CHECKS_PASSED',
|
||||
passed: steps.filter((step) => step.passed && !step.skipped).length,
|
||||
failed: failed.length,
|
||||
skipped: skipped.length,
|
||||
skippedModules,
|
||||
readOnlyModules: (report.modules || []).filter(module => module.coverageMode === 'READ_ONLY').map(module => module.name),
|
||||
blockingGaps: blockers.length,
|
||||
nonBlockingGaps: gaps.length - blockers.length,
|
||||
failedModules: [...new Set(failed.map((step) => step.module).filter(Boolean))],
|
||||
|
||||
@@ -47,10 +47,17 @@ test('注册表返回独立副本,默认与可选不重复', () => {
|
||||
assert.ok(scope.defaultModules.includes('appointment-work'))
|
||||
assert.ok(scope.defaultModules.includes('phase4-overseas-return-followups'))
|
||||
assert.ok(scope.optionalModules.every((name) => !scope.defaultModules.includes(name)))
|
||||
assert.ok(scope.fixedResourceSkippedModules.includes('customers'))
|
||||
assert.ok(scope.defaultModules.includes('mixed-container-receiving'))
|
||||
assert.equal(scope.defaultModules.at(-1), 'mixed-container-receiving')
|
||||
assert.equal(scope.defaultModules.filter(name => name === 'mixed-container-receiving').length, 1)
|
||||
assert.ok(scope.fixedResourceReadOnlyModules.includes('customers'))
|
||||
assert.ok(!scope.fixedResourceSkippedModules.includes('customers'))
|
||||
assert.ok(scope.fixedResourceSkippedModules.includes('supplemental-boundaries'))
|
||||
assert.ok(!scope.fixedResourceSkippedModules.includes('suppliers'))
|
||||
scope.fixedResourceSkippedModules.length = 0
|
||||
assert.ok(describeAcceptanceScope().fixedResourceSkippedModules.length > 0)
|
||||
scope.fixedResourceReadOnlyModules.length = 0
|
||||
assert.ok(describeAcceptanceScope().fixedResourceReadOnlyModules.length > 0)
|
||||
scope.defaultModules.length = 0
|
||||
assert.ok(describeAcceptanceScope().defaultModules.length > 0)
|
||||
})
|
||||
@@ -87,3 +94,24 @@ test('准备数据和局部模式不冒充完整验收,空报告拒绝', () =>
|
||||
assert.equal(summarizeReport({ fullAcceptance: false, steps: [{ passed: true }] }).result, 'INCOMPLETE')
|
||||
assert.throws(() => summarizeReport({ success: true, steps: [] }), /steps/)
|
||||
})
|
||||
|
||||
test('混柜的内部步骤按实际模块完成记录归属,不因步骤标签不同而判漏跑', () => {
|
||||
const report = { success: true, steps: [{ module: 'dropship-pallet-matrix', passed: true }],
|
||||
modules: [{ name: 'mixed-container-receiving', success: true, passed: 1, failed: 0 }],
|
||||
executionScope: { plannedModules: ['mixed-container-receiving'], defaultModules: ['mixed-container-receiving'] } }
|
||||
assert.equal(summarizeReport(report).result, 'RECORDED_CHECKS_PASSED')
|
||||
assert.deepEqual(summarizeReport(report).missingPlannedModules, [])
|
||||
report.modules[0].passed = 0
|
||||
assert.equal(summarizeReport(report).result, 'INCOMPLETE')
|
||||
})
|
||||
|
||||
test('模块退出或清理阻断不能被成功字段掩盖,只读通过单独显示', () => {
|
||||
const report = { success: true, steps: [{ module: 'system', passed: true }],
|
||||
modules: [{ name: 'system', success: true, passed: 1, coverageMode: 'READ_ONLY' }] }
|
||||
assert.deepEqual(summarizeReport(report).readOnlyModules, ['system'])
|
||||
report.modules[0].success = false
|
||||
assert.equal(summarizeReport(report).result, 'FAILED')
|
||||
report.modules[0].success = true
|
||||
report.deploymentBlockers = [{ module: 'cleanup' }]
|
||||
assert.equal(summarizeReport(report).result, 'FAILED')
|
||||
})
|
||||
|
||||
@@ -5,17 +5,17 @@ import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
import { normalizeFixedCustomerId, requireFixedSecondaryCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
|
||||
import { FIXED_RESOURCE_READONLY_MODULES, runFixedResourceReadOnly } from './admin-test-env-modules/fixed-resource-readonly.mjs'
|
||||
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', '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-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-return-followups', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
|
||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', '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-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-return-followups', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics', 'mixed-container-receiving']
|
||||
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
|
||||
'manual-preload-push-fixture', 'manual-preload-push-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['customers', 'supplemental-boundaries', 'system', 'master-data',
|
||||
'notifications', 'pricing', 'phase4-overseas-master-data'])
|
||||
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['supplemental-boundaries'])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
|
||||
@@ -295,6 +295,7 @@ export function describeAcceptanceScope() {
|
||||
defaultModules: [...DEFAULT_MODULES],
|
||||
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
|
||||
fixedResourceSkippedModules: [...FIXED_RESOURCE_SKIPPED_MODULES],
|
||||
fixedResourceReadOnlyModules: [...FIXED_RESOURCE_READONLY_MODULES],
|
||||
}
|
||||
}
|
||||
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
|
||||
@@ -675,6 +676,33 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps =
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一规则用于正常结束及 finally 清理之后,覆盖率不能脱离实际执行结果。 */
|
||||
export function finalizeAcceptanceReport(report) {
|
||||
const steps = report.steps || [], modules = report.modules || [], gaps = report.coverageGaps || []
|
||||
const scope = report.executionScope
|
||||
const planned = [...new Set(scope.plannedModules)]
|
||||
const rows = name => modules.filter(module => module.name === name)
|
||||
scope.completedModules = planned.filter(name => rows(name).length > 0 && rows(name).every(module =>
|
||||
!module.skipped && module.success === true && Number(module.passed) > 0 && Number(module.failed) === 0))
|
||||
scope.skippedModules = planned.filter(name => rows(name).some(module => module.skipped))
|
||||
scope.failedModules = planned.filter(name => rows(name).some(module => !module.skipped
|
||||
&& (module.success === false || module.fatal || Number(module.failed) > 0)))
|
||||
scope.unexecutedModules = planned.filter(name => rows(name).length === 0)
|
||||
scope.unverifiedModules = planned.filter(name => !scope.completedModules.includes(name)
|
||||
&& !scope.skippedModules.includes(name) && !scope.failedModules.includes(name))
|
||||
scope.defaultPlanSelected = scope.defaultModules.length > 0 && scope.defaultModules.every(name => planned.includes(name))
|
||||
scope.planCompleted = planned.length > 0 && scope.completedModules.length === planned.length
|
||||
report.summary = buildAcceptanceSummary(steps, modules, gaps)
|
||||
report.success = !report.fatal && !scope.stoppedByRequest && planned.length > 0
|
||||
&& report.summary.failed === 0 && !gaps.some(gap => gap.blocking || gap.deploymentBlocker)
|
||||
&& !(report.deploymentBlockers || []).length && !scope.failedModules.length && !scope.unverifiedModules.length
|
||||
report.fullCoverage = report.success && scope.planCompleted && scope.defaultPlanSelected
|
||||
&& !scope.preparationOnly && !scope.returnMainChainOnly && report.fullAcceptance !== false
|
||||
&& gaps.length === 0 && !steps.some(step => step.skipped)
|
||||
report.coverageStatus = !report.success ? 'FAILED' : report.fullCoverage ? 'FULL' : 'PARTIAL'
|
||||
return report
|
||||
}
|
||||
|
||||
export class AcceptanceRun {
|
||||
constructor(options) {
|
||||
assertGrayExecutionScope(options)
|
||||
@@ -885,13 +913,35 @@ export class AcceptanceRun {
|
||||
reason: '固定资源模式排除生命周期;不计为验收通过' })
|
||||
return
|
||||
}
|
||||
const readOnly = Boolean(this.options.fixedCustomerId && FIXED_RESOURCE_READONLY_MODULES.includes(name))
|
||||
const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null
|
||||
try { await handler() } catch (error) {
|
||||
try {
|
||||
if (readOnly) await runFixedResourceReadOnly(this, name)
|
||||
else await handler()
|
||||
} catch (error) {
|
||||
fatal = error instanceof Error ? error.message : String(error)
|
||||
this.addStep(name, '模块执行', null, false, fatal)
|
||||
}
|
||||
const steps = this.steps.slice(start)
|
||||
this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal })
|
||||
this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal,
|
||||
...(readOnly ? { coverageMode: 'READ_ONLY', lifecycleSkipped: true } : {}) })
|
||||
}
|
||||
|
||||
async runIsolatedModule(name, handler) {
|
||||
const child = Object.create(this)
|
||||
child.options = { ...this.options }
|
||||
child.context = { ...this.context }
|
||||
child.createdResources = {}
|
||||
child.phase4ProducerAttempts = new Map()
|
||||
child.marker = `${this.marker}-MIXED`
|
||||
try { await handler(child) }
|
||||
finally {
|
||||
for (const field of ['token', 'refreshToken', 'expiresAt']) {
|
||||
if (Object.hasOwn(child, field)) this[field] = child[field]
|
||||
}
|
||||
this.createdResources.isolatedModules ??= {}
|
||||
this.createdResources.isolatedModules[name] = child.createdResources
|
||||
}
|
||||
}
|
||||
|
||||
/** 在模块边界结束,随后仍由main的finally恢复故障注入配置并写出失败报告。 */
|
||||
@@ -2040,7 +2090,9 @@ export class AcceptanceRun {
|
||||
try {
|
||||
const extension = await import(new URL(`./admin-test-env-modules/${module}.mjs`, import.meta.url))
|
||||
if (typeof extension.run !== 'function') throw new Error('模块必须导出 async function run(context)')
|
||||
handler = () => extension.run(this)
|
||||
handler = module === 'mixed-container-receiving'
|
||||
? () => this.runIsolatedModule(module, child => extension.run(child))
|
||||
: () => extension.run(this)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`)
|
||||
@@ -2067,7 +2119,7 @@ export class AcceptanceRun {
|
||||
}
|
||||
const laterOverseasModule = this.options.modules.slice(moduleIndex + 1)
|
||||
.some((name) => name.startsWith('phase4-overseas-'))
|
||||
if (module === 'system' && laterOverseasModule) {
|
||||
if (module === 'system' && laterOverseasModule && !this.options.fixedCustomerId) {
|
||||
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
|
||||
if (refreshed) {
|
||||
const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
|
||||
@@ -2105,15 +2157,14 @@ export class AcceptanceRun {
|
||||
this.addStep('report-security', '报告敏感信息扫描', null, !sensitive,
|
||||
sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段')
|
||||
|
||||
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
|
||||
return {
|
||||
return finalizeAcceptanceReport({
|
||||
reportName: this.options.prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker,
|
||||
environment: this.options.environment || 'test', gateway: this.options.gateway,
|
||||
executionScope: {
|
||||
requestedModules: [...(this.options.requestedModules || [])],
|
||||
plannedModules: [...this.options.modules],
|
||||
defaultModules: [...DEFAULT_MODULES],
|
||||
defaultPlanSelected: DEFAULT_MODULES.every((name) => this.options.modules.includes(name)),
|
||||
notSelectedModules: [...AVAILABLE_MODULES].filter(name => !this.options.modules.includes(name)),
|
||||
stoppedByRequest: this.stopRequested === true,
|
||||
preparationOnly: this.options.prepareDomesticPdaH5 === true || this.options.prepareDropshipManualCount === true || this.options.prepareMeasurementReview === true,
|
||||
returnMainChainOnly: this.options.returnMainChainOnly === true,
|
||||
@@ -2121,11 +2172,9 @@ export class AcceptanceRun {
|
||||
pageAcceptance: 'NOT_VERIFIED',
|
||||
},
|
||||
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
|
||||
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
|
||||
fullCoverage: this.coverageGaps.length === 0 && !this.modules.some(module => module.skipped),
|
||||
fatal: this.fatal,
|
||||
...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}),
|
||||
...(this.options.prepareMeasurementReview ? { fullAcceptance: false, purpose: 'MEASUREMENT_REVIEW_UI_FIXTURE' } : {}),
|
||||
summary,
|
||||
modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers,
|
||||
coverageGaps: this.coverageGaps,
|
||||
createdResources: this.createdResources,
|
||||
@@ -2136,7 +2185,7 @@ export class AcceptanceRun {
|
||||
],
|
||||
securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。',
|
||||
steps: this.steps,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2208,8 +2257,7 @@ async function main() {
|
||||
catch (error) { run.addStep('phase4-cross-region-events', '故障验收后必须恢复原服务配置与健康状态', null,
|
||||
false, String(error.message), { scenario: 'direct-delivery-replay', deploymentBlocker: true }) }
|
||||
}
|
||||
report.summary = buildAcceptanceSummary(run.steps, run.modules, run.coverageGaps)
|
||||
report.success = report.fatal === null && report.summary.failed === 0
|
||||
finalizeAcceptanceReport(report)
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
console.log(`REPORT=${outputPath}`)
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
|
||||
|
||||
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。业务订单与实物履约证据仍会新增。
|
||||
固定客户模式下,`fixed-resource-readonly.mjs` 为 `customers`、`system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 执行既有客户、初始化状态、权限/角色/账号、仓库、通知模板/任务、价格簿与海外主档的只读核验;记录 `coverageMode=READ_ONLY` 和 `lifecycleSkipped=true`,写入生命周期继续列为 coverage gap,不以查询通过代替写入验收。离线边界由 `fixed-resource-readonly-contract.test.mjs` 验证。纯写入边界 `supplemental-boundaries` 仍整模块 skipped。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。
|
||||
|
||||
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
|
||||
|
||||
@@ -264,7 +264,7 @@ export async function run(context) {
|
||||
|
||||
混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。
|
||||
|
||||
`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
|
||||
`mixed-container-receiving.mjs` 已接入默认全量,也可通过总入口 `--modules mixed-container-receiving` 定向执行,依赖客户资质准备。总入口使用独立 marker、上下文和资源区,报告保存在 `createdResources.isolatedModules["mixed-container-receiving"].dropshipPalletMatrix`,不覆盖主链订单/财务证据;内部直接调用的资源结构不变。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。内部助手使用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。
|
||||
|
||||
一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
import { FIXED_RESOURCE_READONLY_MODULES } from './fixed-resource-readonly.mjs'
|
||||
|
||||
function runner(mutate = value => value) {
|
||||
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
|
||||
const calls = []
|
||||
context.request = async (method, path) => {
|
||||
calls.push({ method, path })
|
||||
assert.equal(method, 'GET', '固定资源只读检查不得写入或创建资源')
|
||||
let value = path.includes('?page=') ? { page: 1, size: 20, total: 0, records: [] } : []
|
||||
if (path.endsWith('/tenants/123')) value = { id: '123', status: 'ACTIVE' }
|
||||
if (path.endsWith('/dimension-initialization')) value = { status: 'READY' }
|
||||
if (path.endsWith('/permissions')) value = [{ id: '1', permissionCode: 'platform:customer:list' }]
|
||||
if (path.endsWith('/roles')) value = [{ id: '2', roleCode: 'EXISTING_ROLE' }]
|
||||
if (path.includes('/iam/users?')) value.records = [{ membershipId: '3', tenantId: '0' }], value.total = 1
|
||||
if (path.includes('/warehouse-admin?')) value.records = [{ id: '4', tenantId: '0' }], value.total = 1
|
||||
return { method, path, http: 200, body: { code: '200', data: mutate(value, path) } }
|
||||
}
|
||||
return { context, calls }
|
||||
}
|
||||
|
||||
for (const module of FIXED_RESOURCE_READONLY_MODULES) test(`${module}复用现有数据,只读通过且保留生命周期缺口`, async () => {
|
||||
const { context, calls } = runner()
|
||||
await context.executeModule(module, () => assert.fail('不得调用原生命周期处理器'))
|
||||
const result = context.modules[0]
|
||||
assert.equal(result.success, true)
|
||||
assert.ok(result.passed > 0)
|
||||
assert.equal(result.skipped, undefined)
|
||||
assert.equal(result.coverageMode, 'READ_ONLY')
|
||||
assert.equal(result.lifecycleSkipped, true)
|
||||
assert.ok(calls.length > 0)
|
||||
assert.ok(calls.every(row => row.method === 'GET'))
|
||||
assert.equal(context.coverageGaps.length, 1)
|
||||
assert.equal(context.coverageGaps[0].blocking, false)
|
||||
assert.equal(context.createdResources.fixedResourceReadOnly[module].lifecycle, 'NOT_TESTED')
|
||||
})
|
||||
|
||||
for (const [label, module, mutate] of [
|
||||
['客户不匹配', 'customers', (value, path) => path.endsWith('/123') ? { ...value, id: '999' } : value],
|
||||
['客户被停用', 'customers', (value, path) => path.endsWith('/123') ? { ...value, status: 'DISABLED' } : value],
|
||||
['初始化未就绪', 'customers', (value, path) => path.endsWith('/dimension-initialization') ? { status: 'PENDING' } : value],
|
||||
['仓库非公共归属', 'master-data', value => ({ ...value, records: [{ id: '4', tenantId: '999' }], total: 1 })],
|
||||
['平台账号非平台归属', 'system', (value, path) => path.includes('/users?')
|
||||
? { ...value, records: [{ membershipId: '3', tenantId: '123' }] } : value],
|
||||
['分页结构缺失', 'notifications', () => []],
|
||||
['分页范围错误', 'notifications', value => ({ ...value, page: 2 })],
|
||||
['价格目录结构错误', 'pricing', () => ({ records: [] })],
|
||||
]) test(`${label}必须失败,禁止补建或执行原写入处理器`, async () => {
|
||||
const { context, calls } = runner(mutate)
|
||||
await context.executeModule(module, () => assert.fail('不能回退创建数据'))
|
||||
assert.equal(context.modules[0].success, false)
|
||||
assert.ok(context.modules[0].failed > 0)
|
||||
assert.ok(calls.every(row => row.method === 'GET'))
|
||||
})
|
||||
|
||||
test('权限错误保留为真实失败,不以空目录通过', async () => {
|
||||
const { context } = runner()
|
||||
context.request = async () => ({ http: 403, body: { code: 'A0403' } })
|
||||
await context.executeModule('system', () => assert.fail('不能修改授权'))
|
||||
assert.equal(context.modules[0].success, false)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
|
||||
|
||||
export const FIXED_RESOURCE_READONLY_MODULES = Object.freeze([
|
||||
'customers', 'system', 'master-data', 'notifications', 'pricing', 'phase4-overseas-master-data',
|
||||
])
|
||||
|
||||
const IAM = '/api/platform/v1/iam'
|
||||
const MASTER = '/api/platform/v1/last-leg/master-data'
|
||||
const decimalId = value => /^[1-9]\d*$/.test(String(value ?? ''))
|
||||
const pageShape = value => Array.isArray(value?.records)
|
||||
&& Number(value.page) === 1 && Number(value.size) === 20
|
||||
&& ['string', 'number'].includes(typeof value.total) && /^(0|[1-9]\d*)$/.test(String(value.total))
|
||||
&& Number.isSafeInteger(Number(value.total))
|
||||
&& Number(value.total) >= value.records.length && value.records.length <= 20
|
||||
|
||||
/** 固定资源只检查现有数据;生命周期缺口保留,不能以查询通过代替写入验收。 */
|
||||
export async function runFixedResourceReadOnly(context, module) {
|
||||
if (!context.options.fixedCustomerId || !FIXED_RESOURCE_READONLY_MODULES.includes(module))
|
||||
throw Error('固定资源只读检查必须指定已有客户及受支持模块')
|
||||
context.addCoverageGap(module, 'fixed-resource-lifecycle', '现有资源只读检查不覆盖创建、修改、发布及停用生命周期',
|
||||
'复用已有客户、账号、角色和主档;生命周期仍为未验收', 'not-implemented', { blocking: false })
|
||||
const proof = { mode: 'READ_ONLY', lifecycle: 'NOT_TESTED', checks: [] }
|
||||
context.createdResources.fixedResourceReadOnly ??= {}
|
||||
context.createdResources.fixedResourceReadOnly[module] = proof
|
||||
async function get(label, path, validate) {
|
||||
const response = await context.request('GET', path)
|
||||
const value = context.apiSuccess(module, label, response)
|
||||
const passed = response?.http === 200 && String(response.body?.code) === '200' && validate(value)
|
||||
context.assert(module, `${label}结构及归属有效`, response, Boolean(passed), '', { deploymentBlocker: true })
|
||||
proof.checks.push({ path, passed: Boolean(passed),
|
||||
...(Array.isArray(value) ? { count: value.length }
|
||||
: Array.isArray(value?.records) ? { count: value.records.length, total: Number(value.total) } : {}) })
|
||||
if (!passed) throw Error(`${module}现有资源只读核验失败,禁止补建或修改`)
|
||||
}
|
||||
const page = (label, path, validate = () => true) => get(label, `${path}?page=1&size=20`,
|
||||
value => pageShape(value) && value.records.every(validate))
|
||||
switch (module) {
|
||||
case 'customers': {
|
||||
const customerId = normalizeFixedCustomerId(context.options.fixedCustomerId)
|
||||
await get('读取指定固定客户档案', `${IAM}/tenants/${customerId}`,
|
||||
value => String(value?.id) === customerId && value.status === 'ACTIVE')
|
||||
await get('读取指定固定客户尺寸初始化状态', `${IAM}/tenants/${customerId}/dimension-initialization`,
|
||||
value => value?.status === 'READY')
|
||||
break
|
||||
}
|
||||
case 'system':
|
||||
await get('读取既有权限目录', `${IAM}/permissions`, value => Array.isArray(value)
|
||||
&& value.every(row => decimalId(row.id) && typeof row.permissionCode === 'string'))
|
||||
await get('读取既有角色目录', `${IAM}/roles`, value => Array.isArray(value)
|
||||
&& value.every(row => decimalId(row.id) && typeof row.roleCode === 'string'))
|
||||
await page('读取既有平台账号分页', `${IAM}/users`, row => decimalId(row.membershipId) && String(row.tenantId) === '0')
|
||||
break
|
||||
case 'master-data':
|
||||
await page('读取既有公共仓库分页', '/api/platform/v1/warehouse-admin',
|
||||
row => decimalId(row.id) && String(row.tenantId) === '0')
|
||||
break
|
||||
case 'notifications':
|
||||
await page('读取既有通知模板分页', '/api/platform/v1/notification-templates')
|
||||
await page('读取既有通知任务分页', '/api/platform/v1/notification-tasks')
|
||||
break
|
||||
case 'pricing':
|
||||
await get('读取既有价格簿目录', '/api/platform/v1/fba-pricing/carrier-rates/price-books', Array.isArray)
|
||||
break
|
||||
case 'phase4-overseas-master-data':
|
||||
await page('读取既有海外报价分页', `${MASTER}/overseas-quotes`)
|
||||
await page('读取既有汇率分页', `${MASTER}/exchange-rates`)
|
||||
await page('读取既有海外车辆分页', `${MASTER}/vehicles`)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -109,15 +109,14 @@ test('cross-customer financial helpers stop before provisioning or writing', asy
|
||||
}
|
||||
})
|
||||
|
||||
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
|
||||
test('fixed resource runner excludes mutation-only boundary handlers without marking them passed', async () => {
|
||||
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
|
||||
let calls = 0
|
||||
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'notifications', 'pricing',
|
||||
'phase4-overseas-master-data']) {
|
||||
for (const name of ['supplemental-boundaries']) {
|
||||
await runner.executeModule(name, async () => { calls++ })
|
||||
}
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(runner.modules.length, 7)
|
||||
assert.equal(runner.modules.length, 1)
|
||||
assert.ok(runner.modules.every(module => module.skipped && !module.success))
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ test('schedule interleaves three modes and uses FBA cumulative count rather than
|
||||
})
|
||||
|
||||
function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false, repeatIncrement = false,
|
||||
changedPayloadAccepted = false } = {}) {
|
||||
changedPayloadAccepted = false, foreignPalletAccepted = false } = {}) {
|
||||
const group = fixtures()
|
||||
const expected = new Map(group.map(r => [r.source.inboundCode, 0]))
|
||||
const schedule = mixedReceivingSchedule(group)
|
||||
@@ -60,6 +60,9 @@ function driver({ earlyComplete = false, wrongModeAccepted = false, drift = fals
|
||||
receivedPackages, pendingPackages: 4 - receivedPackages })) })
|
||||
return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name),
|
||||
request: async (_method, path, body) => {
|
||||
if (path.endsWith('/pallets/resolve')) return { body: {
|
||||
code: foreignPalletAccepted ? '200' : body.inboundCode === 'FBA' ? 'A0404' : 'A0409',
|
||||
} }
|
||||
if (body.idempotencyKey.endsWith('-duplicate')) {
|
||||
return { body: path.endsWith('/carton') ? { code: 'A0409' }
|
||||
: { code: '200', data: { result: 'ALREADY_RECEIVED', acceptedPackages: repeatIncrement ? 1 : 0 } } }
|
||||
@@ -92,6 +95,10 @@ test('mixed receiving executes the whole schedule and completes only after all t
|
||||
test('mixed receiving fails on early-completion acceptance', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true })))
|
||||
})
|
||||
|
||||
test('mixed receiving fails if another inbound real reserved pallet is accepted', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ foreignPalletAccepted: true })), /同柜另一票真实预留托盘必须拒绝/)
|
||||
})
|
||||
test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true })))
|
||||
})
|
||||
@@ -105,7 +112,7 @@ test('mixed receiving rejects same-key different-payload success', async () => {
|
||||
await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true })))
|
||||
})
|
||||
|
||||
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {}) {
|
||||
function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false, postCompleteReceiptAccepted = false } = {}) {
|
||||
const record = { order: { tenantId: 'CUSTOMER' }, source: { inboundCode: 'IN-OVERSEAS' },
|
||||
expectedCartonMarks: [1, 2, 3, 4].map(index => `IN-OVERSEAS#4-${index}`),
|
||||
overseas: { plan: { planNo: 'IP-SHORTAGE', pallets: [{ palletNo: 'PAL-1', warehousePalletId: '900' }] } } }
|
||||
@@ -114,7 +121,12 @@ function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {})
|
||||
const state = { received: 0, status: 'SIGNED', planStatus: 'PENDING_RECONCILIATION', confirmations: 0 }
|
||||
const snapshot = () => ({ status: state.status, receivedPackages: state.received, pendingPackages: 4 - state.received })
|
||||
return { record, marker: 'SHORTAGE-TEST', state, check: (name, _response, passed) => assert.ok(passed, name),
|
||||
request: async (_method, path) => ({ body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }),
|
||||
request: async (_method, path, body) => {
|
||||
if (/receive-after-(complete|shortage)$/.test(body.idempotencyKey || '')) {
|
||||
return { body: { code: postCompleteReceiptAccepted ? '200' : 'A0404' } }
|
||||
}
|
||||
return { body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }
|
||||
},
|
||||
call: async (_name, method, path, body) => {
|
||||
if (method === 'GET') return snapshot()
|
||||
if (path.endsWith('/cartons/resolve')) return { inboundCode: record.source.inboundCode, destinationType: 'OVERSEAS' }
|
||||
@@ -157,8 +169,12 @@ test('ordinary overseas shortage fails if stock includes an unreceived carton',
|
||||
await assert.rejects(receiveOverseasShortage(shortageDriver({ stockPackages: 4 })))
|
||||
})
|
||||
|
||||
function completeDriver(stockPackages = 4) {
|
||||
const input = shortageDriver({ stockPackages })
|
||||
test('ordinary overseas shortage fails if a missing carton can be received after confirmation', async () => {
|
||||
await assert.rejects(receiveOverseasShortage(shortageDriver({ postCompleteReceiptAccepted: true })), /短少确认后不能继续收取未入库箱/)
|
||||
})
|
||||
|
||||
function completeDriver(stockPackages = 4, postCompleteReceiptAccepted = false) {
|
||||
const input = shortageDriver({ stockPackages, postCompleteReceiptAccepted })
|
||||
const call = input.call
|
||||
input.call = async (name, method, path, body) => {
|
||||
if (path.endsWith('/complete')) {
|
||||
@@ -184,3 +200,7 @@ test('ordinary overseas full receipt uses normal completion and stocks all four
|
||||
test('ordinary overseas full receipt rejects stock inconsistent with actual received cartons', async () => {
|
||||
await assert.rejects(receiveOverseasComplete(completeDriver(3)))
|
||||
})
|
||||
|
||||
test('ordinary overseas full receipt fails if another receipt is accepted after completion', async () => {
|
||||
await assert.rejects(receiveOverseasComplete(completeDriver(4, true)), /正常完成后新键扫码不能重新收货/)
|
||||
})
|
||||
|
||||
@@ -33,6 +33,17 @@ export async function receiveMixedContainer({ group, call, check, request, marke
|
||||
const base = '/api/pda/v1/last-leg/receiving-containers/current'
|
||||
const steps = mixedReceivingSchedule(group)
|
||||
const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 }))
|
||||
for (const record of group.filter(r => r.order.fulfillmentType === 'STANDARD')) {
|
||||
const other = group.find(item => item !== record && item.order.fulfillmentType === 'STANDARD')
|
||||
const before = await call('混柜串票托盘校验前回查', 'GET', base)
|
||||
const invalid = await request('POST', `${base}/pallets/resolve`, {
|
||||
inboundCode: record.source.inboundCode, palletCode: other.overseas.plan.pallets[0].palletNo,
|
||||
})
|
||||
check('同柜另一票真实预留托盘必须拒绝', invalid,
|
||||
invalid.body?.code === (record.order.destinationType === 'FBA' ? 'A0404' : 'A0409'))
|
||||
const after = await call('混柜串票托盘拒绝后回查', 'GET', base)
|
||||
check('同柜串票托盘拒绝后全柜事实不变', null, JSON.stringify(before) === JSON.stringify(after))
|
||||
}
|
||||
for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) {
|
||||
const before = await call('混柜错误收货模式前回查', 'GET', base)
|
||||
const invalid = await request('POST', `${base}/receipts/bulk`, {
|
||||
@@ -82,7 +93,7 @@ export async function receiveMixedContainer({ group, call, check, request, marke
|
||||
return completed
|
||||
}
|
||||
|
||||
export async function receiveOverseasComplete({ record, call, adminCall, check, marker }) {
|
||||
export async function receiveOverseasComplete({ record, call, adminCall, check, request, marker }) {
|
||||
const base = '/api/pda/v1/last-leg/receiving-containers/current'
|
||||
const inboundCode = record.source.inboundCode
|
||||
const plan = record.overseas.plan
|
||||
@@ -104,6 +115,11 @@ export async function receiveOverseasComplete({ record, call, adminCall, check,
|
||||
&& Number(completed.receivedPackages) === expected && Number(completed.pendingPackages) === 0)
|
||||
const replay = await call('正常完成原键重放', 'POST', `${base}/complete`, body)
|
||||
check('正常完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed))
|
||||
const afterComplete = await request('POST', `${base}/receipts/carton`, {
|
||||
cartonMark: record.expectedCartonMarks[0], palletCode: pallet.palletNo,
|
||||
idempotencyKey: `${marker}-receive-after-complete`,
|
||||
})
|
||||
check('正常完成后新键扫码不能重新收货', afterComplete, afterComplete.body?.code === 'A0404')
|
||||
const finalPlan = await adminCall('回查全收最终计划', 'GET',
|
||||
`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}?customerId=${encodeURIComponent(record.order.tenantId)}`)
|
||||
check('全收计划完成且应收实收均为四箱', null, finalPlan.status === 'COMPLETED'
|
||||
@@ -164,6 +180,11 @@ export async function receiveOverseasShortage({ record, call, adminCall, check,
|
||||
check('普通海外仓短少确认成功', null, confirmed.status === 'PARTIAL_INBOUND')
|
||||
const confirmReplay = await adminCall('后台短少确认同键重放', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers)
|
||||
check('后台短少确认重放不重复入库', null, JSON.stringify(confirmReplay) === JSON.stringify(confirmed))
|
||||
const afterComplete = await request('POST', `${base}/receipts/carton`, {
|
||||
cartonMark: record.expectedCartonMarks[actual], palletCode: pallet.palletNo,
|
||||
idempotencyKey: `${marker}-receive-after-shortage`,
|
||||
})
|
||||
check('短少确认后不能继续收取未入库箱', afterComplete, afterComplete.body?.code === 'A0404')
|
||||
const finalPlan = await adminCall('回查短少入库最终计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`)
|
||||
check('短少入库保留原应收及实际箱数', null, finalPlan.status === 'PARTIAL_INBOUND'
|
||||
&& Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === actual)
|
||||
|
||||
@@ -1,10 +1,117 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
|
||||
import { AcceptanceRun, parseArguments, buildAcceptanceSummary, finalizeAcceptanceReport } from './admin-test-env-acceptance.mjs'
|
||||
import { mkdtemp, writeFile, unlink, rmdir } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
test('登录失败且未执行业务模块不能报告全量覆盖', async () => {
|
||||
const run = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true,
|
||||
modules: ['dashboard'], requestedModules: ['dashboard'] })
|
||||
run.request = async () => ({ http: 200, body: { code: 'A0401' } })
|
||||
const report = await run.run()
|
||||
assert.equal(report.success, false)
|
||||
assert.equal(report.fullCoverage, false)
|
||||
assert.equal(report.coverageStatus, 'FAILED')
|
||||
assert.deepEqual(report.executionScope.unexecutedModules, ['dashboard'])
|
||||
})
|
||||
|
||||
function completeReport() {
|
||||
return { fatal: null, executionScope: { plannedModules: ['orders', 'finance'], defaultModules: ['orders', 'finance'] },
|
||||
steps: [{ module: 'orders', passed: true }, { module: 'finance', passed: true }],
|
||||
modules: ['orders', 'finance'].map(name => ({ name, success: true, passed: 1, failed: 0 })),
|
||||
coverageGaps: [], deploymentBlockers: [] }
|
||||
}
|
||||
|
||||
test('全量覆盖要求计划全部实际完成;定向成功单独保留通过结果', () => {
|
||||
const full = finalizeAcceptanceReport(completeReport())
|
||||
assert.equal(full.success, true)
|
||||
assert.equal(full.fullCoverage, true)
|
||||
assert.equal(full.coverageStatus, 'FULL')
|
||||
const focused = completeReport()
|
||||
focused.executionScope.plannedModules = ['orders']
|
||||
focused.modules.pop(); focused.steps.pop()
|
||||
finalizeAcceptanceReport(focused)
|
||||
assert.equal(focused.success, true)
|
||||
assert.equal(focused.fullCoverage, false)
|
||||
assert.equal(focused.executionScope.planCompleted, true)
|
||||
assert.equal(focused.coverageStatus, 'PARTIAL')
|
||||
})
|
||||
|
||||
for (const [label, mutate, failed] of [
|
||||
['模块未执行', report => { report.modules.pop() }, true],
|
||||
['模块没有断言', report => { report.modules[1].passed = 0 }, true],
|
||||
['模块失败但没有失败步骤', report => { report.modules[1].success = false }, true],
|
||||
['中止', report => { report.executionScope.stoppedByRequest = true }, true],
|
||||
['阻断覆盖缺口', report => { report.coverageGaps.push({ blocking: true }) }, true],
|
||||
['外部部署阻断', report => { report.deploymentBlockers.push({ module: 'orders' }) }, true],
|
||||
['跳过模块', report => { Object.assign(report.modules[1], { skipped: true, success: false, passed: 0 }) }, false],
|
||||
['只读生命周期缺口', report => { report.coverageGaps.push({ blocking: false }) }, false],
|
||||
['仅准备数据', report => { report.executionScope.preparationOnly = true }, false],
|
||||
['退货仅主链', report => { report.executionScope.returnMainChainOnly = true }, false],
|
||||
['显式局部验收', report => { report.fullAcceptance = false }, false],
|
||||
]) test(`${label}不允许标记全量覆盖`, () => {
|
||||
const report = completeReport(); mutate(report); finalizeAcceptanceReport(report)
|
||||
assert.equal(report.success, !failed)
|
||||
assert.equal(report.fullCoverage, false)
|
||||
})
|
||||
|
||||
test('清理失败后重新计算报告,不能保留先前的成功与覆盖率', () => {
|
||||
const report = finalizeAcceptanceReport(completeReport())
|
||||
report.steps.push({ module: 'cleanup', passed: false })
|
||||
finalizeAcceptanceReport(report)
|
||||
assert.equal(report.success, false)
|
||||
assert.equal(report.fullCoverage, false)
|
||||
assert.equal(report.summary.failed, 1)
|
||||
})
|
||||
|
||||
test('混柜专项隔离上下文与报告资源,失败仍保留本轮独立证据', async () => {
|
||||
const run = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
|
||||
run.context.orderDestinationType = 'FBA'
|
||||
run.createdResources.orders = { orderNo: 'MAIN-ORDER' }
|
||||
const marker = run.marker
|
||||
await assert.rejects(run.runIsolatedModule('mixed-container-receiving', async child => {
|
||||
assert.equal(child.fixedPdaLeases, run.fixedPdaLeases)
|
||||
child.context.orderDestinationType = 'PRIVATE'
|
||||
child.options.prepareDropshipManualCount = true
|
||||
child.createdResources.orders = { orderNo: 'ISOLATED-ORDER' }
|
||||
child.token = 'rotated-session'
|
||||
child.refreshToken = 'rotated-refresh'
|
||||
child.expiresAt = 123
|
||||
child.marker = 'CHANGED'
|
||||
throw Error('fixture failure')
|
||||
}), /fixture failure/)
|
||||
assert.equal(run.marker, marker)
|
||||
assert.equal(run.context.orderDestinationType, 'FBA')
|
||||
assert.equal(run.options.prepareDropshipManualCount, undefined)
|
||||
assert.equal(run.token, 'rotated-session')
|
||||
assert.equal(run.refreshToken, 'rotated-refresh')
|
||||
assert.equal(run.expiresAt, 123)
|
||||
assert.equal(run.createdResources.orders.orderNo, 'MAIN-ORDER')
|
||||
assert.equal(run.createdResources.isolatedModules['mixed-container-receiving'].orders.orderNo, 'ISOLATED-ORDER')
|
||||
})
|
||||
|
||||
test('真实总入口调度混柜模块必须经过隔离处理器', async () => {
|
||||
const run = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true,
|
||||
modules: ['mixed-container-receiving'], requestedModules: ['mixed-container-receiving'] })
|
||||
run.request = async (method, path) => {
|
||||
assert.equal(method, 'POST'); assert.equal(path, '/api/auth/login')
|
||||
return { http: 200, body: { code: '200', data: { accessToken: 'mock-session', refreshToken: 'mock-refresh' } } }
|
||||
}
|
||||
let isolated = false
|
||||
run.runIsolatedModule = async (name, handler) => {
|
||||
assert.equal(name, 'mixed-container-receiving')
|
||||
assert.equal(typeof handler, 'function')
|
||||
isolated = true
|
||||
run.addStep(name, 'offline isolated workflow', null, true)
|
||||
}
|
||||
const report = await run.run()
|
||||
assert.equal(isolated, true)
|
||||
assert.equal(report.success, true)
|
||||
assert.equal(report.fullCoverage, false)
|
||||
assert.deepEqual(report.executionScope.completedModules, ['mixed-container-receiving'])
|
||||
})
|
||||
|
||||
test('explicit stop file waits for a module boundary and records an interrupted failure once', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'acceptance-stop-'))
|
||||
const stopFile = join(directory, 'stop')
|
||||
|
||||
@@ -616,7 +616,10 @@ $inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-se
|
||||
Assert-Contains $businessFacts '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由'
|
||||
Assert-Contains $businessFacts 'exceptionorder/' '根海外异常服务入口'
|
||||
Assert-Contains $lastlegIndex '退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位' 'Lastleg 海外异常库存恢复规则'
|
||||
Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
|
||||
$exceptionReturnInboundContract = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/exception-return-inbound-v1.yaml')
|
||||
Assert-Contains $adminOverseasAgents '建单不恢复库存' 'Admin 异常回仓建单与实收分开'
|
||||
Assert-Contains $exceptionReturnInboundContract '/api/pda/v1/last-leg/return-receipts/exceptions/{number}/receive:' '异常回仓专用 PDA 实收入口'
|
||||
Assert-Contains $exceptionReturnInboundContract '收齐后异常COMPLETED,退回尾程INBOUND_COMPLETED' '异常与退回尾程按实收完成'
|
||||
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
|
||||
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
|
||||
Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界'
|
||||
@@ -1194,7 +1197,12 @@ foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'PROCURE
|
||||
Assert-Matches $overseasFinanceOpenApi 'collectionMode:\s*\{\s*type:\s*string,\s*enum:\s*\[BILL,\s*WALLET\]' '海外费用区分账单与余额收款'
|
||||
Assert-Matches $overseasManualFeeService 'if\s*\(approved\s*&&\s*walletCollection\)\s*send\(fee,\s*state,\s*"SETTLE"\)' '余额费用审核发送实扣指令'
|
||||
Assert-Matches $overseasManualFeeService 'else\s+if\s*\(approved\)\s*outbox\.publish\(' '账单费用审核保留可靠投递'
|
||||
Assert-Contains $overseasFinanceOpenApi '已发布BILL作废等待Finance持久化确认才VOIDED' '账单作废等待财务确认'
|
||||
# 人工费用审核前作废释放冻结;已有账单来源撤销仍在可靠确认后落账。
|
||||
Assert-Contains $overseasFinanceOpenApi '已审核通过、已扣款或已发布均不允许这两个动作' '人工费用审核后不允许调价或作废'
|
||||
Assert-Contains $overseasFinanceOpenApi '审核前作废直接关闭并释放冻结,不向Finance发送账单作废事件' '人工费用作废释放冻结且不撤销账单'
|
||||
$feeCancellationAcknowledgement = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/financefee/state/OverseasFeeCancellation.java')
|
||||
Assert-Contains $feeCancellationAcknowledgement 'public void acknowledge(' '已有费用来源撤销由投递确认推进'
|
||||
Assert-Matches $feeCancellationAcknowledgement '@Transactional\(propagation\s*=\s*Propagation\.MANDATORY\)' '已有费用撤销确认与投递日志共用事务'
|
||||
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
|
||||
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'
|
||||
Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本'
|
||||
|
||||
@@ -29,6 +29,29 @@ test('fixed Client authenticates and verifies ownership without registration', a
|
||||
await assert.rejects(scope.reuseFixedClient(), /fixed customer identity/)
|
||||
})
|
||||
|
||||
test('固定客户补回企业资料、资质及角色成员查询,全程只读且校验归属', async () => {
|
||||
const calls = []
|
||||
const scope = vm.createContext({ fixedCustomerId: '123', merchantToken: 'existing-session', report: {},
|
||||
assertEqual: assert.equal, assertTrue: assert.ok,
|
||||
requireSuccess: json => assert.equal(json.code, 200),
|
||||
async apiStep(_module, _name, request, validate) {
|
||||
calls.push(request)
|
||||
assert.equal(request.method, 'GET')
|
||||
assert.equal(request.token, 'existing-session')
|
||||
const json = { code: 200, data: request.path.endsWith('/tenant/profile')
|
||||
? { id: '123', status: 'ACTIVE' } : [] }
|
||||
validate({ json }); return { json }
|
||||
},
|
||||
})
|
||||
const helper = source.slice(source.indexOf('async function fixedClientReadOnlyModule()'), source.indexOf('async function registrationModule()'))
|
||||
vm.runInContext(helper, scope)
|
||||
await scope.fixedClientReadOnlyModule()
|
||||
assert.equal(calls.length, 6)
|
||||
assert.deepEqual([...scope.report.readOnlyCoverage], ['qualification-review', 'profile-management', 'rbac-members'])
|
||||
scope.fixedCustomerId = '999'
|
||||
await assert.rejects(scope.fixedClientReadOnlyModule(), /fixed customer read-only identity/)
|
||||
})
|
||||
|
||||
test('client registration distinguishes incomplete-registration replay from credential conflicts', () => {
|
||||
assert.match(source, /same credentials replay the incomplete registration/)
|
||||
assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/)
|
||||
|
||||
@@ -99,11 +99,12 @@ let temporaryOwnerPassword
|
||||
try {
|
||||
await reuseFixedClient()
|
||||
await platformReviewerLogin()
|
||||
await fixedClientReadOnlyModule()
|
||||
for (const module of ['registration', 'qualification-review', 'profile-management', 'login-identifiers',
|
||||
'rbac-members', 'isolation', 'order-submission-balance', 'order-mutation-evidence',
|
||||
'order-submission-channel', 'notification-inbox', 'account-security']) {
|
||||
recordSkipped(module, '固定客户模式排除生命周期或共享配置写入',
|
||||
'不注册客户、不修改固定账号资料或余额、不发布通知;跨客户场景需第二个固定客户')
|
||||
recordSkipped(module, '固定客户模式排除写入生命周期或尚未实现的边界',
|
||||
'企业资料、资质和角色成员查询单独只读核验;注册、配置修改、账号安全及隔离边界仍未验收')
|
||||
}
|
||||
await orderLifecycleModule()
|
||||
await orderExcelImportModule()
|
||||
@@ -126,6 +127,8 @@ try {
|
||||
if (!logout) report.success = false
|
||||
}
|
||||
report.completedAt = new Date().toISOString()
|
||||
report.fullCoverage = report.success && !report.steps.some(step => step.skipped)
|
||||
report.coverageStatus = !report.success ? 'FAILED' : report.fullCoverage ? 'FULL' : 'PARTIAL'
|
||||
await mkdir(reportDir, { recursive: true })
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
printSummary()
|
||||
@@ -151,6 +154,29 @@ async function reuseFixedClient() {
|
||||
report.testAccount = { username, reused: true }
|
||||
}
|
||||
|
||||
async function fixedClientReadOnlyModule() {
|
||||
await apiStep('profile-management', '固定客户企业资料只读核验', {
|
||||
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(String(json.data?.id), fixedCustomerId, 'fixed customer read-only identity')
|
||||
assertEqual(json.data?.status, 'ACTIVE', 'fixed customer read-only status')
|
||||
})
|
||||
for (const [module, name, path] of [
|
||||
['qualification-review', '既有企业资质', '/api/merchant/v1/iam/tenant/qualifications'],
|
||||
['profile-management', '既有企业地址', '/api/merchant/v1/iam/tenant/addresses'],
|
||||
['rbac-members', '既有客户角色', '/api/merchant/v1/iam/roles'],
|
||||
['rbac-members', '既有客户成员', '/api/merchant/v1/iam/members'],
|
||||
['rbac-members', '既有有效权限', '/api/merchant/v1/iam/authorization/permissions'],
|
||||
]) {
|
||||
await apiStep(module, `${name}只读核验`, { method: 'GET', path, token: merchantToken }, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Array.isArray(json.data), `${name} must be an array`)
|
||||
}, ({ json }) => ({ count: json.data.length, coverageMode: 'READ_ONLY' }))
|
||||
}
|
||||
report.readOnlyCoverage = ['qualification-review', 'profile-management', 'rbac-members']
|
||||
}
|
||||
|
||||
async function registrationModule() {
|
||||
const registrationBody = {
|
||||
username,
|
||||
|
||||
Reference in New Issue
Block a user