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 张逐字节未变)。
This commit is contained in:
AskaEth committed 2026-09-14 03:52:34 +08:00
1 parent b631418a85
commit 830a4dbdb7
61 files changed
+5649 -121

No files matched your search

+15 -4
View File
@@ -156,7 +156,11 @@ func LoadAthleteDetailWorkbook(db *sql.DB, opts Options) (AthleteDetailWorkbook,
if err != nil {
return AthleteDetailWorkbook{}, err
}
rows, err := loadAthleteDetailRows(db)
joints, err := loadJointTeams(db)
if err != nil {
return AthleteDetailWorkbook{}, err
}
rows, err := loadAthleteDetailRows(db, joints)
if err != nil {
return AthleteDetailWorkbook{}, err
}
@@ -208,7 +212,7 @@ const athleteDetailRankExpr = `COALESCE((
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) ([]AthleteDetailRow, error) {
func loadAthleteDetailRows(db *sql.DB, joints map[int64]jointTeam) ([]AthleteDetailRow, error) {
// 聚合流水的组别回推与单位明细同一条思路:某个运动员在本小项只参加了一个组别时,那个组别就是答案;
// 多个组别时回推不出来,宁可留空也不猜(回推只对 entry_id / result_id 都为空的聚合流水生效)。
rows, err := db.Query(`
@@ -217,7 +221,7 @@ func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
` + 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.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
@@ -247,16 +251,23 @@ func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
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); err != nil {
&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 {
+33 -3
View File
@@ -123,13 +123,18 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
if err != nil {
return EntriesWorkbook{}, err
}
// 联队配置:导出名单里的单位编码回写成导入表的 A+B 写法,单位名写全成员,备注里写清份额。
joints, err := loadJointTeams(db)
if err != nil {
return EntriesWorkbook{}, err
}
query := `
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
COALESCE(t.code, ''), COALESCE(t.name, ''),
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat,
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, '')
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, ''), en.id
FROM entries en
JOIN events ev ON ev.id = en.event_id
JOIN teams t ON t.id = en.team_id
@@ -145,7 +150,6 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
}{
{"en.event_id", filter.EventID},
{"en.category_id", filter.CategoryID},
{"en.team_id", filter.TeamID},
{"en.heat", filter.Heat},
} {
if f.value != 0 {
@@ -153,6 +157,24 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
args = append(args, f.value)
}
}
// 单位筛选要连「联队里的成员单位」一起算:entries.team_id 只是主单位(加号左边那个),
// 但成员单位同样在这条报名上拿分拿牌——按 B 导出时看不到它,B 就不知道自己派了谁上场。
// 主单位那一半条件照旧留着:份额行缺失的报名(手工造的老数据)不能因此从筛选结果里掉出去。
// 份额表不在(库还没跑到 0024)时不加这段:那种库里根本没有联队。
if filter.TeamID != 0 {
teamCond := "en.team_id = ?"
args = append(args, filter.TeamID)
ready, err := entrySharesReady(db)
if err != nil {
return EntriesWorkbook{}, err
}
if ready {
teamCond += ` OR EXISTS (SELECT 1 FROM entry_team_shares s
WHERE s.entry_id = en.id AND s.team_id = ?)`
args = append(args, filter.TeamID)
}
conds = append(conds, "("+teamCond+")")
}
if len(conds) > 0 {
query += " WHERE " + strings.Join(conds, " AND ")
}
@@ -176,10 +198,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
heat sql.NullInt64
age sql.NullInt64
athleteName string
entryID int64
)
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
&row.Gender, &age, &row.Status, &row.Remark); err != nil {
&row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil {
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
}
row.Name = participantName(athleteName, memberJSON)
@@ -192,6 +215,13 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie
row.Age = ""
}
row.Status = entryExportStatusLabel(row.Status)
// 联队:单位编码回写成 A+B(这份名单能直接拿去重导),单位名写全成员,
// 备注里写清这条分拆给了哪几个单位、各占多少。报名名单没有积分列,只写份额比例。
if joint, ok := joints[entryID]; ok {
row.TeamCode = joint.Code()
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareRatioNote())
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
+260
View File
@@ -0,0 +1,260 @@
package export
import (
"database/sql"
"fmt"
"math"
"strconv"
"strings"
)
// 本文件是「联队 + 份额分成」(devdocs/19)在导出侧的读法。
//
// 一条报名配了多个成员单位时(现场有两个单位因人数不足合并参赛),导出件要做两件事:
// - 把联队的名字写出来,**成员单位名写全**,形如「第一分公司+第二分公司联队」;
// - 讲清这条分拆给了哪几个单位、各多少(份额比例 + 各单位实得积分)。
//
// 两个口径必须分开(已拍板,见 domain.TeamShare):积分按份额拆(允许 0.5 分),
// 奖牌**每个成员单位各记一枚整枚**。后者会让「各单位的奖牌数加起来」大于实际颁发的奖牌数,
// 那是定稿口径、不是 bug,所以联队奖牌那一格自己要把「各单位各记 1 枚」写出来,
// 含联队的榜单类导出件另在副标题里写一句口径说明(见 jointMedalNote)。
// jointMedalNote 是含联队的榜单类导出件(总表 / 单位奖牌明细)副标题上的口径说明。
// 奖牌数之和会大于实际颁发数是定稿口径(每个成员单位各记一整枚),
// 不说这一句,拿导出件对账的人会以为多印了奖牌。
const jointMedalNote = "注:本赛事含联队,奖牌按「每个成员单位各记 1 枚」统计,各单位奖牌数之和会大于实际颁发数"
// jointMember 是联队里的一个成员单位:展示字段 + 份额 + 本单位实得积分。
type jointMember struct {
TeamID int64
Code string
// Name 是单位全称(缺全称时退简称)——现场看的就是这个,不能用编码凑合。
Name string
Weight float64
// CountsMedal 为 false 时这枚奖牌不记给本单位(缺省 true = 整枚各记)。
CountsMedal bool
// Points 是本单位从这条成绩实得的积分(按流水统计:引擎已按份额拆好;没出分前是 0)。
Points float64
// Primary 标记这是报名的主单位(entries.team_id):显示名与导出的编码都以它打头。
Primary bool
}
// jointTeam 是一条报名配的联队:成员单位按份额顺序(主单位排第一),至少两个。
type jointTeam struct {
members []jointMember
}
// Name 是联队的显示名:成员单位名写全,用 + 连起来再加「联队」——「第一分公司+第二分公司联队」。
// 写法与导入表 team_code 的 `A+B` 同构,现场拿着一份表能一眼对上另一份。
func (t jointTeam) Name() string {
names := make([]string, 0, len(t.members))
for _, m := range t.members {
names = append(names, m.Name)
}
return strings.Join(names, "+") + "联队"
}
// Code 是联队的单位编码:`T1+T2`,与导入表的 team_code 写法逐字一致,
// 于是导出的报名名单可以直接拿去重导(round-trip),不必手工改回单单位。
func (t jointTeam) Code() string {
codes := make([]string, 0, len(t.members))
for _, m := range t.members {
codes = append(codes, m.Code)
}
return strings.Join(codes, "+")
}
// shareRatioNote 是只讲份额的说明:「联队分拆:第一分公司 50%、第二分公司 50%」。
// 报名名单用它:那张表是报名侧的数据(号码布 / 批次 / 状态),没有积分列,
// 凭空冒出「5 分」反而让人以为这里也在算分。
func (t jointTeam) shareRatioNote() string {
return "联队分拆:" + strings.Join(t.shareItems(false), "、")
}
// shareNote 是份额说明:「联队分拆:第一分公司 7.5 分(50%)、第二分公司 7.5 分(50%)」。
//
// 已经出分(成员里有非零积分)时把各单位的实得积分一并写出来——「各多少」问的就是它,
// 而导出件上的「积分」列只写了联队拿到的总分。还没出分时退化成只写份额比例。
func (t jointTeam) shareNote() string {
return "联队分拆:" + strings.Join(t.shareItems(true), "、")
}
// shareItems 拼各成员单位的份额描述,顺序与份额一致(主单位在前)。
func (t jointTeam) shareItems(withPoints bool) []string {
sum := 0.0
for _, m := range t.members {
sum += shareWeightOf(m.Weight)
}
if withPoints {
withPoints = false
for _, m := range t.members {
if m.Points != 0 {
withPoints = true
}
}
}
items := make([]string, 0, len(t.members))
for _, m := range t.members {
percent := formatPercent(shareWeightOf(m.Weight), sum)
if withPoints {
items = append(items, fmt.Sprintf("%s %s 分(%s)", m.Name, formatPoints(m.Points), percent))
continue
}
items = append(items, m.Name+" "+percent)
}
return items
}
// medalCell 把一枚联队奖牌写全:「金牌(联队 第一分公司+第二分公司,各单位各记 1 枚)」。
//
// 括注必须留在奖牌这一格:奖牌明细里非联队行只有「金牌」两个字,读者把各单位的奖牌数加起来
// 会比实际颁发的多(口径如此),这一句就是给那个数字的解释。有成员被显式关掉记牌时改写成
// 「记给 甲、乙」——那种写法现在只有改份额的接口做得出来,但照样不能让人猜。
func (t jointTeam) medalCell(medal string) string {
all := make([]string, 0, len(t.members))
counting := make([]string, 0, len(t.members))
for _, m := range t.members {
all = append(all, m.Name)
if m.CountsMedal {
counting = append(counting, m.Name)
}
}
if len(counting) == len(all) {
return fmt.Sprintf("%s(联队 %s,各单位各记 1 枚)", medal, strings.Join(all, "+"))
}
return fmt.Sprintf("%s(联队 %s,记给 %s)", medal, strings.Join(all, "+"), strings.Join(counting, "、"))
}
// shareWeightOf 与引擎的 shareWeight 同一口径:0 与负数按 1 处理(份额是相对权重,不填即均分)。
func shareWeightOf(weight float64) float64 {
if weight <= 0 {
return 1
}
return weight
}
// formatPercent 把份额写成占比(weight / Σweight):1+1 → 50%、2:1 → 66.7% / 33.3%。
// 保留一位小数并去掉多余的 0:现场看的是一眼能懂的比例,不是浮点数的原貌。
func formatPercent(weight, sum float64) string {
if sum <= 0 {
sum = 1
}
return strconv.FormatFloat(math.Round(weight/sum*1000)/10, 'f', -1, 64) + "%"
}
// formatPoints 把积分写成文本:整数不带小数点,小数最多两位(引擎按份额拆出的 7.5 照实写)。
func formatPoints(v float64) string {
return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
}
// appendNote 把一句说明补进一格文本:原有内容(报名备注、修订痕迹)排在前面,用分号隔开。
func appendNote(existing, note string) string {
if strings.TrimSpace(note) == "" {
return existing
}
if strings.TrimSpace(existing) == "" {
return note
}
return existing + ";" + note
}
// entrySharesReady 判断份额表在不在。
//
// 读路径要能读「还没跑到 0024 的库」:没有那张表就没有任何联队配置,显示照旧(主单位独占)。
// 与 httpapi.entryTeamSharesReady 同一判据,只是这边没有 context 参数。
func entrySharesReady(db *sql.DB) (bool, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return false, fmt.Errorf("export: 查询报名份额表: %w", err)
}
return n != 0, nil
}
// loadJointTeams 读全库的联队配置,按报名 id 归组;只有一条份额的报名不算联队,不进结果。
//
// 「只有一条」这一档必须排掉:迁移会把每条老报名回填成一条「主单位独占 100%」,
// 导入也给普通报名写一条,把它们当成联队会让**所有**导出件的单位名列变成「XX联队」——
// 那是老数据逐字节不变这条硬门槛要挡的事。
//
// 各单位的实得积分一并读出来(分数按份额拆、末位吞余数,只在库里才准),
// 用它拼「联队分拆:A 7.5 分、B 7.5 分」。表不在时返回空:没有份额表就没有联队配置。
func loadJointTeams(db *sql.DB) (map[int64]jointTeam, error) {
ready, err := entrySharesReady(db)
if err != nil {
return nil, err
}
if !ready {
return nil, nil
}
rows, err := db.Query(`
SELECT s.entry_id, s.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''),
s.points_share, s.medal_share, e.team_id,
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = s.entry_id AND l.team_id = s.team_id AND l.voided = 0), 0)
FROM entry_team_shares s
JOIN teams t ON t.id = s.team_id
JOIN entries e ON e.id = s.entry_id
ORDER BY s.entry_id, s.sort_order, s.team_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取联队份额: %w", err)
}
defer func() { _ = rows.Close() }()
grouped := make(map[int64][]jointMember)
for rows.Next() {
var (
entryID int64
m jointMember
name string
short string
medal int
primary int64
)
if err := rows.Scan(&entryID, &m.TeamID, &m.Code, &name, &short,
&m.Weight, &medal, &primary, &m.Points); err != nil {
return nil, fmt.Errorf("export: 扫描联队份额: %w", err)
}
m.Name = displayName(name, short)
if m.Name == "" {
m.Name = m.Code
}
m.CountsMedal = medal != 0
m.Primary = primary == m.TeamID
grouped[entryID] = append(grouped[entryID], m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历联队份额: %w", err)
}
out := make(map[int64]jointTeam, len(grouped))
for entryID, members := range grouped {
if len(members) < 2 {
continue
}
out[entryID] = jointTeam{members: primaryFirst(members)}
}
return out, nil
}
// primaryFirst 把主单位挪到成员列表最前面,其余保持份额表的顺序。
//
// 显示名与导出的 `A+B` 都以主单位打头(口径:加号左边那个是主单位),而份额表里主单位
// 不一定排第一——改份额的接口只要求主单位在成员里,没要求它排头。不归位的话,
// 一份导出件里的 `B+A` 重导回去会把主单位换成 B,报名身份(唯一键)跟着变。
func primaryFirst(members []jointMember) []jointMember {
out := make([]jointMember, 0, len(members))
for _, m := range members {
if m.Primary {
out = append(out, m)
}
}
for _, m := range members {
if !m.Primary {
out = append(out, m)
}
}
return out
}
@@ -0,0 +1,408 @@
package export
import (
"database/sql"
"path/filepath"
"strings"
"testing"
)
// 联队的导出侧(devdocs/19):一条成绩按份额拆给多个单位时,导出件必须
// 1. 把联队的名字写出来,成员单位名写全(形如「第一分公司+第二分公司联队」);
// 2. 看得出这条分拆给了哪几个单位、各多少(份额 + 各单位实得积分)。
// 本文件逐份导出件钉住这两条;另一条硬门槛(没有联队的老数据一字不变)由
// TestSingleUnitExportsHaveNoJointTeamText 与既有的各份导出用例共同守住。
// jointTeamMemberNames 是联队里的两个成员单位(与 seedEvent 的单位一致)。
const (
jointNameT1 = "第一分公司"
jointNameT2 = "第二分公司"
// jointTeamDisplay 是联队的显示名:成员单位名写全,中间用 + 连、末尾标「联队」。
jointTeamDisplay = jointNameT1 + "+" + jointNameT2 + "联队"
// jointShareNote 是均分两份时的份额说明(两条流水的实得积分都是 5 分)。
jointShareNote = "联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)"
// jointShareOnlyNote 是没有流水时(报名阶段)的份额说明:只讲份额,不讲分。
jointShareOnlyNote = "联队分拆:第一分公司 50%、第二分公司 50%"
)
// seedJointTeamEntry 把 seedEvent 的第 1 名改成联队(T1+T2)拿的:份额均分、奖牌整枚各记,
// 流水也按份额拆成两行。现场这两样都是引擎算出来的,导出测试直接造库(与 seedChannelRanks 同一套做法)。
func seedJointTeamEntry(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (1, 1, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (1, 2, 1, 1, 1)`,
// 引擎把这一条成绩的 10 分拆成两行流水(份额之和归一,两名单位各一半)。
`UPDATE score_ledger SET points = 5 WHERE entry_id = 1`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (2, 1, 1, 1, 1, 'result', 1, 5)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造联队数据 %q: %v", s, err)
}
}
}
// seedSingleShareRow 给某条报名只配一条份额:迁移与导入都会这么写(普通报名是「主单位独占 100%」),
// 它**不是**联队,导出件必须与升级前一模一样。
func seedSingleShareRow(t *testing.T, db *sql.DB, entryID, teamID int64) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (?, ?, 1, 1, 0)`, entryID, teamID); err != nil {
t.Fatalf("造独占份额: %v", err)
}
}
// 分项成绩表:单位列写全联队成员,备注列写清分给了谁、各多少。
func TestJointTeamEventSheetShowsMembersAndShares(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "分项.xlsx")
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
rows := readSheet(t, dst, "田径·100米跑·男子")
// 第 1 名那条是联队(列序:名次/号码/单位/运动员-队/成绩/积分/状态/备注)。
if got := cell(rows, 4, 2); got != jointTeamDisplay {
t.Errorf("联队行的单位 = %q,期望 %q", got, jointTeamDisplay)
}
if got := cell(rows, 4, 5); got != "10" {
t.Errorf("联队行的积分 = %q,期望 10(联队拿到的总分)", got)
}
if got := cell(rows, 4, 7); !strings.Contains(got, jointShareNote) {
t.Errorf("联队行的备注 = %q,期望含 %q", got, jointShareNote)
}
// 第 2 名那条不是联队:单位与备注都不该多出任何东西。
if got := cell(rows, 5, 2); got != jointNameT2 {
t.Errorf("非联队行的单位 = %q,期望 %q", got, jointNameT2)
}
if got := cell(rows, 5, 7); got != "" {
t.Errorf("非联队行的备注应为空,得到 %q", got)
}
}
// 报名名单:单位编码回写成导入时的 A+B 写法(导出件可直接拿去重导),单位名写全联队成员。
func TestJointTeamEntriesSheetWritesJointTeamCode(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "报名.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
// 列序:小项编码/小项名/组别/单位编码/单位名/姓名/证件号/号码布/批次/性别/年龄/状态/备注。
if got := cell(rows, 4, 3); got != "T1+T2" {
t.Errorf("联队行的单位编码 = %q,期望 T1+T2(与导入表的写法一致)", got)
}
if got := cell(rows, 4, 4); got != jointTeamDisplay {
t.Errorf("联队行的单位名 = %q,期望 %q", got, jointTeamDisplay)
}
if got := cell(rows, 4, 12); !strings.Contains(got, jointShareOnlyNote) {
t.Errorf("联队行的备注 = %q,期望含 %q", got, jointShareOnlyNote)
}
if got := cell(rows, 5, 3); got != "T2" {
t.Errorf("非联队行的单位编码 = %q,期望 T2", got)
}
}
// 单位积分明细:某单位拿到的这一条来自联队,备注要说清分给了哪几个单位、各多少。
func TestJointTeamTeamDetailNotesShare(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "单位明细.xlsx")
if err := ExportTeamDetailXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出单位积分明细: %v", err)
}
rows := readSheet(t, dst, "第一分公司积分明细")
if got := cell(rows, 4, 8); got != "5" {
t.Errorf("联队份额行的积分 = %q,期望 5(本单位那一半)", got)
}
if got := cell(rows, 4, 9); !strings.Contains(got, jointShareNote) {
t.Errorf("联队份额行的备注 = %q,期望含 %q", got, jointShareNote)
}
// 总明细(第一张总表 + 每单位一张分表)与单表同口径,逐格写的是同一句话。
allDst := filepath.Join(t.TempDir(), "单位总明细.xlsx")
if err := ExportAllTeamDetailsXLSX(db, allDst, Options{}); err != nil {
t.Fatalf("导出所有单位积分明细: %v", err)
}
master := readSheet(t, allDst, "积分总明细")
found := false
for r := range master {
if cell(master, r, 0) == jointNameT1 && strings.Contains(cell(master, r, 10), jointShareNote) {
found = true
}
}
if !found {
t.Errorf("总明细里第一分公司的联队份额行没有份额说明:%v", master)
}
}
// 个人积分总明细:没有备注列,联队说明写在「来源」列(该行得分的来处)。
func TestJointTeamAthleteDetailNotesShare(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "个人.xlsx")
if err := ExportAthleteDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出个人得分明细: %v", err)
}
rows := readSheet(t, dst, "第一分公司个人得分明细")
// 列序:运动员/大项/小项/组别/名次/成绩/得分/来源。
if got := cell(rows, 4, 6); got != "5" {
t.Errorf("联队份额行的得分 = %q,期望 5", got)
}
if got := cell(rows, 4, 7); !strings.Contains(got, jointShareNote) {
t.Errorf("联队份额行的来源 = %q,期望含 %q", got, jointShareNote)
}
}
// 单位奖牌明细:联队金牌两个单位各记一枚,导出件要在那一行就说清「各单位各记 1 枚」,
// 否则把各单位的奖牌数加起来会比实际颁发的多(口径 3,不是 bug)。
func TestJointTeamMedalDetailShowsEveryUnit(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
want := "金牌(联队 " + jointNameT1 + "+" + jointNameT2 + ",各单位各记 1 枚)"
master := readSheet(t, dst, "单位奖牌明细")
gold := make([]string, 0, 2)
for r := range master {
if strings.HasPrefix(cell(master, r, 5), "金牌") {
gold = append(gold, cell(master, r, 5))
}
}
if len(gold) != 2 {
t.Fatalf("联队金牌应在总表出两行(两个单位各一行),得到 %d 行:%v", len(gold), master)
}
for i, got := range gold {
if got != want {
t.Errorf("总表第 %d 行联队奖牌 = %q,期望 %q", i+1, got, want)
}
}
// 非联队的银牌那一行一字不动。
plain := false
for r := range master {
if cell(master, r, 5) == "银牌" {
plain = true
}
}
if !plain {
t.Errorf("普通奖牌行应保持「银牌」原文:%v", master)
}
// 分表与总表同口径:第一分公司那一张表里的金牌也是同一句话。
team := readSheet(t, dst, "第一分公司奖牌明细")
if got := cell(team, 4, 4); got != want {
t.Errorf("分表联队奖牌 = %q,期望 %q", got, want)
}
// 分表小计照旧数「本单位拿到几枚」——联队口径下它每个单位各算 1 枚。
if got := cell(team, 5, 4); got != "金 1 / 银 0 / 铜 0" {
t.Errorf("分表小计 = %q,期望 金 1 / 银 0 / 铜 0", got)
}
}
// 含联队的榜单类导出件要有一句口径说明:奖牌按「每个成员单位各记 1 枚」统计,
// 各单位奖牌数之和会大于实际颁发数。合计行本身不动(口径 3)。
func TestJointTeamStandingsNotesMedalRule(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "总表.xlsx")
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出总表: %v", err)
}
medal := readSheet(t, dst, "单位奖牌榜")
if got := cell(medal, 1, 0); !strings.Contains(got, "每个成员单位各记 1 枚") {
t.Errorf("单位奖牌榜的副标题 = %q,期望含奖牌口径说明", got)
}
// 口径没变:两个单位各记 1 枚金牌(总表两行都是 1,不是各 0.5)。
count := 0
for r := range medal {
if cell(medal, r, 1) == "T1" || cell(medal, r, 1) == "T2" {
if cell(medal, r, 3) != "1" {
t.Errorf("%s 金牌 = %q,期望 1(整枚各记)", cell(medal, r, 1), cell(medal, r, 3))
}
count++
}
}
if count != 2 {
t.Errorf("单位奖牌榜应有 2 个单位行,得到 %d", count)
}
}
// 球类现场的形态:名次是按单位手工录的(团体通道、没有对应成绩行),channel_ranks 的
// entry_id 指到那条联队报名(引擎的 jointTeamEntry 干的活)。奖牌明细同样要认得出联队——
// 只按成绩 id 找来源的做法在这里取不到任何展示字段(result_id 为空)。
func TestJointTeamMedalDetailFromManualTeamRank(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
var profileID int64
if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读团体积分模板: %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'team', 'manual', ?, 1)`, profileID); err != nil {
t.Fatalf("造团体通道: %v", err)
}
var channelID int64
if err := db.QueryRow(`SELECT id FROM event_score_channels
WHERE event_id = 1 AND scope = 'team' AND basis = 'manual'`).Scan(&channelID); err != nil {
t.Fatalf("读团体通道: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, team_id, rank, points, status)
VALUES (1, 1, ?, 'manual', 'team', 'e:1', 1, NULL, 1, 1, 0, 'ok')`, channelID); err != nil {
t.Fatalf("造团体手工名次: %v", err)
}
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
t.Fatalf("导出单位奖牌明细: %v", err)
}
want := "金牌(联队 " + jointNameT1 + "+" + jointNameT2 + ",各单位各记 1 枚)"
master := readSheet(t, dst, "单位奖牌明细")
gold := 0
for r := range master {
if strings.HasPrefix(cell(master, r, 5), "金牌") {
gold++
if got := cell(master, r, 5); got != want {
t.Errorf("总表联队奖牌 = %q,期望 %q", got, want)
}
}
}
if gold != 2 {
t.Fatalf("联队金牌应给两个单位各出一行,得到 %d 行:%v", gold, master)
}
}
// 按成员单位筛报名名单:联队那条报名挂在主单位(T1)名下,但成员单位 T2 的名单里也得有它,
// 否则 T2 导出自己的名单时,会以为自己没派人上场。
func TestJointTeamEntriesSheetFilteredByMemberUnit(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedJointTeamEntry(t, db)
dst := filepath.Join(t.TempDir(), "报名.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{TeamID: 2}, Options{}); err != nil {
t.Fatalf("按成员单位导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
found := false
for r := range rows {
if cell(rows, r, 3) == "T1+T2" {
found = true
}
}
if !found {
t.Errorf("按成员单位 T2 导出时应能看到联队那条报名:%v", rows)
}
// 主单位筛选照旧(老行为一字不改)。
primaryDst := filepath.Join(t.TempDir(), "报名主单位.xlsx")
if err := ExportEntriesXLSX(db, primaryDst, EntriesFilter{TeamID: 1}, Options{}); err != nil {
t.Fatalf("按主单位导出报名名单: %v", err)
}
primary := readSheet(t, primaryDst, "报名名单")
if len(primary) != 5 { // 4 行版式 + 主单位自己的那一条
t.Fatalf("按主单位筛应只出 T1 的那一条,得到 %d 行:%v", len(primary), primary)
}
}
// 份额表里主单位不排第一时(改份额的接口允许这样写),导出件的名字与 A+B 仍以主单位打头:
// 否则 `B+A` 重导回去会把报名的主单位换成 B。
func TestJointTeamDisplayPutsPrimaryFirst(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 故意把成员单位排在主单位前面(sort_order 0 = T2,1 = T1)。
for _, s := range []string{
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 2, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 1, 1, 1, 1)`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造份额 %q: %v", s, err)
}
}
dst := filepath.Join(t.TempDir(), "报名.xlsx")
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
rows := readSheet(t, dst, "报名名单")
if got := cell(rows, 4, 3); got != "T1+T2" {
t.Errorf("单位编码 = %q,期望 T1+T2(主单位永远排最前)", got)
}
if got := cell(rows, 4, 4); got != jointTeamDisplay {
t.Errorf("单位名 = %q,期望 %q", got, jointTeamDisplay)
}
}
// 老数据门槛:只有一条独占份额(普通报名 / 迁移回填)时,各份导出件一个字都不能多。
func TestSingleUnitExportsHaveNoJointTeamText(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 迁移与导入都会给每条报名写一条独占份额:它不是联队。
seedSingleShareRow(t, db, 1, 1)
seedSingleShareRow(t, db, 2, 2)
seedChannelRanks(t, db)
dir := t.TempDir()
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
rows := readSheet(t, eventDst, "田径·100米跑·男子")
if got := cell(rows, 4, 2); got != jointNameT1 {
t.Errorf("普通报名的单位 = %q,期望 %q(联队显示名不能套到独占份额上)", got, jointNameT1)
}
if got := cell(rows, 4, 7); got != "" {
t.Errorf("普通报名的备注应为空,得到 %q", got)
}
entriesDst := filepath.Join(dir, "报名.xlsx")
if err := ExportEntriesXLSX(db, entriesDst, EntriesFilter{}, Options{}); err != nil {
t.Fatalf("导出报名名单: %v", err)
}
erows := readSheet(t, entriesDst, "报名名单")
if got := cell(erows, 4, 3); got != "T1" {
t.Errorf("普通报名的单位编码 = %q,期望 T1", got)
}
if got := cell(erows, 4, 4); got != jointNameT1 {
t.Errorf("普通报名的单位名 = %q,期望 %q", got, jointNameT1)
}
standingsDst := filepath.Join(dir, "总表.xlsx")
if err := ExportStandingsXLSX(db, standingsDst, Options{}); err != nil {
t.Fatalf("导出总表: %v", err)
}
medal := readSheet(t, standingsDst, "单位奖牌榜")
for r := range medal {
if strings.Contains(cell(medal, r, 0), "联队") || strings.Contains(cell(medal, r, 1), "联队") {
t.Errorf("无联队时副标题不该出现联队口径说明:%v", medal[r])
}
}
}
+37
View File
@@ -0,0 +1,37 @@
package export
import (
"testing"
)
// 导出是联队奖牌的第三条读路径(另有 REST 与 WS 快照):它同样读 channel_ranks,
// 再按报名项的份额补归属。少了这一步,导出的单位奖牌榜会把联队的金牌只记给主单位。
func TestJointTeamMedalExportCountsEveryMember(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
// 把 T1 的第 1 名改成联队(T1 + T2)拿的:份额均分、奖牌整枚各记。
for _, s := range []string{
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 1, 1, 1, 0)`,
`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 2, 1, 1, 1)`,
} {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造联队份额 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
book, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读榜单工作簿: %v", err)
}
gold := make(map[int64]int, len(book.Medal))
for _, row := range book.Medal {
gold[row.TeamID] = row.Gold
}
if gold[1] != 1 {
t.Fatalf("主单位 T1 应记 1 枚金牌,得到 %d(完整:%+v)", gold[1], book.Medal)
}
if gold[2] != 1 {
t.Fatalf("成员单位 T2 也应记 1 枚金牌(不是 0.5 枚、也不是 0 枚),得到 %d(完整:%+v)", gold[2], book.Medal)
}
}
+92 -12
View File
@@ -23,6 +23,10 @@ type Meta struct {
// 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。
// 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。
GeneratedBy string
// Note 是附在副标题后面的口径说明,由 Load* 按数据内容补(当前只有「本赛事含联队」时才会写:
// 奖牌每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数)。
// 留空时副标题与改动前逐字一致——无联队的老导出件不能因为加了这个字段而变形。
Note string
}
func (m Meta) normalized() Meta {
@@ -132,6 +136,15 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
medal := domain.BuildMedalStandings(in)
athlete := domain.BuildAthleteStandings(in)
fillTeamDisplay(team, medal, athlete, allTeams)
// 含联队时在副标题上写清奖牌口径:每个成员单位各记 1 枚,各单位奖牌数之和会大于实际颁发数。
// 合计行本身不改(那是定稿口径),只把那个数字解释清楚。
joints, err := loadJointTeams(db)
if err != nil {
return StandingsWorkbook{}, err
}
if len(joints) > 0 {
meta.Note = jointMedalNote
}
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
}
@@ -330,11 +343,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
v := value.Float64
rr.ResultValue = &v
}
// 归属份额沿用「主单位独占 100%」:team_id 为空(聚合 / 团体手工名次理论上不会)时不造份额,
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
if rr.TeamID != 0 {
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMed != 0
@@ -343,9 +351,60 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
}
// 归属份额按报名项配置补(联队:一条成绩归多个单位):与 REST 的 buildRanked、
// 快照的 loadRanked 同口径。没有报名项的名次与没配份额的老数据都退回「主单位独占 100%」,
// team_id 为空时不造份额,免得给 0 号单位凭空记牌。
shares, err := loadEntryTeamShares(db)
if err != nil {
return nil, err
}
for i := range out {
out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID)
}
return out, nil
}
// loadEntryTeamShares 读全部报名的份额(联队配置),按 entry_id 归组。
// 与 HTTP 侧的 loadEntryTeamSharesQ 同一份表、同一套排序:展示与流水顺序要稳定。
//
// 表不在(库还没跑到 0024)时返回空:没有份额表就没有联队配置,主单位独占 100% 正是老库
// 本来就该得到的结果,不是容错降级(见 httpapi.entryTeamSharesReady)。
func loadEntryTeamShares(db *sql.DB) (map[int64][]domain.TeamShare, error) {
var n int
if err := db.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return nil, fmt.Errorf("export: 查询报名份额表: %w", err)
}
if n == 0 {
return nil, nil
}
rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share
FROM entry_team_shares
ORDER BY entry_id, sort_order, team_id`)
if err != nil {
return nil, fmt.Errorf("export: 读取报名份额: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, fmt.Errorf("export: 扫描报名份额: %w", err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
return out, rows.Err()
}
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
@@ -372,13 +431,18 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
}
sheets := make([]EventSheet, 0, len(events))
// 联队配置读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。
joints, err := loadJointTeams(db)
if err != nil {
return EventsWorkbook{}, err
}
for _, ev := range events {
cats, err := loadCategories(db, ev.id)
if err != nil {
return EventsWorkbook{}, err
}
if len(cats) == 0 {
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts)
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts, joints)
if err != nil {
return EventsWorkbook{}, err
}
@@ -386,7 +450,7 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook,
continue
}
for _, cat := range cats {
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts)
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts, joints)
if err != nil {
return EventsWorkbook{}, err
}
@@ -465,7 +529,7 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
@@ -484,7 +548,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, ''),
r.result_value IS NOT NULL,
COALESCE(gs.required, 0), COALESCE(gs.published, 0)
COALESCE(gs.required, 0), COALESCE(gs.published, 0), en.id
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
@@ -535,16 +599,23 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
resultNote string
hasValue int
required, published int
entryID int64
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote, &hasValue, &required, &published); err != nil {
&entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantName(athleteName, memberJSON)
row.Rank = rankLabel(rank, isTie != 0)
row.Status = statusLabel(resultStat, resultState, entryStat)
row.Remark = joinRemark(entryRemark, resultNote)
// 联队:单位列写全成员单位(「第一分公司+第二分公司联队」),备注里写清分给了谁、各多少。
// 普通报名(含迁移回填的单条独占份额)不走这里,单位名与备注与改动前逐字一致。
if joint, ok := joints[entryID]; ok {
row.TeamName = joint.Name()
row.Remark = appendNote(row.Remark, joint.shareNote())
}
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
row.NoResult = !resultState.Valid
@@ -585,7 +656,11 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID)
joints, err := loadJointTeams(db)
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID, joints)
if err != nil {
return TeamDetailWorkbook{}, err
}
@@ -638,7 +713,7 @@ const teamDetailRankExpr = `COALESCE((
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 loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([]TeamDetailRow, error) {
// 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空,
// 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定;
// 库里没把它落到流水上,只能回推:单位在本小项只参加了一个组别时,那个组别就是答案(ac 子查询)。
@@ -691,6 +766,11 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
// 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。
r.ResultText = displayResult(resultText, unit)
r.Source = sourceLabel(source)
// 这条流水来自联队时说明份额:本单位拿到的是拆给它的那一份(积分列已经是拆后的值),
// 备注里再把「分给了哪几个单位、各多少」一次写全,核对时不必回头查另一张表。
if joint, ok := joints[entryID]; ok {
r.Remark = appendNote(r.Remark, joint.shareNote())
}
r.individual = athleteID != 0
keys = append(keys, revisions.KeyFor(source, eventID, entryID))
out = append(out, r)
+41 -9
View File
@@ -32,6 +32,10 @@ type MedalDetailRow struct {
Medal string
Participant string
// jointNote 是联队奖牌的展示值(非空时覆盖 Medal 列):「金牌(联队 A+B,各单位各记 1 枚)」。
// 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。
jointNote string
// 排序键,只在本包内用,不导出。
sportSort int
eventSort int
@@ -59,7 +63,9 @@ var medalDetailColumns = []column{
{"小项", 18, kindText},
{"组别", 14, kindText},
{"名次", 8, kindCenter},
{"奖牌", 8, kindCenter},
// 奖牌列不是窄的居中列:联队奖牌要把「各单位各记 1 枚」写在奖牌格子里(口径 3),
// 窄列 + 居中 + 不换行会被右边非空的「运动员/队」裁掉,那句话就白写了。
{"奖牌", 26, kindText},
// 与分项成绩表的「运动员/队」同宽:奖牌明细同样会印接力队名单。
{"运动员/队", 24, kindText},
}
@@ -153,7 +159,11 @@ func medalRow(r MedalDetailRow) []any {
if r.Rank > 0 {
rank = strconv.Itoa(r.Rank)
}
return []any{r.SportName, r.EventName, r.Category, rank, r.Medal, r.Participant}
medal := any(r.Medal)
if r.jointNote != "" {
medal = r.jointNote
}
return []any{r.SportName, r.EventName, r.Category, rank, medal, r.Participant}
}
// LoadMedalDetailWorkbook 读取全部奖牌来源。
@@ -168,6 +178,14 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
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 {
@@ -197,7 +215,7 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
sheets := make([]MedalDetailSheet, 0, len(teams))
for _, t := range teams {
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx)
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints)
if len(rows) == 0 {
continue
}
@@ -208,10 +226,13 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
// medalHit 是一枚奖牌:来自哪个小项 / 组别的哪条成绩、什么名次、什么牌。
// 聚合名次与团体手工名次没有单条成绩(resultID 为 0),小项 + 组别是展示字段的兜底来源。
// entryID 是这条名次对应的报名项:联队份额挂在报名的,联队奖牌的那句说明只能靠它查到
// (球类现场的名次正是手工按单位录的,此时 resultID 为空,只有 entryID 认得出来)。
type medalHit struct {
eventID int64
categoryID int64
resultID int64
entryID int64
rank int
medal string
}
@@ -235,15 +256,18 @@ func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medal
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, rank: 1, medal: medalGold})
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, rank: 2, medal: medalSilver})
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, rank: 3, medal: medalBronze})
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID,
entryID: rr.EntryID, rank: 3, medal: medalBronze})
}
}
}
@@ -385,7 +409,7 @@ func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext) []MedalDetailRow {
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam) []MedalDetailRow {
list := hits[teamID]
rows := make([]MedalDetailRow, 0, len(list))
for _, h := range list {
@@ -393,7 +417,7 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
if !ok {
c = eventCtx[eventCategoryKey{eventID: h.eventID, categoryID: h.categoryID}]
}
rows = append(rows, MedalDetailRow{
row := MedalDetailRow{
SportName: c.sportName,
EventName: c.eventName,
Category: c.category,
@@ -406,7 +430,15 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda
eventID: c.eventID,
categoryID: c.categoryID,
resultID: h.resultID,
})
}
// 联队奖牌:把成员单位与「各单位各记 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]
+3
View File
@@ -343,6 +343,9 @@ func metaSubtitle(m Meta) string {
if m.GeneratedBy != "" {
parts = append(parts, "导出人 "+m.GeneratedBy)
}
if m.Note != "" {
parts = append(parts, m.Note)
}
return strings.Join(parts, " · ")
}
+7 -1
View File
@@ -126,9 +126,15 @@ func LoadAllTeamDetailsWorkbook(db *sql.DB, opts Options) (AllTeamDetailsWorkboo
}
orderTeamsByStandings(teams, order)
// 联队配置读一次给全部单位分表复用(几个单位就查几遍纯属浪费)。
joints, err := loadJointTeams(db)
if err != nil {
return AllTeamDetailsWorkbook{}, err
}
sheets := make([]TeamDetailSheet, 0, len(teams))
for _, t := range teams {
rows, err := loadTeamDetailRows(db, t.ID)
rows, err := loadTeamDetailRows(db, t.ID, joints)
if err != nil {
return AllTeamDetailsWorkbook{}, err
}