Files
Scoreboard/server/internal/export/entries.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

259 lines
8.6 KiB
Go

package export
import (
"database/sql"
"fmt"
"io"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// entriesSheet 是「报名名单」唯一那张表的表名。
const entriesSheet = "报名名单"
// EntriesFilter 是报名名单的筛选条件;某一项为零值即该维度不筛选(全部)。
// heat 的合法值从 1 起,所以 0 可以安全地表示「不按批次筛选」。
type EntriesFilter struct {
EventID int64
CategoryID int64
TeamID int64
Heat int64
}
// EntryExportRow 是报名名单的一行。
// 批次(heat)属于报名数据,是用户点名要看到的列:未分批时留空,也要占着一列。
type EntryExportRow struct {
EventCode string
EventName string
Category string
TeamCode string
TeamName string
Name string
IDCard string
Bib string
Heat string
Gender string
Age any
Status string
Remark string
}
// EntriesWorkbook 是「报名名单」的内容:一张平铺表。
type EntriesWorkbook struct {
Meta Meta
Rows []EntryExportRow
}
// entriesColumns 的列序与用户点名要的口径一致:批次紧跟在号码布之后。
// 号码布用 kindText:它是带前导零的字符串("007"),任何数字格式都会把它吃掉。
var entriesColumns = []column{
{"小项编码", 12, kindCenter},
{"小项名", 18, kindText},
{"组别", 14, kindText},
{"单位编码", 12, kindCenter},
{"单位名", 22, kindText},
{"姓名", 16, kindText},
{"证件号", 22, kindCenter},
{"号码布", 10, kindText},
{"批次", 8, kindCenter},
{"性别", 8, kindCenter},
{"年龄", 8, kindInt},
{"状态", 12, kindCenter},
{"备注", 20, kindText},
}
// 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 {
return err
}
f, err := entriesFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteEntriesXLSX 把报名名单写成 xlsx 流,供 HTTP 直接响应下载。
func WriteEntriesXLSX(w io.Writer, wb EntriesWorkbook) error {
f, err := entriesFile(wb)
if err != nil {
return err
}
return f.Write(w)
}
func entriesFile(wb EntriesWorkbook) (*excelize.File, error) {
return buildWorkbook(entriesSheet, func(f *excelize.File, st *styleSet) error {
meta := wb.Meta.normalized()
name := sanitizeSheetName(entriesSheet)
if err := addSheet(f, name); err != nil {
return err
}
return writeSheet(f, st, name, meta, sheetTitle(meta, entriesSheet), entriesColumns, entriesDataRows(wb.Rows))
})
}
func entriesDataRows(rows []EntryExportRow) [][]any {
out := make([][]any, 0, len(rows))
for _, r := range rows {
out = append(out, []any{
r.EventCode, r.EventName, r.Category, r.TeamCode, r.TeamName,
r.Name, r.IDCard, r.Bib, r.Heat, r.Gender, r.Age, r.Status, r.Remark,
})
}
return out
}
// LoadEntriesWorkbook 读取报名名单;筛选条件为空表示导出全部。
//
// 读的是报名表本身(entries),不是成绩表:批次、号码布、状态都是报名侧的数据。
// 姓名取运动员姓名,团体行(athlete_id 为空)回落到成员名单,与分项成绩表同口径。
func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (EntriesWorkbook, error) {
if db == nil {
return EntriesWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
if err != nil {
return EntriesWorkbook{}, err
}
// 联队配置:导出名单里的单位编码回写成导入表的 A+B 写法,单位名写全成员,备注里写清份额。
joints, err := loadJointTeams(db)
if err != nil {
return EntriesWorkbook{}, err
}
// 队员关联(devdocs/20):整份名单读一次,逐行按 entry_id 取。
members, err := loadEntryMembers(db)
if err != nil {
return EntriesWorkbook{}, err
}
query := `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.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
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN teams t ON t.id = en.team_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN athletes a ON a.id = en.athlete_id`
var (
conds []string
args []any
)
for _, f := range []struct {
column string
value int64
}{
{"en.event_id", filter.EventID},
{"en.category_id", filter.CategoryID},
{"en.heat", filter.Heat},
} {
if f.value != 0 {
conds = append(conds, f.column+" = ?")
args = append(args, f.value)
}
}
// 单位筛选要连「联队里的成员单位」一起算:entries.team_id 只是主单位(加号左边那个),
// 但成员单位同样在这条报名上拿分拿牌——按 B 导出时看不到它,B 就不知道自己派了谁上场。
// 主单位那一半条件照旧留着:份额行缺失的报名(手工造的老数据)不能因此从筛选结果里掉出去。
// 份额表不在(库还没跑到 0024)时不加这段:那种库里根本没有联队。
if filter.TeamID != 0 {
teamCond := "en.team_id = ?"
args = append(args, filter.TeamID)
ready, err := entrySharesReady(db)
if err != nil {
return EntriesWorkbook{}, err
}
if ready {
teamCond += ` OR EXISTS (SELECT 1 FROM entry_team_shares s
WHERE s.entry_id = en.id AND s.team_id = ?)`
args = append(args, filter.TeamID)
}
conds = append(conds, "("+teamCond+")")
}
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
// 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。
// 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。
query += `
ORDER BY ev.sort_order, ev.code, c.sort_order, c.id,
en.heat IS NOT NULL, en.heat, t.sort_order, t.code, en.id`
rows, err := db.Query(query, args...)
if err != nil {
return EntriesWorkbook{}, fmt.Errorf("export: 读取报名名单: %w", err)
}
defer func() { _ = rows.Close() }()
var out []EntryExportRow
for rows.Next() {
var (
row EntryExportRow
memberJSON string
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,
&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)
}
row.Name = participantLabel(athleteName, memberJSON, members[entryID])
if heat.Valid {
row.Heat = strconv.FormatInt(heat.Int64, 10)
}
if age.Valid {
row.Age = int(age.Int64)
} else {
row.Age = ""
}
row.Status = entryExportStatusLabel(row.Status)
// 联队:单位编码回写成 A+B(这份名单能直接拿去重导),单位名写全成员,
// 备注里写清这条分拆给了哪几个单位、各占多少。报名名单没有积分列,只写份额比例。
if joint, ok := joints[entryID]; ok {
row.TeamCode = joint.Code()
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 {
return EntriesWorkbook{}, fmt.Errorf("export: 遍历报名名单: %w", err)
}
return EntriesWorkbook{Meta: meta, Rows: out}, nil
}
// entryExportStatusLabel 与录入页报名状态徽标同口径(registered 正常参赛 / scratched 已退赛 /
// dq 取消资格 / dns 未出场),导出件上的字与现场在界面上看到的一致。
func entryExportStatusLabel(status string) string {
switch domain.EntryStatus(status) {
case domain.EntryStatusRegistered, "":
return "正常参赛"
case domain.EntryStatusScratched:
return "已退赛"
case domain.EntryStatusDQ:
return "取消资格"
case domain.EntryStatusDNS:
return "未出场"
default:
return status
}
}