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:
1 parent
b5f40642ff
commit
592e47aef7
19 files changed
+1318
-117
No files matched your search
@@ -109,6 +109,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
||||
|
||||
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
|
||||
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
|
||||
// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。
|
||||
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
|
||||
basis := BasisResult
|
||||
if in.Event.ResultMode == ResultModeManual {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -12,6 +12,9 @@ const (
|
||||
EventTypeIndividual EventType = "individual"
|
||||
EventTypeTeam EventType = "team"
|
||||
EventTypeMatch EventType = "match"
|
||||
// EventTypeIndividualTeam 个人+团体:团体与个人赛事合并的项目,个人榜与团体榜同时记分。
|
||||
// 双计靠两条计分通道(athlete + team)表达,不在这里做特殊计算。
|
||||
EventTypeIndividualTeam EventType = "individual_team"
|
||||
)
|
||||
|
||||
// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。
|
||||
|
||||
Reference in new issue
Block a user