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 张逐字节未变)。
This commit is contained in:
1 parent
b631418a85
commit
830a4dbdb7
61 files changed
+5649
-121
No files matched your search
@@ -39,8 +39,11 @@ type EventScore struct {
|
||||
|
||||
// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。
|
||||
// Points 尚未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
|
||||
// Shares 是该名次的归属单位;当前只有「整体归主单位」一种口径(联队未实现),
|
||||
// 保留份额结构是因为导出 / 实时快照等消费方按份额遍历。
|
||||
//
|
||||
// Shares 是该名次的归属单位份额:报名项配了联队就是多条(如 A、B 各半),否则是主单位独占
|
||||
// 一条;积分按 Weight 拆、奖牌按 CountsMedal 整枚各记(见 TeamShare)。名次本身只有一条,
|
||||
// 分数与奖牌的下发(拆成几份、记给谁)全部由 Shares 决定——流水侧在 ledgerFromRanked 拆成多行,
|
||||
// 奖牌侧在 standings.teamMedals 逐份额记牌。
|
||||
type RankedResult struct {
|
||||
EntryID int64
|
||||
ResultID int64
|
||||
@@ -295,6 +298,13 @@ func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidat
|
||||
if taken[or.teamID] {
|
||||
continue
|
||||
}
|
||||
// 联队:该主单位在本组别报了联队(团体行 + 多单位份额)时,这条按单位录的名次就是
|
||||
// 联队的名次,份额取自那条报名——否则 A+B 拿到的名次会整份记在 A 头上,B 一分没有。
|
||||
// 没有联队报名(老数据、普通报名)时仍走下面的合成报名项,名次主体照样是单位。
|
||||
if joint, ok := jointTeamEntry(in, or.teamID); ok {
|
||||
out = append(out, candidate{entry: joint, rankIn: or.rank})
|
||||
continue
|
||||
}
|
||||
// 合成报名项:ID=0 表示「不对应任何一条报名项」,团体名次的主体是单位。
|
||||
out = append(out, candidate{
|
||||
entry: Entry{TeamID: or.teamID},
|
||||
@@ -304,6 +314,48 @@ func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidat
|
||||
return out
|
||||
}
|
||||
|
||||
// jointTeamEntry 找「主单位 teamID 在本组别的联队报名」:团体行(athlete_id=0)+ 配了多个单位的份额。
|
||||
//
|
||||
// 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上,
|
||||
// 所以录名次时得回头找这条报名。找不到(含只配了一个单位的普通报名)就返回 false,
|
||||
// 调用方退回「主单位独占 100%」的合成报名项。
|
||||
//
|
||||
// 为什么把「只有一个单位」也排除:那样与独占 100% 等价,却会把名次的身份从单位(subject_key
|
||||
// `t:<id>`)换成报名项(`e:<id>`),channel_ranks 的既有行会跟着变——老数据必须逐字节不变。
|
||||
//
|
||||
// 同一个组别同一主单位最多只有一条团体行(entries 的唯一索引兜着),真出现多条且份额不一致时
|
||||
// 退回独占:宁可少拆,也不能随便挑一条把分拆错。
|
||||
func jointTeamEntry(in EventScoreInput, teamID int64) (Entry, bool) {
|
||||
var found Entry
|
||||
ok := false
|
||||
for _, e := range in.Entries {
|
||||
if e.AthleteID != 0 || e.TeamID != teamID || !entryMatches(in, e) || !e.Status.rankable() {
|
||||
continue
|
||||
}
|
||||
if len(e.TeamShares) < 2 {
|
||||
continue
|
||||
}
|
||||
if ok && !sameShares(found.TeamShares, e.TeamShares) {
|
||||
return Entry{}, false
|
||||
}
|
||||
found, ok = e, true
|
||||
}
|
||||
return found, ok
|
||||
}
|
||||
|
||||
// sameShares 判断两组份额是否一致(单位集合与各自权重、奖牌开关都比)。
|
||||
func sameShares(a, b []TeamShare) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].TeamID != b[i].TeamID || !nearlyEqual(a[i].Weight, b[i].Weight) || a[i].CountsMedal() != b[i].CountsMedal() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
cands, err := collectCandidates(in, ch)
|
||||
if err != nil {
|
||||
@@ -449,7 +501,7 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
AthleteID: c.entry.AthleteID,
|
||||
AthleteName: c.entry.AthleteName,
|
||||
TeamID: c.entry.primaryTeamID(),
|
||||
Shares: wholeShare(c.entry.primaryTeamID()),
|
||||
Shares: ResolveShares(c.entry.TeamShares, c.entry.primaryTeamID()),
|
||||
Rank: rank,
|
||||
Points: points,
|
||||
Status: c.result.Status,
|
||||
@@ -573,6 +625,10 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
Rank: g.startRank,
|
||||
Points: pts,
|
||||
}
|
||||
// 聚合名次不对应任何一条报名项(EntryID=0,主体是单位或运动员),读取侧
|
||||
// (channel_ranks → buildRanked)没有报名项可以回查份额,只能按主体单位独占;
|
||||
// 引擎必须同口径,否则「流水按份额拆了、奖牌榜还按单位独占」,两处对不上。
|
||||
// 因此联队 + 聚合通道不拆份额(已记进 devdocs/19 的未决项:聚合按主单位分桶)。
|
||||
if ch.Scope == ScopeTeam {
|
||||
rr.TeamID = c.subject
|
||||
rr.Shares = wholeShare(c.subject)
|
||||
@@ -649,9 +705,9 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
if sourceID == 0 {
|
||||
sourceID = ch.ID
|
||||
}
|
||||
for _, s := range rr.Shares {
|
||||
pts := rr.Points * s.Weight
|
||||
if pts == 0 {
|
||||
split := splitSharePoints(rr.Points, rr.Shares)
|
||||
for i, s := range rr.Shares {
|
||||
if split[i] == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, LedgerEntry{
|
||||
@@ -664,7 +720,7 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
ChannelID: ch.ID,
|
||||
SourceType: SourceResult,
|
||||
SourceID: sourceID,
|
||||
Points: pts,
|
||||
Points: split[i],
|
||||
CountToTeam: ch.countToTeam(),
|
||||
})
|
||||
}
|
||||
@@ -688,10 +744,60 @@ func subjectKeyOf(rr RankedResult) string {
|
||||
}
|
||||
}
|
||||
|
||||
// wholeShare 构造「主单位独占 100%」的归属份额。联队拆分未实现,这里只产出这一种口径。
|
||||
// ResolveShares 把「报名项配的份额」解析成最终归属份额:配了(联队)就用配置,
|
||||
// 没配就是主单位独占 100%,与升级前逐字段一致。读侧(channel_ranks → RankedResult)
|
||||
// 与引擎共用它,避免两边各写一份「没有份额时怎么办」。
|
||||
func ResolveShares(configured []TeamShare, teamID int64) []TeamShare {
|
||||
if len(configured) > 0 {
|
||||
return configured
|
||||
}
|
||||
return wholeShare(teamID)
|
||||
}
|
||||
|
||||
// wholeShare 构造「主单位独占 100%」的归属份额:没有联队配置时的唯一口径。
|
||||
func wholeShare(teamID int64) []TeamShare {
|
||||
if teamID == 0 {
|
||||
return nil
|
||||
}
|
||||
return []TeamShare{{TeamID: teamID, Weight: 1}}
|
||||
}
|
||||
|
||||
// splitSharePoints 把一条成绩的积分按份额拆给各成员单位,返回值与 shares 一一对应。
|
||||
//
|
||||
// 权重是相对值(默认各 1 = 均分),先按权重之和归一:联队的两个单位各 1 份时总分照旧,
|
||||
// 2:1 时也拆成两单位之和等于原来那一份分——**份额只改「分记在谁头上」,不改变总分**。
|
||||
// 没有配份额(老数据、普通报名)时是单条独占:total·1/1,结果与升级前逐位相同。
|
||||
//
|
||||
// 最后一份取「总分减去前面各份」的余数,而不是再算一次乘法:2:1 这种除不尽的权重
|
||||
// 各份直接算会剩下 1e-15 级零头,榜单上就会印出 9.999999999999998 这种数。
|
||||
//
|
||||
// 权重 <= 0 按 1 处理:库里有 CHECK(points_share > 0) 兜着,这里是引擎侧的兜底,
|
||||
// 与其它地方的「零值按 1」口径(multiplier / effectiveWeight)一致。
|
||||
func splitSharePoints(total float64, shares []TeamShare) []float64 {
|
||||
out := make([]float64, len(shares))
|
||||
if len(shares) == 0 {
|
||||
return out
|
||||
}
|
||||
sum := 0.0
|
||||
for _, s := range shares {
|
||||
sum += shareWeight(s)
|
||||
}
|
||||
acc := 0.0
|
||||
for i, s := range shares {
|
||||
if i == len(shares)-1 {
|
||||
out[i] = total - acc
|
||||
break
|
||||
}
|
||||
out[i] = total * shareWeight(s) / sum
|
||||
acc += out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shareWeight 取份额的积分权重,零值与负值按 1 处理(见 splitSharePoints)。
|
||||
func shareWeight(s TeamShare) float64 {
|
||||
if s.Weight <= 0 {
|
||||
return 1
|
||||
}
|
||||
return s.Weight
|
||||
}
|
||||
Reference in new issue
Block a user