新增中台引擎(L2 中台层)源码:server/ + packages/shared
This commit is contained in:
parent
933caa4a09
commit
b8ca18d781
2
.gitignore
vendored
2
.gitignore
vendored
@ -11,3 +11,5 @@ docs/api/
|
|||||||
*.js
|
*.js
|
||||||
!vendor/**
|
!vendor/**
|
||||||
!assets/**
|
!assets/**
|
||||||
|
!server/**
|
||||||
|
!packages/**
|
||||||
|
|||||||
53
packages/shared/backup-tables.js
Normal file
53
packages/shared/backup-tables.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整业务库备份表(profile-backup-v2)。
|
||||||
|
* 刻意排除:登录/RBAC、审计哈希链、导出任务临时表。
|
||||||
|
* 服务端 backup.js 与 Electron 本地导出共用此列表。
|
||||||
|
*/
|
||||||
|
const BACKUP_TABLES = [
|
||||||
|
// 业务原表
|
||||||
|
'employees',
|
||||||
|
'online_exams',
|
||||||
|
'missing_exams',
|
||||||
|
'question_banks',
|
||||||
|
'papers',
|
||||||
|
'paper_steps',
|
||||||
|
'practical_exams',
|
||||||
|
'practical_exam_steps',
|
||||||
|
'kilometers_records',
|
||||||
|
'performance_assessments',
|
||||||
|
'fault_disposals',
|
||||||
|
'incident_events',
|
||||||
|
// 预计算 / 画像 / 看板
|
||||||
|
'online_exam_stats_monthly',
|
||||||
|
'online_exam_question_stats_monthly',
|
||||||
|
'online_exam_stats_yearly',
|
||||||
|
'online_exam_category_stats_monthly',
|
||||||
|
'online_exam_category_stats_yearly',
|
||||||
|
'practical_exam_stats_monthly',
|
||||||
|
'practical_exam_step_stats_monthly',
|
||||||
|
'practical_exam_person_stats_monthly',
|
||||||
|
'practical_exam_scope_stats_monthly',
|
||||||
|
'practical_exam_stats_yearly',
|
||||||
|
'practical_exam_person_stats_yearly',
|
||||||
|
'practical_exam_scope_stats_yearly',
|
||||||
|
'kilometers_stats_employee_monthly',
|
||||||
|
'kilometers_stats_team_monthly',
|
||||||
|
'kilometers_stats_overall_monthly',
|
||||||
|
'kilometers_control_stats_monthly',
|
||||||
|
'kilometers_stats_employee_yearly',
|
||||||
|
'kilometers_stats_team_yearly',
|
||||||
|
'kilometers_stats_overall_yearly',
|
||||||
|
'dashboard_summary',
|
||||||
|
'crew_metrics_employee_yearly',
|
||||||
|
'crew_metrics_employee_monthly',
|
||||||
|
// 元数据
|
||||||
|
'modules',
|
||||||
|
'settings',
|
||||||
|
'upload_batches',
|
||||||
|
'sync_table_versions',
|
||||||
|
'sync_log',
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = { BACKUP_TABLES };
|
||||||
16
packages/shared/constants.js
Normal file
16
packages/shared/constants.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RBAC actions 位掩码(migration-plan §4.1.1)
|
||||||
|
* 组合用按位或:READ|WRITE = 3
|
||||||
|
*/
|
||||||
|
const ACTIONS = {
|
||||||
|
READ: 1,
|
||||||
|
WRITE: 2,
|
||||||
|
DELETE: 4,
|
||||||
|
IMPORT: 8,
|
||||||
|
EXPORT: 16,
|
||||||
|
APPROVE: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { ACTIONS };
|
||||||
21
packages/shared/index.js
Normal file
21
packages/shared/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { ACTIONS } = require('./constants');
|
||||||
|
const {
|
||||||
|
MODULE_REGISTRY,
|
||||||
|
SYNC_TABLE_LABELS,
|
||||||
|
syncTableLabel,
|
||||||
|
allSyncTables,
|
||||||
|
validateRegistry,
|
||||||
|
} = require('./module-registry');
|
||||||
|
const { BACKUP_TABLES } = require('./backup-tables');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ACTIONS,
|
||||||
|
MODULE_REGISTRY,
|
||||||
|
SYNC_TABLE_LABELS,
|
||||||
|
syncTableLabel,
|
||||||
|
allSyncTables,
|
||||||
|
validateRegistry,
|
||||||
|
BACKUP_TABLES,
|
||||||
|
};
|
||||||
367
packages/shared/module-registry.js
Normal file
367
packages/shared/module-registry.js
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 模块注册表 —— SOURCE OF TRUTH(migration-plan §5.4)
|
||||||
|
// 服务端 modules 表仅承载 status/override;本文件为唯一权威源。
|
||||||
|
// 新增模块:在此声明 + DDL + Precomputer,参考 §15 扩展指南。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ModuleDescriptor
|
||||||
|
* @property {string} key
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} rawTable
|
||||||
|
* @property {{ l1Monthly?: string, l1Detail?: string, l2Yearly?: string }} statsTables
|
||||||
|
* @property {'monthly'|'full'|'none'} uploadMode
|
||||||
|
* @property {string[]} uploadDimensions
|
||||||
|
* @property {string} [uploadParser]
|
||||||
|
* @property {string} [precomputer] 有 statsTables 时必填(见 validateRegistry)
|
||||||
|
* @property {Array<'month'|'quarter'|'year'>} statsGrain
|
||||||
|
* @property {string[]} statsDimensions
|
||||||
|
* @property {string} rbacModule
|
||||||
|
* @property {'full_mirror'|'dictionary'|'monthly'|'none'} cacheStrategy
|
||||||
|
* @property {string[]} syncTables
|
||||||
|
* @property {Array<'details'|'extras'>} extensibleFields
|
||||||
|
* @property {{ icon: string, menuGroup: string, sortOrder: number, route: string, pageComponent: string }} ui
|
||||||
|
* @property {boolean} mcpExposed
|
||||||
|
* @property {string[]} [mcpTools]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {ModuleDescriptor[]} */
|
||||||
|
const MODULE_REGISTRY = [
|
||||||
|
{
|
||||||
|
key: 'online_exams',
|
||||||
|
label: '在线考试',
|
||||||
|
rawTable: 'online_exams',
|
||||||
|
statsTables: {
|
||||||
|
l1Monthly: 'online_exam_stats_monthly',
|
||||||
|
l1Detail: 'online_exam_question_stats_monthly',
|
||||||
|
l2Yearly: 'online_exam_stats_yearly',
|
||||||
|
},
|
||||||
|
uploadMode: 'incremental',
|
||||||
|
uploadDimensions: ['exam_uuid', 'source_machine', 'source_row_id'],
|
||||||
|
uploadParser: 'OnlineExamParser',
|
||||||
|
precomputer: 'OnlineExamsPrecomputer',
|
||||||
|
statsGrain: ['month', 'year'],
|
||||||
|
statsDimensions: ['team', 'paper_class'],
|
||||||
|
rbacModule: 'online_exams',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: [
|
||||||
|
'online_exams',
|
||||||
|
'online_exam_stats_monthly',
|
||||||
|
'online_exam_question_stats_monthly',
|
||||||
|
'online_exam_stats_yearly',
|
||||||
|
'online_exam_category_stats_monthly',
|
||||||
|
'online_exam_category_stats_yearly',
|
||||||
|
],
|
||||||
|
extensibleFields: ['details', 'extras'],
|
||||||
|
ui: { icon: 'laptop', menuGroup: 'exams', sortOrder: 10, route: '/online-exams', pageComponent: 'pages/online-exams.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: [
|
||||||
|
'profile_query_online_exam_stats_monthly',
|
||||||
|
'profile_query_online_exam_stats_yearly',
|
||||||
|
'profile_query_online_exam_question_stats',
|
||||||
|
'profile_query_online_exam_trend',
|
||||||
|
'profile_get_pass_rate_ranking',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'practical_exams',
|
||||||
|
label: '实操考试',
|
||||||
|
rawTable: 'practical_exams',
|
||||||
|
statsTables: {
|
||||||
|
l1Monthly: 'practical_exam_stats_monthly',
|
||||||
|
l1Detail: 'practical_exam_step_stats_monthly',
|
||||||
|
l2Yearly: 'practical_exam_stats_yearly',
|
||||||
|
},
|
||||||
|
uploadMode: 'monthly',
|
||||||
|
uploadDimensions: ['year', 'month'],
|
||||||
|
uploadParser: 'PracticalExamParser',
|
||||||
|
precomputer: 'PracticalExamsPrecomputer',
|
||||||
|
statsGrain: ['month', 'year'],
|
||||||
|
statsDimensions: ['employee_id', 'team', 'all', 'paper_id', 'step_index'],
|
||||||
|
rbacModule: 'practical_exams',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: [
|
||||||
|
'practical_exams',
|
||||||
|
'papers',
|
||||||
|
'paper_steps',
|
||||||
|
'practical_exam_stats_monthly',
|
||||||
|
'practical_exam_step_stats_monthly',
|
||||||
|
'practical_exam_person_stats_monthly',
|
||||||
|
'practical_exam_scope_stats_monthly',
|
||||||
|
'practical_exam_stats_yearly',
|
||||||
|
'practical_exam_person_stats_yearly',
|
||||||
|
'practical_exam_scope_stats_yearly',
|
||||||
|
],
|
||||||
|
extensibleFields: ['details', 'extras'],
|
||||||
|
ui: { icon: 'tools', menuGroup: 'exams', sortOrder: 20, route: '/practical-exams', pageComponent: 'pages/practical-exams.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: [
|
||||||
|
'profile_query_practical_exam_stats_monthly',
|
||||||
|
'profile_query_practical_exam_stats_yearly',
|
||||||
|
'profile_query_practical_exam_step_error_rate',
|
||||||
|
'profile_query_practical_exam_top_errors',
|
||||||
|
'profile_query_paper_list',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kilometers',
|
||||||
|
label: '公里数',
|
||||||
|
rawTable: 'kilometers_records',
|
||||||
|
statsTables: {
|
||||||
|
l1Monthly: 'kilometers_stats_team_monthly',
|
||||||
|
l1Detail: 'kilometers_stats_employee_monthly',
|
||||||
|
l2Yearly: 'kilometers_stats_team_yearly',
|
||||||
|
},
|
||||||
|
uploadMode: 'monthly',
|
||||||
|
uploadDimensions: ['year', 'month'],
|
||||||
|
uploadParser: 'KilometersParser',
|
||||||
|
precomputer: 'KilometersPrecomputer',
|
||||||
|
statsGrain: ['month', 'year'],
|
||||||
|
statsDimensions: ['team', 'employee_id'],
|
||||||
|
rbacModule: 'kilometers',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: [
|
||||||
|
'kilometers_records',
|
||||||
|
'kilometers_stats_employee_monthly',
|
||||||
|
'kilometers_stats_team_monthly',
|
||||||
|
'kilometers_stats_overall_monthly',
|
||||||
|
'kilometers_control_stats_monthly',
|
||||||
|
'kilometers_stats_employee_yearly',
|
||||||
|
'kilometers_stats_team_yearly',
|
||||||
|
'kilometers_stats_overall_yearly',
|
||||||
|
],
|
||||||
|
extensibleFields: ['details', 'extras'],
|
||||||
|
ui: { icon: 'road', menuGroup: 'operation', sortOrder: 30, route: '/kilometers', pageComponent: 'pages/kilometers.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: [
|
||||||
|
'profile_query_kilometers_team_monthly',
|
||||||
|
'profile_query_kilometers_team_yearly',
|
||||||
|
'profile_query_kilometers_employee',
|
||||||
|
'profile_query_kilometers_top_drivers',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'employees',
|
||||||
|
label: '员工档案',
|
||||||
|
rawTable: 'employees',
|
||||||
|
statsTables: {},
|
||||||
|
uploadMode: 'full',
|
||||||
|
uploadDimensions: [],
|
||||||
|
uploadParser: 'EmployeeParser',
|
||||||
|
statsGrain: [],
|
||||||
|
statsDimensions: [],
|
||||||
|
rbacModule: 'employees',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: ['employees'],
|
||||||
|
extensibleFields: ['details', 'extras'],
|
||||||
|
ui: { icon: 'users', menuGroup: 'base', sortOrder: 1, route: '/employees', pageComponent: 'pages/employees.js' },
|
||||||
|
mcpExposed: true, // 脱敏后
|
||||||
|
mcpTools: [
|
||||||
|
'profile_query_employees',
|
||||||
|
'profile_get_employee_by_id',
|
||||||
|
'profile_query_team_roster',
|
||||||
|
'profile_query_employee_history',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'question_banks',
|
||||||
|
label: '题库',
|
||||||
|
rawTable: 'question_banks',
|
||||||
|
statsTables: {},
|
||||||
|
uploadMode: 'full',
|
||||||
|
uploadDimensions: [],
|
||||||
|
uploadParser: 'QuestionBankParser',
|
||||||
|
statsGrain: [],
|
||||||
|
statsDimensions: [],
|
||||||
|
rbacModule: 'questions',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: ['question_banks'],
|
||||||
|
extensibleFields: ['details', 'extras'],
|
||||||
|
ui: { icon: 'book', menuGroup: 'base', sortOrder: 5, route: '/question-banks', pageComponent: 'pages/question-banks.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: ['profile_query_question_bank', 'profile_get_paper_steps', 'profile_query_question_categories'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'missing_exams',
|
||||||
|
label: '缺考',
|
||||||
|
rawTable: 'missing_exams',
|
||||||
|
statsTables: {},
|
||||||
|
uploadMode: 'monthly',
|
||||||
|
uploadDimensions: ['year', 'month'],
|
||||||
|
uploadParser: 'MissingExamParser',
|
||||||
|
statsGrain: [],
|
||||||
|
statsDimensions: [],
|
||||||
|
rbacModule: 'online_exams', // 缺考归在线考试权限域(§4.1.4.4)
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: ['missing_exams'],
|
||||||
|
extensibleFields: ['details', 'extras'],
|
||||||
|
ui: { icon: 'user-x', menuGroup: 'exams', sortOrder: 15, route: '/missing-exams', pageComponent: 'pages/missing-exams.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: ['profile_query_missing_exams', 'profile_get_missing_summary'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'performance_assessments',
|
||||||
|
label: '绩效考核',
|
||||||
|
rawTable: 'performance_assessments',
|
||||||
|
statsTables: {},
|
||||||
|
uploadMode: 'full',
|
||||||
|
uploadDimensions: [],
|
||||||
|
uploadParser: 'MonthlyMaterialsParser',
|
||||||
|
statsGrain: [],
|
||||||
|
statsDimensions: ['team', 'employee_id'],
|
||||||
|
rbacModule: 'performance',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: ['performance_assessments'],
|
||||||
|
extensibleFields: [],
|
||||||
|
ui: { icon: 'award', menuGroup: 'operation', sortOrder: 40, route: '/performance-assessments', pageComponent: 'pages/imported-records.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: ['profile_query_performance_assessments'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fault_disposals',
|
||||||
|
label: '故障处置',
|
||||||
|
rawTable: 'fault_disposals',
|
||||||
|
statsTables: {},
|
||||||
|
uploadMode: 'full',
|
||||||
|
uploadDimensions: [],
|
||||||
|
uploadParser: 'MonthlyMaterialsParser',
|
||||||
|
statsGrain: [],
|
||||||
|
statsDimensions: ['fault_type', 'employee_id'],
|
||||||
|
rbacModule: 'faults',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: ['fault_disposals'],
|
||||||
|
extensibleFields: [],
|
||||||
|
ui: { icon: 'alert-triangle', menuGroup: 'operation', sortOrder: 41, route: '/fault-disposals', pageComponent: 'pages/imported-records.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: ['profile_query_fault_disposals'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'incident_events',
|
||||||
|
label: '事故事件',
|
||||||
|
rawTable: 'incident_events',
|
||||||
|
statsTables: {},
|
||||||
|
uploadMode: 'full',
|
||||||
|
uploadDimensions: [],
|
||||||
|
uploadParser: 'MonthlyMaterialsParser',
|
||||||
|
statsGrain: [],
|
||||||
|
statsDimensions: ['fault_category', 'employee_id'],
|
||||||
|
rbacModule: 'incidents',
|
||||||
|
cacheStrategy: 'full_mirror',
|
||||||
|
syncTables: ['incident_events'],
|
||||||
|
extensibleFields: [],
|
||||||
|
ui: { icon: 'shield-alert', menuGroup: 'operation', sortOrder: 42, route: '/incident-events', pageComponent: 'pages/imported-records.js' },
|
||||||
|
mcpExposed: true,
|
||||||
|
mcpTools: ['profile_query_incident_events'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端镜像表中文短名(同步进度展示用)
|
||||||
|
* 键为逻辑表名;未收录时回退为原英文名。
|
||||||
|
*/
|
||||||
|
const SYNC_TABLE_LABELS = {
|
||||||
|
employees: '员工档案',
|
||||||
|
question_banks: '题库',
|
||||||
|
missing_exams: '缺考明细',
|
||||||
|
performance_assessments: '绩效考核',
|
||||||
|
fault_disposals: '故障处置',
|
||||||
|
incident_events: '事故事件',
|
||||||
|
online_exams: '在线考试明细',
|
||||||
|
online_exam_stats_monthly: '在线考试月度统计',
|
||||||
|
online_exam_question_stats_monthly: '在线考试题目月度统计',
|
||||||
|
online_exam_stats_yearly: '在线考试年度统计',
|
||||||
|
online_exam_category_stats_monthly: '在线考试分类月度统计',
|
||||||
|
online_exam_category_stats_yearly: '在线考试分类年度统计',
|
||||||
|
practical_exams: '实操考试明细',
|
||||||
|
papers: '实操试卷',
|
||||||
|
paper_steps: '实操试卷步骤',
|
||||||
|
practical_exam_stats_monthly: '实操考试月度统计',
|
||||||
|
practical_exam_step_stats_monthly: '实操步骤月度统计',
|
||||||
|
practical_exam_person_stats_monthly: '实操个人月度统计',
|
||||||
|
practical_exam_scope_stats_monthly: '实操范围月度统计',
|
||||||
|
practical_exam_stats_yearly: '实操考试年度统计',
|
||||||
|
practical_exam_person_stats_yearly: '实操个人年度统计',
|
||||||
|
practical_exam_scope_stats_yearly: '实操范围年度统计',
|
||||||
|
kilometers_records: '公里数明细',
|
||||||
|
kilometers_stats_employee_monthly: '员工公里数月度统计',
|
||||||
|
kilometers_stats_team_monthly: '班组公里数月度统计',
|
||||||
|
kilometers_stats_overall_monthly: '整体公里数月度统计',
|
||||||
|
kilometers_control_stats_monthly: '公里数控制图月度统计',
|
||||||
|
kilometers_stats_employee_yearly: '员工公里数年度统计',
|
||||||
|
kilometers_stats_team_yearly: '班组公里数年度统计',
|
||||||
|
kilometers_stats_overall_yearly: '整体公里数年度统计',
|
||||||
|
dashboard_summary: '首页看板摘要',
|
||||||
|
crew_metrics_employee_yearly: '乘务画像年度指标',
|
||||||
|
crew_metrics_employee_monthly: '乘务画像月度指标',
|
||||||
|
practical_exam_steps: '实操步骤明细',
|
||||||
|
modules: '模块元数据',
|
||||||
|
settings: '系统配置',
|
||||||
|
upload_batches: '上传批次',
|
||||||
|
sync_table_versions: '同步版本',
|
||||||
|
sync_log: '同步日志',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} table
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function syncTableLabel(table) {
|
||||||
|
return SYNC_TABLE_LABELS[table] || table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部参与同步的表(去重),供 SyncModule 白名单使用
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function allSyncTables() {
|
||||||
|
const set = new Set();
|
||||||
|
for (const m of MODULE_REGISTRY) {
|
||||||
|
for (const t of m.syncTables) set.add(t);
|
||||||
|
}
|
||||||
|
set.add('dashboard_summary');
|
||||||
|
set.add('crew_metrics_employee_yearly');
|
||||||
|
set.add('crew_metrics_employee_monthly');
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册表结构校验(启动时调用)。
|
||||||
|
* 规则(review v3.x N5 定稿):statsTables 非空 ⇒ precomputer 必填。
|
||||||
|
* @param {ModuleDescriptor[]} [registry]
|
||||||
|
* @returns {string[]} 错误列表(空数组表示通过)
|
||||||
|
*/
|
||||||
|
function validateRegistry(registry = MODULE_REGISTRY) {
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const m of registry) {
|
||||||
|
if (seen.has(m.key)) errors.push(`duplicate module key: ${m.key}`);
|
||||||
|
seen.add(m.key);
|
||||||
|
|
||||||
|
const hasStats = Object.values(m.statsTables || {}).some(Boolean);
|
||||||
|
if (hasStats && !m.precomputer) {
|
||||||
|
errors.push(`module ${m.key}: has statsTables but no precomputer`);
|
||||||
|
}
|
||||||
|
if (m.uploadMode === 'monthly' && (!m.uploadDimensions || m.uploadDimensions.length === 0)) {
|
||||||
|
errors.push(`module ${m.key}: uploadMode=monthly requires uploadDimensions`);
|
||||||
|
}
|
||||||
|
if (m.mcpExposed && (!m.mcpTools || m.mcpTools.length === 0)) {
|
||||||
|
errors.push(`module ${m.key}: mcpExposed but no mcpTools declared`);
|
||||||
|
}
|
||||||
|
for (const t of m.mcpTools || []) {
|
||||||
|
if (!/^profile_(query|get|list)_[a-z0-9_]+$/.test(t)) {
|
||||||
|
errors.push(`module ${m.key}: mcp tool "${t}" violates naming (§16.4.1, 只读动词)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MODULE_REGISTRY,
|
||||||
|
SYNC_TABLE_LABELS,
|
||||||
|
syncTableLabel,
|
||||||
|
allSyncTables,
|
||||||
|
validateRegistry,
|
||||||
|
};
|
||||||
7
packages/shared/package.json
Normal file
7
packages/shared/package.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "@profile/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "共享模块注册表(SSOT,migration-plan §5.4)· 纯 JavaScript",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
26
server/.env.example
Normal file
26
server/.env.example
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# 开发环境配置模板(复制为 .env)
|
||||||
|
# 数据库/Redis 对应 deploy/docker-compose.dev.yml
|
||||||
|
DATABASE_URL="mysql://profile:profile_dev_pass@127.0.0.1:3306/profile_dev"
|
||||||
|
REDIS_URL="redis://127.0.0.1:6379"
|
||||||
|
|
||||||
|
# JWT Ed25519(EdDSA)。先运行:node scripts/generate-jwt-keys.js
|
||||||
|
# 默认读取 apps/server/keys/jwt_ed25519_{private,public}.pem
|
||||||
|
# JWT_PRIVATE_KEY_PATH="/absolute/path/to/jwt_ed25519_private.pem"
|
||||||
|
# JWT_PUBLIC_KEY_PATH="/absolute/path/to/jwt_ed25519_public.pem"
|
||||||
|
# 或直接贴 PEM(换行写成 \n):
|
||||||
|
# JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||||
|
# JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||||
|
JWT_KEY_ID=ed25519-1
|
||||||
|
JWT_ACCESS_TTL_MIN=30
|
||||||
|
JWT_REFRESH_TTL_HOURS=8
|
||||||
|
|
||||||
|
# PII 字段加密(§10.2;生产由密钥目录管理,切勿用开发值)
|
||||||
|
DATA_ENC_KEY="dev-only-data-encryption-key-change-me"
|
||||||
|
DATA_HMAC_KEY="dev-only-data-hmac-key-change-me"
|
||||||
|
|
||||||
|
# 可选;不配置时默认使用仓库 data/exports
|
||||||
|
EXPORT_DIR="/absolute/path/to/profile/data/exports"
|
||||||
|
EXPORT_RETENTION_DAYS=7
|
||||||
|
DEVELOPER_USERNAMES="dev_admin"
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
18
server/README.md
Normal file
18
server/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 中台引擎(L2 · :8088)
|
||||||
|
|
||||||
|
城轨智能中台四层架构中的 L2 中台层:Express API 服务,仅绑定 127.0.0.1。
|
||||||
|
|
||||||
|
- 入口:`src/main.js`(`app.listen(port, '127.0.0.1')`)
|
||||||
|
- 职责:请求验证与路由转发、审计(链式哈希)、JWT Ed25519 鉴权、RBAC、数据导入/导出、预计算
|
||||||
|
- 协议:向上承接 L1 客户端(经 CORS 代理),向下经 MCP 路由对接 L3 子系统(:7777/:7778)
|
||||||
|
- 依赖 `packages/shared`(与本目录一同提供)
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # 按需修改
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
密钥说明:JWT Ed25519 密钥由 `scripts/generate-jwt-keys.js` 本地生成,存放于本地密钥目录,**不入库**。
|
||||||
2465
server/package-lock.json
generated
Normal file
2465
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
server/package.json
Normal file
34
server/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@profile/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Express API(纯 JavaScript,migration-plan §6/§12 · v1.9)",
|
||||||
|
"main": "src/main.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/main.js",
|
||||||
|
"start:dev": "node --watch src/main.js",
|
||||||
|
"worker": "node src/export/worker.js",
|
||||||
|
"set-password": "node scripts/set-password.js",
|
||||||
|
"generate-jwt-keys": "node scripts/generate-jwt-keys.js",
|
||||||
|
"migrate:missing-exams": "node scripts/migrate-missing-exams.js",
|
||||||
|
"migrate:p45": "node scripts/migrate-p45.js",
|
||||||
|
"migrate:monthly-materials": "node scripts/migrate-monthly-materials.js",
|
||||||
|
"migrate:monthly-exams": "node scripts/migrate-monthly-exams.js",
|
||||||
|
"migrate:user-real-name": "node scripts/migrate-user-real-name.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@profile/shared": "file:../../packages/shared",
|
||||||
|
"argon2": "^0.41.0",
|
||||||
|
"bullmq": "^5.80.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"mysql2": "^3.11.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
server/scripts/generate-jwt-keys.js
Normal file
41
server/scripts/generate-jwt-keys.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 JWT 用 Ed25519 密钥对(PEM)。
|
||||||
|
* 用法:node scripts/generate-jwt-keys.js [--force]
|
||||||
|
* 输出目录默认:apps/server/keys/(已 gitignore)
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { generateKeyPairSync } = require('crypto');
|
||||||
|
const { DEFAULT_DIR, DEFAULT_PRIVATE, DEFAULT_PUBLIC } = require('../src/auth/jwt-keys');
|
||||||
|
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!force && (fs.existsSync(DEFAULT_PRIVATE) || fs.existsSync(DEFAULT_PUBLIC))) {
|
||||||
|
console.error('密钥已存在。若要覆盖请加 --force');
|
||||||
|
console.error(` private: ${DEFAULT_PRIVATE}`);
|
||||||
|
console.error(` public: ${DEFAULT_PUBLIC}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(DEFAULT_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||||
|
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
||||||
|
const pubPem = publicKey.export({ type: 'spki', format: 'pem' });
|
||||||
|
|
||||||
|
fs.writeFileSync(DEFAULT_PRIVATE, privPem, { mode: 0o600 });
|
||||||
|
fs.writeFileSync(DEFAULT_PUBLIC, pubPem, { mode: 0o644 });
|
||||||
|
|
||||||
|
console.log('已生成 Ed25519 JWT 密钥对:');
|
||||||
|
console.log(` private: ${DEFAULT_PRIVATE} (mode 600)`);
|
||||||
|
console.log(` public: ${DEFAULT_PUBLIC}`);
|
||||||
|
console.log('可选 .env:');
|
||||||
|
console.log(` JWT_PRIVATE_KEY_PATH=${DEFAULT_PRIVATE}`);
|
||||||
|
console.log(` JWT_PUBLIC_KEY_PATH=${DEFAULT_PUBLIC}`);
|
||||||
|
console.log(' JWT_KEY_ID=ed25519-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
71
server/scripts/migrate-missing-exams.js
Normal file
71
server/scripts/migrate-missing-exams.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../src/infra/db');
|
||||||
|
const sync = require('../src/sync/sync');
|
||||||
|
const audit = require('../src/audit/audit');
|
||||||
|
const dashboardSummary = require('../src/precompute/dashboard-summary');
|
||||||
|
const precompute = require('../src/precompute');
|
||||||
|
const precomputeService = require('../src/precompute/service');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
precompute.registerAll();
|
||||||
|
const legacy = await db.query(
|
||||||
|
`SELECT id, employee_id, exam_year, exam_month, submit_time, details, extras
|
||||||
|
FROM online_exams
|
||||||
|
WHERE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(details, '$.isMissingExam')), 'false') = 'true'`,
|
||||||
|
);
|
||||||
|
const periods = new Set();
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
for (const row of legacy) {
|
||||||
|
const month = `${row.exam_year}-${String(row.exam_month).padStart(2, '0')}`;
|
||||||
|
const details = typeof row.details === 'string' ? JSON.parse(row.details) : (row.details || {});
|
||||||
|
const extras = typeof row.extras === 'string' ? JSON.parse(row.extras) : row.extras;
|
||||||
|
const [existing] = await conn.execute(
|
||||||
|
'SELECT id FROM missing_exams WHERE employee_id = ? AND missing_month = ? LIMIT 1',
|
||||||
|
[row.employee_id, month],
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO missing_exams
|
||||||
|
(employee_id, missing_month, reason, import_time, details, extras, version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0)`,
|
||||||
|
[
|
||||||
|
row.employee_id,
|
||||||
|
month,
|
||||||
|
details.reason || '缺考',
|
||||||
|
row.submit_time || new Date(),
|
||||||
|
JSON.stringify({ ...details, legacyOnlineExamId: row.id }),
|
||||||
|
extras == null ? null : JSON.stringify(extras),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await conn.execute('DELETE FROM online_exams WHERE id = ?', [row.id]);
|
||||||
|
periods.add(`${row.exam_year}-${row.exam_month}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (legacy.length > 0) {
|
||||||
|
await sync.bumpVersions(['online_exams', 'missing_exams']);
|
||||||
|
for (const period of periods) {
|
||||||
|
const [year, month] = period.split('-').map(Number);
|
||||||
|
await precomputeService.onUploadComplete('online_exams', year, month);
|
||||||
|
}
|
||||||
|
await dashboardSummary.recompute();
|
||||||
|
await audit.log({
|
||||||
|
clientType: 'system',
|
||||||
|
action: 'migrate_missing_exams',
|
||||||
|
module: 'missing_exams',
|
||||||
|
payload: { migratedCount: legacy.length, periods: [...periods] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ migratedCount: legacy.length, periods: [...periods] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => db.close());
|
||||||
39
server/scripts/migrate-monthly-exams.js
Normal file
39
server/scripts/migrate-monthly-exams.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../src/infra/db');
|
||||||
|
|
||||||
|
const TABLES = [
|
||||||
|
'online_exam_category_stats_monthly',
|
||||||
|
'online_exam_category_stats_yearly',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const schema = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../../deploy/mysql/init/01-schema.sql'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
for (const table of TABLES) {
|
||||||
|
const start = schema.indexOf(`CREATE TABLE ${table} (`);
|
||||||
|
const marker = ') ENGINE=InnoDB;';
|
||||||
|
const end = schema.indexOf(marker, start);
|
||||||
|
if (start < 0 || end < 0) throw new Error(`未找到 ${table} DDL`);
|
||||||
|
const statement = schema.slice(start, end + marker.length);
|
||||||
|
await db.execute(statement.replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS'));
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO sync_table_versions (table_name, version) VALUES (?, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE table_name = VALUES(table_name)`,
|
||||||
|
[table],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('monthly exams category stats schema migrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => db.close())
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
await db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
56
server/scripts/migrate-monthly-materials.js
Normal file
56
server/scripts/migrate-monthly-materials.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../src/infra/db');
|
||||||
|
|
||||||
|
const TABLES = ['performance_assessments', 'fault_disposals', 'incident_events'];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const schema = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../../deploy/mysql/init/01-schema.sql'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
for (const table of TABLES) {
|
||||||
|
const start = schema.indexOf(`CREATE TABLE ${table} (`);
|
||||||
|
const marker = ') ENGINE=InnoDB;';
|
||||||
|
const end = schema.indexOf(marker, start);
|
||||||
|
if (start < 0 || end < 0) throw new Error(`未找到 ${table} DDL`);
|
||||||
|
const statement = schema.slice(start, end + marker.length);
|
||||||
|
await db.execute(statement.replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS'));
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO sync_table_versions (table_name, version) VALUES (?, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE table_name = VALUES(table_name)`,
|
||||||
|
[table],
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO modules (\`key\`, status, notes) VALUES (?, 'active', ?)
|
||||||
|
ON DUPLICATE KEY UPDATE status = 'active', notes = VALUES(notes)`,
|
||||||
|
[table, { performance_assessments: '绩效考核', fault_disposals: '故障处置', incident_events: '事故事件' }[table]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.execute('ALTER TABLE fault_disposals MODIFY final_result TEXT NULL');
|
||||||
|
|
||||||
|
for (const module of ['performance', 'faults', 'incidents', 'audit']) {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO role_permissions (role_id, module, actions)
|
||||||
|
SELECT id, ?, 1 FROM roles
|
||||||
|
ON DUPLICATE KEY UPDATE actions = actions | 1`,
|
||||||
|
[module],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO role_permissions (role_id, module, actions)
|
||||||
|
SELECT id, 'exports', 16 FROM roles
|
||||||
|
ON DUPLICATE KEY UPDATE actions = actions | 16`,
|
||||||
|
);
|
||||||
|
console.log('monthly materials schema migrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => db.close())
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
await db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
38
server/scripts/migrate-p45.js
Normal file
38
server/scripts/migrate-p45.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../src/infra/db');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await db.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS export_jobs (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
job_uuid CHAR(36) NOT NULL,
|
||||||
|
export_type VARCHAR(32) NOT NULL,
|
||||||
|
params JSON NULL,
|
||||||
|
status ENUM('queued','processing','completed','failed','expired') NOT NULL DEFAULT 'queued',
|
||||||
|
progress INT NOT NULL DEFAULT 0,
|
||||||
|
requested_by BIGINT NOT NULL,
|
||||||
|
file_name VARCHAR(255) NULL,
|
||||||
|
file_path VARCHAR(1024) NULL,
|
||||||
|
mime_type VARCHAR(128) NULL,
|
||||||
|
file_size BIGINT NULL,
|
||||||
|
file_hash CHAR(64) NULL,
|
||||||
|
error VARCHAR(1000) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
expires_at DATETIME NULL,
|
||||||
|
UNIQUE KEY uk_job_uuid (job_uuid),
|
||||||
|
KEY idx_requested_created (requested_by, created_at),
|
||||||
|
KEY idx_status_expires (status, expires_at)
|
||||||
|
) ENGINE=InnoDB`,
|
||||||
|
);
|
||||||
|
console.log('P4.5 schema ready: export_jobs');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => db.close());
|
||||||
31
server/scripts/migrate-reward-amount.js
Normal file
31
server/scripts/migrate-reward-amount.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../src/infra/db');
|
||||||
|
|
||||||
|
async function hasColumn(table, column) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1`,
|
||||||
|
[table, column],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (await hasColumn('performance_assessments', 'reward_amount')) {
|
||||||
|
console.log('reward_amount already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
`ALTER TABLE performance_assessments
|
||||||
|
ADD COLUMN reward_amount DECIMAL(12,2) NULL AFTER performance_points`,
|
||||||
|
);
|
||||||
|
console.log('added performance_assessments.reward_amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
20
server/scripts/migrate-user-real-name.js
Normal file
20
server/scripts/migrate-user-real-name.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../src/infra/db');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cols = await db.query("SHOW COLUMNS FROM users LIKE 'real_name'");
|
||||||
|
if (!cols.length) {
|
||||||
|
await db.execute('ALTER TABLE users ADD COLUMN real_name VARCHAR(64) NULL AFTER display_name');
|
||||||
|
console.log('users.real_name column added');
|
||||||
|
} else {
|
||||||
|
console.log('users.real_name already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => db.close());
|
||||||
42
server/scripts/set-password.js
Normal file
42
server/scripts/set-password.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#!/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);
|
||||||
|
});
|
||||||
53
server/src/app.js
Normal file
53
server/src/app.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { errorMiddleware } = require('./common/http');
|
||||||
|
|
||||||
|
const healthRoutes = require('./health/health.routes');
|
||||||
|
const authRoutes = require('./auth/auth.routes');
|
||||||
|
const modulesRoutes = require('./modules/modules.routes');
|
||||||
|
const syncRoutes = require('./sync/sync.routes');
|
||||||
|
const auditRoutes = require('./audit/audit.routes');
|
||||||
|
const precomputeRoutes = require('./precompute/precompute.routes');
|
||||||
|
const uploadRoutes = require('./upload/upload.routes');
|
||||||
|
const exportRoutes = require('./export/export.routes');
|
||||||
|
const statsRoutes = require('./stats/stats.routes');
|
||||||
|
const maintenanceRoutes = require('./maintenance.routes');
|
||||||
|
const importRoutes = require('./import/import.routes');
|
||||||
|
const usersRoutes = require('./users/users.routes');
|
||||||
|
const systemSettingsRoutes = require('./system-settings/system-settings.routes');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装 Express 应用(路由挂载 + 全局错误中间件)。
|
||||||
|
* @returns {import('express').Express}
|
||||||
|
*/
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', true); // 取真实 req.ip(Nginx 反代后,§11.1)
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
|
const base = '/api/v1';
|
||||||
|
app.use(`${base}/health`, healthRoutes);
|
||||||
|
app.use(`${base}/auth`, authRoutes);
|
||||||
|
app.use(`${base}/modules`, modulesRoutes);
|
||||||
|
app.use(`${base}/sync`, syncRoutes);
|
||||||
|
app.use(`${base}/audit-logs`, auditRoutes);
|
||||||
|
app.use(`${base}/precompute`, precomputeRoutes);
|
||||||
|
app.use(`${base}/uploads`, uploadRoutes);
|
||||||
|
app.use(`${base}/exports`, exportRoutes);
|
||||||
|
app.use(`${base}/stats`, statsRoutes);
|
||||||
|
app.use(`${base}/maintenance`, maintenanceRoutes);
|
||||||
|
app.use(`${base}/imports`, importRoutes);
|
||||||
|
app.use(`${base}/users`, usersRoutes);
|
||||||
|
app.use(`${base}/system-settings`, systemSettingsRoutes);
|
||||||
|
|
||||||
|
// 兜底 404
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ code: 1004, message: `未知路由:${req.method} ${req.path}`, data: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorMiddleware);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createApp };
|
||||||
102
server/src/audit/audit.js
Normal file
102
server/src/audit/audit.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createHash } = require('crypto');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计哈希链(migration-plan §10.3):
|
||||||
|
* current_hash = SHA-256(prev_hash + canonical(entry))
|
||||||
|
*
|
||||||
|
* 并发序列化:进程内 promise 队列(PM2 单实例假设)。
|
||||||
|
* 多实例部署时需改 DB 级锁或专用审计写入进程 —— 见实施日志 §已知问题。
|
||||||
|
*/
|
||||||
|
let chain = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AuditEntry
|
||||||
|
* @property {number} [userId]
|
||||||
|
* @property {string} [username]
|
||||||
|
* @property {string} [roleCode]
|
||||||
|
* @property {'ui'|'mcp'|'system'} clientType
|
||||||
|
* @property {string} action
|
||||||
|
* @property {string} [module]
|
||||||
|
* @property {string} [targetType]
|
||||||
|
* @property {string} [targetId]
|
||||||
|
* @property {any} [payload]
|
||||||
|
* @property {string} [ip]
|
||||||
|
* @property {string} [userAgent]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记一条审计(不阻塞业务;失败仅告警)。
|
||||||
|
* @param {AuditEntry} entry
|
||||||
|
* @returns {Promise<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 };
|
||||||
165
server/src/audit/audit.routes.js
Normal file
165
server/src/audit/audit.routes.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const audit = require('./audit');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function sanitizeMcpArgs(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeMcpArgs);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
const out = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
// 只过滤凭证类字段,保留业务参数(employeeId/name/year/team 等)供审计追踪
|
||||||
|
if (/(password|secret|token|_enc|_hmac)/i.test(key)) continue;
|
||||||
|
out[key] = sanitizeMcpArgs(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成人类可读的 MCP 调用摘要(供审计日志"结果摘要"列显示)
|
||||||
|
*/
|
||||||
|
function formatMcpSummary(tool, args) {
|
||||||
|
if (!args || typeof args !== 'object') return '';
|
||||||
|
const parts = [];
|
||||||
|
if (args.keyword) parts.push(`关键词:${args.keyword}`);
|
||||||
|
if (args.employeeId) parts.push(`工号:${args.employeeId}`);
|
||||||
|
if (args.employeeName) parts.push(`姓名:${args.employeeName}`);
|
||||||
|
if (args.year) parts.push(`年份:${args.year}`);
|
||||||
|
if (args.month) parts.push(`月份:${args.month}`);
|
||||||
|
if (args.team) parts.push(`班组:${args.team}`);
|
||||||
|
if (args.modules && Array.isArray(args.modules)) parts.push(`模块:${args.modules.join(',')}`);
|
||||||
|
if (args.openUi === false) parts.push('不开窗');
|
||||||
|
return parts.join('、') || JSON.stringify(args).slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/view',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { page, table, filters, rowCount } = req.body || {};
|
||||||
|
const safePage = typeof page === 'string' && /^[a-z0-9_-]{1,64}$/.test(page) ? page : null;
|
||||||
|
const safeTable = typeof table === 'string' && /^[a-z0-9_]{1,64}$/.test(table) ? table : null;
|
||||||
|
if (!safePage && !safeTable) {
|
||||||
|
const err = new Error('页面或数据表标识无效');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
roleCode: req.user.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: safeTable ? 'view_data' : 'view_page',
|
||||||
|
module: safePage || safeTable,
|
||||||
|
targetType: safeTable ? 'table' : 'page',
|
||||||
|
targetId: safeTable || safePage,
|
||||||
|
payload: safeTable
|
||||||
|
? { filters: sanitizeMcpArgs(filters || {}), rowCount: Math.max(0, Number(rowCount) || 0) }
|
||||||
|
: null,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
ok(res, { recorded: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/mcp',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { tool, args, success, durationMs, error } = req.body || {};
|
||||||
|
if (typeof tool !== 'string' || !/^profile_(query|get|list|health)_[a-z0-9_]+$/.test(tool)) {
|
||||||
|
const err = new Error('非法 MCP 工具名');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
roleCode: req.user.roles?.[0],
|
||||||
|
clientType: 'agent',
|
||||||
|
action: 'mcp_call',
|
||||||
|
module: 'mcp',
|
||||||
|
targetType: 'tool',
|
||||||
|
targetId: tool,
|
||||||
|
payload: {
|
||||||
|
summary: formatMcpSummary(tool, args),
|
||||||
|
args: sanitizeMcpArgs(args || {}),
|
||||||
|
success: Boolean(success),
|
||||||
|
durationMs: Math.max(0, Number(durationMs) || 0),
|
||||||
|
error: error ? String(error).slice(0, 500) : null,
|
||||||
|
},
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
ok(res, { recorded: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('audit', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const take = Math.min(Number(req.query.size) || 50, 200);
|
||||||
|
const page = Math.max(Number(req.query.page) || 1, 1);
|
||||||
|
const offset = (page - 1) * take;
|
||||||
|
|
||||||
|
const conds = [];
|
||||||
|
const params = [];
|
||||||
|
if (req.query.client_type) {
|
||||||
|
conds.push('a.client_type = ?');
|
||||||
|
params.push(req.query.client_type);
|
||||||
|
}
|
||||||
|
if (req.query.action) {
|
||||||
|
conds.push('a.action = ?');
|
||||||
|
params.push(req.query.action);
|
||||||
|
}
|
||||||
|
if (req.query.username) {
|
||||||
|
const keyword = `%${String(req.query.username).slice(0, 64)}%`;
|
||||||
|
conds.push('(a.username LIKE ? OR u.real_name LIKE ? OR u.display_name LIKE ?)');
|
||||||
|
params.push(keyword, keyword, keyword);
|
||||||
|
}
|
||||||
|
if (req.query.module) {
|
||||||
|
conds.push('a.module = ?');
|
||||||
|
params.push(String(req.query.module).slice(0, 64));
|
||||||
|
}
|
||||||
|
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT a.*, u.display_name, u.real_name
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL
|
||||||
|
${where}
|
||||||
|
ORDER BY a.id DESC
|
||||||
|
LIMIT ${take} OFFSET ${offset}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const countRows = await db.query(
|
||||||
|
`SELECT COUNT(*) AS total
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL
|
||||||
|
${where}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
ok(res, { rows, total: Number(countRows[0].total), page, size: take });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/verify',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('audit', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await audit.verifyChain());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
255
server/src/auth/auth.js
Normal file
255
server/src/auth/auth.js
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const argon2 = require('argon2');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const config = require('../config');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
const { signEdDsa, verifyEdDsa } = require('./jwt-ed25519');
|
||||||
|
|
||||||
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
|
const LOCK_MINUTES = 15;
|
||||||
|
|
||||||
|
function isStrongPassword(pwd) {
|
||||||
|
return typeof pwd === 'string' && pwd.length >= 10 && /[a-z]/.test(pwd) && /[A-Z]/.test(pwd) && /\d/.test(pwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(payload, expiresIn) {
|
||||||
|
return signEdDsa(payload, config.jwtPrivateKey, {
|
||||||
|
expiresIn,
|
||||||
|
kid: config.jwtKid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
return verifyEdDsa(token, config.jwtPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueTokens(userId, username, roles) {
|
||||||
|
const jti = randomUUID();
|
||||||
|
const accessToken = signToken(
|
||||||
|
{ sub: userId, username, roles, type: 'access' },
|
||||||
|
`${config.accessTtlMin}m`,
|
||||||
|
);
|
||||||
|
const refreshToken = signToken(
|
||||||
|
{ sub: userId, username, type: 'refresh', jti },
|
||||||
|
`${config.refreshTtlHours}h`,
|
||||||
|
);
|
||||||
|
const ttlSec = config.refreshTtlHours * 3600;
|
||||||
|
try {
|
||||||
|
await redis.client.set(`refresh:${jti}`, String(userId), 'EX', ttlSec);
|
||||||
|
await redis.client.sadd(`user_refresh:${userId}`, jti);
|
||||||
|
await redis.client.expire(`user_refresh:${userId}`, ttlSec);
|
||||||
|
} catch (e) {
|
||||||
|
// Redis 不可用时 refresh 无法旋转失效,但 access 仍有效;记录告警
|
||||||
|
console.error('[auth] redis unavailable while issuing tokens:', e.message);
|
||||||
|
}
|
||||||
|
return { accessToken, refreshToken, expiresInSec: config.accessTtlMin * 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyRefresh(token) {
|
||||||
|
try {
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (payload.type !== 'refresh') throw new Error('not refresh');
|
||||||
|
return payload;
|
||||||
|
} catch (e) {
|
||||||
|
throw new ApiError(API_CODES.UNAUTHENTICATED, 'refresh token 无效', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password, meta) {
|
||||||
|
const rows = await db.query('SELECT * FROM users WHERE username = ? LIMIT 1', [username]);
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
|
const fail = async (reason) => {
|
||||||
|
await audit.log({
|
||||||
|
username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'login_failed',
|
||||||
|
module: 'users',
|
||||||
|
payload: { reason },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
throw new ApiError(API_CODES.UNAUTHENTICATED, '用户名或密码错误', 401);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user || user.deleted_at) return fail('user_not_found');
|
||||||
|
if (user.status === 'disabled') return fail('user_disabled');
|
||||||
|
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||||
|
throw new ApiError(API_CODES.ACCOUNT_LOCKED, '账户已锁定,请稍后重试', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = await argon2.verify(user.password_hash, password);
|
||||||
|
} catch (e) {
|
||||||
|
ok = false; // 占位/非法 hash 一律视为失败
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
const attempts = Number(user.failed_attempts) + 1;
|
||||||
|
const lockedUntil = attempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null;
|
||||||
|
await db.execute('UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?', [
|
||||||
|
attempts,
|
||||||
|
lockedUntil,
|
||||||
|
user.id,
|
||||||
|
]);
|
||||||
|
return fail(`bad_password(attempt=${attempts})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW(), last_login_ip = ? WHERE id = ?',
|
||||||
|
[meta.ip || null, user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const roles = await rbac.getRoleCodes(Number(user.id));
|
||||||
|
const tokens = await issueTokens(Number(user.id), user.username, roles);
|
||||||
|
const isDeveloper = config.developerUsers.has(user.username);
|
||||||
|
const isOperations = isDeveloper || roles.includes('ops');
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: Number(user.id),
|
||||||
|
username: user.username,
|
||||||
|
roleCode: roles[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'login',
|
||||||
|
module: 'users',
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...tokens,
|
||||||
|
user: {
|
||||||
|
id: Number(user.id),
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.display_name,
|
||||||
|
realName: user.real_name || '',
|
||||||
|
roles,
|
||||||
|
isDeveloper,
|
||||||
|
isOperations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(refreshToken) {
|
||||||
|
const payload = verifyRefresh(refreshToken);
|
||||||
|
const key = `refresh:${payload.jti}`;
|
||||||
|
// GETDEL 原子消费旧 jti,避免并发刷新请求同时复用同一 refresh token。
|
||||||
|
const owner = await redis.client.getdel(key);
|
||||||
|
if (!owner || Number(owner) !== payload.sub) {
|
||||||
|
throw new ApiError(API_CODES.UNAUTHENTICATED, 'refresh token 已失效', 401);
|
||||||
|
}
|
||||||
|
await redis.client.srem(`user_refresh:${payload.sub}`, payload.jti);
|
||||||
|
const roles = await rbac.getRoleCodes(payload.sub);
|
||||||
|
return issueTokens(payload.sub, payload.username, roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(user, refreshToken, meta) {
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const payload = verifyRefresh(refreshToken);
|
||||||
|
await redis.client.del(`refresh:${payload.jti}`);
|
||||||
|
await redis.client.srem(`user_refresh:${payload.sub}`, payload.jti);
|
||||||
|
} catch (e) {
|
||||||
|
/* 过期/非法忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await audit.log({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'logout',
|
||||||
|
module: 'users',
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(user, oldPassword, newPassword, meta) {
|
||||||
|
const rows = await db.query('SELECT * FROM users WHERE id = ? LIMIT 1', [user.id]);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404);
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = await argon2.verify(row.password_hash, oldPassword);
|
||||||
|
} catch (e) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
if (!ok) throw new ApiError(API_CODES.UNAUTHENTICATED, '原密码错误', 401);
|
||||||
|
|
||||||
|
if (!isStrongPassword(newPassword)) {
|
||||||
|
throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await argon2.hash(newPassword, { type: argon2.argon2id });
|
||||||
|
await db.execute('UPDATE users SET password_hash = ?, pwd_changed_at = NOW() WHERE id = ?', [hash, row.id]);
|
||||||
|
|
||||||
|
// 吊销该用户所有 refresh token(强制重新登录)
|
||||||
|
try {
|
||||||
|
const jtis = await redis.client.smembers(`user_refresh:${user.id}`);
|
||||||
|
if (jtis.length > 0) await redis.client.del(...jtis.map((j) => `refresh:${j}`));
|
||||||
|
await redis.client.del(`user_refresh:${user.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'change_password',
|
||||||
|
module: 'users',
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profile(user) {
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT username, display_name, real_name FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1',
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404);
|
||||||
|
const roles = await rbac.getRoleCodes(user.id);
|
||||||
|
const permissions = await rbac.getPermissions(user.id);
|
||||||
|
const isDeveloper = config.developerUsers.has(row.username);
|
||||||
|
return {
|
||||||
|
id: Number(user.id),
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
realName: row.real_name || '',
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
isDeveloper,
|
||||||
|
isOperations: isDeveloper || roles.includes('ops'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express 中间件:校验 access JWT,挂 req.user = { id, username, roles }。
|
||||||
|
*/
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header || !header.startsWith('Bearer ')) {
|
||||||
|
return next(new ApiError(API_CODES.UNAUTHENTICATED, '未认证', 401));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = verifyToken(header.slice(7));
|
||||||
|
if (payload.type !== 'access') throw new Error('wrong token type');
|
||||||
|
req.user = { id: payload.sub, username: payload.username, roles: payload.roles || [] };
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
next(new ApiError(API_CODES.UNAUTHENTICATED, 'token 无效或已过期', 401));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { login, refresh, logout, changePassword, profile, requireAuth, isStrongPassword };
|
||||||
65
server/src/auth/auth.routes.js
Normal file
65
server/src/auth/auth.routes.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const auth = require('./auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function meta(req) {
|
||||||
|
return { ip: req.ip, userAgent: req.headers['user-agent'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFields(body, fields) {
|
||||||
|
for (const f of fields) {
|
||||||
|
if (body == null || body[f] == null || body[f] === '') {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `缺少参数:${f}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公共端点(无需 JWT)
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
requireFields(req.body, ['username', 'password']);
|
||||||
|
ok(res, await auth.login(req.body.username, req.body.password, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/refresh',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
requireFields(req.body, ['refreshToken']);
|
||||||
|
ok(res, await auth.refresh(req.body.refreshToken));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 需认证端点
|
||||||
|
router.post(
|
||||||
|
'/logout',
|
||||||
|
auth.requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await auth.logout(req.user, req.body && req.body.refreshToken, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/change-password',
|
||||||
|
auth.requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
requireFields(req.body, ['oldPassword', 'newPassword']);
|
||||||
|
ok(res, await auth.changePassword(req.user, req.body.oldPassword, req.body.newPassword, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/profile',
|
||||||
|
auth.requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await auth.profile(req.user));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
75
server/src/auth/jwt-ed25519.js
Normal file
75
server/src/auth/jwt-ed25519.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最小 Ed25519 JWT(alg=EdDSA),用 Node crypto 签验。
|
||||||
|
* jsonwebtoken/jwa 尚不支持 EdDSA,故自实现。
|
||||||
|
*/
|
||||||
|
const { sign, verify } = require('crypto');
|
||||||
|
|
||||||
|
function b64urlJson(obj) {
|
||||||
|
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpiresIn(expiresIn) {
|
||||||
|
if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) return Math.floor(expiresIn);
|
||||||
|
const raw = String(expiresIn || '').trim();
|
||||||
|
const m = /^(\d+)([smhd])?$/i.exec(raw);
|
||||||
|
if (!m) throw new Error(`无效 expiresIn: ${expiresIn}`);
|
||||||
|
const n = Number(m[1]);
|
||||||
|
const unit = (m[2] || 's').toLowerCase();
|
||||||
|
const mult = unit === 'm' ? 60 : unit === 'h' ? 3600 : unit === 'd' ? 86400 : 1;
|
||||||
|
return n * mult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} payload
|
||||||
|
* @param {import('crypto').KeyObject} privateKey
|
||||||
|
* @param {{ expiresIn: string|number, kid?: string }} options
|
||||||
|
*/
|
||||||
|
function signEdDsa(payload, privateKey, options) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const body = {
|
||||||
|
...payload,
|
||||||
|
iat: now,
|
||||||
|
exp: now + parseExpiresIn(options.expiresIn),
|
||||||
|
};
|
||||||
|
const header = { alg: 'EdDSA', typ: 'JWT' };
|
||||||
|
if (options.kid) header.kid = options.kid;
|
||||||
|
const encoded = `${b64urlJson(header)}.${b64urlJson(body)}`;
|
||||||
|
const signature = sign(null, Buffer.from(encoded, 'utf8'), privateKey);
|
||||||
|
return `${encoded}.${signature.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {import('crypto').KeyObject} publicKey
|
||||||
|
*/
|
||||||
|
function verifyEdDsa(token, publicKey) {
|
||||||
|
if (typeof token !== 'string' || token.split('.').length !== 3) {
|
||||||
|
throw new Error('jwt malformed');
|
||||||
|
}
|
||||||
|
const [headerB64, payloadB64, sigB64] = token.split('.');
|
||||||
|
const encoded = `${headerB64}.${payloadB64}`;
|
||||||
|
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
|
||||||
|
if (header.alg !== 'EdDSA') throw new Error(`unexpected alg: ${header.alg}`);
|
||||||
|
const ok = verify(
|
||||||
|
null,
|
||||||
|
Buffer.from(encoded, 'utf8'),
|
||||||
|
publicKey,
|
||||||
|
Buffer.from(sigB64, 'base64url'),
|
||||||
|
);
|
||||||
|
if (!ok) throw new Error('invalid signature');
|
||||||
|
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (typeof payload.exp === 'number' && now >= payload.exp) {
|
||||||
|
const err = new Error('jwt expired');
|
||||||
|
err.name = 'TokenExpiredError';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (typeof payload.nbf === 'number' && now < payload.nbf) {
|
||||||
|
throw new Error('jwt not active');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { signEdDsa, verifyEdDsa, parseExpiresIn };
|
||||||
59
server/src/auth/jwt-keys.js
Normal file
59
server/src/auth/jwt-keys.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Ed25519 密钥加载(EdDSA)。
|
||||||
|
* 优先级:环境变量 PEM 明文 → 路径文件 → 默认 keys/ 目录。
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createPrivateKey, createPublicKey } = require('crypto');
|
||||||
|
|
||||||
|
const DEFAULT_DIR = path.join(__dirname, '..', '..', 'keys');
|
||||||
|
const DEFAULT_PRIVATE = path.join(DEFAULT_DIR, 'jwt_ed25519_private.pem');
|
||||||
|
const DEFAULT_PUBLIC = path.join(DEFAULT_DIR, 'jwt_ed25519_public.pem');
|
||||||
|
|
||||||
|
function normalizePem(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
return String(value).replace(/\\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileIfExists(filePath) {
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) return null;
|
||||||
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadJwtKeys() {
|
||||||
|
const privatePem =
|
||||||
|
normalizePem(process.env.JWT_PRIVATE_KEY) ||
|
||||||
|
readFileIfExists(process.env.JWT_PRIVATE_KEY_PATH) ||
|
||||||
|
readFileIfExists(DEFAULT_PRIVATE);
|
||||||
|
const publicPem =
|
||||||
|
normalizePem(process.env.JWT_PUBLIC_KEY) ||
|
||||||
|
readFileIfExists(process.env.JWT_PUBLIC_KEY_PATH) ||
|
||||||
|
readFileIfExists(DEFAULT_PUBLIC);
|
||||||
|
|
||||||
|
if (!privatePem || !publicPem) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
'JWT Ed25519 密钥未配置。请先生成密钥对:',
|
||||||
|
' node scripts/generate-jwt-keys.js',
|
||||||
|
'或设置 JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH(或 JWT_PRIVATE_KEY / JWT_PUBLIC_KEY)。',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKey = createPrivateKey(privatePem);
|
||||||
|
const publicKey = createPublicKey(publicPem);
|
||||||
|
if (privateKey.asymmetricKeyType !== 'ed25519' || publicKey.asymmetricKeyType !== 'ed25519') {
|
||||||
|
throw new Error('JWT 密钥必须为 Ed25519(algorithm=EdDSA)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
kid: process.env.JWT_KEY_ID || 'ed25519-1',
|
||||||
|
algorithm: 'EdDSA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadJwtKeys, DEFAULT_DIR, DEFAULT_PRIVATE, DEFAULT_PUBLIC };
|
||||||
41
server/src/common/api-codes.js
Normal file
41
server/src/common/api-codes.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** 业务错误码(migration-plan §6.4) */
|
||||||
|
const API_CODES = {
|
||||||
|
OK: 0,
|
||||||
|
BAD_PARAMS: 1001,
|
||||||
|
UNAUTHENTICATED: 1002,
|
||||||
|
FORBIDDEN: 1003,
|
||||||
|
NOT_FOUND: 1004,
|
||||||
|
CONFLICT: 1005,
|
||||||
|
IDEMPOTENT_DUP: 1006,
|
||||||
|
WEAK_PASSWORD: 2001,
|
||||||
|
ACCOUNT_LOCKED: 2002,
|
||||||
|
CERT_INVALID: 2003,
|
||||||
|
UPLOAD_PARSE_FAILED: 3001,
|
||||||
|
UPLOAD_VALIDATE_FAILED: 3002,
|
||||||
|
BATCH_NOT_FOUND: 3003,
|
||||||
|
UPLOAD_CONFLICT: 3004,
|
||||||
|
EXPORT_QUEUED: 4001,
|
||||||
|
EXPORT_FAILED: 4002,
|
||||||
|
SERVER_ERROR: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 携带业务码的异常。HTTP 状态与业务码解耦。
|
||||||
|
*/
|
||||||
|
class ApiError extends Error {
|
||||||
|
/**
|
||||||
|
* @param {number} code 业务码(API_CODES)
|
||||||
|
* @param {string} message
|
||||||
|
* @param {number} [httpStatus=400]
|
||||||
|
*/
|
||||||
|
constructor(code, message, httpStatus = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { API_CODES, ApiError };
|
||||||
48
server/src/common/crypto.js
Normal file
48
server/src/common/crypto.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } = require('crypto');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
// PII 字段加密(migration-plan §10.2):AES-256-GCM
|
||||||
|
// 存储布局(VARBINARY):iv(12) || authTag(16) || ciphertext
|
||||||
|
// HMAC-SHA256 用于密文列的等值查询(*_hmac,明文不落库)
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
|
||||||
|
// 用 SHA-256 派生固定 32 字节密钥,兼容任意长度的 env 秘密串
|
||||||
|
const ENC_KEY = createHash('sha256').update(String(config.dataEncKey)).digest();
|
||||||
|
const HMAC_KEY = createHash('sha256').update(String(config.dataHmacKey)).digest();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} plain
|
||||||
|
* @returns {Buffer} iv||tag||ciphertext
|
||||||
|
*/
|
||||||
|
function encrypt(plain) {
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', ENC_KEY, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
||||||
|
return Buffer.concat([iv, cipher.getAuthTag(), ct]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} buf iv||tag||ciphertext
|
||||||
|
* @returns {string} 明文
|
||||||
|
*/
|
||||||
|
function decrypt(buf) {
|
||||||
|
const iv = buf.subarray(0, IV_LEN);
|
||||||
|
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||||
|
const ct = buf.subarray(IV_LEN + TAG_LEN);
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', ENC_KEY, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} plain
|
||||||
|
* @returns {string} 64 位十六进制
|
||||||
|
*/
|
||||||
|
function hmac(plain) {
|
||||||
|
return createHmac('sha256', HMAC_KEY).update(String(plain)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { encrypt, decrypt, hmac };
|
||||||
63
server/src/common/http.js
Normal file
63
server/src/common/http.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { API_CODES, ApiError } = require('./api-codes');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 安全化:BigInt 无法被 JSON.stringify 序列化(直接抛 TypeError);
|
||||||
|
* Date 统一转 ISO 字符串。
|
||||||
|
* @param {any} value
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
function sanitize(value) {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
|
||||||
|
}
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (Array.isArray(value)) return value.map(sanitize);
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) out[k] = sanitize(v);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一成功响应:{ code:0, message:'ok', data }(migration-plan §6.1) */
|
||||||
|
function ok(res, data) {
|
||||||
|
res.json({ code: API_CODES.OK, message: 'ok', data: sanitize(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 包装异步路由 handler,自动 catch 转交 error 中间件。
|
||||||
|
* 用法:router.get('/x', asyncHandler(async (req,res) => {...}))
|
||||||
|
* @param {(req: any, res: any, next: any) => Promise<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 };
|
||||||
145
server/src/common/responsible.js
Normal file
145
server/src/common/responsible.js
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../infra/db');
|
||||||
|
|
||||||
|
let settingsCache = null;
|
||||||
|
let settingsCacheAt = 0;
|
||||||
|
const SETTINGS_TTL_MS = 30 * 1000; // 30 秒缓存
|
||||||
|
|
||||||
|
async function queryWith(conn, sql, params) {
|
||||||
|
if (conn) {
|
||||||
|
const [rows] = await conn.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return db.query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResponsibleSettings(force = false, conn = null) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && !conn && settingsCache && (now - settingsCacheAt) < SETTINGS_TTL_MS) {
|
||||||
|
return settingsCache;
|
||||||
|
}
|
||||||
|
const rows = await queryWith(
|
||||||
|
conn,
|
||||||
|
`SELECT setting_key, setting_value FROM system_settings
|
||||||
|
WHERE setting_key IN ('manager_name', 'online_exam_responsible')`,
|
||||||
|
);
|
||||||
|
const result = { managerName: '', onlineExamResponsible: '' };
|
||||||
|
for (const row of rows || []) {
|
||||||
|
if (row.setting_key === 'manager_name') {
|
||||||
|
result.managerName = row.setting_value == null ? '' : String(row.setting_value);
|
||||||
|
} else if (row.setting_key === 'online_exam_responsible') {
|
||||||
|
result.onlineExamResponsible = row.setting_value == null ? '' : String(row.setting_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!conn) {
|
||||||
|
settingsCache = result;
|
||||||
|
settingsCacheAt = now;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateSettingsCache() {
|
||||||
|
settingsCache = null;
|
||||||
|
settingsCacheAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取所有未删除员工(用于班组→乘务正组长映射)。
|
||||||
|
* 事务内请传 conn,否则看不到本事务刚写入的班组/岗位。
|
||||||
|
* @param {import('mysql2/promise').PoolConnection|null} [conn]
|
||||||
|
* @returns {Promise<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,
|
||||||
|
};
|
||||||
88
server/src/common/table-utils.js
Normal file
88
server/src/common/table-utils.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../infra/db');
|
||||||
|
|
||||||
|
const AUTO_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
|
||||||
|
const columnsCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取表列名(缓存)。DDL 稳定,进程级缓存即可。
|
||||||
|
* @param {string} table
|
||||||
|
* @returns {Promise<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/标量原样;undefined→null。
|
||||||
|
* @param {any} v
|
||||||
|
*/
|
||||||
|
function coerceValue(v) {
|
||||||
|
if (v === undefined || v === null) return null;
|
||||||
|
if (Buffer.isBuffer(v) || v instanceof Date) return v;
|
||||||
|
if (typeof v === 'object') return JSON.stringify(v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成多行 INSERT(列 = 表列 ∩ 行键,排除自增/时间戳自动列)。
|
||||||
|
* @param {string} table
|
||||||
|
* @param {string[]} tableColumns
|
||||||
|
* @param {Object[]} rows
|
||||||
|
* @returns {{ sql: string, params: any[], columns: string[] } | null} 无有效列时返回 null
|
||||||
|
*/
|
||||||
|
function buildMultiInsert(table, tableColumns, rows) {
|
||||||
|
const rowKeys = new Set();
|
||||||
|
for (const r of rows) for (const k of Object.keys(r)) rowKeys.add(k);
|
||||||
|
|
||||||
|
const columns = tableColumns.filter((c) => !AUTO_COLUMNS.has(c) && rowKeys.has(c));
|
||||||
|
if (columns.length === 0) return null;
|
||||||
|
|
||||||
|
const placeholderRow = `(${columns.map(() => '?').join(', ')})`;
|
||||||
|
const sql =
|
||||||
|
`INSERT INTO \`${table}\` (${columns.map((c) => `\`${c}\``).join(', ')}) ` +
|
||||||
|
`VALUES ${rows.map(() => placeholderRow).join(', ')}`;
|
||||||
|
const params = [];
|
||||||
|
for (const r of rows) for (const c of columns) params.push(coerceValue(r[c]));
|
||||||
|
return { sql, params, columns };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将数组按 size 切块 */
|
||||||
|
function chunk(arr, size) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getColumns, monthWhere, coerceValue, buildMultiInsert, chunk };
|
||||||
36
server/src/config.js
Normal file
36
server/src/config.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
|
|
||||||
|
const { loadJwtKeys } = require('./auth/jwt-keys');
|
||||||
|
|
||||||
|
if (process.env.JWT_SECRET) {
|
||||||
|
console.warn('[config] JWT_SECRET 已弃用:现使用 Ed25519(EdDSA)非对称签名,请改用 JWT_*_KEY(_PATH)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtKeys = loadJwtKeys();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
databaseUrl: process.env.DATABASE_URL || 'mysql://profile:profile_dev_pass@127.0.0.1:3306/profile_dev',
|
||||||
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
|
jwtPrivateKey: jwtKeys.privateKey,
|
||||||
|
jwtPublicKey: jwtKeys.publicKey,
|
||||||
|
jwtAlgorithm: jwtKeys.algorithm,
|
||||||
|
jwtKid: jwtKeys.kid,
|
||||||
|
accessTtlMin: Number(process.env.JWT_ACCESS_TTL_MIN || 30),
|
||||||
|
refreshTtlHours: Number(process.env.JWT_REFRESH_TTL_HOURS || 8),
|
||||||
|
dataEncKey: process.env.DATA_ENC_KEY || 'dev-only-data-encryption-key-change-me',
|
||||||
|
dataHmacKey: process.env.DATA_HMAC_KEY || 'dev-only-data-hmac-key-change-me',
|
||||||
|
exportDir: process.env.EXPORT_DIR || path.resolve(__dirname, '..', '..', '..', 'data', 'exports'),
|
||||||
|
exportRetentionDays: Number(process.env.EXPORT_RETENTION_DAYS || 7),
|
||||||
|
developerUsers: new Set(
|
||||||
|
String(process.env.DEVELOPER_USERNAMES || 'dev_admin')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
170
server/src/export/backup.js
Normal file
170
server/src/export/backup.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { allSyncTables, BACKUP_TABLES } = require('@profile/shared');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const precomputeService = require('../precompute/service');
|
||||||
|
const dashboardSummary = require('../precompute/dashboard-summary');
|
||||||
|
const { getColumns, coerceValue, chunk } = require('../common/table-utils');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
/** v1 仅含原表;恢复时需清空派生表并重算 */
|
||||||
|
const V1_SOURCE_TABLES = [
|
||||||
|
'employees', 'online_exams', 'missing_exams', 'practical_exams', 'practical_exam_steps',
|
||||||
|
'papers', 'paper_steps', 'kilometers_records', 'question_banks',
|
||||||
|
];
|
||||||
|
|
||||||
|
const V1_DERIVED_TABLES = [
|
||||||
|
'online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly',
|
||||||
|
'online_exam_category_stats_monthly', 'online_exam_category_stats_yearly',
|
||||||
|
'practical_exam_stats_monthly', 'practical_exam_step_stats_monthly',
|
||||||
|
'practical_exam_person_stats_monthly', 'practical_exam_scope_stats_monthly',
|
||||||
|
'practical_exam_stats_yearly', 'practical_exam_person_stats_yearly', 'practical_exam_scope_stats_yearly',
|
||||||
|
'kilometers_stats_employee_monthly', 'kilometers_stats_team_monthly',
|
||||||
|
'kilometers_stats_overall_monthly', 'kilometers_control_stats_monthly',
|
||||||
|
'kilometers_stats_employee_yearly', 'kilometers_stats_team_yearly', 'kilometers_stats_overall_yearly',
|
||||||
|
'dashboard_summary',
|
||||||
|
'crew_metrics_employee_yearly',
|
||||||
|
'crew_metrics_employee_monthly',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED = new Set(BACKUP_TABLES);
|
||||||
|
|
||||||
|
function revive(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(revive);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
if (value.encoding === 'base64' && typeof value.data === 'string') {
|
||||||
|
return Buffer.from(value.data, 'base64');
|
||||||
|
}
|
||||||
|
if (value.type === 'Buffer' && Array.isArray(value.data)) return Buffer.from(value.data);
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, revive(item)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInsert(table, columns, rows) {
|
||||||
|
const keys = new Set();
|
||||||
|
for (const row of rows) for (const key of Object.keys(row)) keys.add(key);
|
||||||
|
const selected = columns.filter((column) => keys.has(column));
|
||||||
|
if (selected.length === 0) return null;
|
||||||
|
const placeholders = `(${selected.map(() => '?').join(',')})`;
|
||||||
|
return {
|
||||||
|
sql: `INSERT INTO \`${table}\` (${selected.map((column) => `\`${column}\``).join(',')}) VALUES ${rows.map(() => placeholders).join(',')}`,
|
||||||
|
params: rows.flatMap((row) => selected.map((column) => coerceValue(row[column]))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dumpTables(tableNames) {
|
||||||
|
const tables = {};
|
||||||
|
for (const table of tableNames) {
|
||||||
|
tables[table] = await db.query(`SELECT * FROM \`${table}\``);
|
||||||
|
}
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成完整业务库备份 JSON(供导出任务调用) */
|
||||||
|
async function buildBackupPayload() {
|
||||||
|
return {
|
||||||
|
format: 'profile-backup-v2',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
tables: await dumpTables(BACKUP_TABLES),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreTableRows(conn, table, rows) {
|
||||||
|
const columns = await getColumns(table);
|
||||||
|
for (const part of chunk(rows || [], 200)) {
|
||||||
|
const insert = buildInsert(table, columns, part);
|
||||||
|
if (insert) await conn.execute(insert.sql, insert.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(buffer, operator, meta) {
|
||||||
|
let backup;
|
||||||
|
try {
|
||||||
|
backup = revive(JSON.parse(buffer.toString('utf8')));
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '备份文件不是有效 JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = backup.format;
|
||||||
|
if ((format !== 'profile-backup-v1' && format !== 'profile-backup-v2')
|
||||||
|
|| !backup.tables
|
||||||
|
|| typeof backup.tables !== 'object') {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '不支持的备份格式', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of Object.keys(backup.tables)) {
|
||||||
|
if (!ALLOWED.has(table)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `备份包含未授权表:${table}`, 400);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(backup.tables[table])) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `备份表格式错误:${table}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isV2 = format === 'profile-backup-v2';
|
||||||
|
const restoreTables = isV2
|
||||||
|
? [...BACKUP_TABLES]
|
||||||
|
: Object.keys(backup.tables).filter((table) => V1_SOURCE_TABLES.includes(table));
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
try {
|
||||||
|
if (!isV2) {
|
||||||
|
for (const table of V1_DERIVED_TABLES) {
|
||||||
|
await conn.execute(`DELETE FROM \`${table}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const table of [...restoreTables].reverse()) {
|
||||||
|
await conn.execute(`DELETE FROM \`${table}\``);
|
||||||
|
}
|
||||||
|
for (const table of restoreTables) {
|
||||||
|
await restoreTableRows(conn, table, backup.tables[table] || []);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await conn.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let recomputed = [];
|
||||||
|
if (!isV2) {
|
||||||
|
const reports = await precomputeService.onEmployeesChanged();
|
||||||
|
await dashboardSummary.recompute();
|
||||||
|
recomputed = reports.map((report) => report.module);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sync.bumpVersions([...new Set([...BACKUP_TABLES, ...allSyncTables()])]);
|
||||||
|
|
||||||
|
const counts = Object.fromEntries(
|
||||||
|
restoreTables.map((table) => [table, (backup.tables[table] || []).length]),
|
||||||
|
);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'backup_restore',
|
||||||
|
module: 'exports',
|
||||||
|
targetType: 'backup',
|
||||||
|
payload: {
|
||||||
|
format,
|
||||||
|
createdAt: backup.createdAt || null,
|
||||||
|
tables: counts,
|
||||||
|
recomputed,
|
||||||
|
},
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
restored: true,
|
||||||
|
format,
|
||||||
|
tables: restoreTables.length,
|
||||||
|
rowCounts: counts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
restore,
|
||||||
|
BACKUP_TABLES,
|
||||||
|
buildBackupPayload,
|
||||||
|
};
|
||||||
85
server/src/export/export.routes.js
Normal file
85
server/src/export/export.routes.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const service = require('./export.service');
|
||||||
|
const backup = require('./backup');
|
||||||
|
const maintenance = require('../maintenance');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const uploadBackup = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 100 * 1024 * 1024, files: 1 },
|
||||||
|
fileFilter: (_req, file, callback) => {
|
||||||
|
callback(null, file.originalname.toLowerCase().endsWith('.json'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function meta(req) {
|
||||||
|
return { ip: req.ip, userAgent: req.headers['user-agent'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/jobs/:id',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('exports', ACTIONS.EXPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await service.getJob(req.params.id, req.user));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/jobs/:id/download',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('exports', ACTIONS.EXPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const job = await service.getDownload(req.params.id, req.user);
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
roleCode: req.user.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'export_download',
|
||||||
|
module: 'exports',
|
||||||
|
targetType: 'export_job',
|
||||||
|
targetId: req.params.id,
|
||||||
|
payload: { fileName: job.file_name, fileHash: job.file_hash },
|
||||||
|
...meta(req),
|
||||||
|
});
|
||||||
|
res.type(job.mime_type || 'application/octet-stream');
|
||||||
|
res.download(job.file_path, job.file_name);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/backup/restore',
|
||||||
|
requireAuth,
|
||||||
|
maintenance.requireDeveloper,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.APPROVE),
|
||||||
|
uploadBackup.single('backup'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
const error = new Error('请选择有效的 JSON 备份文件');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
ok(res, await backup.restore(req.file.buffer, req.user, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:type',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('exports', ACTIONS.EXPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
res.status(202);
|
||||||
|
ok(res, await service.submit(req.params.type, req.body || {}, req.user, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
100
server/src/export/export.service.js
Normal file
100
server/src/export/export.service.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const config = require('../config');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { exportQueue } = require('./queue');
|
||||||
|
const { EXPORT_TYPES } = require('./generator');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
function canAccess(job, user) {
|
||||||
|
return Number(job.requested_by) === Number(user.id) || (user.roles || []).includes('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(type, params, user, meta) {
|
||||||
|
if (!EXPORT_TYPES.has(type)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `不支持的导出类型:${type}`, 400);
|
||||||
|
}
|
||||||
|
const jobUuid = randomUUID();
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO export_jobs
|
||||||
|
(job_uuid, export_type, params, status, progress, requested_by, expires_at)
|
||||||
|
VALUES (?, ?, ?, 'queued', 0, ?, DATE_ADD(NOW(), INTERVAL ? DAY))`,
|
||||||
|
[jobUuid, type, JSON.stringify(params || {}), user.id, config.exportRetentionDays],
|
||||||
|
);
|
||||||
|
await exportQueue.add(type, { jobUuid, type, params: params || {}, requestedBy: user.id }, { jobId: jobUuid });
|
||||||
|
await audit.log({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roleCode: user.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'export_submit',
|
||||||
|
module: 'exports',
|
||||||
|
targetType: 'export_job',
|
||||||
|
targetId: jobUuid,
|
||||||
|
payload: { type, params: params || {} },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { jobId: jobUuid, status: 'queued', expiresInDays: config.exportRetentionDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJob(jobUuid, user) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT job_uuid, export_type, params, status, progress, requested_by,
|
||||||
|
file_name, mime_type, file_size, file_hash, error,
|
||||||
|
created_at, started_at, completed_at, expires_at
|
||||||
|
FROM export_jobs WHERE job_uuid = ? LIMIT 1`,
|
||||||
|
[jobUuid],
|
||||||
|
);
|
||||||
|
const job = rows[0];
|
||||||
|
if (!job) throw new ApiError(API_CODES.NOT_FOUND, '导出任务不存在', 404);
|
||||||
|
if (!canAccess(job, user)) throw new ApiError(API_CODES.FORBIDDEN, '无权访问该导出任务', 403);
|
||||||
|
return {
|
||||||
|
jobId: job.job_uuid,
|
||||||
|
type: job.export_type,
|
||||||
|
params: job.params,
|
||||||
|
status: job.status,
|
||||||
|
progress: Number(job.progress),
|
||||||
|
fileName: job.file_name,
|
||||||
|
mimeType: job.mime_type,
|
||||||
|
fileSize: job.file_size == null ? null : Number(job.file_size),
|
||||||
|
fileHash: job.file_hash,
|
||||||
|
error: job.error,
|
||||||
|
createdAt: job.created_at,
|
||||||
|
startedAt: job.started_at,
|
||||||
|
completedAt: job.completed_at,
|
||||||
|
expiresAt: job.expires_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDownload(jobUuid, user) {
|
||||||
|
const rows = await db.query('SELECT * FROM export_jobs WHERE job_uuid = ? LIMIT 1', [jobUuid]);
|
||||||
|
const job = rows[0];
|
||||||
|
if (!job) throw new ApiError(API_CODES.NOT_FOUND, '导出任务不存在', 404);
|
||||||
|
if (!canAccess(job, user)) throw new ApiError(API_CODES.FORBIDDEN, '无权下载该导出文件', 403);
|
||||||
|
if (job.status !== 'completed') throw new ApiError(API_CODES.BAD_PARAMS, '导出任务尚未完成', 409);
|
||||||
|
if (job.expires_at && new Date(job.expires_at) <= new Date()) {
|
||||||
|
throw new ApiError(API_CODES.NOT_FOUND, '导出文件已过期', 410);
|
||||||
|
}
|
||||||
|
if (!job.file_path || !fs.existsSync(job.file_path)) {
|
||||||
|
throw new ApiError(API_CODES.NOT_FOUND, '导出文件不存在', 404);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupExpired() {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT id, file_path FROM export_jobs
|
||||||
|
WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at <= NOW()`,
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.file_path && fs.existsSync(row.file_path)) fs.unlinkSync(row.file_path);
|
||||||
|
await db.execute("UPDATE export_jobs SET status = 'expired', file_path = NULL WHERE id = ?", [row.id]);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { submit, getJob, getDownload, cleanupExpired };
|
||||||
279
server/src/export/generator.js
Normal file
279
server/src/export/generator.js
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const config = require('../config');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const { buildBackupPayload } = require('./backup');
|
||||||
|
|
||||||
|
const EXPORT_TYPES = new Set([
|
||||||
|
'employees',
|
||||||
|
'online-exams',
|
||||||
|
'practical-exams',
|
||||||
|
'team-analysis',
|
||||||
|
'analysis-report',
|
||||||
|
'question-details',
|
||||||
|
'backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function safeNumber(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonCell(value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
return typeof value === 'object' ? JSON.stringify(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRows(rows) {
|
||||||
|
return rows.map((row) => Object.fromEntries(
|
||||||
|
Object.entries(row).map(([key, value]) => [key, jsonCell(value)]),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSheet(workbook, name, rows) {
|
||||||
|
const normalized = normalizeRows(rows);
|
||||||
|
const data = normalized.length > 0 ? normalized : [{ message: '无数据' }];
|
||||||
|
const keys = [...new Set(data.flatMap((row) => Object.keys(row)))];
|
||||||
|
const sheet = workbook.addWorksheet(name.slice(0, 31));
|
||||||
|
sheet.columns = keys.map((key) => ({ header: key, key, width: Math.min(40, Math.max(12, key.length + 4)) }));
|
||||||
|
sheet.addRows(data);
|
||||||
|
sheet.getRow(1).font = { bold: true };
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodWhere(alias, params, values) {
|
||||||
|
const clauses = [];
|
||||||
|
const year = safeNumber(params.year);
|
||||||
|
const month = safeNumber(params.month);
|
||||||
|
if (year != null) {
|
||||||
|
clauses.push(`${alias}.exam_year = ?`);
|
||||||
|
values.push(year);
|
||||||
|
}
|
||||||
|
if (month != null) {
|
||||||
|
clauses.push(`${alias}.exam_month = ?`);
|
||||||
|
values.push(month);
|
||||||
|
}
|
||||||
|
if (params.team) {
|
||||||
|
clauses.push('e.team = ?');
|
||||||
|
values.push(String(params.team));
|
||||||
|
}
|
||||||
|
if (params.employeeId) {
|
||||||
|
clauses.push(`${alias}.employee_id = ?`);
|
||||||
|
values.push(String(params.employeeId));
|
||||||
|
}
|
||||||
|
return clauses;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function employeesWorkbook(params = {}) {
|
||||||
|
const values = [];
|
||||||
|
const where = params.employeeId ? 'AND employee_id = ?' : 'AND (employment_status = \'在职\' OR employment_status IS NULL)';
|
||||||
|
if (params.employeeId) values.push(String(params.employeeId));
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT employee_id, internal_employee_id, name, team, position, native_place,
|
||||||
|
nationality, political_status, education, education_type, graduate_school,
|
||||||
|
graduate_date, skill_level, hire_date, birth_date, status, employment_status, source, details, extras
|
||||||
|
FROM employees
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
${where}
|
||||||
|
ORDER BY team, employee_id`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
addSheet(workbook, '员工档案', rows);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onlineWorkbook(params) {
|
||||||
|
const values = [];
|
||||||
|
const clauses = periodWhere('o', params, values);
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
const exams = await db.query(
|
||||||
|
`SELECT o.employee_id, e.name AS employee_name, e.team, e.position,
|
||||||
|
o.exam_year, o.exam_month, o.paper_class, o.score, o.result,
|
||||||
|
o.faults, o.choice_faults, o.submit_time, o.elapsed_seconds, o.details
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL
|
||||||
|
${where}
|
||||||
|
ORDER BY o.exam_year, o.exam_month, e.team, o.employee_id`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
const missingValues = [];
|
||||||
|
const missingClauses = [];
|
||||||
|
if (safeNumber(params.year) != null && safeNumber(params.month) != null) {
|
||||||
|
missingClauses.push('m.missing_month = ?');
|
||||||
|
missingValues.push(`${safeNumber(params.year)}-${String(safeNumber(params.month)).padStart(2, '0')}`);
|
||||||
|
} else if (safeNumber(params.year) != null) {
|
||||||
|
missingClauses.push('m.missing_month LIKE ?');
|
||||||
|
missingValues.push(`${safeNumber(params.year)}-%`);
|
||||||
|
}
|
||||||
|
if (params.team) {
|
||||||
|
missingClauses.push('e.team = ?');
|
||||||
|
missingValues.push(String(params.team));
|
||||||
|
}
|
||||||
|
const missingWhere = missingClauses.length ? `WHERE ${missingClauses.join(' AND ')}` : '';
|
||||||
|
const missing = await db.query(
|
||||||
|
`SELECT m.employee_id, e.name AS employee_name, e.team, e.position,
|
||||||
|
m.missing_month, m.reason, m.import_time, m.details
|
||||||
|
FROM missing_exams m
|
||||||
|
LEFT JOIN employees e ON e.employee_id = m.employee_id AND e.deleted_at IS NULL
|
||||||
|
${missingWhere}
|
||||||
|
ORDER BY m.missing_month, e.team, m.employee_id`,
|
||||||
|
missingValues,
|
||||||
|
);
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
addSheet(workbook, '在线考试', exams);
|
||||||
|
addSheet(workbook, '缺考', missing);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function practicalWorkbook(params) {
|
||||||
|
const values = [];
|
||||||
|
const clauses = periodWhere('p', params, values);
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT p.employee_id, e.name AS employee_name, e.team, e.position,
|
||||||
|
p.exam_year, p.exam_month, p.paper_id, p.pc_class AS paper_class,
|
||||||
|
p.score, p.result, p.faults, p.create_time, p.details
|
||||||
|
FROM practical_exams p
|
||||||
|
LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL
|
||||||
|
${where}
|
||||||
|
ORDER BY p.exam_year, p.exam_month, e.team, p.employee_id`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
addSheet(workbook, '实操考试', rows);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function questionDetailsWorkbook(params) {
|
||||||
|
const values = [];
|
||||||
|
const clauses = periodWhere('o', params, values);
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT o.employee_id, e.name AS employee_name, e.team, o.exam_year, o.exam_month,
|
||||||
|
o.paper_class, o.score, o.result,
|
||||||
|
JSON_EXTRACT(o.details, '$.questionScores') AS question_scores,
|
||||||
|
o.details
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL
|
||||||
|
${where}
|
||||||
|
ORDER BY o.exam_year, o.exam_month, e.team, o.employee_id`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
addSheet(workbook, '题目作答原始明细', rows);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamWorkbook(params) {
|
||||||
|
const year = safeNumber(params.year);
|
||||||
|
const month = safeNumber(params.month);
|
||||||
|
const values = [];
|
||||||
|
const clauses = [];
|
||||||
|
if (year != null) {
|
||||||
|
clauses.push('year = ?');
|
||||||
|
values.push(year);
|
||||||
|
}
|
||||||
|
if (month != null) {
|
||||||
|
clauses.push('month = ?');
|
||||||
|
values.push(month);
|
||||||
|
}
|
||||||
|
if (params.team) {
|
||||||
|
clauses.push('team = ?');
|
||||||
|
values.push(String(params.team));
|
||||||
|
}
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
const [online, practical, kilometers] = await Promise.all([
|
||||||
|
db.query(`SELECT * FROM online_exam_stats_monthly ${where} ORDER BY year, month, team`, values),
|
||||||
|
db.query(`SELECT * FROM practical_exam_stats_monthly ${where} ORDER BY year, month, team`, values),
|
||||||
|
db.query(`SELECT * FROM kilometers_stats_team_monthly ${where} ORDER BY year, month, team`, values),
|
||||||
|
]);
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
addSheet(workbook, '在线考试班组统计', online);
|
||||||
|
addSheet(workbook, '实操考试班组统计', practical);
|
||||||
|
addSheet(workbook, '班组公里数', kilometers);
|
||||||
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
})[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analysisHtml(params) {
|
||||||
|
const summaryRows = await db.query("SELECT payload, computed_at FROM dashboard_summary WHERE scope_key = 'global' LIMIT 1");
|
||||||
|
const summary = summaryRows[0]?.payload || {};
|
||||||
|
const team = params.team ? ` · ${escapeHtml(params.team)}` : '';
|
||||||
|
return `<!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 };
|
||||||
22
server/src/export/queue.js
Normal file
22
server/src/export/queue.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const IORedis = require('ioredis');
|
||||||
|
const { Queue } = require('bullmq');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const connection = new IORedis(config.redisUrl, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
enableReadyCheck: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportQueue = new Queue('profile-exports', {
|
||||||
|
connection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: 'exponential', delay: 2000 },
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 200,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { exportQueue, connection };
|
||||||
77
server/src/export/worker.js
Normal file
77
server/src/export/worker.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Worker } = require('bullmq');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { connection } = require('./queue');
|
||||||
|
const { generate } = require('./generator');
|
||||||
|
const exportService = require('./export.service');
|
||||||
|
|
||||||
|
const worker = new Worker(
|
||||||
|
'profile-exports',
|
||||||
|
async (job) => {
|
||||||
|
const { jobUuid, type, params } = job.data;
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE export_jobs SET status = 'processing', progress = 10, started_at = NOW(), error = NULL WHERE job_uuid = ?",
|
||||||
|
[jobUuid],
|
||||||
|
);
|
||||||
|
await job.updateProgress(10);
|
||||||
|
const file = await generate(jobUuid, type, params);
|
||||||
|
await job.updateProgress(90);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE export_jobs
|
||||||
|
SET status = 'completed', progress = 100, file_name = ?, file_path = ?,
|
||||||
|
mime_type = ?, file_size = ?, file_hash = ?, completed_at = NOW()
|
||||||
|
WHERE job_uuid = ?`,
|
||||||
|
[file.fileName, file.filePath, file.mimeType, file.fileSize, file.fileHash, jobUuid],
|
||||||
|
);
|
||||||
|
await audit.log({
|
||||||
|
userId: Number(job.data.requestedBy),
|
||||||
|
clientType: 'system',
|
||||||
|
action: 'export_completed',
|
||||||
|
module: 'exports',
|
||||||
|
targetType: 'export_job',
|
||||||
|
targetId: jobUuid,
|
||||||
|
payload: { type, fileName: file.fileName, fileSize: file.fileSize, fileHash: file.fileHash },
|
||||||
|
});
|
||||||
|
return { fileName: file.fileName, fileHash: file.fileHash };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: connection.duplicate(),
|
||||||
|
concurrency: 2,
|
||||||
|
limiter: { max: 10, duration: 60 * 1000 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('failed', async (job, error) => {
|
||||||
|
if (!job) return;
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE export_jobs SET status = 'failed', progress = 0, error = ?, completed_at = NOW() WHERE job_uuid = ?",
|
||||||
|
[String(error.message || error).slice(0, 1000), job.data.jobUuid],
|
||||||
|
);
|
||||||
|
await audit.log({
|
||||||
|
userId: Number(job.data.requestedBy),
|
||||||
|
clientType: 'system',
|
||||||
|
action: 'export_failed',
|
||||||
|
module: 'exports',
|
||||||
|
targetType: 'export_job',
|
||||||
|
targetId: job.data.jobUuid,
|
||||||
|
payload: { type: job.data.type, error: String(error.message || error).slice(0, 500) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanupTimer = setInterval(() => {
|
||||||
|
exportService.cleanupExpired().catch((error) => console.error('[export-worker] cleanup failed:', error.message));
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
await worker.close();
|
||||||
|
await connection.quit();
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
console.log('[export-worker] ready, concurrency=2');
|
||||||
30
server/src/health/health.routes.js
Normal file
30
server/src/health/health.routes.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = { server: 'ok' };
|
||||||
|
try {
|
||||||
|
await db.query('SELECT 1');
|
||||||
|
result.mysql = 'ok';
|
||||||
|
} catch (e) {
|
||||||
|
result.mysql = 'down';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await redis.client.ping();
|
||||||
|
result.redis = 'ok';
|
||||||
|
} catch (e) {
|
||||||
|
result.redis = 'down';
|
||||||
|
}
|
||||||
|
ok(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
610
server/src/import/crew-rewards.js
Normal file
610
server/src/import/crew-rewards.js
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createHash, randomUUID } = require('crypto');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const dashboardSummary = require('../precompute/dashboard-summary');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const SOURCE_TYPE = 'crew_rewards';
|
||||||
|
const PREVIEW_TTL_SECONDS = 30 * 60;
|
||||||
|
const PREVIEW_PREFIX = 'crew-rewards-import:';
|
||||||
|
const LABEL = '嘉奖';
|
||||||
|
const REMARKS_KEY_CHARS = 80;
|
||||||
|
|
||||||
|
let schemaReady = null;
|
||||||
|
|
||||||
|
async function hasColumn(table, column) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1`,
|
||||||
|
[table, column],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSchema() {
|
||||||
|
if (schemaReady) return schemaReady;
|
||||||
|
schemaReady = (async () => {
|
||||||
|
if (!await hasColumn('performance_assessments', 'reward_amount')) {
|
||||||
|
await db.execute(
|
||||||
|
`ALTER TABLE performance_assessments
|
||||||
|
ADD COLUMN reward_amount DECIMAL(12,2) NULL AFTER performance_points`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
schemaReady = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return schemaReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOf(cell) {
|
||||||
|
const value = cell?.value;
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value !== 'object') return value;
|
||||||
|
if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result });
|
||||||
|
if (value.error) return value.error;
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('');
|
||||||
|
if (value.text != null) return value.text;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value) {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (value.error) return value.error;
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('').trim();
|
||||||
|
if (value.result !== undefined) return text(value.result);
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function excelDate(number) {
|
||||||
|
return new Date(Date.UTC(1899, 11, 30) + Math.floor(number) * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateString(value) {
|
||||||
|
let date = null;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
date = value;
|
||||||
|
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
date = excelDate(value);
|
||||||
|
} else {
|
||||||
|
const raw = text(value).replace(/\s+/g, '');
|
||||||
|
let match = raw.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/);
|
||||||
|
if (match) {
|
||||||
|
date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||||
|
}
|
||||||
|
if (!date) {
|
||||||
|
match = raw.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})$/);
|
||||||
|
if (match) {
|
||||||
|
const a = Number(match[1]);
|
||||||
|
const b = Number(match[2]);
|
||||||
|
let c = Number(match[3]);
|
||||||
|
let year;
|
||||||
|
let month;
|
||||||
|
let day;
|
||||||
|
if (String(match[3]).length >= 4) {
|
||||||
|
year = c;
|
||||||
|
if (a > 12) {
|
||||||
|
day = a;
|
||||||
|
month = b;
|
||||||
|
} else {
|
||||||
|
month = a;
|
||||||
|
day = b;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 国内常见 yy/mm/dd,如 25/12/31
|
||||||
|
year = a < 100 ? 2000 + a : a;
|
||||||
|
month = b;
|
||||||
|
day = c;
|
||||||
|
if (month > 12) {
|
||||||
|
year = c < 100 ? 2000 + c : c;
|
||||||
|
month = a;
|
||||||
|
day = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!date && /^\d{4}-\d{2}-\d{2}/.test(raw)) date = new Date(raw);
|
||||||
|
}
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return null;
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimal(value) {
|
||||||
|
const raw = text(value);
|
||||||
|
if (raw === '' || /^#N\/A$/i.test(raw)) return null;
|
||||||
|
const number = Number(raw.replace(/,/g, ''));
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmployeeId(value) {
|
||||||
|
const digits = String(value ?? '').replace(/\D/g, '');
|
||||||
|
if (!digits) return '';
|
||||||
|
if (digits.length > 5) return digits;
|
||||||
|
return digits.padStart(5, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInternalId(value) {
|
||||||
|
return String(value ?? '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉重名数字后缀,如 孙俊1 → 孙俊 */
|
||||||
|
function normalizedName(value) {
|
||||||
|
return String(value ?? '').trim().replace(/\d+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function namesCompatible(excelName, rosterName) {
|
||||||
|
const left = text(excelName);
|
||||||
|
const right = text(rosterName);
|
||||||
|
if (!left || !right) return false;
|
||||||
|
if (left === right) return true;
|
||||||
|
return normalizedName(left) === normalizedName(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha1(value) {
|
||||||
|
return createHash('sha1').update(String(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return createHash('sha256').update(String(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function remarksKey(remarks) {
|
||||||
|
return String(remarks || '').replace(/\s+/g, ' ').trim().slice(0, REMARKS_KEY_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSourceKey(row) {
|
||||||
|
return sha1([
|
||||||
|
SOURCE_TYPE,
|
||||||
|
row.employee_id,
|
||||||
|
row.assessment_date,
|
||||||
|
row.reward_amount,
|
||||||
|
remarksKey(row.violation_reason),
|
||||||
|
row.source_row,
|
||||||
|
].join('|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSheet(workbook) {
|
||||||
|
return workbook.getWorksheet('Sheet1') || workbook.worksheets[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoster() {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT employee_id, internal_employee_id, name, team
|
||||||
|
FROM employees
|
||||||
|
WHERE deleted_at IS NULL AND status = 'active'
|
||||||
|
AND (employment_status = '在职' OR employment_status IS NULL)`,
|
||||||
|
);
|
||||||
|
const byEmployeeId = new Map();
|
||||||
|
const byInternalAndName = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const employeeId = normalizeEmployeeId(row.employee_id);
|
||||||
|
const internalId = normalizeInternalId(row.internal_employee_id);
|
||||||
|
const name = text(row.name);
|
||||||
|
const entry = {
|
||||||
|
employee_id: employeeId || text(row.employee_id),
|
||||||
|
internal_employee_id: internalId,
|
||||||
|
name,
|
||||||
|
team: text(row.team) || null,
|
||||||
|
};
|
||||||
|
if (entry.employee_id) byEmployeeId.set(entry.employee_id, entry);
|
||||||
|
if (internalId && name) byInternalAndName.set(`${internalId}|${name}`, entry);
|
||||||
|
}
|
||||||
|
return { byEmployeeId, byInternalAndName };
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRow(raw, roster) {
|
||||||
|
const workId = normalizeInternalId(raw.workCertificate);
|
||||||
|
const name = text(raw.name);
|
||||||
|
const excelEmployeeId = normalizeEmployeeId(raw.employeeId);
|
||||||
|
const errors = [];
|
||||||
|
let matchStatus = 'ok';
|
||||||
|
let matchMessage = '匹配通过';
|
||||||
|
let employee = null;
|
||||||
|
|
||||||
|
if (excelEmployeeId) {
|
||||||
|
employee = roster.byEmployeeId.get(excelEmployeeId) || null;
|
||||||
|
if (!employee) {
|
||||||
|
// 花名册无此工号:直接跳过,不入库
|
||||||
|
matchStatus = 'skipped';
|
||||||
|
matchMessage = `工号 ${excelEmployeeId} 未在花名册中找到,已自动跳过`;
|
||||||
|
} else {
|
||||||
|
const idOk = workId && normalizeInternalId(employee.internal_employee_id) === workId;
|
||||||
|
const nameExact = name && employee.name === name;
|
||||||
|
const nameOk = namesCompatible(name, employee.name);
|
||||||
|
if (!idOk || !nameOk) {
|
||||||
|
matchStatus = 'conflict';
|
||||||
|
const parts = [];
|
||||||
|
if (!idOk) {
|
||||||
|
parts.push(workId
|
||||||
|
? `工作证不符(Excel ${workId} / 花名册 ${employee.internal_employee_id || '空'})`
|
||||||
|
: '工作证为空');
|
||||||
|
}
|
||||||
|
if (!nameOk) {
|
||||||
|
parts.push(name
|
||||||
|
? `姓名不符(Excel ${name} / 花名册 ${employee.name})`
|
||||||
|
: '姓名为空');
|
||||||
|
}
|
||||||
|
matchMessage = parts.join(';');
|
||||||
|
} else if (!nameExact) {
|
||||||
|
matchMessage = `花名册重名后缀已自动确认(Excel ${name} / 花名册 ${employee.name})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (workId && name) {
|
||||||
|
employee = roster.byInternalAndName.get(`${workId}|${name}`) || null;
|
||||||
|
if (!employee) {
|
||||||
|
// 尝试按去后缀姓名 + 工作证反查
|
||||||
|
for (const entry of roster.byEmployeeId.values()) {
|
||||||
|
if (normalizeInternalId(entry.internal_employee_id) === workId
|
||||||
|
&& namesCompatible(name, entry.name)) {
|
||||||
|
employee = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!employee) {
|
||||||
|
matchStatus = 'skipped';
|
||||||
|
matchMessage = `工作证 ${workId} + 姓名 ${name} 未在花名册中找到,已自动跳过`;
|
||||||
|
} else if (employee.name !== name) {
|
||||||
|
matchMessage = `I 列为空,已按工作证+去后缀姓名反查(花名册 ${employee.name})`;
|
||||||
|
} else {
|
||||||
|
matchMessage = 'I 列为空,已按工作证+姓名反查工号';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matchStatus = 'error';
|
||||||
|
matchMessage = '缺少工号(I)且无法用工作证+姓名兜底';
|
||||||
|
errors.push(matchMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { employee, matchStatus, matchMessage, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkbook(workbook, roster) {
|
||||||
|
const sheet = findSheet(workbook);
|
||||||
|
if (!sheet) throw new ApiError(API_CODES.BAD_PARAMS, '未找到工作表', 400);
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
const errors = [];
|
||||||
|
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||||
|
const seq = text(valueOf(sheet.getCell(rowNumber, 1)));
|
||||||
|
const workCertificate = text(valueOf(sheet.getCell(rowNumber, 2)));
|
||||||
|
const name = text(valueOf(sheet.getCell(rowNumber, 3)));
|
||||||
|
const amountRaw = valueOf(sheet.getCell(rowNumber, 4));
|
||||||
|
const remarks = text(valueOf(sheet.getCell(rowNumber, 5)));
|
||||||
|
const employeeIdRaw = valueOf(sheet.getCell(rowNumber, 9));
|
||||||
|
const dateRaw = valueOf(sheet.getCell(rowNumber, 10));
|
||||||
|
|
||||||
|
if (!seq && !workCertificate && !name && !text(amountRaw) && !remarks
|
||||||
|
&& !text(employeeIdRaw) && !text(dateRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(seq) && !workCertificate && !name) continue;
|
||||||
|
|
||||||
|
const rewardAmount = decimal(amountRaw);
|
||||||
|
const assessmentDate = dateString(dateRaw);
|
||||||
|
const excelEmployeeId = normalizeEmployeeId(employeeIdRaw);
|
||||||
|
const awardReason = remarks;
|
||||||
|
const rowErrors = [];
|
||||||
|
|
||||||
|
if (rewardAmount == null || rewardAmount < 0) {
|
||||||
|
rowErrors.push('金额无效');
|
||||||
|
}
|
||||||
|
if (!assessmentDate) {
|
||||||
|
rowErrors.push('J 列日期无法识别');
|
||||||
|
}
|
||||||
|
if (!awardReason) {
|
||||||
|
rowErrors.push('奖项名称/事由为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = matchRow({
|
||||||
|
workCertificate,
|
||||||
|
name,
|
||||||
|
employeeId: excelEmployeeId,
|
||||||
|
}, roster);
|
||||||
|
rowErrors.push(...matched.errors);
|
||||||
|
|
||||||
|
const performancePoints = rewardAmount != null
|
||||||
|
? Number((-(rewardAmount / 200)).toFixed(4))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
rowId: `r${rowNumber}`,
|
||||||
|
source_row: rowNumber,
|
||||||
|
excel_employee_id: excelEmployeeId || null,
|
||||||
|
work_certificate: workCertificate || null,
|
||||||
|
excel_name: name || null,
|
||||||
|
reward_amount: rewardAmount,
|
||||||
|
performance_points: performancePoints,
|
||||||
|
assessment_date: assessmentDate,
|
||||||
|
remarks: LABEL,
|
||||||
|
violation_reason: awardReason || '',
|
||||||
|
match_status: matched.matchStatus,
|
||||||
|
match_message: matched.matchMessage,
|
||||||
|
employee_id: matched.employee?.employee_id || excelEmployeeId || null,
|
||||||
|
employee_name: matched.employee?.name || name || null,
|
||||||
|
team: matched.employee?.team || null,
|
||||||
|
roster_internal_id: matched.employee?.internal_employee_id || null,
|
||||||
|
assessment_category: LABEL,
|
||||||
|
assessment_item: LABEL,
|
||||||
|
twelve_point_deduction: 0,
|
||||||
|
star_points: 0,
|
||||||
|
performance_deduction: null,
|
||||||
|
safe_kilometers: null,
|
||||||
|
source_payload: {
|
||||||
|
sequence: seq,
|
||||||
|
workCertificate,
|
||||||
|
name,
|
||||||
|
amount: rewardAmount,
|
||||||
|
awardReason,
|
||||||
|
remarks: LABEL,
|
||||||
|
employeeId: excelEmployeeId,
|
||||||
|
assessmentDate,
|
||||||
|
matchStatus: matched.matchStatus,
|
||||||
|
matchMessage: matched.matchMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (matched.matchStatus === 'skipped') {
|
||||||
|
record.match_status = 'skipped';
|
||||||
|
record.match_message = matched.matchMessage;
|
||||||
|
record.source_payload.matchStatus = 'skipped';
|
||||||
|
record.source_payload.matchMessage = matched.matchMessage;
|
||||||
|
} else if (rowErrors.length) {
|
||||||
|
record.match_status = 'error';
|
||||||
|
if (matched.matchStatus !== 'error') {
|
||||||
|
record.match_message = rowErrors.join(';');
|
||||||
|
}
|
||||||
|
record.source_payload.matchStatus = 'error';
|
||||||
|
record.source_payload.matchMessage = record.match_message;
|
||||||
|
for (const message of rowErrors) {
|
||||||
|
errors.push({ sheet: sheet.name, row: rowNumber, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.employee_id && record.assessment_date && record.reward_amount != null
|
||||||
|
&& record.match_status !== 'skipped' && record.match_status !== 'error') {
|
||||||
|
record.source_key = buildSourceKey(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sheetName: sheet.name, rows, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview(file) {
|
||||||
|
await ensureSchema();
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(file.buffer);
|
||||||
|
const roster = await loadRoster();
|
||||||
|
const parsed = parseWorkbook(workbook, roster);
|
||||||
|
const importId = randomUUID();
|
||||||
|
const payload = {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
fileHash: sha256(file.buffer),
|
||||||
|
fileSize: file.size,
|
||||||
|
sheetName: parsed.sheetName,
|
||||||
|
rows: parsed.rows,
|
||||||
|
errors: parsed.errors,
|
||||||
|
};
|
||||||
|
await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS);
|
||||||
|
|
||||||
|
const okCount = parsed.rows.filter((row) => row.match_status === 'ok').length;
|
||||||
|
const conflictCount = parsed.rows.filter((row) => row.match_status === 'conflict').length;
|
||||||
|
const errorCount = parsed.rows.filter((row) => row.match_status === 'error').length;
|
||||||
|
const skippedCount = parsed.rows.filter((row) => row.match_status === 'skipped').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
sheetName: parsed.sheetName,
|
||||||
|
summary: {
|
||||||
|
total: parsed.rows.length,
|
||||||
|
matched: okCount,
|
||||||
|
conflicts: conflictCount,
|
||||||
|
errors: errorCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
validationErrors: parsed.errors.length,
|
||||||
|
},
|
||||||
|
errors: parsed.errors.slice(0, 500),
|
||||||
|
rows: parsed.rows.map((row) => ({
|
||||||
|
rowId: row.rowId,
|
||||||
|
sourceRow: row.source_row,
|
||||||
|
employeeId: row.employee_id,
|
||||||
|
excelEmployeeId: row.excel_employee_id,
|
||||||
|
workCertificate: row.work_certificate,
|
||||||
|
name: row.employee_name || row.excel_name,
|
||||||
|
excelName: row.excel_name,
|
||||||
|
team: row.team,
|
||||||
|
rewardAmount: row.reward_amount,
|
||||||
|
performancePoints: row.performance_points,
|
||||||
|
assessmentDate: row.assessment_date,
|
||||||
|
remarks: row.remarks,
|
||||||
|
violationReason: row.violation_reason,
|
||||||
|
matchStatus: row.match_status,
|
||||||
|
matchMessage: row.match_message,
|
||||||
|
})),
|
||||||
|
canCommit: okCount + conflictCount > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholders(rows, columns) {
|
||||||
|
return rows.map(() => `(${columns.map(() => '?').join(',')})`).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkUpsert(conn, table, columns, rows, updateColumns) {
|
||||||
|
for (let offset = 0; offset < rows.length; offset += 200) {
|
||||||
|
const part = rows.slice(offset, offset + 200);
|
||||||
|
const params = part.flatMap((row) => columns.map((col) => {
|
||||||
|
const value = row[col];
|
||||||
|
return value != null && typeof value === 'object' ? JSON.stringify(value) : value ?? null;
|
||||||
|
}));
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO \`${table}\` (${columns.map((col) => `\`${col}\``).join(',')})
|
||||||
|
VALUES ${placeholders(part, columns)}
|
||||||
|
ON DUPLICATE KEY UPDATE ${updateColumns.map((col) =>
|
||||||
|
col === 'version' ? '`version`=`version`+1' : `\`${col}\`=VALUES(\`${col}\`)`
|
||||||
|
).join(',')}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCommitRows(payload, selections = {}) {
|
||||||
|
const result = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const row of payload.rows) {
|
||||||
|
if (row.match_status === 'error' || row.match_status === 'skipped') {
|
||||||
|
skipped.push({ rowId: row.rowId, reason: row.match_status });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.match_status === 'conflict') {
|
||||||
|
const choice = selections[row.rowId];
|
||||||
|
if (choice === 'skip') {
|
||||||
|
skipped.push({ rowId: row.rowId, reason: 'skip' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (choice !== 'confirm') {
|
||||||
|
throw new ApiError(
|
||||||
|
API_CODES.BAD_PARAMS,
|
||||||
|
`第 ${row.source_row} 行存在匹配冲突,请确认或跳过后再导入`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!row.employee_id || !row.assessment_date || row.reward_amount == null) {
|
||||||
|
skipped.push({ rowId: row.rowId, reason: 'incomplete' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(row);
|
||||||
|
}
|
||||||
|
return { rows: result, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit({ importId, selections = {}, operator, meta }) {
|
||||||
|
await ensureSchema();
|
||||||
|
const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`);
|
||||||
|
if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '嘉奖导入预览已过期,请重新选择文件', 404);
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
const resolved = resolveCommitRows(payload, selections);
|
||||||
|
if (!resolved.rows.length) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '没有可导入的嘉奖记录', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchUuid = randomUUID();
|
||||||
|
// 批量计算责任人快照
|
||||||
|
const responsible = require('../common/responsible');
|
||||||
|
const respMap = await responsible.computeResponsiblePersonBatch(
|
||||||
|
resolved.rows.map((r) => String(r.employee_id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
'source_type', 'source_key', 'assessment_date', 'employee_id', 'employee_name', 'team',
|
||||||
|
'violation_reason', 'assessment_category', 'assessment_item', 'performance_deduction',
|
||||||
|
'performance_points', 'reward_amount', 'star_points', 'safe_kilometers', 'twelve_point_deduction',
|
||||||
|
'remarks', 'responsible_person', 'source_payload', 'source_row', 'import_batch', 'deleted_at', 'version',
|
||||||
|
];
|
||||||
|
|
||||||
|
const dbRows = resolved.rows.map((row) => ({
|
||||||
|
source_type: SOURCE_TYPE,
|
||||||
|
source_key: row.source_key || buildSourceKey(row),
|
||||||
|
assessment_date: row.assessment_date,
|
||||||
|
employee_id: row.employee_id,
|
||||||
|
employee_name: row.employee_name,
|
||||||
|
team: row.team,
|
||||||
|
violation_reason: row.violation_reason || LABEL,
|
||||||
|
assessment_category: LABEL,
|
||||||
|
assessment_item: LABEL,
|
||||||
|
performance_deduction: null,
|
||||||
|
performance_points: row.performance_points,
|
||||||
|
reward_amount: row.reward_amount,
|
||||||
|
star_points: 0,
|
||||||
|
safe_kilometers: null,
|
||||||
|
twelve_point_deduction: 0,
|
||||||
|
remarks: LABEL,
|
||||||
|
responsible_person: respMap[String(row.employee_id)] || '',
|
||||||
|
source_payload: row.source_payload,
|
||||||
|
source_row: row.source_row,
|
||||||
|
import_batch: batchUuid,
|
||||||
|
deleted_at: null,
|
||||||
|
version: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO upload_batches
|
||||||
|
(batch_uuid, module, data_type, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at)
|
||||||
|
VALUES (?, 'crew_rewards', 'xlsx', ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`,
|
||||||
|
[
|
||||||
|
batchUuid, payload.fileName, payload.fileHash, payload.fileSize,
|
||||||
|
dbRows.length, JSON.stringify({ sourceType: SOURCE_TYPE }), operator.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// 全量替换嘉奖来源行,避免日期修正后 source_key 变化导致旧行残留
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE performance_assessments
|
||||||
|
SET deleted_at=NOW(), version=version+1
|
||||||
|
WHERE source_type=? AND deleted_at IS NULL`,
|
||||||
|
[SOURCE_TYPE],
|
||||||
|
);
|
||||||
|
await bulkUpsert(
|
||||||
|
conn,
|
||||||
|
'performance_assessments',
|
||||||
|
columns,
|
||||||
|
dbRows,
|
||||||
|
columns.filter((col) => !['source_type', 'source_key'].includes(col)),
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE upload_batches SET status='completed', completed_at=NOW(), payload=? WHERE batch_uuid=?`,
|
||||||
|
[JSON.stringify({ imported: dbRows.length, skipped: resolved.skipped.length }), batchUuid],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.bumpVersions(['performance_assessments']);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'crew_rewards_import',
|
||||||
|
module: 'imports',
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: {
|
||||||
|
fileName: payload.fileName,
|
||||||
|
fileHash: payload.fileHash,
|
||||||
|
imported: dbRows.length,
|
||||||
|
skipped: resolved.skipped.length,
|
||||||
|
},
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
await redis.client.del(`${PREVIEW_PREFIX}${importId}`);
|
||||||
|
return {
|
||||||
|
batchUuid,
|
||||||
|
counts: {
|
||||||
|
imported: dbRows.length,
|
||||||
|
skipped: resolved.skipped.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
preview,
|
||||||
|
commit,
|
||||||
|
ensureSchema,
|
||||||
|
SOURCE_TYPE,
|
||||||
|
};
|
||||||
164
server/src/import/import.routes.js
Normal file
164
server/src/import/import.routes.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const materials = require('./monthly-materials');
|
||||||
|
const questionBank = require('./question-bank');
|
||||||
|
const monthlyExams = require('./monthly-exams');
|
||||||
|
const crewRewards = require('./crew-rewards');
|
||||||
|
const personnelRoster = require('./personnel-roster');
|
||||||
|
const maintenance = require('../maintenance');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 30 * 1024 * 1024, files: 1 },
|
||||||
|
fileFilter: (_req, file, callback) => {
|
||||||
|
callback(null, /\.xlsx$/i.test(file.originalname));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(requireAuth, maintenance.requireDeveloper);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/monthly-materials/preview',
|
||||||
|
upload.single('file'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
const error = new Error('请选择 .xlsx 文件');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
ok(res, await materials.preview(req.file));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/monthly-materials/commit',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await materials.commit({
|
||||||
|
importId: req.body?.importId,
|
||||||
|
selections: req.body?.selections || {},
|
||||||
|
operator: req.user,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/question-bank/preview',
|
||||||
|
upload.single('file'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
const error = new Error('请选择 .xlsx 文件');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
ok(res, await questionBank.preview(req.file));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/question-bank/commit',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await questionBank.commit({
|
||||||
|
importId: req.body?.importId,
|
||||||
|
operator: req.user,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/monthly-exams/preview',
|
||||||
|
upload.single('file'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
const error = new Error('请选择 .xlsx 文件');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
ok(res, await monthlyExams.preview(req.file, {
|
||||||
|
year: req.body?.year,
|
||||||
|
month: req.body?.month,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/monthly-exams/commit',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await monthlyExams.commit({
|
||||||
|
importId: req.body?.importId,
|
||||||
|
year: req.body?.year,
|
||||||
|
month: req.body?.month,
|
||||||
|
selections: req.body?.selections || {},
|
||||||
|
operator: req.user,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/crew-rewards/preview',
|
||||||
|
upload.single('file'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
const error = new Error('请选择 .xlsx 文件');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
ok(res, await crewRewards.preview(req.file));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/crew-rewards/commit',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await crewRewards.commit({
|
||||||
|
importId: req.body?.importId,
|
||||||
|
selections: req.body?.selections || {},
|
||||||
|
operator: req.user,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/personnel-roster/preview',
|
||||||
|
upload.single('file'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
const error = new Error('请选择 .xlsx 文件');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
ok(res, await personnelRoster.preview(req.file));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/personnel-roster/commit',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await personnelRoster.commit({
|
||||||
|
importId: req.body?.importId,
|
||||||
|
selections: {
|
||||||
|
confirmExtraInFile: Boolean(
|
||||||
|
req.body?.selections?.confirmExtraInFile ?? req.body?.confirmExtraInFile,
|
||||||
|
),
|
||||||
|
confirmMissingInFile: Boolean(
|
||||||
|
req.body?.selections?.confirmMissingInFile ?? req.body?.confirmMissingInFile,
|
||||||
|
),
|
||||||
|
confirmInsertClerks: Boolean(
|
||||||
|
req.body?.selections?.confirmInsertClerks ?? req.body?.confirmInsertClerks,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
operator: req.user,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
1148
server/src/import/monthly-exams.js
Normal file
1148
server/src/import/monthly-exams.js
Normal file
File diff suppressed because it is too large
Load Diff
833
server/src/import/monthly-materials.js
Normal file
833
server/src/import/monthly-materials.js
Normal file
@ -0,0 +1,833 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createHash, randomUUID } = require('crypto');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const precomputeService = require('../precompute/service');
|
||||||
|
const dashboardSummary = require('../precompute/dashboard-summary');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const SOURCE_TYPE = 'monthly_materials';
|
||||||
|
const PREVIEW_TTL_SECONDS = 30 * 60;
|
||||||
|
const UNKNOWN_EMPLOYEE = '__UNKNOWN__';
|
||||||
|
const DRIVER_MAP_SETTING_KEY = 'monthly_materials_driver_map';
|
||||||
|
const SHEETS = {
|
||||||
|
roster: '人员月报(更新)',
|
||||||
|
performance: '绩效考核(累积)',
|
||||||
|
faults: '年故障(累积)',
|
||||||
|
incidents: '事故事件(累积)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function driverMapKey(sourceName, contextTeam) {
|
||||||
|
return `${sourceName}|${contextTeam || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonValue(value, fallback) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨月记住「涉及人员核对」结果:Excel 姓名+班组 → 工号 */
|
||||||
|
async function loadDriverNameMap(roster = []) {
|
||||||
|
const map = {};
|
||||||
|
const settingRows = await db.query(
|
||||||
|
`SELECT setting_value FROM system_settings WHERE setting_key=? LIMIT 1`,
|
||||||
|
[DRIVER_MAP_SETTING_KEY],
|
||||||
|
);
|
||||||
|
Object.assign(map, parseJsonValue(settingRows?.[0]?.setting_value, {}) || {});
|
||||||
|
|
||||||
|
// 从已入库记录回填(兼容升级前已提交、但尚未写入 setting 的情况)
|
||||||
|
const faultRows = await db.query(
|
||||||
|
`SELECT employee_ids, source_payload
|
||||||
|
FROM fault_disposals
|
||||||
|
WHERE source_type=? AND deleted_at IS NULL AND employee_ids IS NOT NULL`,
|
||||||
|
[SOURCE_TYPE],
|
||||||
|
);
|
||||||
|
const incidentRows = await db.query(
|
||||||
|
`SELECT employee_ids, involved_team, source_payload
|
||||||
|
FROM incident_events
|
||||||
|
WHERE source_type=? AND deleted_at IS NULL AND employee_ids IS NOT NULL`,
|
||||||
|
[SOURCE_TYPE],
|
||||||
|
);
|
||||||
|
for (const row of [...(faultRows || []), ...(incidentRows || [])]) {
|
||||||
|
const ids = parseJsonValue(row.employee_ids, []);
|
||||||
|
if (!Array.isArray(ids) || !ids.length) continue;
|
||||||
|
const payload = parseJsonValue(row.source_payload, {}) || {};
|
||||||
|
const names = nameTokens(payload.drivers || '', roster);
|
||||||
|
if (names.length !== ids.length) continue;
|
||||||
|
const team = String(row.involved_team || '');
|
||||||
|
names.forEach((name, index) => {
|
||||||
|
const id = String(ids[index] || '').trim().padStart(5, '0');
|
||||||
|
if (!name || !id || id === '00000') return;
|
||||||
|
const key = driverMapKey(name, team);
|
||||||
|
if (!map[key]) map[key] = id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDriverNameMap(map) {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`,
|
||||||
|
[DRIVER_MAP_SETTING_KEY, JSON.stringify(map || {})],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHistoricalSelections(matches, nameMap, roster) {
|
||||||
|
const knownIds = new Set((roster || []).map((employee) => employee.employee_id));
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.selectedEmployeeId) continue;
|
||||||
|
let prev = nameMap[driverMapKey(match.sourceName, match.contextTeam)];
|
||||||
|
if (!prev) continue;
|
||||||
|
if (prev !== UNKNOWN_EMPLOYEE) prev = String(prev).trim().padStart(5, '0');
|
||||||
|
if (prev === UNKNOWN_EMPLOYEE || knownIds.has(prev)) {
|
||||||
|
match.selectedEmployeeId = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOf(cell) {
|
||||||
|
const value = cell?.value;
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value !== 'object') return value;
|
||||||
|
if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result });
|
||||||
|
if (value.error) return value.error;
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('');
|
||||||
|
if (value.text != null) return value.text;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value) {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (value.error) return value.error;
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('').trim();
|
||||||
|
if (value.result !== undefined) return text(value.result);
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function present(value) {
|
||||||
|
return text(value) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function excelDate(number) {
|
||||||
|
const date = new Date(Date.UTC(1899, 11, 30) + Math.floor(number) * 86400000);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateString(value, inferredYear) {
|
||||||
|
let date = null;
|
||||||
|
if (value instanceof Date) date = value;
|
||||||
|
else if (typeof value === 'number' && Number.isFinite(value)) date = excelDate(value);
|
||||||
|
else {
|
||||||
|
const raw = text(value).replace(/\s+/g, '');
|
||||||
|
let match = raw.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/);
|
||||||
|
if (match) date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||||
|
if (!date) {
|
||||||
|
match = raw.match(/^(\d{1,2})月(\d{1,2})日?$/);
|
||||||
|
if (match && inferredYear) date = new Date(Date.UTC(inferredYear, Number(match[1]) - 1, Number(match[2])));
|
||||||
|
}
|
||||||
|
if (!date && /^\d{4}-\d{2}-\d{2}T/.test(raw)) date = new Date(raw);
|
||||||
|
}
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return null;
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeString(value) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return [value.getUTCHours(), value.getUTCMinutes(), value.getUTCSeconds()]
|
||||||
|
.map((item) => String(item).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
const seconds = Math.round((value % 1) * 86400) % 86400;
|
||||||
|
return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), seconds % 60]
|
||||||
|
.map((item) => String(item).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
const match = text(value).match(/(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/);
|
||||||
|
if (!match) return null;
|
||||||
|
return [match[1], match[2], match[3] || '00'].map((item) => String(item).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimal(value) {
|
||||||
|
const raw = text(value);
|
||||||
|
if (raw === '' || /^#N\/A$/i.test(raw)) return null;
|
||||||
|
const number = Number(raw.replace(/,/g, ''));
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha(value) {
|
||||||
|
return createHash('sha256').update(String(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerMap(sheet, rowNumber) {
|
||||||
|
const map = new Map();
|
||||||
|
sheet.getRow(rowNumber).eachCell({ includeEmpty: false }, (cell, column) => {
|
||||||
|
const label = text(valueOf(cell)).replace(/\s+/g, '');
|
||||||
|
if (label) map.set(label, column);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function column(headers, aliases) {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const found = headers.get(alias.replace(/\s+/g, ''));
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowValues(sheet, rowNumber, definitions) {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, col] of Object.entries(definitions)) {
|
||||||
|
out[key] = col ? valueOf(sheet.getCell(rowNumber, col)) : '';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFields(errors, sheet, row, values, fields, labels) {
|
||||||
|
const missing = fields.filter((field) => !present(values[field])).map((field) => labels[field] || field);
|
||||||
|
if (missing.length) errors.push({ sheet, row, message: `缺少必填列数据:${missing.join('、')}` });
|
||||||
|
return missing.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferYear(fileName) {
|
||||||
|
const match = String(fileName).match(/20\d{2}/);
|
||||||
|
return match ? Number(match[0]) : new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoster(sheet, errors) {
|
||||||
|
const headers = headerMap(sheet, 12);
|
||||||
|
const cols = {
|
||||||
|
sequence: column(headers, ['序号']),
|
||||||
|
workCertificate: column(headers, ['工作证号', '工作证编号']),
|
||||||
|
employeeId: column(headers, ['工号']),
|
||||||
|
name: column(headers, ['姓名']),
|
||||||
|
team: column(headers, ['所在班组']),
|
||||||
|
position: column(headers, ['岗位']),
|
||||||
|
remarks: column(headers, ['备注']),
|
||||||
|
performanceTotal: column(headers, ['绩效累计', '绩效累积']),
|
||||||
|
driverPointsTotal: column(headers, ['司机记分累计', '司机记分累积']),
|
||||||
|
skillLevel: column(headers, ['技能等级']),
|
||||||
|
starLevel: column(headers, ['星级']),
|
||||||
|
};
|
||||||
|
const records = [];
|
||||||
|
const employeeIds = new Set();
|
||||||
|
for (let row = 13; row <= sheet.rowCount; row += 1) {
|
||||||
|
const sequence = valueOf(sheet.getCell(row, cols.sequence || 1));
|
||||||
|
if (!/^\d+$/.test(text(sequence))) continue;
|
||||||
|
const values = rowValues(sheet, row, cols);
|
||||||
|
const required = Object.keys(cols).filter((key) => !['sequence', 'remarks'].includes(key));
|
||||||
|
requireFields(errors, SHEETS.roster, row, values, required, {
|
||||||
|
workCertificate: '工作证号', employeeId: '工号', name: '姓名', team: '所在班组',
|
||||||
|
position: '岗位', remarks: '备注', performanceTotal: '绩效累计',
|
||||||
|
driverPointsTotal: '司机记分累计', skillLevel: '技能等级', starLevel: '星级',
|
||||||
|
});
|
||||||
|
if (!present(values.employeeId) || !present(values.name)) continue;
|
||||||
|
const employeeId = text(values.employeeId).padStart(5, '0');
|
||||||
|
if (employeeIds.has(employeeId)) {
|
||||||
|
errors.push({ sheet: SHEETS.roster, row, message: `工号重复:${employeeId}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
employeeIds.add(employeeId);
|
||||||
|
records.push({
|
||||||
|
employee_id: employeeId,
|
||||||
|
internal_employee_id: text(values.workCertificate),
|
||||||
|
name: text(values.name),
|
||||||
|
team: text(values.team),
|
||||||
|
position: text(values.position),
|
||||||
|
skill_level: text(values.skillLevel),
|
||||||
|
details: {
|
||||||
|
remarks: text(values.remarks),
|
||||||
|
performanceTotal: text(values.performanceTotal),
|
||||||
|
driverPointsTotal: text(values.driverPointsTotal),
|
||||||
|
starLevel: text(values.starLevel),
|
||||||
|
},
|
||||||
|
source_row: row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosterIndexes(roster) {
|
||||||
|
const byId = new Map();
|
||||||
|
const byName = new Map();
|
||||||
|
for (const employee of roster) {
|
||||||
|
byId.set(employee.employee_id, employee);
|
||||||
|
const list = byName.get(employee.name) || [];
|
||||||
|
list.push(employee);
|
||||||
|
byName.set(employee.name, list);
|
||||||
|
}
|
||||||
|
return { byId, byName };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePerformance(sheet, year, roster, errors) {
|
||||||
|
const headers = headerMap(sheet, 7);
|
||||||
|
const cols = {
|
||||||
|
date: column(headers, ['日期']),
|
||||||
|
employeeId: column(headers, ['工号']),
|
||||||
|
name: column(headers, ['姓名']),
|
||||||
|
reason: column(headers, ['违章事由']),
|
||||||
|
category: column(headers, ['考核分类']),
|
||||||
|
item: column(headers, ['考核项']),
|
||||||
|
deduction: column(headers, ['绩效扣款']),
|
||||||
|
points: column(headers, ['绩效扣分']),
|
||||||
|
starPoints: column(headers, ['星级扣分']),
|
||||||
|
safeKilometers: column(headers, ['安全公里数']),
|
||||||
|
twelvePoints: column(headers, ['12分制扣分']),
|
||||||
|
remarks: column(headers, ['备注']),
|
||||||
|
};
|
||||||
|
const indexes = rosterIndexes(roster);
|
||||||
|
const records = [];
|
||||||
|
for (let row = 8; row <= sheet.rowCount; row += 1) {
|
||||||
|
const rawDate = valueOf(sheet.getCell(row, cols.date || 2));
|
||||||
|
const assessmentDate = dateString(rawDate, year);
|
||||||
|
if (!assessmentDate) continue;
|
||||||
|
const values = rowValues(sheet, row, cols);
|
||||||
|
if (!present(values.employeeId)) {
|
||||||
|
requireFields(errors, SHEETS.performance, row, values, ['employeeId'], { employeeId: '工号' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const employeeId = text(values.employeeId).padStart(5, '0');
|
||||||
|
const employee = indexes.byId.get(employeeId);
|
||||||
|
if (!employee) continue;
|
||||||
|
const required = ['date', 'employeeId', 'reason', 'category', 'item', 'deduction', 'points', 'starPoints', 'safeKilometers', 'remarks'];
|
||||||
|
requireFields(errors, SHEETS.performance, row, values, required, {
|
||||||
|
date: '日期', employeeId: '工号', reason: '违章事由', category: '考核分类', item: '考核项',
|
||||||
|
deduction: '绩效扣款', points: '绩效扣分', starPoints: '星级扣分',
|
||||||
|
safeKilometers: '安全公里数', twelvePoints: '12分制扣分', remarks: '备注',
|
||||||
|
});
|
||||||
|
const identity = [assessmentDate, employeeId, text(values.reason), text(values.category), text(values.item)].join('|');
|
||||||
|
records.push({
|
||||||
|
source_key: sha(identity),
|
||||||
|
assessment_date: assessmentDate,
|
||||||
|
employee_id: employeeId,
|
||||||
|
employee_name: employee?.name || text(values.name) || '#N/A',
|
||||||
|
team: employee?.team || null,
|
||||||
|
violation_reason: text(values.reason),
|
||||||
|
assessment_category: text(values.category),
|
||||||
|
assessment_item: text(values.item),
|
||||||
|
performance_deduction: decimal(values.deduction),
|
||||||
|
performance_points: decimal(values.points),
|
||||||
|
star_points: decimal(values.starPoints),
|
||||||
|
safe_kilometers: decimal(values.safeKilometers),
|
||||||
|
twelve_point_deduction: decimal(values.twelvePoints),
|
||||||
|
remarks: text(values.remarks),
|
||||||
|
source_payload: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, text(value)])),
|
||||||
|
source_row: row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levenshtein(left, right) {
|
||||||
|
const rows = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||||
|
for (let i = 1; i <= left.length; i += 1) {
|
||||||
|
let previous = rows[0];
|
||||||
|
rows[0] = i;
|
||||||
|
for (let j = 1; j <= right.length; j += 1) {
|
||||||
|
const old = rows[j];
|
||||||
|
rows[j] = Math.min(rows[j] + 1, rows[j - 1] + 1, previous + (left[i - 1] === right[j - 1] ? 0 : 1));
|
||||||
|
previous = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows[right.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameTokens(raw, roster) {
|
||||||
|
const input = text(raw).replace(/\s+/g, '');
|
||||||
|
const found = [...new Set(roster.filter((employee) => input.includes(employee.name)).map((employee) => employee.name))];
|
||||||
|
if (found.length) return found;
|
||||||
|
return [...new Set(
|
||||||
|
input
|
||||||
|
.replace(/(涉及)?司机[::;;]?/g, '')
|
||||||
|
.split(/[、,,;;/]/)
|
||||||
|
.map((item) => item.replace(/[^\u3400-\u9fff·]/g, ''))
|
||||||
|
.filter((item) => item.length >= 2 && item.length <= 8),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSourceDetails(row) {
|
||||||
|
const isFault = Boolean(row.fault_date);
|
||||||
|
return {
|
||||||
|
sheet: isFault ? SHEETS.faults : SHEETS.incidents,
|
||||||
|
row: row.source_row,
|
||||||
|
date: row.fault_date || row.event_date,
|
||||||
|
time: row.fault_time || null,
|
||||||
|
trainNumber: row.train_number || null,
|
||||||
|
carNumber: row.car_number || null,
|
||||||
|
location: isFault
|
||||||
|
? [row.location_primary, row.location_secondary].filter(Boolean).join(' / ')
|
||||||
|
: row.event_location,
|
||||||
|
category: row.fault_type || row.fault_category || null,
|
||||||
|
phenomenon: row.fault_phenomenon || row.phenomenon_summary || null,
|
||||||
|
involvedDrivers: row.involved_driver_text,
|
||||||
|
involvedTeam: row.involved_team || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatches(rows, roster) {
|
||||||
|
const matchMap = new Map();
|
||||||
|
const byName = rosterIndexes(roster).byName;
|
||||||
|
for (const row of rows) {
|
||||||
|
row.driver_match_ids = nameTokens(row.involved_driver_text, roster).map((name) => {
|
||||||
|
const contextTeam = row.involved_team || '';
|
||||||
|
const matchId = sha(`${name}|${contextTeam}`);
|
||||||
|
if (!matchMap.has(matchId)) {
|
||||||
|
let candidates = (byName.get(name) || []).map((employee) => ({ ...employee, score: 100 }));
|
||||||
|
if (!candidates.length) {
|
||||||
|
candidates = roster
|
||||||
|
.map((employee) => ({ ...employee, score: Math.max(0, 90 - levenshtein(name, employee.name) * 25) }))
|
||||||
|
.filter((employee) => employee.score >= 40)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
// 同名/同分:班组一致优先,其次在职优先(非在职仍可选),未软删优先
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
if (contextTeam) {
|
||||||
|
const teamDiff = Number(b.team === contextTeam) - Number(a.team === contextTeam);
|
||||||
|
if (teamDiff) return teamDiff;
|
||||||
|
}
|
||||||
|
const activeA = String(a.employment_status || '').trim() || '在职';
|
||||||
|
const activeB = String(b.employment_status || '').trim() || '在职';
|
||||||
|
const activeDiff = Number(activeB === '在职') - Number(activeA === '在职');
|
||||||
|
if (activeDiff) return activeDiff;
|
||||||
|
const delDiff = Number(Boolean(a.soft_deleted)) - Number(Boolean(b.soft_deleted));
|
||||||
|
if (delDiff) return delDiff;
|
||||||
|
return b.score - a.score;
|
||||||
|
});
|
||||||
|
matchMap.set(matchId, {
|
||||||
|
matchId,
|
||||||
|
sourceName: name,
|
||||||
|
contextTeam,
|
||||||
|
candidates: candidates.map((candidate) => ({
|
||||||
|
employeeId: candidate.employee_id,
|
||||||
|
name: candidate.name,
|
||||||
|
team: candidate.team,
|
||||||
|
position: candidate.position,
|
||||||
|
employmentStatus: candidate.employment_status || '在职',
|
||||||
|
score: candidate.score,
|
||||||
|
})),
|
||||||
|
selectedEmployeeId: candidates.length === 1 && candidates[0].score === 100
|
||||||
|
? candidates[0].employee_id
|
||||||
|
: null,
|
||||||
|
occurrences: 0,
|
||||||
|
sources: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const match = matchMap.get(matchId);
|
||||||
|
match.occurrences += 1;
|
||||||
|
match.sources.push(matchSourceDetails(row));
|
||||||
|
return matchId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...matchMap.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFaults(sheet, year, errors) {
|
||||||
|
const headers = headerMap(sheet, 12);
|
||||||
|
const cols = {
|
||||||
|
date: column(headers, ['故障日期']), time: column(headers, ['故障时间']),
|
||||||
|
model: column(headers, ['故障车型']), keyTrain: column(headers, ['关键车次']),
|
||||||
|
train: column(headers, ['故障车次']), car: column(headers, ['故障车号']),
|
||||||
|
location1: column(headers, ['故障地点-1']), location2: column(headers, ['故障地点-2']),
|
||||||
|
drivers: column(headers, ['涉及司机']), phenomenon: column(headers, ['故障现象']),
|
||||||
|
type: column(headers, ['故障类型']), attribution: column(headers, ['故障归属']),
|
||||||
|
impactMidway: column(headers, ['运营影响(中途)']), impactTerminal: column(headers, ['运营影响(终到)']),
|
||||||
|
firstOperation: column(headers, ['首次处置操作']), firstResult: column(headers, ['首次处置结果']),
|
||||||
|
finalResult: column(headers, ['最终处置结果']), tracking: column(headers, ['故障跟踪']),
|
||||||
|
};
|
||||||
|
const records = [];
|
||||||
|
for (let row = 13; row <= sheet.rowCount; row += 1) {
|
||||||
|
const faultDate = dateString(valueOf(sheet.getCell(row, cols.date || 1)), year);
|
||||||
|
if (!faultDate) continue;
|
||||||
|
const values = rowValues(sheet, row, cols);
|
||||||
|
if (!present(values.type)) continue;
|
||||||
|
const faultTime = timeString(values.time);
|
||||||
|
const required = ['date', 'time', 'location1', 'location2', 'drivers', 'phenomenon', 'type', 'attribution', 'firstOperation', 'firstResult'];
|
||||||
|
requireFields(errors, SHEETS.faults, row, values, required, {
|
||||||
|
date: '故障日期', time: '故障时间', location1: '故障地点-1', location2: '故障地点-2',
|
||||||
|
drivers: '涉及司机', phenomenon: '故障现象', type: '故障类型', attribution: '故障归属',
|
||||||
|
firstOperation: '首次处置操作', firstResult: '首次处置结果', finalResult: '最终处置结果',
|
||||||
|
});
|
||||||
|
if (!faultTime) {
|
||||||
|
errors.push({ sheet: SHEETS.faults, row, message: '故障时间格式无法识别' });
|
||||||
|
}
|
||||||
|
const identity = [faultDate, faultTime, text(values.train), text(values.car), text(values.location1), text(values.phenomenon)].join('|');
|
||||||
|
records.push({
|
||||||
|
source_key: sha(identity), fault_date: faultDate, fault_time: faultTime || '00:00:00',
|
||||||
|
vehicle_model: text(values.model) || null, key_train_number: text(values.keyTrain) || null,
|
||||||
|
train_number: text(values.train) || null, car_number: text(values.car) || null,
|
||||||
|
location_primary: text(values.location1), location_secondary: text(values.location2),
|
||||||
|
involved_driver_text: text(values.drivers), fault_phenomenon: text(values.phenomenon),
|
||||||
|
fault_type: text(values.type), fault_attribution: text(values.attribution),
|
||||||
|
impact_midway: text(values.impactMidway) || null, impact_terminal: text(values.impactTerminal) || null,
|
||||||
|
first_operation: text(values.firstOperation), first_result: text(values.firstResult),
|
||||||
|
final_result: text(values.finalResult) || null, fault_tracking: text(values.tracking) || null,
|
||||||
|
source_payload: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, text(value)])),
|
||||||
|
source_row: row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIncidents(sheet, year, errors) {
|
||||||
|
const headers = headerMap(sheet, 1);
|
||||||
|
const cols = {
|
||||||
|
sequence: column(headers, ['序号']), date: column(headers, ['日期']), train: column(headers, ['车次']),
|
||||||
|
car: column(headers, ['车号']), location: column(headers, ['事发位置']),
|
||||||
|
category: column(headers, ['故障类别']), phenomenon: column(headers, ['故障现象(简述)']),
|
||||||
|
drivers: column(headers, ['涉及司机']), team: column(headers, ['涉及班组']),
|
||||||
|
reason: column(headers, ['事件原因']), delay: column(headers, ['晚点情况']),
|
||||||
|
issues: column(headers, ['操作是否存在问题(详细)']),
|
||||||
|
};
|
||||||
|
const records = [];
|
||||||
|
for (let row = 2; row <= sheet.rowCount; row += 1) {
|
||||||
|
if (!/^\d+$/.test(text(valueOf(sheet.getCell(row, cols.sequence || 1))))) continue;
|
||||||
|
const values = rowValues(sheet, row, cols);
|
||||||
|
let eventDate = dateString(values.date, year);
|
||||||
|
const required = Object.keys(cols).filter((key) => key !== 'sequence');
|
||||||
|
requireFields(errors, SHEETS.incidents, row, values, required, {
|
||||||
|
date: '日期', train: '车次', car: '车号', location: '事发位置', category: '故障类别',
|
||||||
|
phenomenon: '故障现象(简述)', drivers: '涉及司机', team: '涉及班组',
|
||||||
|
reason: '事件原因', delay: '晚点情况', issues: '操作是否存在问题(详细)',
|
||||||
|
});
|
||||||
|
if (!eventDate) {
|
||||||
|
errors.push({ sheet: SHEETS.incidents, row, message: '日期格式无法识别' });
|
||||||
|
eventDate = `${year}-01-01`;
|
||||||
|
}
|
||||||
|
const identity = [eventDate, text(values.train), text(values.car), text(values.location), text(values.phenomenon)].join('|');
|
||||||
|
records.push({
|
||||||
|
source_key: sha(identity), event_date: eventDate, train_number: text(values.train),
|
||||||
|
car_number: text(values.car), event_location: text(values.location),
|
||||||
|
fault_category: text(values.category), phenomenon_summary: text(values.phenomenon),
|
||||||
|
involved_driver_text: text(values.drivers), involved_team: text(values.team),
|
||||||
|
event_reason: text(values.reason), delay_details: text(values.delay),
|
||||||
|
operation_issues: text(values.issues),
|
||||||
|
source_payload: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, text(value)])),
|
||||||
|
source_row: row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDbMatchRoster() {
|
||||||
|
// 匹配池 = 系统花名册全量(含非在职、含曾被误软删的记录),不用 Excel「人员月报」
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT employee_id, internal_employee_id, name, team, position, employment_status, deleted_at
|
||||||
|
FROM employees`,
|
||||||
|
);
|
||||||
|
return (rows || []).map((row) => ({
|
||||||
|
employee_id: String(row.employee_id || '').trim().padStart(5, '0'),
|
||||||
|
internal_employee_id: String(row.internal_employee_id || '').trim(),
|
||||||
|
name: String(row.name || '').trim(),
|
||||||
|
team: String(row.team || '').trim(),
|
||||||
|
position: String(row.position || '').trim(),
|
||||||
|
employment_status: String(row.employment_status || '').trim() || '在职',
|
||||||
|
soft_deleted: row.deleted_at != null,
|
||||||
|
})).filter((row) => row.employee_id && row.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview(file) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(file.buffer);
|
||||||
|
const missingSheets = Object.values(SHEETS).filter((name) => !workbook.getWorksheet(name));
|
||||||
|
if (missingSheets.length) throw new ApiError(API_CODES.BAD_PARAMS, `缺少工作表:${missingSheets.join('、')}`, 400);
|
||||||
|
const errors = [];
|
||||||
|
const year = inferYear(file.originalname);
|
||||||
|
const employees = parseRoster(workbook.getWorksheet(SHEETS.roster), errors);
|
||||||
|
const performance = parsePerformance(workbook.getWorksheet(SHEETS.performance), year, employees, errors);
|
||||||
|
const faults = parseFaults(workbook.getWorksheet(SHEETS.faults), year, errors);
|
||||||
|
const incidents = parseIncidents(workbook.getWorksheet(SHEETS.incidents), year, errors);
|
||||||
|
// 涉及司机:对系统花名册(含非在职);Excel 人员月报通常只有在职,会导致严敏等匹配不到
|
||||||
|
const matchRoster = await loadDbMatchRoster();
|
||||||
|
const nameMap = await loadDriverNameMap(matchRoster);
|
||||||
|
const matches = applyHistoricalSelections(
|
||||||
|
buildMatches([...faults, ...incidents], matchRoster),
|
||||||
|
nameMap,
|
||||||
|
matchRoster,
|
||||||
|
);
|
||||||
|
const importId = randomUUID();
|
||||||
|
const payload = {
|
||||||
|
importId, fileName: file.originalname, fileHash: sha(file.buffer), fileSize: file.size,
|
||||||
|
year, employees, performance, faults, incidents, matches, matchRoster,
|
||||||
|
validationErrorCount: errors.length,
|
||||||
|
};
|
||||||
|
await redis.client.set(`monthly-import:${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS);
|
||||||
|
return {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
summary: {
|
||||||
|
employees: employees.length,
|
||||||
|
performanceAssessments: performance.length,
|
||||||
|
faultDisposals: faults.length,
|
||||||
|
incidentEvents: incidents.length,
|
||||||
|
validationErrors: errors.length,
|
||||||
|
personMatches: matches.length,
|
||||||
|
unresolvedMatches: matches.filter((item) => !item.selectedEmployeeId).length,
|
||||||
|
},
|
||||||
|
errors: errors.slice(0, 500),
|
||||||
|
matches,
|
||||||
|
roster: matchRoster.map((employee) => ({
|
||||||
|
employeeId: employee.employee_id,
|
||||||
|
name: employee.name,
|
||||||
|
team: employee.team,
|
||||||
|
position: employee.position,
|
||||||
|
employmentStatus: employee.employment_status || '在职',
|
||||||
|
})),
|
||||||
|
canCommit: errors.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholders(rows, columns) {
|
||||||
|
return rows.map(() => `(${columns.map(() => '?').join(',')})`).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkUpsert(conn, table, columns, rows, updateColumns) {
|
||||||
|
for (let offset = 0; offset < rows.length; offset += 200) {
|
||||||
|
const part = rows.slice(offset, offset + 200);
|
||||||
|
const params = part.flatMap((row) => columns.map((col) => {
|
||||||
|
const value = row[col];
|
||||||
|
return value != null && typeof value === 'object' ? JSON.stringify(value) : value ?? null;
|
||||||
|
}));
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO \`${table}\` (${columns.map((col) => `\`${col}\``).join(',')})
|
||||||
|
VALUES ${placeholders(part, columns)}
|
||||||
|
ON DUPLICATE KEY UPDATE ${updateColumns.map((col) =>
|
||||||
|
col === 'version' ? '`version`=`version`+1' : `\`${col}\`=VALUES(\`${col}\`)`
|
||||||
|
).join(',')}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMatches(rows, matches, selections, roster) {
|
||||||
|
const knownIds = new Set(roster.map((employee) => employee.employee_id));
|
||||||
|
const namesById = new Map(roster.map((employee) => [employee.employee_id, employee.name]));
|
||||||
|
const selected = new Map();
|
||||||
|
for (const match of matches) {
|
||||||
|
const employeeId = selections[match.matchId] || match.selectedEmployeeId;
|
||||||
|
if (!employeeId || (employeeId !== UNKNOWN_EMPLOYEE && !knownIds.has(employeeId))) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `人员“${match.sourceName}”尚未完成花名册匹配`, 400);
|
||||||
|
}
|
||||||
|
selected.set(match.matchId, employeeId);
|
||||||
|
}
|
||||||
|
return rows.map((row) => {
|
||||||
|
const resolved = row.driver_match_ids.map((matchId) => selected.get(matchId));
|
||||||
|
const employeeIds = [...new Set(resolved.filter((employeeId) => employeeId !== UNKNOWN_EMPLOYEE))];
|
||||||
|
const displayNames = [...new Set(resolved.map((employeeId) =>
|
||||||
|
employeeId === UNKNOWN_EMPLOYEE ? '未知' : namesById.get(employeeId)
|
||||||
|
).filter(Boolean))];
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
involved_driver_text: displayNames.length ? displayNames.join('、') : row.involved_driver_text,
|
||||||
|
employee_ids: employeeIds,
|
||||||
|
driver_match_ids: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit({ importId, selections = {}, operator, meta }) {
|
||||||
|
const raw = await redis.client.get(`monthly-import:${importId}`);
|
||||||
|
if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '导入预览已过期,请重新选择文件', 404);
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (payload.validationErrorCount > 0) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `存在 ${payload.validationErrorCount} 条完整性错误,禁止提交`, 400);
|
||||||
|
}
|
||||||
|
const faults = applyMatches(payload.faults, payload.matches, selections, payload.matchRoster || payload.employees);
|
||||||
|
const incidents = applyMatches(payload.incidents, payload.matches, selections, payload.matchRoster || payload.employees);
|
||||||
|
const batchUuid = randomUUID();
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO upload_batches
|
||||||
|
(batch_uuid, module, data_type, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at)
|
||||||
|
VALUES (?, 'monthly_materials', 'xlsx', ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`,
|
||||||
|
[
|
||||||
|
batchUuid, payload.fileName, payload.fileHash, payload.fileSize,
|
||||||
|
payload.employees.length + payload.performance.length + faults.length + incidents.length,
|
||||||
|
JSON.stringify({ year: payload.year }), operator.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 增量导入:不软删花名册;在职/非在职以「9.乘务人事月报」为准
|
||||||
|
const employeeRows = payload.employees.map((row) => ({
|
||||||
|
employee_id: row.employee_id,
|
||||||
|
internal_employee_id: row.internal_employee_id,
|
||||||
|
name: row.name,
|
||||||
|
team: row.team,
|
||||||
|
position: row.position,
|
||||||
|
skill_level: row.skill_level,
|
||||||
|
status: 'active',
|
||||||
|
source: SOURCE_TYPE,
|
||||||
|
created_by: operator.id,
|
||||||
|
details: row.details,
|
||||||
|
deleted_at: null,
|
||||||
|
version: 1,
|
||||||
|
}));
|
||||||
|
await bulkUpsert(
|
||||||
|
conn,
|
||||||
|
'employees',
|
||||||
|
['employee_id', 'internal_employee_id', 'name', 'team', 'position', 'skill_level', 'status', 'source', 'created_by', 'details', 'deleted_at', 'version'],
|
||||||
|
employeeRows,
|
||||||
|
[
|
||||||
|
'internal_employee_id', 'name', 'team', 'position', 'skill_level', 'status', 'source',
|
||||||
|
'created_by', 'deleted_at',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for (const row of employeeRows) {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE employees
|
||||||
|
SET details=JSON_MERGE_PATCH(COALESCE(details, JSON_OBJECT()), ?), version=version+1
|
||||||
|
WHERE employee_id=?`,
|
||||||
|
[JSON.stringify(row.details), row.employee_id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量计算所有涉及员工的责任人快照(performance 单员工 / fault+incident 多员工取第一个)
|
||||||
|
const responsible = require('../common/responsible');
|
||||||
|
const allEmployeeIdsForResp = new Set();
|
||||||
|
for (const row of payload.performance || []) {
|
||||||
|
if (row.employee_id) allEmployeeIdsForResp.add(String(row.employee_id));
|
||||||
|
}
|
||||||
|
for (const row of faults) {
|
||||||
|
const ids = Array.isArray(row.employee_ids) ? row.employee_ids : [];
|
||||||
|
if (ids.length > 0) allEmployeeIdsForResp.add(String(ids[0]));
|
||||||
|
}
|
||||||
|
for (const row of incidents) {
|
||||||
|
const ids = Array.isArray(row.employee_ids) ? row.employee_ids : [];
|
||||||
|
if (ids.length > 0) allEmployeeIdsForResp.add(String(ids[0]));
|
||||||
|
}
|
||||||
|
// 必须用事务 conn:否则读不到本事务刚 upsert 的班组/岗位,会误落成管理部经理
|
||||||
|
const responsibleMap = await responsible.computeResponsiblePersonBatch(
|
||||||
|
[...allEmployeeIdsForResp],
|
||||||
|
{ conn },
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
table: 'performance_assessments',
|
||||||
|
rows: payload.performance,
|
||||||
|
columns: [
|
||||||
|
'source_type', 'source_key', 'assessment_date', 'employee_id', 'employee_name', 'team',
|
||||||
|
'violation_reason', 'assessment_category', 'assessment_item', 'performance_deduction',
|
||||||
|
'performance_points', 'star_points', 'safe_kilometers', 'twelve_point_deduction',
|
||||||
|
'remarks', 'responsible_person', 'source_payload', 'source_row', 'import_batch', 'deleted_at', 'version',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'fault_disposals',
|
||||||
|
rows: faults,
|
||||||
|
columns: [
|
||||||
|
'source_type', 'source_key', 'fault_date', 'fault_time', 'vehicle_model', 'key_train_number',
|
||||||
|
'train_number', 'car_number', 'location_primary', 'location_secondary',
|
||||||
|
'involved_driver_text', 'employee_ids', 'fault_phenomenon', 'fault_type',
|
||||||
|
'fault_attribution', 'impact_midway', 'impact_terminal', 'first_operation',
|
||||||
|
'first_result', 'final_result', 'fault_tracking', 'responsible_person', 'source_payload', 'source_row',
|
||||||
|
'import_batch', 'deleted_at', 'version',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'incident_events',
|
||||||
|
rows: incidents,
|
||||||
|
columns: [
|
||||||
|
'source_type', 'source_key', 'event_date', 'train_number', 'car_number', 'event_location',
|
||||||
|
'fault_category', 'phenomenon_summary', 'involved_driver_text', 'employee_ids',
|
||||||
|
'involved_team', 'event_reason', 'delay_details', 'operation_issues', 'responsible_person', 'source_payload',
|
||||||
|
'source_row', 'import_batch', 'deleted_at', 'version',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// 累积表按 source_key upsert:本月有的新增/更新,历史行保留不软删
|
||||||
|
for (const group of groups) {
|
||||||
|
const rows = group.rows.map((row) => {
|
||||||
|
const ids = Array.isArray(row.employee_ids) ? row.employee_ids : [];
|
||||||
|
const primaryId = String(row.employee_id || ids[0] || '');
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
source_type: SOURCE_TYPE,
|
||||||
|
import_batch: batchUuid,
|
||||||
|
responsible_person: responsibleMap[primaryId] || '',
|
||||||
|
deleted_at: null,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await bulkUpsert(
|
||||||
|
conn,
|
||||||
|
group.table,
|
||||||
|
group.columns,
|
||||||
|
rows,
|
||||||
|
group.columns.filter((col) => !['source_type', 'source_key'].includes(col)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE upload_batches SET status='completed', completed_at=NOW(),
|
||||||
|
payload=? WHERE batch_uuid=?`,
|
||||||
|
[JSON.stringify({
|
||||||
|
employees: payload.employees.length,
|
||||||
|
performanceAssessments: payload.performance.length,
|
||||||
|
faultDisposals: faults.length,
|
||||||
|
incidentEvents: incidents.length,
|
||||||
|
}), batchUuid],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记住本次人员核对,下月预览自动带出,避免重复出现在「涉及人员核对」
|
||||||
|
const nameMap = await loadDriverNameMap(payload.matchRoster || payload.employees);
|
||||||
|
for (const match of payload.matches || []) {
|
||||||
|
const employeeId = selections[match.matchId] || match.selectedEmployeeId;
|
||||||
|
if (!employeeId) continue;
|
||||||
|
nameMap[driverMapKey(match.sourceName, match.contextTeam)] =
|
||||||
|
employeeId === UNKNOWN_EMPLOYEE ? UNKNOWN_EMPLOYEE : String(employeeId).trim().padStart(5, '0');
|
||||||
|
}
|
||||||
|
await saveDriverNameMap(nameMap);
|
||||||
|
|
||||||
|
await sync.bumpVersions(['employees', 'performance_assessments', 'fault_disposals', 'incident_events']);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'monthly_materials_import',
|
||||||
|
module: 'imports',
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: {
|
||||||
|
fileName: payload.fileName,
|
||||||
|
fileHash: payload.fileHash,
|
||||||
|
employees: payload.employees.length,
|
||||||
|
performanceAssessments: payload.performance.length,
|
||||||
|
faultDisposals: faults.length,
|
||||||
|
incidentEvents: incidents.length,
|
||||||
|
},
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
await redis.client.del(`monthly-import:${importId}`);
|
||||||
|
return {
|
||||||
|
batchUuid,
|
||||||
|
counts: {
|
||||||
|
employees: payload.employees.length,
|
||||||
|
performanceAssessments: payload.performance.length,
|
||||||
|
faultDisposals: faults.length,
|
||||||
|
incidentEvents: incidents.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { preview, commit, SHEETS };
|
||||||
913
server/src/import/personnel-roster.js
Normal file
913
server/src/import/personnel-roster.js
Normal file
@ -0,0 +1,913 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 乘务人事月报导入(开发者维护 · 导入 9)
|
||||||
|
* - 工作表「基础信息」
|
||||||
|
* - 按工号 + 姓名精确匹配现有花名册
|
||||||
|
* - 匹配到的人:更新人事字段(籍贯/学历/在职状态/手机等);岗位、班组以「1.月度材料」为准,本导入不覆盖
|
||||||
|
* - 未匹配到的新人:INSERT 时可写入本表岗位/班组
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createHash, randomUUID } = require('crypto');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const crypto = require('../common/crypto');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const PREVIEW_TTL_SECONDS = 30 * 60;
|
||||||
|
const PREVIEW_PREFIX = 'personnel-roster-import:';
|
||||||
|
const SOURCE_TAG = 'personnel_roster';
|
||||||
|
|
||||||
|
const HEADER_ALIASES = {
|
||||||
|
employeeId: ['工号'],
|
||||||
|
internalEmployeeId: ['工作证号', '工作证编号'],
|
||||||
|
name: ['姓名'],
|
||||||
|
position: ['岗位'],
|
||||||
|
team: ['班组'],
|
||||||
|
mobile: ['请输入手机号', '手机号', '联系方式'],
|
||||||
|
idCard: ['个人身份证号', '身份证号'],
|
||||||
|
address: ['现居详细地址', '地址'],
|
||||||
|
nativePlace: ['籍贯'],
|
||||||
|
nationality: ['民族'],
|
||||||
|
politicalStatus: ['政治面貌'],
|
||||||
|
education: ['最高学历信息:最高学历', '最高学历', '学历'],
|
||||||
|
educationType: ['最高学历信息:全日制/非全日制', '全日制/非全日制', '学制'],
|
||||||
|
graduateSchool: ['最高学历信息:毕业院校', '毕业院校'],
|
||||||
|
emergencyContactName: ['紧急联系人姓名'],
|
||||||
|
emergencyContactPhone: ['紧急联系人手机号'],
|
||||||
|
emergencyContactRelation: ['紧急联系人与职工关系'],
|
||||||
|
cardNumber: ['卡内号'],
|
||||||
|
hireDate: ['入职时间', '入职年月'],
|
||||||
|
totalKilometers: ['司机安全公里数', '安全公里数'],
|
||||||
|
remarks: ['备注'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOf(cell) {
|
||||||
|
const value = cell?.value;
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value !== 'object') return value;
|
||||||
|
if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result });
|
||||||
|
if (value.error) return value.error;
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('');
|
||||||
|
if (value.text != null) return value.text;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const y = value.getUTCFullYear();
|
||||||
|
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(value.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (value.error) return String(value.error);
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('').trim();
|
||||||
|
if (value.result !== undefined) return text(value.result);
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmployeeId(value) {
|
||||||
|
const raw = text(value).replace(/\s+/g, '');
|
||||||
|
if (!raw) return '';
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
if (!digits) return raw;
|
||||||
|
return digits.padStart(5, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉重名数字后缀,如 孙俊1 → 孙俊 */
|
||||||
|
function normalizedName(value) {
|
||||||
|
return String(value ?? '').trim().replace(/\d+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 精确相等,或去数字后缀后相等(花名册重名后缀) */
|
||||||
|
function namesCompatible(excelName, rosterName) {
|
||||||
|
const left = text(excelName);
|
||||||
|
const right = text(rosterName);
|
||||||
|
if (!left || !right) return false;
|
||||||
|
if (left === right) return true;
|
||||||
|
return normalizedName(left) === normalizedName(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function excelSerialToDate(number) {
|
||||||
|
return new Date(Date.UTC(1899, 11, 30) + Math.floor(number) * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入职时间:支持 Date / Excel 序列 / 年.月小数(2012.3) / 20000701 / 200007 / 2000-07 / 2000/7 / 2000年7月 / 月/日/年 */
|
||||||
|
function parseHireDate(value) {
|
||||||
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
|
const y = value.getUTCFullYear();
|
||||||
|
const m = value.getUTCMonth() + 1;
|
||||||
|
const d = value.getUTCDate();
|
||||||
|
if (!isValidYMD(y, m, d)) return null;
|
||||||
|
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
const str = String(value);
|
||||||
|
// 「年.月」小数格式:2012.3 = 2012年3月。用字符串解析避免 JS 浮点 2012.10===2012.1 丢零
|
||||||
|
if (/^\d{4}\.\d{1,2}$/.test(str)) {
|
||||||
|
const [yy, mm] = str.split('.');
|
||||||
|
const y = Number(yy);
|
||||||
|
const m = Number(mm);
|
||||||
|
if (y >= 1900 && y <= 2100 && m >= 1 && m <= 12) return `${y}-${pad2(m)}-01`;
|
||||||
|
}
|
||||||
|
// Excel 序列号:整数且 >= 20000(约 1954-09-25 之后),避免把小数年误当序列
|
||||||
|
if (Number.isInteger(value) && value >= 20000) {
|
||||||
|
return parseHireDate(excelSerialToDate(value));
|
||||||
|
}
|
||||||
|
// 其他整数:尝试 Excel 序列,但只在结果年份合理(1990-2100)时采用
|
||||||
|
if (Number.isInteger(value) && value > 0) {
|
||||||
|
const d = excelSerialToDate(value);
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
if (y >= 1990 && y <= 2100) return parseHireDate(d);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = text(value).replace(/\s+/g, '');
|
||||||
|
if (!raw) return null;
|
||||||
|
// 8 位连写:20000701 → 2000-07-01
|
||||||
|
let match = raw.match(/^(\d{4})(\d{2})(\d{2})$/);
|
||||||
|
if (match) {
|
||||||
|
const y = Number(match[1]);
|
||||||
|
const m = Number(match[2]);
|
||||||
|
const d = Number(match[3]);
|
||||||
|
if (isValidYMD(y, m, d)) return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||||
|
}
|
||||||
|
// 6 位连写:200007 → 2000-07-01
|
||||||
|
match = raw.match(/^(\d{4})(\d{2})$/);
|
||||||
|
if (match) {
|
||||||
|
const y = Number(match[1]);
|
||||||
|
const m = Number(match[2]);
|
||||||
|
if (y >= 1900 && y <= 2100 && m >= 1 && m <= 12) return `${y}-${pad2(m)}-01`;
|
||||||
|
}
|
||||||
|
// 通用:2000-07 / 2000/7 / 2000.7 / 2000年7月 / 2000-07-01 / 2000.7.1 / 2000年7月1日
|
||||||
|
match = raw.match(/^(\d{4})[-/.年](\d{1,2})(?:[-/.月](\d{1,2})日?)?/);
|
||||||
|
if (match) {
|
||||||
|
const y = Number(match[1]);
|
||||||
|
const m = Number(match[2]);
|
||||||
|
const d = match[3] ? Number(match[3]) : 1;
|
||||||
|
if (isValidYMD(y, m, d)) return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||||
|
}
|
||||||
|
// 月/日/年(2 位年):7-2-24 → 2024-07-02
|
||||||
|
match = raw.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})$/);
|
||||||
|
if (match) {
|
||||||
|
let y = Number(match[3]);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
const m = Number(match[1]);
|
||||||
|
const d = Number(match[2]);
|
||||||
|
if (isValidYMD(y, m, d)) return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidYMD(y, m, d) {
|
||||||
|
return y >= 1900 && y <= 2100 && m >= 1 && m <= 12 && d >= 1 && d <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKilometers(value) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
const raw = text(value).replace(/,/g, '');
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = Number(raw);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSheet(workbook) {
|
||||||
|
const exact = workbook.worksheets.find((sheet) => sheet.name.trim() === '基础信息');
|
||||||
|
if (exact) return exact;
|
||||||
|
return workbook.worksheets.find((sheet) => sheet.name.includes('基础信息')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaderMap(headerRow) {
|
||||||
|
const map = {};
|
||||||
|
headerRow.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
||||||
|
const label = text(valueOf(cell));
|
||||||
|
if (!label) return;
|
||||||
|
if (map[label] == null) map[label] = colNumber;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colOf(headerMap, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (headerMap[key] != null) return headerMap[key];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(sheet, rowNumber, col) {
|
||||||
|
if (col == null) return '';
|
||||||
|
return text(valueOf(sheet.getCell(rowNumber, col)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellRaw(sheet, rowNumber, col) {
|
||||||
|
if (col == null) return '';
|
||||||
|
return valueOf(sheet.getCell(rowNumber, col));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoster() {
|
||||||
|
// 含软删:人事月报应能「复活」并更新;否则会被当成 newHires 再 INSERT → uk_employee_id 冲突 → 500
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT employee_id, name, internal_employee_id, team, status, deleted_at
|
||||||
|
FROM employees`,
|
||||||
|
);
|
||||||
|
const byEmployeeId = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = normalizeEmployeeId(row.employee_id);
|
||||||
|
if (!id) continue;
|
||||||
|
byEmployeeId.set(id, {
|
||||||
|
employee_id: id,
|
||||||
|
name: String(row.name || '').trim(),
|
||||||
|
internal_employee_id: row.internal_employee_id,
|
||||||
|
team: row.team,
|
||||||
|
status: row.status,
|
||||||
|
softDeleted: row.deleted_at != null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return byEmployeeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** employment_status 列 VARCHAR(50);备注可能更长,超长会 Data truncated → 500 */
|
||||||
|
function employmentStatusValue(remarks) {
|
||||||
|
const raw = String(remarks || '').trim() || '在职';
|
||||||
|
return raw.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mysql2 拒绝 undefined,缺字段必须转成 null */
|
||||||
|
function sqlVal(value) {
|
||||||
|
return value === undefined ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整组绑定参数消毒;若仍有 undefined 则抛出带索引的调试信息 */
|
||||||
|
function sqlParams(label, values) {
|
||||||
|
const out = values.map((value) => (value === undefined ? null : value));
|
||||||
|
const bad = [];
|
||||||
|
for (let i = 0; i < values.length; i += 1) {
|
||||||
|
if (values[i] === undefined) bad.push(i);
|
||||||
|
}
|
||||||
|
if (bad.length) {
|
||||||
|
console.error(`[personnel-roster] ${label} had undefined at indexes:`, bad, 'raw=', values);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主任办事员预览项字段较少,提交前归一成可 INSERT 结构 */
|
||||||
|
function normalizeInsertRow(row) {
|
||||||
|
if (!row || typeof row !== 'object') return row;
|
||||||
|
const employeeId = row.employee_id || row.employeeId || null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
employee_id: employeeId,
|
||||||
|
internal_employee_id: row.internal_employee_id ?? row.internalEmployeeId ?? null,
|
||||||
|
name: row.name ?? '',
|
||||||
|
team: row.team ?? null,
|
||||||
|
position: row.position ?? null,
|
||||||
|
native_place: row.native_place ?? null,
|
||||||
|
nationality: row.nationality ?? null,
|
||||||
|
political_status: row.political_status ?? null,
|
||||||
|
education: row.education ?? null,
|
||||||
|
education_type: row.education_type ?? null,
|
||||||
|
graduate_school: row.graduate_school ?? null,
|
||||||
|
hire_date: row.hire_date ?? null,
|
||||||
|
mobile: row.mobile ?? null,
|
||||||
|
id_card: row.id_card ?? null,
|
||||||
|
address: row.address ?? null,
|
||||||
|
emergency_contact: row.emergency_contact ?? null,
|
||||||
|
employment_status_value: row.employment_status_value || '在职',
|
||||||
|
details: row.details || {
|
||||||
|
personnelRosterImportedAt: new Date().toISOString(),
|
||||||
|
personnelRosterSource: SOURCE_TAG,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 控制明文长度,避免 AES 后超出 VARBINARY 上限 */
|
||||||
|
function truncatePlain(value, maxBytes) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const textValue = String(value);
|
||||||
|
const buf = Buffer.from(textValue, 'utf8');
|
||||||
|
if (buf.length <= maxBytes) return textValue;
|
||||||
|
return buf.subarray(0, maxBytes).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkbook(workbook, roster) {
|
||||||
|
const sheet = findSheet(workbook);
|
||||||
|
if (!sheet) throw new ApiError(API_CODES.BAD_PARAMS, '未找到工作表「基础信息」', 400);
|
||||||
|
|
||||||
|
const headerRow = sheet.getRow(1);
|
||||||
|
const headerMap = buildHeaderMap(headerRow);
|
||||||
|
const cols = {};
|
||||||
|
for (const [field, aliases] of Object.entries(HEADER_ALIASES)) {
|
||||||
|
cols[field] = colOf(headerMap, aliases);
|
||||||
|
}
|
||||||
|
if (!cols.employeeId || !cols.name) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '表头缺少「工号」或「姓名」', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const newHires = []; // 文件有、花名册无、岗位非主任办事员 → 自动 INSERT 完整字段
|
||||||
|
const missingClerks = []; // 文件有、花名册无、岗位=主任办事员 → 默认跳过,提交时弹窗确认
|
||||||
|
const skippedRemarks = [];
|
||||||
|
const unmatched = []; // 工号命中但姓名冲突 → 硬阻断
|
||||||
|
const idsInFile = new Set();
|
||||||
|
// Excel 内工号重复检测:第 2 次出现的行记入 duplicateIds,不进 updates/newHires(避免 UNIQUE KEY 冲突)
|
||||||
|
const duplicateIds = [];
|
||||||
|
const seenEmployeeRows = new Map(); // employee_id → 第一次出现的 sourceRow
|
||||||
|
let halted = false;
|
||||||
|
let haltedAtRow = null;
|
||||||
|
|
||||||
|
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||||
|
const employeeId = normalizeEmployeeId(cellRaw(sheet, rowNumber, cols.employeeId));
|
||||||
|
const name = cellText(sheet, rowNumber, cols.name);
|
||||||
|
const remarks = cellText(sheet, rowNumber, cols.remarks).trim();
|
||||||
|
|
||||||
|
if (!employeeId && !name && !remarks) {
|
||||||
|
const probe = cellText(sheet, rowNumber, cols.mobile)
|
||||||
|
|| cellText(sheet, rowNumber, cols.idCard)
|
||||||
|
|| cellText(sheet, rowNumber, cols.address);
|
||||||
|
if (!probe) continue;
|
||||||
|
}
|
||||||
|
if (!employeeId && !name) continue;
|
||||||
|
|
||||||
|
// Excel 内工号重复:第二次出现计入 duplicateIds,不进任何 push(避免 INSERT 时 UNIQUE KEY 冲突)
|
||||||
|
if (employeeId) {
|
||||||
|
if (seenEmployeeRows.has(employeeId)) {
|
||||||
|
const firstRow = seenEmployeeRows.get(employeeId);
|
||||||
|
duplicateIds.push({
|
||||||
|
rowId: `dup-${rowNumber}`,
|
||||||
|
sourceRow: rowNumber,
|
||||||
|
employeeId,
|
||||||
|
name: name || null,
|
||||||
|
reason: `工号 ${employeeId} 在第 ${firstRow} 行已出现(Excel 内重复),跳过本次导入`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenEmployeeRows.set(employeeId, rowNumber);
|
||||||
|
idsInFile.add(employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterRow = employeeId ? roster.get(employeeId) : null;
|
||||||
|
|
||||||
|
// 先读位置/班组用于分流
|
||||||
|
const position = cellText(sheet, rowNumber, cols.position);
|
||||||
|
const team = cellText(sheet, rowNumber, cols.team);
|
||||||
|
const internalEmployeeId = cellText(sheet, rowNumber, cols.internalEmployeeId);
|
||||||
|
|
||||||
|
// 文件有、花名册无:按岗位分流(主任办事员默认跳过、其他自动新增)
|
||||||
|
if (employeeId && !rosterRow) {
|
||||||
|
if (position === '主任办事员') {
|
||||||
|
const hireDate = parseHireDate(cellRaw(sheet, rowNumber, cols.hireDate));
|
||||||
|
const totalKilometers = parseKilometers(cellRaw(sheet, rowNumber, cols.totalKilometers));
|
||||||
|
const emergencyContactName = cellText(sheet, rowNumber, cols.emergencyContactName);
|
||||||
|
const emergencyContactPhone = cellText(sheet, rowNumber, cols.emergencyContactPhone);
|
||||||
|
const emergencyContactRelation = cellText(sheet, rowNumber, cols.emergencyContactRelation);
|
||||||
|
missingClerks.push({
|
||||||
|
rowId: `clerk-${rowNumber}`,
|
||||||
|
sourceRow: rowNumber,
|
||||||
|
employeeId,
|
||||||
|
name: name || null,
|
||||||
|
position,
|
||||||
|
reason: `主任办事员 ${employeeId} 未在花名册中(默认跳过,提交时可确认一并新增)`,
|
||||||
|
// 提交可选 INSERT 时用完整字段(与 newHires 同结构)
|
||||||
|
employee_id: employeeId,
|
||||||
|
internal_employee_id: internalEmployeeId || null,
|
||||||
|
team: team || null,
|
||||||
|
employment_status_value: employmentStatusValue(remarks),
|
||||||
|
mobile: cellText(sheet, rowNumber, cols.mobile) || null,
|
||||||
|
id_card: cellText(sheet, rowNumber, cols.idCard) || null,
|
||||||
|
address: cellText(sheet, rowNumber, cols.address) || null,
|
||||||
|
native_place: cellText(sheet, rowNumber, cols.nativePlace) || null,
|
||||||
|
nationality: cellText(sheet, rowNumber, cols.nationality) || null,
|
||||||
|
political_status: cellText(sheet, rowNumber, cols.politicalStatus) || null,
|
||||||
|
education: cellText(sheet, rowNumber, cols.education) || null,
|
||||||
|
education_type: cellText(sheet, rowNumber, cols.educationType) || null,
|
||||||
|
graduate_school: cellText(sheet, rowNumber, cols.graduateSchool) || null,
|
||||||
|
hire_date: hireDate,
|
||||||
|
emergency_contact: [emergencyContactName, emergencyContactRelation, emergencyContactPhone]
|
||||||
|
.filter(Boolean).join('|') || null,
|
||||||
|
details: {
|
||||||
|
cardNumber: cellText(sheet, rowNumber, cols.cardNumber) || '',
|
||||||
|
totalKilometers: totalKilometers == null ? '' : totalKilometers,
|
||||||
|
emergencyContactName: emergencyContactName || '',
|
||||||
|
emergencyContactPhone: emergencyContactPhone || '',
|
||||||
|
emergencyContactRelation: emergencyContactRelation || '',
|
||||||
|
personnelRosterImportedAt: new Date().toISOString(),
|
||||||
|
personnelRosterSource: SOURCE_TAG,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 完整字段收集,commit 时走 INSERT
|
||||||
|
const hireDate = parseHireDate(cellRaw(sheet, rowNumber, cols.hireDate));
|
||||||
|
const totalKilometers = parseKilometers(cellRaw(sheet, rowNumber, cols.totalKilometers));
|
||||||
|
const emergencyContactName = cellText(sheet, rowNumber, cols.emergencyContactName);
|
||||||
|
const emergencyContactPhone = cellText(sheet, rowNumber, cols.emergencyContactPhone);
|
||||||
|
const emergencyContactRelation = cellText(sheet, rowNumber, cols.emergencyContactRelation);
|
||||||
|
newHires.push({
|
||||||
|
sourceRow: rowNumber,
|
||||||
|
employee_id: employeeId,
|
||||||
|
internal_employee_id: internalEmployeeId || null,
|
||||||
|
name: name || '',
|
||||||
|
position: position || null,
|
||||||
|
team: team || null,
|
||||||
|
employment_status_value: employmentStatusValue(remarks),
|
||||||
|
mobile: cellText(sheet, rowNumber, cols.mobile) || null,
|
||||||
|
id_card: cellText(sheet, rowNumber, cols.idCard) || null,
|
||||||
|
address: cellText(sheet, rowNumber, cols.address) || null,
|
||||||
|
native_place: cellText(sheet, rowNumber, cols.nativePlace) || null,
|
||||||
|
nationality: cellText(sheet, rowNumber, cols.nationality) || null,
|
||||||
|
political_status: cellText(sheet, rowNumber, cols.politicalStatus) || null,
|
||||||
|
education: cellText(sheet, rowNumber, cols.education) || null,
|
||||||
|
education_type: cellText(sheet, rowNumber, cols.educationType) || null,
|
||||||
|
graduate_school: cellText(sheet, rowNumber, cols.graduateSchool) || null,
|
||||||
|
hire_date: hireDate,
|
||||||
|
emergency_contact: [emergencyContactName, emergencyContactRelation, emergencyContactPhone]
|
||||||
|
.filter(Boolean).join('|') || null,
|
||||||
|
details: {
|
||||||
|
cardNumber: cellText(sheet, rowNumber, cols.cardNumber) || '',
|
||||||
|
totalKilometers: totalKilometers == null ? '' : totalKilometers,
|
||||||
|
emergencyContactName: emergencyContactName || '',
|
||||||
|
emergencyContactPhone: emergencyContactPhone || '',
|
||||||
|
emergencyContactRelation: emergencyContactRelation || '',
|
||||||
|
personnelRosterImportedAt: new Date().toISOString(),
|
||||||
|
personnelRosterSource: SOURCE_TAG,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!employeeId || !name || !rosterRow || !namesCompatible(name, rosterRow.name)) {
|
||||||
|
if (!halted) {
|
||||||
|
halted = true;
|
||||||
|
haltedAtRow = rowNumber;
|
||||||
|
}
|
||||||
|
unmatched.push({
|
||||||
|
sourceRow: rowNumber,
|
||||||
|
employeeId: employeeId || null,
|
||||||
|
name: name || null,
|
||||||
|
rosterName: rosterRow?.name || null,
|
||||||
|
reason: !employeeId
|
||||||
|
? '工号为空'
|
||||||
|
: (!name
|
||||||
|
? '姓名为空'
|
||||||
|
: (!rosterRow
|
||||||
|
? `工号 ${employeeId} 未在花名册中`
|
||||||
|
: `姓名不符(Excel「${name}」/ 花名册「${rosterRow.name}」)`)),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hireDate = parseHireDate(cellRaw(sheet, rowNumber, cols.hireDate));
|
||||||
|
const totalKilometers = parseKilometers(cellRaw(sheet, rowNumber, cols.totalKilometers));
|
||||||
|
const mobile = cellText(sheet, rowNumber, cols.mobile);
|
||||||
|
const idCard = cellText(sheet, rowNumber, cols.idCard);
|
||||||
|
const address = cellText(sheet, rowNumber, cols.address);
|
||||||
|
const emergencyContactName = cellText(sheet, rowNumber, cols.emergencyContactName);
|
||||||
|
const emergencyContactPhone = cellText(sheet, rowNumber, cols.emergencyContactPhone);
|
||||||
|
const emergencyContactRelation = cellText(sheet, rowNumber, cols.emergencyContactRelation);
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
sourceRow: rowNumber,
|
||||||
|
employee_id: rosterRow.employee_id,
|
||||||
|
name: rosterRow.name,
|
||||||
|
internal_employee_id: internalEmployeeId || null,
|
||||||
|
position: position || null,
|
||||||
|
team: team || null,
|
||||||
|
employment_status_value: employmentStatusValue(remarks),
|
||||||
|
mobile: mobile || null,
|
||||||
|
id_card: idCard || null,
|
||||||
|
address: address || null,
|
||||||
|
native_place: cellText(sheet, rowNumber, cols.nativePlace) || null,
|
||||||
|
nationality: cellText(sheet, rowNumber, cols.nationality) || null,
|
||||||
|
political_status: cellText(sheet, rowNumber, cols.politicalStatus) || null,
|
||||||
|
education: cellText(sheet, rowNumber, cols.education) || null,
|
||||||
|
education_type: cellText(sheet, rowNumber, cols.educationType) || null,
|
||||||
|
graduate_school: cellText(sheet, rowNumber, cols.graduateSchool) || null,
|
||||||
|
hire_date: hireDate,
|
||||||
|
emergency_contact: [emergencyContactName, emergencyContactRelation, emergencyContactPhone]
|
||||||
|
.filter(Boolean).join('|') || null,
|
||||||
|
details: {
|
||||||
|
cardNumber: cellText(sheet, rowNumber, cols.cardNumber) || '',
|
||||||
|
totalKilometers: totalKilometers == null ? '' : totalKilometers,
|
||||||
|
emergencyContactName: emergencyContactName || '',
|
||||||
|
emergencyContactPhone: emergencyContactPhone || '',
|
||||||
|
emergencyContactRelation: emergencyContactRelation || '',
|
||||||
|
personnelRosterImportedAt: new Date().toISOString(),
|
||||||
|
personnelRosterSource: SOURCE_TAG,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 花名册有、本次文件无(仅统计未软删;软删的不算「应出现在文件中」)
|
||||||
|
const missingFromImport = [];
|
||||||
|
for (const [employeeId, entry] of roster.entries()) {
|
||||||
|
if (entry.softDeleted) continue;
|
||||||
|
if (idsInFile.has(employeeId)) continue;
|
||||||
|
missingFromImport.push({
|
||||||
|
rowId: `roster-${employeeId}`,
|
||||||
|
employeeId,
|
||||||
|
name: entry.name,
|
||||||
|
team: entry.team || null,
|
||||||
|
reason: '花名册有此人,本次导入文件未出现',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
missingFromImport.sort((a, b) => String(a.employeeId).localeCompare(String(b.employeeId), 'zh'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sheetName: sheet.name,
|
||||||
|
updates,
|
||||||
|
newHires,
|
||||||
|
missingClerks,
|
||||||
|
duplicateIds,
|
||||||
|
skippedRemarks,
|
||||||
|
missingFromImport,
|
||||||
|
unmatched,
|
||||||
|
halted,
|
||||||
|
haltedAtRow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview(file) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(file.buffer);
|
||||||
|
const roster = await loadRoster();
|
||||||
|
const parsed = parseWorkbook(workbook, roster);
|
||||||
|
const importId = randomUUID();
|
||||||
|
const hasNameConflict = parsed.unmatched.length > 0;
|
||||||
|
const needsConfirmMissing = parsed.missingFromImport.length > 0;
|
||||||
|
// 服务端:无姓名硬冲突且有可更新行或可新增行即可进入确认流;两侧差异需前端确认后才能 commit
|
||||||
|
const canCommit = !hasNameConflict && (parsed.updates.length > 0 || parsed.newHires.length > 0);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
fileHash: sha256(file.buffer),
|
||||||
|
fileSize: file.size,
|
||||||
|
sheetName: parsed.sheetName,
|
||||||
|
updates: parsed.updates,
|
||||||
|
newHires: parsed.newHires,
|
||||||
|
missingClerks: parsed.missingClerks,
|
||||||
|
duplicateIds: parsed.duplicateIds,
|
||||||
|
skippedRemarks: parsed.skippedRemarks,
|
||||||
|
missingFromImport: parsed.missingFromImport,
|
||||||
|
unmatched: parsed.unmatched,
|
||||||
|
halted: parsed.halted,
|
||||||
|
haltedAtRow: parsed.haltedAtRow,
|
||||||
|
};
|
||||||
|
await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS);
|
||||||
|
|
||||||
|
let blockReason = null;
|
||||||
|
if (hasNameConflict) {
|
||||||
|
blockReason = `匹配已暂停:有 ${parsed.unmatched.length} 条姓名/工号冲突`
|
||||||
|
+ (parsed.haltedAtRow ? `(自首个冲突行第 ${parsed.haltedAtRow} 行起)` : '')
|
||||||
|
+ '。请修正后重试。';
|
||||||
|
} else if (parsed.updates.length === 0 && parsed.newHires.length === 0) {
|
||||||
|
blockReason = '没有可更新或可新增的记录';
|
||||||
|
} else if (needsConfirmMissing) {
|
||||||
|
blockReason = '存在花名册与文件差异,请确认后再提交';
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonActiveCount = parsed.updates.filter((u) => u.employment_status_value !== '在职').length
|
||||||
|
+ parsed.newHires.filter((u) => u.employment_status_value !== '在职').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
sheetName: parsed.sheetName,
|
||||||
|
summary: {
|
||||||
|
matched: parsed.updates.length,
|
||||||
|
newHires: parsed.newHires.length,
|
||||||
|
missingClerks: parsed.missingClerks.length,
|
||||||
|
duplicateIds: parsed.duplicateIds.length,
|
||||||
|
skippedRemarks: parsed.skippedRemarks.length,
|
||||||
|
nonActive: nonActiveCount,
|
||||||
|
missingFromImport: parsed.missingFromImport.length,
|
||||||
|
unmatched: parsed.unmatched.length,
|
||||||
|
halted: parsed.halted,
|
||||||
|
haltedAtRow: parsed.haltedAtRow,
|
||||||
|
needsConfirmMissing,
|
||||||
|
},
|
||||||
|
updates: parsed.updates.slice(0, 30).map((row) => ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
employeeId: row.employee_id,
|
||||||
|
name: row.name,
|
||||||
|
mobile: row.mobile,
|
||||||
|
nativePlace: row.native_place,
|
||||||
|
education: row.education,
|
||||||
|
hireDate: row.hire_date,
|
||||||
|
cardNumber: row.details.cardNumber,
|
||||||
|
totalKilometers: row.details.totalKilometers,
|
||||||
|
})),
|
||||||
|
newHires: parsed.newHires.slice(0, 50).map((row) => ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
employeeId: row.employee_id,
|
||||||
|
name: row.name,
|
||||||
|
position: row.position,
|
||||||
|
team: row.team,
|
||||||
|
mobile: row.mobile,
|
||||||
|
hireDate: row.hire_date,
|
||||||
|
})),
|
||||||
|
missingClerks: parsed.missingClerks.slice(0, 200),
|
||||||
|
duplicateIds: parsed.duplicateIds.slice(0, 200),
|
||||||
|
missingFromImport: parsed.missingFromImport.slice(0, 200),
|
||||||
|
unmatched: parsed.unmatched.slice(0, 200),
|
||||||
|
canCommit,
|
||||||
|
blockReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit({ importId, selections = {}, operator, meta }) {
|
||||||
|
const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`);
|
||||||
|
if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '人事月报预览已过期,请重新选择文件', 404);
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (payload.unmatched && payload.unmatched.length > 0) {
|
||||||
|
throw new ApiError(
|
||||||
|
API_CODES.BAD_PARAMS,
|
||||||
|
`存在 ${payload.unmatched.length} 条姓名冲突,禁止提交`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.updates) || payload.updates.length === 0) {
|
||||||
|
if (!Array.isArray(payload.newHires) || payload.newHires.length === 0) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '没有可更新或可新增的记录', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 客户端弹窗确认后提交;此处只记录,不再二次拦截(避免 preload 未热更新时 selections 传丢误拒)
|
||||||
|
const confirmedMissing = selections.confirmMissingInFile === true
|
||||||
|
|| selections.confirmMissingInFile === 1
|
||||||
|
|| selections.confirmMissingInFile === 'true';
|
||||||
|
// 主任办事员(missingClerks)默认跳过;用户在客户端勾选"也新增主任办事员"后才 INSERT
|
||||||
|
const insertClerks = selections.confirmInsertClerks === true
|
||||||
|
|| selections.confirmInsertClerks === 1
|
||||||
|
|| selections.confirmInsertClerks === 'true';
|
||||||
|
if ((payload.missingFromImport || []).length > 0 && !confirmedMissing) {
|
||||||
|
console.warn(
|
||||||
|
'[personnel-roster] commit without confirmMissingInFile; proceeding after client dialog',
|
||||||
|
{ importId, missingFromImport: payload.missingFromImport.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并 INSERT 候选:newHires(非主任办事员,必 INSERT) + 可选 missingClerks
|
||||||
|
const inserts = [
|
||||||
|
...(payload.newHires || []),
|
||||||
|
...(insertClerks ? (payload.missingClerks || []) : []),
|
||||||
|
].map(normalizeInsertRow);
|
||||||
|
|
||||||
|
const batchUuid = randomUUID();
|
||||||
|
let updated = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
const operatorId = Number(operator?.id);
|
||||||
|
if (!Number.isFinite(operatorId)) {
|
||||||
|
throw new ApiError(API_CODES.UNAUTHENTICATED, '登录用户无效,请重新登录后再导入', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO upload_batches
|
||||||
|
(batch_uuid, module, data_type, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at)
|
||||||
|
VALUES (?, 'personnel_roster', 'xlsx', ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`,
|
||||||
|
sqlParams('upload_batches', [
|
||||||
|
batchUuid,
|
||||||
|
sqlVal(payload.fileName),
|
||||||
|
sqlVal(payload.fileHash),
|
||||||
|
sqlVal(payload.fileSize),
|
||||||
|
(payload.updates?.length || 0) + inserts.length,
|
||||||
|
JSON.stringify({
|
||||||
|
source: SOURCE_TAG,
|
||||||
|
newHires: (payload.newHires || []).length,
|
||||||
|
missingClerks: (payload.missingClerks || []).length,
|
||||||
|
missingFromImport: (payload.missingFromImport || []).length,
|
||||||
|
insertClerks,
|
||||||
|
confirmed: selections,
|
||||||
|
}),
|
||||||
|
operatorId,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of payload.updates || []) {
|
||||||
|
const idCardEnc = row.id_card ? crypto.encrypt(truncatePlain(row.id_card, 400)) : null;
|
||||||
|
const idCardHmac = row.id_card ? crypto.hmac(String(row.id_card)) : null;
|
||||||
|
const mobileEnc = row.mobile ? crypto.encrypt(truncatePlain(row.mobile, 400)) : null;
|
||||||
|
const mobileHmac = row.mobile ? crypto.hmac(String(row.mobile)) : null;
|
||||||
|
const addressEnc = row.address ? crypto.encrypt(truncatePlain(row.address, 900)) : null;
|
||||||
|
const emergencyEnc = row.emergency_contact
|
||||||
|
? crypto.encrypt(truncatePlain(row.emergency_contact, 900))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const updateParams = sqlParams(`update:${row.employee_id || row.sourceRow}`, [
|
||||||
|
sqlVal(row.native_place),
|
||||||
|
sqlVal(row.nationality),
|
||||||
|
sqlVal(row.political_status),
|
||||||
|
sqlVal(row.education),
|
||||||
|
sqlVal(row.education_type),
|
||||||
|
sqlVal(row.graduate_school),
|
||||||
|
sqlVal(row.hire_date),
|
||||||
|
idCardEnc,
|
||||||
|
idCardHmac,
|
||||||
|
mobileEnc,
|
||||||
|
mobileHmac,
|
||||||
|
addressEnc,
|
||||||
|
emergencyEnc,
|
||||||
|
employmentStatusValue(row.employment_status_value),
|
||||||
|
sqlVal(row.internal_employee_id || null),
|
||||||
|
JSON.stringify(row.details || {}),
|
||||||
|
sqlVal(row.employee_id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`UPDATE employees SET
|
||||||
|
native_place = ?,
|
||||||
|
nationality = ?,
|
||||||
|
political_status = ?,
|
||||||
|
education = ?,
|
||||||
|
education_type = ?,
|
||||||
|
graduate_school = ?,
|
||||||
|
hire_date = ?,
|
||||||
|
id_card_enc = ?,
|
||||||
|
id_card_hmac = ?,
|
||||||
|
mobile_enc = ?,
|
||||||
|
mobile_hmac = ?,
|
||||||
|
address_enc = ?,
|
||||||
|
emergency_contact_enc = ?,
|
||||||
|
employment_status = ?,
|
||||||
|
internal_employee_id = COALESCE(?, internal_employee_id),
|
||||||
|
status = 'active',
|
||||||
|
deleted_at = NULL,
|
||||||
|
details = JSON_MERGE_PATCH(COALESCE(details, JSON_OBJECT()), ?),
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE employee_id = ?`,
|
||||||
|
updateParams,
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增员工 INSERT(已存在/软删则 upsert 复活,避免 uk_employee_id 500)
|
||||||
|
for (const row of inserts) {
|
||||||
|
const idCardEnc = row.id_card ? crypto.encrypt(truncatePlain(row.id_card, 400)) : null;
|
||||||
|
const idCardHmac = row.id_card ? crypto.hmac(String(row.id_card)) : null;
|
||||||
|
const mobileEnc = row.mobile ? crypto.encrypt(truncatePlain(row.mobile, 400)) : null;
|
||||||
|
const mobileHmac = row.mobile ? crypto.hmac(String(row.mobile)) : null;
|
||||||
|
const addressEnc = row.address ? crypto.encrypt(truncatePlain(row.address, 900)) : null;
|
||||||
|
const emergencyEnc = row.emergency_contact
|
||||||
|
? crypto.encrypt(truncatePlain(row.emergency_contact, 900))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const insertParams = sqlParams(`insert:${row.employee_id || row.sourceRow}`, [
|
||||||
|
sqlVal(row.employee_id),
|
||||||
|
sqlVal(row.internal_employee_id || null),
|
||||||
|
sqlVal(row.name),
|
||||||
|
sqlVal(row.team || null),
|
||||||
|
sqlVal(row.position || null),
|
||||||
|
sqlVal(row.native_place || null),
|
||||||
|
sqlVal(row.nationality || null),
|
||||||
|
sqlVal(row.political_status || null),
|
||||||
|
sqlVal(row.education || null),
|
||||||
|
sqlVal(row.education_type || null),
|
||||||
|
sqlVal(row.graduate_school || null),
|
||||||
|
sqlVal(row.hire_date || null),
|
||||||
|
idCardEnc,
|
||||||
|
idCardHmac,
|
||||||
|
mobileEnc,
|
||||||
|
mobileHmac,
|
||||||
|
addressEnc,
|
||||||
|
emergencyEnc,
|
||||||
|
employmentStatusValue(row.employment_status_value),
|
||||||
|
SOURCE_TAG,
|
||||||
|
operatorId,
|
||||||
|
JSON.stringify(row.details || {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`INSERT INTO employees (
|
||||||
|
employee_id, internal_employee_id, name, team, position,
|
||||||
|
native_place, nationality, political_status,
|
||||||
|
education, education_type, graduate_school,
|
||||||
|
hire_date,
|
||||||
|
id_card_enc, id_card_hmac,
|
||||||
|
mobile_enc, mobile_hmac,
|
||||||
|
address_enc, emergency_contact_enc,
|
||||||
|
status, employment_status, source, created_by, details, deleted_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
internal_employee_id = COALESCE(VALUES(internal_employee_id), internal_employee_id),
|
||||||
|
name = VALUES(name),
|
||||||
|
team = COALESCE(team, VALUES(team)),
|
||||||
|
position = COALESCE(position, VALUES(position)),
|
||||||
|
native_place = VALUES(native_place),
|
||||||
|
nationality = VALUES(nationality),
|
||||||
|
political_status = VALUES(political_status),
|
||||||
|
education = VALUES(education),
|
||||||
|
education_type = VALUES(education_type),
|
||||||
|
graduate_school = VALUES(graduate_school),
|
||||||
|
hire_date = VALUES(hire_date),
|
||||||
|
id_card_enc = VALUES(id_card_enc),
|
||||||
|
id_card_hmac = VALUES(id_card_hmac),
|
||||||
|
mobile_enc = VALUES(mobile_enc),
|
||||||
|
mobile_hmac = VALUES(mobile_hmac),
|
||||||
|
address_enc = VALUES(address_enc),
|
||||||
|
emergency_contact_enc = VALUES(emergency_contact_enc),
|
||||||
|
status = 'active',
|
||||||
|
employment_status = VALUES(employment_status),
|
||||||
|
source = VALUES(source),
|
||||||
|
details = VALUES(details),
|
||||||
|
deleted_at = NULL,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
insertParams,
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) inserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE upload_batches SET status='completed', completed_at=NOW(), payload=? WHERE batch_uuid=?`,
|
||||||
|
sqlParams('upload_batches_complete', [
|
||||||
|
JSON.stringify({
|
||||||
|
updated,
|
||||||
|
inserted,
|
||||||
|
requested: (payload.updates?.length || 0) + inserts.length,
|
||||||
|
}),
|
||||||
|
batchUuid,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}).catch((err) => {
|
||||||
|
const detail = err?.sqlMessage || err?.message || String(err);
|
||||||
|
console.error('[personnel-roster] commit failed:', detail);
|
||||||
|
throw new ApiError(API_CODES.SERVER_ERROR, `人事月报写入失败:${detail}`, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 主数据已提交:后续副作用失败不应再让客户端以为整单失败
|
||||||
|
try {
|
||||||
|
await sync.bumpVersions(['employees']);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[personnel-roster] bumpVersions failed:', err?.message || err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'personnel_roster_import',
|
||||||
|
module: 'imports',
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: {
|
||||||
|
fileName: payload.fileName,
|
||||||
|
fileHash: payload.fileHash,
|
||||||
|
updated,
|
||||||
|
inserted,
|
||||||
|
requested: (payload.updates?.length || 0) + inserts.length,
|
||||||
|
skippedRemarks: payload.skippedRemarks?.length || 0,
|
||||||
|
nonActive: (payload.updates || []).filter((u) => u.employment_status_value !== '在职').length
|
||||||
|
+ (payload.newHires || []).filter((u) => u.employment_status_value !== '在职').length,
|
||||||
|
newHires: (payload.newHires || []).length,
|
||||||
|
missingClerks: (payload.missingClerks || []).length,
|
||||||
|
clerksInserted: insertClerks ? (payload.missingClerks || []).length : 0,
|
||||||
|
missingFromImport: payload.missingFromImport?.length || 0,
|
||||||
|
selections,
|
||||||
|
},
|
||||||
|
ip: meta?.ip,
|
||||||
|
userAgent: meta?.userAgent,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[personnel-roster] audit.log failed:', err?.message || err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await redis.client.del(`${PREVIEW_PREFIX}${importId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[personnel-roster] redis del failed:', err?.message || err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
batchUuid,
|
||||||
|
counts: {
|
||||||
|
updated,
|
||||||
|
inserted,
|
||||||
|
requested: (payload.updates?.length || 0) + inserts.length,
|
||||||
|
skippedRemarks: payload.skippedRemarks?.length || 0,
|
||||||
|
nonActive: (payload.updates || []).filter((u) => u.employment_status_value !== '在职').length
|
||||||
|
+ (payload.newHires || []).filter((u) => u.employment_status_value !== '在职').length,
|
||||||
|
newHires: (payload.newHires || []).length,
|
||||||
|
missingClerks: (payload.missingClerks || []).length,
|
||||||
|
clerksInserted: insertClerks ? (payload.missingClerks || []).length : 0,
|
||||||
|
missingFromImport: payload.missingFromImport?.length || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { preview, commit };
|
||||||
208
server/src/import/question-bank.js
Normal file
208
server/src/import/question-bank.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const uploadService = require('../upload/upload');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const PREVIEW_TTL_SECONDS = 30 * 60;
|
||||||
|
const PREVIEW_PREFIX = 'question-bank-import:';
|
||||||
|
const REQUIRED_HEADERS = ['题型', '题目', '选项1', '选项2', '选项3', '选项4', '选项5', '正确答案', '答案解析'];
|
||||||
|
const REQUIRED_VALUES = ['题型', '题目', '选项1', '选项2', '正确答案', '答案解析'];
|
||||||
|
|
||||||
|
function valueOf(cell) {
|
||||||
|
const value = cell?.value;
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value !== 'object') return value;
|
||||||
|
if (value.result !== undefined) return value.result == null ? '' : valueOf({ value: value.result });
|
||||||
|
if (value.error) return value.error;
|
||||||
|
if (Array.isArray(value.richText)) return value.richText.map((part) => part.text || '').join('');
|
||||||
|
if (value.text != null) return value.text;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value) {
|
||||||
|
return String(value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePunctuation(value) {
|
||||||
|
const punctuation = {
|
||||||
|
',': ',', '。': '.', ';': ';', ':': ':', '?': '?', '!': '!',
|
||||||
|
'(': '(', ')': ')', '【': '[', '】': ']', '〔': '[', '〕': ']',
|
||||||
|
'{': '{', '}': '}', '《': '<', '》': '>', '〈': '<', '〉': '>',
|
||||||
|
'“': '"', '”': '"', '‘': "'", '’': "'", '「': '"', '」': '"',
|
||||||
|
'『': '"', '』': '"', '、': ',', '…': '...', '—': '-', '–': '-',
|
||||||
|
'-': '-', '~': '~', '·': '.', '/': '/', '\': '\\', '|': '|',
|
||||||
|
'﹐': ',', '﹑': ',', '﹒': '.', '﹔': ';', '﹕': ':', '﹖': '?',
|
||||||
|
'﹗': '!', '﹙': '(', '﹚': ')', '﹛': '{', '﹜': '}', '﹝': '[',
|
||||||
|
'﹞': ']', '﹤': '<', '﹥': '>', '﹣': '-', '﹏': '_', '〖': '[',
|
||||||
|
'〗': ']', '〝': '"', '〞': '"',
|
||||||
|
};
|
||||||
|
return text(value)
|
||||||
|
.replace(/[\uFF01-\uFF5E]/g, (character) =>
|
||||||
|
String.fromCharCode(character.charCodeAt(0) - 0xFEE0)
|
||||||
|
)
|
||||||
|
.replace(/\u3000/g, ' ')
|
||||||
|
.replace(/[,。;:?!()【】〔〕{}《》〈〉“”‘’「」『』、…—–-~·/\|﹐﹑﹒﹔﹕﹖﹗﹙﹚﹛﹜﹝﹞﹤﹥﹣﹏〖〗〝〞]/g, (character) =>
|
||||||
|
punctuation[character] || character
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerMap(sheet) {
|
||||||
|
const headers = new Map();
|
||||||
|
sheet.getRow(1).eachCell((cell, column) => {
|
||||||
|
const name = text(valueOf(cell));
|
||||||
|
if (name) headers.set(name, column);
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuestionSheet(workbook) {
|
||||||
|
return workbook.worksheets.find((sheet) => {
|
||||||
|
const headers = headerMap(sheet);
|
||||||
|
return REQUIRED_HEADERS.every((header) => headers.has(header));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBy(rows, key) {
|
||||||
|
const counts = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const value = row[key] || '未分类';
|
||||||
|
counts[value] = (counts[value] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview(file) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(file.buffer);
|
||||||
|
const sheet = findQuestionSheet(workbook);
|
||||||
|
if (!sheet) {
|
||||||
|
throw new ApiError(
|
||||||
|
API_CODES.BAD_PARAMS,
|
||||||
|
`未找到包含以下列的工作表:${REQUIRED_HEADERS.join('、')}`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = headerMap(sheet);
|
||||||
|
const errors = [];
|
||||||
|
const rows = [];
|
||||||
|
const importBatch = randomUUID();
|
||||||
|
const importedAt = new Date().toISOString();
|
||||||
|
let normalizedFields = 0;
|
||||||
|
for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||||
|
const sourceValues = {};
|
||||||
|
const values = {};
|
||||||
|
for (const header of ['题型', '题目', '选项1', '选项2', '选项3', '选项4', '选项5', '正确答案', '答案解析']) {
|
||||||
|
const column = headers.get(header);
|
||||||
|
sourceValues[header] = column ? text(valueOf(sheet.getCell(rowNumber, column))) : '';
|
||||||
|
// 去掉单元格内换行/制表,避免同题因 Excel 自动换行导致题干对不上
|
||||||
|
values[header] = normalizePunctuation(sourceValues[header]).replace(/[\r\n\t]+/g, '');
|
||||||
|
if (values[header] !== sourceValues[header]) normalizedFields += 1;
|
||||||
|
}
|
||||||
|
if (!Object.values(values).some(Boolean)) continue;
|
||||||
|
const missing = REQUIRED_VALUES.filter((header) => !values[header]);
|
||||||
|
if (missing.length) {
|
||||||
|
errors.push({ sheet: sheet.name, row: rowNumber, message: `缺少必填列数据:${missing.join('、')}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
A: values['选项1'],
|
||||||
|
B: values['选项2'],
|
||||||
|
C: values['选项3'],
|
||||||
|
D: values['选项4'],
|
||||||
|
E: values['选项5'],
|
||||||
|
};
|
||||||
|
rows.push({
|
||||||
|
category: values['答案解析'],
|
||||||
|
sub_category: null,
|
||||||
|
question_type: values['题型'],
|
||||||
|
difficulty: null,
|
||||||
|
position: '列车司机',
|
||||||
|
content: {
|
||||||
|
question: values['题目'],
|
||||||
|
options,
|
||||||
|
optionA: options.A,
|
||||||
|
optionB: options.B,
|
||||||
|
optionC: options.C,
|
||||||
|
optionD: options.D,
|
||||||
|
optionE: options.E,
|
||||||
|
correctAnswer: values['正确答案'],
|
||||||
|
answer: values['正确答案'],
|
||||||
|
questionCategory: values['答案解析'],
|
||||||
|
},
|
||||||
|
source_file: file.originalname,
|
||||||
|
import_batch: importBatch,
|
||||||
|
details: {
|
||||||
|
importTime: importedAt,
|
||||||
|
fileName: file.originalname,
|
||||||
|
source: '问卷星',
|
||||||
|
sourceRow: rowNumber,
|
||||||
|
questionCategory: values['答案解析'],
|
||||||
|
},
|
||||||
|
extras: { sourceHeaders: sourceValues, normalizedPunctuation: true },
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const importId = randomUUID();
|
||||||
|
const payload = {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
rows,
|
||||||
|
errors,
|
||||||
|
importBatch,
|
||||||
|
};
|
||||||
|
await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS);
|
||||||
|
return {
|
||||||
|
importId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
sheetName: sheet.name,
|
||||||
|
summary: {
|
||||||
|
questions: rows.length,
|
||||||
|
validationErrors: errors.length,
|
||||||
|
normalizedFields,
|
||||||
|
questionTypes: countBy(rows, 'question_type'),
|
||||||
|
categories: countBy(rows, 'category'),
|
||||||
|
},
|
||||||
|
errors: errors.slice(0, 500),
|
||||||
|
sample: rows.slice(0, 20).map((row) => ({
|
||||||
|
questionType: row.question_type,
|
||||||
|
question: row.content.question,
|
||||||
|
optionA: row.content.optionA,
|
||||||
|
optionB: row.content.optionB,
|
||||||
|
correctAnswer: row.content.correctAnswer,
|
||||||
|
questionCategory: row.category,
|
||||||
|
})),
|
||||||
|
canCommit: errors.length === 0 && rows.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit({ importId, operator, meta }) {
|
||||||
|
const raw = await redis.client.get(`${PREVIEW_PREFIX}${importId}`);
|
||||||
|
if (!raw) throw new ApiError(API_CODES.NOT_FOUND, '题库导入预览已过期,请重新选择文件', 404);
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (payload.errors.length) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `存在 ${payload.errors.length} 条完整性错误,禁止提交`, 400);
|
||||||
|
}
|
||||||
|
if (!payload.rows.length) throw new ApiError(API_CODES.BAD_PARAMS, '题库没有可导入的数据', 400);
|
||||||
|
|
||||||
|
const result = await uploadService.upload({
|
||||||
|
module: 'question_banks',
|
||||||
|
rows: payload.rows,
|
||||||
|
operator,
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
await redis.client.del(`${PREVIEW_PREFIX}${importId}`);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
fileName: payload.fileName,
|
||||||
|
questionCount: payload.rows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { preview, commit };
|
||||||
62
server/src/infra/db.js
Normal file
62
server/src/infra/db.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
// mysql2 连接池(migration-plan §11:API 与 MySQL 同机回环)
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
uri: config.databaseUrl,
|
||||||
|
connectionLimit: 10,
|
||||||
|
namedPlaceholders: true,
|
||||||
|
timezone: '+08:00',
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
decimalNumbers: true, // DECIMAL 返回为 number 而非字符串
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行查询,返回行数组。
|
||||||
|
* @param {string} sql
|
||||||
|
* @param {any[]|Object} [params]
|
||||||
|
* @returns {Promise<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 };
|
||||||
21
server/src/infra/redis.js
Normal file
21
server/src/infra/redis.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
// 权限缓存 / refresh token 存储 / 队列(migration-plan §10.5)
|
||||||
|
const client = new Redis(config.redisUrl, {
|
||||||
|
maxRetriesPerRequest: 2,
|
||||||
|
lazyConnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
// 不抛出:RBAC 降级直查 MySQL(§14 风险表:Redis 同机宕机)
|
||||||
|
console.error('[redis] error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
await client.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { client, close };
|
||||||
39
server/src/main.js
Normal file
39
server/src/main.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const { createApp } = require('./app');
|
||||||
|
const db = require('./infra/db');
|
||||||
|
const redis = require('./infra/redis');
|
||||||
|
const modules = require('./modules/modules');
|
||||||
|
const precompute = require('./precompute');
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
// 1. 注册预计算器
|
||||||
|
precompute.registerAll();
|
||||||
|
await require('./precompute/crew-metrics').ensureSchema();
|
||||||
|
|
||||||
|
// 2. 启动一致性校验(注册表 SSOT + modules 表对齐,§5.4)
|
||||||
|
await modules.syncModulesOnBoot();
|
||||||
|
|
||||||
|
// 3. 起 API 服务;导出 Worker 以独立进程运行,避免阻塞 HTTP 请求。
|
||||||
|
const app = createApp();
|
||||||
|
const server = app.listen(config.port, '127.0.0.1', () => {
|
||||||
|
console.log(`[server] listening on http://127.0.0.1:${config.port}/api/v1`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 优雅退出
|
||||||
|
const shutdown = async (sig) => {
|
||||||
|
console.log(`[server] ${sig} received, shutting down...`);
|
||||||
|
server.close();
|
||||||
|
await db.close().catch(() => {});
|
||||||
|
await redis.close().catch(() => {});
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('[server] bootstrap failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
134
server/src/maintenance.js
Normal file
134
server/src/maintenance.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
const db = require('./infra/db');
|
||||||
|
const sync = require('./sync/sync');
|
||||||
|
const audit = require('./audit/audit');
|
||||||
|
const precomputeService = require('./precompute/service');
|
||||||
|
const dashboardSummary = require('./precompute/dashboard-summary');
|
||||||
|
const { ApiError, API_CODES } = require('./common/api-codes');
|
||||||
|
|
||||||
|
const DEFINITIONS = Object.freeze({
|
||||||
|
employees: {
|
||||||
|
label: '员工档案',
|
||||||
|
tables: ['employees'],
|
||||||
|
recompute: ['kilometers', 'online_exams', 'practical_exams'],
|
||||||
|
},
|
||||||
|
online_exams: {
|
||||||
|
label: '在线考试',
|
||||||
|
tables: ['online_exams'],
|
||||||
|
stats: ['online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly'],
|
||||||
|
recompute: ['online_exams'],
|
||||||
|
},
|
||||||
|
missing_exams: {
|
||||||
|
label: '缺考记录',
|
||||||
|
tables: ['missing_exams'],
|
||||||
|
stats: ['online_exam_stats_monthly', 'online_exam_question_stats_monthly', 'online_exam_stats_yearly'],
|
||||||
|
recompute: ['online_exams'],
|
||||||
|
},
|
||||||
|
practical_exams: {
|
||||||
|
label: '实操考试',
|
||||||
|
tables: ['practical_exam_steps', 'practical_exams'],
|
||||||
|
stats: ['practical_exam_stats_monthly', 'practical_exam_step_stats_monthly', 'practical_exam_stats_yearly'],
|
||||||
|
recompute: ['practical_exams'],
|
||||||
|
},
|
||||||
|
practical_standards: {
|
||||||
|
label: '实操试卷',
|
||||||
|
tables: ['paper_steps', 'papers'],
|
||||||
|
},
|
||||||
|
kilometers: {
|
||||||
|
label: '里程记录',
|
||||||
|
tables: ['kilometers_records'],
|
||||||
|
stats: [
|
||||||
|
'kilometers_stats_employee_monthly', 'kilometers_stats_team_monthly',
|
||||||
|
'kilometers_stats_employee_yearly', 'kilometers_stats_team_yearly',
|
||||||
|
],
|
||||||
|
recompute: ['kilometers'],
|
||||||
|
},
|
||||||
|
question_banks: {
|
||||||
|
label: '在线题库',
|
||||||
|
tables: ['question_banks'],
|
||||||
|
},
|
||||||
|
performance_assessments: {
|
||||||
|
label: '绩效考核',
|
||||||
|
tables: ['performance_assessments'],
|
||||||
|
},
|
||||||
|
fault_disposals: {
|
||||||
|
label: '故障处置',
|
||||||
|
tables: ['fault_disposals'],
|
||||||
|
},
|
||||||
|
incident_events: {
|
||||||
|
label: '事故事件',
|
||||||
|
tables: ['incident_events'],
|
||||||
|
},
|
||||||
|
audit_logs: {
|
||||||
|
label: '操作日志',
|
||||||
|
tables: ['audit_logs'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDeveloper(user) {
|
||||||
|
return Boolean(user?.username && config.developerUsers.has(user.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireDeveloper(req, _res, next) {
|
||||||
|
if (!isDeveloper(req.user)) {
|
||||||
|
return next(new ApiError(API_CODES.FORBIDDEN, '仅开发者可以执行数据写入与维护操作', 403));
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTables() {
|
||||||
|
const versions = await db.query('SELECT table_name, updated_at FROM sync_table_versions');
|
||||||
|
const updatedByTable = new Map(versions.map((row) => [row.table_name, row.updated_at]));
|
||||||
|
const result = [];
|
||||||
|
for (const [key, definition] of Object.entries(DEFINITIONS)) {
|
||||||
|
let rowCount = 0;
|
||||||
|
let updatedAt = null;
|
||||||
|
for (const table of definition.tables) {
|
||||||
|
const [count] = await db.query(`SELECT COUNT(*) AS total FROM \`${table}\``);
|
||||||
|
rowCount += Number(count.total);
|
||||||
|
const time = updatedByTable.get(table);
|
||||||
|
if (time && (!updatedAt || new Date(time) > new Date(updatedAt))) updatedAt = time;
|
||||||
|
}
|
||||||
|
result.push({ key, label: definition.label, tables: definition.tables, rowCount, updatedAt });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTable(key, operator, meta) {
|
||||||
|
const definition = DEFINITIONS[key];
|
||||||
|
if (!definition) throw new ApiError(API_CODES.NOT_FOUND, '不允许清空该表', 404);
|
||||||
|
|
||||||
|
let deletedRows = 0;
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
for (const table of definition.tables) {
|
||||||
|
const [result] = await conn.execute(`DELETE FROM \`${table}\``);
|
||||||
|
deletedRows += Number(result.affectedRows) || 0;
|
||||||
|
}
|
||||||
|
for (const table of definition.stats || []) await conn.execute(`DELETE FROM \`${table}\``);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.bumpVersions(
|
||||||
|
[...(definition.tables || []), ...(definition.stats || [])].filter((table) => sync.isSyncTable(table)),
|
||||||
|
);
|
||||||
|
for (const module of definition.recompute || []) await precomputeService.recomputeAll(module);
|
||||||
|
await dashboardSummary.recompute();
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'developer_clear_table',
|
||||||
|
module: 'maintenance',
|
||||||
|
targetType: 'table_group',
|
||||||
|
targetId: key,
|
||||||
|
payload: { label: definition.label, tables: definition.tables, deletedRows },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { key, label: definition.label, deletedRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isDeveloper, requireDeveloper, listTables, clearTable };
|
||||||
29
server/src/maintenance.routes.js
Normal file
29
server/src/maintenance.routes.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const maintenance = require('./maintenance');
|
||||||
|
const { requireAuth } = require('./auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('./common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAuth, maintenance.requireDeveloper);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/tables',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
ok(res, await maintenance.listTables());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/tables/:key',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await maintenance.clearTable(req.params.key, req.user, {
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
61
server/src/modules/modules.js
Normal file
61
server/src/modules/modules.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { MODULE_REGISTRY, validateRegistry } = require('@profile/shared');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动一致性校验(migration-plan §5.4)。
|
||||||
|
* 在 app.listen 前调用一次。
|
||||||
|
*/
|
||||||
|
async function syncModulesOnBoot() {
|
||||||
|
const errors = validateRegistry();
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`MODULE_REGISTRY invalid:\n${errors.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbRows = await db.query('SELECT `key`, status FROM modules');
|
||||||
|
const dbKeys = new Set(dbRows.map((r) => r.key));
|
||||||
|
const codeKeys = new Set(MODULE_REGISTRY.map((m) => m.key));
|
||||||
|
|
||||||
|
// code 有但 DB 无 → 自动 INSERT
|
||||||
|
for (const m of MODULE_REGISTRY) {
|
||||||
|
if (!dbKeys.has(m.key)) {
|
||||||
|
await db.execute('INSERT INTO modules (`key`, status, notes) VALUES (?, ?, ?)', [m.key, 'active', m.label]);
|
||||||
|
console.log(`[modules] auto-registered: ${m.key}`);
|
||||||
|
await audit.log({ clientType: 'system', action: 'module_auto_register', module: m.key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 有但 code 无 → orphan 告警 + 审计,不阻塞
|
||||||
|
const orphans = dbRows.filter((r) => !codeKeys.has(r.key)).map((r) => r.key);
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
console.warn(`[modules] orphan in DB: ${orphans.join(',')}`);
|
||||||
|
await audit.log({ clientType: 'system', action: 'module_orphan_detected', payload: { orphans } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并 code descriptor + DB status/override,过滤 disabled 与无 READ 权限的模块。
|
||||||
|
* @param {Record<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 };
|
||||||
20
server/src/modules/modules.routes.js
Normal file
20
server/src/modules/modules.routes.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const modules = require('./modules');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const perms = await rbac.getPermissions(req.user.id);
|
||||||
|
ok(res, await modules.getVisible(perms));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
954
server/src/precompute/crew-metrics.js
Normal file
954
server/src/precompute/crew-metrics.js
Normal file
@ -0,0 +1,954 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
|
||||||
|
/** 与前端 export-employees.js 保持一致:六维相加为月画像分,累计取各月平均 */
|
||||||
|
const FORMULA_VERSION = 'v3';
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
km: 15,
|
||||||
|
performance: 25,
|
||||||
|
online: 15,
|
||||||
|
practical: 15,
|
||||||
|
fault: 10,
|
||||||
|
incident: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let schemaReady = false;
|
||||||
|
|
||||||
|
function normalizeEmployeeId(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
return raw.replace(/^0+/, '') || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function padEmployeeId(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
if (/^\d+$/.test(raw) && raw.length <= 5) return raw.padStart(5, '0');
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idVariants(employeeId) {
|
||||||
|
return [...new Set([
|
||||||
|
String(employeeId || '').trim(),
|
||||||
|
padEmployeeId(employeeId),
|
||||||
|
normalizeEmployeeId(employeeId),
|
||||||
|
].filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmployeeIds(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeEmployeeId(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (value == null || value === '') return [];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item) => normalizeEmployeeId(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch (_error) { /* ignore */ }
|
||||||
|
return text.split(/[,,;\s]+/).map((item) => normalizeEmployeeId(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) return parseEmployeeIds(value.toString('utf8'));
|
||||||
|
return [normalizeEmployeeId(value)].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFaultSuccess(value) {
|
||||||
|
return String(value ?? '').includes('成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIncidentIssue(value) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if (!text) return false;
|
||||||
|
if (text === '/' || text === '-' || text === '无' || text === '无问题') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drivePointsValue(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(value) {
|
||||||
|
return Math.round(Number(value) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymKey(year, month) {
|
||||||
|
return `${Number(year)}-${String(Number(month)).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYm(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
|
return { year: value.getFullYear(), month: value.getMonth() + 1 };
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
const match = text.match(/(\d{4})\D+(\d{1,2})/);
|
||||||
|
if (!match) return null;
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) return null;
|
||||||
|
return { year, month };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务月:本月 21 日~下月 20 日归入「下月」(跨年进次年 1 月)。
|
||||||
|
* 仅用于绩效考核/嘉奖 assessment_date。
|
||||||
|
* 例:2025-12-21 → 2026-01;2025-12-20 → 2025-12。
|
||||||
|
*/
|
||||||
|
function calendarPartsShanghai(value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const dateOnly = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (dateOnly) {
|
||||||
|
return {
|
||||||
|
year: Number(dateOnly[1]),
|
||||||
|
month: Number(dateOnly[2]),
|
||||||
|
day: Number(dateOnly[3]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
const formatted = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
const matched = formatted.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!matched) return null;
|
||||||
|
return {
|
||||||
|
year: Number(matched[1]),
|
||||||
|
month: Number(matched[2]),
|
||||||
|
day: Number(matched[3]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBusinessYm(value) {
|
||||||
|
const parts = calendarPartsShanghai(value);
|
||||||
|
if (!parts) return null;
|
||||||
|
let { year, month, day } = parts;
|
||||||
|
if (day >= 21) {
|
||||||
|
month += 1;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) return null;
|
||||||
|
return { year, month };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自然月:按故障/事故发生日期的日历年月归桶(上海时区) */
|
||||||
|
function parseCalendarYm(value) {
|
||||||
|
const parts = calendarPartsShanghai(value);
|
||||||
|
if (!parts) return parseYm(value);
|
||||||
|
return { year: parts.year, month: parts.month };
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthRange(start, end) {
|
||||||
|
const out = [];
|
||||||
|
if (!start || !end) return out;
|
||||||
|
let y = start.year;
|
||||||
|
let m = start.month;
|
||||||
|
const endKey = ymKey(end.year, end.month);
|
||||||
|
while (ymKey(y, m) <= endKey) {
|
||||||
|
out.push({ year: y, month: m });
|
||||||
|
m += 1;
|
||||||
|
if (m > 12) { m = 1; y += 1; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function earlierYm(a, b) {
|
||||||
|
if (!a) return b;
|
||||||
|
if (!b) return a;
|
||||||
|
return ymKey(a.year, a.month) <= ymKey(b.year, b.month) ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRemarks(details) {
|
||||||
|
if (details == null || details === '') return '';
|
||||||
|
let obj = details;
|
||||||
|
if (typeof details === 'string') {
|
||||||
|
try { obj = JSON.parse(details); } catch (_e) { return ''; }
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(details)) {
|
||||||
|
try { obj = JSON.parse(details.toString('utf8')); } catch (_e) { return ''; }
|
||||||
|
}
|
||||||
|
return String(obj?.remarks || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注含「组长」(含后备班组长、组长/施工负责人等)→ 公里分可按班组均值保底。
|
||||||
|
* 仅写「施工负责人」、不含「组长」的不适用。
|
||||||
|
*/
|
||||||
|
function usesTeamAvgKm(remarks) {
|
||||||
|
const text = String(remarks || '').trim();
|
||||||
|
if (!text) return false;
|
||||||
|
return text.includes('组长');
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcTotalScore(metrics) {
|
||||||
|
return round1(
|
||||||
|
(Number(metrics.kmScore) || 0)
|
||||||
|
+ (Number(metrics.performanceScore) || 0)
|
||||||
|
+ (Number(metrics.onlineExam) || 0)
|
||||||
|
+ (Number(metrics.practicalExam) || 0)
|
||||||
|
+ (Number(metrics.faultScore) || 0)
|
||||||
|
+ (Number(metrics.incidentScore) || 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMonthScores(parts) {
|
||||||
|
const kmAnomalyCount = Number(parts.kmAnomalyCount) || 0;
|
||||||
|
// 无公里数据按 0 分;有数据则满分 15,异常一次扣 5
|
||||||
|
const kmScore = parts.hasKm
|
||||||
|
? Math.max(0, round1(BASE.km - 5 * kmAnomalyCount))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const performanceDeduction = Number(parts.performanceDeduction) || 0;
|
||||||
|
const drivingDeduction = Number(parts.drivingDeduction) || 0;
|
||||||
|
const performanceScore = Math.max(
|
||||||
|
0,
|
||||||
|
round1((BASE.performance - performanceDeduction) * (1 - 0.1 * drivingDeduction)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 有成绩:均分×0.15;缺考且无成绩:0;该月无考试记录:基础 15
|
||||||
|
let onlineExam = BASE.online;
|
||||||
|
if (parts.hasOnline) {
|
||||||
|
onlineExam = Math.max(0, round1(Number(parts.onlineAvg) * 0.15));
|
||||||
|
} else if (parts.hasMissingOnline) {
|
||||||
|
onlineExam = 0;
|
||||||
|
}
|
||||||
|
const practicalExam = parts.hasPractical
|
||||||
|
? Math.max(0, round1(Number(parts.practicalAvg) * 0.15))
|
||||||
|
: BASE.practical;
|
||||||
|
|
||||||
|
let faultScore = BASE.fault;
|
||||||
|
const faultSuccessCount = Number(parts.faultSuccessCount) || 0;
|
||||||
|
const faultTotalCount = Number(parts.faultTotalCount) || 0;
|
||||||
|
if (faultTotalCount > 0) {
|
||||||
|
faultScore = Math.max(0, round1(BASE.fault + 2 * faultSuccessCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
let incidentScore = BASE.incident;
|
||||||
|
const incidentOkCount = Number(parts.incidentOkCount) || 0;
|
||||||
|
const incidentIssueCount = Number(parts.incidentIssueCount) || 0;
|
||||||
|
const incidentTotalCount = incidentOkCount + incidentIssueCount;
|
||||||
|
if (incidentTotalCount > 0) {
|
||||||
|
incidentScore = Math.max(
|
||||||
|
0,
|
||||||
|
round1(BASE.incident + 5 * incidentOkCount - 10 * incidentIssueCount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalScore = round1(
|
||||||
|
kmScore + performanceScore + onlineExam + practicalExam + faultScore + incidentScore,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kmScore,
|
||||||
|
kmAnomalyCount,
|
||||||
|
performanceScore,
|
||||||
|
performanceDeduction,
|
||||||
|
drivingDeduction,
|
||||||
|
onlineExam,
|
||||||
|
practicalExam,
|
||||||
|
faultScore,
|
||||||
|
faultSuccessCount,
|
||||||
|
incidentScore,
|
||||||
|
incidentOkCount,
|
||||||
|
incidentIssueCount,
|
||||||
|
totalScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSchema() {
|
||||||
|
if (schemaReady) return;
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS crew_metrics_employee_yearly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
employee_id VARCHAR(20) NOT NULL,
|
||||||
|
km_score DECIMAL(8,2) NOT NULL DEFAULT 12,
|
||||||
|
km_anomaly_count INT NOT NULL DEFAULT 0,
|
||||||
|
performance_score DECIMAL(8,2) NOT NULL DEFAULT 100,
|
||||||
|
performance_deduction DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
driving_score DECIMAL(8,2) NOT NULL DEFAULT 12,
|
||||||
|
driving_deduction DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
online_exam DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
practical_exam DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
fault_score DECIMAL(8,2) NOT NULL DEFAULT 80,
|
||||||
|
incident_score DECIMAL(8,2) NOT NULL DEFAULT 100,
|
||||||
|
total_score DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||||
|
formula_version VARCHAR(16) NOT NULL DEFAULT 'v1',
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_employee_year (employee_id, year),
|
||||||
|
KEY idx_year_total (year, total_score)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS crew_metrics_employee_monthly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
month TINYINT NOT NULL,
|
||||||
|
employee_id VARCHAR(20) NOT NULL,
|
||||||
|
km_score DECIMAL(8,2) NOT NULL DEFAULT 15,
|
||||||
|
km_anomaly_count INT NOT NULL DEFAULT 0,
|
||||||
|
performance_score DECIMAL(8,2) NOT NULL DEFAULT 25,
|
||||||
|
performance_deduction DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
driving_deduction DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
online_exam DECIMAL(8,2) NOT NULL DEFAULT 15,
|
||||||
|
practical_exam DECIMAL(8,2) NOT NULL DEFAULT 15,
|
||||||
|
fault_score DECIMAL(8,2) NOT NULL DEFAULT 10,
|
||||||
|
fault_success_count INT NOT NULL DEFAULT 0,
|
||||||
|
incident_score DECIMAL(8,2) NOT NULL DEFAULT 10,
|
||||||
|
incident_ok_count INT NOT NULL DEFAULT 0,
|
||||||
|
incident_issue_count INT NOT NULL DEFAULT 0,
|
||||||
|
total_score DECIMAL(8,2) NOT NULL DEFAULT 100,
|
||||||
|
formula_version VARCHAR(16) NOT NULL DEFAULT 'v2',
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_employee_year_month (employee_id, year, month),
|
||||||
|
KEY idx_year_month_total (year, month, total_score),
|
||||||
|
KEY idx_employee_total (employee_id, total_score)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
await db.execute(
|
||||||
|
`INSERT IGNORE INTO sync_table_versions (table_name, version) VALUES
|
||||||
|
('crew_metrics_employee_yearly', 0),
|
||||||
|
('crew_metrics_employee_monthly', 0)`,
|
||||||
|
);
|
||||||
|
schemaReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesEmployee(ids, targetVariants) {
|
||||||
|
const normalizedTargets = new Set(targetVariants.map(normalizeEmployeeId));
|
||||||
|
return ids.some((id) => normalizedTargets.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushBucket(map, year, month, init) {
|
||||||
|
const key = ymKey(year, month);
|
||||||
|
if (!map.has(key)) map.set(key, typeof init === 'function' ? init() : { ...init });
|
||||||
|
return map.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEmployeeContext(employeeId) {
|
||||||
|
const variants = idVariants(employeeId);
|
||||||
|
const canonicalId = padEmployeeId(employeeId) || String(employeeId).trim();
|
||||||
|
const placeholders = variants.map(() => '?').join(',');
|
||||||
|
if (!placeholders) {
|
||||||
|
return {
|
||||||
|
canonicalId,
|
||||||
|
variants,
|
||||||
|
hireYm: null,
|
||||||
|
team: '',
|
||||||
|
remarks: '',
|
||||||
|
useTeamAvgKm: false,
|
||||||
|
kmByMonth: new Map(),
|
||||||
|
perfByMonth: new Map(),
|
||||||
|
onlineByMonth: new Map(),
|
||||||
|
onlineMissingByMonth: new Set(),
|
||||||
|
practicalByMonth: new Map(),
|
||||||
|
faultByMonth: new Map(),
|
||||||
|
incidentByMonth: new Map(),
|
||||||
|
earliestYm: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
employeeRows,
|
||||||
|
kmRows,
|
||||||
|
kmSubsidyRows,
|
||||||
|
perfRows,
|
||||||
|
onlineRows,
|
||||||
|
missingRows,
|
||||||
|
practicalRows,
|
||||||
|
faultRows,
|
||||||
|
incidentRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT hire_date, team, details FROM employees
|
||||||
|
WHERE deleted_at IS NULL AND employee_id IN (${placeholders})
|
||||||
|
AND (employment_status = '在职' OR employment_status IS NULL)
|
||||||
|
LIMIT 1`,
|
||||||
|
variants,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT year, month, is_anomaly
|
||||||
|
FROM kilometers_control_stats_monthly
|
||||||
|
WHERE employee_id IN (${placeholders})`,
|
||||||
|
variants,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT year, month, MAX(COALESCE(has_subsidy, 0)) AS has_subsidy
|
||||||
|
FROM kilometers_records
|
||||||
|
WHERE employee_id IN (${placeholders})
|
||||||
|
GROUP BY year, month`,
|
||||||
|
variants,
|
||||||
|
).catch(() => []),
|
||||||
|
db.query(
|
||||||
|
`SELECT assessment_date, performance_points, twelve_point_deduction
|
||||||
|
FROM performance_assessments
|
||||||
|
WHERE deleted_at IS NULL AND employee_id IN (${placeholders})`,
|
||||||
|
variants,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT exam_year, exam_month, score, details
|
||||||
|
FROM online_exams
|
||||||
|
WHERE employee_id IN (${placeholders})`,
|
||||||
|
variants,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT missing_month
|
||||||
|
FROM missing_exams
|
||||||
|
WHERE employee_id IN (${placeholders})`,
|
||||||
|
variants,
|
||||||
|
).catch(() => []),
|
||||||
|
db.query(
|
||||||
|
`SELECT exam_year, exam_month, score
|
||||||
|
FROM practical_exams
|
||||||
|
WHERE employee_id IN (${placeholders})`,
|
||||||
|
variants,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT fault_date, final_result, employee_ids
|
||||||
|
FROM fault_disposals
|
||||||
|
WHERE deleted_at IS NULL`,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT event_date, operation_issues, employee_ids
|
||||||
|
FROM incident_events
|
||||||
|
WHERE deleted_at IS NULL`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hireYm = parseYm(employeeRows[0]?.hire_date);
|
||||||
|
const team = String(employeeRows[0]?.team || '').trim();
|
||||||
|
const remarks = extractRemarks(employeeRows[0]?.details);
|
||||||
|
let earliestYm = hireYm;
|
||||||
|
|
||||||
|
const subsidyByMonth = new Map();
|
||||||
|
for (const row of kmSubsidyRows || []) {
|
||||||
|
const year = Number(row.year);
|
||||||
|
const month = Number(row.month);
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) continue;
|
||||||
|
subsidyByMonth.set(ymKey(year, month), Number(row.has_subsidy) === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公里「有数据」以 kilometers_records 为准;异常次数来自控制图。
|
||||||
|
// 若控制图被算空/失败,不能把全员公里分打成 0。
|
||||||
|
const kmByMonth = new Map();
|
||||||
|
for (const row of kmSubsidyRows || []) {
|
||||||
|
const year = Number(row.year);
|
||||||
|
const month = Number(row.month);
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) continue;
|
||||||
|
const key = ymKey(year, month);
|
||||||
|
const bucket = pushBucket(kmByMonth, year, month, () => ({
|
||||||
|
anomalyCount: 0,
|
||||||
|
hasSubsidy: false,
|
||||||
|
}));
|
||||||
|
if (subsidyByMonth.get(key)) bucket.hasSubsidy = true;
|
||||||
|
earliestYm = earlierYm(earliestYm, { year, month });
|
||||||
|
}
|
||||||
|
for (const row of kmRows || []) {
|
||||||
|
const year = Number(row.year);
|
||||||
|
const month = Number(row.month);
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) continue;
|
||||||
|
const key = ymKey(year, month);
|
||||||
|
const bucket = pushBucket(kmByMonth, year, month, () => ({
|
||||||
|
anomalyCount: 0,
|
||||||
|
hasSubsidy: Boolean(subsidyByMonth.get(key)),
|
||||||
|
}));
|
||||||
|
if (Number(row.is_anomaly) === 1) bucket.anomalyCount += 1;
|
||||||
|
if (subsidyByMonth.get(key)) bucket.hasSubsidy = true;
|
||||||
|
earliestYm = earlierYm(earliestYm, { year, month });
|
||||||
|
}
|
||||||
|
|
||||||
|
const perfByMonth = new Map();
|
||||||
|
for (const row of perfRows || []) {
|
||||||
|
// 绩效/嘉奖按业务月归桶:本月21日~下月20日 → 下月
|
||||||
|
const ym = parseBusinessYm(row.assessment_date);
|
||||||
|
if (!ym) continue;
|
||||||
|
const bucket = pushBucket(perfByMonth, ym.year, ym.month, () => ({
|
||||||
|
performanceDeduction: 0,
|
||||||
|
drivingDeduction: 0,
|
||||||
|
}));
|
||||||
|
bucket.performanceDeduction += Number(row.performance_points) || 0;
|
||||||
|
bucket.drivingDeduction += drivePointsValue(row.twelve_point_deduction);
|
||||||
|
earliestYm = earlierYm(earliestYm, ym);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineByMonth = new Map();
|
||||||
|
const onlineMissingByMonth = new Set();
|
||||||
|
for (const row of onlineRows || []) {
|
||||||
|
const year = Number(row.exam_year);
|
||||||
|
const month = Number(row.exam_month);
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) continue;
|
||||||
|
let details = row.details;
|
||||||
|
if (typeof details === 'string') {
|
||||||
|
try { details = JSON.parse(details); } catch (_e) { details = {}; }
|
||||||
|
}
|
||||||
|
if (details && details.isMissingExam === true) {
|
||||||
|
onlineMissingByMonth.add(ymKey(year, month));
|
||||||
|
earliestYm = earlierYm(earliestYm, { year, month });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(Number(row.score))) continue;
|
||||||
|
const bucket = pushBucket(onlineByMonth, year, month, () => ({ scores: [] }));
|
||||||
|
bucket.scores.push(Number(row.score));
|
||||||
|
earliestYm = earlierYm(earliestYm, { year, month });
|
||||||
|
}
|
||||||
|
for (const row of missingRows || []) {
|
||||||
|
const ym = parseYm(row.missing_month);
|
||||||
|
if (!ym) continue;
|
||||||
|
onlineMissingByMonth.add(ymKey(ym.year, ym.month));
|
||||||
|
earliestYm = earlierYm(earliestYm, ym);
|
||||||
|
}
|
||||||
|
|
||||||
|
const practicalByMonth = new Map();
|
||||||
|
for (const row of practicalRows || []) {
|
||||||
|
const year = Number(row.exam_year);
|
||||||
|
const month = Number(row.exam_month);
|
||||||
|
if (!Number.isFinite(year) || month < 1 || month > 12) continue;
|
||||||
|
if (!Number.isFinite(Number(row.score))) continue;
|
||||||
|
const bucket = pushBucket(practicalByMonth, year, month, () => ({ scores: [] }));
|
||||||
|
bucket.scores.push(Number(row.score));
|
||||||
|
earliestYm = earlierYm(earliestYm, { year, month });
|
||||||
|
}
|
||||||
|
|
||||||
|
const faultByMonth = new Map();
|
||||||
|
for (const row of faultRows || []) {
|
||||||
|
const ids = parseEmployeeIds(row.employee_ids);
|
||||||
|
if (!rowMatchesEmployee(ids, variants)) continue;
|
||||||
|
const ym = parseCalendarYm(row.fault_date);
|
||||||
|
if (!ym) continue;
|
||||||
|
const bucket = pushBucket(faultByMonth, ym.year, ym.month, () => ({
|
||||||
|
total: 0,
|
||||||
|
success: 0,
|
||||||
|
}));
|
||||||
|
bucket.total += 1;
|
||||||
|
if (isFaultSuccess(row.final_result)) bucket.success += 1;
|
||||||
|
earliestYm = earlierYm(earliestYm, ym);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incidentByMonth = new Map();
|
||||||
|
for (const row of incidentRows || []) {
|
||||||
|
const ids = parseEmployeeIds(row.employee_ids);
|
||||||
|
if (!rowMatchesEmployee(ids, variants)) continue;
|
||||||
|
const ym = parseCalendarYm(row.event_date);
|
||||||
|
if (!ym) continue;
|
||||||
|
const bucket = pushBucket(incidentByMonth, ym.year, ym.month, () => ({
|
||||||
|
ok: 0,
|
||||||
|
issue: 0,
|
||||||
|
}));
|
||||||
|
if (hasIncidentIssue(row.operation_issues)) bucket.issue += 1;
|
||||||
|
else bucket.ok += 1;
|
||||||
|
earliestYm = earlierYm(earliestYm, ym);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonicalId,
|
||||||
|
variants,
|
||||||
|
hireYm,
|
||||||
|
team,
|
||||||
|
remarks,
|
||||||
|
useTeamAvgKm: usesTeamAvgKm(remarks),
|
||||||
|
kmByMonth,
|
||||||
|
perfByMonth,
|
||||||
|
onlineByMonth,
|
||||||
|
onlineMissingByMonth,
|
||||||
|
practicalByMonth,
|
||||||
|
faultByMonth,
|
||||||
|
incidentByMonth,
|
||||||
|
earliestYm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMonthSpan(ctx, yearFilter = null) {
|
||||||
|
const now = new Date();
|
||||||
|
const end = { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||||
|
let start = ctx.hireYm || ctx.earliestYm;
|
||||||
|
if (!start) start = end;
|
||||||
|
if (start && ymKey(start.year, start.month) > ymKey(end.year, end.month)) {
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
let months = monthRange(start, end);
|
||||||
|
if (yearFilter != null && Number.isFinite(Number(yearFilter))) {
|
||||||
|
const y = Number(yearFilter);
|
||||||
|
months = months.filter((item) => item.year === y);
|
||||||
|
if (!months.length) {
|
||||||
|
for (let m = 1; m <= 12; m += 1) {
|
||||||
|
if (ymKey(y, m) <= ymKey(end.year, end.month)) months.push({ year: y, month: m });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该月是否有任一维度的实际导入数据(无数据月不落库、不参与排名) */
|
||||||
|
function monthHasSourceData(ctx, year, month) {
|
||||||
|
const key = ymKey(year, month);
|
||||||
|
return ctx.kmByMonth.has(key)
|
||||||
|
|| ctx.perfByMonth.has(key)
|
||||||
|
|| ctx.onlineByMonth.has(key)
|
||||||
|
|| ctx.practicalByMonth.has(key)
|
||||||
|
|| ctx.faultByMonth.has(key)
|
||||||
|
|| ctx.incidentByMonth.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeEmployeeMonth(ctx, year, month) {
|
||||||
|
const key = ymKey(year, month);
|
||||||
|
const km = ctx.kmByMonth.get(key);
|
||||||
|
const perf = ctx.perfByMonth.get(key);
|
||||||
|
const online = ctx.onlineByMonth.get(key);
|
||||||
|
const practical = ctx.practicalByMonth.get(key);
|
||||||
|
const fault = ctx.faultByMonth.get(key);
|
||||||
|
const incident = ctx.incidentByMonth.get(key);
|
||||||
|
|
||||||
|
const onlineScores = online?.scores || [];
|
||||||
|
const practicalScores = practical?.scores || [];
|
||||||
|
const hasMissingOnline = Boolean(ctx.onlineMissingByMonth?.has(key));
|
||||||
|
|
||||||
|
const scores = computeMonthScores({
|
||||||
|
hasKm: Boolean(km),
|
||||||
|
kmAnomalyCount: km?.anomalyCount || 0,
|
||||||
|
performanceDeduction: perf?.performanceDeduction || 0,
|
||||||
|
drivingDeduction: perf?.drivingDeduction || 0,
|
||||||
|
hasOnline: onlineScores.length > 0,
|
||||||
|
hasMissingOnline,
|
||||||
|
onlineAvg: onlineScores.length
|
||||||
|
? onlineScores.reduce((a, b) => a + b, 0) / onlineScores.length
|
||||||
|
: 0,
|
||||||
|
hasPractical: practicalScores.length > 0,
|
||||||
|
practicalAvg: practicalScores.length
|
||||||
|
? practicalScores.reduce((a, b) => a + b, 0) / practicalScores.length
|
||||||
|
: 0,
|
||||||
|
faultTotalCount: fault?.total || 0,
|
||||||
|
faultSuccessCount: fault?.success || 0,
|
||||||
|
incidentOkCount: incident?.ok || 0,
|
||||||
|
incidentIssueCount: incident?.issue || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId: ctx.canonicalId,
|
||||||
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
|
...scores,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertMonthlyMetrics(metrics) {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO crew_metrics_employee_monthly
|
||||||
|
(year, month, employee_id, km_score, km_anomaly_count,
|
||||||
|
performance_score, performance_deduction, driving_deduction,
|
||||||
|
online_exam, practical_exam, fault_score, fault_success_count,
|
||||||
|
incident_score, incident_ok_count, incident_issue_count,
|
||||||
|
total_score, formula_version, computed_at, version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
km_score = VALUES(km_score),
|
||||||
|
km_anomaly_count = VALUES(km_anomaly_count),
|
||||||
|
performance_score = VALUES(performance_score),
|
||||||
|
performance_deduction = VALUES(performance_deduction),
|
||||||
|
driving_deduction = VALUES(driving_deduction),
|
||||||
|
online_exam = VALUES(online_exam),
|
||||||
|
practical_exam = VALUES(practical_exam),
|
||||||
|
fault_score = VALUES(fault_score),
|
||||||
|
fault_success_count = VALUES(fault_success_count),
|
||||||
|
incident_score = VALUES(incident_score),
|
||||||
|
incident_ok_count = VALUES(incident_ok_count),
|
||||||
|
incident_issue_count = VALUES(incident_issue_count),
|
||||||
|
total_score = VALUES(total_score),
|
||||||
|
formula_version = VALUES(formula_version),
|
||||||
|
computed_at = NOW(),
|
||||||
|
version = version + 1`,
|
||||||
|
[
|
||||||
|
metrics.year,
|
||||||
|
metrics.month,
|
||||||
|
metrics.employeeId,
|
||||||
|
metrics.kmScore,
|
||||||
|
metrics.kmAnomalyCount,
|
||||||
|
metrics.performanceScore,
|
||||||
|
metrics.performanceDeduction,
|
||||||
|
metrics.drivingDeduction,
|
||||||
|
metrics.onlineExam,
|
||||||
|
metrics.practicalExam,
|
||||||
|
metrics.faultScore,
|
||||||
|
metrics.faultSuccessCount,
|
||||||
|
metrics.incidentScore,
|
||||||
|
metrics.incidentOkCount,
|
||||||
|
metrics.incidentIssueCount,
|
||||||
|
metrics.totalScore,
|
||||||
|
FORMULA_VERSION,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} employeeIds
|
||||||
|
* @param {number[]|null} years 若传入则只重算这些年的月份;否则重算入职→当前全部月
|
||||||
|
*/
|
||||||
|
async function recomputeEmployeesYears(employeeIds = [], years = []) {
|
||||||
|
await ensureSchema();
|
||||||
|
const ids = [...new Set((employeeIds || []).map((id) => String(id || '').trim()).filter(Boolean))];
|
||||||
|
const yearList = [...new Set((years || []).map(Number).filter((y) => Number.isFinite(y)))];
|
||||||
|
let recomputed = 0;
|
||||||
|
let changed = false;
|
||||||
|
if (!ids.length) {
|
||||||
|
return { recomputed, formulaVersion: FORMULA_VERSION };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组长 / 休假补贴公里保底需要同班组原始公里分,故一并加载队友
|
||||||
|
const idPlaceholders = ids.map(() => '?').join(',');
|
||||||
|
const seedRows = await db.query(
|
||||||
|
`SELECT employee_id, team, details FROM employees
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (employment_status = '在职' OR employment_status IS NULL)
|
||||||
|
AND employee_id IN (${idPlaceholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
const teamsNeeded = new Set();
|
||||||
|
for (const row of seedRows || []) {
|
||||||
|
if (usesTeamAvgKm(extractRemarks(row.details)) && row.team) {
|
||||||
|
teamsNeeded.add(String(row.team).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const subsidyTeams = await db.query(
|
||||||
|
`SELECT DISTINCT e.team
|
||||||
|
FROM kilometers_records k
|
||||||
|
INNER JOIN employees e
|
||||||
|
ON e.employee_id = k.employee_id AND e.deleted_at IS NULL
|
||||||
|
WHERE k.has_subsidy = 1
|
||||||
|
AND k.employee_id IN (${idPlaceholders})
|
||||||
|
AND e.team IS NOT NULL AND TRIM(e.team) != ''`,
|
||||||
|
ids,
|
||||||
|
).catch(() => []);
|
||||||
|
for (const row of subsidyTeams || []) {
|
||||||
|
if (row.team) teamsNeeded.add(String(row.team).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeIdSet = new Set(ids);
|
||||||
|
if (teamsNeeded.size) {
|
||||||
|
const teamList = [...teamsNeeded];
|
||||||
|
const teamPlaceholders = teamList.map(() => '?').join(',');
|
||||||
|
const teammates = await db.query(
|
||||||
|
`SELECT employee_id FROM employees
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (employment_status = '在职' OR employment_status IS NULL)
|
||||||
|
AND team IN (${teamPlaceholders})`,
|
||||||
|
teamList,
|
||||||
|
);
|
||||||
|
for (const row of teammates || []) {
|
||||||
|
if (row.employee_id) computeIdSet.add(String(row.employee_id).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertKeys = new Set();
|
||||||
|
for (const id of ids) {
|
||||||
|
for (const variant of idVariants(id)) upsertKeys.add(variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Array<{ metrics: object, hasKm: boolean, team: string, useTeamAvgKm: boolean, hasSubsidy: boolean, shouldUpsert: boolean }>} */
|
||||||
|
const pending = [];
|
||||||
|
|
||||||
|
for (const employeeId of computeIdSet) {
|
||||||
|
const ctx = await loadEmployeeContext(employeeId);
|
||||||
|
const shouldUpsert = ctx.variants.some((variant) => upsertKeys.has(variant))
|
||||||
|
|| upsertKeys.has(ctx.canonicalId);
|
||||||
|
const months = yearList.length
|
||||||
|
? yearList.flatMap((year) => resolveMonthSpan(ctx, year))
|
||||||
|
: resolveMonthSpan(ctx, null);
|
||||||
|
const uniqueMonths = new Map();
|
||||||
|
for (const item of months) uniqueMonths.set(ymKey(item.year, item.month), item);
|
||||||
|
|
||||||
|
for (const { year, month } of uniqueMonths.values()) {
|
||||||
|
if (!monthHasSourceData(ctx, year, month)) {
|
||||||
|
if (shouldUpsert) {
|
||||||
|
const del = await db.execute(
|
||||||
|
`DELETE FROM crew_metrics_employee_monthly
|
||||||
|
WHERE employee_id = ? AND year = ? AND month = ?`,
|
||||||
|
[ctx.canonicalId, year, month],
|
||||||
|
);
|
||||||
|
if ((del?.affectedRows || 0) > 0) changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const metrics = computeEmployeeMonth(ctx, year, month);
|
||||||
|
const kmBucket = ctx.kmByMonth.get(ymKey(year, month));
|
||||||
|
pending.push({
|
||||||
|
metrics,
|
||||||
|
hasKm: Boolean(kmBucket),
|
||||||
|
team: ctx.team || '',
|
||||||
|
useTeamAvgKm: Boolean(ctx.useTeamAvgKm),
|
||||||
|
hasSubsidy: Boolean(kmBucket?.hasSubsidy),
|
||||||
|
shouldUpsert,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班组当月「有公里数据」人员的原始公里分均值
|
||||||
|
const teamMonthScores = new Map();
|
||||||
|
for (const item of pending) {
|
||||||
|
if (!item.team || !item.hasKm) continue;
|
||||||
|
const key = `${item.team}|${ymKey(item.metrics.year, item.metrics.month)}`;
|
||||||
|
if (!teamMonthScores.has(key)) teamMonthScores.set(key, []);
|
||||||
|
teamMonthScores.get(key).push(Number(item.metrics.kmScore) || 0);
|
||||||
|
}
|
||||||
|
const teamMonthAvg = new Map();
|
||||||
|
for (const [key, scores] of teamMonthScores.entries()) {
|
||||||
|
if (!scores.length) continue;
|
||||||
|
teamMonthAvg.set(key, round1(scores.reduce((a, b) => a + b, 0) / scores.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of pending) {
|
||||||
|
// 组长或休假补贴:低于班组均值则保底,高于则按实际
|
||||||
|
if ((item.useTeamAvgKm || item.hasSubsidy) && item.team) {
|
||||||
|
const avg = teamMonthAvg.get(`${item.team}|${ymKey(item.metrics.year, item.metrics.month)}`);
|
||||||
|
if (avg != null && Number(item.metrics.kmScore) < avg) {
|
||||||
|
item.metrics.kmScore = avg;
|
||||||
|
item.metrics.totalScore = recalcTotalScore(item.metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!item.shouldUpsert) continue;
|
||||||
|
await upsertMonthlyMetrics(item.metrics);
|
||||||
|
recomputed += 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await sync.bumpVersions(['crew_metrics_employee_monthly']);
|
||||||
|
}
|
||||||
|
return { recomputed, formulaVersion: FORMULA_VERSION };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧调用:按人年对列表做重算 */
|
||||||
|
async function recomputePairs(pairs = []) {
|
||||||
|
const byYear = new Map();
|
||||||
|
for (const pair of pairs || []) {
|
||||||
|
const employeeId = String(pair.employeeId || '').trim();
|
||||||
|
const year = Number(pair.year);
|
||||||
|
if (!employeeId || !Number.isFinite(year)) continue;
|
||||||
|
if (!byYear.has(year)) byYear.set(year, new Set());
|
||||||
|
byYear.get(year).add(employeeId);
|
||||||
|
}
|
||||||
|
let recomputed = 0;
|
||||||
|
for (const [year, idSet] of byYear.entries()) {
|
||||||
|
const report = await recomputeEmployeesYears([...idSet], [year]);
|
||||||
|
recomputed += report.recomputed;
|
||||||
|
}
|
||||||
|
return { recomputed, formulaVersion: FORMULA_VERSION };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeYear(year) {
|
||||||
|
await ensureSchema();
|
||||||
|
const employees = await db.query(
|
||||||
|
`SELECT employee_id FROM employees
|
||||||
|
WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)`,
|
||||||
|
);
|
||||||
|
return recomputeEmployeesYears(
|
||||||
|
employees.map((row) => row.employee_id),
|
||||||
|
[Number(year)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeAll() {
|
||||||
|
await ensureSchema();
|
||||||
|
const employees = await db.query(
|
||||||
|
`SELECT employee_id FROM employees
|
||||||
|
WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)`,
|
||||||
|
);
|
||||||
|
return recomputeEmployeesYears(
|
||||||
|
employees.map((row) => row.employee_id),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供前端/调试:单人累计平均(六维与总分) */
|
||||||
|
function averageMetrics(monthRows = []) {
|
||||||
|
if (!monthRows.length) {
|
||||||
|
return {
|
||||||
|
kmScore: 0,
|
||||||
|
performanceScore: BASE.performance,
|
||||||
|
onlineExam: BASE.online,
|
||||||
|
practicalExam: BASE.practical,
|
||||||
|
faultScore: BASE.fault,
|
||||||
|
incidentScore: BASE.incident,
|
||||||
|
totalScore: round1(0 + BASE.performance + BASE.online + BASE.practical + BASE.fault + BASE.incident),
|
||||||
|
monthCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sum = monthRows.reduce((acc, row) => {
|
||||||
|
const kmScore = Number(row.km_score ?? row.kmScore) || 0;
|
||||||
|
const kmAnomaly = Number(row.km_anomaly_count ?? row.kmAnomalyCount) || 0;
|
||||||
|
// 公里维:只平均「有公里数据」的月(无数据月 km=0 且 anomaly=0,不拉低)
|
||||||
|
if (kmScore > 0 || kmAnomaly > 0) {
|
||||||
|
acc.kmScore += kmScore;
|
||||||
|
acc.kmMonths += 1;
|
||||||
|
}
|
||||||
|
acc.performanceScore += Number(row.performance_score ?? row.performanceScore) || 0;
|
||||||
|
acc.onlineExam += Number(row.online_exam ?? row.onlineExam) || 0;
|
||||||
|
acc.practicalExam += Number(row.practical_exam ?? row.practicalExam) || 0;
|
||||||
|
acc.faultScore += Number(row.fault_score ?? row.faultScore) || 0;
|
||||||
|
acc.incidentScore += Number(row.incident_score ?? row.incidentScore) || 0;
|
||||||
|
acc.totalScore += Number(row.total_score ?? row.totalScore) || 0;
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
kmScore: 0,
|
||||||
|
kmMonths: 0,
|
||||||
|
performanceScore: 0,
|
||||||
|
onlineExam: 0,
|
||||||
|
practicalExam: 0,
|
||||||
|
faultScore: 0,
|
||||||
|
incidentScore: 0,
|
||||||
|
totalScore: 0,
|
||||||
|
});
|
||||||
|
const n = monthRows.length;
|
||||||
|
const kmScore = sum.kmMonths ? round1(sum.kmScore / sum.kmMonths) : 0;
|
||||||
|
const performanceScore = round1(sum.performanceScore / n);
|
||||||
|
const onlineExam = round1(sum.onlineExam / n);
|
||||||
|
const practicalExam = round1(sum.practicalExam / n);
|
||||||
|
const faultScore = round1(sum.faultScore / n);
|
||||||
|
const incidentScore = round1(sum.incidentScore / n);
|
||||||
|
// 画像分 = 各维累计平均之和(公里维已排除无公里月),避免「仅嘉奖月 km=0」拉低总分
|
||||||
|
return {
|
||||||
|
kmScore,
|
||||||
|
performanceScore,
|
||||||
|
onlineExam,
|
||||||
|
practicalExam,
|
||||||
|
faultScore,
|
||||||
|
incidentScore,
|
||||||
|
totalScore: round1(kmScore + performanceScore + onlineExam + practicalExam + faultScore + incidentScore),
|
||||||
|
monthCount: n,
|
||||||
|
kmMonthCount: sum.kmMonths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FORMULA_VERSION,
|
||||||
|
BASE,
|
||||||
|
ensureSchema,
|
||||||
|
computeMonthScores,
|
||||||
|
computeEmployeeMonth,
|
||||||
|
averageMetrics,
|
||||||
|
recomputePairs,
|
||||||
|
recomputeEmployeesYears,
|
||||||
|
recomputeYear,
|
||||||
|
recomputeAll,
|
||||||
|
idVariants,
|
||||||
|
normalizeEmployeeId,
|
||||||
|
padEmployeeId,
|
||||||
|
ymKey,
|
||||||
|
parseYm,
|
||||||
|
usesTeamAvgKm,
|
||||||
|
extractRemarks,
|
||||||
|
};
|
||||||
72
server/src/precompute/dashboard-summary.js
Normal file
72
server/src/precompute/dashboard-summary.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
|
||||||
|
const SCOPE_KEY = 'global';
|
||||||
|
|
||||||
|
async function recompute() {
|
||||||
|
const [row] = await db.query(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM employees WHERE deleted_at IS NULL AND (employment_status = '在职' OR employment_status IS NULL)) AS total_employees,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM online_exams
|
||||||
|
WHERE COALESCE(JSON_UNQUOTE(JSON_EXTRACT(details, '$.isMissingExam')), 'false') <> 'true')
|
||||||
|
AS total_online_exams,
|
||||||
|
(SELECT COUNT(*) FROM practical_exams) AS total_practical_exams,
|
||||||
|
(SELECT COUNT(*) FROM fault_disposals WHERE deleted_at IS NULL) AS total_error_analysis,
|
||||||
|
(SELECT COUNT(*) FROM performance_assessments WHERE deleted_at IS NULL) AS total_performance_assessments,
|
||||||
|
(SELECT COUNT(*) FROM incident_events WHERE deleted_at IS NULL) AS total_incident_events,
|
||||||
|
(SELECT COUNT(*) FROM question_banks WHERE deleted_at IS NULL AND status = 'active') AS total_question_banks,
|
||||||
|
(SELECT COUNT(*) FROM kilometers_records) AS total_kilometer_records,
|
||||||
|
(SELECT COALESCE(SUM(kilometers), 0) FROM kilometers_records) AS total_kilometers,
|
||||||
|
(SELECT COUNT(DISTINCT employee_id) FROM kilometers_records) AS unique_kilometer_employees,
|
||||||
|
(SELECT ROUND(
|
||||||
|
COALESCE(SUM(kilometers), 0) / NULLIF(COUNT(DISTINCT employee_id), 0),
|
||||||
|
2
|
||||||
|
) FROM kilometers_records) AS average_kilometers,
|
||||||
|
(SELECT COALESCE(MAX(employee_total), 0)
|
||||||
|
FROM (
|
||||||
|
SELECT SUM(kilometers) AS employee_total
|
||||||
|
FROM kilometers_records
|
||||||
|
GROUP BY employee_id
|
||||||
|
) kilometer_totals) AS max_kilometers,
|
||||||
|
(SELECT ROUND(
|
||||||
|
100 * SUM(pass_count)
|
||||||
|
/ NULLIF(SUM(total_count), 0),
|
||||||
|
2
|
||||||
|
)
|
||||||
|
FROM online_exam_stats_monthly)
|
||||||
|
AS pass_rate`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
totalEmployees: Number(row.total_employees) || 0,
|
||||||
|
totalOnlineExams: Number(row.total_online_exams) || 0,
|
||||||
|
totalPracticalExams: Number(row.total_practical_exams) || 0,
|
||||||
|
totalErrorAnalysis: Number(row.total_error_analysis) || 0,
|
||||||
|
totalPerformanceAssessments: Number(row.total_performance_assessments) || 0,
|
||||||
|
totalIncidentEvents: Number(row.total_incident_events) || 0,
|
||||||
|
totalQuestionBanks: Number(row.total_question_banks) || 0,
|
||||||
|
totalKilometerRecords: Number(row.total_kilometer_records) || 0,
|
||||||
|
totalKilometers: Number(row.total_kilometers) || 0,
|
||||||
|
uniqueKilometerEmployees: Number(row.unique_kilometer_employees) || 0,
|
||||||
|
averageKilometers: Number(row.average_kilometers) || 0,
|
||||||
|
maxKilometers: Number(row.max_kilometers) || 0,
|
||||||
|
passRate: Number(row.pass_rate) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO dashboard_summary (scope_key, payload, computed_at, version)
|
||||||
|
VALUES (?, ?, NOW(), 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
payload = VALUES(payload),
|
||||||
|
computed_at = VALUES(computed_at),
|
||||||
|
version = version + 1`,
|
||||||
|
[SCOPE_KEY, JSON.stringify(payload)],
|
||||||
|
);
|
||||||
|
await sync.bumpVersions(['dashboard_summary']);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { recompute, SCOPE_KEY };
|
||||||
204
server/src/precompute/impls/kilometers.js
Normal file
204
server/src/precompute/impls/kilometers.js
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../../infra/db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公里数预计算(migration-plan §4.1.3)。
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - team / employee_name 一律取自花名册(employees),按工号 employee_id LEFT JOIN
|
||||||
|
* (用户决策 2026-07-16:team 仅花名册维护,其它表通过工号回查;§5.2 口径修正)
|
||||||
|
* - 花名册缺此工号 → team 归入「未分组」,员工名回退工号,避免丢记录
|
||||||
|
* - L2 严格基于 L1(§5.2 关键设计)
|
||||||
|
* - team_yearly.driver_count = 全年出现过的司机去重数,取自 L1 员工月度
|
||||||
|
*/
|
||||||
|
|
||||||
|
const module_ = 'kilometers';
|
||||||
|
|
||||||
|
async function recomputeMonth(year, month) {
|
||||||
|
const start = Date.now();
|
||||||
|
const rowCounts = {};
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
// L1 员工月度(employee_name / team 取自花名册)
|
||||||
|
await conn.execute('DELETE FROM kilometers_stats_employee_monthly WHERE year = ? AND month = ?', [year, month]);
|
||||||
|
const [empRes] = await conn.execute(
|
||||||
|
`INSERT INTO kilometers_stats_employee_monthly
|
||||||
|
(year, month, employee_id, employee_name, team,
|
||||||
|
total_kilometers, record_count, avg_per_record, max_single, computed_at, version)
|
||||||
|
SELECT k.year, k.month, k.employee_id,
|
||||||
|
COALESCE(MAX(e.name), k.employee_id), COALESCE(MAX(e.team), '未分组'),
|
||||||
|
SUM(k.kilometers), COUNT(*), ROUND(AVG(k.kilometers), 2), MAX(k.kilometers), NOW(), 0
|
||||||
|
FROM kilometers_records k
|
||||||
|
LEFT JOIN employees e ON e.employee_id = k.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE k.year = ? AND k.month = ?
|
||||||
|
GROUP BY k.year, k.month, k.employee_id`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
rowCounts.kilometers_stats_employee_monthly = empRes.affectedRows;
|
||||||
|
|
||||||
|
// L1 班组月度(team 取自花名册,缺失归「未分组」)
|
||||||
|
await conn.execute('DELETE FROM kilometers_stats_team_monthly WHERE year = ? AND month = ?', [year, month]);
|
||||||
|
const [teamRes] = await conn.execute(
|
||||||
|
`INSERT INTO kilometers_stats_team_monthly
|
||||||
|
(year, month, team, total_kilometers, driver_count,
|
||||||
|
avg_kilometers, max_kilometers, min_kilometers, computed_at, version)
|
||||||
|
SELECT k.year, k.month, COALESCE(e.team, '未分组') AS team,
|
||||||
|
SUM(k.kilometers), COUNT(DISTINCT k.employee_id),
|
||||||
|
ROUND(SUM(k.kilometers) / COUNT(DISTINCT k.employee_id), 2),
|
||||||
|
MAX(k.kilometers), MIN(k.kilometers), NOW(), 0
|
||||||
|
FROM kilometers_records k
|
||||||
|
LEFT JOIN employees e ON e.employee_id = k.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE k.year = ? AND k.month = ?
|
||||||
|
GROUP BY k.year, k.month, COALESCE(e.team, '未分组')`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
rowCounts.kilometers_stats_team_monthly = teamRes.affectedRows;
|
||||||
|
|
||||||
|
await conn.execute('DELETE FROM kilometers_stats_overall_monthly WHERE year = ? AND month = ?', [year, month]);
|
||||||
|
const [overallRes] = await conn.execute(
|
||||||
|
`INSERT INTO kilometers_stats_overall_monthly
|
||||||
|
(year, month, total_kilometers, driver_count, avg_kilometers,
|
||||||
|
max_kilometers, min_kilometers, computed_at, version)
|
||||||
|
SELECT ?, ?, COALESCE(SUM(total_kilometers), 0), COUNT(*),
|
||||||
|
ROUND(AVG(total_kilometers), 2), MAX(total_kilometers),
|
||||||
|
MIN(total_kilometers), NOW(), 0
|
||||||
|
FROM kilometers_stats_employee_monthly
|
||||||
|
WHERE year = ? AND month = ?`,
|
||||||
|
[year, month, year, month],
|
||||||
|
);
|
||||||
|
rowCounts.kilometers_stats_overall_monthly = overallRes.affectedRows;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
module: module_,
|
||||||
|
grain: 'month',
|
||||||
|
period: `${year}-${String(month).padStart(2, '0')}`,
|
||||||
|
touchedTables: [
|
||||||
|
'kilometers_stats_employee_monthly',
|
||||||
|
'kilometers_stats_team_monthly',
|
||||||
|
'kilometers_stats_overall_monthly',
|
||||||
|
],
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeYear(year) {
|
||||||
|
const start = Date.now();
|
||||||
|
const rowCounts = {};
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
// L2 员工年度(基于 L1 员工月度)
|
||||||
|
await conn.execute('DELETE FROM kilometers_stats_employee_yearly WHERE year = ?', [year]);
|
||||||
|
const [empRes] = await conn.execute(
|
||||||
|
`INSERT INTO kilometers_stats_employee_yearly
|
||||||
|
(year, employee_id, employee_name, team, total_kilometers,
|
||||||
|
month_count, avg_monthly_kilometers, monthly_breakdown, computed_at, version)
|
||||||
|
SELECT year, employee_id, MAX(employee_name), MAX(team),
|
||||||
|
SUM(total_kilometers), COUNT(*), ROUND(AVG(total_kilometers), 2),
|
||||||
|
JSON_OBJECTAGG(CAST(month AS CHAR), total_kilometers),
|
||||||
|
NOW(), 0
|
||||||
|
FROM kilometers_stats_employee_monthly
|
||||||
|
WHERE year = ?
|
||||||
|
GROUP BY year, employee_id`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
rowCounts.kilometers_stats_employee_yearly = empRes.affectedRows;
|
||||||
|
|
||||||
|
// L2 班组年度(基于 L1;driver_count 用员工月度去重)
|
||||||
|
await conn.execute('DELETE FROM kilometers_stats_team_yearly WHERE year = ?', [year]);
|
||||||
|
const [teamRes] = await conn.execute(
|
||||||
|
`INSERT INTO kilometers_stats_team_yearly
|
||||||
|
(year, team, total_kilometers, driver_count, avg_kilometers,
|
||||||
|
avg_annual_per_driver, avg_per_person_month,
|
||||||
|
monthly_breakdown, computed_at, version)
|
||||||
|
SELECT tm.year, tm.team,
|
||||||
|
SUM(tm.total_kilometers),
|
||||||
|
(SELECT COUNT(DISTINCT em.employee_id)
|
||||||
|
FROM kilometers_stats_employee_monthly em
|
||||||
|
WHERE em.year = tm.year AND em.team = tm.team),
|
||||||
|
ROUND(AVG(tm.avg_kilometers), 2),
|
||||||
|
ROUND(SUM(tm.total_kilometers) / NULLIF((
|
||||||
|
SELECT COUNT(DISTINCT em.employee_id)
|
||||||
|
FROM kilometers_stats_employee_monthly em
|
||||||
|
WHERE em.year = tm.year AND em.team = tm.team
|
||||||
|
), 0), 2),
|
||||||
|
ROUND(SUM(tm.total_kilometers) / NULLIF(SUM(tm.driver_count), 0), 2),
|
||||||
|
JSON_OBJECTAGG(CAST(tm.month AS CHAR), tm.total_kilometers),
|
||||||
|
NOW(), 0
|
||||||
|
FROM kilometers_stats_team_monthly tm
|
||||||
|
WHERE tm.year = ?
|
||||||
|
GROUP BY tm.year, tm.team`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
rowCounts.kilometers_stats_team_yearly = teamRes.affectedRows;
|
||||||
|
|
||||||
|
await conn.execute('DELETE FROM kilometers_stats_overall_yearly WHERE year = ?', [year]);
|
||||||
|
const [overallRes] = await conn.execute(
|
||||||
|
`INSERT INTO kilometers_stats_overall_yearly
|
||||||
|
(year, total_kilometers, driver_count, person_month_count,
|
||||||
|
avg_annual_per_driver, avg_per_person_month, monthly_breakdown,
|
||||||
|
computed_at, version)
|
||||||
|
SELECT ?, COALESCE(SUM(total_kilometers), 0),
|
||||||
|
COUNT(DISTINCT employee_id), COUNT(*),
|
||||||
|
ROUND(SUM(total_kilometers) / NULLIF(COUNT(DISTINCT employee_id), 0), 2),
|
||||||
|
ROUND(SUM(total_kilometers) / NULLIF(COUNT(*), 0), 2),
|
||||||
|
(SELECT JSON_OBJECTAGG(CAST(month AS CHAR), total_kilometers)
|
||||||
|
FROM kilometers_stats_overall_monthly WHERE year = ?),
|
||||||
|
NOW(), 0
|
||||||
|
FROM kilometers_stats_employee_monthly
|
||||||
|
WHERE year = ?`,
|
||||||
|
[year, year, year],
|
||||||
|
);
|
||||||
|
rowCounts.kilometers_stats_overall_yearly = overallRes.affectedRows;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
module: module_,
|
||||||
|
grain: 'year',
|
||||||
|
period: String(year),
|
||||||
|
touchedTables: [
|
||||||
|
'kilometers_stats_employee_yearly',
|
||||||
|
'kilometers_stats_team_yearly',
|
||||||
|
'kilometers_stats_overall_yearly',
|
||||||
|
],
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeAll() {
|
||||||
|
const start = Date.now();
|
||||||
|
const periods = await db.query('SELECT DISTINCT year, month FROM kilometers_records ORDER BY year, month');
|
||||||
|
const rowCounts = {};
|
||||||
|
const years = new Set();
|
||||||
|
for (const p of periods) {
|
||||||
|
const r = await recomputeMonth(p.year, p.month);
|
||||||
|
for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v;
|
||||||
|
years.add(p.year);
|
||||||
|
}
|
||||||
|
for (const y of years) {
|
||||||
|
const r = await recomputeYear(y);
|
||||||
|
for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
module: module_,
|
||||||
|
grain: 'all',
|
||||||
|
period: 'all',
|
||||||
|
touchedTables: [
|
||||||
|
'kilometers_stats_employee_monthly',
|
||||||
|
'kilometers_stats_team_monthly',
|
||||||
|
'kilometers_stats_overall_monthly',
|
||||||
|
'kilometers_stats_employee_yearly',
|
||||||
|
'kilometers_stats_team_yearly',
|
||||||
|
'kilometers_stats_overall_yearly',
|
||||||
|
],
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { module: module_, recomputeMonth, recomputeYear, recomputeAll };
|
||||||
156
server/src/precompute/impls/online-exams.js
Normal file
156
server/src/precompute/impls/online-exams.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../../infra/db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线考试预计算(migration-plan §4.1.3)。
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - team 取自花名册(employees),按工号 LEFT JOIN;缺失归「未分组」
|
||||||
|
* - paper_class 缺失归「未分类」(stats 表该列 NOT NULL)
|
||||||
|
* - 通过率 pass_rate = pass_count / total_count(total 为该维度全部作答数)
|
||||||
|
* - 年度 avg_score 按 total_count 加权(严格基于 L1,避免月度简单平均失真)
|
||||||
|
*
|
||||||
|
* 未实现(依赖后续,见实施日志 TODO):
|
||||||
|
* - online_exam_question_stats_monthly:题目级统计依赖 details JSON 结构(parser 未定),本轮不产出
|
||||||
|
* 缺考主路径:读取 missing_exams,按班组写入 paper_class='缺考' 的统计行;
|
||||||
|
* 旧 details.isMissingExam 仅用于迁移期兼容。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const module_ = 'online_exams';
|
||||||
|
|
||||||
|
async function recomputeMonth(year, month) {
|
||||||
|
const start = Date.now();
|
||||||
|
const rowCounts = {};
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute('DELETE FROM online_exam_stats_monthly WHERE year = ? AND month = ?', [year, month]);
|
||||||
|
const [res] = await conn.execute(
|
||||||
|
`INSERT INTO online_exam_stats_monthly
|
||||||
|
(year, month, team, paper_class,
|
||||||
|
total_count, pass_count, fail_count, pass_rate,
|
||||||
|
avg_score, max_score, min_score, avg_elapsed_seconds, missing_count, computed_at, version)
|
||||||
|
SELECT o.exam_year, o.exam_month,
|
||||||
|
COALESCE(e.team, '未分组') AS team,
|
||||||
|
COALESCE(o.paper_class, '未分类') AS paper_class,
|
||||||
|
SUM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'),
|
||||||
|
SUM(o.result = 'pass' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'),
|
||||||
|
SUM(o.result = 'fail' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'),
|
||||||
|
ROUND(
|
||||||
|
SUM(o.result = 'pass' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true')
|
||||||
|
/ NULLIF(SUM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true'), 0),
|
||||||
|
4
|
||||||
|
),
|
||||||
|
ROUND(AVG(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.score END), 2),
|
||||||
|
MAX(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.score END),
|
||||||
|
MIN(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.score END),
|
||||||
|
ROUND(AVG(CASE WHEN COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') <> 'true' THEN o.elapsed_seconds END)),
|
||||||
|
SUM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.details, '$.isMissingExam')), 'false') = 'true'),
|
||||||
|
NOW(), 0
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE o.exam_year = ? AND o.exam_month = ?
|
||||||
|
GROUP BY o.exam_year, o.exam_month, COALESCE(e.team, '未分组'), COALESCE(o.paper_class, '未分类')`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
const missingMonth = `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
const [missingRes] = await conn.execute(
|
||||||
|
`INSERT INTO online_exam_stats_monthly
|
||||||
|
(year, month, team, paper_class,
|
||||||
|
total_count, pass_count, fail_count, pass_rate,
|
||||||
|
avg_score, max_score, min_score, avg_elapsed_seconds, missing_count, computed_at, version)
|
||||||
|
SELECT ?, ?, COALESCE(e.team, '未分组'), '缺考',
|
||||||
|
0, 0, 0, NULL, NULL, NULL, NULL, NULL, COUNT(*), NOW(), 0
|
||||||
|
FROM missing_exams m
|
||||||
|
LEFT JOIN employees e ON e.employee_id = m.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE m.missing_month = ?
|
||||||
|
GROUP BY COALESCE(e.team, '未分组')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
missing_count = VALUES(missing_count),
|
||||||
|
computed_at = VALUES(computed_at),
|
||||||
|
version = version + 1`,
|
||||||
|
[year, month, missingMonth],
|
||||||
|
);
|
||||||
|
rowCounts.online_exam_stats_monthly = res.affectedRows + missingRes.affectedRows;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
module: module_,
|
||||||
|
grain: 'month',
|
||||||
|
period: `${year}-${String(month).padStart(2, '0')}`,
|
||||||
|
touchedTables: ['online_exam_stats_monthly'],
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeYear(year) {
|
||||||
|
const start = Date.now();
|
||||||
|
const rowCounts = {};
|
||||||
|
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute('DELETE FROM online_exam_stats_yearly WHERE year = ?', [year]);
|
||||||
|
const [res] = await conn.execute(
|
||||||
|
`INSERT INTO online_exam_stats_yearly
|
||||||
|
(year, team, paper_class, total_count, pass_count, pass_rate, avg_score, monthly_breakdown, computed_at, version)
|
||||||
|
SELECT year, team, paper_class,
|
||||||
|
SUM(total_count), SUM(pass_count),
|
||||||
|
ROUND(SUM(pass_count) / NULLIF(SUM(total_count), 0), 4),
|
||||||
|
ROUND(SUM(avg_score * total_count) / NULLIF(SUM(total_count), 0), 2),
|
||||||
|
JSON_OBJECTAGG(CAST(month AS CHAR), total_count),
|
||||||
|
NOW(), 0
|
||||||
|
FROM online_exam_stats_monthly
|
||||||
|
WHERE year = ?
|
||||||
|
GROUP BY year, team, paper_class`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
rowCounts.online_exam_stats_yearly = res.affectedRows;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
module: module_,
|
||||||
|
grain: 'year',
|
||||||
|
period: String(year),
|
||||||
|
touchedTables: ['online_exam_stats_yearly'],
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeAll() {
|
||||||
|
const start = Date.now();
|
||||||
|
const periods = await db.query(
|
||||||
|
`SELECT DISTINCT y, m
|
||||||
|
FROM (
|
||||||
|
SELECT exam_year AS y, exam_month AS m FROM online_exams
|
||||||
|
UNION
|
||||||
|
SELECT CAST(SUBSTRING(missing_month, 1, 4) AS UNSIGNED) AS y,
|
||||||
|
CAST(SUBSTRING(missing_month, 6, 2) AS UNSIGNED) AS m
|
||||||
|
FROM missing_exams
|
||||||
|
) periods
|
||||||
|
ORDER BY y, m`,
|
||||||
|
);
|
||||||
|
const rowCounts = {};
|
||||||
|
const years = new Set();
|
||||||
|
for (const p of periods) {
|
||||||
|
const r = await recomputeMonth(p.y, p.m);
|
||||||
|
for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v;
|
||||||
|
years.add(p.y);
|
||||||
|
}
|
||||||
|
for (const y of years) {
|
||||||
|
const r = await recomputeYear(y);
|
||||||
|
for (const [k, v] of Object.entries(r.rowCounts)) rowCounts[k] = (rowCounts[k] || 0) + v;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
module: module_,
|
||||||
|
grain: 'all',
|
||||||
|
period: 'all',
|
||||||
|
touchedTables: ['online_exam_stats_monthly', 'online_exam_stats_yearly'],
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { module: module_, recomputeMonth, recomputeYear, recomputeAll };
|
||||||
329
server/src/precompute/impls/practical-exams.js
Normal file
329
server/src/precompute/impls/practical-exams.js
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../../infra/db');
|
||||||
|
|
||||||
|
const MONTH_TABLES = [
|
||||||
|
'practical_exam_stats_monthly',
|
||||||
|
'practical_exam_step_stats_monthly',
|
||||||
|
'practical_exam_person_stats_monthly',
|
||||||
|
'practical_exam_scope_stats_monthly',
|
||||||
|
];
|
||||||
|
const YEAR_TABLES = [
|
||||||
|
'practical_exam_stats_yearly',
|
||||||
|
'practical_exam_person_stats_yearly',
|
||||||
|
'practical_exam_scope_stats_yearly',
|
||||||
|
];
|
||||||
|
// 班组只认当前花名册;考试快照中的旧班组不参与管理统计。
|
||||||
|
const teamExpr = "COALESCE(NULLIF(e.team, ''), '未分组')";
|
||||||
|
|
||||||
|
async function fillStepTopErrors(year, month) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT scope_team, paper_id, scenario_index, step_index, error_label,
|
||||||
|
COUNT(DISTINCT exam_uuid) AS error_count
|
||||||
|
FROM (
|
||||||
|
SELECT ${teamExpr} AS scope_team,
|
||||||
|
s.paper_id, s.scenario_index, s.step_index, s.exam_uuid,
|
||||||
|
CONCAT(
|
||||||
|
COALESCE(NULLIF(s.operation, ''), NULLIF(s.target, ''), '未知操作'),
|
||||||
|
' → ',
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.actualValue')), ''),
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.result')), ''),
|
||||||
|
'错误'
|
||||||
|
)
|
||||||
|
) AS error_label
|
||||||
|
FROM practical_exam_steps s
|
||||||
|
LEFT JOIN practical_exams p ON p.exam_uuid = s.exam_uuid
|
||||||
|
LEFT JOIN employees e ON e.employee_id = s.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE s.exam_year = ? AND s.exam_month = ?
|
||||||
|
AND (s.is_correct = 0 OR s.faults > 0)
|
||||||
|
AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0
|
||||||
|
AND COALESCE(s.operation, '') <> '选择题提交'
|
||||||
|
AND COALESCE(s.target, '') <> '选择题提交'
|
||||||
|
UNION ALL
|
||||||
|
SELECT '全部', s.paper_id, s.scenario_index, s.step_index, s.exam_uuid,
|
||||||
|
CONCAT(
|
||||||
|
COALESCE(NULLIF(s.operation, ''), NULLIF(s.target, ''), '未知操作'),
|
||||||
|
' → ',
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.actualValue')), ''),
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.details, '$.result')), ''),
|
||||||
|
'错误'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM practical_exam_steps s
|
||||||
|
WHERE s.exam_year = ? AND s.exam_month = ?
|
||||||
|
AND (s.is_correct = 0 OR s.faults > 0)
|
||||||
|
AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0
|
||||||
|
AND COALESCE(s.operation, '') <> '选择题提交'
|
||||||
|
AND COALESCE(s.target, '') <> '选择题提交'
|
||||||
|
) errors
|
||||||
|
GROUP BY scope_team, paper_id, scenario_index, step_index, error_label
|
||||||
|
ORDER BY error_count DESC`,
|
||||||
|
[year, month, year, month],
|
||||||
|
);
|
||||||
|
const groups = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = [row.scope_team, row.paper_id, row.scenario_index, row.step_index].join('|');
|
||||||
|
const list = groups.get(key) || [];
|
||||||
|
list.push({ label: row.error_label, count: Number(row.error_count) || 0 });
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
for (const [key, errors] of groups) {
|
||||||
|
const [team, paperId, scenarioIndex, stepIndex] = key.split('|');
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE practical_exam_step_stats_monthly
|
||||||
|
SET top3_errors = ?
|
||||||
|
WHERE year = ? AND month = ? AND team = ? AND paper_id = ?
|
||||||
|
AND scenario_index = ? AND step_index = ?`,
|
||||||
|
[
|
||||||
|
JSON.stringify(errors.slice(0, 3)),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
team,
|
||||||
|
Number(paperId),
|
||||||
|
Number(scenarioIndex),
|
||||||
|
Number(stepIndex),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeMonth(year, month) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
for (const table of MONTH_TABLES) {
|
||||||
|
await conn.execute(`DELETE FROM ${table} WHERE year = ? AND month = ?`, [year, month]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_person_stats_monthly
|
||||||
|
(year, month, employee_id, employee_name, team, total_count,
|
||||||
|
pass_count, fail_count, pass_rate, computed_at)
|
||||||
|
SELECT ?, ?, p.employee_id,
|
||||||
|
MAX(COALESCE(NULLIF(e.name, ''), JSON_UNQUOTE(JSON_EXTRACT(p.details, '$.employeeName')))),
|
||||||
|
MAX(${teamExpr}),
|
||||||
|
COUNT(*), SUM(p.result = 'pass'), SUM(p.result = 'fail'),
|
||||||
|
SUM(p.result = 'pass') / NULLIF(COUNT(*), 0), NOW()
|
||||||
|
FROM practical_exams p
|
||||||
|
LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE p.exam_year = ? AND p.exam_month = ?
|
||||||
|
GROUP BY p.employee_id`,
|
||||||
|
[year, month, year, month],
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_scope_stats_monthly
|
||||||
|
(year, month, scope_type, scope_key, team, total_count, pass_count,
|
||||||
|
fail_count, pass_rate, participant_count, computed_at)
|
||||||
|
SELECT ?, ?, 'team', CONCAT('team:', scoped.team), scoped.team,
|
||||||
|
COUNT(*), SUM(scoped.result = 'pass'), SUM(scoped.result = 'fail'),
|
||||||
|
SUM(scoped.result = 'pass') / NULLIF(COUNT(*), 0),
|
||||||
|
COUNT(DISTINCT scoped.employee_id), NOW()
|
||||||
|
FROM (
|
||||||
|
SELECT ${teamExpr} AS team, p.result, p.employee_id
|
||||||
|
FROM practical_exams p
|
||||||
|
LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE p.exam_year = ? AND p.exam_month = ?
|
||||||
|
) scoped
|
||||||
|
GROUP BY scoped.team
|
||||||
|
UNION ALL
|
||||||
|
SELECT ?, ?, 'all', 'all', NULL,
|
||||||
|
COUNT(*), COALESCE(SUM(p.result = 'pass'), 0),
|
||||||
|
COALESCE(SUM(p.result = 'fail'), 0),
|
||||||
|
COALESCE(SUM(p.result = 'pass'), 0) / NULLIF(COUNT(*), 0),
|
||||||
|
COUNT(DISTINCT p.employee_id), NOW()
|
||||||
|
FROM practical_exams p
|
||||||
|
WHERE p.exam_year = ? AND p.exam_month = ?`,
|
||||||
|
[year, month, year, month, year, month, year, month],
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_stats_monthly
|
||||||
|
(year, month, team, paper_id, paper_class, total_count, pass_count,
|
||||||
|
fail_count, pass_rate, avg_score, max_score, min_score, avg_faults, computed_at)
|
||||||
|
SELECT ?, ?, scope_team, paper_id, MAX(pc_class), COUNT(*),
|
||||||
|
SUM(result = 'pass'), SUM(result = 'fail'),
|
||||||
|
SUM(result = 'pass') / NULLIF(COUNT(*), 0),
|
||||||
|
AVG(score), MAX(score), MIN(score), AVG(faults), NOW()
|
||||||
|
FROM (
|
||||||
|
SELECT ${teamExpr} AS scope_team, p.*
|
||||||
|
FROM practical_exams p
|
||||||
|
LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE p.exam_year = ? AND p.exam_month = ? AND p.paper_id IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT '全部', p.*
|
||||||
|
FROM practical_exams p
|
||||||
|
WHERE p.exam_year = ? AND p.exam_month = ? AND p.paper_id IS NOT NULL
|
||||||
|
) scoped
|
||||||
|
GROUP BY scope_team, paper_id`,
|
||||||
|
[year, month, year, month, year, month],
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_step_stats_monthly
|
||||||
|
(year, month, paper_id, scenario_index, step_index, team,
|
||||||
|
total_count, error_count, error_rate, computed_at)
|
||||||
|
SELECT ?, ?, paper_id, scenario_index, step_index, scope_team,
|
||||||
|
COUNT(*), SUM(has_error), SUM(has_error) / NULLIF(COUNT(*), 0), NOW()
|
||||||
|
FROM (
|
||||||
|
SELECT ${teamExpr} AS scope_team, collapsed.*
|
||||||
|
FROM (
|
||||||
|
SELECT s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index,
|
||||||
|
MAX(s.is_correct = 0 OR s.faults > 0) AS has_error
|
||||||
|
FROM practical_exam_steps s
|
||||||
|
WHERE s.exam_year = ? AND s.exam_month = ? AND s.paper_id IS NOT NULL
|
||||||
|
AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0
|
||||||
|
AND COALESCE(s.operation, '') <> '选择题提交'
|
||||||
|
AND COALESCE(s.target, '') <> '选择题提交'
|
||||||
|
GROUP BY s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index
|
||||||
|
) collapsed
|
||||||
|
LEFT JOIN practical_exams p ON p.exam_uuid = collapsed.exam_uuid
|
||||||
|
LEFT JOIN employees e ON e.employee_id = collapsed.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
UNION ALL
|
||||||
|
SELECT '全部', collapsed.*
|
||||||
|
FROM (
|
||||||
|
SELECT s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index,
|
||||||
|
MAX(s.is_correct = 0 OR s.faults > 0) AS has_error
|
||||||
|
FROM practical_exam_steps s
|
||||||
|
WHERE s.exam_year = ? AND s.exam_month = ? AND s.paper_id IS NOT NULL
|
||||||
|
AND COALESCE(JSON_EXTRACT(s.details, '$.isChoice') + 0, 0) = 0
|
||||||
|
AND COALESCE(s.operation, '') <> '选择题提交'
|
||||||
|
AND COALESCE(s.target, '') <> '选择题提交'
|
||||||
|
GROUP BY s.exam_uuid, s.employee_id, s.paper_id, s.scenario_index, s.step_index
|
||||||
|
) collapsed
|
||||||
|
) scoped
|
||||||
|
GROUP BY scope_team, paper_id, scenario_index, step_index`,
|
||||||
|
[year, month, year, month, year, month],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await fillStepTopErrors(year, month);
|
||||||
|
|
||||||
|
const rowCounts = {};
|
||||||
|
for (const table of MONTH_TABLES) {
|
||||||
|
const [row] = await db.query(
|
||||||
|
`SELECT COUNT(*) AS count FROM ${table} WHERE year = ? AND month = ?`,
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
rowCounts[table] = Number(row.count);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
grain: 'month',
|
||||||
|
period: `${year}-${String(month).padStart(2, '0')}`,
|
||||||
|
touchedTables: MONTH_TABLES,
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeYear(year) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
for (const table of YEAR_TABLES) {
|
||||||
|
await conn.execute(`DELETE FROM ${table} WHERE year = ?`, [year]);
|
||||||
|
}
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_person_stats_yearly
|
||||||
|
(year, employee_id, employee_name, team, total_count, pass_count,
|
||||||
|
fail_count, pass_rate, monthly_breakdown, computed_at)
|
||||||
|
SELECT year, employee_id, MAX(employee_name), MAX(team),
|
||||||
|
SUM(total_count), SUM(pass_count), SUM(fail_count),
|
||||||
|
SUM(pass_count) / NULLIF(SUM(total_count), 0),
|
||||||
|
JSON_OBJECTAGG(LPAD(month, 2, '0'), JSON_OBJECT(
|
||||||
|
'total', total_count, 'pass', pass_count, 'fail', fail_count,
|
||||||
|
'passRate', pass_rate
|
||||||
|
)), NOW()
|
||||||
|
FROM practical_exam_person_stats_monthly
|
||||||
|
WHERE year = ?
|
||||||
|
GROUP BY year, employee_id`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_scope_stats_yearly
|
||||||
|
(year, scope_type, scope_key, team, total_count, pass_count, fail_count,
|
||||||
|
pass_rate, participant_count, monthly_breakdown, computed_at)
|
||||||
|
SELECT year, scope_type, scope_key, MAX(team),
|
||||||
|
SUM(total_count), SUM(pass_count), SUM(fail_count),
|
||||||
|
SUM(pass_count) / NULLIF(SUM(total_count), 0),
|
||||||
|
MAX(participant_count),
|
||||||
|
JSON_OBJECTAGG(LPAD(month, 2, '0'), JSON_OBJECT(
|
||||||
|
'total', total_count, 'pass', pass_count, 'fail', fail_count,
|
||||||
|
'passRate', pass_rate, 'participants', participant_count
|
||||||
|
)), NOW()
|
||||||
|
FROM practical_exam_scope_stats_monthly
|
||||||
|
WHERE year = ?
|
||||||
|
GROUP BY year, scope_type, scope_key`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE practical_exam_scope_stats_yearly s
|
||||||
|
SET participant_count = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM practical_exam_person_stats_yearly p
|
||||||
|
WHERE p.year = s.year
|
||||||
|
AND (s.scope_type = 'all' OR p.team = s.team)
|
||||||
|
)
|
||||||
|
WHERE s.year = ?`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO practical_exam_stats_yearly
|
||||||
|
(year, team, paper_id, paper_class, total_count, pass_count, pass_rate,
|
||||||
|
avg_score, avg_faults, monthly_breakdown, computed_at)
|
||||||
|
SELECT year, team, paper_id, MAX(paper_class),
|
||||||
|
SUM(total_count), SUM(pass_count),
|
||||||
|
SUM(pass_count) / NULLIF(SUM(total_count), 0),
|
||||||
|
SUM(avg_score * total_count) / NULLIF(SUM(total_count), 0),
|
||||||
|
SUM(avg_faults * total_count) / NULLIF(SUM(total_count), 0),
|
||||||
|
JSON_OBJECTAGG(LPAD(month, 2, '0'), JSON_OBJECT(
|
||||||
|
'total', total_count, 'pass', pass_count, 'fail', fail_count,
|
||||||
|
'passRate', pass_rate
|
||||||
|
)), NOW()
|
||||||
|
FROM practical_exam_stats_monthly
|
||||||
|
WHERE year = ?
|
||||||
|
GROUP BY year, team, paper_id`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const rowCounts = {};
|
||||||
|
for (const table of YEAR_TABLES) {
|
||||||
|
const [row] = await db.query(`SELECT COUNT(*) AS count FROM ${table} WHERE year = ?`, [year]);
|
||||||
|
rowCounts[table] = Number(row.count);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
grain: 'year',
|
||||||
|
period: String(year),
|
||||||
|
touchedTables: YEAR_TABLES,
|
||||||
|
rowCounts,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeAll() {
|
||||||
|
const periods = await db.query(
|
||||||
|
'SELECT DISTINCT exam_year AS year, exam_month AS month FROM practical_exams ORDER BY year, month',
|
||||||
|
);
|
||||||
|
const reports = [];
|
||||||
|
for (const period of periods) reports.push(await recomputeMonth(period.year, period.month));
|
||||||
|
const years = [...new Set(periods.map((row) => row.year))];
|
||||||
|
for (const year of years) reports.push(await recomputeYear(year));
|
||||||
|
return {
|
||||||
|
grain: 'all',
|
||||||
|
touchedTables: [...MONTH_TABLES, ...YEAR_TABLES],
|
||||||
|
rowCounts: {},
|
||||||
|
durationMs: reports.reduce((sum, report) => sum + report.durationMs, 0),
|
||||||
|
reports,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
module: 'practical_exams',
|
||||||
|
recomputeMonth,
|
||||||
|
recomputeYear,
|
||||||
|
recomputeAll,
|
||||||
|
};
|
||||||
17
server/src/precompute/index.js
Normal file
17
server/src/precompute/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const registry = require('./registry');
|
||||||
|
const kilometers = require('./impls/kilometers');
|
||||||
|
const onlineExams = require('./impls/online-exams');
|
||||||
|
const practicalExams = require('./impls/practical-exams');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时注册所有 Precomputer(§15.1 checklist 第 4 步:新模块在此 register)。
|
||||||
|
*/
|
||||||
|
function registerAll() {
|
||||||
|
registry.register(kilometers);
|
||||||
|
registry.register(onlineExams);
|
||||||
|
registry.register(practicalExams);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAll };
|
||||||
163
server/src/precompute/precompute.routes.js
Normal file
163
server/src/precompute/precompute.routes.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const registry = require('./registry');
|
||||||
|
const service = require('./service');
|
||||||
|
const dashboardSummary = require('./dashboard-summary');
|
||||||
|
const crewMetrics = require('./crew-metrics');
|
||||||
|
const kilometersImport = require('../upload/kilometers-import');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
const maintenance = require('../maintenance');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const MODULES = ['kilometers', 'online_exams', 'practical_exams'];
|
||||||
|
|
||||||
|
/** 全量重算各业务模块预计算表 */
|
||||||
|
async function recomputeAllModules() {
|
||||||
|
const reports = {};
|
||||||
|
for (const mod of MODULES) {
|
||||||
|
try {
|
||||||
|
reports[mod] = await service.recomputeAll(mod);
|
||||||
|
} catch (error) {
|
||||||
|
reports[mod] = { error: error?.message || 'failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/registered',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, registry.registered());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量计算(设置页「7.全量计算」):
|
||||||
|
* 各模块 stats 全量 → 公里控制图 → 人月画像 → dashboard
|
||||||
|
* 指定 year / employeeId 时仍支持按年/按人(模块 stats 仅在无 employeeId 时全量重算)
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/crew-metrics/recompute',
|
||||||
|
requireAuth,
|
||||||
|
maintenance.requireDeveloper,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { year, employeeId } = req.body || {};
|
||||||
|
const y = year != null ? Number(year) : null;
|
||||||
|
const moduleReports = employeeId ? {} : await recomputeAllModules();
|
||||||
|
|
||||||
|
// 画像公里维依赖控制图 is_anomaly
|
||||||
|
const controlReport = await kilometersImport.recomputeAllControlYears(
|
||||||
|
y != null ? [y] : null,
|
||||||
|
);
|
||||||
|
let report;
|
||||||
|
if (employeeId && y != null) {
|
||||||
|
report = await crewMetrics.recomputeEmployeesYears([String(employeeId)], [y]);
|
||||||
|
} else if (y != null) {
|
||||||
|
report = await crewMetrics.recomputeYear(y);
|
||||||
|
} else {
|
||||||
|
report = await crewMetrics.recomputeAll();
|
||||||
|
}
|
||||||
|
await dashboardSummary.recompute();
|
||||||
|
report = { ...report, control: controlReport, modules: moduleReports };
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'recompute_crew_metrics',
|
||||||
|
module: 'imports',
|
||||||
|
payload: { year: y, employeeId: employeeId || null, report },
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
ok(res, report);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单月计算(设置页「8.单月计算」):
|
||||||
|
* 该月三模块 stats → 该年控制图 → 该年画像 → dashboard
|
||||||
|
* 必须在 /:module/recompute 之前注册
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/month-all',
|
||||||
|
requireAuth,
|
||||||
|
maintenance.requireDeveloper,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { year, month } = req.body || {};
|
||||||
|
if (year == null || month == null) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '缺少参数:year / month', 400);
|
||||||
|
}
|
||||||
|
const y = Number(year);
|
||||||
|
const m = Number(month);
|
||||||
|
const reports = {};
|
||||||
|
for (const mod of MODULES) {
|
||||||
|
try {
|
||||||
|
reports[mod] = await service.recomputePeriod(mod, y, m);
|
||||||
|
} catch (error) {
|
||||||
|
reports[mod] = { error: error?.message || 'failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reports.control = await kilometersImport.recomputeAllControlYears([y]);
|
||||||
|
} catch (error) {
|
||||||
|
reports.control = { error: error?.message || 'failed' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reports.crew_metrics = await crewMetrics.recomputeYear(y);
|
||||||
|
} catch (error) {
|
||||||
|
reports.crew_metrics = { error: error?.message || 'failed' };
|
||||||
|
}
|
||||||
|
await dashboardSummary.recompute();
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'recompute_month_all',
|
||||||
|
module: 'imports',
|
||||||
|
payload: { year: y, month: m, reports },
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
ok(res, reports);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 单模块按月重算(开发者调试用)
|
||||||
|
router.post(
|
||||||
|
'/:module/recompute',
|
||||||
|
requireAuth,
|
||||||
|
maintenance.requireDeveloper,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { year, month } = req.body || {};
|
||||||
|
if (year == null || month == null) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '缺少参数:year / month', 400);
|
||||||
|
}
|
||||||
|
const report = await service.onUploadComplete(req.params.module, Number(year), Number(month));
|
||||||
|
await dashboardSummary.recompute();
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'recompute',
|
||||||
|
module: req.params.module,
|
||||||
|
payload: { year: Number(year), month: Number(month), rowCounts: report.rowCounts },
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
ok(res, report);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
50
server/src/precompute/registry.js
Normal file
50
server/src/precompute/registry.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预计算注册表(migration-plan §5.2)。
|
||||||
|
* 反模式约束(§15.4):禁止 switch(module),一律注册表分发。
|
||||||
|
*
|
||||||
|
* @typedef {Object} RecomputeReport
|
||||||
|
* @property {string} module
|
||||||
|
* @property {'month'|'year'|'all'} grain
|
||||||
|
* @property {string} period
|
||||||
|
* @property {string[]} touchedTables
|
||||||
|
* @property {Record<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 };
|
||||||
57
server/src/precompute/service.js
Normal file
57
server/src/precompute/service.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { MODULE_REGISTRY } = require('@profile/shared');
|
||||||
|
const registry = require('./registry');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入即算入口(§5.1 阶段 2):月度重算 + 年度重算 + 表级 bumpVersion。
|
||||||
|
* @param {string} module
|
||||||
|
* @param {number} year
|
||||||
|
* @param {number} month
|
||||||
|
*/
|
||||||
|
async function recomputePeriod(module, year, month, includeRaw = false) {
|
||||||
|
const impl = registry.get(module);
|
||||||
|
const descriptor = MODULE_REGISTRY.find((item) => item.key === module);
|
||||||
|
const monthReport = await impl.recomputeMonth(year, month);
|
||||||
|
const yearReport = await impl.recomputeYear(year);
|
||||||
|
|
||||||
|
const touched = [
|
||||||
|
...new Set([
|
||||||
|
includeRaw ? descriptor?.rawTable : null,
|
||||||
|
...monthReport.touchedTables,
|
||||||
|
...yearReport.touchedTables,
|
||||||
|
].filter(Boolean)),
|
||||||
|
];
|
||||||
|
await sync.bumpVersions(touched);
|
||||||
|
|
||||||
|
return {
|
||||||
|
module,
|
||||||
|
grain: 'month',
|
||||||
|
period: monthReport.period,
|
||||||
|
touchedTables: touched,
|
||||||
|
rowCounts: { ...monthReport.rowCounts, ...yearReport.rowCounts },
|
||||||
|
durationMs: monthReport.durationMs + yearReport.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadComplete(module, year, month) {
|
||||||
|
return recomputePeriod(module, year, month, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeAll(module) {
|
||||||
|
const impl = registry.get(module);
|
||||||
|
const report = await impl.recomputeAll();
|
||||||
|
await sync.bumpVersions(report.touchedTables);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEmployeesChanged() {
|
||||||
|
const reports = [];
|
||||||
|
for (const module of registry.registered()) {
|
||||||
|
reports.push(await recomputeAll(module));
|
||||||
|
}
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { onUploadComplete, recomputePeriod, recomputeAll, onEmployeesChanged };
|
||||||
96
server/src/rbac/rbac.js
Normal file
96
server/src/rbac/rbac.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const CACHE_TTL_SEC = 300; // 5min(migration-plan §10.5)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查用户角色 code 列表
|
||||||
|
* @param {number} userId
|
||||||
|
* @returns {Promise<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 缓存 5min,Redis 不可用则降级直查。
|
||||||
|
* @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 };
|
||||||
733
server/src/stats/stats.routes.js
Normal file
733
server/src/stats/stats.routes.js
Normal file
@ -0,0 +1,733 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
const practicalIncremental = require('../upload/practical-incremental');
|
||||||
|
const kilometersImport = require('../upload/kilometers-import');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const practicalCategoryExpr = `CASE
|
||||||
|
WHEN UPPER(LEFT(TRIM(COALESCE(p.name, '')), 1)) = 'S' THEN '车辆排故'
|
||||||
|
WHEN UPPER(LEFT(TRIM(COALESCE(p.name, '')), 1)) = 'T' THEN '通号排故'
|
||||||
|
WHEN UPPER(LEFT(TRIM(COALESCE(p.name, '')), 1)) IN ('J', 'Z') THEN '基础操作'
|
||||||
|
ELSE '其他'
|
||||||
|
END`;
|
||||||
|
const practicalDeviceCategoryExpr = `COALESCE(
|
||||||
|
NULLIF(REPLACE(JSON_UNQUOTE(JSON_EXTRACT(px.details, '$.deviceType')), '车型', ''), ''),
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(p.name) LIKE '%11A01%' THEN '11A01'
|
||||||
|
WHEN UPPER(p.name) LIKE '%11A02%' THEN '11A02'
|
||||||
|
WHEN UPPER(p.name) LIKE '%11A03%' THEN '11A03'
|
||||||
|
ELSE '未分车型'
|
||||||
|
END
|
||||||
|
)`;
|
||||||
|
const PRACTICAL_TEAMS = ['乘务一组', '乘务二组', '乘务三组', '乘务四组', '乘务五组'];
|
||||||
|
const PRACTICAL_RADAR_AXES = ['车辆排故', '通号排故', '基础操作', '11A01', '11A02', '11A03'];
|
||||||
|
|
||||||
|
function normalizePracticalCategoryRow(row, categoryGroup) {
|
||||||
|
const passRate = Number(row.pass_rate);
|
||||||
|
let scoreRate = Number(row.score_rate);
|
||||||
|
if (!Number.isFinite(scoreRate) || scoreRate < 0) {
|
||||||
|
scoreRate = Number.isFinite(passRate) ? passRate : null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
category_group: categoryGroup,
|
||||||
|
pass_rate: Number.isFinite(passRate) ? passRate : null,
|
||||||
|
score_rate: scoreRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPracticalCategoryRates({ year, month, isMonthly, team }) {
|
||||||
|
const periodParams = [year, ...(isMonthly ? [month] : []), ...(team ? [team] : [])];
|
||||||
|
// 得分率只统计 score≥0;排除无法归入三大类的「其他」
|
||||||
|
const scoreRateExpr = 'AVG(CASE WHEN px.score IS NOT NULL AND px.score >= 0 THEN px.score END) / 100';
|
||||||
|
const [paperRows, deviceRows] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT ${practicalCategoryExpr} AS paper_category,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(px.result = 'pass') AS pass_count,
|
||||||
|
SUM(px.result = 'fail') AS fail_count,
|
||||||
|
SUM(px.result = 'pass') / NULLIF(COUNT(*), 0) AS pass_rate,
|
||||||
|
${scoreRateExpr} AS score_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN papers p ON p.id = px.paper_id
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
${team ? 'AND e.team = ?' : ''}
|
||||||
|
AND px.paper_id IS NOT NULL
|
||||||
|
AND ${practicalCategoryExpr} IN ('车辆排故', '通号排故', '基础操作')
|
||||||
|
GROUP BY paper_category
|
||||||
|
ORDER BY score_rate ASC, total_count DESC`,
|
||||||
|
periodParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT ${practicalDeviceCategoryExpr} AS paper_category,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(px.result = 'pass') AS pass_count,
|
||||||
|
SUM(px.result = 'fail') AS fail_count,
|
||||||
|
SUM(px.result = 'pass') / NULLIF(COUNT(*), 0) AS pass_rate,
|
||||||
|
${scoreRateExpr} AS score_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN papers p ON p.id = px.paper_id
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
${team ? 'AND e.team = ?' : ''}
|
||||||
|
AND ${practicalDeviceCategoryExpr} IN ('11A01', '11A02', '11A03')
|
||||||
|
GROUP BY paper_category
|
||||||
|
ORDER BY
|
||||||
|
FIELD(paper_category, '11A01', '11A02', '11A03'),
|
||||||
|
score_rate ASC`,
|
||||||
|
periodParams,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
...paperRows.map((row) => normalizePracticalCategoryRow(row, 'paper')),
|
||||||
|
...deviceRows.map((row) => normalizePracticalCategoryRow(row, 'device')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPracticalRadarSeries({ year, month, isMonthly, team }) {
|
||||||
|
const seriesKeys = team ? [team, null] : PRACTICAL_TEAMS;
|
||||||
|
const periodParams = [year, ...(isMonthly ? [month] : [])];
|
||||||
|
const scoreRateExpr = 'AVG(CASE WHEN px.score IS NOT NULL AND px.score >= 0 THEN px.score END) / 100';
|
||||||
|
const [paperByTeam, deviceByTeam, allPaper, allDevice] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT COALESCE(e.team, '未分组') AS team,
|
||||||
|
${practicalCategoryExpr} AS paper_category,
|
||||||
|
${scoreRateExpr} AS score_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN papers p ON p.id = px.paper_id
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
AND px.paper_id IS NOT NULL
|
||||||
|
AND ${practicalCategoryExpr} IN ('车辆排故', '通号排故', '基础操作')
|
||||||
|
GROUP BY COALESCE(e.team, '未分组'), paper_category`,
|
||||||
|
periodParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT COALESCE(e.team, '未分组') AS team,
|
||||||
|
${practicalDeviceCategoryExpr} AS paper_category,
|
||||||
|
${scoreRateExpr} AS score_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN papers p ON p.id = px.paper_id
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
AND ${practicalDeviceCategoryExpr} IN ('11A01', '11A02', '11A03')
|
||||||
|
GROUP BY COALESCE(e.team, '未分组'), paper_category`,
|
||||||
|
periodParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT ${practicalCategoryExpr} AS paper_category,
|
||||||
|
${scoreRateExpr} AS score_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN papers p ON p.id = px.paper_id
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
AND px.paper_id IS NOT NULL
|
||||||
|
AND ${practicalCategoryExpr} IN ('车辆排故', '通号排故', '基础操作')
|
||||||
|
GROUP BY paper_category`,
|
||||||
|
periodParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT ${practicalDeviceCategoryExpr} AS paper_category,
|
||||||
|
${scoreRateExpr} AS score_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN papers p ON p.id = px.paper_id
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
AND ${practicalDeviceCategoryExpr} IN ('11A01', '11A02', '11A03')
|
||||||
|
GROUP BY paper_category`,
|
||||||
|
periodParams,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const teamMaps = new Map();
|
||||||
|
for (const row of [...paperByTeam, ...deviceByTeam]) {
|
||||||
|
if (!teamMaps.has(row.team)) teamMaps.set(row.team, new Map());
|
||||||
|
teamMaps.get(row.team).set(String(row.paper_category), Number(row.score_rate));
|
||||||
|
}
|
||||||
|
const allMap = new Map();
|
||||||
|
for (const row of [...allPaper, ...allDevice]) {
|
||||||
|
allMap.set(String(row.paper_category), Number(row.score_rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
const toValues = (rateMap) => PRACTICAL_RADAR_AXES.map((axis) => {
|
||||||
|
const value = rateMap?.get(axis);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: PRACTICAL_RADAR_AXES,
|
||||||
|
series: seriesKeys.map((key) => ({
|
||||||
|
team: key || '全部班组',
|
||||||
|
values: toValues(key ? teamMaps.get(key) : allMap),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/kilometers-summary',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('kilometers', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await kilometersImport.ensureSchema();
|
||||||
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
|
const month = req.query.month == null || req.query.month === ''
|
||||||
|
? null
|
||||||
|
: Number(req.query.month);
|
||||||
|
const team = req.query.team ? String(req.query.team) : null;
|
||||||
|
const employeeId = req.query.employeeId ? String(req.query.employeeId) : null;
|
||||||
|
const isMonthly = month != null;
|
||||||
|
let [overview] = isMonthly
|
||||||
|
? await db.query(
|
||||||
|
`SELECT total_kilometers, driver_count AS unique_employees,
|
||||||
|
avg_kilometers AS average_kilometers, max_kilometers
|
||||||
|
FROM kilometers_stats_overall_monthly
|
||||||
|
WHERE year = ? AND month = ? LIMIT 1`,
|
||||||
|
[year, month],
|
||||||
|
)
|
||||||
|
: await db.query(
|
||||||
|
`SELECT total_kilometers, driver_count AS unique_employees,
|
||||||
|
avg_per_person_month AS average_kilometers,
|
||||||
|
avg_annual_per_driver, avg_per_person_month,
|
||||||
|
total_kilometers AS max_kilometers
|
||||||
|
FROM kilometers_stats_overall_yearly
|
||||||
|
WHERE year = ? LIMIT 1`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
const teams = isMonthly
|
||||||
|
? await db.query(
|
||||||
|
`SELECT team, total_kilometers, driver_count,
|
||||||
|
avg_kilometers, max_kilometers, min_kilometers
|
||||||
|
FROM kilometers_stats_team_monthly
|
||||||
|
WHERE year = ? AND month = ? ${team ? 'AND team = ?' : ''}
|
||||||
|
ORDER BY team`,
|
||||||
|
[year, month, ...(team ? [team] : [])],
|
||||||
|
)
|
||||||
|
: await db.query(
|
||||||
|
`SELECT team, total_kilometers, driver_count, avg_kilometers,
|
||||||
|
avg_annual_per_driver, avg_per_person_month
|
||||||
|
FROM kilometers_stats_team_yearly
|
||||||
|
WHERE year = ? ${team ? 'AND team = ?' : ''}
|
||||||
|
ORDER BY team`,
|
||||||
|
[year, ...(team ? [team] : [])],
|
||||||
|
);
|
||||||
|
if (team && teams[0]) {
|
||||||
|
overview = {
|
||||||
|
...teams[0],
|
||||||
|
unique_employees: teams[0].driver_count,
|
||||||
|
average_kilometers: isMonthly
|
||||||
|
? teams[0].avg_kilometers
|
||||||
|
: teams[0].avg_per_person_month,
|
||||||
|
max_kilometers: teams[0].max_kilometers || teams[0].total_kilometers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const anomalies = await db.query(
|
||||||
|
`SELECT month, employee_id, employee_name, team, kilometers, center_line,
|
||||||
|
warning_lower, warning_upper, control_lower, control_upper,
|
||||||
|
warning_side, consecutive_warning, limit_breach, anomaly_reason
|
||||||
|
FROM kilometers_control_stats_monthly
|
||||||
|
WHERE year = ? ${isMonthly ? 'AND month = ?' : ''}
|
||||||
|
${team ? 'AND team = ?' : ''} AND is_anomaly = 1
|
||||||
|
ORDER BY month, team, employee_name`,
|
||||||
|
[year, ...(isMonthly ? [month] : []), ...(team ? [team] : [])],
|
||||||
|
);
|
||||||
|
const controls = await db.query(
|
||||||
|
`SELECT month, employee_id, baseline_year, baseline_month, center_line,
|
||||||
|
is_anomaly, anomaly_reason
|
||||||
|
FROM kilometers_control_stats_monthly
|
||||||
|
WHERE year = ? ${isMonthly ? 'AND month = ?' : ''}
|
||||||
|
${team ? 'AND team = ?' : ''}`,
|
||||||
|
[year, ...(isMonthly ? [month] : []), ...(team ? [team] : [])],
|
||||||
|
);
|
||||||
|
const controlChart = employeeId
|
||||||
|
? await db.query(
|
||||||
|
`SELECT c.year, c.month, c.employee_id, c.employee_name, c.team, c.kilometers,
|
||||||
|
c.center_line, c.warning_lower, c.warning_upper, c.control_lower,
|
||||||
|
c.control_upper, c.is_anomaly, c.anomaly_reason,
|
||||||
|
t.avg_kilometers AS team_average,
|
||||||
|
COALESCE(k.has_subsidy, 0) AS has_subsidy
|
||||||
|
FROM kilometers_control_stats_monthly c
|
||||||
|
LEFT JOIN kilometers_stats_team_monthly t
|
||||||
|
ON t.year = c.year AND t.month = c.month AND t.team = c.team
|
||||||
|
LEFT JOIN kilometers_records k
|
||||||
|
ON k.year = c.year AND k.month = c.month AND k.employee_id = c.employee_id
|
||||||
|
WHERE c.year = ? AND c.employee_id = ? ORDER BY c.month`,
|
||||||
|
[year, employeeId],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
// 总览折线:全年各月平均公里数 + 异常人数(不受 month 筛选影响)
|
||||||
|
const monthlyAvgRows = team
|
||||||
|
? await db.query(
|
||||||
|
`SELECT month, avg_kilometers AS team_average
|
||||||
|
FROM kilometers_stats_team_monthly
|
||||||
|
WHERE year = ? AND team = ?
|
||||||
|
ORDER BY month`,
|
||||||
|
[year, team],
|
||||||
|
)
|
||||||
|
: await db.query(
|
||||||
|
`SELECT month, avg_kilometers AS team_average
|
||||||
|
FROM kilometers_stats_overall_monthly
|
||||||
|
WHERE year = ?
|
||||||
|
ORDER BY month`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
const monthlyAnomalyRows = await db.query(
|
||||||
|
`SELECT month, COUNT(*) AS anomaly_count
|
||||||
|
FROM kilometers_control_stats_monthly
|
||||||
|
WHERE year = ? AND is_anomaly = 1 ${team ? 'AND team = ?' : ''}
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month`,
|
||||||
|
[year, ...(team ? [team] : [])],
|
||||||
|
);
|
||||||
|
const avgByMonth = new Map(
|
||||||
|
monthlyAvgRows.map((row) => [Number(row.month), Number(row.team_average)]),
|
||||||
|
);
|
||||||
|
const anomalyByMonth = new Map(
|
||||||
|
monthlyAnomalyRows.map((row) => [Number(row.month), Number(row.anomaly_count) || 0]),
|
||||||
|
);
|
||||||
|
const monthlyTrend = Array.from({ length: 12 }, (_, index) => {
|
||||||
|
const m = index + 1;
|
||||||
|
const avg = avgByMonth.get(m);
|
||||||
|
return {
|
||||||
|
month: m,
|
||||||
|
teamAverage: avg == null || Number.isNaN(avg) ? null : avg,
|
||||||
|
anomalyCount: anomalyByMonth.get(m) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const row = overview || {};
|
||||||
|
ok(res, {
|
||||||
|
totalKilometers: Number(row.total_kilometers) || 0,
|
||||||
|
uniqueEmployees: Number(row.unique_employees) || 0,
|
||||||
|
averageKilometers: Number(row.average_kilometers) || 0,
|
||||||
|
maxKilometers: Number(row.max_kilometers) || 0,
|
||||||
|
avgAnnualPerDriver: Number(row.avg_annual_per_driver) || 0,
|
||||||
|
avgPerPersonMonth: Number(row.avg_per_person_month) || 0,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
team,
|
||||||
|
teams,
|
||||||
|
anomalies,
|
||||||
|
controls,
|
||||||
|
controlChart,
|
||||||
|
monthlyTrend,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/practical-summary',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('practical_exams', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await practicalIncremental.ensureSchema();
|
||||||
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
|
const month = req.query.month == null || req.query.month === ''
|
||||||
|
? null
|
||||||
|
: Number(req.query.month);
|
||||||
|
const team = req.query.team ? String(req.query.team) : null;
|
||||||
|
const mode = String(req.query.mode || '').trim();
|
||||||
|
const isMonthly = month != null;
|
||||||
|
const scopeTable = isMonthly
|
||||||
|
? 'practical_exam_scope_stats_monthly'
|
||||||
|
: 'practical_exam_scope_stats_yearly';
|
||||||
|
const personTable = isMonthly
|
||||||
|
? 'practical_exam_person_stats_monthly'
|
||||||
|
: 'practical_exam_person_stats_yearly';
|
||||||
|
const periodWhere = isMonthly ? 'year = ? AND month = ?' : 'year = ?';
|
||||||
|
const periodParams = isMonthly ? [year, month] : [year];
|
||||||
|
const emptyOverview = {
|
||||||
|
total_count: 0,
|
||||||
|
pass_count: 0,
|
||||||
|
fail_count: 0,
|
||||||
|
pass_rate: null,
|
||||||
|
participant_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 个人弹窗班组对比:只返回 overview + categories,避开弱项/步骤重查询
|
||||||
|
if (mode === 'teamCompare') {
|
||||||
|
const [[overview], categories] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT total_count, pass_count, fail_count, pass_rate, participant_count
|
||||||
|
FROM ${scopeTable}
|
||||||
|
WHERE ${periodWhere} AND scope_key = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[...periodParams, team ? `team:${team}` : 'all'],
|
||||||
|
),
|
||||||
|
queryPracticalCategoryRates({ year, month, isMonthly, team }),
|
||||||
|
]);
|
||||||
|
ok(res, {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
team,
|
||||||
|
mode,
|
||||||
|
overview: overview || emptyOverview,
|
||||||
|
categories,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnosisTeam = team || '全部';
|
||||||
|
const [
|
||||||
|
overviewRows,
|
||||||
|
teams,
|
||||||
|
people,
|
||||||
|
mergedCategories,
|
||||||
|
categoryRadar,
|
||||||
|
weakPapers,
|
||||||
|
weakSteps,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT total_count, pass_count, fail_count, pass_rate, participant_count
|
||||||
|
FROM ${scopeTable}
|
||||||
|
WHERE ${periodWhere} AND scope_key = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[...periodParams, team ? `team:${team}` : 'all'],
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT team, total_count, pass_count, fail_count, pass_rate, participant_count
|
||||||
|
FROM ${scopeTable}
|
||||||
|
WHERE ${periodWhere} AND scope_type = 'team' ${team ? 'AND team = ?' : ''}
|
||||||
|
ORDER BY pass_rate ASC, total_count DESC`,
|
||||||
|
[...periodParams, ...(team ? [team] : [])],
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT employee_id, employee_name, team, total_count, pass_count, fail_count, pass_rate
|
||||||
|
FROM ${personTable}
|
||||||
|
WHERE ${periodWhere} ${team ? 'AND team = ?' : ''} AND pass_rate < 0.8
|
||||||
|
ORDER BY pass_rate ASC, total_count DESC, employee_id
|
||||||
|
LIMIT 500`,
|
||||||
|
[...periodParams, ...(team ? [team] : [])],
|
||||||
|
),
|
||||||
|
queryPracticalCategoryRates({ year, month, isMonthly, team }),
|
||||||
|
queryPracticalRadarSeries({ year, month, isMonthly, team }),
|
||||||
|
db.query(
|
||||||
|
`SELECT s.paper_id, p.name AS paper_name,
|
||||||
|
${practicalCategoryExpr} AS paper_category,
|
||||||
|
s.total_count, s.pass_count,
|
||||||
|
s.total_count - s.pass_count AS fail_count, s.pass_rate
|
||||||
|
FROM ${isMonthly ? 'practical_exam_stats_monthly' : 'practical_exam_stats_yearly'} s
|
||||||
|
LEFT JOIN papers p ON p.id = s.paper_id
|
||||||
|
WHERE s.year = ? ${isMonthly ? 'AND s.month = ?' : ''} AND s.team = ?
|
||||||
|
ORDER BY s.pass_rate ASC, s.total_count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
[year, ...(isMonthly ? [month] : []), diagnosisTeam],
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
isMonthly
|
||||||
|
? `SELECT s.paper_id, p.name AS paper_name,
|
||||||
|
${practicalCategoryExpr} AS paper_category,
|
||||||
|
s.scenario_index, s.step_index,
|
||||||
|
s.total_count, s.error_count,
|
||||||
|
s.error_rate, s.top3_errors
|
||||||
|
FROM practical_exam_step_stats_monthly s
|
||||||
|
LEFT JOIN papers p ON p.id = s.paper_id
|
||||||
|
WHERE s.year = ? AND s.month = ? AND s.team = ?
|
||||||
|
AND s.error_count > 0 AND s.total_count >= 10
|
||||||
|
ORDER BY s.error_rate DESC, s.error_count DESC
|
||||||
|
LIMIT 10`
|
||||||
|
: `SELECT s.paper_id, p.name AS paper_name,
|
||||||
|
${practicalCategoryExpr} AS paper_category,
|
||||||
|
s.scenario_index, s.step_index,
|
||||||
|
SUM(s.total_count) AS total_count, SUM(s.error_count) AS error_count,
|
||||||
|
SUM(s.error_count) / NULLIF(SUM(s.total_count), 0) AS error_rate,
|
||||||
|
NULL AS top3_errors
|
||||||
|
FROM practical_exam_step_stats_monthly s
|
||||||
|
LEFT JOIN papers p ON p.id = s.paper_id
|
||||||
|
WHERE s.year = ? AND s.team = ? AND s.error_count > 0
|
||||||
|
GROUP BY s.paper_id, p.name, paper_category, s.scenario_index, s.step_index
|
||||||
|
HAVING SUM(s.total_count) >= 10
|
||||||
|
ORDER BY error_rate DESC, error_count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
[year, ...(isMonthly ? [month] : []), diagnosisTeam],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overview = overviewRows[0] || emptyOverview;
|
||||||
|
const weakPaperIds = weakPapers.map((row) => Number(row.paper_id)).filter(Number.isFinite);
|
||||||
|
|
||||||
|
// 仅对 Top10 弱项补车型 / 失败人员,避免相关子查询与步骤表全扫
|
||||||
|
let deviceByPaper = new Map();
|
||||||
|
let weakPaperPeople = [];
|
||||||
|
if (weakPaperIds.length) {
|
||||||
|
const placeholders = weakPaperIds.map(() => '?').join(',');
|
||||||
|
const [deviceRows, peopleRows] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT paper_id,
|
||||||
|
MAX(JSON_UNQUOTE(JSON_EXTRACT(details, '$.deviceType'))) AS device_type
|
||||||
|
FROM practical_exams
|
||||||
|
WHERE paper_id IN (${placeholders})
|
||||||
|
GROUP BY paper_id`,
|
||||||
|
weakPaperIds,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT px.paper_id, px.employee_id,
|
||||||
|
MAX(e.internal_employee_id) AS work_card_id,
|
||||||
|
MAX(COALESCE(NULLIF(e.name, ''),
|
||||||
|
JSON_UNQUOTE(JSON_EXTRACT(px.details, '$.employeeName')))) AS employee_name,
|
||||||
|
MAX(COALESCE(NULLIF(e.team, ''), '未分组')) AS team,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(px.result = 'fail') AS fail_count,
|
||||||
|
SUM(px.result = 'pass') / NULLIF(COUNT(*), 0) AS pass_rate
|
||||||
|
FROM practical_exams px
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = px.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE px.exam_year = ? ${isMonthly ? 'AND px.exam_month = ?' : ''}
|
||||||
|
${team ? 'AND e.team = ?' : ''}
|
||||||
|
AND px.paper_id IN (${placeholders})
|
||||||
|
GROUP BY px.paper_id, px.employee_id
|
||||||
|
HAVING SUM(px.result = 'fail') > 0
|
||||||
|
ORDER BY fail_count DESC, employee_name, px.employee_id`,
|
||||||
|
[year, ...(isMonthly ? [month] : []), ...(team ? [team] : []), ...weakPaperIds],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
deviceByPaper = new Map(
|
||||||
|
deviceRows.map((row) => [Number(row.paper_id), row.device_type || null]),
|
||||||
|
);
|
||||||
|
weakPaperPeople = peopleRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedWeakPapers = weakPapers.map((row) => ({
|
||||||
|
...row,
|
||||||
|
device_type: deviceByPaper.get(Number(row.paper_id)) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
team,
|
||||||
|
overview,
|
||||||
|
teams,
|
||||||
|
categories: mergedCategories,
|
||||||
|
categoryRadar,
|
||||||
|
people,
|
||||||
|
weakPapers: enrichedWeakPapers,
|
||||||
|
weakPaperPeople,
|
||||||
|
// 步骤详情改用预计算 top3_errors,不再扫 practical_exam_steps
|
||||||
|
weakSteps,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/online-exam-summary',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('online_exams', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
|
const month = req.query.month == null || req.query.month === ''
|
||||||
|
? null
|
||||||
|
: Number(req.query.month);
|
||||||
|
const team = req.query.team ? String(req.query.team) : null;
|
||||||
|
const isMonthly = month != null && Number.isInteger(month);
|
||||||
|
const examClauses = ['o.exam_year = ?'];
|
||||||
|
const examParams = [year];
|
||||||
|
if (isMonthly) {
|
||||||
|
examClauses.push('o.exam_month = ?');
|
||||||
|
examParams.push(month);
|
||||||
|
}
|
||||||
|
if (team) {
|
||||||
|
examClauses.push('e.team = ?');
|
||||||
|
examParams.push(team);
|
||||||
|
}
|
||||||
|
const examWhere = examClauses.join(' AND ');
|
||||||
|
|
||||||
|
const missingClauses = ['LEFT(m.missing_month, 4) = ?'];
|
||||||
|
const missingParams = [String(year)];
|
||||||
|
if (isMonthly) {
|
||||||
|
missingClauses.push('CAST(SUBSTRING(m.missing_month, 6, 2) AS UNSIGNED) = ?');
|
||||||
|
missingParams.push(month);
|
||||||
|
}
|
||||||
|
if (team) {
|
||||||
|
missingClauses.push('COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, "$.team"))) = ?');
|
||||||
|
missingParams.push(team);
|
||||||
|
}
|
||||||
|
const missingWhere = missingClauses.join(' AND ');
|
||||||
|
|
||||||
|
// 独立查询并行,缩短总览弹窗等待
|
||||||
|
const [
|
||||||
|
teams,
|
||||||
|
missingByTeam,
|
||||||
|
below80People,
|
||||||
|
missingPeople,
|
||||||
|
weakQuestions,
|
||||||
|
categories,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.query(
|
||||||
|
`SELECT COALESCE(e.team, '未分组') AS team,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(CASE WHEN o.result = 'pass' OR COALESCE(o.score, 0) >= 60 THEN 1 ELSE 0 END) AS pass_count,
|
||||||
|
SUM(CASE WHEN o.result = 'fail' OR COALESCE(o.score, 0) < 60 THEN 1 ELSE 0 END) AS fail_count,
|
||||||
|
ROUND(AVG(o.score), 2) AS avg_score,
|
||||||
|
ROUND(
|
||||||
|
SUM(CASE WHEN o.result = 'pass' OR COALESCE(o.score, 0) >= 60 THEN 1 ELSE 0 END)
|
||||||
|
/ NULLIF(COUNT(*), 0),
|
||||||
|
4
|
||||||
|
) AS pass_rate,
|
||||||
|
SUM(CASE WHEN o.result = 'fail' OR COALESCE(o.score, 0) < 80 THEN 1 ELSE 0 END) AS below_80_count
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE ${examWhere}
|
||||||
|
GROUP BY COALESCE(e.team, '未分组')
|
||||||
|
ORDER BY team`,
|
||||||
|
examParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.team')), '未分组') AS team,
|
||||||
|
COUNT(*) AS missing_count
|
||||||
|
FROM missing_exams m
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = m.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE ${missingWhere}
|
||||||
|
GROUP BY COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.team')), '未分组')`,
|
||||||
|
missingParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT o.employee_id, COALESCE(e.name, o.employee_id) AS employee_name,
|
||||||
|
COALESCE(e.team, '未分组') AS team, o.exam_month, o.score, o.result
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE ${examWhere}
|
||||||
|
AND (o.result = 'fail' OR COALESCE(o.score, 0) < 80)
|
||||||
|
ORDER BY o.exam_month, o.score, e.team`,
|
||||||
|
examParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT m.employee_id,
|
||||||
|
COALESCE(e.name, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.name')), m.employee_id) AS employee_name,
|
||||||
|
COALESCE(e.team, JSON_UNQUOTE(JSON_EXTRACT(m.details, '$.team')), '未分组') AS team,
|
||||||
|
CAST(SUBSTRING(m.missing_month, 6, 2) AS UNSIGNED) AS exam_month,
|
||||||
|
m.reason
|
||||||
|
FROM missing_exams m
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = m.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE ${missingWhere}
|
||||||
|
ORDER BY m.missing_month, team, employee_name`,
|
||||||
|
missingParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED) AS question_id,
|
||||||
|
MAX(CAST(JSON_UNQUOTE(JSON_EXTRACT(a.val, '$.category')) AS CHAR)) AS category,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) = 0 THEN 1 ELSE 0 END) AS error_count,
|
||||||
|
ROUND(
|
||||||
|
SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) = 0 THEN 1 ELSE 0 END)
|
||||||
|
/ NULLIF(COUNT(*), 0),
|
||||||
|
4
|
||||||
|
) AS error_rate,
|
||||||
|
MAX(COALESCE(
|
||||||
|
JSON_UNQUOTE(JSON_EXTRACT(q.content, '$.question')),
|
||||||
|
JSON_UNQUOTE(JSON_EXTRACT(q.content, '$.stem')),
|
||||||
|
CONCAT('题目#', CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED))
|
||||||
|
)) AS question_text
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
JOIN JSON_TABLE(o.details, '$.answers[*]' COLUMNS (val JSON PATH '$')) a
|
||||||
|
LEFT JOIN question_banks q
|
||||||
|
ON q.id = CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED)
|
||||||
|
AND q.deleted_at IS NULL
|
||||||
|
WHERE ${examWhere}
|
||||||
|
AND JSON_EXTRACT(a.val, '$.questionId') IS NOT NULL
|
||||||
|
GROUP BY CAST(JSON_EXTRACT(a.val, '$.questionId') AS UNSIGNED)
|
||||||
|
HAVING total_count >= 5 AND error_rate > 0.3
|
||||||
|
ORDER BY error_rate DESC, error_count DESC`,
|
||||||
|
examParams,
|
||||||
|
),
|
||||||
|
db.query(
|
||||||
|
`SELECT COALESCE(
|
||||||
|
NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(a.val, '$.category')) AS CHAR), ''),
|
||||||
|
'未分类'
|
||||||
|
) AS category,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) > 0 THEN 1 ELSE 0 END) AS pass_count,
|
||||||
|
SUM(CASE WHEN CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)) = 0 THEN 1 ELSE 0 END) AS error_count,
|
||||||
|
ROUND(
|
||||||
|
SUM(CAST(JSON_EXTRACT(a.val, '$.score') AS DECIMAL(8,2)))
|
||||||
|
/ NULLIF(COUNT(*), 0),
|
||||||
|
4
|
||||||
|
) AS score_rate
|
||||||
|
FROM online_exams o
|
||||||
|
LEFT JOIN employees e
|
||||||
|
ON e.employee_id = o.employee_id AND e.deleted_at IS NULL AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
JOIN JSON_TABLE(o.details, '$.answers[*]' COLUMNS (val JSON PATH '$')) a
|
||||||
|
WHERE ${examWhere}
|
||||||
|
AND JSON_EXTRACT(a.val, '$.score') IS NOT NULL
|
||||||
|
GROUP BY COALESCE(
|
||||||
|
NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(a.val, '$.category')) AS CHAR), ''),
|
||||||
|
'未分类'
|
||||||
|
)
|
||||||
|
ORDER BY score_rate ASC, total_count DESC`,
|
||||||
|
examParams,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const missingMap = new Map(missingByTeam.map((row) => [row.team, Number(row.missing_count) || 0]));
|
||||||
|
const teamRows = teams.map((row) => ({
|
||||||
|
...row,
|
||||||
|
missing_count: missingMap.get(row.team) || 0,
|
||||||
|
}));
|
||||||
|
for (const [teamName, count] of missingMap.entries()) {
|
||||||
|
if (!teamRows.some((row) => row.team === teamName)) {
|
||||||
|
teamRows.push({
|
||||||
|
team: teamName,
|
||||||
|
total_count: 0,
|
||||||
|
pass_count: 0,
|
||||||
|
fail_count: 0,
|
||||||
|
avg_score: null,
|
||||||
|
pass_rate: null,
|
||||||
|
below_80_count: 0,
|
||||||
|
missing_count: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
teamRows.sort((a, b) => String(a.team).localeCompare(String(b.team), 'zh'));
|
||||||
|
|
||||||
|
const overview = teamRows.reduce((acc, row) => {
|
||||||
|
acc.total_count += Number(row.total_count) || 0;
|
||||||
|
acc.pass_count += Number(row.pass_count) || 0;
|
||||||
|
acc.fail_count += Number(row.fail_count) || 0;
|
||||||
|
acc.below_80_count += Number(row.below_80_count) || 0;
|
||||||
|
acc.missing_count += Number(row.missing_count) || 0;
|
||||||
|
acc.score_sum += (Number(row.avg_score) || 0) * (Number(row.total_count) || 0);
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
total_count: 0,
|
||||||
|
pass_count: 0,
|
||||||
|
fail_count: 0,
|
||||||
|
below_80_count: 0,
|
||||||
|
missing_count: 0,
|
||||||
|
score_sum: 0,
|
||||||
|
});
|
||||||
|
overview.avg_score = overview.total_count
|
||||||
|
? Math.round((overview.score_sum / overview.total_count) * 100) / 100
|
||||||
|
: null;
|
||||||
|
overview.pass_rate = overview.total_count
|
||||||
|
? Math.round((overview.pass_count / overview.total_count) * 10000) / 10000
|
||||||
|
: null;
|
||||||
|
delete overview.score_sum;
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
team,
|
||||||
|
overview,
|
||||||
|
teams: teamRows,
|
||||||
|
categories,
|
||||||
|
below80People,
|
||||||
|
missingPeople,
|
||||||
|
weakQuestions,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
134
server/src/sync/sync.js
Normal file
134
server/src/sync/sync.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { allSyncTables } = require('@profile/shared');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const crypto = require('../common/crypto');
|
||||||
|
const { getColumns, monthWhere } = require('../common/table-utils');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const WHITELIST = new Set(allSyncTables());
|
||||||
|
|
||||||
|
/** employees:密文列解密后以下发明文(开发决策:A4/员工列表需展示) */
|
||||||
|
const EMPLOYEE_PII_SYNC = [
|
||||||
|
{ plain: 'id_card', enc: 'id_card_enc' },
|
||||||
|
{ plain: 'mobile', enc: 'mobile_enc' },
|
||||||
|
{ plain: 'address', enc: 'address_enc' },
|
||||||
|
{ plain: 'emergency_contact', enc: 'emergency_contact_enc' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isSyncTable(name) {
|
||||||
|
return WHITELIST.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBuffer(value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (Buffer.isBuffer(value)) return value;
|
||||||
|
if (value instanceof Uint8Array) return Buffer.from(value);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// mysql2 偶发返回 hex / binary string
|
||||||
|
try {
|
||||||
|
return Buffer.from(value, 'binary');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptEmployeeRows(rows) {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const out = { ...row };
|
||||||
|
for (const field of EMPLOYEE_PII_SYNC) {
|
||||||
|
const buf = toBuffer(out[field.enc]);
|
||||||
|
if (buf && buf.length > 0) {
|
||||||
|
try {
|
||||||
|
out[field.plain] = crypto.decrypt(buf);
|
||||||
|
} catch {
|
||||||
|
out[field.plain] = null;
|
||||||
|
}
|
||||||
|
} else if (out[field.plain] == null) {
|
||||||
|
out[field.plain] = null;
|
||||||
|
}
|
||||||
|
delete out[field.enc];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各表当前 version(GET /sync/state);仅返回客户端镜像白名单内的表 */
|
||||||
|
async function getState() {
|
||||||
|
const rows = await db.query('SELECT table_name, version, updated_at FROM sync_table_versions ORDER BY table_name');
|
||||||
|
return rows
|
||||||
|
.filter((r) => isSyncTable(r.table_name))
|
||||||
|
.map((r) => ({ table: r.table_name, version: Number(r.version), updatedAt: r.updated_at }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整表/分片拉取(GET /sync/table/:name)。
|
||||||
|
* PII 密文列(*_enc / *_hmac)永不原样下发;employees 解密后下发明文列(id_card/mobile/address/emergency_contact)。
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number} [year]
|
||||||
|
* @param {number} [month]
|
||||||
|
*/
|
||||||
|
async function getTable(name, year, month) {
|
||||||
|
if (!WHITELIST.has(name)) {
|
||||||
|
throw new ApiError(API_CODES.NOT_FOUND, `表不在同步白名单:${name}`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = await getColumns(name);
|
||||||
|
if (columns.length === 0) {
|
||||||
|
throw new ApiError(API_CODES.NOT_FOUND, `表不存在:${name}`, 404);
|
||||||
|
}
|
||||||
|
const selected = columns.filter((c) => !c.endsWith('_enc') && !c.endsWith('_hmac'));
|
||||||
|
const encNeeded = name === 'employees'
|
||||||
|
? EMPLOYEE_PII_SYNC.map((f) => f.enc).filter((c) => columns.includes(c))
|
||||||
|
: [];
|
||||||
|
const selectCols = [...selected, ...encNeeded];
|
||||||
|
const selectSql = selectCols.map((c) => `\`${c}\``).join(', ');
|
||||||
|
|
||||||
|
let where = '';
|
||||||
|
let params = [];
|
||||||
|
if (year != null && month != null) {
|
||||||
|
// 字典表无月份维度 → clause 为空,忽略分片参数返回全量
|
||||||
|
const mw = monthWhere(columns, year, month);
|
||||||
|
if (mw.clause) {
|
||||||
|
where = `WHERE ${mw.clause}`;
|
||||||
|
params = mw.params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = await db.query(`SELECT ${selectSql} FROM \`${name}\` ${where}`, params);
|
||||||
|
let outColumns = selected;
|
||||||
|
if (name === 'employees') {
|
||||||
|
rows = decryptEmployeeRows(rows);
|
||||||
|
const plainCols = EMPLOYEE_PII_SYNC.map((f) => f.plain);
|
||||||
|
outColumns = [...selected.filter((c) => !plainCols.includes(c)), ...plainCols];
|
||||||
|
}
|
||||||
|
const verRows = await db.query('SELECT version FROM sync_table_versions WHERE table_name = ?', [name]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
table: name,
|
||||||
|
version: verRows[0] ? Number(verRows[0].version) : 0,
|
||||||
|
sliced: params.length > 0,
|
||||||
|
// 空表也必须返回列定义,sql.js 才能创建稳定的 cache_* 镜像。
|
||||||
|
columns: outColumns,
|
||||||
|
rowCount: rows.length,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预计算完成后调用(§5.2):表级 version +1。
|
||||||
|
* @param {string[]} tables
|
||||||
|
*/
|
||||||
|
async function bumpVersions(tables) {
|
||||||
|
for (const t of tables) {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO sync_table_versions (table_name, version) VALUES (?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE version = version + 1`,
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getState, getTable, bumpVersions, isSyncTable };
|
||||||
32
server/src/sync/sync.routes.js
Normal file
32
server/src/sync/sync.routes.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const sync = require('./sync');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/state',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('sync', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await sync.getState());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/table/:name',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('sync', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const year = req.query.year != null && req.query.year !== '' ? Number(req.query.year) : undefined;
|
||||||
|
const month = req.query.month != null && req.query.month !== '' ? Number(req.query.month) : undefined;
|
||||||
|
ok(res, await sync.getTable(req.params.name, year, month));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
71
server/src/system-settings/system-settings.routes.js
Normal file
71
server/src/system-settings/system-settings.routes.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const KNOWN_KEYS = ['manager_name', 'online_exam_responsible'];
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('dashboard', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN (?)`,
|
||||||
|
[KNOWN_KEYS],
|
||||||
|
);
|
||||||
|
const result = {};
|
||||||
|
for (const key of KNOWN_KEYS) result[key] = '';
|
||||||
|
for (const row of rows || []) {
|
||||||
|
result[row.setting_key] = row.setting_value == null ? '' : String(row.setting_value);
|
||||||
|
}
|
||||||
|
ok(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('users', ACTIONS.WRITE),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const updates = {};
|
||||||
|
for (const key of KNOWN_KEYS) {
|
||||||
|
if (body[key] != null) updates[key] = String(body[key]).slice(0, 255);
|
||||||
|
}
|
||||||
|
const changed = Object.keys(updates);
|
||||||
|
if (changed.length === 0) {
|
||||||
|
ok(res, { updated: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
for (const key of changed) {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
|
||||||
|
[key, updates[key]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await audit.log({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'update_system_settings',
|
||||||
|
module: 'users',
|
||||||
|
payload: updates,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
ok(res, { updated: changed.length, values: updates });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
498
server/src/upload/kilometers-import.js
Normal file
498
server/src/upload/kilometers-import.js
Normal file
@ -0,0 +1,498 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const dashboardSummary = require('../precompute/dashboard-summary');
|
||||||
|
const kilometersPrecompute = require('../precompute/impls/kilometers');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
let schemaReady = null;
|
||||||
|
|
||||||
|
async function hasColumn(table, column) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1`,
|
||||||
|
[table, column],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureColumn(table, column, ddl) {
|
||||||
|
if (!await hasColumn(table, column)) await db.execute(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSchema() {
|
||||||
|
if (schemaReady) return schemaReady;
|
||||||
|
schemaReady = (async () => {
|
||||||
|
await ensureColumn(
|
||||||
|
'kilometers_records',
|
||||||
|
'piecework_system',
|
||||||
|
'piecework_system DECIMAL(10,4) NOT NULL DEFAULT 0 AFTER kilometers',
|
||||||
|
);
|
||||||
|
await ensureColumn(
|
||||||
|
'kilometers_records',
|
||||||
|
'has_subsidy',
|
||||||
|
'has_subsidy TINYINT(1) NOT NULL DEFAULT 0 AFTER piecework_system',
|
||||||
|
);
|
||||||
|
await ensureColumn(
|
||||||
|
'kilometers_stats_employee_yearly',
|
||||||
|
'month_count',
|
||||||
|
'month_count INT NOT NULL DEFAULT 0 AFTER total_kilometers',
|
||||||
|
);
|
||||||
|
await ensureColumn(
|
||||||
|
'kilometers_stats_employee_yearly',
|
||||||
|
'avg_monthly_kilometers',
|
||||||
|
'avg_monthly_kilometers DECIMAL(12,2) NULL AFTER month_count',
|
||||||
|
);
|
||||||
|
await ensureColumn(
|
||||||
|
'kilometers_stats_team_yearly',
|
||||||
|
'avg_annual_per_driver',
|
||||||
|
'avg_annual_per_driver DECIMAL(12,2) NULL AFTER avg_kilometers',
|
||||||
|
);
|
||||||
|
await ensureColumn(
|
||||||
|
'kilometers_stats_team_yearly',
|
||||||
|
'avg_per_person_month',
|
||||||
|
'avg_per_person_month DECIMAL(12,2) NULL AFTER avg_annual_per_driver',
|
||||||
|
);
|
||||||
|
await db.execute(`CREATE TABLE IF NOT EXISTS kilometers_stats_overall_monthly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, month INT NOT NULL,
|
||||||
|
total_kilometers DECIMAL(14,2) NOT NULL DEFAULT 0, driver_count INT NOT NULL DEFAULT 0,
|
||||||
|
avg_kilometers DECIMAL(12,2) NULL, max_kilometers DECIMAL(12,2) NULL,
|
||||||
|
min_kilometers DECIMAL(12,2) NULL, computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_dim (year, month)
|
||||||
|
) ENGINE=InnoDB`);
|
||||||
|
await db.execute(`CREATE TABLE IF NOT EXISTS kilometers_stats_overall_yearly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL,
|
||||||
|
total_kilometers DECIMAL(14,2) NOT NULL DEFAULT 0, driver_count INT NOT NULL DEFAULT 0,
|
||||||
|
person_month_count INT NOT NULL DEFAULT 0, avg_annual_per_driver DECIMAL(12,2) NULL,
|
||||||
|
avg_per_person_month DECIMAL(12,2) NULL, monthly_breakdown JSON NULL,
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_dim (year)
|
||||||
|
) ENGINE=InnoDB`);
|
||||||
|
await db.execute(`CREATE TABLE IF NOT EXISTS kilometers_control_stats_monthly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, month INT NOT NULL,
|
||||||
|
employee_id VARCHAR(20) NOT NULL, employee_name VARCHAR(50) NULL,
|
||||||
|
team VARCHAR(50) NOT NULL, kilometers DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
baseline_year INT NULL, baseline_month INT NULL, center_line DECIMAL(12,2) NULL,
|
||||||
|
warning_lower DECIMAL(12,2) NULL, warning_upper DECIMAL(12,2) NULL,
|
||||||
|
control_lower DECIMAL(12,2) NULL, control_upper DECIMAL(12,2) NULL,
|
||||||
|
warning_side ENUM('low','high') NULL, consecutive_warning TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
limit_breach TINYINT(1) NOT NULL DEFAULT 0, is_anomaly TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
anomaly_reason VARCHAR(255) NULL, computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0, UNIQUE KEY uk_dim (year, month, employee_id),
|
||||||
|
KEY idx_anomaly (year, month, is_anomaly), KEY idx_employee_year (employee_id, year)
|
||||||
|
) ENGINE=InnoDB`);
|
||||||
|
})().catch((error) => {
|
||||||
|
schemaReady = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return schemaReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmployeeId(value) {
|
||||||
|
const digits = String(value ?? '').replace(/\D/g, '');
|
||||||
|
return digits && digits.length <= 5 ? digits.padStart(5, '0') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedName(value) {
|
||||||
|
return String(value ?? '').trim().replace(/\d+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFiles(files) {
|
||||||
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '没有可导入的公里数文件', 400);
|
||||||
|
}
|
||||||
|
const periods = new Set();
|
||||||
|
const normalized = [];
|
||||||
|
const validationErrors = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const year = Number(file.year);
|
||||||
|
const month = Number(file.month);
|
||||||
|
const period = `${year}-${month}`;
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
|
||||||
|
validationErrors.push(`${file.fileName || '未知文件'}:年月无效`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (periods.has(period)) {
|
||||||
|
validationErrors.push(`${file.fileName || '未知文件'}:存在重复月份 ${period}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
periods.add(period);
|
||||||
|
if (!Array.isArray(file.rows) || file.rows.length === 0) {
|
||||||
|
validationErrors.push(`${file.fileName || '未知文件'}:没有有效数据`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
for (const source of file.rows) {
|
||||||
|
const missingPiecework = source.pieceworkSystem == null
|
||||||
|
|| String(source.pieceworkSystem).trim() === '';
|
||||||
|
const missingKilometers = source.kilometers == null
|
||||||
|
|| String(source.kilometers).trim() === '';
|
||||||
|
const employeeId = normalizeEmployeeId(source.employeeId);
|
||||||
|
const employeeName = String(source.employeeName ?? '').trim();
|
||||||
|
const pieceworkSystem = Number(source.pieceworkSystem);
|
||||||
|
const kilometers = Number(source.kilometers);
|
||||||
|
const rowNumber = Number(source.rowNumber) || 0;
|
||||||
|
if (!/^\d{5}$/.test(employeeId) || !employeeName
|
||||||
|
|| missingPiecework || missingKilometers
|
||||||
|
|| !Number.isFinite(pieceworkSystem) || !Number.isFinite(kilometers)) {
|
||||||
|
validationErrors.push(
|
||||||
|
`${file.fileName || '未知文件'} 第${rowNumber || '?'}行:工号、姓名、计件制、公里数必须完整且格式正确`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(employeeId)) {
|
||||||
|
validationErrors.push(`${file.fileName || '未知文件'} 第${rowNumber || '?'}行:工号 ${employeeId} 重复`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(employeeId);
|
||||||
|
const hasSubsidy = source.hasSubsidy === true
|
||||||
|
|| source.hasSubsidy === 1
|
||||||
|
|| source.hasSubsidy === '1'
|
||||||
|
|| String(source.hasSubsidy || '').trim() === '是';
|
||||||
|
normalized.push({
|
||||||
|
fileName: file.fileName || '',
|
||||||
|
rowNumber,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
employeeId,
|
||||||
|
employeeName,
|
||||||
|
pieceworkSystem,
|
||||||
|
kilometers,
|
||||||
|
hasSubsidy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validationErrors.length) {
|
||||||
|
throw new ApiError(
|
||||||
|
API_CODES.UPLOAD_VALIDATE_FAILED,
|
||||||
|
`公里数导入校验失败:${validationErrors.slice(0, 20).join(';')}`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { rows: normalized, periods: [...periods] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function meanAndStd(values) {
|
||||||
|
if (!values.length) return null;
|
||||||
|
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
|
const variance = values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / values.length;
|
||||||
|
return { mean, stddev: Math.sqrt(variance) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeControlYear(year) {
|
||||||
|
await ensureSchema();
|
||||||
|
const fromYear = year - 1;
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT k.year, k.month, k.employee_id, MAX(e.name) AS employee_name,
|
||||||
|
MAX(e.team) AS team, SUM(k.kilometers) AS kilometers,
|
||||||
|
MAX(COALESCE(k.has_subsidy, 0)) AS has_subsidy
|
||||||
|
FROM kilometers_records k
|
||||||
|
INNER JOIN employees e
|
||||||
|
ON e.employee_id = k.employee_id AND e.deleted_at IS NULL
|
||||||
|
AND (e.employment_status = '在职' OR e.employment_status IS NULL)
|
||||||
|
WHERE ((k.year = ? AND k.month = 12) OR k.year = ?)
|
||||||
|
AND (e.position = '电客司机' OR e.position = '列车司机')
|
||||||
|
GROUP BY k.year, k.month, k.employee_id
|
||||||
|
ORDER BY k.year, k.month, k.employee_id`,
|
||||||
|
[fromYear, year],
|
||||||
|
);
|
||||||
|
const groupValues = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.year}-${row.month}|${row.team}`;
|
||||||
|
const values = groupValues.get(key) || [];
|
||||||
|
values.push(Number(row.kilometers) || 0);
|
||||||
|
groupValues.set(key, values);
|
||||||
|
}
|
||||||
|
// employee_id -> { side: 'high'|'low'|null, wasAnomaly: boolean }
|
||||||
|
// 同一侧连续偏离时,若上月已记异常,本月不再因「连续 ±1.5σ」重复记一次。
|
||||||
|
const previousWarnings = new Map();
|
||||||
|
const previousDecember = await db.query(
|
||||||
|
`SELECT employee_id, warning_side, is_anomaly
|
||||||
|
FROM kilometers_control_stats_monthly
|
||||||
|
WHERE year = ? AND month = 12`,
|
||||||
|
[year - 1],
|
||||||
|
);
|
||||||
|
for (const row of previousDecember) {
|
||||||
|
previousWarnings.set(String(row.employee_id), {
|
||||||
|
side: row.warning_side || null,
|
||||||
|
wasAnomaly: Number(row.is_anomaly) === 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const computed = [];
|
||||||
|
for (const row of rows.filter((item) => Number(item.year) === Number(year))) {
|
||||||
|
const month = Number(row.month);
|
||||||
|
const hasSubsidy = Number(row.has_subsidy) === 1;
|
||||||
|
let baselineYear = month === 1 ? year - 1 : year;
|
||||||
|
let baselineMonth = month === 1 ? 12 : month - 1;
|
||||||
|
const previousValues = groupValues.get(`${baselineYear}-${baselineMonth}|${row.team}`) || [];
|
||||||
|
let baseline = meanAndStd(previousValues);
|
||||||
|
const canJudge = Boolean(baseline && baseline.stddev > 0);
|
||||||
|
// 1 月没有上月基线时,仅用本月班组分布绘制参考线,不参与异常判定。
|
||||||
|
if (!canJudge && month === 1) {
|
||||||
|
baselineYear = year;
|
||||||
|
baselineMonth = 1;
|
||||||
|
baseline = meanAndStd(groupValues.get(`${year}-1|${row.team}`) || []);
|
||||||
|
}
|
||||||
|
let warningSide = null;
|
||||||
|
let consecutiveWarning = false;
|
||||||
|
let limitBreach = false;
|
||||||
|
let reason = null;
|
||||||
|
let lines = {};
|
||||||
|
if (baseline && baseline.stddev > 0) {
|
||||||
|
lines = {
|
||||||
|
centerLine: baseline.mean,
|
||||||
|
warningLower: Math.max(0, baseline.mean - (1.5 * baseline.stddev)),
|
||||||
|
warningUpper: baseline.mean + (1.5 * baseline.stddev),
|
||||||
|
controlLower: Math.max(0, baseline.mean - (2 * baseline.stddev)),
|
||||||
|
controlUpper: baseline.mean + (2 * baseline.stddev),
|
||||||
|
};
|
||||||
|
const mileage = Number(row.kilometers) || 0;
|
||||||
|
if (canJudge) {
|
||||||
|
warningSide = mileage < lines.warningLower ? 'low'
|
||||||
|
: (mileage > lines.warningUpper ? 'high' : null);
|
||||||
|
limitBreach = mileage < lines.controlLower || mileage > lines.controlUpper;
|
||||||
|
// 有休假补贴:不记偏低侧异常,也不把偏低 warning 传给下月
|
||||||
|
if (hasSubsidy && warningSide === 'low') {
|
||||||
|
warningSide = null;
|
||||||
|
if (mileage < lines.controlLower) limitBreach = false;
|
||||||
|
} else if (hasSubsidy && limitBreach && mileage < lines.controlLower) {
|
||||||
|
limitBreach = false;
|
||||||
|
}
|
||||||
|
const previous = previousWarnings.get(String(row.employee_id)) || {};
|
||||||
|
// 连续 ±1.5σ:仅当上月同侧偏离且上月尚未记为异常时,才记本月异常
|
||||||
|
// (避免「4 月 +2σ」后「5 月连续 +1.5σ」重复扣一次)
|
||||||
|
consecutiveWarning = Boolean(
|
||||||
|
warningSide
|
||||||
|
&& previous.side === warningSide
|
||||||
|
&& !previous.wasAnomaly,
|
||||||
|
);
|
||||||
|
if (limitBreach) reason = mileage < lines.controlLower ? '单月低于下限(-2σ)' : '单月高于上限(+2σ)';
|
||||||
|
else if (consecutiveWarning) reason = warningSide === 'low'
|
||||||
|
? '连续两个月低于控制线(-1.5σ)'
|
||||||
|
: '连续两个月高于控制线(+1.5σ)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isAnomaly = limitBreach || consecutiveWarning;
|
||||||
|
previousWarnings.set(String(row.employee_id), {
|
||||||
|
side: warningSide,
|
||||||
|
wasAnomaly: isAnomaly,
|
||||||
|
});
|
||||||
|
computed.push({
|
||||||
|
...row,
|
||||||
|
baselineYear: baseline ? baselineYear : null,
|
||||||
|
baselineMonth: baseline ? baselineMonth : null,
|
||||||
|
...lines,
|
||||||
|
warningSide,
|
||||||
|
consecutiveWarning,
|
||||||
|
limitBreach,
|
||||||
|
isAnomaly,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 算不出任何行时不要清空该年控制图(避免画像公里分全员变 0)
|
||||||
|
if (!computed.length) {
|
||||||
|
console.warn(
|
||||||
|
`[kilometers-control] year=${year} computed 0 rows `
|
||||||
|
+ `(joined source=${rows.length}); keep existing control stats`,
|
||||||
|
);
|
||||||
|
return { year, rows: 0, skippedWipe: true };
|
||||||
|
}
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute('DELETE FROM kilometers_control_stats_monthly WHERE year = ?', [year]);
|
||||||
|
for (let offset = 0; offset < computed.length; offset += 500) {
|
||||||
|
const chunk = computed.slice(offset, offset + 500);
|
||||||
|
if (!chunk.length) continue;
|
||||||
|
const placeholders = chunk.map(() => '(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),0)').join(',');
|
||||||
|
const values = chunk.flatMap((row) => [
|
||||||
|
year, row.month, row.employee_id, row.employee_name, row.team, row.kilometers,
|
||||||
|
row.baselineYear, row.baselineMonth, row.centerLine ?? null,
|
||||||
|
row.warningLower ?? null, row.warningUpper ?? null,
|
||||||
|
row.controlLower ?? null, row.controlUpper ?? null, row.warningSide,
|
||||||
|
row.consecutiveWarning ? 1 : 0, row.limitBreach ? 1 : 0,
|
||||||
|
row.isAnomaly ? 1 : 0, row.reason,
|
||||||
|
]);
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO kilometers_control_stats_monthly
|
||||||
|
(year, month, employee_id, employee_name, team, kilometers,
|
||||||
|
baseline_year, baseline_month, center_line, warning_lower, warning_upper,
|
||||||
|
control_lower, control_upper, warning_side, consecutive_warning,
|
||||||
|
limit_breach, is_anomaly, anomaly_reason, computed_at, version)
|
||||||
|
VALUES ${placeholders}`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { year, rows: computed.length, skippedWipe: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFiles({ files, operator, meta }) {
|
||||||
|
await ensureSchema();
|
||||||
|
const validated = validateFiles(files);
|
||||||
|
const employeeIds = [...new Set(validated.rows.map((row) => row.employeeId))];
|
||||||
|
const employees = employeeIds.length
|
||||||
|
? await db.query(
|
||||||
|
`SELECT employee_id, name, team, position
|
||||||
|
FROM employees
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (employment_status = '在职' OR employment_status IS NULL)
|
||||||
|
AND employee_id IN (${employeeIds.map(() => '?').join(',')})`,
|
||||||
|
employeeIds,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const employeeById = new Map(employees.map((row) => [String(row.employee_id), row]));
|
||||||
|
const skipped = [];
|
||||||
|
const nameMismatches = [];
|
||||||
|
const accepted = [];
|
||||||
|
for (const row of validated.rows) {
|
||||||
|
const employee = employeeById.get(row.employeeId);
|
||||||
|
if (!employee) {
|
||||||
|
skipped.push({
|
||||||
|
fileName: row.fileName,
|
||||||
|
rowNumber: row.rowNumber,
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
employeeName: row.employeeName,
|
||||||
|
reason: '花名册无此工号',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (normalizedName(employee.name) !== normalizedName(row.employeeName)) {
|
||||||
|
nameMismatches.push(
|
||||||
|
`${row.fileName} 第${row.rowNumber}行:${row.employeeId},Excel“${row.employeeName}”与花名册“${employee.name}”不一致`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.push({ ...row, employee });
|
||||||
|
}
|
||||||
|
if (nameMismatches.length) {
|
||||||
|
throw new ApiError(
|
||||||
|
API_CODES.UPLOAD_VALIDATE_FAILED,
|
||||||
|
`工号与姓名匹配失败,未导入任何数据:${nameMismatches.slice(0, 20).join(';')}`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!accepted.length) {
|
||||||
|
throw new ApiError(API_CODES.UPLOAD_VALIDATE_FAILED, '没有匹配花名册的有效公里数数据', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchUuid = randomUUID();
|
||||||
|
const periodPairs = validated.periods.map((period) => {
|
||||||
|
const [year, month] = period.split('-').map(Number);
|
||||||
|
return { year, month };
|
||||||
|
});
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO upload_batches
|
||||||
|
(batch_uuid, module, record_count, status, operator_id, payload, locked_at)
|
||||||
|
VALUES (?, 'kilometers', ?, 'lock-writing', ?, ?, NOW())`,
|
||||||
|
[batchUuid, accepted.length, operator.id, JSON.stringify({
|
||||||
|
files: files.map((file) => file.fileName),
|
||||||
|
skipped,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
// 批量计算责任人快照
|
||||||
|
const responsible = require('../common/responsible');
|
||||||
|
const respMap = await responsible.computeResponsiblePersonBatch(
|
||||||
|
accepted.map((r) => String(r.employeeId)),
|
||||||
|
{ conn },
|
||||||
|
);
|
||||||
|
for (const period of periodPairs) {
|
||||||
|
await conn.execute('DELETE FROM kilometers_records WHERE year = ? AND month = ?', [
|
||||||
|
period.year,
|
||||||
|
period.month,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < accepted.length; offset += 500) {
|
||||||
|
const chunk = accepted.slice(offset, offset + 500);
|
||||||
|
const placeholders = chunk.map(() => '(?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW(),0)').join(',');
|
||||||
|
const values = chunk.flatMap((row) => [
|
||||||
|
row.employeeId,
|
||||||
|
row.employee.name,
|
||||||
|
row.employee.team,
|
||||||
|
`${row.year}-${String(row.month).padStart(2, '0')}-01`,
|
||||||
|
row.year,
|
||||||
|
row.month,
|
||||||
|
row.kilometers,
|
||||||
|
row.pieceworkSystem,
|
||||||
|
row.hasSubsidy ? 1 : 0,
|
||||||
|
respMap[String(row.employeeId)] || '',
|
||||||
|
JSON.stringify({ sourceFile: row.fileName, sourceRow: row.rowNumber }),
|
||||||
|
null,
|
||||||
|
batchUuid,
|
||||||
|
]);
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO kilometers_records
|
||||||
|
(employee_id, employee_name, team, record_date, year, month, kilometers,
|
||||||
|
piecework_system, has_subsidy, responsible_person, details, extras, upload_batch,
|
||||||
|
created_at, updated_at, version)
|
||||||
|
VALUES ${placeholders}`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE upload_batches SET status = 'precomputing' WHERE batch_uuid = ?`,
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.bumpVersions(['kilometers_records']);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE upload_batches SET status = 'completed', completed_at = NOW()
|
||||||
|
WHERE batch_uuid = ?`,
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'kilometers_import_complete',
|
||||||
|
module: 'kilometers',
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: {
|
||||||
|
imported: accepted.length,
|
||||||
|
skipped: skipped.length,
|
||||||
|
periods: validated.periods,
|
||||||
|
},
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
batchUuid,
|
||||||
|
importedCount: accepted.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
skipped,
|
||||||
|
periods: validated.periods,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按库内已有公里年份全量重算控制图,并 bump 同步版本(供「计算画像分」等入口调用) */
|
||||||
|
async function recomputeAllControlYears(years = null) {
|
||||||
|
await ensureSchema();
|
||||||
|
let yearList = Array.isArray(years)
|
||||||
|
? [...new Set(years.map(Number).filter((y) => Number.isFinite(y)))]
|
||||||
|
: [];
|
||||||
|
if (!yearList.length) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT DISTINCT year FROM kilometers_records ORDER BY year`,
|
||||||
|
);
|
||||||
|
yearList = rows.map((row) => Number(row.year)).filter((y) => Number.isFinite(y));
|
||||||
|
}
|
||||||
|
for (const year of yearList) {
|
||||||
|
await recomputeControlYear(year);
|
||||||
|
}
|
||||||
|
if (yearList.length) {
|
||||||
|
await sync.bumpVersions(['kilometers_control_stats_monthly']);
|
||||||
|
}
|
||||||
|
return { years: yearList, recomputedYears: yearList.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureSchema,
|
||||||
|
importFiles,
|
||||||
|
recomputeControlYear,
|
||||||
|
recomputeAllControlYears,
|
||||||
|
};
|
||||||
470
server/src/upload/practical-incremental.js
Normal file
470
server/src/upload/practical-incremental.js
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const precompute = require('../precompute/service');
|
||||||
|
const dashboardSummary = require('../precompute/dashboard-summary');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const MAX_CHUNK = 2000;
|
||||||
|
let schemaReady = null;
|
||||||
|
|
||||||
|
async function hasColumn(table, column) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[table, column],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasIndex(table, index) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[table, index],
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSchema() {
|
||||||
|
if (schemaReady) return schemaReady;
|
||||||
|
schemaReady = (async () => {
|
||||||
|
for (const [table, column, definition] of [
|
||||||
|
['practical_exams', 'source_machine', 'VARCHAR(32) NULL'],
|
||||||
|
['practical_exams', 'source_row_id', 'BIGINT NULL'],
|
||||||
|
['practical_exam_steps', 'source_machine', 'VARCHAR(32) NULL'],
|
||||||
|
['practical_exam_steps', 'source_row_id', 'BIGINT NULL'],
|
||||||
|
['practical_exam_steps', 'upload_batch', 'VARCHAR(64) NULL'],
|
||||||
|
]) {
|
||||||
|
if (!(await hasColumn(table, column))) {
|
||||||
|
await db.execute(`ALTER TABLE \`${table}\` ADD COLUMN \`${column}\` ${definition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await hasIndex('practical_exam_steps', 'uk_source_step'))) {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE practical_exam_steps ADD UNIQUE KEY uk_source_step (exam_uuid, source_row_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await hasIndex('practical_exams', 'idx_source_exam'))) {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE practical_exams ADD KEY idx_source_exam (source_machine, source_row_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS practical_exam_person_stats_monthly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL, month INT NOT NULL,
|
||||||
|
employee_id VARCHAR(20) NOT NULL, employee_name VARCHAR(50) NULL,
|
||||||
|
team VARCHAR(50) NOT NULL,
|
||||||
|
total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0,
|
||||||
|
fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL,
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_dim (year, month, employee_id),
|
||||||
|
KEY idx_team_period (team, year, month)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS practical_exam_scope_stats_monthly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL, month INT NOT NULL,
|
||||||
|
scope_type ENUM('team','all') NOT NULL, scope_key VARCHAR(64) NOT NULL,
|
||||||
|
team VARCHAR(50) NULL,
|
||||||
|
total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0,
|
||||||
|
fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL,
|
||||||
|
participant_count INT NOT NULL DEFAULT 0,
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_dim (year, month, scope_key)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS practical_exam_person_stats_yearly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL, employee_id VARCHAR(20) NOT NULL,
|
||||||
|
employee_name VARCHAR(50) NULL, team VARCHAR(50) NOT NULL,
|
||||||
|
total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0,
|
||||||
|
fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL,
|
||||||
|
monthly_breakdown JSON NULL,
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_dim (year, employee_id),
|
||||||
|
KEY idx_team_year (team, year)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS practical_exam_scope_stats_yearly (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
scope_type ENUM('team','all') NOT NULL, scope_key VARCHAR(64) NOT NULL,
|
||||||
|
team VARCHAR(50) NULL,
|
||||||
|
total_count INT NOT NULL DEFAULT 0, pass_count INT NOT NULL DEFAULT 0,
|
||||||
|
fail_count INT NOT NULL DEFAULT 0, pass_rate DECIMAL(5,4) NULL,
|
||||||
|
participant_count INT NOT NULL DEFAULT 0, monthly_breakdown JSON NULL,
|
||||||
|
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_dim (year, scope_key)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
})().catch((error) => {
|
||||||
|
schemaReady = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return schemaReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireBatch(batchUuid) {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT * FROM upload_batches
|
||||||
|
WHERE batch_uuid = ? AND module = 'practical_exams'
|
||||||
|
LIMIT 1`,
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new ApiError(API_CODES.BATCH_NOT_FOUND, '实训导入批次不存在', 404);
|
||||||
|
if (!['lock-writing', 'precomputing'].includes(rows[0].status)) {
|
||||||
|
throw new ApiError(API_CODES.CONFLICT, `批次状态不可写:${rows[0].status}`, 409);
|
||||||
|
}
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start({ operator, meta, sourceSummary }) {
|
||||||
|
await ensureSchema();
|
||||||
|
const batchUuid = randomUUID();
|
||||||
|
const result = await db.execute(
|
||||||
|
`INSERT INTO upload_batches
|
||||||
|
(batch_uuid, module, data_type, record_count, status, payload, operator_id, locked_at)
|
||||||
|
VALUES (?, 'practical_exams', 'sqlite_incremental', 0, 'lock-writing', ?, ?, NOW())`,
|
||||||
|
[
|
||||||
|
batchUuid,
|
||||||
|
JSON.stringify({
|
||||||
|
sourceSummary: sourceSummary || null,
|
||||||
|
insertedExams: 0,
|
||||||
|
insertedSteps: 0,
|
||||||
|
}),
|
||||||
|
operator.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'practical_incremental_start',
|
||||||
|
module: 'practical_exams',
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: sourceSummary || {},
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { batchId: result.insertId, batchUuid };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function paperMap(rows) {
|
||||||
|
const uuids = [...new Set(rows.map((row) => row.source_paper_uuid).filter(Boolean))];
|
||||||
|
if (uuids.length === 0) return new Map();
|
||||||
|
const placeholders = uuids.map(() => '?').join(',');
|
||||||
|
const papers = await db.query(
|
||||||
|
`SELECT id, uuid FROM papers WHERE uuid IN (${placeholders})`,
|
||||||
|
uuids,
|
||||||
|
);
|
||||||
|
return new Map(papers.map((row) => [String(row.uuid), Number(row.id)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(value) {
|
||||||
|
return value == null ? null : JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingestExams(batchUuid, rows, createdBy) {
|
||||||
|
const employeeIds = [...new Set(rows.map((row) => row.employee_id).filter(Boolean))];
|
||||||
|
const validEmployees = employeeIds.length
|
||||||
|
? await db.query(
|
||||||
|
`SELECT employee_id FROM employees
|
||||||
|
WHERE deleted_at IS NULL AND employee_id IN (${employeeIds.map(() => '?').join(',')})`,
|
||||||
|
employeeIds,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const allowed = new Set(validEmployees.map((row) => String(row.employee_id)));
|
||||||
|
const acceptedRows = rows.filter((row) => allowed.has(String(row.employee_id)));
|
||||||
|
if (acceptedRows.length === 0) return 0;
|
||||||
|
|
||||||
|
const papers = await paperMap(acceptedRows);
|
||||||
|
const examUuids = acceptedRows.map((row) => row.exam_uuid);
|
||||||
|
const existingRows = await db.query(
|
||||||
|
`SELECT exam_uuid FROM practical_exams WHERE exam_uuid IN (${examUuids.map(() => '?').join(',')})`,
|
||||||
|
examUuids,
|
||||||
|
);
|
||||||
|
const inserted = acceptedRows.length - existingRows.length;
|
||||||
|
// 批量计算责任人快照: 优先用 details.examinerName(考官), 否则 fallback 到 computeResponsiblePerson
|
||||||
|
const responsible = require('../common/responsible');
|
||||||
|
const respMap = await responsible.computeResponsiblePersonBatch(
|
||||||
|
acceptedRows.map((r) => String(r.employee_id)),
|
||||||
|
);
|
||||||
|
const params = [];
|
||||||
|
const placeholders = acceptedRows.map((row) => {
|
||||||
|
const examinerName = row.details?.examinerName
|
||||||
|
|| (typeof row.details === 'string' ? (safeParseJson(row.details) || {}).examinerName : '') || '';
|
||||||
|
params.push(
|
||||||
|
row.employee_id,
|
||||||
|
papers.get(String(row.source_paper_uuid)) || null,
|
||||||
|
row.exam_uuid,
|
||||||
|
row.source_machine || null,
|
||||||
|
row.source_row_id ?? null,
|
||||||
|
row.score ?? null,
|
||||||
|
row.result || null,
|
||||||
|
row.faults ?? null,
|
||||||
|
Number(row.exam_year),
|
||||||
|
Number(row.exam_month),
|
||||||
|
row.create_time || null,
|
||||||
|
row.pc_class || null,
|
||||||
|
examinerName || respMap[String(row.employee_id)] || '',
|
||||||
|
json(row.details),
|
||||||
|
json({ sourcePaperUuid: row.source_paper_uuid || null }),
|
||||||
|
batchUuid,
|
||||||
|
createdBy || null,
|
||||||
|
);
|
||||||
|
return `(${Array(17).fill('?').join(',')})`;
|
||||||
|
});
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO practical_exams
|
||||||
|
(employee_id, paper_id, exam_uuid, source_machine, source_row_id,
|
||||||
|
score, result, faults, exam_year, exam_month, create_time, pc_class,
|
||||||
|
responsible_person, details, extras, upload_batch, created_by)
|
||||||
|
VALUES ${placeholders.join(',')}
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
employee_id = VALUES(employee_id),
|
||||||
|
paper_id = VALUES(paper_id),
|
||||||
|
source_machine = VALUES(source_machine),
|
||||||
|
source_row_id = VALUES(source_row_id),
|
||||||
|
score = VALUES(score),
|
||||||
|
result = VALUES(result),
|
||||||
|
faults = VALUES(faults),
|
||||||
|
exam_year = VALUES(exam_year),
|
||||||
|
exam_month = VALUES(exam_month),
|
||||||
|
create_time = VALUES(create_time),
|
||||||
|
pc_class = VALUES(pc_class),
|
||||||
|
responsible_person = VALUES(responsible_person),
|
||||||
|
details = VALUES(details),
|
||||||
|
extras = VALUES(extras),
|
||||||
|
upload_batch = VALUES(upload_batch)`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(s) {
|
||||||
|
try { return JSON.parse(s) || {}; } catch (_e) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingestSteps(batchUuid, rows) {
|
||||||
|
const examUuids = [...new Set(rows.map((row) => row.exam_uuid).filter(Boolean))];
|
||||||
|
const exams = examUuids.length
|
||||||
|
? await db.query(
|
||||||
|
`SELECT exam_uuid FROM practical_exams
|
||||||
|
WHERE exam_uuid IN (${examUuids.map(() => '?').join(',')})`,
|
||||||
|
examUuids,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const allowed = new Set(exams.map((row) => String(row.exam_uuid)));
|
||||||
|
const acceptedRows = rows.filter((row) => allowed.has(String(row.exam_uuid)));
|
||||||
|
if (acceptedRows.length === 0) return 0;
|
||||||
|
|
||||||
|
const papers = await paperMap(acceptedRows);
|
||||||
|
const params = [];
|
||||||
|
const placeholders = acceptedRows.map((row) => {
|
||||||
|
params.push(
|
||||||
|
row.exam_uuid,
|
||||||
|
papers.get(String(row.source_paper_uuid)) || null,
|
||||||
|
row.employee_id,
|
||||||
|
row.source_machine || null,
|
||||||
|
row.source_row_id ?? null,
|
||||||
|
Number(row.scenario_index) || 0,
|
||||||
|
Number(row.step_index) || 0,
|
||||||
|
row.operation || null,
|
||||||
|
row.target || null,
|
||||||
|
row.is_correct == null ? null : Number(row.is_correct),
|
||||||
|
row.faults ?? null,
|
||||||
|
row.exam_date || null,
|
||||||
|
Number(row.exam_year),
|
||||||
|
Number(row.exam_month),
|
||||||
|
json(row.details),
|
||||||
|
json({ sourcePaperUuid: row.source_paper_uuid || null }),
|
||||||
|
batchUuid,
|
||||||
|
);
|
||||||
|
return `(${Array(17).fill('?').join(',')})`;
|
||||||
|
});
|
||||||
|
const result = await db.execute(
|
||||||
|
`INSERT IGNORE INTO practical_exam_steps
|
||||||
|
(exam_uuid, paper_id, employee_id, source_machine, source_row_id,
|
||||||
|
scenario_index, step_index, operation, target, is_correct, faults,
|
||||||
|
exam_date, exam_year, exam_month, details, extras, upload_batch)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return result.affectedRows || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingest({ batchUuid, type, rows, operator }) {
|
||||||
|
await ensureSchema();
|
||||||
|
await requireBatch(batchUuid);
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0 || rows.length > MAX_CHUNK) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `rows 数量必须为 1-${MAX_CHUNK}`, 400);
|
||||||
|
}
|
||||||
|
let inserted;
|
||||||
|
if (type === 'exams') inserted = await ingestExams(batchUuid, rows, operator?.id);
|
||||||
|
else if (type === 'steps') inserted = await ingestSteps(batchUuid, rows);
|
||||||
|
else throw new ApiError(API_CODES.BAD_PARAMS, `未知分块类型:${type}`, 400);
|
||||||
|
|
||||||
|
const payloadKey = type === 'exams' ? '$.insertedExams' : '$.insertedSteps';
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE upload_batches
|
||||||
|
SET record_count = record_count + ?,
|
||||||
|
payload = JSON_SET(COALESCE(payload, JSON_OBJECT()), ?, COALESCE(JSON_EXTRACT(payload, ?), 0) + ?)
|
||||||
|
WHERE batch_uuid = ?`,
|
||||||
|
[type === 'exams' ? inserted : 0, payloadKey, payloadKey, inserted, batchUuid],
|
||||||
|
);
|
||||||
|
return { type, received: rows.length, inserted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairImportedMetadata() {
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE practical_exams
|
||||||
|
SET employee_id = LPAD(RIGHT(employee_id, 5), 5, '0')
|
||||||
|
WHERE CHAR_LENGTH(employee_id) > 5`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE practical_exams p
|
||||||
|
JOIN employees e
|
||||||
|
ON e.internal_employee_id = CASE
|
||||||
|
WHEN NULLIF(JSON_UNQUOTE(JSON_EXTRACT(p.details, '$.sourceWorkId')), '') IS NOT NULL
|
||||||
|
THEN LPAD(JSON_UNQUOTE(JSON_EXTRACT(p.details, '$.sourceWorkId')), 11, '0')
|
||||||
|
ELSE CONCAT(LEFT(e.internal_employee_id, 6), RIGHT(p.employee_id, 5))
|
||||||
|
END
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
SET p.employee_id = e.employee_id,
|
||||||
|
p.details = JSON_SET(
|
||||||
|
COALESCE(p.details, JSON_OBJECT()),
|
||||||
|
'$.sourceWorkId',
|
||||||
|
e.internal_employee_id
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE practical_exam_steps s
|
||||||
|
JOIN practical_exams p ON p.exam_uuid = s.exam_uuid
|
||||||
|
SET s.employee_id = p.employee_id`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE practical_exams
|
||||||
|
SET details = JSON_SET(
|
||||||
|
COALESCE(details, JSON_OBJECT()),
|
||||||
|
'$.deviceType',
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.deviceType')), ''),
|
||||||
|
CONCAT('车型', source_machine)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE source_machine IS NOT NULL`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE practical_exams p
|
||||||
|
JOIN (
|
||||||
|
SELECT exam_uuid,
|
||||||
|
GROUP_CONCAT(scenario_index ORDER BY id SEPARATOR ',') AS error_indices,
|
||||||
|
JSON_ARRAYAGG(
|
||||||
|
CONCAT(
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(JSON_EXTRACT(details, '$.isChoice') + 0, 0) = 1
|
||||||
|
THEN CONCAT(
|
||||||
|
'选择题:', COALESCE(NULLIF(operation, ''), '未知题目'),
|
||||||
|
';应选:', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.targetOperation')), ''), '-'),
|
||||||
|
';实际:', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.actualValue')), ''), '-')
|
||||||
|
)
|
||||||
|
ELSE CONCAT(
|
||||||
|
'应:', COALESCE(NULLIF(target, ''), '-'),
|
||||||
|
' / ', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.targetOperation')), ''), '-'),
|
||||||
|
';实际:', COALESCE(NULLIF(operation, ''), '-'),
|
||||||
|
' / ', COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.actualValue')), ''), '-')
|
||||||
|
)
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN NULLIF(JSON_UNQUOTE(JSON_EXTRACT(details, '$.reason')), '') IS NOT NULL
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(details, '$.reason')) <> '-'
|
||||||
|
THEN CONCAT('(', JSON_UNQUOTE(JSON_EXTRACT(details, '$.reason')), ')')
|
||||||
|
ELSE ''
|
||||||
|
END
|
||||||
|
)
|
||||||
|
) AS error_steps
|
||||||
|
FROM practical_exam_steps
|
||||||
|
WHERE is_correct = 0 OR faults > 0
|
||||||
|
GROUP BY exam_uuid
|
||||||
|
) errors ON errors.exam_uuid = p.exam_uuid
|
||||||
|
SET p.details = JSON_SET(
|
||||||
|
COALESCE(p.details, JSON_OBJECT()),
|
||||||
|
'$.errorStepIndices', errors.error_indices,
|
||||||
|
'$.errorSteps', errors.error_steps
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalize({ batchUuid, periods, operator, meta }) {
|
||||||
|
await ensureSchema();
|
||||||
|
await requireBatch(batchUuid);
|
||||||
|
const normalized = [...new Set((periods || []).map(String))]
|
||||||
|
.map((period) => {
|
||||||
|
const match = period.match(/^(\d{4})-(\d{2})$/);
|
||||||
|
return match ? { year: Number(match[1]), month: Number(match[2]) } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '没有可重算的月份', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE upload_batches SET status = 'precomputing' WHERE batch_uuid = ?`,
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
const reports = [];
|
||||||
|
try {
|
||||||
|
await repairImportedMetadata();
|
||||||
|
await sync.bumpVersions(['practical_exams', 'practical_exam_steps', 'papers', 'paper_steps']);
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE upload_batches
|
||||||
|
SET status = 'completed', completed_at = NOW()
|
||||||
|
WHERE batch_uuid = ?`,
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE upload_batches SET status = 'failed' WHERE batch_uuid = ?`,
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [batch] = await db.query(
|
||||||
|
'SELECT record_count, payload FROM upload_batches WHERE batch_uuid = ?',
|
||||||
|
[batchUuid],
|
||||||
|
);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'practical_incremental_complete',
|
||||||
|
module: 'practical_exams',
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: { periods: normalized, ...(batch?.payload || {}) },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { batchUuid, periods: normalized, reports, payload: batch?.payload || {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureSchema, start, ingest, finalize, repairImportedMetadata };
|
||||||
292
server/src/upload/upload.js
Normal file
292
server/src/upload/upload.js
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const { MODULE_REGISTRY } = require('@profile/shared');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const sync = require('../sync/sync');
|
||||||
|
const registry = require('../precompute/registry');
|
||||||
|
const precomputeService = require('../precompute/service');
|
||||||
|
const dashboardSummary = require('../precompute/dashboard-summary');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const crypto = require('../common/crypto');
|
||||||
|
const { getColumns, monthWhere, buildMultiInsert, chunk } = require('../common/table-utils');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const INSERT_CHUNK = 500;
|
||||||
|
|
||||||
|
// employees 明文 → 密文列映射(§10.2)。address/emergency 无等值查询需求,不建 hmac。
|
||||||
|
const EMPLOYEE_PII = [
|
||||||
|
{ plain: 'id_card', enc: 'id_card_enc', hmac: 'id_card_hmac' },
|
||||||
|
{ plain: 'mobile', enc: 'mobile_enc', hmac: 'mobile_hmac' },
|
||||||
|
{ plain: 'address', enc: 'address_enc' },
|
||||||
|
{ plain: 'emergency_contact', enc: 'emergency_contact_enc' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function findModule(moduleKey) {
|
||||||
|
const desc = MODULE_REGISTRY.find((m) => m.key === moduleKey);
|
||||||
|
if (!desc) throw new ApiError(API_CODES.NOT_FOUND, `未知模块:${moduleKey}`, 404);
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** employees 行:加密 PII 明文列,剥离明文,生成 *_enc / *_hmac */
|
||||||
|
function encryptEmployeeRow(row) {
|
||||||
|
const out = { ...row };
|
||||||
|
for (const f of EMPLOYEE_PII) {
|
||||||
|
if (out[f.plain] != null && out[f.plain] !== '') {
|
||||||
|
out[f.enc] = crypto.encrypt(out[f.plain]);
|
||||||
|
if (f.hmac) out[f.hmac] = crypto.hmac(out[f.plain]);
|
||||||
|
}
|
||||||
|
delete out[f.plain];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用上传(选 A:入参为已解析的 JSON 行数组;Excel 解析后续接入)。
|
||||||
|
* 阶段 1:短事务写原始表;阶段 2:导入即算(有 Precomputer 的模块)。
|
||||||
|
*
|
||||||
|
* @param {{ module: string, year?: number, month?: number, rows: Object[], operator: { id:number, username:string }, meta: { ip?:string, userAgent?:string } }} args
|
||||||
|
*/
|
||||||
|
async function upload({ module, year, month, rows, operator, meta }) {
|
||||||
|
const desc = findModule(module);
|
||||||
|
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, 'rows 不能为空(防止误清空整表)', 400);
|
||||||
|
}
|
||||||
|
if (desc.uploadMode === 'monthly' && (year == null || month == null)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `模块 ${module} 为按月上传,需提供 year / month`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = desc.rawTable;
|
||||||
|
const columns = await getColumns(table);
|
||||||
|
if (columns.length === 0) throw new ApiError(API_CODES.NOT_FOUND, `原始表不存在:${table}`, 404);
|
||||||
|
|
||||||
|
let preparedRows = module === 'employees' ? rows.map(encryptEmployeeRow) : rows;
|
||||||
|
if (module === 'employees') {
|
||||||
|
const existingRows = await db.query(
|
||||||
|
`SELECT employee_id, id_card_enc, id_card_hmac, mobile_enc, mobile_hmac,
|
||||||
|
address_enc, emergency_contact_enc
|
||||||
|
FROM employees`,
|
||||||
|
);
|
||||||
|
const existingByEmployee = new Map(existingRows.map((row) => [String(row.employee_id), row]));
|
||||||
|
preparedRows = preparedRows.map((row) => {
|
||||||
|
const existing = existingByEmployee.get(String(row.employee_id));
|
||||||
|
if (!existing) return row;
|
||||||
|
const merged = { ...row };
|
||||||
|
for (const column of [
|
||||||
|
'id_card_enc', 'id_card_hmac', 'mobile_enc', 'mobile_hmac',
|
||||||
|
'address_enc', 'emergency_contact_enc',
|
||||||
|
]) {
|
||||||
|
if (merged[column] == null && existing[column] != null) merged[column] = existing[column];
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const batchUuid = randomUUID();
|
||||||
|
|
||||||
|
// ---- 阶段 1:原始数据事务(短) ----
|
||||||
|
const batchId = await db.transaction(async (conn) => {
|
||||||
|
const [ins] = await conn.execute(
|
||||||
|
`INSERT INTO upload_batches (batch_uuid, module, year, month, record_count, status, operator_id, locked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'lock-writing', ?, NOW())`,
|
||||||
|
[batchUuid, module, year ?? null, month ?? null, preparedRows.length, operator.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同 (module, year, month) 并发串行化(§5.1)
|
||||||
|
await conn.execute(
|
||||||
|
`SELECT id FROM upload_batches
|
||||||
|
WHERE module = ? AND (year <=> ?) AND (month <=> ?) AND status IN ('lock-writing','precomputing')
|
||||||
|
FOR UPDATE`,
|
||||||
|
[module, year ?? null, month ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删旧:按月覆盖 / 全量镜像替换(§5.1 幂等:同月重复上传=覆盖)
|
||||||
|
if (desc.uploadMode === 'monthly') {
|
||||||
|
const mw = monthWhere(columns, year, month);
|
||||||
|
if (mw.clause) await conn.execute(`DELETE FROM \`${table}\` WHERE ${mw.clause}`, mw.params);
|
||||||
|
} else {
|
||||||
|
await conn.execute(`DELETE FROM \`${table}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写新(分批)
|
||||||
|
for (const part of chunk(preparedRows, INSERT_CHUNK)) {
|
||||||
|
const built = buildMultiInsert(table, columns, part);
|
||||||
|
if (built) await conn.execute(built.sql, built.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE upload_batches SET status = 'precomputing' WHERE id = ?`,
|
||||||
|
[ins.insertId],
|
||||||
|
);
|
||||||
|
return ins.insertId;
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'upload',
|
||||||
|
module,
|
||||||
|
targetType: 'upload_batch',
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: { year, month, recordCount: preparedRows.length },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 阶段 2:仅同步业务表,预计算改为用户手动触发(设置页全量/单月计算)----
|
||||||
|
let report = null;
|
||||||
|
try {
|
||||||
|
await sync.bumpVersions([table]);
|
||||||
|
await db.execute(`UPDATE upload_batches SET status = 'completed', completed_at = NOW() WHERE id = ?`, [batchId]);
|
||||||
|
} catch (err) {
|
||||||
|
await db.execute(`UPDATE upload_batches SET status = 'failed' WHERE id = ?`, [batchId]);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'system',
|
||||||
|
action: 'upload_precompute_failed',
|
||||||
|
module,
|
||||||
|
targetId: batchUuid,
|
||||||
|
payload: { error: err.message },
|
||||||
|
});
|
||||||
|
throw new ApiError(API_CODES.SERVER_ERROR, `原始数据已入库,但预计算失败:${err.message}(可重试 recompute)`, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { batchId, batchUuid, recordCount: preparedRows.length, status: 'completed', report };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearData({ module, year, month, operator, meta }) {
|
||||||
|
const desc = findModule(module);
|
||||||
|
if (desc.uploadMode === 'monthly' && (year == null || month == null)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, `模块 ${module} 为按月数据,清空时需提供 year / month`, 400);
|
||||||
|
}
|
||||||
|
const table = desc.rawTable;
|
||||||
|
const columns = await getColumns(table);
|
||||||
|
let result;
|
||||||
|
if (desc.uploadMode === 'monthly') {
|
||||||
|
const mw = monthWhere(columns, year, month);
|
||||||
|
if (!mw.clause) throw new ApiError(API_CODES.BAD_PARAMS, `表 ${table} 无法按年月清空`, 400);
|
||||||
|
result = await db.execute(`DELETE FROM \`${table}\` WHERE ${mw.clause}`, mw.params);
|
||||||
|
} else {
|
||||||
|
result = await db.execute(`DELETE FROM \`${table}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sync.bumpVersions([table]);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'clear',
|
||||||
|
module,
|
||||||
|
targetType: desc.uploadMode === 'monthly' ? 'month' : 'table',
|
||||||
|
targetId: desc.uploadMode === 'monthly' ? `${year}-${String(month).padStart(2, '0')}` : table,
|
||||||
|
payload: { deletedCount: result.affectedRows || 0 },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return { module, year: year ?? null, month: month ?? null, deletedCount: result.affectedRows || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPracticalPaperSteps({ rows, operator, meta }) {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, 'paperSteps 不能为空', 400);
|
||||||
|
}
|
||||||
|
const groups = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const uuid = String(row.paperUUID || row.paperUuid || `legacy-${row.paperId || ''}`).trim();
|
||||||
|
if (!uuid || uuid === 'legacy-') {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '标准步骤缺少 paperUUID / paperId', 400);
|
||||||
|
}
|
||||||
|
if (!groups.has(uuid)) groups.set(uuid, []);
|
||||||
|
groups.get(uuid).push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await db.transaction(async (conn) => {
|
||||||
|
let stepCount = 0;
|
||||||
|
for (const [uuid, steps] of groups) {
|
||||||
|
const first = steps[0];
|
||||||
|
const scenarioCount = Math.max(...steps.map((row) => Number(row.scenarioIndex) || 0)) + 1;
|
||||||
|
const [paperResult] = await conn.execute(
|
||||||
|
`INSERT INTO papers (uuid, name, class, scenario_count, status, details)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
id = LAST_INSERT_ID(id),
|
||||||
|
name = VALUES(name),
|
||||||
|
class = VALUES(class),
|
||||||
|
scenario_count = VALUES(scenario_count),
|
||||||
|
details = VALUES(details)`,
|
||||||
|
[
|
||||||
|
uuid,
|
||||||
|
first.paperName || uuid,
|
||||||
|
first.paperClass || null,
|
||||||
|
scenarioCount,
|
||||||
|
JSON.stringify({ examType: first.examType || null, legacyPaperId: first.paperId ?? null }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const paperId = paperResult.insertId;
|
||||||
|
await conn.execute('DELETE FROM paper_steps WHERE paper_id = ?', [paperId]);
|
||||||
|
const normalized = steps.map((row) => ({
|
||||||
|
paper_id: paperId,
|
||||||
|
scenario_index: Number(row.scenarioIndex) || 0,
|
||||||
|
step_index: Number(row.stepIndex) || 0,
|
||||||
|
operation: row.stepName || row.targetOperation || null,
|
||||||
|
target: row.targetDevice || null,
|
||||||
|
criteria: row.stepDescription || row.scenarioDescription || null,
|
||||||
|
score_weight: Number(row.stepScore) >= 0 ? Number(row.stepScore) : null,
|
||||||
|
details: {
|
||||||
|
scenarioId: row.scenarioId ?? null,
|
||||||
|
scenarioName: row.scenarioName || null,
|
||||||
|
stepId: row.stepId ?? null,
|
||||||
|
targetOperation: row.targetOperation || null,
|
||||||
|
operationValue: row.operationValue || null,
|
||||||
|
deviceType: row.deviceType || null,
|
||||||
|
deviceTypeName: row.deviceTypeName || null,
|
||||||
|
deviceId: row.deviceId || null,
|
||||||
|
itemId: row.itemId || null,
|
||||||
|
faultTolerance: row.faultTolerance ?? null,
|
||||||
|
timeout: row.timeout ?? null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
for (const part of chunk(normalized, INSERT_CHUNK)) {
|
||||||
|
const built = buildMultiInsert('paper_steps', await getColumns('paper_steps'), part);
|
||||||
|
if (built) await conn.execute(built.sql, built.params);
|
||||||
|
}
|
||||||
|
stepCount += normalized.length;
|
||||||
|
}
|
||||||
|
return { paperCount: groups.size, stepCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.bumpVersions(['papers', 'paper_steps']);
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'upload',
|
||||||
|
module: 'practical_exams',
|
||||||
|
targetType: 'paper_steps',
|
||||||
|
payload: report,
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBatch(id) {
|
||||||
|
const rows = await db.query('SELECT * FROM upload_batches WHERE id = ? LIMIT 1', [id]);
|
||||||
|
if (!rows[0]) throw new ApiError(API_CODES.BATCH_NOT_FOUND, `上传批次不存在:${id}`, 404);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listBatches(moduleKey, limit = 50) {
|
||||||
|
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||||
|
if (moduleKey) {
|
||||||
|
return db.query(
|
||||||
|
`SELECT * FROM upload_batches WHERE module = ? ORDER BY id DESC LIMIT ${safeLimit}`,
|
||||||
|
[moduleKey],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return db.query(`SELECT * FROM upload_batches ORDER BY id DESC LIMIT ${safeLimit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { upload, clearData, uploadPracticalPaperSteps, getBatch, listBatches };
|
||||||
138
server/src/upload/upload.routes.js
Normal file
138
server/src/upload/upload.routes.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ACTIONS } = require('@profile/shared');
|
||||||
|
const upload = require('./upload');
|
||||||
|
const practicalIncremental = require('./practical-incremental');
|
||||||
|
const kilometersImport = require('./kilometers-import');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
const maintenance = require('../maintenance');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth, maintenance.requireDeveloper);
|
||||||
|
|
||||||
|
function meta(req) {
|
||||||
|
return { ip: req.ip, userAgent: req.headers['user-agent'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选 A:body 传已解析的 JSON 行数组;Excel multipart 解析后续接入
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { module, year, month, rows } = req.body || {};
|
||||||
|
if (!module) throw new ApiError(API_CODES.BAD_PARAMS, '缺少参数:module', 400);
|
||||||
|
const result = await upload.upload({
|
||||||
|
module,
|
||||||
|
year: year != null ? Number(year) : undefined,
|
||||||
|
month: month != null ? Number(month) : undefined,
|
||||||
|
rows,
|
||||||
|
operator: req.user,
|
||||||
|
meta: meta(req),
|
||||||
|
});
|
||||||
|
ok(res, result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/kilometers',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await kilometersImport.importFiles({
|
||||||
|
files: req.body && req.body.files,
|
||||||
|
operator: req.user,
|
||||||
|
meta: meta(req),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/practical-paper-steps',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await upload.uploadPracticalPaperSteps({
|
||||||
|
rows: req.body && req.body.rows,
|
||||||
|
operator: req.user,
|
||||||
|
meta: meta(req),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/practical-incremental/start',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await practicalIncremental.start({
|
||||||
|
operator: req.user,
|
||||||
|
meta: meta(req),
|
||||||
|
sourceSummary: req.body && req.body.sourceSummary,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/practical-incremental/chunk',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { batchUuid, type, rows } = req.body || {};
|
||||||
|
ok(res, await practicalIncremental.ingest({ batchUuid, type, rows, operator: req.user }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/practical-incremental/finalize',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { batchUuid, periods } = req.body || {};
|
||||||
|
ok(res, await practicalIncremental.finalize({
|
||||||
|
batchUuid,
|
||||||
|
periods,
|
||||||
|
operator: req.user,
|
||||||
|
meta: meta(req),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:module',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.IMPORT),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await upload.clearData({
|
||||||
|
module: req.params.module,
|
||||||
|
year: req.query.year != null ? Number(req.query.year) : undefined,
|
||||||
|
month: req.query.month != null ? Number(req.query.month) : undefined,
|
||||||
|
operator: req.user,
|
||||||
|
meta: meta(req),
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/batches',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await upload.listBatches(req.query.module, req.query.size));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/batches/:id',
|
||||||
|
requireAuth,
|
||||||
|
rbac.requirePermission('imports', ACTIONS.READ),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
ok(res, await upload.getBatch(Number(req.params.id)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
255
server/src/users/users.js
Normal file
255
server/src/users/users.js
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const argon2 = require('argon2');
|
||||||
|
const config = require('../config');
|
||||||
|
const db = require('../infra/db');
|
||||||
|
const redis = require('../infra/redis');
|
||||||
|
const rbac = require('../rbac/rbac');
|
||||||
|
const audit = require('../audit/audit');
|
||||||
|
const { isStrongPassword } = require('../auth/auth');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
|
||||||
|
const REAL_NAME_RE = /^[\u4e00-\u9fa5]{2,16}$/;
|
||||||
|
const ALLOWED_ROLES = new Set(['admin', 'manager', 'operator', 'viewer', 'ai_readonly']);
|
||||||
|
const ALLOWED_STATUS = new Set(['active', 'disabled']);
|
||||||
|
|
||||||
|
async function revokeRefreshTokens(userId) {
|
||||||
|
try {
|
||||||
|
const jtis = await redis.client.smembers(`user_refresh:${userId}`);
|
||||||
|
if (jtis.length > 0) await redis.client.del(...jtis.map((j) => `refresh:${j}`));
|
||||||
|
await redis.client.del(`user_refresh:${userId}`);
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUsers() {
|
||||||
|
const rows = await db.query(
|
||||||
|
`SELECT u.id, u.username, u.display_name, u.real_name, u.status, u.last_login_at, u.last_login_ip,
|
||||||
|
u.pwd_changed_at, u.failed_attempts, u.locked_until, u.created_at
|
||||||
|
FROM users u
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
ORDER BY u.username`,
|
||||||
|
);
|
||||||
|
const roleRows = await db.query(
|
||||||
|
`SELECT ur.user_id, r.code, r.name
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id`,
|
||||||
|
);
|
||||||
|
const rolesByUser = new Map();
|
||||||
|
for (const row of roleRows) {
|
||||||
|
if (!rolesByUser.has(row.user_id)) rolesByUser.set(row.user_id, []);
|
||||||
|
rolesByUser.get(row.user_id).push({ code: row.code, name: row.name });
|
||||||
|
}
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
realName: row.real_name || '',
|
||||||
|
status: row.status,
|
||||||
|
roles: rolesByUser.get(row.id) || [],
|
||||||
|
isDeveloper: config.developerUsers.has(row.username),
|
||||||
|
lastLoginAt: row.last_login_at,
|
||||||
|
lastLoginIp: row.last_login_ip,
|
||||||
|
pwdChangedAt: row.pwd_changed_at,
|
||||||
|
failedAttempts: Number(row.failed_attempts) || 0,
|
||||||
|
lockedUntil: row.locked_until,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(payload, operator, meta) {
|
||||||
|
const username = String(payload.username || '').trim();
|
||||||
|
const displayName = String(payload.displayName || '').trim();
|
||||||
|
const realName = String(payload.realName || '').trim();
|
||||||
|
const password = payload.password;
|
||||||
|
const roleCode = String(payload.roleCode || 'operator').trim();
|
||||||
|
|
||||||
|
if (!USERNAME_RE.test(username)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '用户名须为 3-32 位字母、数字、下划线、点或连字符', 400);
|
||||||
|
}
|
||||||
|
if (!displayName) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '显示名称不能为空', 400);
|
||||||
|
}
|
||||||
|
if (!realName) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '姓名不能为空', 400);
|
||||||
|
}
|
||||||
|
if (!REAL_NAME_RE.test(realName)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '姓名须为 2-16 位中文', 400);
|
||||||
|
}
|
||||||
|
if (!isStrongPassword(password)) {
|
||||||
|
throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400);
|
||||||
|
}
|
||||||
|
if (!ALLOWED_ROLES.has(roleCode)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '角色无效', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||||
|
if (existing.length) {
|
||||||
|
throw new ApiError(API_CODES.CONFLICT, '用户名已存在', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRows = await db.query('SELECT id FROM roles WHERE code = ? LIMIT 1', [roleCode]);
|
||||||
|
if (!roleRows.length) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '角色不存在', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await argon2.hash(password, { type: argon2.argon2id });
|
||||||
|
let userId = null;
|
||||||
|
await db.transaction(async (conn) => {
|
||||||
|
const [insert] = await conn.execute(
|
||||||
|
'INSERT INTO users (username, password_hash, display_name, real_name, status) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[username, hash, displayName, realName, 'active'],
|
||||||
|
);
|
||||||
|
userId = Number(insert.insertId);
|
||||||
|
await conn.execute('INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)', [userId, roleRows[0].id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'admin_create_user',
|
||||||
|
module: 'users',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: String(userId),
|
||||||
|
payload: { username, displayName, realName, roleCode },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: userId, username, displayName, realName, roleCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(userId, payload, operator, meta) {
|
||||||
|
const id = Number(userId);
|
||||||
|
if (!id) throw new ApiError(API_CODES.BAD_PARAMS, '用户 ID 无效', 400);
|
||||||
|
|
||||||
|
const displayName = payload.displayName !== undefined ? String(payload.displayName).trim() : undefined;
|
||||||
|
const realName = payload.realName !== undefined ? String(payload.realName).trim() : undefined;
|
||||||
|
if (displayName === undefined && realName === undefined) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '没有可更新的字段', 400);
|
||||||
|
}
|
||||||
|
if (displayName !== undefined && !displayName) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '显示名称不能为空', 400);
|
||||||
|
}
|
||||||
|
if (realName !== undefined) {
|
||||||
|
if (!realName) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '姓名不能为空', 400);
|
||||||
|
}
|
||||||
|
if (!REAL_NAME_RE.test(realName)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '姓名须为 2-16 位中文', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT id, username, display_name, real_name FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404);
|
||||||
|
|
||||||
|
const nextDisplayName = displayName !== undefined ? displayName : row.display_name;
|
||||||
|
const nextRealName = realName !== undefined ? realName : (row.real_name || '');
|
||||||
|
await db.execute('UPDATE users SET display_name = ?, real_name = ? WHERE id = ?', [
|
||||||
|
nextDisplayName,
|
||||||
|
nextRealName,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'admin_update_user',
|
||||||
|
module: 'users',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: String(id),
|
||||||
|
payload: {
|
||||||
|
targetUsername: row.username,
|
||||||
|
from: { displayName: row.display_name, realName: row.real_name || '' },
|
||||||
|
to: { displayName: nextDisplayName, realName: nextRealName },
|
||||||
|
},
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id, username: row.username, displayName: nextDisplayName, realName: nextRealName };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(userId, newPassword, operator, meta) {
|
||||||
|
const id = Number(userId);
|
||||||
|
if (!id) throw new ApiError(API_CODES.BAD_PARAMS, '用户 ID 无效', 400);
|
||||||
|
if (!isStrongPassword(newPassword)) {
|
||||||
|
throw new ApiError(API_CODES.WEAK_PASSWORD, '密码强度不足:至少 10 位,须含大小写字母与数字', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.query('SELECT id, username FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', [id]);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404);
|
||||||
|
|
||||||
|
const hash = await argon2.hash(newPassword, { type: argon2.argon2id });
|
||||||
|
await db.execute(
|
||||||
|
'UPDATE users SET password_hash = ?, pwd_changed_at = NOW(), failed_attempts = 0, locked_until = NULL WHERE id = ?',
|
||||||
|
[hash, id],
|
||||||
|
);
|
||||||
|
await revokeRefreshTokens(id);
|
||||||
|
await rbac.invalidate(id);
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'admin_reset_password',
|
||||||
|
module: 'users',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: String(id),
|
||||||
|
payload: { targetUsername: row.username },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id, username: row.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(userId, status, operator, meta) {
|
||||||
|
const id = Number(userId);
|
||||||
|
if (!id) throw new ApiError(API_CODES.BAD_PARAMS, '用户 ID 无效', 400);
|
||||||
|
if (!ALLOWED_STATUS.has(status)) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '状态无效', 400);
|
||||||
|
}
|
||||||
|
if (Number(operator.id) === id && status === 'disabled') {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '不能停用自己的账号', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.query('SELECT id, username, status FROM users WHERE id = ? AND deleted_at IS NULL LIMIT 1', [id]);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new ApiError(API_CODES.NOT_FOUND, '用户不存在', 404);
|
||||||
|
|
||||||
|
await db.execute('UPDATE users SET status = ? WHERE id = ?', [status, id]);
|
||||||
|
if (status === 'disabled') {
|
||||||
|
await revokeRefreshTokens(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit.log({
|
||||||
|
userId: operator.id,
|
||||||
|
username: operator.username,
|
||||||
|
roleCode: operator.roles?.[0],
|
||||||
|
clientType: 'ui',
|
||||||
|
action: 'admin_update_user_status',
|
||||||
|
module: 'users',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: String(id),
|
||||||
|
payload: { targetUsername: row.username, from: row.status, to: status },
|
||||||
|
ip: meta.ip,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id, username: row.username, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listUsers, createUser, updateUser, resetPassword, updateStatus, ALLOWED_ROLES };
|
||||||
69
server/src/users/users.routes.js
Normal file
69
server/src/users/users.routes.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const users = require('./users');
|
||||||
|
const maintenance = require('../maintenance');
|
||||||
|
const { requireAuth } = require('../auth/auth');
|
||||||
|
const { ok, asyncHandler } = require('../common/http');
|
||||||
|
const { ApiError, API_CODES } = require('../common/api-codes');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function meta(req) {
|
||||||
|
return { ip: req.ip, userAgent: req.headers['user-agent'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requireAuth, maintenance.requireDeveloper);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
ok(res, await users.listUsers());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { username, displayName, realName, password, roleCode } = req.body || {};
|
||||||
|
if (!username || !displayName || !realName || !password) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '缺少用户名、显示名称、姓名或密码', 400);
|
||||||
|
}
|
||||||
|
ok(res, await users.createUser({ username, displayName, realName, password, roleCode }, req.user, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { displayName, realName } = req.body || {};
|
||||||
|
if (displayName === undefined && realName === undefined) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '缺少显示名称或姓名', 400);
|
||||||
|
}
|
||||||
|
ok(res, await users.updateUser(req.params.id, { displayName, realName }, req.user, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/reset-password',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { newPassword } = req.body || {};
|
||||||
|
if (!newPassword) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '缺少新密码', 400);
|
||||||
|
}
|
||||||
|
ok(res, await users.resetPassword(req.params.id, newPassword, req.user, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/:id/status',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { status } = req.body || {};
|
||||||
|
if (!status) {
|
||||||
|
throw new ApiError(API_CODES.BAD_PARAMS, '缺少状态', 400);
|
||||||
|
}
|
||||||
|
ok(res, await users.updateStatus(req.params.id, status, req.user, meta(req)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Loading…
Reference in New Issue
Block a user