package export import ( "database/sql" "fmt" "io" "sort" "strconv" "github.com/xuri/excelize/v2" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // athleteDetailSheet 是「个人积分总明细」总表的表名。 const athleteDetailSheet = "个人积分总明细" // AthleteDetailRow 是个人得分明细的一行:某运动员一次得分的来源。 // Points 是记到个人的原始分,与个人积分榜同口径,不乘 individual_to_team_weight。 type AthleteDetailRow struct { AthleteName string SportName string EventName string Category string Rank int ResultText string Points float64 Source string // 排序键与所属单位,只在本包内用,不导出。 teamID int64 sportSort int eventSort int catSort int eventID int64 categoryID int64 ledgerID int64 } // AthleteDetailSheet 是某个单位的一张分表(按单位分表,不按人分)。 type AthleteDetailSheet struct { Team domain.Team Rows []AthleteDetailRow } // AthleteDetailWorkbook 是「个人积分总明细」的内容:一张总表 + 每个有得分的单位一张分表。 type AthleteDetailWorkbook struct { Meta Meta Sheets []AthleteDetailSheet } var athleteDetailColumns = []column{ {"单位", 22, kindText}, {"运动员", 14, kindText}, {"大项", 14, kindText}, {"小项", 18, kindText}, {"组别", 14, kindText}, {"名次", 8, kindCenter}, {"成绩", 12, kindCenter}, {"得分", 8, kindNumber}, {"来源", 12, kindCenter}, } // athleteTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。 var athleteTeamColumns = athleteDetailColumns[1:] // ExportAthleteDetailXLSX 导出个人得分明细,落盘到 dst。 // 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportAthleteDetailXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadAthleteDetailWorkbook(db, opts) if err != nil { return err } f, err := athleteDetailFile(wb) if err != nil { return err } return saveWorkbook(f, dst) } // WriteAthleteDetailXLSX 把个人得分明细写成 xlsx 流,供 HTTP 直接响应下载。 func WriteAthleteDetailXLSX(w io.Writer, wb AthleteDetailWorkbook) error { f, err := athleteDetailFile(wb) if err != nil { return err } return f.Write(w) } func athleteDetailFile(wb AthleteDetailWorkbook) (*excelize.File, error) { return buildWorkbook(athleteDetailSheet, func(f *excelize.File, st *styleSet) error { meta := wb.Meta.normalized() namer := &sheetNamer{} master := namer.add(athleteDetailSheet) if err := addSheet(f, master); err != nil { return err } if err := writeSheet(f, st, master, meta, sheetTitle(meta, athleteDetailSheet), athleteDetailColumns, athleteMasterRows(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+" · 个人得分明细"), athleteTeamColumns, athleteTeamRows(s.Rows)); err != nil { return err } } return nil }) } func athleteMasterRows(sheets []AthleteDetailSheet) [][]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}, athleteRow(r)...)) } } return out } func athleteTeamRows(rows []AthleteDetailRow) [][]any { out := make([][]any, 0, len(rows)) for _, r := range rows { out = append(out, athleteRow(r)) } return out } func athleteRow(r AthleteDetailRow) []any { rank := "" if r.Rank > 0 { rank = strconv.Itoa(r.Rank) } return []any{r.AthleteName, r.SportName, r.EventName, r.Category, rank, r.ResultText, r.Points, r.Source} } // LoadAthleteDetailWorkbook 读取记到个人的积分流水,逐条列出来源。 // // 得分口径 = 个人积分榜:只取未作废且 athlete_id 非空的流水,points 保持原始分, // 既不乘 standings.individual_to_team_weight,也不按 count_to_team 过滤 // (「个人+团体」项目的个人通道照样算在个人头上)。 // 没有得分流水的运动员不会出现在任何一张表里。 func LoadAthleteDetailWorkbook(db *sql.DB, opts Options) (AthleteDetailWorkbook, error) { if db == nil { return AthleteDetailWorkbook{}, ErrNoDatabase } meta, _, err := resolveMeta(db, opts.Meta) if err != nil { return AthleteDetailWorkbook{}, err } joints, err := loadJointTeams(db) if err != nil { return AthleteDetailWorkbook{}, err } rows, err := loadAthleteDetailRows(db, joints) if err != nil { return AthleteDetailWorkbook{}, err } byTeam := make(map[int64][]AthleteDetailRow, len(rows)) for _, r := range rows { byTeam[r.teamID] = append(byTeam[r.teamID], r) } teams, err := loadTeams(db, false) if err != nil { return AthleteDetailWorkbook{}, err } order, err := teamStandingsOrder(db, opts) if err != nil { return AthleteDetailWorkbook{}, err } orderTeamsByStandings(teams, order) sheets := make([]AthleteDetailSheet, 0, len(teams)) for _, t := range teams { teamRows := byTeam[t.ID] if len(teamRows) == 0 { continue } sortAthleteDetailRows(teamRows) sheets = append(sheets, AthleteDetailSheet{Team: t, Rows: teamRows}) } return AthleteDetailWorkbook{Meta: meta, Sheets: sheets}, nil } // athleteDetailRankExpr 取一条个人流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。 // // score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找: // - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order); // - entry_id / result_id 都为空的流水(个人聚合名次):写入侧把 source_id 退化为通道 id,就拿它 + 运动员关联。 // // 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。 // channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。 const athleteDetailRankExpr = `COALESCE(( SELECT cr.rank FROM channel_ranks cr JOIN event_score_channels ch ON ch.id = cr.channel_id WHERE cr.event_id = l.event_id AND cr.category_id = COALESCE(c.id, ca.id) AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id) OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' AND cr.channel_id = l.source_id AND cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id)) ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id LIMIT 1), COALESCE(r.rank, 0))` func loadAthleteDetailRows(db *sql.DB, joints map[int64]jointTeam) ([]AthleteDetailRow, error) { // 聚合流水的组别回推与单位明细同一条思路:某个运动员在本小项只参加了一个组别时,那个组别就是答案; // 多个组别时回推不出来,宁可留空也不猜(回推只对 entry_id / result_id 都为空的聚合流水生效)。 rows, err := db.Query(` SELECT l.team_id, COALESCE(a.name, ''), COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''), ` + athleteDetailRankExpr + `, COALESCE(r.result_text, ''), COALESCE(r.unit, ''), l.points, l.source_type, COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, ca.sort_order, 0), COALESCE(l.event_id, 0), COALESCE(c.id, ca.id, 0), l.id, COALESCE(l.entry_id, 0) FROM score_ledger l LEFT JOIN athletes a ON a.id = l.athlete_id LEFT JOIN events e ON e.id = l.event_id LEFT JOIN sports s ON s.id = e.sport_id LEFT JOIN entries en ON en.id = l.entry_id LEFT JOIN event_categories c ON c.id = en.category_id LEFT JOIN ( SELECT en2.athlete_id, en2.event_id, MIN(en2.category_id) AS category_id FROM entries en2 WHERE en2.athlete_id IS NOT NULL GROUP BY en2.athlete_id, en2.event_id HAVING COUNT(DISTINCT en2.category_id) = 1 ) aac ON aac.event_id = l.event_id AND aac.athlete_id = l.athlete_id AND l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' LEFT JOIN event_categories ca ON ca.id = aac.category_id LEFT JOIN results r ON r.id = l.result_id WHERE l.athlete_id IS NOT NULL AND l.athlete_id <> 0 AND l.voided = 0`) if err != nil { return nil, fmt.Errorf("export: 读取个人得分明细: %w", err) } defer func() { _ = rows.Close() }() var out []AthleteDetailRow for rows.Next() { var ( row AthleteDetailRow source string resultText string unit string entryID int64 ) if err := rows.Scan(&row.teamID, &row.AthleteName, &row.SportName, &row.EventName, &row.Category, &row.Rank, &resultText, &unit, &row.Points, &source, &row.sportSort, &row.eventSort, &row.catSort, &row.eventID, &row.categoryID, &row.ledgerID, &entryID); err != nil { return nil, fmt.Errorf("export: 扫描个人得分明细: %w", err) } row.ResultText = displayResult(resultText, unit) row.Source = sourceLabel(source) // 联队说明挂在「来源」列:这张表没有备注列,而「来源」写的正是这一分是从哪来的 //(成绩 / 破纪录加分 / 人工调整)。联队的一条成绩会给每个成员单位各留一行流水, // 不写这一句,读表的人只会看到同一个人在同一小项出现了两次、每次半份分。 if joint, ok := joints[entryID]; ok { row.Source += "(" + joint.shareNote() + ")" } out = append(out, row) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历个人得分明细: %w", err) } return out, nil } // sortAthleteDetailRows 按运动员分组、组内按项目既有顺序排列,读起来像「某人的得分清单」。 func sortAthleteDetailRows(rows []AthleteDetailRow) { sort.SliceStable(rows, func(i, j int) bool { a, b := rows[i], rows[j] switch { case a.AthleteName != b.AthleteName: return a.AthleteName < b.AthleteName 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 default: return a.ledgerID < b.ledgerID } }) }