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:
1 parent
592e47aef7
commit
6bd7bd1627
47 files changed
+1770
-153
No files matched your search
@@ -6,13 +6,14 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// ---------- events ----------
|
||||
|
||||
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark`
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
|
||||
|
||||
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
||||
var (
|
||||
@@ -21,7 +22,7 @@ func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
||||
)
|
||||
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
|
||||
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e)
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &e.AvgMode)
|
||||
e.ScheduledAt = strPtr(scheduledAt)
|
||||
e.Venue = strPtr(venue)
|
||||
e.Remark = strPtr(note)
|
||||
@@ -88,6 +89,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||||
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
|
||||
}
|
||||
avgMode, err := normalizeAvgMode(in.AvgMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
@@ -96,13 +101,13 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
|
||||
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark))
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -127,6 +132,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
}
|
||||
|
||||
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
|
||||
avgMode, err := normalizeAvgMode(in.AvgMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
@@ -136,13 +145,13 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
|
||||
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
|
||||
WHERE id = ?`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"),
|
||||
orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"),
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"),
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id)
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -211,6 +220,18 @@ func multiplierOrOne(v float64) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
|
||||
func normalizeAvgMode(v string) (string, error) {
|
||||
switch v {
|
||||
case "":
|
||||
return string(domain.AvgModeValid), nil
|
||||
case string(domain.AvgModeValid), string(domain.AvgModeAll):
|
||||
return v, nil
|
||||
default:
|
||||
return "", badRequest("avgMode 只能是 valid / all", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- categories ----------
|
||||
|
||||
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
|
||||
@@ -289,7 +310,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
|
||||
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -298,15 +319,25 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
|
||||
|
||||
out := make([]channelDTO, 0)
|
||||
for rows.Next() {
|
||||
var c channelDTO
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
|
||||
var (
|
||||
c channelDTO
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
c.CountToTeam = boolPtr(countToTeam != 0)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// channelCountsToTeam 读请求体里的通道开关:缺省(null)按 true 处理,
|
||||
// 保证不带该字段的旧客户端保存通道时不会把单位总分关掉。
|
||||
func channelCountsToTeam(c channelDTO) bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -330,9 +361,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
return nil, badRequest("通道 profileId 必填", i)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil {
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, conflict("同一 scope+basis 只能配置一个通道")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user