Agent/server/src/export/generator.js

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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[char]);
}
async function analysisHtml(params) {
const summaryRows = await db.query("SELECT payload, computed_at FROM dashboard_summary WHERE scope_key = 'global' LIMIT 1");
const summary = summaryRows[0]?.payload || {};
const team = params.team ? ` · ${escapeHtml(params.team)}` : '';
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>综合分析报告</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:40px;color:#1f2937}h1{color:#0f766e}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.card{border:1px solid #d1d5db;border-radius:10px;padding:18px}.value{font-size:28px;font-weight:700}</style>
</head><body><h1>综合分析报告${team}</h1><p>统计时间:${escapeHtml(summaryRows[0]?.computed_at || new Date().toISOString())}</p>
<div class="grid">
<div class="card">员工总数<div class="value">${Number(summary.totalEmployees) || 0}</div></div>
<div class="card">在线考试<div class="value">${Number(summary.totalOnlineExams) || 0}</div></div>
<div class="card">实操考试<div class="value">${Number(summary.totalPracticalExams) || 0}</div></div>
<div class="card">在线通过率<div class="value">${Number(summary.passRate) || 0}%</div></div>
<div class="card">累计公里数<div class="value">${Number(summary.totalKilometers) || 0}</div></div>
<div class="card">驾驶员人数<div class="value">${Number(summary.uniqueKilometerEmployees) || 0}</div></div>
</div></body></html>`;
}
function backupReplacer(_key, value) {
if (Buffer.isBuffer(value)) return { encoding: 'base64', data: value.toString('base64') };
return value;
}
async function backupJson() {
const data = await buildBackupPayload();
return JSON.stringify(data, backupReplacer, 2);
}
async function generate(jobUuid, type, params = {}) {
if (!EXPORT_TYPES.has(type)) throw new Error(`不支持的导出类型:${type}`);
fs.mkdirSync(config.exportDir, { recursive: true, mode: 0o700 });
const baseName = `${type}-${jobUuid}`;
let fileName;
let filePath;
let mimeType;
if (type === 'analysis-report') {
fileName = `${baseName}.html`;
filePath = path.join(config.exportDir, fileName);
mimeType = 'text/html; charset=utf-8';
fs.writeFileSync(filePath, await analysisHtml(params), { mode: 0o600 });
} else if (type === 'backup') {
fileName = `${baseName}.json`;
filePath = path.join(config.exportDir, fileName);
mimeType = 'application/json; charset=utf-8';
fs.writeFileSync(filePath, await backupJson(), { mode: 0o600 });
} else {
const factories = {
employees: employeesWorkbook,
'online-exams': onlineWorkbook,
'practical-exams': practicalWorkbook,
'team-analysis': teamWorkbook,
'question-details': questionDetailsWorkbook,
};
const workbook = await factories[type](params);
fileName = `${baseName}.xlsx`;
filePath = path.join(config.exportDir, fileName);
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
await workbook.xlsx.writeFile(filePath);
fs.chmodSync(filePath, 0o600);
}
const bytes = fs.readFileSync(filePath);
return {
fileName,
filePath,
mimeType,
fileSize: bytes.length,
fileHash: crypto.createHash('sha256').update(bytes).digest('hex'),
};
}
module.exports = { EXPORT_TYPES, generate };