1149 lines
42 KiB
JavaScript
1149 lines
42 KiB
JavaScript
'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,
|
||
};
|