package export import ( "database/sql" "fmt" "io" "sort" "strconv" "strings" "github.com/xuri/excelize/v2" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // medalDetailSheet 是「团体奖牌明细」总表的表名。 const medalDetailSheet = "团体奖牌明细" // 奖牌文案:与名次 1/2/3 一一对应。 const ( medalGold = "金牌" medalSilver = "银牌" medalBronze = "铜牌" ) // MedalDetailRow 是团体奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。 type MedalDetailRow struct { SportName string EventName string Category string Rank int Medal string Participant string // 排序键,只在本包内用,不导出。 sportSort int eventSort int catSort int eventID int64 categoryID int64 resultID int64 } // MedalDetailSheet 是某个有奖牌的单位的一张分表。 type MedalDetailSheet struct { Team domain.Team Rows []MedalDetailRow } // MedalDetailWorkbook 是「团体奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。 type MedalDetailWorkbook struct { Meta Meta Sheets []MedalDetailSheet } var medalDetailColumns = []column{ {"单位", 22, kindText}, {"大项", 14, kindText}, {"小项", 18, kindText}, {"组别", 14, kindText}, {"名次", 8, kindCenter}, {"奖牌", 8, kindCenter}, {"运动员/队", 18, kindText}, } // medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。 var medalTeamColumns = medalDetailColumns[1:] // ExportMedalDetailXLSX 导出团体奖牌明细,落盘到 dst。 func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadMedalDetailWorkbook(db, opts) if err != nil { return err } f, err := medalDetailFile(wb) if err != nil { return err } return saveWorkbook(f, dst) } // WriteMedalDetailXLSX 把团体奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。 func WriteMedalDetailXLSX(w io.Writer, wb MedalDetailWorkbook) error { f, err := medalDetailFile(wb) if err != nil { return err } return f.Write(w) } func medalDetailFile(wb MedalDetailWorkbook) (*excelize.File, error) { return buildWorkbook(medalDetailSheet, func(f *excelize.File, st *styleSet) error { meta := wb.Meta.normalized() namer := &sheetNamer{} master := namer.add(medalDetailSheet) if err := addSheet(f, master); err != nil { return err } if err := writeSheet(f, st, master, meta, sheetTitle(meta, medalDetailSheet), medalDetailColumns, medalMasterRows(wb.Sheets)); err != nil { return err } for _, s := range wb.Sheets { unit := displayName(s.Team.Name, s.Team.ShortName) name := namer.add(unit + "奖牌明细") if err := addSheet(f, name); err != nil { return err } if err := writeSheet(f, st, name, meta, sheetTitle(meta, unit+" · 奖牌明细"), medalTeamColumns, medalTeamRows(s.Rows)); err != nil { return err } } return nil }) } func medalMasterRows(sheets []MedalDetailSheet) [][]any { var out [][]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)...)) } } return out } // medalTeamRows 单位分表:逐项奖牌行 + 末尾一行小计(金/银/铜数量)。 func medalTeamRows(rows []MedalDetailRow) [][]any { out := make([][]any, 0, len(rows)+1) var gold, silver, bronze int for _, r := range rows { switch r.Medal { case medalGold: gold++ case medalSilver: silver++ case medalBronze: bronze++ } out = append(out, medalRow(r)) } // 没有奖牌的单位不会进 Sheets,这里的合计行必然跟着数据行;列序 大项/小项/组别/名次/奖牌/运动员-队。 return append(out, []any{"小计", "", "", "", fmt.Sprintf("金 %d / 银 %d / 铜 %d", gold, silver, bronze), ""}) } func medalRow(r MedalDetailRow) []any { rank := "" if r.Rank > 0 { rank = strconv.Itoa(r.Rank) } return []any{r.SportName, r.EventName, r.Category, rank, r.Medal, r.Participant} } // LoadMedalDetailWorkbook 读取全部奖牌来源。 // 单位顺序沿用「团体奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。 func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, error) { if db == nil { return MedalDetailWorkbook{}, ErrNoDatabase } meta, settings, err := resolveMeta(db, opts.Meta) if err != nil { return MedalDetailWorkbook{}, err } // 名次输入与榜单同源:loadRanked 只取已发布、有效、计分小项的名次。 ranked, err := loadRanked(db) if err != nil { return MedalDetailWorkbook{}, err } excludeTeam := strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false") hits := medalHits(ranked, excludeTeam) ctx, err := loadMedalContext(db) if err != nil { return MedalDetailWorkbook{}, err } teams, err := loadTeams(db, false) if err != nil { return MedalDetailWorkbook{}, err } order, err := teamStandingsOrder(db, opts) if err != nil { return MedalDetailWorkbook{}, err } orderTeamsByStandings(teams, order) sheets := make([]MedalDetailSheet, 0, len(teams)) for _, t := range teams { rows := medalRowsOfTeam(t.ID, hits, ctx) if len(rows) == 0 { continue } sheets = append(sheets, MedalDetailSheet{Team: t, Rows: rows}) } return MedalDetailWorkbook{Meta: meta, Sheets: sheets}, nil } // medalHit 是一枚奖牌:来自哪条成绩、什么名次、什么牌。 type medalHit struct { resultID int64 rank int medal string } // medalHits 逐「报名项」判定获奖单位。 // // 同一报名项在多条计分通道下会展开成多条名次,只能记一枚奖牌——选通道、联队份额展开、 // 名次换牌色这些规则都交给 domain 的团体奖牌榜函数(domain.BuildMedalStandings)判定, // 导出侧不复制计奖规则。传入的单位/流水/运动员都为空,返回的行只可能来自该报名项的奖牌。 func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medalHit { out := make(map[int64][]medalHit) for _, group := range medalGroups(ranked) { if len(group) == 0 { continue } rows := domain.BuildMedalStandings(domain.StandingsInput{ Ranked: group, ExcludeTeamEventMedals: excludeTeam, }) rr := group[0] for _, row := range rows { for i := 0; i < row.Gold; i++ { out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 1, medalGold}) } for i := 0; i < row.Silver; i++ { out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 2, medalSilver}) } for i := 0; i < row.Bronze; i++ { out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 3, medalBronze}) } } } return out } // medalGroupKey 与 domain 的奖牌去重键同构:同一小项、同一组别的同一报名项只记一枚奖牌。 // 聚合通道不对应单条报名项(entryID = 0)时退回「名次主体」,仍无法识别主体时每条自成一枚。 type medalGroupKey struct { eventID int64 categoryID int64 entryID int64 scope domain.Scope subject int64 unique int } func medalGroups(ranked []domain.RankedResult) [][]domain.RankedResult { index := make(map[medalGroupKey]int, len(ranked)) var groups [][]domain.RankedResult degenerate := 0 for _, rr := range ranked { k := medalGroupKey{eventID: rr.EventID, categoryID: rr.CategoryID, entryID: rr.EntryID} if k.entryID == 0 { k.scope = rr.Scope if rr.Scope == domain.ScopeAthlete { k.subject = rr.AthleteID } else { k.subject = rr.TeamID } if k.subject == 0 { degenerate++ k.unique = degenerate } } i, ok := index[k] if !ok { i = len(groups) index[k] = i groups = append(groups, nil) } groups[i] = append(groups[i], rr) } return groups } // medalContext 是一条成绩的展示字段与排序键。 type medalContext struct { sportName string eventName string category string participant string sportSort int eventSort int catSort int eventID int64 categoryID int64 } func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) { rows, err := db.Query(` SELECT r.id, COALESCE(s.name, ''), e.name, COALESCE(c.name, ''), COALESCE(a.name, ''), COALESCE(en.member_names, ''), COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0), e.id, COALESCE(c.id, 0) FROM results r JOIN entries en ON en.id = r.entry_id JOIN events e ON e.id = r.event_id LEFT JOIN sports s ON s.id = e.sport_id LEFT JOIN event_categories c ON c.id = en.category_id LEFT JOIN athletes a ON a.id = en.athlete_id`) if err != nil { return nil, fmt.Errorf("export: 读取奖牌来源: %w", err) } defer func() { _ = rows.Close() }() out := make(map[int64]medalContext) for rows.Next() { var ( resultID int64 athleteName string memberJSON string c medalContext ) if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category, &athleteName, &memberJSON, &c.sportSort, &c.eventSort, &c.catSort, &c.eventID, &c.categoryID); err != nil { return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err) } c.participant = participantName(athleteName, memberJSON) out[resultID] = c } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历奖牌来源: %w", err) } return out, nil } // medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。 func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext) []MedalDetailRow { list := hits[teamID] rows := make([]MedalDetailRow, 0, len(list)) for _, h := range list { c := ctx[h.resultID] rows = append(rows, MedalDetailRow{ SportName: c.sportName, EventName: c.eventName, Category: c.category, Rank: h.rank, Medal: h.medal, Participant: c.participant, sportSort: c.sportSort, eventSort: c.eventSort, catSort: c.catSort, eventID: c.eventID, categoryID: c.categoryID, resultID: h.resultID, }) } sort.SliceStable(rows, func(i, j int) bool { a, b := rows[i], rows[j] switch { case a.sportSort != b.sportSort: return a.sportSort < b.sportSort case a.eventSort != b.eventSort: return a.eventSort < b.eventSort case a.eventID != b.eventID: return a.eventID < b.eventID case a.catSort != b.catSort: return a.catSort < b.catSort case a.categoryID != b.categoryID: return a.categoryID < b.categoryID case a.Rank != b.Rank: return a.Rank < b.Rank default: return a.resultID < b.resultID } }) return rows }