'use strict'; /** * 大题成绩 Excel 导入 —— 纯判定层(R172 建,R174 改三小问四列制) * * 为什么单独抽一个 lib: * 1) **可被 `--self-test` 直接自证**:本文件零依赖(不 require Egg / 不连库 / 不读文件), * `scripts/verify_big_score_import.py` 可直接 `node -e "require(...)"` 拿到**真实实现**, * 而不是在测试里另写一份「看起来一样」的副本(⛔ 副本自证 = 恒绿假自证)。 * 2) 判据与主流程**同源**:service/bigQuestion.js 只能调这里,不得内联第二套算法。 * * 业务口径(用户 2026-10-19 / 2026-10-20 两次裁定): * · Excel 为**四列**:`小组编号` + `第1小问` + `第2小问` + `第3小问`,四列都必填; * 总分由系统**求和**,表中不再有「分数」列。 * · **任一小问留空 ⇒ 整行拒绝并报错**(用户明确裁定:留空按 0 会静默抹分)。 * · **缺任一小问列 ⇒ 拒绝整份文件**,并提示重新下载模板。 * · **旧版两列模板(小组编号 + 分数)不再支持**,整份拒绝并提示改用三小问模板。 * · **一个小组只会有一人参加考试**(真正抽题作答的那位),但成绩**组内共享** * —— 因此只需把分写给「抽题者」,其余成员由 score.js `_resolveTeamScoreSource` 共享读取; * · 已评分默认拒绝,需显式勾选「覆盖已评分」。 * * ⚠️ R174 变更史(改动前必读): * 旧实现只有 `scoreRaw` 一列总分,由 `splitScoreAcrossSubs()` **均分**到三小问。 * 用户指出「模板忘了改成三小问形式」,故: * · 删除 `splitScoreAcrossSubs()`; * · 新增 `scoreSubs()`(逐项取值 + 逐项校验,不再做均分); * · 行状态 `SCORE_MISSING/SCORE_INVALID/SCORE_OUT_OF_RANGE` → `SUB_MISSING/SUB_INVALID/SUB_OUT_OF_RANGE`; * · 文件级错误码新增 `LEGACY_TEMPLATE` / `SUB_COLUMNS_MISSING`。 * ⛔ 不得为「兼容老文件」保留均分分支 —— 用户已裁定「不再支持,只认三小问模板」。 */ /** 小问数量(题制固定 3 问,与 BIG_QUESTION 域一致) */ const SUB_COUNT = 3; /** 默认每小问分值(题库可配 score_per_sub,此处仅兜底) */ const DEFAULT_PER_SUB = 10; /** 分数两位小数(与 DECIMAL(6,2) 同口径) */ const SCORE_SCALE = 100; /** 表头别名(与实际 Excel 填写习惯对齐;⛔ 大小写与空格在匹配前已归一) */ const COLUMN_ALIAS = { teamNumber: ['小组编号', '队伍编号', '团队编号', '小组号', '队号', 'team_number', 'teamnumber', 'team_id', 'teamid'], sub1: [ '第1小问', '第1问', '小问1', '小问一', '第一小问', '1小问', 'score1', 'score_1', 'sub1', 'sub_score_1', '大题第1小问', ], sub2: [ '第2小问', '第2问', '小问2', '小问二', '第二小问', '2小问', 'score2', 'score_2', 'sub2', 'sub_score_2', '大题第2小问', ], sub3: [ '第3小问', '第3问', '小问3', '小问三', '第三小问', '3小问', 'score3', 'score_3', 'sub3', 'sub_score_3', '大题第3小问', ], /** * 旧版两列模板的「总分」列。 * ⚠️ 只用于**识别旧文件并给出可读拒绝**,绝不参与取值(用户裁定:不再支持旧模板)。 */ legacyTotal: ['分数', '得分', '成绩', '总分', '大题得分', '大题总分', 'score', 'totalscore', 'total'], }; const ROW_STATUS = { OK: 'OK', // 可导入(该考生尚未评分) OVERWRITE: 'OVERWRITE', // 可导入,但会覆盖既有评分 ⇒ 需显式勾选 EMPTY_ROW: 'EMPTY_ROW', // 整行空白 ⇒ 静默跳过,不计错误 TEAM_MISSING: 'TEAM_MISSING', // 小组编号列为空 SUB_MISSING: 'SUB_MISSING', // 至少一个小问留空 ⇒ 整行拒绝(不按 0 分处理) SUB_INVALID: 'SUB_INVALID', // 小问不是数值 / 负数 SUB_OUT_OF_RANGE: 'SUB_OUT_OF_RANGE', // 某小问超过单价分值(perSub) DUPLICATE_IN_FILE: 'DUPLICATE_IN_FILE', // 同一文件内该小组编号出现多次 TEAM_NOT_FOUND: 'TEAM_NOT_FOUND', // 全库都没有这个小组编号 TEAM_IN_OTHER_GROUP: 'TEAM_IN_OTHER_GROUP', // 小组编号存在,但属于别的组 NO_OPERATOR: 'NO_OPERATOR', // 组内无人抽取大题 ⇒ 无 rows 可评分 MULTI_OPERATOR: 'MULTI_OPERATOR', // 组内多人抽取大题 ⇒ 违反「一组一人参赛」,拒绝猜测 }; /** 判定为「阻断」的状态(不写库、计入错误) */ const BLOCKING_STATUS = [ ROW_STATUS.TEAM_MISSING, ROW_STATUS.SUB_MISSING, ROW_STATUS.SUB_INVALID, ROW_STATUS.SUB_OUT_OF_RANGE, ROW_STATUS.DUPLICATE_IN_FILE, ROW_STATUS.TEAM_NOT_FOUND, ROW_STATUS.TEAM_IN_OTHER_GROUP, ROW_STATUS.NO_OPERATOR, ROW_STATUS.MULTI_OPERATOR, ]; /** 文件级错误码(`parseScoreRows` 返回的 `code`) */ const FILE_CODE = { EMPTY_FILE: 'EMPTY_FILE', HEADER_NOT_FOUND: 'HEADER_NOT_FOUND', LEGACY_TEMPLATE: 'LEGACY_TEMPLATE', // 只有「分数」一列 ⇒ 旧版两列模板 SUB_COLUMNS_MISSING: 'SUB_COLUMNS_MISSING', // 有小问列但不足三列 NO_DATA_ROW: 'NO_DATA_ROW', }; /** * 规范化「小组编号」单元格。 * * Excel 常见污染:首尾空格、全角空格(U+3000)、不换行空格(U+00A0)、零宽字符(U+200B/200C/200D/FEFF)。 * 这些肉眼不可见,但会让「BRICS2026-ST-027-91001」与库中值不相等 ⇒ 全部行莫名「小组编号不存在」。 * * ⛔ 不做大小写折叠:队号含字母,大小写是其字面的一部分,擅自 toUpperCase 会掩盖录入错误。 * @param {*} v * @returns {string} */ function normalizeTeamNumber(v) { if (v === null || v === undefined) return ''; return String(v) .replace(/[\u3000\u00a0\u200b-\u200d\ufeff]/g, '') .trim(); } /** 表头单元格归一:去空白/下划线/全角空格后小写,仅用于别名匹配 */ function normalizeHeader(v) { if (v === null || v === undefined) return ''; return String(v) .replace(/[\s\u3000\u00a0_\-()()]/g, '') .toLowerCase(); } /** * 按别名在同义词表里定位列下标。 * @param {Array} headerRow * @param {Array} aliases * @returns {number} 命中列下标,未命中 -1 */ function findColumn(headerRow, aliases) { const cells = (headerRow || []).map(normalizeHeader); for (const alias of aliases) { const want = normalizeHeader(alias); const idx = cells.indexOf(want); if (idx >= 0) return idx; } return -1; } /** 单元格是否「空」(null / undefined / 纯空白串三者都算空) */ function isBlank(v) { return v === null || v === undefined || String(v).trim() === ''; } /** * 逐项取三小问得分并校验(**不再均分**)。 * * 校验顺序(顺序即报错优先级,⛔ 不可调换): * 1) 先扫全量「留空」⇒ 一次性报出**所有**空列(用户裁定:留空整行拒绝,不按 0 分处理); * 2) 再逐项验「是否数值 / 是否负数」; * 3) 再逐项验「是否超过单问上限 perSub」。 * * 为什么「留空」优先于「数值非法」: * 评卷人填到一半时最常见的形态就是「后两问还空着」,此时报「第 2、3 小问未填写」 * 比报「第 2 小问 xx 不是数值」更贴近真实原因。 * * ⚠️ 为什么不给「留空按 0」的宽松模式: * 用户 2026-10-20 明确裁定「整行拒绝并报错」——留空按 0 会**静默抹掉**已评分数, * 且导入后三问合计与评卷人预期不一致时,无从判断是漏填还是真 0 分。 * * @param {Array<*>} subRaws - 三小问原始单元格值,长度不足按空处理 * @param {number} [subCount=3] * @param {number} [perSub=10] * @returns {{ok:boolean, subs?:number[], total?:number, message?:string, status?:string}} */ function scoreSubs(subRaws, subCount, perSub) { const n = Number.isFinite(Number(subCount)) && Number(subCount) > 0 ? Math.floor(Number(subCount)) : SUB_COUNT; const per = Number.isFinite(Number(perSub)) && Number(perSub) > 0 ? Number(perSub) : DEFAULT_PER_SUB; const perCents = Math.round(per * SCORE_SCALE); const list = Array.isArray(subRaws) ? subRaws : []; const raws = []; for (let i = 0; i < n; i++) raws.push(list[i]); // 1) 留空优先:一次性报出所有空列 const emptyIdx = []; raws.forEach((raw, i) => { if (isBlank(raw)) emptyIdx.push(i + 1); }); if (emptyIdx.length) { return { ok: false, status: ROW_STATUS.SUB_MISSING, message: `第 ${emptyIdx.join('、')} 小问未填写(三小问必须逐项填写,留空不会按 0 分处理)`, }; } // 2) / 3) 逐项验数值与上限 const cents = []; for (let i = 0; i < n; i++) { const raw = raws[i]; const v = Number(raw); if (!Number.isFinite(v)) { return { ok: false, status: ROW_STATUS.SUB_INVALID, message: `第 ${i + 1} 小问「${raw}」不是数值`, }; } const c = Math.round(v * SCORE_SCALE); if (c < 0) { return { ok: false, status: ROW_STATUS.SUB_INVALID, message: `第 ${i + 1} 小问不能为负数(填的是 ${(c / SCORE_SCALE).toFixed(2)})`, }; } if (c > perCents) { return { ok: false, status: ROW_STATUS.SUB_OUT_OF_RANGE, message: `第 ${i + 1} 小问 ${(c / SCORE_SCALE).toFixed(2)} 超过单问上限 ${(perCents / SCORE_SCALE).toFixed(2)} 分`, }; } cents.push(c); } const totalCents = cents.reduce((s, c) => s + c, 0); return { ok: true, subs: cents.map(c => c / SCORE_SCALE), total: totalCents / SCORE_SCALE, message: '', }; } /** * 单行判定(纯函数,零 IO)。 * * 入参是**预查好的上下文**,判定逻辑与主流程共用同一份代码: * · foundInGroup / foundInOtherGroup 由 service 查 teams 得到; * · operatorCount = 该小组内「有 big_question_answers 行」的考生数; * · currentTotal = 该考生现有大题总分(null/undefined ⇒ 未评分)。 * * ⚠️ 判定顺序即优先级:先排「表格自身问题」,再排「编码/归属问题」,最后才是「覆盖确认」。 * 顺序不能乱:否则「已评分但小问越界」会被报成 OVERWRITE,前端勾选覆盖后照样写不进去。 * * @param {Object} input * @param {*} input.teamNumber 小组编号单元格原值 * @param {Array<*>} input.subRaws 三小问原始单元格值 * @returns {{status:string, message:string, subs?:number[], total?:number}} */ function classifyScoreRow(input) { const it = input || {}; const teamNumber = normalizeTeamNumber(it.teamNumber); const subRaws = Array.isArray(it.subRaws) ? it.subRaws : []; const allSubsBlank = subRaws.every(isBlank); // 1) 整行空白 ⇒ 静默跳过(模板示例后的空行常见) if (!teamNumber && allSubsBlank) { return { status: ROW_STATUS.EMPTY_ROW, message: '' }; } // 2) 小组编号为空 if (!teamNumber) { return { status: ROW_STATUS.TEAM_MISSING, message: '小组编号为空' }; } // 3) 本文件内重复 if (Number(it.dupCount) > 1) { return { status: ROW_STATUS.DUPLICATE_IN_FILE, message: `小组编号「${teamNumber}」在本文件中出现 ${it.dupCount} 次,无法判断以哪行为准 ⇒ 全部拒绝`, }; } // 4) 三小问本身非法(留空 / 非数值 / 负数 / 越界) const subs = scoreSubs(subRaws, it.subCount, it.perSub); if (!subs.ok) { return { status: subs.status, message: subs.message }; } // 5) 小组编号归属 if (!it.foundInGroup) { if (it.foundInOtherGroup) { return { status: ROW_STATUS.TEAM_IN_OTHER_GROUP, // ⚠️ otherGroupLabel 已含「组」字(GROUP_LABELS 的值形如「高职组」)⇒ 这里不得再拼「组」 message: `小组编号「${teamNumber}」属于${it.otherGroupLabel || '其他组别'},请在对应组别页签导入`, }; } return { status: ROW_STATUS.TEAM_NOT_FOUND, message: `小组编号「${teamNumber}」在本组不存在(请核对空格 / 是否为纯数字编号被 Excel 转成数值)`, }; } // 6) 参赛者(抽题者)唯一性 const opCount = Number(it.operatorCount) || 0; if (opCount === 0) { return { status: ROW_STATUS.NO_OPERATOR, message: '该小组尚无人抽取综合大题,无法录入成绩(考试结束后再导入)', }; } if (opCount > 1) { return { status: ROW_STATUS.MULTI_OPERATOR, message: `该小组有 ${opCount} 人抽取了大题,与「一组一人参赛」不符 ⇒ 拒绝猜测,请先人工核对`, }; } // 7) 覆盖确认 const hasScore = !isBlank(it.currentTotal); if (hasScore) { return { status: ROW_STATUS.OVERWRITE, message: `该考生已评分(${Number(it.currentTotal).toFixed(2)} 分),需勾选「覆盖已评分」才会写入`, subs: subs.subs, total: subs.total, }; } return { status: ROW_STATUS.OK, message: '', subs: subs.subs, total: subs.total }; } /** * 把 sheet 的 AOA 解析成规范化行(纯函数)。 * * 表头契约:**同一行**须同时命中「小组编号」列 + 三个小问列。 * 缺列时的错误码必须可区分,否则用户只看到「表头未找到」,不知道是自己删了列还是用了旧模板: * · 只有「分数」一列(无任何小问列)⇒ `LEGACY_TEMPLATE`(提示重新下载三小问模板) * · 有小问列但不足三列 ⇒ `SUB_COLUMNS_MISSING`(点名缺第几列) * · 两者都认不出 ⇒ `HEADER_NOT_FOUND` * * @param {Array} aoa - 含表头的二维数组(`sheet_to_json(sheet, { header: 1 })`) * @returns {{ok:boolean, code?:string, message?:string, rows?:Array}} */ function parseScoreRows(aoa) { const grid = Array.isArray(aoa) ? aoa : []; if (!grid.length) { return { ok: false, code: FILE_CODE.EMPTY_FILE, message: 'Excel 内容为空(首行须为表头,第 1 页为「成绩导入」)', }; } // 表头可能不在第 1 行(用户常先写标题)⇒ 在前 5 行里找同时命中「编号 + 三小问」的 let headerRowIdx = -1; let teamCol = -1; let subCols = null; /** 部分命中的证据,用于给出可区分的拒绝理由 */ let partial = null; for (let i = 0; i < Math.min(grid.length, 5); i++) { const line = grid[i] || []; const t = findColumn(line, COLUMN_ALIAS.teamNumber); if (t < 0) continue; const found = [ findColumn(line, COLUMN_ALIAS.sub1), findColumn(line, COLUMN_ALIAS.sub2), findColumn(line, COLUMN_ALIAS.sub3), ]; const hit = found.filter(x => x >= 0).length; if (hit === SUB_COUNT) { headerRowIdx = i; teamCol = t; subCols = found; break; } if (!partial) { if (hit > 0) { partial = { kind: 'subs', missing: [1, 2, 3].filter(k => found[k - 1] < 0) }; } else if (findColumn(line, COLUMN_ALIAS.legacyTotal) >= 0) { partial = { kind: 'legacy' }; } } } if (headerRowIdx < 0) { if (partial && partial.kind === 'legacy') { return { ok: false, code: FILE_CODE.LEGACY_TEMPLATE, message: '检测到旧版「小组编号 + 分数」两列模板,该模板已不再支持。' + '请在管理端重新下载「成绩导入模板」,按「小组编号 + 第1/2/3小问」四列填写后重试', }; } if (partial && partial.kind === 'subs') { return { ok: false, code: FILE_CODE.SUB_COLUMNS_MISSING, message: `表头缺少「第 ${partial.missing.join('、')} 小问」列。` + '三小问必须逐列填写(缺列会导致该小问成绩被静默丢失)⇒ 整份文件已拒绝,' + '请重新下载模板后重试', }; } return { ok: false, code: FILE_CODE.HEADER_NOT_FOUND, message: '未找到表头:同一行须同时存在「小组编号」列与「第1小问」「第2小问」「第3小问」三列(请先下载模板)', }; } const rows = []; const seen = {}; for (let i = headerRowIdx + 1; i < grid.length; i++) { const line = grid[i] || []; const teamNumber = normalizeTeamNumber(line[teamCol]); const subRaws = subCols.map(c => line[c]); const rowIndex = i + 1; // Excel 行号(1 基) rows.push({ row: rowIndex, teamNumber, subRaws }); if (teamNumber) { const k = teamNumber; seen[k] = (seen[k] || 0) + 1; } } for (const r of rows) { r.dupCount = r.teamNumber ? (seen[r.teamNumber] || 0) : 0; } if (!rows.length) { return { ok: false, code: FILE_CODE.NO_DATA_ROW, message: '表头之后没有数据行' }; } return { ok: true, rows }; } module.exports = { SUB_COUNT, DEFAULT_PER_SUB, SCORE_SCALE, COLUMN_ALIAS, ROW_STATUS, BLOCKING_STATUS, FILE_CODE, isBlank, normalizeTeamNumber, normalizeHeader, findColumn, scoreSubs, classifyScoreRow, parseScoreRows, };