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
@@ -54,6 +54,9 @@ type channelDTO struct {
|
||||
Basis string `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
|
||||
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
|
||||
CountToTeam *bool `json:"countToTeam"`
|
||||
}
|
||||
|
||||
type eventDTO struct {
|
||||
@@ -73,6 +76,7 @@ type eventDTO struct {
|
||||
Venue *string `json:"venue"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Remark *string `json:"remark"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
Categories []categoryDTO `json:"categories,omitempty"`
|
||||
Channels []channelDTO `json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 「个人+团体」的算分口径(REST 端到端):
|
||||
// - 个人通道按个人成绩排名,分只记在个人头上,不进单位总分;
|
||||
// - 团体通道把同单位成员成绩取平均后排名,这部分才进单位总分。
|
||||
func TestIndividualTeamDoubleCountStandings(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")
|
||||
|
||||
// 换成「个人+团体」:成绩越大越好,团体成绩按成员平均。
|
||||
env.exec(t, `UPDATE events SET event_type = 'individual_team', result_type = 'count',
|
||||
compare = 'desc', avg_mode = 'valid' WHERE id = ?`, ids.event)
|
||||
|
||||
var individualProfile, teamProfile int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&individualProfile); err != nil {
|
||||
t.Fatalf("读取个人模板: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
|
||||
t.Fatalf("读取团体模板: %v", err)
|
||||
}
|
||||
|
||||
// 请求体里个人通道显式关闭 countToTeam,团体通道不写该字段(缺省计入)。
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
|
||||
map[string]any{"channels": []map[string]any{
|
||||
{"scope": "athlete", "basis": "result", "profileId": individualProfile, "sortOrder": 0, "countToTeam": false},
|
||||
{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1},
|
||||
}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("保存通道应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/channels", ids.event), nil, true)
|
||||
channels := decodeData[[]map[string]any](t, env2)
|
||||
if len(channels) != 2 {
|
||||
t.Fatalf("通道数 = %d,期望 2", len(channels))
|
||||
}
|
||||
if got := channels[0]["countToTeam"]; got != false {
|
||||
t.Fatalf("个人通道 countToTeam = %v,期望 false", got)
|
||||
}
|
||||
if got := channels[1]["countToTeam"]; got != true {
|
||||
t.Fatalf("缺省通道 countToTeam = %v,期望 true", got)
|
||||
}
|
||||
|
||||
// 单位 A 一人 10,单位 B 一人 20:个人通道 8/10,团体通道 10/15。
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 10, "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 20, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
points := map[string]float64{}
|
||||
for _, row := range decodeData[[]map[string]any](t, env2) {
|
||||
points[row["teamCode"].(string)] = row["totalPoints"].(float64)
|
||||
}
|
||||
// 只算团体通道:A 第 2 名 10 分,B 第 1 名 15 分。
|
||||
if points["A"] != 10 || points["B"] != 15 {
|
||||
t.Fatalf("单位总分 = %v,期望 A:10 / B:15(个人通道不计入)", points)
|
||||
}
|
||||
|
||||
// 个人榜照常保留个人通道的分:B(20)10 分排第一,A(10)8 分排第二。
|
||||
_, env2 = env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
|
||||
athletes := decodeData[[]map[string]any](t, env2)
|
||||
if len(athletes) != 2 {
|
||||
t.Fatalf("个人榜行数 = %d,期望 2", len(athletes))
|
||||
}
|
||||
if got := athletes[0]["totalPoints"].(float64); got != 10 {
|
||||
t.Fatalf("个人榜榜首得分 = %v,期望 10", got)
|
||||
}
|
||||
if got := athletes[1]["totalPoints"].(float64); got != 8 {
|
||||
t.Fatalf("个人榜第二名得分 = %v,期望 8(个人通道的分保留)", got)
|
||||
}
|
||||
|
||||
// 单位积分明细与总积分榜同口径:不计入单位总分的流水不出现在明细里。
|
||||
_, env2 = env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
||||
breakdown := decodeData[map[string]any](t, env2)
|
||||
if got := breakdown["totalPoints"].(float64); got != 10 {
|
||||
t.Fatalf("单位 A 明细合计 = %v,期望 10(与总积分榜一致)", got)
|
||||
}
|
||||
items := breakdown["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("单位 A 明细行数 = %d,期望 1(只有团体通道的流水)", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// avg_mode 决定团体平均值的分母:valid 只算有成绩的人,all 把没成绩的人按 0 计入。
|
||||
func TestIndividualTeamAvgModeChangesRanking(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")
|
||||
|
||||
// A 两人(只有一人录成绩 10),B 两人(6 / 7)。
|
||||
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status, avg_mode)
|
||||
VALUES (?, 'E_BOTH', '团体引体向上', 'individual_team', 'auto', 'count',
|
||||
'desc', 1, 1, 'ongoing', 'valid')`, ids.sport)
|
||||
cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, eventID)
|
||||
athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王五', '男', ?, 1)`, ids.teamA)
|
||||
athB2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵六', '男', ?, 1)`, ids.teamB)
|
||||
|
||||
entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, ids.athleteA)
|
||||
env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamA, athA2)
|
||||
entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, ids.athB)
|
||||
entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, eventID, cat, ids.teamB, athB2)
|
||||
|
||||
var teamProfile int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
|
||||
t.Fatalf("读取团体模板: %v", err)
|
||||
}
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'aggregate', ?, 0)`, eventID, teamProfile)
|
||||
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": eventID,
|
||||
"results": []map[string]any{
|
||||
{"entryId": entryA1, "resultValue": 10, "status": "ok"},
|
||||
{"entryId": entryB1, "resultValue": 6, "status": "ok"},
|
||||
{"entryId": entryB2, "resultValue": 7, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
|
||||
standings := func(name string) map[string]float64 {
|
||||
t.Helper()
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": eventID,
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("%s发布应 200,得到 %d %+v", name, status, env2.Error)
|
||||
}
|
||||
_, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
out := map[string]float64{}
|
||||
for _, row := range decodeData[[]map[string]any](t, env2) {
|
||||
out[row["teamCode"].(string)] = row["totalPoints"].(float64)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// valid:A 平均 10/1 = 10,B 平均 6.5 → A 第一 15 分、B 第二 10 分。
|
||||
if got := standings("valid"); got["A"] != 15 || got["B"] != 10 {
|
||||
t.Fatalf("valid 口径总分 = %v,期望 A:15 / B:10", got)
|
||||
}
|
||||
|
||||
// all:A 平均 (10+0)/2 = 5,B 平均 6.5 → B 第一 15 分、A 第二 10 分。
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
|
||||
"sportId": ids.sport, "code": "E_BOTH", "name": "团体引体向上", "eventType": "individual_team",
|
||||
"resultMode": "auto", "resultType": "count", "compare": "desc", "scored": true,
|
||||
"multiplier": 1, "status": "ongoing", "avgMode": "all",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改 avgMode 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := decodeData[map[string]any](t, env2)["avgMode"]; got != "all" {
|
||||
t.Fatalf("改回 avgMode = %v,期望 all", got)
|
||||
}
|
||||
if got := standings("all"); got["A"] != 10 || got["B"] != 15 {
|
||||
t.Fatalf("all 口径总分 = %v,期望 A:10 / B:15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// avgMode 的读写与校验:缺省 valid、非法值 400。
|
||||
func TestEventAvgModeRoundTrip(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")
|
||||
|
||||
// 老客户端不传 avgMode:按 valid 存。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
||||
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["avgMode"] != "valid" {
|
||||
t.Fatalf("缺省 avgMode = %v,期望 valid", created["avgMode"])
|
||||
}
|
||||
eventID := int64(created["id"].(float64))
|
||||
|
||||
_, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
|
||||
if got := decodeData[map[string]any](t, env2); got["avgMode"] != "valid" {
|
||||
t.Fatalf("读回 avgMode = %v,期望 valid", got["avgMode"])
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
|
||||
"sportId": ids.sport, "code": "E_ALL", "name": "双计项", "eventType": "individual_team",
|
||||
"avgMode": "bogus",
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("非法 avgMode 应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ func nullI64(p *int64) any {
|
||||
return *p
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// boolToInt 把布尔写给 SQLite 的 0/1 列。
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullInt(p *int) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
|
||||
@@ -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 只能配置一个通道")
|
||||
}
|
||||
|
||||
@@ -464,7 +464,8 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, err
|
||||
}
|
||||
// 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
|
||||
// 合计必须与总积分榜同口径:只算计入单位总分的流水,
|
||||
// 其中仅记单位的直加,记到个人的乘折算系数。
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, err
|
||||
@@ -482,7 +483,7 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
WHERE l.team_id = ? AND l.voided = 0
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY l.id DESC`, teamID)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, internal(err)
|
||||
|
||||
@@ -263,7 +263,7 @@ func joinAnd(parts []string) string {
|
||||
|
||||
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
|
||||
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
|
||||
source_type, source_id, points, voided, created_at, count_to_team FROM score_ledger WHERE voided = 0`)
|
||||
}
|
||||
|
||||
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
|
||||
@@ -276,15 +276,16 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d
|
||||
out := make([]domain.LedgerEntry, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
l domain.LedgerEntry
|
||||
athlete sql.NullInt64
|
||||
event sql.NullInt64
|
||||
entry sql.NullInt64
|
||||
result sql.NullInt64
|
||||
created sql.NullString
|
||||
l domain.LedgerEntry
|
||||
athlete sql.NullInt64
|
||||
event sql.NullInt64
|
||||
entry sql.NullInt64
|
||||
result sql.NullInt64
|
||||
created sql.NullString
|
||||
countToTeam int
|
||||
)
|
||||
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
|
||||
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil {
|
||||
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created, &countToTeam); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
l.AthleteID = athlete.Int64
|
||||
@@ -292,6 +293,7 @@ func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]d
|
||||
l.EntryID = entry.Int64
|
||||
l.ResultID = result.Int64
|
||||
l.CreatedAt = created.String
|
||||
l.CountToTeam = countToTeam != 0
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -29,10 +29,10 @@ func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, er
|
||||
)
|
||||
err := q.QueryRowContext(ctx,
|
||||
`SELECT 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
|
||||
FROM events WHERE id = ?`, eventID).
|
||||
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, &remark)
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Event{}, notFound("小项不存在")
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
|
||||
|
||||
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
|
||||
rows, err := q.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)
|
||||
@@ -89,10 +89,17 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
|
||||
|
||||
out := make([]domain.ScoreChannel, 0)
|
||||
for rows.Next() {
|
||||
var c domain.ScoreChannel
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
|
||||
var (
|
||||
c domain.ScoreChannel
|
||||
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)
|
||||
}
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。
|
||||
if countToTeam == 0 {
|
||||
c.CountToTeam = boolPtr(false)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -379,10 +386,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
||||
}
|
||||
for _, l := range ledger {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
|
||||
l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil {
|
||||
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
|
||||
return domain.EventScore{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user