Files
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

177 lines
7.0 KiB
Go

package event
import (
"database/sql"
"fmt"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0024:联队 + 份额分成(entry_team_shares)。
//
// 三条底线:
// - 老库升级后每条既有报名都拿到一条「主单位独占 100%」(权重 1、整枚奖牌),
// 于是引擎与榜单对老数据的行为一分不差;
// - 其它行(尤其是 entries.team_id 这个主单位)一行不动,也没有任何报名被删;
// - 幂等:重复执行(含把这条 SQL 的正文重放一遍)不会覆盖已经配好的联队份额。
func TestMigration0024EntryTeamShares(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0024") // 回到「升级前」:库结构到 0023 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`,
`INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (1, 'BALL', '篮球', 'match', 'manual', 'count', 'none', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张伟', '男', 1)`,
// 个人报名(athlete_id 非空)与团体报名(athlete_id 为空)都要被回填。
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, NULL, 'registered')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
// 升级前:这张表还不该存在。
if _, err := db.Exec(`SELECT count(*) FROM entry_team_shares`); err == nil {
t.Fatal("升级前不应存在 entry_team_shares 表")
} else if !strings.Contains(err.Error(), "entry_team_shares") {
t.Fatalf("查询报错应点明缺表 entry_team_shares,实际 %v", err)
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 回填:每条报名一条「主单位独占 100%」,主单位就是 entries.team_id。
rows, err := db.Query(`SELECT e.id, e.team_id, s.team_id, s.points_share, s.medal_share, s.sort_order
FROM entries e JOIN entry_team_shares s ON s.entry_id = e.id
ORDER BY e.id`)
if err != nil {
t.Fatalf("读回填结果: %v", err)
}
defer func() { _ = rows.Close() }()
seen := 0
for rows.Next() {
var (
entryID, primary, shareTeam int64
pointsShare float64
medalShare, sortOrder int
)
if err := rows.Scan(&entryID, &primary, &shareTeam, &pointsShare, &medalShare, &sortOrder); err != nil {
t.Fatalf("扫描回填结果: %v", err)
}
if shareTeam != primary {
t.Errorf("报名 %d 回填出来的份额单位 = %d,期望主单位 %d", entryID, shareTeam, primary)
}
if pointsShare != 1 || medalShare != 1 || sortOrder != 0 {
t.Errorf("报名 %d 的独占份额应为 points_share=1 medal_share=1 sort_order=0,得到 %v/%d/%d",
entryID, pointsShare, medalShare, sortOrder)
}
seen++
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历回填结果: %v", err)
}
if seen != 2 {
t.Fatalf("回填行数 = %d,期望 2(每条既有报名一条)", seen)
}
// entries 一行不少、主单位一列不改(回填只增一张表)。
if got := countRows(t, db, "entries"); got != 2 {
t.Fatalf("升级后报名数 = %d,期望 2", got)
}
if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id NOT IN (SELECT id FROM teams)`); got != 0 {
t.Errorf("条目里出现了不存在的单位: %d 条", got)
}
// 约束:权重必须为正、奖牌份数只有 0/1、同一报名同一单位只能一条。
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, points_share) VALUES (1, 2, 0)`); err == nil {
t.Error("points_share = 0 应被 CHECK 拒绝(份额为零等于没写)")
}
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, medal_share) VALUES (1, 2, 2)`); err == nil {
t.Error("medal_share = 2 应被 CHECK 拒绝(奖牌只有整枚/不记两态)")
}
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id) VALUES (1, 1)`); err == nil {
t.Error("同一报名同一单位重复应被主键拒绝")
}
// 联队:给报名 1 再加一条成员单位 B(这就是升级后写侧要写的东西)。
if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order)
VALUES (1, 2, 1, 1, 1)`); err != nil {
t.Fatalf("写联队份额应成功: %v", err)
}
// 幂等:再跑一次迁移,行数与内容都不变。
before := dumpShares(t, db)
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
if got := dumpShares(t, db); got != before {
t.Fatalf("重复迁移改动了份额:\n之前 %s\n之后 %s", before, got)
}
// 迁移被重放(schema_migrations 少一行)时:只补缺的行,绝不覆盖已配好的联队份额。
if _, err := db.Exec(readMigration(t, "0024")); err != nil {
t.Fatalf("重放 0024: %v", err)
}
if got := dumpShares(t, db); got != before {
t.Fatalf("重放 0024 改动了份额:\n之前 %s\n之后 %s", before, got)
}
// 报名被删时份额跟着走(ON DELETE CASCADE),不留孤儿。
if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil {
t.Fatalf("删报名: %v", err)
}
if got := queryInt(t, db, `SELECT count(*) FROM entry_team_shares WHERE entry_id = 2`); got != 0 {
t.Errorf("报名删掉后份额应级联清空,还剩 %d 条", got)
}
// 外键悬挂检查。
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("迁移后存在外键悬挂")
}
// 迁移登记数对得上(这条迁移被记账一次)。
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
}
}
// dumpShares 把份额表按稳定顺序导成文本。
func dumpShares(t *testing.T, db *sql.DB) string {
t.Helper()
var b strings.Builder
rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share, sort_order
FROM entry_team_shares ORDER BY entry_id, team_id`)
if err != nil {
t.Fatalf("读份额: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
entryID, teamID int64
pointsShare float64
medalShare, sortOrder int
)
if err := rows.Scan(&entryID, &teamID, &pointsShare, &medalShare, &sortOrder); err != nil {
t.Fatalf("扫描份额: %v", err)
}
fmt.Fprintf(&b, "%d:%d:%v:%d:%d\n", entryID, teamID, pointsShare, medalShare, sortOrder)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历份额: %v", err)
}
return b.String()
}