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:
@@ -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 行 */
|
||||
|
||||
Reference in New Issue
Block a user