export function buildMaintenanceContract({ route, spec, apiMatches = [] }) { const pageKind = inferPageKind(route, spec, apiMatches) const capabilities = inferCapabilities(spec, apiMatches) const dataModel = inferDataModel(route, spec, apiMatches) const rowActions = inferRowActions(spec) const businessRules = inferBusinessRules(spec) const template = recommendTemplate(pageKind, spec, capabilities, dataModel) return { pageKind, capabilities, recommendedTemplate: template.name, confidence: template.confidence, reasons: template.reasons, warnings: buildWarnings(pageKind, template.name, spec, dataModel, apiMatches, rowActions), dataModel, rowActions, businessRules } } function inferPageKind(route, spec, apiMatches) { const path = route.prototype ?? spec.page const text = contractText(spec) if (['auth', 'auth-support'].includes(route.kind)) return route.targetRole ?? 'auth' if (route.kind === 'reference') return 'reference' if (route.kind === 'legacy-shell-reference') return 'layout-reference' if (route.kind === 'prototype-navigation' || /choose|index/i.test(path) && /(挑選|選擇|導覽|返回雛型導覽)/.test(text)) return 'chooser' if (/print|prt/i.test(path) || /(列印|申請表)/.test(text) && !hasAction(spec, ['edit', 'delete', 'save'])) return 'print' if (hasAction(spec, ['edit', 'delete'])) return 'maintenance' if (hasAction(spec, ['save', 'create'])) return 'application' if (hasAction(spec, ['search']) || spec.pageContract.tables.some((table) => table.role === 'resultTable')) return 'query' if (apiMatches.some((api) => ['PUT', 'DELETE'].includes(api.method))) return 'maintenance' if (apiMatches.some((api) => api.method === 'POST')) return 'application' return route.kind === 'feature-page' ? 'query' : 'reference' } function inferCapabilities(spec, apiMatches) { const capabilities = new Set() for (const action of spec.pageContract.actions) { if (action.actionType && action.actionType !== 'custom') capabilities.add(action.actionType) } for (const api of apiMatches) { if (api.method === 'GET' && /print-view|print/i.test(api.path)) capabilities.add('print') else if (api.method === 'GET') capabilities.add('search') else if (api.method === 'POST' && /availability/i.test(api.path)) capabilities.add('availabilityCheck') else if (api.method === 'POST') capabilities.add('create') else if (api.method === 'PUT' || api.method === 'PATCH') capabilities.add('edit') else if (api.method === 'DELETE') capabilities.add('delete') } if (isChooserPage(spec)) { for (const capability of [...capabilities]) { if (!['back', 'select', 'lookup', 'view'].includes(capability)) capabilities.delete(capability) } } if (isPrintPage(spec)) { for (const capability of [...capabilities]) { if (!['back', 'print'].includes(capability)) capabilities.delete(capability) } } return [...capabilities] } function inferDataModel(route, spec, apiMatches) { const primaryEntity = inferPrimaryEntity(route, apiMatches) const detailEntities = [] const fieldNames = spec.pageContract.forms.flatMap((form) => form.fields.map((field) => field.name ?? field.label)).filter(Boolean) const collectionFields = fieldNames.filter((name) => /s$|list|items|details|明細|志願|節次|設備|場地/i.test(name)) for (const field of collectionFields) { detailEntities.push({ name: entityName(field), source: 'field' }) } return { primaryEntity, detailEntities: dedupeByName(detailEntities), fields: fieldNames, searchFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => field.sourceTable && spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role === 'searchTable'), formFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => !field.sourceTable || spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role !== 'searchTable'), tableColumns: spec.pageContract.tables.flatMap((table) => table.headers.map((header) => ({ tableId: table.id, role: table.role, label: header }))), detailCollections: dedupeByName(detailEntities) } } function inferPrimaryEntity(route, apiMatches) { const targetView = /\.vue$/i.test(route.guide?.targetView ?? '') ? route.guide.targetView.replace(/View\.vue$/i, '').replace(/\.vue$/i, '') : null if (targetView) return targetView const strongest = apiMatches[0] if (strongest) { const parts = strongest.path.split('/').filter((part) => part && !part.startsWith('{') && !part.startsWith('_')) return entityName(parts.at(-1) ?? parts.at(-2) ?? 'Record') } return entityName((route.page ?? route.prototype ?? 'Record').replace(/\.html$/i, '')) } function recommendTemplate(pageKind, spec, capabilities, dataModel) { if (!['maintenance', 'application'].includes(pageKind)) { return { name: 'none', confidence: 0.9, reasons: [`pageKind=${pageKind} 不套維護頁 CRUD 範本`] } } if (hasEditableResultTable(spec)) { return { name: 'editable-grid', confidence: 0.75, reasons: ['結果表格內含可編輯欄位'] } } if (dataModel.detailEntities.length > 1 || hasGroupedDetails(spec)) { return { name: 'master-detail-b', confidence: 0.65, reasons: ['偵測到多組明細集合或群組結構'] } } if (dataModel.detailEntities.length === 1 || hasDetailEvidence(spec)) { return { name: 'master-detail-c', confidence: 0.7, reasons: ['主檔表單搭配簡單明細集合'] } } if (capabilities.includes('edit') || capabilities.includes('delete') || capabilities.includes('create')) { return { name: 'single-record', confidence: 0.7, reasons: ['具備查詢列表與單筆 CRUD 操作'] } } return { name: 'none', confidence: 0.5, reasons: ['沒有足夠維護頁操作證據'] } } function buildWarnings(pageKind, recommendedTemplate, spec, dataModel, apiMatches, rowActions = []) { const warnings = [] if (pageKind === 'maintenance' && !hasAction(spec, ['search']) && spec.pageContract.tables.length === 0) { warnings.push('maintenance 頁缺少查詢 action 或表格 evidence') } if (recommendedTemplate !== 'none' && !dataModel.primaryEntity) { warnings.push('建議維護頁範本但缺少 primaryEntity') } if (hasAction(spec, ['edit', 'delete']) && apiMatches.length === 0) { warnings.push('有 edit/delete action 但沒有匹配 API endpoint') } for (const action of rowActions) { if (action.enabledWhen === null && action.disabled) warnings.push(`row action ${action.label} 有狀態限制但未產出 enabledWhen`) } return warnings } function inferRowActions(spec) { const guideText = [ ...(spec.prototypeGuide?.checklist ?? []), contractText(spec) ].join(' ') return spec.pageContract.actions .filter((action) => action.scope === 'rowAction') .map((action) => ({ label: action.label, actionType: action.actionType, disabled: action.disabled, enabledWhen: inferEnabledWhen(action, guideText) })) } function inferEnabledWhen(action, text) { const normalized = normalizeConditionText(`${action.label} ${text}`) const aprv = normalized.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?/i) if (aprv) return `aprvYn === '${aprv[1]}'` const aprvNotDisabled = normalized.match(/aprvYn\s*(?:!==|!=|≠|不等於)\s*['"]?([A-Z0-9])['"]?[^。;\n]*(?:disabled|停用|不可|不能|隱藏|不顯示)/i) if (aprvNotDisabled && ['edit', 'delete'].includes(action.actionType)) return `aprvYn === '${aprvNotDisabled[1]}'` const quoted = text.match(/`([^`]+)`\s*才(?:能|可)/) if (quoted) return quoted[1] return null } function inferBusinessRules(spec) { const sourceTexts = [ ...(spec.prototypeGuide?.checklist ?? []), ...spec.pageContract.tables.flatMap((table) => table.sampleRows.flat()) ].filter(Boolean) const rules = { dataSources: [], validationRules: [], collectionLimits: [], statusRules: [] } for (const text of sourceTexts) { collectDataSourceRules(rules, text) collectValidationRules(rules, text) collectCollectionLimitRules(rules, text) collectStatusRules(rules, text) } return { dataSources: dedupeRules(rules.dataSources), validationRules: dedupeRules(rules.validationRules), collectionLimits: dedupeRules(rules.collectionLimits), statusRules: dedupeRules(rules.statusRules) } } function collectDataSourceRules(rules, text) { const value = String(text) for (const match of value.matchAll(/從\s*([^;,。))]+?)\s*帶入/g)) { rules.dataSources.push({ ruleType: 'initial-state-source', source: match[1].trim(), text: value }) } for (const match of value.matchAll(/依\s*([A-Za-z0-9_]+)\s*過濾/g)) { rules.dataSources.push({ ruleType: 'lookup-filter', field: match[1], text: value }) } for (const match of value.matchAll(/([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\s*=\s*['"]?([^'";;,。))\s]+)['"]?/g)) { rules.dataSources.push({ ruleType: 'source-filter', field: match[1], value: match[2], text: value }) } if (/不填用今天|預設.*今天|今天/.test(value)) rules.dataSources.push({ ruleType: 'default-today', text: value }) } function collectValidationRules(rules, text) { const value = String(text) if (/不可空白|不可空|必填|required/i.test(value)) rules.validationRules.push({ ruleType: 'required', text: value }) if (/IsNumeric|數字|number/i.test(value)) rules.validationRules.push({ ruleType: 'numeric', text: value }) if (/email|Email|EMAIL|含\s*@|@/.test(value)) rules.validationRules.push({ ruleType: 'email', text: value }) if (/大於\s*0|>\s*0/.test(value)) rules.validationRules.push({ ruleType: 'positive-number', text: value }) if (/不可重複|不得重複|不能重複/.test(value)) rules.validationRules.push({ ruleType: 'unique', text: value }) } function collectCollectionLimitRules(rules, text) { const value = String(text) for (const match of value.matchAll(/最多\s*(\d+)\s*(項|組|筆|個|行|列)/g)) { rules.collectionLimits.push({ ruleType: 'max-count', max: Number(match[1]), unit: match[2], text: value }) } for (const match of value.matchAll(/(\d+)\s*個\s*(checkbox|欄位|input|select)/gi)) { rules.collectionLimits.push({ ruleType: 'fixed-count', count: Number(match[1]), unit: match[2], text: value }) } } function collectStatusRules(rules, text) { const value = normalizeConditionText(String(text)) const aprvNotDisabled = value.match(/aprvYn\s*(?:!==|!=|≠|不等於)\s*['"]?([A-Z0-9])['"]?[^。;\n]*(?:disabled|停用|不可|不能|隱藏|不顯示)/i) if (aprvNotDisabled) rules.statusRules.push({ ruleType: 'disabled-when-not-status', field: 'aprvYn', value: aprvNotDisabled[1], text }) const aprvEnabled = value.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?[^。;\n]*(?:才(?:能|可)|enabled|可|能)/i) if (aprvEnabled) rules.statusRules.push({ ruleType: 'enabled-when-status', field: 'aprvYn', value: aprvEnabled[1], text }) } function normalizeConditionText(text) { return String(text) .replace(/aprv_yn|aprv-yn/gi, 'aprvYn') .replace(/\baprv\b/gi, 'aprvYn') .replace(/['‘’]/g, "'") .replace(/[=]/g, '=') .replace(/[!!]\s*=/g, '!=') } function dedupeRules(rules) { const seen = new Set() return rules.filter((rule) => { const key = JSON.stringify(rule) if (seen.has(key)) return false seen.add(key) return true }) } function hasEditableResultTable(spec) { return spec.pageContract.tables.some((table) => table.role === 'resultTable' && table.sampleRows.flat().some((cell) => / table.role === 'detailTable') || spec.pageContract.forms.some((form) => form.fields.some((field) => /明細|志願|節次|設備|場地|items|details/i.test(`${field.name ?? ''} ${field.label ?? ''}`))) } function hasAction(spec, actionTypes) { return spec.pageContract.actions.some((action) => actionTypes.includes(action.actionType)) } function isChooserPage(spec) { return /choose|index/i.test(spec.page) && /(挑選|選擇|導覽|返回雛型導覽)/.test(contractText(spec)) } function isPrintPage(spec) { if (/print|prt/i.test(spec.page)) return true return /(申請表|列印頁)/.test(contractText(spec)) && !hasAction(spec, ['search', 'edit', 'delete', 'save']) } function contractText(spec) { return [ spec.pageContract.title, ...spec.pageContract.textSamples, ...spec.pageContract.actions.map((action) => action.label), ...spec.pageContract.tables.flatMap((table) => [...table.headers, ...table.sampleRows.flat()]) ].filter(Boolean).join(' ') } function entityName(value) { const parts = String(value) .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[^a-z0-9\u4e00-\u9fff]+/gi, ' ') .trim() .split(/\s+/) .filter(Boolean) if (parts.length === 0) return 'Record' return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join('') } function dedupeByName(items) { const seen = new Set() return items.filter((item) => { if (seen.has(item.name)) return false seen.add(item.name) return true }) }