Files
AskaEth 830a4dbdb7 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 张逐字节未变)。
2026-09-14 03:52:34 +08:00

397 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}