feat(score): 成绩排名表导出 R220(客观题分排名 + 三档底色 + 前80红框)+ 修公示表参赛单位

中职/高职按小组客观题分降序全量导出,一行 = 一个小组,6 列:
名次 / 小组编号 / 学校 / 参赛选手 / 人数 / 客观题分(不含大题分与总分)。

- 整行按客观题分区间上底色:>=60 绿 / 50-59.99 黄 / <50 红
- 前 min(80, 行数) 行用**一个红色外框整体圈住**(首行画上边、末行画下边、
  行间不画横线),红框范围与统计块「前80名」同源,恒等于 min(topN, 行数)
- 表尾三区间小组数统计:全量 + 全量排名前 80 两列(是"全部小组降序后连续取
  前 80",不是各分数区间分别取 80)
- 零新增运行时依赖:CE 版 @e965/xlsx 写盘会丢弃全部单元格样式 => zip 层手改
  styles.xml 追加 fills/fonts/borders/cellXfs,再重建 sheetData 写入 s=,
  复用 publicitySheet 的 readZip/writeZip(Node 内置 zlib)
- 分数走 getScores(includeIdle:true) 实时口径(autoScore+wordScore),
  不读 contestants.score;includeIdle 必须传,否则"从未参赛"者被隐藏
- 组归属只认 resolveTeamLabels;代表成员按客观题分最高者选取
- 上色/红框失败自动降级为无色版(文件名加「-无底色」),自检从产物反查
  红边索引逐行核对,错误不会静默流到用户手里
- 路由登记 adminPermissions(scores 模块);未登记会被"默认拒绝"判 403

修复 buildPublicityXlsx 参赛单位取值(R220):
where:{group} -> account_uid IN (contestants.id)。
生产库 aggregation.group 中职/高职 161 行全 NULL,旧写法导出的公示表
「参赛单位名称」整列空白;改后非空率 100%。
This commit is contained in:
2026-10-22 17:25:22 +08:00
parent 78f34ea9f5
commit c15b46a0f9
5 changed files with 904 additions and 8 deletions

View File

@@ -1532,15 +1532,22 @@ answeredCount,
}
// ---- 4) 参赛单位 ----
const aggRows = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'organization_cn'],
where: { group },
raw: true,
});
// 🔴 R220 修复:必须按 account_uid 关联,⛔ 不能用 where:{ group }。
// 生产实测(2026-10-22):`aggregation.group` 只有本科 600 行有值,
// 中职/高职 161 行全 NULL ⇒ 旧写法导出的中职/高职公示表「参赛单位名称」
// 整列空白。改按 account_uid IN (contestants.id) 取:生产 518 名中高职选手
// organization_cn 非空率 100%。
const orgByCid = new Map();
for (const a of aggRows) {
const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim();
if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v);
if (contestants.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'organization_cn'],
where: { account_uid: { [Op.in]: contestants.map(c => c.id) } },
raw: true,
});
for (const a of aggRows) {
const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim();
if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v);
}
}
// ---- 5) 按小组聚合 ----
@@ -1610,6 +1617,146 @@ answeredCount,
};
}
/**
* 成绩排名表(R220)—— 中职组 / 高职组按小组客观题分降序全量导出。
*
* 一行 = 一个参赛小组;整行按分数区间上底色;表尾给三区间小组数统计。
*
* 🔴 分数口径必须走 getScores(实时口径 autoScore + wordScore):
* `contestants.score` 只有管理员在「成绩管理」保存过分数后才有值,
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
*
* 🔴 必须 includeIdle=true:本表要「所有小组都导出」,而 getScores 默认
* 隐藏「从未参赛」者(判据 answeredCount>0 || hasWordFile || formalSubmitted)。
*
* 🔴 组归属只认 resolveTeamLabels 给出的 teamUid(aggregation.team_uid 优先,
* contestants.team_id 兜底),⛔ 不另起一套归组逻辑。
*
* 🔴 代表成员按**客观题分**最高者选取(⛔ 不是总分最高者);
* 该成员的客观题分/大题分一次带出,⛔ 不各项独立取 MAX
* (否则会出现「客观题分 + 大题分 ≠ 总分」,被当成 bug 报上来)。
*
* @param {string} group zhongzhi | gaozhi
* @returns {Promise<Object>} { buffer, fileName, rowCount, colored, stats, ... }
*/
async buildScoreRankXlsx(group) {
const { ctx } = this;
const scoreRankSheet = require('../lib/scoreRankSheet');
const { Op } = ctx.app.Sequelize;
if (group !== 'zhongzhi' && group !== 'gaozhi') {
const err = new Error('成绩排名表仅支持中职组 / 高职组');
err.bizCode = 'BAD_GROUP';
throw err;
}
const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组' };
const groupLabel = GROUP_LABEL[group];
// ---- 1) 实时分数口径(含从未参赛者)----
const list = await this.getScores(group, { includeIdle: true });
// ---- 2) 学校(参赛单位)----
// 🔴 必须按 account_uid 关联:生产库 `aggregation.group` 只有本科行有值,
// 中职/高职 161 行全 NULL ⇒ 用 where:{group} 取会整列为空。
const ids = list.map(x => x.uid);
const orgByCid = new Map();
if (ids.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: ids } },
attributes: ['account_uid', 'organization_cn'],
raw: true,
});
for (const a of aggRows) {
const v = a.organization_cn === null || a.organization_cn === undefined
? '' : String(a.organization_cn).trim();
if (v && a.account_uid !== null && a.account_uid !== undefined && !orgByCid.has(a.account_uid)) {
orgByCid.set(a.account_uid, v);
}
}
}
// ---- 3) 按小组聚合 ----
const bucket = new Map();
let noTeamCount = 0;
for (const it of list) {
const hasTeam = it.teamUid !== null && it.teamUid !== undefined;
if (!hasTeam) noTeamCount++;
// 无队伍元信息的人各自成行(覆盖率不足是数据问题,⛔ 不在导出时静默猜)
const key = hasTeam ? `team:${it.teamUid}` : `single:${it.uid}`;
let b = bucket.get(key);
if (!b) {
b = {
teamId: hasTeam
? String(it.teamId || it.teamNumber || it.teamUid)
: `未分小组#${it.uid}`,
org: '',
names: [],
examScore: -Infinity, // 哨兵:保证 0 分小组也会被「首个成员」覆盖赋值
wordScore: 0,
total: 0,
memberCount: 0,
};
bucket.set(key, b);
}
b.memberCount += 1;
if (it.name) {
const n = String(it.name).trim();
if (n) b.names.push(n);
}
const examScore = Number(it.examScore) || 0;
const wordScore = Number(it.wordScore) || 0;
const total = Number(it.score) || 0;
// 代表成员 = 组内**客观题分**最高者(R220-C)。
// 🔴 取值维度必须与排序维度一致:表格按客观题分排名,若代表成员仍按总分挑,
// 会出现「展示 72 分的组排在展示 68 分的组前面」——名次无法自洽解释。
if (examScore > b.examScore) {
b.examScore = examScore;
b.wordScore = wordScore;
b.total = total;
}
const org = orgByCid.get(it.uid);
if (org && !b.org) b.org = org;
}
const teams = Array.from(bucket.values()).map(b => ({
teamId: b.teamId,
org: b.org,
players: Array.from(new Set(b.names)).join('、'),
memberCount: b.memberCount,
examScore: b.examScore === -Infinity ? 0 : b.examScore,
wordScore: b.wordScore,
total: b.total,
}));
// ---- 4) 出表(内部含自检;上色失败自动降级为无色版)----
const built = scoreRankSheet.buildXlsx({
teams,
sheetName: `${groupLabel}成绩排名`,
topN: scoreRankSheet.DEFAULT_TOP_N,
});
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const dateTag = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
return {
buffer: built.buffer,
fileName: `成绩排名-${groupLabel}-${dateTag}${built.colored ? '' : '-无底色'}.xlsx`,
rowCount: built.rows.length,
colored: built.colored,
degradeReason: built.degradeReason || '',
stats: built.stats,
topN: built.stats.topN,
totalContestants: list.length,
noTeamCount,
groupLabel,
};
}
// ==================== 赛项信息配置(R176) ====================
/** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */