Files
competition-website/app/lib/teacherBackfill.js

288 lines
9.4 KiB
JavaScript
Raw Normal View History

'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<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<Object>
* }}
*/
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,
};