Files
Scoreboard/server/internal/export/athletes.go
T
AskaEth 830a4dbdb7 feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
2026-09-14 03:52:34 +08:00

301 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
})
}