## 需求
现场 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 张逐字节未变)。
397 lines
19 KiB
Go
397 lines
19 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件钉住「联队 + 份额分成」从导入到榜单的完整链路(devdocs/19):
|
||
// - 报名表 team_code 写 `A+B` → 一条报名挂两个单位(默认均分),主单位是加号左边那个;
|
||
// - 团体第 1 名 15 分 → 两个单位各 7.5 分;金牌两个单位**各记 1 枚**(不是各 0.5 枚);
|
||
// - 权重可配(2:1 → 10 / 5),改完当场重算推榜;
|
||
// - 非法写法(单位不存在 / 缺一段 / 重复 / 太多)必须拦下并给出照得着改的报错。
|
||
|
||
// seedMatchEvent 造一个「球类」小项:match 类型 + 按单位录名次的团体通道
|
||
// (规则包给 match 的模板就是 scope=team + basis=manual),返回小项与组别 id。
|
||
func seedMatchEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) {
|
||
t.Helper()
|
||
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||
compare, scored, multiplier, status)
|
||
VALUES (?, 'BALL', '篮球', 'match', 'manual', 'count', 'none', 1, 1, 'ongoing')`, ids.sport)
|
||
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||
VALUES (?, '男子', 1, 0)`, eventID)
|
||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||
VALUES (?, 'team', 'manual', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
|
||
return eventID, categoryID
|
||
}
|
||
|
||
// jointImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeader 一致。
|
||
func jointImportRow(eventCode, category, teamCode string) string {
|
||
return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/李强"}, ",")
|
||
}
|
||
|
||
// importCSV 上传一份单表 CSV(表头 + 若干行)。
|
||
func importCSV(t *testing.T, env *testEnv, client *http.Client, rows ...string) (int, envelope) {
|
||
t.Helper()
|
||
content := importHeader + "\n" + strings.Join(rows, "\n") + "\n"
|
||
return env.postImport(t, client, "/api/entries/import", content, "")
|
||
}
|
||
|
||
// sharesDump 导出某小项「报名 + 份额」的原始落库事实(列序见下面的查询):
|
||
// share_team 为 "<nil>" 表示这条报名一条份额都没有(不该出现)。
|
||
func sharesDump(t *testing.T, env *testEnv, eventID int64) string {
|
||
t.Helper()
|
||
return tableDump(t, env, fmt.Sprintf(
|
||
`SELECT e.id AS entry_id, COALESCE(e.athlete_id, 0) AS athlete_id, e.team_id,
|
||
COALESCE(s.team_id, 0) AS share_team, COALESCE(s.points_share, 0) AS points_share,
|
||
COALESCE(s.medal_share, 0) AS medal_share, COALESCE(s.sort_order, 0) AS sort_order
|
||
FROM entries e
|
||
LEFT JOIN entry_team_shares s ON s.entry_id = e.id
|
||
WHERE e.event_id = %d
|
||
ORDER BY e.id, s.sort_order, s.team_id`, eventID))
|
||
}
|
||
|
||
// shareRow 按 tableDump 的格式拼一行份额(列名=值,行尾一个空格)。
|
||
func shareRow(entryID, athleteID, teamID, shareTeam int64, weight float64, medal, order int) string {
|
||
return fmt.Sprintf("entry_id=%d athlete_id=%d team_id=%d share_team=%d points_share=%v medal_share=%d sort_order=%d \n",
|
||
entryID, athleteID, teamID, shareTeam, weight, medal, order)
|
||
}
|
||
|
||
// shareAbsentRow 是「这条报名一条份额行都没有」的 dump 行:种子 / 手工造的报名直接写 entries,
|
||
// 绕过了导入,库里就没有份额行——读侧与引擎都按主单位独占 100% 兜底(老数据同理)。
|
||
func shareAbsentRow(entryID, athleteID, teamID int64) string {
|
||
return fmt.Sprintf("entry_id=%d athlete_id=%d team_id=%d share_team=0 points_share=0 medal_share=0 sort_order=0 \n",
|
||
entryID, athleteID, teamID)
|
||
}
|
||
|
||
// firstEntryID 取某小项的第一条报名 id。
|
||
func firstEntryID(t *testing.T, env *testEnv, eventID int64) int64 {
|
||
t.Helper()
|
||
var id int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? ORDER BY id LIMIT 1`, eventID).Scan(&id); err != nil {
|
||
t.Fatalf("读报名项: %v", err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
// standingsRow 取积分榜上某个单位那一行(REST /api/standings 的原始读数)。
|
||
func standingsRow(t *testing.T, env *testEnv, client *http.Client, teamID int64) map[string]any {
|
||
t.Helper()
|
||
status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("读积分榜应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
for _, row := range decodeData[[]map[string]any](t, resp) {
|
||
if row["teamId"] == float64(teamID) {
|
||
return row
|
||
}
|
||
}
|
||
t.Fatalf("积分榜上没有单位 %d", teamID)
|
||
return nil
|
||
}
|
||
|
||
// assertStanding 断言某单位的总分与金银铜。
|
||
func assertStanding(t *testing.T, env *testEnv, client *http.Client, teamID int64, points float64, gold, silver, bronze int) {
|
||
t.Helper()
|
||
row := standingsRow(t, env, client, teamID)
|
||
if got := row["totalPoints"].(float64); got != points {
|
||
t.Errorf("单位 %d 总分 = %v,期望 %v(整行:%+v)", teamID, got, points, row)
|
||
}
|
||
for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} {
|
||
if got := row[name].(float64); got != float64(want) {
|
||
t.Errorf("单位 %d 的 %s = %v,期望 %d(整行:%+v)", teamID, name, got, want, row)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 现场场景:两个单位因人数不足合并参赛,团体第 1 名 15 分 → A、B 各 7.5 分、各 1 枚金牌。
|
||
func TestImportJointTeamSplitsPointsAndMedals(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, categoryID := seedMatchEvent(t, env, ids)
|
||
|
||
status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B"))
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
if result := decodeData[map[string]any](t, resp); result["created"].(float64) != 1 {
|
||
t.Fatalf("应新建 1 条报名,得到 %+v", result)
|
||
}
|
||
|
||
// 主单位是加号左边那个(A),份额表里两条均分、奖牌各记整枚。
|
||
entryID := firstEntryID(t, env, eventID)
|
||
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) +
|
||
shareRow(entryID, 0, ids.teamA, ids.teamB, 1, 1, 1)
|
||
if got := sharesDump(t, env, eventID); got != want {
|
||
t.Fatalf("联队报名落库不对:\n得到 %s期望 %s", got, want)
|
||
}
|
||
|
||
// 团体名次录在主单位上(现场就是按单位录名次):联队第 1 名。
|
||
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
|
||
{"teamId": ids.teamA, "rank": 1},
|
||
}); status != http.StatusOK {
|
||
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
|
||
// 积分各 7.5(允许出现小数),金牌各 1 枚。
|
||
assertStanding(t, env, client, ids.teamA, 7.5, 1, 0, 0)
|
||
assertStanding(t, env, client, ids.teamB, 7.5, 1, 0, 0)
|
||
|
||
ledger := tableDump(t, env, `SELECT team_id, points FROM score_ledger WHERE voided = 0 ORDER BY team_id`)
|
||
wantLedger := fmt.Sprintf("team_id=%d points=7.5 \nteam_id=%d points=7.5 \n", ids.teamA, ids.teamB)
|
||
if ledger != wantLedger {
|
||
t.Fatalf("积分流水应按份额拆成两条:\n得到 %s期望 %s", ledger, wantLedger)
|
||
}
|
||
}
|
||
|
||
// 权重可配:改成 2:1 → 10 / 5;奖牌仍是各 1 枚(两个口径互不影响)。
|
||
func TestUpdateEntryTeamSharesWeights(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, categoryID := seedMatchEvent(t, env, ids)
|
||
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")); status != http.StatusOK {
|
||
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
entryID := firstEntryID(t, env, eventID)
|
||
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
|
||
{"teamId": ids.teamA, "rank": 1},
|
||
}); status != http.StatusOK {
|
||
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
assertStanding(t, env, client, ids.teamA, 7.5, 1, 0, 0)
|
||
|
||
path := fmt.Sprintf("/api/entries/%d/team-shares", entryID)
|
||
status, resp := env.do(client, http.MethodPut, path, map[string]any{
|
||
"teamShares": []map[string]any{
|
||
{"teamId": ids.teamA, "weight": 2},
|
||
{"teamId": ids.teamB, "weight": 1},
|
||
},
|
||
}, true)
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("改份额应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
updated := decodeData[map[string]any](t, resp)
|
||
shares, _ := updated["teamShares"].([]any)
|
||
if len(shares) != 2 {
|
||
t.Fatalf("回显应带两条份额,得到 %+v", updated["teamShares"])
|
||
}
|
||
first, _ := shares[0].(map[string]any)
|
||
if first["teamId"] != float64(ids.teamA) || first["weight"] != float64(2) {
|
||
t.Fatalf("回显的第一条应是主单位 A 权重 2,得到 %+v", first)
|
||
}
|
||
if first["teamCode"] != "A" || first["teamName"] != "第一分公司" {
|
||
t.Fatalf("回显应带上单位编码与名称(前端拼「A+B联队」用),得到 %+v", first)
|
||
}
|
||
|
||
// 改完当场重算:2:1 拆成 10 / 5,奖牌不受权重影响,仍是各 1 枚。
|
||
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
|
||
assertStanding(t, env, client, ids.teamB, 5, 1, 0, 0)
|
||
|
||
// 奖牌开关按单位存(medal=false 落成 medal_share=0 再读回来),积分照旧按权重拆。
|
||
status, resp = env.do(client, http.MethodPut, path, map[string]any{
|
||
"teamShares": []map[string]any{
|
||
{"teamId": ids.teamA, "weight": 2},
|
||
{"teamId": ids.teamB, "weight": 1, "medal": false},
|
||
},
|
||
}, true)
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("关掉成员单位的奖牌应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
shares, _ = decodeData[map[string]any](t, resp)["teamShares"].([]any)
|
||
second, _ := shares[1].(map[string]any)
|
||
if second["medal"] != false {
|
||
t.Fatalf("回显应带上「这个单位不记奖牌」(medal:false),得到 %+v", second)
|
||
}
|
||
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
|
||
assertStanding(t, env, client, ids.teamB, 5, 0, 0, 0)
|
||
}
|
||
|
||
// 非法份额的报错必须说清怎么改(人话),且一律不落库。
|
||
func TestUpdateEntryTeamSharesRejectsBadPayloads(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, _ := seedMatchEvent(t, env, ids)
|
||
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")); status != http.StatusOK {
|
||
t.Fatalf("导入单人报名应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
entryID := firstEntryID(t, env, eventID)
|
||
path := fmt.Sprintf("/api/entries/%d/team-shares", entryID)
|
||
baseline := tableDump(t, env, `SELECT team_id, points_share FROM entry_team_shares ORDER BY team_id`)
|
||
|
||
cases := []struct {
|
||
name string
|
||
body map[string]any
|
||
want string
|
||
}{
|
||
{"空份额", map[string]any{"teamShares": []map[string]any{}}, "teamShares 不能为空"},
|
||
{"缺主单位", map[string]any{"teamShares": []map[string]any{{"teamId": ids.teamB}}}, "必须包含这条报名的主单位"},
|
||
{"单位重复", map[string]any{"teamShares": []map[string]any{
|
||
{"teamId": ids.teamA}, {"teamId": ids.teamA}}}, "出现了两次"},
|
||
{"权重为负", map[string]any{"teamShares": []map[string]any{
|
||
{"teamId": ids.teamA, "weight": -1}}}, "不能为负数"},
|
||
{"单位不存在", map[string]any{"teamShares": []map[string]any{
|
||
{"teamId": ids.teamA}, {"teamId": 999999}}}, "不存在或已删除"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
status, resp := env.do(client, http.MethodPut, path, tc.body, true)
|
||
if status != http.StatusBadRequest {
|
||
t.Fatalf("应 400,得到 %d %+v", status, resp)
|
||
}
|
||
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) {
|
||
t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error)
|
||
}
|
||
// 拦下就不能落库:份额表必须一个字节都没动。
|
||
if got := tableDump(t, env, `SELECT team_id, points_share FROM entry_team_shares ORDER BY team_id`); got != baseline {
|
||
t.Fatalf("被拒的请求改动了份额表:\n之前 %s之后 %s", baseline, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 报名表里非法写法的联队:拦下这一行并给出照得着改的报错,其余行照常导入。
|
||
func TestImportJointTeamRejectsBadForms(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
teamCode string
|
||
want string
|
||
}{
|
||
{"成员单位不存在", "A+ZZ", "单位 ZZ 不存在(来自联队编码 A+ZZ)"},
|
||
{"加号后面缺一段", "A+", "里有一段是空的"},
|
||
{"加号前面缺一段", "+B", "里有一段是空的"},
|
||
{"同一个单位写两遍", "A+A", "同一个单位出现了两次"},
|
||
{"成员太多", "A+B+C+D+E+F", "一个联队最多 5 个单位"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, _ := seedMatchEvent(t, env, ids)
|
||
|
||
// 前面一行是正常的单单位报名:它必须照常导入(坏行不能拖垮整张表)。
|
||
status, resp := importCSV(t, env, client,
|
||
jointImportRow("BALL", "男子", "A"),
|
||
jointImportRow("BALL", "男子", tc.teamCode),
|
||
)
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("导入端点应 200(逐行报错),得到 %d %+v", status, resp.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, resp)
|
||
if result["created"].(float64) != 1 || result["skipped"].(float64) != 1 {
|
||
t.Fatalf("应 1 条成功 1 条跳过,得到 %+v", result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
if len(errs) != 1 || !strings.Contains(fmt.Sprint(errs[0]), tc.want) {
|
||
t.Fatalf("第 2 行的报错应含 %q,得到 %+v", tc.want, errs)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 1 {
|
||
t.Fatalf("坏行不该落库,报名数 = %d(明细:\n%s)", n, sharesDump(t, env, eventID))
|
||
}
|
||
// 成功那行必须是「主单位独占」一条:坏行的解析结果不能污染它。
|
||
entryID := firstEntryID(t, env, eventID)
|
||
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0)
|
||
if got := sharesDump(t, env, eventID); got != want {
|
||
t.Fatalf("正常行应只挂主单位一条份额:\n得到 %s期望 %s", got, want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 全角加号与两侧空格照样认(现场用中文输入法填表,打出来的常是全角)。
|
||
func TestImportJointTeamAcceptsFullWidthPlus(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, _ := seedMatchEvent(t, env, ids)
|
||
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A + B")); status != http.StatusOK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
entryID := firstEntryID(t, env, eventID)
|
||
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) +
|
||
shareRow(entryID, 0, ids.teamA, ids.teamB, 1, 1, 1)
|
||
if got := sharesDump(t, env, eventID); got != want {
|
||
t.Fatalf("全角加号应解析成同一个联队:\n得到 %s期望 %s", got, want)
|
||
}
|
||
}
|
||
|
||
// 联队改回单单位(或换成员单位)时份额必须跟着变:只增不改会把旧配置一直用下去。
|
||
func TestImportJointTeamResetsSharesOnReimport(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, _ := seedMatchEvent(t, env, ids)
|
||
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")); status != http.StatusOK {
|
||
t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A"))
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("重传单单位应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
if result := decodeData[map[string]any](t, resp); result["updated"].(float64) != 1 {
|
||
t.Fatalf("应更新同一行,得到 %+v", result)
|
||
}
|
||
entryID := firstEntryID(t, env, eventID)
|
||
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0)
|
||
if got := sharesDump(t, env, eventID); got != want {
|
||
t.Fatalf("改回单单位后份额应只剩主单位一条:\n得到 %s期望 %s", got, want)
|
||
}
|
||
}
|
||
|
||
// 单个单位的报名照旧:库里写一条「主单位独占 100%」,读接口也下发一条,前端不必分支。
|
||
func TestSingleUnitEntryKeepsWholeShare(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
eventID, categoryID := seedMatchEvent(t, env, ids)
|
||
if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")); status != http.StatusOK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
entryID := firstEntryID(t, env, eventID)
|
||
want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0)
|
||
if got := sharesDump(t, env, eventID); got != want {
|
||
t.Fatalf("单单位报名应写一条独占份额:\n得到 %s期望 %s", got, want)
|
||
}
|
||
|
||
board := getEntryBoard(t, env, client, eventID)
|
||
if len(board.Entries) != 1 {
|
||
t.Fatalf("应有一条报名,得到 %+v", board.Entries)
|
||
}
|
||
shares, _ := board.Entries[0]["teamShares"].([]any)
|
||
if len(shares) != 1 {
|
||
t.Fatalf("读接口应下发一条份额(普通报名也一样),得到 %+v", board.Entries[0]["teamShares"])
|
||
}
|
||
share, _ := shares[0].(map[string]any)
|
||
if share["teamId"] != float64(ids.teamA) || share["weight"] != float64(1) || share["teamCode"] != "A" {
|
||
t.Fatalf("份额读数应为 {teamId:A, weight:1, teamCode:A},得到 %+v", share)
|
||
}
|
||
|
||
// 独占口径下名次与积分照旧:第 1 名 15 分全给 A,B 一分没有。
|
||
if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{
|
||
{"teamId": ids.teamA, "rank": 1},
|
||
}); status != http.StatusOK {
|
||
t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
assertStanding(t, env, client, ids.teamA, 15, 1, 0, 0)
|
||
assertStanding(t, env, client, ids.teamB, 0, 0, 0, 0)
|
||
}
|
||
|
||
// 人员匹配可能把报名改挂到库里那条档案的单位(keep-existing 的既有口径「报名跟随库里已有的单位」):
|
||
// 份额必须跟着**最终主单位**走。跟不上的话就会出现「报名挂在 A 名下、分却记给了表格里写的 B」——
|
||
// 现场看不出来,但榜单直接是错的。
|
||
func TestImportSharesFollowMatchedUnit(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
// 表里写 B,库里这个人的单位是 A,用户选 keep-existing → 报名仍挂在 A。
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
|
||
decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]`
|
||
status, resp := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||
if status != http.StatusOK || !resp.OK {
|
||
t.Fatalf("决策导入应 200,得到 %d %+v", status, resp.Error)
|
||
}
|
||
|
||
want := shareRow(ids.entryA, ids.athleteA, ids.teamA, ids.teamA, 1, 1, 0) +
|
||
shareAbsentRow(ids.entryB, ids.athB, ids.teamB)
|
||
if got := sharesDump(t, env, ids.event); got != want {
|
||
t.Fatalf("份额应跟随最终主单位 A:\n得到 %s期望 %s", got, want)
|
||
}
|
||
|
||
// 榜单上分也只能记给 A:报名挂在 A 名下,B 一分没有。
|
||
publishResults(t, env, client, ids.event, "shares-follow-matched-unit",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.2, "resultText": "11.2", "unit": "秒", "status": "ok"})
|
||
assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0)
|
||
assertStanding(t, env, client, ids.teamB, 0, 0, 0, 0)
|
||
}
|