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 // jointNote 是联队奖牌的展示值(非空时覆盖 Medal 列):「金牌(联队 A+B,各单位各记 1 枚)」。 // 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。 jointNote string // squad 是这块奖牌对应报名项的队名(entries.squad),只有总表的「单位」列用它: // 一个单位两个队各拿一块牌时,总表上要认得出来哪块是哪个队的。分表没有「单位」列, // 表名本身就是单位(它是一整份单位级清单,不按队拆)。 squad 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}, // 奖牌列不是窄的居中列:联队奖牌要把「各单位各记 1 枚」写在奖牌格子里(口径 3), // 窄列 + 居中 + 不换行会被右边非空的「运动员/队」裁掉,那句话就白写了。 {"奖牌", 26, kindText}, // 与分项成绩表的「运动员/队」同宽:奖牌明细同样会印接力队名单。 {"运动员/队", 24, kindText}, } // medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。 var medalTeamColumns = medalDetailColumns[1:] // ExportMedalDetailXLSX 导出单位奖牌明细,落盘到 dst。 // 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 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{withSquad(unit, r.squad)}, 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) } medal := any(r.Medal) if r.jointNote != "" { medal = r.jointNote } return []any{r.SportName, r.EventName, r.Category, rank, medal, r.Participant} } // LoadMedalDetailWorkbook 读取全部奖牌来源。 // 单位顺序按「单位总分榜」(teamStandingsOrder,得分高的在前),**不跟单位奖牌榜**: // 奖牌榜已独立按金→银→铜排(domain.BuildMedalStandings),两份顺序本来就不一样。 // 没有奖牌的单位不出分表。 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 } // 联队配置:一条成绩归多个单位时,每个成员单位各记一枚整牌,奖牌那一格要写清这件事。 joints, err := loadJointTeams(db) if err != nil { return MedalDetailWorkbook{}, err } if len(joints) > 0 { meta.Note = jointMedalNote } // 名次输入与榜单同源: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) // 队员关联读一次给全部奖牌行复用(与联队份额同一次读取的理由)。 members, err := loadEntryMembers(db) if err != nil { return MedalDetailWorkbook{}, err } ctx, err := loadMedalContext(db, members) if err != nil { return MedalDetailWorkbook{}, err } // 聚合通道与团体手工名次没有对应的成绩(channel_ranks.result_id 为空),按成绩 id 查不到展示字段, // 按小项 + 组别补一份兜底。 eventCtx, err := loadMedalEventContext(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) // 队名按报名项读一次给全部奖牌行复用(逐行去查库是白跑)。 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, squads) if len(rows) == 0 { continue } sheets = append(sheets, MedalDetailSheet{Team: t, Rows: rows}) } return MedalDetailWorkbook{Meta: meta, Sheets: sheets}, nil } // medalHit 是一枚奖牌:来自哪个小项 / 组别的哪条成绩、什么名次、什么牌。 // 聚合名次与团体手工名次没有单条成绩(resultID 为 0),小项 + 组别是展示字段的兜底来源。 // entryID 是这条名次对应的报名项:联队份额挂在报名的,联队奖牌的那句说明只能靠它查到 // (球类现场的名次正是手工按单位录的,此时 resultID 为空,只有 entryID 认得出来)。 type medalHit struct { eventID int64 categoryID int64 resultID int64 entryID 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{ eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, entryID: rr.EntryID, rank: 1, medal: medalGold}) } for i := 0; i < row.Silver; i++ { out[row.TeamID] = append(out[row.TeamID], medalHit{ eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, entryID: rr.EntryID, rank: 2, medal: medalSilver}) } for i := 0; i < row.Bronze; i++ { out[row.TeamID] = append(out[row.TeamID], medalHit{ eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, entryID: rr.EntryID, rank: 3, medal: 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 } // loadMedalContext 取每条成绩的展示字段;members 是报名 id → 队员(见 loadEntryMembers), // 用来把团体报名的队员写进「运动员 / 队」列。 func loadMedalContext(db *sql.DB, members map[int64][]entryMember) (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, ''), en.id, 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 entryID int64 c medalContext ) if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category, &athleteName, &memberJSON, &entryID, &c.sportSort, &c.eventSort, &c.catSort, &c.eventID, &c.categoryID); err != nil { return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err) } c.participant = participantLabel(athleteName, memberJSON, members[entryID]) out[resultID] = c } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历奖牌来源: %w", err) } return out, nil } // eventCategoryKey 是「小项 + 组别」的键。 type eventCategoryKey struct { eventID int64 categoryID int64 } // loadMedalEventContext 按 (小项, 组别) 建一份展示字段,给没有单条成绩的奖牌兜底: // 聚合通道与团体手工名次在 channel_ranks 里 result_id 为空,按成绩 id 查不到来源, // 只能回到小项 + 组别上取大项 / 小项 / 组别名(运动员/队留空——聚合名次不对应单个人)。 func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error) { rows, err := db.Query(` SELECT e.id, COALESCE(c.id, 0), COALESCE(s.name, ''), e.name, COALESCE(c.name, ''), COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0) FROM events e LEFT JOIN sports s ON s.id = e.sport_id LEFT JOIN event_categories c ON c.event_id = e.id`) if err != nil { return nil, fmt.Errorf("export: 读取奖牌小项来源: %w", err) } defer func() { _ = rows.Close() }() out := make(map[eventCategoryKey]medalContext) for rows.Next() { var ( key eventCategoryKey c medalContext ) if err := rows.Scan(&key.eventID, &key.categoryID, &c.sportName, &c.eventName, &c.category, &c.sportSort, &c.eventSort, &c.catSort); err != nil { return nil, fmt.Errorf("export: 扫描奖牌小项来源: %w", err) } c.eventID = key.eventID c.categoryID = key.categoryID out[key] = c } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历奖牌小项来源: %w", err) } return out, nil } // medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。 // 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。 // 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 { c, ok := ctx[h.resultID] if !ok { c = eventCtx[eventCategoryKey{eventID: h.eventID, categoryID: h.categoryID}] } row := 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, squad: squads[h.entryID], } // 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。 // 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。 if h.entryID != 0 { if joint, ok := joints[h.entryID]; ok { row.jointNote = joint.medalCell(h.medal) } } rows = append(rows, row) } 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 }