'use strict'; /** * 指导教师回填 —— 纯判定层(R176) * * 背景:`contestants` 原本没有任何「指导教师」承载字段,而成绩公示表 * (`7-2026一带一路暨金砖大赛成绩公示表-参考模版.xlsx`)第 4 列必须填写。 * 报名表里其实有指导教师行,但导入流程按业务规则只导入「参赛选手」身份 * (`service/admin.js` 的 IMPORT_IDENTITY),教师行被计入 `ignored` 丢弃。 * * 本模块只负责**从报名表二维数组里把指导教师找出来、按小组归并**, * 不碰数据库、不碰文件、不依赖 Egg。落库由 `service/admin.js#backfillTeachers` 完成。 * * 🔴 为什么不直接复用既有导入:`importContestants` 对已存在的身份证号一律 skip * (`result.skipped++`),所以「重导一次」不会更新任何字段。回填必须是独立通路。 * * 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号(实测本科组也有 * team_uid=1),只按 teamId 归并会把别的组的教师挂到本组头上。 */ /** 教师身份白名单(归一化:去掉所有空白字符后比对) */ const TEACHER_IDENTITIES = [ '指导教师', '指导老师', '指导专家', '指导教师(专家)', '指导教师(专家)', '指导教师(教练)', '指导教师(教练)', '教练', '导师', ]; /** 组别别名 → 系统 key。⚠️ 名字带 TEACHER_ 前缀,避免与 service/admin.js 的 GROUP_ALIAS 撞名 */ const TEACHER_GROUP_ALIAS = { 中职: 'zhongzhi', 中职组: 'zhongzhi', 高职: 'gaozhi', 高职组: 'gaozhi', 本科: 'benke', 本科组: 'benke', 教师: 'jiaoshi', 教师组: 'jiaoshi', zhongzhi: 'zhongzhi', gaozhi: 'gaozhi', benke: 'benke', jiaoshi: 'jiaoshi', }; /** 报名表列名别名(与导入契约同源,但这里只取回填必需的 4 列) */ const TEACHER_COLUMN_ALIAS = { name: ['姓名', '全名', 'name'], teamId: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'], group: ['组别', 'group'], identity: ['身份', 'identity'], }; const ROW_STATUS = { TEACHER: 'TEACHER', NOT_TEACHER: 'NOT_TEACHER', TEACHER_NO_TEAM: 'TEACHER_NO_TEAM', TEACHER_NO_NAME: 'TEACHER_NO_NAME', TEACHER_NO_GROUP: 'TEACHER_NO_GROUP', BLANK_ROW: 'BLANK_ROW', }; /** * 去掉所有空白字符(含全角空格、不换行空格、零宽字符)。 * ⛔ 不能只 trim:上游表格里「指导 教师」「指导教师」混用很常见, * 只 trim 会把前者判成非教师身份而整行丢掉。 */ function squash(v) { if (v === null || v === undefined) return ''; return String(v) .replace(/[\u3000\u00a0\u200b\u200c\u200d\ufeff]/g, '') .replace(/\s+/g, ''); } /** 组别归一;无法识别时返回空串(调用方据此判定"组别缺失") */ function normalizeGroup(v) { const s = squash(v); if (!s) return ''; return TEACHER_GROUP_ALIAS[s] || TEACHER_GROUP_ALIAS[s.toLowerCase()] || ''; } /** 是否教师身份。归一化后精确命中白名单才算,⛔ 不做包含匹配(避免「非指导教师」被误判) */ function isTeacherIdentity(v) { const s = squash(v); if (!s) return false; return TEACHER_IDENTITIES.indexOf(s) >= 0; } /** 队伍编号归一:只 trim 首尾空白与零宽字符,⛔ 不折叠大小写(编号大小写不敏感会串组) */ function normalizeTeamId(v) { if (v === null || v === undefined) return ''; return String(v).replace(/[\u200b\u200c\u200d\ufeff]/g, '').replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, ''); } /** 在给定行里按别名找列下标,返回 -1 表示未命中 */ function findColumn(row, aliases) { if (!Array.isArray(row)) return -1; for (let i = 0; i < row.length; i++) { const cell = squash(row[i]); if (!cell) continue; for (const a of aliases) { if (cell === squash(a)) return i; } } return -1; } /** * 解析报名表 AOA,提取「小组 → 指导教师姓名列表」。 * * @param {Array} aoa 二维数组(xlsx sheet_to_json header:1 的产物) * @param {Object} [opts] * @param {number} [opts.maxHeaderScan=10] 表头最多向下扫描的行数 * @returns {{ * ok: boolean, code: string, message: string, * teams: Array<{group: string, teamId: string, names: string[], rowNumbers: number[]}>, * stats: Object, errors: Array * }} */ function extractTeachers(aoa, opts) { const maxHeaderScan = (opts && opts.maxHeaderScan) || 10; const empty = { teams: [], stats: { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }, errors: [] }; if (!Array.isArray(aoa) || aoa.length === 0) { return Object.assign({ ok: false, code: 'EMPTY_FILE', message: '文件内容为空' }, empty); } // ---- 1) 定位表头 ---- let headerRowIdx = -1; let colName = -1; let colTeam = -1; let colGroup = -1; let colIdentity = -1; for (let i = 0; i < Math.min(aoa.length, maxHeaderScan); i++) { const r = aoa[i]; const cn = findColumn(r, TEACHER_COLUMN_ALIAS.name); const ci = findColumn(r, TEACHER_COLUMN_ALIAS.identity); if (cn >= 0 && ci >= 0) { headerRowIdx = i; colName = cn; colIdentity = ci; colTeam = findColumn(r, TEACHER_COLUMN_ALIAS.teamId); colGroup = findColumn(r, TEACHER_COLUMN_ALIAS.group); break; } } if (headerRowIdx < 0) { return Object.assign({ ok: false, code: 'HEADER_NOT_FOUND', message: '前 10 行内没有同时包含「姓名」与「身份」的表头,请确认上传的是报名表', }, empty); } if (colTeam < 0) { return Object.assign({ ok: false, code: 'TEAM_COLUMN_MISSING', message: '报名表缺少「队伍编号」列,无法把指导教师归到具体小组', }, empty); } if (colGroup < 0) { return Object.assign({ ok: false, code: 'GROUP_COLUMN_MISSING', message: '报名表缺少「组别」列,无法限定同组(team_uid 跨组重号,不能只按编号归并)', }, empty); } // ---- 2) 逐行收集 ---- const stats = { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }; const errors = []; // key = group + '\u0000' + teamId,用不可见分隔符避免与编号里的字符冲突 const bucket = new Map(); for (let i = headerRowIdx + 1; i < aoa.length; i++) { const r = aoa[i] || []; stats.rows++; const rowNumber = i + 1; // 1 基,与 Excel 行号一致 const nameRaw = colName >= 0 ? r[colName] : ''; const identityRaw = colIdentity >= 0 ? r[colIdentity] : ''; const allBlank = r.every(c => squash(c) === ''); if (allBlank) { stats.blank++; continue; } if (!isTeacherIdentity(identityRaw)) { stats.notTeachers++; continue; } const name = String(nameRaw === null || nameRaw === undefined ? '' : nameRaw).trim(); if (!name) { stats.teachers++; errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_NAME, message: '身份为导师但姓名为空,已跳过' }); continue; } const group = normalizeGroup(colGroup >= 0 ? r[colGroup] : ''); if (!group) { stats.teachers++; errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_GROUP, message: `指导教师「${name}」所在行组别为空或无法识别,已跳过(缺少 group 无法安全归组)`, }); continue; } const teamId = normalizeTeamId(colTeam >= 0 ? r[colTeam] : ''); if (!teamId) { stats.teachers++; errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_TEAM, message: `指导教师「${name}」所在行队伍编号为空,已跳过(无法定位到具体小组)`, }); continue; } stats.teachers++; const key = `${group}\u0000${teamId}`; let item = bucket.get(key); if (!item) { item = { group, teamId, names: [], rowNumbers: [] }; bucket.set(key, item); } if (item.names.indexOf(name) < 0) item.names.push(name); item.rowNumbers.push(rowNumber); } const teams = Array.from(bucket.values()); if (teams.length === 0) { return Object.assign({ ok: false, code: 'NO_TEACHER_FOUND', message: stats.teachers === 0 ? '报名表里没有身份为「指导教师」的行(请确认「身份」列的写法)' : '识别到指导教师行,但都缺少组别或队伍编号,无法归组', }, { stats, errors }); } return Object.assign({ ok: true, code: 'OK', message: `识别到 ${teams.length} 个小组的指导教师` }, { teams, stats, errors }); } /** * 把同一小组的多个指导教师姓名合成一个展示串。 * ⛔ 去重保序:报名表里同一教师可能重复出现(同一人被登记两次), * 直接 join 会得到「张三、张三」。 */ function joinNames(names) { if (!Array.isArray(names)) return ''; const seen = new Set(); const out = []; for (const n of names) { const s = String(n === null || n === undefined ? '' : n).trim(); if (!s || seen.has(s)) continue; seen.add(s); out.push(s); } return out.join('、'); } /** 1 基数组是报名表原始行号,直接透出(调用方拼提示用) */ function formatRowNumbers(nums) { if (!Array.isArray(nums) || nums.length === 0) return ''; return nums.join('、'); } module.exports = { TEACHER_IDENTITIES, TEACHER_GROUP_ALIAS, TEACHER_COLUMN_ALIAS, ROW_STATUS, squash, normalizeGroup, normalizeTeamId, isTeacherIdentity, findColumn, extractTeachers, joinNames, formatRowNumbers, };