#!/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(/]*)\/?>/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(/]*)>([\s\S]*?)<\/entity-options>|]*)\/>/g)) { const optionAttrs = attrs(match[1] || match[3] || '') const constraints = [] const constraintBody = match[2] || '' for (const constraintMatch of constraintBody.matchAll(/]*)\/?>/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(/]*)\/?>/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(/]*)\/?>/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(/]*)\/?>/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(/]*)>([\s\S]*?)<\/request-map>/g)) { const attr = attrs(match[1]) const body = match[2] || '' const eventMatch = body.match(/]*)\/?>/) const event = eventMatch ? attrs(eventMatch[1]) : {} const securityMatch = body.match(/]*)\/?>/) const security = securityMatch ? attrs(securityMatch[1]) : {} const responses = [] for (const responseMatch of body.matchAll(/]*)\/?>/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(/]*)>([\s\S]*?)<\/form>/g)) { const attr = attrs(match[1]) if (!attr.name) continue const fields = [] for (const fieldMatch of match[2].matchAll(/]*)>([\s\S]*?)<\/field>|]*)\/>/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(/]*)>([\s\S]*?)<\/screen>/g)) { const attr = attrs(match[1]) if (!attr.name) continue const body = match[2] || '' const includedForms = [...body.matchAll(/]*)\/?>/g)].map((item) => attrs(item[1])) const includedMenus = [...body.matchAll(/]*)\/?>/g)].map((item) => attrs(item[1])) const includedScreens = [...body.matchAll(/]*)\/?>/g)].map((item) => attrs(item[1])) const templates = [...body.matchAll(/]*)\/?>/g)].map((item) => attrs(item[1])) const scripts = [...body.matchAll(/]*)\/?>/g)].map((item) => attrs(item[1])) const screenlets = [...body.matchAll(/]*)>/g)].map((item) => attrs(item[1])) const links = [...body.matchAll(/]*)\/?>/g)].map((item) => attrs(item[1])) const permissions = [...body.matchAll(/]*)\/?>/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(/]*)>([\s\S]*?)<\/menu>/g)) { const attr = attrs(match[1]) if (!attr.name) continue const items = [...match[2].matchAll(/]*)>([\s\S]*?)<\/menu-item>|]*)\/>/g)].map((item) => { const itemAttrs = attrs(item[1] || item[3] || '') const body = item[2] || '' const linkMatch = body.match(/]*)\/?>/) 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(/)([^>]*)>/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(/]*)\/?>/g)) { const field = attrs(fieldMatch[1]) if (field.name) fields.add(field.name) } for (const aliasMatch of body.matchAll(/]*)\/?>/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('&', '&') 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 (/)/.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 })