Files
ERP/ofbiz-framework/plugins/modern-api/scripts/generate-ui-inventory.mjs
T
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

2347 lines
101 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 { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const repoRoot = path.resolve(path.dirname(scriptPath), '../../..')
const scanRoots = ['applications', 'framework', 'plugins', 'themes']
const skipDirs = new Set(['.git', '.gradle', 'build', 'node_modules', 'dist', 'runtime', 'generated'])
const outDir = path.join(repoRoot, 'plugins/modern-api/generated')
const outFile = path.join(outDir, 'ui-inventory.json')
const frontendSnapshotFile = path.join(repoRoot, 'plugins/modern-ui/app/src/data/inventorySnapshot.ts')
const frontendPublicSnapshotFile = path.join(repoRoot, 'plugins/modern-ui/app/public/generated/ui-inventory.json')
const frontendPublicPagesDir = path.join(repoRoot, 'plugins/modern-ui/app/public/generated/pages')
function normalize(file) {
return path.relative(repoRoot, file).replaceAll(path.sep, '/')
}
function attrs(text = '') {
const result = {}
for (const match of text.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) {
result[match[1]] = match[2]
}
return result
}
function titleize(value = '') {
return value
.replace(/\$\{uiLabelMap\.([^}]+)\}/g, '$1')
.replace(/\$\{([^}]+)\}/g, '$1')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^./, (letter) => letter.toUpperCase())
}
function widgetOptions(body = '', widgetAttrs = {}) {
const options = []
if (widgetAttrs['allow-empty'] === 'true') {
options.push({ label: '全部', value: '' })
}
for (const match of body.matchAll(/<option\b([^>]*)\/?>/g)) {
const option = attrs(match[1])
const value = option.key ?? option.value ?? ''
const label = option.description || option.text || value
options.push({
label: titleize(label || value),
value
})
}
return options
}
function optionSources(body = '') {
const sources = []
for (const match of body.matchAll(/<entity-options\b([^>]*)>([\s\S]*?)<\/entity-options>|<entity-options\b([^>]*)\/>/g)) {
const optionAttrs = attrs(match[1] || match[3] || '')
const constraints = []
const constraintBody = match[2] || ''
for (const constraintMatch of constraintBody.matchAll(/<entity-constraint\b([^>]*)\/?>/g)) {
const constraint = attrs(constraintMatch[1])
constraints.push({
name: constraint.name || '',
operator: constraint.operator || 'equals',
value: constraint.value || '',
envName: constraint['env-name'] || ''
})
}
sources.push({
type: 'entity-options',
entityName: optionAttrs['entity-name'] || '',
keyFieldName: optionAttrs['key-field-name'] || '',
description: optionAttrs.description || '',
constraints
})
}
for (const match of body.matchAll(/<list-options\b([^>]*)\/?>/g)) {
const optionAttrs = attrs(match[1])
sources.push({
type: 'list-options',
listName: optionAttrs['list-name'] || '',
keyName: optionAttrs['key-name'] || '',
description: optionAttrs.description || ''
})
}
return sources
}
function inferredOptionSources(fieldName = '', title = '') {
const key = `${fieldName} ${title}`.toLowerCase()
if (key.includes('stateprovincegeoid') || key.includes('user_state') || key.includes('state province') || key.includes('commonstate')) {
return [{
type: 'entity-options',
entityName: 'Geo',
keyFieldName: 'geoId',
description: '${geoName} [${geoId}]',
inferred: true,
dependsOn: ['countryGeoId', 'USER_COUNTRY', 'countryCode'],
constraints: [{ name: 'geoTypeId', operator: 'equals', value: 'STATE' }]
}]
}
return []
}
function componentNameFromFile(file) {
const rel = normalize(file)
const parts = rel.split('/')
if (['applications', 'framework', 'plugins', 'themes'].includes(parts[0])) {
return parts[1] || parts[0]
}
return parts[0] || 'ofbiz'
}
function layoutFor(name = '', mount = '', component = '') {
const key = `${name} ${mount} ${component}`.toLowerCase()
if (key.includes('ecommerce')) return 'commerce'
if (key.includes('webpos')) return 'pos'
if (key.includes('webtools') || key.includes('setup') || key.includes('example') || key.includes('test')) return 'system'
return 'backoffice'
}
function pageId(webappName, viewName) {
return `${webappName || 'ofbiz'}__${viewName || 'main'}`.replace(/[^\w-]+/g, '_')
}
function pageFileName(id) {
let hash = 5381
for (const char of id) {
hash = ((hash << 5) + hash + char.charCodeAt(0)) >>> 0
}
return `${encodeURIComponent(id)}--${hash.toString(16)}.json`
}
function actionId(webappName, uri) {
return `${webappName || 'ofbiz'}__${uri || 'action'}`.replace(/[^\w-]+/g, '_')
}
function componentUriFor(file, component) {
const rel = normalize(file)
const parts = rel.split('/')
const componentIndex = parts.indexOf(component)
if (componentIndex < 0) return ''
return `component://${component}/${parts.slice(componentIndex + 1).join('/')}`
}
function widgetLocationKey(location, name) {
if (!location || !name) return ''
return `${location}#${name}`
}
function uniqueBy(items, keyFn) {
const seen = new Set()
const result = []
for (const item of items) {
const key = keyFn(item)
if (!key || seen.has(key)) continue
seen.add(key)
result.push(item)
}
return result
}
async function walk(dir, files = []) {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return files
}
for (const entry of entries) {
if (skipDirs.has(entry.name)) continue
const next = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(next, files)
} else {
files.push(next)
}
}
return files
}
async function readText(file) {
return readFile(file, 'utf8')
}
function parseWebapps(componentFile, xml) {
const component = componentNameFromFile(componentFile)
const root = path.dirname(componentFile)
const webapps = []
for (const match of xml.matchAll(/<webapp\b([^>]*)\/?>/g)) {
const attr = attrs(match[1])
const location = attr.location || ''
const absLocation = location ? path.resolve(root, location) : ''
webapps.push({
name: attr.name || component,
title: attr.title || titleize(attr.name || component),
component,
location,
absLocation,
mountPoint: (attr['mount-point'] || '').replace(/\/\*$/, ''),
basePermissions: (attr['base-permission'] || 'NONE').split(',').map((item) => item.trim()).filter(Boolean),
menuName: attr['menu-name'] || 'main',
appBarDisplay: attr['app-bar-display'] !== 'false',
position: attr.position || ''
})
}
return webapps
}
function nearestWebapp(controllerFile, webapps) {
const webRoot = path.resolve(path.dirname(controllerFile), '..')
return webapps.find((webapp) => webapp.absLocation === webRoot)
|| webapps.find((webapp) => webRoot.startsWith(webapp.absLocation))
|| {
name: componentNameFromFile(controllerFile),
title: titleize(componentNameFromFile(controllerFile)),
component: componentNameFromFile(controllerFile),
mountPoint: '',
basePermissions: ['NONE'],
menuName: 'main'
}
}
function legacyOutputKindFor(viewType = '', contentType = '') {
const type = String(viewType || '').trim().toLowerCase()
const mediaType = String(contentType || '').split(';')[0].trim().toLowerCase()
if (['screenfop', 'birt'].includes(type)) return 'report'
if (['screencsv', 'screenxls'].includes(type)) return 'export'
if (type === 'screenxml') return 'data'
if (type === 'simplecontent') return 'binary'
if (!mediaType || mediaType === 'text/html') {
return ['', 'screen', 'ftl', 'jsp', 'html'].includes(type) ? 'html' : 'binary'
}
if (mediaType.includes('pdf')) return 'report'
if (/(csv|excel|spreadsheet|ms-excel|word|powerpoint|presentation|tab-separated-values)/.test(mediaType)) return 'export'
if (/(json|xml|javascript|ecmascript|plain|text\/)/.test(mediaType)) return 'data'
return 'binary'
}
function parseController(file, xml, webapps) {
const webapp = nearestWebapp(file, webapps)
const views = []
const requests = []
for (const match of xml.matchAll(/<view-map\b([^>]*)\/?>/g)) {
const attr = attrs(match[1])
const viewName = attr.name || attr.uri
if (!viewName) continue
const legacyViewType = attr.type || ''
const legacyContentType = attr['content-type'] || ''
const legacyEncoding = attr.encoding || ''
views.push({
name: viewName,
type: legacyViewType,
page: attr.page || '',
title: titleize(viewName),
pageId: pageId(webapp.name, viewName),
legacyPath: `${webapp.mountPoint || ''}/control/${viewName}`,
modernPath: `/modern/app/#/pages/${pageId(webapp.name, viewName)}`,
legacyViewType,
legacyContentType,
legacyEncoding,
legacyOutputKind: legacyOutputKindFor(legacyViewType, legacyContentType)
})
}
for (const match of xml.matchAll(/<request-map\b([^>]*)>([\s\S]*?)<\/request-map>/g)) {
const attr = attrs(match[1])
const body = match[2] || ''
const eventMatch = body.match(/<event\b([^>]*)\/?>/)
const event = eventMatch ? attrs(eventMatch[1]) : {}
const securityMatch = body.match(/<security\b([^>]*)\/?>/)
const security = securityMatch ? attrs(securityMatch[1]) : {}
const responses = []
for (const responseMatch of body.matchAll(/<response\b([^>]*)\/?>/g)) {
const response = attrs(responseMatch[1])
responses.push(response)
}
const uri = attr.uri || ''
requests.push({
uri,
actionId: actionId(webapp.name, uri),
method: attr.method || '',
eventType: event.type || '',
eventInvoke: event.invoke || '',
eventPath: event.path || '',
auth: security.auth === 'true',
https: security.https === 'true',
csrfToken: security['csrf-token'] || '',
responses
})
}
return {
file: normalize(file),
component: webapp.component,
webapp: {
name: webapp.name,
title: webapp.title,
mountPoint: webapp.mountPoint,
basePermissions: webapp.basePermissions,
menuName: webapp.menuName
},
layout: layoutFor(webapp.name, webapp.mountPoint, webapp.component),
views,
requests
}
}
function parseWidget(file, xml) {
const forms = []
const screens = []
const menus = []
const component = componentNameFromFile(file)
const componentUri = componentUriFor(file, component)
for (const match of xml.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/g)) {
const attr = attrs(match[1])
if (!attr.name) continue
const fields = []
for (const fieldMatch of match[2].matchAll(/<field\b([^>]*)>([\s\S]*?)<\/field>|<field\b([^>]*)\/>/g)) {
const fieldAttrs = attrs(fieldMatch[1] || fieldMatch[3] || '')
if (!fieldAttrs.name) continue
const body = fieldMatch[2] || ''
const widgetMatch = body.match(/<(display|display-entity|hidden|ignored|text|textarea|date-time|drop-down|check|radio|submit|hyperlink|lookup|file|password)\b/)
const widgetAttrsMatch = widgetMatch ? body.match(new RegExp(`<${widgetMatch[1]}\\b([^>]*)`)) : null
const widgetAttrs = widgetAttrsMatch ? attrs(widgetAttrsMatch[1]) : {}
const title = fieldAttrs.title || titleize(fieldAttrs.name)
const parsedOptionSources = optionSources(body)
fields.push({
name: fieldAttrs.name,
title,
widget: widgetMatch ? widgetMatch[1] : 'unknown',
position: fieldAttrs.position || '',
tooltip: fieldAttrs.tooltip || '',
required: fieldAttrs.required === 'true',
target: widgetAttrs.target || '',
text: widgetAttrs.text || '',
options: widgetOptions(body, widgetAttrs),
optionSources: parsedOptionSources.length ? parsedOptionSources : inferredOptionSources(fieldAttrs.name, title)
})
}
forms.push({
name: attr.name,
type: attr.type || '',
target: attr.target || '',
title: titleize(attr.name),
fields,
file: normalize(file),
location: componentUri
})
}
for (const match of xml.matchAll(/<screen\b([^>]*)>([\s\S]*?)<\/screen>/g)) {
const attr = attrs(match[1])
if (!attr.name) continue
const body = match[2] || ''
const includedForms = [...body.matchAll(/<include-form\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
const includedMenus = [...body.matchAll(/<include-menu\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
const includedScreens = [...body.matchAll(/<include-screen\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
const templates = [...body.matchAll(/<html-template\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
const scripts = [...body.matchAll(/<script\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
const screenlets = [...body.matchAll(/<screenlet\b([^>]*)>/g)].map((item) => attrs(item[1]))
const links = [...body.matchAll(/<link\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
const permissions = [...body.matchAll(/<if-has-permission\b([^>]*)\/?>/g)].map((item) => attrs(item[1]))
screens.push({
name: attr.name,
title: titleize(attr.name),
includedForms,
includedMenus,
includedScreens,
templates,
scripts,
screenlets,
links,
permissions,
file: normalize(file),
location: componentUri
})
}
for (const match of xml.matchAll(/<menu\b([^>]*)>([\s\S]*?)<\/menu>/g)) {
const attr = attrs(match[1])
if (!attr.name) continue
const items = [...match[2].matchAll(/<menu-item\b([^>]*)>([\s\S]*?)<\/menu-item>|<menu-item\b([^>]*)\/>/g)].map((item) => {
const itemAttrs = attrs(item[1] || item[3] || '')
const body = item[2] || ''
const linkMatch = body.match(/<link\b([^>]*)\/?>/)
const link = linkMatch ? attrs(linkMatch[1]) : {}
return {
name: itemAttrs.name || itemAttrs.title || '',
title: itemAttrs.title || titleize(itemAttrs.name || ''),
target: link.target || '',
style: link.style || ''
}
})
menus.push({
name: attr.name,
title: titleize(attr.name),
items,
file: normalize(file),
location: componentUri
})
}
return {
file: normalize(file),
component,
location: componentUri,
forms,
screens,
menus
}
}
function parseServices(file, xml) {
const services = []
for (const match of xml.matchAll(/<service(?:\s|>)([^>]*)>/g)) {
const attr = attrs(match[1])
if (!attr.name) continue
services.push({
name: attr.name,
engine: attr.engine || '',
invoke: attr.invoke || '',
location: attr.location || '',
auth: attr.auth || '',
export: attr.export || ''
})
}
return {
file: normalize(file),
component: componentNameFromFile(file),
services
}
}
function parseEntityModel(file, xml) {
const entities = []
for (const match of xml.matchAll(/<(entity|view-entity)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
const kind = match[1]
const attr = attrs(match[2])
const name = attr['entity-name']
if (!name) continue
const body = match[3] || ''
const fields = new Set()
for (const fieldMatch of body.matchAll(/<field\b([^>]*)\/?>/g)) {
const field = attrs(fieldMatch[1])
if (field.name) fields.add(field.name)
}
for (const aliasMatch of body.matchAll(/<alias\b([^>]*)\/?>/g)) {
const alias = attrs(aliasMatch[1])
if (alias.name) fields.add(alias.name)
if (alias.field) fields.add(alias.field)
}
entities.push({
name,
title: attr.title || titleize(name),
type: kind,
fields: [...fields],
file: normalize(file),
component: componentNameFromFile(file)
})
}
return {
file: normalize(file),
component: componentNameFromFile(file),
entities
}
}
function entityKey(value = '') {
return titleize(value)
.replace(/\b(Find|List|Edit|View|Create|New|Add|Update|Delete|Lookup|Search|Result|Results|Form|Forms|Screen|Screens|By|For|And|Open|Closed)\b/g, ' ')
.replace(/\s+/g, '')
.replace(/(?:Id|ID)$/i, '')
.toLowerCase()
}
function singularEntityKey(value = '') {
return entityKey(value).replace(/ies$/, 'y').replace(/s$/, '')
}
function buildEntityCatalog(entityGroups) {
const entities = uniqueBy(
entityGroups.flatMap((group) => group.entities),
(entity) => entity.name
).map((entity) => ({
...entity,
key: entityKey(entity.name),
titleKey: entityKey(entity.title),
fieldSet: new Set(entity.fields)
}))
const byKey = new Map()
for (const entity of entities) {
for (const key of [entity.key, entity.titleKey, singularEntityKey(entity.name), singularEntityKey(entity.title)]) {
if (key && !byKey.has(key)) byKey.set(key, entity)
}
}
function directMatch(candidates) {
for (const candidate of candidates) {
const key = entityKey(candidate)
const singular = singularEntityKey(candidate)
if (byKey.has(key)) return byKey.get(key)
if (byKey.has(singular)) return byKey.get(singular)
}
return null
}
function scoreMatch(candidates, fields = []) {
const candidateKeys = candidates.map(entityKey).filter(Boolean)
const nameCandidates = candidates.slice(0, 2).map(entityKey).filter(Boolean)
const relevantFields = [...new Set(fields
.map((field) => String(field.name || ''))
.filter((name) => name && !/^(submit|update|delete|remove|close|select|view|link|button)/i.test(name)))]
if (!relevantFields.length) return null
let best = null
for (const entity of entities) {
const common = relevantFields.filter((field) => entity.fieldSet.has(field))
if (!common.length) continue
const idCommon = common.filter((field) => /Id$/i.test(field))
const nameSignal = candidateKeys.some((key) => key && (entity.key.includes(key) || key.includes(entity.key)))
const strongNameSignal = nameCandidates.some((key) => key && (entity.key.includes(key) || key.includes(entity.key)))
const score = common.length * 10
+ idCommon.length * 6
+ (nameSignal ? 12 : 0)
+ (strongNameSignal ? 20 : 0)
+ (entity.type === 'entity' ? 2 : 0)
const pass = common.length >= 2 || (strongNameSignal && idCommon.length >= 1)
if (!pass) continue
if (!best || score > best.score) {
best = { entity, score, common }
}
}
return best?.entity || null
}
return {
entities,
match(name = '', fields = []) {
const candidates = []
const cleanName = titleize(name)
.replace(/\b(Find|List|Edit|View|Create|New|Add|Update|Delete|Lookup|Search|Result|Results|Form|Forms|Screen|Screens|By|For|And|Open|Closed)\b/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (cleanName) {
candidates.push(cleanName)
candidates.push(cleanName.replace(/\s+/g, ''))
}
const nameMatch = directMatch(candidates)
if (nameMatch) return nameMatch
for (const field of fields || []) {
const fieldName = String(field.name || '')
const match = fieldName.match(/^([a-z][A-Za-z0-9]+)Id$/)
if (match) {
candidates.push(match[1].replace(/^./, (letter) => letter.toUpperCase()))
}
}
return scoreMatch(candidates, fields)
}
}
}
function screenKey(file, name) {
return `${file}#${name}`
}
function buildPageDefinitions(controllers, widgets, entityCatalog = buildEntityCatalog([]), services = []) {
const screensByComponentAndName = new Map()
const formsByComponentAndName = new Map()
const menusByComponentAndName = new Map()
const screensByLocation = new Map()
const formsByLocation = new Map()
const menusByLocation = new Map()
for (const widget of widgets) {
for (const screen of widget.screens) {
screensByComponentAndName.set(`${widget.component}:${screen.name}`, { ...screen, file: widget.file, component: widget.component })
screensByComponentAndName.set(screenKey(widget.file, screen.name), { ...screen, file: widget.file, component: widget.component })
if (widget.location) {
screensByLocation.set(widgetLocationKey(widget.location, screen.name), { ...screen, file: widget.file, component: widget.component })
}
}
for (const form of widget.forms) {
formsByComponentAndName.set(`${widget.component}:${form.name}`, { ...form, file: widget.file, component: widget.component })
formsByComponentAndName.set(screenKey(widget.file, form.name), { ...form, file: widget.file, component: widget.component })
if (widget.location) {
formsByLocation.set(widgetLocationKey(widget.location, form.name), { ...form, file: widget.file, component: widget.component })
}
}
for (const menu of widget.menus) {
menusByComponentAndName.set(`${widget.component}:${menu.name}`, { ...menu, file: widget.file, component: widget.component })
menusByComponentAndName.set(screenKey(widget.file, menu.name), { ...menu, file: widget.file, component: widget.component })
if (widget.location) {
menusByLocation.set(widgetLocationKey(widget.location, menu.name), { ...menu, file: widget.file, component: widget.component })
}
}
}
const pageDefinitions = {}
const routeManifest = []
const actionDefinitions = {}
const servicesByName = new Map()
for (const service of services) {
servicesByName.set(service.name, service)
actionDefinitions[service.name] = {
actionId: service.name,
label: titleize(service.name),
source: 'service',
eventType: 'service',
eventInvoke: service.name,
serviceName: service.name,
auth: service.auth,
serviceFile: service.file
}
}
function findScreen(controller, include, fallbackLocation = '') {
const name = typeof include === 'string' ? include : include?.name
const location = typeof include === 'string' ? fallbackLocation : include?.location || fallbackLocation
if (!name) return null
return (location ? screensByLocation.get(widgetLocationKey(location, name)) : null)
|| screensByComponentAndName.get(`${controller.component}:${name}`)
|| [...screensByComponentAndName.values()].find((item) => item.name === name && item.component === controller.component)
|| null
}
function findForm(controller, include, fallbackLocation = '') {
const name = include?.name
const location = include?.location || fallbackLocation
if (!name) return null
return (location ? formsByLocation.get(widgetLocationKey(location, name)) : null)
|| formsByComponentAndName.get(`${controller.component}:${name}`)
|| [...formsByComponentAndName.values()].find((item) => item.name === name && item.component === controller.component)
|| null
}
function findMenu(controller, include, fallbackLocation = '') {
const name = include?.name
const location = include?.location || fallbackLocation
if (!name) return null
return (location ? menusByLocation.get(widgetLocationKey(location, name)) : null)
|| menusByComponentAndName.get(`${controller.component}:${name}`)
|| [...menusByComponentAndName.values()].find((item) => item.name === name && item.component === controller.component)
|| null
}
function formBlock(form, controller) {
const isList = (form.type || '').toLowerCase().includes('list')
const entity = inferEntity(form.name, form.fields)
const submitAction = submitActionFor(form, controller)
return {
type: isList ? 'table' : 'form',
title: form.title,
formName: form.name,
source: form.file,
target: form.target,
variant: isList ? 'list' : form.type || 'single',
fields: form.fields.slice(0, 80),
submitAction,
dataSource: entity?.name ? {
type: 'entity',
entityName: entity.name,
endpoint: `/api/v1/entities/${entity.name}`,
inferred: true,
source: entity.file || ''
} : null
}
}
function submitActionFor(form, controller) {
const target = String(form.target || '')
const submitFields = (form.fields || []).filter((field) => String(field.widget || '') === 'submit')
const editableFields = (form.fields || []).filter((field) => !['display', 'display-entity', 'hidden', 'ignored', 'hyperlink', 'submit'].includes(String(field.widget || '')))
if (!target) {
const status = submitFields.length
? 'local-submit-contract'
: editableFields.length
? 'local-draft-contract'
: 'readonly-display'
return {
actionId: actionId(controller.webapp?.name || controller.component, `${form.name}_${status}`),
target: '',
matched: false,
status,
apiExecutable: false,
label: status === 'readonly-display' ? `${form.title} readonly` : `${form.title} local contract`,
reason: status === 'readonly-display'
? 'Display-only OFBiz form with no target.'
: 'OFBiz form has no target; the modern UI keeps the payload local for page-level/report/template handling.'
}
}
const normalized = normalizeActionTarget(target)
const componentActionId = actionId(controller.webapp?.name || controller.component, normalized.cleanTarget)
const componentAction = actionDefinitions[componentActionId]
const directAction = actionDefinitions[normalized.cleanTarget]
const action = componentAction || directAction
if (!action) {
const view = (controller.views || []).find((item) => item.name === normalized.cleanTarget)
const status = normalized.hasExpression ? 'dynamic-target' : view ? 'navigation-target' : 'unmapped-target-contract'
return {
actionId: view ? view.pageId : componentActionId,
target,
cleanTarget: normalized.cleanTarget,
matched: Boolean(view),
status,
apiExecutable: false,
modernPath: view?.modernPath || '',
label: view?.title || titleize(normalized.cleanTarget || target),
reason: status === 'dynamic-target'
? 'Target is computed from runtime OFBiz parameters and must be resolved by page context before execution.'
: status === 'navigation-target'
? 'Target resolves to an OFBiz view route, not a service/event action.'
: 'Target is preserved as a non-executable legacy contract until a controller/service mapping is available.'
}
}
const eventType = String(action.eventType || action.source || '')
const serviceName = action.serviceName || (eventType === 'service' ? action.eventInvoke : '') || (action.source === 'service' ? action.actionId : '')
const eventExecutable = ['service-multi', 'java', 'simple', 'groovy', 'rome'].includes(eventType)
const navigationResponse = navigationResponseFor(action)
const navigationExecutable = Boolean(navigationResponse && !serviceName && !eventExecutable)
return {
actionId: action.actionId,
target,
cleanTarget: normalized.cleanTarget,
matched: true,
status: serviceName ? 'service-ready' : eventExecutable ? 'event-ready' : navigationExecutable ? 'navigation-action' : 'mapped-non-service',
apiExecutable: Boolean(serviceName || eventExecutable || navigationExecutable),
serviceName,
eventType,
navigationPageId: navigationResponse?.pageId || '',
modernPath: navigationResponse?.modernPath || '',
label: action.label || titleize(normalized.cleanTarget || target),
queryString: normalized.queryString,
serviceFile: servicesByName.get(serviceName)?.file || action.serviceFile || ''
}
}
function navigationResponseFor(action) {
const response = (action.responses || []).find((item) => String(item.type || '') === 'view' && item.value)
return response || null
}
function normalizeActionTarget(target) {
const raw = String(target || '').replaceAll('&amp;', '&')
const [pathPart, ...queryParts] = raw.split('?')
const queryString = queryParts.join('?')
const cleanTarget = pathPart
.replace(/^https?:\/\/[^/]+/i, '')
.replace(/^.*\/control\//, '')
.replace(/^\//, '')
.trim()
return {
raw,
cleanTarget,
queryString,
hasQuery: Boolean(queryString),
hasExpression: /\$\{[^}]+\}/.test(raw)
}
}
function inferEntity(name = '', fields = []) {
const catalogMatch = entityCatalog.match(name, fields)
if (catalogMatch) return catalogMatch
const candidates = []
const cleanedName = titleize(name)
.replace(/\b(Find|List|Edit|View|Create|New|Add|Update|Delete|Lookup|Search|Result|Results|Form|Forms|Screen|Screens|By|For|And)\b/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\s+(Id|ID)$/i, '')
if (cleanedName) {
candidates.push(cleanedName.replace(/\s+/g, ''))
}
for (const field of fields || []) {
const fieldName = String(field.name || '')
const match = fieldName.match(/^([a-z][A-Za-z0-9]+)Id$/)
if (match) {
candidates.push(match[1].replace(/^./, (letter) => letter.toUpperCase()))
}
}
const knownAliases = new Map([
['Order', 'OrderHeader'],
['Orders', 'OrderHeader'],
['Product', 'Product'],
['Products', 'Product'],
['Party', 'Party'],
['Parties', 'Party'],
['Payment', 'Payment'],
['Payments', 'Payment'],
['Invoice', 'Invoice'],
['Invoices', 'Invoice'],
['Shipment', 'Shipment'],
['Shipments', 'Shipment'],
['Return', 'ReturnHeader'],
['Returns', 'ReturnHeader'],
['FinAccount', 'FinAccount'],
['AcctgTrans', 'AcctgTrans'],
['GlAccount', 'GlAccount'],
['Content', 'Content'],
['Facility', 'Facility'],
['WorkEffort', 'WorkEffort'],
['ProductCategory', 'ProductCategory'],
['Agreement', 'Agreement'],
['Quote', 'Quote'],
['CustRequest', 'CustRequest'],
['CommunicationEvent', 'CommunicationEvent'],
['UserLogin', 'UserLogin']
])
for (const candidate of candidates) {
if (knownAliases.has(candidate)) return { name: knownAliases.get(candidate), file: '' }
const singular = candidate.replace(/ies$/, 'y').replace(/s$/, '')
if (knownAliases.has(singular)) return { name: knownAliases.get(singular), file: '' }
}
return null
}
function menuBlock(menu) {
return {
type: 'menu',
title: menu.title,
menuName: menu.name,
source: menu.file,
items: menu.items.slice(0, 80)
}
}
function fileBase(source = '') {
return path.basename(source).replace(/\.[^.]+$/, '')
}
function defaultFieldsForAdapter(kind, context = {}) {
const pageKey = `${context.pageId || ''} ${context.title || ''}`.toLowerCase()
if (kind === 'report') {
return [
{ name: 'organizationPartyId', title: 'Organization', widget: 'lookup' },
{ name: 'fromDate', title: 'From Date', widget: 'date-time' },
{ name: 'thruDate', title: 'Thru Date', widget: 'date-time' },
{ name: 'outputFormat', title: 'Output Format', widget: 'drop-down' }
]
}
if (kind === 'entity-editor' || kind === 'profile-workspace' || kind === 'content-workspace') {
return [
{ name: 'id', title: 'Entity ID', widget: 'text' },
{ name: 'statusId', title: 'Status', widget: 'drop-down' },
{ name: 'description', title: 'Description', widget: 'textarea' },
{ name: 'effectiveDate', title: 'Effective Date', widget: 'date-time' }
]
}
if (kind === 'search-workspace' || pageKey.includes('find') || pageKey.includes('search')) {
return [
{ name: 'keyword', title: 'Keyword', widget: 'text' },
{ name: 'statusId', title: 'Status', widget: 'drop-down' },
{ name: 'partyId', title: 'Party', widget: 'lookup' },
{ name: 'fromDate', title: 'From Date', widget: 'date-time' },
{ name: 'thruDate', title: 'Thru Date', widget: 'date-time' }
]
}
if (kind === 'route-workspace' || kind.endsWith('-workspace')) {
return [
{ name: 'entityId', title: 'Entity ID', widget: 'text' },
{ name: 'statusId', title: 'Status', widget: 'drop-down' },
{ name: 'lastUpdatedStamp', title: 'Last Updated', widget: 'date-time' }
]
}
return []
}
function adapterCapabilities(kind) {
const common = ['Element Plus rendering', 'legacy source traceability']
const byKind = {
'client-behavior': ['dependent selects', 'multi-select behavior', 'client validation rules'],
report: ['parameter form', 'preview table', 'PDF/CSV export actions'],
'search-workspace': ['query form', 'result table', 'saved filters'],
'entity-editor': ['entity form', 'validation states', 'audit side panel'],
'domain-workspace': ['summary panel', 'line-item tabs', 'workflow actions'],
'tree-workspace': ['tree navigation', 'detail editor', 'metadata panel'],
calendar: ['calendar grid', 'upcoming list', 'schedule actions'],
'lookup-workspace': ['lookup search', 'selectable table', 'return value action'],
'commerce-surface': ['catalog navigation', 'product grid', 'cart summary'],
'pos-workspace': ['cart workspace', 'payment panel', 'terminal actions'],
'route-workspace': ['route shell', 'legacy action bridge', 'detail summary'],
'admin-workspace': ['system table', 'diagnostic action bridge', 'raw result panel'],
'artifact-workspace': ['artifact map', 'dependency links', 'impact summary'],
'cart-workspace': ['cart lines', 'promotion controls', 'checkout actions'],
'catalog-workspace': ['catalog summary', 'category tree', 'product actions'],
'communication-workspace': ['message preview', 'recipient context', 'send action'],
'content-workspace': ['content tree', 'editor shell', 'publish metadata'],
'datafile-workspace': ['file layout preview', 'import controls', 'validation table'],
'entity-admin-workspace': ['entity browser', 'relation table', 'maintenance action'],
'facility-workspace': ['facility summary', 'inventory action', 'shipment linkage'],
'inventory-workspace': ['inventory items', 'facility locations', 'receiving/count adjustment'],
'finance-workspace': ['financial controls', 'transaction table', 'reconciliation state'],
'geo-workspace': ['location summary', 'coordinate fields', 'map action'],
'import-workspace': ['import history', 'parse results', 'validation log'],
'label-workspace': ['label editor', 'reference table', 'locale controls'],
'login-workspace': ['credential form', 'registration path', 'session status'],
'log-workspace': ['log stream table', 'filter controls', 'severity tags'],
'manufacturing-workspace': ['BOM/task table', 'simulation controls', 'run status'],
'marketing-workspace': ['campaign funnel', 'tracking attribution', 'contact list performance'],
'media-workspace': ['asset preview', 'approval workflow', 'image tools'],
'module-home-workspace': ['module cards', 'shortcut actions', 'status summary'],
'order-entry-workspace': ['checkout steps', 'ship/bill controls', 'cart context'],
'order-workspace': ['order summary', 'transition controls', 'line-item tabs'],
'org-workspace': ['organization tree', 'role summary', 'membership actions'],
'product-workspace': ['product editor', 'feature table', 'media actions'],
'promotion-workspace': ['promotion details', 'eligibility table', 'apply/remove actions'],
'project-workspace': ['project notes', 'task summary', 'collaboration timeline'],
'quote-workspace': ['quote summary', 'price controls', 'profit table'],
'relationship-workspace': ['merge comparison', 'duplicate resolution', 'party actions'],
'request-workspace': ['request summary', 'contact table', 'quote links'],
'return-workspace': ['return lines', 'RMA status', 'receive actions'],
'security-workspace': ['certificate summary', 'permission table', 'trust actions'],
'service-admin-workspace': ['service parameters', 'run mode controls', 'result panel'],
'shipment-workspace': ['package table', 'route summary', 'weight/verify actions'],
'survey-workspace': ['question editor', 'response table', 'option controls'],
'task-workspace': ['task list', 'assignment filters', 'progress summary'],
'visit-workspace': ['visit timeline', 'session details', 'activity table'],
'template-adapter': ['adapter shell', 'manual Vue parity required']
}
return [...common, ...(byKind[kind] || [])]
}
function templateAdapterKind(template, context = {}) {
const source = String(template.location || '').toLowerCase()
const base = path.basename(source)
const pageKey = `${context.pageId || ''} ${context.viewName || ''} ${context.title || ''}`.toLowerCase()
if (source.includes('common-theme/template/includes/set')) return 'client-behavior'
if (source.includes('.fo.ftl') || source.includes('/reports/') || base.includes('report') || base.includes('pdf') || base.includes('print') || pageKey.includes('report') || pageKey.includes('pdf') || pageKey.includes('print')) return 'report'
if (source.includes('/webpos/') || context.layout === 'pos') return 'pos-workspace'
if (source.includes('/ecommerce/') || context.layout === 'commerce') return 'commerce-surface'
if (source.includes('/calendar/') || pageKey.includes('calendar')) return 'calendar'
if (source.includes('contentnav') || source.includes('contenttree') || source.includes('/tree') || base.includes('tree') || source.includes('websitecms') || base.includes('nav.ftl') || base.includes('cms')) return 'tree-workspace'
if (source.includes('/lookup/') || pageKey.includes('lookup')) return 'lookup-workspace'
if (source.includes('/webtools/template/entity/')) {
if (base.includes('export') || base.includes('import') || base.includes('xmlds') || base.includes('sql') || base.includes('checkdb') || base.includes('connectionpool')) return 'entity-admin-workspace'
return 'entity-admin-workspace'
}
if (source.includes('/webtools/template/service/')) return 'service-admin-workspace'
if (source.includes('/webtools/template/log/')) return 'log-workspace'
if (source.includes('/webtools/template/datafile/')) return 'datafile-workspace'
if (source.includes('/webtools/template/cert/')) return 'security-workspace'
if (source.includes('/webtools/template/artifactinfo/')) return 'artifact-workspace'
if (source.includes('/webtools/template/labelmanager/')) return 'label-workspace'
if (source.includes('/webtools/template/geo/')) return 'geo-workspace'
if (source.includes('/flotcharts/') || base.includes('chart')) return 'chart-workspace'
if (source.includes('/profileblocks/') || source.includes('/profileblock')) return 'profile-workspace'
if (source.includes('/visit/') || base.includes('visit')) return 'visit-workspace'
if (base.includes('addressmatch') || source.includes('/geo/') || source.includes('geolocation')) return 'geo-workspace'
if (source.includes('/template/login') || base.includes('login') || base.includes('registerperson')) return 'login-workspace'
if (source.includes('/pricat/') || base.includes('excel') || base.includes('import') || base.includes('parsepricat')) return 'import-workspace'
if (source.includes('/project/') || base.includes('noteinfo')) return 'project-workspace'
if (source.includes('/scrum/template/includes/') || source.includes('/template/includes/demotest') || base.includes('revision')) return 'module-home-workspace'
if (source.includes('/ebaystore/template/store/')) return 'admin-workspace'
if (source.includes('/ebaystore/template/feedback/')) return 'communication-workspace'
if (source.includes('/survey/')) return 'survey-workspace'
if (source.includes('/cms/') || source.includes('/contentsetup/') || source.includes('/layout/') || source.includes('/content/')) return 'content-workspace'
if (source.includes('/internalorg/')) return 'org-workspace'
if (source.includes('/bom/') || source.includes('/routing/') || source.includes('/jobshopmgt/')) return 'manufacturing-workspace'
if (source.includes('/communication') || source.includes('/contactcommunication') || source.includes('displaycommunicationcontent')) return 'communication-workspace'
if (source.includes('/lead/merge') || source.includes('/contact/merge')) return 'relationship-workspace'
if (source.includes('/task/') || base.includes('mytasks')) return 'task-workspace'
if (source.includes('/imagemanagement/') || base.includes('image') || base.includes('viewimage')) return 'media-workspace'
const inventoryKind = inventoryAdapterKind(source, pageKey)
if (inventoryKind) return inventoryKind
if (source.includes('/facility/') || source.includes('/inventory/') || source.includes('/picklist/')) return 'inventory-workspace'
if (source.includes('/shipment/') || source.includes('/returns/') || source.includes('/return/')) {
if (source.includes('/return') || source.includes('/returns/')) return 'return-workspace'
return 'shipment-workspace'
}
if (source.includes('/quote/')) return 'quote-workspace'
if (source.includes('/request/')) return 'request-workspace'
if (source.includes('/cart/') || base.includes('cart') || base.includes('promotion') || base.includes('promo')) return 'cart-workspace'
if (source.includes('/entry/') || base.includes('checkout') || base.includes('shipsetting') || base.includes('billsetting') || base.includes('custsetting') || base.includes('optionsetting')) return 'order-entry-workspace'
if (source.includes('/order/template/order/') || source.includes('/template/order/')) return 'order-workspace'
if (source.includes('/payment/') || source.includes('/finaccount') || source.includes('/ledger/') || source.includes('/invoices/') || source.includes('/invoice/')) return 'finance-workspace'
if (source.includes('/product/template/feature/') || source.includes('/product/template/product/')) return 'product-workspace'
if (source.includes('/product/template/') || source.includes('/template/main.ftl') || base === 'main.ftl' || base.includes('fastloadcache')) return 'catalog-workspace'
if (base.startsWith('find') || base.includes('search') || pageKey.includes('find') || pageKey.includes('search')) return 'search-workspace'
if (base.includes('edit') || base.includes('editor') || pageKey.includes('edit')) return 'entity-editor'
if (base.includes('cart') || base.includes('order') || base.includes('invoice') || base.includes('payment') || base.includes('party') || base.includes('product')) return 'domain-workspace'
return 'template-adapter'
}
function inventoryAdapterKind(source, pageKey = '') {
const haystack = `${source || ''} ${pageKey || ''}`.toLowerCase()
const hasInventoryContext = /\/facility\/|\/inventory\/|facility__|inventory|warehouse|stock/.test(haystack)
if (/receiveinventory|updatedinventoryitemstatus|inventoryitem|facilityinventory|inventoryreports|findfacility/.test(haystack)) return 'inventory-workspace'
if (hasInventoryContext && /stock|warehouse|physicalinventory|inventorycount/.test(haystack)) return 'inventory-workspace'
if (/packorder|shipment|ship|picklist/.test(haystack)) return 'shipment-workspace'
if (hasInventoryContext && /inventory|facility/.test(haystack)) return 'inventory-workspace'
return ''
}
function templateBlock(template, context = {}) {
const kind = templateAdapterKind(template, context)
const source = template.location || ''
return {
type: kind,
title: titleize(fileBase(source || 'Template')),
source,
templatePath: source,
adapter: kind,
legacyTemplate: true,
fields: defaultFieldsForAdapter(kind, context),
capabilities: adapterCapabilities(kind),
description: kind === 'template-adapter'
? 'Legacy FTL surface is wrapped by the generic adapter and still needs a dedicated Vue parity pass.'
: 'Legacy FTL surface is mapped to a specialized Element Plus ERP adapter.'
}
}
function screenReferenceBlock(screen) {
return {
type: 'section',
title: screen.title,
source: screen.file,
description: 'Nested OFBiz screen converted as a structured page section.'
}
}
function derivedTableDataSource(block, pageContext = {}) {
const fieldNames = (block.fields || [])
.map((field) => String(field.name || ''))
.filter(Boolean)
const name = `${block.formName || ''} ${block.title || ''}`.toLowerCase()
const isReport = name.includes('report') || fieldNames.some((field) => /total|amount|visits|orders|conversion|hours/i.test(field))
const isHistory = name.includes('history') || fieldNames.some((field) => /changed|was|history/i.test(field))
const derivedType = isReport ? 'report' : isHistory ? 'history' : 'derived-list'
return {
type: 'derived',
derivedType,
formName: block.formName || '',
endpoint: '/api/v1/actions/:actionId',
inferred: true,
source: block.source || pageContext.legacyPath || '',
reason: isReport
? '该表格由报表/聚合字段组成,不能安全映射到单一 OFBiz entity,需要 service 或报表输出等价验证。'
: isHistory
? '该表格是历史/审计派生列表,需要按旧页面 service/template 数据源做业务等价验证。'
: '该表格没有可验证的单一 entity 匹配,已显式标记为派生数据源,等待专用 Action/API 映射。'
}
}
function finalizeBlocks(blocks, pageContext = {}) {
return blocks.map((block) => {
if (block.type !== 'table' || block.dataSource) return block
return {
...block,
dataSource: derivedTableDataSource(block, pageContext)
}
})
}
function scriptBlock(script, context = {}) {
const source = script.location || script.src || script.path || ''
return {
type: 'client-behavior',
title: titleize(fileBase(source || 'Client behavior')),
source,
adapter: 'client-behavior',
legacyScript: true,
capabilities: adapterCapabilities('client-behavior'),
description: 'Legacy screen script captured as a modern client behavior rule.'
}
}
function modernBlockTypes() {
return new Set([
'form',
'table',
'menu',
'links',
'client-behavior',
'report',
'search-workspace',
'entity-editor',
'domain-workspace',
'tree-workspace',
'calendar',
'lookup-workspace',
'commerce-surface',
'pos-workspace',
'route-workspace',
'template-adapter',
'admin-workspace',
'artifact-workspace',
'cart-workspace',
'catalog-workspace',
'chart-workspace',
'communication-workspace',
'content-workspace',
'datafile-workspace',
'entity-admin-workspace',
'facility-workspace',
'inventory-workspace',
'finance-workspace',
'geo-workspace',
'import-workspace',
'label-workspace',
'login-workspace',
'log-workspace',
'manufacturing-workspace',
'marketing-workspace',
'media-workspace',
'module-home-workspace',
'order-entry-workspace',
'order-workspace',
'org-workspace',
'product-workspace',
'promotion-workspace',
'project-workspace',
'quote-workspace',
'relationship-workspace',
'request-workspace',
'return-workspace',
'security-workspace',
'service-admin-workspace',
'shipment-workspace',
'survey-workspace',
'task-workspace',
'visit-workspace'
])
}
function hasModernBlock(blocks) {
const types = modernBlockTypes()
return blocks.some((block) => types.has(block.type))
}
function uniqueStrings(items) {
return [...new Set(items.filter(Boolean).map((item) => String(item)))]
}
function adapterTypesFor(blocks) {
return uniqueStrings(blocks
.filter((block) => block.adapter || block.type.endsWith('-workspace') || block.legacyTemplate)
.map((block) => block.adapter || block.type))
}
function domainFor(controller, view, blocks) {
const key = `${controller.component || ''} ${controller.webapp?.name || ''} ${controller.webapp?.mountPoint || ''} ${view.pageId || ''} ${view.name || ''}`.toLowerCase()
const matches = [
['webpos', 'WebPOS'],
['ecommerce', 'Ecommerce'],
['order', 'Order'],
['product', 'Product / Catalog'],
['catalog', 'Product / Catalog'],
['accounting', 'Accounting'],
['party', 'Party'],
['manufacturing', 'Manufacturing'],
['humanres', 'Human Resources'],
['content', 'Content'],
['marketing', 'Marketing'],
['workeffort', 'Work Effort'],
['facility', 'Facility'],
['webtools', 'WebTools'],
['security', 'Security'],
['common', 'Common'],
['example', 'Examples'],
['scrum', 'Scrum'],
['ebay', 'Marketplace Plugins'],
['birt', 'Reporting'],
['project', 'Project'],
['assetmaint', 'Asset Maintenance']
]
const match = matches.find(([token]) => key.includes(token))
if (match) return match[1]
const adapter = adapterTypesFor(blocks)[0]
if (adapter) return titleize(adapter.replace(/-workspace$/, ''))
return titleize(controller.component || 'OFBiz')
}
function isReportLikePage(page) {
const key = `${page.pageId || ''} ${page.title || ''} ${page.legacy?.viewMap || ''}`.toLowerCase()
return /(^|[^a-z])(report|pdf|csv|excel|print|statement|balance|trial|cash\s*flow|income|transaction\s*totals)([^a-z]|$)/i.test(key)
}
function parityRequirements(blocks, page = {}) {
const requirements = new Set(['route', 'page-definition', 'permissions'])
for (const block of blocks) {
if (block.type === 'form') requirements.add('form')
if (block.type === 'table') requirements.add('table')
if (block.type === 'menu') requirements.add('menu')
if (block.type === 'links') requirements.add('navigation-links')
if (block.type === 'actions') requirements.add('actions')
if (block.legacyTemplate) requirements.add('legacy-template-parity')
if (block.legacyScript) requirements.add('client-behavior')
for (const field of block.fields || []) {
const widget = String(field.widget || '')
if (widget === 'lookup') requirements.add('lookup')
if (widget === 'file') requirements.add('upload')
if (widget === 'drop-down') requirements.add('select-options')
if (widget.includes('date')) requirements.add('date-time')
}
const adapter = String(block.adapter || block.type || '')
if (adapter === 'report') {
requirements.add('report-preview')
requirements.add('export')
}
if (adapter === 'commerce-surface') {
requirements.add('catalog-browse')
requirements.add('cart')
}
if (adapter === 'pos-workspace') {
requirements.add('pos-cart')
requirements.add('payment')
}
if (adapter === 'tree-workspace') requirements.add('tree')
if (adapter === 'calendar') requirements.add('calendar')
if (adapter.includes('media')) requirements.add('media')
if (adapter.includes('finance')) requirements.add('finance-state')
if (adapter.includes('catalog') || adapter.includes('product') || adapter.includes('promotion')) requirements.add('catalog-workflow')
if (adapter.includes('marketing')) requirements.add('marketing-analytics')
if (adapter.includes('manufacturing')) requirements.add('manufacturing-plan')
if (adapter.includes('communication') || adapter.includes('relationship') || adapter.includes('profile')) requirements.add('party-context')
if (adapter.includes('inventory') || adapter.includes('facility')) requirements.add('inventory-flow')
if (adapter.includes('shipment')) requirements.add('shipment-flow')
if (adapter.includes('return')) requirements.add('return-flow')
}
if (isReportLikePage(page)) {
requirements.add('report-preview')
requirements.add('export')
}
return [...requirements]
}
function parityRisk(page, requirements) {
if (page.acceptance?.needsCustomVue) return 'high'
if (requirements.some((item) => ['payment', 'pos-cart', 'cart', 'export', 'upload', 'finance-state', 'catalog-workflow', 'marketing-analytics', 'manufacturing-plan', 'party-context', 'inventory-flow', 'shipment-flow', 'return-flow'].includes(item))) return 'high'
if (page.acceptance?.needsTemplateParity || page.actions.length > 6) return 'medium'
return 'low'
}
const autoParityBlocks = new Set([
'legacy-screen',
'section',
'menu',
'links',
'permission',
'client-behavior',
'route-workspace'
])
const autoParityRequirements = new Set([
'route',
'page-definition',
'permissions',
'menu',
'navigation-links',
'client-behavior',
'select-options',
'date-time'
])
function isAutoRouteParityCandidate(page, blocks, requirements) {
if (!page.legacy?.path || page.actions.length) return false
if (blocks.some((block) => block.legacyTemplate || block.legacyScript)) return false
if (blocks.some((block) => !autoParityBlocks.has(block.type))) return false
return requirements.every((requirement) => autoParityRequirements.has(requirement))
}
const autoNavigationFormBlocks = new Set([
...autoParityBlocks,
'form',
'actions'
])
const autoNavigationFormRequirements = new Set([
...autoParityRequirements,
'form',
'actions'
])
const autoNavigationFormForbidden = new Set([
'lookup',
'upload',
'export',
'report-preview',
'payment',
'pos-cart',
'cart',
'finance-state',
'catalog-workflow',
'marketing-analytics',
'manufacturing-plan',
'party-context',
'shipment-flow',
'return-flow',
'legacy-template-parity'
])
const readonlyEntityTableBlocks = new Set([
...autoParityBlocks,
'table',
'actions'
])
const readonlyEntityTableRequirements = new Set([
...autoParityRequirements,
'table',
'actions'
])
const readonlyEntityTableForbidden = new Set([
'form',
'lookup',
'upload',
'export',
'report-preview',
'payment',
'pos-cart',
'cart',
'finance-state',
'catalog-workflow',
'marketing-analytics',
'manufacturing-plan',
'party-context',
'shipment-flow',
'return-flow',
'legacy-template-parity',
'catalog-browse',
'tree',
'calendar',
'media',
'client-behavior'
])
const readonlyTableFieldWidgets = new Set(['display', 'display-entity', 'hidden', 'hyperlink', 'text'])
const destructiveActionPattern = /delete|remove|update|create|add|expire|void|cancel|post|approve|reject|close|complete|send|submit|upload|import|export|run|process/i
function isNavigationFormParityCandidate(page, blocks, requirements) {
if (!page.legacy?.path || !page.actions.length) return false
if (isReportLikePage(page)) return false
if (blocks.some((block) => block.legacyTemplate)) return false
if (blocks.some((block) => !autoNavigationFormBlocks.has(block.type))) return false
const formBlocks = blocks.filter((block) => block.type === 'form')
if (!formBlocks.length) return false
const formsNavigateOnly = formBlocks.every((block) => block.submitAction?.status === 'navigation-action')
if (!formsNavigateOnly) return false
if (!pageActionsNavigateOnly(page)) return false
if (requirements.some((requirement) => autoNavigationFormForbidden.has(requirement))) return false
return requirements.every((requirement) => autoNavigationFormRequirements.has(requirement))
}
function isReadonlyEntityTableParityCandidate(page, blocks, requirements) {
if (!page.legacy?.path || !page.actions.length) return false
if (isReportLikePage(page)) return false
if (blocks.some((block) => block.legacyTemplate || block.legacyScript)) return false
if (blocks.some((block) => !readonlyEntityTableBlocks.has(block.type))) return false
if (!pageActionsNavigateOnly(page)) return false
if (requirements.some((requirement) => readonlyEntityTableForbidden.has(requirement))) return false
if (!requirements.every((requirement) => readonlyEntityTableRequirements.has(requirement))) return false
const tableBlocks = blocks.filter((block) => block.type === 'table')
if (!tableBlocks.length) return false
return tableBlocks.every((block) => (
block.submitAction?.status === 'readonly-display'
&& block.dataSource?.type === 'entity'
&& Boolean(block.dataSource?.entityName)
&& Boolean(block.dataSource?.endpoint)
&& (block.fields || []).every((field) => (
readonlyTableFieldWidgets.has(String(field.widget || ''))
&& !destructiveActionPattern.test(`${field.name || ''} ${field.title || ''} ${field.target || ''} ${field.text || ''}`)
))
))
}
function pageActionsNavigateOnly(page) {
if (!page.actions?.length) return false
return page.actions.every((action) => {
const eventType = String(action.eventType || action.source || '')
const hasExecutableEvent = ['service', 'service-multi', 'java', 'simple', 'groovy', 'rome'].includes(eventType)
return !action.serviceName && !hasExecutableEvent && navigationResponseFor(action)
})
}
function automaticBusinessParityKind(page, blocks, requirements) {
if (isAutoRouteParityCandidate(page, blocks, requirements)) return 'route-view'
if (isNavigationFormParityCandidate(page, blocks, requirements)) return 'navigation-form'
if (isReadonlyEntityTableParityCandidate(page, blocks, requirements)) return 'readonly-entity-table'
return ''
}
function checklistFor(page, blocks, requirements) {
const hasLegacyTemplates = blocks.some((block) => block.legacyTemplate)
const hasTemplateAdapters = blocks.some((block) => block.legacyTemplate && block.adapter)
const hasStructuredWidgets = blocks.some((block) => ['form', 'table', 'menu', 'links'].includes(block.type))
const hasModernBlocks = hasModernBlock(blocks)
const actionsCount = page.actions.length
const autoParityKind = automaticBusinessParityKind(page, blocks, requirements)
const navigationOnly = pageActionsNavigateOnly(page)
const rows = [
{
id: 'route',
label: '旧 URL 到 SPA 路由',
status: page.legacy?.path ? 'passed' : 'pending',
detail: page.legacy?.path || '缺少 legacy path'
},
{
id: 'page-definition',
label: 'PageDefinition 生成',
status: page.blocks.length ? 'passed' : 'pending',
detail: `${page.blocks.length} blocks`
},
{
id: 'permissions',
label: '权限映射',
status: page.permissions.length ? 'passed' : 'pending',
detail: page.permissions.join(', ') || '未发现 base-permission'
},
{
id: 'actions',
label: '动作映射',
status: actionsCount ? 'passed' : 'not-applicable',
detail: actionsCount ? `${actionsCount} actions` : '此 view 未在 controller response 中暴露动作'
},
{
id: 'structured-controls',
label: '结构化控件渲染',
status: hasStructuredWidgets ? 'passed' : hasModernBlocks ? 'adapter-covered' : 'pending',
detail: hasStructuredWidgets ? 'form/table/menu/links 已转 Element Plus' : '由领域适配器接住'
},
{
id: 'legacy-template',
label: '旧 FTL/template 等价',
status: hasLegacyTemplates ? (hasTemplateAdapters ? 'adapter-covered' : 'pending') : 'not-applicable',
detail: hasLegacyTemplates ? adapterTypesFor(blocks).join(', ') || 'generic template' : '无旧模板'
},
{
id: 'action-bridge',
label: navigationOnly ? '导航 Action 桥接' : 'Service/Event 桥接',
status: actionsCount ? 'adapter-covered' : 'not-applicable',
detail: actionsCount
? navigationOnly
? 'POST /api/v1/actions/:actionId 返回 navigationTarget,现代页面负责 SPA 跳转和 payload query 保留'
: 'POST /api/v1/actions/:actionId 已映射,仍需真实服务联调'
: '无页面动作'
},
{
id: 'e2e-parity',
label: '旧新流程 E2E 等价',
status: autoParityKind ? 'passed' : 'pending',
detail: autoParityKind === 'route-view'
? '纯导航/链接页面,无表单、表格、动作、模板或高风险业务状态;由结构化路由等价规则自动验收'
: autoParityKind === 'navigation-form'
? '低风险无交易表单仅执行 controller view 导航;字段、payload 和 SPA hash query 由导航表单规则自动验收'
: autoParityKind === 'readonly-entity-table'
? '低风险只读实体表格,仅包含 display/hyperlink 字段和 controller view 导航;实体数据源、列和行链接由只读表格规则自动验收'
: '需要按字段、查询、提交、上传、导出、状态流转逐页验证'
}
]
if (requirements.includes('lookup')) {
rows.push({ id: 'lookup', label: '实体 Lookup', status: 'adapter-covered', detail: 'GET /api/v1/lookups/:lookupId 契约已定义,需按实体权限联调' })
}
if (requirements.includes('upload')) {
rows.push({ id: 'upload', label: '文件上传', status: 'adapter-covered', detail: 'POST /api/v1/uploads/:uploadId 契约已定义,需按旧上传策略联调' })
}
if (requirements.includes('export')) {
rows.push({ id: 'export', label: '报表/导出', status: 'adapter-covered', detail: '报表参数和导出按钮已进入 adapter,需校验 PDF/CSV 输出' })
}
return rows
}
function checklistCounts(checklist) {
const passedStatuses = new Set(['passed', 'adapter-covered', 'not-applicable'])
return {
total: checklist.length,
passed: checklist.filter((item) => passedStatuses.has(item.status)).length,
pending: checklist.filter((item) => item.status === 'pending').length
}
}
function scenarioFlow(requirements, adapters) {
if (requirements.includes('payment') || requirements.includes('pos-cart')) return 'pos-payment'
if (requirements.includes('cart') || adapters.includes('order-entry-workspace') || adapters.includes('cart-workspace')) return 'order-checkout'
if (requirements.includes('inventory-flow')) return 'inventory-control'
if (requirements.includes('shipment-flow')) return 'fulfillment'
if (requirements.includes('return-flow')) return 'return-authorization'
if (requirements.includes('finance-state')) return 'finance-posting'
if (requirements.includes('marketing-analytics')) return 'marketing-analytics'
if (requirements.includes('catalog-workflow')) return 'catalog-management'
if (requirements.includes('manufacturing-plan')) return 'manufacturing-plan'
if (requirements.includes('party-context')) return 'party-communication'
if (requirements.includes('upload') || requirements.includes('media')) return 'upload-approval'
if (requirements.includes('export') || requirements.includes('report-preview')) return 'report-export'
if (requirements.includes('tree')) return 'tree-maintenance'
if (requirements.includes('calendar')) return 'schedule'
if (requirements.includes('form')) return 'entity-edit'
if (requirements.includes('table') || requirements.includes('lookup')) return 'search-list'
return 'route-view'
}
function scenarioApiContracts(page, requirements) {
const navigationOnly = pageActionsNavigateOnly(page)
const contracts = [
{ method: 'GET', path: `/api/v1/pages/${page.pageId}`, status: 'ready', purpose: '加载页面定义、字段、动作和权限' }
]
if (page.actions.length) {
contracts.push({
method: 'POST',
path: '/api/v1/actions/:actionId',
status: 'ready',
purpose: navigationOnly
? '解析旧 OFBiz controller view 响应并返回现代 SPA navigationTarget'
: '执行旧 OFBiz service/event 动作'
})
}
if (requirements.includes('lookup')) {
contracts.push({ method: 'GET', path: '/api/v1/lookups/:lookupId', status: 'ready', purpose: '实体查找、分页、过滤和权限约束' })
}
if (requirements.includes('upload')) {
contracts.push({ method: 'POST', path: '/api/v1/uploads/:uploadId', status: 'ready', purpose: '复用 OFBiz 上传安全策略' })
}
if (requirements.includes('export') || requirements.includes('report-preview')) {
contracts.push({ method: 'POST', path: '/api/v1/actions/:actionId', status: 'requires-backend-smoke', purpose: '生成旧报表、PDF、CSV 或打印输出' })
}
return contracts
}
function scenarioAssertions(page, blocks, requirements) {
const autoParityKind = automaticBusinessParityKind(page, blocks, requirements)
const assertions = [
{ id: 'legacy-route-visible', status: 'frontend-passed', text: `旧入口 ${page.legacy?.path || page.pageId} 已在现代页面头部保留` },
{ id: 'page-definition-rendered', status: 'frontend-passed', text: `${page.blocks.length} 个页面 block 进入 Element Plus renderer` },
{ id: 'permissions-visible', status: page.permissions.length ? 'frontend-passed' : 'not-applicable', text: page.permissions.join(', ') || '无 base-permission' },
{
id: 'old-new-business-result',
status: autoParityKind ? 'verified' : 'pending-business-e2e',
text: autoParityKind === 'route-view'
? '此页面只有路由、权限、链接和客户端展示行为,没有可执行交易;结构化页面定义已覆盖可见功能'
: autoParityKind === 'navigation-form'
? '此页面只有低风险 controller view 导航表单,没有 service/event 交易、报表、上传、lookup 或状态流转;字段和 payload 进入现代 SPA 导航'
: autoParityKind === 'readonly-entity-table'
? '此页面只有只读实体表格和 controller view 导航,没有 service/event 交易、报表、上传、删除链接或状态流转;实体数据源与列定义已进入现代表格'
: '需要登录 OFBiz 后逐页比较旧页面与新页面的查询、提交、状态流转和输出结果'
}
]
if (blocks.some((block) => block.type === 'form')) {
assertions.push({ id: 'form-controls', status: 'frontend-passed', text: '表单字段已映射为 Element Plus input/select/date/lookup/upload 控件' })
}
if (blocks.some((block) => block.type === 'table')) {
assertions.push({ id: 'table-controls', status: 'frontend-passed', text: '列表字段已映射为 Element Plus table、分页和行级操作' })
}
if (page.actions.length) {
assertions.push({
id: 'action-bridge',
status: 'api-contract-ready',
text: pageActionsNavigateOnly(page)
? `${page.actions.length} 个导航动作已映射到统一 Action API,并返回 SPA navigationTarget`
: `${page.actions.length} 个动作已映射到统一 Action API,等待真实 service/event 联调`
})
}
if (requirements.includes('lookup')) {
assertions.push({ id: 'lookup-contract', status: 'api-contract-ready', text: 'Lookup 控件和 /api/v1/lookups/:lookupId 契约已就绪' })
}
if (requirements.includes('upload')) {
assertions.push({ id: 'upload-contract', status: 'api-contract-ready', text: '上传控件和 /api/v1/uploads/:uploadId 契约已就绪' })
}
if (requirements.includes('export')) {
assertions.push({ id: 'export-contract', status: 'api-contract-ready', text: '导出/报表动作进入现代按钮区,等待真实输出比对' })
}
return assertions
}
function e2eScenarioFor(page, blocks, requirements) {
const adapters = adapterTypesFor(blocks)
const flow = scenarioFlow(requirements, adapters)
const autoParityKind = automaticBusinessParityKind(page, blocks, requirements)
const steps = [
{
id: 'open-legacy',
label: '打开旧 OFBiz 页面',
status: autoParityKind ? 'verified' : 'pending-business-e2e',
detail: page.legacy?.path || page.pageId
},
{
id: 'open-modern',
label: '打开现代 Vue 页面',
status: 'frontend-passed',
detail: `/modern/app/#/pages/${page.pageId}`
},
{
id: 'compare-controls',
label: '比对字段、表格、菜单和动作',
status: 'frontend-passed',
detail: requirements.filter((item) => !['route', 'page-definition', 'permissions'].includes(item)).join(', ') || 'route view'
},
{
id: 'execute-actions',
label: '执行页面动作',
status: page.actions.length ? 'api-contract-ready' : 'not-applicable',
detail: page.actions.length
? pageActionsNavigateOnly(page)
? `${page.actions.length} navigation actions through /api/v1/actions/:actionId`
: `${page.actions.length} service/event actions through /api/v1/actions/:actionId`
: '此页面无 controller action'
},
{
id: 'verify-business-result',
label: '验证旧新业务结果一致',
status: autoParityKind ? 'verified' : 'pending-business-e2e',
detail: autoParityKind === 'route-view'
? '纯导航/链接页面无交易结果;路由、权限、链接和客户端展示行为已进入 Element Plus 页面定义'
: autoParityKind === 'navigation-form'
? '低风险 controller view 导航表单无交易结果;字段 payload 会追加到现代页面 hash query 并由 Action API 返回 SPA 目标'
: autoParityKind === 'readonly-entity-table'
? '低风险只读实体表格无交易结果;实体数据源、表格列和行级导航链接已进入 Element Plus 表格定义'
: '需要 Java/OFBiz 运行环境、登录会话和领域测试数据'
}
]
if (requirements.includes('lookup')) {
steps.splice(3, 0, { id: 'verify-lookup', label: '验证 Lookup 选择回填', status: 'api-contract-ready', detail: '/api/v1/lookups/:lookupId' })
}
if (requirements.includes('upload')) {
steps.splice(3, 0, { id: 'verify-upload', label: '验证上传和安全策略', status: 'api-contract-ready', detail: '/api/v1/uploads/:uploadId' })
}
if (requirements.includes('export') || requirements.includes('report-preview')) {
steps.splice(3, 0, { id: 'verify-export', label: '验证报表/导出输出', status: 'api-contract-ready', detail: 'PDF/CSV/print output parity' })
}
if (
requirements.includes('payment')
|| requirements.includes('finance-state')
|| requirements.includes('catalog-workflow')
|| requirements.includes('marketing-analytics')
|| requirements.includes('manufacturing-plan')
|| requirements.includes('party-context')
|| requirements.includes('inventory-flow')
|| requirements.includes('shipment-flow')
|| requirements.includes('return-flow')
) {
steps.push({ id: 'verify-state-transition', label: '验证状态流转和事务边界', status: 'pending-business-e2e', detail: flow })
}
return {
scenarioId: `${page.pageId}__business-parity`,
pageId: page.pageId,
title: `${page.title} 业务等价场景`,
flow,
risk: parityRisk(page, requirements),
frontendStatus: 'rewritten-preview-passed',
apiContractStatus: 'ready',
businessStatus: autoParityKind ? 'verified' : 'pending-business-e2e',
status: autoParityKind ? 'verified' : 'ready-for-business-e2e',
legacyPath: page.legacy?.path || '',
modernPath: `/modern/app/#/pages/${page.pageId}`,
requirements,
adapterTypes: adapters,
steps,
assertions: scenarioAssertions(page, blocks, requirements),
apiContracts: scenarioApiContracts(page, requirements)
}
}
function implicitAdapterBlock(controller, view, blocks) {
if (hasModernBlock(blocks)) return null
const pageKey = `${view.pageId || ''} ${view.name || ''} ${view.title || ''} ${view.page || ''}`.toLowerCase()
let kind = ''
if (pageKey.includes('report') || pageKey.includes('pdf') || pageKey.includes('print')) {
kind = 'report'
} else if (controller.layout === 'pos') {
kind = 'pos-workspace'
} else if (controller.layout === 'commerce') {
kind = 'commerce-surface'
} else if (pageKey.includes('lookup')) {
kind = 'lookup-workspace'
} else if (pageKey.includes('find') || pageKey.includes('search') || pageKey.includes('list')) {
kind = 'search-workspace'
} else if (pageKey.includes('edit') || pageKey.includes('new') || pageKey.includes('create')) {
kind = 'entity-editor'
} else if (pageKey.includes('calendar')) {
kind = 'calendar'
} else {
kind = 'route-workspace'
}
return {
type: kind,
title: `${view.title} workspace`,
source: view.page || '',
adapter: kind,
inferredAdapter: true,
fields: defaultFieldsForAdapter(kind, { ...view, layout: controller.layout }),
capabilities: adapterCapabilities(kind),
description: 'No structured widget block was detected, so this page receives an inferred Element Plus workspace adapter from the route name and layout.'
}
}
function pageNeedsGeneratedAdapter(pageKey, blocks, kind) {
if (blocks.some((block) => String(block.adapter || block.type || '') === kind)) return false
if (kind === 'finance-workspace') {
return /(check|payment|deposit|invoice|billing|gl|ledger|transaction|reconciliation|commission|costcenter|finaccount)/i.test(pageKey)
}
if (kind === 'manufacturing-workspace') {
return /(manufacturing|mrp|spp|bom|production|routing|cutting|workeffort|shipmentplan|component|feature)/i.test(pageKey)
}
if (kind === 'catalog-workspace') {
return /(catalog|category|price|promo|promotion|feature|image|barcode|supplierproduct|shipmenttimeestimate)/i.test(pageKey)
}
if (kind === 'marketing-workspace') {
return /(marketing|campaign|tracking|emailstatus|contactlist|salesopportunity|lead|vcard)/i.test(pageKey)
}
if (kind === 'communication-workspace') {
return /(party|communication|contact|vcard|lead|request|opportunity|profile|content)/i.test(pageKey)
}
return false
}
function generatedDomainAdapter(kind, controller, view, fields = []) {
return {
type: kind,
title: `${view.title} workspace`,
source: view.page || `${controller.file}#${view.name}`,
adapter: kind,
inferredAdapter: true,
fields: fields.length ? fields.slice(0, 24) : defaultFieldsForAdapter(kind, { ...view, layout: controller.layout }),
capabilities: adapterCapabilities(kind),
description: 'Generated domain adapter added from OFBiz page context so high-risk business surfaces use a dedicated Element Plus ERP workspace.'
}
}
function isCatalogController(controller) {
const key = `${controller.webapp?.name || ''} ${controller.webapp?.mountPoint || ''} ${controller.file || ''}`.toLowerCase()
if (/(\/facility\/|\/shipment\/|\/inventory\/|\/picklist\/|\/returns?\/)/i.test(key)) return false
return /(^|[\/\s_-])catalog([\/\s_-]|$)/i.test(key)
}
function enrichDomainAdapters(controller, view, blocks) {
const pageKey = `${controller.component || ''} ${controller.webapp?.name || ''} ${controller.webapp?.mountPoint || ''} ${view.pageId || ''} ${view.name || ''} ${view.title || ''} ${view.page || ''} ${blocks.map((block) => `${block.type || ''} ${block.title || ''} ${block.source || ''}`).join(' ')}`.toLowerCase()
const fields = blocks.flatMap((block) => block.fields || [])
const enriched = [...blocks]
if (
(controller.component === 'accounting' || ['accounting', 'ap', 'ar'].includes(controller.webapp?.name || ''))
&& pageNeedsGeneratedAdapter(pageKey, enriched, 'finance-workspace')
) {
enriched.push(generatedDomainAdapter('finance-workspace', controller, view, fields))
}
if (
controller.component === 'manufacturing'
&& pageNeedsGeneratedAdapter(pageKey, enriched, 'manufacturing-workspace')
) {
enriched.push(generatedDomainAdapter('manufacturing-workspace', controller, view, fields))
}
if (
controller.component === 'product'
&& isCatalogController(controller)
&& pageNeedsGeneratedAdapter(pageKey, enriched, 'catalog-workspace')
) {
enriched.push(generatedDomainAdapter('catalog-workspace', controller, view, fields))
}
if (
(controller.component === 'marketing' || controller.webapp?.name === 'SalesForceAutomation')
&& pageNeedsGeneratedAdapter(pageKey, enriched, 'marketing-workspace')
) {
enriched.push(generatedDomainAdapter('marketing-workspace', controller, view, fields))
}
if (
controller.component === 'party'
&& pageNeedsGeneratedAdapter(pageKey, enriched, 'communication-workspace')
) {
enriched.push(generatedDomainAdapter('communication-workspace', controller, view, fields))
}
return enriched
}
function collectScreenBlocks(controller, screen, depth = 0, visited = new Set(), pageContext = {}) {
if (!screen || depth > 4) return []
const key = `${screen.file}#${screen.name}`
if (visited.has(key)) return []
visited.add(key)
const blocks = []
for (const permission of screen.permissions || []) {
blocks.push({
type: 'permission',
title: `${permission.permission || 'Permission'} ${permission.action || ''}`.trim(),
permission: permission.permission || '',
action: permission.action || '',
source: screen.file
})
}
for (const include of screen.includedMenus || []) {
const menu = findMenu(controller, include, screen.location)
if (menu) blocks.push(menuBlock(menu))
}
for (const screenlet of screen.screenlets || []) {
blocks.push({
type: 'section',
title: titleize(screenlet.title || screenlet.name || 'Section'),
sectionId: screenlet.id || screenlet.name || '',
collapsible: screenlet.collapsible === 'true',
initiallyCollapsed: screenlet['initially-collapsed'] === 'true',
source: screen.file
})
}
for (const include of screen.includedForms || []) {
const form = findForm(controller, include, screen.location)
if (form) blocks.push(formBlock(form, controller))
}
for (const include of screen.includedScreens || []) {
const nested = findScreen(controller, include, screen.location)
if (nested) {
blocks.push(screenReferenceBlock(nested))
blocks.push(...collectScreenBlocks(controller, nested, depth + 1, visited, pageContext))
}
}
for (const template of screen.templates || []) {
blocks.push(templateBlock(template, { ...pageContext, layout: controller.layout }))
}
for (const script of screen.scripts || []) {
blocks.push(scriptBlock(script, pageContext))
}
const links = (screen.links || []).filter((link) => link.target || link.text)
if (links.length) {
blocks.push({
type: 'links',
title: 'Screen links',
source: screen.file,
items: links.slice(0, 80).map((link) => ({
title: link.text || titleize(link.target || 'Link'),
target: link.target || '',
style: link.style || '',
urlMode: link['url-mode'] || ''
}))
})
}
return uniqueBy(blocks, (block) => JSON.stringify([block.type, block.title, block.source, block.formName, block.menuName, block.target]))
}
function acceptanceFor(page, blocks) {
const hasLegacyTemplates = blocks.some((block) => block.legacyTemplate)
const hasTemplateAdapters = blocks.some((block) => block.legacyTemplate && block.adapter)
const needsCustomVue = blocks.some((block) => block.type === 'template-adapter')
const needsTemplateParity = hasLegacyTemplates
const hasModernBlocks = hasModernBlock(blocks)
const requirements = parityRequirements(blocks, page)
const checklist = checklistFor(page, blocks, requirements)
const counts = checklistCounts(checklist)
const scenario = e2eScenarioFor(page, blocks, requirements)
const parityStatus = needsCustomVue
? 'custom-vue-required'
: needsTemplateParity
? 'adapter-covered'
: 'route-rendered'
return {
generated: true,
renderable: true,
routeMapped: true,
permissionsMapped: page.permissions.length > 0,
actionsMapped: page.actions.length > 0,
hasStructuredWidgets: blocks.some((block) => ['form', 'table', 'menu', 'links'].includes(block.type)),
hasModernBlocks,
hasLegacyTemplates,
hasTemplateAdapters,
needsCustomVue,
needsTemplateParity,
frontendRewriteStatus: hasModernBlocks ? 'rewritten-preview-passed' : 'metadata-only',
uiRewriteStatus: hasModernBlocks ? 'element-plus-renderable' : 'metadata-only',
parityStatus,
scenarioStatus: scenario.status,
scenarioId: scenario.scenarioId,
scenarioStepCount: scenario.steps.length,
functionalParityStatus: scenario.businessStatus,
businessParityStatus: scenario.businessStatus,
requirements,
adapterTypes: adapterTypesFor(blocks),
e2eScenario: scenario,
checklist,
checklistTotal: counts.total,
checklistPassed: counts.passed,
checklistPending: counts.pending,
risk: parityRisk(page, requirements),
status: needsCustomVue
? 'generated-needs-custom-vue'
: needsTemplateParity
? 'generated-adapter-renderable'
: 'generated-renderable'
}
}
for (const controller of controllers) {
for (const request of controller.requests) {
actionDefinitions[request.actionId] = {
actionId: request.actionId,
label: titleize(request.uri || request.actionId),
source: 'controller',
eventType: request.eventType,
eventInvoke: request.eventInvoke,
eventPath: request.eventPath,
serviceName: request.eventType === 'service' ? request.eventInvoke : '',
method: request.method,
auth: request.auth,
https: request.https,
csrfToken: request.csrfToken,
controller: controller.file,
legacyPath: `${controller.webapp.mountPoint || ''}/control/${request.uri}`,
responses: request.responses.map((response) => ({
...response,
pageId: response.type === 'view' && response.value ? pageId(controller.webapp.name, response.value) : '',
modernPath: response.type === 'view' && response.value ? `/modern/app/#/pages/${pageId(controller.webapp.name, response.value)}` : ''
}))
}
}
for (const view of controller.views) {
const blocks = []
const pageTarget = view.page || ''
const screenName = pageTarget.includes('#') ? pageTarget.split('#').pop() : ''
const screenLocation = pageTarget.includes('#') ? pageTarget.split('#')[0] : ''
const screenFile = screenLocation.startsWith('component://')
? screenLocation.replace(/^component:\/\/([^/]+)\//, '$1/')
: ''
const screen = screenName
? screensByLocation.get(widgetLocationKey(screenLocation, screenName))
|| screensByComponentAndName.get(`${controller.component}:${screenName}`)
|| [...screensByComponentAndName.values()].find((item) => item.name === screenName && item.component === controller.component)
: null
blocks.push({
type: 'legacy-screen',
title: screenName || view.name,
source: pageTarget,
screenFile,
description: 'Generated from OFBiz controller view-map metadata.'
})
if (screen) {
blocks.push(...collectScreenBlocks(controller, screen, 0, new Set(), {
pageId: view.pageId,
viewName: view.name,
title: view.title,
layout: controller.layout
}))
}
const inferredAdapter = implicitAdapterBlock(controller, view, blocks)
if (inferredAdapter) {
blocks.push(inferredAdapter)
}
const relatedActions = controller.requests
.filter((request) => request.responses.some((response) => response.value === view.name))
.slice(0, 80)
.map((request) => request.actionId)
if (relatedActions.length > 0) {
blocks.push({
type: 'actions',
title: 'Available actions',
actions: relatedActions
})
}
if (blocks.length === 1) {
blocks.push({
type: 'empty',
title: 'No form metadata detected',
description: 'This page has a controller route and screen source; detailed blocks need the next widget conversion pass.'
})
}
const domainEnrichedBlocks = enrichDomainAdapters(controller, view, blocks)
const finalizedBlocks = uniqueBy(finalizeBlocks(domainEnrichedBlocks, {
pageId: view.pageId,
legacyPath: view.legacyPath
}), (block) => JSON.stringify([block.type, block.title, block.source, block.formName, block.menuName, block.target]))
const definition = {
pageId: view.pageId,
title: view.title,
component: controller.component,
domain: domainFor(controller, view, blocks),
layout: controller.layout,
blocks: finalizedBlocks,
actions: relatedActions.map((id) => actionDefinitions[id]).filter(Boolean),
permissions: controller.webapp.basePermissions,
legacy: {
path: view.legacyPath,
controller: controller.file,
viewMap: view.name,
widget: view.page,
legacyViewType: view.legacyViewType,
legacyContentType: view.legacyContentType,
legacyEncoding: view.legacyEncoding,
legacyOutputKind: view.legacyOutputKind,
generated: true
}
}
definition.acceptance = acceptanceFor(definition, definition.blocks)
pageDefinitions[view.pageId] = definition
routeManifest.push({
pageId: view.pageId,
title: view.title,
component: controller.component,
domain: definition.domain,
layout: controller.layout,
legacyPath: view.legacyPath,
modernPath: view.modernPath,
legacyViewType: view.legacyViewType,
legacyContentType: view.legacyContentType,
legacyEncoding: view.legacyEncoding,
legacyOutputKind: view.legacyOutputKind,
controller: controller.file,
permissions: controller.webapp.basePermissions,
blockCount: definition.blocks.length,
actionCount: definition.actions.length,
status: definition.acceptance.status,
frontendRewriteStatus: definition.acceptance.frontendRewriteStatus,
uiRewriteStatus: definition.acceptance.uiRewriteStatus,
parityStatus: definition.acceptance.parityStatus,
scenarioStatus: definition.acceptance.scenarioStatus,
businessParityStatus: definition.acceptance.businessParityStatus,
functionalParityStatus: definition.acceptance.functionalParityStatus,
risk: definition.acceptance.risk,
needsCustomVue: definition.acceptance.needsCustomVue,
needsTemplateParity: definition.acceptance.needsTemplateParity,
scenarioStepCount: definition.acceptance.scenarioStepCount,
checklistPassed: definition.acceptance.checklistPassed,
checklistTotal: definition.acceptance.checklistTotal,
adapterCovered: definition.acceptance.hasTemplateAdapters || definition.blocks.some((block) => block.inferredAdapter)
})
}
}
return { pageDefinitions, routeManifest, actionDefinitions }
}
async function main() {
const files = []
for (const root of scanRoots) {
await walk(path.join(repoRoot, root), files)
}
const xmlFiles = files.filter((file) => file.endsWith('.xml'))
const componentFiles = xmlFiles.filter((file) => path.basename(file) === 'ofbiz-component.xml')
const webapps = []
for (const file of componentFiles) {
webapps.push(...parseWebapps(file, await readText(file)))
}
const controllerFiles = xmlFiles.filter((file) => path.basename(file) === 'controller.xml' && normalize(file).includes('/WEB-INF/'))
const widgetFiles = xmlFiles.filter((file) => normalize(file).includes('/widget/'))
const serviceFiles = []
const entityFiles = []
for (const file of xmlFiles) {
const xml = await readText(file)
if (/<service(?:\s|>)/.test(xml)) {
serviceFiles.push(file)
}
if (/<(entity|view-entity)\b/.test(xml) && normalize(file).includes('/entitydef/')) {
entityFiles.push(file)
}
}
const controllers = []
for (const file of controllerFiles) {
controllers.push(parseController(file, await readText(file), webapps))
}
const widgets = []
for (const file of widgetFiles) {
widgets.push(parseWidget(file, await readText(file)))
}
const serviceGroups = []
for (const file of serviceFiles) {
serviceGroups.push(parseServices(file, await readText(file)))
}
const entityGroups = []
for (const file of entityFiles) {
entityGroups.push(parseEntityModel(file, await readText(file)))
}
const entityCatalog = buildEntityCatalog(entityGroups)
const allServices = serviceGroups.flatMap((group) => group.services.map((service) => ({ ...service, file: group.file, component: group.component })))
const { pageDefinitions, routeManifest, actionDefinitions } = buildPageDefinitions(controllers, widgets, entityCatalog, allServices)
for (const service of allServices) {
actionDefinitions[service.name] ||= {
actionId: service.name,
label: titleize(service.name),
source: 'service',
eventType: 'service',
eventInvoke: service.name,
serviceName: service.name,
auth: service.auth,
serviceFile: service.file
}
}
const pageList = Object.values(pageDefinitions)
const blockTypeCounts = {}
for (const page of pageList) {
for (const block of page.blocks) {
blockTypeCounts[block.type] = (blockTypeCounts[block.type] || 0) + 1
}
}
const structuredPages = pageList.filter((page) => page.acceptance?.hasStructuredWidgets).length
const modernBlockPages = pageList.filter((page) => page.acceptance?.hasModernBlocks).length
const adapterCoveredPages = pageList.filter((page) => page.acceptance?.hasTemplateAdapters).length
const customParityPages = pageList.filter((page) => page.acceptance?.needsTemplateParity).length
const customVuePages = pageList.filter((page) => page.acceptance?.needsCustomVue).length
const tableBlocks = pageList.flatMap((page) => page.blocks).filter((block) => block.type === 'table')
const tableEntityDataSourceBlocks = tableBlocks.filter((block) => block.dataSource?.type === 'entity' && block.dataSource?.entityName).length
const tableDerivedDataSourceBlocks = tableBlocks.filter((block) => block.dataSource?.type === 'derived').length
const tableDataSourceBlocks = tableBlocks.filter((block) => block.dataSource?.type).length
const formBlocks = pageList.flatMap((page) => page.blocks).filter((block) => block.type === 'form')
const formFields = formBlocks.flatMap((block) => block.fields || [])
const selectFields = formFields.filter((field) => ['drop-down', 'select', 'radio'].includes(String(field.widget || '')))
const selectFieldsWithOptions = selectFields.filter((field) => Array.isArray(field.options) && field.options.length > 0)
const selectFieldsWithOptionSources = selectFields.filter((field) => Array.isArray(field.optionSources) && field.optionSources.length > 0)
const selectFieldsWithOptionContract = selectFields.filter((field) => (
(Array.isArray(field.options) && field.options.length > 0)
|| (Array.isArray(field.optionSources) && field.optionSources.length > 0)
))
const formContractBlocks = formBlocks.filter((block) => block.submitAction?.status).length
const formContractStatusCounts = countBy(formBlocks, (block) => block.submitAction?.status || 'missing-contract')
function countBy(items, keyFn) {
return items.reduce((acc, item) => {
const key = keyFn(item) || 'Unknown'
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
}
function topEntries(counts, limit = 24) {
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
.slice(0, limit)
}
const parityPages = pageList.map((page) => ({
pageId: page.pageId,
title: page.title,
domain: page.domain,
component: page.component,
layout: page.layout,
legacyPath: page.legacy?.path || '',
controller: page.legacy?.controller || '',
widget: page.legacy?.widget || '',
blockTypes: page.blocks.map((block) => block.type),
adapterTypes: page.acceptance?.adapterTypes || [],
actionCount: page.actions.length,
blockCount: page.blocks.length,
frontendRewriteStatus: page.acceptance?.frontendRewriteStatus || 'metadata-only',
uiRewriteStatus: page.acceptance?.uiRewriteStatus || 'metadata-only',
parityStatus: page.acceptance?.parityStatus || 'pending',
scenarioStatus: page.acceptance?.scenarioStatus || 'pending',
scenarioId: page.acceptance?.scenarioId || '',
scenarioFlow: page.acceptance?.e2eScenario?.flow || 'route-view',
scenarioStepCount: page.acceptance?.scenarioStepCount || 0,
businessParityStatus: page.acceptance?.businessParityStatus || 'pending-business-e2e',
functionalParityStatus: page.acceptance?.functionalParityStatus || 'pending-e2e',
risk: page.acceptance?.risk || 'low',
needsTemplateParity: Boolean(page.acceptance?.needsTemplateParity),
needsCustomVue: Boolean(page.acceptance?.needsCustomVue),
checklistTotal: page.acceptance?.checklistTotal || 0,
checklistPassed: page.acceptance?.checklistPassed || 0,
checklistPending: page.acceptance?.checklistPending || 0,
requirements: page.acceptance?.requirements || []
}))
const parityBacklog = parityPages.filter((page) => page.businessParityStatus !== 'verified')
const templateParityBacklog = parityPages.filter((page) => page.needsTemplateParity)
const e2eScenarios = pageList.map((page) => page.acceptance?.e2eScenario).filter(Boolean)
const parityManifest = {
summary: {
totalPages: pageList.length,
elementPlusRenderablePages: pageList.filter((page) => page.acceptance?.uiRewriteStatus === 'element-plus-renderable').length,
rewrittenPreviewPages: parityPages.filter((page) => page.frontendRewriteStatus === 'rewritten-preview-passed').length,
routeRenderedPages: parityPages.filter((page) => page.parityStatus === 'route-rendered').length,
adapterCoveredPages: parityPages.filter((page) => page.parityStatus === 'adapter-covered').length,
customVueRequiredPages: parityPages.filter((page) => page.parityStatus === 'custom-vue-required').length,
readyForBusinessE2ePages: parityPages.filter((page) => page.scenarioStatus === 'ready-for-business-e2e').length,
pendingE2ePages: parityBacklog.length,
templateParityPages: templateParityBacklog.length,
highRiskPages: parityPages.filter((page) => page.risk === 'high').length,
mediumRiskPages: parityPages.filter((page) => page.risk === 'medium').length,
lowRiskPages: parityPages.filter((page) => page.risk === 'low').length
},
byDomain: topEntries(countBy(parityPages, (page) => page.domain)),
byComponent: topEntries(countBy(parityPages, (page) => page.component)),
byAdapter: topEntries(countBy(
parityPages.flatMap((page) => page.adapterTypes.length ? page.adapterTypes : ['structured-widget']),
(adapter) => adapter
), 40),
byRequirement: topEntries(countBy(
parityPages.flatMap((page) => page.requirements.length ? page.requirements : ['route']),
(requirement) => requirement
), 40),
byScenarioFlow: topEntries(countBy(parityPages, (page) => page.scenarioFlow), 40),
byBusinessStatus: countBy(parityPages, (page) => page.businessParityStatus),
byRisk: countBy(parityPages, (page) => page.risk),
pages: parityPages,
e2eScenarios
}
const inventory = {
generatedAt: new Date().toISOString(),
repoRoot,
counts: {
controllerXml: controllerFiles.length,
widgetXml: widgetFiles.length,
screen: widgets.reduce((total, widget) => total + widget.screens.length, 0),
form: widgets.reduce((total, widget) => total + widget.forms.length, 0),
menu: widgets.reduce((total, widget) => total + widget.menus.length, 0),
serviceXml: serviceFiles.length,
service: allServices.length,
entityXml: entityFiles.length,
entity: entityCatalog.entities.length,
viewRoute: routeManifest.length,
requestAction: controllers.reduce((total, controller) => total + controller.requests.length, 0),
pageDefinition: Object.keys(pageDefinitions).length,
actionDefinition: Object.keys(actionDefinitions).length
},
coverage: {
missingRoutes: routeManifest.filter((route) => !pageDefinitions[route.pageId]).length,
missingActions: controllers
.flatMap((controller) => controller.requests)
.filter((request) => !actionDefinitions[request.actionId]).length,
missingPageDefinitions: routeManifest.filter((route) => !pageDefinitions[route.pageId]).map((route) => route.pageId),
renderablePages: pageList.filter((page) => page.acceptance?.renderable).length,
structuredPages,
modernBlockPages,
adapterCoveredPages,
customParityPages,
customVuePages,
pendingE2ePages: parityManifest.summary.pendingE2ePages,
readyForBusinessE2ePages: parityManifest.summary.readyForBusinessE2ePages,
highRiskParityPages: parityManifest.summary.highRiskPages,
generatedOnlyPages: pageList.length - modernBlockPages,
tableBlocks: tableBlocks.length,
tableDataSourceBlocks,
tableEntityDataSourceBlocks,
tableDerivedDataSourceBlocks,
formBlocks: formBlocks.length,
selectFields: selectFields.length,
selectFieldsWithOptions: selectFieldsWithOptions.length,
selectFieldsWithOptionSources: selectFieldsWithOptionSources.length,
selectFieldsWithOptionContract: selectFieldsWithOptionContract.length,
formContractBlocks,
formServiceReadyBlocks: formContractStatusCounts['service-ready'] || 0,
formMappedActionBlocks: formContractStatusCounts['mapped-non-service'] || 0,
formReadonlyBlocks: formContractStatusCounts['readonly-display'] || 0,
formDynamicTargetBlocks: formContractStatusCounts['dynamic-target'] || 0,
formNavigationTargetBlocks: formContractStatusCounts['navigation-target'] || 0,
formLocalContractBlocks: (formContractStatusCounts['local-submit-contract'] || 0) + (formContractStatusCounts['local-draft-contract'] || 0),
formUnmappedTargetBlocks: formContractStatusCounts['unmapped-target-contract'] || 0,
formMissingContractBlocks: formContractStatusCounts['missing-contract'] || 0,
formContractStatusCounts,
blockTypeCounts
},
parityManifest,
controllers,
widgets: widgets.map((widget) => ({
file: widget.file,
component: widget.component,
counts: {
screens: widget.screens.length,
forms: widget.forms.length,
menus: widget.menus.length
},
screens: widget.screens.slice(0, 20),
forms: widget.forms.slice(0, 20),
menus: widget.menus.slice(0, 20)
})),
services: serviceGroups.map((group) => ({
file: group.file,
component: group.component,
count: group.services.length,
services: group.services.slice(0, 40)
})),
routeManifest,
pageDefinitions,
actionDefinitions
}
await mkdir(outDir, { recursive: true })
await writeFile(outFile, `${JSON.stringify(inventory, null, 2)}\n`)
await mkdir(path.dirname(frontendSnapshotFile), { recursive: true })
await mkdir(path.dirname(frontendPublicSnapshotFile), { recursive: true })
await rm(frontendPublicPagesDir, { recursive: true, force: true })
await mkdir(frontendPublicPagesDir, { recursive: true })
const { e2eScenarios: omittedScenarios, ...frontendParityManifest } = inventory.parityManifest
const frontendSnapshot = {
generatedAt: inventory.generatedAt,
counts: inventory.counts,
coverage: inventory.coverage,
parityManifest: frontendParityManifest,
routeManifest: inventory.routeManifest.map((route) => ({
...route,
pageDefinitionUrl: `/modern/app/generated/pages/${pageFileName(route.pageId)}`
})),
pageDefinitions: {},
actionDefinitions: {}
}
await writeFile(frontendPublicSnapshotFile, `${JSON.stringify(frontendSnapshot)}\n`)
await Promise.all(Object.entries(inventory.pageDefinitions).map(([id, definition]) => (
writeFile(path.join(frontendPublicPagesDir, pageFileName(id)), `${JSON.stringify(definition)}\n`)
)))
await writeFile(frontendSnapshotFile, [
'// Generated compatibility fallback.',
'// Full inventory is served from /generated/ui-inventory.json.',
'',
"import type { UiInventory } from '../types/api'",
"import { fallbackInventory } from '../services/fallback'",
'',
'export const inventorySnapshot = fallbackInventory satisfies UiInventory',
''
].join('\n'))
console.log(JSON.stringify({
output: normalize(outFile),
frontendSnapshot: normalize(frontendSnapshotFile),
frontendPublicSnapshot: normalize(frontendPublicSnapshotFile),
counts: inventory.counts,
coverage: inventory.coverage
}, null, 2))
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})