## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
259 lines
8.6 KiB
Go
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
|
|
}
|
|
}
|