From 99c91f99f719336d223964837054f995c3dfd2ee Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 14 Sep 2026 14:05:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(entry):=20=E5=9B=A2=E4=BD=93=E6=8A=A5?= =?UTF-8?q?=E5=90=8D=E3=80=8C=E9=98=9F=E5=91=98=E8=BA=AB=E4=BB=BD=E3=80=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=80=E6=9D=A1=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E6=8C=82=E5=A4=9A=E4=B8=AA=E8=BF=90=E5=8A=A8=E5=91=98=EF=BC=8C?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E7=94=A8=E5=8F=B7=E7=A0=81=E5=B8=83=EF=BC=9B?= =?UTF-8?q?=E7=89=88=E6=9C=AC=20dev=5F0.5.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 需求(主人原话) > 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」 > 「把队员身份证留住,**应该是服务于整个团体报名**」 **现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字 (`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢** → 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。 ## 口径 - **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。 - **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。 - **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。 ## 一、数据 - 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。 `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。 - **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。 ## 二、引擎 - `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。 - **单位维度不重复计算的三道锁**(本单最大的坑): 1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关; 2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**; 3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。 反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。 ## 三、写 / 读 - `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空; 改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。 - 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。 ## 四、导入 - 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。 - 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。 - **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。 ## 五、界面与显示 - 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。 - **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。 - 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**; ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。 ## 验证 - 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。 - 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。 ## 文档 `docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、 `docs/升级说明.md`(新增 `dev_0.5.6` 一节)。 ## 版本 `dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。 --- VERSION | 2 +- console/installer/art/1x/brand.bmp | Bin 316854 -> 316854 bytes console/installer/art/2x/brand.bmp | Bin 1267254 -> 1267254 bytes console/installer/art/version.txt | 2 +- console/package.json | 2 +- docs/使用手册.md | 44 +- docs/升级说明.md | 55 +- docs/报名名单导入规范.md | 58 ++- server/internal/domain/entry_members_test.go | 267 ++++++++++ server/internal/domain/helpers_test.go | 4 +- server/internal/domain/scoring.go | 101 +++- server/internal/domain/standings.go | 37 +- server/internal/domain/types.go | 16 + server/internal/export/entries.go | 7 +- server/internal/export/entry_members.go | 143 ++++++ .../export/entry_members_dump_test.go | 67 +++ .../export/entry_members_rows_test.go | 71 +++ server/internal/export/entry_members_test.go | 263 ++++++++++ server/internal/export/load.go | 61 ++- server/internal/export/medals.go | 18 +- server/internal/httpapi/dto.go | 22 +- server/internal/httpapi/entry_athletes.go | 280 +++++++++++ .../internal/httpapi/entry_athletes_test.go | 249 +++++++++ .../httpapi/entry_athletes_upgrade_test.go | 202 ++++++++ server/internal/httpapi/entry_shares.go | 5 + server/internal/httpapi/handlers_config.go | 178 ++++++- .../httpapi/handlers_import_members_test.go | 394 +++++++++++++++ server/internal/httpapi/payload.go | 7 + server/internal/httpapi/repo_events.go | 6 + server/internal/httpapi/repo_import.go | 81 ++- server/internal/httpapi/scoring.go | 14 + server/internal/httpapi/server.go | 2 + .../migrations/event/0026_entry_athletes.sql | 69 +++ .../migrations/event/migration_0026_test.go | 242 +++++++++ .../admin/components/baseData/EntryPicker.vue | 54 +- .../components/baseData/entrySelection.ts | 139 +++++- web/src/admin/pages/EntryPage.vue | 328 +++++++++++- web/src/admin/pages/HistoryPage.vue | 4 +- web/src/admin/pages/RecordsPage.vue | 4 +- web/src/admin/pages/ReviewPage.vue | 13 +- web/src/admin/pages/entryAthletes.ts | 279 +++++++++++ web/src/admin/pages/entryBatch.ts | 17 +- web/src/admin/pages/entryRowDisplay.ts | 19 +- web/src/admin/tests/entry-athletes.test.ts | 471 ++++++++++++++++++ web/src/admin/tests/entry-selection.test.ts | 32 +- web/src/admin/tests/entry-squad.test.ts | 4 +- web/src/display/components/TickerBar.vue | 12 +- .../components/pages/EventResultsPage.vue | 28 +- web/src/display/components/pages/LivePage.vue | 19 +- web/src/display/fitFont.ts | 38 +- web/src/display/members.ts | 129 +++++ web/src/display/results.ts | 18 +- web/src/display/tests/members.test.ts | 255 ++++++++++ web/src/display/useScoreboard.ts | 27 +- web/src/shared/entryAthletes.ts | 103 ++++ web/src/shared/index.ts | 7 + web/src/shared/types.ts | 28 ++ 57 files changed, 4903 insertions(+), 94 deletions(-) create mode 100644 server/internal/domain/entry_members_test.go create mode 100644 server/internal/export/entry_members.go create mode 100644 server/internal/export/entry_members_dump_test.go create mode 100644 server/internal/export/entry_members_rows_test.go create mode 100644 server/internal/export/entry_members_test.go create mode 100644 server/internal/httpapi/entry_athletes.go create mode 100644 server/internal/httpapi/entry_athletes_test.go create mode 100644 server/internal/httpapi/entry_athletes_upgrade_test.go create mode 100644 server/internal/httpapi/handlers_import_members_test.go create mode 100644 server/migrations/event/0026_entry_athletes.sql create mode 100644 server/migrations/event/migration_0026_test.go create mode 100644 web/src/admin/pages/entryAthletes.ts create mode 100644 web/src/admin/tests/entry-athletes.test.ts create mode 100644 web/src/display/members.ts create mode 100644 web/src/display/tests/members.test.ts create mode 100644 web/src/shared/entryAthletes.ts diff --git a/VERSION b/VERSION index f47b9ad..9be3726 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.5 +dev_0.5.6 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index bc59ccefda2c330992caaedd8b31ef91fd13c931..18825d27cd5fe59ea27afb62d8c4b1f8df440c40 100644 GIT binary patch delta 434 zcmdnCT6o)P;SCZwoW5H8e%b>5Is%&=84Z~x2jxxRhX?^lcNI=Ab?(g{^A0iz`DpTa zYw!YCHEthGUZ7!#c1l3&K!&YtQem2$STj-5Pm6zcWn5Lbjhiw@l7q_p+N59u5wJ0< zo79;m8`bwp`+z+*r7)}|(GBRR>7`NmzJ_o|0bS12)GOhuE#RTb1$1_zowA>{V2Gip zhYE+EHvi`3O}m*yfrbM;G9fS6NuC9$Gu%`XBBjkVc~N_>1keUxAOQoWE8S;eUT|-A zKwF9@(8)l}Kovz@lR<$7^ii^-T867;fUY3Wft%eKfpSj0(?9_awAw?JbMwRAQ%r2| bNHAe)embfB=_E!VW&&d7?N2AMTyz2e8y13; delta 421 zcmdnCT6o)P;SCZwY<}7TK(yJ8(S&JoP~HT7AjemW-&2j-U4_#}lXvsSyn{@lUg|vF z>O6^d%31C@p+;g}>fHW10-N0#RhcHgDV{7nF+T*TqBlFBKR2i&)oWgLLYlKWRFM~> z64T^8WfSD1EMz0hqyuz?Qk^xtHF%xmnDc!MXIIAhYVrAL^ZRYiF5ki=j|U zpN}SApq|jYn#3?;37|{-H?M9|W15^;Gf@;M6KW(LZXyX(SRP_IyD|2Z9z9>j@l^undLEE={_^dqLUp|;Lg!tn#|PHD*;sGs>EIw?NA%-00{%Aisena znS_8+6=Bw`N$x-f&=CQ;f}58#=`l@S)ZQ!Yt1aN6%9Z7zyRa^KLSFFn(x}QXE2!pG zO+e#|x+V(&jRZOo=<*0t$q++PpeKRB1$3WN?=()J?}5lilV|h8-cwAQtr@MDnx9T; Te>#Z~h?#(xdHd5zEEk;s$8>&( diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index ba30d4d698d4d167992276983516bd8d6bccd213..cc822bbe3ec25d0c0dd17affef863c3ae231b74b 100644 GIT binary patch delta 1074 zcmX|AOHfi#7@l*xlz@mxW-|tX3dlpvUZz$I<_i%a5K|EG(bGptKBBj2WxijPO;a;X zHDzU)fu5bK7OiZWR;}8!s!fZcX4>>!ZZ*Sq=iI~po&P)E|J{`Z`^tj7KSJ`}%qiSr zq0nxSR_YV%M(Nz-JCYo3U@BNT|CX3YD*mZ5CgNF^Aw?_X@WCNui$js@vUau=hjxJ#jQ^V5Hk7!~l*or0~LCg8N{mZkVet(fwE5>V? zMtH$z#?D}4iB%(Vm=eRajGLv#Pee-$woHjTchlo5C+?mNKpJ|+I!l~cGN^kraI88v zxl%8|MZ-Rhwf32)NETjka=SGfa?lQIu(&Il5-W7#u~x^ciSDNTX;^dZe4NbS7os3p zju`QhzbHK2cf7<hL75oCVN^e8mvL2zoP_9**A`fI#iDyx1%DkSB;j= ztR!rru`N@45nK0u*=^W1yMoT zc$@R(Sg<}{iSa%iYG0h_M!yIBN4lN6Y&ved7X_-=?FRzIxc#wWy+ygsy)C)foDtZk zcI6~-M=q|CW|rp0<4h&*Rj_IYW_PBh5G~*^o>rY0!;i(0lApxM()Tjdr$RO|;LY3e zeE8(8o;p4cum|4q#jC3X6AY#>=_CX9A8&Ppr+PbV+h9a2YY9*Q7cg#||JM$rc%y<* z5jBqU?NPvyXV*J$f219rH56_|8qa&pm^5z(jif<~hRZ^1U`L8{2oYj}xWf$Il?z@o z=2S(aQPRND>P#>)z~D(x=GmSS@)W?(>CS4n)woa1E2B5{%z$7sUx(x(1n{F=E5cXD zDd4}l(H)G5B>z7TI=G(+N#T76HzCMFJM$S}9l)&xzgJ$UuGKW`3JhF8@JX~SKV|~7j===WzXR-a(kcOerM--&-=X3`<}fG``(6qYKIu# zr#N(CL~UL&7Y@C6_2);D9`2xQH1pyE(UTK+U!5zkDEYY58&aKmNwrE~Qzvpn4`rtr z$3Dp>ic)EfCJEmGySq{~>$5)zbU(k<|8%N%uHV^hQd(641|M&ynIE5tf~d_o@r?!P zH@hs`3;s8uq5J-pxjx5((YCES*YW7DFF}G+FAg_R7ilK+g~*A%Tm~KOW!ZQ?f2Gb{ zoM}}hq8!dXiypN$LPID7acrKOYNQ$6SE3?nn_AdWF5^1E$;AaZZ>;G|cyTo3vqww0 z*;4!8h>U3MS`lbSbdP9m`n~Xv&G;{4ck&@L-k`Sjv9!HOz zwRuTRr!t-ez3V|QE_h`A9uj0penqqRa!%CII<3a6moo!vf$qkFG}MWvY#+o@xVgY` zF892gH_~8ycXwoQ&{d-m!UHyCl*g$8ZKSaj0Wu6jAAME2^=U8c9?recA_#zrnaP=vo?T3SKt% zROVt5VJFo|GX^0gBiU#UDzHIoH+mjTbYhkWwuxN6O)ZEl6hr=yI-2<;frMoP#ZP}Y zb_vy8g&B~IOlQ8NE?*L(V#5C8__Q2@S{ZJJU64|xNI-*DHM^zg3)VSwBIe+bKuDUD zWuqqc=k@I1fYpG-@SIb6DiofIoGz0x>NUsS$$<mj*fP4nUI6tPQl8#+%PhaN%YzldTmkC0XnatmIKaUKX@M@&8aJSRw&NxPdiq z@ie81{D*A+v4|J}FJN#lJyTRh0mlIL&I96rcpw4b0|Fos5CTa+G9UuPfCNZkipo+c F=|7fqF`EDY diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index d1d899f..b49b253 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.5 +0.5.6 diff --git a/console/package.json b/console/package.json index 240a134..16178aa 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.5", + "version": "0.5.6", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/使用手册.md b/docs/使用手册.md index 1f0bf3a..9ae38c9 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -100,7 +100,7 @@ ### 4.2 录入成绩 -表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。 +表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。**纯团体报名这一格旁边还有一个「队员」小按钮**(点开登记 / 修改这支队的队员,见 4.13);普通报名没有这个按钮。 **「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。 @@ -342,6 +342,45 @@ - **改队名不影响成绩与榜单**:队名只是给现场看的一个标签,不参与名次、积分、奖牌的计算,所以改它**不会重算榜单**,也**不会给别处的大屏 / 录入页推刷新**——别的窗口要等下一次刷新才看到新队名(你自己那一次保存的页面上会立刻变); - 队伍**不参与「团体名次」按单位录入**的那一类项目(篮球那种每个单位只有一支队):名次仍然是按单位录的,与队名无关。 +### 4.13 团体报名的队员(谁在这支队里) + +**团体/对抗项目(双打、接力、拔河、篮球…)的报名上可以挂「队员」**——挂上之后,系统知道**这支队伍里坐着谁**。这条报名在配了「个人通道」的项目里就会**给每个队员各发一份完整的个人分、各一枚个人奖牌**,他们也因此能上**个人积分榜**(过去只存一串姓名文字,谁都上不了个人榜)。 + +**「每人各一份」不是「几个人分同一份」**:一对双打赢了拿 10 分,是**两个人各 10 分**(不是各 5 分)——这与「联队把分拆给几个单位」正相反。**单位总分与单位奖牌不受影响**:一个单位拿到多少分、多少牌,由「计分通道」里的 `计入单位总分` / 个人奖牌是否并入单位榜两个开关决定;**双打现场通常都设成不计入**,也就是个人分给到人、单位那本账不变。 + +**怎么挂队员(三条路)** + +| 方式 | 怎么做 | +| --- | --- | +| **补报名时勾人** | 「成绩录入」页工具栏的 **「补报名」**(或「基础数据 → 报名导入」的**团体报名**页签):勾上单位后可以**登记队员**(逐个勾选,可按姓名 / 工号搜索)。**勾上的人会真正提交上去**,写进那一条团体报名 | +| **录入页改队员** | 「成绩录入」页表格里**「运动员 / 队」那一格旁边的「队员」小按钮**(**只有纯团体行才有**)→ 打开勾选式名单(带搜索)→ 勾完保存。这是**把已经报过的报名补挂 / 改挂队员**的地方 | +| **导入报名表** | 把「队员名单」那一格写成**号码布**(例如 `101/102`),导入时会自动关联到人;**写姓名只当文字**,详见[报名名单导入规范 · 第三节「队员名单」](./报名名单导入规范.md) | + +**号码布是唯一标识** + +- 系统**只按号码布认人**(`101` → 号码布是 101 的那个人)。**姓名一律不参与匹配**——同名会认错人,而认错的分已经上了榜; +- 所以**「队员名单」里只写姓名是拿不到个人分的**(名单照常显示,也**不报错**);界面上手动勾人不受这条限制(勾的是人); +- 号码布认到的是**别的单位**的人时**照常关联**,并给一条提示让你核对号码布是不是抄错了(号码布是唯一标识,系统不看单位)。 + +**显示成什么样** + +- **导出件**:一个队员写 **`姓名(号码布)`**(例如 `张三(101)`),多个用 `、` 连——报名名单的「姓名」列、分项成绩表 / 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列都这样;**个人得分明细里每个队员各占一行**(双打就是两行,各一整份分); +- **大屏**:分项成绩 / 实时赛况的运动员列、底栏「最新完成项目」的名字格都会带上队员名单(**只写姓名**,号码布太长会把整屏字号压小); +- ⚠️ **一条报名仍然只占一行**,不会因为挂了两个队员就变成两行(队里的两个人是**一条报名**里的两个身份); +- ⚠️ **队名(4.12)与队员是两件独立的事**:单位列照旧显示「单位名 + 队名」,队员只出现在运动员列 / 底栏。 + +**老数据怎么办** + +- 升级时会**尽力回填**一次:拿原来那串姓名文字去对——**先按号码布认**,再按「姓名 + 单位」(**同一个单位同名的人只有一个**时才认);**认不上的就留空**(不硬猜,避免把分记到别人头上); +- 所以**升级后请抽查几个团体报名**:只写姓名(或没写队员)的老报名,很可能**还是没有队员身份**——需要的话,到录入页点「队员」按钮手动挂上,或重新导入一份**写着号码布**的报名表; +- ⚠️ **改完队员之后**:这条报名的个人分、个人奖牌会**当场重算并推榜**;但**名单文字(`member_names`)与队员关联是并存的**——名字文字里没被认成人的那些名字**永远上不了个人榜**,要上榜就得挂成队员。 + +**要注意的** + +- **权限**:读得到成绩的人都能看见;**能改的是录入员及以上**(与录成绩同档)。访客只读时按钮置灰;示例数据模式下不能保存; +- **一条报名最多 30 个队员**(超过会被拦下,多半是号码布写错了位置);**个人报名(按人报的那些)没有队员**(它自己就是那个人的身份); +- **改队员不会重算整张榜**(与改队名一样只重算这条报名所在的组别),**也不会给别处的大屏推刷新**——别的窗口要等下一次刷新才看到新名单。 + --- ## 5. 成绩审核 @@ -756,7 +795,8 @@ 1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员; 2. **同一个单位要派两支队时**(羽毛球双打那种):勾上这个单位后,那一行右侧会出现 **「队伍(留空=不区分)」** 输入框,填上队名(例如 `一队`);**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)`——**要再报第二支队,就再填一个新的队名提交一次**(同一个队名重复提交只是更新队员名单,不会新增)。详见 4.12; -3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补; +3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补。 + - **这里勾上的队员会真正提交到报名上**(不是只存一串姓名):配了「个人通道」的项目里,他们会各拿一份个人分、各一枚个人奖牌,也上得了个人积分榜——详见 **4.13**; 4. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单; 5. 点「加入报名」,一次为所有勾选的单位各建一条报名(**同一个单位 + 同一个队名**只留一条)。 - 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。 diff --git a/docs/升级说明.md b/docs/升级说明.md index ca79bb5..ec2632e 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,60 @@ --- -## dev_0.5.5(当前版本) +## dev_0.5.6(当前版本) + +### 一句话 + +**有一条数据迁移(`0026`),老数据一个字节都不变**:团体 / 对抗项目的报名现在能**挂具体的队员了**——界面本来就能一次勾多个运动员,但以前提交时只把姓名拼成一串文字,**身份丢了**,所以配了「个人通道」的团体项目**给不出个人分、个人奖牌,队员也上不了个人积分榜**。升级后:队员**每人各拿一份完整的个人分、各一枚个人奖牌**(是「每人各一份」,不是几个人分同一份);**单位总分与单位奖牌不受影响**(由计分通道的开关决定,双打现场通常都设成不计入)。没有挂队员的老报名、以及只配了团体通道的项目(现场绝大多数球类项目),**行为与升级前逐字一样**。 + +### ① 数据迁移:一条(`0026`) + +- **它做什么**:新建一张 `entry_athletes` 表,记「这条报名里有哪几个运动员」;**报名表本身一个字节没动**(原来那串姓名文字 `member_names` 保留为**显示用名单**,报名的唯一键、队名、名单导出都不受影响)。 +- **老数据回填(尽力而为,匹配不上就留空)**:拿原来那串姓名文字去对运动员档案——**先按号码布认**;号码布对不上再按「**姓名 + 单位**」,且**同一个单位里同名的人只有一个**时才认(重名留给人工确认);**认不上的不猜,留空**(维持升级前的行为:这条报名只有单位、没有队员身份)。 +- **回填之后会发生什么**:配了**个人通道**的老团体项目会**开始给这些队员发个人分与个人奖牌**(这正是这一版的目的);**只配了团体通道的项目一个字节都不变**(单位总分、单位奖牌、榜单都不动)。⚠️ 所以升级后请**抽查几个团体项目**:原来只写姓名的报名可能仍然没有队员(那就手动挂上,见下)。 +- **怎么看它跑过**:启动一次 Console,在运行日志或 `logs\operations\` 当天文件里能看到 `已应用迁移 … name=0026_entry_athletes.sql`。 + +### ② 新能力:团体报名的队员(谁在这支队里) + +| 场景 | 怎么做 | +| --- | --- | +| **导入报名表** | 「队员名单」那一格写**号码布**(例如 `101/102`)→ 自动关联到人;**写姓名只当文字**(名单照常显示,但拿不到个人分),也**不报错**。⚠️ 号码布要**单独成段**:`张三(101)` 这种混写认不出人;一条报名最多 30 人 | +| **补报名时勾人** | 「成绩录入」页的「补报名」/「基础数据 → 报名导入」的团体报名页签:勾上人**会真正提交**到报名上 | +| **录入页改队员** | 「成绩录入」页表格里**「运动员 / 队」那一格旁边的「队员」小按钮**(只有纯团体行才有)→ 勾选式名单(带搜索)→ 保存。改完**当场重算该组别并推榜** | + +**口径**: + +- **号码布是运动员的唯一标识**(不是证件号、也不是姓名):系统**只按号码布认人**;号码布认到的是**别的单位**的人时**照样关联**,只给一条提示让你核对; +- **每人各拿一份完整的分**(一对双打赢了拿 10 分 → 两个人各 10 分),**各一枚个人奖牌**; +- **单位那本账不变**:单位总分与单位奖牌由「计分通道」的 `计入单位总分` / 个人奖牌是否并入单位榜两个开关决定;**双打现场通常都是不计入**; +- **个人报名(按人报名的那些)没有队员**——它自己就是那个人的身份;**一条报名仍然只占一行**(挂了两个队员也不会变成两行)。 + +### ③ 显示:一个队员写成 `姓名(号码布)` + +- **导出件**:报名名单「姓名」列、分项成绩表 / 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列都写成 `张三(101)`,多个用 `、` 连;**个人得分明细里每人各占一行**(双打两行,各一整份分); +- **大屏**:分项成绩 / 实时赛况的运动员列、底栏「最新完成项目」都带上队员名单(**只写姓名**,号码布太长会把整屏字号压小); +- ⚠️ **没有队员关联的老数据一个字不变**:老报名的导出件与大屏画面与升级前逐字 / 逐像素一致(姓名文字照旧、行为照旧)。 + +### ④ 升级后 5 分钟核对 + +1. 启动一次 Console,看运行日志 / `logs\operations\` 当天文件:应能看到 `0026` 这条迁移记录,没有别的报错; +2. 刷新管理端「积分榜」与大屏:**单位总分榜、单位奖牌榜的数字与升级前一致**(本版不动单位那本账); +3. 打开「成绩录入」页:挑一条**团体报名**看「运动员 / 队」那一格——纯团体行旁边应有一个**「队员」小按钮**(个人行没有,这是对的);点开看看有没有队员(老报名可能一个都没有,那是回填没认上,属正常); +4. 挑一个**原来只写了姓名**的团体报名(羽毛球双打那种),点「队员」按钮**把队员勾上**保存;再到「积分榜 → 个人积分榜」确认**这些队员出现了、各有一条分**; +5. 拿一份**写着号码布**的报名表(「队员名单」列写成 `101/102`)试导一次,导入结果里应能看到一条关于队员的汇总提示; +6. 用《上线检查清单》过一遍。 + +### ⑤ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 队员怎么挂、改在哪、显示成什么样 | 《使用手册》4.13(以及 4.2、10.7 的衔接说明) | +| 导入表里「队员名单」怎么写(号码布 / 姓名各是什么效果) | 《报名名单导入规范》第三节「`member_names` 队员名单」、第四节的例 6、第六节 | +| 迁移与两处判定 | `server/migrations/event/0026_entry_athletes.sql` | + +--- + +## dev_0.5.5 ### 一句话 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index ac6fa0e..49f3c63 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -45,7 +45,7 @@ | `seed` | 种子(也认「道次」) | 道次 / 种子 | | `heat` | 批次(也认「批次号」) | 批次,可缺省 | | `status` | 状态(也认「报名状态」) | 报名状态 | -| `member_names` | 队员名单 | 团体项目的队员名单 | +| `member_names` | 队员名单 | 团体项目的队员名单;**写号码布才会关联到人**(写姓名只当文字) | | `squad` | 队伍(也认「队名」「队伍名」) | **队名,可缺省**;同一个单位在同一组别派两支队时用它区分(留空 = 不区分队伍) | > **第 13 列 `squad`(队伍)永远排在最后**:它是 2026 年 9 月新增的,加在表尾是为了让**手里那些原来 12 列的老表格一列都不会错位**(表格少的正是最后这一列)。整列不写、整列留空、表头没有它,三种情况都合法。 @@ -69,7 +69,7 @@ | `seed`(种子) | 可选 | 道次 / 种子,随便写或留空 | `种子1` | | `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` | | `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` | -| `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` | +| `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开。**写号码布 = 关联到那个人(能拿个人分)**;只写姓名 = 只当文字 | `101/102`(号码布);`张三/李四`(仅文字) | | `squad`(队伍) | 可选 | **队名**(自由写,例如 `一队`);**留空 = 不区分队伍**。**同一个单位在同一个组别派两支队就靠它区分**,见第三节 | `一队` | > 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。 @@ -180,6 +180,7 @@ F1+F2 > 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空) ### `member_names` 队员名单 + 团体项目用这一列写队员,多人之间用 `/`、`,` 或 `,` 分开: ``` @@ -190,6 +191,31 @@ F1+F2 - 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。 - 系统界面里会把这条名单显示成「张三、李四、王五」。 +**想让队员真正「上场」——这一列要写号码布** + +这一列有**两种内容**,系统按段(用 `/`、`,`、`,` 分开的每一段)分别处理: + +| 这一段写的是 | 系统怎么处理 | +| --- | --- | +| **某个运动员的号码布**(例如 `101`、`A001`) | **认成那个人**:把这条团体报名与他**关联**起来(这一格也原样保留成文字)。**关联上的队员才能拿个人分、上个人积分榜、拿个人奖牌**(该项目配了「个人通道」时) | +| **只有姓名**(例如 `张三`) | **只当文字**:名单照常显示,**不会关联到人**(姓名可能重名,认错的分已经上了榜——所以系统**故意不按姓名认人**),也**不报错** | + +``` +101/102 ← 推荐写法:号码布,两个队员都能拿到个人分 +张三/李四 ← 只当文字:名单会显示,但这两个人没有身份(拿不到个人分) +101/李四 ← 混着写也行:101 关联到人,李四只当文字 +``` + +几条要记住的: + +- ⚠️ **号码布要单独成一段**:`张三(101)` 这种「姓名和号码布写在同一段」的写法**认不出人**(系统只拿整段去比对号码布),要写成 `张三/101`,或者干脆只写 `101`。**推荐只写号码布**; +- **号码布是运动员的唯一标识,不看单位**:号码布认到的是**别的单位**的人时,**照常关联**,并在导入结果里给一条提示让你核对号码布是不是写错了(不是冲突、不拦截); +- **认不出人的段不报错**:没录档案的临时队员、写错的名字,都只当文字留在名单里(这条报名仍然只有单位、没有队员身份); +- **同一格里同一个号码布写两次**:只关联一次,并给一条提示(同一条报名里一个人只占一个位置); +- **个人行(填了姓名的那几行)这一列只当文字**:个人报名自己就是那个人的身份,不存在「队员」。这一格里写了号码布也不关联,只会给一条提示; +- **一条报名最多关联 30 个人**:超过的那一行会被**整行拦下**(多半是把号码布写错了位置); +- **老数据的人要补挂**:升级前已经报好的团体报名,如果当时只写了姓名(或连姓名都没写),**升级时会尽力按「号码布 → 姓名 + 单位」回填一次**;**回填不上的,要重新导入一份写着号码布的名单,或到录入页手动挂人**(见[使用手册 · 4.13](./使用手册.md))。 + ### `squad` 队伍 —— 可选(表的最后一列) **这一列只在「同一个单位在同一个组别派了不止一支队」时才需要填**,比如羽毛球双打:一个单位报了一队、二队,两队可能各占一个名次。 @@ -220,7 +246,7 @@ F1+F2 --- -## 四、五个完整例子 +## 四、六个完整例子 ### 例 1:个人项目(七单位 × 若干人) @@ -289,6 +315,24 @@ F1+F2 - 显示上写成 **`第一分公司一队` / `第一分公司二队`**(单位名后面直接跟队名,不加括号不加分隔符);**单位总分榜、单位奖牌榜、个人积分榜上只写 `第一分公司`**(两队的分数都已经算进这个单位的总分); - 报了之后想改队名**不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ **改完记得把这张表里的队名也改成一样**——以后再导一份还写着旧队名的表,会**新增出旧队名那一条报名**(导入是按「小项 + 组别 + 单位 + 队名」认人的)。 +### 例 6:同一支队里的两个队员(队员名单写号码布) + +`BD` 羽毛球双打、`男子组`,F1 的「一队」是张三(号码布 `101`)与李四(号码布 `102`);F2 那支队只报了单位、没写队员: + +| event_code | category | team_code | athlete_name | id_card | gender | age | bib | seed | heat | status | member_names | squad | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| BD | 男子组 | F1 | | | | | | | | registered | 101/102 | 一队 | +| BD | 男子组 | F2 | | | | | | | | registered | | | + +要点: + +- **队员名单里写号码布,系统才认得出队里坐着谁**:`101/102` → 这条报名关联到张三、李四两个人。**该项目配了「个人通道」时,两个人各拿一份完整的个人分、各一枚个人奖牌,都上个人积分榜**(是「每人各一份」,不是两个人分同一份); +- **单位总分与单位奖牌不受影响**:上不上的开关在「计分通道」里(`计入单位总分` / 个人奖牌是否并入单位榜)。**双打现场通常都设成「不计入」**——个人分给到人,单位那本账不变; +- **只写姓名(`张三/李四`)也合法**:名单照常显示,但**这两个人没有身份**——拿不到个人分、上不了个人榜,也**不报错**。要让他们上榜,把这一格改成他们的**号码布**(`101/102`),或到录入页手动挂人(见[使用手册 · 4.13](./使用手册.md)); +- ⚠️ **号码布要单独成段**:写成 `张三(101)/李四(102)` **认不出人**(系统拿整段去比号码布)。要「名字 + 号码布」都看得见,就先写号码布(`101/102`)——导出件与录入页会自己显示成 `张三(101)、李四(102)`; +- **号码布写错成别的单位的人**:照号码布关联,并给一条提示让你核对(号码布是唯一标识,系统不按单位猜); +- **F2 那一行留空**:只报单位、没有队员身份,这是合法的,也是升级前所有团体报名的样子。 + --- ## 五、导入前请先检查(自检清单) @@ -306,6 +350,7 @@ F1+F2 - [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组) - [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节) - [ ] **「队伍」列**(最后一列)只在**同一个单位派了不止一支队**时才填:两队写了**两个不一样的队名**(都写一样或都留空会被拦下);只有一支队的单位留空即可;**个人行(有姓名的行)没有填队名**(填了也不会写进库里,只给一条提示) +- [ ] **要让队员拿到个人分**:`member_names`(队员名单)里写的是**单独的号码布**(`101/102`),不是姓名、也不是 `张三(101)` 这种混写;号码布抄对(写错会关联到别人,系统会提示但不会拦) - [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的) - [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节) @@ -331,6 +376,11 @@ F1+F2 | 第 N 行:单位 A 在本组别已经有一条队伍「一队」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队) | 同一个单位、同一个组别、**同一个队名**写了两遍(两支队要写两个不同的队名) | 把其中一行的「队伍」列改成另一个队名(`一队` / `二队`);如果本来就是同一支队、只是想把队员名单补全,改完重导**不会新增**(同一单位同一队名是更新) | | 第 N 行:单位 A 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请在队伍列分别写清(例如 一队 / 二队) | **两行的队伍列都留空**(等于都说「这个单位在这个组别只有一支队」),或者库里以前报过一条不分队的、这行又留空 | 要派两支队就在两行的「队伍」列分别写 `一队` / `二队`;确实只有一支队,就把多余的那一行删掉 | | 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 | +| 第 N 行:队员名单里认出了 31 名运动员,超过一条报名最多 30 人:请检查这一格是不是把号码布写错了位置 | 这一格里能对上号码布的段**超过 30 个**(多半是把别的内容写进去了) | 核对这一格:一条报名最多 30 个队员;只有一支队时这一格留空即可。**这一行整行未导入** | +| 第 N 行:队员名单里的号码布 101 写了两次,只关联一个人(同一条报名里一个人只占一个位置) | 同一格里同一个号码布写了两次 | 不算错误(照常导入、只关联一次);把这一格里重复的那个号码布删掉即可 | +| 第 N 行的队员名单里有 2 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人 | **个人行**(有姓名的行)里写了号码布。个人报名自己就是那个人的身份,不存在「队员」 | 不算错误(这一行照常导入,只是**没有**建立队员关联);团体报名的那一行才需要写号码布 | +| 第 N 行:队员名单里的号码布 205 是单位 F3 的 李四,本行报名挂在单位 F1 名下 —— 号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错 | 号码布认到的是**别的单位**的人。号码布是唯一标识,系统**照它认、不看单位** | 先核对号码布有没有抄错:抄错了改成正确的号码布;**确实就是借调/跨单位的人**,这条提示可以忽略(关联已经生效) | +| **(没有报错,但名单里的人上不了个人榜)** | 队员名单里写的是**姓名**(例如 `张三/李四`),或号码布写成了 `张三(101)` 这种「姓名 + 号码布写在同一段」的形式 —— 系统拿**整段**去比号码布,认不出人 | 这是**正常行为**(不报错):把这一段改成**单独的号码布**(`101/102`),队员才会被关联、才会拿到个人分。要「姓名 + 号码布」都显示,直接写号码布即可(导出件与录入页会显示成 `张三(101)`) | | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | | 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 | | 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 | @@ -436,7 +486,7 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿 > 2. **一个人一行**;一个人报几项就写几行; > 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编; > 4. 每人要有**证件号或号码布**,至少一个;**证件号是系统认人的第一依据**(有就优先填、填准、别和人重号);没有证件号时号码布顶替它认人,**号码布也不能给两个人**; -> 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开; +> 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开——**写号码布(`101/102`)才会关联到人、才拿得到个人分**,只写姓名就只是文字名单; > 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号; > 7. 表里**不要合并单元格**、不要加标题行、不要插图; > 8. 填完先对着第五节的清单自查一遍,再发给收表人; diff --git a/server/internal/domain/entry_members_test.go b/server/internal/domain/entry_members_test.go new file mode 100644 index 0000000..5888cf8 --- /dev/null +++ b/server/internal/domain/entry_members_test.go @@ -0,0 +1,267 @@ +package domain + +import ( + "fmt" + "strings" + "testing" +) + +// 本文件钉住「团体报名的队员身份」在引擎里的口径(devdocs/20): +// +// - 一条团体报名(athlete_id 为空)可以挂多个队员;个人通道给**每个队员各一份完整的**分 +// (不是按份额拆,与联队 entry_team_shares 的「拆」相反),个人奖牌也是每人各一枚; +// - **单位维度一份都不多**:单位总分与单位奖牌仍然只算这条报名一次,由通道开关 +// count_to_team / count_to_team_medals 控制; +// - 没有队员关联的老数据(Entry.Athletes 为空)逐字节不变。 + +// memberEntry 造一条带队员的团体报名。 +func memberEntry(entryID, teamID int64, value float64, members ...EntryAthlete) participant { + return participant{entryID: entryID, teamID: teamID, value: value, members: members} +} + +// twoDoubles 是「一个单位派两个人打双打 + 另一个单位一个人」的最小现场形态: +// 成绩越小越好(compare=asc,与 100 米那种打法一致)。 +func twoDoubles() ([]Entry, []Result) { + return fixture([]participant{ + memberEntry(1, 1001, 11.2, + EntryAthlete{AthleteID: 201, Name: "队员一"}, + EntryAthlete{AthleteID: 202, Name: "队员二"}), + {entryID: 2, teamID: 1002, value: 11.5}, + }) +} + +// twoDoublesLegacy 是升级前的老数据形态:同样两条报名、同样的成绩,但队员只有姓名文字 +// (认不出是哪个人),也就是回填不出关联或干脆没回填的那些报名。 +func twoDoublesLegacy() ([]Entry, []Result) { + entries, results := twoDoubles() + entries[0].Athletes = nil + return entries, results +} + +// doublesEventInput 是双打的计分输入:团体项目 + 一个按成绩排名的个人通道。 +// teamFlagsOff 打开通道的两个单位开关「都不计入」(现场的双打口径)。 +func doublesEventInput(entries []Entry, results []Result, teamFlagsOff bool) EventScoreInput { + ch := athleteResultChannel(9) + if teamFlagsOff { + ch.CountToTeam = boolPtr(false) + ch.CountToTeamMedals = boolPtr(false) + } + return EventScoreInput{ + Event: teamEvent(1), Category: testCategory(), + Channels: []ScoreChannel{ch}, + Profiles: map[int64]ScoringProfile{9: individualTop6()}, + Entries: entries, Results: results, + } +} + +// 双打:两个队员各拿一份完整的个人分(第 1 名 10 分 → 两人各 10 分,不是各 5 分), +// 单位总分仍然只有一份 10 分;个人奖牌每人各一枚,单位奖牌只有一枚。 +func TestTeamEntryMembersScoreIndividually(t *testing.T) { + entries, results := twoDoubles() + es := mustScore(t, doublesEventInput(entries, results, false)) + + // 名次明细:报名项自己那一份(单位维度认它)+ 每个队员一份个人副本。 + wantRanked := []string{ + "key=e:1 scope=athlete basis=result team=1001 athlete=0 rank=1 points=10", + "key=e:1:a:201 scope=athlete basis=result team=1001 athlete=201 rank=1 points=10", + "key=e:1:a:202 scope=athlete basis=result team=1001 athlete=202 rank=1 points=10", + "key=e:2 scope=athlete basis=result team=1002 athlete=0 rank=2 points=8", + } + if got := rankedSummary(es.Ranked); strings.Join(got, "\n") != strings.Join(wantRanked, "\n") { + t.Fatalf("名次明细不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantRanked, "\n")) + } + + // 流水:单位那一份照旧(count_to_team=true),队员的个人分是「只记个人」的额外流水—— + // 它们 count_to_team=false,所以既不会让单位总分变成两份,也不会进单位奖牌。 + wantLedger := []string{ + "team=1001 athlete=0 entry=1 result=100 points=10 countToTeam=true", + "team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false", + "team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false", + "team=1002 athlete=0 entry=2 result=200 points=8 countToTeam=true", + } + if got := ledgerSummary(es.Ledger); strings.Join(got, "\n") != strings.Join(wantLedger, "\n") { + t.Fatalf("流水不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantLedger, "\n")) + } + + in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} + + // 单位总分只算一次:10 分(不是 30 分),金牌一枚(不是三枚)。 + rows := BuildTeamStandings(in) + if len(rows) != 2 { + t.Fatalf("积分榜应有两行,得到 %+v", rows) + } + if rows[0].TeamID != 1001 || !nearlyEqual(rows[0].Points, 10) || + rows[0].TeamOnlyPoints != 10 || rows[0].IndividualPoints != 0 || rows[0].Gold != 1 || rows[0].Medals != 1 { + t.Fatalf("双打单位那一行 = %+v,期望总分 10(仅单位 10、个人 0)、1 枚金牌(不进个人分)", rows[0]) + } + if rows[1].TeamID != 1002 || rows[1].Points != 8 || rows[1].Silver != 1 { + t.Fatalf("对照单位那一行 = %+v,期望 8 分 1 银", rows[1]) + } + medals := BuildMedalStandings(in) + if medals[0].TeamID != 1001 || medals[0].Gold != 1 || medals[0].Total != 1 { + t.Fatalf("单位奖牌榜第一行 = %+v,期望单位 1001 只记 1 枚金牌", medals[0]) + } + + // 个人榜:两个队员各一份满分、各一枚金牌。 + athletes := BuildAthleteStandings(in) + wantAthletes := []string{ + "athlete=201 team=1001 points=10 1/0/0 medals=1", + "athlete=202 team=1001 points=10 1/0/0 medals=1", + } + got := make([]string, 0, len(athletes)) + for _, row := range athletes { + got = append(got, fmt.Sprintf("athlete=%d team=%d points=%v %d/%d/%d medals=%d", + row.AthleteID, row.TeamID, row.Points, row.Gold, row.Silver, row.Bronze, row.Medals)) + } + // 两人同分同牌,排序落到姓名(队员一 < 队员二),顺序稳定。 + if strings.Join(got, "\n") != strings.Join(wantAthletes, "\n") { + t.Fatalf("个人榜不对:\n得到 %s期望 %s", strings.Join(got, "\n"), strings.Join(wantAthletes, "\n")) + } +} + +// 现场的双打口径(通道两个单位开关都关掉):队员照拿个人分与个人奖牌,单位一分一牌都不拿。 +func TestTeamEntryMembersWithTeamFlagsOff(t *testing.T) { + entries, results := twoDoubles() + es := mustScore(t, doublesEventInput(entries, results, true)) + + in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} + for _, row := range BuildTeamStandings(in) { + if row.Points != 0 || row.Medals != 0 { + t.Errorf("单位 %d 在「都不计入」下应为 0 分 0 牌,得到 %+v", row.TeamID, row) + } + } + for _, row := range BuildMedalStandings(in) { + if row.Total != 0 { + t.Errorf("单位 %d 在「都不计入」下不该有奖牌,得到 %+v", row.TeamID, row) + } + } + athletes := BuildAthleteStandings(in) + if len(athletes) != 2 || athletes[0].Points != 10 || athletes[0].Gold != 1 || + athletes[1].Points != 10 || athletes[1].Gold != 1 { + t.Fatalf("队员的个人分与个人奖牌照发,得到 %+v", athletes) + } +} + +// 联队 + 队员同时存在(A+B 联队派两个人打双打):两套机制各管一维,不许互相污染—— +// 单位那一份仍按份额**拆**(各 5 分)、奖牌各 1 枚;队员各拿**一整份**(各 10 分,不是各 5 分)。 +func TestJointTeamWithMembersKeepsSharesAndCopiesSeparate(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, teamID: 1001, value: 11.2, + shares: []TeamShare{{TeamID: 1001, Weight: 1}, {TeamID: 1002, Weight: 1}}, + members: []EntryAthlete{{AthleteID: 201, Name: "队员一"}, {AthleteID: 202, Name: "队员二"}}}, + }) + es := mustScore(t, doublesEventInput(entries, results, false)) + + // 流水:单位那两份各 5 分(按份额拆),队员那两份各 10 分(整份,只记个人)。 + ledger := ledgerSummary(es.Ledger) + want := []string{ + "team=1001 athlete=0 entry=1 result=100 points=5 countToTeam=true", + "team=1002 athlete=0 entry=1 result=100 points=5 countToTeam=true", + "team=1001 athlete=201 entry=1 result=100 points=10 countToTeam=false", + "team=1001 athlete=202 entry=1 result=100 points=10 countToTeam=false", + } + if strings.Join(ledger, "\n") != strings.Join(want, "\n") { + t.Fatalf("联队 + 队员的流水不对:\n得到 %s期望 %s", strings.Join(ledger, "\n"), strings.Join(want, "\n")) + } + + in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} + for _, row := range BuildMedalStandings(in) { + if row.Gold != 1 || row.Total != 1 { + t.Errorf("联队单位 %d 应各记 1 枚金牌(队员不重复发牌),得到 %+v", row.TeamID, row) + } + } + athletes := BuildAthleteStandings(in) + if len(athletes) != 2 || athletes[0].Points != 10 || athletes[1].Points != 10 { + t.Fatalf("联队里的队员各拿一整份个人分,得到 %+v", athletes) + } +} + +// 硬门槛:挂上队员之后,**单位维度逐字节不变**。 +// +// 同一份成绩算两遍:一遍是升级前的老数据形态(只有姓名文字、认不出人),一遍是升级后 +// 回填出队员身份。单位那一份名次、流水、总分、奖牌必须一模一样——队员的分只走个人维度, +// 绝不能渗进单位总分或单位奖牌(那是本单最容易错的地方)。 +func TestTeamEntryMembersKeepTeamDimensionByteIdentical(t *testing.T) { + legacyEntries, legacyResults := twoDoublesLegacy() + without := mustScore(t, doublesEventInput(legacyEntries, legacyResults, false)) + + entries, results := twoDoubles() + with := mustScore(t, doublesEventInput(entries, results, false)) + + before := teamDimensionDump(without) + after := teamDimensionDump(with) + if before != after { + t.Fatalf("挂上队员后单位维度变了:\n之前\n%s之后\n%s", before, after) + } + if !strings.Contains(before, "单位榜") || !strings.Contains(before, "单位奖牌榜") { + t.Fatalf("对拍文本里必须有单位榜与单位奖牌榜,否则等于两份空文本相等:\n%s", before) + } + // 防空转:老形态的个人维度确实是空的(队员认不出来就上不了个人榜)。 + if got := len(BuildAthleteStandings(StandingsInput{Ledger: without.Ledger, Ranked: without.Ranked})); got != 0 { + t.Fatalf("没有队员关联时个人榜应为空,得到 %d 行", got) + } + if got := len(BuildAthleteStandings(StandingsInput{Ledger: with.Ledger, Ranked: with.Ranked})); got != 2 { + t.Fatalf("挂上队员后个人榜应有两个队员,得到 %d 行", got) + } +} + +// teamDimensionDump 把「单位维度」的全部事实拼成稳定文本:单位那一份流水、单位总分、 +// 单位榜与单位奖牌榜。队员的个人流水不进这份文本(它不属于单位维度)。 +func teamDimensionDump(es EventScore) string { + var lines []string + for _, l := range es.Ledger { + if l.AthleteID != 0 { + continue + } + lines = append(lines, fmt.Sprintf("流水 team=%d entry=%d result=%d points=%v countToTeam=%v", + l.TeamID, l.EntryID, l.ResultID, l.Points, l.CountToTeam)) + } + for _, rr := range es.Ranked { + if rr.AthleteID != 0 { + continue + } + lines = append(lines, fmt.Sprintf("名次 key=%s scope=%s team=%d rank=%d points=%v shares=%s", + rr.SubjectKey, rr.Scope, rr.TeamID, rr.Rank, rr.Points, formatShares(rr.Shares))) + } + in := StandingsInput{Teams: testTeams(1001, 1002), Ledger: es.Ledger, Ranked: es.Ranked} + for _, weight := range []float64{1, 0.5} { + for _, exclude := range []bool{false, true} { + for _, row := range BuildTeamStandings(StandingsInput{ + Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked, + IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude, + }) { + lines = append(lines, fmt.Sprintf("单位榜 w=%v ex=%v team=%d pts=%v only=%v indiv=%v %d/%d/%d", + weight, exclude, row.TeamID, row.Points, row.TeamOnlyPoints, row.IndividualPoints, + row.Gold, row.Silver, row.Bronze)) + } + for _, row := range BuildMedalStandings(StandingsInput{ + Teams: in.Teams, Ledger: in.Ledger, Ranked: in.Ranked, + IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude, + }) { + lines = append(lines, fmt.Sprintf("单位奖牌榜 w=%v ex=%v team=%d %d/%d/%d total=%d", + weight, exclude, row.TeamID, row.Gold, row.Silver, row.Bronze, row.Total)) + } + } + } + return strings.Join(lines, "\n") + "\n" +} + +// rankedSummary 打印名次明细里「谁、什么名次、多少分」这几件事。 +func rankedSummary(ranked []RankedResult) []string { + out := make([]string, 0, len(ranked)) + for _, rr := range ranked { + out = append(out, fmt.Sprintf("key=%s scope=%s basis=%s team=%d athlete=%d rank=%d points=%v", + rr.SubjectKey, rr.Scope, rr.Basis, rr.TeamID, rr.AthleteID, rr.Rank, rr.Points)) + } + return out +} + +// ledgerSummary 打印流水的归属与是否计入单位总分。 +func ledgerSummary(ledger []LedgerEntry) []string { + out := make([]string, 0, len(ledger)) + for _, l := range ledger { + out = append(out, fmt.Sprintf("team=%d athlete=%d entry=%d result=%d points=%v countToTeam=%v", + l.TeamID, l.AthleteID, l.EntryID, l.ResultID, l.Points, l.CountToTeam)) + } + return out +} diff --git a/server/internal/domain/helpers_test.go b/server/internal/domain/helpers_test.go index 27b9c27..86d86a9 100644 --- a/server/internal/domain/helpers_test.go +++ b/server/internal/domain/helpers_test.go @@ -84,6 +84,8 @@ type participant struct { state ResultState // shares 是联队份额(多个单位共享一条成绩);留空 = 主单位独占 100%。 shares []TeamShare + // members 是团体报名的队员(团队项目的成员运动员);留空 = 只报单位(老数据)。 + members []EntryAthlete } func fixture(ps []participant) ([]Entry, []Result) { @@ -93,7 +95,7 @@ func fixture(ps []participant) ([]Entry, []Result) { entries = append(entries, Entry{ ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID, AthleteID: p.athleteID, AthleteName: p.name, - Status: p.entryStatus, TeamShares: p.shares, + Status: p.entryStatus, TeamShares: p.shares, Athletes: p.members, }) state := p.state if state == "" { diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index 5d187a7..fb85e0f 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -69,6 +69,14 @@ type RankedResult struct { AwardMedals bool CountToMedals bool CountToTeamMedals bool + // MemberCopy 为 true 表示这条名次是「团体报名的队员各拿一份」的个人副本(devdocs/20): + // 分记到 AthleteID 头上(个人榜),但**不进单位维度**——流水恒为 count_to_team=false, + // 单位奖牌按报名项去重时也只认报名项那一份。 + // + // 它只在「引擎产出 → 落库」这一段有意义:channel_ranks 不存这个开关,落库后认出这些行 + // 靠 subject_key 的 'e:<报名项>:a:<运动员>' 形状与 athlete_id 关联,而下游(榜单、导出、 + // 快照)本来就不需要它——它们看的是「按报名项去重」与 athlete_id 这两件既有事实。 + MemberCopy bool } // ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。 @@ -430,12 +438,65 @@ func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, gro for _, g := range groups { pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category) for _, c := range g.members { - out = append(out, rankedResult(in, ch, c, g.startRank, pts)) + out = append(out, entryRankedResults(in, ch, c, g.startRank, pts)...) } } return out } +// entryRankedResults 产出一条报名的**全部**名次明细:第一条是报名项自己那份(单位维度只认它, +// 老数据也只有这一条),后面是每个队员的个人副本(团队报名才有,每人一份完整的个人分)。 +// +// 顺序是有意的:报名项那一份排在最前。channel_ranks 按 (…, 名次, id) 读出、奖牌去重按报名项 +// 取同键里的第一条,于是「单位维度读到的是哪一份」与升级前完全一致。 +func entryRankedResults(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) []RankedResult { + base := rankedResult(in, ch, c, rank, points) + members := memberAthletes(ch, c.entry) + if len(members) == 0 { + return []RankedResult{base} + } + out := make([]RankedResult, 0, len(members)+1) + out = append(out, base) + for _, m := range members { + out = append(out, memberCopy(base, m)) + } + return out +} + +// memberAthletes 判断这条名次要不要给队员各发一份:只有**个人通道**(scope=athlete)才发。 +// 团体通道(scope=team)的名次本来就记在单位头上,队员不参与;带运动员的报名(个人报名) +// 自己就是那个人的身份,没有队员这回事。 +func memberAthletes(ch ScoreChannel, e Entry) []EntryAthlete { + if ch.Scope != ScopeAthlete || e.AthleteID != 0 { + return nil + } + return e.Athletes +} + +// memberCopy 把「报名项那一份名次」复制成某个队员的个人分:分数、名次、归属单位与份额全部照抄, +// 只把身份换成这个队员,并打上 MemberCopy 标记(落库时按它走「只记个人」的流水)。 +// +// 照抄 TeamID 与 Shares 是**必须的**:单位维度按报名项去重(standings.medalKeyOf), +// 无论同键里的哪一份胜出,单位拿到的分与牌都是同一份;换成队员自己的单位或另造份额, +// 就会在「谁在去重里胜出」这件事上分叉,联队报名尤其容易给错单位记牌。 +func memberCopy(base RankedResult, m EntryAthlete) RankedResult { + cp := base + cp.MemberCopy = true + cp.AthleteID = m.AthleteID + cp.AthleteName = m.Name + cp.SubjectKey = memberSubjectKey(base.EntryID, m.AthleteID) + return cp +} + +// memberSubjectKey 是队员个人副本的主体标识:'e:<报名项>:a:<运动员>'。 +// +// 必须带上报名项:同一个运动员可以同时出现在同组别的两条报名里(例如一个单位的一队与二队 +// 各带了他),只用 'a:<运动员>' 会撞 channel_ranks 的 (通道, 组别, 主体) 唯一索引, +// 整次发布直接 500。既有名次键(e: / a: / t: / ch:)一个字符都没变,老行不受影响。 +func memberSubjectKey(entryID, athleteID int64) string { + return fmt.Sprintf("e:%d:a:%d", entryID, athleteID) +} + // groupPoints 计算一组并列者拿到的分。 // 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1; // lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。 @@ -684,6 +745,9 @@ func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) { // 0 分不产生流水,避免流水表被零分噪声撑大。 // 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。 // +// 队员的个人副本走另一条路(memberLedger):每个队员一整份分、只记个人(count_to_team=false), +// 于是单位总分与单位奖牌一个字节都不动——那是本单最容易错的地方,见 memberLedger。 +// // 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0), // 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id, // 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」 @@ -694,10 +758,6 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult if rr.Points == 0 { continue } - athleteID := int64(0) - if rr.Scope == ScopeAthlete { - athleteID = rr.AthleteID - } sourceID := rr.ResultID if sourceID == 0 { sourceID = rr.EntryID @@ -705,6 +765,14 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult if sourceID == 0 { sourceID = ch.ID } + if rr.MemberCopy { + out = append(out, memberLedger(rr, in, ch, sourceID)) + continue + } + athleteID := int64(0) + if rr.Scope == ScopeAthlete { + athleteID = rr.AthleteID + } split := splitSharePoints(rr.Points, rr.Shares) for i, s := range rr.Shares { if split[i] == 0 { @@ -728,6 +796,29 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult return out } +// memberLedger 是队员个人副本的流水:**只记个人**(CountToTeam 恒为 false), +// 每个队员一整份分,不按份额拆(联队那套是「拆」,这里是「复制」,别抄混)。 +// +// 为什么恒为 false、而不是照抄通道开关:单位维度只认报名项那一份流水(它照抄通道开关), +// 队员这两份若也计入,单位总分就会变成 N 份(双打就是双倍),单位奖牌同理。 +// SumByTeam 遇到 count_to_team=0 直接跳过,所以单位总分、单位明细、单位奖牌全都不会看到它们; +// 而 SumByAthlete 不看这个开关,队员的个人分照拿(这也正是「双打不进单位、但上个人榜」的实现)。 +func memberLedger(rr RankedResult, in EventScoreInput, ch ScoreChannel, sourceID int64) LedgerEntry { + return LedgerEntry{ + TeamID: rr.TeamID, + AthleteID: rr.AthleteID, + EventID: in.Event.ID, + CategoryID: in.Category.ID, + EntryID: rr.EntryID, + ResultID: rr.ResultID, + ChannelID: ch.ID, + SourceType: SourceResult, + SourceID: sourceID, + Points: rr.Points, + CountToTeam: false, + } +} + // subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径): // 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位), // 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。 diff --git a/server/internal/domain/standings.go b/server/internal/domain/standings.go index 799b8c5..d824598 100644 --- a/server/internal/domain/standings.go +++ b/server/internal/domain/standings.go @@ -298,9 +298,12 @@ func medalKeyOf(rr RankedResult, index int) medalKey { return k } -// pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细去重成「每个主体一条」: +// pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细按 keyOf 去重成「每个主体一条」: // 只保留优先级最高的通道名次(个人通道优先),否则双通道项目一条成绩会拿两枚奖牌。 -func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult { +// +// 去重键由调用方给:**单位维度按报名项**(medalKeyOf,一条报名只记一枚牌), +// **个人维度按报名项 + 运动员**(athleteMedalKeyOf,团体报名的队员各记各的)。 +func pickMedalChannels(ranked []RankedResult, excludeTeam bool, keyOf func(RankedResult, int) medalKey) []RankedResult { best := make(map[medalKey]RankedResult, len(ranked)) order := make([]medalKey, 0, len(ranked)) for i, rr := range ranked { @@ -310,7 +313,7 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult { if excludeTeam && rr.Scope == ScopeTeam { continue } - k := medalKeyOf(rr, i) + k := keyOf(rr, i) cur, ok := best[k] if !ok { best[k] = rr @@ -328,10 +331,31 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult { return out } +// athleteMedalKeyOf 是个人维度的奖牌去重键:在单位维度的键上再带上运动员。 +// +// 团体报名的队员各有一份个人副本,它们与报名项那一份共享 entry_id——只按报名项去重的话, +// 三份会并成一条(且胜出的是排在最前的报名项那份,AthleteID=0,个人维度根本不看它), +// 队员的个人奖牌会**一枚都发不出来**。带上运动员之后:同一报名项同一运动员仍旧只记一枚 +// (多通道项目不会重复发牌),不同队员各记各的。 +// +// 只对「有报名项」的名次加这一维:没有报名项的聚合名次本来就用主体区分(medalKeyOf 已处理), +// 老数据的名次构不成同一个键下的多条,行为逐字节不变。 +func athleteMedalKeyOf(rr RankedResult, index int) medalKey { + k := medalKeyOf(rr, index) + if k.entryID != 0 && rr.AthleteID != 0 { + k.scope, k.subject = ScopeAthlete, rr.AthleteID + } + return k +} + // teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:每个份额各记**一枚整牌**, // 联队拿到的金牌因此两个成员单位各记 1 枚(不是各 0.5 枚,口径见 TeamShare.Medal)。 // 份额里的 Medal 显式关掉时该单位不记这枚牌(积分份额 Weight 不参与奖牌计算)。 // +// 去重按**报名项**(medalKeyOf):一条报名在同一小项同一组别只记一枚牌,而团体报名的队员 +// 个人副本与报名项那一份共享 entry_id,因此**不会把单位奖牌数放大成队员人数倍** +// ——这是本单最容易错的地方,由 TestTeamEntryMembersKeepTeamDimensionByteIdentical 钉住。 +// // 两层开关(devdocs 17 §4.5): // - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜; // - 通道开关 count_to_team_medals:scope=athlete 的通道置 false 时,它的个人奖牌不进单位榜。 @@ -339,7 +363,7 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult { // 两者是「与」的关系;scope=team 的奖牌本来就记在单位头上,不受通道开关影响。 func teamMedals(in StandingsInput) map[int64]medalTally { out := make(map[int64]medalTally) - for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) { + for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals, medalKeyOf) { if rr.Scope != ScopeTeam && !rr.CountToTeamMedals { continue } @@ -368,9 +392,12 @@ func teamMedals(in StandingsInput) map[int64]medalTally { // athleteMedals 只统计个人项目(scope=athlete)的奖牌; // 通道的 count_to_team_medals 与个人榜无关,只决定这枚奖牌算不算进单位榜。 +// +// 去重按**报名项 + 运动员**(athleteMedalKeyOf):团体报名的每个队员各记一枚自己的奖牌 +// (他们共享同一条报名项,只按报名项去重会把队员的奖牌并成一枚、甚至一枚都不发)。 func athleteMedals(in StandingsInput) map[int64]medalTally { out := make(map[int64]medalTally) - for _, rr := range pickMedalChannels(in.Ranked, false) { + for _, rr := range pickMedalChannels(in.Ranked, false, athleteMedalKeyOf) { if rr.Scope != ScopeAthlete || rr.AthleteID == 0 { continue } diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index 6d58aa0..0fb04d7 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -311,6 +311,18 @@ func (s TeamShare) CountsMedal() bool { return s.Medal == nil || *s.Medal } +// EntryAthlete 是团体报名里的一名队员(团队成员),身份靠运动员档案(AthleteID)确立; +// 人工认人时以**号码布**为准(姓名会重名,号码布不会,见迁移 0014 与 devdocs/20)。 +// +// 与 TeamShare 对称但语义**相反**(devdocs/20 §二):TeamShare 是「一条报名 → 多个单位、 +// 按权重**拆**分」,EntryAthlete 是「一条报名 → 多个运动员、每人各拿**整份**分」。 +// 抄联队那套「拆」会把双打两个人的分各砍一半,这正是本单最容易抄错的地方。 +type EntryAthlete struct { + AthleteID int64 `json:"athleteId"` + // Name 是冗余姓名,与 Entry.AthleteName 同一个用途:引擎不必回查库就能产出个人榜的那一行。 + Name string `json:"name,omitempty"` +} + // Entry 报名项:某单位派某人/某队参加某小项。TeamID 是**主单位**(联队时也保留它, // 报名项的唯一键、榜单兜底、导入去重都还认这一个),联队的其余成员单位在 TeamShares 里。 type Entry struct { @@ -328,6 +340,10 @@ type Entry struct { // TeamShares 是这条报名的归属份额配置:空 = 主单位独占 100%(老数据、普通报名), // 多条 = 联队。引擎只读不改,取分的唯一入口是 ResolveShares。 TeamShares []TeamShare `json:"teamShares,omitempty"` + // Athletes 是这条报名的队员(团队 / 对抗项目的成员),空 = 只报单位(升级前的老数据、 + // 以及「人没录档案只登记姓名」的降级形态)。只对纯团体报名(AthleteID == 0)有意义。 + // 队员各拿一份完整的个人分与个人奖牌;单位维度仍然只算这条报名一次。 + Athletes []EntryAthlete `json:"athletes,omitempty"` } // Result 成绩;一条报名项一条成绩。 diff --git a/server/internal/export/entries.go b/server/internal/export/entries.go index 02c55ca..8408f77 100644 --- a/server/internal/export/entries.go +++ b/server/internal/export/entries.go @@ -128,6 +128,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie if err != nil { return EntriesWorkbook{}, err } + // 队员关联(devdocs/20):整份名单读一次,逐行按 entry_id 取。 + members, err := loadEntryMembers(db) + if err != nil { + return EntriesWorkbook{}, err + } query := ` SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''), @@ -206,7 +211,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie &row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil { return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err) } - row.Name = participantName(athleteName, memberJSON) + row.Name = participantLabel(athleteName, memberJSON, members[entryID]) if heat.Valid { row.Heat = strconv.FormatInt(heat.Int64, 10) } diff --git a/server/internal/export/entry_members.go b/server/internal/export/entry_members.go new file mode 100644 index 0000000..d466be9 --- /dev/null +++ b/server/internal/export/entry_members.go @@ -0,0 +1,143 @@ +package export + +import ( + "database/sql" + "fmt" + "strings" +) + +// 本文件是「团体报名的队员」(devdocs/20)在导出侧的读法:一条团体报名可以关联多个运动员 +// (`entry_athletes`),导出件上要看得出队里坐着谁。 +// +// 写法是 `姓名(号码布)`:号码布是运动员的唯一标识(迁移 0014、devdocs/20 §二), +// 光写名字遇到重名就认不出是谁。多个队员用顿号连起来——与 member_names 原有的写法同形, +// 拿导出件对账的人不必为两种名单学两套读法。 +// +// member_names **不因为挂了队员而消失**:它是显示用的文字名单,里面没被队员认走的那些名字 +// (人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。同一个口径也用在 +// 大屏侧(web/src/display/members.ts),两处必须一致,否则同一场比赛的同一支队伍 +// 在导出件与屏幕上会写成两份名单。 +// +// 队员**不改每一行的高度与条数**:一条报名仍然只占一行(护栏见 entry_members_rows_test.go), +// 队员只让这一格更长。 + +// entryMember 是报名里一名队员的展示字段。身份是 athlete_id(引擎按它发个人分与个人奖牌), +// 这里只读展示需要的两列:姓名与号码布。 +type entryMember struct { + Name string + Bib string +} + +// entryMembersReady 判断队员表在不在。 +// +// 表不在(库还没跑到 0026)= 库里没有任何队员关联 = 只报单位,与升级前一致, +// 所以按「没有队员」处理是**精确**的,不是容错降级(与 entrySharesReady 同一判据)。 +func entryMembersReady(db *sql.DB) (bool, error) { + var n int + if err := db.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil { + return false, fmt.Errorf("export: 查询报名队员表: %w", err) + } + return n != 0, nil +} + +// loadEntryMembers 读全库的队员关联,按报名 id 归组。 +// +// 顺序按 sort_order(写侧按请求给的顺序编号,老数据按文字名单的书写顺序回填): +// 展示顺序稳定,导出件不会因为主键顺序而变。各导出件都读一次给全部工作表复用—— +// 逐张表去查是白跑几十遍(与 loadJointTeams 同一个取舍)。 +func loadEntryMembers(db *sql.DB) (map[int64][]entryMember, error) { + ready, err := entryMembersReady(db) + if err != nil { + return nil, err + } + if !ready { + return nil, nil + } + rows, err := db.Query(` + SELECT ea.entry_id, COALESCE(a.name, ''), COALESCE(a.bib, '') + FROM entry_athletes ea + JOIN athletes a ON a.id = ea.athlete_id + ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`) + if err != nil { + return nil, fmt.Errorf("export: 读取报名队员: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64][]entryMember) + for rows.Next() { + var ( + entryID int64 + m entryMember + ) + if err := rows.Scan(&entryID, &m.Name, &m.Bib); err != nil { + return nil, fmt.Errorf("export: 扫描报名队员: %w", err) + } + out[entryID] = append(out[entryID], m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历报名队员: %w", err) + } + return out, nil +} + +// memberLabel 把一名队员写成导出件上的字:`张伟(A001)`。 +// +// 姓名或号码布缺一个时只写有的那个(不留「()」这种空壳);两个都空的人在名单里一个字都不写。 +func memberLabel(m entryMember) string { + name := strings.TrimSpace(m.Name) + bib := strings.TrimSpace(m.Bib) + switch { + case name != "" && bib != "": + return name + "(" + bib + ")" + case name != "": + return name + default: + return bib + } +} + +// coveredByMember 判断文字名单里的一个名字是不是已经由某名队员写过了。 +// +// 判据与迁移 0026 的回填同源:文字名单里写的可能是**姓名**,也可能是**号码布** +// (导入规范允许「队员名单」列填号码布)。认走之后不再重复写一遍,否则一个队员在导出件上 +// 会出现两次(「王五(A001)、王五」这种)。 +func coveredByMember(name string, members []entryMember) bool { + for _, m := range members { + if name == strings.TrimSpace(m.Name) || name == strings.TrimSpace(m.Bib) { + return true + } + } + return false +} + +// participantLabel 是报名名单 / 分项成绩表 / 全部成绩单页 / 单位奖牌明细共用的 +// 「姓名」「运动员 / 队」列取值。 +// +// 没有队员关联时**逐字**等于 participantName:个人写姓名、团体写文字名单,一个字都不多 +// ——老数据(含迁移按文字名单回填不上人的那些)的导出件不许有任何变化。 +func participantLabel(athleteName, memberJSON string, members []entryMember) string { + if len(members) == 0 { + return participantName(athleteName, memberJSON) + } + out := make([]string, 0, len(members)+1) + for _, m := range members { + if label := memberLabel(m); label != "" { + out = append(out, label) + } + } + seen := make(map[string]bool) + for _, name := range memberNamesOf(memberJSON) { + name = strings.TrimSpace(name) + if name == "" || seen[name] || coveredByMember(name, members) { + continue + } + seen[name] = true + out = append(out, name) + } + if len(out) == 0 { + // 队员既没姓名也没号码布、文字名单也是空的:退回原来的取值,不把这一格写空。 + return participantName(athleteName, memberJSON) + } + return strings.Join(out, "、") +} diff --git a/server/internal/export/entry_members_dump_test.go b/server/internal/export/entry_members_dump_test.go new file mode 100644 index 0000000..f2dc049 --- /dev/null +++ b/server/internal/export/entry_members_dump_test.go @@ -0,0 +1,67 @@ +package export + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 本文件是「没有队员关联的报名,导出件逐行一致」这条硬门槛的对拍工具(devdocs/20 的导出侧)。 +// +// 做法与 squad_dump_test.go 完全一致:把每一份导出件的每一张工作表、每一个单元格 dump 成文本 +// (`工作簿|工作表|行|列|值`),先落盘到 EXPORT_MEMBERS_DUMP 指定的路径(不设时写 t.TempDir(), +// 平时只当一次「全部导出件都能加载」的冒烟)。把**同一个文件**放到改动前的代码上再跑一次 +// (对拍目录里的那份树只补这个用例、不补源码改动),两份 dump `diff` 必须为空。 +// +// 为什么这份数据里没有 entry_athletes 行:老库升级后绝大多数报名都没有队员关联(只报单位的 +// 团体报名、个人报名、只登记文字名单的报名),本单不许让它们的导出件发生任何变化——包括 +// 因为「多查了一张表」「多拼了一个空串」而变的那一种。挂上队员之后的变化由下面的灵敏度自检盯着。 + +// TestMemberlessExportDumpIsUnchanged 见文件头。数据集复用 seedSquadDumpEvent:它覆盖了 +// 个人小项(含团体行回落成员名单)、纯团体报名、联队份额与队名,且**一条 entry_athletes 都没有**。 +func TestMemberlessExportDumpIsUnchanged(t *testing.T) { + db := openEventDB(t) + seedSquadDumpEvent(t, db) + + opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}} + memberless := dumpAllWorkbooks(t, db, opts) + + // 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。 + path := os.Getenv("EXPORT_MEMBERS_DUMP") + if path == "" { + path = filepath.Join(t.TempDir(), "members-empty-dump.txt") + } + if err := os.WriteFile(path, []byte(memberless), 0o644); err != nil { + t.Fatalf("写出 dump: %v", err) + } + t.Logf("无队员 dump:%s(%d 个单元格)", path, strings.Count(memberless, "\n")) + + // 灵敏度自检:给双打那条报名挂上一名队员(另一名仍然是文字名单里的人),重新 dump,两次必须不同。 + // 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到队员列——那就白对拍了。 + // 在对拍用的旧代码上这条自检本来就会红(旧代码根本不读 entry_athletes),正说明两份 dump 的 + // 差异只可能来自这次改动。 + stmts := []string{ + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A001')`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) + SELECT e.id, a.id, 0 FROM entries e, athletes a + WHERE e.event_id = 2 AND e.member_names LIKE '%王磊%' AND a.name = '王磊'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + withMembers := dumpAllWorkbooks(t, db, opts) + if withMembers == memberless { + t.Fatal("给报名挂上队员后 dump 没变:这份对拍看不见队员列,对拍结论无效") + } + // 关联上的写「姓名(号码布)」,没关联上的文字名字接在后面(人没录档案的临时队员照旧在名单里)。 + if !strings.Contains(withMembers, "王磊(A001)、赵雷") { + t.Fatal("挂队员后的 dump 里找不到「王磊(A001)、赵雷」:队员没有按「姓名(号码布)」写进导出件") + } + if strings.Contains(memberless, "(A001)") { + t.Fatal("无队员的 dump 里不该出现号码布写法") + } +} diff --git a/server/internal/export/entry_members_rows_test.go b/server/internal/export/entry_members_rows_test.go new file mode 100644 index 0000000..063ee17 --- /dev/null +++ b/server/internal/export/entry_members_rows_test.go @@ -0,0 +1,71 @@ +package export + +import ( + "database/sql" + "testing" +) + +// 本文件钉住「团体报名挂上队员之后,成绩表里这条报名仍然只占一行」(devdocs/20 的导出侧边界)。 +// +// 原因:一条报名在同一通道下会有 1 + N 行 channel_ranks(报名项自己那份 + 每个队员一份个人副本), +// 成绩表按 (通道, 报名, 组别) JOIN 名次时会把这条报名印 N+1 遍——现场看到的就是「双打报了三次」。 +// 名次取「报名项那一份」(最小 id),值与升级前一致。 +func TestEventRowsKeepOneRowPerEntryWithMembers(t *testing.T) { + db := openEventDB(t) + seedEntryWithMembers(t, db) + + rows, err := loadEventRows(db, 1, 0, true, map[int64]jointTeam{}) + if err != nil { + t.Fatalf("读成绩行: %v", err) + } + if len(rows) != 1 { + t.Fatalf("成绩表应只有一条报名那一行,得到 %d 行:%+v", len(rows), rows) + } + if rows[0].Rank != "1" { + t.Errorf("名次应取报名项那一份(第 1 名),得到 %q", rows[0].Rank) + } + // 运动员列写队员的「姓名(号码布)」:号码布是唯一标识,导出侧要认得出是谁上了场 + // (写法与理由见 entry_members.go)。这条报名仍然只占这一行——挂队员不许把行展开。 + if rows[0].Participant != "王五(A001)、赵六(A002)" { + t.Errorf("队员名单应写成「姓名(号码布)」,得到 %q", rows[0].Participant) + } +} + +// seedEntryWithMembers 造一条「双打」团体报名:队员关联两条,channel_ranks 里三条名次 +// (报名项 + 两个队员的个人副本),与发布后的真实落库形状一致。 +func seedEntryWithMembers(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E1', '羽毛球双打', 'team', 'auto', 'count', 'desc', 'published')`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, member_names) + VALUES (1, 1, 1, '["王五","赵六"]')`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 0)`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 2, 1)`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 1, 1, '21', 21, '分', 'published')`, + // 报名项那一份(先落库,id 最小)+ 两个队员的个人副本。 + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, rank, points) + VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, athlete_id, rank, points) + VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:1', 1, 1, 1, 1, 10)`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, athlete_id, rank, points) + VALUES (1, 1, 1, 'result', 'athlete', 'e:1:a:2', 1, 1, 2, 1, 10)`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} diff --git a/server/internal/export/entry_members_test.go b/server/internal/export/entry_members_test.go new file mode 100644 index 0000000..d5d5a16 --- /dev/null +++ b/server/internal/export/entry_members_test.go @@ -0,0 +1,263 @@ +package export + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// 本文件钉住导出侧的「团体报名的队员」(devdocs/20):报名名单的「姓名」列、分项成绩表 / +// 全部成绩单页 / 单位奖牌明细的「运动员 / 队」列,都要看得出这条报名里坐着谁。 +// +// 写法是 `姓名(号码布)`(断言里的字面量就是导出件上那一格的字):号码布是运动员的唯一标识 +// (迁移 0014、devdocs/20 §二),光写名字遇到重名认不出人。多个队员用顿号连起来——与 +// member_names 原有的写法同形,读者不必为两种名单学两套读法。 +// +// member_names **不因为挂了队员而消失**:它是文字名单,里面没被队员关联认走的那些名字 +// (人没录档案、临时借调的)照旧接在后面。两种情况都要能一眼看出谁上了场。 +// +// 一条报名仍然只占一行(护栏在 entry_members_rows_test.go):队员只让这一行更长, +// 不把报名展开成 N+1 行。 + +// seedDoublesMembers 造一个「双打」小项,三种报名形态各一条: +// - 挂了队员的团体报名(文字名单里还多一个没录档案的名字); +// - 只有文字名单的团体报名(升级前的老形状,用来钉住「没有队员关联时逐字不变」); +// - seedEvent 的个人小项(100 米跑),个人报名的姓名一个字都不该变。 +// +// channel_ranks 与流水的形状与发布后的真实落库一致:一条报名 1 + N 行名次(报名项自己那份 +// 排在 id 最小的位置 + 每个队员一份个人副本,subject_key 是 'e:<报名项>:a:<运动员>'), +// 队员那两份流水恒 count_to_team=0(只进个人榜,不进单位总分)。 +func seedDoublesMembers(t *testing.T, db *sql.DB) { + t.Helper() + seedEvent(t, db) // T1/T2、张三 / 李四、100 米跑(两条已发布成绩与流水) + stmts := []string{ + `INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A001')`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '男', 1, 'A002')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`, + // 组别 id 是 2(seedEvent 的 100 米跑已经占了 id 1):报名与名次都必须挂在它名下, + // 否则导出按 (小项, 组别) 取成绩时一条都取不到。 + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`, + // 双打配的是个人通道(队长口径:上个人积分榜);通道的 count_to_team 由现场在通道里配, + // 这里留默认 1——单位维度只算这条报名一次,与通道开关是两件事(见 domain.memberLedger)。 + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, member_names, status) + VALUES (2, 2, 1, '["王五","赵六","临时借调的老周"]', 'registered')`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 0)`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 4, 1)`, + `INSERT INTO entries(event_id, category_id, team_id, member_names, status) + VALUES (2, 2, 2, '["孙七","周八"]', 'registered')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (2, 3, 1, '21', 21, 'pts', 'published')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (2, 4, 2, '18', 18, 'pts', 'published')`, + // 报名项那一份先落(id 最小 = 单位维度与成绩表读到的那一份),后面是队员的个人副本。 + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, team_id, rank, points) + VALUES (2, 2, 2, 'result', 'athlete', 'e:3', 3, 3, 1, 1, 10)`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, athlete_id, team_id, rank, points) + VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:3', 3, 3, 3, 1, 1, 10)`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, athlete_id, team_id, rank, points) + VALUES (2, 2, 2, 'result', 'athlete', 'e:3:a:4', 3, 3, 4, 1, 1, 10)`, + `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, + entry_id, result_id, team_id, rank, points) + VALUES (2, 2, 2, 'result', 'athlete', 'e:4', 4, 4, 2, 2, 8)`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, + points, count_to_team) + VALUES (1, 2, 3, 3, 'result', 3, 10, 1)`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, + source_id, points, count_to_team) + VALUES (1, 3, 2, 3, 3, 'result', 3, 10, 0)`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, + source_id, points, count_to_team) + VALUES (1, 4, 2, 3, 3, 'result', 3, 10, 0)`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, + points, count_to_team) + VALUES (2, 2, 4, 4, 'result', 4, 8, 1)`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// 报名名单:「姓名」列写队员的「姓名(号码布)」,文字名单里没被认走的那个名字接在后面; +// 没有队员关联的报名(老形状)一个字都不变。 +func TestEntriesWorkbookShowsEntryMembers(t *testing.T) { + db := openEventDB(t) + seedDoublesMembers(t, db) + + wb, err := LoadEntriesWorkbook(db, EntriesFilter{EventID: 2}, Options{}) + if err != nil { + t.Fatalf("读报名名单: %v", err) + } + if len(wb.Rows) != 2 { + t.Fatalf("双打应有 2 条报名,得到 %d 条:%+v", len(wb.Rows), wb.Rows) + } + if got := wb.Rows[0].Name; got != "王五(A001)、赵六(A002)、临时借调的老周" { + t.Errorf("挂队员那行的姓名 = %q,期望 王五(A001)、赵六(A002)、临时借调的老周", got) + } + if got := wb.Rows[1].Name; got != "孙七、周八" { + t.Errorf("没挂队员那行的姓名 = %q,期望 孙七、周八(与升级前逐字一致)", got) + } + + // 导出件本身(落盘那份与响应体同源)也要是这一格的字。 + dst := filepath.Join(t.TempDir(), "报名名单.xlsx") + if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil { + t.Fatalf("导出报名名单: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + rows, err := f.GetRows(entriesSheet) + if err != nil { + t.Fatalf("读取报名名单工作表: %v", err) + } + // 4 行版式 + 4 条报名(个人两条 + 双打两条);姓名是第 6 列。 + names := make([]string, 0, len(rows)) + for i := 4; i < len(rows); i++ { + names = append(names, cell(rows, i, 5)) + } + want := []string{"张三", "李四", "王五(A001)、赵六(A002)、临时借调的老周", "孙七、周八"} + if !equalStrings(names, want) { + t.Errorf("报名名单姓名列 = %v,期望 %v", names, want) + } +} + +// 分项成绩表与全部成绩单页的「运动员/队」列:队员带号码布写全,且**一条报名只占一行**。 +func TestEventWorkbookShowsEntryMembers(t *testing.T) { + db := openEventDB(t) + seedDoublesMembers(t, db) + + wb, err := LoadEventWorkbook(db, 2, Options{}) + if err != nil { + t.Fatalf("读分项成绩: %v", err) + } + if len(wb.Sheets) != 1 { + t.Fatalf("双打只有 1 个组别,得到 %d 张表", len(wb.Sheets)) + } + rows := wb.Sheets[0].Rows + if len(rows) != 2 { + t.Fatalf("成绩表应只有 2 条报名那两行,得到 %d 行:%+v", len(rows), rows) + } + if rows[0].Participant != "王五(A001)、赵六(A002)、临时借调的老周" { + t.Errorf("第 1 名运动员/队 = %q,期望队员带号码布写全", rows[0].Participant) + } + if rows[0].Rank != "1" { + t.Errorf("第 1 名名次 = %q,期望 1(取报名项那一份名次)", rows[0].Rank) + } + if rows[1].Participant != "孙七、周八" { + t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", rows[1].Participant) + } + + flat, err := LoadFlatWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读全部成绩单页: %v", err) + } + var found bool + for _, sh := range flat.Sheets { + for _, r := range sh.Rows { + if r.Participant == "王五(A001)、赵六(A002)、临时借调的老周" { + found = true + } + } + } + if !found { + t.Error("全部成绩单页里找不到队员带号码布的写法") + } +} + +// 单位奖牌明细的「运动员/队」列:这块奖牌是哪支队拿的,队员照样写全。 +func TestMedalDetailShowsEntryMembers(t *testing.T) { + db := openEventDB(t) + seedDoublesMembers(t, db) + + wb, err := LoadMedalDetailWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读单位奖牌明细: %v", err) + } + var participants []string + for _, sheet := range wb.Sheets { + for _, r := range sheet.Rows { + if r.EventName == "男子双打" { + participants = append(participants, r.Participant) + } + } + } + if len(participants) != 2 { + t.Fatalf("两块奖牌各一行,得到 %d 行:%v", len(participants), participants) + } + if participants[0] != "王五(A001)、赵六(A002)、临时借调的老周" { + t.Errorf("双打金牌那行 = %q,期望队员带号码布写全", participants[0]) + } + if participants[1] != "孙七、周八" { + t.Errorf("没挂队员那行 = %q,期望 孙七、周八(与升级前逐字一致)", participants[1]) + } +} + +// 个人得分明细:一条双打报名在个人维度上是**每人一行、每人一整份**(不是一行两个人、 +// 也不是一行半份)。这是主人定的口径(devdocs/20 §二):每个队员各拿一份完整的个人分, +// 于是两个人都能上个人积分榜——导出件上就该是这样两行。 +// +// 名单里没关联上人的名字(临时借调的)**不产生个人行**:他上不了个人榜,导出件也不该凭空给他记分。 +func TestAthleteDetailGivesEachMemberAFullCopy(t *testing.T) { + db := openEventDB(t) + seedDoublesMembers(t, db) + + wb, err := LoadAthleteDetailWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读个人得分明细: %v", err) + } + byName := make(map[string][]AthleteDetailRow) + for _, sheet := range wb.Sheets { + for _, r := range sheet.Rows { + byName[r.AthleteName] = append(byName[r.AthleteName], r) + } + } + for _, name := range []string{"王五", "赵六"} { + rows := byName[name] + if len(rows) != 1 { + t.Fatalf("%s 应有 1 行个人明细,得到 %d 行:%+v", name, len(rows), rows) + } + if rows[0].EventName != "男子双打" || rows[0].Points != 10 || rows[0].Rank != 1 { + t.Errorf("%s 的个人明细 = %+v,期望 男子双打 / 第 1 名 / 10 分(一整份,不拆)", name, rows[0]) + } + } + for _, name := range []string{"孙七", "周八", "临时借调的老周"} { + if rows := byName[name]; len(rows) != 0 { + t.Errorf("%s 没有个人关联 / 没有个人流水,不该出现在个人明细里,得到 %+v", name, rows) + } + } +} + +// 单位积分明细:队员的个人副本恒 count_to_team=0,所以这条报名在单位维度上**只算一次**—— +// 双打不会让单位的总分变成两份,单位明细里也只有报名项那一行。 +func TestTeamDetailCountsEntryOnceWithMembers(t *testing.T) { + db := openEventDB(t) + seedDoublesMembers(t, db) + + wb, err := LoadTeamDetailWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("读单位积分明细: %v", err) + } + var doubles []TeamDetailRow + for _, r := range wb.Rows { + if r.EventName == "男子双打" { + doubles = append(doubles, r) + } + } + if len(doubles) != 1 { + t.Fatalf("双打在单位明细里应只有 1 行(队员的副本不进单位总分),得到 %d 行:%+v", len(doubles), doubles) + } + if doubles[0].Points != 10 { + t.Errorf("单位实得分 = %v,期望 10(只算报名项那一份,不是 30)", doubles[0].Points) + } + if doubles[0].AthleteName != "" { + t.Errorf("单位明细的运动员列 = %q,期望留空(这一分记在单位头上)", doubles[0].AthleteName) + } +} diff --git a/server/internal/export/load.go b/server/internal/export/load.go index 5001599..018082b 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -431,18 +431,22 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, } sheets := make([]EventSheet, 0, len(events)) - // 联队配置读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。 + // 联队配置与队员关联各读一次给全部工作表复用:一个小项组别一张表,逐张去查会白跑几十遍。 joints, err := loadJointTeams(db) if err != nil { return EventsWorkbook{}, err } + members, err := loadEntryMembers(db) + if err != nil { + return EventsWorkbook{}, err + } for _, ev := range events { cats, err := loadCategories(db, ev.id) if err != nil { return EventsWorkbook{}, err } if len(cats) == 0 { - rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts, joints) + rows, err := loadEventRowsWithMembers(db, ev.id, 0, opts.IncludeDrafts, joints, members) if err != nil { return EventsWorkbook{}, err } @@ -450,7 +454,7 @@ func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, continue } for _, cat := range cats { - rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts, joints) + rows, err := loadEventRowsWithMembers(db, ev.id, cat.id, opts.IncludeDrafts, joints, members) if err != nil { return EventsWorkbook{}, err } @@ -529,12 +533,30 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) { // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 // includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished); // 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。 +// +// 队员关联在这里自己读一次。LoadEventWorkbook 逐张小项 / 组别表地调它,所以那边走的是 +// loadEventRowsWithMembers(读一次给全部表复用),这条只是给单表调用与既有用例留的入口。 func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam) ([]EventResultRow, error) { + members, err := loadEntryMembers(db) + if err != nil { + return nil, err + } + return loadEventRowsWithMembers(db, eventID, categoryID, includeDrafts, joints, members) +} + +// loadEventRowsWithMembers 是 loadEventRows 的本体:队员关联由调用方读好传进来。 +func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDrafts bool, joints map[int64]jointTeam, members map[int64][]entryMember) ([]EventResultRow, error) { // 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次, // 口径 = scope=athlete 优先,其次通道 sort_order 小者。 // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // 落不了库)回落到 results.rank,保持名次列不空白。 // + // 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN: + // 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份 + // 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。 + // 取最小 id = 报名项那一份(引擎先产出它),名次值与升级前完全一致; + // 老数据每 (通道, 组别, 报名项) 最多一行,两种写法逐字节等价。 + // // gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源: // 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩), // 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。 @@ -559,8 +581,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo WHERE c2.event_id = en.event_id ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id LIMIT 1) - LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id - AND cr.category_id = en.category_id + LEFT JOIN channel_ranks cr ON cr.id = ( + SELECT MIN(cr2.id) FROM channel_ranks cr2 + WHERE cr2.channel_id = mc.id AND cr2.entry_id = en.id AND cr2.category_id = en.category_id) LEFT JOIN ( SELECT e2.event_id, e2.category_id, COUNT(*) AS required, SUM(CASE WHEN r2.state = 'published' @@ -608,7 +631,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo &entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil { return nil, fmt.Errorf("export: 扫描成绩: %w", err) } - row.Participant = participantName(athleteName, memberJSON) + row.Participant = participantLabel(athleteName, memberJSON, members[entryID]) row.Rank = rankLabel(rank, isTie != 0) row.Status = statusLabel(resultStat, resultState, entryStat) row.Remark = joinRemark(entryRemark, resultNote) @@ -931,13 +954,25 @@ func participantName(athleteName, memberJSON string) string { if strings.TrimSpace(athleteName) != "" { return athleteName } - memberJSON = strings.TrimSpace(memberJSON) - if memberJSON == "" { - return "" - } - var names []string - if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 { + if names := memberNamesOf(memberJSON); names != nil { return strings.Join(names, "、") } - return memberJSON + // 空值或坏 JSON:原样写出来(历史上 member_names 被手工写成别的样子也不能丢字)。 + return strings.TrimSpace(memberJSON) +} + +// memberNamesOf 解析 member_names(库里是 JSON 数组文本);空值、坏 JSON、空数组都返回 nil。 +// +// **每一项原样返回**(不去空白、不丢空串):它直接写进老数据的导出件,改动它等于改动老导出件 +// 的每一个字。新的队员路径在合并文字名单时自己决定怎么去空白(见 participantLabel)。 +func memberNamesOf(memberJSON string) []string { + memberJSON = strings.TrimSpace(memberJSON) + if memberJSON == "" { + return nil + } + var raw []string + if err := json.Unmarshal([]byte(memberJSON), &raw); err != nil || len(raw) == 0 { + return nil + } + return raw } diff --git a/server/internal/export/medals.go b/server/internal/export/medals.go index 9337d94..8b293e4 100644 --- a/server/internal/export/medals.go +++ b/server/internal/export/medals.go @@ -200,7 +200,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err } excludeTeam := strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false") hits := medalHits(ranked, excludeTeam) - ctx, err := loadMedalContext(db) + // 队员关联读一次给全部奖牌行复用(与联队份额同一次读取的理由)。 + members, err := loadEntryMembers(db) + if err != nil { + return MedalDetailWorkbook{}, err + } + ctx, err := loadMedalContext(db, members) if err != nil { return MedalDetailWorkbook{}, err } @@ -340,10 +345,12 @@ type medalContext struct { categoryID int64 } -func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) { +// loadMedalContext 取每条成绩的展示字段;members 是报名 id → 队员(见 loadEntryMembers), +// 用来把团体报名的队员写进「运动员 / 队」列。 +func loadMedalContext(db *sql.DB, members map[int64][]entryMember) (map[int64]medalContext, error) { rows, err := db.Query(` SELECT r.id, COALESCE(s.name, ''), e.name, COALESCE(c.name, ''), - COALESCE(a.name, ''), COALESCE(en.member_names, ''), + COALESCE(a.name, ''), COALESCE(en.member_names, ''), en.id, COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0), e.id, COALESCE(c.id, 0) FROM results r @@ -363,14 +370,15 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) { resultID int64 athleteName string memberJSON string + entryID int64 c medalContext ) if err := rows.Scan(&resultID, &c.sportName, &c.eventName, &c.category, - &athleteName, &memberJSON, &c.sportSort, &c.eventSort, &c.catSort, + &athleteName, &memberJSON, &entryID, &c.sportSort, &c.eventSort, &c.catSort, &c.eventID, &c.categoryID); err != nil { return nil, fmt.Errorf("export: 扫描奖牌来源: %w", err) } - c.participant = participantName(athleteName, memberJSON) + c.participant = participantLabel(athleteName, memberJSON, members[entryID]) out[resultID] = c } if err := rows.Err(); err != nil { diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index c4ce668..2921a90 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -123,8 +123,13 @@ type entryDTO struct { Squad string `json:"squad"` AthleteID *int64 `json:"athleteId"` MemberNames []string `json:"memberNames"` - Bib *string `json:"bib"` - Seed *string `json:"seed"` + // Athletes 是这条报名的**队员**(团队 / 对抗项目的成员运动员),读侧始终下发数组 + // (空数组 = 只报单位,与升级前一致;不是 null,前端不必写两套渲染)。 + // 它是人员关联(athleteId),memberNames 只是显示用的文字名单——两者可以同时存在: + // 老数据、以及「人没录档案只登记姓名」的报名就只有 memberNames。 + Athletes []entryAthleteDTO `json:"athletes"` + Bib *string `json:"bib"` + Seed *string `json:"seed"` // Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。 Heat *int64 `json:"heat"` Status string `json:"status"` @@ -139,6 +144,19 @@ type entryDTO struct { TeamShares []entryTeamShareDTO `json:"teamShares"` } +// entryAthleteDTO 是报名上的一名**队员**(devdocs/20):团队 / 对抗项目的成员运动员。 +// +// - athleteId:人员档案的 id,**唯一身份**(认人时以号码布为准,姓名会重名); +// - name / bib:展示字段(前端显示「姓名(号码布)」),写侧不需要带。 +// +// 写侧(PUT /api/entries/{id}/athletes)只认 athleteId:把姓名 / 号码布认成人是导入与界面 +// 的事(导入的「队员名单」列可以写号码布),服务端不在这里二次猜测。 +type entryAthleteDTO struct { + AthleteID int64 `json:"athleteId"` + Name string `json:"name,omitempty"` + Bib *string `json:"bib,omitempty"` +} + // entryTeamShareDTO 是报名份额的一条(联队的一个成员单位)。 // // - weight:积分份额,**相对权重**(缺省 1)。引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价; diff --git a/server/internal/httpapi/entry_athletes.go b/server/internal/httpapi/entry_athletes.go new file mode 100644 index 0000000..759577d --- /dev/null +++ b/server/internal/httpapi/entry_athletes.go @@ -0,0 +1,280 @@ +package httpapi + +import ( + "context" + "database/sql" + "fmt" + "net/http" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" +) + +// 本文件是「团体报名的队员」(devdocs/20)的读写。 +// +// 表达方式:**队员是报名上的人员关联**(entry_athletes),不是新的一条报名,也不改任何唯一键。 +// 与联队(entry_team_shares)对称而语义相反:那边是「一条报名 → 多个单位、按权重**拆**分」, +// 这边是「一条报名 → 多个运动员、每人各拿**整份**分」(个人奖牌也是每人各一枚)。 +// +// 写侧只认**运动员 id**:把姓名 / 号码布认成人是导入与界面的事(导入口径见 devdocs/20 §三: +// 队员名单列可以填号码布),这里只负责把已经认准的身份留在报名上,不做二次猜测。 + +// maxEntryAthletes 是单条报名的队员上限。现场最大的团体项目(篮球、拔河)也就十几人, +// 给到 30 已经远超需要;上限的作用是拦住「一整格都是 id」这类误填——每个队员都会产出 +// 一条名次与一条个人流水,放进来多少就落多少。 +const maxEntryAthletes = 30 + +// entryAthletesReady 判断队员表在不在。 +// +// 与 entryTeamSharesReady 同一套:读路径要能读「还没跑到 0026 的库」(迁移在服务启动时补跑, +// 但榜单可能在那之前被读一次)。表不在 = 库里没有任何队员关联 = 只报单位,正是老数据本来 +// 就该得到的结果,所以按「没有队员」处理是**精确**的,不是容错降级。 +func entryAthletesReady(ctx context.Context, q queryer) (bool, error) { + var n int + if err := q.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil { + return false, err + } + return n != 0, nil +} + +// loadEntryAthletesQ 读一条 SQL 里给定范围内报名的队员,按 entry_id 归组,供引擎使用。 +// eventID 为 0 表示全库(导出与快照用,与它们读 channel_ranks 的范围一致)。 +// +// 排序按 (sort_order, athlete_id):写侧按请求给的顺序编号,展示顺序稳定; +// 引擎不依赖顺序(每个队员各拿一整份分,与先后无关)。 +func loadEntryAthletesQ(ctx context.Context, q queryer, eventID int64) (map[int64][]domain.EntryAthlete, error) { + ready, err := entryAthletesReady(ctx, q) + if err != nil { + return nil, internal(err) + } + if !ready { + return nil, nil + } + query := `SELECT ea.entry_id, ea.athlete_id, COALESCE(a.name, '') + FROM entry_athletes ea + JOIN athletes a ON a.id = ea.athlete_id` + args := []any{} + if eventID != 0 { + query += ` JOIN entries e ON e.id = ea.entry_id WHERE e.event_id = ?` + args = append(args, eventID) + } + query += ` ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id` + + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64][]domain.EntryAthlete) + for rows.Next() { + var ( + entryID int64 + m domain.EntryAthlete + ) + if err := rows.Scan(&entryID, &m.AthleteID, &m.Name); err != nil { + return nil, internal(err) + } + out[entryID] = append(out[entryID], m) + } + return out, rows.Err() +} + +// loadEntryAthletesDTOs 读某小项全部报名的队员读数形态:带上号码布(运动员的唯一标识), +// 前端据此显示「姓名(号码布)」并让人对得上下一次导入写的是谁。 +func (s *server) loadEntryAthletesDTOs(ctx context.Context, q queryer, eventID int64) (map[int64][]entryAthleteDTO, error) { + ready, err := entryAthletesReady(ctx, q) + if err != nil { + return nil, internal(err) + } + if !ready { + return nil, nil + } + rows, err := q.QueryContext(ctx, + `SELECT ea.entry_id, ea.athlete_id, a.name, a.bib + FROM entry_athletes ea + JOIN entries e ON e.id = ea.entry_id + JOIN athletes a ON a.id = ea.athlete_id + WHERE e.event_id = ? + ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64][]entryAthleteDTO) + for rows.Next() { + var ( + entryID int64 + item entryAthleteDTO + bib sql.NullString + ) + if err := rows.Scan(&entryID, &item.AthleteID, &item.Name, &bib); err != nil { + return nil, internal(err) + } + item.Bib = strPtr(bib) + out[entryID] = append(out[entryID], item) + } + return out, rows.Err() +} + +// entryAthletesOf 把一条报名的队员补齐成「始终有值」的数组:没有队员时是空数组而不是 null, +// 前端不必为「有没有队员」写两套渲染(与 memberNames 那种可空文本不同,队员是一组关联)。 +func entryAthletesOf(configured []entryAthleteDTO) []entryAthleteDTO { + if len(configured) == 0 { + return []entryAthleteDTO{} + } + return configured +} + +// replaceEntryAthletes 按给定顺序重写一条报名的队员(先删后插,幂等)。 +// +// 为什么每次都重写、而不是「有队员才写」:报名可以被改成另一个样子(换人、减人、清空)。 +// 只增不减会留下旧队员,那条报名以后一直按早就改掉的人头发个人分。 +// 传空数组是**合法值**:清空队员 = 只报单位(与升级前一致)。 +func replaceEntryAthletes(ctx context.Context, q queryer, entryID int64, athletes []entryAthleteDTO) error { + if _, err := q.ExecContext(ctx, `DELETE FROM entry_athletes WHERE entry_id = ?`, entryID); err != nil { + return internal(err) + } + for i, a := range athletes { + if _, err := q.ExecContext(ctx, + `INSERT INTO entry_athletes (entry_id, athlete_id, sort_order) VALUES (?, ?, ?)`, + entryID, a.AthleteID, i); err != nil { + return internal(err) + } + } + return nil +} + +// validateEntryAthletes 校验一次队员改动,返回给人看的 400 错误。 +// +// 规则(每一条都对着一种会算错的写法): +// - athleteId 必填且必须存在:队员身份只认运动员档案,姓名与号码布只是显示; +// - 同一个人只能出现一次:重复会让同一条成绩给同一个人记两份分、两枚牌; +// - 人数不超过 maxEntryAthletes:拦住「一整格都是 id」这类误填。 +func (s *server) validateEntryAthletes(ctx context.Context, athletes []entryAthleteDTO) error { + if len(athletes) > maxEntryAthletes { + return badRequest(fmt.Sprintf("一条报名的队员最多 %d 人:队伍人数不可能这么多,请检查是不是误填", maxEntryAthletes), nil) + } + ids := make([]int64, 0, len(athletes)) + seen := make(map[int64]bool, len(athletes)) + for i, a := range athletes { + if a.AthleteID == 0 { + return badRequest("athletes[].athleteId 必填:队员身份只认运动员档案里的那个人(不是姓名文字)", i) + } + if seen[a.AthleteID] { + return badRequest(fmt.Sprintf("athletes 里运动员 %d 出现了两次:同一条报名里一个人只能占一个位置", a.AthleteID), i) + } + seen[a.AthleteID] = true + ids = append(ids, a.AthleteID) + } + if len(ids) == 0 { + return nil + } + + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + rows, err := s.ev.QueryContext(ctx, + `SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, args...) + if err != nil { + return internal(err) + } + defer func() { _ = rows.Close() }() + exists := make(map[int64]bool, len(ids)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return internal(err) + } + exists[id] = true + } + if err := rows.Err(); err != nil { + return err + } + missing := make([]int64, 0) + for _, id := range ids { + if !exists[id] { + missing = append(missing, id) + } + } + if len(missing) > 0 { + return badRequest("athleteId 不存在(人员已被彻底删除时也会查不到)", missing) + } + return nil +} + +// updateEntryAthletes 整组替换一条报名的队员并重算它所在的小项组别。 +// +// 必须重算(与改份额那条接口同口径,而与改队名那条不同):队员决定「哪些人拿到个人分与 +// 个人奖牌」,改完不重算就是「界面显示换人了、榜单还是旧人」。队名 / 批次那类纯展示字段 +// 才走「不重算」那条路。 +func (s *server) updateEntryAthletes(ctx context.Context, entryID int64, athletes []entryAthleteDTO, actorID int64) (entryDTO, error) { + entry, err := s.getEntry(ctx, entryID) + if err != nil { + return entryDTO{}, err + } + // 带运动员的报名自己就是那个人的身份((组别, 单位, 运动员) 唯一),再挂队员等于说 + // 「这条报名既是一个人又是一支队」,两条口径会同时往个人榜上写。直接拒掉并说清原因。 + if entry.AthleteID != nil { + return entryDTO{}, badRequest("这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名", nil) + } + if err := s.validateEntryAthletes(ctx, athletes); err != nil { + return entryDTO{}, err + } + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return entryDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + if err := replaceEntryAthletes(ctx, tx, entryID, athletes); err != nil { + return entryDTO{}, err + } + score, err := recomputeLedgerTx(ctx, tx, entry.EventID, entry.CategoryID, actorID, "报名队员变更后重算") + if err != nil { + return entryDTO{}, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return entryDTO{}, err + } + if err := s.bumpEvent(ctx, tx, entry.EventID); err != nil { + return entryDTO{}, err + } + if err := s.bumpStandings(ctx, tx, actorID); err != nil { + return entryDTO{}, err + } + if err := tx.Commit(); err != nil { + return entryDTO{}, internal(err) + } + return s.getEntry(ctx, entryID) +} + +// updateEntryAthletesHandler 是 PUT /api/entries/{id}/athletes: +// 整组替换一条团体报名的队员(空数组 = 清空,只报单位),成功后返回这条报名的完整读数。 +func (s *server) updateEntryAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in entryAthletesPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + before, err := s.getEntry(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateEntryAthletes(r.Context(), id, in.Athletes, middleware.User(r.Context()).ID) + if err != nil { + return nil, err + } + s.audit(r.Context(), "entry.athletes.update", "entry", id, + map[string]any{"athletes": before.Athletes}, map[string]any{"athletes": updated.Athletes}) + return updated, nil + }) +} diff --git a/server/internal/httpapi/entry_athletes_test.go b/server/internal/httpapi/entry_athletes_test.go new file mode 100644 index 0000000..11eebac --- /dev/null +++ b/server/internal/httpapi/entry_athletes_test.go @@ -0,0 +1,249 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 本文件钉住「团体报名的队员身份」(devdocs/20)从报到榜的完整链路: +// - 一条团体报名可以关联多个运动员(队员),0 个 = 只报单位(与升级前一致); +// - 队员各拿**一份完整的**个人分、各一枚个人奖牌(不是按份额拆,别与联队抄混); +// - **单位维度一份都不多**:单位总分与单位奖牌仍只算这条报名一次, +// 由通道开关 count_to_team / count_to_team_medals 控制(双打现场是都不计入)。 + +// seedTeamEntryAthleteChannel 造一个「双打」小项:团体项目 + 一个按成绩排名的**个人**通道 +// (现场的双打就是给团体报名配个人通道,分才上得了个人积分榜)。 +// 两个单位开关由调用方给:现场是「都不计入」(0/0),默认是都计入(1/1)。 +func seedTeamEntryAthleteChannel(t *testing.T, env *testEnv, sportID int64, countToTeam, countToTeamMedals int) (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 (?, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, sportID) + 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, + count_to_team, count_to_team_medals) + VALUES (?, 'athlete', 'result', ?, 0, ?, ?)`, + eventID, profileIDByCode(t, env, "individual_top6"), countToTeam, countToTeamMedals) + return eventID, categoryID +} + +// doublesImportRow 拼一行团体报名(不填姓名 = 团体行,队员名单写两个人名)。 +func doublesImportRow(eventCode, category, teamCode string) string { + return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/王五"}, ",") +} + +// putEntryAthletes 调 PUT /api/entries/{id}/athletes 整组替换队员。 +func putEntryAthletes(t *testing.T, env *testEnv, client *http.Client, entryID int64, athleteIDs ...int64) (int, envelope) { + t.Helper() + items := make([]map[string]any, 0, len(athleteIDs)) + for _, id := range athleteIDs { + items = append(items, map[string]any{"athleteId": id}) + } + return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID), + map[string]any{"athletes": items}, true) +} + +// entryAthleteRows 把某小项的队员关联表导成稳定文本。 +func entryAthleteRows(t *testing.T, env *testEnv, eventID int64) string { + t.Helper() + return tableDump(t, env, fmt.Sprintf( + `SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order + FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id + JOIN entries e ON e.id = ea.entry_id + WHERE e.event_id = %d + ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID)) +} + +// athleteBoard 是 GET /api/standings/athletes 的原始读数。 +func athleteBoard(t *testing.T, env *testEnv, client *http.Client) []map[string]any { + t.Helper() + status, resp := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读个人积分榜应 200,得到 %d %+v", status, resp.Error) + } + return decodeData[[]map[string]any](t, resp) +} + +// athleteRow 取个人榜上某个运动员那一行(不在榜上直接失败)。 +func athleteRow(t *testing.T, env *testEnv, client *http.Client, athleteID int64) map[string]any { + t.Helper() + for _, row := range athleteBoard(t, env, client) { + if row["athleteId"] == float64(athleteID) { + return row + } + } + t.Fatalf("个人积分榜上没有运动员 %d:%+v", athleteID, athleteBoard(t, env, client)) + return nil +} + +// assertAthlete 断言个人榜上某人的总分与金银铜。 +func assertAthlete(t *testing.T, env *testEnv, client *http.Client, athleteID int64, points float64, gold, silver, bronze int) { + t.Helper() + row := athleteRow(t, env, client, athleteID) + if got := row["totalPoints"].(float64); got != points { + t.Errorf("运动员 %d 个人总分 = %v,期望 %v(整行:%+v)", athleteID, 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)", athleteID, name, got, want, row) + } + } +} + +// 双打:一条团体报名 + 两个队员 → 每人各拿一份完整的个人分、各一枚个人奖牌; +// 单位维度仍然只算这条报名一次(10 分、1 枚金牌,不是 30 分、3 枚)。 +func TestDoublesEntryMembersGetOwnIndividualScore(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1) + // 双打是同一个单位的两个人:种子里另一个运动员在 B 单位,另外补一个 A 单位的人。 + partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) + VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) + + if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { + t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + + // 队员关联:这就是本单新增的写入路径(界面勾了谁,就把它留在报名上)。 + if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK || !resp.OK { + t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) + } + + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n", + entryID, ids.athleteA, entryID, partner) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("队员关联落库不对:\n得到 %s期望 %s", got, want) + } + + publishResults(t, env, client, eventID, "doubles-members", + map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) + + // 个人榜:两个队员各一份满分(第 1 名 10 分)+ 各一枚金牌。 + assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) + assertAthlete(t, env, client, partner, 10, 1, 0, 0) + + // 单位维度:仍然只算这条报名一次 —— 既不是两份 20 分,也不是三枚金牌。 + assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) +} + +// 现场的双打口径:通道两个单位开关都关掉 → 单位 0 分 0 牌,但队员的个人分与个人奖牌照发。 +func TestDoublesEntryMembersWithTeamFlagsOff(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) + VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) + + if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { + t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK { + t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) + } + publishResults(t, env, client, eventID, "doubles-flags-off", + map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) + + // 单位总分与单位奖牌都不受队员关联影响(双打现场就是「都不计入」)。 + assertStanding(t, env, client, ids.teamA, 0, 0, 0, 0) + // 个人分与个人奖牌照发:一人一份满分、一人一枚金牌。 + assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) + assertAthlete(t, env, client, partner, 10, 1, 0, 0) +} + +// 报名读数要带上队员(界面据此显示「队里有谁」,并能改)。 +func TestEntryReadsIncludeAthletes(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) + VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) + env.exec(t, `UPDATE athletes SET bib = 'A001' WHERE id = ?`, ids.athleteA) + if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { + t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK { + t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) + } + + status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error) + } + board := decodeData[entryBoard](t, resp) + if len(board.Entries) != 1 { + t.Fatalf("应有一条报名,得到 %+v", board.Entries) + } + athletes, _ := board.Entries[0]["athletes"].([]any) + if len(athletes) != 2 { + t.Fatalf("报名读数应带两个队员,得到 %+v", board.Entries[0]["athletes"]) + } + first, _ := athletes[0].(map[string]any) + if first["athleteId"] != float64(ids.athleteA) || first["name"] != "张伟" || first["bib"] != "A001" { + t.Fatalf("队员读数应带 athleteId / 姓名 / 号码布,得到 %+v", first) + } +} + +// 队员改动要能拦住会算错的写法,并且一律不落库。 +func TestUpdateEntryAthletesRejectsBadPayloads(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { + t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA); status != http.StatusOK { + t.Fatalf("先设一个队员应 200,得到 %d %+v", status, resp.Error) + } + baseline := entryAthleteRows(t, env, eventID) + + // 个人报名(带运动员)不该有队员:它的身份就是那一个运动员。 + personalEntryID := ids.entryA + + cases := []struct { + name string + path string + body map[string]any + want string + }{ + {"运动员不存在", fmt.Sprintf("/api/entries/%d/athletes", entryID), + map[string]any{"athletes": []map[string]any{{"athleteId": 999999}}}, "不存在"}, + {"缺 athleteId", fmt.Sprintf("/api/entries/%d/athletes", entryID), + map[string]any{"athletes": []map[string]any{{"name": "张三"}}}, "athleteId"}, + {"同一个人写两次", fmt.Sprintf("/api/entries/%d/athletes", entryID), + map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}, {"athleteId": ids.athleteA}}}, "两次"}, + {"个人报名不能挂队员", fmt.Sprintf("/api/entries/%d/athletes", personalEntryID), + map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "个人报名"}, + {"报名不存在", "/api/entries/999999/athletes", + map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "不存在"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, resp := env.do(client, http.MethodPut, tc.path, tc.body, true) + wantStatus := http.StatusBadRequest + if tc.name == "报名不存在" { + wantStatus = http.StatusNotFound + } + if status != wantStatus { + t.Fatalf("应 %d,得到 %d %+v", wantStatus, status, resp) + } + if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) { + t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error) + } + if got := entryAthleteRows(t, env, eventID); got != baseline { + t.Fatalf("被拒的请求改动了队员表:\n之前 %s之后 %s", baseline, got) + } + }) + } + + // 空数组是合法值:清空队员 = 只报单位(与升级前一致)。 + if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID), + map[string]any{"athletes": []map[string]any{}}, true); status != http.StatusOK || !resp.OK { + t.Fatalf("清空队员应 200,得到 %d %+v", status, resp.Error) + } + if got := entryAthleteRows(t, env, eventID); got != "" { + t.Fatalf("清空后队员表应为空,得到 %s", got) + } +} diff --git a/server/internal/httpapi/entry_athletes_upgrade_test.go b/server/internal/httpapi/entry_athletes_upgrade_test.go new file mode 100644 index 0000000..fecc251 --- /dev/null +++ b/server/internal/httpapi/entry_athletes_upgrade_test.go @@ -0,0 +1,202 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "testing" +) + +// 本文件是「老库升级到 0026」这一跳在接口层的验证:拿一个升级前(0025)的库,塞进老数据, +// 走真实升级路径(store.MigrateEvent)后从报名一路打到榜单。 +// +// 三条底线: +// - 回填:老报名 member_names 里认得出的人被挂上(号码布优先、姓名+单位其次、重名不猜); +// - 队员拿个人分与个人奖牌(这是本单要的效果); +// - **单位维度不变**:单位总分与单位奖牌仍旧只算这条报名一次。 + +// seedLegacyDoublesEvent 在升级前的库里造两条「双打」团体报名(A、B 两个单位各一条): +// 团体项目 + 个人通道(现场的双打就是这么配的),队员名单只有文字(老数据的唯一形态)。 +// 两个 memberNames 参数分别是 B 单位那条与 A 单位那条的队员名单(NULL 表示没填名单)。 +func seedLegacyDoublesEvent(t *testing.T, db *sql.DB, memberNamesB, memberNamesA string) { + t.Helper() + exec := func(query string, args ...any) { + t.Helper() + if _, err := db.Exec(query, args...); err != nil { + t.Fatalf("造老库数据失败: %v\n%s", err, query) + } + } + exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`) + exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`) + // 号码布是运动员的唯一标识:下面按号码布/姓名两种写法各认一个。 + exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('张伟', '男', 1, 'A001', 1)`) + exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('王五', '男', 1, 'A002', 1)`) + exec(`INSERT INTO athletes(name, gender, team_id, bib, active) VALUES ('李强', '男', 2, 'B001', 1)`) + exec(`INSERT INTO sports(code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`) + exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status) + VALUES (1, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 1, 'ongoing')`) + exec(`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`) + exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) + VALUES (1, 'athlete', 'result', (SELECT id FROM scoring_profiles WHERE code = 'individual_top6'), 0)`) + exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status) + VALUES (1, 1, 2, NULL, ?, 'registered')`, memberNamesB) + exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status, squad) + VALUES (1, 1, 1, NULL, ?, 'registered', '')`, memberNamesA) +} + +// newLegacyDoublesEnv 造一个「升级前的老库 → 升级 → 起服务」的完整现场。 +func newLegacyDoublesEnv(t *testing.T, memberNamesB, memberNamesA string) *testEnv { + t.Helper() + return newTestEnvPrepared(t, func(ev *sql.DB) { + applyEventMigrationsBefore(t, ev, "0026") // 回到「升级前」:库结构到 0025 为止 + seedLegacyDoublesEvent(t, ev, memberNamesB, memberNamesA) + }, nil) +} + +// 老库升级:名单里认得出的人被回填成队员,于是双打两个人各拿一份个人分、各一枚个人奖牌; +// 而单位维度与升级前逐字节一致(单位只算一次分、只拿一枚牌)。 +func TestLegacyDoublesUpgradeBackfillsMembersAndScoresIndividually(t *testing.T) { + // 老报名写的是一半号码布(A001 = 张伟)、一半姓名(王五)——两种写法都该认出来。 + // 另一条报名(B 单位)写的是姓名「李强」,也在升级时认出来(下面 dump 里是第 1 行)。 + env := newLegacyDoublesEnv(t, `["李强"]`, `["A001","王五"]`) + + // 升级那一刻做的回填:两个人都挂上了,顺序按 member_names 的书写顺序。 + dump := tableDump(t, env, `SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order + FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id + ORDER BY ea.entry_id, ea.sort_order`) + want := "entry_id=1 athlete_id=3 name=李强 sort_order=0 \n" + + "entry_id=2 athlete_id=1 name=张伟 sort_order=0 \n" + + "entry_id=2 athlete_id=2 name=王五 sort_order=1 \n" + if dump != want { + t.Fatalf("升级回填不对:\n得到 %s期望 %s", dump, want) + } + + client, _ := env.client() + env.createUser("recorder", "recorder-pass", "recorder") + env.login(client, "recorder", "recorder-pass") + + entryA := entryIDOf(t, env, 1, 1) // A 单位的双打 + entryB := entryIDOf(t, env, 2, 1) // B 单位的单人报名 + publishResults(t, env, client, 1, "legacy-doubles-upgrade", + map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}, + map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"}, + ) + + // 个人榜:双打的两个人各一份满分(第 1 名 10 分)+ 各一枚金牌。 + assertAthlete(t, env, client, 1, 10, 1, 0, 0) // 张伟 + assertAthlete(t, env, client, 2, 10, 1, 0, 0) // 王五 + assertAthlete(t, env, client, 3, 8, 0, 1, 0) // 李强(对照) + + // 单位维度:分配了队员也仍然只算这条报名一次(不是 20 分、也不是两枚金牌)。 + assertStanding(t, env, client, 1, 10, 1, 0, 0) + assertStanding(t, env, client, 2, 8, 0, 1, 0) + + // 流水层面把「不重复计算」摊开看:单位那一份只有一条(athlete_id 为空、count_to_team=1), + // 队员的两条是「只记个人」(count_to_team=0),所以单位总分与单位奖牌都看不到它们。 + ledger := tableDump(t, env, `SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team + FROM score_ledger WHERE voided = 0 ORDER BY team_id, athlete_id`) + wantLedger := "team_id=1 athlete_id=0 points=10 count_to_team=1 \n" + + "team_id=1 athlete_id=1 points=10 count_to_team=0 \n" + + "team_id=1 athlete_id=2 points=10 count_to_team=0 \n" + + "team_id=2 athlete_id=0 points=8 count_to_team=1 \n" + + "team_id=2 athlete_id=3 points=8 count_to_team=0 \n" + if ledger != wantLedger { + t.Fatalf("流水不对:\n得到 %s期望 %s", ledger, wantLedger) + } +} + +// 名单认不出人的老报名(重名、查无此人)升级后照旧:上不了个人榜,单位维度一个字不变。 +func TestLegacyDoublesUpgradeWithoutMatchKeepsBehavior(t *testing.T) { + // 两条报名写的名字都查不到人(重名 / 查无此人):一条关联都不许回填。 + env := newLegacyDoublesEnv(t, `NULL`, `["查无此人","李强","李强"]`) + + if got := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes`); got != 0 { + t.Fatalf("认不出人时不该回填任何关联(B 单位没填名单、A 单位查无此人),得到 %d 条", got) + } + + client, _ := env.client() + env.createUser("recorder", "recorder-pass", "recorder") + env.login(client, "recorder", "recorder-pass") + entryA := entryIDOf(t, env, 1, 1) + entryB := entryIDOf(t, env, 2, 1) + publishResults(t, env, client, 1, "legacy-doubles-nomatch", + map[string]any{"entryId": entryA, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}, + map[string]any{"entryId": entryB, "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"}, + ) + + // 单位维度与升级前一样(A 第 1 名 10 分 1 金,B 第 2 名 8 分 1 银)。 + assertStanding(t, env, client, 1, 10, 1, 0, 0) + assertStanding(t, env, client, 2, 8, 0, 1, 0) + // 个人榜上一条都没有:队员认不出来就上不了个人榜(这正是回填要解决的事)。 + if rows := athleteBoard(t, env, client); len(rows) != 0 { + t.Fatalf("认不出人时个人榜应为空,得到 %+v", rows) + } +} + +// 双打 + 双通道(个人成绩通道 + 团体手工通道):连发两次必须逐字节一致。 +// +// results.rank 在团体报名项上是「单位名次」的录入位(团体手工通道的兜底来源)。 +// 队员的个人副本不许往这一列写回自动名次——否则第二次发布会把自动名次当成团体名次再发一遍分, +// 就是工单 A-1 那个坑,只不过这次是队员副本踩进去的。 +func TestDoublesMembersDoNotClobberManualTeamRank(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, categoryID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, + compare, scored, multiplier, status) + VALUES (?, 'DBL2', '羽毛球双打(双通道)', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport), int64(0) + 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 (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6")) + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6")) + + partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) + VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) + entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA) + if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != 200 { + t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) + } + // 团体名次按单位录(第 3 名 = 8 分),双打自己的成绩值给出个人通道的第 1 名(10 分)。 + if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ + {"teamId": ids.teamA, "rank": 3}, + }); status != 200 { + t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error) + } + + publishResults(t, env, client, eventID, "doubles-dual-p1", + map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) + firstLedger := tableDump(t, env, fmt.Sprintf( + `SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team + FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID)) + // 名次不许被队员副本写回:团体报名项的 results.rank 仍是「没写」(名次按单位录入在 + // channel_manual_ranks 里,这一列是它的兜底录入位,被自动名次覆盖就会在第二次发布时 + // 被当成团体名次再发一遍分)。 + if got := resultRanksDump(t, env, eventID); got != fmt.Sprintf("entry_id=%d rank=0 value=90 \n", entryID) { + t.Fatalf("团体名次(results.rank 录入位)被自动名次改写了:\n%s", got) + } + // 单位总分 = 个人通道第 1 名的 10 分(报名项那一份)+ 团体手工通道第 3 名的 8 分,只算一次。 + // 奖牌 1 金 1 铜是双通道本来就有的口径(两条通道各自发自己那份名次的牌,与队员无关)。 + assertStanding(t, env, client, ids.teamA, 18, 1, 0, 1) + // 队员各拿个人通道那一份 10 分与个人金牌。 + assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) + assertAthlete(t, env, client, partner, 10, 1, 0, 0) + + publishEvent(t, env, client, eventID, "doubles-dual-p2") + if got := tableDump(t, env, fmt.Sprintf( + `SELECT team_id, COALESCE(athlete_id, 0) AS athlete_id, points, count_to_team + FROM score_ledger WHERE voided = 0 AND event_id = %d ORDER BY team_id, athlete_id`, eventID)); got != firstLedger { + t.Fatalf("第二次发布改了流水(同一份数据发两次结果不同):\n第一次\n%s第二次\n%s", firstLedger, got) + } +} + +// entryIDOf 取某 (单位, 小项) 的纯团体报名 id。 +func entryIDOf(t *testing.T, env *testEnv, teamID, eventID int64) int64 { + t.Helper() + var id int64 + if err := env.ev.QueryRow( + `SELECT id FROM entries WHERE team_id = ? AND event_id = ? AND athlete_id IS NULL`, teamID, eventID).Scan(&id); err != nil { + t.Fatalf("读报名项: %v", err) + } + return id +} diff --git a/server/internal/httpapi/entry_shares.go b/server/internal/httpapi/entry_shares.go index 1d6e16a..30a5522 100644 --- a/server/internal/httpapi/entry_shares.go +++ b/server/internal/httpapi/entry_shares.go @@ -393,6 +393,11 @@ func (s *server) getEntry(ctx context.Context, entryID int64) (entryDTO, error) return entryDTO{}, err } e.TeamShares = entrySharesOf(shares[e.ID], e.TeamID, e.TeamCode, e.TeamName) + athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, e.EventID) + if err != nil { + return entryDTO{}, err + } + e.Athletes = entryAthletesOf(athletes[e.ID]) return e, nil } diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 9c4a0a9..1af0fd9 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "database/sql" "encoding/csv" "encoding/hex" "encoding/json" @@ -13,6 +14,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -1189,6 +1191,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de // 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算, // 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。 affectedScopes := make([]entryScope, 0, 2) + // 「队员名单」列按号码布认出来、并挂到报名上的队员总数。收尾说一句:这些人会参与个人算分, + // 认不出的照旧只是文字名单——不报错,但现场得知道「这份表到底认上了几个人」。 + linkedMembers := 0 for _, table := range tables { index, err := importColumnIndex(table.rows[0]) if err != nil { @@ -1447,6 +1452,68 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de squad = "" } + // 「队员名单」列既是显示用的文字,也能写**号码布**:一格里的每一段先按号码布去 + // athletes 认人,认得上就把这条报名跟那个人关联起来(entry_athletes), + // 认不上的照旧只当文字(现状不变,不报错)。号码布是运动员的唯一标识(devdocs/20 §三)。 + memberTokens := splitNames(cell("member_names")) + link, err := s.importMemberAthletes(ctx, memberTokens) + if err != nil { + result.Skipped++ + result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err))) + continue + } + for _, name := range link.Repeated { + result.Notices = append(result.Notices, prefix+fmt.Sprintf( + "第 %d 行:队员名单里的号码布 %s 写了两次,只关联一个人(同一条报名里一个人只占一个位置)", line, name)) + } + + // 队员只对**纯团体报名**生效:带运动员姓名的行按个人报名处理,那种报名自己就是那个人的 + // 身份(见 PUT /api/entries/{id}/athletes 的同一条口径),所以这一格只留文字、不关联。 + // 认得出人时才说一句——这一格在个人行上本来就只是备注,空着或只写姓名都不必打扰现场。 + if athleteID != nil && len(link.Members) > 0 { + result.Notices = append(result.Notices, prefix+fmt.Sprintf( + "第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:"+ + "队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人", + line, len(link.Members))) + link.Members = nil + } + + // 一条报名的队员上限与 PUT /api/entries/{id}/athletes 共用:超过就整行拦下,不写一条 + // 界面上改不动的报名(与联队「一个联队最多 5 个单位」同一条口径)。认不出人的段不计入, + // 所以只写姓名的老表一行都不会被它拦下。 + if len(link.Members) > maxEntryAthletes { + result.Skipped++ + result.Errors = append(result.Errors, prefix+s.interceptRow(line, + "队员名单里认出了 %d 名运动员,超过一条报名最多 %d 人:请检查这一格是不是把号码布写错了位置", + len(link.Members), maxEntryAthletes)) + continue + } + + // 号码布认到别的单位的人:照号码布认(与迁移回填同一口径:号码布是唯一标识,不看单位), + // 但要点名说清 —— 团体报名挂在 A 单位、队员却是 B 单位的人,最常见的原因是号码布写错了。 + // 联队(team_code 写 A+B)的成员单位都算「本行的单位」:队员本来就该来自其中任何一个, + // 不该报一个会让人去改正确号码布的假警报。 + // 这里**不**进冲突面板:那是「要不要用表里的字段覆盖库里这条人员档案」的决策, + // 而队员关联一个字段都不改(与 PUT /api/entries/{id}/athletes 一样只认 athleteId)。 + for _, member := range link.Members { + if member.TeamID == teamID || sharesContainTeam(teamShares, member.TeamID) { + continue + } + memberTeam := member.TeamCode + if memberTeam == "" { + memberTeam = "(无单位)" + } + result.Notices = append(result.Notices, prefix+fmt.Sprintf( + "第 %d 行:队员名单里的号码布 %s 是单位 %s 的 %s,本行报名挂在单位 %s 名下 —— "+ + "号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错", + line, member.Bib, memberTeam, member.Name, teamCode)) + } + + members := make([]entryAthleteDTO, 0, len(link.Members)) + for _, member := range link.Members { + members = append(members, entryAthleteDTO{AthleteID: member.AthleteID}) + } + outcome, err := s.upsertImportedEntry(ctx, entryDTO{ EventID: eventID, CategoryID: categoryID, @@ -1454,7 +1521,8 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de TeamShares: teamShares, Squad: squad, AthleteID: athleteID, - MemberNames: splitNames(cell("member_names")), + MemberNames: memberTokens, + Athletes: members, Bib: nilIfEmpty(bib), Seed: nilIfEmpty(cell("seed")), Heat: heat, @@ -1466,6 +1534,7 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error())) case outcome.Created: result.Created++ + linkedMembers += len(members) affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID) // 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员 // 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。 @@ -1476,7 +1545,10 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de } default: result.Updated++ - if outcome.StatusChanged { + linkedMembers += len(members) + // 队员关联与 status 跨界同一类:它们决定「谁拿到分」,改了就要重算这一组, + // 否则会出现「名单里认出了队员、榜上还没他的分」(与 PUT /api/entries/{id}/athletes 同口径)。 + if outcome.StatusChanged || outcome.MembersChanged { affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID) } } @@ -1519,6 +1591,14 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de result.Notices = append(result.Notices, fmt.Sprintf( "号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows)) } + // 队员名单认了多少人,用既有 notices 通道说一句:号码布认得出人时,这些人就从「文字名单」 + // 变成了「真实的队员」,会各自拿到个人分与个人奖牌(配了个人通道的项目才上个人榜)。 + // 认不出的一律照旧只是文字,不报错——人没录档案是常态,这正是降级形态存在的意义。 + if linkedMembers > 0 { + result.Notices = append(result.Notices, fmt.Sprintf( + "队员名单列按号码布关联了 %d 名队员(号码布是运动员的唯一标识):配了个人通道的项目,他们会各自拿到个人分与个人奖牌;"+ + "只写姓名、或号码布认不出人的照旧只作文字名单", linkedMembers)) + } // 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。 // 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。 if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil { @@ -1574,6 +1654,100 @@ func splitNames(raw string) []string { return out } +// importMember 是一格「队员名单」里按号码布认出来的一个人。 +// +// 写进报名报的只有 AthleteID(与 PUT /api/entries/{id}/athletes 同一口径:认人是调用方的事); +// 姓名、号码布与单位只用于报告 —— 「认到的是谁、他属于哪个单位」。 +type importMember struct { + AthleteID int64 + Name string + Bib string + TeamID int64 + TeamCode string +} + +// importMemberLink 是一格「队员名单」认人的结果。 +type importMemberLink struct { + // Members 按这一格里的书写顺序给出认出来的人(同一个号码布写多次只留一次), + // 写进报名的 sort_order 就用这个顺序(与迁移回填「按 member_names 书写顺序」同一口径)。 + Members []importMember + // Repeated 是同一格里写了两次以上、且认得出人的号码布,按首次出现顺序。 + Repeated []string +} + +// importMemberAthletes 把「队员名单」一格里的每一段按**号码布**认成人。 +// +// 口径(devdocs/20 §三,主人 2026-09-14 定的): +// - 号码布是运动员的唯一标识(0014 上有部分唯一索引):一段文字与某个运动员的号码布完全相同, +// 那就是他 —— **与单位无关**(迁移里的回填 1 同样不看单位),单位不同只作提示; +// - 一格里的内容同时能当成号码布与姓名时,以号码布为准:先认号码布,命中了就不再看姓名; +// - 认不出的段**保持原样当文字**(member_names 照常保留,现状不变):现场难免有没录档案的人。 +// 姓名一律不做匹配——同名会认错人,而认错的分已经上了榜(老数据的姓名回填是迁移里的一次性动作, +// 且要求同单位唯一,导入不重复这条不确定性)。 +// +// 一格一次查询:格子里的段数最多十几段,但一份表可能有几百行,逐段查会把导入变成几百次往返。 +func (s *server) importMemberAthletes(ctx context.Context, tokens []string) (importMemberLink, error) { + if len(tokens) == 0 { + return importMemberLink{}, nil + } + seen := make(map[string]bool, len(tokens)) + args := make([]any, 0, len(tokens)) + for _, token := range tokens { + if seen[token] { + continue + } + seen[token] = true + args = append(args, token) + } + rows, err := s.ev.QueryContext(ctx, + `SELECT a.bib, a.id, a.name, a.team_id, COALESCE(t.code, '') + FROM athletes a LEFT JOIN teams t ON t.id = a.team_id + WHERE a.bib IN (`+placeholders(len(args))+`)`, args...) + if err != nil { + return importMemberLink{}, internal(err) + } + defer func() { _ = rows.Close() }() + + byBib := make(map[string]importMember, len(args)) + for rows.Next() { + var ( + m importMember + bib sql.NullString + code string + ) + if err := rows.Scan(&bib, &m.AthleteID, &m.Name, &m.TeamID, &code); err != nil { + return importMemberLink{}, internal(err) + } + m.Bib = bib.String + m.TeamCode = code + byBib[m.Bib] = m + } + if err := rows.Err(); err != nil { + return importMemberLink{}, internal(err) + } + + var link importMemberLink + emitted := make(map[int64]bool) + for _, token := range tokens { + member, ok := byBib[token] + if !ok { + // 认不出人:照旧只当文字(member_names 已经把它留在库里了)。 + continue + } + if emitted[member.AthleteID] { + // 同一个号码布写了两次:同一条报名里一个人只占一个位置(表的主键也这么定), + // 这里就地去掉并记下来,由调用方说一句。 + if !slices.Contains(link.Repeated, member.Bib) { + link.Repeated = append(link.Repeated, member.Bib) + } + continue + } + emitted[member.AthleteID] = true + link.Members = append(link.Members, member) + } + return link, nil +} + // ---------- 图片上传与静态访问 ---------- // maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节, diff --git a/server/internal/httpapi/handlers_import_members_test.go b/server/internal/httpapi/handlers_import_members_test.go new file mode 100644 index 0000000..fe2c7e3 --- /dev/null +++ b/server/internal/httpapi/handlers_import_members_test.go @@ -0,0 +1,394 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 本文件钉住「导入侧把队员名单认成人」(devdocs/20 §三): +// - 「队员名单」列除了是显示用的文字,还能写**号码布**:认得上就建立 entry_athletes 关联, +// 认不上照旧只当文字(现状不变,不报错); +// - 号码布是运动员的唯一标识:一格里的内容同时能当成号码布与姓名时,以号码布为准; +// - 新增与更新两条路都要同步队员关联:认出了人的那一格整组重写(与 squad / 份额同一口径), +// 一个号码布都认不出的格子只当文字、不动已有队员(老表与界面两条路的并存,见各用例)。 + +// memberImportRow 拼一行团体报名(不填姓名 = 团体行),列序与 importHeader 一致。 +func memberImportRow(eventCode, category, teamCode, members string) string { + return strings.Join([]string{eventCode, category, teamCode, "", + "", "", "", "", "", "registered", members}, ",") +} + +// seedBib 给库里已有的运动员写一个号码布(种子里的运动员默认没有号码布)。 +func seedBib(t *testing.T, env *testEnv, athleteID int64, bib string) { + t.Helper() + env.exec(t, `UPDATE athletes SET bib = ? WHERE id = ?`, bib, athleteID) +} + +// seedMember 造一个带号码布的运动员(导入要认的就是号码布)。 +func seedMember(t *testing.T, env *testEnv, name string, teamID int64, bib string) int64 { + t.Helper() + return env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) + VALUES (?, '男', ?, ?, 1)`, name, teamID, bib) +} + +// entryMemberNames 读一条报名的文字名单原文(member_names 在库里是 JSON 数组文本)。 +func entryMemberNames(t *testing.T, env *testEnv, entryID int64) string { + t.Helper() + var raw string + if err := env.ev.QueryRow(`SELECT COALESCE(member_names, '') FROM entries WHERE id = ?`, entryID).Scan(&raw); err != nil { + t.Fatalf("读 member_names: %v", err) + } + return raw +} + +// noticesText 把 notices 拼成一段文本,便于断言「说了什么」。 +func noticesText(result entryImportResultBody) string { + return strings.Join(result.Notices, "\n") +} + +// 号码布认得人:一格里的号码布与该行的文字名单并存 —— 关联建立、文字原样保留, +// 且这些队员当场就能上个人积分榜(导入路径的完整目的)。 +func TestImportMemberNamesLinksByBibAndKeepsText(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1) + seedBib(t, env, ids.athleteA, "A001") + partner := seedMember(t, env, "王五", ids.teamA, "A002") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "A002/A001/李四")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 || len(result.Errors) != 0 { + t.Fatalf("应新建 1 条报名且无报错,得到 %+v", result) + } + entryID := firstEntryID(t, env, eventID) + + // 顺序跟这一格的书写顺序走(号码布写在前的排在前)。 + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \nentry_id=%d athlete_id=%d name=张伟 sort_order=1 \n", + entryID, partner, entryID, ids.athleteA) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("队员关联应按号码布认出王五与张伟:\n得到 %s期望 %s", got, want) + } + // 认不出的「李四」不报错,文字名单三个都原样留着。 + if got, want := entryMemberNames(t, env, entryID), `["A002","A001","李四"]`; got != want { + t.Fatalf("member_names 应原样保留,得到 %s 期望 %s", got, want) + } + if got := noticesText(result); !strings.Contains(got, "关联了 2 名队员") { + t.Fatalf("认了人要说得出来(现场要知道谁上了个人榜),得到 notices:%s", got) + } + + // 认出来的人当场参与算分:每人一份满分、各一枚个人奖牌;单位仍然只算这一条报名一次。 + publishResults(t, env, client, eventID, "import-members", + map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) + assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) + assertAthlete(t, env, client, partner, 10, 1, 0, 0) + assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) +} + +// 一格里的内容同时能当成号码布与姓名时,以号码布为准(主人 2026-09-14 定的口径)。 +func TestImportMemberNamesPrefersBibOverName(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + byBib := seedMember(t, env, "赵六", ids.teamA, "1001") + byName := seedMember(t, env, "1001", ids.teamA, "") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "1001")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=赵六 sort_order=0 \n", entryID, byBib) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("1001 既是号码布又是另一个人的姓名时应关联号码布那个人:\n得到 %s期望 %s", got, want) + } + var linked int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entry_athletes WHERE athlete_id = ?`, byName).Scan(&linked); err != nil { + t.Fatalf("查关联: %v", err) + } + if linked != 0 { + t.Fatalf("不该关联同名的另一个人(%d)", byName) + } +} + +// 只写姓名(认不出号码布)时现状不变:只当文字,不报错、不关联。 +func TestImportMemberNamesUnknownStaysTextOnly(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + // 库里有个同单位同名的张伟,但号码布列认的是号码布:姓名不做匹配(同名会认错人)。 + seedBib(t, env, ids.athleteA, "A001") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "张伟/李四")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 || len(result.Errors) != 0 { + t.Fatalf("只写姓名应照常导入、不报错,得到 %+v", result) + } + entryID := firstEntryID(t, env, eventID) + if got := entryAthleteRows(t, env, eventID); got != "" { + t.Fatalf("只写姓名不该建立任何关联,得到 %s", got) + } + if got, want := entryMemberNames(t, env, entryID), `["张伟","李四"]`; got != want { + t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want) + } + if got := noticesText(result); strings.Contains(got, "关联") { + t.Fatalf("没认到人时不该说关联,得到 notices:%s", got) + } +} + +// 号码布认到的人属于别的单位:照号码布认(号码布是唯一标识,与迁移回填同一口径), +// 但要说清楚是谁、在哪两个单位之间 —— 这是「填错号码布」最容易发生的写法。 +func TestImportMemberNamesReportsCrossTeamMember(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athB, "B001") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "B001")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + entryID := firstEntryID(t, env, eventID) + + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=李强 sort_order=0 \n", entryID, ids.athB) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("号码布认到的人照关联(不看单位):\n得到 %s期望 %s", got, want) + } + notice := noticesText(result) + for _, part := range []string{"第 2 行", "B001", "李强", "单位 B", "单位 A"} { + if !strings.Contains(notice, part) { + t.Fatalf("跨单位关联要说清 %q,得到 notices:%s", part, notice) + } + } +} + +// 后补队员:先导入一条只报单位的团体报名、成绩已经发布上榜;之后同一行补上号码布重传, +// 队员要**当场参与算分**(个人榜上出现他们),不必再发布一次成绩。 +// 这与「改了谁算数就必须重算这一组」是同一条口径(updateEntryAthletes 也是这样)。 +func TestImportMemberNamesRecomputesAfterUpdate(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1) + seedBib(t, env, ids.athleteA, "A001") + partner := seedMember(t, env, "王五", ids.teamA, "A002") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + publishResults(t, env, client, eventID, "members-later", + map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) + // 前置条件:还没有队员,个人榜上不该有这两个人。 + if got := entryAthleteRows(t, env, eventID); got != "" { + t.Fatalf("前置条件:首次导入没有队员,得到 %s", got) + } + + status, resp = importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "A001/A002")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("补队员重传应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 { + t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got) + } + assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) + assertAthlete(t, env, client, partner, 10, 1, 0, 0) + assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) +} + +// 重传同一行(走更新那条路)也要整组重写队员:换人跟得上,不留上一次的旧队员; +// 而认不出号码布的格子(整列留空 / 只写姓名)只当文字,不动已有的队员关联。 +func TestImportMemberNamesRewrittenOnUpdate(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athleteA, "A001") + partner := seedMember(t, env, "王五", ids.teamA, "A002") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "A001")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("首次导入应 200,得到 %d %+v", status, resp.Error) + } + entryID := firstEntryID(t, env, eventID) + + status, resp = importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "A002")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[entryImportResultBody](t, resp).Updated; got != 1 { + t.Fatalf("重传同一条报名应走更新,得到 updated=%d", got) + } + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=王五 sort_order=0 \n", entryID, partner) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("换人后队员应整组重写:\n得到 %s期望 %s", got, want) + } + + // 走界面把队员改成两个人(导入与界面是两条并用的路)。 + if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK { + t.Fatalf("界面设置队员应 200,得到 %d %+v", status, resp.Error) + } + both := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n", + entryID, ids.athleteA, entryID, partner) + + // 这一格一个号码布都认不出来(整列留空 / 只写姓名)时**只当文字**:已有的队员关联一行都不动。 + // 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移回填出来的队员、界面上勾好的人, + // 都不该被一份没有号码布的名单悄悄清掉(那等于让人无声无息地掉出个人积分榜)。 + for _, cell := range []string{"", "张伟/王五"} { + status, resp = importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", cell)) + if status != http.StatusOK || !resp.OK { + t.Fatalf("重传(队员列 %q)应 200,得到 %d %+v", cell, status, resp.Error) + } + if got := entryAthleteRows(t, env, eventID); got != both { + t.Fatalf("队员列 %q 认不出号码布时不该改动已有队员:\n得到 %s期望 %s", cell, got, both) + } + } +} + +// 联队(单位编码写 A+B)的队员本来就该来自任何一个成员单位:认人都认, +// 但这类「跨单位」不是填错号码布,不该报假警报去让人改一个正确的号码布。 +func TestImportMemberNamesAcceptsJointTeamMembers(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athleteA, "A001") + seedBib(t, env, ids.athB, "B001") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A+B", "A001/B001")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + entryID := firstEntryID(t, env, eventID) + + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=李强 sort_order=1 \n", + entryID, ids.athleteA, entryID, ids.athB) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("联队两个成员单位的队员都该关联:\n得到 %s期望 %s", got, want) + } + if got := noticesText(result); strings.Contains(got, "请核对号码布") { + t.Fatalf("联队成员单位的人不该被当成填错号码布,得到 notices:%s", got) + } +} + +// 同一格里把同一个号码布写了两次:只关联一次(同一条报名里一个人只能占一个位置),并说一声。 +func TestImportMemberNamesDedupesRepeatedBib(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athleteA, "A001") + + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", "A001/A001")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入团体报名应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + entryID := firstEntryID(t, env, eventID) + + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \n", entryID, ids.athleteA) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("同一个号码布写两次只该关联一次:\n得到 %s期望 %s", got, want) + } + if got := noticesText(result); !strings.Contains(got, "A001") || !strings.Contains(got, "两次") { + t.Fatalf("号码布写了两次要说一声,得到 notices:%s", got) + } +} + +// 带运动员姓名的那一行按个人报名处理:队员只属于「只报单位」的团体报名, +// 这一格只作文字保留,并且要说出来(与队伍列同一条口径,不静默丢)。 +func TestImportMemberNamesOnIndividualRowIsTextOnly(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athleteA, "A001") + + row := strings.Join([]string{"DBL", "男子", "A", "张三", "110101199001011234", "男", "26", "A101", + "", "registered", "A001"}, ",") + status, resp := importCSVWithHeader(t, env, client, importHeader, row) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 || len(result.Errors) != 0 { + t.Fatalf("个人行应照常导入,得到 %+v", result) + } + var entryID int64 + if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND athlete_id IS NOT NULL`, eventID).Scan(&entryID); err != nil { + t.Fatalf("读个人报名: %v", err) + } + if got := entryAthleteRows(t, env, eventID); got != "" { + t.Fatalf("个人报名不该挂队员,得到 %s", got) + } + if got, want := entryMemberNames(t, env, entryID), `["A001"]`; got != want { + t.Fatalf("文字名单应原样保留,得到 %s 期望 %s", got, want) + } + if got := noticesText(result); !strings.Contains(got, "个人报名") { + t.Fatalf("个人行里的队员名单没有关联,要说清楚,得到 notices:%s", got) + } +} + +// 分工作表模板(界面上的「补报名 / 团体报名 CSV」走的就是导入)同样认号码布。 +func TestImportSheetTemplateLinksMembersByBib(t *testing.T) { + env, ids, client := recorderWithSeed(t) + seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athleteA, "A001") + partner := seedMember(t, env, "王五", ids.teamA, "A002") + + data := buildTestXLSX(t, []testSheet{{name: "羽毛球双打@男子", rows: [][]string{ + strings.Split(sheetImportHeader, ","), + {"", "", "A", "", "", "", "", "", "", "", "registered", "A001/A002", ""}, + }}}) + status, resp := env.uploadEntries(t, client, "团体报名.xlsx", data) + if status != http.StatusOK || !resp.OK { + t.Fatalf("分工作表模板应导入成功,得到 %d %+v", status, resp.Error) + } + var eventID int64 + if err := env.ev.QueryRow(`SELECT id FROM events WHERE code = 'DBL'`).Scan(&eventID); err != nil { + t.Fatalf("读小项: %v", err) + } + entryID := firstEntryID(t, env, eventID) + want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n", + entryID, ids.athleteA, entryID, partner) + if got := entryAthleteRows(t, env, eventID); got != want { + t.Fatalf("分工作表导入也该按号码布关联队员:\n得到 %s期望 %s", got, want) + } +} + +// 一格号码布超过一条报名的队员上限(maxEntryAthletes = 30):整行拦下并说清, +// 不静默写成一条界面改不动的报名(与联队「最多 5 个单位」同一条口径)。 +func TestImportMemberNamesRejectsTooManyMembers(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + + tokens := make([]string, 0, 31) + for i := 1; i <= 31; i++ { + bib := fmt.Sprintf("M%03d", i) + seedMember(t, env, fmt.Sprintf("队员%d", i), ids.teamA, bib) + tokens = append(tokens, bib) + } + status, resp := importCSVWithHeader(t, env, client, importHeader, + memberImportRow("DBL", "男子", "A", strings.Join(tokens, "/"))) + if status != http.StatusOK || !resp.OK { + t.Fatalf("行级拦截应回 200 + 行级报错,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Skipped != 1 || result.Created != 0 { + t.Fatalf("超过上限的行应被拦下,得到 %+v", result) + } + if got := strings.Join(result.Errors, "|"); !strings.Contains(got, "30") { + t.Fatalf("报错要说清上限是多少,得到 %s", got) + } + if got := entryAthleteRows(t, env, eventID); got != "" { + t.Fatalf("被拦下的行不该落队员,得到 %s", got) + } +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index ebaa088..bac771a 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -61,6 +61,13 @@ type entrySquadPayload struct { RequestID string `json:"requestId"` } +// entryAthletesPayload 是 PUT /api/entries/{id}/athletes 的请求体:整组替换一条报名的队员。 +// 空数组是**合法值**(清空队员 = 只报单位,与升级前一致);只认 athleteId。 +type entryAthletesPayload struct { + Athletes []entryAthleteDTO `json:"athletes"` + RequestID string `json:"requestId"` +} + // entryBatchPayload 是报名项批量操作的请求体: // action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名), // ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。 diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index ab185df..7a5567b 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -861,8 +861,14 @@ func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, er if err != nil { return nil, err } + // 队员同理:一条报名 0..N 个,单独查一次按 entry_id 挂上(空数组 = 只报单位)。 + athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, eventID) + if err != nil { + return nil, err + } for i := range out { out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName) + out[i].Athletes = entryAthletesOf(athletes[out[i].ID]) } return out, nil } diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index baeb38b..bb887f5 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -525,6 +525,10 @@ type entryImportOutcome struct { // StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。 // 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。 StatusChanged bool + // MembersChanged 表示这次更新改动了报名的**队员关联**(换人、加人)。 + // 队员决定谁拿到个人分与个人奖牌,所以它必须重算——与 status 跨界同一类「谁还算数」的改动, + // 而与姓名 / 号码布 / 队伍 / 批次那类纯展示字段不同(那些改了不重算)。 + MembersChanged bool // MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。 // 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见 // 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。 @@ -693,6 +697,9 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil { return entryImportOutcome{}, err } + if _, err := s.syncImportedEntryMembers(ctx, newID, in); err != nil { + return entryImportOutcome{}, err + } outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID} if len(moved) > 0 { // 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分, @@ -722,14 +729,82 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil { return entryImportOutcome{}, err } + membersChanged, err := s.syncImportedEntryMembers(ctx, existing, in) + if err != nil { + return entryImportOutcome{}, err + } return entryImportOutcome{ - EventID: in.EventID, - CategoryID: in.CategoryID, - StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status), + EventID: in.EventID, + CategoryID: in.CategoryID, + StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status), + MembersChanged: membersChanged, }, nil } } +// syncImportedEntryMembers 把导入这一行认出来的队员整组写到报名上(新增与更新两条路都要调), +// 返回这次有没有真的改动队员关联。 +// +// **一格认出了人才重写**:认出了号码布的一格就是这条报名的完整队员名单 —— 整组重写(先删后插, +// 与份额同一口径),重传同一行幂等,删掉一个号码布那个人就跟着从队里出去。 +// 一个号码布都认不出来(整列留空、只写姓名、或号码布认不出人)时这一格**只当文字**, +// 已有的队员关联一行都不动: +// - 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移按「号码布优先、其次姓名+单位唯一」回填 +// 出来的队员,不能因为重传一份没有号码布的名单就被悄悄清掉(那等于让人无声无息掉出个人积分榜); +// - 界面上勾好的队员也不能被「用模板重导一遍」冲掉(模板的队员名单列本来就是空的); +// - 要清空队员走界面(PUT /api/entries/{id}/athletes 传空数组),那是明确表达出来的意图。 +// 这条与「认不上就只当文字、别报错」是同一句话:认不出的格子对这条报名不产生任何影响。 +// +// 返回值只比**人的集合**,不比顺序:队员顺序只影响「队里有谁」的排列,名次与积分不看它 +// (见 entry_athletes 表注释),所以只是换了个写法重传不该把这一组白算一遍。 +// 关联真的变了就必须重算——队员决定谁拿到个人分与个人奖牌,不重算就是「名单里换人了、榜上还是旧人」。 +// +// 带运动员的报名一个字节都不写:它的身份就是那一个运动员,队员只属于「只报单位」的团体报名 +// (与 PUT /api/entries/{id}/athletes 的同一条口径)。 +func (s *server) syncImportedEntryMembers(ctx context.Context, entryID int64, in entryDTO) (bool, error) { + if in.AthleteID != nil || len(in.Athletes) == 0 { + return false, nil + } + before, err := entryAthleteIDsQ(ctx, s.ev, entryID) + if err != nil { + return false, err + } + after := entryAthleteIDs(in.Athletes) + if err := replaceEntryAthletes(ctx, s.ev, entryID, in.Athletes); err != nil { + return false, err + } + return !slices.Equal(before, after), nil +} + +// entryAthleteIDsQ 读一条报名当前的队员 id(升序),用于判断这次导入有没有改动关联。 +func entryAthleteIDsQ(ctx context.Context, q queryer, entryID int64) ([]int64, error) { + rows, err := q.QueryContext(ctx, + `SELECT athlete_id FROM entry_athletes WHERE entry_id = ? ORDER BY athlete_id`, entryID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + out := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, internal(err) + } + out = append(out, id) + } + return out, rows.Err() +} + +// entryAthleteIDs 把要写进去的队员摊成升序的 id 列表(与 entryAthleteIDsQ 同一种形状好比较)。 +func entryAthleteIDs(athletes []entryAthleteDTO) []int64 { + out := make([]int64, 0, len(athletes)) + for _, a := range athletes { + out = append(out, a.AthleteID) + } + slices.Sort(out) + return out +} + // entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。 // // 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串, diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 0326ae1..1337bc3 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -244,8 +244,14 @@ func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([] if err != nil { return nil, err } + // 队员(devdocs/20)同理随报名进引擎:每个队员各拿一份完整的个人分(见 domain 的 memberCopy)。 + members, err := loadEntryAthletesQ(ctx, q, eventID) + if err != nil { + return nil, err + } for i := range out { out[i].TeamShares = shares[out[i].ID] + out[i].Athletes = members[out[i].ID] } return out, nil } @@ -571,6 +577,11 @@ func resultStatusPtr(s domain.ResultStatus) *string { // 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源), // 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。 // +// 队员的个人副本(MemberCopy)一律不写回:它们是报名项那一份的复制品,写回等于让「同一条报名 +// 的每个队员」轮流改写同一个录入位——团体报名项的 results.rank 是团体手工通道的输入, +// 被队员副本覆盖后,第二次发布会把自动名次当成团体名次再发一遍分(A-1 的同一个坑)。 +// 该写的那一份是报名项自己(上面第一条规则已经管着它),一条成绩始终只有一个写入者。 +// // 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次), // 其次通道 id 小者,保证同一份输入每次写回同一个值。 func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error { @@ -583,6 +594,9 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 { continue } + if rr.MemberCopy { + continue + } if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) { continue } diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index f6c600b..0fe8604 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -282,6 +282,8 @@ func (s *server) routes(mux *http.ServeMux) { // 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。 // 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。 mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler)) + // 队员(devdocs/20):整组替换一条团体报名的队员,改完当场重算并推榜——队员决定谁拿个人分、个人奖牌。 + mux.HandleFunc("PUT /api/entries/{id}/athletes", s.write(auth.RoleRecorder, s.updateEntryAthletesHandler)) // 成绩 mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) diff --git a/server/migrations/event/0026_entry_athletes.sql b/server/migrations/event/0026_entry_athletes.sql new file mode 100644 index 0000000..1061a5f --- /dev/null +++ b/server/migrations/event/0026_entry_athletes.sql @@ -0,0 +1,69 @@ +-- 团体报名的「队员身份」(devdocs/20 的实现)。 +-- +-- 背景:双打这类团体 / 对抗项目,界面本来就能一次勾多个运动员(EntryPicker),但提交时只把 +-- 姓名拼成文字写进 entries.member_names —— 系统只知道「这条报名属于某单位」,不知道队里坐着谁, +-- 于是所有配了个人通道的团体项目都给不出个人分、个人奖牌,上不了个人积分榜。 +-- +-- 表达方式:**队员是报名的人员关联,不是新的一条报名**。一条团体报名 0..N 个队员 +-- (0 个 = 只报单位,与升级前逐字节一致)。队员与「队伍」(0025 的 entries.squad)是两个 +-- 正交的维度:squad 是「这个单位派的第几支队」,队员是「这支队的队员是谁」,可以叠加。 +-- **报名唯一键 (category_id, team_id, squad) WHERE athlete_id IS NULL 一个字都不动**: +-- 队员不参与报名去重(同一个人在同一组别的两条报名里出现是允许的,见 devdocs/20 §五)。 +-- +-- member_names 保留:它是**显示用的文字名单**(导出、大屏、录入页都读它),老数据靠它, +-- 也是「人没录档案、只登记不关联」的降级形态。人员身份只认这张表。 +-- +-- 老数据回填(尽力而为,匹配不上就不关联,绝不硬猜): +-- 1) 号码布优先:member_names 里的每一项先按 athletes.bib 认人(0014 有部分唯一索引, +-- 号码布是运动员的唯一标识); +-- 2) 其次「姓名 + 单位」,且**同单位同名的人只有一个**时才认(重名留给人工确认); +-- 3) 其余留空 = 维持升级前的行为(只报单位,上不了个人榜)。 +-- 回填只在升级那一刻做一次(写进 schema_migrations),不会在每次启动时重跑。它是 +-- INSERT OR IGNORE:万一被重放(schema_migrations 少了一行被补跑),已有的关联——包括人工 +-- 确认过的——一行都不会被覆盖或重复,最多补上「还没有的关联」(与 0024 的份额回填同一口径)。 +-- 回填只碰**纯团体报名**(athlete_id IS NULL):带运动员的报名本来就带身份,不需要队员。 +-- +-- 回填之后,配了个人通道(scope=athlete)的老团体项目会开始给队员发个人分 / 个人奖牌 +-- ——这正是本单的目的;**单位维度(单位总分、单位奖牌)不受影响**(见 domain 侧的口径与 +-- httpapi 的端到端对拍)。只配了团体通道的球类项目(现场绝大多数)一个字节都不变。 + +CREATE TABLE IF NOT EXISTS entry_athletes ( + entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + athlete_id INTEGER NOT NULL REFERENCES athletes(id) ON DELETE CASCADE, + -- 展示与流水顺序都用它:写侧按请求给的顺序编号,老数据回填按 member_names 里的书写顺序。 + -- 名次与积分不依赖顺序(每个队员各拿一整份),它只影响「队里有谁」的排列。 + sort_order INTEGER NOT NULL DEFAULT 0, + -- 同一条报名里同一个人只能出现一次(重复 = 同一条成绩给同一个人记两份分、两枚牌)。 + PRIMARY KEY (entry_id, athlete_id) +); + +-- 不另建 entry_id 索引:主键 (entry_id, athlete_id) 的索引已经以 entry_id 打头, +-- 读侧的「按小项批量取队员」走的正是它(与 0024 的份额表同一个取舍)。 +-- athlete_id 上的级联删除(人员被彻底删除时带走关联行)走全表扫描,但这张表只有报名量级。 + +-- 回填 1:号码布优先。member_names 里写号码布(导入规范允许)时按号码布认人。 +-- json_valid 的守卫是必须的:member_names 在库里是 JSON 数组文本,但历史上不排除被手工写成 +-- 别的样子;json_each 遇到坏 JSON 会直接报错,那会让整个升级失败、现场开不了机。 +-- 坏值按「没有名单」处理('[]'),等价于不回填这条报名。 +INSERT OR IGNORE INTO entry_athletes (entry_id, athlete_id, sort_order) +SELECT e.id, a.id, je.key + FROM entries e + JOIN json_each(CASE WHEN json_valid(e.member_names) THEN e.member_names ELSE '[]' END) AS je + JOIN athletes a ON a.bib = TRIM(je.value) + WHERE e.athlete_id IS NULL + AND TRIM(COALESCE(je.value, '')) <> ''; + +-- 回填 2:姓名 + 单位。同单位同名不止一个时(重名)不认——猜错会把分记到另一个人头上, +-- 而人还能事后在界面上补;猜错的分却已经上了榜。 +INSERT OR IGNORE INTO entry_athletes (entry_id, athlete_id, sort_order) +SELECT e.id, a.id, MIN(je.key) + FROM entries e + JOIN json_each(CASE WHEN json_valid(e.member_names) THEN e.member_names ELSE '[]' END) AS je + JOIN athletes a ON a.name = TRIM(je.value) AND a.team_id = e.team_id + WHERE e.athlete_id IS NULL + AND TRIM(COALESCE(je.value, '')) <> '' + -- 上面那条已经按号码布认过人时不再按姓名认第二遍(同一条报名里同一个人只能一行,主键兜着, + -- 这条只是让「认了谁」这件事一眼可读)。 + AND NOT EXISTS (SELECT 1 FROM entry_athletes ea WHERE ea.entry_id = e.id AND ea.athlete_id = a.id) + AND (SELECT COUNT(*) FROM athletes a2 WHERE a2.name = TRIM(je.value) AND a2.team_id = e.team_id) = 1 + GROUP BY e.id, a.id; diff --git a/server/migrations/event/migration_0026_test.go b/server/migrations/event/migration_0026_test.go new file mode 100644 index 0000000..062a8bc --- /dev/null +++ b/server/migrations/event/migration_0026_test.go @@ -0,0 +1,242 @@ +package event + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// 0026:团体报名的「队员身份」(entry_athletes)。 +// +// 四条底线: +// - 老数据零变化:既有表(entries / results / score_ledger / channel_ranks)一行一列都不改, +// 新增的只有这张关联表; +// - 回填**以号码布为准**、其次「姓名 + 单位」,且只在「同单位同名唯一」时才认,重名与查无此人 +// 一律留空(不硬猜:猜错就是把某人的分记到另一个人头上); +// - 只碰纯团体报名(athlete_id IS NULL):带运动员的报名自己就是那个人的身份; +// - 幂等:重复执行迁移(含把这条 SQL 重放一遍)不报错、不重复回填、不覆盖人工改过的关联。 +func TestMigration0026EntryAthletes(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0026") // 回到「升级前」:库结构到 0025 为止 + + stmts := []string{ + `INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`, + `INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`, + // 号码布是运动员的唯一标识(0014 上有部分唯一索引)。 + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('张伟', '男', 1, 'A001')`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A002')`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`, + // 同单位两个同名的人:回填不许猜(下面这条报名里写的「李强」认不出是哪个)。 + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`, + // 另一个单位的运动员:号码布能认到他(号码布不分单位),姓名+单位认不到。 + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '女', 2, 'B001')`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (1, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, + `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, + // 1) 姓名 + 单位都认得上:张伟 + 王五(顺序也要留住)。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, status) + VALUES (1, 1, 1, '["张伟","王五"]', 'registered')`, + // 2) 号码布优先:写的是 A002(同一个单位的王五)与 B001(另一个单位的赵六)。 + // 用队伍名与上一条区分开(一个单位在一个组别只能有一条不分队的团体报名,见 0025)。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad) + VALUES (1, 1, 1, '["A002","B001"]', 'registered', '二队')`, + // 3) 重名(同单位两个李强):不认。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, status) + VALUES (1, 1, 2, '["李强"]', 'registered')`, + // 4) 查无此人:不认。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad) + VALUES (1, 1, 2, '["查无此人"]', 'registered', '二队')`, + // 5) 带运动员的报名:本来就有身份,不回填。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status) + VALUES (1, 1, 1, 1, '["张伟"]', 'registered')`, + // 6) member_names 是坏 JSON(历史上被手工写过非 JSON 文本):按「没有名单」处理,迁移不许失败。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad) + VALUES (1, 1, 2, '张伟/李强', 'registered', '三队')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造老库数据 %q: %v", s, err) + } + } + + // 升级前:这张表还不该存在。 + if _, err := db.Exec(`SELECT count(*) FROM entry_athletes`); err == nil { + t.Fatal("升级前不应存在 entry_athletes 表") + } else if !strings.Contains(err.Error(), "entry_athletes") { + t.Fatalf("查询报错应点明缺表 entry_athletes,实际 %v", err) + } + + // 既有表在升级前的原始内容:升级后必须逐字节一致。 + untouched := []string{ + `SELECT id, event_id, category_id, team_id, COALESCE(athlete_id, 0), COALESCE(member_names, ''), + COALESCE(bib, ''), COALESCE(seed, ''), COALESCE(status, ''), squad + FROM entries ORDER BY id`, + } + before := make([]string, len(untouched)) + for i, q := range untouched { + before[i] = tableText(t, db, q) + } + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("升级赛事库: %v", err) + } + + // 回填结果:只有「认得出」的两种情形落行,顺序按 member_names 的书写顺序。 + want := map[int64][]string{ + 1: {"1:0", "2:1"}, // 张伟、王五 + 2: {"2:0", "5:1"}, // 号码布 A002 → 王五;号码布 B001 → 赵六(另一个单位也认) + 3: {}, // 重名不猜 + 4: {}, // 查无此人不猜 + 5: {}, // 带运动员的报名不回填 + 6: {}, // 坏 JSON:按没有名单处理 + } + got := entryAthletesText(t, db) + for entryID, wantRows := range want { + if strings.Join(got[entryID], ",") != strings.Join(wantRows, ",") { + t.Errorf("报名 %d 回填出 %v,期望 %v", entryID, got[entryID], wantRows) + } + } + + // 老数据零变化:既有表的每一行每一个字段都没动。 + for i, q := range untouched { + if after := tableText(t, db, q); after != before[i] { + t.Fatalf("升级改动了既有数据(第 %d 条对拍):\n之前\n%s之后\n%s", i+1, before[i], after) + } + } + + // 表结构:主键 (entry_id, athlete_id) 保证同一条报名里同一个人只有一行。 + if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 9)`); err == nil { + t.Error("同一条报名里同一个运动员重复应被主键拒绝") + } + // 外键:报名 / 人员不存在都写不进去。 + if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (999, 1)`); err == nil { + t.Error("entry_id 外键应拦住不存在的报名") + } + if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (1, 999)`); err == nil { + t.Error("athlete_id 外键应拦住不存在的人员") + } + + // 幂等:再跑一次迁移,内容不变。 + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("重复升级应无副作用: %v", err) + } + if after := entryAthletesText(t, db); fmt.Sprint(after) != fmt.Sprint(got) { + t.Fatalf("重复迁移改动了队员关联:\n之前 %v\n之后 %v", got, after) + } + // 迁移被重放(schema_migrations 少一行被补跑)时:已有的关联(含人工确认过的)不被覆盖、 + // 不产生重复行(INSERT OR IGNORE 的口径与 0024 的份额回填一致)。 + if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 7)`); err != nil { + t.Fatalf("人工补一条关联: %v", err) + } + if _, err := db.Exec(readMigration(t, "0026")); err != nil { + t.Fatalf("重放 0026: %v", err) + } + replayed := entryAthletesText(t, db) + if rows := replayed[3]; strings.Join(rows, ",") != "3:7" { + t.Errorf("重放改动了人工确认的关联(顺序必须原样保留):%v", rows) + } + if rows := replayed[1]; strings.Join(rows, ",") != "1:0,2:1" { + t.Errorf("重放后报名 1 的关联应保持不变、也不重复:%v", rows) + } + + // 级联:报名删掉带走它的队员行;人员被彻底删除也带走(purgeAthlete 走的就是这条外键)。 + if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil { + t.Fatalf("删报名: %v", err) + } + if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE entry_id = 2`); got != 0 { + t.Errorf("报名删掉后队员行应级联清空,还剩 %d 条", got) + } + if _, err := db.Exec(`DELETE FROM athletes WHERE id = 5`); err != nil { + t.Fatalf("删人员: %v", err) + } + if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE athlete_id = 5`); got != 0 { + t.Errorf("人员删掉后队员行应级联清空,还剩 %d 条", got) + } + + // 外键悬挂检查。 + fkRows, err := db.Query(`PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("外键检查: %v", err) + } + defer func() { _ = fkRows.Close() }() + if fkRows.Next() { + t.Error("迁移后存在外键悬挂") + } + + // 迁移登记数对得上(这条迁移被记账一次)。 + if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { + t.Errorf("迁移登记数 = %d,期望 %d", got, want) + } +} + +// entryAthletesText 把队员关联按报名归组导成 "%d:%d"(运动员 id:顺序)的稳定文本。 +func entryAthletesText(t *testing.T, db *sql.DB) map[int64][]string { + t.Helper() + out := make(map[int64][]string) + rows, err := db.Query(`SELECT entry_id, athlete_id, sort_order FROM entry_athletes + ORDER BY entry_id, sort_order, athlete_id`) + if err != nil { + t.Fatalf("读队员关联: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var ( + entryID, athleteID, order int64 + ) + if err := rows.Scan(&entryID, &athleteID, &order); err != nil { + t.Fatalf("扫描队员关联: %v", err) + } + out[entryID] = append(out[entryID], fmt.Sprintf("%d:%d", athleteID, order)) + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历队员关联: %v", err) + } + return out +} + +// tableText 把一条查询的全部行拼成一份对拍文本(列之间用 | 连)。 +func tableText(t *testing.T, db *sql.DB, query string) string { + t.Helper() + rows, err := db.Query(query) + if err != nil { + t.Fatalf("查询 %q: %v", query, err) + } + defer func() { _ = rows.Close() }() + cols, err := rows.Columns() + if err != nil { + t.Fatalf("读列名: %v", err) + } + var b strings.Builder + for rows.Next() { + cells := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range cells { + ptrs[i] = &cells[i] + } + if err := rows.Scan(ptrs...); err != nil { + t.Fatalf("扫描 %q: %v", query, err) + } + for i, c := range cells { + if i > 0 { + b.WriteString("|") + } + switch v := c.(type) { + case nil: + b.WriteString("") + case []byte: + b.Write(v) + default: + fmt.Fprintf(&b, "%v", v) + } + } + b.WriteString("\n") + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历 %q: %v", query, err) + } + return b.String() +} diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index c3e07b4..fcb9fcd 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -1,8 +1,10 @@