package export import ( "database/sql" "io" "sort" "github.com/xuri/excelize/v2" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // allTeamDetailsSheet 是总明细工作簿第一张总表的表名。 const allTeamDetailsSheet = "积分总明细" // TeamDetailSheet 是总明细里某个单位的一张分表:列定义与单位明细完全一致。 // 只有有明细行的单位才会有分表(Rows 恒非空)。 type TeamDetailSheet struct { Team domain.Team Rows []TeamDetailRow } // AllTeamDetailsWorkbook 是「所有单位积分明细」(总明细)的内容: // 第一张总表把所有单位的明细合在一起,其后每个有明细行的单位一张分表。 type AllTeamDetailsWorkbook struct { Meta Meta Sheets []TeamDetailSheet IndividualToTeamWeight float64 } // allTeamDetailColumns 是总表的列:在单位明细列的最前面加一列「单位」。 var allTeamDetailColumns = append([]column{{"单位", 22, kindText}}, teamDetailColumns...) // ExportAllTeamDetailsXLSX 导出所有单位的积分明细(总表 + 每单位一张分表),落盘到 dst。 func ExportAllTeamDetailsXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadAllTeamDetailsWorkbook(db, opts) if err != nil { return err } f, err := allTeamDetailsFile(wb) if err != nil { return err } return saveWorkbook(f, dst) } // WriteAllTeamDetailsXLSX 把总明细写成 xlsx 流,供 HTTP 直接响应下载。 func WriteAllTeamDetailsXLSX(w io.Writer, wb AllTeamDetailsWorkbook) error { f, err := allTeamDetailsFile(wb) if err != nil { return err } return f.Write(w) } func allTeamDetailsFile(wb AllTeamDetailsWorkbook) (*excelize.File, error) { return buildWorkbook(allTeamDetailsSheet, func(f *excelize.File, st *styleSet) error { meta := wb.Meta.normalized() // 先登记总表名:分表即使和总表同名也会自动补后缀,不会撞名覆盖。 namer := &sheetNamer{} master := namer.add(allTeamDetailsSheet) if err := addSheet(f, master); err != nil { return err } if err := writeSheet(f, st, master, meta, sheetTitle(meta, allTeamDetailsSheet), allTeamDetailColumns, allTeamDetailRows(wb.Sheets, wb.IndividualToTeamWeight)); 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 } title := sheetTitle(meta, unit+" · 积分明细") if err := writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(s.Rows, wb.IndividualToTeamWeight)); err != nil { return err } } return nil }) } // allTeamDetailRows 把各单位明细拼成一张总表。 // 逐单位复用 teamDetailDataRows(因此序号在每个单位内重新从 1 开始,折算口径与单位明细完全一致), // 末尾补一行跨单位合计;即使一条流水都没有也保留合计行,保持版式一致。 func allTeamDetailRows(sheets []TeamDetailSheet, weight float64) [][]any { var out [][]any var total float64 for _, s := range sheets { unit := displayName(s.Team.Name, s.Team.ShortName) rows, sub := teamDetailDataRows(s.Rows, weight) total += sub for _, row := range rows { out = append(out, append([]any{unit}, row...)) } } // 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。 return append(out, []any{"合计", "", "", "", "", "", "", "", "", total, ""}) } // LoadAllTeamDetailsWorkbook 读取全量单位的积分流水。 // 单位顺序沿用「单位总分榜」(得分高的在前),与管理面板上看到的顺序一致; // 榜上没有的单位(例如停用且没有任何流水/奖牌)按 teams.sort_order 排在其后。 // 只有真正有明细行的单位才进 Sheets:没有流水的单位既没有信息量, // 也不能把「彻底删除」(假删除)后无流水的单位名字重新印进导出件。 func LoadAllTeamDetailsWorkbook(db *sql.DB, opts Options) (AllTeamDetailsWorkbook, error) { if db == nil { return AllTeamDetailsWorkbook{}, ErrNoDatabase } meta, settings, err := resolveMeta(db, opts.Meta) if err != nil { return AllTeamDetailsWorkbook{}, err } // 全量单位表:停用的单位也可能有历史流水,明细要能查到。 teams, err := loadTeams(db, false) if err != nil { return AllTeamDetailsWorkbook{}, err } order, err := teamStandingsOrder(db, opts) if err != nil { return AllTeamDetailsWorkbook{}, err } orderTeamsByStandings(teams, order) sheets := make([]TeamDetailSheet, 0, len(teams)) for _, t := range teams { rows, err := loadTeamDetailRows(db, t.ID) if err != nil { return AllTeamDetailsWorkbook{}, err } // 没有明细行就不出分表。已隐藏(deleted_at 非空)但有流水的单位仍在榜单上, // 照常出表;隐藏且无流水的单位不出表,否则等于把藏起来的条目印在导出件里。 if len(rows) == 0 { continue } sheets = append(sheets, TeamDetailSheet{Team: t, Rows: rows}) } return AllTeamDetailsWorkbook{ Meta: meta, Sheets: sheets, IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1), }, nil } // teamStandingsOrder 返回 单位ID → 总积分榜名次(0 起)。 // 名次规则只在 domain 里有一份,这里直接复用总积分榜的结果,不另算一套排序。 func teamStandingsOrder(db *sql.DB, opts Options) (map[int64]int, error) { wb, err := LoadStandingsWorkbook(db, opts) if err != nil { return nil, err } order := make(map[int64]int, len(wb.Team)) for i, r := range wb.Team { if _, ok := order[r.TeamID]; !ok { order[r.TeamID] = i } } return order, nil } // orderTeamsByStandings 把单位按「单位总分榜」顺序(得分高的在前)原地排序; // 榜上没有的单位保持 loadTeams 的 sort_order, code 顺序,排在榜上有名的单位之后。 func orderTeamsByStandings(teams []domain.Team, order map[int64]int) { sort.SliceStable(teams, func(i, j int) bool { oi, iok := order[teams[i].ID] oj, jok := order[teams[j].ID] if iok != jok { return iok } if iok { return oi < oj } return false }) }