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:
@@ -349,6 +349,63 @@ class AdminController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩排名表导出(R220)
|
||||
* GET /api/admin/export-score-rank/:group
|
||||
*
|
||||
* 一行 = 一个参赛小组,按小组**客观题分**降序**全量**导出(不截断 80 个),
|
||||
* 整行按客观题分区间上底色(≥60 绿 / 50–59.99 黄 / <50 红),
|
||||
* 表尾给三区间小组数统计(全量 + 全量排名前 80)。
|
||||
*
|
||||
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号 / 手机号);
|
||||
* 本表只有名次 / 小组编号 / 学校 / 姓名 / 客观题分,可直接给评审看。
|
||||
* 🔴 R220-C 用户裁定:本表按**客观题分**筛选 ⇒ 只出 6 列,「大题分」「总分」不出现;
|
||||
* 排序键 / 并列判据 / 区间底色三者同源,均为客观题分。
|
||||
* ⚠️ 客观题分仍走「实时口径」(与成绩管理页面同源),⛔ 不读 contestants.score。
|
||||
*/
|
||||
async exportScoreRank() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
// 第二参数 false = 只允许考试组(中职 / 高职)
|
||||
if (!this._validateGroup(group, false)) return;
|
||||
|
||||
const built = await service.admin.buildScoreRankXlsx(group);
|
||||
|
||||
// `?meta=1` 只回统计、不下载:前端在点「导出」前用它提示
|
||||
// 「本组 N 人无小组归属」「底色生成失败已降级」这类需要管理员知情的事实。
|
||||
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
|
||||
if (String(ctx.query.meta || '') === '1') {
|
||||
ctx.body = ctx.helper.success({
|
||||
fileName: built.fileName,
|
||||
rowCount: built.rowCount,
|
||||
colored: built.colored,
|
||||
degradeReason: built.degradeReason,
|
||||
stats: built.stats,
|
||||
topN: built.topN,
|
||||
totalContestants: built.totalContestants,
|
||||
noTeamCount: built.noTeamCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
|
||||
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
|
||||
ctx.attachment(built.fileName);
|
||||
ctx.body = built.buffer;
|
||||
} catch (err) {
|
||||
ctx.logger.error('导出成绩排名表失败:', err);
|
||||
if (err && err.bizCode === 'BAD_GROUP') {
|
||||
ctx.body = ctx.helper.error(400, err.message);
|
||||
return;
|
||||
}
|
||||
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩排名表失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 赛项信息配置(R176) ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -116,6 +116,8 @@ const ROUTE_TABLE = [
|
||||
['GET', '/api/admin/export-csv/:group', ['scores']],
|
||||
['GET', '/api/admin/export-excel/:group', ['scores']],
|
||||
['GET', '/api/admin/export-publicity/:group', ['scores']],
|
||||
// R220 成绩排名表:同属「成绩管理」导出能力,与 export-excel / export-publicity 同权限
|
||||
['GET', '/api/admin/export-score-rank/:group', ['scores']],
|
||||
['GET', '/api/admin/competition-item', ['scores']],
|
||||
['PUT', '/api/admin/competition-item', ['scores']],
|
||||
['GET', '/api/admin/big-answers/:group', ['scores', 'review']],
|
||||
|
||||
685
app/lib/scoreRankSheet.js
Normal file
685
app/lib/scoreRankSheet.js
Normal file
@@ -0,0 +1,685 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 成绩排名表导出引擎(R220)
|
||||
*
|
||||
* 目标:中职组 / 高职组考试结束后,按小组客观题分降序全量导出排名表,
|
||||
* 整行按分数区间上底色,表尾给出三区间的小组数统计。
|
||||
*
|
||||
* 🔴 为什么要自己在 zip 层动 styles.xml:
|
||||
* `@e965/xlsx`(SheetJS CE)写 xlsx 时**完全丢弃样式**(连 `s=` 属性都不写),
|
||||
* 实测 2026-10-19:模板 214 个样式引用写回后变 0、styles.xml 从 22773 → 1546 字节。
|
||||
* ⇒ 唯一能上色的做法 = 先用该库产出一个合法的 xlsx 骨架,
|
||||
* 再解压 → 改 `xl/styles.xml`(追加 fills + cellXfs)→ 重建 `sheetData`(写 `s=`)
|
||||
* → 按原始压缩字节重新打包。
|
||||
*
|
||||
* 🔴 零新依赖:zip 读写与 XML 片段工具全部复用 `lib/publicitySheet.js`
|
||||
* (该模块只用 Node 内置 zlib + 自实现 CRC32)⇒ 生产上线**不需要 npm install**,
|
||||
* 也就不必为此停机。
|
||||
*
|
||||
* 🔴 全量重建 sheetData 而不是「只补 s 属性」:该库把字符串写成 `t="str"` + `<v>文本</v>`
|
||||
* (这是「公式字符串」语义,无 `<f>` 时并不规范)。既然要逐行改样式,
|
||||
* 索性用 `cellXml()` 以 `inlineStr` 重建每一行 ⇒ 顺带消掉这个隐患,
|
||||
* 也保证「整行」A..H 每格都存在(该库会跳过空值单元格,底色会断成小块)。
|
||||
*
|
||||
* 🔴 分数口径:**必须由调用方传入实时口径**(客观题分 + 大题分)。
|
||||
* `contestants.score` 只有管理员「成绩管理」里保存过分数后才有值,
|
||||
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
|
||||
*/
|
||||
|
||||
const XLSX = require('@e965/xlsx');
|
||||
const {
|
||||
readZip,
|
||||
writeZip,
|
||||
entryText,
|
||||
replaceEntryText,
|
||||
cellXml,
|
||||
} = require('./publicitySheet');
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
/**
|
||||
* 表头(6 列,顺序即写入顺序)。
|
||||
*
|
||||
* 🔴 R220-C 用户裁定:本表**按客观题分筛选**,故「大题分」「总分」两列不出现。
|
||||
* 删掉总分列有个强连带约束:底色/名次若仍按总分算,用户看不到总分就无法
|
||||
* 解释某行为什么是绿的 ⇒ 排序键、并列判据、区间判定**三者必须同为客观题分**。
|
||||
*/
|
||||
const HEADERS = ['名次', '小组编号', '学校', '参赛选手', '人数', '客观题分'];
|
||||
|
||||
/** 6 个数据列 */
|
||||
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
|
||||
/** 列宽(字符数) */
|
||||
const COL_WIDTHS = [
|
||||
{ wch: 7 }, // 名次
|
||||
{ wch: 18 }, // 小组编号
|
||||
{ wch: 34 }, // 学校
|
||||
{ wch: 34 }, // 参赛选手
|
||||
{ wch: 7 }, // 人数
|
||||
{ wch: 11 }, // 客观题分
|
||||
];
|
||||
|
||||
/** 默认统计口径:前 N 名 */
|
||||
const DEFAULT_TOP_N = 80;
|
||||
|
||||
/**
|
||||
* 客观题满分(R220-D 用户补充的事实)。
|
||||
*
|
||||
* ⚠️ 它**只用于说明行文字**,不参与任何判定:
|
||||
* 区间阈值仍是用户裁定的 60 / 50(70 分制下 60 分 = 86%,属高分段)。
|
||||
* ⛔ 不要因为满分变化就自动缩放阈值 —— 那是用户裁定过的业务规则。
|
||||
* 若某次考试的满分不是 70,由调用方传 fullScore 覆盖即可。
|
||||
*/
|
||||
const DEFAULT_FULL_SCORE = 70;
|
||||
|
||||
/**
|
||||
* 三个分数区间的**单一判定源**。
|
||||
* 顺序即优先级:先 ≥60,再 ≥50,其余归红。
|
||||
* 边界按用户裁定:≥60 绿 / 50–59.99 黄 / <50 红。
|
||||
*
|
||||
* 🔴 判定输入是**客观题分**(examScore),不是总分(R220-C 用户裁定)。
|
||||
* 阈值本身未变,只是被量的对象换了 —— 因为表格里已无总分列。
|
||||
*/
|
||||
const BANDS = [
|
||||
{ key: 'green', label: '≥60 分(绿)', min: 60, rgb: 'FFC6EFCE' },
|
||||
{ key: 'yellow', label: '50–59.99 分(黄)', min: 50, rgb: 'FFFFEB9C' },
|
||||
{ key: 'red', label: '<50 分(红)', min: -Infinity, rgb: 'FFFFC7CE' },
|
||||
];
|
||||
|
||||
/** 表头底色(灰) */
|
||||
const HEADER_RGB = 'FFD9D9D9';
|
||||
|
||||
/** 前 N 名「红框」的线色(R220-D) */
|
||||
const RED_BORDER_RGB = 'FFFF0000';
|
||||
|
||||
// ==================== 纯函数层 ====================
|
||||
|
||||
/** 转数字,非法值归 0 */
|
||||
function toNum(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** 保留两位小数,消除浮点噪声(如 59.99999999) */
|
||||
function round2(v) {
|
||||
return Math.round(toNum(v) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分数区间判定(唯一入口)。
|
||||
* @param {number} score 小组**客观题分**(examScore)
|
||||
* @returns {'green'|'yellow'|'red'}
|
||||
*/
|
||||
function scoreBand(score) {
|
||||
const n = toNum(score);
|
||||
for (const b of BANDS) {
|
||||
if (n >= b.min) return b.key;
|
||||
}
|
||||
return 'red';
|
||||
}
|
||||
|
||||
/**
|
||||
* 名次编排:按**客观题分**(examScore)降序,同分并列(1、2、2、4)。
|
||||
*
|
||||
* 🔴 排序键 = 客观题分,与「区间底色判定」同源(R220-C 用户裁定)。
|
||||
* ⛔ 不要改成 total:表格里已无总分列,按总分排序会让名次与可见分数矛盾
|
||||
* (出现「客观题 90 分排第 5、客观题 70 分排第 1」这种用户无法理解的结果)。
|
||||
*
|
||||
* 并列组内的先后顺序用「人数升序 → 小组编号升序」定,纯为了结果可复现,
|
||||
* 不影响名次数值本身。
|
||||
*
|
||||
* 注:返回项里仍保留 wordScore / total 字段供内部排查与既有断言使用,
|
||||
* 但 **不写入表格**(HEADERS 只有 6 列)。
|
||||
*
|
||||
* @param {Array<Object>} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total}
|
||||
* @returns {Array<Object>} 追加了 rank 字段的新数组(不改入参)
|
||||
*/
|
||||
function buildRankRows(teams) {
|
||||
const list = (teams || []).map(t => ({
|
||||
teamId: t && t.teamId != null ? String(t.teamId) : '',
|
||||
org: t && t.org != null ? String(t.org) : '',
|
||||
players: t && t.players != null ? String(t.players) : '',
|
||||
memberCount: Math.trunc(toNum(t && t.memberCount)),
|
||||
examScore: round2(t && t.examScore),
|
||||
wordScore: round2(t && t.wordScore),
|
||||
total: round2(t && t.total),
|
||||
}));
|
||||
|
||||
list.sort((a, b) => {
|
||||
if (b.examScore !== a.examScore) return b.examScore - a.examScore;
|
||||
if (a.memberCount !== b.memberCount) return a.memberCount - b.memberCount;
|
||||
return a.teamId < b.teamId ? -1 : (a.teamId > b.teamId ? 1 : 0);
|
||||
});
|
||||
|
||||
let prevScore = null;
|
||||
let prevRank = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const cur = list[i].examScore;
|
||||
if (prevScore !== null && cur === prevScore) {
|
||||
list[i].rank = prevRank; // 并列:沿用上一个名次
|
||||
} else {
|
||||
list[i].rank = i + 1; // 跳号:1、2、2、4
|
||||
prevScore = cur;
|
||||
prevRank = list[i].rank;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三区间小组数统计,两套口径(全量 / 全量排名前 N)。
|
||||
*
|
||||
* 🔴 「前 N 名」= 全部小组降序排序后**连续**取前 N 个(跨区间截取),
|
||||
* ⛔ 不是「名次 ≤ N」,⛔ 也不是「每个区间各取前 N 个」。
|
||||
* 保证该列合计恒 = min(总数, N),与「筛选出排名靠前的 80 个小组」的口径一致。
|
||||
* selfCheck() 里有对应断言,误实现成区间各取会在自检阶段直接抛错。
|
||||
*
|
||||
* @param {Array<Object>} rows buildRankRows 的产物
|
||||
* @param {number} topN 默认 80
|
||||
*/
|
||||
function buildStats(rows, topN) {
|
||||
const n = Math.max(0, Math.trunc(toNum(topN == null ? DEFAULT_TOP_N : topN)));
|
||||
const all = rows || [];
|
||||
const top = all.slice(0, n);
|
||||
const countOf = arr => {
|
||||
const c = { green: 0, yellow: 0, red: 0 };
|
||||
// 区间判定与排序键同源:客观题分(R220-C)
|
||||
for (const r of arr) c[scoreBand(r.examScore)]++;
|
||||
return c;
|
||||
};
|
||||
return {
|
||||
topN: n,
|
||||
total: all.length,
|
||||
topTotal: top.length,
|
||||
all: countOf(all),
|
||||
top: countOf(top),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计块下方的口径说明文字。
|
||||
*
|
||||
* 🔴 为什么需要它(R220 用户反馈):当小组总数 ≤ topN 时,「全量小组」与
|
||||
* 「前N名(总排名)」两列数字必然完全相同 —— 用户看到 7 / 7 会合理怀疑
|
||||
* 「是不是每个区间各取了 7 个」。列名再清楚也压不住这种猜疑,
|
||||
* 干脆用一行文字把口径写死,同时把「为何两列一样」当场解释掉。
|
||||
*/
|
||||
function buildScopeNote(stats, fullScore) {
|
||||
const n = stats.topN;
|
||||
const full = fullScore == null ? DEFAULT_FULL_SCORE : fullScore;
|
||||
const head = `说明:本表按客观题分(满分 ${full} 分)降序排列,名次、区间底色与红框范围均以客观题分为准;`;
|
||||
const boxed = Math.min(n, stats.total);
|
||||
const tail = `表格中前 ${boxed} 行已用红色外框圈出(跨区间连续,行间不画横线,以便看成一个整体)。`;
|
||||
if (stats.total <= n) {
|
||||
return `${head}本次共 ${stats.total} 个小组,未超过 ${n} 个,故「前${n}名(总排名)」列等于全量,两列数字相同属正常。`
|
||||
+ `前${n}名 = 全部小组按客观题分降序排列后连续取前 ${n} 个,不是各分数区间分别取前 ${n} 个。${tail}`;
|
||||
}
|
||||
return `${head}「前${n}名(总排名)」= 全部 ${stats.total} 个小组按客观题分降序排列后连续取前 ${n} 个,可跨分数区间截取;`
|
||||
+ `不是各分数区间分别取前 ${n} 个(若按区间各取,合计会远超 ${n})。${tail}`;
|
||||
}
|
||||
|
||||
// ==================== styles.xml 手术 ====================
|
||||
|
||||
/** 取某个元素块内的直接子元素个数(按标签名首段统计) */
|
||||
function countChildren(inner, tag) {
|
||||
const re = new RegExp(`<${tag}\\b`, 'g');
|
||||
return (inner.match(re) || []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 styles.xml 末尾追加「表头灰底 + 三档底色 + 前 N 名红框」的
|
||||
* fills / fonts / borders / cellXfs。
|
||||
*
|
||||
* ⚠️ 不假定基线 count 的具体数值:一律按**实际子元素个数**取索引并回写 count,
|
||||
* 这样即使将来 @e965/xlsx 版本升级改了默认 fills 数量也不会把索引错位。
|
||||
*
|
||||
* 🔴 红框为什么要 4 个 border + 12 个 xf(R220-D):
|
||||
* 用户要「前 80 个小组被一个红框整体圈住」,而不是每行各画一个框。
|
||||
* 单格边框只能表达「这条边上有没有线」,所以整体外框得靠**按行位置分工**拼出来:
|
||||
* 首行 → 上边 + 左右;中间行 → 只有左右;末行 → 下边 + 左右。
|
||||
* 每行还有 3 种底色,故 3 底色 × 4 位置(含「只有 1 行」的四边全画)= 12 个 xf。
|
||||
* 若改成「每行四边全画」,相邻行之间的红线会叠成红色网格,视觉上不再是「框选」。
|
||||
*
|
||||
* @param {string} xml styles.xml 原文
|
||||
* @returns {{ xml: string, style: Object }} style 含 header/green/yellow/red 与 box[位置][颜色]
|
||||
*/
|
||||
function patchStyles(xml) {
|
||||
const fillsRe = /<fills\b([^>]*)>([\s\S]*?)<\/fills>/;
|
||||
const fontsRe = /<fonts\b([^>]*)>([\s\S]*?)<\/fonts>/;
|
||||
const bordersRe = /<borders\b([^>]*)>([\s\S]*?)<\/borders>/;
|
||||
const xfsRe = /<cellXfs\b([^>]*)>([\s\S]*?)<\/cellXfs>/;
|
||||
|
||||
const fillsM = fillsRe.exec(xml);
|
||||
const fontsM = fontsRe.exec(xml);
|
||||
const bordersM = bordersRe.exec(xml);
|
||||
const xfsM = xfsRe.exec(xml);
|
||||
if (!fillsM) throw new Error('styles.xml 缺少 <fills>');
|
||||
if (!fontsM) throw new Error('styles.xml 缺少 <fonts>');
|
||||
if (!bordersM) throw new Error('styles.xml 缺少 <borders>');
|
||||
if (!xfsM) throw new Error('styles.xml 缺少 <cellXfs>');
|
||||
|
||||
const baseFillCount = countChildren(fillsM[2], 'fill');
|
||||
const baseFontCount = countChildren(fontsM[2], 'font');
|
||||
const baseBorderCount = countChildren(bordersM[2], 'border');
|
||||
const baseXfCount = countChildren(xfsM[2], 'xf');
|
||||
if (baseFillCount < 2) throw new Error(`styles.xml fills 基线异常(${baseFillCount})`);
|
||||
if (baseFontCount < 1) throw new Error(`styles.xml fonts 基线异常(${baseFontCount})`);
|
||||
if (baseXfCount < 1) throw new Error(`styles.xml cellXfs 基线异常(${baseXfCount})`);
|
||||
|
||||
// ---- fills:表头灰 + 三档底色 ----
|
||||
const solid = rgb => `<fill><patternFill patternType="solid"><fgColor rgb="${rgb}"/><bgColor indexed="64"/></patternFill></fill>`;
|
||||
const addedFills = [HEADER_RGB, ...BANDS.map(b => b.rgb)].map(solid).join('');
|
||||
const fHeader = baseFillCount;
|
||||
const fBand = {};
|
||||
BANDS.forEach((b, i) => { fBand[b.key] = baseFillCount + 1 + i; });
|
||||
|
||||
let out = xml.replace(fillsM[0], () =>
|
||||
`<fills count="${baseFillCount + 1 + BANDS.length}">${fillsM[2]}${addedFills}</fills>`);
|
||||
|
||||
// ---- fonts:表头加粗 ----
|
||||
const boldFontId = baseFontCount;
|
||||
const boldFont = '<font><b/><sz val="12"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>';
|
||||
out = out.replace(fontsM[0], () =>
|
||||
`<fonts count="${baseFontCount + 1}">${fontsM[2]}${boldFont}</fonts>`);
|
||||
|
||||
// ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)----
|
||||
const side = name => `<${name} style="medium"><color rgb="${RED_BORDER_RGB}"/></${name}>`;
|
||||
const empty = name => `<${name}/>`;
|
||||
const borderXml = (top, bottom) =>
|
||||
`<border>${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}<diagonal/></border>`;
|
||||
|
||||
// 位置 → [是否有上边, 是否有下边]
|
||||
const BOX_POS = {
|
||||
single: [true, true], // 前 N 名只有 1 行时,这一个格子四边都要有
|
||||
top: [true, false],
|
||||
mid: [false, false],
|
||||
bot: [false, true],
|
||||
};
|
||||
const BOX_KEYS = ['single', 'top', 'mid', 'bot'];
|
||||
const addedBorders = BOX_KEYS.map(k => borderXml(BOX_POS[k][0], BOX_POS[k][1])).join('');
|
||||
const bId = {};
|
||||
BOX_KEYS.forEach((k, i) => { bId[k] = baseBorderCount + i; });
|
||||
|
||||
out = out.replace(bordersM[0], () =>
|
||||
`<borders count="${baseBorderCount + BOX_KEYS.length}">${bordersM[2]}${addedBorders}</borders>`);
|
||||
|
||||
// ---- cellXfs ----
|
||||
const xf = (fillId, opt) => {
|
||||
const o = opt || {};
|
||||
const borderId = o.borderId == null ? 0 : o.borderId;
|
||||
const attrs = [
|
||||
'numFmtId="0"',
|
||||
`fontId="${o.bold ? boldFontId : 0}"`,
|
||||
`fillId="${fillId}"`,
|
||||
`borderId="${borderId}"`,
|
||||
'xfId="0"',
|
||||
];
|
||||
if (fillId !== 0) attrs.push('applyFill="1"');
|
||||
if (o.bold) attrs.push('applyFont="1"');
|
||||
if (borderId !== 0) attrs.push('applyBorder="1"');
|
||||
return `<xf ${attrs.join(' ')}/>`;
|
||||
};
|
||||
|
||||
const addedXfs = [xf(fHeader, { bold: true })];
|
||||
// 纯底色(不在前 N 名里的行)
|
||||
BANDS.forEach(b => addedXfs.push(xf(fBand[b.key])));
|
||||
// 前 N 名:底色 × 位置
|
||||
const xfIndex = {};
|
||||
BOX_KEYS.forEach(k => {
|
||||
xfIndex[k] = {};
|
||||
BANDS.forEach(b => {
|
||||
xfIndex[k][b.key] = baseXfCount + addedXfs.length;
|
||||
addedXfs.push(xf(fBand[b.key], { borderId: bId[k] }));
|
||||
});
|
||||
});
|
||||
|
||||
const style = {
|
||||
header: baseXfCount,
|
||||
box: xfIndex,
|
||||
};
|
||||
BANDS.forEach(b => { style[b.key] = baseXfCount + 1 + BANDS.indexOf(b); });
|
||||
|
||||
out = out.replace(xfsM[0], () =>
|
||||
`<cellXfs count="${baseXfCount + addedXfs.length}">${xfsM[2]}${addedXfs.join('')}</cellXfs>`);
|
||||
|
||||
return { xml: out, style };
|
||||
}
|
||||
|
||||
// ==================== sheetData 重建 ====================
|
||||
|
||||
function rowXml(r, cells) {
|
||||
return `<row r="${r}">${cells.join('')}</row>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完整的 sheetData(含表头行、数据行、空行、统计块)。
|
||||
*
|
||||
* 行号布局:
|
||||
* 1 表头
|
||||
* 2 .. 1+N 数据(N = 小组数;N=0 时数据区为空)
|
||||
* 2+N 空行
|
||||
* 3+N 统计表头(区间统计 / 全量小组 / 前 K 名(总排名))
|
||||
* 4+N .. 6+N 三个区间
|
||||
* 7+N 合计
|
||||
* 8+N 口径说明(一行文字,解释「前 K 名」是全量跨区间截取)
|
||||
*
|
||||
* @returns {{ xml:string, lastRow:number }}
|
||||
*/
|
||||
function buildSheetData(rows, stats, style, fullScore) {
|
||||
const out = [];
|
||||
const R = k => k;
|
||||
|
||||
// 表头
|
||||
out.push(rowXml(R(1), HEADERS.map((h, i) => cellXml(DATA_COLS[i], 1, style.header, h))));
|
||||
|
||||
// 数据行(6 列:名次/小组编号/学校/参赛选手/人数/客观题分)
|
||||
// 🔴 底色取值按 examScore 判定 —— 与排序键同源,否则「分数高低」与「颜色」矛盾
|
||||
// 🔴 前 N 行额外套红框:红框范围与统计块「前N名」列**同一口径**(排序后前 N 行),
|
||||
// 保证「框住的」和「统计里算的」永远一致,不会一个框 80 行、一个算 80 组。
|
||||
const boxCount = Math.min(stats.topN, rows.length);
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = i + 2;
|
||||
const row = rows[i];
|
||||
const band = scoreBand(row.examScore);
|
||||
let s;
|
||||
if (i < boxCount) {
|
||||
// 首行画上边、末行画下边、中间只画左右 ⇒ 拼成一个整体外框(行间无横线)
|
||||
const pos = boxCount === 1 ? 'single' : (i === 0 ? 'top' : (i === boxCount - 1 ? 'bot' : 'mid'));
|
||||
s = style.box[pos][band];
|
||||
} else {
|
||||
s = style[band];
|
||||
}
|
||||
out.push(rowXml(r, [
|
||||
cellXml('A', r, s, row.rank),
|
||||
cellXml('B', r, s, row.teamId),
|
||||
cellXml('C', r, s, row.org),
|
||||
cellXml('D', r, s, row.players),
|
||||
cellXml('E', r, s, row.memberCount),
|
||||
cellXml('F', r, s, row.examScore),
|
||||
]));
|
||||
}
|
||||
|
||||
const n = rows.length;
|
||||
const blankRow = n + 2;
|
||||
const statHeadRow = n + 3;
|
||||
|
||||
// 统计块表头
|
||||
// 🔴 列标题曾写「其中前80名」,被用户读成「各个区间其中前80名」(R220 反馈)。
|
||||
// 实际口径是**全量排名**前 N,故列名改为「前N名(总排名)」消除歧义。
|
||||
out.push(rowXml(statHeadRow, [
|
||||
cellXml('A', statHeadRow, style.header, '区间统计'),
|
||||
cellXml('B', statHeadRow, style.header, '全量小组'),
|
||||
cellXml('C', statHeadRow, style.header, `前${stats.topN}名(总排名)`),
|
||||
]));
|
||||
|
||||
// 三个区间(A 列带该区间的底色,作为图例)
|
||||
BANDS.forEach((b, i) => {
|
||||
const r = statHeadRow + 1 + i;
|
||||
out.push(rowXml(r, [
|
||||
cellXml('A', r, style[b.key], b.label),
|
||||
cellXml('B', r, undefined, stats.all[b.key]),
|
||||
cellXml('C', r, undefined, stats.top[b.key]),
|
||||
]));
|
||||
});
|
||||
|
||||
// 合计
|
||||
const sumRow = statHeadRow + 1 + BANDS.length;
|
||||
out.push(rowXml(sumRow, [
|
||||
cellXml('A', sumRow, style.header, '合计'),
|
||||
cellXml('B', sumRow, undefined, stats.total),
|
||||
cellXml('C', sumRow, undefined, stats.topTotal),
|
||||
]));
|
||||
|
||||
// 口径说明行(无底色,A 列文字自然溢出到右侧空列)
|
||||
const noteRow = sumRow + 1;
|
||||
out.push(rowXml(noteRow, [
|
||||
cellXml('A', noteRow, undefined, buildScopeNote(stats, fullScore)),
|
||||
]));
|
||||
|
||||
return { xml: `<sheetData>${out.join('')}</sheetData>`, lastRow: noteRow, blankRow };
|
||||
}
|
||||
|
||||
// ==================== 组装 ====================
|
||||
|
||||
/** 造一个最小合法 xlsx 骨架(表头一行的空表),后续全部靠 zip 层替换 */
|
||||
function buildBaseBuffer(sheetName) {
|
||||
const ws = XLSX.utils.aoa_to_sheet([HEADERS.slice()]);
|
||||
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || 'Sheet1').slice(0, 31));
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/** 从 zip 条目里取某文件文本 */
|
||||
function textOf(entries, name) {
|
||||
const e = entries.find(x => x.name === name);
|
||||
if (!e) throw new Error(`xlsx 缺少条目 ${name}`);
|
||||
return entryText(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:生成成绩排名表 xlsx。
|
||||
*
|
||||
* @param {Object} opt
|
||||
* @param {Array<Object>} opt.teams 原始小组数组(未经排序)
|
||||
* @param {string} [opt.sheetName] 工作表名
|
||||
* @param {number} [opt.topN=80] 统计口径里的「前 N 名」
|
||||
* @returns {{ buffer: Buffer, colored: boolean, rows: Array<Object>, stats: Object, lastRow: number }}
|
||||
* colored=false 表示上色链路失败已自动降级为无色版
|
||||
*/
|
||||
function buildXlsx(opt) {
|
||||
const options = opt || {};
|
||||
const rows = buildRankRows(options.teams);
|
||||
const topN = options.topN == null ? DEFAULT_TOP_N : options.topN;
|
||||
const fullScore = options.fullScore == null ? DEFAULT_FULL_SCORE : options.fullScore;
|
||||
const stats = buildStats(rows, topN);
|
||||
const sheetName = options.sheetName || '成绩排名';
|
||||
const base = buildBaseBuffer(sheetName);
|
||||
|
||||
// ---- 第一路:带底色 ----
|
||||
try {
|
||||
const entries = readZip(base);
|
||||
|
||||
const stylesPatched = patchStyles(textOf(entries, 'xl/styles.xml'));
|
||||
let out = replaceEntryText(entries, 'xl/styles.xml', stylesPatched.xml);
|
||||
|
||||
const built = buildSheetData(rows, stats, stylesPatched.style, fullScore);
|
||||
let sheetXml = textOf(out, 'xl/worksheets/sheet1.xml');
|
||||
|
||||
const sdRe = /<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/;
|
||||
if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 <sheetData>');
|
||||
sheetXml = sheetXml.replace(sdRe, built.xml);
|
||||
|
||||
// dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断
|
||||
sheetXml = sheetXml.replace(
|
||||
/<dimension\b[^>]*\bref="[^"]*"/,
|
||||
`<dimension ref="A1:${DATA_COLS[DATA_COLS.length - 1]}${built.lastRow}"`
|
||||
);
|
||||
|
||||
// 骨架带出来的 ignoredErrors 指向 A1:H1,范围已失真,直接删掉避免误报
|
||||
sheetXml = sheetXml.replace(/<ignoredErrors\b[^>]*\/>/g, '')
|
||||
.replace(/<ignoredErrors\b[^>]*>[\s\S]*?<\/ignoredErrors>/g, '');
|
||||
|
||||
out = replaceEntryText(out, 'xl/worksheets/sheet1.xml', sheetXml);
|
||||
|
||||
const buffer = writeZip(out);
|
||||
selfCheck(buffer, rows, stats, true);
|
||||
|
||||
return { buffer, colored: true, rows, stats, lastRow: built.lastRow };
|
||||
} catch (err) {
|
||||
// ---- 第二路:降级无色 ----
|
||||
const buffer = buildPlain(rows, stats, sheetName, fullScore);
|
||||
selfCheck(buffer, rows, stats, false);
|
||||
return {
|
||||
buffer,
|
||||
colored: false,
|
||||
rows,
|
||||
stats,
|
||||
lastRow: rows.length + 8,
|
||||
degradeReason: err && err.message ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 降级版:不上任何底色,但数据/表头/统计块与带色版完全一致 */
|
||||
function buildPlain(rows, stats, sheetName, fullScore) {
|
||||
const aoa = [HEADERS.slice()];
|
||||
for (const row of rows) {
|
||||
aoa.push([row.rank, row.teamId, row.org, row.players, row.memberCount, row.examScore]);
|
||||
}
|
||||
aoa.push([]);
|
||||
aoa.push(['区间统计', '全量小组', `前${stats.topN}名(总排名)`]);
|
||||
for (const b of BANDS) aoa.push([b.label, stats.all[b.key], stats.top[b.key]]);
|
||||
aoa.push(['合计', stats.total, stats.topTotal]);
|
||||
aoa.push([buildScopeNote(stats, fullScore)]);
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || '成绩排名').slice(0, 31));
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 自检:回读产物,确认「能读通 + 行数对 + 首行值对 +(带色版)styles 里真有底色」。
|
||||
* 任一条不满足就抛错,由上层降级。
|
||||
*/
|
||||
function selfCheck(buffer, rows, stats, expectColor) {
|
||||
const wb = XLSX.read(buffer, { type: 'buffer' });
|
||||
const name = wb.SheetNames[0];
|
||||
const ws = wb.Sheets[name];
|
||||
if (!ws) throw new Error('自检失败:产物无工作表');
|
||||
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: true });
|
||||
if (!aoa.length) throw new Error('自检失败:产物为空');
|
||||
|
||||
const head = aoa[0] || [];
|
||||
for (let i = 0; i < HEADERS.length; i++) {
|
||||
if (String(head[i] == null ? '' : head[i]) !== HEADERS[i]) {
|
||||
throw new Error(`自检失败:表头第 ${i + 1} 列应为「${HEADERS[i]}」,实为「${head[i]}」`);
|
||||
}
|
||||
}
|
||||
|
||||
// 数据区行数
|
||||
const dataRows = aoa.slice(1, 1 + rows.length);
|
||||
if (dataRows.length !== rows.length) {
|
||||
throw new Error(`自检失败:数据行数 ${dataRows.length} ≠ 期望 ${rows.length}`);
|
||||
}
|
||||
// 首行关键值抽查(名次 / 小组编号 / 客观题分)
|
||||
if (rows.length) {
|
||||
const first = dataRows[0] || [];
|
||||
if (String(first[0]) !== String(rows[0].rank)) throw new Error('自检失败:首行名次不符');
|
||||
if (String(first[1]) !== String(rows[0].teamId)) throw new Error('自检失败:首行小组编号不符');
|
||||
if (Number(first[5]) !== Number(rows[0].examScore)) throw new Error('自检失败:首行客观题分不符');
|
||||
}
|
||||
|
||||
// 🔴 列数锁死:本表只出 6 列,「大题分」「总分」不得回流(R220-C 用户裁定)
|
||||
if (HEADERS.length !== 6) throw new Error(`自检失败:表头列数应为 6,实为 ${HEADERS.length}`);
|
||||
for (let i = 6; i < head.length; i++) {
|
||||
if (String(head[i] == null ? '' : head[i]).trim() !== '') {
|
||||
throw new Error(`自检失败:第 ${i + 1} 列应不存在,实为「${head[i]}」`);
|
||||
}
|
||||
}
|
||||
// 排序/名次必须由客观题分驱动:逐行校验「客观题分不得逆序」
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (rows[i - 1].examScore < rows[i].examScore) {
|
||||
throw new Error(`自检失败:第 ${i} 行客观题分逆序(${rows[i - 1].examScore} < ${rows[i].examScore})`);
|
||||
}
|
||||
}
|
||||
// 统计块
|
||||
const statHead = aoa[1 + rows.length + 1] || [];
|
||||
if (String(statHead[0] || '') !== '区间统计') throw new Error('自检失败:统计块表头缺失');
|
||||
|
||||
const expectC = `前${stats.topN}名(总排名)`;
|
||||
if (String(statHead[2] || '') !== expectC) {
|
||||
throw new Error(`自检失败:统计块 C 列表头应为「${expectC}」,实为「${statHead[2]}」`);
|
||||
}
|
||||
// 🔴 口径自证:「前N名」列合计必须 = min(总数, N)。
|
||||
// 若被误实现成「各分数区间分别取前 N 个」,总数 > N 时该值会超 N ⇒ 此处直接红。
|
||||
const expectTopTotal = Math.min(stats.total, stats.topN);
|
||||
if (stats.topTotal !== expectTopTotal) {
|
||||
throw new Error(
|
||||
`自检失败:前${stats.topN}名合计 ${stats.topTotal} ≠ min(总数 ${stats.total}, ${stats.topN})`
|
||||
);
|
||||
}
|
||||
const topSum = stats.top.green + stats.top.yellow + stats.top.red;
|
||||
if (topSum !== stats.topTotal) {
|
||||
throw new Error(`自检失败:三区间前N名之和 ${topSum} ≠ 合计 ${stats.topTotal}`);
|
||||
}
|
||||
for (const b of BANDS) {
|
||||
if (stats.top[b.key] > stats.all[b.key]) {
|
||||
throw new Error(`自检失败:${b.label} 的「前N名」数 ${stats.top[b.key]} 超过全量数 ${stats.all[b.key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectColor) {
|
||||
const entries = readZip(buffer);
|
||||
const styles = textOf(entries, 'xl/styles.xml');
|
||||
for (const b of BANDS) {
|
||||
if (styles.indexOf(b.rgb) < 0) throw new Error(`自检失败:styles.xml 缺底色 ${b.rgb}`);
|
||||
}
|
||||
const sheetXml = textOf(entries, 'xl/worksheets/sheet1.xml');
|
||||
if (!/\bs="\d+"/.test(sheetXml)) throw new Error('自检失败:sheet1.xml 无任何样式引用');
|
||||
|
||||
// ---- 红框校验(R220-D):从产物反推,不依赖内存里的 style 索引 ----
|
||||
// 先找「含红色 + medium 线宽」的 border 定义,再看哪些 xf 引用了它们,
|
||||
// 最后逐行核对「前 min(topN, 行数) 行必须是红框 xf,其余行必须不是」。
|
||||
const bordersBlock = /<borders\b[^>]*>([\s\S]*?)<\/borders>/.exec(styles);
|
||||
const borderList = bordersBlock ? (bordersBlock[1].match(/<border\b[\s\S]*?<\/border>/g) || []) : [];
|
||||
const redBorderIds = new Set();
|
||||
borderList.forEach((b, i) => {
|
||||
if (b.indexOf(RED_BORDER_RGB) >= 0 && /style="medium"/.test(b)) redBorderIds.add(i);
|
||||
});
|
||||
if (redBorderIds.size === 0) throw new Error('自检失败:styles.xml 里没有红色粗边框定义');
|
||||
|
||||
const xfsBlock = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(styles);
|
||||
const xfList = xfsBlock ? (xfsBlock[1].match(/<xf\b[^>]*\/>/g) || []) : [];
|
||||
const boxXfIds = new Set();
|
||||
xfList.forEach((x, i) => {
|
||||
const mm = /borderId="(\d+)"/.exec(x);
|
||||
if (mm && redBorderIds.has(Number(mm[1]))) boxXfIds.add(i);
|
||||
});
|
||||
if (boxXfIds.size === 0) throw new Error('自检失败:没有 cellXfs 引用红色边框');
|
||||
|
||||
const expectBox = Math.min(stats.topN, rows.length);
|
||||
const styleAt = k => {
|
||||
const rm = new RegExp(`<row r="${k}">([\\s\\S]*?)</row>`).exec(sheetXml);
|
||||
const sm = rm ? /\bs="(\d+)"/.exec(rm[1]) : null;
|
||||
return sm ? Number(sm[1]) : -1;
|
||||
};
|
||||
for (let k = 0; k < rows.length; k++) {
|
||||
const sx = styleAt(k + 2);
|
||||
const hasBox = boxXfIds.has(sx);
|
||||
const want = k < expectBox;
|
||||
if (hasBox !== want) {
|
||||
throw new Error(
|
||||
`自检失败:第 ${k + 2} 行红框归属错误(应为「${want ? '有框' : '无框'}」,`
|
||||
+ `实为「${hasBox ? '有框' : '无框'}」,s=${sx},前 ${expectBox} 行应有框)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rows: aoa.length, sheetName: name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HEADERS,
|
||||
DATA_COLS,
|
||||
COL_WIDTHS,
|
||||
DEFAULT_TOP_N,
|
||||
DEFAULT_FULL_SCORE,
|
||||
BANDS,
|
||||
HEADER_RGB,
|
||||
RED_BORDER_RGB,
|
||||
toNum,
|
||||
round2,
|
||||
scoreBand,
|
||||
buildRankRows,
|
||||
buildStats,
|
||||
buildScopeNote,
|
||||
patchStyles,
|
||||
buildSheetData,
|
||||
buildXlsx,
|
||||
buildPlain,
|
||||
selfCheck,
|
||||
};
|
||||
@@ -155,6 +155,11 @@ module.exports = app => {
|
||||
// ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号),
|
||||
// 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。
|
||||
router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity);
|
||||
// 成绩排名表导出(R220):中职/高职按小组**客观题分**降序**全量**导出,整行按客观题分区间上底色。
|
||||
// ⚠️ 第三个产物,⛔ 不可与上面两个互换:export-excel = 内部核查表(含身份证号/手机号)、
|
||||
// export-publicity = 对外公示表(模板半成品、获奖等级留空)、
|
||||
// 本接口 = 排名速览表(一小组一行、含区间底色与区间统计、无任何敏感字段)。
|
||||
router.get('/api/admin/export-score-rank/:group', adminAuth, controller.admin.exportScoreRank);
|
||||
// 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。
|
||||
// ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。
|
||||
router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo);
|
||||
|
||||
@@ -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