280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
'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 };
|