移除中台引擎与共享包:Agent 仓库回归纯 L1 客户端,README/.gitignore 还原

This commit is contained in:
fiser_jun 2026-08-20 01:16:28 +08:00
parent b128e33fb8
commit b2c377210e
72 changed files with 6 additions and 14388 deletions

2
.gitignore vendored
View File

@ -11,5 +11,3 @@ docs/api/
*.js *.js
!vendor/** !vendor/**
!assets/** !assets/**
!server/**
!packages/**

View File

@ -1,14 +1,14 @@
# Agent · 城轨智能中台(L1 Web 客户端 + L2 中台引擎) # Agent · MCP 智能体 Web 客户端
基于 MCP(Model Context Protocol)协议的智能体系统:仓库根目录为 **L1 AI 客户端**(Web UI,TypeScript,经 esbuild 编译为浏览器原生 ES module,加载即用);`server/` 为 **L2 中台引擎**(Express API,仅绑定 127.0.0.1) 基于 MCP(Model Context Protocol)协议的智能体 Web 客户端,使用 **TypeScript** 开发,经 esbuild 编译为浏览器原生 ES module,加载即用。
## 项目定位 ## 项目定位
这是一个**协议原生的智能体系统**: 这是一个**协议原生的智能体 Web 客户端**:
- 不依赖任何 Skill 脚本机制 — 工具全部是注册的 JS 函数,AI 只填 JSON Schema 参数 - 不依赖任何 Skill 脚本机制 — 工具全部是注册的 JS 函数,AI 只填 JSON Schema 参数
- 不依赖特定模型 — 4 种模型源任选(Ollama / oMLX / Sparkle / 云端 API),换模型不改业务代码 - 不依赖特定模型 — 4 种模型源任选(Ollama / oMLX / Sparkle / 云端 API),换模型不改业务代码
- 不依赖特定业务 — 通过 MCP 协议接入任意子系统(本仓库自带 L1 webui + L2 中台引擎,业务子系统各自独立部署) - 不依赖特定业务 — 通过 MCP 协议接入任意子系统(本仓库自带 webui,业务子系统各自独立部署)
## 核心特性 ## 核心特性
@ -82,17 +82,6 @@ Agent/
│ ├── md-splitter.ts # Markdown 分模块 │ ├── md-splitter.ts # Markdown 分模块
│ └── markdown.ts # 轻量 Markdown 渲染器 │ └── markdown.ts # 轻量 Markdown 渲染器
├── server/ # L2 中台引擎(Express API,:8088,仅 127.0.0.1)
│ ├── src/main.js # 入口(本地回环监听)
│ ├── src/auth/ # JWT Ed25519 鉴权 + 密钥管理
│ ├── src/audit/ # 审计日志(链式哈希防篡改)
│ ├── src/rbac/ # 角色权限
│ ├── src/import/ upload/ # 数据导入(batch_uuid 幂等防重)
│ ├── src/precompute/ # 业务预计算(AI 不算账,业务系统算好)
│ └── src/export/ # 报表导出工作队列
├── packages/shared/ # 中台公共包(模块注册表 / 常量 / 备份表定义)
└── utils/ # 工具函数 └── utils/ # 工具函数
├── config.ts # 全局配置 + localStorage 持久化 ├── config.ts # 全局配置 + localStorage 持久化
├── metrics-client.ts # 资源监控 + Token 速率 ├── metrics-client.ts # 资源监控 + Token 速率
@ -205,20 +194,9 @@ OLLAMA_ORIGINS=http://localhost:8090 ollama serve
- 有业务子系统 → 子系统启动后,从其日志或 `~/.metro-driver/token` 读取,然后 `http://localhost:8090/?token=<读取的值>` - 有业务子系统 → 子系统启动后,从其日志或 `~/.metro-driver/token` 读取,然后 `http://localhost:8090/?token=<读取的值>`
- 没有业务子系统(只看 webui)→ 留空,前端能跑但 MCP 调用会失败(预期行为) - 没有业务子系统(只看 webui)→ 留空,前端能跑但 MCP 调用会失败(预期行为)
### 可选:启动中台引擎(L2,:8088) ### 可选:启动中台 proxy
仓库已含 L2 中台引擎源码(`server/`): 如果用云端 API / oMLX,浏览器直调会被 CORS 拦截。需要起一个本地 proxy 转发(本项目不含,自行实现):
```bash
cd server
cp .env.example .env
npm install
npm start # 监听 http://127.0.0.1:8088/api/v1
```
职责:请求验证与路由转发、JWT Ed25519 鉴权、链式哈希审计、RBAC、数据导入/导出与预计算。业务子系统(MCP Server,:7777/:7778)不在本仓库。
如用云端 API / oMLX,浏览器直调仍会被 CORS 拦截,需本地 proxy 转发(本项目不含,自行实现):
``` ```
POST /proxy/anthropic-messages # 转发到 Anthropic 兼容端点 POST /proxy/anthropic-messages # 转发到 Anthropic 兼容端点

View File

@ -1,53 +0,0 @@
'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 };

View File

@ -1,16 +0,0 @@
'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 };

View File

@ -1,21 +0,0 @@
'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,
};

View File

@ -1,367 +0,0 @@
'use strict';
// ============================================================
// 模块注册表 —— SOURCE OF TRUTHmigration-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,
};

View File

@ -1,7 +0,0 @@
{
"name": "@profile/shared",
"version": "0.1.0",
"description": "共享模块注册表SSOTmigration-plan §5.4)· 纯 JavaScript",
"main": "index.js",
"type": "commonjs"
}

View File

@ -1,26 +0,0 @@
# 开发环境配置模板(复制为 .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 Ed25519EdDSA。先运行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

View File

@ -1,41 +0,0 @@
# 中台引擎(L2 · :8088)
城轨智能中台四层架构中的 **L2 中台层**,Express API 服务,**仅绑定 127.0.0.1 本机回环**。中台只解决"接入"与"安全",业务能力在各子系统内部实现——本目录不含业务子系统代码。
## 架构位置
```
L1 AI 客户端(:8090)
L2 中台引擎 = 本目录(:8088,仅 127.0.0.1)
↓ MCP 路由
L3 子系统 MCP Server(:7777 排班 / :7778 画像)
↓ dispatcher
L4 业务工具层(子系统内部,2496 个既有业务函数,零侵入)
```
## 核心模块(附具体代码位置)
| 模块 | 位置 | 说明 |
| --- | --- | --- |
| 服务入口 | `src/main.js:20` | `app.listen(config.port, '127.0.0.1')`,本地回环监听 |
| 路由装配 | `src/app.js` | Express 装配 + 鉴权中间件挂载(`:87` 经 `hasPermission` 校验) |
| JWT Ed25519 鉴权 | `src/auth/jwt-ed25519.js:29,47` | `signEdDsa` / `verifyEdDsa`;校验入口 `src/auth/auth.js:28`;密钥强制 ed25519 `src/auth/jwt-keys.js:47` |
| 审计·链式哈希 | `src/audit/audit.js:45,58,90-94` | `:45` 承接上一条 current_hash(首条为 64 个 0);`:58` `SHA-256(prev_hash + canonical(entry))`;`:90-94` 逐条校验,断链返回 brokenAtId——篡改任一条,整链断裂 |
| RBAC 权限 | `src/rbac/rbac.js:64` | `hasPermission(userId, module, action)`;权限缓存 TTL 5 分钟(`:7`) |
| 数据导入 | `src/import/monthly-materials.js`(月度材料)、`monthly-exams.js`(月考)、`personnel-roster.js`(人员名册)、`question-bank.js`(题库);`src/upload/kilometers-import.js`(公里数) | 上传登记 `src/upload/upload.js:90`(batch_uuid 入库)、`:103`(同月重复上传即覆盖,§5.1 幂等) |
| 预计算 | `src/precompute/service.js:13,38,42` | `recomputePeriod` / `onUploadComplete` / `recomputeAll`;指标实现在 `src/precompute/impls/`(公里数/在线考试/实操考试)——AI 不算账,业务系统算好 |
| 导出工作队列 | `src/export/queue.js:12`(BullMQ Queue)、`src/export/worker.js:10`(Worker) | 报表异步生成;另含 `backup.js`(备份) |
| 公共包 | `../packages/shared/` | `module-registry.js`(模块注册)、`constants.js`(常量)、`backup-tables.js`(备份表定义) |
## 运行
```bash
cp .env.example .env # 按需修改(开发占位值,生产密钥走本地密钥目录)
npm install
npm start # node src/main.js
```
## 密钥说明
JWT Ed25519 密钥对由 `scripts/generate-jwt-keys.js` 本地生成,存放于本地密钥目录(`keys/` 已被 .gitignore 排除,任何情况下不入库);PII 字段加密密钥(DATA_ENC_KEY / DATA_HMAC_KEY)同理,生产环境由密钥目录管理。

2465
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +0,0 @@
{
"name": "@profile/server",
"version": "0.1.0",
"description": "Express API纯 JavaScriptmigration-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"
}
}

View File

@ -1,41 +0,0 @@
#!/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();

View File

@ -1,71 +0,0 @@
'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());

View File

@ -1,39 +0,0 @@
'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);
});

View File

@ -1,56 +0,0 @@
'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);
});

View File

@ -1,38 +0,0 @@
'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());

View File

@ -1,31 +0,0 @@
'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);
});

View File

@ -1,20 +0,0 @@
'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());

View File

@ -1,42 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* 开发运维脚本重置用户密码argon2id
* 用法node scripts/set-password.js <username> <newPassword>
* 说明绕过 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 <username> <newPassword>');
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);
});

View File

@ -1,53 +0,0 @@
'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.ipNginx 反代后§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 };

View File

@ -1,102 +0,0 @@
'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<void>}
*/
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 };

View File

@ -1,165 +0,0 @@
'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;

View File

@ -1,255 +0,0 @@
'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 };

View File

@ -1,65 +0,0 @@
'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;

View File

@ -1,75 +0,0 @@
'use strict';
/**
* 最小 Ed25519 JWTalg=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 };

View File

@ -1,59 +0,0 @@
'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 密钥必须为 Ed25519algorithm=EdDSA');
}
return {
privateKey,
publicKey,
kid: process.env.JWT_KEY_ID || 'ed25519-1',
algorithm: 'EdDSA',
};
}
module.exports = { loadJwtKeys, DEFAULT_DIR, DEFAULT_PRIVATE, DEFAULT_PUBLIC };

View File

@ -1,41 +0,0 @@
'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 };

View File

@ -1,48 +0,0 @@
'use strict';
const { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } = require('crypto');
const config = require('../config');
// PII 字段加密migration-plan §10.2AES-256-GCM
// 存储布局VARBINARYiv(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 };

View File

@ -1,63 +0,0 @@
'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<any>} 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 };

View File

@ -1,145 +0,0 @@
'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<Map<string, object>>}
*/
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<string>}
*/
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<Object<string,string>>} { 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,
};

View File

@ -1,88 +0,0 @@
'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<string[]>}
*/
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/标量原样undefinednull
* @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 };

View File

@ -1,36 +0,0 @@
'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 已弃用:现使用 Ed25519EdDSA非对称签名请改用 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;

View File

@ -1,170 +0,0 @@
'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,
};

View File

@ -1,85 +0,0 @@
'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;

View File

@ -1,100 +0,0 @@
'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 };

View File

@ -1,279 +0,0 @@
'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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[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 `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>综合分析报告</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:40px;color:#1f2937}h1{color:#0f766e}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.card{border:1px solid #d1d5db;border-radius:10px;padding:18px}.value{font-size:28px;font-weight:700}</style>
</head><body><h1>${team}</h1><p>${escapeHtml(summaryRows[0]?.computed_at || new Date().toISOString())}</p>
<div class="grid">
<div class="card">员工总数<div class="value">${Number(summary.totalEmployees) || 0}</div></div>
<div class="card">在线考试<div class="value">${Number(summary.totalOnlineExams) || 0}</div></div>
<div class="card">实操考试<div class="value">${Number(summary.totalPracticalExams) || 0}</div></div>
<div class="card">在线通过率<div class="value">${Number(summary.passRate) || 0}%</div></div>
<div class="card">累计公里数<div class="value">${Number(summary.totalKilometers) || 0}</div></div>
<div class="card">驾驶员人数<div class="value">${Number(summary.uniqueKilometerEmployees) || 0}</div></div>
</div></body></html>`;
}
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 };

View File

@ -1,22 +0,0 @@
'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 };

View File

@ -1,77 +0,0 @@
'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');

View File

@ -1,30 +0,0 @@
'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;

View File

@ -1,610 +0,0 @@
'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,
};

View File

@ -1,164 +0,0 @@
'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;

File diff suppressed because it is too large Load Diff

View File

@ -1,833 +0,0 @@
'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 };

View File

@ -1,913 +0,0 @@
'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 };

View File

@ -1,208 +0,0 @@
'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 };

View File

@ -1,62 +0,0 @@
'use strict';
const mysql = require('mysql2/promise');
const config = require('../config');
// mysql2 连接池migration-plan §11API 与 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<any[]>}
*/
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<T>} cb
* @returns {Promise<T>}
*/
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 };

View File

@ -1,21 +0,0 @@
'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 };

View File

@ -1,39 +0,0 @@
'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);
});

View File

@ -1,134 +0,0 @@
'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 };

View File

@ -1,29 +0,0 @@
'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;

View File

@ -1,61 +0,0 @@
'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<string, number>} perms module -> 位掩码
* @returns {Promise<Object[]>}
*/
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 };

View File

@ -1,20 +0,0 @@
'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;

View File

@ -1,954 +0,0 @@
'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-012025-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,
};

View File

@ -1,72 +0,0 @@
'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 };

View File

@ -1,204 +0,0 @@
'use strict';
const db = require('../../infra/db');
/**
* 公里数预计算migration-plan §4.1.3
*
* 口径
* - team / employee_name 一律取自花名册employees按工号 employee_id LEFT JOIN
* 用户决策 2026-07-16team 仅花名册维护其它表通过工号回查§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 班组年度(基于 L1driver_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 };

View File

@ -1,156 +0,0 @@
'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_counttotal 为该维度全部作答数
* - 年度 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 };

View File

@ -1,329 +0,0 @@
'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,
};

View File

@ -1,17 +0,0 @@
'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 };

View File

@ -1,163 +0,0 @@
'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;

View File

@ -1,50 +0,0 @@
'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<string, number>} rowCounts
* @property {number} durationMs
*
* @typedef {Object} ModulePrecomputer
* @property {string} module
* @property {(year:number, month:number) => Promise<RecomputeReport>} recomputeMonth
* @property {(year:number) => Promise<RecomputeReport>} recomputeYear
* @property {() => Promise<RecomputeReport>} 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 };

View File

@ -1,57 +0,0 @@
'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 };

View File

@ -1,96 +0,0 @@
'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; // 5minmigration-plan §10.5
/**
* 查用户角色 code 列表
* @param {number} userId
* @returns {Promise<string[]>}
*/
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 缓存 5minRedis 不可用则降级直查
* @param {number} userId
* @returns {Promise<Record<string, number>>}
*/
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<boolean>}
*/
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 };

View File

@ -1,733 +0,0 @@
'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;

View File

@ -1,134 +0,0 @@
'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;
});
}
/** 各表当前 versionGET /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 };

View File

@ -1,32 +0,0 @@
'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;

View File

@ -1,71 +0,0 @@
'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;

View File

@ -1,498 +0,0 @@
'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,
};

View File

@ -1,470 +0,0 @@
'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 };

View File

@ -1,292 +0,0 @@
'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 };

View File

@ -1,138 +0,0 @@
'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'] };
}
// 选 Abody 传已解析的 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;

View File

@ -1,255 +0,0 @@
'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 };

View File

@ -1,69 +0,0 @@
'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;