Files
Scoreboard/server/internal/export/joint_team.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

261 lines
10 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"
"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
}