feat: 小项类型加「个人+团体」+ 报名导入拆团体/个人两部分

小项类型:
- 迁移 0016 给 events.event_type 的 CHECK 增加 individual_team。SQLite 改不了 CHECK,
  而 events 被多张子表引用(含 ON DELETE CASCADE/SET NULL),事务内 DROP 重建会级联删数据,
  故用 writable_schema 就地改写建表语句文本(只追加一个枚举值,'match' 在建表语句里仅出现一次,
  无副作用),零数据搬动
- domain 加 EventTypeIndividualTeam;管理端下拉加「个人+团体」并提示会同时走两条通道;
  guards/rules 的 eventType 白名单同步(否则会被降级成 individual)
- 注意:该类型目前只解决「能被选出来」,双计的算分语义(个人分不计入单位、团体按队内平均排名)
  尚未实现,等口径确认后再动数据模型

报名导入:
- 卡片拆成「个人报名 / 团体报名」两个 tab,小项+组别提到 tab 之上共享
- 团体报名 = 从已配置单位清单里多选(默认全不勾,正好表达「部分单位不参加」),
  可逐单位为登记队员(0 人即纯单位报名),一次提交为多个单位各建一条报名
- 已报名单位仍可覆盖名单,但会提示「提交将清空原名单」,且「全选」只勾启用中且未报名的单位

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 117/117;console 16 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 11:19:31 +08:00
1 parent b5f40642ff
commit 592e47aef7
19 files changed
+1318 -117

No files matched your search

+1
View File
@@ -109,6 +109,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
basis := BasisResult
if in.Event.ResultMode == ResultModeManual {
+35
View File
@@ -379,6 +379,41 @@ func TestRankOnlyRespectsResultMode(t *testing.T) {
}
}
// TestIndividualTeamDualChannel 验证「个人+团体」按配置的两条通道双计:
// 同一份成绩同时产出个人通道与团体通道的名次(类型本身不参与计算)。
func TestIndividualTeamDualChannel(t *testing.T) {
entries, results := fixture(lineupValues(11.5, 11.8, 12.0))
ev := individualEvent(1)
ev.EventType = EventTypeIndividualTeam
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
athleteResultChannel(1),
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
byScope := make(map[Scope]map[int64]int, 2)
for _, rr := range es.Ranked {
if byScope[rr.Scope] == nil {
byScope[rr.Scope] = make(map[int64]int, len(es.Ranked))
}
byScope[rr.Scope][rr.EntryID] = rr.Rank
}
for _, scope := range []Scope{ScopeAthlete, ScopeTeam} {
ranks := byScope[scope]
if ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 {
t.Fatalf("%s 通道名次 = %v,期望 1/2/3", scope, ranks)
}
}
if len(es.Ledger) != 6 {
t.Errorf("双通道流水条数 = %d,期望 6(3 条个人 + 3 条团体)", len(es.Ledger))
}
}
// TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。
func TestScoreEventErrors(t *testing.T) {
entries, results := fixture(lineup(1))
+3
View File
@@ -12,6 +12,9 @@ const (
EventTypeIndividual EventType = "individual"
EventTypeTeam EventType = "team"
EventTypeMatch EventType = "match"
// EventTypeIndividualTeam 个人+团体:团体与个人赛事合并的项目,个人榜与团体榜同时记分。
// 双计靠两条计分通道(athlete + team)表达,不在这里做特殊计算。
EventTypeIndividualTeam EventType = "individual_team"
)
// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。
+41
View File
@@ -88,6 +88,47 @@ func TestEventWritesWriteChangeLog(t *testing.T) {
}
}
// 小项类型新增「个人+团体」:REST 写接口要能存下来并原样返回,不被当成未知类型。
func TestEventAcceptsIndividualTeamType(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上",
"eventType": "individual_team", "resultType": "count", "compare": "desc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建「个人+团体」项目应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["eventType"] != "individual_team" {
t.Fatalf("建回的 eventType = %v,期望 individual_team", created["eventType"])
}
eventID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读项目应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2); got["eventType"] != "individual_team" {
t.Fatalf("读回的 eventType = %v,期望 individual_team", got["eventType"])
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
"sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上",
"eventType": "team", "resultType": "count", "compare": "desc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改回团体应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2); got["eventType"] != "team" {
t.Fatalf("改回的 eventType = %v,期望 team", got["eventType"])
}
}
// 证件号是人员的唯一标识:可读写、重复 409、空值不互斥(部分唯一索引)。
func TestAthleteIDCardUnique(t *testing.T) {
env := newTestEnv(t)
+60
View File
@@ -257,3 +257,63 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
}
})
}
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
const individualTeamPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 个人+团体模板 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 10 }
- code: team_top6
name: 团体项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 15 }
channelTemplates:
- match: { eventType: individual_team }
channels:
- { scope: athlete, basis: result, profile: individual_top6 }
- { scope: team, basis: manual, profile: team_top6 }
`
func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
cur := testSnapshot(t,
[]Profile{
{Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 10}}},
{Code: "team_top6", Name: "团体项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 15}}},
},
[]EventInfo{
{ID: 1, Code: "EVT_BOTH", Name: "团体引体向上", EventType: domain.EventTypeIndividualTeam,
ResultType: domain.ResultTypeCount, Scored: true, Multiplier: 1,
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}},
})
next, err := Parse([]byte(individualTeamPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
targets, _, err := resolveTargets(cur, next)
if err != nil {
t.Fatalf("resolveTargets: %v", err)
}
got := targets["EVT_BOTH"]
if len(got) != 2 {
t.Fatalf("individual_team 应展开成两条通道,实际 %+v", got)
}
if got[0].Scope != domain.ScopeAthlete || got[0].Basis != domain.BasisResult || got[0].Profile != "individual_top6" {
t.Errorf("个人通道 = %+v", got[0])
}
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisManual || got[1].Profile != "team_top6" {
t.Errorf("团体通道 = %+v", got[1])
}
}