'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const ExcelJS = require('exceljs'); const config = require('../config'); const db = require('../infra/db'); const { buildBackupPayload } = require('./backup'); const EXPORT_TYPES = new Set([ 'employees', 'online-exams', 'practical-exams', 'team-analysis', 'analysis-report', 'question-details', 'backup', ]); function safeNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : null; } function jsonCell(value) { if (value == null) return null; return typeof value === 'object' ? JSON.stringify(value) : value; } function normalizeRows(rows) { return rows.map((row) => Object.fromEntries( Object.entries(row).map(([key, value]) => [key, jsonCell(value)]), )); } function addSheet(workbook, name, rows) { const normalized = normalizeRows(rows); const data = normalized.length > 0 ? normalized : [{ message: '无数据' }]; const keys = [...new Set(data.flatMap((row) => Object.keys(row)))]; const sheet = workbook.addWorksheet(name.slice(0, 31)); sheet.columns = keys.map((key) => ({ header: key, key, width: Math.min(40, Math.max(12, key.length + 4)) })); sheet.addRows(data); sheet.getRow(1).font = { bold: true }; sheet.views = [{ state: 'frozen', ySplit: 1 }]; } function periodWhere(alias, params, values) { const clauses = []; const year = safeNumber(params.year); const month = safeNumber(params.month); if (year != null) { clauses.push(`${alias}.exam_year = ?`); values.push(year); } if (month != null) { clauses.push(`${alias}.exam_month = ?`); values.push(month); } if (params.team) { clauses.push('e.team = ?'); values.push(String(params.team)); } if (params.employeeId) { clauses.push(`${alias}.employee_id = ?`); values.push(String(params.employeeId)); } return clauses; } async function employeesWorkbook(params = {}) { const values = []; const where = params.employeeId ? 'AND employee_id = ?' : 'AND (employment_status = \'在职\' OR employment_status IS NULL)'; if (params.employeeId) values.push(String(params.employeeId)); const rows = await db.query( `SELECT employee_id, internal_employee_id, name, team, position, native_place, nationality, political_status, education, education_type, graduate_school, graduate_date, skill_level, hire_date, birth_date, status, employment_status, source, details, extras FROM employees WHERE deleted_at IS NULL ${where} ORDER BY team, employee_id`, values, ); const workbook = new ExcelJS.Workbook(); addSheet(workbook, '员工档案', rows); return workbook; } async function onlineWorkbook(params) { const values = []; const clauses = periodWhere('o', params, values); const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const exams = await db.query( `SELECT o.employee_id, e.name AS employee_name, e.team, e.position, o.exam_year, o.exam_month, o.paper_class, o.score, o.result, o.faults, o.choice_faults, o.submit_time, o.elapsed_seconds, o.details FROM online_exams o LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL ${where} ORDER BY o.exam_year, o.exam_month, e.team, o.employee_id`, values, ); const missingValues = []; const missingClauses = []; if (safeNumber(params.year) != null && safeNumber(params.month) != null) { missingClauses.push('m.missing_month = ?'); missingValues.push(`${safeNumber(params.year)}-${String(safeNumber(params.month)).padStart(2, '0')}`); } else if (safeNumber(params.year) != null) { missingClauses.push('m.missing_month LIKE ?'); missingValues.push(`${safeNumber(params.year)}-%`); } if (params.team) { missingClauses.push('e.team = ?'); missingValues.push(String(params.team)); } const missingWhere = missingClauses.length ? `WHERE ${missingClauses.join(' AND ')}` : ''; const missing = await db.query( `SELECT m.employee_id, e.name AS employee_name, e.team, e.position, m.missing_month, m.reason, m.import_time, m.details FROM missing_exams m LEFT JOIN employees e ON e.employee_id = m.employee_id AND e.deleted_at IS NULL ${missingWhere} ORDER BY m.missing_month, e.team, m.employee_id`, missingValues, ); const workbook = new ExcelJS.Workbook(); addSheet(workbook, '在线考试', exams); addSheet(workbook, '缺考', missing); return workbook; } async function practicalWorkbook(params) { const values = []; const clauses = periodWhere('p', params, values); const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const rows = await db.query( `SELECT p.employee_id, e.name AS employee_name, e.team, e.position, p.exam_year, p.exam_month, p.paper_id, p.pc_class AS paper_class, p.score, p.result, p.faults, p.create_time, p.details FROM practical_exams p LEFT JOIN employees e ON e.employee_id = p.employee_id AND e.deleted_at IS NULL ${where} ORDER BY p.exam_year, p.exam_month, e.team, p.employee_id`, values, ); const workbook = new ExcelJS.Workbook(); addSheet(workbook, '实操考试', rows); return workbook; } async function questionDetailsWorkbook(params) { const values = []; const clauses = periodWhere('o', params, values); const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const rows = await db.query( `SELECT o.employee_id, e.name AS employee_name, e.team, o.exam_year, o.exam_month, o.paper_class, o.score, o.result, JSON_EXTRACT(o.details, '$.questionScores') AS question_scores, o.details FROM online_exams o LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL ${where} ORDER BY o.exam_year, o.exam_month, e.team, o.employee_id`, values, ); const workbook = new ExcelJS.Workbook(); addSheet(workbook, '题目作答原始明细', rows); return workbook; } async function teamWorkbook(params) { const year = safeNumber(params.year); const month = safeNumber(params.month); const values = []; const clauses = []; if (year != null) { clauses.push('year = ?'); values.push(year); } if (month != null) { clauses.push('month = ?'); values.push(month); } if (params.team) { clauses.push('team = ?'); values.push(String(params.team)); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const [online, practical, kilometers] = await Promise.all([ db.query(`SELECT * FROM online_exam_stats_monthly ${where} ORDER BY year, month, team`, values), db.query(`SELECT * FROM practical_exam_stats_monthly ${where} ORDER BY year, month, team`, values), db.query(`SELECT * FROM kilometers_stats_team_monthly ${where} ORDER BY year, month, team`, values), ]); const workbook = new ExcelJS.Workbook(); addSheet(workbook, '在线考试班组统计', online); addSheet(workbook, '实操考试班组统计', practical); addSheet(workbook, '班组公里数', kilometers); return workbook; } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[char]); } async function analysisHtml(params) { const summaryRows = await db.query("SELECT payload, computed_at FROM dashboard_summary WHERE scope_key = 'global' LIMIT 1"); const summary = summaryRows[0]?.payload || {}; const team = params.team ? ` · ${escapeHtml(params.team)}` : ''; return `综合分析报告

综合分析报告${team}

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

员工总数
${Number(summary.totalEmployees) || 0}
在线考试
${Number(summary.totalOnlineExams) || 0}
实操考试
${Number(summary.totalPracticalExams) || 0}
在线通过率
${Number(summary.passRate) || 0}%
累计公里数
${Number(summary.totalKilometers) || 0}
驾驶员人数
${Number(summary.uniqueKilometerEmployees) || 0}
`; } function backupReplacer(_key, value) { if (Buffer.isBuffer(value)) return { encoding: 'base64', data: value.toString('base64') }; return value; } async function backupJson() { const data = await buildBackupPayload(); return JSON.stringify(data, backupReplacer, 2); } async function generate(jobUuid, type, params = {}) { if (!EXPORT_TYPES.has(type)) throw new Error(`不支持的导出类型:${type}`); fs.mkdirSync(config.exportDir, { recursive: true, mode: 0o700 }); const baseName = `${type}-${jobUuid}`; let fileName; let filePath; let mimeType; if (type === 'analysis-report') { fileName = `${baseName}.html`; filePath = path.join(config.exportDir, fileName); mimeType = 'text/html; charset=utf-8'; fs.writeFileSync(filePath, await analysisHtml(params), { mode: 0o600 }); } else if (type === 'backup') { fileName = `${baseName}.json`; filePath = path.join(config.exportDir, fileName); mimeType = 'application/json; charset=utf-8'; fs.writeFileSync(filePath, await backupJson(), { mode: 0o600 }); } else { const factories = { employees: employeesWorkbook, 'online-exams': onlineWorkbook, 'practical-exams': practicalWorkbook, 'team-analysis': teamWorkbook, 'question-details': questionDetailsWorkbook, }; const workbook = await factories[type](params); fileName = `${baseName}.xlsx`; filePath = path.join(config.exportDir, fileName); mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; await workbook.xlsx.writeFile(filePath); fs.chmodSync(filePath, 0o600); } const bytes = fs.readFileSync(filePath); return { fileName, filePath, mimeType, fileSize: bytes.length, fileHash: crypto.createHash('sha256').update(bytes).digest('hex'), }; } module.exports = { EXPORT_TYPES, generate };