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:
1 parent
b631418a85
commit
830a4dbdb7
61 files changed
+5649
-121
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, " · ")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user