'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 PREVIEW_TTL_SECONDS = 30 * 60; const PREVIEW_PREFIX = 'monthly-exam-import:'; const PAPER_CLASS = '管理部月考'; const LINE_SCOPE = '所有班组'; const EXCLUDED_POSITIONS = new Set(['主任办事员']); /** 备注含「借调」:合理不在岗考试,不记入缺考 */ 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 isSecondmentRemarks(remarks) { return String(remarks || '').includes('借调'); } function isExcludedFromMissingExam(row) { if (EXCLUDED_POSITIONS.has(text(row.position))) return true; if (isSecondmentRemarks(row.remarks || extractRemarks(row.details))) return true; return false; } const META_HEADERS = new Set([ '序号', '用户ID', '提交答卷时间', '所用时间', '来源', '来源详情', '来自IP', '总分', '您的姓名', '您的线路', '您的班组', '工作证号码', '您当前的年龄', '您在本岗位的累计工作年限', '您与本岗位关联的最高技能等级', '您当前的最高学历(含在职学历)', '您所学专业是否为理工类专业', ]); 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 normalizeQuestionStem(raw) { return normalizePunctuation(raw) .replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符 .replace(/##[^#]+##/g, '') .replace(/^\d+[,..、]\s*/, '') .replace(/[\r\n\t\f\v]+/g, '') .replace(/\s+/g, '') .toLowerCase(); } function sha(value) { return createHash('sha256').update(String(value)).digest('hex'); } function parseDateToYearMonth(raw) { if (raw instanceof Date && !Number.isNaN(raw.getTime())) { return { year: raw.getFullYear(), month: raw.getMonth() + 1 }; } const value = text(raw); if (!value) return null; const matched = value.match(/(\d{4})[/\-.](\d{1,2})/); if (!matched) return null; const year = Number(matched[1]); const month = Number(matched[2]); if (!year || month < 1 || month > 12) return null; return { year, month }; } function parseFirstSubmitYearMonth(sheet, submitColumn) { if (!submitColumn) return null; for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { const parsed = parseDateToYearMonth(valueOf(sheet.getCell(rowNumber, submitColumn))); if (parsed) return parsed; } return null; } function parseYearMonth({ fileName, explicit, sheet, submitColumn } = {}) { if (explicit?.year && explicit?.month) { return { year: Number(explicit.year), month: Number(explicit.month), source: 'manual', }; } const fromSubmit = parseFirstSubmitYearMonth(sheet, submitColumn); if (fromSubmit) { return { ...fromSubmit, source: 'submit_time' }; } const matched = String(fileName || '').match(/[((]\s*(\d{4})\.(\d{1,2})\s*[))]/); if (!matched) return { year: null, month: null, source: null }; return { year: Number(matched[1]), month: Number(matched[2]), source: 'file_name' }; } function parseElapsedSeconds(raw) { const matched = text(raw).match(/(\d+(?:\.\d+)?)\s*秒?/); return matched ? Math.round(Number(matched[1])) : null; } function headerColumns(sheet) { const columns = []; sheet.getRow(1).eachCell({ includeEmpty: true }, (cell, column) => { const header = text(valueOf(cell)); if (header) columns.push({ column, header }); }); return columns; } async function loadRoster() { const rows = await db.query( `SELECT employee_id, internal_employee_id, name, team, position, details FROM employees WHERE deleted_at IS NULL AND status = 'active' AND (employment_status = '在职' OR employment_status IS NULL)`, ); return (rows || []).map((row) => ({ ...row, remarks: extractRemarks(row.details), })); } async function loadQuestionBank() { const rows = await db.query( `SELECT id, category, content FROM question_banks WHERE deleted_at IS NULL AND status = 'active'`, ); const byStem = new Map(); for (const row of rows) { let content = row.content; if (typeof content === 'string') { try { content = JSON.parse(content); } catch (_) { content = {}; } } const stem = normalizeQuestionStem(content?.question || ''); if (!stem) continue; if (!byStem.has(stem)) byStem.set(stem, []); byStem.get(stem).push({ id: Number(row.id), category: row.category || '未分类', question: content.question || '', }); } return byStem; } function monthWrongIdTag(year, month) { return `${String(year).slice(-2)}/${String(month).padStart(2, '0')}`; } function stripMonthFromWrongIdDates(dates, year, month) { const y = String(year); const yShort = y.slice(-2); const m = String(month); const mPad = m.padStart(2, '0'); const patterns = new Set([`${y}/${mPad}`, `${yShort}/${mPad}`, `${y}/${m}`, `${yShort}/${m}`]); return String(dates || '') .split(',') .map((part) => part.trim()) .filter((part) => part && !patterns.has(part)) .join(', '); } function appendWrongIdDate(dates, year, month) { const tag = monthWrongIdTag(year, month); const stripped = stripMonthFromWrongIdDates(dates, year, month); return stripped ? `${stripped}, ${tag}` : tag; } function countWrongIdDates(dates) { return String(dates || '').split(',').map((part) => part.trim()).filter(Boolean).length; } /** 问卷星「用户ID」多为 OpenID;姓名取「您的姓名」,没有时才回退用户ID */ function resolveExamIdentity(sheet, rowNumber, meta) { const openId = meta['用户ID'] ? text(valueOf(sheet.getCell(rowNumber, meta['用户ID']))) : ''; const realName = meta['您的姓名'] ? text(valueOf(sheet.getCell(rowNumber, meta['您的姓名']))) : ''; const name = realName || openId; return { name, openId: openId || null, nameFrom: realName ? '您的姓名' : (openId ? '用户ID' : null), }; } function toPerson(row) { return { employeeId: row.employee_id, name: row.name, team: row.team, position: row.position, workCertificate: row.internal_employee_id, }; } /** 花名册用「陈浩2/3」区分同名;考试填「陈浩」且工作证一致时视为同名,不算写错 */ function isRosterNameAlias(examName, rosterName) { const exam = text(examName); const roster = text(rosterName); if (!exam || !roster) return false; if (exam === roster) return true; if (roster.startsWith(exam) && /^\d+$/.test(roster.slice(exam.length))) return true; if (exam.startsWith(roster) && /^\d+$/.test(exam.slice(roster.length))) return true; return false; } function toCandidate(row, name, certificate) { const certHit = text(row.internal_employee_id) === certificate; const nameHit = isRosterNameAlias(name, row.name); let reason = '候选匹配(确认后记工号写错)'; let mismatchKind = 'manual'; if (certHit && nameHit) { reason = '工作证命中(花名册同名区分)'; mismatchKind = 'exact'; } else if (certHit && !nameHit) { reason = '工作证命中、姓名不符(勿自动绑定,请核对是否误填工作证)'; mismatchKind = 'name_mismatch'; } else if (nameHit && !certHit) { reason = '姓名命中、工作证未命中(写错工作证)'; mismatchKind = 'certificate_mismatch'; } else if (!nameHit && !certHit) { reason = '姓名与工作证均未直接命中'; mismatchKind = 'both_mismatch'; } return { ...toPerson(row), reason, mismatchKind }; } /** * 匹配口径(5 位工号不参与): * 1) 姓名+11位工作证同时命中 → 正常命中 * 2) 工作证唯一命中,且花名册为「姓名/姓名2」同名区分 → 正常命中(不算写错) * 3) 工作证唯一命中但姓名完全不符 → 待人工核对(禁止自动绑到证主) * 4) 姓名唯一命中但工作证不符(无人/他人) → 待人工核对(禁止自动绑定,确认后记写错工作证) * 5) 冲突 / 同名多人 / 均未命中 → 待核对 */ function matchPerson(name, certificate, roster) { const byCert = roster.filter((row) => text(row.internal_employee_id) === certificate); // 含「陈浩 / 陈浩2」等同名区分 const byName = roster.filter((row) => isRosterNameAlias(name, row.name)); const exact = byCert.find((row) => isRosterNameAlias(name, row.name)); if (exact) { const aliasOnly = text(exact.name) !== text(name); return { status: 'matched', mismatchKind: 'exact', message: aliasOnly ? '工作证命中,花名册同名区分(如姓名2),正常命中' : '姓名与工作证号同时命中', person: { status: 'matched', ...toPerson(exact) }, candidates: [], }; } // 工作证命中他人:禁止自动绑定(如夏再青误填陶云毅的工作证) if (byCert.length === 1) { const conflictCandidates = [...new Map( [...byCert, ...byName].map((row) => [row.employee_id, toCandidate(row, name, certificate)]), ).values()]; return { status: 'unresolved', mismatchKind: byName.length ? 'conflict' : 'name_mismatch', message: byName.length ? `工作证属于「${byCert[0].name}」,姓名指向「${byName.map((row) => row.name).join('/')}」,请人工核对` : `工作证属于花名册「${byCert[0].name}」,与填报姓名「${name}」不符,请人工核对(可能是写错工作证)`, person: null, candidates: conflictCandidates, }; } // 姓名命中、工作证不符:禁止自动绑定(须人工确认后记写错工作证) if (byName.length === 1) { const person = byName[0]; const rosterCert = text(person.internal_employee_id); const conflictCandidates = [...new Map( [...byName, ...byCert].map((row) => [row.employee_id, toCandidate(row, name, certificate)]), ).values()]; return { status: 'unresolved', mismatchKind: byCert.length ? 'conflict' : 'certificate_mismatch', message: byCert.length ? `姓名命中「${person.name}」,但工作证另指向他人,请人工核对` : `姓名命中「${person.name}」,工作证「${certificate}」与花名册「${rosterCert || '无'}」不符,请人工核对(确认后记写错工作证)`, person: null, candidates: conflictCandidates, }; } // 冲突:工作证是 A,姓名是 B(或同名多人 / 工作证多人) const candidates = [...new Map( [...byCert, ...byName].map((row) => [row.employee_id, toCandidate(row, name, certificate)]), ).values()]; let mismatchKind = 'both_mismatch'; let message = '姓名与工作证均未命中花名册'; if (byCert.length && byName.length) { mismatchKind = 'conflict'; message = '工作证与姓名分别指向不同人,请人工核对'; } else if (byCert.length > 1) { mismatchKind = 'certificate_ambiguous'; message = '工作证命中多人,请人工核对'; } else if (byName.length > 1) { mismatchKind = 'name_ambiguous'; message = '姓名命中多人且工作证未命中,请人工核对'; } return { status: 'unresolved', mismatchKind, message, person: null, candidates, }; } function readAnswers( sheet, rowNumber, questionColumns, resolvedQuestions, questionErrors, skippedQuestions, context = {}, ) { const answers = []; const name = text(context.name || ''); const totalScore = context.totalScore; for (const column of questionColumns) { const rawScore = text(valueOf(sheet.getCell(rowNumber, column.column))); if (rawScore === '') continue; if (rawScore !== '0' && rawScore !== '1') { questionErrors.push({ type: 'invalid_score', row: rowNumber, column: column.column, header: column.header, message: `第 ${rowNumber} 行题目得分只能是空/0/1,当前为:${rawScore}`, }); continue; } const question = resolvedQuestions.get(column.column); if (!question) { // 未匹配题库但有人抽到:记录明细供预览展示,本月不计入对错明细(不阻断导入) const questionScore = Number(rawScore); skippedQuestions.push({ row: rowNumber, name: name || '未知', totalScore: Number.isFinite(Number(totalScore)) ? Number(totalScore) : totalScore ?? null, questionScore, questionResult: questionScore === 1 ? '对' : '错', column: column.column, header: column.header, message: `抽到了未匹配题库的题目:${column.header}`, }); continue; } answers.push({ questionId: question.id, score: Number(rawScore), category: question.category, }); } return answers; } async function preview(file, options = {}) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer); const sheet = workbook.worksheets.find((item) => { const headers = new Set(headerColumns(item).map((column) => column.header)); return headers.has('用户ID') && headers.has('工作证号码') && headers.has('总分'); }); if (!sheet) { throw new ApiError(API_CODES.BAD_PARAMS, '未找到包含“用户ID、工作证号码、总分”的工作表', 400); } const columns = headerColumns(sheet); const meta = Object.fromEntries(columns .filter((column) => META_HEADERS.has(column.header)) .map((column) => [column.header, column.column])); const questionColumns = columns.filter((column) => !META_HEADERS.has(column.header)); if (!meta['用户ID'] || !meta['工作证号码'] || !meta['总分']) { throw new ApiError(API_CODES.BAD_PARAMS, '缺少必填列:用户ID、工作证号码、总分', 400); } const period = parseYearMonth({ fileName: file.originalname, explicit: options, sheet, submitColumn: meta['提交答卷时间'], }); const roster = await loadRoster(); const questionBank = await loadQuestionBank(); if (!questionBank.size) { throw new ApiError(API_CODES.BAD_PARAMS, '题库为空,请先完成“导入2:在线考试题库”', 400); } const questionErrors = []; /** @type {Array<{ row: number, name: string, totalScore: number|null, questionScore: number, questionResult: string, column: number, header: string, message: string }>} */ const skippedQuestions = []; const resolvedQuestions = new Map(); const unmatchedColumns = new Map(); let duplicateStemResolved = 0; for (const column of questionColumns) { const stem = normalizeQuestionStem(column.header); const hits = [...(questionBank.get(stem) || [])].sort((a, b) => a.id - b.id); if (!hits.length) { // 表头可能含其他线路题;无人抽到时整列为空,后面再按实际作答校验 unmatchedColumns.set(column.column, column.header); continue; } if (hits.length > 1) { // 问卷星题库存在同题干多条,静默取最小 id,不在预览中展示 duplicateStemResolved += 1; } resolvedQuestions.set(column.column, hits[0]); } const exams = []; const personIssues = []; const autoIgnoredPeople = []; /** @type {Map} */ const claimedCertificates = new Map(); let retakeDropped = 0; for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber += 1) { const identity = resolveExamIdentity(sheet, rowNumber, meta); const name = identity.name; const certificate = text(valueOf(sheet.getCell(rowNumber, meta['工作证号码']))); const totalScore = text(valueOf(sheet.getCell(rowNumber, meta['总分']))); if (!name && !certificate && !totalScore) continue; const parsedTotalScore = Number(totalScore); const answers = readAnswers( sheet, rowNumber, questionColumns, resolvedQuestions, questionErrors, skippedQuestions, { name, totalScore: Number.isFinite(parsedTotalScore) ? parsedTotalScore : null, }, ); const score = Number(totalScore); const elapsedSeconds = parseElapsedSeconds(valueOf(sheet.getCell(rowNumber, meta['所用时间'] || 0))); const sourceDetail = meta['来源详情'] ? text(valueOf(sheet.getCell(rowNumber, meta['来源详情']))) : ''; const base = { sourceRow: rowNumber, sourceName: name, sourceOpenId: identity.openId, sourceCertificate: certificate, // 仅预览展示,不落库 sourceDetail, score: Number.isFinite(score) ? score : null, elapsedSeconds, answers, }; if (!name || !certificate) { autoIgnoredPeople.push({ issueId: sha(`${rowNumber}|missing-fields`), row: rowNumber, ...base, mismatchKind: 'missing_fields', message: '缺少您的姓名或工作证号码,已自动选择「忽略该行」(不导入)', candidates: [], autoIgnored: true, }); continue; } const matched = matchPerson(name, certificate, roster); // 同人重考(姓名+工作证完全一致):只保留最低分 if (claimedCertificates.has(certificate)) { const prev = claimedCertificates.get(certificate); if ( matched.status === 'matched' && matched.person.employeeId === prev.employeeId ) { const candidate = { ...base, person: matched.person, wrongId: false, mismatchKind: 'exact', }; const existing = exams[prev.examIndex]; const keepCandidate = shouldKeepLowerScoreExam(candidate, existing); const dropped = keepCandidate ? existing : candidate; const kept = keepCandidate ? candidate : existing; const droppedRows = [ ...(existing.retakeDroppedRows || []), dropped.sourceRow, ].filter((row, index, arr) => arr.indexOf(row) === index && row !== kept.sourceRow); exams[prev.examIndex] = { ...kept, retakeDroppedRows: droppedRows, retakeNote: `同人重考 ${droppedRows.length + 1} 次,已保留最低分 ${kept.score ?? '—'}(丢弃行:${droppedRows.join('、')})`, }; retakeDropped += 1; continue; } // 工作证已被他人占用:误填他人证号 → 人工核对 const dupCandidates = matched.candidates?.length ? matched.candidates : roster .filter((row) => text(row.internal_employee_id) === certificate || isRosterNameAlias(name, row.name)) .map((row) => toCandidate(row, name, certificate)); personIssues.push({ issueId: sha(`${rowNumber}|duplicate-cert|${certificate}`), row: rowNumber, ...base, mismatchKind: 'duplicate_certificate', message: '工作证号码在本文件中已被其他人占用,请人工核对(可能误填了他人工作证)', candidates: [...new Map(dupCandidates.map((item) => [item.employeeId, item])).values()], }); continue; } if (matched.status === 'matched') { claimedCertificates.set(certificate, { examIndex: exams.length, employeeId: matched.person.employeeId, }); exams.push({ ...base, person: matched.person, wrongId: false, mismatchKind: 'exact' }); } else if (!(matched.candidates || []).length) { autoIgnoredPeople.push({ issueId: sha(`${rowNumber}|${name}|${certificate}|ignore`), row: rowNumber, ...base, mismatchKind: matched.mismatchKind || 'both_mismatch', message: '花名册无匹配候选,已自动选择「忽略该行」(不导入)', candidates: [], autoIgnored: true, }); } else { personIssues.push({ issueId: sha(`${rowNumber}|${name}|${certificate}`), row: rowNumber, ...base, mismatchKind: matched.mismatchKind, message: matched.message, candidates: matched.candidates, }); } } const matchedIds = new Set(exams.map((exam) => exam.person.employeeId)); const secondmentSkipped = roster .filter((row) => !matchedIds.has(row.employee_id)) .filter((row) => isSecondmentRemarks(row.remarks)) .map((row) => ({ employeeId: row.employee_id, name: row.name, team: row.team, position: row.position, remarks: row.remarks, workCertificate: row.internal_employee_id, reason: '借调免考', })); const missing = roster .filter((row) => !isExcludedFromMissingExam(row)) .filter((row) => !matchedIds.has(row.employee_id)) .map((row) => ({ employeeId: row.employee_id, name: row.name, team: row.team, position: row.position, workCertificate: row.internal_employee_id, })); const skippedColumns = new Set(skippedQuestions.map((item) => item.column)); const ignoredUnmatchedQuestions = [...unmatchedColumns.keys()] .filter((column) => !skippedColumns.has(column)) .length; const importId = randomUUID(); const payload = { importId, fileName: file.originalname, fileHash: sha(file.buffer), fileSize: file.size, sheetName: sheet.name, year: period.year, month: period.month, yearMonthSource: period.source, exams, personIssues, autoIgnoredPeople, secondmentSkipped, missing, questionErrors, // Redis 只存列级摘要,避免逐人次题干把预览撑爆;完整明细仅回传前端展示 skippedQuestions: [...skippedColumns].map((column) => { const rows = skippedQuestions.filter((item) => item.column === column); return { column, header: rows[0]?.header || unmatchedColumns.get(column) || '', answerCount: rows.length, }; }), resolvedQuestionCount: resolvedQuestions.size, duplicateStemResolved, ignoredUnmatchedQuestions, retakeDropped, }; await redis.client.set(`${PREVIEW_PREFIX}${importId}`, JSON.stringify(payload), 'EX', PREVIEW_TTL_SECONDS); const retakePeople = exams.filter((exam) => (exam.retakeDroppedRows || []).length > 0); return { importId, fileName: file.originalname, sheetName: sheet.name, year: period.year, month: period.month, yearMonthSource: period.source, summary: { examinees: exams.length + personIssues.length + autoIgnoredPeople.length + retakeDropped, matchedPeople: exams.filter((exam) => !exam.wrongId).length, wrongIdAuto: exams.filter((exam) => exam.wrongId).length, wrongIdName: exams.filter((exam) => exam.wrongIdReason === 'name_mismatch').length, wrongIdCertificate: exams.filter((exam) => exam.wrongIdReason === 'certificate_mismatch').length, unresolvedPeople: personIssues.length, autoIgnoredPeople: autoIgnoredPeople.length, retakeDropped, retakePeople: retakePeople.length, missingExams: missing.length, secondmentSkipped: secondmentSkipped.length, questionColumns: questionColumns.length, matchedQuestions: resolvedQuestions.size, ignoredUnmatchedQuestions, skippedQuestions: skippedColumns.size, skippedAnswers: skippedQuestions.length, questionErrors: questionErrors.length, duplicateStemResolved, avgAnswers: exams.length ? Math.round(exams.reduce((sum, exam) => sum + exam.answers.length, 0) / exams.length) : 0, }, questionErrors: questionErrors.slice(0, 200), skippedQuestions: skippedQuestions.slice(0, 200), personIssues, autoIgnoredPeople, retakePreview: retakePeople.slice(0, 50).map((exam) => ({ name: exam.person?.name || exam.sourceName, workCertificate: exam.sourceCertificate, keptScore: exam.score, keptRow: exam.sourceRow, droppedRows: exam.retakeDroppedRows || [], note: exam.retakeNote, })), missingPreview: missing.slice(0, 50), secondmentSkippedPreview: secondmentSkipped.slice(0, 50), canCommit: questionErrors.length === 0 && personIssues.length === 0 && Boolean(period.year) && Boolean(period.month) && exams.length > 0, }; } function resolveExams(payload, selections = {}) { const unresolved = []; // 完全命中不记写错;人工核对确认的在下方统一记 wrongId const resolved = (payload.exams || []).map((exam) => ({ ...exam, wrongId: Boolean(exam.wrongId), wrongIdReason: exam.wrongId ? (exam.wrongIdReason || exam.mismatchKind || 'manual_resolve') : null, })); for (const issue of payload.personIssues || []) { const selected = selections[issue.issueId]; if (!selected) { unresolved.push(issue); continue; } if (selected === '__IGNORE__') continue; const employee = (issue.candidates || []).find((item) => item.employeeId === selected); if (!employee) { unresolved.push({ ...issue, message: '选择的花名册人员无效' }); continue; } const nameMismatch = !isRosterNameAlias(issue.sourceName, employee.name); const certMismatch = text(issue.sourceCertificate) !== text(employee.workCertificate); resolved.push({ sourceRow: issue.sourceRow, sourceName: issue.sourceName, sourceOpenId: issue.sourceOpenId || null, sourceCertificate: issue.sourceCertificate, score: issue.score, elapsedSeconds: issue.elapsedSeconds, answers: issue.answers || [], // 凡人工核对确认的,一律记工号写错(考核口径) wrongId: true, wrongIdReason: nameMismatch && certMismatch ? 'name_and_certificate_mismatch' : nameMismatch ? 'name_mismatch' : certMismatch ? 'certificate_mismatch' : 'manual_resolve', person: { status: 'matched', employeeId: employee.employeeId, name: employee.name, team: employee.team, position: employee.position, workCertificate: employee.workCertificate, }, }); } return { unresolved, resolved }; } /** 重考/重复:优先保留更低分;分数相同保留较晚行 */ function shouldKeepLowerScoreExam(candidate, existing) { const oldScore = Number(existing.score); const newScore = Number(candidate.score); if (Number.isFinite(newScore) && Number.isFinite(oldScore) && newScore !== oldScore) { return newScore < oldScore; } if (Number.isFinite(newScore) && !Number.isFinite(oldScore)) return true; if (!Number.isFinite(newScore) && Number.isFinite(oldScore)) return false; return Number(candidate.sourceRow || 0) >= Number(existing.sourceRow || 0); } /** 同一人同月只保留一条,避免 exam_uuid 唯一键冲突;重考保留最低分 */ function dedupeResolvedExams(resolved) { const byEmployee = new Map(); for (const exam of resolved) { const employeeId = exam.person?.employeeId; if (!employeeId) continue; const existing = byEmployee.get(employeeId); if (!existing) { byEmployee.set(employeeId, exam); continue; } const preferNew = (() => { const oldWrong = Boolean(existing.wrongId); const newWrong = Boolean(exam.wrongId); if (oldWrong !== newWrong) return oldWrong && !newWrong; // 优先完全命中 return shouldKeepLowerScoreExam(exam, existing); })(); if (preferNew) byEmployee.set(employeeId, exam); } return { exams: [...byEmployee.values()], duplicateCount: Math.max(0, resolved.length - byEmployee.size), }; } async function commit({ importId, year, month, 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); const period = parseYearMonth({ fileName: payload.fileName, explicit: { year: year || payload.year, month: month || payload.month, }, }); if (!period.year || !period.month) { throw new ApiError(API_CODES.BAD_PARAMS, '未能识别年月,请手动选择后再提交', 400); } // 仅非法得分(非空/0/1)阻断;未匹配题库的题目已在预览阶段跳过,不写入对错明细 if ((payload.questionErrors || []).length) { throw new ApiError(API_CODES.BAD_PARAMS, `存在 ${payload.questionErrors.length} 条题目得分格式错误,禁止提交`, 400); } const { unresolved, resolved: resolvedRaw } = resolveExams(payload, selections); if (unresolved.length) { throw new ApiError(API_CODES.BAD_PARAMS, `仍有 ${unresolved.length} 名人员未完成花名册核对`, 400); } const { exams: resolved, duplicateCount } = dedupeResolvedExams(resolvedRaw); if (!resolved.length) throw new ApiError(API_CODES.BAD_PARAMS, '没有可导入的考试记录', 400); const roster = await loadRoster(); const matchedIds = new Set(resolved.map((exam) => exam.person.employeeId)); const missing = roster .filter((row) => !isExcludedFromMissingExam(row)) .filter((row) => !matchedIds.has(row.employee_id)); const batchUuid = randomUUID(); const missingMonth = `${period.year}-${String(period.month).padStart(2, '0')}`; await db.transaction(async (conn) => { await conn.execute( `INSERT INTO upload_batches (batch_uuid, module, data_type, year, month, file_name, file_hash, file_size, record_count, status, payload, operator_id, locked_at) VALUES (?, 'online_exams', 'xlsx', ?, ?, ?, ?, ?, ?, 'lock-writing', ?, ?, NOW())`, [ batchUuid, period.year, period.month, payload.fileName, payload.fileHash, payload.fileSize, resolved.length + missing.length, JSON.stringify({ paperClass: PAPER_CLASS, duplicateCount, skippedQuestions: payload.skippedQuestions || [], }), operator.id, ], ); // 覆盖本月前:撤掉上次管理部月考写入的工号写错月份标记 const [previousWrongRows] = await conn.execute( `SELECT employee_id, details FROM online_exams WHERE exam_year = ? AND exam_month = ? AND paper_class = ? AND JSON_EXTRACT(details, '$.wrongId') = true`, [period.year, period.month, PAPER_CLASS], ); for (const row of previousWrongRows) { const [employeeRows] = await conn.execute( 'SELECT details FROM employees WHERE employee_id = ? AND deleted_at IS NULL LIMIT 1', [row.employee_id], ); if (!employeeRows.length) continue; let details = employeeRows[0].details; if (typeof details === 'string') { try { details = JSON.parse(details); } catch (_) { details = {}; } } details = details && typeof details === 'object' ? details : {}; const nextDates = stripMonthFromWrongIdDates(details.wrongIdDates, period.year, period.month); details.wrongIdDates = nextDates; details.wrongIdCount = countWrongIdDates(nextDates); await conn.execute( `UPDATE employees SET details = ?, version = version + 1 WHERE employee_id = ?`, [JSON.stringify(details), row.employee_id], ); } await conn.execute( 'DELETE FROM online_exams WHERE exam_year = ? AND exam_month = ? AND paper_class = ?', [period.year, period.month, PAPER_CLASS], ); await conn.execute( `DELETE FROM missing_exams WHERE missing_month = ? AND JSON_UNQUOTE(JSON_EXTRACT(details, '$.source')) = 'monthly_exam'`, [missingMonth], ); for (const exam of resolved) { const score = Number(exam.score); const result = Number.isFinite(score) ? (score >= 60 ? 'pass' : 'fail') : null; const wrongCount = (exam.answers || []).filter((answer) => Number(answer.score) === 0).length; const examUuid = sha(`monthly-exam|${period.year}|${period.month}|${exam.person.employeeId}`).slice(0, 64); const wrongId = Boolean(exam.wrongId); await conn.execute( `INSERT INTO online_exams (employee_id, paper_id, paper_class, score, result, faults, choice_faults, duty_point, exam_year, exam_month, submit_time, elapsed_seconds, details, extras, exam_uuid, upload_batch, created_by, version) VALUES (?, NULL, ?, ?, ?, ?, NULL, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, 0)`, [ exam.person.employeeId, PAPER_CLASS, Number.isFinite(score) ? score : null, result, wrongCount, exam.person.team || null, period.year, period.month, exam.elapsedSeconds, JSON.stringify({ source: 'monthly_exam', sourceName: exam.sourceName, sourceOpenId: exam.sourceOpenId || null, workCertificate: exam.sourceCertificate, // 工号写错只记考核标记;已绑到花名册人员,不算「查无此人」 wrongId, wrongIdReason: wrongId ? (exam.wrongIdReason || 'manual_resolve') : null, isUnknownEmployee: false, answers: (exam.answers || []).map((answer) => ({ questionId: answer.questionId, score: Number(answer.score), category: answer.category || '未分类', })), }), JSON.stringify({ fileName: payload.fileName, sourceRow: exam.sourceRow }), examUuid, batchUuid, operator.id, ], ); if (wrongId) { const [employeeRows] = await conn.execute( 'SELECT details FROM employees WHERE employee_id = ? AND deleted_at IS NULL LIMIT 1', [exam.person.employeeId], ); if (employeeRows.length) { let details = employeeRows[0].details; if (typeof details === 'string') { try { details = JSON.parse(details); } catch (_) { details = {}; } } details = details && typeof details === 'object' ? details : {}; const nextDates = appendWrongIdDate(details.wrongIdDates, period.year, period.month); details.wrongIdDates = nextDates; details.wrongIdCount = countWrongIdDates(nextDates); await conn.execute( `UPDATE employees SET details = ?, version = version + 1 WHERE employee_id = ?`, [JSON.stringify(details), exam.person.employeeId], ); } } } for (const row of missing) { await conn.execute( `INSERT INTO missing_exams (employee_id, missing_month, reason, import_time, details, extras) VALUES (?, ?, '缺考', NOW(), ?, ?)`, [ row.employee_id, missingMonth, JSON.stringify({ source: 'monthly_exam', name: row.name, team: row.team, position: row.position, }), JSON.stringify({ fileName: payload.fileName }), ], ); } await conn.execute( `UPDATE upload_batches SET status = 'precomputing' WHERE batch_uuid = ?`, [batchUuid], ); }); await sync.bumpVersions([ 'employees', 'online_exams', 'missing_exams', ]); 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, roleCode: operator.roles?.[0], clientType: 'ui', action: 'monthly_exam_import', module: 'imports', targetType: 'upload_batch', targetId: batchUuid, payload: { fileName: payload.fileName, year: period.year, month: period.month, exams: resolved.length, missing: missing.length, wrongId: resolved.filter((exam) => exam.wrongId).length, }, ip: meta.ip, userAgent: meta.userAgent, }); await redis.client.del(`${PREVIEW_PREFIX}${importId}`); return { batchUuid, year: period.year, month: period.month, counts: { exams: resolved.length, missing: missing.length, wrongId: resolved.filter((exam) => exam.wrongId).length, duplicatesMerged: duplicateCount, }, }; } async function recomputeCategoryStats(year, month) { const exams = await db.query( `SELECT o.employee_id, o.details, COALESCE(e.team, '未分组') AS team FROM online_exams o LEFT JOIN employees e ON e.employee_id = o.employee_id AND e.deleted_at IS NULL WHERE o.exam_year = ? AND o.exam_month = ? AND o.paper_class = ?`, [year, month, PAPER_CLASS], ); const buckets = new Map(); const touch = (scopeType, scopeKey, category, score) => { const key = `${scopeType}|${scopeKey}|${category}`; if (!buckets.has(key)) { buckets.set(key, { scopeType, scopeKey, category, questionCount: 0, correctCount: 0, }); } const bucket = buckets.get(key); bucket.questionCount += 1; bucket.correctCount += Number(score) === 1 ? 1 : 0; }; for (const exam of exams) { let details = exam.details; if (typeof details === 'string') { try { details = JSON.parse(details); } catch (_) { details = {}; } } for (const answer of Array.isArray(details?.answers) ? details.answers : []) { const category = answer.category || '未分类'; touch('employee', exam.employee_id, category, answer.score); touch('team', exam.team || '未分组', category, answer.score); touch('line', LINE_SCOPE, category, answer.score); } } await db.transaction(async (conn) => { await conn.execute( 'DELETE FROM online_exam_category_stats_monthly WHERE year = ? AND month = ?', [year, month], ); for (const bucket of buckets.values()) { await conn.execute( `INSERT INTO online_exam_category_stats_monthly (year, month, scope_type, scope_key, category, question_count, correct_count, score_rate, computed_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), 0)`, [ year, month, bucket.scopeType, bucket.scopeKey, bucket.category, bucket.questionCount, bucket.correctCount, bucket.questionCount ? Number((bucket.correctCount / bucket.questionCount).toFixed(4)) : null, ], ); } await conn.execute('DELETE FROM online_exam_category_stats_yearly WHERE year = ?', [year]); await conn.execute( `INSERT INTO online_exam_category_stats_yearly (year, scope_type, scope_key, category, question_count, correct_count, score_rate, monthly_breakdown, computed_at, version) SELECT year, scope_type, scope_key, category, SUM(question_count), SUM(correct_count), ROUND(SUM(correct_count) / NULLIF(SUM(question_count), 0), 4), JSON_OBJECTAGG(CAST(month AS CHAR), score_rate), NOW(), 0 FROM online_exam_category_stats_monthly WHERE year = ? GROUP BY year, scope_type, scope_key, category`, [year], ); await conn.execute( `DELETE FROM online_exam_stats_monthly WHERE year = ? AND month = ? AND team = ? AND paper_class = ?`, [year, month, LINE_SCOPE, PAPER_CLASS], ); 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 ?, ?, ?, ?, COUNT(*), SUM(result = 'pass'), SUM(result = 'fail'), ROUND(SUM(result = 'pass') / NULLIF(COUNT(*), 0), 4), ROUND(AVG(score), 2), MAX(score), MIN(score), ROUND(AVG(elapsed_seconds)), 0, NOW(), 0 FROM online_exams WHERE exam_year = ? AND exam_month = ? AND paper_class = ?`, [year, month, LINE_SCOPE, PAPER_CLASS, year, month, PAPER_CLASS], ); await conn.execute( `DELETE FROM online_exam_stats_yearly WHERE year = ? AND team = ? AND paper_class = ?`, [year, LINE_SCOPE, PAPER_CLASS], ); 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), avg_score), NOW(), 0 FROM online_exam_stats_monthly WHERE year = ? AND team = ? AND paper_class = ? GROUP BY year, team, paper_class`, [year, LINE_SCOPE, PAPER_CLASS], ); }); } module.exports = { preview, commit, recomputeCategoryStats, normalizeQuestionStem, PAPER_CLASS, LINE_SCOPE, };