- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底) 与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项 - R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据 - R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT) + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version) + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js - R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源) - R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段 - R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态) - R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
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,
|
||
};
|