Agent/server/src/import/monthly-exams.js

1149 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<string, { examIndex: number, employeeId: string }>} */
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,
};