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

+8 -6
View File
@@ -176,7 +176,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status,
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '')
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, ''), avg_mode
FROM events
ORDER BY sort_order, id`)
if err != nil {
@@ -198,7 +198,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
if err := rows.Scan(
&e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare,
&recordEligible, &scored, &e.Multiplier, &status,
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark,
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark, &e.AvgMode,
); err != nil {
return fmt.Errorf("解析小项: %w", err)
}
@@ -259,7 +259,7 @@ func splitTieBreak(raw string) []string {
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points, count_to_team
FROM score_ledger WHERE voided = 0 ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("读取积分流水: %w", err)
@@ -269,13 +269,15 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
var out []domain.LedgerEntry
for rows.Next() {
var (
e domain.LedgerEntry
sourceType string
e domain.LedgerEntry
sourceType string
countToTeam int
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil {
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points, &countToTeam); err != nil {
return nil, fmt.Errorf("解析积分流水: %w", err)
}
e.SourceType = domain.SourceType(sourceType)
e.CountToTeam = countToTeam != 0
out = append(out, e)
}
return out, rows.Err()