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 } }