288 lines
9.4 KiB
JavaScript
288 lines
9.4 KiB
JavaScript
|
|
'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,
|
|||
|
|
};
|