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。 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 } rows, err := loadAthleteDetailRows(db) 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 } func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) { rows, err := db.Query(` SELECT l.team_id, COALESCE(a.name, ''), COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''), COALESCE(r.rank, 0), 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, 0), COALESCE(l.event_id, 0), COALESCE(c.id, 0), l.id 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 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 ) 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); err != nil { return nil, fmt.Errorf("export: 扫描个人得分明细: %w", err) } row.ResultText = displayResult(resultText, unit) row.Source = sourceLabel(source) 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 } }) }