diff --git a/app/controller/admin.js b/app/controller/admin.js index fe99018..8651f94 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -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) ==================== /** diff --git a/app/lib/adminPermissions.js b/app/lib/adminPermissions.js index 0f25006..ac4cd7d 100644 --- a/app/lib/adminPermissions.js +++ b/app/lib/adminPermissions.js @@ -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']], diff --git a/app/lib/scoreRankSheet.js b/app/lib/scoreRankSheet.js new file mode 100644 index 0000000..e8720bd --- /dev/null +++ b/app/lib/scoreRankSheet.js @@ -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"` + `文本` + * (这是「公式字符串」语义,无 `` 时并不规范)。既然要逐行改样式, + * 索性用 `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} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total} + * @returns {Array} 追加了 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} 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 = /]*)>([\s\S]*?)<\/fills>/; + const fontsRe = /]*)>([\s\S]*?)<\/fonts>/; + const bordersRe = /]*)>([\s\S]*?)<\/borders>/; + const xfsRe = /]*)>([\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 缺少 '); + if (!fontsM) throw new Error('styles.xml 缺少 '); + if (!bordersM) throw new Error('styles.xml 缺少 '); + if (!xfsM) throw new Error('styles.xml 缺少 '); + + 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 => ``; + 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], () => + `${fillsM[2]}${addedFills}`); + + // ---- fonts:表头加粗 ---- + const boldFontId = baseFontCount; + const boldFont = ''; + out = out.replace(fontsM[0], () => + `${fontsM[2]}${boldFont}`); + + // ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)---- + const side = name => `<${name} style="medium">`; + const empty = name => `<${name}/>`; + const borderXml = (top, bottom) => + `${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}`; + + // 位置 → [是否有上边, 是否有下边] + 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], () => + `${bordersM[2]}${addedBorders}`); + + // ---- 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 ``; + }; + + 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], () => + `${xfsM[2]}${addedXfs.join('')}`); + + return { xml: out, style }; +} + +// ==================== sheetData 重建 ==================== + +function rowXml(r, cells) { + return `${cells.join('')}`; +} + +/** + * 生成完整的 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: `${out.join('')}`, 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} opt.teams 原始小组数组(未经排序) + * @param {string} [opt.sheetName] 工作表名 + * @param {number} [opt.topN=80] 统计口径里的「前 N 名」 + * @returns {{ buffer: Buffer, colored: boolean, rows: Array, 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 = /]*>[\s\S]*?<\/sheetData>/; + if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 '); + sheetXml = sheetXml.replace(sdRe, built.xml); + + // dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断 + sheetXml = sheetXml.replace( + /]*\bref="[^"]*"/, + `]*\/>/g, '') + .replace(/]*>[\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 = /]*>([\s\S]*?)<\/borders>/.exec(styles); + const borderList = bordersBlock ? (bordersBlock[1].match(//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 = /]*>([\s\S]*?)<\/cellXfs>/.exec(styles); + const xfList = xfsBlock ? (xfsBlock[1].match(/]*\/>/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(`([\\s\\S]*?)`).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, +}; diff --git a/app/router.js b/app/router.js index 0b0a4d0..e546b75 100644 --- a/app/router.js +++ b/app/router.js @@ -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); diff --git a/app/service/admin.js b/app/service/admin.js index 4bb12e7..bfbafb8 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -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} { 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 行 */