'use strict'; // ============================================================ // 模块注册表 —— SOURCE OF TRUTH(migration-plan §5.4) // 服务端 modules 表仅承载 status/override;本文件为唯一权威源。 // 新增模块:在此声明 + DDL + Precomputer,参考 §15 扩展指南。 // ============================================================ /** * @typedef {Object} ModuleDescriptor * @property {string} key * @property {string} label * @property {string} rawTable * @property {{ l1Monthly?: string, l1Detail?: string, l2Yearly?: string }} statsTables * @property {'monthly'|'full'|'none'} uploadMode * @property {string[]} uploadDimensions * @property {string} [uploadParser] * @property {string} [precomputer] 有 statsTables 时必填(见 validateRegistry) * @property {Array<'month'|'quarter'|'year'>} statsGrain * @property {string[]} statsDimensions * @property {string} rbacModule * @property {'full_mirror'|'dictionary'|'monthly'|'none'} cacheStrategy * @property {string[]} syncTables * @property {Array<'details'|'extras'>} extensibleFields * @property {{ icon: string, menuGroup: string, sortOrder: number, route: string, pageComponent: string }} ui * @property {boolean} mcpExposed * @property {string[]} [mcpTools] */ /** @type {ModuleDescriptor[]} */ const MODULE_REGISTRY = [ { key: 'online_exams', label: '在线考试', rawTable: 'online_exams', statsTables: { l1Monthly: 'online_exam_stats_monthly', l1Detail: 'online_exam_question_stats_monthly', l2Yearly: 'online_exam_stats_yearly', }, uploadMode: 'incremental', uploadDimensions: ['exam_uuid', 'source_machine', 'source_row_id'], uploadParser: 'OnlineExamParser', precomputer: 'OnlineExamsPrecomputer', statsGrain: ['month', 'year'], statsDimensions: ['team', 'paper_class'], rbacModule: 'online_exams', cacheStrategy: 'full_mirror', syncTables: [ 'online_exams', 'online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly', 'online_exam_category_stats_monthly', 'online_exam_category_stats_yearly', ], extensibleFields: ['details', 'extras'], ui: { icon: 'laptop', menuGroup: 'exams', sortOrder: 10, route: '/online-exams', pageComponent: 'pages/online-exams.js' }, mcpExposed: true, mcpTools: [ 'profile_query_online_exam_stats_monthly', 'profile_query_online_exam_stats_yearly', 'profile_query_online_exam_question_stats', 'profile_query_online_exam_trend', 'profile_get_pass_rate_ranking', ], }, { key: 'practical_exams', label: '实操考试', rawTable: 'practical_exams', statsTables: { l1Monthly: 'practical_exam_stats_monthly', l1Detail: 'practical_exam_step_stats_monthly', l2Yearly: 'practical_exam_stats_yearly', }, uploadMode: 'monthly', uploadDimensions: ['year', 'month'], uploadParser: 'PracticalExamParser', precomputer: 'PracticalExamsPrecomputer', statsGrain: ['month', 'year'], statsDimensions: ['employee_id', 'team', 'all', 'paper_id', 'step_index'], rbacModule: 'practical_exams', cacheStrategy: 'full_mirror', syncTables: [ 'practical_exams', 'papers', 'paper_steps', 'practical_exam_stats_monthly', 'practical_exam_step_stats_monthly', 'practical_exam_person_stats_monthly', 'practical_exam_scope_stats_monthly', 'practical_exam_stats_yearly', 'practical_exam_person_stats_yearly', 'practical_exam_scope_stats_yearly', ], extensibleFields: ['details', 'extras'], ui: { icon: 'tools', menuGroup: 'exams', sortOrder: 20, route: '/practical-exams', pageComponent: 'pages/practical-exams.js' }, mcpExposed: true, mcpTools: [ 'profile_query_practical_exam_stats_monthly', 'profile_query_practical_exam_stats_yearly', 'profile_query_practical_exam_step_error_rate', 'profile_query_practical_exam_top_errors', 'profile_query_paper_list', ], }, { key: 'kilometers', label: '公里数', rawTable: 'kilometers_records', statsTables: { l1Monthly: 'kilometers_stats_team_monthly', l1Detail: 'kilometers_stats_employee_monthly', l2Yearly: 'kilometers_stats_team_yearly', }, uploadMode: 'monthly', uploadDimensions: ['year', 'month'], uploadParser: 'KilometersParser', precomputer: 'KilometersPrecomputer', statsGrain: ['month', 'year'], statsDimensions: ['team', 'employee_id'], rbacModule: 'kilometers', cacheStrategy: 'full_mirror', syncTables: [ 'kilometers_records', 'kilometers_stats_employee_monthly', 'kilometers_stats_team_monthly', 'kilometers_stats_overall_monthly', 'kilometers_control_stats_monthly', 'kilometers_stats_employee_yearly', 'kilometers_stats_team_yearly', 'kilometers_stats_overall_yearly', ], extensibleFields: ['details', 'extras'], ui: { icon: 'road', menuGroup: 'operation', sortOrder: 30, route: '/kilometers', pageComponent: 'pages/kilometers.js' }, mcpExposed: true, mcpTools: [ 'profile_query_kilometers_team_monthly', 'profile_query_kilometers_team_yearly', 'profile_query_kilometers_employee', 'profile_query_kilometers_top_drivers', ], }, { key: 'employees', label: '员工档案', rawTable: 'employees', statsTables: {}, uploadMode: 'full', uploadDimensions: [], uploadParser: 'EmployeeParser', statsGrain: [], statsDimensions: [], rbacModule: 'employees', cacheStrategy: 'full_mirror', syncTables: ['employees'], extensibleFields: ['details', 'extras'], ui: { icon: 'users', menuGroup: 'base', sortOrder: 1, route: '/employees', pageComponent: 'pages/employees.js' }, mcpExposed: true, // 脱敏后 mcpTools: [ 'profile_query_employees', 'profile_get_employee_by_id', 'profile_query_team_roster', 'profile_query_employee_history', ], }, { key: 'question_banks', label: '题库', rawTable: 'question_banks', statsTables: {}, uploadMode: 'full', uploadDimensions: [], uploadParser: 'QuestionBankParser', statsGrain: [], statsDimensions: [], rbacModule: 'questions', cacheStrategy: 'full_mirror', syncTables: ['question_banks'], extensibleFields: ['details', 'extras'], ui: { icon: 'book', menuGroup: 'base', sortOrder: 5, route: '/question-banks', pageComponent: 'pages/question-banks.js' }, mcpExposed: true, mcpTools: ['profile_query_question_bank', 'profile_get_paper_steps', 'profile_query_question_categories'], }, { key: 'missing_exams', label: '缺考', rawTable: 'missing_exams', statsTables: {}, uploadMode: 'monthly', uploadDimensions: ['year', 'month'], uploadParser: 'MissingExamParser', statsGrain: [], statsDimensions: [], rbacModule: 'online_exams', // 缺考归在线考试权限域(§4.1.4.4) cacheStrategy: 'full_mirror', syncTables: ['missing_exams'], extensibleFields: ['details', 'extras'], ui: { icon: 'user-x', menuGroup: 'exams', sortOrder: 15, route: '/missing-exams', pageComponent: 'pages/missing-exams.js' }, mcpExposed: true, mcpTools: ['profile_query_missing_exams', 'profile_get_missing_summary'], }, { key: 'performance_assessments', label: '绩效考核', rawTable: 'performance_assessments', statsTables: {}, uploadMode: 'full', uploadDimensions: [], uploadParser: 'MonthlyMaterialsParser', statsGrain: [], statsDimensions: ['team', 'employee_id'], rbacModule: 'performance', cacheStrategy: 'full_mirror', syncTables: ['performance_assessments'], extensibleFields: [], ui: { icon: 'award', menuGroup: 'operation', sortOrder: 40, route: '/performance-assessments', pageComponent: 'pages/imported-records.js' }, mcpExposed: true, mcpTools: ['profile_query_performance_assessments'], }, { key: 'fault_disposals', label: '故障处置', rawTable: 'fault_disposals', statsTables: {}, uploadMode: 'full', uploadDimensions: [], uploadParser: 'MonthlyMaterialsParser', statsGrain: [], statsDimensions: ['fault_type', 'employee_id'], rbacModule: 'faults', cacheStrategy: 'full_mirror', syncTables: ['fault_disposals'], extensibleFields: [], ui: { icon: 'alert-triangle', menuGroup: 'operation', sortOrder: 41, route: '/fault-disposals', pageComponent: 'pages/imported-records.js' }, mcpExposed: true, mcpTools: ['profile_query_fault_disposals'], }, { key: 'incident_events', label: '事故事件', rawTable: 'incident_events', statsTables: {}, uploadMode: 'full', uploadDimensions: [], uploadParser: 'MonthlyMaterialsParser', statsGrain: [], statsDimensions: ['fault_category', 'employee_id'], rbacModule: 'incidents', cacheStrategy: 'full_mirror', syncTables: ['incident_events'], extensibleFields: [], ui: { icon: 'shield-alert', menuGroup: 'operation', sortOrder: 42, route: '/incident-events', pageComponent: 'pages/imported-records.js' }, mcpExposed: true, mcpTools: ['profile_query_incident_events'], }, ]; /** * 客户端镜像表中文短名(同步进度展示用) * 键为逻辑表名;未收录时回退为原英文名。 */ const SYNC_TABLE_LABELS = { employees: '员工档案', question_banks: '题库', missing_exams: '缺考明细', performance_assessments: '绩效考核', fault_disposals: '故障处置', incident_events: '事故事件', online_exams: '在线考试明细', online_exam_stats_monthly: '在线考试月度统计', online_exam_question_stats_monthly: '在线考试题目月度统计', online_exam_stats_yearly: '在线考试年度统计', online_exam_category_stats_monthly: '在线考试分类月度统计', online_exam_category_stats_yearly: '在线考试分类年度统计', practical_exams: '实操考试明细', papers: '实操试卷', paper_steps: '实操试卷步骤', practical_exam_stats_monthly: '实操考试月度统计', practical_exam_step_stats_monthly: '实操步骤月度统计', practical_exam_person_stats_monthly: '实操个人月度统计', practical_exam_scope_stats_monthly: '实操范围月度统计', practical_exam_stats_yearly: '实操考试年度统计', practical_exam_person_stats_yearly: '实操个人年度统计', practical_exam_scope_stats_yearly: '实操范围年度统计', kilometers_records: '公里数明细', kilometers_stats_employee_monthly: '员工公里数月度统计', kilometers_stats_team_monthly: '班组公里数月度统计', kilometers_stats_overall_monthly: '整体公里数月度统计', kilometers_control_stats_monthly: '公里数控制图月度统计', kilometers_stats_employee_yearly: '员工公里数年度统计', kilometers_stats_team_yearly: '班组公里数年度统计', kilometers_stats_overall_yearly: '整体公里数年度统计', dashboard_summary: '首页看板摘要', crew_metrics_employee_yearly: '乘务画像年度指标', crew_metrics_employee_monthly: '乘务画像月度指标', practical_exam_steps: '实操步骤明细', modules: '模块元数据', settings: '系统配置', upload_batches: '上传批次', sync_table_versions: '同步版本', sync_log: '同步日志', }; /** * @param {string} table * @returns {string} */ function syncTableLabel(table) { return SYNC_TABLE_LABELS[table] || table; } /** * 全部参与同步的表(去重),供 SyncModule 白名单使用 * @returns {string[]} */ function allSyncTables() { const set = new Set(); for (const m of MODULE_REGISTRY) { for (const t of m.syncTables) set.add(t); } set.add('dashboard_summary'); set.add('crew_metrics_employee_yearly'); set.add('crew_metrics_employee_monthly'); return [...set]; } /** * 注册表结构校验(启动时调用)。 * 规则(review v3.x N5 定稿):statsTables 非空 ⇒ precomputer 必填。 * @param {ModuleDescriptor[]} [registry] * @returns {string[]} 错误列表(空数组表示通过) */ function validateRegistry(registry = MODULE_REGISTRY) { const errors = []; const seen = new Set(); for (const m of registry) { if (seen.has(m.key)) errors.push(`duplicate module key: ${m.key}`); seen.add(m.key); const hasStats = Object.values(m.statsTables || {}).some(Boolean); if (hasStats && !m.precomputer) { errors.push(`module ${m.key}: has statsTables but no precomputer`); } if (m.uploadMode === 'monthly' && (!m.uploadDimensions || m.uploadDimensions.length === 0)) { errors.push(`module ${m.key}: uploadMode=monthly requires uploadDimensions`); } if (m.mcpExposed && (!m.mcpTools || m.mcpTools.length === 0)) { errors.push(`module ${m.key}: mcpExposed but no mcpTools declared`); } for (const t of m.mcpTools || []) { if (!/^profile_(query|get|list)_[a-z0-9_]+$/.test(t)) { errors.push(`module ${m.key}: mcp tool "${t}" violates naming (§16.4.1, 只读动词)`); } } } return errors; } module.exports = { MODULE_REGISTRY, SYNC_TABLE_LABELS, syncTableLabel, allSyncTables, validateRegistry, };