feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5

## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
This commit is contained in:
AskaEth committed 2026-09-14 11:45:32 +08:00
1 parent 9663d5bfb0
commit 7a3e4ad365
67 files changed
+4658 -228

No files matched your search

+7 -2
View File
@@ -131,7 +131,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
query := `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
COALESCE(t.code, ''), COALESCE(t.name, ''),
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''),
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat,
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, ''), en.id
@@ -198,10 +198,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
heat sql.NullInt64
age sql.NullInt64
athleteName string
squad string
entryID int64
)
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
&squad, &athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
}
@@ -222,6 +223,10 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareRatioNote())
}
// 队名接在单位名后面(口径:单位名+队名,不加括号),空队名时一个字都不加:
// 老数据与单队单位的这份名单必须与升级前逐行一致。单位编码不动 —— 队名不是编码的一部分,
// 编码列照旧是可以直接拿去重导的写法。
row.TeamName = withSquad(row.TeamName, squad)
out = append(out, row)
}
if err := rows.Err(); err != nil {
+10 -4
View File
@@ -542,7 +542,8 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
// 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 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(en.member_names, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''),
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),
@@ -594,6 +595,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
row EventResultRow
rank, isTie int
memberJSON, athleteName string
squad string
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
@@ -601,7 +603,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
required, published int
entryID int64
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &squad, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
@@ -616,6 +618,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareNote())
}
// 队名接在单位名后面(口径:单位名+队名,不加括号)。放在联队之后:联队名本身就是单位名,
// 「A+B联队二队」读起来才是「这个联队的第二支队」。空队名时这一行不动,导出件与升级前逐字一致。
row.TeamName = withSquad(row.TeamName, squad)
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
row.NoResult = !resultState.Valid
@@ -724,7 +729,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([
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),
COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0)
COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0), COALESCE(en.squad, '')
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
@@ -759,7 +764,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([
var athleteID, eventID, entryID int64
var resultText, unit string
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID); err != nil {
&resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID,
&r.squad); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
// 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩
+18 -3
View File
@@ -36,6 +36,11 @@ type MedalDetailRow struct {
// 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。
jointNote string
// squad 是这块奖牌对应报名项的队名(entries.squad),只有总表的「单位」列用它:
// 一个单位两个队各拿一块牌时,总表上要认得出来哪块是哪个队的。分表没有「单位」列,
// 表名本身就是单位(它是一整份单位级清单,不按队拆)。
squad string
// 排序键,只在本包内用,不导出。
sportSort int
eventSort int
@@ -129,7 +134,9 @@ func medalMasterRows(sheets []MedalDetailSheet) [][]any {
for _, s := range sheets {
unit := displayName(s.Team.Name, s.Team.ShortName)
for _, r := range s.Rows {
out = append(out, append([]any{unit}, medalRow(r)...))
// 队名接在单位名后面(口径:单位名+队名):一个单位两个队各拿一块牌时,
// 总表上两块牌的单位列不是同一行字,读者才知道哪块是哪个队拿的。空队名时一个字都不加。
out = append(out, append([]any{withSquad(unit, r.squad)}, medalRow(r)...))
}
}
return out
@@ -213,9 +220,15 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
}
orderTeamsByStandings(teams, order)
// 队名按报名项读一次给全部奖牌行复用(逐行去查库是白跑)。
squads, err := loadEntrySquads(db)
if err != nil {
return MedalDetailWorkbook{}, err
}
sheets := make([]MedalDetailSheet, 0, len(teams))
for _, t := range teams {
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints)
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints, squads)
if len(rows) == 0 {
continue
}
@@ -409,7 +422,8 @@ func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam) []MedalDetailRow {
// squads 是「报名 id → 队名」,用来给总表的单位列补上这块奖牌是哪个队拿的。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam, squads map[int64]string) []MedalDetailRow {
list := hits[teamID]
rows := make([]MedalDetailRow, 0, len(list))
for _, h := range list {
@@ -430,6 +444,7 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
eventID: c.eventID,
categoryID: c.categoryID,
resultID: h.resultID,
squad: squads[h.entryID],
}
// 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。
// 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。
+54
View File
@@ -0,0 +1,54 @@
package export
import (
"database/sql"
"fmt"
"strings"
)
// 本文件是「队名」(entries.squad,迁移 0025)在导出侧的读法。
//
// 同一个单位在同一组别可以派多支队参赛(例如羽毛球双打,一个单位两个队各占一个名次),
// 报名上多出一列自由文本队名,空串 = 不区分队伍 —— 那是老数据与单队单位的默认值。
//
// 显示口径(与大屏、管理端同一份实现):**单位名 + 队名**,不加括号也不加分隔符,例如「第一分公司二队」。
// 现场就是把它当一个单位名念的,括号与空格只会白占列宽,还会让同一支队在两份材料里写法不一样。
// withSquad 把队名接在单位名后面;队名为空(或只有空白)时**一个字都不加**。
//
// 空队名什么都不加是硬门槛:升级后导出的老赛事必须与升级前逐行一致。
// 顺带去掉队名首尾空白:它由现场自由填写,带着空格会把「第一分公司 二队」这种读法印进导出件。
func withSquad(name, squad string) string {
squad = strings.TrimSpace(squad)
if squad == "" {
return name
}
return name + squad
}
// loadEntrySquads 读全部带队名的报名,按报名 id 归组。
//
// 只取非空的行:绝大多数报名的 squad 都是空串,整表扫回来再逐行判空是白花内存。
func loadEntrySquads(db *sql.DB) (map[int64]string, error) {
rows, err := db.Query(`SELECT id, squad FROM entries WHERE TRIM(COALESCE(squad, '')) <> ''`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名队名: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]string)
for rows.Next() {
var (
entryID int64
squad string
)
if err := rows.Scan(&entryID, &squad); err != nil {
return nil, fmt.Errorf("export: 扫描报名队名: %w", err)
}
out[entryID] = squad
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历报名队名: %w", err)
}
return out, nil
}
+183
View File
@@ -0,0 +1,183 @@
package export
import (
"bytes"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
)
// 这份用例是「squad 为空时导出件逐行一致」这条硬门槛的对拍工具。
//
// 做法:造一份**全部报名都没有队名**的赛事库(队名列留空 = 老数据与单队单位的原样),
// 把每一份导出件的每一张工作表、每一个单元格 dump 成文本;同一个用例文件放到改动前的代码上
// 再跑一次(`git archive` 取一份 HEAD 的树,只补上这次迁移与这个用例文件),两份 dump
// `diff` 必须为空。
//
// 落地路径由 EXPORT_SQUAD_DUMP 指定,不设时写进 t.TempDir() —— 也就是说平时它只当一次
// 「全部导出件都能加载」的冒烟,不往仓库里落文件。
func TestSquadEmptyExportDumpIsUnchanged(t *testing.T) {
db := openEventDB(t)
seedSquadDumpEvent(t, db)
opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}}
empty := dumpAllWorkbooks(t, db, opts)
// 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。
path := os.Getenv("EXPORT_SQUAD_DUMP")
if path == "" {
path = filepath.Join(t.TempDir(), "squad-empty-dump.txt")
}
if err := os.WriteFile(path, []byte(empty), 0o644); err != nil {
t.Fatalf("写出 dump: %v", err)
}
t.Logf("空队名 dump:%s(%d 个单元格)", path, strings.Count(empty, "\n"))
// 敏感度自检:给那条团体报名写上队名后重新 dump,两次必须不同。
// 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到单位列 —— 那就白对拍了。
if _, err := db.Exec(`UPDATE entries SET squad = '二队' WHERE event_id = 2`); err != nil {
t.Fatalf("写入队名: %v", err)
}
withSquadDump := dumpAllWorkbooks(t, db, opts)
if withSquadDump == empty {
t.Fatal("给报名写上队名后 dump 没变:这份对拍看不见单位列,对拍结论无效" +
"(在对拍用的旧代码上这条自检本来就会红——旧代码根本不看 squad,正说明两份 dump 的差异只可能来自这次改动)")
}
if !strings.Contains(withSquadDump, "第一分公司二队") {
t.Fatal("带队名的 dump 里找不到「第一分公司二队」:队名没有写进导出件")
}
}
// seedSquadDumpEvent 造一份覆盖面尽量宽的赛事库,且**所有报名的队名都留空**:
// 个人小项(含空批次、团体行回落成员名单)+ 一条纯团体报名 + 联队份额。
// 个人路径与团体路径都要有,否则这次改动覆盖的两处读取点里会漏掉一处。
func seedSquadDumpEvent(t *testing.T, db *sql.DB) {
t.Helper()
seedEvent(t, db) // T1/T2、100 米跑、两条已发布成绩与流水
addBronzeEntry(t, db) // 第 3 名(一个单位两条报名)
stmts := []string{
`INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 2, 'team', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, bib, heat)
VALUES (2, 1, 1, '["王磊","赵雷"]', '201', 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
SELECT 2, id, 1, '2:0', 2, 'pts', 'published' FROM entries WHERE event_id = 2`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
SELECT 1, 2, e.id, r.id, 'result', r.id, 10
FROM entries e JOIN results r ON r.entry_id = e.id WHERE e.event_id = 2`,
// 联队:这条团体报名由两个单位合报(奖牌各记一枚),队名列仍然是空的。
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
SELECT e.id, t.id, 1, 1, t.id - 1 FROM entries e CROSS JOIN teams t WHERE e.event_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
}
// dumpAllWorkbooks 把全部导出件 dump 成一份文本:先写每个工作簿的 sheet 列表(表名里也有单位名),
// 再逐单元格写 `工作簿|工作表|行|列|值`。用 Write*(生产走的是同一条流)而不是落盘再读,
// 保证 dump 的就是响应体里那份文件。
func dumpAllWorkbooks(t *testing.T, db *sql.DB, opts Options) string {
t.Helper()
var sb strings.Builder
dump := func(name string, write func(io.Writer) error) {
t.Helper()
var buf bytes.Buffer
if err := write(&buf); err != nil {
t.Fatalf("导出 %s: %v", name, err)
}
f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("打开 %s: %v", name, err)
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
sort.Strings(sheets)
fmt.Fprintf(&sb, "== %s sheets=%s\n", name, strings.Join(sheets, ","))
for _, sheet := range sheets {
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取 %s!%s: %v", name, sheet, err)
}
for r, row := range rows {
for c, v := range row {
fmt.Fprintf(&sb, "%s|%s|%d|%d|%q\n", name, sheet, r+1, c+1, v)
}
}
}
}
standings, err := LoadStandingsWorkbook(db, opts)
if err != nil {
t.Fatalf("读总表: %v", err)
}
dump("总表", func(w io.Writer) error { return WriteStandingsXLSX(w, standings) })
events, err := LoadEventWorkbook(db, 0, opts)
if err != nil {
t.Fatalf("读分项成绩表: %v", err)
}
dump("分项成绩表", func(w io.Writer) error { return WriteEventXLSX(w, events) })
flat, err := LoadFlatWorkbook(db, opts)
if err != nil {
t.Fatalf("读全部成绩单页: %v", err)
}
dump("全部成绩单页", func(w io.Writer) error { return WriteFlatXLSX(w, flat) })
all, err := LoadAllWorkbook(db, opts)
if err != nil {
t.Fatalf("读全部: %v", err)
}
dump("全部", func(w io.Writer) error { return WriteAllXLSX(w, all) })
entries, err := LoadEntriesWorkbook(db, EntriesFilter{}, opts)
if err != nil {
t.Fatalf("读报名名单: %v", err)
}
dump("报名名单", func(w io.Writer) error { return WriteEntriesXLSX(w, entries) })
medals, err := LoadMedalDetailWorkbook(db, opts)
if err != nil {
t.Fatalf("读单位奖牌明细: %v", err)
}
dump("单位奖牌明细", func(w io.Writer) error { return WriteMedalDetailXLSX(w, medals) })
athletes, err := LoadAthleteDetailWorkbook(db, opts)
if err != nil {
t.Fatalf("读个人得分明细: %v", err)
}
dump("个人得分明细", func(w io.Writer) error { return WriteAthleteDetailXLSX(w, athletes) })
details, err := LoadAllTeamDetailsWorkbook(db, opts)
if err != nil {
t.Fatalf("读总明细: %v", err)
}
dump("总明细", func(w io.Writer) error { return WriteAllTeamDetailsXLSX(w, details) })
for _, teamID := range []int64{1, 2} {
wb, err := LoadTeamDetailWorkbook(db, teamID, opts)
if err != nil {
t.Fatalf("读单位 %d 积分明细: %v", teamID, err)
}
name := fmt.Sprintf("单位%d积分明细", teamID)
dump(name, func(w io.Writer) error { return WriteTeamDetailXLSX(w, wb) })
}
return sb.String()
}
+210
View File
@@ -0,0 +1,210 @@
package export
import (
"database/sql"
"path/filepath"
"testing"
)
// seedSquads 造一场「一个单位派两支队」的团体赛:羽毛球男子双打,第一分公司派一队 / 二队,
// 两队分别拿第 1、第 2 名(口径 3:一个单位两个队各占一个名次、各拿一块牌,不去重不合并),
// 第二分公司一支不区分队伍的队(squad 留空 = 老写法)拿第 3 名。
func seedSquads(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T2', '第二分公司', '二公司', 2)`,
`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', '男子双打', 'team', 'auto', 'score', 'desc', '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, 'team', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
// 同一个单位在同一组别的两条团体报名:靠 squad 区分,只有带上 squad 的唯一索引才放得下。
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, bib)
VALUES (1, 1, 1, '["王磊","赵雷"]', '一队', '101')`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, squad, bib)
VALUES (1, 1, 1, '["陈刚","郑凯"]', '二队', '102')`,
`INSERT INTO entries(event_id, category_id, team_id, member_names, bib)
VALUES (1, 1, 2, '["孙七","周八"]', '103')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 1, 1, '2:0', 2, 'pts', 'published')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 2, 2, '2:1', 2, 'pts', 'published')`,
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 3, 3, '2:1', 2, 'pts', 'published')`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 2, 2, 'result', 2, 8)`,
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (2, 1, 3, 3, 'result', 3, 6)`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
`UPDATE settings SET value = '测试主办方' WHERE key = 'event.organizer'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// 分项成绩表:单位列在队名非空时写「单位名+队名」,一个单位的两个队各占一行。
func TestEventSheetShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "成绩表.xlsx")
if err := ExportEventXLSX(db, 0, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
rows := readSheet(t, dst, "羽毛球·男子双打·男子")
// 4 行版式 + 3 条报名,按名次排:一队、二队、第二分公司。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 2); got != w {
t.Errorf("第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
}
// 全部成绩(单页):与分项成绩表同源,单位列同样带上队名。
func TestFlatSheetShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "单页.xlsx")
if err := ExportFlatXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出全部成绩(单页): %v", err)
}
rows := readSheet(t, dst, "全部成绩")
// 单页表在「名次」前多了 大项/小项/组别 三列,单位列是第 6 列。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 5); got != w {
t.Errorf("第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
}
// 报名名单:单位名列在队名非空时写「单位名+队名」,不区分队伍的报名照旧只写单位名。
func TestEntriesSheetShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
// 列序:小项编码/小项名/组别/单位编码/单位名/…,单位名是第 5 列。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 4); got != w {
t.Errorf("第 %d 行单位名 = %q,期望 %q", i+1, got, w)
}
}
}
// 单位奖牌明细:总表的单位列按该奖牌对应报名项的队名写全;一个单位两个队各拿一块牌,
// 就是两块牌(口径 3),不合并、不去重。
func TestMedalDetailShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
wantSheets := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"}
if got := f.GetSheetList(); !equalStrings(got, wantSheets) {
t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheets)
}
master, err := f.GetRows("单位奖牌明细")
if err != nil {
t.Fatalf("读取总表: %v", err)
}
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(master, 4+i, 0); got != w {
t.Errorf("总表第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
// 第一分公司是一枚金牌加一枚银牌(两个队各一块),分表的小计照实写两块。
unit, err := f.GetRows("第一分公司奖牌明细")
if err != nil {
t.Fatalf("读取分表: %v", err)
}
if got := cell(unit, len(unit)-1, 4); got != "金 1 / 银 1 / 铜 0" {
t.Errorf("第一分公司小计 = %q,期望 金 1 / 银 1 / 铜 0", got)
}
}
// 单位积分总明细:总表的单位列按该条流水对应报名项的队名写全(分表没有单位列,表名就是单位)。
func TestAllTeamDetailsShowsSquadAfterUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "总明细.xlsx")
if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出总明细: %v", err)
}
rows := readSheet(t, dst, "积分总明细")
// 列序:单位/序号/大项/小项/组别/运动员/名次/成绩/来源/积分/备注;
// 行序按单位总分榜(第一分公司在前),单位内按名次:一队 10 分、二队 8 分,第二分公司 6 分。
want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"}
for i, w := range want {
if got := cell(rows, 4+i, 0); got != w {
t.Errorf("总明细第 %d 行单位 = %q,期望 %q", i+1, got, w)
}
}
if got := cell(rows, 7, 0); got != "合计" {
t.Errorf("末行首列 = %q,期望 合计", got)
}
}
// 队名为空(不区分队伍)时,五份导出件的单位名列一个字都不多:这是老数据与单队单位的默认,
// 导出件必须与升级前逐行一致。
func TestEmptySquadKeepsPlainUnitName(t *testing.T) {
db := openEventDB(t)
seedSquads(t, db)
seedChannelRanks(t, db)
dir := t.TempDir()
entriesDst := filepath.Join(dir, "报名名单.xlsx")
if err := ExportEntriesXLSX(db, entriesDst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
// 第三条报名(第二分公司)没有队名。
if got := cell(readSheet(t, entriesDst, "报名名单"), 6, 4); got != "第二分公司" {
t.Errorf("空队名的单位名 = %q,期望 第二分公司", got)
}
medalDst := filepath.Join(dir, "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, medalDst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
if got := cell(readSheet(t, medalDst, "单位奖牌明细"), 6, 0); got != "第二分公司" {
t.Errorf("空队名的奖牌总表单位 = %q,期望 第二分公司", got)
}
detailDst := filepath.Join(dir, "总明细.xlsx")
if err := ExportAllTeamDetailsXLSX(db, detailDst, Options{}); err != nil {
t.Fatalf("导出总明细: %v", err)
}
if got := cell(readSheet(t, detailDst, "积分总明细"), 6, 0); got != "第二分公司" {
t.Errorf("空队名的总明细单位 = %q,期望 第二分公司", got)
}
}
+4
View File
@@ -23,6 +23,10 @@ type TeamDetailRow struct {
Remark string
// individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。
individual bool
// squad 是这条流水对应报名项的队名(entries.squad),只有总明细的「单位」列用它:
// 单位分表的表名就是单位(一份单位级清单),没有单位列可写,所以那里不展示队名。
squad string
}
// TeamDetailWorkbook 是「单位积分明细」的内容。
+9 -2
View File
@@ -94,8 +94,15 @@ func allTeamDetailRows(sheets []TeamDetailSheet, weight float64) [][]any {
unit := displayName(s.Team.Name, s.Team.ShortName)
rows, sub := teamDetailDataRows(s.Rows, weight)
total += sub
for _, row := range rows {
out = append(out, append([]any{unit}, row...))
for i, row := range rows {
// 队名接在单位名后面(口径:单位名+队名):一行分是哪个队拿的,总表上要认得出来。
// 空队名时一个字都不加(老数据与单队单位的导出件与升级前逐行一致)。
// dataRows 逐行按原序产出,索引与 s.Rows 一一对应。
label := unit
if i < len(s.Rows) {
label = withSquad(unit, s.Rows[i].squad)
}
out = append(out, append([]any{label}, row...))
}
}
// 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。