'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