From b8ca18d781d29ef1ffee1d05d4fa322bea8a1255 Mon Sep 17 00:00:00 2001 From: fiser_jun Date: Thu, 20 Aug 2026 01:04:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E5=8F=B0=E5=BC=95?= =?UTF-8?q?=E6=93=8E(L2=20=E4=B8=AD=E5=8F=B0=E5=B1=82)=E6=BA=90=E7=A0=81:s?= =?UTF-8?q?erver/=20+=20packages/shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + packages/shared/backup-tables.js | 53 + packages/shared/constants.js | 16 + packages/shared/index.js | 21 + packages/shared/module-registry.js | 367 +++ packages/shared/package.json | 7 + server/.env.example | 26 + server/README.md | 18 + server/package-lock.json | 2465 +++++++++++++++++ server/package.json | 34 + server/scripts/generate-jwt-keys.js | 41 + server/scripts/migrate-missing-exams.js | 71 + server/scripts/migrate-monthly-exams.js | 39 + server/scripts/migrate-monthly-materials.js | 56 + server/scripts/migrate-p45.js | 38 + server/scripts/migrate-reward-amount.js | 31 + server/scripts/migrate-user-real-name.js | 20 + server/scripts/set-password.js | 42 + server/src/app.js | 53 + server/src/audit/audit.js | 102 + server/src/audit/audit.routes.js | 165 ++ server/src/auth/auth.js | 255 ++ server/src/auth/auth.routes.js | 65 + server/src/auth/jwt-ed25519.js | 75 + server/src/auth/jwt-keys.js | 59 + server/src/common/api-codes.js | 41 + server/src/common/crypto.js | 48 + server/src/common/http.js | 63 + server/src/common/responsible.js | 145 + server/src/common/table-utils.js | 88 + server/src/config.js | 36 + server/src/export/backup.js | 170 ++ server/src/export/export.routes.js | 85 + server/src/export/export.service.js | 100 + server/src/export/generator.js | 279 ++ server/src/export/queue.js | 22 + server/src/export/worker.js | 77 + server/src/health/health.routes.js | 30 + server/src/import/crew-rewards.js | 610 ++++ server/src/import/import.routes.js | 164 ++ server/src/import/monthly-exams.js | 1148 ++++++++ server/src/import/monthly-materials.js | 833 ++++++ server/src/import/personnel-roster.js | 913 ++++++ server/src/import/question-bank.js | 208 ++ server/src/infra/db.js | 62 + server/src/infra/redis.js | 21 + server/src/main.js | 39 + server/src/maintenance.js | 134 + server/src/maintenance.routes.js | 29 + server/src/modules/modules.js | 61 + server/src/modules/modules.routes.js | 20 + server/src/precompute/crew-metrics.js | 954 +++++++ server/src/precompute/dashboard-summary.js | 72 + server/src/precompute/impls/kilometers.js | 204 ++ server/src/precompute/impls/online-exams.js | 156 ++ .../src/precompute/impls/practical-exams.js | 329 +++ server/src/precompute/index.js | 17 + server/src/precompute/precompute.routes.js | 163 ++ server/src/precompute/registry.js | 50 + server/src/precompute/service.js | 57 + server/src/rbac/rbac.js | 96 + server/src/stats/stats.routes.js | 733 +++++ server/src/sync/sync.js | 134 + server/src/sync/sync.routes.js | 32 + .../system-settings/system-settings.routes.js | 71 + server/src/upload/kilometers-import.js | 498 ++++ server/src/upload/practical-incremental.js | 470 ++++ server/src/upload/upload.js | 292 ++ server/src/upload/upload.routes.js | 138 + server/src/users/users.js | 255 ++ server/src/users/users.routes.js | 69 + 71 files changed, 14337 insertions(+) create mode 100644 packages/shared/backup-tables.js create mode 100644 packages/shared/constants.js create mode 100644 packages/shared/index.js create mode 100644 packages/shared/module-registry.js create mode 100644 packages/shared/package.json create mode 100644 server/.env.example create mode 100644 server/README.md create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/scripts/generate-jwt-keys.js create mode 100644 server/scripts/migrate-missing-exams.js create mode 100644 server/scripts/migrate-monthly-exams.js create mode 100644 server/scripts/migrate-monthly-materials.js create mode 100644 server/scripts/migrate-p45.js create mode 100644 server/scripts/migrate-reward-amount.js create mode 100644 server/scripts/migrate-user-real-name.js create mode 100644 server/scripts/set-password.js create mode 100644 server/src/app.js create mode 100644 server/src/audit/audit.js create mode 100644 server/src/audit/audit.routes.js create mode 100644 server/src/auth/auth.js create mode 100644 server/src/auth/auth.routes.js create mode 100644 server/src/auth/jwt-ed25519.js create mode 100644 server/src/auth/jwt-keys.js create mode 100644 server/src/common/api-codes.js create mode 100644 server/src/common/crypto.js create mode 100644 server/src/common/http.js create mode 100644 server/src/common/responsible.js create mode 100644 server/src/common/table-utils.js create mode 100644 server/src/config.js create mode 100644 server/src/export/backup.js create mode 100644 server/src/export/export.routes.js create mode 100644 server/src/export/export.service.js create mode 100644 server/src/export/generator.js create mode 100644 server/src/export/queue.js create mode 100644 server/src/export/worker.js create mode 100644 server/src/health/health.routes.js create mode 100644 server/src/import/crew-rewards.js create mode 100644 server/src/import/import.routes.js create mode 100644 server/src/import/monthly-exams.js create mode 100644 server/src/import/monthly-materials.js create mode 100644 server/src/import/personnel-roster.js create mode 100644 server/src/import/question-bank.js create mode 100644 server/src/infra/db.js create mode 100644 server/src/infra/redis.js create mode 100644 server/src/main.js create mode 100644 server/src/maintenance.js create mode 100644 server/src/maintenance.routes.js create mode 100644 server/src/modules/modules.js create mode 100644 server/src/modules/modules.routes.js create mode 100644 server/src/precompute/crew-metrics.js create mode 100644 server/src/precompute/dashboard-summary.js create mode 100644 server/src/precompute/impls/kilometers.js create mode 100644 server/src/precompute/impls/online-exams.js create mode 100644 server/src/precompute/impls/practical-exams.js create mode 100644 server/src/precompute/index.js create mode 100644 server/src/precompute/precompute.routes.js create mode 100644 server/src/precompute/registry.js create mode 100644 server/src/precompute/service.js create mode 100644 server/src/rbac/rbac.js create mode 100644 server/src/stats/stats.routes.js create mode 100644 server/src/sync/sync.js create mode 100644 server/src/sync/sync.routes.js create mode 100644 server/src/system-settings/system-settings.routes.js create mode 100644 server/src/upload/kilometers-import.js create mode 100644 server/src/upload/practical-incremental.js create mode 100644 server/src/upload/upload.js create mode 100644 server/src/upload/upload.routes.js create mode 100644 server/src/users/users.js create mode 100644 server/src/users/users.routes.js diff --git a/.gitignore b/.gitignore index 269f45e..76ecd0c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ docs/api/ *.js !vendor/** !assets/** +!server/** +!packages/** diff --git a/packages/shared/backup-tables.js b/packages/shared/backup-tables.js new file mode 100644 index 0000000..901138d --- /dev/null +++ b/packages/shared/backup-tables.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * 完整业务库备份表(profile-backup-v2)。 + * 刻意排除:登录/RBAC、审计哈希链、导出任务临时表。 + * 服务端 backup.js 与 Electron 本地导出共用此列表。 + */ +const BACKUP_TABLES = [ + // 业务原表 + 'employees', + 'online_exams', + 'missing_exams', + 'question_banks', + 'papers', + 'paper_steps', + 'practical_exams', + 'practical_exam_steps', + 'kilometers_records', + 'performance_assessments', + 'fault_disposals', + 'incident_events', + // 预计算 / 画像 / 看板 + 'online_exam_stats_monthly', + 'online_exam_question_stats_monthly', + 'online_exam_stats_yearly', + 'online_exam_category_stats_monthly', + 'online_exam_category_stats_yearly', + '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_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', + // 元数据 + 'modules', + 'settings', + 'upload_batches', + 'sync_table_versions', + 'sync_log', +]; + +module.exports = { BACKUP_TABLES }; diff --git a/packages/shared/constants.js b/packages/shared/constants.js new file mode 100644 index 0000000..3a53741 --- /dev/null +++ b/packages/shared/constants.js @@ -0,0 +1,16 @@ +'use strict'; + +/** + * RBAC actions 位掩码(migration-plan §4.1.1) + * 组合用按位或:READ|WRITE = 3 + */ +const ACTIONS = { + READ: 1, + WRITE: 2, + DELETE: 4, + IMPORT: 8, + EXPORT: 16, + APPROVE: 32, +}; + +module.exports = { ACTIONS }; diff --git a/packages/shared/index.js b/packages/shared/index.js new file mode 100644 index 0000000..1c11262 --- /dev/null +++ b/packages/shared/index.js @@ -0,0 +1,21 @@ +'use strict'; + +const { ACTIONS } = require('./constants'); +const { + MODULE_REGISTRY, + SYNC_TABLE_LABELS, + syncTableLabel, + allSyncTables, + validateRegistry, +} = require('./module-registry'); +const { BACKUP_TABLES } = require('./backup-tables'); + +module.exports = { + ACTIONS, + MODULE_REGISTRY, + SYNC_TABLE_LABELS, + syncTableLabel, + allSyncTables, + validateRegistry, + BACKUP_TABLES, +}; diff --git a/packages/shared/module-registry.js b/packages/shared/module-registry.js new file mode 100644 index 0000000..c56ad3e --- /dev/null +++ b/packages/shared/module-registry.js @@ -0,0 +1,367 @@ +'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, +}; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..29c7616 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,7 @@ +{ + "name": "@profile/shared", + "version": "0.1.0", + "description": "共享模块注册表(SSOT,migration-plan §5.4)· 纯 JavaScript", + "main": "index.js", + "type": "commonjs" +} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..b6f61e5 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,26 @@ +# 开发环境配置模板(复制为 .env) +# 数据库/Redis 对应 deploy/docker-compose.dev.yml +DATABASE_URL="mysql://profile:profile_dev_pass@127.0.0.1:3306/profile_dev" +REDIS_URL="redis://127.0.0.1:6379" + +# JWT Ed25519(EdDSA)。先运行:node scripts/generate-jwt-keys.js +# 默认读取 apps/server/keys/jwt_ed25519_{private,public}.pem +# JWT_PRIVATE_KEY_PATH="/absolute/path/to/jwt_ed25519_private.pem" +# JWT_PUBLIC_KEY_PATH="/absolute/path/to/jwt_ed25519_public.pem" +# 或直接贴 PEM(换行写成 \n): +# JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" +# JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" +JWT_KEY_ID=ed25519-1 +JWT_ACCESS_TTL_MIN=30 +JWT_REFRESH_TTL_HOURS=8 + +# PII 字段加密(§10.2;生产由密钥目录管理,切勿用开发值) +DATA_ENC_KEY="dev-only-data-encryption-key-change-me" +DATA_HMAC_KEY="dev-only-data-hmac-key-change-me" + +# 可选;不配置时默认使用仓库 data/exports +EXPORT_DIR="/absolute/path/to/profile/data/exports" +EXPORT_RETENTION_DAYS=7 +DEVELOPER_USERNAMES="dev_admin" + +PORT=3000 diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8a3c042 --- /dev/null +++ b/server/README.md @@ -0,0 +1,18 @@ +# 中台引擎(L2 · :8088) + +城轨智能中台四层架构中的 L2 中台层:Express API 服务,仅绑定 127.0.0.1。 + +- 入口:`src/main.js`(`app.listen(port, '127.0.0.1')`) +- 职责:请求验证与路由转发、审计(链式哈希)、JWT Ed25519 鉴权、RBAC、数据导入/导出、预计算 +- 协议:向上承接 L1 客户端(经 CORS 代理),向下经 MCP 路由对接 L3 子系统(:7777/:7778) +- 依赖 `packages/shared`(与本目录一同提供) + +## 运行 + +```bash +cp .env.example .env # 按需修改 +npm install +npm start +``` + +密钥说明:JWT Ed25519 密钥由 `scripts/generate-jwt-keys.js` 本地生成,存放于本地密钥目录,**不入库**。 diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..d00e142 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2465 @@ +{ + "name": "@profile/server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@profile/server", + "version": "0.1.0", + "dependencies": { + "@profile/shared": "file:../../packages/shared", + "argon2": "^0.41.0", + "bullmq": "^5.80.5", + "dotenv": "^16.4.5", + "exceljs": "^4.4.0", + "express": "^4.19.2", + "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", + "multer": "^2.2.0", + "mysql2": "^3.11.0" + } + }, + "../../packages/shared": { + "name": "@profile/shared", + "version": "0.1.0" + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@profile/shared": { + "resolved": "../../packages/shared", + "link": true + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argon2": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", + "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/bullmq": { + "version": "5.80.5", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.80.5.tgz", + "integrity": "sha512-3cVpkFXvmi7U7clGQGqlIzWLNJgX77Q0U0xCfbsiu6CFdiAcrKem1rK+xHIq0jIzMA6swq9ss6uvMqzCeTbAgg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.0.tgz", + "integrity": "sha512-ZwyGLoG9BRlL7hKNDcIy7q18hA6WTWGEpLAerGHXj5Xahemia4msZdwjIOUIzesjK3B5z5gWLktf+2tYSLAyTA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5d91920 --- /dev/null +++ b/server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@profile/server", + "version": "0.1.0", + "description": "Express API(纯 JavaScript,migration-plan §6/§12 · v1.9)", + "main": "src/main.js", + "type": "commonjs", + "scripts": { + "start": "node src/main.js", + "start:dev": "node --watch src/main.js", + "worker": "node src/export/worker.js", + "set-password": "node scripts/set-password.js", + "generate-jwt-keys": "node scripts/generate-jwt-keys.js", + "migrate:missing-exams": "node scripts/migrate-missing-exams.js", + "migrate:p45": "node scripts/migrate-p45.js", + "migrate:monthly-materials": "node scripts/migrate-monthly-materials.js", + "migrate:monthly-exams": "node scripts/migrate-monthly-exams.js", + "migrate:user-real-name": "node scripts/migrate-user-real-name.js" + }, + "dependencies": { + "@profile/shared": "file:../../packages/shared", + "argon2": "^0.41.0", + "bullmq": "^5.80.5", + "dotenv": "^16.4.5", + "exceljs": "^4.4.0", + "express": "^4.19.2", + "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", + "multer": "^2.2.0", + "mysql2": "^3.11.0" + }, + "overrides": { + "uuid": "^11.1.1" + } +} diff --git a/server/scripts/generate-jwt-keys.js b/server/scripts/generate-jwt-keys.js new file mode 100644 index 0000000..f09c478 --- /dev/null +++ b/server/scripts/generate-jwt-keys.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 生成 JWT 用 Ed25519 密钥对(PEM)。 + * 用法:node scripts/generate-jwt-keys.js [--force] + * 输出目录默认:apps/server/keys/(已 gitignore) + */ +const fs = require('fs'); +const path = require('path'); +const { generateKeyPairSync } = require('crypto'); +const { DEFAULT_DIR, DEFAULT_PRIVATE, DEFAULT_PUBLIC } = require('../src/auth/jwt-keys'); + +const force = process.argv.includes('--force'); + +function main() { + if (!force && (fs.existsSync(DEFAULT_PRIVATE) || fs.existsSync(DEFAULT_PUBLIC))) { + console.error('密钥已存在。若要覆盖请加 --force'); + console.error(` private: ${DEFAULT_PRIVATE}`); + console.error(` public: ${DEFAULT_PUBLIC}`); + process.exit(1); + } + + fs.mkdirSync(DEFAULT_DIR, { recursive: true, mode: 0o700 }); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + const pubPem = publicKey.export({ type: 'spki', format: 'pem' }); + + fs.writeFileSync(DEFAULT_PRIVATE, privPem, { mode: 0o600 }); + fs.writeFileSync(DEFAULT_PUBLIC, pubPem, { mode: 0o644 }); + + console.log('已生成 Ed25519 JWT 密钥对:'); + console.log(` private: ${DEFAULT_PRIVATE} (mode 600)`); + console.log(` public: ${DEFAULT_PUBLIC}`); + console.log('可选 .env:'); + console.log(` JWT_PRIVATE_KEY_PATH=${DEFAULT_PRIVATE}`); + console.log(` JWT_PUBLIC_KEY_PATH=${DEFAULT_PUBLIC}`); + console.log(' JWT_KEY_ID=ed25519-1'); +} + +main(); diff --git a/server/scripts/migrate-missing-exams.js b/server/scripts/migrate-missing-exams.js new file mode 100644 index 0000000..3be1176 --- /dev/null +++ b/server/scripts/migrate-missing-exams.js @@ -0,0 +1,71 @@ +'use strict'; + +const db = require('../src/infra/db'); +const sync = require('../src/sync/sync'); +const audit = require('../src/audit/audit'); +const dashboardSummary = require('../src/precompute/dashboard-summary'); +const precompute = require('../src/precompute'); +const precomputeService = require('../src/precompute/service'); + +async function main() { + precompute.registerAll(); + const legacy = await db.query( + `SELECT id, employee_id, exam_year, exam_month, submit_time, details, extras + FROM online_exams + WHERE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(details, '$.isMissingExam')), 'false') = 'true'`, + ); + const periods = new Set(); + + await db.transaction(async (conn) => { + for (const row of legacy) { + const month = `${row.exam_year}-${String(row.exam_month).padStart(2, '0')}`; + const details = typeof row.details === 'string' ? JSON.parse(row.details) : (row.details || {}); + const extras = typeof row.extras === 'string' ? JSON.parse(row.extras) : row.extras; + const [existing] = await conn.execute( + 'SELECT id FROM missing_exams WHERE employee_id = ? AND missing_month = ? LIMIT 1', + [row.employee_id, month], + ); + if (existing.length === 0) { + await conn.execute( + `INSERT INTO missing_exams + (employee_id, missing_month, reason, import_time, details, extras, version) + VALUES (?, ?, ?, ?, ?, ?, 0)`, + [ + row.employee_id, + month, + details.reason || '缺考', + row.submit_time || new Date(), + JSON.stringify({ ...details, legacyOnlineExamId: row.id }), + extras == null ? null : JSON.stringify(extras), + ], + ); + } + await conn.execute('DELETE FROM online_exams WHERE id = ?', [row.id]); + periods.add(`${row.exam_year}-${row.exam_month}`); + } + }); + + if (legacy.length > 0) { + await sync.bumpVersions(['online_exams', 'missing_exams']); + for (const period of periods) { + const [year, month] = period.split('-').map(Number); + await precomputeService.onUploadComplete('online_exams', year, month); + } + await dashboardSummary.recompute(); + await audit.log({ + clientType: 'system', + action: 'migrate_missing_exams', + module: 'missing_exams', + payload: { migratedCount: legacy.length, periods: [...periods] }, + }); + } + + console.log(JSON.stringify({ migratedCount: legacy.length, periods: [...periods] })); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => db.close()); diff --git a/server/scripts/migrate-monthly-exams.js b/server/scripts/migrate-monthly-exams.js new file mode 100644 index 0000000..64fa784 --- /dev/null +++ b/server/scripts/migrate-monthly-exams.js @@ -0,0 +1,39 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const db = require('../src/infra/db'); + +const TABLES = [ + 'online_exam_category_stats_monthly', + 'online_exam_category_stats_yearly', +]; + +async function main() { + const schema = fs.readFileSync( + path.resolve(__dirname, '../../../deploy/mysql/init/01-schema.sql'), + 'utf8', + ); + for (const table of TABLES) { + const start = schema.indexOf(`CREATE TABLE ${table} (`); + const marker = ') ENGINE=InnoDB;'; + const end = schema.indexOf(marker, start); + if (start < 0 || end < 0) throw new Error(`未找到 ${table} DDL`); + const statement = schema.slice(start, end + marker.length); + await db.execute(statement.replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS')); + await db.execute( + `INSERT INTO sync_table_versions (table_name, version) VALUES (?, 0) + ON DUPLICATE KEY UPDATE table_name = VALUES(table_name)`, + [table], + ); + } + console.log('monthly exams category stats schema migrated'); +} + +main() + .then(() => db.close()) + .catch(async (error) => { + console.error(error); + await db.close(); + process.exit(1); + }); diff --git a/server/scripts/migrate-monthly-materials.js b/server/scripts/migrate-monthly-materials.js new file mode 100644 index 0000000..591da09 --- /dev/null +++ b/server/scripts/migrate-monthly-materials.js @@ -0,0 +1,56 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const db = require('../src/infra/db'); + +const TABLES = ['performance_assessments', 'fault_disposals', 'incident_events']; + +async function main() { + const schema = fs.readFileSync( + path.resolve(__dirname, '../../../deploy/mysql/init/01-schema.sql'), + 'utf8', + ); + for (const table of TABLES) { + const start = schema.indexOf(`CREATE TABLE ${table} (`); + const marker = ') ENGINE=InnoDB;'; + const end = schema.indexOf(marker, start); + if (start < 0 || end < 0) throw new Error(`未找到 ${table} DDL`); + const statement = schema.slice(start, end + marker.length); + await db.execute(statement.replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS')); + await db.execute( + `INSERT INTO sync_table_versions (table_name, version) VALUES (?, 0) + ON DUPLICATE KEY UPDATE table_name = VALUES(table_name)`, + [table], + ); + await db.execute( + `INSERT INTO modules (\`key\`, status, notes) VALUES (?, 'active', ?) + ON DUPLICATE KEY UPDATE status = 'active', notes = VALUES(notes)`, + [table, { performance_assessments: '绩效考核', fault_disposals: '故障处置', incident_events: '事故事件' }[table]], + ); + } + await db.execute('ALTER TABLE fault_disposals MODIFY final_result TEXT NULL'); + + for (const module of ['performance', 'faults', 'incidents', 'audit']) { + await db.execute( + `INSERT INTO role_permissions (role_id, module, actions) + SELECT id, ?, 1 FROM roles + ON DUPLICATE KEY UPDATE actions = actions | 1`, + [module], + ); + } + await db.execute( + `INSERT INTO role_permissions (role_id, module, actions) + SELECT id, 'exports', 16 FROM roles + ON DUPLICATE KEY UPDATE actions = actions | 16`, + ); + console.log('monthly materials schema migrated'); +} + +main() + .then(() => db.close()) + .catch(async (error) => { + console.error(error); + await db.close(); + process.exit(1); + }); diff --git a/server/scripts/migrate-p45.js b/server/scripts/migrate-p45.js new file mode 100644 index 0000000..a089a84 --- /dev/null +++ b/server/scripts/migrate-p45.js @@ -0,0 +1,38 @@ +'use strict'; + +const db = require('../src/infra/db'); + +async function main() { + await db.execute( + `CREATE TABLE IF NOT EXISTS export_jobs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + job_uuid CHAR(36) NOT NULL, + export_type VARCHAR(32) NOT NULL, + params JSON NULL, + status ENUM('queued','processing','completed','failed','expired') NOT NULL DEFAULT 'queued', + progress INT NOT NULL DEFAULT 0, + requested_by BIGINT NOT NULL, + file_name VARCHAR(255) NULL, + file_path VARCHAR(1024) NULL, + mime_type VARCHAR(128) NULL, + file_size BIGINT NULL, + file_hash CHAR(64) NULL, + error VARCHAR(1000) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME NULL, + completed_at DATETIME NULL, + expires_at DATETIME NULL, + UNIQUE KEY uk_job_uuid (job_uuid), + KEY idx_requested_created (requested_by, created_at), + KEY idx_status_expires (status, expires_at) + ) ENGINE=InnoDB`, + ); + console.log('P4.5 schema ready: export_jobs'); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => db.close()); diff --git a/server/scripts/migrate-reward-amount.js b/server/scripts/migrate-reward-amount.js new file mode 100644 index 0000000..8b914e3 --- /dev/null +++ b/server/scripts/migrate-reward-amount.js @@ -0,0 +1,31 @@ +'use strict'; + +const db = require('../src/infra/db'); + +async function hasColumn(table, column) { + const rows = await db.query( + `SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1`, + [table, column], + ); + return rows.length > 0; +} + +async function main() { + if (await hasColumn('performance_assessments', 'reward_amount')) { + console.log('reward_amount already exists'); + return; + } + await db.execute( + `ALTER TABLE performance_assessments + ADD COLUMN reward_amount DECIMAL(12,2) NULL AFTER performance_points`, + ); + console.log('added performance_assessments.reward_amount'); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/server/scripts/migrate-user-real-name.js b/server/scripts/migrate-user-real-name.js new file mode 100644 index 0000000..456ab16 --- /dev/null +++ b/server/scripts/migrate-user-real-name.js @@ -0,0 +1,20 @@ +'use strict'; + +const db = require('../src/infra/db'); + +async function main() { + const cols = await db.query("SHOW COLUMNS FROM users LIKE 'real_name'"); + if (!cols.length) { + await db.execute('ALTER TABLE users ADD COLUMN real_name VARCHAR(64) NULL AFTER display_name'); + console.log('users.real_name column added'); + } else { + console.log('users.real_name already exists'); + } +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => db.close()); diff --git a/server/scripts/set-password.js b/server/scripts/set-password.js new file mode 100644 index 0000000..a48f42c --- /dev/null +++ b/server/scripts/set-password.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 开发运维脚本:重置用户密码(argon2id) + * 用法:node scripts/set-password.js + * 说明:绕过 API 直接写库,仅限开发/运维;生产操作须记入变更单。 + */ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const mysql = require('mysql2/promise'); +const argon2 = require('argon2'); + +async function main() { + const [username, password] = process.argv.slice(2); + if (!username || !password) { + console.error('用法: node scripts/set-password.js '); + process.exit(1); + } + if (password.length < 10 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password)) { + console.error('密码强度不足:至少 10 位,须含大小写字母与数字'); + process.exit(1); + } + const conn = await mysql.createConnection(process.env.DATABASE_URL); + const hash = await argon2.hash(password, { type: argon2.argon2id }); + const [res] = await conn.execute( + 'UPDATE users SET password_hash = ?, pwd_changed_at = NOW(), failed_attempts = 0, locked_until = NULL WHERE username = ?', + [hash, username], + ); + if (res.affectedRows === 0) { + console.error(`用户不存在: ${username}`); + process.exit(1); + } + console.log(`密码已重置: ${username}`); + await conn.end(); +} + +main().catch((e) => { + console.error(e.message); + process.exit(1); +}); diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..296b822 --- /dev/null +++ b/server/src/app.js @@ -0,0 +1,53 @@ +'use strict'; + +const express = require('express'); +const { errorMiddleware } = require('./common/http'); + +const healthRoutes = require('./health/health.routes'); +const authRoutes = require('./auth/auth.routes'); +const modulesRoutes = require('./modules/modules.routes'); +const syncRoutes = require('./sync/sync.routes'); +const auditRoutes = require('./audit/audit.routes'); +const precomputeRoutes = require('./precompute/precompute.routes'); +const uploadRoutes = require('./upload/upload.routes'); +const exportRoutes = require('./export/export.routes'); +const statsRoutes = require('./stats/stats.routes'); +const maintenanceRoutes = require('./maintenance.routes'); +const importRoutes = require('./import/import.routes'); +const usersRoutes = require('./users/users.routes'); +const systemSettingsRoutes = require('./system-settings/system-settings.routes'); + +/** + * 组装 Express 应用(路由挂载 + 全局错误中间件)。 + * @returns {import('express').Express} + */ +function createApp() { + const app = express(); + app.set('trust proxy', true); // 取真实 req.ip(Nginx 反代后,§11.1) + app.use(express.json({ limit: '2mb' })); + + const base = '/api/v1'; + app.use(`${base}/health`, healthRoutes); + app.use(`${base}/auth`, authRoutes); + app.use(`${base}/modules`, modulesRoutes); + app.use(`${base}/sync`, syncRoutes); + app.use(`${base}/audit-logs`, auditRoutes); + app.use(`${base}/precompute`, precomputeRoutes); + app.use(`${base}/uploads`, uploadRoutes); + app.use(`${base}/exports`, exportRoutes); + app.use(`${base}/stats`, statsRoutes); + app.use(`${base}/maintenance`, maintenanceRoutes); + app.use(`${base}/imports`, importRoutes); + app.use(`${base}/users`, usersRoutes); + app.use(`${base}/system-settings`, systemSettingsRoutes); + + // 兜底 404 + app.use((req, res) => { + res.status(404).json({ code: 1004, message: `未知路由:${req.method} ${req.path}`, data: null }); + }); + + app.use(errorMiddleware); + return app; +} + +module.exports = { createApp }; diff --git a/server/src/audit/audit.js b/server/src/audit/audit.js new file mode 100644 index 0000000..efdab6a --- /dev/null +++ b/server/src/audit/audit.js @@ -0,0 +1,102 @@ +'use strict'; + +const { createHash } = require('crypto'); +const db = require('../infra/db'); + +/** + * 审计哈希链(migration-plan §10.3): + * current_hash = SHA-256(prev_hash + canonical(entry)) + * + * 并发序列化:进程内 promise 队列(PM2 单实例假设)。 + * 多实例部署时需改 DB 级锁或专用审计写入进程 —— 见实施日志 §已知问题。 + */ +let chain = Promise.resolve(); + +/** + * @typedef {Object} AuditEntry + * @property {number} [userId] + * @property {string} [username] + * @property {string} [roleCode] + * @property {'ui'|'mcp'|'system'} clientType + * @property {string} action + * @property {string} [module] + * @property {string} [targetType] + * @property {string} [targetId] + * @property {any} [payload] + * @property {string} [ip] + * @property {string} [userAgent] + */ + +/** + * 记一条审计(不阻塞业务;失败仅告警)。 + * @param {AuditEntry} entry + * @returns {Promise} + */ +function log(entry) { + const task = chain.then(() => write(entry)).catch((err) => { + console.error('[audit] write failed:', err && err.message ? err.message : err); + }); + chain = task; + return task; +} + +async function write(entry) { + const last = await db.query('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1'); + const prevHash = last[0] ? last[0].current_hash : '0'.repeat(64); + + const canonical = JSON.stringify({ + u: entry.userId ?? null, + n: entry.username ?? null, + c: entry.clientType, + a: entry.action, + m: entry.module ?? null, + tt: entry.targetType ?? null, + ti: entry.targetId ?? null, + p: entry.payload ?? null, + ts: Date.now(), + }); + const currentHash = createHash('sha256').update(prevHash).update(canonical).digest('hex'); + + await db.execute( + `INSERT INTO audit_logs + (user_id, username, role_code, client_type, action, module, + target_type, target_id, payload, prev_hash, current_hash, client_ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.userId ?? null, + entry.username ?? null, + entry.roleCode ?? null, + entry.clientType, + entry.action, + entry.module ?? null, + entry.targetType ?? null, + entry.targetId ?? null, + entry.payload != null ? JSON.stringify(entry.payload) : null, + prevHash, + currentHash, + entry.ip ?? null, + entry.userAgent ? String(entry.userAgent).slice(0, 255) : null, + ], + ); +} + +/** + * 校验哈希链完整性(GET /audit-logs/verify) + * @param {number} [limit=1000] + */ +async function verifyChain(limit = 1000) { + const safeLimit = Math.min(Math.max(Number(limit) || 1000, 1), 100000); + const rows = await db.query( + `SELECT id, prev_hash, current_hash FROM audit_logs ORDER BY id ASC LIMIT ${safeLimit}`, + ); + let prev = null; + for (const row of rows) { + if (prev !== null && row.prev_hash !== prev) { + return { ok: false, checked: rows.length, brokenAtId: Number(row.id) }; + } + prev = row.current_hash; + } + return { ok: true, checked: rows.length }; +} + +module.exports = { log, verifyChain }; diff --git a/server/src/audit/audit.routes.js b/server/src/audit/audit.routes.js new file mode 100644 index 0000000..7d0c27b --- /dev/null +++ b/server/src/audit/audit.routes.js @@ -0,0 +1,165 @@ +'use strict'; + +const express = require('express'); +const { ACTIONS } = require('@profile/shared'); +const audit = require('./audit'); +const db = require('../infra/db'); +const rbac = require('../rbac/rbac'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); + +function sanitizeMcpArgs(value) { + if (Array.isArray(value)) return value.map(sanitizeMcpArgs); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const [key, item] of Object.entries(value)) { + // 只过滤凭证类字段,保留业务参数(employeeId/name/year/team 等)供审计追踪 + if (/(password|secret|token|_enc|_hmac)/i.test(key)) continue; + out[key] = sanitizeMcpArgs(item); + } + return out; +} + +/** + * 生成人类可读的 MCP 调用摘要(供审计日志"结果摘要"列显示) + */ +function formatMcpSummary(tool, args) { + if (!args || typeof args !== 'object') return ''; + const parts = []; + if (args.keyword) parts.push(`关键词:${args.keyword}`); + if (args.employeeId) parts.push(`工号:${args.employeeId}`); + if (args.employeeName) parts.push(`姓名:${args.employeeName}`); + if (args.year) parts.push(`年份:${args.year}`); + if (args.month) parts.push(`月份:${args.month}`); + if (args.team) parts.push(`班组:${args.team}`); + if (args.modules && Array.isArray(args.modules)) parts.push(`模块:${args.modules.join(',')}`); + if (args.openUi === false) parts.push('不开窗'); + return parts.join('、') || JSON.stringify(args).slice(0, 200); +} + +router.post( + '/view', + requireAuth, + asyncHandler(async (req, res) => { + const { page, table, filters, rowCount } = req.body || {}; + const safePage = typeof page === 'string' && /^[a-z0-9_-]{1,64}$/.test(page) ? page : null; + const safeTable = typeof table === 'string' && /^[a-z0-9_]{1,64}$/.test(table) ? table : null; + if (!safePage && !safeTable) { + const err = new Error('页面或数据表标识无效'); + err.status = 400; + throw err; + } + await audit.log({ + userId: req.user.id, + username: req.user.username, + roleCode: req.user.roles?.[0], + clientType: 'ui', + action: safeTable ? 'view_data' : 'view_page', + module: safePage || safeTable, + targetType: safeTable ? 'table' : 'page', + targetId: safeTable || safePage, + payload: safeTable + ? { filters: sanitizeMcpArgs(filters || {}), rowCount: Math.max(0, Number(rowCount) || 0) } + : null, + ip: req.ip, + userAgent: req.headers['user-agent'], + }); + ok(res, { recorded: true }); + }), +); + +router.post( + '/mcp', + requireAuth, + asyncHandler(async (req, res) => { + const { tool, args, success, durationMs, error } = req.body || {}; + if (typeof tool !== 'string' || !/^profile_(query|get|list|health)_[a-z0-9_]+$/.test(tool)) { + const err = new Error('非法 MCP 工具名'); + err.status = 400; + throw err; + } + await audit.log({ + userId: req.user.id, + username: req.user.username, + roleCode: req.user.roles?.[0], + clientType: 'agent', + action: 'mcp_call', + module: 'mcp', + targetType: 'tool', + targetId: tool, + payload: { + summary: formatMcpSummary(tool, args), + args: sanitizeMcpArgs(args || {}), + success: Boolean(success), + durationMs: Math.max(0, Number(durationMs) || 0), + error: error ? String(error).slice(0, 500) : null, + }, + ip: req.ip, + userAgent: req.headers['user-agent'], + }); + ok(res, { recorded: true }); + }), +); + +router.get( + '/', + requireAuth, + rbac.requirePermission('audit', ACTIONS.READ), + asyncHandler(async (req, res) => { + const take = Math.min(Number(req.query.size) || 50, 200); + const page = Math.max(Number(req.query.page) || 1, 1); + const offset = (page - 1) * take; + + const conds = []; + const params = []; + if (req.query.client_type) { + conds.push('a.client_type = ?'); + params.push(req.query.client_type); + } + if (req.query.action) { + conds.push('a.action = ?'); + params.push(req.query.action); + } + if (req.query.username) { + const keyword = `%${String(req.query.username).slice(0, 64)}%`; + conds.push('(a.username LIKE ? OR u.real_name LIKE ? OR u.display_name LIKE ?)'); + params.push(keyword, keyword, keyword); + } + if (req.query.module) { + conds.push('a.module = ?'); + params.push(String(req.query.module).slice(0, 64)); + } + const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; + + const rows = await db.query( + `SELECT a.*, u.display_name, u.real_name + FROM audit_logs a + LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL + ${where} + ORDER BY a.id DESC + LIMIT ${take} OFFSET ${offset}`, + params, + ); + const countRows = await db.query( + `SELECT COUNT(*) AS total + FROM audit_logs a + LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL + ${where}`, + params, + ); + ok(res, { rows, total: Number(countRows[0].total), page, size: take }); + }), +); + +router.get( + '/verify', + requireAuth, + rbac.requirePermission('audit', ACTIONS.READ), + asyncHandler(async (req, res) => { + ok(res, await audit.verifyChain()); + }), +); + +module.exports = router; diff --git a/server/src/auth/auth.js b/server/src/auth/auth.js new file mode 100644 index 0000000..8ebe913 --- /dev/null +++ b/server/src/auth/auth.js @@ -0,0 +1,255 @@ +'use strict'; + +const argon2 = require('argon2'); +const { randomUUID } = require('crypto'); +const config = require('../config'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const rbac = require('../rbac/rbac'); +const audit = require('../audit/audit'); +const { ApiError, API_CODES } = require('../common/api-codes'); +const { signEdDsa, verifyEdDsa } = require('./jwt-ed25519'); + +const MAX_FAILED_ATTEMPTS = 5; +const LOCK_MINUTES = 15; + +function isStrongPassword(pwd) { + return typeof pwd === 'string' && pwd.length >= 10 && /[a-z]/.test(pwd) && /[A-Z]/.test(pwd) && /\d/.test(pwd); +} + +function signToken(payload, expiresIn) { + return signEdDsa(payload, config.jwtPrivateKey, { + expiresIn, + kid: config.jwtKid, + }); +} + +function verifyToken(token) { + return verifyEdDsa(token, config.jwtPublicKey); +} + +async function issueTokens(userId, username, roles) { + const jti = randomUUID(); + const accessToken = signToken( + { sub: userId, username, roles, type: 'access' }, + `${config.accessTtlMin}m`, + ); + const refreshToken = signToken( + { sub: userId, username, type: 'refresh', jti }, + `${config.refreshTtlHours}h`, + ); + const ttlSec = config.refreshTtlHours * 3600; + try { + await redis.client.set(`refresh:${jti}`, String(userId), 'EX', ttlSec); + await redis.client.sadd(`user_refresh:${userId}`, jti); + await redis.client.expire(`user_refresh:${userId}`, ttlSec); + } catch (e) { + // Redis 不可用时 refresh 无法旋转失效,但 access 仍有效;记录告警 + console.error('[auth] redis unavailable while issuing tokens:', e.message); + } + return { accessToken, refreshToken, expiresInSec: config.accessTtlMin * 60 }; +} + +function verifyRefresh(token) { + try { + const payload = verifyToken(token); + if (payload.type !== 'refresh') throw new Error('not refresh'); + return payload; + } catch (e) { + throw new ApiError(API_CODES.UNAUTHENTICATED, 'refresh token 无效', 401); + } +} + +async function login(username, password, meta) { + const rows = await db.query('SELECT * FROM users WHERE username = ? LIMIT 1', [username]); + const user = rows[0]; + + const fail = async (reason) => { + await audit.log({ + username, + clientType: 'ui', + action: 'login_failed', + module: 'users', + payload: { reason }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + throw new ApiError(API_CODES.UNAUTHENTICATED, '用户名或密码错误', 401); + }; + + if (!user || user.deleted_at) return fail('user_not_found'); + if (user.status === 'disabled') return fail('user_disabled'); + if (user.locked_until && new Date(user.locked_until) > new Date()) { + throw new ApiError(API_CODES.ACCOUNT_LOCKED, '账户已锁定,请稍后重试', 401); + } + + let ok = false; + try { + ok = await argon2.verify(user.password_hash, password); + } catch (e) { + ok = false; // 占位/非法 hash 一律视为失败 + } + + if (!ok) { + const attempts = Number(user.failed_attempts) + 1; + const lockedUntil = attempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null; + await db.execute('UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?', [ + attempts, + lockedUntil, + user.id, + ]); + return fail(`bad_password(attempt=${attempts})`); + } + + await db.execute( + 'UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW(), last_login_ip = ? WHERE id = ?', + [meta.ip || null, user.id], + ); + + const roles = await rbac.getRoleCodes(Number(user.id)); + const tokens = await issueTokens(Number(user.id), user.username, roles); + const isDeveloper = config.developerUsers.has(user.username); + const isOperations = isDeveloper || roles.includes('ops'); + + await audit.log({ + userId: Number(user.id), + username: user.username, + roleCode: roles[0], + clientType: 'ui', + action: 'login', + module: 'users', + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { + ...tokens, + user: { + id: Number(user.id), + username: user.username, + displayName: user.display_name, + realName: user.real_name || '', + roles, + isDeveloper, + isOperations, + }, + }; +} + +async function refresh(refreshToken) { + const payload = verifyRefresh(refreshToken); + const key = `refresh:${payload.jti}`; + // GETDEL 原子消费旧 jti,避免并发刷新请求同时复用同一 refresh token。 + const owner = await redis.client.getdel(key); + if (!owner || Number(owner) !== payload.sub) { + throw new ApiError(API_CODES.UNAUTHENTICATED, 'refresh token 已失效', 401); + } + await redis.client.srem(`user_refresh:${payload.sub}`, payload.jti); + const roles = await rbac.getRoleCodes(payload.sub); + return issueTokens(payload.sub, payload.username, roles); +} + +async function logout(user, refreshToken, meta) { + if (refreshToken) { + try { + const payload = verifyRefresh(refreshToken); + await redis.client.del(`refresh:${payload.jti}`); + await redis.client.srem(`user_refresh:${payload.sub}`, payload.jti); + } catch (e) { + /* 过期/非法忽略 */ + } + } + await audit.log({ + userId: user.id, + username: user.username, + clientType: 'ui', + action: 'logout', + module: 'users', + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { ok: true }; +} + +async function changePassword(user, oldPassword, newPassword, meta) { + const rows = await db.query('SELECT * FROM users WHERE id = ? LIMIT 1', [user.id]); + const row = rows[0]; + if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); + + let ok = false; + try { + ok = await argon2.verify(row.password_hash, oldPassword); + } catch (e) { + ok = false; + } + if (!ok) throw new ApiError(API_CODES.UNAUTHENTICATED, '原密码错误', 401); + + if (!isStrongPassword(newPassword)) { + throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400); + } + + const hash = await argon2.hash(newPassword, { type: argon2.argon2id }); + await db.execute('UPDATE users SET password_hash = ?, pwd_changed_at = NOW() WHERE id = ?', [hash, row.id]); + + // 吊销该用户所有 refresh token(强制重新登录) + try { + const jtis = await redis.client.smembers(`user_refresh:${user.id}`); + if (jtis.length > 0) await redis.client.del(...jtis.map((j) => `refresh:${j}`)); + await redis.client.del(`user_refresh:${user.id}`); + } catch (e) { + /* ignore */ + } + + await audit.log({ + userId: user.id, + username: user.username, + clientType: 'ui', + action: 'change_password', + module: 'users', + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { ok: true }; +} + +async function profile(user) { + const rows = await db.query( + 'SELECT username, display_name, real_name FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', + [user.id], + ); + const row = rows[0]; + if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); + const roles = await rbac.getRoleCodes(user.id); + const permissions = await rbac.getPermissions(user.id); + const isDeveloper = config.developerUsers.has(row.username); + return { + id: Number(user.id), + username: row.username, + displayName: row.display_name, + realName: row.real_name || '', + roles, + permissions, + isDeveloper, + isOperations: isDeveloper || roles.includes('ops'), + }; +} + +/** + * Express 中间件:校验 access JWT,挂 req.user = { id, username, roles }。 + */ +function requireAuth(req, res, next) { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + return next(new ApiError(API_CODES.UNAUTHENTICATED, '未认证', 401)); + } + try { + const payload = verifyToken(header.slice(7)); + if (payload.type !== 'access') throw new Error('wrong token type'); + req.user = { id: payload.sub, username: payload.username, roles: payload.roles || [] }; + next(); + } catch (e) { + next(new ApiError(API_CODES.UNAUTHENTICATED, 'token 无效或已过期', 401)); + } +} + +module.exports = { login, refresh, logout, changePassword, profile, requireAuth, isStrongPassword }; diff --git a/server/src/auth/auth.routes.js b/server/src/auth/auth.routes.js new file mode 100644 index 0000000..8a933a6 --- /dev/null +++ b/server/src/auth/auth.routes.js @@ -0,0 +1,65 @@ +'use strict'; + +const express = require('express'); +const auth = require('./auth'); +const { ok, asyncHandler } = require('../common/http'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const router = express.Router(); + +function meta(req) { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; +} + +function requireFields(body, fields) { + for (const f of fields) { + if (body == null || body[f] == null || body[f] === '') { + throw new ApiError(API_CODES.BAD_PARAMS, `缺少参数:${f}`, 400); + } + } +} + +// 公共端点(无需 JWT) +router.post( + '/login', + asyncHandler(async (req, res) => { + requireFields(req.body, ['username', 'password']); + ok(res, await auth.login(req.body.username, req.body.password, meta(req))); + }), +); + +router.post( + '/refresh', + asyncHandler(async (req, res) => { + requireFields(req.body, ['refreshToken']); + ok(res, await auth.refresh(req.body.refreshToken)); + }), +); + +// 需认证端点 +router.post( + '/logout', + auth.requireAuth, + asyncHandler(async (req, res) => { + ok(res, await auth.logout(req.user, req.body && req.body.refreshToken, meta(req))); + }), +); + +router.post( + '/change-password', + auth.requireAuth, + asyncHandler(async (req, res) => { + requireFields(req.body, ['oldPassword', 'newPassword']); + ok(res, await auth.changePassword(req.user, req.body.oldPassword, req.body.newPassword, meta(req))); + }), +); + +router.get( + '/profile', + auth.requireAuth, + asyncHandler(async (req, res) => { + ok(res, await auth.profile(req.user)); + }), +); + +module.exports = router; diff --git a/server/src/auth/jwt-ed25519.js b/server/src/auth/jwt-ed25519.js new file mode 100644 index 0000000..fac8f6e --- /dev/null +++ b/server/src/auth/jwt-ed25519.js @@ -0,0 +1,75 @@ +'use strict'; + +/** + * 最小 Ed25519 JWT(alg=EdDSA),用 Node crypto 签验。 + * jsonwebtoken/jwa 尚不支持 EdDSA,故自实现。 + */ +const { sign, verify } = require('crypto'); + +function b64urlJson(obj) { + return Buffer.from(JSON.stringify(obj)).toString('base64url'); +} + +function parseExpiresIn(expiresIn) { + if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) return Math.floor(expiresIn); + const raw = String(expiresIn || '').trim(); + const m = /^(\d+)([smhd])?$/i.exec(raw); + if (!m) throw new Error(`无效 expiresIn: ${expiresIn}`); + const n = Number(m[1]); + const unit = (m[2] || 's').toLowerCase(); + const mult = unit === 'm' ? 60 : unit === 'h' ? 3600 : unit === 'd' ? 86400 : 1; + return n * mult; +} + +/** + * @param {object} payload + * @param {import('crypto').KeyObject} privateKey + * @param {{ expiresIn: string|number, kid?: string }} options + */ +function signEdDsa(payload, privateKey, options) { + const now = Math.floor(Date.now() / 1000); + const body = { + ...payload, + iat: now, + exp: now + parseExpiresIn(options.expiresIn), + }; + const header = { alg: 'EdDSA', typ: 'JWT' }; + if (options.kid) header.kid = options.kid; + const encoded = `${b64urlJson(header)}.${b64urlJson(body)}`; + const signature = sign(null, Buffer.from(encoded, 'utf8'), privateKey); + return `${encoded}.${signature.toString('base64url')}`; +} + +/** + * @param {string} token + * @param {import('crypto').KeyObject} publicKey + */ +function verifyEdDsa(token, publicKey) { + if (typeof token !== 'string' || token.split('.').length !== 3) { + throw new Error('jwt malformed'); + } + const [headerB64, payloadB64, sigB64] = token.split('.'); + const encoded = `${headerB64}.${payloadB64}`; + const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8')); + if (header.alg !== 'EdDSA') throw new Error(`unexpected alg: ${header.alg}`); + const ok = verify( + null, + Buffer.from(encoded, 'utf8'), + publicKey, + Buffer.from(sigB64, 'base64url'), + ); + if (!ok) throw new Error('invalid signature'); + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')); + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === 'number' && now >= payload.exp) { + const err = new Error('jwt expired'); + err.name = 'TokenExpiredError'; + throw err; + } + if (typeof payload.nbf === 'number' && now < payload.nbf) { + throw new Error('jwt not active'); + } + return payload; +} + +module.exports = { signEdDsa, verifyEdDsa, parseExpiresIn }; diff --git a/server/src/auth/jwt-keys.js b/server/src/auth/jwt-keys.js new file mode 100644 index 0000000..d23f70a --- /dev/null +++ b/server/src/auth/jwt-keys.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * JWT Ed25519 密钥加载(EdDSA)。 + * 优先级:环境变量 PEM 明文 → 路径文件 → 默认 keys/ 目录。 + */ +const fs = require('fs'); +const path = require('path'); +const { createPrivateKey, createPublicKey } = require('crypto'); + +const DEFAULT_DIR = path.join(__dirname, '..', '..', 'keys'); +const DEFAULT_PRIVATE = path.join(DEFAULT_DIR, 'jwt_ed25519_private.pem'); +const DEFAULT_PUBLIC = path.join(DEFAULT_DIR, 'jwt_ed25519_public.pem'); + +function normalizePem(value) { + if (!value) return null; + return String(value).replace(/\\n/g, '\n').trim(); +} + +function readFileIfExists(filePath) { + if (!filePath || !fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath, 'utf8'); +} + +function loadJwtKeys() { + const privatePem = + normalizePem(process.env.JWT_PRIVATE_KEY) || + readFileIfExists(process.env.JWT_PRIVATE_KEY_PATH) || + readFileIfExists(DEFAULT_PRIVATE); + const publicPem = + normalizePem(process.env.JWT_PUBLIC_KEY) || + readFileIfExists(process.env.JWT_PUBLIC_KEY_PATH) || + readFileIfExists(DEFAULT_PUBLIC); + + if (!privatePem || !publicPem) { + throw new Error( + [ + 'JWT Ed25519 密钥未配置。请先生成密钥对:', + ' node scripts/generate-jwt-keys.js', + '或设置 JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH(或 JWT_PRIVATE_KEY / JWT_PUBLIC_KEY)。', + ].join('\n'), + ); + } + + const privateKey = createPrivateKey(privatePem); + const publicKey = createPublicKey(publicPem); + if (privateKey.asymmetricKeyType !== 'ed25519' || publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error('JWT 密钥必须为 Ed25519(algorithm=EdDSA)'); + } + + return { + privateKey, + publicKey, + kid: process.env.JWT_KEY_ID || 'ed25519-1', + algorithm: 'EdDSA', + }; +} + +module.exports = { loadJwtKeys, DEFAULT_DIR, DEFAULT_PRIVATE, DEFAULT_PUBLIC }; diff --git a/server/src/common/api-codes.js b/server/src/common/api-codes.js new file mode 100644 index 0000000..97c31d4 --- /dev/null +++ b/server/src/common/api-codes.js @@ -0,0 +1,41 @@ +'use strict'; + +/** 业务错误码(migration-plan §6.4) */ +const API_CODES = { + OK: 0, + BAD_PARAMS: 1001, + UNAUTHENTICATED: 1002, + FORBIDDEN: 1003, + NOT_FOUND: 1004, + CONFLICT: 1005, + IDEMPOTENT_DUP: 1006, + WEAK_PASSWORD: 2001, + ACCOUNT_LOCKED: 2002, + CERT_INVALID: 2003, + UPLOAD_PARSE_FAILED: 3001, + UPLOAD_VALIDATE_FAILED: 3002, + BATCH_NOT_FOUND: 3003, + UPLOAD_CONFLICT: 3004, + EXPORT_QUEUED: 4001, + EXPORT_FAILED: 4002, + SERVER_ERROR: 5000, +}; + +/** + * 携带业务码的异常。HTTP 状态与业务码解耦。 + */ +class ApiError extends Error { + /** + * @param {number} code 业务码(API_CODES) + * @param {string} message + * @param {number} [httpStatus=400] + */ + constructor(code, message, httpStatus = 400) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.httpStatus = httpStatus; + } +} + +module.exports = { API_CODES, ApiError }; diff --git a/server/src/common/crypto.js b/server/src/common/crypto.js new file mode 100644 index 0000000..bb388ed --- /dev/null +++ b/server/src/common/crypto.js @@ -0,0 +1,48 @@ +'use strict'; + +const { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } = require('crypto'); +const config = require('../config'); + +// PII 字段加密(migration-plan §10.2):AES-256-GCM +// 存储布局(VARBINARY):iv(12) || authTag(16) || ciphertext +// HMAC-SHA256 用于密文列的等值查询(*_hmac,明文不落库) +const IV_LEN = 12; +const TAG_LEN = 16; + +// 用 SHA-256 派生固定 32 字节密钥,兼容任意长度的 env 秘密串 +const ENC_KEY = createHash('sha256').update(String(config.dataEncKey)).digest(); +const HMAC_KEY = createHash('sha256').update(String(config.dataHmacKey)).digest(); + +/** + * @param {string} plain + * @returns {Buffer} iv||tag||ciphertext + */ +function encrypt(plain) { + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv('aes-256-gcm', ENC_KEY, iv); + const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ct]); +} + +/** + * @param {Buffer} buf iv||tag||ciphertext + * @returns {string} 明文 + */ +function decrypt(buf) { + const iv = buf.subarray(0, IV_LEN); + const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN); + const ct = buf.subarray(IV_LEN + TAG_LEN); + const decipher = createDecipheriv('aes-256-gcm', ENC_KEY, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); +} + +/** + * @param {string} plain + * @returns {string} 64 位十六进制 + */ +function hmac(plain) { + return createHmac('sha256', HMAC_KEY).update(String(plain)).digest('hex'); +} + +module.exports = { encrypt, decrypt, hmac }; diff --git a/server/src/common/http.js b/server/src/common/http.js new file mode 100644 index 0000000..b8eda13 --- /dev/null +++ b/server/src/common/http.js @@ -0,0 +1,63 @@ +'use strict'; + +const { API_CODES, ApiError } = require('./api-codes'); + +/** + * JSON 安全化:BigInt 无法被 JSON.stringify 序列化(直接抛 TypeError); + * Date 统一转 ISO 字符串。 + * @param {any} value + * @returns {any} + */ +function sanitize(value) { + if (value === null || value === undefined) return value; + if (typeof value === 'bigint') { + return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString(); + } + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map(sanitize); + if (typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = sanitize(v); + return out; + } + return value; +} + +/** 统一成功响应:{ code:0, message:'ok', data }(migration-plan §6.1) */ +function ok(res, data) { + res.json({ code: API_CODES.OK, message: 'ok', data: sanitize(data) }); +} + +/** + * 包装异步路由 handler,自动 catch 转交 error 中间件。 + * 用法:router.get('/x', asyncHandler(async (req,res) => {...})) + * @param {(req: any, res: any, next: any) => Promise} fn + */ +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +/** Express 全局错误中间件(4 参数) */ +// eslint-disable-next-line no-unused-vars +function errorMiddleware(err, req, res, next) { + if (err instanceof ApiError) { + return res.status(err.httpStatus).json({ code: err.code, message: err.message, data: null }); + } + const HTTP_TO_CODE = { 400: 1001, 401: 1002, 403: 1003, 404: 1004, 409: 1005 }; + if (err && typeof err.status === 'number') { + return res.status(err.status).json({ + code: HTTP_TO_CODE[err.status] || API_CODES.SERVER_ERROR, + message: err.message || '请求错误', + data: null, + }); + } + console.error('[error]', err && err.stack ? err.stack : err); + const detail = err?.sqlMessage || err?.message; + return res.status(500).json({ + code: API_CODES.SERVER_ERROR, + message: detail ? `服务器错误:${detail}` : '服务器错误', + data: null, + }); +} + +module.exports = { sanitize, ok, asyncHandler, errorMiddleware }; diff --git a/server/src/common/responsible.js b/server/src/common/responsible.js new file mode 100644 index 0000000..cc70d74 --- /dev/null +++ b/server/src/common/responsible.js @@ -0,0 +1,145 @@ +'use strict'; + +const db = require('../infra/db'); + +let settingsCache = null; +let settingsCacheAt = 0; +const SETTINGS_TTL_MS = 30 * 1000; // 30 秒缓存 + +async function queryWith(conn, sql, params) { + if (conn) { + const [rows] = await conn.execute(sql, params); + return rows; + } + return db.query(sql, params); +} + +async function getResponsibleSettings(force = false, conn = null) { + const now = Date.now(); + if (!force && !conn && settingsCache && (now - settingsCacheAt) < SETTINGS_TTL_MS) { + return settingsCache; + } + const rows = await queryWith( + conn, + `SELECT setting_key, setting_value FROM system_settings + WHERE setting_key IN ('manager_name', 'online_exam_responsible')`, + ); + const result = { managerName: '', onlineExamResponsible: '' }; + for (const row of rows || []) { + if (row.setting_key === 'manager_name') { + result.managerName = row.setting_value == null ? '' : String(row.setting_value); + } else if (row.setting_key === 'online_exam_responsible') { + result.onlineExamResponsible = row.setting_value == null ? '' : String(row.setting_value); + } + } + if (!conn) { + settingsCache = result; + settingsCacheAt = now; + } + return result; +} + +function invalidateSettingsCache() { + settingsCache = null; + settingsCacheAt = 0; +} + +/** + * 取所有未删除员工(用于班组→乘务正组长映射)。 + * 事务内请传 conn,否则看不到本事务刚写入的班组/岗位。 + * @param {import('mysql2/promise').PoolConnection|null} [conn] + * @returns {Promise>} + */ +async function loadEmployeesIndex(conn = null) { + const rows = await queryWith( + conn, + `SELECT employee_id, name, team, position, employment_status + FROM employees + WHERE deleted_at IS NULL`, + ); + const map = new Map(); + for (const row of rows || []) { + map.set(String(row.employee_id), { + employee_id: String(row.employee_id), + name: String(row.name || ''), + team: row.team ? String(row.team) : '', + position: row.position ? String(row.position) : '', + employment_status: row.employment_status || '在职', + }); + } + return map; +} + +/** 班组 → 乘务组长名字(查不到返回 '')*/ +function buildTeamLeaderMap(employeesMap) { + const teamLeader = new Map(); + for (const emp of employeesMap.values()) { + if (emp.position === '乘务组长' && (emp.employment_status === '在职' || !emp.employment_status)) { + if (emp.team && !teamLeader.has(emp.team)) teamLeader.set(emp.team, emp.name); + } + } + return teamLeader; +} + +/** + * 给一个员工推算责任人(导入时调用,写入业务表快照)。 + * 规则: + * - 本人 position='乘务组长'/'乘务副组长' → manager_name + * - 否则 → 同班组乘务组长名字 + * - 查不到 → manager_name + * @param {string} employeeId + * @param {Map} [employeesMapOverride] 可选:批量导入时预加载,避免重复 SQL + * @param {import('mysql2/promise').PoolConnection|null} [conn] + * @returns {Promise} + */ +async function computeResponsiblePerson(employeeId, employeesMapOverride, conn = null) { + const settings = await getResponsibleSettings(false, conn); + const manager = settings.managerName || ''; + const employeesMap = employeesMapOverride || (await loadEmployeesIndex(conn)); + const emp = employeesMap.get(String(employeeId || '')); + if (!emp) return manager; + if (emp.position === '乘务组长' || emp.position === '乘务副组长') { + return manager; + } + const teamLeader = buildTeamLeaderMap(employeesMap); + const leaderName = emp.team ? (teamLeader.get(emp.team) || '') : ''; + return leaderName || manager; +} + +/** + * 批量给多个员工计算责任人(避免每个员工一次 SQL)。 + * @param {string[]} employeeIds + * @param {{ conn?: import('mysql2/promise').PoolConnection|null }} [options] + * @returns {Promise>} { employeeId: responsibleName } + */ +async function computeResponsiblePersonBatch(employeeIds, options = {}) { + const conn = options.conn || null; + const settings = await getResponsibleSettings(false, conn); + const manager = settings.managerName || ''; + const employeesMap = await loadEmployeesIndex(conn); + const teamLeader = buildTeamLeaderMap(employeesMap); + const result = {}; + for (const rawId of employeeIds || []) { + const id = String(rawId || ''); + const emp = employeesMap.get(id); + if (!emp) { + result[id] = manager; + continue; + } + if (emp.position === '乘务组长' || emp.position === '乘务副组长') { + result[id] = manager; + continue; + } + result[id] = (emp.team && teamLeader.get(emp.team)) || manager; + } + return result; +} + +module.exports = { + getResponsibleSettings, + invalidateSettingsCache, + loadEmployeesIndex, + buildTeamLeaderMap, + computeResponsiblePerson, + computeResponsiblePersonBatch, +}; diff --git a/server/src/common/table-utils.js b/server/src/common/table-utils.js new file mode 100644 index 0000000..c378809 --- /dev/null +++ b/server/src/common/table-utils.js @@ -0,0 +1,88 @@ +'use strict'; + +const db = require('../infra/db'); + +const AUTO_COLUMNS = new Set(['id', 'created_at', 'updated_at']); +const columnsCache = new Map(); + +/** + * 取表列名(缓存)。DDL 稳定,进程级缓存即可。 + * @param {string} table + * @returns {Promise} + */ +async function getColumns(table) { + if (columnsCache.has(table)) return columnsCache.get(table); + const rows = await db.query( + `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION`, + [table], + ); + const cols = rows.map((r) => r.COLUMN_NAME); + columnsCache.set(table, cols); + return cols; +} + +/** + * 依据表实际列,推断按月过滤条件(兼容三种月份维度命名)。 + * 无月份维度(字典/全量表)→ 返回空条件。 + * @param {string[]} columns + * @param {number} year + * @param {number} month + * @returns {{ clause: string, params: any[] }} + */ +function monthWhere(columns, year, month) { + if (columns.includes('exam_year') && columns.includes('exam_month')) { + return { clause: 'exam_year = ? AND exam_month = ?', params: [year, month] }; + } + if (columns.includes('year') && columns.includes('month')) { + return { clause: '`year` = ? AND `month` = ?', params: [year, month] }; + } + if (columns.includes('missing_month')) { + return { clause: 'missing_month = ?', params: [`${year}-${String(month).padStart(2, '0')}`] }; + } + return { clause: '', params: [] }; +} + +/** + * 值归一化:对象/数组转 JSON 字符串;Buffer/Date/标量原样;undefined→null。 + * @param {any} v + */ +function coerceValue(v) { + if (v === undefined || v === null) return null; + if (Buffer.isBuffer(v) || v instanceof Date) return v; + if (typeof v === 'object') return JSON.stringify(v); + return v; +} + +/** + * 生成多行 INSERT(列 = 表列 ∩ 行键,排除自增/时间戳自动列)。 + * @param {string} table + * @param {string[]} tableColumns + * @param {Object[]} rows + * @returns {{ sql: string, params: any[], columns: string[] } | null} 无有效列时返回 null + */ +function buildMultiInsert(table, tableColumns, rows) { + const rowKeys = new Set(); + for (const r of rows) for (const k of Object.keys(r)) rowKeys.add(k); + + const columns = tableColumns.filter((c) => !AUTO_COLUMNS.has(c) && rowKeys.has(c)); + if (columns.length === 0) return null; + + const placeholderRow = `(${columns.map(() => '?').join(', ')})`; + const sql = + `INSERT INTO \`${table}\` (${columns.map((c) => `\`${c}\``).join(', ')}) ` + + `VALUES ${rows.map(() => placeholderRow).join(', ')}`; + const params = []; + for (const r of rows) for (const c of columns) params.push(coerceValue(r[c])); + return { sql, params, columns }; +} + +/** 将数组按 size 切块 */ +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +module.exports = { getColumns, monthWhere, coerceValue, buildMultiInsert, chunk }; diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..8d86f4d --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,36 @@ +'use strict'; + +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const { loadJwtKeys } = require('./auth/jwt-keys'); + +if (process.env.JWT_SECRET) { + console.warn('[config] JWT_SECRET 已弃用:现使用 Ed25519(EdDSA)非对称签名,请改用 JWT_*_KEY(_PATH)'); +} + +const jwtKeys = loadJwtKeys(); + +const config = { + port: Number(process.env.PORT || 3000), + databaseUrl: process.env.DATABASE_URL || 'mysql://profile:profile_dev_pass@127.0.0.1:3306/profile_dev', + redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379', + jwtPrivateKey: jwtKeys.privateKey, + jwtPublicKey: jwtKeys.publicKey, + jwtAlgorithm: jwtKeys.algorithm, + jwtKid: jwtKeys.kid, + accessTtlMin: Number(process.env.JWT_ACCESS_TTL_MIN || 30), + refreshTtlHours: Number(process.env.JWT_REFRESH_TTL_HOURS || 8), + dataEncKey: process.env.DATA_ENC_KEY || 'dev-only-data-encryption-key-change-me', + dataHmacKey: process.env.DATA_HMAC_KEY || 'dev-only-data-hmac-key-change-me', + exportDir: process.env.EXPORT_DIR || path.resolve(__dirname, '..', '..', '..', 'data', 'exports'), + exportRetentionDays: Number(process.env.EXPORT_RETENTION_DAYS || 7), + developerUsers: new Set( + String(process.env.DEVELOPER_USERNAMES || 'dev_admin') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + ), +}; + +module.exports = config; diff --git a/server/src/export/backup.js b/server/src/export/backup.js new file mode 100644 index 0000000..08e07af --- /dev/null +++ b/server/src/export/backup.js @@ -0,0 +1,170 @@ +'use strict'; + +const { allSyncTables, BACKUP_TABLES } = require('@profile/shared'); +const db = require('../infra/db'); +const sync = require('../sync/sync'); +const audit = require('../audit/audit'); +const precomputeService = require('../precompute/service'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const { getColumns, coerceValue, chunk } = require('../common/table-utils'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +/** v1 仅含原表;恢复时需清空派生表并重算 */ +const V1_SOURCE_TABLES = [ + 'employees', 'online_exams', 'missing_exams', 'practical_exams', 'practical_exam_steps', + 'papers', 'paper_steps', 'kilometers_records', 'question_banks', +]; + +const V1_DERIVED_TABLES = [ + 'online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly', + 'online_exam_category_stats_monthly', 'online_exam_category_stats_yearly', + '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_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', +]; + +const ALLOWED = new Set(BACKUP_TABLES); + +function revive(value) { + if (Array.isArray(value)) return value.map(revive); + if (!value || typeof value !== 'object') return value; + if (value.encoding === 'base64' && typeof value.data === 'string') { + return Buffer.from(value.data, 'base64'); + } + if (value.type === 'Buffer' && Array.isArray(value.data)) return Buffer.from(value.data); + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, revive(item)])); +} + +function buildInsert(table, columns, rows) { + const keys = new Set(); + for (const row of rows) for (const key of Object.keys(row)) keys.add(key); + const selected = columns.filter((column) => keys.has(column)); + if (selected.length === 0) return null; + const placeholders = `(${selected.map(() => '?').join(',')})`; + return { + sql: `INSERT INTO \`${table}\` (${selected.map((column) => `\`${column}\``).join(',')}) VALUES ${rows.map(() => placeholders).join(',')}`, + params: rows.flatMap((row) => selected.map((column) => coerceValue(row[column]))), + }; +} + +async function dumpTables(tableNames) { + const tables = {}; + for (const table of tableNames) { + tables[table] = await db.query(`SELECT * FROM \`${table}\``); + } + return tables; +} + +/** 生成完整业务库备份 JSON(供导出任务调用) */ +async function buildBackupPayload() { + return { + format: 'profile-backup-v2', + createdAt: new Date().toISOString(), + tables: await dumpTables(BACKUP_TABLES), + }; +} + +async function restoreTableRows(conn, table, rows) { + const columns = await getColumns(table); + for (const part of chunk(rows || [], 200)) { + const insert = buildInsert(table, columns, part); + if (insert) await conn.execute(insert.sql, insert.params); + } +} + +async function restore(buffer, operator, meta) { + let backup; + try { + backup = revive(JSON.parse(buffer.toString('utf8'))); + } catch { + throw new ApiError(API_CODES.BAD_PARAMS, '备份文件不是有效 JSON', 400); + } + + const format = backup.format; + if ((format !== 'profile-backup-v1' && format !== 'profile-backup-v2') + || !backup.tables + || typeof backup.tables !== 'object') { + throw new ApiError(API_CODES.BAD_PARAMS, '不支持的备份格式', 400); + } + + for (const table of Object.keys(backup.tables)) { + if (!ALLOWED.has(table)) { + throw new ApiError(API_CODES.BAD_PARAMS, `备份包含未授权表:${table}`, 400); + } + if (!Array.isArray(backup.tables[table])) { + throw new ApiError(API_CODES.BAD_PARAMS, `备份表格式错误:${table}`, 400); + } + } + + const isV2 = format === 'profile-backup-v2'; + const restoreTables = isV2 + ? [...BACKUP_TABLES] + : Object.keys(backup.tables).filter((table) => V1_SOURCE_TABLES.includes(table)); + + await db.transaction(async (conn) => { + await conn.query('SET FOREIGN_KEY_CHECKS = 0'); + try { + if (!isV2) { + for (const table of V1_DERIVED_TABLES) { + await conn.execute(`DELETE FROM \`${table}\``); + } + } + for (const table of [...restoreTables].reverse()) { + await conn.execute(`DELETE FROM \`${table}\``); + } + for (const table of restoreTables) { + await restoreTableRows(conn, table, backup.tables[table] || []); + } + } finally { + await conn.query('SET FOREIGN_KEY_CHECKS = 1'); + } + }); + + let recomputed = []; + if (!isV2) { + const reports = await precomputeService.onEmployeesChanged(); + await dashboardSummary.recompute(); + recomputed = reports.map((report) => report.module); + } + + await sync.bumpVersions([...new Set([...BACKUP_TABLES, ...allSyncTables()])]); + + const counts = Object.fromEntries( + restoreTables.map((table) => [table, (backup.tables[table] || []).length]), + ); + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'backup_restore', + module: 'exports', + targetType: 'backup', + payload: { + format, + createdAt: backup.createdAt || null, + tables: counts, + recomputed, + }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { + restored: true, + format, + tables: restoreTables.length, + rowCounts: counts, + }; +} + +module.exports = { + restore, + BACKUP_TABLES, + buildBackupPayload, +}; diff --git a/server/src/export/export.routes.js b/server/src/export/export.routes.js new file mode 100644 index 0000000..4c66945 --- /dev/null +++ b/server/src/export/export.routes.js @@ -0,0 +1,85 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); +const { ACTIONS } = require('@profile/shared'); +const { requireAuth } = require('../auth/auth'); +const rbac = require('../rbac/rbac'); +const audit = require('../audit/audit'); +const service = require('./export.service'); +const backup = require('./backup'); +const maintenance = require('../maintenance'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); +const uploadBackup = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 100 * 1024 * 1024, files: 1 }, + fileFilter: (_req, file, callback) => { + callback(null, file.originalname.toLowerCase().endsWith('.json')); + }, +}); + +function meta(req) { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; +} + +router.get( + '/jobs/:id', + requireAuth, + rbac.requirePermission('exports', ACTIONS.EXPORT), + asyncHandler(async (req, res) => { + ok(res, await service.getJob(req.params.id, req.user)); + }), +); + +router.get( + '/jobs/:id/download', + requireAuth, + rbac.requirePermission('exports', ACTIONS.EXPORT), + asyncHandler(async (req, res) => { + const job = await service.getDownload(req.params.id, req.user); + await audit.log({ + userId: req.user.id, + username: req.user.username, + roleCode: req.user.roles?.[0], + clientType: 'ui', + action: 'export_download', + module: 'exports', + targetType: 'export_job', + targetId: req.params.id, + payload: { fileName: job.file_name, fileHash: job.file_hash }, + ...meta(req), + }); + res.type(job.mime_type || 'application/octet-stream'); + res.download(job.file_path, job.file_name); + }), +); + +router.post( + '/backup/restore', + requireAuth, + maintenance.requireDeveloper, + rbac.requirePermission('imports', ACTIONS.APPROVE), + uploadBackup.single('backup'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('请选择有效的 JSON 备份文件'); + error.status = 400; + throw error; + } + ok(res, await backup.restore(req.file.buffer, req.user, meta(req))); + }), +); + +router.post( + '/:type', + requireAuth, + rbac.requirePermission('exports', ACTIONS.EXPORT), + asyncHandler(async (req, res) => { + res.status(202); + ok(res, await service.submit(req.params.type, req.body || {}, req.user, meta(req))); + }), +); + +module.exports = router; diff --git a/server/src/export/export.service.js b/server/src/export/export.service.js new file mode 100644 index 0000000..4e47954 --- /dev/null +++ b/server/src/export/export.service.js @@ -0,0 +1,100 @@ +'use strict'; + +const fs = require('fs'); +const { randomUUID } = require('crypto'); +const config = require('../config'); +const db = require('../infra/db'); +const audit = require('../audit/audit'); +const { exportQueue } = require('./queue'); +const { EXPORT_TYPES } = require('./generator'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +function canAccess(job, user) { + return Number(job.requested_by) === Number(user.id) || (user.roles || []).includes('admin'); +} + +async function submit(type, params, user, meta) { + if (!EXPORT_TYPES.has(type)) { + throw new ApiError(API_CODES.BAD_PARAMS, `不支持的导出类型:${type}`, 400); + } + const jobUuid = randomUUID(); + await db.execute( + `INSERT INTO export_jobs + (job_uuid, export_type, params, status, progress, requested_by, expires_at) + VALUES (?, ?, ?, 'queued', 0, ?, DATE_ADD(NOW(), INTERVAL ? DAY))`, + [jobUuid, type, JSON.stringify(params || {}), user.id, config.exportRetentionDays], + ); + await exportQueue.add(type, { jobUuid, type, params: params || {}, requestedBy: user.id }, { jobId: jobUuid }); + await audit.log({ + userId: user.id, + username: user.username, + roleCode: user.roles?.[0], + clientType: 'ui', + action: 'export_submit', + module: 'exports', + targetType: 'export_job', + targetId: jobUuid, + payload: { type, params: params || {} }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { jobId: jobUuid, status: 'queued', expiresInDays: config.exportRetentionDays }; +} + +async function getJob(jobUuid, user) { + const rows = await db.query( + `SELECT job_uuid, export_type, params, status, progress, requested_by, + file_name, mime_type, file_size, file_hash, error, + created_at, started_at, completed_at, expires_at + FROM export_jobs WHERE job_uuid = ? LIMIT 1`, + [jobUuid], + ); + const job = rows[0]; + if (!job) throw new ApiError(API_CODES.NOT_FOUND, '导出任务不存在', 404); + if (!canAccess(job, user)) throw new ApiError(API_CODES.FORBIDDEN, '无权访问该导出任务', 403); + return { + jobId: job.job_uuid, + type: job.export_type, + params: job.params, + status: job.status, + progress: Number(job.progress), + fileName: job.file_name, + mimeType: job.mime_type, + fileSize: job.file_size == null ? null : Number(job.file_size), + fileHash: job.file_hash, + error: job.error, + createdAt: job.created_at, + startedAt: job.started_at, + completedAt: job.completed_at, + expiresAt: job.expires_at, + }; +} + +async function getDownload(jobUuid, user) { + const rows = await db.query('SELECT * FROM export_jobs WHERE job_uuid = ? LIMIT 1', [jobUuid]); + const job = rows[0]; + if (!job) throw new ApiError(API_CODES.NOT_FOUND, '导出任务不存在', 404); + if (!canAccess(job, user)) throw new ApiError(API_CODES.FORBIDDEN, '无权下载该导出文件', 403); + if (job.status !== 'completed') throw new ApiError(API_CODES.BAD_PARAMS, '导出任务尚未完成', 409); + if (job.expires_at && new Date(job.expires_at) <= new Date()) { + throw new ApiError(API_CODES.NOT_FOUND, '导出文件已过期', 410); + } + if (!job.file_path || !fs.existsSync(job.file_path)) { + throw new ApiError(API_CODES.NOT_FOUND, '导出文件不存在', 404); + } + return job; +} + +async function cleanupExpired() { + const rows = await db.query( + `SELECT id, file_path FROM export_jobs + WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at <= NOW()`, + ); + for (const row of rows) { + if (row.file_path && fs.existsSync(row.file_path)) fs.unlinkSync(row.file_path); + await db.execute("UPDATE export_jobs SET status = 'expired', file_path = NULL WHERE id = ?", [row.id]); + } + return rows.length; +} + +module.exports = { submit, getJob, getDownload, cleanupExpired }; diff --git a/server/src/export/generator.js b/server/src/export/generator.js new file mode 100644 index 0000000..80ffac0 --- /dev/null +++ b/server/src/export/generator.js @@ -0,0 +1,279 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const ExcelJS = require('exceljs'); +const config = require('../config'); +const db = require('../infra/db'); +const { buildBackupPayload } = require('./backup'); + +const EXPORT_TYPES = new Set([ + 'employees', + 'online-exams', + 'practical-exams', + 'team-analysis', + 'analysis-report', + 'question-details', + 'backup', +]); + +function safeNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function jsonCell(value) { + if (value == null) return null; + return typeof value === 'object' ? JSON.stringify(value) : value; +} + +function normalizeRows(rows) { + return rows.map((row) => Object.fromEntries( + Object.entries(row).map(([key, value]) => [key, jsonCell(value)]), + )); +} + +function addSheet(workbook, name, rows) { + const normalized = normalizeRows(rows); + const data = normalized.length > 0 ? normalized : [{ message: '无数据' }]; + const keys = [...new Set(data.flatMap((row) => Object.keys(row)))]; + const sheet = workbook.addWorksheet(name.slice(0, 31)); + sheet.columns = keys.map((key) => ({ header: key, key, width: Math.min(40, Math.max(12, key.length + 4)) })); + sheet.addRows(data); + sheet.getRow(1).font = { bold: true }; + sheet.views = [{ state: 'frozen', ySplit: 1 }]; +} + +function periodWhere(alias, params, values) { + const clauses = []; + const year = safeNumber(params.year); + const month = safeNumber(params.month); + if (year != null) { + clauses.push(`${alias}.exam_year = ?`); + values.push(year); + } + if (month != null) { + clauses.push(`${alias}.exam_month = ?`); + values.push(month); + } + if (params.team) { + clauses.push('e.team = ?'); + values.push(String(params.team)); + } + if (params.employeeId) { + clauses.push(`${alias}.employee_id = ?`); + values.push(String(params.employeeId)); + } + return clauses; +} + +async function employeesWorkbook(params = {}) { + const values = []; + const where = params.employeeId ? 'AND employee_id = ?' : 'AND (employment_status = \'在职\' OR employment_status IS NULL)'; + if (params.employeeId) values.push(String(params.employeeId)); + const rows = await db.query( + `SELECT employee_id, internal_employee_id, name, team, position, native_place, + nationality, political_status, education, education_type, graduate_school, + graduate_date, skill_level, hire_date, birth_date, status, employment_status, source, details, extras + FROM employees + WHERE deleted_at IS NULL + ${where} + ORDER BY team, employee_id`, + values, + ); + const workbook = new ExcelJS.Workbook(); + addSheet(workbook, '员工档案', rows); + return workbook; +} + +async function onlineWorkbook(params) { + const values = []; + const clauses = periodWhere('o', params, values); + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const exams = await db.query( + `SELECT o.employee_id, e.name AS employee_name, e.team, e.position, + o.exam_year, o.exam_month, o.paper_class, o.score, o.result, + o.faults, o.choice_faults, o.submit_time, o.elapsed_seconds, o.details + FROM online_exams o + LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL + ${where} + ORDER BY o.exam_year, o.exam_month, e.team, o.employee_id`, + values, + ); + const missingValues = []; + const missingClauses = []; + if (safeNumber(params.year) != null && safeNumber(params.month) != null) { + missingClauses.push('m.missing_month = ?'); + missingValues.push(`${safeNumber(params.year)}-${String(safeNumber(params.month)).padStart(2, '0')}`); + } else if (safeNumber(params.year) != null) { + missingClauses.push('m.missing_month LIKE ?'); + missingValues.push(`${safeNumber(params.year)}-%`); + } + if (params.team) { + missingClauses.push('e.team = ?'); + missingValues.push(String(params.team)); + } + const missingWhere = missingClauses.length ? `WHERE ${missingClauses.join(' AND ')}` : ''; + const missing = await db.query( + `SELECT m.employee_id, e.name AS employee_name, e.team, e.position, + m.missing_month, m.reason, m.import_time, m.details + FROM missing_exams m + LEFT JOIN employees e ON e.employee_id = m.employee_id AND e.deleted_at IS NULL + ${missingWhere} + ORDER BY m.missing_month, e.team, m.employee_id`, + missingValues, + ); + const workbook = new ExcelJS.Workbook(); + addSheet(workbook, '在线考试', exams); + addSheet(workbook, '缺考', missing); + return workbook; +} + +async function practicalWorkbook(params) { + const values = []; + const clauses = periodWhere('p', params, values); + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const rows = await db.query( + `SELECT p.employee_id, e.name AS employee_name, e.team, e.position, + p.exam_year, p.exam_month, p.paper_id, p.pc_class AS paper_class, + p.score, p.result, p.faults, p.create_time, p.details + FROM practical_exams p + LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL + ${where} + ORDER BY p.exam_year, p.exam_month, e.team, p.employee_id`, + values, + ); + const workbook = new ExcelJS.Workbook(); + addSheet(workbook, '实操考试', rows); + return workbook; +} + +async function questionDetailsWorkbook(params) { + const values = []; + const clauses = periodWhere('o', params, values); + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const rows = await db.query( + `SELECT o.employee_id, e.name AS employee_name, e.team, o.exam_year, o.exam_month, + o.paper_class, o.score, o.result, + JSON_EXTRACT(o.details, '$.questionScores') AS question_scores, + o.details + FROM online_exams o + LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL + ${where} + ORDER BY o.exam_year, o.exam_month, e.team, o.employee_id`, + values, + ); + const workbook = new ExcelJS.Workbook(); + addSheet(workbook, '题目作答原始明细', rows); + return workbook; +} + +async function teamWorkbook(params) { + const year = safeNumber(params.year); + const month = safeNumber(params.month); + const values = []; + const clauses = []; + if (year != null) { + clauses.push('year = ?'); + values.push(year); + } + if (month != null) { + clauses.push('month = ?'); + values.push(month); + } + if (params.team) { + clauses.push('team = ?'); + values.push(String(params.team)); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [online, practical, kilometers] = await Promise.all([ + db.query(`SELECT * FROM online_exam_stats_monthly ${where} ORDER BY year, month, team`, values), + db.query(`SELECT * FROM practical_exam_stats_monthly ${where} ORDER BY year, month, team`, values), + db.query(`SELECT * FROM kilometers_stats_team_monthly ${where} ORDER BY year, month, team`, values), + ]); + const workbook = new ExcelJS.Workbook(); + addSheet(workbook, '在线考试班组统计', online); + addSheet(workbook, '实操考试班组统计', practical); + addSheet(workbook, '班组公里数', kilometers); + return workbook; +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[char]); +} + +async function analysisHtml(params) { + const summaryRows = await db.query("SELECT payload, computed_at FROM dashboard_summary WHERE scope_key = 'global' LIMIT 1"); + const summary = summaryRows[0]?.payload || {}; + const team = params.team ? ` · ${escapeHtml(params.team)}` : ''; + return `综合分析报告 + +

综合分析报告${team}

统计时间:${escapeHtml(summaryRows[0]?.computed_at || new Date().toISOString())}

+
+
员工总数
${Number(summary.totalEmployees) || 0}
+
在线考试
${Number(summary.totalOnlineExams) || 0}
+
实操考试
${Number(summary.totalPracticalExams) || 0}
+
在线通过率
${Number(summary.passRate) || 0}%
+
累计公里数
${Number(summary.totalKilometers) || 0}
+
驾驶员人数
${Number(summary.uniqueKilometerEmployees) || 0}
+
`; +} + +function backupReplacer(_key, value) { + if (Buffer.isBuffer(value)) return { encoding: 'base64', data: value.toString('base64') }; + return value; +} + +async function backupJson() { + const data = await buildBackupPayload(); + return JSON.stringify(data, backupReplacer, 2); +} + +async function generate(jobUuid, type, params = {}) { + if (!EXPORT_TYPES.has(type)) throw new Error(`不支持的导出类型:${type}`); + fs.mkdirSync(config.exportDir, { recursive: true, mode: 0o700 }); + const baseName = `${type}-${jobUuid}`; + let fileName; + let filePath; + let mimeType; + + if (type === 'analysis-report') { + fileName = `${baseName}.html`; + filePath = path.join(config.exportDir, fileName); + mimeType = 'text/html; charset=utf-8'; + fs.writeFileSync(filePath, await analysisHtml(params), { mode: 0o600 }); + } else if (type === 'backup') { + fileName = `${baseName}.json`; + filePath = path.join(config.exportDir, fileName); + mimeType = 'application/json; charset=utf-8'; + fs.writeFileSync(filePath, await backupJson(), { mode: 0o600 }); + } else { + const factories = { + employees: employeesWorkbook, + 'online-exams': onlineWorkbook, + 'practical-exams': practicalWorkbook, + 'team-analysis': teamWorkbook, + 'question-details': questionDetailsWorkbook, + }; + const workbook = await factories[type](params); + fileName = `${baseName}.xlsx`; + filePath = path.join(config.exportDir, fileName); + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + await workbook.xlsx.writeFile(filePath); + fs.chmodSync(filePath, 0o600); + } + + const bytes = fs.readFileSync(filePath); + return { + fileName, + filePath, + mimeType, + fileSize: bytes.length, + fileHash: crypto.createHash('sha256').update(bytes).digest('hex'), + }; +} + +module.exports = { EXPORT_TYPES, generate }; diff --git a/server/src/export/queue.js b/server/src/export/queue.js new file mode 100644 index 0000000..8be4a47 --- /dev/null +++ b/server/src/export/queue.js @@ -0,0 +1,22 @@ +'use strict'; + +const IORedis = require('ioredis'); +const { Queue } = require('bullmq'); +const config = require('../config'); + +const connection = new IORedis(config.redisUrl, { + maxRetriesPerRequest: null, + enableReadyCheck: true, +}); + +const exportQueue = new Queue('profile-exports', { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + removeOnComplete: 100, + removeOnFail: 200, + }, +}); + +module.exports = { exportQueue, connection }; diff --git a/server/src/export/worker.js b/server/src/export/worker.js new file mode 100644 index 0000000..aa18a05 --- /dev/null +++ b/server/src/export/worker.js @@ -0,0 +1,77 @@ +'use strict'; + +const { Worker } = require('bullmq'); +const db = require('../infra/db'); +const audit = require('../audit/audit'); +const { connection } = require('./queue'); +const { generate } = require('./generator'); +const exportService = require('./export.service'); + +const worker = new Worker( + 'profile-exports', + async (job) => { + const { jobUuid, type, params } = job.data; + await db.execute( + "UPDATE export_jobs SET status = 'processing', progress = 10, started_at = NOW(), error = NULL WHERE job_uuid = ?", + [jobUuid], + ); + await job.updateProgress(10); + const file = await generate(jobUuid, type, params); + await job.updateProgress(90); + await db.execute( + `UPDATE export_jobs + SET status = 'completed', progress = 100, file_name = ?, file_path = ?, + mime_type = ?, file_size = ?, file_hash = ?, completed_at = NOW() + WHERE job_uuid = ?`, + [file.fileName, file.filePath, file.mimeType, file.fileSize, file.fileHash, jobUuid], + ); + await audit.log({ + userId: Number(job.data.requestedBy), + clientType: 'system', + action: 'export_completed', + module: 'exports', + targetType: 'export_job', + targetId: jobUuid, + payload: { type, fileName: file.fileName, fileSize: file.fileSize, fileHash: file.fileHash }, + }); + return { fileName: file.fileName, fileHash: file.fileHash }; + }, + { + connection: connection.duplicate(), + concurrency: 2, + limiter: { max: 10, duration: 60 * 1000 }, + }, +); + +worker.on('failed', async (job, error) => { + if (!job) return; + await db.execute( + "UPDATE export_jobs SET status = 'failed', progress = 0, error = ?, completed_at = NOW() WHERE job_uuid = ?", + [String(error.message || error).slice(0, 1000), job.data.jobUuid], + ); + await audit.log({ + userId: Number(job.data.requestedBy), + clientType: 'system', + action: 'export_failed', + module: 'exports', + targetType: 'export_job', + targetId: job.data.jobUuid, + payload: { type: job.data.type, error: String(error.message || error).slice(0, 500) }, + }); +}); + +const cleanupTimer = setInterval(() => { + exportService.cleanupExpired().catch((error) => console.error('[export-worker] cleanup failed:', error.message)); +}, 60 * 60 * 1000); + +async function shutdown() { + clearInterval(cleanupTimer); + await worker.close(); + await connection.quit(); + await db.close(); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +console.log('[export-worker] ready, concurrency=2'); diff --git a/server/src/health/health.routes.js b/server/src/health/health.routes.js new file mode 100644 index 0000000..cdd65da --- /dev/null +++ b/server/src/health/health.routes.js @@ -0,0 +1,30 @@ +'use strict'; + +const express = require('express'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + const result = { server: 'ok' }; + try { + await db.query('SELECT 1'); + result.mysql = 'ok'; + } catch (e) { + result.mysql = 'down'; + } + try { + await redis.client.ping(); + result.redis = 'ok'; + } catch (e) { + result.redis = 'down'; + } + ok(res, result); + }), +); + +module.exports = router; diff --git a/server/src/import/crew-rewards.js b/server/src/import/crew-rewards.js new file mode 100644 index 0000000..608bbb5 --- /dev/null +++ b/server/src/import/crew-rewards.js @@ -0,0 +1,610 @@ +'use strict'; + +const { createHash, randomUUID } = require('crypto'); +const ExcelJS = require('exceljs'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const sync = require('../sync/sync'); +const audit = require('../audit/audit'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const SOURCE_TYPE = 'crew_rewards'; +const PREVIEW_TTL_SECONDS = 30 * 60; +const PREVIEW_PREFIX = 'crew-rewards-import:'; +const LABEL = '嘉奖'; +const REMARKS_KEY_CHARS = 80; + +let schemaReady = null; + +async function hasColumn(table, column) { + const rows = await db.query( + `SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1`, + [table, column], + ); + return rows.length > 0; +} + +async function ensureSchema() { + if (schemaReady) return schemaReady; + schemaReady = (async () => { + if (!await hasColumn('performance_assessments', 'reward_amount')) { + await db.execute( + `ALTER TABLE performance_assessments + ADD COLUMN reward_amount DECIMAL(12,2) NULL AFTER performance_points`, + ); + } + })().catch((error) => { + schemaReady = null; + throw error; + }); + return schemaReady; +} + +function valueOf(cell) { + const value = cell?.value; + if (value == null) return ''; + if (value instanceof Date) return value; + if (typeof value !== 'object') return value; + if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result }); + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join(''); + if (value.text != null) return value.text; + return String(value); +} + +function text(value) { + if (value instanceof Date) return value.toISOString(); + if (value == null) return ''; + if (typeof value === 'object') { + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('').trim(); + if (value.result !== undefined) return text(value.result); + } + return String(value).trim(); +} + +function excelDate(number) { + return new Date(Date.UTC(1899, 11, 30) + Math.floor(number) * 86400000); +} + +function dateString(value) { + let date = null; + if (value instanceof Date) { + date = value; + } else if (typeof value === 'number' && Number.isFinite(value)) { + date = excelDate(value); + } else { + const raw = text(value).replace(/\s+/g, ''); + let match = raw.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/); + if (match) { + date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + } + if (!date) { + match = raw.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})$/); + if (match) { + const a = Number(match[1]); + const b = Number(match[2]); + let c = Number(match[3]); + let year; + let month; + let day; + if (String(match[3]).length >= 4) { + year = c; + if (a > 12) { + day = a; + month = b; + } else { + month = a; + day = b; + } + } else { + // 国内常见 yy/mm/dd,如 25/12/31 + year = a < 100 ? 2000 + a : a; + month = b; + day = c; + if (month > 12) { + year = c < 100 ? 2000 + c : c; + month = a; + day = b; + } + } + date = new Date(Date.UTC(year, month - 1, day)); + } + } + if (!date && /^\d{4}-\d{2}-\d{2}/.test(raw)) date = new Date(raw); + } + if (!date || Number.isNaN(date.getTime())) return null; + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function decimal(value) { + const raw = text(value); + if (raw === '' || /^#N\/A$/i.test(raw)) return null; + const number = Number(raw.replace(/,/g, '')); + return Number.isFinite(number) ? number : null; +} + +function normalizeEmployeeId(value) { + const digits = String(value ?? '').replace(/\D/g, ''); + if (!digits) return ''; + if (digits.length > 5) return digits; + return digits.padStart(5, '0'); +} + +function normalizeInternalId(value) { + return String(value ?? '').replace(/\D/g, ''); +} + +/** 去掉重名数字后缀,如 孙俊1 → 孙俊 */ +function normalizedName(value) { + return String(value ?? '').trim().replace(/\d+$/, ''); +} + +function namesCompatible(excelName, rosterName) { + const left = text(excelName); + const right = text(rosterName); + if (!left || !right) return false; + if (left === right) return true; + return normalizedName(left) === normalizedName(right); +} + +function sha1(value) { + return createHash('sha1').update(String(value)).digest('hex'); +} + +function sha256(value) { + return createHash('sha256').update(String(value)).digest('hex'); +} + +function remarksKey(remarks) { + return String(remarks || '').replace(/\s+/g, ' ').trim().slice(0, REMARKS_KEY_CHARS); +} + +function buildSourceKey(row) { + return sha1([ + SOURCE_TYPE, + row.employee_id, + row.assessment_date, + row.reward_amount, + remarksKey(row.violation_reason), + row.source_row, + ].join('|')); +} + +function findSheet(workbook) { + return workbook.getWorksheet('Sheet1') || workbook.worksheets[0] || null; +} + +async function loadRoster() { + const rows = await db.query( + `SELECT employee_id, internal_employee_id, name, team + FROM employees + WHERE deleted_at IS NULL AND status = 'active' + AND (employment_status = '在职' OR employment_status IS NULL)`, + ); + const byEmployeeId = new Map(); + const byInternalAndName = new Map(); + for (const row of rows) { + const employeeId = normalizeEmployeeId(row.employee_id); + const internalId = normalizeInternalId(row.internal_employee_id); + const name = text(row.name); + const entry = { + employee_id: employeeId || text(row.employee_id), + internal_employee_id: internalId, + name, + team: text(row.team) || null, + }; + if (entry.employee_id) byEmployeeId.set(entry.employee_id, entry); + if (internalId && name) byInternalAndName.set(`${internalId}|${name}`, entry); + } + return { byEmployeeId, byInternalAndName }; +} + +function matchRow(raw, roster) { + const workId = normalizeInternalId(raw.workCertificate); + const name = text(raw.name); + const excelEmployeeId = normalizeEmployeeId(raw.employeeId); + const errors = []; + let matchStatus = 'ok'; + let matchMessage = '匹配通过'; + let employee = null; + + if (excelEmployeeId) { + employee = roster.byEmployeeId.get(excelEmployeeId) || null; + if (!employee) { + // 花名册无此工号:直接跳过,不入库 + matchStatus = 'skipped'; + matchMessage = `工号 ${excelEmployeeId} 未在花名册中找到,已自动跳过`; + } else { + const idOk = workId && normalizeInternalId(employee.internal_employee_id) === workId; + const nameExact = name && employee.name === name; + const nameOk = namesCompatible(name, employee.name); + if (!idOk || !nameOk) { + matchStatus = 'conflict'; + const parts = []; + if (!idOk) { + parts.push(workId + ? `工作证不符(Excel ${workId} / 花名册 ${employee.internal_employee_id || '空'})` + : '工作证为空'); + } + if (!nameOk) { + parts.push(name + ? `姓名不符(Excel ${name} / 花名册 ${employee.name})` + : '姓名为空'); + } + matchMessage = parts.join(';'); + } else if (!nameExact) { + matchMessage = `花名册重名后缀已自动确认(Excel ${name} / 花名册 ${employee.name})`; + } + } + } else if (workId && name) { + employee = roster.byInternalAndName.get(`${workId}|${name}`) || null; + if (!employee) { + // 尝试按去后缀姓名 + 工作证反查 + for (const entry of roster.byEmployeeId.values()) { + if (normalizeInternalId(entry.internal_employee_id) === workId + && namesCompatible(name, entry.name)) { + employee = entry; + break; + } + } + } + if (!employee) { + matchStatus = 'skipped'; + matchMessage = `工作证 ${workId} + 姓名 ${name} 未在花名册中找到,已自动跳过`; + } else if (employee.name !== name) { + matchMessage = `I 列为空,已按工作证+去后缀姓名反查(花名册 ${employee.name})`; + } else { + matchMessage = 'I 列为空,已按工作证+姓名反查工号'; + } + } else { + matchStatus = 'error'; + matchMessage = '缺少工号(I)且无法用工作证+姓名兜底'; + errors.push(matchMessage); + } + + return { employee, matchStatus, matchMessage, errors }; +} + +function parseWorkbook(workbook, roster) { + const sheet = findSheet(workbook); + if (!sheet) throw new ApiError(API_CODES.BAD_PARAMS, '未找到工作表', 400); + + const rows = []; + const errors = []; + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { + const seq = text(valueOf(sheet.getCell(rowNumber, 1))); + const workCertificate = text(valueOf(sheet.getCell(rowNumber, 2))); + const name = text(valueOf(sheet.getCell(rowNumber, 3))); + const amountRaw = valueOf(sheet.getCell(rowNumber, 4)); + const remarks = text(valueOf(sheet.getCell(rowNumber, 5))); + const employeeIdRaw = valueOf(sheet.getCell(rowNumber, 9)); + const dateRaw = valueOf(sheet.getCell(rowNumber, 10)); + + if (!seq && !workCertificate && !name && !text(amountRaw) && !remarks + && !text(employeeIdRaw) && !text(dateRaw)) { + continue; + } + if (!/^\d+$/.test(seq) && !workCertificate && !name) continue; + + const rewardAmount = decimal(amountRaw); + const assessmentDate = dateString(dateRaw); + const excelEmployeeId = normalizeEmployeeId(employeeIdRaw); + const awardReason = remarks; + const rowErrors = []; + + if (rewardAmount == null || rewardAmount < 0) { + rowErrors.push('金额无效'); + } + if (!assessmentDate) { + rowErrors.push('J 列日期无法识别'); + } + if (!awardReason) { + rowErrors.push('奖项名称/事由为空'); + } + + const matched = matchRow({ + workCertificate, + name, + employeeId: excelEmployeeId, + }, roster); + rowErrors.push(...matched.errors); + + const performancePoints = rewardAmount != null + ? Number((-(rewardAmount / 200)).toFixed(4)) + : null; + + const record = { + rowId: `r${rowNumber}`, + source_row: rowNumber, + excel_employee_id: excelEmployeeId || null, + work_certificate: workCertificate || null, + excel_name: name || null, + reward_amount: rewardAmount, + performance_points: performancePoints, + assessment_date: assessmentDate, + remarks: LABEL, + violation_reason: awardReason || '', + match_status: matched.matchStatus, + match_message: matched.matchMessage, + employee_id: matched.employee?.employee_id || excelEmployeeId || null, + employee_name: matched.employee?.name || name || null, + team: matched.employee?.team || null, + roster_internal_id: matched.employee?.internal_employee_id || null, + assessment_category: LABEL, + assessment_item: LABEL, + twelve_point_deduction: 0, + star_points: 0, + performance_deduction: null, + safe_kilometers: null, + source_payload: { + sequence: seq, + workCertificate, + name, + amount: rewardAmount, + awardReason, + remarks: LABEL, + employeeId: excelEmployeeId, + assessmentDate, + matchStatus: matched.matchStatus, + matchMessage: matched.matchMessage, + }, + }; + + if (matched.matchStatus === 'skipped') { + record.match_status = 'skipped'; + record.match_message = matched.matchMessage; + record.source_payload.matchStatus = 'skipped'; + record.source_payload.matchMessage = matched.matchMessage; + } else if (rowErrors.length) { + record.match_status = 'error'; + if (matched.matchStatus !== 'error') { + record.match_message = rowErrors.join(';'); + } + record.source_payload.matchStatus = 'error'; + record.source_payload.matchMessage = record.match_message; + for (const message of rowErrors) { + errors.push({ sheet: sheet.name, row: rowNumber, message }); + } + } + + if (record.employee_id && record.assessment_date && record.reward_amount != null + && record.match_status !== 'skipped' && record.match_status !== 'error') { + record.source_key = buildSourceKey(record); + } + + rows.push(record); + } + + return { sheetName: sheet.name, rows, errors }; +} + +async function preview(file) { + await ensureSchema(); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file.buffer); + const roster = await loadRoster(); + const parsed = parseWorkbook(workbook, roster); + const importId = randomUUID(); + const payload = { + importId, + fileName: file.originalname, + fileHash: sha256(file.buffer), + fileSize: file.size, + sheetName: parsed.sheetName, + rows: parsed.rows, + errors: parsed.errors, + }; + await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS); + + const okCount = parsed.rows.filter((row) => row.match_status === 'ok').length; + const conflictCount = parsed.rows.filter((row) => row.match_status === 'conflict').length; + const errorCount = parsed.rows.filter((row) => row.match_status === 'error').length; + const skippedCount = parsed.rows.filter((row) => row.match_status === 'skipped').length; + + return { + importId, + fileName: file.originalname, + sheetName: parsed.sheetName, + summary: { + total: parsed.rows.length, + matched: okCount, + conflicts: conflictCount, + errors: errorCount, + skipped: skippedCount, + validationErrors: parsed.errors.length, + }, + errors: parsed.errors.slice(0, 500), + rows: parsed.rows.map((row) => ({ + rowId: row.rowId, + sourceRow: row.source_row, + employeeId: row.employee_id, + excelEmployeeId: row.excel_employee_id, + workCertificate: row.work_certificate, + name: row.employee_name || row.excel_name, + excelName: row.excel_name, + team: row.team, + rewardAmount: row.reward_amount, + performancePoints: row.performance_points, + assessmentDate: row.assessment_date, + remarks: row.remarks, + violationReason: row.violation_reason, + matchStatus: row.match_status, + matchMessage: row.match_message, + })), + canCommit: okCount + conflictCount > 0, + }; +} + +function placeholders(rows, columns) { + return rows.map(() => `(${columns.map(() => '?').join(',')})`).join(','); +} + +async function bulkUpsert(conn, table, columns, rows, updateColumns) { + for (let offset = 0; offset < rows.length; offset += 200) { + const part = rows.slice(offset, offset + 200); + const params = part.flatMap((row) => columns.map((col) => { + const value = row[col]; + return value != null && typeof value === 'object' ? JSON.stringify(value) : value ?? null; + })); + await conn.execute( + `INSERT INTO \`${table}\` (${columns.map((col) => `\`${col}\``).join(',')}) + VALUES ${placeholders(part, columns)} + ON DUPLICATE KEY UPDATE ${updateColumns.map((col) => + col === 'version' ? '`version`=`version`+1' : `\`${col}\`=VALUES(\`${col}\`)` + ).join(',')}`, + params, + ); + } +} + +function resolveCommitRows(payload, selections = {}) { + const result = []; + const skipped = []; + for (const row of payload.rows) { + if (row.match_status === 'error' || row.match_status === 'skipped') { + skipped.push({ rowId: row.rowId, reason: row.match_status }); + continue; + } + if (row.match_status === 'conflict') { + const choice = selections[row.rowId]; + if (choice === 'skip') { + skipped.push({ rowId: row.rowId, reason: 'skip' }); + continue; + } + if (choice !== 'confirm') { + throw new ApiError( + API_CODES.BAD_PARAMS, + `第 ${row.source_row} 行存在匹配冲突,请确认或跳过后再导入`, + 400, + ); + } + } + if (!row.employee_id || !row.assessment_date || row.reward_amount == null) { + skipped.push({ rowId: row.rowId, reason: 'incomplete' }); + continue; + } + result.push(row); + } + return { rows: result, skipped }; +} + +async function commit({ importId, selections = {}, operator, meta }) { + await ensureSchema(); + const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`); + if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '嘉奖导入预览已过期,请重新选择文件', 404); + const payload = JSON.parse(raw); + const resolved = resolveCommitRows(payload, selections); + if (!resolved.rows.length) { + throw new ApiError(API_CODES.BAD_PARAMS, '没有可导入的嘉奖记录', 400); + } + + const batchUuid = randomUUID(); + // 批量计算责任人快照 + const responsible = require('../common/responsible'); + const respMap = await responsible.computeResponsiblePersonBatch( + resolved.rows.map((r) => String(r.employee_id)), + ); + + const columns = [ + 'source_type', 'source_key', 'assessment_date', 'employee_id', 'employee_name', 'team', + 'violation_reason', 'assessment_category', 'assessment_item', 'performance_deduction', + 'performance_points', 'reward_amount', 'star_points', 'safe_kilometers', 'twelve_point_deduction', + 'remarks', 'responsible_person', 'source_payload', 'source_row', 'import_batch', 'deleted_at', 'version', + ]; + + const dbRows = resolved.rows.map((row) => ({ + source_type: SOURCE_TYPE, + source_key: row.source_key || buildSourceKey(row), + assessment_date: row.assessment_date, + employee_id: row.employee_id, + employee_name: row.employee_name, + team: row.team, + violation_reason: row.violation_reason || LABEL, + assessment_category: LABEL, + assessment_item: LABEL, + performance_deduction: null, + performance_points: row.performance_points, + reward_amount: row.reward_amount, + star_points: 0, + safe_kilometers: null, + twelve_point_deduction: 0, + remarks: LABEL, + responsible_person: respMap[String(row.employee_id)] || '', + source_payload: row.source_payload, + source_row: row.source_row, + import_batch: batchUuid, + deleted_at: null, + version: 1, + })); + + await db.transaction(async (conn) => { + await conn.execute( + `INSERT INTO upload_batches + (batch_uuid, module, data_type, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at) + VALUES (?, 'crew_rewards', 'xlsx', ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`, + [ + batchUuid, payload.fileName, payload.fileHash, payload.fileSize, + dbRows.length, JSON.stringify({ sourceType: SOURCE_TYPE }), operator.id, + ], + ); + // 全量替换嘉奖来源行,避免日期修正后 source_key 变化导致旧行残留 + await conn.execute( + `UPDATE performance_assessments + SET deleted_at=NOW(), version=version+1 + WHERE source_type=? AND deleted_at IS NULL`, + [SOURCE_TYPE], + ); + await bulkUpsert( + conn, + 'performance_assessments', + columns, + dbRows, + columns.filter((col) => !['source_type', 'source_key'].includes(col)), + ); + await conn.execute( + `UPDATE upload_batches SET status='completed', completed_at=NOW(), payload=? WHERE batch_uuid=?`, + [JSON.stringify({ imported: dbRows.length, skipped: resolved.skipped.length }), batchUuid], + ); + }); + + await sync.bumpVersions(['performance_assessments']); + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'crew_rewards_import', + module: 'imports', + targetType: 'upload_batch', + targetId: batchUuid, + payload: { + fileName: payload.fileName, + fileHash: payload.fileHash, + imported: dbRows.length, + skipped: resolved.skipped.length, + }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + await redis.client.del(`${PREVIEW_PREFIX}${importId}`); + return { + batchUuid, + counts: { + imported: dbRows.length, + skipped: resolved.skipped.length, + }, + }; +} + +module.exports = { + preview, + commit, + ensureSchema, + SOURCE_TYPE, +}; diff --git a/server/src/import/import.routes.js b/server/src/import/import.routes.js new file mode 100644 index 0000000..0b96492 --- /dev/null +++ b/server/src/import/import.routes.js @@ -0,0 +1,164 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); +const materials = require('./monthly-materials'); +const questionBank = require('./question-bank'); +const monthlyExams = require('./monthly-exams'); +const crewRewards = require('./crew-rewards'); +const personnelRoster = require('./personnel-roster'); +const maintenance = require('../maintenance'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 30 * 1024 * 1024, files: 1 }, + fileFilter: (_req, file, callback) => { + callback(null, /\.xlsx$/i.test(file.originalname)); + }, +}); + +router.use(requireAuth, maintenance.requireDeveloper); + +router.post( + '/monthly-materials/preview', + upload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('请选择 .xlsx 文件'); + error.status = 400; + throw error; + } + ok(res, await materials.preview(req.file)); + }), +); + +router.post( + '/monthly-materials/commit', + asyncHandler(async (req, res) => { + ok(res, await materials.commit({ + importId: req.body?.importId, + selections: req.body?.selections || {}, + operator: req.user, + meta: { ip: req.ip, userAgent: req.headers['user-agent'] }, + })); + }), +); + +router.post( + '/question-bank/preview', + upload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('请选择 .xlsx 文件'); + error.status = 400; + throw error; + } + ok(res, await questionBank.preview(req.file)); + }), +); + +router.post( + '/question-bank/commit', + asyncHandler(async (req, res) => { + ok(res, await questionBank.commit({ + importId: req.body?.importId, + operator: req.user, + meta: { ip: req.ip, userAgent: req.headers['user-agent'] }, + })); + }), +); + +router.post( + '/monthly-exams/preview', + upload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('请选择 .xlsx 文件'); + error.status = 400; + throw error; + } + ok(res, await monthlyExams.preview(req.file, { + year: req.body?.year, + month: req.body?.month, + })); + }), +); + +router.post( + '/monthly-exams/commit', + asyncHandler(async (req, res) => { + ok(res, await monthlyExams.commit({ + importId: req.body?.importId, + year: req.body?.year, + month: req.body?.month, + selections: req.body?.selections || {}, + operator: req.user, + meta: { ip: req.ip, userAgent: req.headers['user-agent'] }, + })); + }), +); + +router.post( + '/crew-rewards/preview', + upload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('请选择 .xlsx 文件'); + error.status = 400; + throw error; + } + ok(res, await crewRewards.preview(req.file)); + }), +); + +router.post( + '/crew-rewards/commit', + asyncHandler(async (req, res) => { + ok(res, await crewRewards.commit({ + importId: req.body?.importId, + selections: req.body?.selections || {}, + operator: req.user, + meta: { ip: req.ip, userAgent: req.headers['user-agent'] }, + })); + }), +); + +router.post( + '/personnel-roster/preview', + upload.single('file'), + asyncHandler(async (req, res) => { + if (!req.file) { + const error = new Error('请选择 .xlsx 文件'); + error.status = 400; + throw error; + } + ok(res, await personnelRoster.preview(req.file)); + }), +); + +router.post( + '/personnel-roster/commit', + asyncHandler(async (req, res) => { + ok(res, await personnelRoster.commit({ + importId: req.body?.importId, + selections: { + confirmExtraInFile: Boolean( + req.body?.selections?.confirmExtraInFile ?? req.body?.confirmExtraInFile, + ), + confirmMissingInFile: Boolean( + req.body?.selections?.confirmMissingInFile ?? req.body?.confirmMissingInFile, + ), + confirmInsertClerks: Boolean( + req.body?.selections?.confirmInsertClerks ?? req.body?.confirmInsertClerks, + ), + }, + operator: req.user, + meta: { ip: req.ip, userAgent: req.headers['user-agent'] }, + })); + }), +); + +module.exports = router; diff --git a/server/src/import/monthly-exams.js b/server/src/import/monthly-exams.js new file mode 100644 index 0000000..4a9134b --- /dev/null +++ b/server/src/import/monthly-exams.js @@ -0,0 +1,1148 @@ +'use strict'; + +const { createHash, randomUUID } = require('crypto'); +const ExcelJS = require('exceljs'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const sync = require('../sync/sync'); +const audit = require('../audit/audit'); +const precomputeService = require('../precompute/service'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const PREVIEW_TTL_SECONDS = 30 * 60; +const PREVIEW_PREFIX = 'monthly-exam-import:'; +const PAPER_CLASS = '管理部月考'; +const LINE_SCOPE = '所有班组'; +const EXCLUDED_POSITIONS = new Set(['主任办事员']); +/** 备注含「借调」:合理不在岗考试,不记入缺考 */ +function extractRemarks(details) { + if (details == null || details === '') return ''; + let obj = details; + if (typeof details === 'string') { + try { obj = JSON.parse(details); } catch (_e) { return ''; } + } + if (Buffer.isBuffer(details)) { + try { obj = JSON.parse(details.toString('utf8')); } catch (_e) { return ''; } + } + return String(obj?.remarks || '').trim(); +} + +function isSecondmentRemarks(remarks) { + return String(remarks || '').includes('借调'); +} + +function isExcludedFromMissingExam(row) { + if (EXCLUDED_POSITIONS.has(text(row.position))) return true; + if (isSecondmentRemarks(row.remarks || extractRemarks(row.details))) return true; + return false; +} + +const META_HEADERS = new Set([ + '序号', '用户ID', '提交答卷时间', '所用时间', '来源', '来源详情', '来自IP', '总分', + '您的姓名', '您的线路', '您的班组', '工作证号码', '您当前的年龄', + '您在本岗位的累计工作年限', '您与本岗位关联的最高技能等级', + '您当前的最高学历(含在职学历)', '您所学专业是否为理工类专业', +]); + +function valueOf(cell) { + const value = cell?.value; + if (value == null) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value !== 'object') return value; + if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result }); + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join(''); + if (value.text != null) return value.text; + return String(value); +} + +function text(value) { + return String(value ?? '').trim(); +} + +function normalizePunctuation(value) { + const punctuation = { + ',': ',', '。': '.', ';': ';', ':': ':', '?': '?', '!': '!', + '(': '(', ')': ')', '【': '[', '】': ']', '〔': '[', '〕': ']', + '{': '{', '}': '}', '《': '<', '》': '>', '〈': '<', '〉': '>', + '“': '"', '”': '"', '‘': "'", '’': "'", '「': '"', '」': '"', + '『': '"', '』': '"', '、': ',', '…': '...', '—': '-', '–': '-', + '-': '-', '~': '~', '·': '.', '/': '/', '\': '\\', '|': '|', + '﹐': ',', '﹑': ',', '﹒': '.', '﹔': ';', '﹕': ':', '﹖': '?', + '﹗': '!', '﹙': '(', '﹚': ')', '﹛': '{', '﹜': '}', '﹝': '[', + '﹞': ']', '﹤': '<', '﹥': '>', '﹣': '-', '﹏': '_', '〖': '[', + '〗': ']', '〝': '"', '〞': '"', + }; + return text(value) + .replace(/[\uFF01-\uFF5E]/g, (character) => + String.fromCharCode(character.charCodeAt(0) - 0xFEE0) + ) + .replace(/\u3000/g, ' ') + .replace(/[,。;:?!()【】〔〕{}《》〈〉“”‘’「」『』、…—–-~·/\|﹐﹑﹒﹔﹕﹖﹗﹙﹚﹛﹜﹝﹞﹤﹥﹣﹏〖〗〝〞]/g, (character) => + punctuation[character] || character + ); +} + +function normalizeQuestionStem(raw) { + return normalizePunctuation(raw) + .replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符 + .replace(/##[^#]+##/g, '') + .replace(/^\d+[,..、]\s*/, '') + .replace(/[\r\n\t\f\v]+/g, '') + .replace(/\s+/g, '') + .toLowerCase(); +} + +function sha(value) { + return createHash('sha256').update(String(value)).digest('hex'); +} + +function parseDateToYearMonth(raw) { + if (raw instanceof Date && !Number.isNaN(raw.getTime())) { + return { year: raw.getFullYear(), month: raw.getMonth() + 1 }; + } + const value = text(raw); + if (!value) return null; + const matched = value.match(/(\d{4})[/\-.](\d{1,2})/); + if (!matched) return null; + const year = Number(matched[1]); + const month = Number(matched[2]); + if (!year || month < 1 || month > 12) return null; + return { year, month }; +} + +function parseFirstSubmitYearMonth(sheet, submitColumn) { + if (!submitColumn) return null; + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { + const parsed = parseDateToYearMonth(valueOf(sheet.getCell(rowNumber, submitColumn))); + if (parsed) return parsed; + } + return null; +} + +function parseYearMonth({ fileName, explicit, sheet, submitColumn } = {}) { + if (explicit?.year && explicit?.month) { + return { + year: Number(explicit.year), + month: Number(explicit.month), + source: 'manual', + }; + } + const fromSubmit = parseFirstSubmitYearMonth(sheet, submitColumn); + if (fromSubmit) { + return { ...fromSubmit, source: 'submit_time' }; + } + const matched = String(fileName || '').match(/[((]\s*(\d{4})\.(\d{1,2})\s*[))]/); + if (!matched) return { year: null, month: null, source: null }; + return { year: Number(matched[1]), month: Number(matched[2]), source: 'file_name' }; +} + +function parseElapsedSeconds(raw) { + const matched = text(raw).match(/(\d+(?:\.\d+)?)\s*秒?/); + return matched ? Math.round(Number(matched[1])) : null; +} + +function headerColumns(sheet) { + const columns = []; + sheet.getRow(1).eachCell({ includeEmpty: true }, (cell, column) => { + const header = text(valueOf(cell)); + if (header) columns.push({ column, header }); + }); + return columns; +} + +async function loadRoster() { + const rows = await db.query( + `SELECT employee_id, internal_employee_id, name, team, position, details + FROM employees + WHERE deleted_at IS NULL AND status = 'active' + AND (employment_status = '在职' OR employment_status IS NULL)`, + ); + return (rows || []).map((row) => ({ + ...row, + remarks: extractRemarks(row.details), + })); +} + +async function loadQuestionBank() { + const rows = await db.query( + `SELECT id, category, content + FROM question_banks + WHERE deleted_at IS NULL AND status = 'active'`, + ); + const byStem = new Map(); + for (const row of rows) { + let content = row.content; + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (_) { content = {}; } + } + const stem = normalizeQuestionStem(content?.question || ''); + if (!stem) continue; + if (!byStem.has(stem)) byStem.set(stem, []); + byStem.get(stem).push({ + id: Number(row.id), + category: row.category || '未分类', + question: content.question || '', + }); + } + return byStem; +} + +function monthWrongIdTag(year, month) { + return `${String(year).slice(-2)}/${String(month).padStart(2, '0')}`; +} + +function stripMonthFromWrongIdDates(dates, year, month) { + const y = String(year); + const yShort = y.slice(-2); + const m = String(month); + const mPad = m.padStart(2, '0'); + const patterns = new Set([`${y}/${mPad}`, `${yShort}/${mPad}`, `${y}/${m}`, `${yShort}/${m}`]); + return String(dates || '') + .split(',') + .map((part) => part.trim()) + .filter((part) => part && !patterns.has(part)) + .join(', '); +} + +function appendWrongIdDate(dates, year, month) { + const tag = monthWrongIdTag(year, month); + const stripped = stripMonthFromWrongIdDates(dates, year, month); + return stripped ? `${stripped}, ${tag}` : tag; +} + +function countWrongIdDates(dates) { + return String(dates || '').split(',').map((part) => part.trim()).filter(Boolean).length; +} + +/** 问卷星「用户ID」多为 OpenID;姓名取「您的姓名」,没有时才回退用户ID */ +function resolveExamIdentity(sheet, rowNumber, meta) { + const openId = meta['用户ID'] ? text(valueOf(sheet.getCell(rowNumber, meta['用户ID']))) : ''; + const realName = meta['您的姓名'] ? text(valueOf(sheet.getCell(rowNumber, meta['您的姓名']))) : ''; + const name = realName || openId; + return { + name, + openId: openId || null, + nameFrom: realName ? '您的姓名' : (openId ? '用户ID' : null), + }; +} + +function toPerson(row) { + return { + employeeId: row.employee_id, + name: row.name, + team: row.team, + position: row.position, + workCertificate: row.internal_employee_id, + }; +} + +/** 花名册用「陈浩2/3」区分同名;考试填「陈浩」且工作证一致时视为同名,不算写错 */ +function isRosterNameAlias(examName, rosterName) { + const exam = text(examName); + const roster = text(rosterName); + if (!exam || !roster) return false; + if (exam === roster) return true; + if (roster.startsWith(exam) && /^\d+$/.test(roster.slice(exam.length))) return true; + if (exam.startsWith(roster) && /^\d+$/.test(exam.slice(roster.length))) return true; + return false; +} + +function toCandidate(row, name, certificate) { + const certHit = text(row.internal_employee_id) === certificate; + const nameHit = isRosterNameAlias(name, row.name); + let reason = '候选匹配(确认后记工号写错)'; + let mismatchKind = 'manual'; + if (certHit && nameHit) { + reason = '工作证命中(花名册同名区分)'; + mismatchKind = 'exact'; + } else if (certHit && !nameHit) { + reason = '工作证命中、姓名不符(勿自动绑定,请核对是否误填工作证)'; + mismatchKind = 'name_mismatch'; + } else if (nameHit && !certHit) { + reason = '姓名命中、工作证未命中(写错工作证)'; + mismatchKind = 'certificate_mismatch'; + } else if (!nameHit && !certHit) { + reason = '姓名与工作证均未直接命中'; + mismatchKind = 'both_mismatch'; + } + return { ...toPerson(row), reason, mismatchKind }; +} + +/** + * 匹配口径(5 位工号不参与): + * 1) 姓名+11位工作证同时命中 → 正常命中 + * 2) 工作证唯一命中,且花名册为「姓名/姓名2」同名区分 → 正常命中(不算写错) + * 3) 工作证唯一命中但姓名完全不符 → 待人工核对(禁止自动绑到证主) + * 4) 姓名唯一命中但工作证不符(无人/他人) → 待人工核对(禁止自动绑定,确认后记写错工作证) + * 5) 冲突 / 同名多人 / 均未命中 → 待核对 + */ +function matchPerson(name, certificate, roster) { + const byCert = roster.filter((row) => text(row.internal_employee_id) === certificate); + // 含「陈浩 / 陈浩2」等同名区分 + const byName = roster.filter((row) => isRosterNameAlias(name, row.name)); + const exact = byCert.find((row) => isRosterNameAlias(name, row.name)); + if (exact) { + const aliasOnly = text(exact.name) !== text(name); + return { + status: 'matched', + mismatchKind: 'exact', + message: aliasOnly + ? '工作证命中,花名册同名区分(如姓名2),正常命中' + : '姓名与工作证号同时命中', + person: { status: 'matched', ...toPerson(exact) }, + candidates: [], + }; + } + + // 工作证命中他人:禁止自动绑定(如夏再青误填陶云毅的工作证) + if (byCert.length === 1) { + const conflictCandidates = [...new Map( + [...byCert, ...byName].map((row) => [row.employee_id, toCandidate(row, name, certificate)]), + ).values()]; + return { + status: 'unresolved', + mismatchKind: byName.length ? 'conflict' : 'name_mismatch', + message: byName.length + ? `工作证属于「${byCert[0].name}」,姓名指向「${byName.map((row) => row.name).join('/')}」,请人工核对` + : `工作证属于花名册「${byCert[0].name}」,与填报姓名「${name}」不符,请人工核对(可能是写错工作证)`, + person: null, + candidates: conflictCandidates, + }; + } + + // 姓名命中、工作证不符:禁止自动绑定(须人工确认后记写错工作证) + if (byName.length === 1) { + const person = byName[0]; + const rosterCert = text(person.internal_employee_id); + const conflictCandidates = [...new Map( + [...byName, ...byCert].map((row) => [row.employee_id, toCandidate(row, name, certificate)]), + ).values()]; + return { + status: 'unresolved', + mismatchKind: byCert.length ? 'conflict' : 'certificate_mismatch', + message: byCert.length + ? `姓名命中「${person.name}」,但工作证另指向他人,请人工核对` + : `姓名命中「${person.name}」,工作证「${certificate}」与花名册「${rosterCert || '无'}」不符,请人工核对(确认后记写错工作证)`, + person: null, + candidates: conflictCandidates, + }; + } + + // 冲突:工作证是 A,姓名是 B(或同名多人 / 工作证多人) + const candidates = [...new Map( + [...byCert, ...byName].map((row) => [row.employee_id, toCandidate(row, name, certificate)]), + ).values()]; + + let mismatchKind = 'both_mismatch'; + let message = '姓名与工作证均未命中花名册'; + if (byCert.length && byName.length) { + mismatchKind = 'conflict'; + message = '工作证与姓名分别指向不同人,请人工核对'; + } else if (byCert.length > 1) { + mismatchKind = 'certificate_ambiguous'; + message = '工作证命中多人,请人工核对'; + } else if (byName.length > 1) { + mismatchKind = 'name_ambiguous'; + message = '姓名命中多人且工作证未命中,请人工核对'; + } + + return { + status: 'unresolved', + mismatchKind, + message, + person: null, + candidates, + }; +} + +function readAnswers( + sheet, + rowNumber, + questionColumns, + resolvedQuestions, + questionErrors, + skippedQuestions, + context = {}, +) { + const answers = []; + const name = text(context.name || ''); + const totalScore = context.totalScore; + for (const column of questionColumns) { + const rawScore = text(valueOf(sheet.getCell(rowNumber, column.column))); + if (rawScore === '') continue; + if (rawScore !== '0' && rawScore !== '1') { + questionErrors.push({ + type: 'invalid_score', + row: rowNumber, + column: column.column, + header: column.header, + message: `第 ${rowNumber} 行题目得分只能是空/0/1,当前为:${rawScore}`, + }); + continue; + } + const question = resolvedQuestions.get(column.column); + if (!question) { + // 未匹配题库但有人抽到:记录明细供预览展示,本月不计入对错明细(不阻断导入) + const questionScore = Number(rawScore); + skippedQuestions.push({ + row: rowNumber, + name: name || '未知', + totalScore: Number.isFinite(Number(totalScore)) ? Number(totalScore) : totalScore ?? null, + questionScore, + questionResult: questionScore === 1 ? '对' : '错', + column: column.column, + header: column.header, + message: `抽到了未匹配题库的题目:${column.header}`, + }); + continue; + } + answers.push({ + questionId: question.id, + score: Number(rawScore), + category: question.category, + }); + } + return answers; +} + +async function preview(file, options = {}) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file.buffer); + const sheet = workbook.worksheets.find((item) => { + const headers = new Set(headerColumns(item).map((column) => column.header)); + return headers.has('用户ID') && headers.has('工作证号码') && headers.has('总分'); + }); + if (!sheet) { + throw new ApiError(API_CODES.BAD_PARAMS, '未找到包含“用户ID、工作证号码、总分”的工作表', 400); + } + + const columns = headerColumns(sheet); + const meta = Object.fromEntries(columns + .filter((column) => META_HEADERS.has(column.header)) + .map((column) => [column.header, column.column])); + const questionColumns = columns.filter((column) => !META_HEADERS.has(column.header)); + if (!meta['用户ID'] || !meta['工作证号码'] || !meta['总分']) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少必填列:用户ID、工作证号码、总分', 400); + } + const period = parseYearMonth({ + fileName: file.originalname, + explicit: options, + sheet, + submitColumn: meta['提交答卷时间'], + }); + + const roster = await loadRoster(); + const questionBank = await loadQuestionBank(); + if (!questionBank.size) { + throw new ApiError(API_CODES.BAD_PARAMS, '题库为空,请先完成“导入2:在线考试题库”', 400); + } + + const questionErrors = []; + /** @type {Array<{ row: number, name: string, totalScore: number|null, questionScore: number, questionResult: string, column: number, header: string, message: string }>} */ + const skippedQuestions = []; + const resolvedQuestions = new Map(); + const unmatchedColumns = new Map(); + let duplicateStemResolved = 0; + for (const column of questionColumns) { + const stem = normalizeQuestionStem(column.header); + const hits = [...(questionBank.get(stem) || [])].sort((a, b) => a.id - b.id); + if (!hits.length) { + // 表头可能含其他线路题;无人抽到时整列为空,后面再按实际作答校验 + unmatchedColumns.set(column.column, column.header); + continue; + } + if (hits.length > 1) { + // 问卷星题库存在同题干多条,静默取最小 id,不在预览中展示 + duplicateStemResolved += 1; + } + resolvedQuestions.set(column.column, hits[0]); + } + + const exams = []; + const personIssues = []; + const autoIgnoredPeople = []; + /** @type {Map} */ + const claimedCertificates = new Map(); + let retakeDropped = 0; + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { + const identity = resolveExamIdentity(sheet, rowNumber, meta); + const name = identity.name; + const certificate = text(valueOf(sheet.getCell(rowNumber, meta['工作证号码']))); + const totalScore = text(valueOf(sheet.getCell(rowNumber, meta['总分']))); + if (!name && !certificate && !totalScore) continue; + const parsedTotalScore = Number(totalScore); + + const answers = readAnswers( + sheet, + rowNumber, + questionColumns, + resolvedQuestions, + questionErrors, + skippedQuestions, + { + name, + totalScore: Number.isFinite(parsedTotalScore) ? parsedTotalScore : null, + }, + ); + const score = Number(totalScore); + const elapsedSeconds = parseElapsedSeconds(valueOf(sheet.getCell(rowNumber, meta['所用时间'] || 0))); + const sourceDetail = meta['来源详情'] + ? text(valueOf(sheet.getCell(rowNumber, meta['来源详情']))) + : ''; + const base = { + sourceRow: rowNumber, + sourceName: name, + sourceOpenId: identity.openId, + sourceCertificate: certificate, + // 仅预览展示,不落库 + sourceDetail, + score: Number.isFinite(score) ? score : null, + elapsedSeconds, + answers, + }; + + if (!name || !certificate) { + autoIgnoredPeople.push({ + issueId: sha(`${rowNumber}|missing-fields`), + row: rowNumber, + ...base, + mismatchKind: 'missing_fields', + message: '缺少您的姓名或工作证号码,已自动选择「忽略该行」(不导入)', + candidates: [], + autoIgnored: true, + }); + continue; + } + + const matched = matchPerson(name, certificate, roster); + + // 同人重考(姓名+工作证完全一致):只保留最低分 + if (claimedCertificates.has(certificate)) { + const prev = claimedCertificates.get(certificate); + if ( + matched.status === 'matched' + && matched.person.employeeId === prev.employeeId + ) { + const candidate = { + ...base, + person: matched.person, + wrongId: false, + mismatchKind: 'exact', + }; + const existing = exams[prev.examIndex]; + const keepCandidate = shouldKeepLowerScoreExam(candidate, existing); + const dropped = keepCandidate ? existing : candidate; + const kept = keepCandidate ? candidate : existing; + const droppedRows = [ + ...(existing.retakeDroppedRows || []), + dropped.sourceRow, + ].filter((row, index, arr) => arr.indexOf(row) === index && row !== kept.sourceRow); + exams[prev.examIndex] = { + ...kept, + retakeDroppedRows: droppedRows, + retakeNote: `同人重考 ${droppedRows.length + 1} 次,已保留最低分 ${kept.score ?? '—'}(丢弃行:${droppedRows.join('、')})`, + }; + retakeDropped += 1; + continue; + } + + // 工作证已被他人占用:误填他人证号 → 人工核对 + const dupCandidates = matched.candidates?.length + ? matched.candidates + : roster + .filter((row) => text(row.internal_employee_id) === certificate || isRosterNameAlias(name, row.name)) + .map((row) => toCandidate(row, name, certificate)); + personIssues.push({ + issueId: sha(`${rowNumber}|duplicate-cert|${certificate}`), + row: rowNumber, + ...base, + mismatchKind: 'duplicate_certificate', + message: '工作证号码在本文件中已被其他人占用,请人工核对(可能误填了他人工作证)', + candidates: [...new Map(dupCandidates.map((item) => [item.employeeId, item])).values()], + }); + continue; + } + + if (matched.status === 'matched') { + claimedCertificates.set(certificate, { + examIndex: exams.length, + employeeId: matched.person.employeeId, + }); + exams.push({ ...base, person: matched.person, wrongId: false, mismatchKind: 'exact' }); + } else if (!(matched.candidates || []).length) { + autoIgnoredPeople.push({ + issueId: sha(`${rowNumber}|${name}|${certificate}|ignore`), + row: rowNumber, + ...base, + mismatchKind: matched.mismatchKind || 'both_mismatch', + message: '花名册无匹配候选,已自动选择「忽略该行」(不导入)', + candidates: [], + autoIgnored: true, + }); + } else { + personIssues.push({ + issueId: sha(`${rowNumber}|${name}|${certificate}`), + row: rowNumber, + ...base, + mismatchKind: matched.mismatchKind, + message: matched.message, + candidates: matched.candidates, + }); + } + } + + const matchedIds = new Set(exams.map((exam) => exam.person.employeeId)); + const secondmentSkipped = roster + .filter((row) => !matchedIds.has(row.employee_id)) + .filter((row) => isSecondmentRemarks(row.remarks)) + .map((row) => ({ + employeeId: row.employee_id, + name: row.name, + team: row.team, + position: row.position, + remarks: row.remarks, + workCertificate: row.internal_employee_id, + reason: '借调免考', + })); + const missing = roster + .filter((row) => !isExcludedFromMissingExam(row)) + .filter((row) => !matchedIds.has(row.employee_id)) + .map((row) => ({ + employeeId: row.employee_id, + name: row.name, + team: row.team, + position: row.position, + workCertificate: row.internal_employee_id, + })); + + const skippedColumns = new Set(skippedQuestions.map((item) => item.column)); + const ignoredUnmatchedQuestions = [...unmatchedColumns.keys()] + .filter((column) => !skippedColumns.has(column)) + .length; + + const importId = randomUUID(); + const payload = { + importId, + fileName: file.originalname, + fileHash: sha(file.buffer), + fileSize: file.size, + sheetName: sheet.name, + year: period.year, + month: period.month, + yearMonthSource: period.source, + exams, + personIssues, + autoIgnoredPeople, + secondmentSkipped, + missing, + questionErrors, + // Redis 只存列级摘要,避免逐人次题干把预览撑爆;完整明细仅回传前端展示 + skippedQuestions: [...skippedColumns].map((column) => { + const rows = skippedQuestions.filter((item) => item.column === column); + return { + column, + header: rows[0]?.header || unmatchedColumns.get(column) || '', + answerCount: rows.length, + }; + }), + resolvedQuestionCount: resolvedQuestions.size, + duplicateStemResolved, + ignoredUnmatchedQuestions, + retakeDropped, + }; + await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS); + + const retakePeople = exams.filter((exam) => (exam.retakeDroppedRows || []).length > 0); + return { + importId, + fileName: file.originalname, + sheetName: sheet.name, + year: period.year, + month: period.month, + yearMonthSource: period.source, + summary: { + examinees: exams.length + personIssues.length + autoIgnoredPeople.length + retakeDropped, + matchedPeople: exams.filter((exam) => !exam.wrongId).length, + wrongIdAuto: exams.filter((exam) => exam.wrongId).length, + wrongIdName: exams.filter((exam) => exam.wrongIdReason === 'name_mismatch').length, + wrongIdCertificate: exams.filter((exam) => exam.wrongIdReason === 'certificate_mismatch').length, + unresolvedPeople: personIssues.length, + autoIgnoredPeople: autoIgnoredPeople.length, + retakeDropped, + retakePeople: retakePeople.length, + missingExams: missing.length, + secondmentSkipped: secondmentSkipped.length, + questionColumns: questionColumns.length, + matchedQuestions: resolvedQuestions.size, + ignoredUnmatchedQuestions, + skippedQuestions: skippedColumns.size, + skippedAnswers: skippedQuestions.length, + questionErrors: questionErrors.length, + duplicateStemResolved, + avgAnswers: exams.length + ? Math.round(exams.reduce((sum, exam) => sum + exam.answers.length, 0) / exams.length) + : 0, + }, + questionErrors: questionErrors.slice(0, 200), + skippedQuestions: skippedQuestions.slice(0, 200), + personIssues, + autoIgnoredPeople, + retakePreview: retakePeople.slice(0, 50).map((exam) => ({ + name: exam.person?.name || exam.sourceName, + workCertificate: exam.sourceCertificate, + keptScore: exam.score, + keptRow: exam.sourceRow, + droppedRows: exam.retakeDroppedRows || [], + note: exam.retakeNote, + })), + missingPreview: missing.slice(0, 50), + secondmentSkippedPreview: secondmentSkipped.slice(0, 50), + canCommit: questionErrors.length === 0 + && personIssues.length === 0 + && Boolean(period.year) + && Boolean(period.month) + && exams.length > 0, + }; +} + +function resolveExams(payload, selections = {}) { + const unresolved = []; + // 完全命中不记写错;人工核对确认的在下方统一记 wrongId + const resolved = (payload.exams || []).map((exam) => ({ + ...exam, + wrongId: Boolean(exam.wrongId), + wrongIdReason: exam.wrongId ? (exam.wrongIdReason || exam.mismatchKind || 'manual_resolve') : null, + })); + for (const issue of payload.personIssues || []) { + const selected = selections[issue.issueId]; + if (!selected) { + unresolved.push(issue); + continue; + } + if (selected === '__IGNORE__') continue; + const employee = (issue.candidates || []).find((item) => item.employeeId === selected); + if (!employee) { + unresolved.push({ ...issue, message: '选择的花名册人员无效' }); + continue; + } + const nameMismatch = !isRosterNameAlias(issue.sourceName, employee.name); + const certMismatch = text(issue.sourceCertificate) !== text(employee.workCertificate); + resolved.push({ + sourceRow: issue.sourceRow, + sourceName: issue.sourceName, + sourceOpenId: issue.sourceOpenId || null, + sourceCertificate: issue.sourceCertificate, + score: issue.score, + elapsedSeconds: issue.elapsedSeconds, + answers: issue.answers || [], + // 凡人工核对确认的,一律记工号写错(考核口径) + wrongId: true, + wrongIdReason: nameMismatch && certMismatch + ? 'name_and_certificate_mismatch' + : nameMismatch + ? 'name_mismatch' + : certMismatch + ? 'certificate_mismatch' + : 'manual_resolve', + person: { + status: 'matched', + employeeId: employee.employeeId, + name: employee.name, + team: employee.team, + position: employee.position, + workCertificate: employee.workCertificate, + }, + }); + } + return { unresolved, resolved }; +} + +/** 重考/重复:优先保留更低分;分数相同保留较晚行 */ +function shouldKeepLowerScoreExam(candidate, existing) { + const oldScore = Number(existing.score); + const newScore = Number(candidate.score); + if (Number.isFinite(newScore) && Number.isFinite(oldScore) && newScore !== oldScore) { + return newScore < oldScore; + } + if (Number.isFinite(newScore) && !Number.isFinite(oldScore)) return true; + if (!Number.isFinite(newScore) && Number.isFinite(oldScore)) return false; + return Number(candidate.sourceRow || 0) >= Number(existing.sourceRow || 0); +} + +/** 同一人同月只保留一条,避免 exam_uuid 唯一键冲突;重考保留最低分 */ +function dedupeResolvedExams(resolved) { + const byEmployee = new Map(); + for (const exam of resolved) { + const employeeId = exam.person?.employeeId; + if (!employeeId) continue; + const existing = byEmployee.get(employeeId); + if (!existing) { + byEmployee.set(employeeId, exam); + continue; + } + const preferNew = (() => { + const oldWrong = Boolean(existing.wrongId); + const newWrong = Boolean(exam.wrongId); + if (oldWrong !== newWrong) return oldWrong && !newWrong; // 优先完全命中 + return shouldKeepLowerScoreExam(exam, existing); + })(); + if (preferNew) byEmployee.set(employeeId, exam); + } + return { + exams: [...byEmployee.values()], + duplicateCount: Math.max(0, resolved.length - byEmployee.size), + }; +} + +async function commit({ importId, year, month, selections = {}, operator, meta }) { + const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`); + if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '月考导入预览已过期,请重新选择文件', 404); + const payload = JSON.parse(raw); + + const period = parseYearMonth({ + fileName: payload.fileName, + explicit: { + year: year || payload.year, + month: month || payload.month, + }, + }); + if (!period.year || !period.month) { + throw new ApiError(API_CODES.BAD_PARAMS, '未能识别年月,请手动选择后再提交', 400); + } + // 仅非法得分(非空/0/1)阻断;未匹配题库的题目已在预览阶段跳过,不写入对错明细 + if ((payload.questionErrors || []).length) { + throw new ApiError(API_CODES.BAD_PARAMS, `存在 ${payload.questionErrors.length} 条题目得分格式错误,禁止提交`, 400); + } + + const { unresolved, resolved: resolvedRaw } = resolveExams(payload, selections); + if (unresolved.length) { + throw new ApiError(API_CODES.BAD_PARAMS, `仍有 ${unresolved.length} 名人员未完成花名册核对`, 400); + } + const { exams: resolved, duplicateCount } = dedupeResolvedExams(resolvedRaw); + if (!resolved.length) throw new ApiError(API_CODES.BAD_PARAMS, '没有可导入的考试记录', 400); + + const roster = await loadRoster(); + const matchedIds = new Set(resolved.map((exam) => exam.person.employeeId)); + const missing = roster + .filter((row) => !isExcludedFromMissingExam(row)) + .filter((row) => !matchedIds.has(row.employee_id)); + + const batchUuid = randomUUID(); + const missingMonth = `${period.year}-${String(period.month).padStart(2, '0')}`; + + await db.transaction(async (conn) => { + await conn.execute( + `INSERT INTO upload_batches + (batch_uuid, module, data_type, year, month, file_name, file_hash, file_size, + record_count, status, payload, operator_id, locked_at) + VALUES (?, 'online_exams', 'xlsx', ?, ?, ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`, + [ + batchUuid, period.year, period.month, payload.fileName, payload.fileHash, payload.fileSize, + resolved.length + missing.length, + JSON.stringify({ + paperClass: PAPER_CLASS, + duplicateCount, + skippedQuestions: payload.skippedQuestions || [], + }), + operator.id, + ], + ); + + // 覆盖本月前:撤掉上次管理部月考写入的工号写错月份标记 + const [previousWrongRows] = await conn.execute( + `SELECT employee_id, details FROM online_exams + WHERE exam_year = ? AND exam_month = ? AND paper_class = ? + AND JSON_EXTRACT(details, '$.wrongId') = true`, + [period.year, period.month, PAPER_CLASS], + ); + for (const row of previousWrongRows) { + const [employeeRows] = await conn.execute( + 'SELECT details FROM employees WHERE employee_id = ? AND deleted_at IS NULL LIMIT 1', + [row.employee_id], + ); + if (!employeeRows.length) continue; + let details = employeeRows[0].details; + if (typeof details === 'string') { + try { details = JSON.parse(details); } catch (_) { details = {}; } + } + details = details && typeof details === 'object' ? details : {}; + const nextDates = stripMonthFromWrongIdDates(details.wrongIdDates, period.year, period.month); + details.wrongIdDates = nextDates; + details.wrongIdCount = countWrongIdDates(nextDates); + await conn.execute( + `UPDATE employees + SET details = ?, version = version + 1 + WHERE employee_id = ?`, + [JSON.stringify(details), row.employee_id], + ); + } + + await conn.execute( + 'DELETE FROM online_exams WHERE exam_year = ? AND exam_month = ? AND paper_class = ?', + [period.year, period.month, PAPER_CLASS], + ); + await conn.execute( + `DELETE FROM missing_exams + WHERE missing_month = ? + AND JSON_UNQUOTE(JSON_EXTRACT(details, '$.source')) = 'monthly_exam'`, + [missingMonth], + ); + + for (const exam of resolved) { + const score = Number(exam.score); + const result = Number.isFinite(score) ? (score >= 60 ? 'pass' : 'fail') : null; + const wrongCount = (exam.answers || []).filter((answer) => Number(answer.score) === 0).length; + const examUuid = sha(`monthly-exam|${period.year}|${period.month}|${exam.person.employeeId}`).slice(0, 64); + const wrongId = Boolean(exam.wrongId); + await conn.execute( + `INSERT INTO online_exams + (employee_id, paper_id, paper_class, score, result, faults, choice_faults, duty_point, + exam_year, exam_month, submit_time, elapsed_seconds, details, extras, exam_uuid, + upload_batch, created_by, version) + VALUES (?, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, 0)`, + [ + exam.person.employeeId, + PAPER_CLASS, + Number.isFinite(score) ? score : null, + result, + wrongCount, + exam.person.team || null, + period.year, + period.month, + exam.elapsedSeconds, + JSON.stringify({ + source: 'monthly_exam', + sourceName: exam.sourceName, + sourceOpenId: exam.sourceOpenId || null, + workCertificate: exam.sourceCertificate, + // 工号写错只记考核标记;已绑到花名册人员,不算「查无此人」 + wrongId, + wrongIdReason: wrongId ? (exam.wrongIdReason || 'manual_resolve') : null, + isUnknownEmployee: false, + answers: (exam.answers || []).map((answer) => ({ + questionId: answer.questionId, + score: Number(answer.score), + category: answer.category || '未分类', + })), + }), + JSON.stringify({ fileName: payload.fileName, sourceRow: exam.sourceRow }), + examUuid, + batchUuid, + operator.id, + ], + ); + + if (wrongId) { + const [employeeRows] = await conn.execute( + 'SELECT details FROM employees WHERE employee_id = ? AND deleted_at IS NULL LIMIT 1', + [exam.person.employeeId], + ); + if (employeeRows.length) { + let details = employeeRows[0].details; + if (typeof details === 'string') { + try { details = JSON.parse(details); } catch (_) { details = {}; } + } + details = details && typeof details === 'object' ? details : {}; + const nextDates = appendWrongIdDate(details.wrongIdDates, period.year, period.month); + details.wrongIdDates = nextDates; + details.wrongIdCount = countWrongIdDates(nextDates); + await conn.execute( + `UPDATE employees + SET details = ?, version = version + 1 + WHERE employee_id = ?`, + [JSON.stringify(details), exam.person.employeeId], + ); + } + } + } + + for (const row of missing) { + await conn.execute( + `INSERT INTO missing_exams + (employee_id, missing_month, reason, import_time, details, extras) + VALUES (?, ?, '缺考', NOW(), ?, ?)`, + [ + row.employee_id, + missingMonth, + JSON.stringify({ + source: 'monthly_exam', + name: row.name, + team: row.team, + position: row.position, + }), + JSON.stringify({ fileName: payload.fileName }), + ], + ); + } + + await conn.execute( + `UPDATE upload_batches SET status = 'precomputing' WHERE batch_uuid = ?`, + [batchUuid], + ); + }); + + await sync.bumpVersions([ + 'employees', + 'online_exams', + 'missing_exams', + ]); + await db.execute( + `UPDATE upload_batches SET status = 'completed', completed_at = NOW() WHERE batch_uuid = ?`, + [batchUuid], + ); + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'monthly_exam_import', + module: 'imports', + targetType: 'upload_batch', + targetId: batchUuid, + payload: { + fileName: payload.fileName, + year: period.year, + month: period.month, + exams: resolved.length, + missing: missing.length, + wrongId: resolved.filter((exam) => exam.wrongId).length, + }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + await redis.client.del(`${PREVIEW_PREFIX}${importId}`); + + return { + batchUuid, + year: period.year, + month: period.month, + counts: { + exams: resolved.length, + missing: missing.length, + wrongId: resolved.filter((exam) => exam.wrongId).length, + duplicatesMerged: duplicateCount, + }, + }; +} + +async function recomputeCategoryStats(year, month) { + const exams = await db.query( + `SELECT o.employee_id, o.details, COALESCE(e.team, '未分组') AS team + FROM online_exams o + LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL + WHERE o.exam_year = ? AND o.exam_month = ? AND o.paper_class = ?`, + [year, month, PAPER_CLASS], + ); + + const buckets = new Map(); + const touch = (scopeType, scopeKey, category, score) => { + const key = `${scopeType}|${scopeKey}|${category}`; + if (!buckets.has(key)) { + buckets.set(key, { + scopeType, scopeKey, category, questionCount: 0, correctCount: 0, + }); + } + const bucket = buckets.get(key); + bucket.questionCount += 1; + bucket.correctCount += Number(score) === 1 ? 1 : 0; + }; + + for (const exam of exams) { + let details = exam.details; + if (typeof details === 'string') { + try { details = JSON.parse(details); } catch (_) { details = {}; } + } + for (const answer of Array.isArray(details?.answers) ? details.answers : []) { + const category = answer.category || '未分类'; + touch('employee', exam.employee_id, category, answer.score); + touch('team', exam.team || '未分组', category, answer.score); + touch('line', LINE_SCOPE, category, answer.score); + } + } + + await db.transaction(async (conn) => { + await conn.execute( + 'DELETE FROM online_exam_category_stats_monthly WHERE year = ? AND month = ?', + [year, month], + ); + for (const bucket of buckets.values()) { + await conn.execute( + `INSERT INTO online_exam_category_stats_monthly + (year, month, scope_type, scope_key, category, question_count, correct_count, score_rate, computed_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), 0)`, + [ + year, month, bucket.scopeType, bucket.scopeKey, bucket.category, + bucket.questionCount, bucket.correctCount, + bucket.questionCount + ? Number((bucket.correctCount / bucket.questionCount).toFixed(4)) + : null, + ], + ); + } + + await conn.execute('DELETE FROM online_exam_category_stats_yearly WHERE year = ?', [year]); + await conn.execute( + `INSERT INTO online_exam_category_stats_yearly + (year, scope_type, scope_key, category, question_count, correct_count, score_rate, monthly_breakdown, computed_at, version) + SELECT year, scope_type, scope_key, category, + SUM(question_count), SUM(correct_count), + ROUND(SUM(correct_count) / NULLIF(SUM(question_count), 0), 4), + JSON_OBJECTAGG(CAST(month AS CHAR), score_rate), + NOW(), 0 + FROM online_exam_category_stats_monthly + WHERE year = ? + GROUP BY year, scope_type, scope_key, category`, + [year], + ); + + await conn.execute( + `DELETE FROM online_exam_stats_monthly + WHERE year = ? AND month = ? AND team = ? AND paper_class = ?`, + [year, month, LINE_SCOPE, PAPER_CLASS], + ); + await conn.execute( + `INSERT INTO online_exam_stats_monthly + (year, month, team, paper_class, total_count, pass_count, fail_count, pass_rate, + avg_score, max_score, min_score, avg_elapsed_seconds, missing_count, computed_at, version) + SELECT ?, ?, ?, ?, + COUNT(*), SUM(result = 'pass'), SUM(result = 'fail'), + ROUND(SUM(result = 'pass') / NULLIF(COUNT(*), 0), 4), + ROUND(AVG(score), 2), MAX(score), MIN(score), ROUND(AVG(elapsed_seconds)), + 0, NOW(), 0 + FROM online_exams + WHERE exam_year = ? AND exam_month = ? AND paper_class = ?`, + [year, month, LINE_SCOPE, PAPER_CLASS, year, month, PAPER_CLASS], + ); + + await conn.execute( + `DELETE FROM online_exam_stats_yearly + WHERE year = ? AND team = ? AND paper_class = ?`, + [year, LINE_SCOPE, PAPER_CLASS], + ); + await conn.execute( + `INSERT INTO online_exam_stats_yearly + (year, team, paper_class, total_count, pass_count, pass_rate, avg_score, monthly_breakdown, computed_at, version) + SELECT year, team, paper_class, + SUM(total_count), SUM(pass_count), + ROUND(SUM(pass_count) / NULLIF(SUM(total_count), 0), 4), + ROUND(SUM(avg_score * total_count) / NULLIF(SUM(total_count), 0), 2), + JSON_OBJECTAGG(CAST(month AS CHAR), avg_score), + NOW(), 0 + FROM online_exam_stats_monthly + WHERE year = ? AND team = ? AND paper_class = ? + GROUP BY year, team, paper_class`, + [year, LINE_SCOPE, PAPER_CLASS], + ); + }); +} + +module.exports = { + preview, + commit, + recomputeCategoryStats, + normalizeQuestionStem, + PAPER_CLASS, + LINE_SCOPE, +}; diff --git a/server/src/import/monthly-materials.js b/server/src/import/monthly-materials.js new file mode 100644 index 0000000..8cb2036 --- /dev/null +++ b/server/src/import/monthly-materials.js @@ -0,0 +1,833 @@ +'use strict'; + +const { createHash, randomUUID } = require('crypto'); +const ExcelJS = require('exceljs'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const sync = require('../sync/sync'); +const audit = require('../audit/audit'); +const precomputeService = require('../precompute/service'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const SOURCE_TYPE = 'monthly_materials'; +const PREVIEW_TTL_SECONDS = 30 * 60; +const UNKNOWN_EMPLOYEE = '__UNKNOWN__'; +const DRIVER_MAP_SETTING_KEY = 'monthly_materials_driver_map'; +const SHEETS = { + roster: '人员月报(更新)', + performance: '绩效考核(累积)', + faults: '年故障(累积)', + incidents: '事故事件(累积)', +}; + +function driverMapKey(sourceName, contextTeam) { + return `${sourceName}|${contextTeam || ''}`; +} + +function parseJsonValue(value, fallback) { + if (value == null) return fallback; + if (typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +/** 跨月记住「涉及人员核对」结果:Excel 姓名+班组 → 工号 */ +async function loadDriverNameMap(roster = []) { + const map = {}; + const settingRows = await db.query( + `SELECT setting_value FROM system_settings WHERE setting_key=? LIMIT 1`, + [DRIVER_MAP_SETTING_KEY], + ); + Object.assign(map, parseJsonValue(settingRows?.[0]?.setting_value, {}) || {}); + + // 从已入库记录回填(兼容升级前已提交、但尚未写入 setting 的情况) + const faultRows = await db.query( + `SELECT employee_ids, source_payload + FROM fault_disposals + WHERE source_type=? AND deleted_at IS NULL AND employee_ids IS NOT NULL`, + [SOURCE_TYPE], + ); + const incidentRows = await db.query( + `SELECT employee_ids, involved_team, source_payload + FROM incident_events + WHERE source_type=? AND deleted_at IS NULL AND employee_ids IS NOT NULL`, + [SOURCE_TYPE], + ); + for (const row of [...(faultRows || []), ...(incidentRows || [])]) { + const ids = parseJsonValue(row.employee_ids, []); + if (!Array.isArray(ids) || !ids.length) continue; + const payload = parseJsonValue(row.source_payload, {}) || {}; + const names = nameTokens(payload.drivers || '', roster); + if (names.length !== ids.length) continue; + const team = String(row.involved_team || ''); + names.forEach((name, index) => { + const id = String(ids[index] || '').trim().padStart(5, '0'); + if (!name || !id || id === '00000') return; + const key = driverMapKey(name, team); + if (!map[key]) map[key] = id; + }); + } + return map; +} + +async function saveDriverNameMap(map) { + await db.execute( + `INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`, + [DRIVER_MAP_SETTING_KEY, JSON.stringify(map || {})], + ); +} + +function applyHistoricalSelections(matches, nameMap, roster) { + const knownIds = new Set((roster || []).map((employee) => employee.employee_id)); + for (const match of matches) { + if (match.selectedEmployeeId) continue; + let prev = nameMap[driverMapKey(match.sourceName, match.contextTeam)]; + if (!prev) continue; + if (prev !== UNKNOWN_EMPLOYEE) prev = String(prev).trim().padStart(5, '0'); + if (prev === UNKNOWN_EMPLOYEE || knownIds.has(prev)) { + match.selectedEmployeeId = prev; + } + } + return matches; +} + +function valueOf(cell) { + const value = cell?.value; + if (value == null) return ''; + if (value instanceof Date) return value; + if (typeof value !== 'object') return value; + if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result }); + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join(''); + if (value.text != null) return value.text; + return String(value); +} + +function text(value) { + if (value instanceof Date) return value.toISOString(); + if (value == null) return ''; + if (typeof value === 'object') { + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('').trim(); + if (value.result !== undefined) return text(value.result); + } + return String(value).trim(); +} + +function present(value) { + return text(value) !== ''; +} + +function excelDate(number) { + const date = new Date(Date.UTC(1899, 11, 30) + Math.floor(number) * 86400000); + return date; +} + +function dateString(value, inferredYear) { + let date = null; + if (value instanceof Date) date = value; + else if (typeof value === 'number' && Number.isFinite(value)) date = excelDate(value); + else { + const raw = text(value).replace(/\s+/g, ''); + let match = raw.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/); + if (match) date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + if (!date) { + match = raw.match(/^(\d{1,2})月(\d{1,2})日?$/); + if (match && inferredYear) date = new Date(Date.UTC(inferredYear, Number(match[1]) - 1, Number(match[2]))); + } + if (!date && /^\d{4}-\d{2}-\d{2}T/.test(raw)) date = new Date(raw); + } + if (!date || Number.isNaN(date.getTime())) return null; + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function timeString(value) { + if (value instanceof Date) { + return [value.getUTCHours(), value.getUTCMinutes(), value.getUTCSeconds()] + .map((item) => String(item).padStart(2, '0')).join(':'); + } + if (typeof value === 'number' && Number.isFinite(value)) { + const seconds = Math.round((value % 1) * 86400) % 86400; + return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), seconds % 60] + .map((item) => String(item).padStart(2, '0')).join(':'); + } + const match = text(value).match(/(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/); + if (!match) return null; + return [match[1], match[2], match[3] || '00'].map((item) => String(item).padStart(2, '0')).join(':'); +} + +function decimal(value) { + const raw = text(value); + if (raw === '' || /^#N\/A$/i.test(raw)) return null; + const number = Number(raw.replace(/,/g, '')); + return Number.isFinite(number) ? number : null; +} + +function sha(value) { + return createHash('sha256').update(String(value)).digest('hex'); +} + +function headerMap(sheet, rowNumber) { + const map = new Map(); + sheet.getRow(rowNumber).eachCell({ includeEmpty: false }, (cell, column) => { + const label = text(valueOf(cell)).replace(/\s+/g, ''); + if (label) map.set(label, column); + }); + return map; +} + +function column(headers, aliases) { + for (const alias of aliases) { + const found = headers.get(alias.replace(/\s+/g, '')); + if (found) return found; + } + return null; +} + +function rowValues(sheet, rowNumber, definitions) { + const out = {}; + for (const [key, col] of Object.entries(definitions)) { + out[key] = col ? valueOf(sheet.getCell(rowNumber, col)) : ''; + } + return out; +} + +function requireFields(errors, sheet, row, values, fields, labels) { + const missing = fields.filter((field) => !present(values[field])).map((field) => labels[field] || field); + if (missing.length) errors.push({ sheet, row, message: `缺少必填列数据:${missing.join('、')}` }); + return missing.length === 0; +} + +function inferYear(fileName) { + const match = String(fileName).match(/20\d{2}/); + return match ? Number(match[0]) : new Date().getFullYear(); +} + +function parseRoster(sheet, errors) { + const headers = headerMap(sheet, 12); + const cols = { + sequence: column(headers, ['序号']), + workCertificate: column(headers, ['工作证号', '工作证编号']), + employeeId: column(headers, ['工号']), + name: column(headers, ['姓名']), + team: column(headers, ['所在班组']), + position: column(headers, ['岗位']), + remarks: column(headers, ['备注']), + performanceTotal: column(headers, ['绩效累计', '绩效累积']), + driverPointsTotal: column(headers, ['司机记分累计', '司机记分累积']), + skillLevel: column(headers, ['技能等级']), + starLevel: column(headers, ['星级']), + }; + const records = []; + const employeeIds = new Set(); + for (let row = 13; row <= sheet.rowCount; row += 1) { + const sequence = valueOf(sheet.getCell(row, cols.sequence || 1)); + if (!/^\d+$/.test(text(sequence))) continue; + const values = rowValues(sheet, row, cols); + const required = Object.keys(cols).filter((key) => !['sequence', 'remarks'].includes(key)); + requireFields(errors, SHEETS.roster, row, values, required, { + workCertificate: '工作证号', employeeId: '工号', name: '姓名', team: '所在班组', + position: '岗位', remarks: '备注', performanceTotal: '绩效累计', + driverPointsTotal: '司机记分累计', skillLevel: '技能等级', starLevel: '星级', + }); + if (!present(values.employeeId) || !present(values.name)) continue; + const employeeId = text(values.employeeId).padStart(5, '0'); + if (employeeIds.has(employeeId)) { + errors.push({ sheet: SHEETS.roster, row, message: `工号重复:${employeeId}` }); + continue; + } + employeeIds.add(employeeId); + records.push({ + employee_id: employeeId, + internal_employee_id: text(values.workCertificate), + name: text(values.name), + team: text(values.team), + position: text(values.position), + skill_level: text(values.skillLevel), + details: { + remarks: text(values.remarks), + performanceTotal: text(values.performanceTotal), + driverPointsTotal: text(values.driverPointsTotal), + starLevel: text(values.starLevel), + }, + source_row: row, + }); + } + return records; +} + +function rosterIndexes(roster) { + const byId = new Map(); + const byName = new Map(); + for (const employee of roster) { + byId.set(employee.employee_id, employee); + const list = byName.get(employee.name) || []; + list.push(employee); + byName.set(employee.name, list); + } + return { byId, byName }; +} + +function parsePerformance(sheet, year, roster, errors) { + const headers = headerMap(sheet, 7); + const cols = { + date: column(headers, ['日期']), + employeeId: column(headers, ['工号']), + name: column(headers, ['姓名']), + reason: column(headers, ['违章事由']), + category: column(headers, ['考核分类']), + item: column(headers, ['考核项']), + deduction: column(headers, ['绩效扣款']), + points: column(headers, ['绩效扣分']), + starPoints: column(headers, ['星级扣分']), + safeKilometers: column(headers, ['安全公里数']), + twelvePoints: column(headers, ['12分制扣分']), + remarks: column(headers, ['备注']), + }; + const indexes = rosterIndexes(roster); + const records = []; + for (let row = 8; row <= sheet.rowCount; row += 1) { + const rawDate = valueOf(sheet.getCell(row, cols.date || 2)); + const assessmentDate = dateString(rawDate, year); + if (!assessmentDate) continue; + const values = rowValues(sheet, row, cols); + if (!present(values.employeeId)) { + requireFields(errors, SHEETS.performance, row, values, ['employeeId'], { employeeId: '工号' }); + continue; + } + const employeeId = text(values.employeeId).padStart(5, '0'); + const employee = indexes.byId.get(employeeId); + if (!employee) continue; + const required = ['date', 'employeeId', 'reason', 'category', 'item', 'deduction', 'points', 'starPoints', 'safeKilometers', 'remarks']; + requireFields(errors, SHEETS.performance, row, values, required, { + date: '日期', employeeId: '工号', reason: '违章事由', category: '考核分类', item: '考核项', + deduction: '绩效扣款', points: '绩效扣分', starPoints: '星级扣分', + safeKilometers: '安全公里数', twelvePoints: '12分制扣分', remarks: '备注', + }); + const identity = [assessmentDate, employeeId, text(values.reason), text(values.category), text(values.item)].join('|'); + records.push({ + source_key: sha(identity), + assessment_date: assessmentDate, + employee_id: employeeId, + employee_name: employee?.name || text(values.name) || '#N/A', + team: employee?.team || null, + violation_reason: text(values.reason), + assessment_category: text(values.category), + assessment_item: text(values.item), + performance_deduction: decimal(values.deduction), + performance_points: decimal(values.points), + star_points: decimal(values.starPoints), + safe_kilometers: decimal(values.safeKilometers), + twelve_point_deduction: decimal(values.twelvePoints), + remarks: text(values.remarks), + source_payload: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, text(value)])), + source_row: row, + }); + } + return records; +} + +function levenshtein(left, right) { + const rows = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let i = 1; i <= left.length; i += 1) { + let previous = rows[0]; + rows[0] = i; + for (let j = 1; j <= right.length; j += 1) { + const old = rows[j]; + rows[j] = Math.min(rows[j] + 1, rows[j - 1] + 1, previous + (left[i - 1] === right[j - 1] ? 0 : 1)); + previous = old; + } + } + return rows[right.length]; +} + +function nameTokens(raw, roster) { + const input = text(raw).replace(/\s+/g, ''); + const found = [...new Set(roster.filter((employee) => input.includes(employee.name)).map((employee) => employee.name))]; + if (found.length) return found; + return [...new Set( + input + .replace(/(涉及)?司机[::;;]?/g, '') + .split(/[、,,;;/]/) + .map((item) => item.replace(/[^\u3400-\u9fff·]/g, '')) + .filter((item) => item.length >= 2 && item.length <= 8), + )]; +} + +function matchSourceDetails(row) { + const isFault = Boolean(row.fault_date); + return { + sheet: isFault ? SHEETS.faults : SHEETS.incidents, + row: row.source_row, + date: row.fault_date || row.event_date, + time: row.fault_time || null, + trainNumber: row.train_number || null, + carNumber: row.car_number || null, + location: isFault + ? [row.location_primary, row.location_secondary].filter(Boolean).join(' / ') + : row.event_location, + category: row.fault_type || row.fault_category || null, + phenomenon: row.fault_phenomenon || row.phenomenon_summary || null, + involvedDrivers: row.involved_driver_text, + involvedTeam: row.involved_team || null, + }; +} + +function buildMatches(rows, roster) { + const matchMap = new Map(); + const byName = rosterIndexes(roster).byName; + for (const row of rows) { + row.driver_match_ids = nameTokens(row.involved_driver_text, roster).map((name) => { + const contextTeam = row.involved_team || ''; + const matchId = sha(`${name}|${contextTeam}`); + if (!matchMap.has(matchId)) { + let candidates = (byName.get(name) || []).map((employee) => ({ ...employee, score: 100 })); + if (!candidates.length) { + candidates = roster + .map((employee) => ({ ...employee, score: Math.max(0, 90 - levenshtein(name, employee.name) * 25) })) + .filter((employee) => employee.score >= 40) + .sort((a, b) => b.score - a.score) + .slice(0, 8); + } + // 同名/同分:班组一致优先,其次在职优先(非在职仍可选),未软删优先 + candidates.sort((a, b) => { + if (contextTeam) { + const teamDiff = Number(b.team === contextTeam) - Number(a.team === contextTeam); + if (teamDiff) return teamDiff; + } + const activeA = String(a.employment_status || '').trim() || '在职'; + const activeB = String(b.employment_status || '').trim() || '在职'; + const activeDiff = Number(activeB === '在职') - Number(activeA === '在职'); + if (activeDiff) return activeDiff; + const delDiff = Number(Boolean(a.soft_deleted)) - Number(Boolean(b.soft_deleted)); + if (delDiff) return delDiff; + return b.score - a.score; + }); + matchMap.set(matchId, { + matchId, + sourceName: name, + contextTeam, + candidates: candidates.map((candidate) => ({ + employeeId: candidate.employee_id, + name: candidate.name, + team: candidate.team, + position: candidate.position, + employmentStatus: candidate.employment_status || '在职', + score: candidate.score, + })), + selectedEmployeeId: candidates.length === 1 && candidates[0].score === 100 + ? candidates[0].employee_id + : null, + occurrences: 0, + sources: [], + }); + } + const match = matchMap.get(matchId); + match.occurrences += 1; + match.sources.push(matchSourceDetails(row)); + return matchId; + }); + } + return [...matchMap.values()]; +} + +function parseFaults(sheet, year, errors) { + const headers = headerMap(sheet, 12); + const cols = { + date: column(headers, ['故障日期']), time: column(headers, ['故障时间']), + model: column(headers, ['故障车型']), keyTrain: column(headers, ['关键车次']), + train: column(headers, ['故障车次']), car: column(headers, ['故障车号']), + location1: column(headers, ['故障地点-1']), location2: column(headers, ['故障地点-2']), + drivers: column(headers, ['涉及司机']), phenomenon: column(headers, ['故障现象']), + type: column(headers, ['故障类型']), attribution: column(headers, ['故障归属']), + impactMidway: column(headers, ['运营影响(中途)']), impactTerminal: column(headers, ['运营影响(终到)']), + firstOperation: column(headers, ['首次处置操作']), firstResult: column(headers, ['首次处置结果']), + finalResult: column(headers, ['最终处置结果']), tracking: column(headers, ['故障跟踪']), + }; + const records = []; + for (let row = 13; row <= sheet.rowCount; row += 1) { + const faultDate = dateString(valueOf(sheet.getCell(row, cols.date || 1)), year); + if (!faultDate) continue; + const values = rowValues(sheet, row, cols); + if (!present(values.type)) continue; + const faultTime = timeString(values.time); + const required = ['date', 'time', 'location1', 'location2', 'drivers', 'phenomenon', 'type', 'attribution', 'firstOperation', 'firstResult']; + requireFields(errors, SHEETS.faults, row, values, required, { + date: '故障日期', time: '故障时间', location1: '故障地点-1', location2: '故障地点-2', + drivers: '涉及司机', phenomenon: '故障现象', type: '故障类型', attribution: '故障归属', + firstOperation: '首次处置操作', firstResult: '首次处置结果', finalResult: '最终处置结果', + }); + if (!faultTime) { + errors.push({ sheet: SHEETS.faults, row, message: '故障时间格式无法识别' }); + } + const identity = [faultDate, faultTime, text(values.train), text(values.car), text(values.location1), text(values.phenomenon)].join('|'); + records.push({ + source_key: sha(identity), fault_date: faultDate, fault_time: faultTime || '00:00:00', + vehicle_model: text(values.model) || null, key_train_number: text(values.keyTrain) || null, + train_number: text(values.train) || null, car_number: text(values.car) || null, + location_primary: text(values.location1), location_secondary: text(values.location2), + involved_driver_text: text(values.drivers), fault_phenomenon: text(values.phenomenon), + fault_type: text(values.type), fault_attribution: text(values.attribution), + impact_midway: text(values.impactMidway) || null, impact_terminal: text(values.impactTerminal) || null, + first_operation: text(values.firstOperation), first_result: text(values.firstResult), + final_result: text(values.finalResult) || null, fault_tracking: text(values.tracking) || null, + source_payload: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, text(value)])), + source_row: row, + }); + } + return records; +} + +function parseIncidents(sheet, year, errors) { + const headers = headerMap(sheet, 1); + const cols = { + sequence: column(headers, ['序号']), date: column(headers, ['日期']), train: column(headers, ['车次']), + car: column(headers, ['车号']), location: column(headers, ['事发位置']), + category: column(headers, ['故障类别']), phenomenon: column(headers, ['故障现象(简述)']), + drivers: column(headers, ['涉及司机']), team: column(headers, ['涉及班组']), + reason: column(headers, ['事件原因']), delay: column(headers, ['晚点情况']), + issues: column(headers, ['操作是否存在问题(详细)']), + }; + const records = []; + for (let row = 2; row <= sheet.rowCount; row += 1) { + if (!/^\d+$/.test(text(valueOf(sheet.getCell(row, cols.sequence || 1))))) continue; + const values = rowValues(sheet, row, cols); + let eventDate = dateString(values.date, year); + const required = Object.keys(cols).filter((key) => key !== 'sequence'); + requireFields(errors, SHEETS.incidents, row, values, required, { + date: '日期', train: '车次', car: '车号', location: '事发位置', category: '故障类别', + phenomenon: '故障现象(简述)', drivers: '涉及司机', team: '涉及班组', + reason: '事件原因', delay: '晚点情况', issues: '操作是否存在问题(详细)', + }); + if (!eventDate) { + errors.push({ sheet: SHEETS.incidents, row, message: '日期格式无法识别' }); + eventDate = `${year}-01-01`; + } + const identity = [eventDate, text(values.train), text(values.car), text(values.location), text(values.phenomenon)].join('|'); + records.push({ + source_key: sha(identity), event_date: eventDate, train_number: text(values.train), + car_number: text(values.car), event_location: text(values.location), + fault_category: text(values.category), phenomenon_summary: text(values.phenomenon), + involved_driver_text: text(values.drivers), involved_team: text(values.team), + event_reason: text(values.reason), delay_details: text(values.delay), + operation_issues: text(values.issues), + source_payload: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, text(value)])), + source_row: row, + }); + } + return records; +} + +async function loadDbMatchRoster() { + // 匹配池 = 系统花名册全量(含非在职、含曾被误软删的记录),不用 Excel「人员月报」 + const rows = await db.query( + `SELECT employee_id, internal_employee_id, name, team, position, employment_status, deleted_at + FROM employees`, + ); + return (rows || []).map((row) => ({ + employee_id: String(row.employee_id || '').trim().padStart(5, '0'), + internal_employee_id: String(row.internal_employee_id || '').trim(), + name: String(row.name || '').trim(), + team: String(row.team || '').trim(), + position: String(row.position || '').trim(), + employment_status: String(row.employment_status || '').trim() || '在职', + soft_deleted: row.deleted_at != null, + })).filter((row) => row.employee_id && row.name); +} + +async function preview(file) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file.buffer); + const missingSheets = Object.values(SHEETS).filter((name) => !workbook.getWorksheet(name)); + if (missingSheets.length) throw new ApiError(API_CODES.BAD_PARAMS, `缺少工作表:${missingSheets.join('、')}`, 400); + const errors = []; + const year = inferYear(file.originalname); + const employees = parseRoster(workbook.getWorksheet(SHEETS.roster), errors); + const performance = parsePerformance(workbook.getWorksheet(SHEETS.performance), year, employees, errors); + const faults = parseFaults(workbook.getWorksheet(SHEETS.faults), year, errors); + const incidents = parseIncidents(workbook.getWorksheet(SHEETS.incidents), year, errors); + // 涉及司机:对系统花名册(含非在职);Excel 人员月报通常只有在职,会导致严敏等匹配不到 + const matchRoster = await loadDbMatchRoster(); + const nameMap = await loadDriverNameMap(matchRoster); + const matches = applyHistoricalSelections( + buildMatches([...faults, ...incidents], matchRoster), + nameMap, + matchRoster, + ); + const importId = randomUUID(); + const payload = { + importId, fileName: file.originalname, fileHash: sha(file.buffer), fileSize: file.size, + year, employees, performance, faults, incidents, matches, matchRoster, + validationErrorCount: errors.length, + }; + await redis.client.set(`monthly-import:${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS); + return { + importId, + fileName: file.originalname, + summary: { + employees: employees.length, + performanceAssessments: performance.length, + faultDisposals: faults.length, + incidentEvents: incidents.length, + validationErrors: errors.length, + personMatches: matches.length, + unresolvedMatches: matches.filter((item) => !item.selectedEmployeeId).length, + }, + errors: errors.slice(0, 500), + matches, + roster: matchRoster.map((employee) => ({ + employeeId: employee.employee_id, + name: employee.name, + team: employee.team, + position: employee.position, + employmentStatus: employee.employment_status || '在职', + })), + canCommit: errors.length === 0, + }; +} + +function placeholders(rows, columns) { + return rows.map(() => `(${columns.map(() => '?').join(',')})`).join(','); +} + +async function bulkUpsert(conn, table, columns, rows, updateColumns) { + for (let offset = 0; offset < rows.length; offset += 200) { + const part = rows.slice(offset, offset + 200); + const params = part.flatMap((row) => columns.map((col) => { + const value = row[col]; + return value != null && typeof value === 'object' ? JSON.stringify(value) : value ?? null; + })); + await conn.execute( + `INSERT INTO \`${table}\` (${columns.map((col) => `\`${col}\``).join(',')}) + VALUES ${placeholders(part, columns)} + ON DUPLICATE KEY UPDATE ${updateColumns.map((col) => + col === 'version' ? '`version`=`version`+1' : `\`${col}\`=VALUES(\`${col}\`)` + ).join(',')}`, + params, + ); + } +} + +function applyMatches(rows, matches, selections, roster) { + const knownIds = new Set(roster.map((employee) => employee.employee_id)); + const namesById = new Map(roster.map((employee) => [employee.employee_id, employee.name])); + const selected = new Map(); + for (const match of matches) { + const employeeId = selections[match.matchId] || match.selectedEmployeeId; + if (!employeeId || (employeeId !== UNKNOWN_EMPLOYEE && !knownIds.has(employeeId))) { + throw new ApiError(API_CODES.BAD_PARAMS, `人员“${match.sourceName}”尚未完成花名册匹配`, 400); + } + selected.set(match.matchId, employeeId); + } + return rows.map((row) => { + const resolved = row.driver_match_ids.map((matchId) => selected.get(matchId)); + const employeeIds = [...new Set(resolved.filter((employeeId) => employeeId !== UNKNOWN_EMPLOYEE))]; + const displayNames = [...new Set(resolved.map((employeeId) => + employeeId === UNKNOWN_EMPLOYEE ? '未知' : namesById.get(employeeId) + ).filter(Boolean))]; + return { + ...row, + involved_driver_text: displayNames.length ? displayNames.join('、') : row.involved_driver_text, + employee_ids: employeeIds, + driver_match_ids: undefined, + }; + }); +} + +async function commit({ importId, selections = {}, operator, meta }) { + const raw = await redis.client.get(`monthly-import:${importId}`); + if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '导入预览已过期,请重新选择文件', 404); + const payload = JSON.parse(raw); + if (payload.validationErrorCount > 0) { + throw new ApiError(API_CODES.BAD_PARAMS, `存在 ${payload.validationErrorCount} 条完整性错误,禁止提交`, 400); + } + const faults = applyMatches(payload.faults, payload.matches, selections, payload.matchRoster || payload.employees); + const incidents = applyMatches(payload.incidents, payload.matches, selections, payload.matchRoster || payload.employees); + const batchUuid = randomUUID(); + + await db.transaction(async (conn) => { + await conn.execute( + `INSERT INTO upload_batches + (batch_uuid, module, data_type, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at) + VALUES (?, 'monthly_materials', 'xlsx', ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`, + [ + batchUuid, payload.fileName, payload.fileHash, payload.fileSize, + payload.employees.length + payload.performance.length + faults.length + incidents.length, + JSON.stringify({ year: payload.year }), operator.id, + ], + ); + + // 增量导入:不软删花名册;在职/非在职以「9.乘务人事月报」为准 + const employeeRows = payload.employees.map((row) => ({ + employee_id: row.employee_id, + internal_employee_id: row.internal_employee_id, + name: row.name, + team: row.team, + position: row.position, + skill_level: row.skill_level, + status: 'active', + source: SOURCE_TYPE, + created_by: operator.id, + details: row.details, + deleted_at: null, + version: 1, + })); + await bulkUpsert( + conn, + 'employees', + ['employee_id', 'internal_employee_id', 'name', 'team', 'position', 'skill_level', 'status', 'source', 'created_by', 'details', 'deleted_at', 'version'], + employeeRows, + [ + 'internal_employee_id', 'name', 'team', 'position', 'skill_level', 'status', 'source', + 'created_by', 'deleted_at', + ], + ); + for (const row of employeeRows) { + await conn.execute( + `UPDATE employees + SET details=JSON_MERGE_PATCH(COALESCE(details, JSON_OBJECT()), ?), version=version+1 + WHERE employee_id=?`, + [JSON.stringify(row.details), row.employee_id], + ); + } + + // 批量计算所有涉及员工的责任人快照(performance 单员工 / fault+incident 多员工取第一个) + const responsible = require('../common/responsible'); + const allEmployeeIdsForResp = new Set(); + for (const row of payload.performance || []) { + if (row.employee_id) allEmployeeIdsForResp.add(String(row.employee_id)); + } + for (const row of faults) { + const ids = Array.isArray(row.employee_ids) ? row.employee_ids : []; + if (ids.length > 0) allEmployeeIdsForResp.add(String(ids[0])); + } + for (const row of incidents) { + const ids = Array.isArray(row.employee_ids) ? row.employee_ids : []; + if (ids.length > 0) allEmployeeIdsForResp.add(String(ids[0])); + } + // 必须用事务 conn:否则读不到本事务刚 upsert 的班组/岗位,会误落成管理部经理 + const responsibleMap = await responsible.computeResponsiblePersonBatch( + [...allEmployeeIdsForResp], + { conn }, + ); + + const groups = [ + { + table: 'performance_assessments', + rows: payload.performance, + columns: [ + 'source_type', 'source_key', 'assessment_date', 'employee_id', 'employee_name', 'team', + 'violation_reason', 'assessment_category', 'assessment_item', 'performance_deduction', + 'performance_points', 'star_points', 'safe_kilometers', 'twelve_point_deduction', + 'remarks', 'responsible_person', 'source_payload', 'source_row', 'import_batch', 'deleted_at', 'version', + ], + }, + { + table: 'fault_disposals', + rows: faults, + columns: [ + 'source_type', 'source_key', 'fault_date', 'fault_time', 'vehicle_model', 'key_train_number', + 'train_number', 'car_number', 'location_primary', 'location_secondary', + 'involved_driver_text', 'employee_ids', 'fault_phenomenon', 'fault_type', + 'fault_attribution', 'impact_midway', 'impact_terminal', 'first_operation', + 'first_result', 'final_result', 'fault_tracking', 'responsible_person', 'source_payload', 'source_row', + 'import_batch', 'deleted_at', 'version', + ], + }, + { + table: 'incident_events', + rows: incidents, + columns: [ + 'source_type', 'source_key', 'event_date', 'train_number', 'car_number', 'event_location', + 'fault_category', 'phenomenon_summary', 'involved_driver_text', 'employee_ids', + 'involved_team', 'event_reason', 'delay_details', 'operation_issues', 'responsible_person', 'source_payload', + 'source_row', 'import_batch', 'deleted_at', 'version', + ], + }, + ]; + // 累积表按 source_key upsert:本月有的新增/更新,历史行保留不软删 + for (const group of groups) { + const rows = group.rows.map((row) => { + const ids = Array.isArray(row.employee_ids) ? row.employee_ids : []; + const primaryId = String(row.employee_id || ids[0] || ''); + return { + ...row, + source_type: SOURCE_TYPE, + import_batch: batchUuid, + responsible_person: responsibleMap[primaryId] || '', + deleted_at: null, + version: 1, + }; + }); + await bulkUpsert( + conn, + group.table, + group.columns, + rows, + group.columns.filter((col) => !['source_type', 'source_key'].includes(col)), + ); + } + await conn.execute( + `UPDATE upload_batches SET status='completed', completed_at=NOW(), + payload=? WHERE batch_uuid=?`, + [JSON.stringify({ + employees: payload.employees.length, + performanceAssessments: payload.performance.length, + faultDisposals: faults.length, + incidentEvents: incidents.length, + }), batchUuid], + ); + }); + + // 记住本次人员核对,下月预览自动带出,避免重复出现在「涉及人员核对」 + const nameMap = await loadDriverNameMap(payload.matchRoster || payload.employees); + for (const match of payload.matches || []) { + const employeeId = selections[match.matchId] || match.selectedEmployeeId; + if (!employeeId) continue; + nameMap[driverMapKey(match.sourceName, match.contextTeam)] = + employeeId === UNKNOWN_EMPLOYEE ? UNKNOWN_EMPLOYEE : String(employeeId).trim().padStart(5, '0'); + } + await saveDriverNameMap(nameMap); + + await sync.bumpVersions(['employees', 'performance_assessments', 'fault_disposals', 'incident_events']); + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'monthly_materials_import', + module: 'imports', + targetType: 'upload_batch', + targetId: batchUuid, + payload: { + fileName: payload.fileName, + fileHash: payload.fileHash, + employees: payload.employees.length, + performanceAssessments: payload.performance.length, + faultDisposals: faults.length, + incidentEvents: incidents.length, + }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + await redis.client.del(`monthly-import:${importId}`); + return { + batchUuid, + counts: { + employees: payload.employees.length, + performanceAssessments: payload.performance.length, + faultDisposals: faults.length, + incidentEvents: incidents.length, + }, + }; +} + +module.exports = { preview, commit, SHEETS }; diff --git a/server/src/import/personnel-roster.js b/server/src/import/personnel-roster.js new file mode 100644 index 0000000..c5d335c --- /dev/null +++ b/server/src/import/personnel-roster.js @@ -0,0 +1,913 @@ +'use strict'; + +/** + * 乘务人事月报导入(开发者维护 · 导入 9) + * - 工作表「基础信息」 + * - 按工号 + 姓名精确匹配现有花名册 + * - 匹配到的人:更新人事字段(籍贯/学历/在职状态/手机等);岗位、班组以「1.月度材料」为准,本导入不覆盖 + * - 未匹配到的新人:INSERT 时可写入本表岗位/班组 + */ + +const { createHash, randomUUID } = require('crypto'); +const ExcelJS = require('exceljs'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const sync = require('../sync/sync'); +const audit = require('../audit/audit'); +const crypto = require('../common/crypto'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const PREVIEW_TTL_SECONDS = 30 * 60; +const PREVIEW_PREFIX = 'personnel-roster-import:'; +const SOURCE_TAG = 'personnel_roster'; + +const HEADER_ALIASES = { + employeeId: ['工号'], + internalEmployeeId: ['工作证号', '工作证编号'], + name: ['姓名'], + position: ['岗位'], + team: ['班组'], + mobile: ['请输入手机号', '手机号', '联系方式'], + idCard: ['个人身份证号', '身份证号'], + address: ['现居详细地址', '地址'], + nativePlace: ['籍贯'], + nationality: ['民族'], + politicalStatus: ['政治面貌'], + education: ['最高学历信息:最高学历', '最高学历', '学历'], + educationType: ['最高学历信息:全日制/非全日制', '全日制/非全日制', '学制'], + graduateSchool: ['最高学历信息:毕业院校', '毕业院校'], + emergencyContactName: ['紧急联系人姓名'], + emergencyContactPhone: ['紧急联系人手机号'], + emergencyContactRelation: ['紧急联系人与职工关系'], + cardNumber: ['卡内号'], + hireDate: ['入职时间', '入职年月'], + totalKilometers: ['司机安全公里数', '安全公里数'], + remarks: ['备注'], +}; + +function sha256(buffer) { + return createHash('sha256').update(buffer).digest('hex'); +} + +function valueOf(cell) { + const value = cell?.value; + if (value == null) return ''; + if (value instanceof Date) return value; + if (typeof value !== 'object') return value; + if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result }); + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join(''); + if (value.text != null) return value.text; + return String(value); +} + +function text(value) { + if (value instanceof Date) { + const y = value.getUTCFullYear(); + const m = String(value.getUTCMonth() + 1).padStart(2, '0'); + const d = String(value.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + if (value == null) return ''; + if (typeof value === 'object') { + if (value.error) return String(value.error); + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('').trim(); + if (value.result !== undefined) return text(value.result); + } + return String(value).trim(); +} + +function normalizeEmployeeId(value) { + const raw = text(value).replace(/\s+/g, ''); + if (!raw) return ''; + const digits = raw.replace(/\D/g, ''); + if (!digits) return raw; + return digits.padStart(5, '0'); +} + +/** 去掉重名数字后缀,如 孙俊1 → 孙俊 */ +function normalizedName(value) { + return String(value ?? '').trim().replace(/\d+$/, ''); +} + +/** 精确相等,或去数字后缀后相等(花名册重名后缀) */ +function namesCompatible(excelName, rosterName) { + const left = text(excelName); + const right = text(rosterName); + if (!left || !right) return false; + if (left === right) return true; + return normalizedName(left) === normalizedName(right); +} + +function excelSerialToDate(number) { + return new Date(Date.UTC(1899, 11, 30) + Math.floor(number) * 86400000); +} + +/** 入职时间:支持 Date / Excel 序列 / 年.月小数(2012.3) / 20000701 / 200007 / 2000-07 / 2000/7 / 2000年7月 / 月/日/年 */ +function parseHireDate(value) { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + const y = value.getUTCFullYear(); + const m = value.getUTCMonth() + 1; + const d = value.getUTCDate(); + if (!isValidYMD(y, m, d)) return null; + return `${y}-${pad2(m)}-${pad2(d)}`; + } + if (typeof value === 'number' && Number.isFinite(value)) { + const str = String(value); + // 「年.月」小数格式:2012.3 = 2012年3月。用字符串解析避免 JS 浮点 2012.10===2012.1 丢零 + if (/^\d{4}\.\d{1,2}$/.test(str)) { + const [yy, mm] = str.split('.'); + const y = Number(yy); + const m = Number(mm); + if (y >= 1900 && y <= 2100 && m >= 1 && m <= 12) return `${y}-${pad2(m)}-01`; + } + // Excel 序列号:整数且 >= 20000(约 1954-09-25 之后),避免把小数年误当序列 + if (Number.isInteger(value) && value >= 20000) { + return parseHireDate(excelSerialToDate(value)); + } + // 其他整数:尝试 Excel 序列,但只在结果年份合理(1990-2100)时采用 + if (Number.isInteger(value) && value > 0) { + const d = excelSerialToDate(value); + const y = d.getUTCFullYear(); + if (y >= 1990 && y <= 2100) return parseHireDate(d); + } + return null; + } + const raw = text(value).replace(/\s+/g, ''); + if (!raw) return null; + // 8 位连写:20000701 → 2000-07-01 + let match = raw.match(/^(\d{4})(\d{2})(\d{2})$/); + if (match) { + const y = Number(match[1]); + const m = Number(match[2]); + const d = Number(match[3]); + if (isValidYMD(y, m, d)) return `${y}-${pad2(m)}-${pad2(d)}`; + } + // 6 位连写:200007 → 2000-07-01 + match = raw.match(/^(\d{4})(\d{2})$/); + if (match) { + const y = Number(match[1]); + const m = Number(match[2]); + if (y >= 1900 && y <= 2100 && m >= 1 && m <= 12) return `${y}-${pad2(m)}-01`; + } + // 通用:2000-07 / 2000/7 / 2000.7 / 2000年7月 / 2000-07-01 / 2000.7.1 / 2000年7月1日 + match = raw.match(/^(\d{4})[-/.年](\d{1,2})(?:[-/.月](\d{1,2})日?)?/); + if (match) { + const y = Number(match[1]); + const m = Number(match[2]); + const d = match[3] ? Number(match[3]) : 1; + if (isValidYMD(y, m, d)) return `${y}-${pad2(m)}-${pad2(d)}`; + } + // 月/日/年(2 位年):7-2-24 → 2024-07-02 + match = raw.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})$/); + if (match) { + let y = Number(match[3]); + if (y < 100) y += 2000; + const m = Number(match[1]); + const d = Number(match[2]); + if (isValidYMD(y, m, d)) return `${y}-${pad2(m)}-${pad2(d)}`; + } + return null; +} + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +function isValidYMD(y, m, d) { + return y >= 1900 && y <= 2100 && m >= 1 && m <= 12 && d >= 1 && d <= 31; +} + +function parseKilometers(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + const raw = text(value).replace(/,/g, ''); + if (!raw) return null; + const num = Number(raw); + return Number.isFinite(num) ? num : null; +} + +function findSheet(workbook) { + const exact = workbook.worksheets.find((sheet) => sheet.name.trim() === '基础信息'); + if (exact) return exact; + return workbook.worksheets.find((sheet) => sheet.name.includes('基础信息')) || null; +} + +function buildHeaderMap(headerRow) { + const map = {}; + headerRow.eachCell({ includeEmpty: false }, (cell, colNumber) => { + const label = text(valueOf(cell)); + if (!label) return; + if (map[label] == null) map[label] = colNumber; + }); + return map; +} + +function colOf(headerMap, keys) { + for (const key of keys) { + if (headerMap[key] != null) return headerMap[key]; + } + return null; +} + +function cellText(sheet, rowNumber, col) { + if (col == null) return ''; + return text(valueOf(sheet.getCell(rowNumber, col))); +} + +function cellRaw(sheet, rowNumber, col) { + if (col == null) return ''; + return valueOf(sheet.getCell(rowNumber, col)); +} + +async function loadRoster() { + // 含软删:人事月报应能「复活」并更新;否则会被当成 newHires 再 INSERT → uk_employee_id 冲突 → 500 + const rows = await db.query( + `SELECT employee_id, name, internal_employee_id, team, status, deleted_at + FROM employees`, + ); + const byEmployeeId = new Map(); + for (const row of rows) { + const id = normalizeEmployeeId(row.employee_id); + if (!id) continue; + byEmployeeId.set(id, { + employee_id: id, + name: String(row.name || '').trim(), + internal_employee_id: row.internal_employee_id, + team: row.team, + status: row.status, + softDeleted: row.deleted_at != null, + }); + } + return byEmployeeId; +} + +/** employment_status 列 VARCHAR(50);备注可能更长,超长会 Data truncated → 500 */ +function employmentStatusValue(remarks) { + const raw = String(remarks || '').trim() || '在职'; + return raw.slice(0, 50); +} + +/** mysql2 拒绝 undefined,缺字段必须转成 null */ +function sqlVal(value) { + return value === undefined ? null : value; +} + +/** 整组绑定参数消毒;若仍有 undefined 则抛出带索引的调试信息 */ +function sqlParams(label, values) { + const out = values.map((value) => (value === undefined ? null : value)); + const bad = []; + for (let i = 0; i < values.length; i += 1) { + if (values[i] === undefined) bad.push(i); + } + if (bad.length) { + console.error(`[personnel-roster] ${label} had undefined at indexes:`, bad, 'raw=', values); + } + return out; +} + +/** 主任办事员预览项字段较少,提交前归一成可 INSERT 结构 */ +function normalizeInsertRow(row) { + if (!row || typeof row !== 'object') return row; + const employeeId = row.employee_id || row.employeeId || null; + return { + ...row, + employee_id: employeeId, + internal_employee_id: row.internal_employee_id ?? row.internalEmployeeId ?? null, + name: row.name ?? '', + team: row.team ?? null, + position: row.position ?? null, + native_place: row.native_place ?? null, + nationality: row.nationality ?? null, + political_status: row.political_status ?? null, + education: row.education ?? null, + education_type: row.education_type ?? null, + graduate_school: row.graduate_school ?? null, + hire_date: row.hire_date ?? null, + mobile: row.mobile ?? null, + id_card: row.id_card ?? null, + address: row.address ?? null, + emergency_contact: row.emergency_contact ?? null, + employment_status_value: row.employment_status_value || '在职', + details: row.details || { + personnelRosterImportedAt: new Date().toISOString(), + personnelRosterSource: SOURCE_TAG, + }, + }; +} + +/** 控制明文长度,避免 AES 后超出 VARBINARY 上限 */ +function truncatePlain(value, maxBytes) { + if (value == null || value === '') return null; + const textValue = String(value); + const buf = Buffer.from(textValue, 'utf8'); + if (buf.length <= maxBytes) return textValue; + return buf.subarray(0, maxBytes).toString('utf8'); +} + +function parseWorkbook(workbook, roster) { + const sheet = findSheet(workbook); + if (!sheet) throw new ApiError(API_CODES.BAD_PARAMS, '未找到工作表「基础信息」', 400); + + const headerRow = sheet.getRow(1); + const headerMap = buildHeaderMap(headerRow); + const cols = {}; + for (const [field, aliases] of Object.entries(HEADER_ALIASES)) { + cols[field] = colOf(headerMap, aliases); + } + if (!cols.employeeId || !cols.name) { + throw new ApiError(API_CODES.BAD_PARAMS, '表头缺少「工号」或「姓名」', 400); + } + + const updates = []; + const newHires = []; // 文件有、花名册无、岗位非主任办事员 → 自动 INSERT 完整字段 + const missingClerks = []; // 文件有、花名册无、岗位=主任办事员 → 默认跳过,提交时弹窗确认 + const skippedRemarks = []; + const unmatched = []; // 工号命中但姓名冲突 → 硬阻断 + const idsInFile = new Set(); + // Excel 内工号重复检测:第 2 次出现的行记入 duplicateIds,不进 updates/newHires(避免 UNIQUE KEY 冲突) + const duplicateIds = []; + const seenEmployeeRows = new Map(); // employee_id → 第一次出现的 sourceRow + let halted = false; + let haltedAtRow = null; + + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { + const employeeId = normalizeEmployeeId(cellRaw(sheet, rowNumber, cols.employeeId)); + const name = cellText(sheet, rowNumber, cols.name); + const remarks = cellText(sheet, rowNumber, cols.remarks).trim(); + + if (!employeeId && !name && !remarks) { + const probe = cellText(sheet, rowNumber, cols.mobile) + || cellText(sheet, rowNumber, cols.idCard) + || cellText(sheet, rowNumber, cols.address); + if (!probe) continue; + } + if (!employeeId && !name) continue; + + // Excel 内工号重复:第二次出现计入 duplicateIds,不进任何 push(避免 INSERT 时 UNIQUE KEY 冲突) + if (employeeId) { + if (seenEmployeeRows.has(employeeId)) { + const firstRow = seenEmployeeRows.get(employeeId); + duplicateIds.push({ + rowId: `dup-${rowNumber}`, + sourceRow: rowNumber, + employeeId, + name: name || null, + reason: `工号 ${employeeId} 在第 ${firstRow} 行已出现(Excel 内重复),跳过本次导入`, + }); + continue; + } + seenEmployeeRows.set(employeeId, rowNumber); + idsInFile.add(employeeId); + } + + const rosterRow = employeeId ? roster.get(employeeId) : null; + + // 先读位置/班组用于分流 + const position = cellText(sheet, rowNumber, cols.position); + const team = cellText(sheet, rowNumber, cols.team); + const internalEmployeeId = cellText(sheet, rowNumber, cols.internalEmployeeId); + + // 文件有、花名册无:按岗位分流(主任办事员默认跳过、其他自动新增) + if (employeeId && !rosterRow) { + if (position === '主任办事员') { + const hireDate = parseHireDate(cellRaw(sheet, rowNumber, cols.hireDate)); + const totalKilometers = parseKilometers(cellRaw(sheet, rowNumber, cols.totalKilometers)); + const emergencyContactName = cellText(sheet, rowNumber, cols.emergencyContactName); + const emergencyContactPhone = cellText(sheet, rowNumber, cols.emergencyContactPhone); + const emergencyContactRelation = cellText(sheet, rowNumber, cols.emergencyContactRelation); + missingClerks.push({ + rowId: `clerk-${rowNumber}`, + sourceRow: rowNumber, + employeeId, + name: name || null, + position, + reason: `主任办事员 ${employeeId} 未在花名册中(默认跳过,提交时可确认一并新增)`, + // 提交可选 INSERT 时用完整字段(与 newHires 同结构) + employee_id: employeeId, + internal_employee_id: internalEmployeeId || null, + team: team || null, + employment_status_value: employmentStatusValue(remarks), + mobile: cellText(sheet, rowNumber, cols.mobile) || null, + id_card: cellText(sheet, rowNumber, cols.idCard) || null, + address: cellText(sheet, rowNumber, cols.address) || null, + native_place: cellText(sheet, rowNumber, cols.nativePlace) || null, + nationality: cellText(sheet, rowNumber, cols.nationality) || null, + political_status: cellText(sheet, rowNumber, cols.politicalStatus) || null, + education: cellText(sheet, rowNumber, cols.education) || null, + education_type: cellText(sheet, rowNumber, cols.educationType) || null, + graduate_school: cellText(sheet, rowNumber, cols.graduateSchool) || null, + hire_date: hireDate, + emergency_contact: [emergencyContactName, emergencyContactRelation, emergencyContactPhone] + .filter(Boolean).join('|') || null, + details: { + cardNumber: cellText(sheet, rowNumber, cols.cardNumber) || '', + totalKilometers: totalKilometers == null ? '' : totalKilometers, + emergencyContactName: emergencyContactName || '', + emergencyContactPhone: emergencyContactPhone || '', + emergencyContactRelation: emergencyContactRelation || '', + personnelRosterImportedAt: new Date().toISOString(), + personnelRosterSource: SOURCE_TAG, + }, + }); + } else { + // 完整字段收集,commit 时走 INSERT + const hireDate = parseHireDate(cellRaw(sheet, rowNumber, cols.hireDate)); + const totalKilometers = parseKilometers(cellRaw(sheet, rowNumber, cols.totalKilometers)); + const emergencyContactName = cellText(sheet, rowNumber, cols.emergencyContactName); + const emergencyContactPhone = cellText(sheet, rowNumber, cols.emergencyContactPhone); + const emergencyContactRelation = cellText(sheet, rowNumber, cols.emergencyContactRelation); + newHires.push({ + sourceRow: rowNumber, + employee_id: employeeId, + internal_employee_id: internalEmployeeId || null, + name: name || '', + position: position || null, + team: team || null, + employment_status_value: employmentStatusValue(remarks), + mobile: cellText(sheet, rowNumber, cols.mobile) || null, + id_card: cellText(sheet, rowNumber, cols.idCard) || null, + address: cellText(sheet, rowNumber, cols.address) || null, + native_place: cellText(sheet, rowNumber, cols.nativePlace) || null, + nationality: cellText(sheet, rowNumber, cols.nationality) || null, + political_status: cellText(sheet, rowNumber, cols.politicalStatus) || null, + education: cellText(sheet, rowNumber, cols.education) || null, + education_type: cellText(sheet, rowNumber, cols.educationType) || null, + graduate_school: cellText(sheet, rowNumber, cols.graduateSchool) || null, + hire_date: hireDate, + emergency_contact: [emergencyContactName, emergencyContactRelation, emergencyContactPhone] + .filter(Boolean).join('|') || null, + details: { + cardNumber: cellText(sheet, rowNumber, cols.cardNumber) || '', + totalKilometers: totalKilometers == null ? '' : totalKilometers, + emergencyContactName: emergencyContactName || '', + emergencyContactPhone: emergencyContactPhone || '', + emergencyContactRelation: emergencyContactRelation || '', + personnelRosterImportedAt: new Date().toISOString(), + personnelRosterSource: SOURCE_TAG, + }, + }); + } + continue; + } + + if (!employeeId || !name || !rosterRow || !namesCompatible(name, rosterRow.name)) { + if (!halted) { + halted = true; + haltedAtRow = rowNumber; + } + unmatched.push({ + sourceRow: rowNumber, + employeeId: employeeId || null, + name: name || null, + rosterName: rosterRow?.name || null, + reason: !employeeId + ? '工号为空' + : (!name + ? '姓名为空' + : (!rosterRow + ? `工号 ${employeeId} 未在花名册中` + : `姓名不符(Excel「${name}」/ 花名册「${rosterRow.name}」)`)), + }); + continue; + } + + const hireDate = parseHireDate(cellRaw(sheet, rowNumber, cols.hireDate)); + const totalKilometers = parseKilometers(cellRaw(sheet, rowNumber, cols.totalKilometers)); + const mobile = cellText(sheet, rowNumber, cols.mobile); + const idCard = cellText(sheet, rowNumber, cols.idCard); + const address = cellText(sheet, rowNumber, cols.address); + const emergencyContactName = cellText(sheet, rowNumber, cols.emergencyContactName); + const emergencyContactPhone = cellText(sheet, rowNumber, cols.emergencyContactPhone); + const emergencyContactRelation = cellText(sheet, rowNumber, cols.emergencyContactRelation); + + updates.push({ + sourceRow: rowNumber, + employee_id: rosterRow.employee_id, + name: rosterRow.name, + internal_employee_id: internalEmployeeId || null, + position: position || null, + team: team || null, + employment_status_value: employmentStatusValue(remarks), + mobile: mobile || null, + id_card: idCard || null, + address: address || null, + native_place: cellText(sheet, rowNumber, cols.nativePlace) || null, + nationality: cellText(sheet, rowNumber, cols.nationality) || null, + political_status: cellText(sheet, rowNumber, cols.politicalStatus) || null, + education: cellText(sheet, rowNumber, cols.education) || null, + education_type: cellText(sheet, rowNumber, cols.educationType) || null, + graduate_school: cellText(sheet, rowNumber, cols.graduateSchool) || null, + hire_date: hireDate, + emergency_contact: [emergencyContactName, emergencyContactRelation, emergencyContactPhone] + .filter(Boolean).join('|') || null, + details: { + cardNumber: cellText(sheet, rowNumber, cols.cardNumber) || '', + totalKilometers: totalKilometers == null ? '' : totalKilometers, + emergencyContactName: emergencyContactName || '', + emergencyContactPhone: emergencyContactPhone || '', + emergencyContactRelation: emergencyContactRelation || '', + personnelRosterImportedAt: new Date().toISOString(), + personnelRosterSource: SOURCE_TAG, + }, + }); + } + + // 花名册有、本次文件无(仅统计未软删;软删的不算「应出现在文件中」) + const missingFromImport = []; + for (const [employeeId, entry] of roster.entries()) { + if (entry.softDeleted) continue; + if (idsInFile.has(employeeId)) continue; + missingFromImport.push({ + rowId: `roster-${employeeId}`, + employeeId, + name: entry.name, + team: entry.team || null, + reason: '花名册有此人,本次导入文件未出现', + }); + } + missingFromImport.sort((a, b) => String(a.employeeId).localeCompare(String(b.employeeId), 'zh')); + + return { + sheetName: sheet.name, + updates, + newHires, + missingClerks, + duplicateIds, + skippedRemarks, + missingFromImport, + unmatched, + halted, + haltedAtRow, + }; +} + +async function preview(file) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file.buffer); + const roster = await loadRoster(); + const parsed = parseWorkbook(workbook, roster); + const importId = randomUUID(); + const hasNameConflict = parsed.unmatched.length > 0; + const needsConfirmMissing = parsed.missingFromImport.length > 0; + // 服务端:无姓名硬冲突且有可更新行或可新增行即可进入确认流;两侧差异需前端确认后才能 commit + const canCommit = !hasNameConflict && (parsed.updates.length > 0 || parsed.newHires.length > 0); + + const payload = { + importId, + fileName: file.originalname, + fileHash: sha256(file.buffer), + fileSize: file.size, + sheetName: parsed.sheetName, + updates: parsed.updates, + newHires: parsed.newHires, + missingClerks: parsed.missingClerks, + duplicateIds: parsed.duplicateIds, + skippedRemarks: parsed.skippedRemarks, + missingFromImport: parsed.missingFromImport, + unmatched: parsed.unmatched, + halted: parsed.halted, + haltedAtRow: parsed.haltedAtRow, + }; + await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS); + + let blockReason = null; + if (hasNameConflict) { + blockReason = `匹配已暂停:有 ${parsed.unmatched.length} 条姓名/工号冲突` + + (parsed.haltedAtRow ? `(自首个冲突行第 ${parsed.haltedAtRow} 行起)` : '') + + '。请修正后重试。'; + } else if (parsed.updates.length === 0 && parsed.newHires.length === 0) { + blockReason = '没有可更新或可新增的记录'; + } else if (needsConfirmMissing) { + blockReason = '存在花名册与文件差异,请确认后再提交'; + } + + const nonActiveCount = parsed.updates.filter((u) => u.employment_status_value !== '在职').length + + parsed.newHires.filter((u) => u.employment_status_value !== '在职').length; + + return { + importId, + fileName: file.originalname, + sheetName: parsed.sheetName, + summary: { + matched: parsed.updates.length, + newHires: parsed.newHires.length, + missingClerks: parsed.missingClerks.length, + duplicateIds: parsed.duplicateIds.length, + skippedRemarks: parsed.skippedRemarks.length, + nonActive: nonActiveCount, + missingFromImport: parsed.missingFromImport.length, + unmatched: parsed.unmatched.length, + halted: parsed.halted, + haltedAtRow: parsed.haltedAtRow, + needsConfirmMissing, + }, + updates: parsed.updates.slice(0, 30).map((row) => ({ + sourceRow: row.sourceRow, + employeeId: row.employee_id, + name: row.name, + mobile: row.mobile, + nativePlace: row.native_place, + education: row.education, + hireDate: row.hire_date, + cardNumber: row.details.cardNumber, + totalKilometers: row.details.totalKilometers, + })), + newHires: parsed.newHires.slice(0, 50).map((row) => ({ + sourceRow: row.sourceRow, + employeeId: row.employee_id, + name: row.name, + position: row.position, + team: row.team, + mobile: row.mobile, + hireDate: row.hire_date, + })), + missingClerks: parsed.missingClerks.slice(0, 200), + duplicateIds: parsed.duplicateIds.slice(0, 200), + missingFromImport: parsed.missingFromImport.slice(0, 200), + unmatched: parsed.unmatched.slice(0, 200), + canCommit, + blockReason, + }; +} + +async function commit({ importId, selections = {}, operator, meta }) { + const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`); + if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '人事月报预览已过期,请重新选择文件', 404); + const payload = JSON.parse(raw); + if (payload.unmatched && payload.unmatched.length > 0) { + throw new ApiError( + API_CODES.BAD_PARAMS, + `存在 ${payload.unmatched.length} 条姓名冲突,禁止提交`, + 400, + ); + } + if (!Array.isArray(payload.updates) || payload.updates.length === 0) { + if (!Array.isArray(payload.newHires) || payload.newHires.length === 0) { + throw new ApiError(API_CODES.BAD_PARAMS, '没有可更新或可新增的记录', 400); + } + } + // 客户端弹窗确认后提交;此处只记录,不再二次拦截(避免 preload 未热更新时 selections 传丢误拒) + const confirmedMissing = selections.confirmMissingInFile === true + || selections.confirmMissingInFile === 1 + || selections.confirmMissingInFile === 'true'; + // 主任办事员(missingClerks)默认跳过;用户在客户端勾选"也新增主任办事员"后才 INSERT + const insertClerks = selections.confirmInsertClerks === true + || selections.confirmInsertClerks === 1 + || selections.confirmInsertClerks === 'true'; + if ((payload.missingFromImport || []).length > 0 && !confirmedMissing) { + console.warn( + '[personnel-roster] commit without confirmMissingInFile; proceeding after client dialog', + { importId, missingFromImport: payload.missingFromImport.length }, + ); + } + + // 合并 INSERT 候选:newHires(非主任办事员,必 INSERT) + 可选 missingClerks + const inserts = [ + ...(payload.newHires || []), + ...(insertClerks ? (payload.missingClerks || []) : []), + ].map(normalizeInsertRow); + + const batchUuid = randomUUID(); + let updated = 0; + let inserted = 0; + const operatorId = Number(operator?.id); + if (!Number.isFinite(operatorId)) { + throw new ApiError(API_CODES.UNAUTHENTICATED, '登录用户无效,请重新登录后再导入', 401); + } + + await db.transaction(async (conn) => { + await conn.execute( + `INSERT INTO upload_batches + (batch_uuid, module, data_type, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at) + VALUES (?, 'personnel_roster', 'xlsx', ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`, + sqlParams('upload_batches', [ + batchUuid, + sqlVal(payload.fileName), + sqlVal(payload.fileHash), + sqlVal(payload.fileSize), + (payload.updates?.length || 0) + inserts.length, + JSON.stringify({ + source: SOURCE_TAG, + newHires: (payload.newHires || []).length, + missingClerks: (payload.missingClerks || []).length, + missingFromImport: (payload.missingFromImport || []).length, + insertClerks, + confirmed: selections, + }), + operatorId, + ]), + ); + + for (const row of payload.updates || []) { + const idCardEnc = row.id_card ? crypto.encrypt(truncatePlain(row.id_card, 400)) : null; + const idCardHmac = row.id_card ? crypto.hmac(String(row.id_card)) : null; + const mobileEnc = row.mobile ? crypto.encrypt(truncatePlain(row.mobile, 400)) : null; + const mobileHmac = row.mobile ? crypto.hmac(String(row.mobile)) : null; + const addressEnc = row.address ? crypto.encrypt(truncatePlain(row.address, 900)) : null; + const emergencyEnc = row.emergency_contact + ? crypto.encrypt(truncatePlain(row.emergency_contact, 900)) + : null; + + const updateParams = sqlParams(`update:${row.employee_id || row.sourceRow}`, [ + sqlVal(row.native_place), + sqlVal(row.nationality), + sqlVal(row.political_status), + sqlVal(row.education), + sqlVal(row.education_type), + sqlVal(row.graduate_school), + sqlVal(row.hire_date), + idCardEnc, + idCardHmac, + mobileEnc, + mobileHmac, + addressEnc, + emergencyEnc, + employmentStatusValue(row.employment_status_value), + sqlVal(row.internal_employee_id || null), + JSON.stringify(row.details || {}), + sqlVal(row.employee_id), + ]); + + const [result] = await conn.execute( + `UPDATE employees SET + native_place = ?, + nationality = ?, + political_status = ?, + education = ?, + education_type = ?, + graduate_school = ?, + hire_date = ?, + id_card_enc = ?, + id_card_hmac = ?, + mobile_enc = ?, + mobile_hmac = ?, + address_enc = ?, + emergency_contact_enc = ?, + employment_status = ?, + internal_employee_id = COALESCE(?, internal_employee_id), + status = 'active', + deleted_at = NULL, + details = JSON_MERGE_PATCH(COALESCE(details, JSON_OBJECT()), ?), + version = version + 1, + updated_at = NOW() + WHERE employee_id = ?`, + updateParams, + ); + if (result.affectedRows > 0) updated += 1; + } + + // 新增员工 INSERT(已存在/软删则 upsert 复活,避免 uk_employee_id 500) + for (const row of inserts) { + const idCardEnc = row.id_card ? crypto.encrypt(truncatePlain(row.id_card, 400)) : null; + const idCardHmac = row.id_card ? crypto.hmac(String(row.id_card)) : null; + const mobileEnc = row.mobile ? crypto.encrypt(truncatePlain(row.mobile, 400)) : null; + const mobileHmac = row.mobile ? crypto.hmac(String(row.mobile)) : null; + const addressEnc = row.address ? crypto.encrypt(truncatePlain(row.address, 900)) : null; + const emergencyEnc = row.emergency_contact + ? crypto.encrypt(truncatePlain(row.emergency_contact, 900)) + : null; + + const insertParams = sqlParams(`insert:${row.employee_id || row.sourceRow}`, [ + sqlVal(row.employee_id), + sqlVal(row.internal_employee_id || null), + sqlVal(row.name), + sqlVal(row.team || null), + sqlVal(row.position || null), + sqlVal(row.native_place || null), + sqlVal(row.nationality || null), + sqlVal(row.political_status || null), + sqlVal(row.education || null), + sqlVal(row.education_type || null), + sqlVal(row.graduate_school || null), + sqlVal(row.hire_date || null), + idCardEnc, + idCardHmac, + mobileEnc, + mobileHmac, + addressEnc, + emergencyEnc, + employmentStatusValue(row.employment_status_value), + SOURCE_TAG, + operatorId, + JSON.stringify(row.details || {}), + ]); + + const [result] = await conn.execute( + `INSERT INTO employees ( + employee_id, internal_employee_id, name, team, position, + native_place, nationality, political_status, + education, education_type, graduate_school, + hire_date, + id_card_enc, id_card_hmac, + mobile_enc, mobile_hmac, + address_enc, emergency_contact_enc, + status, employment_status, source, created_by, details, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL) + ON DUPLICATE KEY UPDATE + internal_employee_id = COALESCE(VALUES(internal_employee_id), internal_employee_id), + name = VALUES(name), + team = COALESCE(team, VALUES(team)), + position = COALESCE(position, VALUES(position)), + native_place = VALUES(native_place), + nationality = VALUES(nationality), + political_status = VALUES(political_status), + education = VALUES(education), + education_type = VALUES(education_type), + graduate_school = VALUES(graduate_school), + hire_date = VALUES(hire_date), + id_card_enc = VALUES(id_card_enc), + id_card_hmac = VALUES(id_card_hmac), + mobile_enc = VALUES(mobile_enc), + mobile_hmac = VALUES(mobile_hmac), + address_enc = VALUES(address_enc), + emergency_contact_enc = VALUES(emergency_contact_enc), + status = 'active', + employment_status = VALUES(employment_status), + source = VALUES(source), + details = VALUES(details), + deleted_at = NULL, + version = version + 1, + updated_at = NOW()`, + insertParams, + ); + if (result.affectedRows > 0) inserted += 1; + } + + await conn.execute( + `UPDATE upload_batches SET status='completed', completed_at=NOW(), payload=? WHERE batch_uuid=?`, + sqlParams('upload_batches_complete', [ + JSON.stringify({ + updated, + inserted, + requested: (payload.updates?.length || 0) + inserts.length, + }), + batchUuid, + ]), + ); + }).catch((err) => { + const detail = err?.sqlMessage || err?.message || String(err); + console.error('[personnel-roster] commit failed:', detail); + throw new ApiError(API_CODES.SERVER_ERROR, `人事月报写入失败:${detail}`, 500); + }); + + // 主数据已提交:后续副作用失败不应再让客户端以为整单失败 + try { + await sync.bumpVersions(['employees']); + } catch (err) { + console.error('[personnel-roster] bumpVersions failed:', err?.message || err); + } + try { + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'personnel_roster_import', + module: 'imports', + targetType: 'upload_batch', + targetId: batchUuid, + payload: { + fileName: payload.fileName, + fileHash: payload.fileHash, + updated, + inserted, + requested: (payload.updates?.length || 0) + inserts.length, + skippedRemarks: payload.skippedRemarks?.length || 0, + nonActive: (payload.updates || []).filter((u) => u.employment_status_value !== '在职').length + + (payload.newHires || []).filter((u) => u.employment_status_value !== '在职').length, + newHires: (payload.newHires || []).length, + missingClerks: (payload.missingClerks || []).length, + clerksInserted: insertClerks ? (payload.missingClerks || []).length : 0, + missingFromImport: payload.missingFromImport?.length || 0, + selections, + }, + ip: meta?.ip, + userAgent: meta?.userAgent, + }); + } catch (err) { + console.error('[personnel-roster] audit.log failed:', err?.message || err); + } + try { + await redis.client.del(`${PREVIEW_PREFIX}${importId}`); + } catch (err) { + console.error('[personnel-roster] redis del failed:', err?.message || err); + } + + return { + batchUuid, + counts: { + updated, + inserted, + requested: (payload.updates?.length || 0) + inserts.length, + skippedRemarks: payload.skippedRemarks?.length || 0, + nonActive: (payload.updates || []).filter((u) => u.employment_status_value !== '在职').length + + (payload.newHires || []).filter((u) => u.employment_status_value !== '在职').length, + newHires: (payload.newHires || []).length, + missingClerks: (payload.missingClerks || []).length, + clerksInserted: insertClerks ? (payload.missingClerks || []).length : 0, + missingFromImport: payload.missingFromImport?.length || 0, + }, + }; +} + +module.exports = { preview, commit }; diff --git a/server/src/import/question-bank.js b/server/src/import/question-bank.js new file mode 100644 index 0000000..d3b3703 --- /dev/null +++ b/server/src/import/question-bank.js @@ -0,0 +1,208 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const ExcelJS = require('exceljs'); +const redis = require('../infra/redis'); +const uploadService = require('../upload/upload'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const PREVIEW_TTL_SECONDS = 30 * 60; +const PREVIEW_PREFIX = 'question-bank-import:'; +const REQUIRED_HEADERS = ['题型', '题目', '选项1', '选项2', '选项3', '选项4', '选项5', '正确答案', '答案解析']; +const REQUIRED_VALUES = ['题型', '题目', '选项1', '选项2', '正确答案', '答案解析']; + +function valueOf(cell) { + const value = cell?.value; + if (value == null) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value !== 'object') return value; + if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result }); + if (value.error) return value.error; + if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join(''); + if (value.text != null) return value.text; + return String(value); +} + +function text(value) { + return String(value ?? '').trim(); +} + +function normalizePunctuation(value) { + const punctuation = { + ',': ',', '。': '.', ';': ';', ':': ':', '?': '?', '!': '!', + '(': '(', ')': ')', '【': '[', '】': ']', '〔': '[', '〕': ']', + '{': '{', '}': '}', '《': '<', '》': '>', '〈': '<', '〉': '>', + '“': '"', '”': '"', '‘': "'", '’': "'", '「': '"', '」': '"', + '『': '"', '』': '"', '、': ',', '…': '...', '—': '-', '–': '-', + '-': '-', '~': '~', '·': '.', '/': '/', '\': '\\', '|': '|', + '﹐': ',', '﹑': ',', '﹒': '.', '﹔': ';', '﹕': ':', '﹖': '?', + '﹗': '!', '﹙': '(', '﹚': ')', '﹛': '{', '﹜': '}', '﹝': '[', + '﹞': ']', '﹤': '<', '﹥': '>', '﹣': '-', '﹏': '_', '〖': '[', + '〗': ']', '〝': '"', '〞': '"', + }; + return text(value) + .replace(/[\uFF01-\uFF5E]/g, (character) => + String.fromCharCode(character.charCodeAt(0) - 0xFEE0) + ) + .replace(/\u3000/g, ' ') + .replace(/[,。;:?!()【】〔〕{}《》〈〉“”‘’「」『』、…—–-~·/\|﹐﹑﹒﹔﹕﹖﹗﹙﹚﹛﹜﹝﹞﹤﹥﹣﹏〖〗〝〞]/g, (character) => + punctuation[character] || character + ); +} + +function headerMap(sheet) { + const headers = new Map(); + sheet.getRow(1).eachCell((cell, column) => { + const name = text(valueOf(cell)); + if (name) headers.set(name, column); + }); + return headers; +} + +function findQuestionSheet(workbook) { + return workbook.worksheets.find((sheet) => { + const headers = headerMap(sheet); + return REQUIRED_HEADERS.every((header) => headers.has(header)); + }); +} + +function countBy(rows, key) { + const counts = {}; + for (const row of rows) { + const value = row[key] || '未分类'; + counts[value] = (counts[value] || 0) + 1; + } + return counts; +} + +async function preview(file) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file.buffer); + const sheet = findQuestionSheet(workbook); + if (!sheet) { + throw new ApiError( + API_CODES.BAD_PARAMS, + `未找到包含以下列的工作表:${REQUIRED_HEADERS.join('、')}`, + 400, + ); + } + + const headers = headerMap(sheet); + const errors = []; + const rows = []; + const importBatch = randomUUID(); + const importedAt = new Date().toISOString(); + let normalizedFields = 0; + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { + const sourceValues = {}; + const values = {}; + for (const header of ['题型', '题目', '选项1', '选项2', '选项3', '选项4', '选项5', '正确答案', '答案解析']) { + const column = headers.get(header); + sourceValues[header] = column ? text(valueOf(sheet.getCell(rowNumber, column))) : ''; + // 去掉单元格内换行/制表,避免同题因 Excel 自动换行导致题干对不上 + values[header] = normalizePunctuation(sourceValues[header]).replace(/[\r\n\t]+/g, ''); + if (values[header] !== sourceValues[header]) normalizedFields += 1; + } + if (!Object.values(values).some(Boolean)) continue; + const missing = REQUIRED_VALUES.filter((header) => !values[header]); + if (missing.length) { + errors.push({ sheet: sheet.name, row: rowNumber, message: `缺少必填列数据:${missing.join('、')}` }); + continue; + } + + const options = { + A: values['选项1'], + B: values['选项2'], + C: values['选项3'], + D: values['选项4'], + E: values['选项5'], + }; + rows.push({ + category: values['答案解析'], + sub_category: null, + question_type: values['题型'], + difficulty: null, + position: '列车司机', + content: { + question: values['题目'], + options, + optionA: options.A, + optionB: options.B, + optionC: options.C, + optionD: options.D, + optionE: options.E, + correctAnswer: values['正确答案'], + answer: values['正确答案'], + questionCategory: values['答案解析'], + }, + source_file: file.originalname, + import_batch: importBatch, + details: { + importTime: importedAt, + fileName: file.originalname, + source: '问卷星', + sourceRow: rowNumber, + questionCategory: values['答案解析'], + }, + extras: { sourceHeaders: sourceValues, normalizedPunctuation: true }, + status: 'active', + }); + } + + const importId = randomUUID(); + const payload = { + importId, + fileName: file.originalname, + rows, + errors, + importBatch, + }; + await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS); + return { + importId, + fileName: file.originalname, + sheetName: sheet.name, + summary: { + questions: rows.length, + validationErrors: errors.length, + normalizedFields, + questionTypes: countBy(rows, 'question_type'), + categories: countBy(rows, 'category'), + }, + errors: errors.slice(0, 500), + sample: rows.slice(0, 20).map((row) => ({ + questionType: row.question_type, + question: row.content.question, + optionA: row.content.optionA, + optionB: row.content.optionB, + correctAnswer: row.content.correctAnswer, + questionCategory: row.category, + })), + canCommit: errors.length === 0 && rows.length > 0, + }; +} + +async function commit({ importId, operator, meta }) { + const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`); + if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '题库导入预览已过期,请重新选择文件', 404); + const payload = JSON.parse(raw); + if (payload.errors.length) { + throw new ApiError(API_CODES.BAD_PARAMS, `存在 ${payload.errors.length} 条完整性错误,禁止提交`, 400); + } + if (!payload.rows.length) throw new ApiError(API_CODES.BAD_PARAMS, '题库没有可导入的数据', 400); + + const result = await uploadService.upload({ + module: 'question_banks', + rows: payload.rows, + operator, + meta, + }); + await redis.client.del(`${PREVIEW_PREFIX}${importId}`); + return { + ...result, + fileName: payload.fileName, + questionCount: payload.rows.length, + }; +} + +module.exports = { preview, commit }; diff --git a/server/src/infra/db.js b/server/src/infra/db.js new file mode 100644 index 0000000..13bf541 --- /dev/null +++ b/server/src/infra/db.js @@ -0,0 +1,62 @@ +'use strict'; + +const mysql = require('mysql2/promise'); +const config = require('../config'); + +// mysql2 连接池(migration-plan §11:API 与 MySQL 同机回环) +const pool = mysql.createPool({ + uri: config.databaseUrl, + connectionLimit: 10, + namedPlaceholders: true, + timezone: '+08:00', + charset: 'utf8mb4', + decimalNumbers: true, // DECIMAL 返回为 number 而非字符串 +}); + +/** + * 执行查询,返回行数组。 + * @param {string} sql + * @param {any[]|Object} [params] + * @returns {Promise} + */ +async function query(sql, params) { + const [rows] = await pool.execute(sql, params); + return rows; +} + +/** + * 执行写操作,返回 { affectedRows, insertId }。 + * @param {string} sql + * @param {any[]|Object} [params] + */ +async function execute(sql, params) { + const [result] = await pool.execute(sql, params); + return result; +} + +/** + * 事务包装:cb 收到一个 connection,抛错自动回滚。 + * @template T + * @param {(conn: import('mysql2/promise').PoolConnection) => Promise} cb + * @returns {Promise} + */ +async function transaction(cb) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const result = await cb(conn); + await conn.commit(); + return result; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} + +async function close() { + await pool.end(); +} + +module.exports = { pool, query, execute, transaction, close }; diff --git a/server/src/infra/redis.js b/server/src/infra/redis.js new file mode 100644 index 0000000..2282f5b --- /dev/null +++ b/server/src/infra/redis.js @@ -0,0 +1,21 @@ +'use strict'; + +const Redis = require('ioredis'); +const config = require('../config'); + +// 权限缓存 / refresh token 存储 / 队列(migration-plan §10.5) +const client = new Redis(config.redisUrl, { + maxRetriesPerRequest: 2, + lazyConnect: false, +}); + +client.on('error', (err) => { + // 不抛出:RBAC 降级直查 MySQL(§14 风险表:Redis 同机宕机) + console.error('[redis] error:', err.message); +}); + +async function close() { + await client.quit(); +} + +module.exports = { client, close }; diff --git a/server/src/main.js b/server/src/main.js new file mode 100644 index 0000000..179a517 --- /dev/null +++ b/server/src/main.js @@ -0,0 +1,39 @@ +'use strict'; + +const config = require('./config'); +const { createApp } = require('./app'); +const db = require('./infra/db'); +const redis = require('./infra/redis'); +const modules = require('./modules/modules'); +const precompute = require('./precompute'); + +async function bootstrap() { + // 1. 注册预计算器 + precompute.registerAll(); + await require('./precompute/crew-metrics').ensureSchema(); + + // 2. 启动一致性校验(注册表 SSOT + modules 表对齐,§5.4) + await modules.syncModulesOnBoot(); + + // 3. 起 API 服务;导出 Worker 以独立进程运行,避免阻塞 HTTP 请求。 + const app = createApp(); + const server = app.listen(config.port, '127.0.0.1', () => { + console.log(`[server] listening on http://127.0.0.1:${config.port}/api/v1`); + }); + + // 4. 优雅退出 + const shutdown = async (sig) => { + console.log(`[server] ${sig} received, shutting down...`); + server.close(); + await db.close().catch(() => {}); + await redis.close().catch(() => {}); + process.exit(0); + }; + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +bootstrap().catch((err) => { + console.error('[server] bootstrap failed:', err); + process.exit(1); +}); diff --git a/server/src/maintenance.js b/server/src/maintenance.js new file mode 100644 index 0000000..a6a0c44 --- /dev/null +++ b/server/src/maintenance.js @@ -0,0 +1,134 @@ +'use strict'; + +const config = require('./config'); +const db = require('./infra/db'); +const sync = require('./sync/sync'); +const audit = require('./audit/audit'); +const precomputeService = require('./precompute/service'); +const dashboardSummary = require('./precompute/dashboard-summary'); +const { ApiError, API_CODES } = require('./common/api-codes'); + +const DEFINITIONS = Object.freeze({ + employees: { + label: '员工档案', + tables: ['employees'], + recompute: ['kilometers', 'online_exams', 'practical_exams'], + }, + online_exams: { + label: '在线考试', + tables: ['online_exams'], + stats: ['online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly'], + recompute: ['online_exams'], + }, + missing_exams: { + label: '缺考记录', + tables: ['missing_exams'], + stats: ['online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly'], + recompute: ['online_exams'], + }, + practical_exams: { + label: '实操考试', + tables: ['practical_exam_steps', 'practical_exams'], + stats: ['practical_exam_stats_monthly', 'practical_exam_step_stats_monthly', 'practical_exam_stats_yearly'], + recompute: ['practical_exams'], + }, + practical_standards: { + label: '实操试卷', + tables: ['paper_steps', 'papers'], + }, + kilometers: { + label: '里程记录', + tables: ['kilometers_records'], + stats: [ + 'kilometers_stats_employee_monthly', 'kilometers_stats_team_monthly', + 'kilometers_stats_employee_yearly', 'kilometers_stats_team_yearly', + ], + recompute: ['kilometers'], + }, + question_banks: { + label: '在线题库', + tables: ['question_banks'], + }, + performance_assessments: { + label: '绩效考核', + tables: ['performance_assessments'], + }, + fault_disposals: { + label: '故障处置', + tables: ['fault_disposals'], + }, + incident_events: { + label: '事故事件', + tables: ['incident_events'], + }, + audit_logs: { + label: '操作日志', + tables: ['audit_logs'], + }, +}); + +function isDeveloper(user) { + return Boolean(user?.username && config.developerUsers.has(user.username)); +} + +function requireDeveloper(req, _res, next) { + if (!isDeveloper(req.user)) { + return next(new ApiError(API_CODES.FORBIDDEN, '仅开发者可以执行数据写入与维护操作', 403)); + } + return next(); +} + +async function listTables() { + const versions = await db.query('SELECT table_name, updated_at FROM sync_table_versions'); + const updatedByTable = new Map(versions.map((row) => [row.table_name, row.updated_at])); + const result = []; + for (const [key, definition] of Object.entries(DEFINITIONS)) { + let rowCount = 0; + let updatedAt = null; + for (const table of definition.tables) { + const [count] = await db.query(`SELECT COUNT(*) AS total FROM \`${table}\``); + rowCount += Number(count.total); + const time = updatedByTable.get(table); + if (time && (!updatedAt || new Date(time) > new Date(updatedAt))) updatedAt = time; + } + result.push({ key, label: definition.label, tables: definition.tables, rowCount, updatedAt }); + } + return result; +} + +async function clearTable(key, operator, meta) { + const definition = DEFINITIONS[key]; + if (!definition) throw new ApiError(API_CODES.NOT_FOUND, '不允许清空该表', 404); + + let deletedRows = 0; + await db.transaction(async (conn) => { + for (const table of definition.tables) { + const [result] = await conn.execute(`DELETE FROM \`${table}\``); + deletedRows += Number(result.affectedRows) || 0; + } + for (const table of definition.stats || []) await conn.execute(`DELETE FROM \`${table}\``); + }); + + await sync.bumpVersions( + [...(definition.tables || []), ...(definition.stats || [])].filter((table) => sync.isSyncTable(table)), + ); + for (const module of definition.recompute || []) await precomputeService.recomputeAll(module); + await dashboardSummary.recompute(); + + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'developer_clear_table', + module: 'maintenance', + targetType: 'table_group', + targetId: key, + payload: { label: definition.label, tables: definition.tables, deletedRows }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { key, label: definition.label, deletedRows }; +} + +module.exports = { isDeveloper, requireDeveloper, listTables, clearTable }; diff --git a/server/src/maintenance.routes.js b/server/src/maintenance.routes.js new file mode 100644 index 0000000..8ace988 --- /dev/null +++ b/server/src/maintenance.routes.js @@ -0,0 +1,29 @@ +'use strict'; + +const express = require('express'); +const maintenance = require('./maintenance'); +const { requireAuth } = require('./auth/auth'); +const { ok, asyncHandler } = require('./common/http'); + +const router = express.Router(); + +router.use(requireAuth, maintenance.requireDeveloper); + +router.get( + '/tables', + asyncHandler(async (_req, res) => { + ok(res, await maintenance.listTables()); + }), +); + +router.delete( + '/tables/:key', + asyncHandler(async (req, res) => { + ok(res, await maintenance.clearTable(req.params.key, req.user, { + ip: req.ip, + userAgent: req.headers['user-agent'], + })); + }), +); + +module.exports = router; diff --git a/server/src/modules/modules.js b/server/src/modules/modules.js new file mode 100644 index 0000000..86ba440 --- /dev/null +++ b/server/src/modules/modules.js @@ -0,0 +1,61 @@ +'use strict'; + +const { MODULE_REGISTRY, validateRegistry } = require('@profile/shared'); +const db = require('../infra/db'); +const audit = require('../audit/audit'); + +/** + * 启动一致性校验(migration-plan §5.4)。 + * 在 app.listen 前调用一次。 + */ +async function syncModulesOnBoot() { + const errors = validateRegistry(); + if (errors.length > 0) { + throw new Error(`MODULE_REGISTRY invalid:\n${errors.join('\n')}`); + } + + const dbRows = await db.query('SELECT `key`, status FROM modules'); + const dbKeys = new Set(dbRows.map((r) => r.key)); + const codeKeys = new Set(MODULE_REGISTRY.map((m) => m.key)); + + // code 有但 DB 无 → 自动 INSERT + for (const m of MODULE_REGISTRY) { + if (!dbKeys.has(m.key)) { + await db.execute('INSERT INTO modules (`key`, status, notes) VALUES (?, ?, ?)', [m.key, 'active', m.label]); + console.log(`[modules] auto-registered: ${m.key}`); + await audit.log({ clientType: 'system', action: 'module_auto_register', module: m.key }); + } + } + + // DB 有但 code 无 → orphan 告警 + 审计,不阻塞 + const orphans = dbRows.filter((r) => !codeKeys.has(r.key)).map((r) => r.key); + if (orphans.length > 0) { + console.warn(`[modules] orphan in DB: ${orphans.join(',')}`); + await audit.log({ clientType: 'system', action: 'module_orphan_detected', payload: { orphans } }); + } +} + +/** + * 合并 code descriptor + DB status/override,过滤 disabled 与无 READ 权限的模块。 + * @param {Record} perms module -> 位掩码 + * @returns {Promise} + */ +async function getVisible(perms) { + const dbRows = await db.query('SELECT `key`, status, override FROM modules'); + const statusMap = new Map(dbRows.map((r) => [r.key, r])); + + return MODULE_REGISTRY.filter((m) => { + const row = statusMap.get(m.key); + if (row && row.status === 'disabled') return false; + return ((perms[m.rbacModule] || 0) & 1) === 1; // READ + }).map((m) => { + const row = statusMap.get(m.key); + let override = {}; + if (row && row.override) { + override = typeof row.override === 'string' ? JSON.parse(row.override) : row.override; + } + return { ...m, ...override }; + }); +} + +module.exports = { syncModulesOnBoot, getVisible }; diff --git a/server/src/modules/modules.routes.js b/server/src/modules/modules.routes.js new file mode 100644 index 0000000..3ba46d6 --- /dev/null +++ b/server/src/modules/modules.routes.js @@ -0,0 +1,20 @@ +'use strict'; + +const express = require('express'); +const modules = require('./modules'); +const rbac = require('../rbac/rbac'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); + +router.get( + '/', + requireAuth, + asyncHandler(async (req, res) => { + const perms = await rbac.getPermissions(req.user.id); + ok(res, await modules.getVisible(perms)); + }), +); + +module.exports = router; diff --git a/server/src/precompute/crew-metrics.js b/server/src/precompute/crew-metrics.js new file mode 100644 index 0000000..8a9d207 --- /dev/null +++ b/server/src/precompute/crew-metrics.js @@ -0,0 +1,954 @@ +'use strict'; + +const db = require('../infra/db'); +const sync = require('../sync/sync'); + +/** 与前端 export-employees.js 保持一致:六维相加为月画像分,累计取各月平均 */ +const FORMULA_VERSION = 'v3'; + +const BASE = { + km: 15, + performance: 25, + online: 15, + practical: 15, + fault: 10, + incident: 10, +}; + +let schemaReady = false; + +function normalizeEmployeeId(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + return raw.replace(/^0+/, '') || '0'; +} + +function padEmployeeId(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + if (/^\d+$/.test(raw) && raw.length <= 5) return raw.padStart(5, '0'); + return raw; +} + +function idVariants(employeeId) { + return [...new Set([ + String(employeeId || '').trim(), + padEmployeeId(employeeId), + normalizeEmployeeId(employeeId), + ].filter(Boolean))]; +} + +function parseEmployeeIds(value) { + if (Array.isArray(value)) { + return value.map((item) => normalizeEmployeeId(item)).filter(Boolean); + } + if (value == null || value === '') return []; + if (typeof value === 'string') { + const text = value.trim(); + if (!text) return []; + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + return parsed.map((item) => normalizeEmployeeId(item)).filter(Boolean); + } + } catch (_error) { /* ignore */ } + return text.split(/[,,;\s]+/).map((item) => normalizeEmployeeId(item)).filter(Boolean); + } + if (Buffer.isBuffer(value)) return parseEmployeeIds(value.toString('utf8')); + return [normalizeEmployeeId(value)].filter(Boolean); +} + +function isFaultSuccess(value) { + return String(value ?? '').includes('成功'); +} + +function hasIncidentIssue(value) { + const text = String(value ?? '').trim(); + if (!text) return false; + if (text === '/' || text === '-' || text === '无' || text === '无问题') return false; + return true; +} + +function drivePointsValue(value) { + const num = Number(value); + return Number.isFinite(num) ? num : 0; +} + +function round1(value) { + return Math.round(Number(value) * 10) / 10; +} + +function ymKey(year, month) { + return `${Number(year)}-${String(Number(month)).padStart(2, '0')}`; +} + +function parseYm(value) { + if (!value) return null; + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return { year: value.getFullYear(), month: value.getMonth() + 1 }; + } + const text = String(value).trim(); + const match = text.match(/(\d{4})\D+(\d{1,2})/); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + if (!Number.isFinite(year) || month < 1 || month > 12) return null; + return { year, month }; +} + +/** + * 业务月:本月 21 日~下月 20 日归入「下月」(跨年进次年 1 月)。 + * 仅用于绩效考核/嘉奖 assessment_date。 + * 例:2025-12-21 → 2026-01;2025-12-20 → 2025-12。 + */ +function calendarPartsShanghai(value) { + if (value == null || value === '') return null; + if (typeof value === 'string') { + const dateOnly = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (dateOnly) { + return { + year: Number(dateOnly[1]), + month: Number(dateOnly[2]), + day: Number(dateOnly[3]), + }; + } + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + const formatted = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); + const matched = formatted.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!matched) return null; + return { + year: Number(matched[1]), + month: Number(matched[2]), + day: Number(matched[3]), + }; +} + +function parseBusinessYm(value) { + const parts = calendarPartsShanghai(value); + if (!parts) return null; + let { year, month, day } = parts; + if (day >= 21) { + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + if (!Number.isFinite(year) || month < 1 || month > 12) return null; + return { year, month }; +} + +/** 自然月:按故障/事故发生日期的日历年月归桶(上海时区) */ +function parseCalendarYm(value) { + const parts = calendarPartsShanghai(value); + if (!parts) return parseYm(value); + return { year: parts.year, month: parts.month }; +} + +function monthRange(start, end) { + const out = []; + if (!start || !end) return out; + let y = start.year; + let m = start.month; + const endKey = ymKey(end.year, end.month); + while (ymKey(y, m) <= endKey) { + out.push({ year: y, month: m }); + m += 1; + if (m > 12) { m = 1; y += 1; } + } + return out; +} + +function earlierYm(a, b) { + if (!a) return b; + if (!b) return a; + return ymKey(a.year, a.month) <= ymKey(b.year, b.month) ? a : b; +} + +function extractRemarks(details) { + if (details == null || details === '') return ''; + let obj = details; + if (typeof details === 'string') { + try { obj = JSON.parse(details); } catch (_e) { return ''; } + } + if (Buffer.isBuffer(details)) { + try { obj = JSON.parse(details.toString('utf8')); } catch (_e) { return ''; } + } + return String(obj?.remarks || '').trim(); +} + +/** + * 备注含「组长」(含后备班组长、组长/施工负责人等)→ 公里分可按班组均值保底。 + * 仅写「施工负责人」、不含「组长」的不适用。 + */ +function usesTeamAvgKm(remarks) { + const text = String(remarks || '').trim(); + if (!text) return false; + return text.includes('组长'); +} + +function recalcTotalScore(metrics) { + return round1( + (Number(metrics.kmScore) || 0) + + (Number(metrics.performanceScore) || 0) + + (Number(metrics.onlineExam) || 0) + + (Number(metrics.practicalExam) || 0) + + (Number(metrics.faultScore) || 0) + + (Number(metrics.incidentScore) || 0), + ); +} + +function computeMonthScores(parts) { + const kmAnomalyCount = Number(parts.kmAnomalyCount) || 0; + // 无公里数据按 0 分;有数据则满分 15,异常一次扣 5 + const kmScore = parts.hasKm + ? Math.max(0, round1(BASE.km - 5 * kmAnomalyCount)) + : 0; + + const performanceDeduction = Number(parts.performanceDeduction) || 0; + const drivingDeduction = Number(parts.drivingDeduction) || 0; + const performanceScore = Math.max( + 0, + round1((BASE.performance - performanceDeduction) * (1 - 0.1 * drivingDeduction)), + ); + + // 有成绩:均分×0.15;缺考且无成绩:0;该月无考试记录:基础 15 + let onlineExam = BASE.online; + if (parts.hasOnline) { + onlineExam = Math.max(0, round1(Number(parts.onlineAvg) * 0.15)); + } else if (parts.hasMissingOnline) { + onlineExam = 0; + } + const practicalExam = parts.hasPractical + ? Math.max(0, round1(Number(parts.practicalAvg) * 0.15)) + : BASE.practical; + + let faultScore = BASE.fault; + const faultSuccessCount = Number(parts.faultSuccessCount) || 0; + const faultTotalCount = Number(parts.faultTotalCount) || 0; + if (faultTotalCount > 0) { + faultScore = Math.max(0, round1(BASE.fault + 2 * faultSuccessCount)); + } + + let incidentScore = BASE.incident; + const incidentOkCount = Number(parts.incidentOkCount) || 0; + const incidentIssueCount = Number(parts.incidentIssueCount) || 0; + const incidentTotalCount = incidentOkCount + incidentIssueCount; + if (incidentTotalCount > 0) { + incidentScore = Math.max( + 0, + round1(BASE.incident + 5 * incidentOkCount - 10 * incidentIssueCount), + ); + } + + const totalScore = round1( + kmScore + performanceScore + onlineExam + practicalExam + faultScore + incidentScore, + ); + + return { + kmScore, + kmAnomalyCount, + performanceScore, + performanceDeduction, + drivingDeduction, + onlineExam, + practicalExam, + faultScore, + faultSuccessCount, + incidentScore, + incidentOkCount, + incidentIssueCount, + totalScore, + }; +} + +async function ensureSchema() { + if (schemaReady) return; + await db.execute(` + CREATE TABLE IF NOT EXISTS crew_metrics_employee_yearly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, + employee_id VARCHAR(20) NOT NULL, + km_score DECIMAL(8,2) NOT NULL DEFAULT 12, + km_anomaly_count INT NOT NULL DEFAULT 0, + performance_score DECIMAL(8,2) NOT NULL DEFAULT 100, + performance_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, + driving_score DECIMAL(8,2) NOT NULL DEFAULT 12, + driving_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, + online_exam DECIMAL(8,2) NOT NULL DEFAULT 0, + practical_exam DECIMAL(8,2) NOT NULL DEFAULT 0, + fault_score DECIMAL(8,2) NOT NULL DEFAULT 80, + incident_score DECIMAL(8,2) NOT NULL DEFAULT 100, + total_score DECIMAL(8,2) NOT NULL DEFAULT 0, + formula_version VARCHAR(16) NOT NULL DEFAULT 'v1', + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_employee_year (employee_id, year), + KEY idx_year_total (year, total_score) + ) ENGINE=InnoDB + `); + await db.execute(` + CREATE TABLE IF NOT EXISTS crew_metrics_employee_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, + month TINYINT NOT NULL, + employee_id VARCHAR(20) NOT NULL, + km_score DECIMAL(8,2) NOT NULL DEFAULT 15, + km_anomaly_count INT NOT NULL DEFAULT 0, + performance_score DECIMAL(8,2) NOT NULL DEFAULT 25, + performance_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, + driving_deduction DECIMAL(10,2) NOT NULL DEFAULT 0, + online_exam DECIMAL(8,2) NOT NULL DEFAULT 15, + practical_exam DECIMAL(8,2) NOT NULL DEFAULT 15, + fault_score DECIMAL(8,2) NOT NULL DEFAULT 10, + fault_success_count INT NOT NULL DEFAULT 0, + incident_score DECIMAL(8,2) NOT NULL DEFAULT 10, + incident_ok_count INT NOT NULL DEFAULT 0, + incident_issue_count INT NOT NULL DEFAULT 0, + total_score DECIMAL(8,2) NOT NULL DEFAULT 100, + formula_version VARCHAR(16) NOT NULL DEFAULT 'v2', + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_employee_year_month (employee_id, year, month), + KEY idx_year_month_total (year, month, total_score), + KEY idx_employee_total (employee_id, total_score) + ) ENGINE=InnoDB + `); + await db.execute( + `INSERT IGNORE INTO sync_table_versions (table_name, version) VALUES + ('crew_metrics_employee_yearly', 0), + ('crew_metrics_employee_monthly', 0)`, + ); + schemaReady = true; +} + +function rowMatchesEmployee(ids, targetVariants) { + const normalizedTargets = new Set(targetVariants.map(normalizeEmployeeId)); + return ids.some((id) => normalizedTargets.has(id)); +} + +function pushBucket(map, year, month, init) { + const key = ymKey(year, month); + if (!map.has(key)) map.set(key, typeof init === 'function' ? init() : { ...init }); + return map.get(key); +} + +async function loadEmployeeContext(employeeId) { + const variants = idVariants(employeeId); + const canonicalId = padEmployeeId(employeeId) || String(employeeId).trim(); + const placeholders = variants.map(() => '?').join(','); + if (!placeholders) { + return { + canonicalId, + variants, + hireYm: null, + team: '', + remarks: '', + useTeamAvgKm: false, + kmByMonth: new Map(), + perfByMonth: new Map(), + onlineByMonth: new Map(), + onlineMissingByMonth: new Set(), + practicalByMonth: new Map(), + faultByMonth: new Map(), + incidentByMonth: new Map(), + earliestYm: null, + }; + } + + const [ + employeeRows, + kmRows, + kmSubsidyRows, + perfRows, + onlineRows, + missingRows, + practicalRows, + faultRows, + incidentRows, + ] = await Promise.all([ + db.query( + `SELECT hire_date, team, details FROM employees + WHERE deleted_at IS NULL AND employee_id IN (${placeholders}) + AND (employment_status = '在职' OR employment_status IS NULL) + LIMIT 1`, + variants, + ), + db.query( + `SELECT year, month, is_anomaly + FROM kilometers_control_stats_monthly + WHERE employee_id IN (${placeholders})`, + variants, + ), + db.query( + `SELECT year, month, MAX(COALESCE(has_subsidy, 0)) AS has_subsidy + FROM kilometers_records + WHERE employee_id IN (${placeholders}) + GROUP BY year, month`, + variants, + ).catch(() => []), + db.query( + `SELECT assessment_date, performance_points, twelve_point_deduction + FROM performance_assessments + WHERE deleted_at IS NULL AND employee_id IN (${placeholders})`, + variants, + ), + db.query( + `SELECT exam_year, exam_month, score, details + FROM online_exams + WHERE employee_id IN (${placeholders})`, + variants, + ), + db.query( + `SELECT missing_month + FROM missing_exams + WHERE employee_id IN (${placeholders})`, + variants, + ).catch(() => []), + db.query( + `SELECT exam_year, exam_month, score + FROM practical_exams + WHERE employee_id IN (${placeholders})`, + variants, + ), + db.query( + `SELECT fault_date, final_result, employee_ids + FROM fault_disposals + WHERE deleted_at IS NULL`, + ), + db.query( + `SELECT event_date, operation_issues, employee_ids + FROM incident_events + WHERE deleted_at IS NULL`, + ), + ]); + + const hireYm = parseYm(employeeRows[0]?.hire_date); + const team = String(employeeRows[0]?.team || '').trim(); + const remarks = extractRemarks(employeeRows[0]?.details); + let earliestYm = hireYm; + + const subsidyByMonth = new Map(); + for (const row of kmSubsidyRows || []) { + const year = Number(row.year); + const month = Number(row.month); + if (!Number.isFinite(year) || month < 1 || month > 12) continue; + subsidyByMonth.set(ymKey(year, month), Number(row.has_subsidy) === 1); + } + + // 公里「有数据」以 kilometers_records 为准;异常次数来自控制图。 + // 若控制图被算空/失败,不能把全员公里分打成 0。 + const kmByMonth = new Map(); + for (const row of kmSubsidyRows || []) { + const year = Number(row.year); + const month = Number(row.month); + if (!Number.isFinite(year) || month < 1 || month > 12) continue; + const key = ymKey(year, month); + const bucket = pushBucket(kmByMonth, year, month, () => ({ + anomalyCount: 0, + hasSubsidy: false, + })); + if (subsidyByMonth.get(key)) bucket.hasSubsidy = true; + earliestYm = earlierYm(earliestYm, { year, month }); + } + for (const row of kmRows || []) { + const year = Number(row.year); + const month = Number(row.month); + if (!Number.isFinite(year) || month < 1 || month > 12) continue; + const key = ymKey(year, month); + const bucket = pushBucket(kmByMonth, year, month, () => ({ + anomalyCount: 0, + hasSubsidy: Boolean(subsidyByMonth.get(key)), + })); + if (Number(row.is_anomaly) === 1) bucket.anomalyCount += 1; + if (subsidyByMonth.get(key)) bucket.hasSubsidy = true; + earliestYm = earlierYm(earliestYm, { year, month }); + } + + const perfByMonth = new Map(); + for (const row of perfRows || []) { + // 绩效/嘉奖按业务月归桶:本月21日~下月20日 → 下月 + const ym = parseBusinessYm(row.assessment_date); + if (!ym) continue; + const bucket = pushBucket(perfByMonth, ym.year, ym.month, () => ({ + performanceDeduction: 0, + drivingDeduction: 0, + })); + bucket.performanceDeduction += Number(row.performance_points) || 0; + bucket.drivingDeduction += drivePointsValue(row.twelve_point_deduction); + earliestYm = earlierYm(earliestYm, ym); + } + + const onlineByMonth = new Map(); + const onlineMissingByMonth = new Set(); + for (const row of onlineRows || []) { + const year = Number(row.exam_year); + const month = Number(row.exam_month); + if (!Number.isFinite(year) || month < 1 || month > 12) continue; + let details = row.details; + if (typeof details === 'string') { + try { details = JSON.parse(details); } catch (_e) { details = {}; } + } + if (details && details.isMissingExam === true) { + onlineMissingByMonth.add(ymKey(year, month)); + earliestYm = earlierYm(earliestYm, { year, month }); + continue; + } + if (!Number.isFinite(Number(row.score))) continue; + const bucket = pushBucket(onlineByMonth, year, month, () => ({ scores: [] })); + bucket.scores.push(Number(row.score)); + earliestYm = earlierYm(earliestYm, { year, month }); + } + for (const row of missingRows || []) { + const ym = parseYm(row.missing_month); + if (!ym) continue; + onlineMissingByMonth.add(ymKey(ym.year, ym.month)); + earliestYm = earlierYm(earliestYm, ym); + } + + const practicalByMonth = new Map(); + for (const row of practicalRows || []) { + const year = Number(row.exam_year); + const month = Number(row.exam_month); + if (!Number.isFinite(year) || month < 1 || month > 12) continue; + if (!Number.isFinite(Number(row.score))) continue; + const bucket = pushBucket(practicalByMonth, year, month, () => ({ scores: [] })); + bucket.scores.push(Number(row.score)); + earliestYm = earlierYm(earliestYm, { year, month }); + } + + const faultByMonth = new Map(); + for (const row of faultRows || []) { + const ids = parseEmployeeIds(row.employee_ids); + if (!rowMatchesEmployee(ids, variants)) continue; + const ym = parseCalendarYm(row.fault_date); + if (!ym) continue; + const bucket = pushBucket(faultByMonth, ym.year, ym.month, () => ({ + total: 0, + success: 0, + })); + bucket.total += 1; + if (isFaultSuccess(row.final_result)) bucket.success += 1; + earliestYm = earlierYm(earliestYm, ym); + } + + const incidentByMonth = new Map(); + for (const row of incidentRows || []) { + const ids = parseEmployeeIds(row.employee_ids); + if (!rowMatchesEmployee(ids, variants)) continue; + const ym = parseCalendarYm(row.event_date); + if (!ym) continue; + const bucket = pushBucket(incidentByMonth, ym.year, ym.month, () => ({ + ok: 0, + issue: 0, + })); + if (hasIncidentIssue(row.operation_issues)) bucket.issue += 1; + else bucket.ok += 1; + earliestYm = earlierYm(earliestYm, ym); + } + + return { + canonicalId, + variants, + hireYm, + team, + remarks, + useTeamAvgKm: usesTeamAvgKm(remarks), + kmByMonth, + perfByMonth, + onlineByMonth, + onlineMissingByMonth, + practicalByMonth, + faultByMonth, + incidentByMonth, + earliestYm, + }; +} + +function resolveMonthSpan(ctx, yearFilter = null) { + const now = new Date(); + const end = { year: now.getFullYear(), month: now.getMonth() + 1 }; + let start = ctx.hireYm || ctx.earliestYm; + if (!start) start = end; + if (start && ymKey(start.year, start.month) > ymKey(end.year, end.month)) { + start = end; + } + + let months = monthRange(start, end); + if (yearFilter != null && Number.isFinite(Number(yearFilter))) { + const y = Number(yearFilter); + months = months.filter((item) => item.year === y); + if (!months.length) { + for (let m = 1; m <= 12; m += 1) { + if (ymKey(y, m) <= ymKey(end.year, end.month)) months.push({ year: y, month: m }); + } + } + } + return months; +} + +/** 该月是否有任一维度的实际导入数据(无数据月不落库、不参与排名) */ +function monthHasSourceData(ctx, year, month) { + const key = ymKey(year, month); + return ctx.kmByMonth.has(key) + || ctx.perfByMonth.has(key) + || ctx.onlineByMonth.has(key) + || ctx.practicalByMonth.has(key) + || ctx.faultByMonth.has(key) + || ctx.incidentByMonth.has(key); +} + +function computeEmployeeMonth(ctx, year, month) { + const key = ymKey(year, month); + const km = ctx.kmByMonth.get(key); + const perf = ctx.perfByMonth.get(key); + const online = ctx.onlineByMonth.get(key); + const practical = ctx.practicalByMonth.get(key); + const fault = ctx.faultByMonth.get(key); + const incident = ctx.incidentByMonth.get(key); + + const onlineScores = online?.scores || []; + const practicalScores = practical?.scores || []; + const hasMissingOnline = Boolean(ctx.onlineMissingByMonth?.has(key)); + + const scores = computeMonthScores({ + hasKm: Boolean(km), + kmAnomalyCount: km?.anomalyCount || 0, + performanceDeduction: perf?.performanceDeduction || 0, + drivingDeduction: perf?.drivingDeduction || 0, + hasOnline: onlineScores.length > 0, + hasMissingOnline, + onlineAvg: onlineScores.length + ? onlineScores.reduce((a, b) => a + b, 0) / onlineScores.length + : 0, + hasPractical: practicalScores.length > 0, + practicalAvg: practicalScores.length + ? practicalScores.reduce((a, b) => a + b, 0) / practicalScores.length + : 0, + faultTotalCount: fault?.total || 0, + faultSuccessCount: fault?.success || 0, + incidentOkCount: incident?.ok || 0, + incidentIssueCount: incident?.issue || 0, + }); + + return { + employeeId: ctx.canonicalId, + year: Number(year), + month: Number(month), + ...scores, + }; +} + +async function upsertMonthlyMetrics(metrics) { + await db.execute( + `INSERT INTO crew_metrics_employee_monthly + (year, month, employee_id, km_score, km_anomaly_count, + performance_score, performance_deduction, driving_deduction, + online_exam, practical_exam, fault_score, fault_success_count, + incident_score, incident_ok_count, incident_issue_count, + total_score, formula_version, computed_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), 1) + ON DUPLICATE KEY UPDATE + km_score = VALUES(km_score), + km_anomaly_count = VALUES(km_anomaly_count), + performance_score = VALUES(performance_score), + performance_deduction = VALUES(performance_deduction), + driving_deduction = VALUES(driving_deduction), + online_exam = VALUES(online_exam), + practical_exam = VALUES(practical_exam), + fault_score = VALUES(fault_score), + fault_success_count = VALUES(fault_success_count), + incident_score = VALUES(incident_score), + incident_ok_count = VALUES(incident_ok_count), + incident_issue_count = VALUES(incident_issue_count), + total_score = VALUES(total_score), + formula_version = VALUES(formula_version), + computed_at = NOW(), + version = version + 1`, + [ + metrics.year, + metrics.month, + metrics.employeeId, + metrics.kmScore, + metrics.kmAnomalyCount, + metrics.performanceScore, + metrics.performanceDeduction, + metrics.drivingDeduction, + metrics.onlineExam, + metrics.practicalExam, + metrics.faultScore, + metrics.faultSuccessCount, + metrics.incidentScore, + metrics.incidentOkCount, + metrics.incidentIssueCount, + metrics.totalScore, + FORMULA_VERSION, + ], + ); +} + +/** + * @param {string[]} employeeIds + * @param {number[]|null} years 若传入则只重算这些年的月份;否则重算入职→当前全部月 + */ +async function recomputeEmployeesYears(employeeIds = [], years = []) { + await ensureSchema(); + const ids = [...new Set((employeeIds || []).map((id) => String(id || '').trim()).filter(Boolean))]; + const yearList = [...new Set((years || []).map(Number).filter((y) => Number.isFinite(y)))]; + let recomputed = 0; + let changed = false; + if (!ids.length) { + return { recomputed, formulaVersion: FORMULA_VERSION }; + } + + // 组长 / 休假补贴公里保底需要同班组原始公里分,故一并加载队友 + const idPlaceholders = ids.map(() => '?').join(','); + const seedRows = await db.query( + `SELECT employee_id, team, details FROM employees + WHERE deleted_at IS NULL + AND (employment_status = '在职' OR employment_status IS NULL) + AND employee_id IN (${idPlaceholders})`, + ids, + ); + const teamsNeeded = new Set(); + for (const row of seedRows || []) { + if (usesTeamAvgKm(extractRemarks(row.details)) && row.team) { + teamsNeeded.add(String(row.team).trim()); + } + } + const subsidyTeams = await db.query( + `SELECT DISTINCT e.team + FROM kilometers_records k + INNER JOIN employees e + ON e.employee_id = k.employee_id AND e.deleted_at IS NULL + WHERE k.has_subsidy = 1 + AND k.employee_id IN (${idPlaceholders}) + AND e.team IS NOT NULL AND TRIM(e.team) != ''`, + ids, + ).catch(() => []); + for (const row of subsidyTeams || []) { + if (row.team) teamsNeeded.add(String(row.team).trim()); + } + + const computeIdSet = new Set(ids); + if (teamsNeeded.size) { + const teamList = [...teamsNeeded]; + const teamPlaceholders = teamList.map(() => '?').join(','); + const teammates = await db.query( + `SELECT employee_id FROM employees + WHERE deleted_at IS NULL + AND (employment_status = '在职' OR employment_status IS NULL) + AND team IN (${teamPlaceholders})`, + teamList, + ); + for (const row of teammates || []) { + if (row.employee_id) computeIdSet.add(String(row.employee_id).trim()); + } + } + + const upsertKeys = new Set(); + for (const id of ids) { + for (const variant of idVariants(id)) upsertKeys.add(variant); + } + + /** @type {Array<{ metrics: object, hasKm: boolean, team: string, useTeamAvgKm: boolean, hasSubsidy: boolean, shouldUpsert: boolean }>} */ + const pending = []; + + for (const employeeId of computeIdSet) { + const ctx = await loadEmployeeContext(employeeId); + const shouldUpsert = ctx.variants.some((variant) => upsertKeys.has(variant)) + || upsertKeys.has(ctx.canonicalId); + const months = yearList.length + ? yearList.flatMap((year) => resolveMonthSpan(ctx, year)) + : resolveMonthSpan(ctx, null); + const uniqueMonths = new Map(); + for (const item of months) uniqueMonths.set(ymKey(item.year, item.month), item); + + for (const { year, month } of uniqueMonths.values()) { + if (!monthHasSourceData(ctx, year, month)) { + if (shouldUpsert) { + const del = await db.execute( + `DELETE FROM crew_metrics_employee_monthly + WHERE employee_id = ? AND year = ? AND month = ?`, + [ctx.canonicalId, year, month], + ); + if ((del?.affectedRows || 0) > 0) changed = true; + } + continue; + } + const metrics = computeEmployeeMonth(ctx, year, month); + const kmBucket = ctx.kmByMonth.get(ymKey(year, month)); + pending.push({ + metrics, + hasKm: Boolean(kmBucket), + team: ctx.team || '', + useTeamAvgKm: Boolean(ctx.useTeamAvgKm), + hasSubsidy: Boolean(kmBucket?.hasSubsidy), + shouldUpsert, + }); + } + } + + // 班组当月「有公里数据」人员的原始公里分均值 + const teamMonthScores = new Map(); + for (const item of pending) { + if (!item.team || !item.hasKm) continue; + const key = `${item.team}|${ymKey(item.metrics.year, item.metrics.month)}`; + if (!teamMonthScores.has(key)) teamMonthScores.set(key, []); + teamMonthScores.get(key).push(Number(item.metrics.kmScore) || 0); + } + const teamMonthAvg = new Map(); + for (const [key, scores] of teamMonthScores.entries()) { + if (!scores.length) continue; + teamMonthAvg.set(key, round1(scores.reduce((a, b) => a + b, 0) / scores.length)); + } + + for (const item of pending) { + // 组长或休假补贴:低于班组均值则保底,高于则按实际 + if ((item.useTeamAvgKm || item.hasSubsidy) && item.team) { + const avg = teamMonthAvg.get(`${item.team}|${ymKey(item.metrics.year, item.metrics.month)}`); + if (avg != null && Number(item.metrics.kmScore) < avg) { + item.metrics.kmScore = avg; + item.metrics.totalScore = recalcTotalScore(item.metrics); + } + } + if (!item.shouldUpsert) continue; + await upsertMonthlyMetrics(item.metrics); + recomputed += 1; + changed = true; + } + + if (changed) { + await sync.bumpVersions(['crew_metrics_employee_monthly']); + } + return { recomputed, formulaVersion: FORMULA_VERSION }; +} + +/** 兼容旧调用:按人年对列表做重算 */ +async function recomputePairs(pairs = []) { + const byYear = new Map(); + for (const pair of pairs || []) { + const employeeId = String(pair.employeeId || '').trim(); + const year = Number(pair.year); + if (!employeeId || !Number.isFinite(year)) continue; + if (!byYear.has(year)) byYear.set(year, new Set()); + byYear.get(year).add(employeeId); + } + let recomputed = 0; + for (const [year, idSet] of byYear.entries()) { + const report = await recomputeEmployeesYears([...idSet], [year]); + recomputed += report.recomputed; + } + return { recomputed, formulaVersion: FORMULA_VERSION }; +} + +async function recomputeYear(year) { + await ensureSchema(); + const employees = await db.query( + `SELECT employee_id FROM employees + WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)`, + ); + return recomputeEmployeesYears( + employees.map((row) => row.employee_id), + [Number(year)], + ); +} + +async function recomputeAll() { + await ensureSchema(); + const employees = await db.query( + `SELECT employee_id FROM employees + WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)`, + ); + return recomputeEmployeesYears( + employees.map((row) => row.employee_id), + [], + ); +} + +/** 供前端/调试:单人累计平均(六维与总分) */ +function averageMetrics(monthRows = []) { + if (!monthRows.length) { + return { + kmScore: 0, + performanceScore: BASE.performance, + onlineExam: BASE.online, + practicalExam: BASE.practical, + faultScore: BASE.fault, + incidentScore: BASE.incident, + totalScore: round1(0 + BASE.performance + BASE.online + BASE.practical + BASE.fault + BASE.incident), + monthCount: 0, + }; + } + const sum = monthRows.reduce((acc, row) => { + const kmScore = Number(row.km_score ?? row.kmScore) || 0; + const kmAnomaly = Number(row.km_anomaly_count ?? row.kmAnomalyCount) || 0; + // 公里维:只平均「有公里数据」的月(无数据月 km=0 且 anomaly=0,不拉低) + if (kmScore > 0 || kmAnomaly > 0) { + acc.kmScore += kmScore; + acc.kmMonths += 1; + } + acc.performanceScore += Number(row.performance_score ?? row.performanceScore) || 0; + acc.onlineExam += Number(row.online_exam ?? row.onlineExam) || 0; + acc.practicalExam += Number(row.practical_exam ?? row.practicalExam) || 0; + acc.faultScore += Number(row.fault_score ?? row.faultScore) || 0; + acc.incidentScore += Number(row.incident_score ?? row.incidentScore) || 0; + acc.totalScore += Number(row.total_score ?? row.totalScore) || 0; + return acc; + }, { + kmScore: 0, + kmMonths: 0, + performanceScore: 0, + onlineExam: 0, + practicalExam: 0, + faultScore: 0, + incidentScore: 0, + totalScore: 0, + }); + const n = monthRows.length; + const kmScore = sum.kmMonths ? round1(sum.kmScore / sum.kmMonths) : 0; + const performanceScore = round1(sum.performanceScore / n); + const onlineExam = round1(sum.onlineExam / n); + const practicalExam = round1(sum.practicalExam / n); + const faultScore = round1(sum.faultScore / n); + const incidentScore = round1(sum.incidentScore / n); + // 画像分 = 各维累计平均之和(公里维已排除无公里月),避免「仅嘉奖月 km=0」拉低总分 + return { + kmScore, + performanceScore, + onlineExam, + practicalExam, + faultScore, + incidentScore, + totalScore: round1(kmScore + performanceScore + onlineExam + practicalExam + faultScore + incidentScore), + monthCount: n, + kmMonthCount: sum.kmMonths, + }; +} + +module.exports = { + FORMULA_VERSION, + BASE, + ensureSchema, + computeMonthScores, + computeEmployeeMonth, + averageMetrics, + recomputePairs, + recomputeEmployeesYears, + recomputeYear, + recomputeAll, + idVariants, + normalizeEmployeeId, + padEmployeeId, + ymKey, + parseYm, + usesTeamAvgKm, + extractRemarks, +}; diff --git a/server/src/precompute/dashboard-summary.js b/server/src/precompute/dashboard-summary.js new file mode 100644 index 0000000..aa069ca --- /dev/null +++ b/server/src/precompute/dashboard-summary.js @@ -0,0 +1,72 @@ +'use strict'; + +const db = require('../infra/db'); +const sync = require('../sync/sync'); + +const SCOPE_KEY = 'global'; + +async function recompute() { + const [row] = await db.query( + `SELECT + (SELECT COUNT(*) FROM employees WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)) AS total_employees, + (SELECT COUNT(*) + FROM online_exams + WHERE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(details, '$.isMissingExam')), 'false') <> 'true') + AS total_online_exams, + (SELECT COUNT(*) FROM practical_exams) AS total_practical_exams, + (SELECT COUNT(*) FROM fault_disposals WHERE deleted_at IS NULL) AS total_error_analysis, + (SELECT COUNT(*) FROM performance_assessments WHERE deleted_at IS NULL) AS total_performance_assessments, + (SELECT COUNT(*) FROM incident_events WHERE deleted_at IS NULL) AS total_incident_events, + (SELECT COUNT(*) FROM question_banks WHERE deleted_at IS NULL AND status = 'active') AS total_question_banks, + (SELECT COUNT(*) FROM kilometers_records) AS total_kilometer_records, + (SELECT COALESCE(SUM(kilometers), 0) FROM kilometers_records) AS total_kilometers, + (SELECT COUNT(DISTINCT employee_id) FROM kilometers_records) AS unique_kilometer_employees, + (SELECT ROUND( + COALESCE(SUM(kilometers), 0) / NULLIF(COUNT(DISTINCT employee_id), 0), + 2 + ) FROM kilometers_records) AS average_kilometers, + (SELECT COALESCE(MAX(employee_total), 0) + FROM ( + SELECT SUM(kilometers) AS employee_total + FROM kilometers_records + GROUP BY employee_id + ) kilometer_totals) AS max_kilometers, + (SELECT ROUND( + 100 * SUM(pass_count) + / NULLIF(SUM(total_count), 0), + 2 + ) + FROM online_exam_stats_monthly) + AS pass_rate`, + ); + + const payload = { + totalEmployees: Number(row.total_employees) || 0, + totalOnlineExams: Number(row.total_online_exams) || 0, + totalPracticalExams: Number(row.total_practical_exams) || 0, + totalErrorAnalysis: Number(row.total_error_analysis) || 0, + totalPerformanceAssessments: Number(row.total_performance_assessments) || 0, + totalIncidentEvents: Number(row.total_incident_events) || 0, + totalQuestionBanks: Number(row.total_question_banks) || 0, + totalKilometerRecords: Number(row.total_kilometer_records) || 0, + totalKilometers: Number(row.total_kilometers) || 0, + uniqueKilometerEmployees: Number(row.unique_kilometer_employees) || 0, + averageKilometers: Number(row.average_kilometers) || 0, + maxKilometers: Number(row.max_kilometers) || 0, + passRate: Number(row.pass_rate) || 0, + }; + + await db.execute( + `INSERT INTO dashboard_summary (scope_key, payload, computed_at, version) + VALUES (?, ?, NOW(), 1) + ON DUPLICATE KEY UPDATE + payload = VALUES(payload), + computed_at = VALUES(computed_at), + version = version + 1`, + [SCOPE_KEY, JSON.stringify(payload)], + ); + await sync.bumpVersions(['dashboard_summary']); + return payload; +} + +module.exports = { recompute, SCOPE_KEY }; diff --git a/server/src/precompute/impls/kilometers.js b/server/src/precompute/impls/kilometers.js new file mode 100644 index 0000000..0c8286e --- /dev/null +++ b/server/src/precompute/impls/kilometers.js @@ -0,0 +1,204 @@ +'use strict'; + +const db = require('../../infra/db'); + +/** + * 公里数预计算(migration-plan §4.1.3)。 + * + * 口径: + * - team / employee_name 一律取自花名册(employees),按工号 employee_id LEFT JOIN + * (用户决策 2026-07-16:team 仅花名册维护,其它表通过工号回查;§5.2 口径修正) + * - 花名册缺此工号 → team 归入「未分组」,员工名回退工号,避免丢记录 + * - L2 严格基于 L1(§5.2 关键设计) + * - team_yearly.driver_count = 全年出现过的司机去重数,取自 L1 员工月度 + */ + +const module_ = 'kilometers'; + +async function recomputeMonth(year, month) { + const start = Date.now(); + const rowCounts = {}; + + await db.transaction(async (conn) => { + // L1 员工月度(employee_name / team 取自花名册) + await conn.execute('DELETE FROM kilometers_stats_employee_monthly WHERE year = ? AND month = ?', [year, month]); + const [empRes] = await conn.execute( + `INSERT INTO kilometers_stats_employee_monthly + (year, month, employee_id, employee_name, team, + total_kilometers, record_count, avg_per_record, max_single, computed_at, version) + SELECT k.year, k.month, k.employee_id, + COALESCE(MAX(e.name), k.employee_id), COALESCE(MAX(e.team), '未分组'), + SUM(k.kilometers), COUNT(*), ROUND(AVG(k.kilometers), 2), MAX(k.kilometers), NOW(), 0 + FROM kilometers_records k + LEFT JOIN employees e ON e.employee_id = k.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE k.year = ? AND k.month = ? + GROUP BY k.year, k.month, k.employee_id`, + [year, month], + ); + rowCounts.kilometers_stats_employee_monthly = empRes.affectedRows; + + // L1 班组月度(team 取自花名册,缺失归「未分组」) + await conn.execute('DELETE FROM kilometers_stats_team_monthly WHERE year = ? AND month = ?', [year, month]); + const [teamRes] = await conn.execute( + `INSERT INTO kilometers_stats_team_monthly + (year, month, team, total_kilometers, driver_count, + avg_kilometers, max_kilometers, min_kilometers, computed_at, version) + SELECT k.year, k.month, COALESCE(e.team, '未分组') AS team, + SUM(k.kilometers), COUNT(DISTINCT k.employee_id), + ROUND(SUM(k.kilometers) / COUNT(DISTINCT k.employee_id), 2), + MAX(k.kilometers), MIN(k.kilometers), NOW(), 0 + FROM kilometers_records k + LEFT JOIN employees e ON e.employee_id = k.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE k.year = ? AND k.month = ? + GROUP BY k.year, k.month, COALESCE(e.team, '未分组')`, + [year, month], + ); + rowCounts.kilometers_stats_team_monthly = teamRes.affectedRows; + + await conn.execute('DELETE FROM kilometers_stats_overall_monthly WHERE year = ? AND month = ?', [year, month]); + const [overallRes] = await conn.execute( + `INSERT INTO kilometers_stats_overall_monthly + (year, month, total_kilometers, driver_count, avg_kilometers, + max_kilometers, min_kilometers, computed_at, version) + SELECT ?, ?, COALESCE(SUM(total_kilometers), 0), COUNT(*), + ROUND(AVG(total_kilometers), 2), MAX(total_kilometers), + MIN(total_kilometers), NOW(), 0 + FROM kilometers_stats_employee_monthly + WHERE year = ? AND month = ?`, + [year, month, year, month], + ); + rowCounts.kilometers_stats_overall_monthly = overallRes.affectedRows; + }); + + return { + module: module_, + grain: 'month', + period: `${year}-${String(month).padStart(2, '0')}`, + touchedTables: [ + 'kilometers_stats_employee_monthly', + 'kilometers_stats_team_monthly', + 'kilometers_stats_overall_monthly', + ], + rowCounts, + durationMs: Date.now() - start, + }; +} + +async function recomputeYear(year) { + const start = Date.now(); + const rowCounts = {}; + + await db.transaction(async (conn) => { + // L2 员工年度(基于 L1 员工月度) + await conn.execute('DELETE FROM kilometers_stats_employee_yearly WHERE year = ?', [year]); + const [empRes] = await conn.execute( + `INSERT INTO kilometers_stats_employee_yearly + (year, employee_id, employee_name, team, total_kilometers, + month_count, avg_monthly_kilometers, monthly_breakdown, computed_at, version) + SELECT year, employee_id, MAX(employee_name), MAX(team), + SUM(total_kilometers), COUNT(*), ROUND(AVG(total_kilometers), 2), + JSON_OBJECTAGG(CAST(month AS CHAR), total_kilometers), + NOW(), 0 + FROM kilometers_stats_employee_monthly + WHERE year = ? + GROUP BY year, employee_id`, + [year], + ); + rowCounts.kilometers_stats_employee_yearly = empRes.affectedRows; + + // L2 班组年度(基于 L1;driver_count 用员工月度去重) + await conn.execute('DELETE FROM kilometers_stats_team_yearly WHERE year = ?', [year]); + const [teamRes] = await conn.execute( + `INSERT INTO kilometers_stats_team_yearly + (year, team, total_kilometers, driver_count, avg_kilometers, + avg_annual_per_driver, avg_per_person_month, + monthly_breakdown, computed_at, version) + SELECT tm.year, tm.team, + SUM(tm.total_kilometers), + (SELECT COUNT(DISTINCT em.employee_id) + FROM kilometers_stats_employee_monthly em + WHERE em.year = tm.year AND em.team = tm.team), + ROUND(AVG(tm.avg_kilometers), 2), + ROUND(SUM(tm.total_kilometers) / NULLIF(( + SELECT COUNT(DISTINCT em.employee_id) + FROM kilometers_stats_employee_monthly em + WHERE em.year = tm.year AND em.team = tm.team + ), 0), 2), + ROUND(SUM(tm.total_kilometers) / NULLIF(SUM(tm.driver_count), 0), 2), + JSON_OBJECTAGG(CAST(tm.month AS CHAR), tm.total_kilometers), + NOW(), 0 + FROM kilometers_stats_team_monthly tm + WHERE tm.year = ? + GROUP BY tm.year, tm.team`, + [year], + ); + rowCounts.kilometers_stats_team_yearly = teamRes.affectedRows; + + await conn.execute('DELETE FROM kilometers_stats_overall_yearly WHERE year = ?', [year]); + const [overallRes] = await conn.execute( + `INSERT INTO kilometers_stats_overall_yearly + (year, total_kilometers, driver_count, person_month_count, + avg_annual_per_driver, avg_per_person_month, monthly_breakdown, + computed_at, version) + SELECT ?, COALESCE(SUM(total_kilometers), 0), + COUNT(DISTINCT employee_id), COUNT(*), + ROUND(SUM(total_kilometers) / NULLIF(COUNT(DISTINCT employee_id), 0), 2), + ROUND(SUM(total_kilometers) / NULLIF(COUNT(*), 0), 2), + (SELECT JSON_OBJECTAGG(CAST(month AS CHAR), total_kilometers) + FROM kilometers_stats_overall_monthly WHERE year = ?), + NOW(), 0 + FROM kilometers_stats_employee_monthly + WHERE year = ?`, + [year, year, year], + ); + rowCounts.kilometers_stats_overall_yearly = overallRes.affectedRows; + }); + + return { + module: module_, + grain: 'year', + period: String(year), + touchedTables: [ + 'kilometers_stats_employee_yearly', + 'kilometers_stats_team_yearly', + 'kilometers_stats_overall_yearly', + ], + rowCounts, + durationMs: Date.now() - start, + }; +} + +async function recomputeAll() { + const start = Date.now(); + const periods = await db.query('SELECT DISTINCT year, month FROM kilometers_records ORDER BY year, month'); + const rowCounts = {}; + const years = new Set(); + for (const p of periods) { + const r = await recomputeMonth(p.year, p.month); + for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v; + years.add(p.year); + } + for (const y of years) { + const r = await recomputeYear(y); + for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v; + } + return { + module: module_, + grain: 'all', + period: 'all', + touchedTables: [ + 'kilometers_stats_employee_monthly', + 'kilometers_stats_team_monthly', + 'kilometers_stats_overall_monthly', + 'kilometers_stats_employee_yearly', + 'kilometers_stats_team_yearly', + 'kilometers_stats_overall_yearly', + ], + rowCounts, + durationMs: Date.now() - start, + }; +} + +module.exports = { module: module_, recomputeMonth, recomputeYear, recomputeAll }; diff --git a/server/src/precompute/impls/online-exams.js b/server/src/precompute/impls/online-exams.js new file mode 100644 index 0000000..668ea51 --- /dev/null +++ b/server/src/precompute/impls/online-exams.js @@ -0,0 +1,156 @@ +'use strict'; + +const db = require('../../infra/db'); + +/** + * 在线考试预计算(migration-plan §4.1.3)。 + * + * 口径: + * - team 取自花名册(employees),按工号 LEFT JOIN;缺失归「未分组」 + * - paper_class 缺失归「未分类」(stats 表该列 NOT NULL) + * - 通过率 pass_rate = pass_count / total_count(total 为该维度全部作答数) + * - 年度 avg_score 按 total_count 加权(严格基于 L1,避免月度简单平均失真) + * + * 未实现(依赖后续,见实施日志 TODO): + * - online_exam_question_stats_monthly:题目级统计依赖 details JSON 结构(parser 未定),本轮不产出 + * 缺考主路径:读取 missing_exams,按班组写入 paper_class='缺考' 的统计行; + * 旧 details.isMissingExam 仅用于迁移期兼容。 + */ + +const module_ = 'online_exams'; + +async function recomputeMonth(year, month) { + const start = Date.now(); + const rowCounts = {}; + + await db.transaction(async (conn) => { + await conn.execute('DELETE FROM online_exam_stats_monthly WHERE year = ? AND month = ?', [year, month]); + const [res] = await conn.execute( + `INSERT INTO online_exam_stats_monthly + (year, month, team, paper_class, + total_count, pass_count, fail_count, pass_rate, + avg_score, max_score, min_score, avg_elapsed_seconds, missing_count, computed_at, version) + SELECT o.exam_year, o.exam_month, + COALESCE(e.team, '未分组') AS team, + COALESCE(o.paper_class, '未分类') AS paper_class, + SUM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'), + SUM(o.result = 'pass' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'), + SUM(o.result = 'fail' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'), + ROUND( + SUM(o.result = 'pass' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true') + / NULLIF(SUM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'), 0), + 4 + ), + ROUND(AVG(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.score END), 2), + MAX(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.score END), + MIN(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.score END), + ROUND(AVG(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.elapsed_seconds END)), + SUM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') = 'true'), + NOW(), 0 + FROM online_exams o + LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE o.exam_year = ? AND o.exam_month = ? + GROUP BY o.exam_year, o.exam_month, COALESCE(e.team, '未分组'), COALESCE(o.paper_class, '未分类')`, + [year, month], + ); + const missingMonth = `${year}-${String(month).padStart(2, '0')}`; + const [missingRes] = await conn.execute( + `INSERT INTO online_exam_stats_monthly + (year, month, team, paper_class, + total_count, pass_count, fail_count, pass_rate, + avg_score, max_score, min_score, avg_elapsed_seconds, missing_count, computed_at, version) + SELECT ?, ?, COALESCE(e.team, '未分组'), '缺考', + 0, 0, 0, NULL, NULL, NULL, NULL, NULL, COUNT(*), NOW(), 0 + FROM missing_exams m + LEFT JOIN employees e ON e.employee_id = m.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE m.missing_month = ? + GROUP BY COALESCE(e.team, '未分组') + ON DUPLICATE KEY UPDATE + missing_count = VALUES(missing_count), + computed_at = VALUES(computed_at), + version = version + 1`, + [year, month, missingMonth], + ); + rowCounts.online_exam_stats_monthly = res.affectedRows + missingRes.affectedRows; + }); + + return { + module: module_, + grain: 'month', + period: `${year}-${String(month).padStart(2, '0')}`, + touchedTables: ['online_exam_stats_monthly'], + rowCounts, + durationMs: Date.now() - start, + }; +} + +async function recomputeYear(year) { + const start = Date.now(); + const rowCounts = {}; + + await db.transaction(async (conn) => { + await conn.execute('DELETE FROM online_exam_stats_yearly WHERE year = ?', [year]); + const [res] = await conn.execute( + `INSERT INTO online_exam_stats_yearly + (year, team, paper_class, total_count, pass_count, pass_rate, avg_score, monthly_breakdown, computed_at, version) + SELECT year, team, paper_class, + SUM(total_count), SUM(pass_count), + ROUND(SUM(pass_count) / NULLIF(SUM(total_count), 0), 4), + ROUND(SUM(avg_score * total_count) / NULLIF(SUM(total_count), 0), 2), + JSON_OBJECTAGG(CAST(month AS CHAR), total_count), + NOW(), 0 + FROM online_exam_stats_monthly + WHERE year = ? + GROUP BY year, team, paper_class`, + [year], + ); + rowCounts.online_exam_stats_yearly = res.affectedRows; + }); + + return { + module: module_, + grain: 'year', + period: String(year), + touchedTables: ['online_exam_stats_yearly'], + rowCounts, + durationMs: Date.now() - start, + }; +} + +async function recomputeAll() { + const start = Date.now(); + const periods = await db.query( + `SELECT DISTINCT y, m + FROM ( + SELECT exam_year AS y, exam_month AS m FROM online_exams + UNION + SELECT CAST(SUBSTRING(missing_month, 1, 4) AS UNSIGNED) AS y, + CAST(SUBSTRING(missing_month, 6, 2) AS UNSIGNED) AS m + FROM missing_exams + ) periods + ORDER BY y, m`, + ); + const rowCounts = {}; + const years = new Set(); + for (const p of periods) { + const r = await recomputeMonth(p.y, p.m); + for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v; + years.add(p.y); + } + for (const y of years) { + const r = await recomputeYear(y); + for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v; + } + return { + module: module_, + grain: 'all', + period: 'all', + touchedTables: ['online_exam_stats_monthly', 'online_exam_stats_yearly'], + rowCounts, + durationMs: Date.now() - start, + }; +} + +module.exports = { module: module_, recomputeMonth, recomputeYear, recomputeAll }; diff --git a/server/src/precompute/impls/practical-exams.js b/server/src/precompute/impls/practical-exams.js new file mode 100644 index 0000000..67993d0 --- /dev/null +++ b/server/src/precompute/impls/practical-exams.js @@ -0,0 +1,329 @@ +'use strict'; + +const db = require('../../infra/db'); + +const MONTH_TABLES = [ + 'practical_exam_stats_monthly', + 'practical_exam_step_stats_monthly', + 'practical_exam_person_stats_monthly', + 'practical_exam_scope_stats_monthly', +]; +const YEAR_TABLES = [ + 'practical_exam_stats_yearly', + 'practical_exam_person_stats_yearly', + 'practical_exam_scope_stats_yearly', +]; +// 班组只认当前花名册;考试快照中的旧班组不参与管理统计。 +const teamExpr = "COALESCE(NULLIF(e.team, ''), '未分组')"; + +async function fillStepTopErrors(year, month) { + const rows = await db.query( + `SELECT scope_team, paper_id, scenario_index, step_index, error_label, + COUNT(DISTINCT exam_uuid) AS error_count + FROM ( + SELECT ${teamExpr} AS scope_team, + s.paper_id, s.scenario_index, s.step_index, s.exam_uuid, + CONCAT( + COALESCE(NULLIF(s.operation, ''), NULLIF(s.target, ''), '未知操作'), + ' → ', + COALESCE( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.actualValue')), ''), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.result')), ''), + '错误' + ) + ) AS error_label + FROM practical_exam_steps s + LEFT JOIN practical_exams p ON p.exam_uuid = s.exam_uuid + LEFT JOIN employees e ON e.employee_id = s.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE s.exam_year = ? AND s.exam_month = ? + AND (s.is_correct = 0 OR s.faults > 0) + AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0 + AND COALESCE(s.operation, '') <> '选择题提交' + AND COALESCE(s.target, '') <> '选择题提交' + UNION ALL + SELECT '全部', s.paper_id, s.scenario_index, s.step_index, s.exam_uuid, + CONCAT( + COALESCE(NULLIF(s.operation, ''), NULLIF(s.target, ''), '未知操作'), + ' → ', + COALESCE( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.actualValue')), ''), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.result')), ''), + '错误' + ) + ) + FROM practical_exam_steps s + WHERE s.exam_year = ? AND s.exam_month = ? + AND (s.is_correct = 0 OR s.faults > 0) + AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0 + AND COALESCE(s.operation, '') <> '选择题提交' + AND COALESCE(s.target, '') <> '选择题提交' + ) errors + GROUP BY scope_team, paper_id, scenario_index, step_index, error_label + ORDER BY error_count DESC`, + [year, month, year, month], + ); + const groups = new Map(); + for (const row of rows) { + const key = [row.scope_team, row.paper_id, row.scenario_index, row.step_index].join('|'); + const list = groups.get(key) || []; + list.push({ label: row.error_label, count: Number(row.error_count) || 0 }); + groups.set(key, list); + } + for (const [key, errors] of groups) { + const [team, paperId, scenarioIndex, stepIndex] = key.split('|'); + await db.execute( + `UPDATE practical_exam_step_stats_monthly + SET top3_errors = ? + WHERE year = ? AND month = ? AND team = ? AND paper_id = ? + AND scenario_index = ? AND step_index = ?`, + [ + JSON.stringify(errors.slice(0, 3)), + year, + month, + team, + Number(paperId), + Number(scenarioIndex), + Number(stepIndex), + ], + ); + } +} + +async function recomputeMonth(year, month) { + const startedAt = Date.now(); + await db.transaction(async (conn) => { + for (const table of MONTH_TABLES) { + await conn.execute(`DELETE FROM ${table} WHERE year = ? AND month = ?`, [year, month]); + } + + await conn.execute( + `INSERT INTO practical_exam_person_stats_monthly + (year, month, employee_id, employee_name, team, total_count, + pass_count, fail_count, pass_rate, computed_at) + SELECT ?, ?, p.employee_id, + MAX(COALESCE(NULLIF(e.name, ''), JSON_UNQUOTE(JSON_EXTRACT(p.details, '$.employeeName')))), + MAX(${teamExpr}), + COUNT(*), SUM(p.result = 'pass'), SUM(p.result = 'fail'), + SUM(p.result = 'pass') / NULLIF(COUNT(*), 0), NOW() + FROM practical_exams p + LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE p.exam_year = ? AND p.exam_month = ? + GROUP BY p.employee_id`, + [year, month, year, month], + ); + + await conn.execute( + `INSERT INTO practical_exam_scope_stats_monthly + (year, month, scope_type, scope_key, team, total_count, pass_count, + fail_count, pass_rate, participant_count, computed_at) + SELECT ?, ?, 'team', CONCAT('team:', scoped.team), scoped.team, + COUNT(*), SUM(scoped.result = 'pass'), SUM(scoped.result = 'fail'), + SUM(scoped.result = 'pass') / NULLIF(COUNT(*), 0), + COUNT(DISTINCT scoped.employee_id), NOW() + FROM ( + SELECT ${teamExpr} AS team, p.result, p.employee_id + FROM practical_exams p + LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE p.exam_year = ? AND p.exam_month = ? + ) scoped + GROUP BY scoped.team + UNION ALL + SELECT ?, ?, 'all', 'all', NULL, + COUNT(*), COALESCE(SUM(p.result = 'pass'), 0), + COALESCE(SUM(p.result = 'fail'), 0), + COALESCE(SUM(p.result = 'pass'), 0) / NULLIF(COUNT(*), 0), + COUNT(DISTINCT p.employee_id), NOW() + FROM practical_exams p + WHERE p.exam_year = ? AND p.exam_month = ?`, + [year, month, year, month, year, month, year, month], + ); + + await conn.execute( + `INSERT INTO practical_exam_stats_monthly + (year, month, team, paper_id, paper_class, total_count, pass_count, + fail_count, pass_rate, avg_score, max_score, min_score, avg_faults, computed_at) + SELECT ?, ?, scope_team, paper_id, MAX(pc_class), COUNT(*), + SUM(result = 'pass'), SUM(result = 'fail'), + SUM(result = 'pass') / NULLIF(COUNT(*), 0), + AVG(score), MAX(score), MIN(score), AVG(faults), NOW() + FROM ( + SELECT ${teamExpr} AS scope_team, p.* + FROM practical_exams p + LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE p.exam_year = ? AND p.exam_month = ? AND p.paper_id IS NOT NULL + UNION ALL + SELECT '全部', p.* + FROM practical_exams p + WHERE p.exam_year = ? AND p.exam_month = ? AND p.paper_id IS NOT NULL + ) scoped + GROUP BY scope_team, paper_id`, + [year, month, year, month, year, month], + ); + + await conn.execute( + `INSERT INTO practical_exam_step_stats_monthly + (year, month, paper_id, scenario_index, step_index, team, + total_count, error_count, error_rate, computed_at) + SELECT ?, ?, paper_id, scenario_index, step_index, scope_team, + COUNT(*), SUM(has_error), SUM(has_error) / NULLIF(COUNT(*), 0), NOW() + FROM ( + SELECT ${teamExpr} AS scope_team, collapsed.* + FROM ( + SELECT s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index, + MAX(s.is_correct = 0 OR s.faults > 0) AS has_error + FROM practical_exam_steps s + WHERE s.exam_year = ? AND s.exam_month = ? AND s.paper_id IS NOT NULL + AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0 + AND COALESCE(s.operation, '') <> '选择题提交' + AND COALESCE(s.target, '') <> '选择题提交' + GROUP BY s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index + ) collapsed + LEFT JOIN practical_exams p ON p.exam_uuid = collapsed.exam_uuid + LEFT JOIN employees e ON e.employee_id = collapsed.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + UNION ALL + SELECT '全部', collapsed.* + FROM ( + SELECT s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index, + MAX(s.is_correct = 0 OR s.faults > 0) AS has_error + FROM practical_exam_steps s + WHERE s.exam_year = ? AND s.exam_month = ? AND s.paper_id IS NOT NULL + AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0 + AND COALESCE(s.operation, '') <> '选择题提交' + AND COALESCE(s.target, '') <> '选择题提交' + GROUP BY s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index + ) collapsed + ) scoped + GROUP BY scope_team, paper_id, scenario_index, step_index`, + [year, month, year, month, year, month], + ); + }); + await fillStepTopErrors(year, month); + + const rowCounts = {}; + for (const table of MONTH_TABLES) { + const [row] = await db.query( + `SELECT COUNT(*) AS count FROM ${table} WHERE year = ? AND month = ?`, + [year, month], + ); + rowCounts[table] = Number(row.count); + } + return { + grain: 'month', + period: `${year}-${String(month).padStart(2, '0')}`, + touchedTables: MONTH_TABLES, + rowCounts, + durationMs: Date.now() - startedAt, + }; +} + +async function recomputeYear(year) { + const startedAt = Date.now(); + await db.transaction(async (conn) => { + for (const table of YEAR_TABLES) { + await conn.execute(`DELETE FROM ${table} WHERE year = ?`, [year]); + } + await conn.execute( + `INSERT INTO practical_exam_person_stats_yearly + (year, employee_id, employee_name, team, total_count, pass_count, + fail_count, pass_rate, monthly_breakdown, computed_at) + SELECT year, employee_id, MAX(employee_name), MAX(team), + SUM(total_count), SUM(pass_count), SUM(fail_count), + SUM(pass_count) / NULLIF(SUM(total_count), 0), + JSON_OBJECTAGG(LPAD(month, 2, '0'), JSON_OBJECT( + 'total', total_count, 'pass', pass_count, 'fail', fail_count, + 'passRate', pass_rate + )), NOW() + FROM practical_exam_person_stats_monthly + WHERE year = ? + GROUP BY year, employee_id`, + [year], + ); + await conn.execute( + `INSERT INTO practical_exam_scope_stats_yearly + (year, scope_type, scope_key, team, total_count, pass_count, fail_count, + pass_rate, participant_count, monthly_breakdown, computed_at) + SELECT year, scope_type, scope_key, MAX(team), + SUM(total_count), SUM(pass_count), SUM(fail_count), + SUM(pass_count) / NULLIF(SUM(total_count), 0), + MAX(participant_count), + JSON_OBJECTAGG(LPAD(month, 2, '0'), JSON_OBJECT( + 'total', total_count, 'pass', pass_count, 'fail', fail_count, + 'passRate', pass_rate, 'participants', participant_count + )), NOW() + FROM practical_exam_scope_stats_monthly + WHERE year = ? + GROUP BY year, scope_type, scope_key`, + [year], + ); + await conn.execute( + `UPDATE practical_exam_scope_stats_yearly s + SET participant_count = ( + SELECT COUNT(*) + FROM practical_exam_person_stats_yearly p + WHERE p.year = s.year + AND (s.scope_type = 'all' OR p.team = s.team) + ) + WHERE s.year = ?`, + [year], + ); + await conn.execute( + `INSERT INTO practical_exam_stats_yearly + (year, team, paper_id, paper_class, total_count, pass_count, pass_rate, + avg_score, avg_faults, monthly_breakdown, computed_at) + SELECT year, team, paper_id, MAX(paper_class), + SUM(total_count), SUM(pass_count), + SUM(pass_count) / NULLIF(SUM(total_count), 0), + SUM(avg_score * total_count) / NULLIF(SUM(total_count), 0), + SUM(avg_faults * total_count) / NULLIF(SUM(total_count), 0), + JSON_OBJECTAGG(LPAD(month, 2, '0'), JSON_OBJECT( + 'total', total_count, 'pass', pass_count, 'fail', fail_count, + 'passRate', pass_rate + )), NOW() + FROM practical_exam_stats_monthly + WHERE year = ? + GROUP BY year, team, paper_id`, + [year], + ); + }); + const rowCounts = {}; + for (const table of YEAR_TABLES) { + const [row] = await db.query(`SELECT COUNT(*) AS count FROM ${table} WHERE year = ?`, [year]); + rowCounts[table] = Number(row.count); + } + return { + grain: 'year', + period: String(year), + touchedTables: YEAR_TABLES, + rowCounts, + durationMs: Date.now() - startedAt, + }; +} + +async function recomputeAll() { + const periods = await db.query( + 'SELECT DISTINCT exam_year AS year, exam_month AS month FROM practical_exams ORDER BY year, month', + ); + const reports = []; + for (const period of periods) reports.push(await recomputeMonth(period.year, period.month)); + const years = [...new Set(periods.map((row) => row.year))]; + for (const year of years) reports.push(await recomputeYear(year)); + return { + grain: 'all', + touchedTables: [...MONTH_TABLES, ...YEAR_TABLES], + rowCounts: {}, + durationMs: reports.reduce((sum, report) => sum + report.durationMs, 0), + reports, + }; +} + +module.exports = { + module: 'practical_exams', + recomputeMonth, + recomputeYear, + recomputeAll, +}; diff --git a/server/src/precompute/index.js b/server/src/precompute/index.js new file mode 100644 index 0000000..d23c4d6 --- /dev/null +++ b/server/src/precompute/index.js @@ -0,0 +1,17 @@ +'use strict'; + +const registry = require('./registry'); +const kilometers = require('./impls/kilometers'); +const onlineExams = require('./impls/online-exams'); +const practicalExams = require('./impls/practical-exams'); + +/** + * 启动时注册所有 Precomputer(§15.1 checklist 第 4 步:新模块在此 register)。 + */ +function registerAll() { + registry.register(kilometers); + registry.register(onlineExams); + registry.register(practicalExams); +} + +module.exports = { registerAll }; diff --git a/server/src/precompute/precompute.routes.js b/server/src/precompute/precompute.routes.js new file mode 100644 index 0000000..d9a7f5e --- /dev/null +++ b/server/src/precompute/precompute.routes.js @@ -0,0 +1,163 @@ +'use strict'; + +const express = require('express'); +const { ACTIONS } = require('@profile/shared'); +const registry = require('./registry'); +const service = require('./service'); +const dashboardSummary = require('./dashboard-summary'); +const crewMetrics = require('./crew-metrics'); +const kilometersImport = require('../upload/kilometers-import'); +const rbac = require('../rbac/rbac'); +const audit = require('../audit/audit'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); +const { ApiError, API_CODES } = require('../common/api-codes'); +const maintenance = require('../maintenance'); + +const router = express.Router(); + +const MODULES = ['kilometers', 'online_exams', 'practical_exams']; + +/** 全量重算各业务模块预计算表 */ +async function recomputeAllModules() { + const reports = {}; + for (const mod of MODULES) { + try { + reports[mod] = await service.recomputeAll(mod); + } catch (error) { + reports[mod] = { error: error?.message || 'failed' }; + } + } + return reports; +} + +router.get( + '/registered', + requireAuth, + rbac.requirePermission('imports', ACTIONS.READ), + asyncHandler(async (req, res) => { + ok(res, registry.registered()); + }), +); + +/** + * 全量计算(设置页「7.全量计算」): + * 各模块 stats 全量 → 公里控制图 → 人月画像 → dashboard + * 指定 year / employeeId 时仍支持按年/按人(模块 stats 仅在无 employeeId 时全量重算) + */ +router.post( + '/crew-metrics/recompute', + requireAuth, + maintenance.requireDeveloper, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + const { year, employeeId } = req.body || {}; + const y = year != null ? Number(year) : null; + const moduleReports = employeeId ? {} : await recomputeAllModules(); + + // 画像公里维依赖控制图 is_anomaly + const controlReport = await kilometersImport.recomputeAllControlYears( + y != null ? [y] : null, + ); + let report; + if (employeeId && y != null) { + report = await crewMetrics.recomputeEmployeesYears([String(employeeId)], [y]); + } else if (y != null) { + report = await crewMetrics.recomputeYear(y); + } else { + report = await crewMetrics.recomputeAll(); + } + await dashboardSummary.recompute(); + report = { ...report, control: controlReport, modules: moduleReports }; + await audit.log({ + userId: req.user.id, + username: req.user.username, + clientType: 'ui', + action: 'recompute_crew_metrics', + module: 'imports', + payload: { year: y, employeeId: employeeId || null, report }, + ip: req.ip, + userAgent: req.headers['user-agent'], + }); + ok(res, report); + }), +); + +/** + * 单月计算(设置页「8.单月计算」): + * 该月三模块 stats → 该年控制图 → 该年画像 → dashboard + * 必须在 /:module/recompute 之前注册 + */ +router.post( + '/month-all', + requireAuth, + maintenance.requireDeveloper, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + const { year, month } = req.body || {}; + if (year == null || month == null) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少参数:year / month', 400); + } + const y = Number(year); + const m = Number(month); + const reports = {}; + for (const mod of MODULES) { + try { + reports[mod] = await service.recomputePeriod(mod, y, m); + } catch (error) { + reports[mod] = { error: error?.message || 'failed' }; + } + } + try { + reports.control = await kilometersImport.recomputeAllControlYears([y]); + } catch (error) { + reports.control = { error: error?.message || 'failed' }; + } + try { + reports.crew_metrics = await crewMetrics.recomputeYear(y); + } catch (error) { + reports.crew_metrics = { error: error?.message || 'failed' }; + } + await dashboardSummary.recompute(); + await audit.log({ + userId: req.user.id, + username: req.user.username, + clientType: 'ui', + action: 'recompute_month_all', + module: 'imports', + payload: { year: y, month: m, reports }, + ip: req.ip, + userAgent: req.headers['user-agent'], + }); + ok(res, reports); + }), +); + +// 单模块按月重算(开发者调试用) +router.post( + '/:module/recompute', + requireAuth, + maintenance.requireDeveloper, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + const { year, month } = req.body || {}; + if (year == null || month == null) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少参数:year / month', 400); + } + const report = await service.onUploadComplete(req.params.module, Number(year), Number(month)); + await dashboardSummary.recompute(); + await audit.log({ + userId: req.user.id, + username: req.user.username, + clientType: 'ui', + action: 'recompute', + module: req.params.module, + payload: { year: Number(year), month: Number(month), rowCounts: report.rowCounts }, + ip: req.ip, + userAgent: req.headers['user-agent'], + }); + ok(res, report); + }), +); + +module.exports = router; diff --git a/server/src/precompute/registry.js b/server/src/precompute/registry.js new file mode 100644 index 0000000..a696c11 --- /dev/null +++ b/server/src/precompute/registry.js @@ -0,0 +1,50 @@ +'use strict'; + +const { ApiError, API_CODES } = require('../common/api-codes'); + +/** + * 预计算注册表(migration-plan §5.2)。 + * 反模式约束(§15.4):禁止 switch(module),一律注册表分发。 + * + * @typedef {Object} RecomputeReport + * @property {string} module + * @property {'month'|'year'|'all'} grain + * @property {string} period + * @property {string[]} touchedTables + * @property {Record} rowCounts + * @property {number} durationMs + * + * @typedef {Object} ModulePrecomputer + * @property {string} module + * @property {(year:number, month:number) => Promise} recomputeMonth + * @property {(year:number) => Promise} recomputeYear + * @property {() => Promise} recomputeAll + */ + +const precomputers = new Map(); + +/** @param {ModulePrecomputer} impl */ +function register(impl) { + precomputers.set(impl.module, impl); +} + +/** + * @param {string} module + * @returns {ModulePrecomputer} + */ +function get(module) { + const impl = precomputers.get(module); + if (!impl) throw new ApiError(API_CODES.NOT_FOUND, `模块 ${module} 未注册 Precomputer`, 404); + return impl; +} + +function registered() { + return [...precomputers.keys()]; +} + +/** @param {string} module */ +function has(module) { + return precomputers.has(module); +} + +module.exports = { register, get, has, registered }; diff --git a/server/src/precompute/service.js b/server/src/precompute/service.js new file mode 100644 index 0000000..84c82e5 --- /dev/null +++ b/server/src/precompute/service.js @@ -0,0 +1,57 @@ +'use strict'; + +const { MODULE_REGISTRY } = require('@profile/shared'); +const registry = require('./registry'); +const sync = require('../sync/sync'); + +/** + * 导入即算入口(§5.1 阶段 2):月度重算 + 年度重算 + 表级 bumpVersion。 + * @param {string} module + * @param {number} year + * @param {number} month + */ +async function recomputePeriod(module, year, month, includeRaw = false) { + const impl = registry.get(module); + const descriptor = MODULE_REGISTRY.find((item) => item.key === module); + const monthReport = await impl.recomputeMonth(year, month); + const yearReport = await impl.recomputeYear(year); + + const touched = [ + ...new Set([ + includeRaw ? descriptor?.rawTable : null, + ...monthReport.touchedTables, + ...yearReport.touchedTables, + ].filter(Boolean)), + ]; + await sync.bumpVersions(touched); + + return { + module, + grain: 'month', + period: monthReport.period, + touchedTables: touched, + rowCounts: { ...monthReport.rowCounts, ...yearReport.rowCounts }, + durationMs: monthReport.durationMs + yearReport.durationMs, + }; +} + +async function onUploadComplete(module, year, month) { + return recomputePeriod(module, year, month, true); +} + +async function recomputeAll(module) { + const impl = registry.get(module); + const report = await impl.recomputeAll(); + await sync.bumpVersions(report.touchedTables); + return report; +} + +async function onEmployeesChanged() { + const reports = []; + for (const module of registry.registered()) { + reports.push(await recomputeAll(module)); + } + return reports; +} + +module.exports = { onUploadComplete, recomputePeriod, recomputeAll, onEmployeesChanged }; diff --git a/server/src/rbac/rbac.js b/server/src/rbac/rbac.js new file mode 100644 index 0000000..72ced68 --- /dev/null +++ b/server/src/rbac/rbac.js @@ -0,0 +1,96 @@ +'use strict'; + +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const CACHE_TTL_SEC = 300; // 5min(migration-plan §10.5) + +/** + * 查用户角色 code 列表 + * @param {number} userId + * @returns {Promise} + */ +async function getRoleCodes(userId) { + const rows = await db.query( + `SELECT r.code FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = ?`, + [userId], + ); + return rows.map((r) => r.code); +} + +/** + * module -> 位掩码;多角色取并集。Redis 缓存 5min,Redis 不可用则降级直查。 + * @param {number} userId + * @returns {Promise>} + */ +async function getPermissions(userId) { + const cacheKey = `rbac:${userId}`; + try { + const cached = await redis.client.get(cacheKey); + if (cached) return JSON.parse(cached); + } catch (e) { + // 降级直查(§14 风险表:Redis 同机宕机) + } + + const rows = await db.query( + `SELECT rp.module, rp.actions FROM role_permissions rp + JOIN user_roles ur ON ur.role_id = rp.role_id + WHERE ur.user_id = ?`, + [userId], + ); + + const map = {}; + for (const row of rows) { + map[row.module] = (map[row.module] || 0) | Number(row.actions); + } + + try { + await redis.client.set(cacheKey, JSON.stringify(map), 'EX', CACHE_TTL_SEC); + } catch (e) { + /* 缓存写失败可忽略 */ + } + return map; +} + +/** + * @param {number} userId + * @param {string} module + * @param {number} action 位掩码 + * @returns {Promise} + */ +async function hasPermission(userId, module, action) { + const perms = await getPermissions(userId); + return ((perms[module] || 0) & action) === action; +} + +/** 权限变更后清缓存 */ +async function invalidate(userId) { + try { + await redis.client.del(`rbac:${userId}`); + } catch (e) { + /* ignore */ + } +} + +/** + * Express 中间件工厂:要求某模块的某 action 权限。 + * 用在已过 requireAuth 的路由上。 + * @param {string} module + * @param {number} action + */ +function requirePermission(module, action) { + return async (req, res, next) => { + try { + const ok = await hasPermission(req.user.id, module, action); + if (!ok) throw new ApiError(API_CODES.FORBIDDEN, `无权限:${module}`, 403); + next(); + } catch (err) { + next(err); + } + }; +} + +module.exports = { getRoleCodes, getPermissions, hasPermission, invalidate, requirePermission }; diff --git a/server/src/stats/stats.routes.js b/server/src/stats/stats.routes.js new file mode 100644 index 0000000..1495326 --- /dev/null +++ b/server/src/stats/stats.routes.js @@ -0,0 +1,733 @@ +'use strict'; + +const express = require('express'); +const { ACTIONS } = require('@profile/shared'); +const db = require('../infra/db'); +const { requireAuth } = require('../auth/auth'); +const rbac = require('../rbac/rbac'); +const { ok, asyncHandler } = require('../common/http'); +const practicalIncremental = require('../upload/practical-incremental'); +const kilometersImport = require('../upload/kilometers-import'); + +const router = express.Router(); +const practicalCategoryExpr = `CASE + WHEN UPPER(LEFT(TRIM(COALESCE(p.name, '')), 1)) = 'S' THEN '车辆排故' + WHEN UPPER(LEFT(TRIM(COALESCE(p.name, '')), 1)) = 'T' THEN '通号排故' + WHEN UPPER(LEFT(TRIM(COALESCE(p.name, '')), 1)) IN ('J', 'Z') THEN '基础操作' + ELSE '其他' +END`; +const practicalDeviceCategoryExpr = `COALESCE( + NULLIF(REPLACE(JSON_UNQUOTE(JSON_EXTRACT(px.details, '$.deviceType')), '车型', ''), ''), + CASE + WHEN UPPER(p.name) LIKE '%11A01%' THEN '11A01' + WHEN UPPER(p.name) LIKE '%11A02%' THEN '11A02' + WHEN UPPER(p.name) LIKE '%11A03%' THEN '11A03' + ELSE '未分车型' + END +)`; +const PRACTICAL_TEAMS = ['乘务一组', '乘务二组', '乘务三组', '乘务四组', '乘务五组']; +const PRACTICAL_RADAR_AXES = ['车辆排故', '通号排故', '基础操作', '11A01', '11A02', '11A03']; + +function normalizePracticalCategoryRow(row, categoryGroup) { + const passRate = Number(row.pass_rate); + let scoreRate = Number(row.score_rate); + if (!Number.isFinite(scoreRate) || scoreRate < 0) { + scoreRate = Number.isFinite(passRate) ? passRate : null; + } + return { + ...row, + category_group: categoryGroup, + pass_rate: Number.isFinite(passRate) ? passRate : null, + score_rate: scoreRate, + }; +} + +async function queryPracticalCategoryRates({ year, month, isMonthly, team }) { + const periodParams = [year, ...(isMonthly ? [month] : []), ...(team ? [team] : [])]; + // 得分率只统计 score≥0;排除无法归入三大类的「其他」 + const scoreRateExpr = 'AVG(CASE WHEN px.score IS NOT NULL AND px.score >= 0 THEN px.score END) / 100'; + const [paperRows, deviceRows] = await Promise.all([ + db.query( + `SELECT ${practicalCategoryExpr} AS paper_category, + COUNT(*) AS total_count, + SUM(px.result = 'pass') AS pass_count, + SUM(px.result = 'fail') AS fail_count, + SUM(px.result = 'pass') / NULLIF(COUNT(*), 0) AS pass_rate, + ${scoreRateExpr} AS score_rate + FROM practical_exams px + LEFT JOIN papers p ON p.id = px.paper_id + LEFT JOIN employees e + ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + ${team ? 'AND e.team = ?' : ''} + AND px.paper_id IS NOT NULL + AND ${practicalCategoryExpr} IN ('车辆排故', '通号排故', '基础操作') + GROUP BY paper_category + ORDER BY score_rate ASC, total_count DESC`, + periodParams, + ), + db.query( + `SELECT ${practicalDeviceCategoryExpr} AS paper_category, + COUNT(*) AS total_count, + SUM(px.result = 'pass') AS pass_count, + SUM(px.result = 'fail') AS fail_count, + SUM(px.result = 'pass') / NULLIF(COUNT(*), 0) AS pass_rate, + ${scoreRateExpr} AS score_rate + FROM practical_exams px + LEFT JOIN papers p ON p.id = px.paper_id + LEFT JOIN employees e + ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + ${team ? 'AND e.team = ?' : ''} + AND ${practicalDeviceCategoryExpr} IN ('11A01', '11A02', '11A03') + GROUP BY paper_category + ORDER BY + FIELD(paper_category, '11A01', '11A02', '11A03'), + score_rate ASC`, + periodParams, + ), + ]); + return [ + ...paperRows.map((row) => normalizePracticalCategoryRow(row, 'paper')), + ...deviceRows.map((row) => normalizePracticalCategoryRow(row, 'device')), + ]; +} + +async function queryPracticalRadarSeries({ year, month, isMonthly, team }) { + const seriesKeys = team ? [team, null] : PRACTICAL_TEAMS; + const periodParams = [year, ...(isMonthly ? [month] : [])]; + const scoreRateExpr = 'AVG(CASE WHEN px.score IS NOT NULL AND px.score >= 0 THEN px.score END) / 100'; + const [paperByTeam, deviceByTeam, allPaper, allDevice] = await Promise.all([ + db.query( + `SELECT COALESCE(e.team, '未分组') AS team, + ${practicalCategoryExpr} AS paper_category, + ${scoreRateExpr} AS score_rate + FROM practical_exams px + LEFT JOIN papers p ON p.id = px.paper_id + LEFT JOIN employees e + ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + AND px.paper_id IS NOT NULL + AND ${practicalCategoryExpr} IN ('车辆排故', '通号排故', '基础操作') + GROUP BY COALESCE(e.team, '未分组'), paper_category`, + periodParams, + ), + db.query( + `SELECT COALESCE(e.team, '未分组') AS team, + ${practicalDeviceCategoryExpr} AS paper_category, + ${scoreRateExpr} AS score_rate + FROM practical_exams px + LEFT JOIN papers p ON p.id = px.paper_id + LEFT JOIN employees e + ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + AND ${practicalDeviceCategoryExpr} IN ('11A01', '11A02', '11A03') + GROUP BY COALESCE(e.team, '未分组'), paper_category`, + periodParams, + ), + db.query( + `SELECT ${practicalCategoryExpr} AS paper_category, + ${scoreRateExpr} AS score_rate + FROM practical_exams px + LEFT JOIN papers p ON p.id = px.paper_id + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + AND px.paper_id IS NOT NULL + AND ${practicalCategoryExpr} IN ('车辆排故', '通号排故', '基础操作') + GROUP BY paper_category`, + periodParams, + ), + db.query( + `SELECT ${practicalDeviceCategoryExpr} AS paper_category, + ${scoreRateExpr} AS score_rate + FROM practical_exams px + LEFT JOIN papers p ON p.id = px.paper_id + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + AND ${practicalDeviceCategoryExpr} IN ('11A01', '11A02', '11A03') + GROUP BY paper_category`, + periodParams, + ), + ]); + + const teamMaps = new Map(); + for (const row of [...paperByTeam, ...deviceByTeam]) { + if (!teamMaps.has(row.team)) teamMaps.set(row.team, new Map()); + teamMaps.get(row.team).set(String(row.paper_category), Number(row.score_rate)); + } + const allMap = new Map(); + for (const row of [...allPaper, ...allDevice]) { + allMap.set(String(row.paper_category), Number(row.score_rate)); + } + + const toValues = (rateMap) => PRACTICAL_RADAR_AXES.map((axis) => { + const value = rateMap?.get(axis); + return Number.isFinite(value) ? value : null; + }); + + return { + labels: PRACTICAL_RADAR_AXES, + series: seriesKeys.map((key) => ({ + team: key || '全部班组', + values: toValues(key ? teamMaps.get(key) : allMap), + })), + }; +} + +router.get( + '/kilometers-summary', + requireAuth, + rbac.requirePermission('kilometers', ACTIONS.READ), + asyncHandler(async (req, res) => { + await kilometersImport.ensureSchema(); + const year = Number(req.query.year) || new Date().getFullYear(); + const month = req.query.month == null || req.query.month === '' + ? null + : Number(req.query.month); + const team = req.query.team ? String(req.query.team) : null; + const employeeId = req.query.employeeId ? String(req.query.employeeId) : null; + const isMonthly = month != null; + let [overview] = isMonthly + ? await db.query( + `SELECT total_kilometers, driver_count AS unique_employees, + avg_kilometers AS average_kilometers, max_kilometers + FROM kilometers_stats_overall_monthly + WHERE year = ? AND month = ? LIMIT 1`, + [year, month], + ) + : await db.query( + `SELECT total_kilometers, driver_count AS unique_employees, + avg_per_person_month AS average_kilometers, + avg_annual_per_driver, avg_per_person_month, + total_kilometers AS max_kilometers + FROM kilometers_stats_overall_yearly + WHERE year = ? LIMIT 1`, + [year], + ); + const teams = isMonthly + ? await db.query( + `SELECT team, total_kilometers, driver_count, + avg_kilometers, max_kilometers, min_kilometers + FROM kilometers_stats_team_monthly + WHERE year = ? AND month = ? ${team ? 'AND team = ?' : ''} + ORDER BY team`, + [year, month, ...(team ? [team] : [])], + ) + : await db.query( + `SELECT team, total_kilometers, driver_count, avg_kilometers, + avg_annual_per_driver, avg_per_person_month + FROM kilometers_stats_team_yearly + WHERE year = ? ${team ? 'AND team = ?' : ''} + ORDER BY team`, + [year, ...(team ? [team] : [])], + ); + if (team && teams[0]) { + overview = { + ...teams[0], + unique_employees: teams[0].driver_count, + average_kilometers: isMonthly + ? teams[0].avg_kilometers + : teams[0].avg_per_person_month, + max_kilometers: teams[0].max_kilometers || teams[0].total_kilometers, + }; + } + const anomalies = await db.query( + `SELECT month, employee_id, employee_name, team, kilometers, center_line, + warning_lower, warning_upper, control_lower, control_upper, + warning_side, consecutive_warning, limit_breach, anomaly_reason + FROM kilometers_control_stats_monthly + WHERE year = ? ${isMonthly ? 'AND month = ?' : ''} + ${team ? 'AND team = ?' : ''} AND is_anomaly = 1 + ORDER BY month, team, employee_name`, + [year, ...(isMonthly ? [month] : []), ...(team ? [team] : [])], + ); + const controls = await db.query( + `SELECT month, employee_id, baseline_year, baseline_month, center_line, + is_anomaly, anomaly_reason + FROM kilometers_control_stats_monthly + WHERE year = ? ${isMonthly ? 'AND month = ?' : ''} + ${team ? 'AND team = ?' : ''}`, + [year, ...(isMonthly ? [month] : []), ...(team ? [team] : [])], + ); + const controlChart = employeeId + ? await db.query( + `SELECT c.year, c.month, c.employee_id, c.employee_name, c.team, c.kilometers, + c.center_line, c.warning_lower, c.warning_upper, c.control_lower, + c.control_upper, c.is_anomaly, c.anomaly_reason, + t.avg_kilometers AS team_average, + COALESCE(k.has_subsidy, 0) AS has_subsidy + FROM kilometers_control_stats_monthly c + LEFT JOIN kilometers_stats_team_monthly t + ON t.year = c.year AND t.month = c.month AND t.team = c.team + LEFT JOIN kilometers_records k + ON k.year = c.year AND k.month = c.month AND k.employee_id = c.employee_id + WHERE c.year = ? AND c.employee_id = ? ORDER BY c.month`, + [year, employeeId], + ) + : []; + // 总览折线:全年各月平均公里数 + 异常人数(不受 month 筛选影响) + const monthlyAvgRows = team + ? await db.query( + `SELECT month, avg_kilometers AS team_average + FROM kilometers_stats_team_monthly + WHERE year = ? AND team = ? + ORDER BY month`, + [year, team], + ) + : await db.query( + `SELECT month, avg_kilometers AS team_average + FROM kilometers_stats_overall_monthly + WHERE year = ? + ORDER BY month`, + [year], + ); + const monthlyAnomalyRows = await db.query( + `SELECT month, COUNT(*) AS anomaly_count + FROM kilometers_control_stats_monthly + WHERE year = ? AND is_anomaly = 1 ${team ? 'AND team = ?' : ''} + GROUP BY month + ORDER BY month`, + [year, ...(team ? [team] : [])], + ); + const avgByMonth = new Map( + monthlyAvgRows.map((row) => [Number(row.month), Number(row.team_average)]), + ); + const anomalyByMonth = new Map( + monthlyAnomalyRows.map((row) => [Number(row.month), Number(row.anomaly_count) || 0]), + ); + const monthlyTrend = Array.from({ length: 12 }, (_, index) => { + const m = index + 1; + const avg = avgByMonth.get(m); + return { + month: m, + teamAverage: avg == null || Number.isNaN(avg) ? null : avg, + anomalyCount: anomalyByMonth.get(m) || 0, + }; + }); + const row = overview || {}; + ok(res, { + totalKilometers: Number(row.total_kilometers) || 0, + uniqueEmployees: Number(row.unique_employees) || 0, + averageKilometers: Number(row.average_kilometers) || 0, + maxKilometers: Number(row.max_kilometers) || 0, + avgAnnualPerDriver: Number(row.avg_annual_per_driver) || 0, + avgPerPersonMonth: Number(row.avg_per_person_month) || 0, + year, + month, + team, + teams, + anomalies, + controls, + controlChart, + monthlyTrend, + }); + }), +); + +router.get( + '/practical-summary', + requireAuth, + rbac.requirePermission('practical_exams', ACTIONS.READ), + asyncHandler(async (req, res) => { + await practicalIncremental.ensureSchema(); + const year = Number(req.query.year) || new Date().getFullYear(); + const month = req.query.month == null || req.query.month === '' + ? null + : Number(req.query.month); + const team = req.query.team ? String(req.query.team) : null; + const mode = String(req.query.mode || '').trim(); + const isMonthly = month != null; + const scopeTable = isMonthly + ? 'practical_exam_scope_stats_monthly' + : 'practical_exam_scope_stats_yearly'; + const personTable = isMonthly + ? 'practical_exam_person_stats_monthly' + : 'practical_exam_person_stats_yearly'; + const periodWhere = isMonthly ? 'year = ? AND month = ?' : 'year = ?'; + const periodParams = isMonthly ? [year, month] : [year]; + const emptyOverview = { + total_count: 0, + pass_count: 0, + fail_count: 0, + pass_rate: null, + participant_count: 0, + }; + + // 个人弹窗班组对比:只返回 overview + categories,避开弱项/步骤重查询 + if (mode === 'teamCompare') { + const [[overview], categories] = await Promise.all([ + db.query( + `SELECT total_count, pass_count, fail_count, pass_rate, participant_count + FROM ${scopeTable} + WHERE ${periodWhere} AND scope_key = ? + LIMIT 1`, + [...periodParams, team ? `team:${team}` : 'all'], + ), + queryPracticalCategoryRates({ year, month, isMonthly, team }), + ]); + ok(res, { + year, + month, + team, + mode, + overview: overview || emptyOverview, + categories, + }); + return; + } + + const diagnosisTeam = team || '全部'; + const [ + overviewRows, + teams, + people, + mergedCategories, + categoryRadar, + weakPapers, + weakSteps, + ] = await Promise.all([ + db.query( + `SELECT total_count, pass_count, fail_count, pass_rate, participant_count + FROM ${scopeTable} + WHERE ${periodWhere} AND scope_key = ? + LIMIT 1`, + [...periodParams, team ? `team:${team}` : 'all'], + ), + db.query( + `SELECT team, total_count, pass_count, fail_count, pass_rate, participant_count + FROM ${scopeTable} + WHERE ${periodWhere} AND scope_type = 'team' ${team ? 'AND team = ?' : ''} + ORDER BY pass_rate ASC, total_count DESC`, + [...periodParams, ...(team ? [team] : [])], + ), + db.query( + `SELECT employee_id, employee_name, team, total_count, pass_count, fail_count, pass_rate + FROM ${personTable} + WHERE ${periodWhere} ${team ? 'AND team = ?' : ''} AND pass_rate < 0.8 + ORDER BY pass_rate ASC, total_count DESC, employee_id + LIMIT 500`, + [...periodParams, ...(team ? [team] : [])], + ), + queryPracticalCategoryRates({ year, month, isMonthly, team }), + queryPracticalRadarSeries({ year, month, isMonthly, team }), + db.query( + `SELECT s.paper_id, p.name AS paper_name, + ${practicalCategoryExpr} AS paper_category, + s.total_count, s.pass_count, + s.total_count - s.pass_count AS fail_count, s.pass_rate + FROM ${isMonthly ? 'practical_exam_stats_monthly' : 'practical_exam_stats_yearly'} s + LEFT JOIN papers p ON p.id = s.paper_id + WHERE s.year = ? ${isMonthly ? 'AND s.month = ?' : ''} AND s.team = ? + ORDER BY s.pass_rate ASC, s.total_count DESC + LIMIT 10`, + [year, ...(isMonthly ? [month] : []), diagnosisTeam], + ), + db.query( + isMonthly + ? `SELECT s.paper_id, p.name AS paper_name, + ${practicalCategoryExpr} AS paper_category, + s.scenario_index, s.step_index, + s.total_count, s.error_count, + s.error_rate, s.top3_errors + FROM practical_exam_step_stats_monthly s + LEFT JOIN papers p ON p.id = s.paper_id + WHERE s.year = ? AND s.month = ? AND s.team = ? + AND s.error_count > 0 AND s.total_count >= 10 + ORDER BY s.error_rate DESC, s.error_count DESC + LIMIT 10` + : `SELECT s.paper_id, p.name AS paper_name, + ${practicalCategoryExpr} AS paper_category, + s.scenario_index, s.step_index, + SUM(s.total_count) AS total_count, SUM(s.error_count) AS error_count, + SUM(s.error_count) / NULLIF(SUM(s.total_count), 0) AS error_rate, + NULL AS top3_errors + FROM practical_exam_step_stats_monthly s + LEFT JOIN papers p ON p.id = s.paper_id + WHERE s.year = ? AND s.team = ? AND s.error_count > 0 + GROUP BY s.paper_id, p.name, paper_category, s.scenario_index, s.step_index + HAVING SUM(s.total_count) >= 10 + ORDER BY error_rate DESC, error_count DESC + LIMIT 10`, + [year, ...(isMonthly ? [month] : []), diagnosisTeam], + ), + ]); + + const overview = overviewRows[0] || emptyOverview; + const weakPaperIds = weakPapers.map((row) => Number(row.paper_id)).filter(Number.isFinite); + + // 仅对 Top10 弱项补车型 / 失败人员,避免相关子查询与步骤表全扫 + let deviceByPaper = new Map(); + let weakPaperPeople = []; + if (weakPaperIds.length) { + const placeholders = weakPaperIds.map(() => '?').join(','); + const [deviceRows, peopleRows] = await Promise.all([ + db.query( + `SELECT paper_id, + MAX(JSON_UNQUOTE(JSON_EXTRACT(details, '$.deviceType'))) AS device_type + FROM practical_exams + WHERE paper_id IN (${placeholders}) + GROUP BY paper_id`, + weakPaperIds, + ), + db.query( + `SELECT px.paper_id, px.employee_id, + MAX(e.internal_employee_id) AS work_card_id, + MAX(COALESCE(NULLIF(e.name, ''), + JSON_UNQUOTE(JSON_EXTRACT(px.details, '$.employeeName')))) AS employee_name, + MAX(COALESCE(NULLIF(e.team, ''), '未分组')) AS team, + COUNT(*) AS total_count, + SUM(px.result = 'fail') AS fail_count, + SUM(px.result = 'pass') / NULLIF(COUNT(*), 0) AS pass_rate + FROM practical_exams px + LEFT JOIN employees e + ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''} + ${team ? 'AND e.team = ?' : ''} + AND px.paper_id IN (${placeholders}) + GROUP BY px.paper_id, px.employee_id + HAVING SUM(px.result = 'fail') > 0 + ORDER BY fail_count DESC, employee_name, px.employee_id`, + [year, ...(isMonthly ? [month] : []), ...(team ? [team] : []), ...weakPaperIds], + ), + ]); + deviceByPaper = new Map( + deviceRows.map((row) => [Number(row.paper_id), row.device_type || null]), + ); + weakPaperPeople = peopleRows; + } + + const enrichedWeakPapers = weakPapers.map((row) => ({ + ...row, + device_type: deviceByPaper.get(Number(row.paper_id)) || null, + })); + + ok(res, { + year, + month, + team, + overview, + teams, + categories: mergedCategories, + categoryRadar, + people, + weakPapers: enrichedWeakPapers, + weakPaperPeople, + // 步骤详情改用预计算 top3_errors,不再扫 practical_exam_steps + weakSteps, + }); + }), +); + +router.get( + '/online-exam-summary', + requireAuth, + rbac.requirePermission('online_exams', ACTIONS.READ), + asyncHandler(async (req, res) => { + const year = Number(req.query.year) || new Date().getFullYear(); + const month = req.query.month == null || req.query.month === '' + ? null + : Number(req.query.month); + const team = req.query.team ? String(req.query.team) : null; + const isMonthly = month != null && Number.isInteger(month); + const examClauses = ['o.exam_year = ?']; + const examParams = [year]; + if (isMonthly) { + examClauses.push('o.exam_month = ?'); + examParams.push(month); + } + if (team) { + examClauses.push('e.team = ?'); + examParams.push(team); + } + const examWhere = examClauses.join(' AND '); + + const missingClauses = ['LEFT(m.missing_month, 4) = ?']; + const missingParams = [String(year)]; + if (isMonthly) { + missingClauses.push('CAST(SUBSTRING(m.missing_month, 6, 2) AS UNSIGNED) = ?'); + missingParams.push(month); + } + if (team) { + missingClauses.push('COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, "$.team"))) = ?'); + missingParams.push(team); + } + const missingWhere = missingClauses.join(' AND '); + + // 独立查询并行,缩短总览弹窗等待 + const [ + teams, + missingByTeam, + below80People, + missingPeople, + weakQuestions, + categories, + ] = await Promise.all([ + db.query( + `SELECT COALESCE(e.team, '未分组') AS team, + COUNT(*) AS total_count, + SUM(CASE WHEN o.result = 'pass' OR COALESCE(o.score, 0) >= 60 THEN 1 ELSE 0 END) AS pass_count, + SUM(CASE WHEN o.result = 'fail' OR COALESCE(o.score, 0) < 60 THEN 1 ELSE 0 END) AS fail_count, + ROUND(AVG(o.score), 2) AS avg_score, + ROUND( + SUM(CASE WHEN o.result = 'pass' OR COALESCE(o.score, 0) >= 60 THEN 1 ELSE 0 END) + / NULLIF(COUNT(*), 0), + 4 + ) AS pass_rate, + SUM(CASE WHEN o.result = 'fail' OR COALESCE(o.score, 0) < 80 THEN 1 ELSE 0 END) AS below_80_count + FROM online_exams o + LEFT JOIN employees e + ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE ${examWhere} + GROUP BY COALESCE(e.team, '未分组') + ORDER BY team`, + examParams, + ), + db.query( + `SELECT COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.team')), '未分组') AS team, + COUNT(*) AS missing_count + FROM missing_exams m + LEFT JOIN employees e + ON e.employee_id = m.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE ${missingWhere} + GROUP BY COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.team')), '未分组')`, + missingParams, + ), + db.query( + `SELECT o.employee_id, COALESCE(e.name, o.employee_id) AS employee_name, + COALESCE(e.team, '未分组') AS team, o.exam_month, o.score, o.result + FROM online_exams o + LEFT JOIN employees e + ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE ${examWhere} + AND (o.result = 'fail' OR COALESCE(o.score, 0) < 80) + ORDER BY o.exam_month, o.score, e.team`, + examParams, + ), + db.query( + `SELECT m.employee_id, + COALESCE(e.name, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.name')), m.employee_id) AS employee_name, + COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.team')), '未分组') AS team, + CAST(SUBSTRING(m.missing_month, 6, 2) AS UNSIGNED) AS exam_month, + m.reason + FROM missing_exams m + LEFT JOIN employees e + ON e.employee_id = m.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE ${missingWhere} + ORDER BY m.missing_month, team, employee_name`, + missingParams, + ), + db.query( + `SELECT CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED) AS question_id, + MAX(CAST(JSON_UNQUOTE(JSON_EXTRACT(a.val, '$.category')) AS CHAR)) AS category, + COUNT(*) AS total_count, + SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) = 0 THEN 1 ELSE 0 END) AS error_count, + ROUND( + SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) = 0 THEN 1 ELSE 0 END) + / NULLIF(COUNT(*), 0), + 4 + ) AS error_rate, + MAX(COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(q.content, '$.question')), + JSON_UNQUOTE(JSON_EXTRACT(q.content, '$.stem')), + CONCAT('题目#', CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED)) + )) AS question_text + FROM online_exams o + LEFT JOIN employees e + ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + JOIN JSON_TABLE(o.details, '$.answers[*]' COLUMNS (val JSON PATH '$')) a + LEFT JOIN question_banks q + ON q.id = CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED) + AND q.deleted_at IS NULL + WHERE ${examWhere} + AND JSON_EXTRACT(a.val, '$.questionId') IS NOT NULL + GROUP BY CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED) + HAVING total_count >= 5 AND error_rate > 0.3 + ORDER BY error_rate DESC, error_count DESC`, + examParams, + ), + db.query( + `SELECT COALESCE( + NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(a.val, '$.category')) AS CHAR), ''), + '未分类' + ) AS category, + COUNT(*) AS total_count, + SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) > 0 THEN 1 ELSE 0 END) AS pass_count, + SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) = 0 THEN 1 ELSE 0 END) AS error_count, + ROUND( + SUM(CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2))) + / NULLIF(COUNT(*), 0), + 4 + ) AS score_rate + FROM online_exams o + LEFT JOIN employees e + ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL) + JOIN JSON_TABLE(o.details, '$.answers[*]' COLUMNS (val JSON PATH '$')) a + WHERE ${examWhere} + AND JSON_EXTRACT(a.val, '$.score') IS NOT NULL + GROUP BY COALESCE( + NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(a.val, '$.category')) AS CHAR), ''), + '未分类' + ) + ORDER BY score_rate ASC, total_count DESC`, + examParams, + ), + ]); + + const missingMap = new Map(missingByTeam.map((row) => [row.team, Number(row.missing_count) || 0])); + const teamRows = teams.map((row) => ({ + ...row, + missing_count: missingMap.get(row.team) || 0, + })); + for (const [teamName, count] of missingMap.entries()) { + if (!teamRows.some((row) => row.team === teamName)) { + teamRows.push({ + team: teamName, + total_count: 0, + pass_count: 0, + fail_count: 0, + avg_score: null, + pass_rate: null, + below_80_count: 0, + missing_count: count, + }); + } + } + teamRows.sort((a, b) => String(a.team).localeCompare(String(b.team), 'zh')); + + const overview = teamRows.reduce((acc, row) => { + acc.total_count += Number(row.total_count) || 0; + acc.pass_count += Number(row.pass_count) || 0; + acc.fail_count += Number(row.fail_count) || 0; + acc.below_80_count += Number(row.below_80_count) || 0; + acc.missing_count += Number(row.missing_count) || 0; + acc.score_sum += (Number(row.avg_score) || 0) * (Number(row.total_count) || 0); + return acc; + }, { + total_count: 0, + pass_count: 0, + fail_count: 0, + below_80_count: 0, + missing_count: 0, + score_sum: 0, + }); + overview.avg_score = overview.total_count + ? Math.round((overview.score_sum / overview.total_count) * 100) / 100 + : null; + overview.pass_rate = overview.total_count + ? Math.round((overview.pass_count / overview.total_count) * 10000) / 10000 + : null; + delete overview.score_sum; + + ok(res, { + year, + month, + team, + overview, + teams: teamRows, + categories, + below80People, + missingPeople, + weakQuestions, + }); + }), +); + +module.exports = router; diff --git a/server/src/sync/sync.js b/server/src/sync/sync.js new file mode 100644 index 0000000..8672c70 --- /dev/null +++ b/server/src/sync/sync.js @@ -0,0 +1,134 @@ +'use strict'; + +const { allSyncTables } = require('@profile/shared'); +const db = require('../infra/db'); +const crypto = require('../common/crypto'); +const { getColumns, monthWhere } = require('../common/table-utils'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const WHITELIST = new Set(allSyncTables()); + +/** employees:密文列解密后以下发明文(开发决策:A4/员工列表需展示) */ +const EMPLOYEE_PII_SYNC = [ + { plain: 'id_card', enc: 'id_card_enc' }, + { plain: 'mobile', enc: 'mobile_enc' }, + { plain: 'address', enc: 'address_enc' }, + { plain: 'emergency_contact', enc: 'emergency_contact_enc' }, +]; + +function isSyncTable(name) { + return WHITELIST.has(name); +} + +function toBuffer(value) { + if (value == null) return null; + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === 'string') { + // mysql2 偶发返回 hex / binary string + try { + return Buffer.from(value, 'binary'); + } catch { + return null; + } + } + return null; +} + +function decryptEmployeeRows(rows) { + return rows.map((row) => { + const out = { ...row }; + for (const field of EMPLOYEE_PII_SYNC) { + const buf = toBuffer(out[field.enc]); + if (buf && buf.length > 0) { + try { + out[field.plain] = crypto.decrypt(buf); + } catch { + out[field.plain] = null; + } + } else if (out[field.plain] == null) { + out[field.plain] = null; + } + delete out[field.enc]; + } + return out; + }); +} + +/** 各表当前 version(GET /sync/state);仅返回客户端镜像白名单内的表 */ +async function getState() { + const rows = await db.query('SELECT table_name, version, updated_at FROM sync_table_versions ORDER BY table_name'); + return rows + .filter((r) => isSyncTable(r.table_name)) + .map((r) => ({ table: r.table_name, version: Number(r.version), updatedAt: r.updated_at })); +} + +/** + * 整表/分片拉取(GET /sync/table/:name)。 + * PII 密文列(*_enc / *_hmac)永不原样下发;employees 解密后下发明文列(id_card/mobile/address/emergency_contact)。 + * @param {string} name + * @param {number} [year] + * @param {number} [month] + */ +async function getTable(name, year, month) { + if (!WHITELIST.has(name)) { + throw new ApiError(API_CODES.NOT_FOUND, `表不在同步白名单:${name}`, 404); + } + + const columns = await getColumns(name); + if (columns.length === 0) { + throw new ApiError(API_CODES.NOT_FOUND, `表不存在:${name}`, 404); + } + const selected = columns.filter((c) => !c.endsWith('_enc') && !c.endsWith('_hmac')); + const encNeeded = name === 'employees' + ? EMPLOYEE_PII_SYNC.map((f) => f.enc).filter((c) => columns.includes(c)) + : []; + const selectCols = [...selected, ...encNeeded]; + const selectSql = selectCols.map((c) => `\`${c}\``).join(', '); + + let where = ''; + let params = []; + if (year != null && month != null) { + // 字典表无月份维度 → clause 为空,忽略分片参数返回全量 + const mw = monthWhere(columns, year, month); + if (mw.clause) { + where = `WHERE ${mw.clause}`; + params = mw.params; + } + } + + let rows = await db.query(`SELECT ${selectSql} FROM \`${name}\` ${where}`, params); + let outColumns = selected; + if (name === 'employees') { + rows = decryptEmployeeRows(rows); + const plainCols = EMPLOYEE_PII_SYNC.map((f) => f.plain); + outColumns = [...selected.filter((c) => !plainCols.includes(c)), ...plainCols]; + } + const verRows = await db.query('SELECT version FROM sync_table_versions WHERE table_name = ?', [name]); + + return { + table: name, + version: verRows[0] ? Number(verRows[0].version) : 0, + sliced: params.length > 0, + // 空表也必须返回列定义,sql.js 才能创建稳定的 cache_* 镜像。 + columns: outColumns, + rowCount: rows.length, + rows, + }; +} + +/** + * 预计算完成后调用(§5.2):表级 version +1。 + * @param {string[]} tables + */ +async function bumpVersions(tables) { + for (const t of tables) { + await db.execute( + `INSERT INTO sync_table_versions (table_name, version) VALUES (?, 1) + ON DUPLICATE KEY UPDATE version = version + 1`, + [t], + ); + } +} + +module.exports = { getState, getTable, bumpVersions, isSyncTable }; diff --git a/server/src/sync/sync.routes.js b/server/src/sync/sync.routes.js new file mode 100644 index 0000000..340d807 --- /dev/null +++ b/server/src/sync/sync.routes.js @@ -0,0 +1,32 @@ +'use strict'; + +const express = require('express'); +const { ACTIONS } = require('@profile/shared'); +const sync = require('./sync'); +const rbac = require('../rbac/rbac'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); + +router.get( + '/state', + requireAuth, + rbac.requirePermission('sync', ACTIONS.READ), + asyncHandler(async (req, res) => { + ok(res, await sync.getState()); + }), +); + +router.get( + '/table/:name', + requireAuth, + rbac.requirePermission('sync', ACTIONS.READ), + asyncHandler(async (req, res) => { + const year = req.query.year != null && req.query.year !== '' ? Number(req.query.year) : undefined; + const month = req.query.month != null && req.query.month !== '' ? Number(req.query.month) : undefined; + ok(res, await sync.getTable(req.params.name, year, month)); + }), +); + +module.exports = router; diff --git a/server/src/system-settings/system-settings.routes.js b/server/src/system-settings/system-settings.routes.js new file mode 100644 index 0000000..0cf1573 --- /dev/null +++ b/server/src/system-settings/system-settings.routes.js @@ -0,0 +1,71 @@ +'use strict'; + +const express = require('express'); +const { ACTIONS } = require('@profile/shared'); +const db = require('../infra/db'); +const rbac = require('../rbac/rbac'); +const audit = require('../audit/audit'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); + +const router = express.Router(); + +const KNOWN_KEYS = ['manager_name', 'online_exam_responsible']; + +router.get( + '/', + requireAuth, + rbac.requirePermission('dashboard', ACTIONS.READ), + asyncHandler(async (req, res) => { + const rows = await db.query( + `SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN (?)`, + [KNOWN_KEYS], + ); + const result = {}; + for (const key of KNOWN_KEYS) result[key] = ''; + for (const row of rows || []) { + result[row.setting_key] = row.setting_value == null ? '' : String(row.setting_value); + } + ok(res, result); + }), +); + +router.put( + '/', + requireAuth, + rbac.requirePermission('users', ACTIONS.WRITE), + asyncHandler(async (req, res) => { + const body = req.body || {}; + const updates = {}; + for (const key of KNOWN_KEYS) { + if (body[key] != null) updates[key] = String(body[key]).slice(0, 255); + } + const changed = Object.keys(updates); + if (changed.length === 0) { + ok(res, { updated: 0 }); + return; + } + await db.transaction(async (conn) => { + for (const key of changed) { + await conn.execute( + `INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, + [key, updates[key]], + ); + } + }); + await audit.log({ + userId: req.user.id, + username: req.user.username, + clientType: 'ui', + action: 'update_system_settings', + module: 'users', + payload: updates, + ip: req.ip, + userAgent: req.headers['user-agent'], + }); + ok(res, { updated: changed.length, values: updates }); + }), +); + +module.exports = router; diff --git a/server/src/upload/kilometers-import.js b/server/src/upload/kilometers-import.js new file mode 100644 index 0000000..1de9ab6 --- /dev/null +++ b/server/src/upload/kilometers-import.js @@ -0,0 +1,498 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const db = require('../infra/db'); +const sync = require('../sync/sync'); +const audit = require('../audit/audit'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const kilometersPrecompute = require('../precompute/impls/kilometers'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +let schemaReady = null; + +async function hasColumn(table, column) { + const rows = await db.query( + `SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1`, + [table, column], + ); + return rows.length > 0; +} + +async function ensureColumn(table, column, ddl) { + if (!await hasColumn(table, column)) await db.execute(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); +} + +async function ensureSchema() { + if (schemaReady) return schemaReady; + schemaReady = (async () => { + await ensureColumn( + 'kilometers_records', + 'piecework_system', + 'piecework_system DECIMAL(10,4) NOT NULL DEFAULT 0 AFTER kilometers', + ); + await ensureColumn( + 'kilometers_records', + 'has_subsidy', + 'has_subsidy TINYINT(1) NOT NULL DEFAULT 0 AFTER piecework_system', + ); + await ensureColumn( + 'kilometers_stats_employee_yearly', + 'month_count', + 'month_count INT NOT NULL DEFAULT 0 AFTER total_kilometers', + ); + await ensureColumn( + 'kilometers_stats_employee_yearly', + 'avg_monthly_kilometers', + 'avg_monthly_kilometers DECIMAL(12,2) NULL AFTER month_count', + ); + await ensureColumn( + 'kilometers_stats_team_yearly', + 'avg_annual_per_driver', + 'avg_annual_per_driver DECIMAL(12,2) NULL AFTER avg_kilometers', + ); + await ensureColumn( + 'kilometers_stats_team_yearly', + 'avg_per_person_month', + 'avg_per_person_month DECIMAL(12,2) NULL AFTER avg_annual_per_driver', + ); + await db.execute(`CREATE TABLE IF NOT EXISTS kilometers_stats_overall_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, month INT NOT NULL, + total_kilometers DECIMAL(14,2) NOT NULL DEFAULT 0, driver_count INT NOT NULL DEFAULT 0, + avg_kilometers DECIMAL(12,2) NULL, max_kilometers DECIMAL(12,2) NULL, + min_kilometers DECIMAL(12,2) NULL, computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_dim (year, month) + ) ENGINE=InnoDB`); + await db.execute(`CREATE TABLE IF NOT EXISTS kilometers_stats_overall_yearly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, + total_kilometers DECIMAL(14,2) NOT NULL DEFAULT 0, driver_count INT NOT NULL DEFAULT 0, + person_month_count INT NOT NULL DEFAULT 0, avg_annual_per_driver DECIMAL(12,2) NULL, + avg_per_person_month DECIMAL(12,2) NULL, monthly_breakdown JSON NULL, + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_dim (year) + ) ENGINE=InnoDB`); + await db.execute(`CREATE TABLE IF NOT EXISTS kilometers_control_stats_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, month INT NOT NULL, + employee_id VARCHAR(20) NOT NULL, employee_name VARCHAR(50) NULL, + team VARCHAR(50) NOT NULL, kilometers DECIMAL(12,2) NOT NULL DEFAULT 0, + baseline_year INT NULL, baseline_month INT NULL, center_line DECIMAL(12,2) NULL, + warning_lower DECIMAL(12,2) NULL, warning_upper DECIMAL(12,2) NULL, + control_lower DECIMAL(12,2) NULL, control_upper DECIMAL(12,2) NULL, + warning_side ENUM('low','high') NULL, consecutive_warning TINYINT(1) NOT NULL DEFAULT 0, + limit_breach TINYINT(1) NOT NULL DEFAULT 0, is_anomaly TINYINT(1) NOT NULL DEFAULT 0, + anomaly_reason VARCHAR(255) NULL, computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_dim (year, month, employee_id), + KEY idx_anomaly (year, month, is_anomaly), KEY idx_employee_year (employee_id, year) + ) ENGINE=InnoDB`); + })().catch((error) => { + schemaReady = null; + throw error; + }); + return schemaReady; +} + +function normalizeEmployeeId(value) { + const digits = String(value ?? '').replace(/\D/g, ''); + return digits && digits.length <= 5 ? digits.padStart(5, '0') : ''; +} + +function normalizedName(value) { + return String(value ?? '').trim().replace(/\d+$/, ''); +} + +function validateFiles(files) { + if (!Array.isArray(files) || files.length === 0) { + throw new ApiError(API_CODES.BAD_PARAMS, '没有可导入的公里数文件', 400); + } + const periods = new Set(); + const normalized = []; + const validationErrors = []; + for (const file of files) { + const year = Number(file.year); + const month = Number(file.month); + const period = `${year}-${month}`; + if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) { + validationErrors.push(`${file.fileName || '未知文件'}:年月无效`); + continue; + } + if (periods.has(period)) { + validationErrors.push(`${file.fileName || '未知文件'}:存在重复月份 ${period}`); + continue; + } + periods.add(period); + if (!Array.isArray(file.rows) || file.rows.length === 0) { + validationErrors.push(`${file.fileName || '未知文件'}:没有有效数据`); + continue; + } + const seen = new Set(); + for (const source of file.rows) { + const missingPiecework = source.pieceworkSystem == null + || String(source.pieceworkSystem).trim() === ''; + const missingKilometers = source.kilometers == null + || String(source.kilometers).trim() === ''; + const employeeId = normalizeEmployeeId(source.employeeId); + const employeeName = String(source.employeeName ?? '').trim(); + const pieceworkSystem = Number(source.pieceworkSystem); + const kilometers = Number(source.kilometers); + const rowNumber = Number(source.rowNumber) || 0; + if (!/^\d{5}$/.test(employeeId) || !employeeName + || missingPiecework || missingKilometers + || !Number.isFinite(pieceworkSystem) || !Number.isFinite(kilometers)) { + validationErrors.push( + `${file.fileName || '未知文件'} 第${rowNumber || '?'}行:工号、姓名、计件制、公里数必须完整且格式正确`, + ); + continue; + } + if (seen.has(employeeId)) { + validationErrors.push(`${file.fileName || '未知文件'} 第${rowNumber || '?'}行:工号 ${employeeId} 重复`); + continue; + } + seen.add(employeeId); + const hasSubsidy = source.hasSubsidy === true + || source.hasSubsidy === 1 + || source.hasSubsidy === '1' + || String(source.hasSubsidy || '').trim() === '是'; + normalized.push({ + fileName: file.fileName || '', + rowNumber, + year, + month, + employeeId, + employeeName, + pieceworkSystem, + kilometers, + hasSubsidy, + }); + } + } + if (validationErrors.length) { + throw new ApiError( + API_CODES.UPLOAD_VALIDATE_FAILED, + `公里数导入校验失败:${validationErrors.slice(0, 20).join(';')}`, + 400, + ); + } + return { rows: normalized, periods: [...periods] }; +} + +function meanAndStd(values) { + if (!values.length) return null; + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const variance = values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / values.length; + return { mean, stddev: Math.sqrt(variance) }; +} + +async function recomputeControlYear(year) { + await ensureSchema(); + const fromYear = year - 1; + const rows = await db.query( + `SELECT k.year, k.month, k.employee_id, MAX(e.name) AS employee_name, + MAX(e.team) AS team, SUM(k.kilometers) AS kilometers, + MAX(COALESCE(k.has_subsidy, 0)) AS has_subsidy + FROM kilometers_records k + INNER JOIN employees e + ON e.employee_id = k.employee_id AND e.deleted_at IS NULL + AND (e.employment_status = '在职' OR e.employment_status IS NULL) + WHERE ((k.year = ? AND k.month = 12) OR k.year = ?) + AND (e.position = '电客司机' OR e.position = '列车司机') + GROUP BY k.year, k.month, k.employee_id + ORDER BY k.year, k.month, k.employee_id`, + [fromYear, year], + ); + const groupValues = new Map(); + for (const row of rows) { + const key = `${row.year}-${row.month}|${row.team}`; + const values = groupValues.get(key) || []; + values.push(Number(row.kilometers) || 0); + groupValues.set(key, values); + } + // employee_id -> { side: 'high'|'low'|null, wasAnomaly: boolean } + // 同一侧连续偏离时,若上月已记异常,本月不再因「连续 ±1.5σ」重复记一次。 + const previousWarnings = new Map(); + const previousDecember = await db.query( + `SELECT employee_id, warning_side, is_anomaly + FROM kilometers_control_stats_monthly + WHERE year = ? AND month = 12`, + [year - 1], + ); + for (const row of previousDecember) { + previousWarnings.set(String(row.employee_id), { + side: row.warning_side || null, + wasAnomaly: Number(row.is_anomaly) === 1, + }); + } + + const computed = []; + for (const row of rows.filter((item) => Number(item.year) === Number(year))) { + const month = Number(row.month); + const hasSubsidy = Number(row.has_subsidy) === 1; + let baselineYear = month === 1 ? year - 1 : year; + let baselineMonth = month === 1 ? 12 : month - 1; + const previousValues = groupValues.get(`${baselineYear}-${baselineMonth}|${row.team}`) || []; + let baseline = meanAndStd(previousValues); + const canJudge = Boolean(baseline && baseline.stddev > 0); + // 1 月没有上月基线时,仅用本月班组分布绘制参考线,不参与异常判定。 + if (!canJudge && month === 1) { + baselineYear = year; + baselineMonth = 1; + baseline = meanAndStd(groupValues.get(`${year}-1|${row.team}`) || []); + } + let warningSide = null; + let consecutiveWarning = false; + let limitBreach = false; + let reason = null; + let lines = {}; + if (baseline && baseline.stddev > 0) { + lines = { + centerLine: baseline.mean, + warningLower: Math.max(0, baseline.mean - (1.5 * baseline.stddev)), + warningUpper: baseline.mean + (1.5 * baseline.stddev), + controlLower: Math.max(0, baseline.mean - (2 * baseline.stddev)), + controlUpper: baseline.mean + (2 * baseline.stddev), + }; + const mileage = Number(row.kilometers) || 0; + if (canJudge) { + warningSide = mileage < lines.warningLower ? 'low' + : (mileage > lines.warningUpper ? 'high' : null); + limitBreach = mileage < lines.controlLower || mileage > lines.controlUpper; + // 有休假补贴:不记偏低侧异常,也不把偏低 warning 传给下月 + if (hasSubsidy && warningSide === 'low') { + warningSide = null; + if (mileage < lines.controlLower) limitBreach = false; + } else if (hasSubsidy && limitBreach && mileage < lines.controlLower) { + limitBreach = false; + } + const previous = previousWarnings.get(String(row.employee_id)) || {}; + // 连续 ±1.5σ:仅当上月同侧偏离且上月尚未记为异常时,才记本月异常 + // (避免「4 月 +2σ」后「5 月连续 +1.5σ」重复扣一次) + consecutiveWarning = Boolean( + warningSide + && previous.side === warningSide + && !previous.wasAnomaly, + ); + if (limitBreach) reason = mileage < lines.controlLower ? '单月低于下限(-2σ)' : '单月高于上限(+2σ)'; + else if (consecutiveWarning) reason = warningSide === 'low' + ? '连续两个月低于控制线(-1.5σ)' + : '连续两个月高于控制线(+1.5σ)'; + } + } + const isAnomaly = limitBreach || consecutiveWarning; + previousWarnings.set(String(row.employee_id), { + side: warningSide, + wasAnomaly: isAnomaly, + }); + computed.push({ + ...row, + baselineYear: baseline ? baselineYear : null, + baselineMonth: baseline ? baselineMonth : null, + ...lines, + warningSide, + consecutiveWarning, + limitBreach, + isAnomaly, + reason, + }); + } + // 算不出任何行时不要清空该年控制图(避免画像公里分全员变 0) + if (!computed.length) { + console.warn( + `[kilometers-control] year=${year} computed 0 rows ` + + `(joined source=${rows.length}); keep existing control stats`, + ); + return { year, rows: 0, skippedWipe: true }; + } + await db.transaction(async (conn) => { + await conn.execute('DELETE FROM kilometers_control_stats_monthly WHERE year = ?', [year]); + for (let offset = 0; offset < computed.length; offset += 500) { + const chunk = computed.slice(offset, offset + 500); + if (!chunk.length) continue; + const placeholders = chunk.map(() => '(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),0)').join(','); + const values = chunk.flatMap((row) => [ + year, row.month, row.employee_id, row.employee_name, row.team, row.kilometers, + row.baselineYear, row.baselineMonth, row.centerLine ?? null, + row.warningLower ?? null, row.warningUpper ?? null, + row.controlLower ?? null, row.controlUpper ?? null, row.warningSide, + row.consecutiveWarning ? 1 : 0, row.limitBreach ? 1 : 0, + row.isAnomaly ? 1 : 0, row.reason, + ]); + await conn.execute( + `INSERT INTO kilometers_control_stats_monthly + (year, month, employee_id, employee_name, team, kilometers, + baseline_year, baseline_month, center_line, warning_lower, warning_upper, + control_lower, control_upper, warning_side, consecutive_warning, + limit_breach, is_anomaly, anomaly_reason, computed_at, version) + VALUES ${placeholders}`, + values, + ); + } + }); + return { year, rows: computed.length, skippedWipe: false }; +} + +async function importFiles({ files, operator, meta }) { + await ensureSchema(); + const validated = validateFiles(files); + const employeeIds = [...new Set(validated.rows.map((row) => row.employeeId))]; + const employees = employeeIds.length + ? await db.query( + `SELECT employee_id, name, team, position + FROM employees + WHERE deleted_at IS NULL + AND (employment_status = '在职' OR employment_status IS NULL) + AND employee_id IN (${employeeIds.map(() => '?').join(',')})`, + employeeIds, + ) + : []; + const employeeById = new Map(employees.map((row) => [String(row.employee_id), row])); + const skipped = []; + const nameMismatches = []; + const accepted = []; + for (const row of validated.rows) { + const employee = employeeById.get(row.employeeId); + if (!employee) { + skipped.push({ + fileName: row.fileName, + rowNumber: row.rowNumber, + employeeId: row.employeeId, + employeeName: row.employeeName, + reason: '花名册无此工号', + }); + continue; + } + if (normalizedName(employee.name) !== normalizedName(row.employeeName)) { + nameMismatches.push( + `${row.fileName} 第${row.rowNumber}行:${row.employeeId},Excel“${row.employeeName}”与花名册“${employee.name}”不一致`, + ); + continue; + } + accepted.push({ ...row, employee }); + } + if (nameMismatches.length) { + throw new ApiError( + API_CODES.UPLOAD_VALIDATE_FAILED, + `工号与姓名匹配失败,未导入任何数据:${nameMismatches.slice(0, 20).join(';')}`, + 400, + ); + } + if (!accepted.length) { + throw new ApiError(API_CODES.UPLOAD_VALIDATE_FAILED, '没有匹配花名册的有效公里数数据', 400); + } + + const batchUuid = randomUUID(); + const periodPairs = validated.periods.map((period) => { + const [year, month] = period.split('-').map(Number); + return { year, month }; + }); + await db.transaction(async (conn) => { + await conn.execute( + `INSERT INTO upload_batches + (batch_uuid, module, record_count, status, operator_id, payload, locked_at) + VALUES (?, 'kilometers', ?, 'lock-writing', ?, ?, NOW())`, + [batchUuid, accepted.length, operator.id, JSON.stringify({ + files: files.map((file) => file.fileName), + skipped, + })], + ); + // 批量计算责任人快照 + const responsible = require('../common/responsible'); + const respMap = await responsible.computeResponsiblePersonBatch( + accepted.map((r) => String(r.employeeId)), + { conn }, + ); + for (const period of periodPairs) { + await conn.execute('DELETE FROM kilometers_records WHERE year = ? AND month = ?', [ + period.year, + period.month, + ]); + } + for (let offset = 0; offset < accepted.length; offset += 500) { + const chunk = accepted.slice(offset, offset + 500); + const placeholders = chunk.map(() => '(?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW(),0)').join(','); + const values = chunk.flatMap((row) => [ + row.employeeId, + row.employee.name, + row.employee.team, + `${row.year}-${String(row.month).padStart(2, '0')}-01`, + row.year, + row.month, + row.kilometers, + row.pieceworkSystem, + row.hasSubsidy ? 1 : 0, + respMap[String(row.employeeId)] || '', + JSON.stringify({ sourceFile: row.fileName, sourceRow: row.rowNumber }), + null, + batchUuid, + ]); + await conn.execute( + `INSERT INTO kilometers_records + (employee_id, employee_name, team, record_date, year, month, kilometers, + piecework_system, has_subsidy, responsible_person, details, extras, upload_batch, + created_at, updated_at, version) + VALUES ${placeholders}`, + values, + ); + } + await conn.execute( + `UPDATE upload_batches SET status = 'precomputing' WHERE batch_uuid = ?`, + [batchUuid], + ); + }); + + await sync.bumpVersions(['kilometers_records']); + await db.execute( + `UPDATE upload_batches SET status = 'completed', completed_at = NOW() + WHERE batch_uuid = ?`, + [batchUuid], + ); + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'ui', + action: 'kilometers_import_complete', + module: 'kilometers', + targetType: 'upload_batch', + targetId: batchUuid, + payload: { + imported: accepted.length, + skipped: skipped.length, + periods: validated.periods, + }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { + batchUuid, + importedCount: accepted.length, + skippedCount: skipped.length, + skipped, + periods: validated.periods, + }; +} + +/** 按库内已有公里年份全量重算控制图,并 bump 同步版本(供「计算画像分」等入口调用) */ +async function recomputeAllControlYears(years = null) { + await ensureSchema(); + let yearList = Array.isArray(years) + ? [...new Set(years.map(Number).filter((y) => Number.isFinite(y)))] + : []; + if (!yearList.length) { + const rows = await db.query( + `SELECT DISTINCT year FROM kilometers_records ORDER BY year`, + ); + yearList = rows.map((row) => Number(row.year)).filter((y) => Number.isFinite(y)); + } + for (const year of yearList) { + await recomputeControlYear(year); + } + if (yearList.length) { + await sync.bumpVersions(['kilometers_control_stats_monthly']); + } + return { years: yearList, recomputedYears: yearList.length }; +} + +module.exports = { + ensureSchema, + importFiles, + recomputeControlYear, + recomputeAllControlYears, +}; diff --git a/server/src/upload/practical-incremental.js b/server/src/upload/practical-incremental.js new file mode 100644 index 0000000..c2707bb --- /dev/null +++ b/server/src/upload/practical-incremental.js @@ -0,0 +1,470 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const db = require('../infra/db'); +const sync = require('../sync/sync'); +const precompute = require('../precompute/service'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const audit = require('../audit/audit'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const MAX_CHUNK = 2000; +let schemaReady = null; + +async function hasColumn(table, column) { + const rows = await db.query( + `SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? + LIMIT 1`, + [table, column], + ); + return rows.length > 0; +} + +async function hasIndex(table, index) { + const rows = await db.query( + `SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? + LIMIT 1`, + [table, index], + ); + return rows.length > 0; +} + +async function ensureSchema() { + if (schemaReady) return schemaReady; + schemaReady = (async () => { + for (const [table, column, definition] of [ + ['practical_exams', 'source_machine', 'VARCHAR(32) NULL'], + ['practical_exams', 'source_row_id', 'BIGINT NULL'], + ['practical_exam_steps', 'source_machine', 'VARCHAR(32) NULL'], + ['practical_exam_steps', 'source_row_id', 'BIGINT NULL'], + ['practical_exam_steps', 'upload_batch', 'VARCHAR(64) NULL'], + ]) { + if (!(await hasColumn(table, column))) { + await db.execute(`ALTER TABLE \`${table}\` ADD COLUMN \`${column}\` ${definition}`); + } + } + if (!(await hasIndex('practical_exam_steps', 'uk_source_step'))) { + await db.execute( + 'ALTER TABLE practical_exam_steps ADD UNIQUE KEY uk_source_step (exam_uuid, source_row_id)', + ); + } + if (!(await hasIndex('practical_exams', 'idx_source_exam'))) { + await db.execute( + 'ALTER TABLE practical_exams ADD KEY idx_source_exam (source_machine, source_row_id)', + ); + } + + await db.execute(` + CREATE TABLE IF NOT EXISTS practical_exam_person_stats_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, month INT NOT NULL, + employee_id VARCHAR(20) NOT NULL, employee_name VARCHAR(50) NULL, + team VARCHAR(50) NOT NULL, + total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL, + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_dim (year, month, employee_id), + KEY idx_team_period (team, year, month) + ) ENGINE=InnoDB + `); + await db.execute(` + CREATE TABLE IF NOT EXISTS practical_exam_scope_stats_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, month INT NOT NULL, + scope_type ENUM('team','all') NOT NULL, scope_key VARCHAR(64) NOT NULL, + team VARCHAR(50) NULL, + total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL, + participant_count INT NOT NULL DEFAULT 0, + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_dim (year, month, scope_key) + ) ENGINE=InnoDB + `); + await db.execute(` + CREATE TABLE IF NOT EXISTS practical_exam_person_stats_yearly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, employee_id VARCHAR(20) NOT NULL, + employee_name VARCHAR(50) NULL, team VARCHAR(50) NOT NULL, + total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL, + monthly_breakdown JSON NULL, + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_dim (year, employee_id), + KEY idx_team_year (team, year) + ) ENGINE=InnoDB + `); + await db.execute(` + CREATE TABLE IF NOT EXISTS practical_exam_scope_stats_yearly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, + scope_type ENUM('team','all') NOT NULL, scope_key VARCHAR(64) NOT NULL, + team VARCHAR(50) NULL, + total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL, + participant_count INT NOT NULL DEFAULT 0, monthly_breakdown JSON NULL, + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_dim (year, scope_key) + ) ENGINE=InnoDB + `); + })().catch((error) => { + schemaReady = null; + throw error; + }); + return schemaReady; +} + +async function requireBatch(batchUuid) { + const rows = await db.query( + `SELECT * FROM upload_batches + WHERE batch_uuid = ? AND module = 'practical_exams' + LIMIT 1`, + [batchUuid], + ); + if (!rows[0]) throw new ApiError(API_CODES.BATCH_NOT_FOUND, '实训导入批次不存在', 404); + if (!['lock-writing', 'precomputing'].includes(rows[0].status)) { + throw new ApiError(API_CODES.CONFLICT, `批次状态不可写:${rows[0].status}`, 409); + } + return rows[0]; +} + +async function start({ operator, meta, sourceSummary }) { + await ensureSchema(); + const batchUuid = randomUUID(); + const result = await db.execute( + `INSERT INTO upload_batches + (batch_uuid, module, data_type, record_count, status, payload, operator_id, locked_at) + VALUES (?, 'practical_exams', 'sqlite_incremental', 0, 'lock-writing', ?, ?, NOW())`, + [ + batchUuid, + JSON.stringify({ + sourceSummary: sourceSummary || null, + insertedExams: 0, + insertedSteps: 0, + }), + operator.id, + ], + ); + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'ui', + action: 'practical_incremental_start', + module: 'practical_exams', + targetType: 'upload_batch', + targetId: batchUuid, + payload: sourceSummary || {}, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { batchId: result.insertId, batchUuid }; +} + +async function paperMap(rows) { + const uuids = [...new Set(rows.map((row) => row.source_paper_uuid).filter(Boolean))]; + if (uuids.length === 0) return new Map(); + const placeholders = uuids.map(() => '?').join(','); + const papers = await db.query( + `SELECT id, uuid FROM papers WHERE uuid IN (${placeholders})`, + uuids, + ); + return new Map(papers.map((row) => [String(row.uuid), Number(row.id)])); +} + +function json(value) { + return value == null ? null : JSON.stringify(value); +} + +async function ingestExams(batchUuid, rows, createdBy) { + const employeeIds = [...new Set(rows.map((row) => row.employee_id).filter(Boolean))]; + const validEmployees = employeeIds.length + ? await db.query( + `SELECT employee_id FROM employees + WHERE deleted_at IS NULL AND employee_id IN (${employeeIds.map(() => '?').join(',')})`, + employeeIds, + ) + : []; + const allowed = new Set(validEmployees.map((row) => String(row.employee_id))); + const acceptedRows = rows.filter((row) => allowed.has(String(row.employee_id))); + if (acceptedRows.length === 0) return 0; + + const papers = await paperMap(acceptedRows); + const examUuids = acceptedRows.map((row) => row.exam_uuid); + const existingRows = await db.query( + `SELECT exam_uuid FROM practical_exams WHERE exam_uuid IN (${examUuids.map(() => '?').join(',')})`, + examUuids, + ); + const inserted = acceptedRows.length - existingRows.length; + // 批量计算责任人快照: 优先用 details.examinerName(考官), 否则 fallback 到 computeResponsiblePerson + const responsible = require('../common/responsible'); + const respMap = await responsible.computeResponsiblePersonBatch( + acceptedRows.map((r) => String(r.employee_id)), + ); + const params = []; + const placeholders = acceptedRows.map((row) => { + const examinerName = row.details?.examinerName + || (typeof row.details === 'string' ? (safeParseJson(row.details) || {}).examinerName : '') || ''; + params.push( + row.employee_id, + papers.get(String(row.source_paper_uuid)) || null, + row.exam_uuid, + row.source_machine || null, + row.source_row_id ?? null, + row.score ?? null, + row.result || null, + row.faults ?? null, + Number(row.exam_year), + Number(row.exam_month), + row.create_time || null, + row.pc_class || null, + examinerName || respMap[String(row.employee_id)] || '', + json(row.details), + json({ sourcePaperUuid: row.source_paper_uuid || null }), + batchUuid, + createdBy || null, + ); + return `(${Array(17).fill('?').join(',')})`; + }); + await db.execute( + `INSERT INTO practical_exams + (employee_id, paper_id, exam_uuid, source_machine, source_row_id, + score, result, faults, exam_year, exam_month, create_time, pc_class, + responsible_person, details, extras, upload_batch, created_by) + VALUES ${placeholders.join(',')} + ON DUPLICATE KEY UPDATE + employee_id = VALUES(employee_id), + paper_id = VALUES(paper_id), + source_machine = VALUES(source_machine), + source_row_id = VALUES(source_row_id), + score = VALUES(score), + result = VALUES(result), + faults = VALUES(faults), + exam_year = VALUES(exam_year), + exam_month = VALUES(exam_month), + create_time = VALUES(create_time), + pc_class = VALUES(pc_class), + responsible_person = VALUES(responsible_person), + details = VALUES(details), + extras = VALUES(extras), + upload_batch = VALUES(upload_batch)`, + params, + ); + return inserted; +} + +function safeParseJson(s) { + try { return JSON.parse(s) || {}; } catch (_e) { return {}; } +} + +async function ingestSteps(batchUuid, rows) { + const examUuids = [...new Set(rows.map((row) => row.exam_uuid).filter(Boolean))]; + const exams = examUuids.length + ? await db.query( + `SELECT exam_uuid FROM practical_exams + WHERE exam_uuid IN (${examUuids.map(() => '?').join(',')})`, + examUuids, + ) + : []; + const allowed = new Set(exams.map((row) => String(row.exam_uuid))); + const acceptedRows = rows.filter((row) => allowed.has(String(row.exam_uuid))); + if (acceptedRows.length === 0) return 0; + + const papers = await paperMap(acceptedRows); + const params = []; + const placeholders = acceptedRows.map((row) => { + params.push( + row.exam_uuid, + papers.get(String(row.source_paper_uuid)) || null, + row.employee_id, + row.source_machine || null, + row.source_row_id ?? null, + Number(row.scenario_index) || 0, + Number(row.step_index) || 0, + row.operation || null, + row.target || null, + row.is_correct == null ? null : Number(row.is_correct), + row.faults ?? null, + row.exam_date || null, + Number(row.exam_year), + Number(row.exam_month), + json(row.details), + json({ sourcePaperUuid: row.source_paper_uuid || null }), + batchUuid, + ); + return `(${Array(17).fill('?').join(',')})`; + }); + const result = await db.execute( + `INSERT IGNORE INTO practical_exam_steps + (exam_uuid, paper_id, employee_id, source_machine, source_row_id, + scenario_index, step_index, operation, target, is_correct, faults, + exam_date, exam_year, exam_month, details, extras, upload_batch) + VALUES ${placeholders.join(',')}`, + params, + ); + return result.affectedRows || 0; +} + +async function ingest({ batchUuid, type, rows, operator }) { + await ensureSchema(); + await requireBatch(batchUuid); + if (!Array.isArray(rows) || rows.length === 0 || rows.length > MAX_CHUNK) { + throw new ApiError(API_CODES.BAD_PARAMS, `rows 数量必须为 1-${MAX_CHUNK}`, 400); + } + let inserted; + if (type === 'exams') inserted = await ingestExams(batchUuid, rows, operator?.id); + else if (type === 'steps') inserted = await ingestSteps(batchUuid, rows); + else throw new ApiError(API_CODES.BAD_PARAMS, `未知分块类型:${type}`, 400); + + const payloadKey = type === 'exams' ? '$.insertedExams' : '$.insertedSteps'; + await db.execute( + `UPDATE upload_batches + SET record_count = record_count + ?, + payload = JSON_SET(COALESCE(payload, JSON_OBJECT()), ?, COALESCE(JSON_EXTRACT(payload, ?), 0) + ?) + WHERE batch_uuid = ?`, + [type === 'exams' ? inserted : 0, payloadKey, payloadKey, inserted, batchUuid], + ); + return { type, received: rows.length, inserted }; +} + +async function repairImportedMetadata() { + await db.execute( + `UPDATE practical_exams + SET employee_id = LPAD(RIGHT(employee_id, 5), 5, '0') + WHERE CHAR_LENGTH(employee_id) > 5`, + ); + await db.execute( + `UPDATE practical_exams p + JOIN employees e + ON e.internal_employee_id = CASE + WHEN NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.details, '$.sourceWorkId')), '') IS NOT NULL + THEN LPAD(JSON_UNQUOTE(JSON_EXTRACT(p.details, '$.sourceWorkId')), 11, '0') + ELSE CONCAT(LEFT(e.internal_employee_id, 6), RIGHT(p.employee_id, 5)) + END + AND e.deleted_at IS NULL + SET p.employee_id = e.employee_id, + p.details = JSON_SET( + COALESCE(p.details, JSON_OBJECT()), + '$.sourceWorkId', + e.internal_employee_id + )`, + ); + await db.execute( + `UPDATE practical_exam_steps s + JOIN practical_exams p ON p.exam_uuid = s.exam_uuid + SET s.employee_id = p.employee_id`, + ); + await db.execute( + `UPDATE practical_exams + SET details = JSON_SET( + COALESCE(details, JSON_OBJECT()), + '$.deviceType', + COALESCE( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.deviceType')), ''), + CONCAT('车型', source_machine) + ) + ) + WHERE source_machine IS NOT NULL`, + ); + await db.execute( + `UPDATE practical_exams p + JOIN ( + SELECT exam_uuid, + GROUP_CONCAT(scenario_index ORDER BY id SEPARATOR ',') AS error_indices, + JSON_ARRAYAGG( + CONCAT( + CASE + WHEN COALESCE(JSON_EXTRACT(details, '$.isChoice') + 0, 0) = 1 + THEN CONCAT( + '选择题:', COALESCE(NULLIF(operation, ''), '未知题目'), + ';应选:', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.targetOperation')), ''), '-'), + ';实际:', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.actualValue')), ''), '-') + ) + ELSE CONCAT( + '应:', COALESCE(NULLIF(target, ''), '-'), + ' / ', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.targetOperation')), ''), '-'), + ';实际:', COALESCE(NULLIF(operation, ''), '-'), + ' / ', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.actualValue')), ''), '-') + ) + END, + CASE + WHEN NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.reason')), '') IS NOT NULL + AND JSON_UNQUOTE(JSON_EXTRACT(details, '$.reason')) <> '-' + THEN CONCAT('(', JSON_UNQUOTE(JSON_EXTRACT(details, '$.reason')), ')') + ELSE '' + END + ) + ) AS error_steps + FROM practical_exam_steps + WHERE is_correct = 0 OR faults > 0 + GROUP BY exam_uuid + ) errors ON errors.exam_uuid = p.exam_uuid + SET p.details = JSON_SET( + COALESCE(p.details, JSON_OBJECT()), + '$.errorStepIndices', errors.error_indices, + '$.errorSteps', errors.error_steps + )`, + ); +} + +async function finalize({ batchUuid, periods, operator, meta }) { + await ensureSchema(); + await requireBatch(batchUuid); + const normalized = [...new Set((periods || []).map(String))] + .map((period) => { + const match = period.match(/^(\d{4})-(\d{2})$/); + return match ? { year: Number(match[1]), month: Number(match[2]) } : null; + }) + .filter(Boolean); + if (normalized.length === 0) { + throw new ApiError(API_CODES.BAD_PARAMS, '没有可重算的月份', 400); + } + + await db.execute( + `UPDATE upload_batches SET status = 'precomputing' WHERE batch_uuid = ?`, + [batchUuid], + ); + const reports = []; + try { + await repairImportedMetadata(); + await sync.bumpVersions(['practical_exams', 'practical_exam_steps', 'papers', 'paper_steps']); + await db.execute( + `UPDATE upload_batches + SET status = 'completed', completed_at = NOW() + WHERE batch_uuid = ?`, + [batchUuid], + ); + } catch (error) { + await db.execute( + `UPDATE upload_batches SET status = 'failed' WHERE batch_uuid = ?`, + [batchUuid], + ); + throw error; + } + + const [batch] = await db.query( + 'SELECT record_count, payload FROM upload_batches WHERE batch_uuid = ?', + [batchUuid], + ); + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'ui', + action: 'practical_incremental_complete', + module: 'practical_exams', + targetType: 'upload_batch', + targetId: batchUuid, + payload: { periods: normalized, ...(batch?.payload || {}) }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { batchUuid, periods: normalized, reports, payload: batch?.payload || {} }; +} + +module.exports = { ensureSchema, start, ingest, finalize, repairImportedMetadata }; diff --git a/server/src/upload/upload.js b/server/src/upload/upload.js new file mode 100644 index 0000000..74b91e3 --- /dev/null +++ b/server/src/upload/upload.js @@ -0,0 +1,292 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const { MODULE_REGISTRY } = require('@profile/shared'); +const db = require('../infra/db'); +const sync = require('../sync/sync'); +const registry = require('../precompute/registry'); +const precomputeService = require('../precompute/service'); +const dashboardSummary = require('../precompute/dashboard-summary'); +const audit = require('../audit/audit'); +const crypto = require('../common/crypto'); +const { getColumns, monthWhere, buildMultiInsert, chunk } = require('../common/table-utils'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const INSERT_CHUNK = 500; + +// employees 明文 → 密文列映射(§10.2)。address/emergency 无等值查询需求,不建 hmac。 +const EMPLOYEE_PII = [ + { plain: 'id_card', enc: 'id_card_enc', hmac: 'id_card_hmac' }, + { plain: 'mobile', enc: 'mobile_enc', hmac: 'mobile_hmac' }, + { plain: 'address', enc: 'address_enc' }, + { plain: 'emergency_contact', enc: 'emergency_contact_enc' }, +]; + +function findModule(moduleKey) { + const desc = MODULE_REGISTRY.find((m) => m.key === moduleKey); + if (!desc) throw new ApiError(API_CODES.NOT_FOUND, `未知模块:${moduleKey}`, 404); + return desc; +} + +/** employees 行:加密 PII 明文列,剥离明文,生成 *_enc / *_hmac */ +function encryptEmployeeRow(row) { + const out = { ...row }; + for (const f of EMPLOYEE_PII) { + if (out[f.plain] != null && out[f.plain] !== '') { + out[f.enc] = crypto.encrypt(out[f.plain]); + if (f.hmac) out[f.hmac] = crypto.hmac(out[f.plain]); + } + delete out[f.plain]; + } + return out; +} + +/** + * 通用上传(选 A:入参为已解析的 JSON 行数组;Excel 解析后续接入)。 + * 阶段 1:短事务写原始表;阶段 2:导入即算(有 Precomputer 的模块)。 + * + * @param {{ module: string, year?: number, month?: number, rows: Object[], operator: { id:number, username:string }, meta: { ip?:string, userAgent?:string } }} args + */ +async function upload({ module, year, month, rows, operator, meta }) { + const desc = findModule(module); + + if (!Array.isArray(rows) || rows.length === 0) { + throw new ApiError(API_CODES.BAD_PARAMS, 'rows 不能为空(防止误清空整表)', 400); + } + if (desc.uploadMode === 'monthly' && (year == null || month == null)) { + throw new ApiError(API_CODES.BAD_PARAMS, `模块 ${module} 为按月上传,需提供 year / month`, 400); + } + + const table = desc.rawTable; + const columns = await getColumns(table); + if (columns.length === 0) throw new ApiError(API_CODES.NOT_FOUND, `原始表不存在:${table}`, 404); + + let preparedRows = module === 'employees' ? rows.map(encryptEmployeeRow) : rows; + if (module === 'employees') { + const existingRows = await db.query( + `SELECT employee_id, id_card_enc, id_card_hmac, mobile_enc, mobile_hmac, + address_enc, emergency_contact_enc + FROM employees`, + ); + const existingByEmployee = new Map(existingRows.map((row) => [String(row.employee_id), row])); + preparedRows = preparedRows.map((row) => { + const existing = existingByEmployee.get(String(row.employee_id)); + if (!existing) return row; + const merged = { ...row }; + for (const column of [ + 'id_card_enc', 'id_card_hmac', 'mobile_enc', 'mobile_hmac', + 'address_enc', 'emergency_contact_enc', + ]) { + if (merged[column] == null && existing[column] != null) merged[column] = existing[column]; + } + return merged; + }); + } + const batchUuid = randomUUID(); + + // ---- 阶段 1:原始数据事务(短) ---- + const batchId = await db.transaction(async (conn) => { + const [ins] = await conn.execute( + `INSERT INTO upload_batches (batch_uuid, module, year, month, record_count, status, operator_id, locked_at) + VALUES (?, ?, ?, ?, ?, 'lock-writing', ?, NOW())`, + [batchUuid, module, year ?? null, month ?? null, preparedRows.length, operator.id], + ); + + // 同 (module, year, month) 并发串行化(§5.1) + await conn.execute( + `SELECT id FROM upload_batches + WHERE module = ? AND (year <=> ?) AND (month <=> ?) AND status IN ('lock-writing','precomputing') + FOR UPDATE`, + [module, year ?? null, month ?? null], + ); + + // 删旧:按月覆盖 / 全量镜像替换(§5.1 幂等:同月重复上传=覆盖) + if (desc.uploadMode === 'monthly') { + const mw = monthWhere(columns, year, month); + if (mw.clause) await conn.execute(`DELETE FROM \`${table}\` WHERE ${mw.clause}`, mw.params); + } else { + await conn.execute(`DELETE FROM \`${table}\``); + } + + // 写新(分批) + for (const part of chunk(preparedRows, INSERT_CHUNK)) { + const built = buildMultiInsert(table, columns, part); + if (built) await conn.execute(built.sql, built.params); + } + + await conn.execute( + `UPDATE upload_batches SET status = 'precomputing' WHERE id = ?`, + [ins.insertId], + ); + return ins.insertId; + }); + + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'ui', + action: 'upload', + module, + targetType: 'upload_batch', + targetId: batchUuid, + payload: { year, month, recordCount: preparedRows.length }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + + // ---- 阶段 2:仅同步业务表,预计算改为用户手动触发(设置页全量/单月计算)---- + let report = null; + try { + await sync.bumpVersions([table]); + await db.execute(`UPDATE upload_batches SET status = 'completed', completed_at = NOW() WHERE id = ?`, [batchId]); + } catch (err) { + await db.execute(`UPDATE upload_batches SET status = 'failed' WHERE id = ?`, [batchId]); + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'system', + action: 'upload_precompute_failed', + module, + targetId: batchUuid, + payload: { error: err.message }, + }); + throw new ApiError(API_CODES.SERVER_ERROR, `原始数据已入库,但预计算失败:${err.message}(可重试 recompute)`, 500); + } + + return { batchId, batchUuid, recordCount: preparedRows.length, status: 'completed', report }; +} + +async function clearData({ module, year, month, operator, meta }) { + const desc = findModule(module); + if (desc.uploadMode === 'monthly' && (year == null || month == null)) { + throw new ApiError(API_CODES.BAD_PARAMS, `模块 ${module} 为按月数据,清空时需提供 year / month`, 400); + } + const table = desc.rawTable; + const columns = await getColumns(table); + let result; + if (desc.uploadMode === 'monthly') { + const mw = monthWhere(columns, year, month); + if (!mw.clause) throw new ApiError(API_CODES.BAD_PARAMS, `表 ${table} 无法按年月清空`, 400); + result = await db.execute(`DELETE FROM \`${table}\` WHERE ${mw.clause}`, mw.params); + } else { + result = await db.execute(`DELETE FROM \`${table}\``); + } + + await sync.bumpVersions([table]); + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'ui', + action: 'clear', + module, + targetType: desc.uploadMode === 'monthly' ? 'month' : 'table', + targetId: desc.uploadMode === 'monthly' ? `${year}-${String(month).padStart(2, '0')}` : table, + payload: { deletedCount: result.affectedRows || 0 }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return { module, year: year ?? null, month: month ?? null, deletedCount: result.affectedRows || 0 }; +} + +async function uploadPracticalPaperSteps({ rows, operator, meta }) { + if (!Array.isArray(rows) || rows.length === 0) { + throw new ApiError(API_CODES.BAD_PARAMS, 'paperSteps 不能为空', 400); + } + const groups = new Map(); + for (const row of rows) { + const uuid = String(row.paperUUID || row.paperUuid || `legacy-${row.paperId || ''}`).trim(); + if (!uuid || uuid === 'legacy-') { + throw new ApiError(API_CODES.BAD_PARAMS, '标准步骤缺少 paperUUID / paperId', 400); + } + if (!groups.has(uuid)) groups.set(uuid, []); + groups.get(uuid).push(row); + } + + const report = await db.transaction(async (conn) => { + let stepCount = 0; + for (const [uuid, steps] of groups) { + const first = steps[0]; + const scenarioCount = Math.max(...steps.map((row) => Number(row.scenarioIndex) || 0)) + 1; + const [paperResult] = await conn.execute( + `INSERT INTO papers (uuid, name, class, scenario_count, status, details) + VALUES (?, ?, ?, ?, 'active', ?) + ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), + name = VALUES(name), + class = VALUES(class), + scenario_count = VALUES(scenario_count), + details = VALUES(details)`, + [ + uuid, + first.paperName || uuid, + first.paperClass || null, + scenarioCount, + JSON.stringify({ examType: first.examType || null, legacyPaperId: first.paperId ?? null }), + ], + ); + const paperId = paperResult.insertId; + await conn.execute('DELETE FROM paper_steps WHERE paper_id = ?', [paperId]); + const normalized = steps.map((row) => ({ + paper_id: paperId, + scenario_index: Number(row.scenarioIndex) || 0, + step_index: Number(row.stepIndex) || 0, + operation: row.stepName || row.targetOperation || null, + target: row.targetDevice || null, + criteria: row.stepDescription || row.scenarioDescription || null, + score_weight: Number(row.stepScore) >= 0 ? Number(row.stepScore) : null, + details: { + scenarioId: row.scenarioId ?? null, + scenarioName: row.scenarioName || null, + stepId: row.stepId ?? null, + targetOperation: row.targetOperation || null, + operationValue: row.operationValue || null, + deviceType: row.deviceType || null, + deviceTypeName: row.deviceTypeName || null, + deviceId: row.deviceId || null, + itemId: row.itemId || null, + faultTolerance: row.faultTolerance ?? null, + timeout: row.timeout ?? null, + }, + })); + for (const part of chunk(normalized, INSERT_CHUNK)) { + const built = buildMultiInsert('paper_steps', await getColumns('paper_steps'), part); + if (built) await conn.execute(built.sql, built.params); + } + stepCount += normalized.length; + } + return { paperCount: groups.size, stepCount }; + }); + + await sync.bumpVersions(['papers', 'paper_steps']); + await audit.log({ + userId: operator.id, + username: operator.username, + clientType: 'ui', + action: 'upload', + module: 'practical_exams', + targetType: 'paper_steps', + payload: report, + ip: meta.ip, + userAgent: meta.userAgent, + }); + return report; +} + +async function getBatch(id) { + const rows = await db.query('SELECT * FROM upload_batches WHERE id = ? LIMIT 1', [id]); + if (!rows[0]) throw new ApiError(API_CODES.BATCH_NOT_FOUND, `上传批次不存在:${id}`, 404); + return rows[0]; +} + +async function listBatches(moduleKey, limit = 50) { + const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); + if (moduleKey) { + return db.query( + `SELECT * FROM upload_batches WHERE module = ? ORDER BY id DESC LIMIT ${safeLimit}`, + [moduleKey], + ); + } + return db.query(`SELECT * FROM upload_batches ORDER BY id DESC LIMIT ${safeLimit}`); +} + +module.exports = { upload, clearData, uploadPracticalPaperSteps, getBatch, listBatches }; diff --git a/server/src/upload/upload.routes.js b/server/src/upload/upload.routes.js new file mode 100644 index 0000000..dd42157 --- /dev/null +++ b/server/src/upload/upload.routes.js @@ -0,0 +1,138 @@ +'use strict'; + +const express = require('express'); +const { ACTIONS } = require('@profile/shared'); +const upload = require('./upload'); +const practicalIncremental = require('./practical-incremental'); +const kilometersImport = require('./kilometers-import'); +const rbac = require('../rbac/rbac'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); +const { ApiError, API_CODES } = require('../common/api-codes'); +const maintenance = require('../maintenance'); + +const router = express.Router(); +router.use(requireAuth, maintenance.requireDeveloper); + +function meta(req) { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; +} + +// 选 A:body 传已解析的 JSON 行数组;Excel multipart 解析后续接入 +router.post( + '/', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + const { module, year, month, rows } = req.body || {}; + if (!module) throw new ApiError(API_CODES.BAD_PARAMS, '缺少参数:module', 400); + const result = await upload.upload({ + module, + year: year != null ? Number(year) : undefined, + month: month != null ? Number(month) : undefined, + rows, + operator: req.user, + meta: meta(req), + }); + ok(res, result); + }), +); + +router.post( + '/kilometers', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + ok(res, await kilometersImport.importFiles({ + files: req.body && req.body.files, + operator: req.user, + meta: meta(req), + })); + }), +); + +router.post( + '/practical-paper-steps', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + ok(res, await upload.uploadPracticalPaperSteps({ + rows: req.body && req.body.rows, + operator: req.user, + meta: meta(req), + })); + }), +); + +router.post( + '/practical-incremental/start', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + ok(res, await practicalIncremental.start({ + operator: req.user, + meta: meta(req), + sourceSummary: req.body && req.body.sourceSummary, + })); + }), +); + +router.post( + '/practical-incremental/chunk', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + const { batchUuid, type, rows } = req.body || {}; + ok(res, await practicalIncremental.ingest({ batchUuid, type, rows, operator: req.user })); + }), +); + +router.post( + '/practical-incremental/finalize', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + const { batchUuid, periods } = req.body || {}; + ok(res, await practicalIncremental.finalize({ + batchUuid, + periods, + operator: req.user, + meta: meta(req), + })); + }), +); + +router.delete( + '/:module', + requireAuth, + rbac.requirePermission('imports', ACTIONS.IMPORT), + asyncHandler(async (req, res) => { + ok(res, await upload.clearData({ + module: req.params.module, + year: req.query.year != null ? Number(req.query.year) : undefined, + month: req.query.month != null ? Number(req.query.month) : undefined, + operator: req.user, + meta: meta(req), + })); + }), +); + +router.get( + '/batches', + requireAuth, + rbac.requirePermission('imports', ACTIONS.READ), + asyncHandler(async (req, res) => { + ok(res, await upload.listBatches(req.query.module, req.query.size)); + }), +); + +router.get( + '/batches/:id', + requireAuth, + rbac.requirePermission('imports', ACTIONS.READ), + asyncHandler(async (req, res) => { + ok(res, await upload.getBatch(Number(req.params.id))); + }), +); + +module.exports = router; diff --git a/server/src/users/users.js b/server/src/users/users.js new file mode 100644 index 0000000..5ce3e23 --- /dev/null +++ b/server/src/users/users.js @@ -0,0 +1,255 @@ +'use strict'; + +const argon2 = require('argon2'); +const config = require('../config'); +const db = require('../infra/db'); +const redis = require('../infra/redis'); +const rbac = require('../rbac/rbac'); +const audit = require('../audit/audit'); +const { isStrongPassword } = require('../auth/auth'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/; +const REAL_NAME_RE = /^[\u4e00-\u9fa5]{2,16}$/; +const ALLOWED_ROLES = new Set(['admin', 'manager', 'operator', 'viewer', 'ai_readonly']); +const ALLOWED_STATUS = new Set(['active', 'disabled']); + +async function revokeRefreshTokens(userId) { + try { + const jtis = await redis.client.smembers(`user_refresh:${userId}`); + if (jtis.length > 0) await redis.client.del(...jtis.map((j) => `refresh:${j}`)); + await redis.client.del(`user_refresh:${userId}`); + } catch (_) { + /* ignore */ + } +} + +async function listUsers() { + const rows = await db.query( + `SELECT u.id, u.username, u.display_name, u.real_name, u.status, u.last_login_at, u.last_login_ip, + u.pwd_changed_at, u.failed_attempts, u.locked_until, u.created_at + FROM users u + WHERE u.deleted_at IS NULL + ORDER BY u.username`, + ); + const roleRows = await db.query( + `SELECT ur.user_id, r.code, r.name + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id`, + ); + const rolesByUser = new Map(); + for (const row of roleRows) { + if (!rolesByUser.has(row.user_id)) rolesByUser.set(row.user_id, []); + rolesByUser.get(row.user_id).push({ code: row.code, name: row.name }); + } + return rows.map((row) => ({ + id: Number(row.id), + username: row.username, + displayName: row.display_name, + realName: row.real_name || '', + status: row.status, + roles: rolesByUser.get(row.id) || [], + isDeveloper: config.developerUsers.has(row.username), + lastLoginAt: row.last_login_at, + lastLoginIp: row.last_login_ip, + pwdChangedAt: row.pwd_changed_at, + failedAttempts: Number(row.failed_attempts) || 0, + lockedUntil: row.locked_until, + createdAt: row.created_at, + })); +} + +async function createUser(payload, operator, meta) { + const username = String(payload.username || '').trim(); + const displayName = String(payload.displayName || '').trim(); + const realName = String(payload.realName || '').trim(); + const password = payload.password; + const roleCode = String(payload.roleCode || 'operator').trim(); + + if (!USERNAME_RE.test(username)) { + throw new ApiError(API_CODES.BAD_PARAMS, '用户名须为 3-32 位字母、数字、下划线、点或连字符', 400); + } + if (!displayName) { + throw new ApiError(API_CODES.BAD_PARAMS, '显示名称不能为空', 400); + } + if (!realName) { + throw new ApiError(API_CODES.BAD_PARAMS, '姓名不能为空', 400); + } + if (!REAL_NAME_RE.test(realName)) { + throw new ApiError(API_CODES.BAD_PARAMS, '姓名须为 2-16 位中文', 400); + } + if (!isStrongPassword(password)) { + throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400); + } + if (!ALLOWED_ROLES.has(roleCode)) { + throw new ApiError(API_CODES.BAD_PARAMS, '角色无效', 400); + } + + const existing = await db.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]); + if (existing.length) { + throw new ApiError(API_CODES.CONFLICT, '用户名已存在', 409); + } + + const roleRows = await db.query('SELECT id FROM roles WHERE code = ? LIMIT 1', [roleCode]); + if (!roleRows.length) { + throw new ApiError(API_CODES.BAD_PARAMS, '角色不存在', 400); + } + + const hash = await argon2.hash(password, { type: argon2.argon2id }); + let userId = null; + await db.transaction(async (conn) => { + const [insert] = await conn.execute( + 'INSERT INTO users (username, password_hash, display_name, real_name, status) VALUES (?, ?, ?, ?, ?)', + [username, hash, displayName, realName, 'active'], + ); + userId = Number(insert.insertId); + await conn.execute('INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)', [userId, roleRows[0].id]); + }); + + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'admin_create_user', + module: 'users', + targetType: 'user', + targetId: String(userId), + payload: { username, displayName, realName, roleCode }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { id: userId, username, displayName, realName, roleCode }; +} + +async function updateUser(userId, payload, operator, meta) { + const id = Number(userId); + if (!id) throw new ApiError(API_CODES.BAD_PARAMS, '用户 ID 无效', 400); + + const displayName = payload.displayName !== undefined ? String(payload.displayName).trim() : undefined; + const realName = payload.realName !== undefined ? String(payload.realName).trim() : undefined; + if (displayName === undefined && realName === undefined) { + throw new ApiError(API_CODES.BAD_PARAMS, '没有可更新的字段', 400); + } + if (displayName !== undefined && !displayName) { + throw new ApiError(API_CODES.BAD_PARAMS, '显示名称不能为空', 400); + } + if (realName !== undefined) { + if (!realName) { + throw new ApiError(API_CODES.BAD_PARAMS, '姓名不能为空', 400); + } + if (!REAL_NAME_RE.test(realName)) { + throw new ApiError(API_CODES.BAD_PARAMS, '姓名须为 2-16 位中文', 400); + } + } + + const rows = await db.query( + 'SELECT id, username, display_name, real_name FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', + [id], + ); + const row = rows[0]; + if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); + + const nextDisplayName = displayName !== undefined ? displayName : row.display_name; + const nextRealName = realName !== undefined ? realName : (row.real_name || ''); + await db.execute('UPDATE users SET display_name = ?, real_name = ? WHERE id = ?', [ + nextDisplayName, + nextRealName, + id, + ]); + + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'admin_update_user', + module: 'users', + targetType: 'user', + targetId: String(id), + payload: { + targetUsername: row.username, + from: { displayName: row.display_name, realName: row.real_name || '' }, + to: { displayName: nextDisplayName, realName: nextRealName }, + }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { id, username: row.username, displayName: nextDisplayName, realName: nextRealName }; +} + +async function resetPassword(userId, newPassword, operator, meta) { + const id = Number(userId); + if (!id) throw new ApiError(API_CODES.BAD_PARAMS, '用户 ID 无效', 400); + if (!isStrongPassword(newPassword)) { + throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400); + } + + const rows = await db.query('SELECT id, username FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', [id]); + const row = rows[0]; + if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); + + const hash = await argon2.hash(newPassword, { type: argon2.argon2id }); + await db.execute( + 'UPDATE users SET password_hash = ?, pwd_changed_at = NOW(), failed_attempts = 0, locked_until = NULL WHERE id = ?', + [hash, id], + ); + await revokeRefreshTokens(id); + await rbac.invalidate(id); + + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'admin_reset_password', + module: 'users', + targetType: 'user', + targetId: String(id), + payload: { targetUsername: row.username }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { id, username: row.username }; +} + +async function updateStatus(userId, status, operator, meta) { + const id = Number(userId); + if (!id) throw new ApiError(API_CODES.BAD_PARAMS, '用户 ID 无效', 400); + if (!ALLOWED_STATUS.has(status)) { + throw new ApiError(API_CODES.BAD_PARAMS, '状态无效', 400); + } + if (Number(operator.id) === id && status === 'disabled') { + throw new ApiError(API_CODES.BAD_PARAMS, '不能停用自己的账号', 400); + } + + const rows = await db.query('SELECT id, username, status FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', [id]); + const row = rows[0]; + if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404); + + await db.execute('UPDATE users SET status = ? WHERE id = ?', [status, id]); + if (status === 'disabled') { + await revokeRefreshTokens(id); + } + + await audit.log({ + userId: operator.id, + username: operator.username, + roleCode: operator.roles?.[0], + clientType: 'ui', + action: 'admin_update_user_status', + module: 'users', + targetType: 'user', + targetId: String(id), + payload: { targetUsername: row.username, from: row.status, to: status }, + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { id, username: row.username, status }; +} + +module.exports = { listUsers, createUser, updateUser, resetPassword, updateStatus, ALLOWED_ROLES }; diff --git a/server/src/users/users.routes.js b/server/src/users/users.routes.js new file mode 100644 index 0000000..a1e3255 --- /dev/null +++ b/server/src/users/users.routes.js @@ -0,0 +1,69 @@ +'use strict'; + +const express = require('express'); +const users = require('./users'); +const maintenance = require('../maintenance'); +const { requireAuth } = require('../auth/auth'); +const { ok, asyncHandler } = require('../common/http'); +const { ApiError, API_CODES } = require('../common/api-codes'); + +const router = express.Router(); + +function meta(req) { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; +} + +router.use(requireAuth, maintenance.requireDeveloper); + +router.get( + '/', + asyncHandler(async (_req, res) => { + ok(res, await users.listUsers()); + }), +); + +router.post( + '/', + asyncHandler(async (req, res) => { + const { username, displayName, realName, password, roleCode } = req.body || {}; + if (!username || !displayName || !realName || !password) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少用户名、显示名称、姓名或密码', 400); + } + ok(res, await users.createUser({ username, displayName, realName, password, roleCode }, req.user, meta(req))); + }), +); + +router.patch( + '/:id', + asyncHandler(async (req, res) => { + const { displayName, realName } = req.body || {}; + if (displayName === undefined && realName === undefined) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少显示名称或姓名', 400); + } + ok(res, await users.updateUser(req.params.id, { displayName, realName }, req.user, meta(req))); + }), +); + +router.post( + '/:id/reset-password', + asyncHandler(async (req, res) => { + const { newPassword } = req.body || {}; + if (!newPassword) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少新密码', 400); + } + ok(res, await users.resetPassword(req.params.id, newPassword, req.user, meta(req))); + }), +); + +router.patch( + '/:id/status', + asyncHandler(async (req, res) => { + const { status } = req.body || {}; + if (!status) { + throw new ApiError(API_CODES.BAD_PARAMS, '缺少状态', 400); + } + ok(res, await users.updateStatus(req.params.id, status, req.user, meta(req))); + }), +); + +module.exports = router;