fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)

## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

+1
View File
@@ -65,6 +65,7 @@ var athleteDetailColumns = []column{
var athleteTeamColumns = athleteDetailColumns[1:]
// ExportAthleteDetailXLSX 导出个人得分明细,落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportAthleteDetailXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAthleteDetailWorkbook(db, opts)
if err != nil {
+9 -1
View File
@@ -126,6 +126,10 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
// BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish,
// 便于在备份列表里一眼看出哪些是发布节点留的。
//
// 当前没有生产调用方(代码卫生项 C29):生产走 Service.RunEvent("publish")(同一把 Service.mu
// 串行化,见 backup.go 的 Service),只有 backup_test.go 在用。留着是因为「reason=publish 的
// 备份名能被 ListBackups/reasonFromName 认回来」这条口径需要它来钉。
func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
if strings.TrimSpace(opts.Reason) == "" {
opts.Reason = "publish"
@@ -281,7 +285,11 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
return out, nil
}
// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。
// LatestBackup 返回最近一份备份(ListBackups 已按时间倒序,取首个)。
//
// 注意:它**不是**健康检查的输入——05 §9 明确说 GET /api/health 不返回「最近备份时间」,
// 备份列表接口走的是 ListBackups。当前只有 backup_test.go 在用它(代码卫生项 C29),
// 加调用方前先想清楚要不要真的把备份时间暴露到健康检查里。
func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
list, err := ListBackups(dir, prefix)
if err != nil || len(list) == 0 {
+10 -4
View File
@@ -404,17 +404,23 @@ func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) {
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
present := make(map[string]bool, len(sheets))
// 认表用去空白后的名字(现场整理过的表常带尾随空格),取表必须用**原名**:
// 之前拿 spec.Sheet 去 GetRows,表就在文件里却报「sheet 单位 does not exist」——
// 一句与事实相反的错,排障时最容易把人带偏。
actual := make(map[string]string, len(sheets))
for _, name := range sheets {
present[strings.TrimSpace(name)] = true
actual[strings.TrimSpace(name)] = name
}
tables := make([]BaseDataTable, 0, len(baseDataSheets))
for _, spec := range BaseDataSpecs() {
if !present[spec.Sheet] {
name, ok := actual[spec.Sheet]
if !ok {
continue
}
rows, err := f.GetRows(spec.Sheet)
// 报错与行级提示一律用契约里的表名(spec.Sheet),不用带空格的原名,
// 免得同一张表在错误信息里出现两种写法。
rows, err := f.GetRows(name)
if err != nil {
return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err)
}
+49
View File
@@ -258,6 +258,55 @@ func TestReadBaseDataPackageErrors(t *testing.T) {
}
}
// B03:工作表名带尾随空格时也必须认得出来,且取表要用文件里的原名。
// 现场把导出件另存 / 手工改过表名后很容易多出个空格;旧代码「认表用 TrimSpace 后的名字、
// 取表却用契约里的名字」,于是报出一句与事实相反的错:表就在文件里,却说它不存在。
func TestReadBaseDataPackageTrimsSheetName(t *testing.T) {
const spaced = BaseDataSheetTeams + " " // "单位 ":末尾一个空格
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", spaced); err != nil {
t.Fatalf("重命名工作表为带尾随空格的名字: %v", err)
}
rows := [][]string{
{"单位编码", "单位名称", "启用"},
{"T9", "第九分公司", "是"},
}
for r, row := range rows {
for c, v := range row {
name, err := excelize.CoordinatesToCellName(c+1, r+1)
if err != nil {
t.Fatalf("算单元格坐标: %v", err)
}
if err := f.SetCellValue(spaced, name, v); err != nil {
t.Fatalf("写单元格: %v", err)
}
}
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("表名带尾随空格时应照样读得出来,得到错误:%v", err)
}
if len(tables) != 1 || tables[0].Sheet != BaseDataSheetTeams {
t.Fatalf("应认出「单位」表,且 Sheet 报契约里的名字,得到 %+v", tables)
}
if len(tables[0].Rows) != 1 {
t.Fatalf("数据行数 = %d,期望 1:%+v", len(tables[0].Rows), tables[0].Rows)
}
if got := tables[0].Rows[0].Cells["code"]; got != "T9" {
t.Errorf("单位编码 = %q,期望 T9", got)
}
if got := tables[0].Rows[0].Cells["name"]; got != "第九分公司" {
t.Errorf("单位名称 = %q,期望 第九分公司", got)
}
}
func TestParseBaseDataHelpers(t *testing.T) {
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
+1
View File
@@ -67,6 +67,7 @@ var entriesColumns = []column{
}
// ExportEntriesXLSX 导出报名名单到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error {
wb, err := LoadEntriesWorkbook(db, filter, opts)
if err != nil {
+20 -3
View File
@@ -26,6 +26,11 @@ type EventResultRow struct {
// NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。
// 与 Unpublished 互斥——有成绩行才会有发布状态。
NoResult bool
// Unsettled 表示这行成绩已发布,但整组还没结算:延迟结算(分批发布)下名次与积分
// 要等该 (小项, 组别) 录完才写,此刻行上没有名次、没有流水。
// 它必须与「算出来就是 0 分、没名次」区分开(R18:占位符口径),见 eventRows。
// 与 Unpublished / NoResult 互斥——那两态是「没有值」,这一态是「值有了,名次与积分还没到」。
Unsettled bool
}
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
@@ -60,6 +65,7 @@ var eventColumns = []column{
}
// ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error {
wb, err := LoadEventWorkbook(db, eventID, opts)
if err != nil {
@@ -130,10 +136,16 @@ const eventCellBlank = "—"
// eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。
const eventCellUnpublished = "分数未录入"
// eventCellUnsettled 是「成绩已发布但整组未结算」行在状态列的文案。
// 只把名次 / 积分写成「—」还不够:三格里的成绩有值、状态又写「有效」,
// 打印件上仍会被读成「算出来就是 0 分、没有名次」(R18 要避免的正是这个),
// 所以状态列直接写「未结算」,让人一眼看出是没结算、不是没算出来。
const eventCellUnsettled = "未结算"
// eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。
//
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值,
// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入 / 未结算时都没有可比的值,
// 半空半 0 反而像填错。已发布行原样写出(这三种行都不带 r.Points 的 0)。
func eventRows(rows []EventResultRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
@@ -141,6 +153,7 @@ func eventRows(rows []EventResultRow) [][]any {
rank any = r.Rank
result any = displayResult(r.ResultText, r.Unit)
points any = r.Points
status any = r.Status
)
switch {
case r.NoResult:
@@ -149,10 +162,14 @@ func eventRows(rows []EventResultRow) [][]any {
case r.Unpublished:
// 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。
rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank
case r.Unsettled:
// 已发布没结算:成绩照常印(它确实跑出来了),名次与积分等结算,
// 状态列写「未结算」把「还没到」与「就是 0 分」彻底分开。
rank, points, status = eventCellBlank, eventCellBlank, eventCellUnsettled
}
out = append(out, []any{
rank, r.Bib, r.TeamName, r.Participant,
result, points, r.Status, r.Remark,
result, points, status, r.Remark,
})
}
return out
+148 -1
View File
@@ -8,6 +8,153 @@ import (
"github.com/xuri/excelize/v2"
)
// seedUnsettledEvent 造一屏「延迟结算」现场:3 个人报名同一个组别,只有 1 个人出了成绩。
//
// 已发布的那条**没有名次、没有流水**——延迟结算(standings.live_on_publish=false)下名次与流水
// 要等整组录完才写(httpapi 的 scopeSettled),这正是 A17 说的「已发布未结算」。
// 另外两条分别是「有草稿没发布」与「连成绩行都没有」,三种占位态因此能并排比。
func seedUnsettledEvent(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 2, '102')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
`INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state)
VALUES (1, 1, '12.30', 12.30, 's', 'published')`,
`INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state)
VALUES (1, 2, '12.40', 12.40, 's', 'draft')`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// A17:延迟结算下「已发布但整组未结算」的行必须写成「名次 —、积分 —、状态 未结算」,
// 不能是「名次空 + 积分 0 + 状态 有效」——打印件上后者会被读成「算出来就是 0 分」,
// 正是 R18 立项要避免的误读。分项成绩表与单页表逐格一致(两者都走 eventRows)。
func TestUnsettledRowsPrintPlaceholders(t *testing.T) {
db := openEventDB(t)
seedUnsettledEvent(t, db)
dir := t.TempDir()
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
flatDst := filepath.Join(dir, "单页.xlsx")
if err := ExportFlatXLSX(db, flatDst, Options{}); err != nil {
t.Fatalf("导出单页成绩表: %v", err)
}
// 三种占位态并排:未结算 / 草稿未发布 / 完全没录。列序见 eventColumns。
want := [][]string{
{"—", "101", "第一分公司", "张三", "12.30 秒", "—", "未结算", ""},
{"—", "102", "第一分公司", "李四", "分数未录入", "—", "草稿", ""},
{"—", "103", "第一分公司", "王五", "—", "—", "未录入", ""},
}
f := openXLSX(t, eventDst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("田径·100米跑·男子")
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
for i, wantRow := range want {
for c, v := range wantRow {
if got := cell(rows, rowHeader+i, c); got != v {
t.Errorf("分项成绩表第 %d 行第 %d 列 = %q,期望 %q", i+1, c, got, v)
}
}
}
// 单页表比多 sheet 版多三列(大项 / 小项 / 组别),后 8 列同序。
ff := openXLSX(t, flatDst)
defer func() { _ = ff.Close() }()
flatRows, err := ff.GetRows(flatSheet)
if err != nil {
t.Fatalf("读取单页成绩: %v", err)
}
for i, wantRow := range want {
for c, v := range wantRow {
if got := cell(flatRows, rowHeader+i, c+3); got != v {
t.Errorf("单页表第 %d 行第 %d 列 = %q,期望 %q(与分项成绩表逐格一致)", i+1, c+3, got, v)
}
}
}
}
// A17 的反向钉子:实时结算(默认)下分批发布同样会让「已发布 < 应出成绩」,
// 但那一批当场结算过、名次与积分都在——不能被标成未结算。
// 同组的犯规行也不许被改写成「未结算」:判罚结果是现场明确给出的,不能弄丢。
func TestSettledAndPenalisedRowsAreNotMarkedUnsettled(t *testing.T) {
db := openEventDB(t)
seedUnsettledEvent(t, db)
stmts := []string{
// 已结算:名次与流水都写好了(发布即结算的形态)。
`UPDATE results SET rank = 1 WHERE entry_id = 1`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
// 第二个人改为「已发布 + 犯规」:犯规不占名次,状态列要保住「犯规」。
`UPDATE results SET state = 'published', status = 'dq' WHERE entry_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
dst := filepath.Join(t.TempDir(), "分项.xlsx")
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("田径·100米跑·男子")
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
// 已结算的那条:名次 1、积分 10、状态 有效。
if got := cell(rows, rowHeader, 0); got != "1" {
t.Errorf("已结算行名次 = %q,期望 1(不该被当成未结算)", got)
}
if got := cell(rows, rowHeader, 5); got != "10" {
t.Errorf("已结算行积分 = %q,期望 10", got)
}
if got := cell(rows, rowHeader, 6); got != "有效" {
t.Errorf("已结算行状态 = %q,期望 有效", got)
}
// 犯规的那条:状态仍是「犯规」,不能被改写成「未结算」(判罚是现场明确给出的结论)。
dq := findRowByCell(t, rows, 1, "102")
if got := cell(rows, dq, 6); got != "犯规" {
t.Errorf("犯规行状态 = %q,期望 犯规(判罚结果不能被改写成未结算)", got)
}
}
// findRowByCell 在表里找「第 col 列等于 value」的数据行下标;找不到直接失败。
func findRowByCell(t *testing.T, rows [][]string, col int, value string) int {
t.Helper()
for r := rowHeader; r < len(rows); r++ {
if cell(rows, r, col) == value {
return r
}
}
t.Fatalf("表里找不到第 %d 列 = %q 的行", col, value)
return -1
}
// seedTieAndRelay 在 seedEvent 之上补两类现场常见、却最容易把列宽顶爆的数据:
// 并列名次(名次列会写成「3(并列)」)与接力队名单(运动员/队 列写成顿号列表)。
func seedTieAndRelay(t *testing.T, db *sql.DB) {
@@ -66,7 +213,7 @@ func TestColumnsFitContent(t *testing.T) {
},
},
{
name: "团体奖牌明细",
name: "单位奖牌明细",
file: "奖牌.xlsx",
export: func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) },
sheets: []sheetLayout{
+1
View File
@@ -26,6 +26,7 @@ var flatColumns = append([]column{
}, eventColumns...)
// ExportFlatXLSX 导出「全部成绩(单页)」到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportFlatXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadFlatWorkbook(db, opts)
if err != nil {
+1 -1
View File
@@ -114,7 +114,7 @@ func TestFlatMatchesAllXLSX(t *testing.T) {
if err != nil {
t.Fatalf("读取全部: %v", err)
}
// 「全部」前 3 张是总表(总积分榜 / 团体奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。
// 「全部」前 3 张是总表(总积分榜 / 单位奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。
sheets := allF.GetSheetList()
if len(sheets) != 3+len(wb.Events.Sheets) {
t.Fatalf("全部导出 sheet 列表 = %v,与 %d 个分项对不上", sheets, len(wb.Events.Sheets))
+53 -5
View File
@@ -17,6 +17,9 @@ type Meta struct {
Subtitle string
Organizer string
GeneratedAt time.Time
// GeneratedBy 是「导出人 XXX」那一行的取值。当前没有任何生产写入口(所有调用方都只填 EventName 等),
// 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。
// 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。
GeneratedBy string
}
@@ -33,6 +36,11 @@ type Options struct {
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出;
// 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
//
// 当前没有生产写入口:HTTP 层所有导出 handler 都传 Options{},只有单测(load_test.go 的
// TestEventRowsIncludeDraftsKeepsDraftValues)设过 true,所以这个分支在生产不可达(代码卫生项 C30)。
// 保留它是因为「草稿值不外露」的口径必须有对照面才能被测住;真要接线请先想清楚
// 「谁能看到别人的草稿值」这个权限问题(现在连只读接口都不外露草稿)。
IncludeDrafts bool
}
@@ -454,19 +462,27 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult)。
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。
//
// gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源:
// 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩),
// 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。
// 延迟结算(standings.live_on_publish=false)下两者不等就说明这一组还没结算,
// 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。
query := `
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '')
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, ''),
r.result_value IS NOT NULL,
COALESCE(gs.required, 0), COALESCE(gs.published, 0)
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
@@ -478,8 +494,19 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id
AND cr.category_id = en.category_id
LEFT JOIN (
SELECT e2.event_id, e2.category_id, COUNT(*) AS required,
SUM(CASE WHEN r2.state = 'published'
AND (r2.result_value IS NOT NULL OR r2.rank IS NOT NULL OR r2.status <> 'ok')
THEN 1 ELSE 0 END) AS published
FROM entries e2
LEFT JOIN results r2 ON r2.entry_id = e2.id
WHERE e2.event_id = ? AND e2.status = 'registered'
GROUP BY e2.event_id, e2.category_id
) gs ON gs.event_id = en.event_id AND gs.category_id = en.category_id
WHERE en.event_id = ?`
args := []any{eventID}
// 子查询里那个 ? 是同一项(先给 gs 的 event_id,再给外层 WHERE)。
args := []any{eventID, eventID}
if categoryID != 0 {
query += ` AND en.category_id = ?`
args = append(args, categoryID)
@@ -504,10 +531,12 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
hasValue int
required, published int
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote); err != nil {
&entryRemark, &resultNote, &hasValue, &required, &published); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantName(athleteName, memberJSON)
@@ -519,6 +548,20 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
row.NoResult = !resultState.Valid
row.Unpublished = !includeDrafts && resultState.Valid &&
domain.ResultState(resultState.String) == domain.ResultStateDraft
// 已发布但整组还没结算:成绩上屏了,名次与积分要等结算(见 Unsettled 的说明)。
// 这里的条件缺一不可,少任何一个都会误伤——
// published < required 且都非 0:这一组确实还没结算(与 /api/results/settlement 同判据);
// **state 是 published**:草稿行不算(它没有对外,未发布口径已经把它划走了;
// includeDrafts=true 时草稿行连值一起导出,也必须按草稿呈现,不能被这一分支抢走);
// 有成绩值、状态是 ok:这才是「等名次」的那种行;犯规 / 弃权行本来就不占名次,
// 把它们的「犯规」改写成「未结算」会把现场明确的判罚结果弄丢;
// 名次与积分都空:实时结算(默认)下分批发布也会出现 published < required,
// 但那一批已经结算过、名次与积分都在,不能标成未结算。
row.Unsettled = published > 0 && published < required &&
!row.NoResult && resultState.Valid &&
domain.ResultState(resultState.String) == domain.ResultStatePublished &&
hasValue != 0 && rank <= 0 && row.Points == 0 &&
domain.ResultStatus(resultStat.String) == domain.ResultStatusOK
out = append(out, row)
}
if err := rows.Err(); err != nil {
@@ -602,6 +645,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
COALESCE(r.unit, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
@@ -633,10 +677,14 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
var r TeamDetailRow
var source string
var athleteID int64
var resultText, unit string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
// 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。
r.ResultText = displayResult(resultText, unit)
r.Source = sourceLabel(source)
r.individual = athleteID != 0
out = append(out, r)
+9 -8
View File
@@ -13,8 +13,8 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// medalDetailSheet 是「团体奖牌明细」总表的表名。
const medalDetailSheet = "团体奖牌明细"
// medalDetailSheet 是「单位奖牌明细」总表的表名。
const medalDetailSheet = "单位奖牌明细"
// 奖牌文案:与名次 1/2/3 一一对应。
const (
@@ -23,7 +23,7 @@ const (
medalBronze = "铜牌"
)
// MedalDetailRow 是团体奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。
// MedalDetailRow 是单位奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。
type MedalDetailRow struct {
SportName string
EventName string
@@ -47,7 +47,7 @@ type MedalDetailSheet struct {
Rows []MedalDetailRow
}
// MedalDetailWorkbook 是「团体奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。
// MedalDetailWorkbook 是「单位奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。
type MedalDetailWorkbook struct {
Meta Meta
Sheets []MedalDetailSheet
@@ -67,7 +67,8 @@ var medalDetailColumns = []column{
// medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。
var medalTeamColumns = medalDetailColumns[1:]
// ExportMedalDetailXLSX 导出团体奖牌明细,落盘到 dst。
// ExportMedalDetailXLSX 导出单位奖牌明细,落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadMedalDetailWorkbook(db, opts)
if err != nil {
@@ -80,7 +81,7 @@ func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error {
return saveWorkbook(f, dst)
}
// WriteMedalDetailXLSX 把团体奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。
// WriteMedalDetailXLSX 把单位奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。
func WriteMedalDetailXLSX(w io.Writer, wb MedalDetailWorkbook) error {
f, err := medalDetailFile(wb)
if err != nil {
@@ -156,7 +157,7 @@ func medalRow(r MedalDetailRow) []any {
}
// LoadMedalDetailWorkbook 读取全部奖牌来源。
// 单位顺序沿用「团体奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。
// 单位顺序沿用「单位奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。
func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, error) {
if db == nil {
return MedalDetailWorkbook{}, ErrNoDatabase
@@ -216,7 +217,7 @@ type medalHit struct {
// medalHits 逐「报名项」判定获奖单位。
//
// 同一报名项在多条计分通道下会展开成多条名次,只能记一枚奖牌——选通道、联队份额展开、
// 名次换牌色这些规则都交给 domain 的团体奖牌榜函数(domain.BuildMedalStandings)判定,
// 名次换牌色这些规则都交给 domain 的单位奖牌榜函数(domain.BuildMedalStandings)判定,
// 导出侧不复制计奖规则。传入的单位/流水/运动员都为空,返回的行只可能来自该报名项的奖牌。
func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medalHit {
out := make(map[int64][]medalHit)
+10 -10
View File
@@ -23,7 +23,7 @@ func addBronzeEntry(t *testing.T, db *sql.DB) {
}
}
// 团体奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。
// 单位奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。
func TestExportMedalDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
@@ -32,17 +32,17 @@ func TestExportMedalDetailXLSX(t *testing.T) {
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出团体奖牌明细: %v", err)
t.Fatalf("导出单位奖牌明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
want := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
master, err := f.GetRows("团体奖牌明细")
master, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
@@ -105,7 +105,7 @@ func TestExportMedalDetailXLSX(t *testing.T) {
}
}
// 奖牌明细必须与团体奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。
// 奖牌明细必须与单位奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。
func TestMedalDetailMatchesBoard(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
@@ -136,7 +136,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
// 双计项目只贡献一枚金牌(去重成一条),不是两条。
@@ -176,7 +176,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
}
// 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出
// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。
// 大项 / 小项 / 组别,并与单位奖牌榜逐单位对得上(devdocs 17 §11.3)。
func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
@@ -201,7 +201,7 @@ func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
for _, row := range board.Medal {
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
@@ -239,10 +239,10 @@ func TestExportMedalDetailEmptyEvent(t *testing.T) {
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
if got := f.GetSheetList(); !equalStrings(got, []string{"团体奖牌明细"}) {
if got := f.GetSheetList(); !equalStrings(got, []string{"单位奖牌明细"}) {
t.Fatalf("sheet 列表 = %v,期望只有总表", got)
}
rows, err := f.GetRows("团体奖牌明细")
rows, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
+5 -3
View File
@@ -9,7 +9,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// StandingsWorkbook 是「总表」的内容:总积分榜 + 团体奖牌榜 + 个人积分奖牌榜。
// StandingsWorkbook 是「总表」的内容:总积分榜 + 单位奖牌榜 + 个人积分奖牌榜。
// 三个榜由 domain 的排名函数算出,本包只负责排版。
type StandingsWorkbook struct {
Meta Meta
@@ -55,6 +55,7 @@ var (
)
// ExportStandingsXLSX 从赛事库读取并导出总表(三个 sheet),落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportStandingsXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadStandingsWorkbook(db, opts)
if err != nil {
@@ -91,7 +92,7 @@ func writeStandingsSheets(f *excelize.File, st *styleSet, wb StandingsWorkbook)
rows [][]any
}{
{"总积分榜", sheetTitle(meta, "总积分榜"), teamColumns, teamRows(wb.Team)},
{"团体奖牌榜", sheetTitle(meta, "团体奖牌榜"), medalColumns, medalRows(wb.Medal)},
{"单位奖牌榜", sheetTitle(meta, "单位奖牌榜"), medalColumns, medalRows(wb.Medal)},
{"个人积分奖牌榜", sheetTitle(meta, "个人积分奖牌榜"), athleteColumns, athleteRows(wb.Athlete)},
}
for _, s := range specs {
@@ -154,6 +155,7 @@ type AllWorkbook struct {
}
// ExportAllXLSX 导出总表 + 全部分项成绩到一个多 sheet 工作簿。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportAllXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAllWorkbook(db, opts)
if err != nil {
@@ -195,7 +197,7 @@ func allFile(wb AllWorkbook) (*excelize.File, error) {
}
namer := &sheetNamer{}
// 三个榜先登记名称,分项表才不会撞名。
for _, n := range []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} {
for _, n := range []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"} {
namer.add(n)
}
return writeEventSheets(f, st, wb.Events.Meta, wb.Events.Sheets, namer)
+6
View File
@@ -300,6 +300,12 @@ func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title s
}
// saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。
//
// 只被本包那 9 个 Export*XLSX 包装函数调用,而它们**只有测试在调**:生产导出(HTTP 层)
// 一律走 Load* + Write*(w io.Writer, wb),把工作簿直接写进响应体,不落临时盘。
// 所以整条「写文件」路径在生产不可达(代码卫生项 C28)。作用是让单测真的落盘、再用
// openXLSX 读回来逐格校验,比只比对内存结构更接近现场拿到的那份文件。
// 要把它接进生产,先想清楚多一份临时文件与一次磁盘写的代价,别在 handler 里随手用。
func saveWorkbook(f *excelize.File, dst string) error {
if strings.TrimSpace(dst) == "" {
return errors.New("export: 导出路径为空")
+29
View File
@@ -1,6 +1,8 @@
package export
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
@@ -56,20 +58,47 @@ func TestExportDocPropsAreNeutral(t *testing.T) {
}
}
// writeWorkbookFile 把「写 xlsx 流」的导出落到磁盘上,供本文件核对文档属性。
// 落盘是给测试读回用的,生产一律直接写响应体(见 saveWorkbook 的说明)。
func writeWorkbookFile(dst string, write func(io.Writer) error) error {
f, err := os.Create(dst)
if err != nil {
return err
}
if err := write(f); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
// 所有 xlsx 导出都走 buildWorkbook,文档属性必须一处设好、处处生效。
func TestEveryWorkbookCarriesDocProps(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
// 表里必须把 9 个落盘导出函数**全部**覆盖到:漏掉哪一个,那份工作簿的文档属性就没人守。
exports := map[string]func(string) error{
"总表.xlsx": func(dst string) error { return ExportStandingsXLSX(db, dst, Options{}) },
"全部.xlsx": func(dst string) error { return ExportAllXLSX(db, dst, Options{}) },
"分项.xlsx": func(dst string) error { return ExportEventXLSX(db, 1, dst, Options{}) },
"单页.xlsx": func(dst string) error { return ExportFlatXLSX(db, dst, Options{}) },
"奖牌.xlsx": func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) },
"个人明细.xlsx": func(dst string) error { return ExportAthleteDetailXLSX(db, dst, Options{}) },
"单位明细.xlsx": func(dst string) error { return ExportTeamDetailXLSX(db, 1, dst, Options{}) },
"单位总明细.xlsx": func(dst string) error { return ExportAllTeamDetailsXLSX(db, dst, Options{}) },
"报名名单.xlsx": func(dst string) error { return ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}) },
// 基础数据包没有落盘版包装函数(生产与这里都走 Load + Write),用流写盘补齐这一项。
"基础数据包.xlsx": func(dst string) error {
return writeWorkbookFile(dst, func(w io.Writer) error {
wb, err := LoadBaseDataWorkbook(db, Options{})
if err != nil {
return err
}
return WriteBaseDataXLSX(w, wb)
})
},
}
for name, run := range exports {
dst := filepath.Join(dir, name)
+4 -4
View File
@@ -48,6 +48,7 @@ var teamDetailColumns = []column{
}
// ExportTeamDetailXLSX 导出某单位的积分明细(逐项列出得分来源)。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportTeamDetailXLSX(db *sql.DB, teamID int64, dst string, opts Options) error {
wb, err := LoadTeamDetailWorkbook(db, teamID, opts)
if err != nil {
@@ -108,11 +109,10 @@ func teamDetailDataRows(rows []TeamDetailRow, weight float64) ([][]any, float64)
return out, total
}
// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时不加合计行。
// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时也保留合计行(写 0)。
// 手册(docs/使用手册.md 第 10 节)承诺「末行是合计」,总明细版也恒有合计行;
// 单单位版若在空数据时省掉它,同一份表在两种入口下版式不一致,读表的人会以为少印了一行。
func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any {
data, total := teamDetailDataRows(rows, weight)
if len(data) == 0 {
return data
}
return append(data, []any{"合计", "", "", "", "", "", "", "", total, ""})
}
+1
View File
@@ -32,6 +32,7 @@ type AllTeamDetailsWorkbook struct {
var allTeamDetailColumns = append([]column{{"单位", 22, kindText}}, teamDetailColumns...)
// ExportAllTeamDetailsXLSX 导出所有单位的积分明细(总表 + 每单位一张分表),落盘到 dst。
// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。
func ExportAllTeamDetailsXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadAllTeamDetailsWorkbook(db, opts)
if err != nil {
+87 -5
View File
@@ -24,7 +24,7 @@ func TestExportStandingsXLSX(t *testing.T) {
}
defer func() { _ = f.Close() }()
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}
want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
@@ -56,12 +56,12 @@ func TestExportStandingsXLSX(t *testing.T) {
t.Errorf("第二名单位代码 = %q,期望 T2", got)
}
medal, err := f.GetRows("团体奖牌榜")
medal, err := f.GetRows("单位奖牌榜")
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
t.Fatalf("读取单位奖牌榜: %v", err)
}
if len(medal) != 6 {
t.Fatalf("团体奖牌榜行数 = %d,期望 6", len(medal))
t.Fatalf("单位奖牌榜行数 = %d,期望 6", len(medal))
}
if got := cell(medal, 4, 3); got != "1" { // T1 金牌
t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got)
@@ -160,7 +160,7 @@ func TestExportAllXLSX(t *testing.T) {
}
defer func() { _ = f.Close() }()
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}
want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}
if got := f.GetSheetList(); !equalStrings(got, want) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
}
@@ -207,6 +207,88 @@ func TestExportTeamDetailXLSX(t *testing.T) {
if got := cell(rows, 5, 8); got != "10" {
t.Errorf("合计积分 = %q,期望 10", got)
}
// A18:单位积分明细的成绩列必须带上单位,与分项成绩表 / 个人明细同口径——
// 同一场比赛的同一个成绩,在一份表里是「11.58 秒」、在另一份里是「11.58」,
// 两张表摆在一起核对时会被当成两个数。
if got := cell(rows, 4, 6); got != "11.58 秒" {
t.Errorf("明细成绩 = %q,期望 11.58 秒(与分项成绩表同口径)", got)
}
}
// A18 的对照面:同一个成绩在「单位积分明细」「个人积分总明细」「分项成绩表」里必须逐字一致。
func TestTeamDetailResultMatchesOtherExports(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
teamDst := filepath.Join(dir, "单位明细.xlsx")
if err := ExportTeamDetailXLSX(db, 1, teamDst, Options{}); err != nil {
t.Fatalf("导出单位积分明细: %v", err)
}
athDst := filepath.Join(dir, "个人明细.xlsx")
if err := ExportAthleteDetailXLSX(db, athDst, Options{}); err != nil {
t.Fatalf("导出个人积分总明细: %v", err)
}
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
teamRows := readSheet(t, teamDst, "第一分公司积分明细")
athRows := readSheet(t, athDst, "第一分公司个人得分明细")
eventRows := readSheet(t, eventDst, "田径·100米跑·男子")
// 单位明细第 5 行(0 基下标 4)的张三 100 米;列 6 是「成绩」。
teamResult := cell(teamRows, 4, 6)
// 个人明细分表数据从同一版式起,列 5 是「成绩」。
athResult := cell(athRows, 4, 5)
// 分项成绩表列 4 是「成绩」。
eventResult := cell(eventRows, 4, 4)
want := "11.58 秒"
for name, got := range map[string]string{
"单位积分明细": teamResult, "个人积分总明细": athResult, "分项成绩表": eventResult,
} {
if got != want {
t.Errorf("%s 里的成绩 = %q,期望 %q", name, got, want)
}
}
}
// B02:单单位「单位积分明细」在没有任何流水时也要有「合计」行(写 0),
// 与总明细版版式一致,手册承诺的「末行是合计」才成立。
func TestEmptyTeamDetailKeepsTotalRow(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 第三个单位一条流水都没有:它是「新加进来的单位,还没拿分」的现场形态。
teamID := insertTeam(t, db, "T3", "第三分公司", "三公司", 3, true)
dst := filepath.Join(t.TempDir(), "单位明细.xlsx")
if err := ExportTeamDetailXLSX(db, teamID, dst, Options{}); err != nil {
t.Fatalf("导出空单位明细: %v", err)
}
rows := readSheet(t, dst, "第三分公司积分明细")
// 4 行版式 + 只有合计行
if len(rows) != 5 {
t.Fatalf("空单位明细行数 = %d,期望 5(末行合计):%v", len(rows), rows)
}
if got := cell(rows, 4, 0); got != "合计" {
t.Errorf("末行首列 = %q,期望 合计", got)
}
if got := cell(rows, 4, 8); got != "0" {
t.Errorf("空单位合计 = %q,期望 0", got)
}
}
// readSheet 打开落盘的 xlsx 并读出某张表;导出类用例一律真的写盘再读回,见 saveWorkbook 的说明。
func readSheet(t *testing.T, path, sheet string) [][]string {
t.Helper()
f := openXLSX(t, path)
defer func() { _ = f.Close() }()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s!%s: %v", path, sheet, err)
}
return rows
}
// TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。