feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名

口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存

服务端:
- 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team;
  events 加 avg_mode(默认 valid)
- 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team →
  默认 athlete(result, 不计入单位) + team(aggregate 队内平均)
- 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名
  (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与)

前端:
- 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」
- 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存
- 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致)
- 大屏/管理端团体成绩只显示数值,不出现「平均」字样

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 12:02:00 +08:00
1 parent 592e47aef7
commit 6bd7bd1627
47 files changed
+1770 -153

No files matched your search

@@ -0,0 +1,53 @@
-- 「个人+团体」(individual_team)双计需要的三处结构:
-- 1) score_ledger 加 count_to_team:个人通道的流水只记个人,不进单位总分;
-- 2) event_score_channels 加同名字段:按通道决定它产出的流水算不算单位分;
-- 3) events 加 avg_mode:团体成绩按成员成绩取平均时的分母口径。
--
-- score_ledger 没有子表,重建最省事(写法与 0012 一致,既有索引一并保留);
-- score_ledger 自身引用 teams/athletes/events/entries/results,删除旧表时
-- 需要 defer_foreign_keys 把校验推迟到事务提交,避免重建期间误报。
PRAGMA defer_foreign_keys = ON;
CREATE TABLE score_ledger_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL REFERENCES teams(id),
athlete_id INTEGER REFERENCES athletes(id),
event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
result_id INTEGER REFERENCES results(id) ON DELETE SET NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('result', 'record_bonus', 'adjustment')),
source_id INTEGER NOT NULL,
points REAL NOT NULL,
count_to_team INTEGER NOT NULL DEFAULT 1 CHECK (count_to_team IN (0, 1)),
voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
voided_by INTEGER,
voided_at TEXT,
void_reason TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 历史流水一律计入单位总分,保持升级前后榜单一分不差。
INSERT INTO score_ledger_new (id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team, voided, voided_by,
voided_at, void_reason, created_by, created_at)
SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, 1, voided, voided_by,
voided_at, void_reason, created_by, created_at
FROM score_ledger;
DROP TABLE score_ledger;
ALTER TABLE score_ledger_new RENAME TO score_ledger;
CREATE INDEX IF NOT EXISTS idx_score_ledger_team_voided ON score_ledger(team_id, voided);
CREATE INDEX IF NOT EXISTS idx_score_ledger_source ON score_ledger(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_score_ledger_athlete ON score_ledger(athlete_id, voided);
CREATE INDEX IF NOT EXISTS idx_score_ledger_entry_voided ON score_ledger(entry_id, voided);
-- 通道开关与平均值口径都是加列,不涉及既有数据,ADD COLUMN 即可
-- (SQLite 允许列级 CHECK,且不回查历史行,旧行取默认值)。
ALTER TABLE event_score_channels ADD COLUMN count_to_team INTEGER NOT NULL DEFAULT 1
CHECK (count_to_team IN (0, 1));
ALTER TABLE events ADD COLUMN avg_mode TEXT NOT NULL DEFAULT 'valid'
CHECK (avg_mode IN ('valid', 'all'));
+214 -6
View File
@@ -13,8 +13,9 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
// 0016 迁移:events.event_type 增加 individual_team。events 被多张子表以级联/置空引用,
// 迁移用 writable_schema 就地改写建表语句,本文件的用例守住「数据不丢 + 新 CHECK 生效」。
// 迁移用例:0016 给 events.event_type 增加 individual_team(writable_schema 就地改写建表语句),
// 0017 给 score_ledger / event_score_channels 加 count_to_team、给 events 加 avg_mode。
// 两个用例都守住同一条底线:老库升级后数据一行不少、新定义与默认值生效。
func openEventDB(t *testing.T) *sql.DB {
t.Helper()
@@ -35,7 +36,7 @@ func openEventDB(t *testing.T) *sql.DB {
return db
}
// applyBefore 执行 0016 之前的迁移并登记到 schema_migrations,
// applyBefore 执行给定迁移之前的全部迁移并登记到 schema_migrations,
// 模拟老库在升级前的状态(store.MigrateEvent 只能补跑未登记的迁移)。
func applyBefore(t *testing.T, db *sql.DB, name string) {
t.Helper()
@@ -141,12 +142,12 @@ func TestMigration0016PreservesExistingRows(t *testing.T) {
}
}
// 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016。
// 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016(及其后的迁移)。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级迁移: %v", err)
}
if got := countRows(t, db, "schema_migrations"); got != 16 {
t.Errorf("迁移登记数 = %d,期望 16", got)
if got := countRows(t, db, "schema_migrations"); got != 17 {
t.Errorf("迁移登记数 = %d,期望 17", got)
}
for table, want := range map[string]int{
@@ -237,3 +238,210 @@ func TestMigration0016PreservesExistingRows(t *testing.T) {
t.Error("迁移后存在外键悬挂")
}
}
// ---------------------------------------------------------------------------
// 0017 迁移:score_ledger 加 count_to_team(重建表)、event_score_channels 加同名字段、
// events 加 avg_mode。本文件的用例守住「老数据不丢 + 默认值正确 + 新 CHECK 生效」。
// ---------------------------------------------------------------------------
// 全量迁移(run 两次验幂等)后,三张表都应按新定义读写。
func TestMigrate0017AppliesCountToTeamAndAvgMode(t *testing.T) {
db := openEventDB(t)
for i := 1; i <= 2; i++ {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("第 %d 次迁移: %v", i, err)
}
}
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E1', '团体引体向上', 'individual_team')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 未指定时取默认值:通道计入单位总分、平均值口径只算有效成绩。
var (
countToTeam int
avgMode string
)
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
t.Fatalf("读通道: %v", err)
}
if countToTeam != 1 {
t.Errorf("通道 count_to_team 默认值 = %d,期望 1", countToTeam)
}
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
t.Fatalf("读小项: %v", err)
}
if avgMode != "valid" {
t.Errorf("avg_mode 默认值 = %q,期望 valid", avgMode)
}
// 显式写:个人通道不计入单位总分。
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("改通道开关: %v", err)
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode)
VALUES (1, 'E2', '团体引体向上', 'individual_team', 'all')`); err != nil {
t.Fatalf("写入 avg_mode=all: %v", err)
}
// 非法取值被 CHECK 挡住。
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 2 WHERE event_id = 1`); err == nil {
t.Error("count_to_team=2 应被拒绝")
}
if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil {
t.Error("avg_mode='bogus' 应被拒绝")
}
// 重建 score_ledger 后:默认计入、单条可置 0、CHECK 生效。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
VALUES (1, 1, 'result', 1, 10)`); err != nil {
t.Fatalf("写入流水: %v", err)
}
var ledgerCount int
if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE id = 1`).Scan(&ledgerCount); err != nil {
t.Fatalf("读流水: %v", err)
}
if ledgerCount != 1 {
t.Errorf("流水 count_to_team 默认值 = %d,期望 1", ledgerCount)
}
if _, err := db.Exec(`UPDATE score_ledger SET count_to_team = 2 WHERE id = 1`); err == nil {
t.Error("流水 count_to_team=2 应被拒绝")
}
}
// 老库升级:0016 之前的赛事库补跑 0016 + 0017,行数与索引一个不少,新列取默认值。
func TestMigration0017PreservesExistingRows(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0017")
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
`INSERT INTO score_ledger(team_id, source_type, source_id, points, void_reason)
VALUES (1, 'adjustment', 1, -3, '人工扣分')`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// 走真实升级路径:只补跑未登记的 0016、0017。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级迁移: %v", err)
}
if got := countRows(t, db, "schema_migrations"); got != 17 {
t.Errorf("迁移登记数 = %d,期望 17", got)
}
for table, want := range map[string]int{
"events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1,
"results": 1, "score_ledger": 2,
} {
if got := countRows(t, db, table); got != want {
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
}
}
// 历史流水原样保留(含分值、作废原因),新列一律为「计入单位总分」。
rows, err := db.Query(`SELECT id, points, count_to_team, COALESCE(void_reason, '') FROM score_ledger ORDER BY id`)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
wantPoints := []float64{10, -3}
i := 0
for rows.Next() {
var (
id int64
points float64
countToTeam int
reason string
)
if err := rows.Scan(&id, &points, &countToTeam, &reason); err != nil {
t.Fatalf("扫描流水: %v", err)
}
if i >= len(wantPoints) || points != wantPoints[i] {
t.Fatalf("第 %d 条流水分值 = %v,期望 %v", i, points, wantPoints[i])
}
if countToTeam != 1 {
t.Errorf("第 %d 条流水 count_to_team = %d,期望 1", i, countToTeam)
}
i++
}
if i != len(wantPoints) {
t.Fatalf("流水条数 = %d,期望 %d", i, len(wantPoints))
}
// 自增序列延续,避免重建后新流水撞号。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (1, 'adjustment', 9, 2)`); err != nil {
t.Fatalf("重建后写入流水: %v", err)
}
var newLedgerID int64
if err := db.QueryRow(`SELECT max(id) FROM score_ledger`).Scan(&newLedgerID); err != nil {
t.Fatalf("读新流水 id: %v", err)
}
if newLedgerID <= int64(len(wantPoints)) {
t.Errorf("score_ledger 自增序列未延续,新 id = %d", newLedgerID)
}
// 通道与旧小项取默认值。
var countToTeam int
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
t.Fatalf("读通道: %v", err)
}
if countToTeam != 1 {
t.Errorf("老通道 count_to_team = %d,期望 1", countToTeam)
}
var avgMode string
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
t.Fatalf("读小项: %v", err)
}
if avgMode != "valid" {
t.Errorf("老小项 avg_mode = %q,期望 valid", avgMode)
}
// 重建表后索引一个不少(0012 补的 (entry_id, voided) 也在内)。
var indexCount int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
('idx_score_ledger_team_voided', 'idx_score_ledger_source',
'idx_score_ledger_athlete', 'idx_score_ledger_entry_voided')`).Scan(&indexCount); err != nil {
t.Fatalf("查索引: %v", err)
}
if indexCount != 4 {
t.Errorf("score_ledger 索引缺失,命中 %d 个", indexCount)
}
// 外键仍然生效,且没有悬挂。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
VALUES (999, 'adjustment', 1, 1)`); err == nil {
t.Error("重建后 score_ledger.team_id 外键应仍然生效")
}
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
if err != nil {
t.Fatalf("外键检查: %v", err)
}
defer func() { _ = fkRows.Close() }()
if fkRows.Next() {
t.Error("迁移后存在外键悬挂")
}
}