Files
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

478 lines
22 KiB
JavaScript

#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const appRoot = path.resolve(path.dirname(scriptPath), '..')
const repoRoot = path.resolve(appRoot, '../../..')
const inventoryFile = path.join(repoRoot, 'plugins/modern-api/generated/ui-inventory.json')
const publicInventoryFile = path.join(appRoot, 'public/generated/ui-inventory.json')
const publicPagesDir = path.join(appRoot, 'public/generated/pages')
const builtInventoryFile = path.join(repoRoot, 'plugins/modern-ui/webapp/modern/app/generated/ui-inventory.json')
const builtPagesDir = path.join(repoRoot, 'plugins/modern-ui/webapp/modern/app/generated/pages')
const outDir = path.join(repoRoot, 'plugins/modern-ui/verification')
const outJson = path.join(outDir, 'coverage-verification.json')
const outMd = path.join(outDir, 'coverage-verification.md')
const supportedBlockTypes = new Set([
'legacy-screen',
'section',
'form',
'table',
'actions',
'menu',
'links',
'permission',
'client-behavior',
'report',
'search-workspace',
'entity-editor',
'domain-workspace',
'tree-workspace',
'calendar',
'lookup-workspace',
'commerce-surface',
'pos-workspace',
'route-workspace',
'template-adapter',
'html-template',
'empty'
])
function passed(id, label, actual = '') {
return { id, label, status: 'passed', actual }
}
function failed(id, label, expected, actual, samples = []) {
return { id, label, status: 'failed', expected, actual, samples: samples.slice(0, 25) }
}
function incomplete(id, label, expected, actual, samples = []) {
return { id, label, status: 'incomplete', expected, actual, samples: samples.slice(0, 25) }
}
function skipped(id, label, actual = '') {
return { id, label, status: 'skipped', actual }
}
function countBy(items, getKey) {
const counts = {}
for (const item of items) {
const key = getKey(item) || 'unknown'
counts[key] = (counts[key] || 0) + 1
}
return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])))
}
async function exists(file) {
try {
await access(file, constants.R_OK)
return true
} catch {
return false
}
}
async function readJson(file) {
return JSON.parse(await readFile(file, 'utf8'))
}
async function listJsonFiles(dir) {
try {
return (await readdir(dir)).filter((file) => file.endsWith('.json'))
} catch {
return null
}
}
function routePageUrl(route) {
return String(route.pageDefinitionUrl || '')
}
function pageFileForRoute(route, pagesDir) {
const url = routePageUrl(route)
if (!url) return ''
return path.join(pagesDir, path.basename(url))
}
function isBlockSupported(type = '') {
return supportedBlockTypes.has(type) || type.endsWith('-workspace')
}
function actionIdOf(action) {
return typeof action === 'string' ? action : action?.actionId
}
function samplePage(page) {
return {
pageId: page.pageId,
title: page.title,
legacyPath: page.legacy?.path || '',
domain: page.domain || '',
status: page.acceptance?.status || ''
}
}
function actionReferenceSamples(pages, actionDefinitions) {
const samples = []
for (const page of pages) {
for (const action of page.actions || []) {
const actionId = actionIdOf(action)
if (actionId && !actionDefinitions[actionId]) {
samples.push({ pageId: page.pageId, actionId, source: 'page.actions' })
}
}
for (const block of page.blocks || []) {
const submitAction = block.submitAction
const actionId = submitAction?.actionId
const status = String(submitAction?.status || '')
const mustResolve = submitAction?.apiExecutable
&& !['navigation-target', 'navigation-action', 'local-submit-contract', 'local-draft-contract', 'readonly-display', 'dynamic-target', 'unmapped-target-contract'].includes(status)
if (mustResolve && actionId && !actionDefinitions[actionId]) {
samples.push({ pageId: page.pageId, actionId, formName: block.formName, source: 'block.submitAction' })
}
}
}
return samples
}
function routeDuplicates(routes) {
const byPage = new Map()
for (const route of routes) {
const items = byPage.get(route.pageId) || []
items.push(route)
byPage.set(route.pageId, items)
}
return [...byPage.entries()]
.filter(([, items]) => items.length > 1)
.map(([pageId, items]) => ({
pageId,
count: items.length,
legacyPaths: items.map((item) => item.legacyPath).filter(Boolean)
}))
}
async function verifySplitInventory(label, inventoryPath, pagesDir, sourceInventory, checks) {
if (!await exists(inventoryPath)) {
checks.push(skipped(`${label}-inventory`, `${label} split inventory exists`, 'not generated yet'))
return
}
const splitInventory = await readJson(inventoryPath)
const pageCount = Object.keys(sourceInventory.pageDefinitions || {}).length
const pageFiles = await listJsonFiles(pagesDir)
const routeManifest = splitInventory.routeManifest || []
const missingUrls = routeManifest.filter((route) => !routePageUrl(route))
const embeddedPageDefinitions = Object.keys(splitInventory.pageDefinitions || {}).length
const embeddedActionDefinitions = Object.keys(splitInventory.actionDefinitions || {}).length
const missingFiles = []
if (pageFiles) {
const pageFileSet = new Set(pageFiles)
for (const route of routeManifest) {
const pageFile = pageFileForRoute(route, pagesDir)
if (pageFile && !pageFileSet.has(path.basename(pageFile))) {
missingFiles.push({ pageId: route.pageId, pageDefinitionUrl: route.pageDefinitionUrl })
}
}
}
checks.push(routeManifest.length === (sourceInventory.routeManifest || []).length
? passed(`${label}-route-count`, `${label} route manifest mirrors generated inventory`, routeManifest.length)
: failed(`${label}-route-count`, `${label} route manifest mirrors generated inventory`, (sourceInventory.routeManifest || []).length, routeManifest.length))
checks.push(pageFiles?.length === pageCount
? passed(`${label}-page-file-count`, `${label} split PageDefinition file count`, pageFiles.length)
: failed(`${label}-page-file-count`, `${label} split PageDefinition file count`, pageCount, pageFiles?.length ?? 'missing pages dir'))
checks.push(embeddedPageDefinitions === 0 && embeddedActionDefinitions === 0
? passed(`${label}-light-index`, `${label} inventory index stays lightweight`, 0)
: failed(`${label}-light-index`, `${label} inventory index stays lightweight`, 0, embeddedPageDefinitions + embeddedActionDefinitions))
checks.push(missingUrls.length === 0
? passed(`${label}-page-definition-urls`, `${label} every route points to a split PageDefinition`, routeManifest.length)
: failed(`${label}-page-definition-urls`, `${label} every route points to a split PageDefinition`, 0, missingUrls.length, missingUrls))
checks.push(missingFiles.length === 0
? passed(`${label}-page-files-resolve`, `${label} every route PageDefinition URL resolves to a file`, routeManifest.length)
: failed(`${label}-page-files-resolve`, `${label} every route PageDefinition URL resolves to a file`, 0, missingFiles.length, missingFiles))
}
const inventory = await readJson(inventoryFile)
const routes = inventory.routeManifest || []
const pagesById = inventory.pageDefinitions || {}
const pages = Object.values(pagesById)
const actionDefinitions = inventory.actionDefinitions || {}
const counts = inventory.counts || {}
const coverage = inventory.coverage || {}
const checks = []
const missingPageDefinitions = routes.filter((route) => !pagesById[route.pageId])
const pagesWithoutRoutes = pages.filter((page) => !routes.some((route) => route.pageId === page.pageId))
const duplicateRoutes = routeDuplicates(routes)
const unsupportedBlocks = []
const pagesMissingBasics = []
const pagesMissingAcceptance = []
const pagesMissingPermissions = []
const pagesMissingApiContract = []
const pagesPendingBusinessE2e = []
const pagesNotBusinessVerified = []
const pagesMissingBlocks = []
for (const page of pages) {
const blocks = page.blocks || []
const acceptance = page.acceptance || {}
const scenario = acceptance.e2eScenario || {}
if (!page.pageId || !page.title || !page.layout || !page.legacy?.path || !page.legacy?.controller) {
pagesMissingBasics.push(samplePage(page))
}
if (!blocks.length) {
pagesMissingBlocks.push(samplePage(page))
}
for (const block of blocks) {
if (!isBlockSupported(block.type)) {
unsupportedBlocks.push({ pageId: page.pageId, blockType: block.type, title: block.title || '' })
}
}
if (!Array.isArray(page.permissions) || page.permissions.length === 0) {
pagesMissingPermissions.push(samplePage(page))
}
if (!acceptance.scenarioId || !Array.isArray(scenario.steps) || scenario.steps.length === 0 || !Array.isArray(scenario.assertions) || scenario.assertions.length === 0) {
pagesMissingAcceptance.push(samplePage(page))
}
if (!Array.isArray(scenario.apiContracts) || !scenario.apiContracts.some((contract) => contract.method === 'GET' && String(contract.path || '').startsWith('/api/v1/pages/'))) {
pagesMissingApiContract.push(samplePage(page))
}
if (acceptance.scenarioStatus !== 'ready-for-business-e2e' && acceptance.businessParityStatus !== 'verified') {
pagesPendingBusinessE2e.push(samplePage(page))
}
if (acceptance.businessParityStatus !== 'verified') {
pagesNotBusinessVerified.push(samplePage(page))
}
}
const requestActions = []
for (const controller of inventory.controllers || []) {
for (const request of controller.requests || []) {
if (request.actionId) requestActions.push({ ...request, controller: controller.file, webapp: controller.webapp?.name || '' })
}
}
const missingRequestActions = requestActions.filter((request) => !actionDefinitions[request.actionId])
const missingActionReferences = actionReferenceSamples(pages, actionDefinitions)
const formContractCounts = coverage.formContractStatusCounts || {}
const explicitNonExecutableContracts = [
'readonly-display',
'local-submit-contract',
'local-draft-contract',
'dynamic-target',
'navigation-target',
'unmapped-target-contract'
].reduce((sum, key) => sum + (formContractCounts[key] || 0), 0)
checks.push(routes.length === counts.viewRoute
? passed('view-route-count', 'generated route count matches scanned legacy view-map count', routes.length)
: failed('view-route-count', 'generated route count matches scanned legacy view-map count', counts.viewRoute, routes.length))
checks.push(pages.length === counts.pageDefinition
? passed('page-definition-count', 'generated PageDefinition count matches inventory count', pages.length)
: failed('page-definition-count', 'generated PageDefinition count matches inventory count', counts.pageDefinition, pages.length))
checks.push(missingPageDefinitions.length === 0
? passed('missing-page-definitions', 'every legacy view route has a PageDefinition', 0)
: failed('missing-page-definitions', 'every legacy view route has a PageDefinition', 0, missingPageDefinitions.length, missingPageDefinitions))
checks.push(pagesWithoutRoutes.length === 0
? passed('orphan-page-definitions', 'every PageDefinition is reachable through a generated route', 0)
: failed('orphan-page-definitions', 'every PageDefinition is reachable through a generated route', 0, pagesWithoutRoutes.length, pagesWithoutRoutes.map(samplePage)))
checks.push(coverage.missingRoutes === 0
? passed('inventory-missing-routes', 'inventory reports missingRoutes=0', coverage.missingRoutes)
: failed('inventory-missing-routes', 'inventory reports missingRoutes=0', 0, coverage.missingRoutes))
checks.push(coverage.missingActions === 0
? passed('inventory-missing-actions', 'inventory reports missingActions=0', coverage.missingActions)
: failed('inventory-missing-actions', 'inventory reports missingActions=0', 0, coverage.missingActions))
checks.push(missingRequestActions.length === 0
? passed('request-action-definitions', 'every legacy request-map has an ActionDefinition', requestActions.length)
: failed('request-action-definitions', 'every legacy request-map has an ActionDefinition', 0, missingRequestActions.length, missingRequestActions))
checks.push(missingActionReferences.length === 0
? passed('page-action-references', 'every PageDefinition action reference resolves', pages.length)
: failed('page-action-references', 'every PageDefinition action reference resolves', 0, missingActionReferences.length, missingActionReferences))
checks.push(coverage.formMissingContractBlocks === 0
? passed('form-contracts', 'every generated form block has an explicit submit/read contract', coverage.formContractBlocks || 0)
: failed('form-contracts', 'every generated form block has an explicit submit/read contract', 0, coverage.formMissingContractBlocks))
checks.push(pagesMissingBasics.length === 0
? passed('page-definition-basics', 'every PageDefinition has title, layout and legacy source trace', pages.length)
: failed('page-definition-basics', 'every PageDefinition has title, layout and legacy source trace', 0, pagesMissingBasics.length, pagesMissingBasics))
checks.push(pagesMissingBlocks.length === 0
? passed('page-blocks', 'every PageDefinition has renderable blocks', pages.length)
: failed('page-blocks', 'every PageDefinition has renderable blocks', 0, pagesMissingBlocks.length, pagesMissingBlocks))
checks.push(unsupportedBlocks.length === 0
? passed('renderer-block-types', 'every generated block type is supported by the modern renderer layer', pages.length)
: failed('renderer-block-types', 'every generated block type is supported by the modern renderer layer', 0, unsupportedBlocks.length, unsupportedBlocks))
checks.push(pagesMissingPermissions.length === 0
? passed('permissions', 'every PageDefinition carries mapped OFBiz permissions', pages.length)
: failed('permissions', 'every PageDefinition carries mapped OFBiz permissions', 0, pagesMissingPermissions.length, pagesMissingPermissions))
checks.push(pagesMissingAcceptance.length === 0
? passed('acceptance-scenarios', 'every PageDefinition has an acceptance scenario', pages.length)
: failed('acceptance-scenarios', 'every PageDefinition has an acceptance scenario', 0, pagesMissingAcceptance.length, pagesMissingAcceptance))
checks.push(pagesMissingApiContract.length === 0
? passed('page-api-contracts', 'every PageDefinition has a GET /api/v1/pages/:pageId contract', pages.length)
: failed('page-api-contracts', 'every PageDefinition has a GET /api/v1/pages/:pageId contract', 0, pagesMissingApiContract.length, pagesMissingApiContract))
checks.push(pagesPendingBusinessE2e.length === 0
? passed('business-e2e-readiness', 'every page is ready for or has passed business e2e', pages.length)
: incomplete('business-e2e-readiness', 'pages still need domain business e2e execution before final rewrite completion', 0, pagesPendingBusinessE2e.length, pagesPendingBusinessE2e))
checks.push(pagesNotBusinessVerified.length === 0
? passed('business-parity-complete', 'every page has verified old/new business parity', pages.length)
: incomplete('business-parity-complete', 'full OFBiz functional parity is not complete until businessParityStatus is verified for every page', 0, pagesNotBusinessVerified.length, pagesNotBusinessVerified))
await verifySplitInventory('public', publicInventoryFile, publicPagesDir, inventory, checks)
await verifySplitInventory('built', builtInventoryFile, builtPagesDir, inventory, checks)
const failures = checks.filter((check) => check.status === 'failed')
const incompletes = checks.filter((check) => check.status === 'incomplete')
const byDomain = countBy(pages, (page) => page.domain)
const byLayout = countBy(pages, (page) => page.layout)
const byBusinessParityStatus = countBy(pages, (page) => page.acceptance?.businessParityStatus)
const byScenarioStatus = countBy(pages, (page) => page.acceptance?.scenarioStatus)
const report = {
status: failures.length ? 'failed' : incompletes.length ? 'structural-passed-business-incomplete' : 'passed',
generatedAt: new Date().toISOString(),
inventoryFile,
verificationBoundary: {
frontendRewriteCoverageGate: failures.length === 0 ? 'passed' : 'failed',
businessDepthParityGate: pagesNotBusinessVerified.length === 0 ? 'passed' : 'pending',
finalGate: failures.length === 0 && pagesNotBusinessVerified.length === 0
? 'full-business-parity-complete'
: failures.length === 0
? 'frontend-coverage-passed-business-depth-parity-pending'
: 'frontend-coverage-failed',
pendingBusinessParityPages: pagesNotBusinessVerified.length,
pendingBusinessE2ePages: pagesPendingBusinessE2e.length,
reportPath: outJson
},
summary: {
controllers: counts.controllerXml || 0,
widgetXml: counts.widgetXml || 0,
legacyViewRoutes: counts.viewRoute || 0,
routeManifest: routes.length,
uniquePageDefinitions: pages.length,
duplicateLegacyRouteAliases: duplicateRoutes.length,
requestActions: requestActions.length,
actionDefinitions: Object.keys(actionDefinitions).length,
serviceDefinitions: counts.service || 0,
renderedPages: coverage.renderablePages || 0,
structuredWidgetPages: coverage.structuredPages || 0,
adapterCoveredPages: coverage.adapterCoveredPages || 0,
pendingBusinessE2ePages: coverage.pendingE2ePages ?? pagesPendingBusinessE2e.length,
verifiedBusinessParityPages: byBusinessParityStatus.verified || 0,
explicitNonExecutableFormContracts: explicitNonExecutableContracts,
tableBlocks: coverage.tableBlocks || 0,
tableDataSourceBlocks: coverage.tableDataSourceBlocks || 0,
formContractBlocks: coverage.formContractBlocks || 0
},
checks,
routeAliases: duplicateRoutes,
distributions: {
byDomain,
byLayout,
byBusinessParityStatus,
byScenarioStatus,
formContractStatusCounts: formContractCounts,
blockTypeCounts: coverage.blockTypeCounts || {}
},
completionVerdict: {
structuralCoverageReady: failures.length === 0,
frontendRewriteCoverageGate: failures.length === 0,
fullBusinessParityVerified: pagesNotBusinessVerified.length === 0,
canClaimFrontendRewriteCoverageComplete: failures.length === 0,
canClaimFullBusinessParityComplete: failures.length === 0 && pagesNotBusinessVerified.length === 0,
mustNotClaim100PercentBusinessParity: pagesNotBusinessVerified.length > 0,
pendingBusinessParityPages: pagesNotBusinessVerified.length,
reason: pagesNotBusinessVerified.length === 0
? 'All generated legacy pages have verified business parity.'
: `Frontend rewrite coverage may pass independently, but ${pagesNotBusinessVerified.length} generated legacy pages still have pending business parity verification. Do not claim 100% full OFBiz business parity.`
}
}
function markdownTable(rows) {
return [
'| Check | Status | Actual |',
'| --- | --- | --- |',
...rows.map((check) => `| ${check.label.replaceAll('|', '\\|')} | ${check.status} | ${String(check.actual ?? '').replaceAll('|', '\\|')} |`)
].join('\n')
}
const md = [
'# Modern UI Coverage Verification',
'',
`Generated: ${report.generatedAt}`,
'',
`Status: ${report.status}`,
'',
'## Summary',
'',
`- Legacy view routes: ${report.summary.legacyViewRoutes}`,
`- Route manifest entries: ${report.summary.routeManifest}`,
`- Unique PageDefinitions: ${report.summary.uniquePageDefinitions}`,
`- Duplicate route aliases: ${report.summary.duplicateLegacyRouteAliases}`,
`- Legacy request actions: ${report.summary.requestActions}`,
`- ActionDefinitions: ${report.summary.actionDefinitions}`,
`- Rendered pages: ${report.summary.renderedPages}`,
`- Adapter-covered pages: ${report.summary.adapterCoveredPages}`,
`- Pending business e2e pages: ${report.summary.pendingBusinessE2ePages}`,
`- Verified business parity pages: ${report.summary.verifiedBusinessParityPages}`,
`- Pending business parity pages: ${report.verificationBoundary.pendingBusinessParityPages}`,
'',
'## Checks',
'',
markdownTable(checks),
'',
'## Completion Verdict',
'',
`- Structural coverage ready: ${report.completionVerdict.structuralCoverageReady}`,
`- Frontend rewrite coverage gate: ${report.verificationBoundary.frontendRewriteCoverageGate}`,
`- Business depth parity gate: ${report.verificationBoundary.businessDepthParityGate}`,
`- Final gate: ${report.verificationBoundary.finalGate}`,
`- Full business parity verified: ${report.completionVerdict.fullBusinessParityVerified}`,
`- Can claim frontend rewrite coverage complete: ${report.completionVerdict.canClaimFrontendRewriteCoverageComplete}`,
`- Can claim full business parity complete: ${report.completionVerdict.canClaimFullBusinessParityComplete}`,
`- Must not claim 100% business parity: ${report.completionVerdict.mustNotClaim100PercentBusinessParity}`,
`- Reason: ${report.completionVerdict.reason}`,
''
].join('\n')
await mkdir(outDir, { recursive: true })
await writeFile(outJson, JSON.stringify(report, null, 2))
await writeFile(outMd, md)
console.log(JSON.stringify({
status: report.status,
verificationBoundary: report.verificationBoundary,
summary: report.summary,
failedChecks: failures.map((check) => check.id),
incompleteChecks: incompletes.map((check) => check.id),
reports: {
json: outJson,
markdown: outMd
},
completionVerdict: report.completionVerdict
}, null, 2))
if (failures.length) {
process.exitCode = 1
}