From 7a3e4ad365be6ab16774c31b0cb9390743456e85 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 14 Sep 2026 11:45:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(entry):=20=E5=9B=A2=E4=BD=93=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=A4=9A=E9=98=9F=EF=BC=88=E8=BF=81=E7=A7=BB=200025?= =?UTF-8?q?=EF=BC=89+=20=E4=BF=AE=E8=81=94=E9=98=9F=E5=9B=A2=E4=BD=93?= =?UTF-8?q?=E5=90=8D=E6=AC=A1=20/=20=E5=A4=A7=E5=B1=8F=E5=88=86=E9=A1=B9?= =?UTF-8?q?=E6=88=90=E7=BB=A9=20/=20=E9=A2=84=E8=A7=88=E7=AE=97=E5=88=86?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=9B=E7=89=88=E6=9C=AC=20dev=5F0.5.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、新能力:团体项目多队 现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。 - 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍); 唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL` —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。 - 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。 - 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。 - 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。 改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。 - **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。 ## 二、修:团体名次控件没体现联队 联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**; 成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。 被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。 ## 三、修:大屏「分项成绩」排名错乱 现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。 **根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。 修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。 复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。 顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。 ## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸) 现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。 1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0; 2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10); 3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字; 4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。 延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。 ## 五、修:手录名次项目的「成绩」列空白 `displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→ 新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。 ## 文档 `docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、 `docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。 ## 验证 - 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。 - 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。 ## 版本 `dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。 ## 下一批 `dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。 --- 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 | 48 +- docs/升级说明.md | 71 ++- docs/报名名单导入规范.md | 60 ++- server/internal/export/entries.go | 9 +- server/internal/export/load.go | 14 +- server/internal/export/medals.go | 21 +- server/internal/export/squad.go | 54 +++ server/internal/export/squad_dump_test.go | 183 +++++++ server/internal/export/squad_test.go | 210 ++++++++ server/internal/export/team.go | 4 + server/internal/export/team_all.go | 11 +- .../httpapi/channel_compare_guard_test.go | 90 ++++ server/internal/httpapi/dto.go | 13 +- .../httpapi/entries_sheet_template.go | 7 +- .../httpapi/entries_sheet_template_test.go | 8 +- server/internal/httpapi/entry_heat_test.go | 3 +- server/internal/httpapi/entry_squad.go | 107 +++++ .../internal/httpapi/entry_squad_edit_test.go | 325 +++++++++++++ .../httpapi/entry_squad_internal_test.go | 93 ++++ server/internal/httpapi/entry_squad_test.go | 331 +++++++++++++ server/internal/httpapi/handlers_config.go | 16 + .../httpapi/joint_team_template_test.go | 2 +- server/internal/httpapi/payload.go | 8 + server/internal/httpapi/repo_events.go | 27 +- server/internal/httpapi/repo_import.go | 58 ++- server/internal/httpapi/server.go | 3 + .../httpapi/team_manual_ranks_score_test.go | 82 ++++ server/migrations/event/0025_entry_squad.sql | 20 + .../migrations/event/migration_0025_test.go | 174 +++++++ web/src/admin/api/scoring.ts | 197 +++++++- .../components/baseData/ChannelsSection.vue | 24 +- .../admin/components/baseData/EntryPicker.vue | 52 +- .../admin/components/baseData/channelRows.ts | 25 +- .../components/baseData/entrySelection.ts | 107 ++++- web/src/admin/pages/EntryPage.vue | 448 ++++++++++++++++-- web/src/admin/pages/ReviewPage.vue | 25 +- web/src/admin/pages/entrySquad.ts | 160 +++++++ web/src/admin/pages/teamRanks.ts | 133 +++++- web/src/admin/tests/avg-mode.test.ts | 7 +- .../tests/channels-compare-guard.test.ts | 38 ++ web/src/admin/tests/entry-selection.test.ts | 194 +++++++- web/src/admin/tests/entry-squad.test.ts | 257 ++++++++++ .../admin/tests/scoring-team-manual.test.ts | 289 +++++++++++ web/src/admin/tests/scoring.test.ts | 9 +- web/src/admin/tests/team-ranks.test.ts | 140 +++++- web/src/display/components/DetailOverlay.vue | 3 +- web/src/display/components/TickerBar.vue | 10 +- .../components/pages/EventResultsPage.vue | 23 +- web/src/display/components/pages/LivePage.vue | 23 +- web/src/display/jointTeams.ts | 49 +- web/src/display/results.ts | 84 +++- .../tests/detail-overlay-athlete.test.ts | 6 +- .../display/tests/event-results-rows.test.ts | 162 +++++++ web/src/display/tests/fitFont.test.ts | 34 ++ web/src/display/tests/joint-teams.test.ts | 70 ++- web/src/display/tests/live-page.test.ts | 10 +- web/src/display/tests/results.test.ts | 119 +++++ web/src/display/useScoreboard.ts | 47 +- web/src/shared/index.ts | 1 + web/src/shared/squad.ts | 30 ++ web/src/shared/tests/squad.test.ts | 44 ++ web/src/shared/types.ts | 8 + 67 files changed, 4658 insertions(+), 228 deletions(-) create mode 100644 server/internal/export/squad.go create mode 100644 server/internal/export/squad_dump_test.go create mode 100644 server/internal/export/squad_test.go create mode 100644 server/internal/httpapi/channel_compare_guard_test.go create mode 100644 server/internal/httpapi/entry_squad.go create mode 100644 server/internal/httpapi/entry_squad_edit_test.go create mode 100644 server/internal/httpapi/entry_squad_internal_test.go create mode 100644 server/internal/httpapi/entry_squad_test.go create mode 100644 server/internal/httpapi/team_manual_ranks_score_test.go create mode 100644 server/migrations/event/0025_entry_squad.sql create mode 100644 server/migrations/event/migration_0025_test.go create mode 100644 web/src/admin/pages/entrySquad.ts create mode 100644 web/src/admin/tests/channels-compare-guard.test.ts create mode 100644 web/src/admin/tests/entry-squad.test.ts create mode 100644 web/src/admin/tests/scoring-team-manual.test.ts create mode 100644 web/src/display/tests/event-results-rows.test.ts create mode 100644 web/src/display/tests/results.test.ts create mode 100644 web/src/shared/squad.ts create mode 100644 web/src/shared/tests/squad.test.ts diff --git a/VERSION b/VERSION index 7cb4ac2..f47b9ad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.4 +dev_0.5.5 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index fab95e9a8055bbb3d5d64f3a07551c7d06ac6607..bc59ccefda2c330992caaedd8b31ef91fd13c931 100644 GIT binary patch delta 434 zcmdnCT6o)P;SCZwY<}7TK(yJ8(S&JoP~HR~AjemW-&2j-U4_#}lhO9`+Jc)M7S?)TaMq*y-+#qRpMpdTCZ;B^NPs|Sis_4xQ=+6!6NcEamosj0N z4prpEsKhjRPuT>yC=1yLGwA?bp;TuLZw+22Ip%yH!`YRwzFK^K+Wecd$~Q5|dui}` zs`CJy0d@nQk0xKBp3uCS#4uwCkdrpAZc<~KoLJK%t0U;EEdZ1bH4+aukp!wP53!tG z83z;rnYF43=x(F>E;*ngKW#xbWsce?`9T~NmCd(8~>?h#H8r0$>*xfkz}tjy(F?H)2}ZlaAAGQjI!uBYXxr&9*}Wsn^c%4 zC)V^x=?eO2@&)P%&#H*cbkp|N5u8>Mnd`0ZqshB@c@xlGM)h5CKHB_1n}AMBbJhU5 z(OH2FNCKVap~?wyng-KkrlwwLA1!`=Z2?y$_V!e-iZJW04BvDYO{lw8HEm@ADFFH$ zVj<9o`L#*O4k~a3KvNdAgG~V{0$S*5Faj|X5HoKVoxrlt6##%fdn*6{ diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index 39c9a0b13a5be5cd2b77a897afc51c3ae9dba5e1..ba30d4d698d4d167992276983516bd8d6bccd213 100644 GIT binary patch delta 1048 zcmYjQ-A__+7~b!6P(D;Z#hio_K@db{tAMn~Py6UQ%uIi?XbY1ow4lB3!`RzSB&wHNtecsuW`2GlDxx*#Cc27b4{lj@ zm;4_>!%zLK3;m8~`^Ix4d9e(`ogOG%bhBZ45# zev1KWkKuJ1IYe*Q zi$Sw#leh#Ymte&9@^kMW-FZ3fZ8WCZ^(j!nK=*!-EE3)#e1RHu9R1%}uS@n^$aoX% z-3<03!6WnMgrG|Li^xehLii#j*VJj9wlevyE+f+h&rh*nlUC(Dw2&>aD_JRcfXkG!Zs1?BzXdxHg-W`%p)nKBbm=9Za~<5 zJ_FsL#91h9D3r3BjdAXt#n)K7v8MHrW|)NrYE*oTXVnTJim70nqd?3YpAg8WD=0;* z0D8DRTk}JMRwHhP_lnwErSewg-ZH7F(3h?Kq^yQkwz*0 E0(2)TJpcdz delta 1066 zcmZuwNla5w6wP~lu#}dz2ob@SLR&fl1tCpk5D;5hIuMl6$_z4vGE)!;f7y=!n-qg~1v@k($4}8cm~pb=1zVj%^|#YNN!eZX z!{N{h3l&_qJ?)Xl)m)&mD#M&Qp|6ydG3<}eL`IY*8ONy;9iiw%*c`o*_x9mvmm{rE z5qdzG{X*i2`dl39;NZjIOo}keL$6on_!b5_ZF);0w>U`@sH6Q#)Fd8V9E3&5%aa5R zm1k{wJ?)mH{J0>ynvdj4hTZ-~q(oDs;#V2YG+$6P<|Q`dDiDqN%IZw%RHJ2mzHg)| zr!GfUWr+2&D*UYO?<9uk(GMEGS#L%Ut26F5_xh2Hz+!E->-nv=wHY@wP1F{cWhbDC z{j*EpgXkxTB|5FpqTse%)V!SQe$ZFhYSMNV=@Bg^?QE;#_1)fynt~2Xa!r=hrslK1 zcL}Pl{~}_dK~*T9ZnD0*(^H$BkRQjuom?^Xlx0F*TcIvbLcwup1%BE)^Ls?KlRA9UMO1Lo}$T+HSUjUgJ`P@odJK-y%T$&wfb7A(gnEDzk% z0gGJ>0nM9CL*(EgcdpF|Bd!&wVg2Y{spBL_O65^0Q3h&3MtS<_m8tf}V+~jjS7dK^ zJ^$f+6468w?{7}V=_$@&mxlXG486q}TZ==-vt10^cZ!PlDjYmHoW0ar@$^RX_|<&e zmfMzkTuP+)2q&P&Wt6N;cYyZdcBlJa`5OsGIQ#a=ICyn<9K^Eqif!!DeK5B|Sldw(4| x5k?>|zIF5xGo5avKoHBf^({1+yMG?oAW diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt index 7d85683..d1d899f 100644 --- a/console/installer/art/version.txt +++ b/console/installer/art/version.txt @@ -1 +1 @@ -0.5.4 +0.5.5 diff --git a/console/package.json b/console/package.json index 89550d5..240a134 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.4", + "version": "0.5.5", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/docs/使用手册.md b/docs/使用手册.md index 730487c..1f0bf3a 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -102,7 +102,7 @@ 表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。 -**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。 +**「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。 | 列 | 操作 | | --- | --- | @@ -300,7 +300,8 @@ 3. 底部「**添加成员单位…**」下拉里选一个单位再点「添加」——下拉里列的是还没在成员里的单位(**已停用**的单位也可以加,会标注「已停用」;被「彻底删除」的单位不出现); 4. 弹窗下方实时显示 `当前比例:第一分公司 50% · 第二分公司 50%`,两个以上成员时旁边还会给出联队名预览(`第一分公司+第二分公司联队`); 5. **主单位不能去掉**(它的「移除」是灰的):报名挂在它名下,要换主单位只能重新建报名; -6. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。 +6. **纯团体报名还会多一个「队伍」输入框**(同一个单位派两个队时写队名,见 4.12):**只有纯团体报名显示这个框,个人报名连框都不出现**;留空 = 不区分队伍。它与份额是**两个接口**,改队名**不会重算榜单**(队名只是标签); +7. 点 **「保存成员单位」** 提交——服务端**当场重算该小项该组别**的名次与积分并刷新大屏榜单,提示「成员单位已保存」。**没改动时保存按钮是灰的**,点「关闭」不会发出任何请求。按钮上的字会按你改了什么变:只改队名时是 **「保存队伍」**、只改份额时是 **「保存成员单位」**、两样都改了是 **「保存成员单位与队伍」**。 **要注意的** @@ -313,6 +314,34 @@ - ⚠️ **「按队内平均」这类聚合通道的项目,联队不拆份额**:这类通道的名次不对应任何一条报名,分仍记在主单位头上(本轮如此)。要保证联队一起算,请用不聚合的口径(或按 4.9 在主单位上录名次)。 - 一件事只做一次:**同一个小项同一个组别里,两个单位联合参赛就写一条 `A+B` 的报名**。`A+B` 与 `B+A` 是**两条不同的报名**(主单位不同),不要两种写法都填。 +### 4.12 团体项目多队(同一个单位派两个队) + +**羽毛球双打那种:一个单位在同一个组别报了两支队**(例如一队、二队),两队可能各占一个名次。系统支持这么报——**不用新建单位、也不用重复报两次**,两队各算各的成绩、名次与积分,最后都汇到同一个单位的总分上(一个单位两个队都进前三时,这个单位拿 **2 枚牌**)。 + +**只对团体项目**:个人项目(按人报名的那些)不受影响,也没有「队」这回事。 + +**怎么录(三条路,任选)** + +| 方式 | 怎么做 | +| --- | --- | +| **导入报名表** | 用**最后一列「队伍」**写队名:一队那行写 `一队`、二队那行写 `二队`(同一个单位、同一个组别写两行)。**只有一支队的单位这一列留空**;不填这一列、表头里没有它,都合法。列名与报错见[报名名单导入规范 · 第三节的「队伍」](./报名名单导入规范.md)——⚠️ **导入表由 12 列变成 13 列,但老 12 列表照常导入**(新列加在最后,一列都不会错位) | +| **补报名(勾选)** | 在「成绩录入」页工具栏点 **「补报名」**(或「基础数据 → 报名导入」的**团体报名**页签):勾上某个单位后,**那一行右侧会出现「队伍(留空=不区分)」输入框**,填队名即可;**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)` | +| **改队名** | 队名填错了**不用删掉重建**:到「成绩录入」页**点那一行的「单位」格**,打开「成员单位与份额」弹窗,里面有 **「队伍」输入框**(**只有纯团体报名才显示**,个人报名连框都不出现)。改完点保存——按钮会按你改了什么都写明(`保存队伍` / `保存成员单位` / `保存成员单位与队伍`),**没改动时按钮是灰的** | + +**显示成什么样** + +- **`单位名 + 队名`,不加括号、不加分隔符**:`第一分公司一队`、`第一分公司二队`;**队名留空时仍然只写 `第一分公司`**(与以前逐字一样); +- 出现的地方:**成绩录入页 / 成绩审核页的「单位」列、批量操作的确认框、报错里的行名、大屏的分项成绩 / 实时赛况 / 底栏「最新完成项目」,以及导出件的「单位」列**(分项成绩表、全部成绩单页、报名名单、单位奖牌明细总表、单位积分总明细总表); +- **单位总分榜、单位奖牌榜、个人积分榜上不写队名**(它们的单位名来自单位汇总,分与奖牌本来就归到单位)。 + +**要注意的** + +- **同一个单位、同一个组别、同一个队名只能有一条**:要派两支队就得写**两个不同的队名**。两个队名写一样(或都留空)时,第二行会被**逐行拦下**并说清是哪个单位、哪个队(见导入规范的第六节); +- **队伍只对团体行生效**:个人行(有姓名的那几行)**填了队名也不会写进库里**,导入结果里会给一条提示说明这一格被忽略了; +- ⚠️ **改完队名之后,记得把报名表里的队名也改掉**:导入是按「小项 + 组别 + 单位 + 队名」认人的,以后再导一份**还写着旧队名**的表,会**新增出旧队名那一条报名**(不会静默合并); +- **改队名不影响成绩与榜单**:队名只是给现场看的一个标签,不参与名次、积分、奖牌的计算,所以改它**不会重算榜单**,也**不会给别处的大屏 / 录入页推刷新**——别的窗口要等下一次刷新才看到新队名(你自己那一次保存的页面上会立刻变); +- 队伍**不参与「团体名次」按单位录入**的那一类项目(篮球那种每个单位只有一支队):名次仍然是按单位录的,与队名无关。 + --- ## 5. 成绩审核 @@ -723,12 +752,13 @@ 1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项; 2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号与号码布。 -**团体报名**(一个单位一条报名) +**团体报名**(默认一个单位一条报名;同一个单位要派两支队时见 4.12) 1. 在参赛单位清单里勾选要参加本组别的单位,**默认一个都不勾**——不参加的单位不勾即可;每行显示该单位是否「已报名」、已登记几名队员; -2. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补; -3. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单; -4. 点「加入报名」,一次为所有勾选的单位各建一条报名。 +2. **同一个单位要派两支队时**(羽毛球双打那种):勾上这个单位后,那一行右侧会出现 **「队伍(留空=不区分)」** 输入框,填上队名(例如 `一队`);**没勾的单位不显示这个框**。已经报过的单位徽标会写成 `已报名(一队、二队)`——**要再报第二支队,就再填一个新的队名提交一次**(同一个队名重复提交只是更新队员名单,不会新增)。详见 4.12; +3. 要登记队员时点该行的「登记队员」展开名单逐个勾选(可按姓名 / 工号搜索);一个队员都不勾就是**纯单位报名**,队员名单之后还能再补; +4. 勾选「已报名」的单位会出现「提交将清空原名单」提醒:再次提交是用本次登记的队员覆盖原名单; +5. 点「加入报名」,一次为所有勾选的单位各建一条报名(**同一个单位 + 同一个队名**只留一条)。 - 「全选」只勾启用中且还没报名的单位(已停用的单位要单独勾),且不会展开队员面板;「清空」只取消单位勾选,已登记的队员名单留着,重新勾上就回来。 **表格导入** @@ -740,8 +770,9 @@ - 「**分工作表 · 中文表头 / 分工作表 · 英文表头**」=**分工作表模板**(`.xlsx`):按当前赛事已配好的**小项 × 组别各建一张工作表**,表名是「**小项名称@组别名称**」(例如 `100 米跑@男子组 30岁以下`),范围由表名承担,数据区只填人和单位。小项组别多时用它更不容易抄串; - 两种模板的表头与列顺序完全一样,只有「范围从哪来」不同(详细规范见 [报名名单导入规范 · 第八节](./报名名单导入规范.md)); - **两个单位联合参赛(联队)**:把「单位编码」那一格写成 `F1+F2`(加号左边是主单位,默认均分,见 4.11)。**分工作表模板**里,「单位编码」表头那一格挂了一条**单元格批注**(鼠标停上去就弹出来)、这一列还有**输入提示**(点中该列的格子就弹出来),中英各一份,写的正是这套写法——表头文字、列顺序与工作表名一个字都没动,**老表格照常导入**; -2. 表头共 12 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)为可选列,缺失不影响导入,其余列必须包含; +2. 表头共 13 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`、`squad`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)、`squad`(队伍)为可选列,缺失不影响导入,其余列必须包含; - `team_code`(单位编码)有两种填法:**一个单位**填它的编码(`F1`);**两个单位联合参赛**填 `F1+F2`(加号左边是主单位,默认均分;非均分要到录入页改,见 4.11); + - `squad`(队伍)是**最后一列**、**新加的(2026 年 9 月)**:同一个单位在同一个组别派两支队时(羽毛球双打的一队 / 二队)用它区分,写队名即可(中文表头里叫「队伍」,也认「队名」「队伍名」);**只有一支队的单位整列留空**、整列不写、表头里没有它,三种都合法。**老 12 列表照常导入**(新列加在最后,一列都不会错位),详见 4.12 与[报名名单导入规范 · 第三节「队伍」](./报名名单导入规范.md); - 用**分工作表模板**时,`event_code`、`category` 两列也可以整列不要或整列留空(范围由工作表名决定);一旦填了就必须与工作表名一致,不一致的那一行会报错并跳过; - 工作表名必须是「小项名称@组别名称」;工作簿里只要有**一张**表名带 `@`,整份文件就按分工作表模板处理,别的表名不合规会**整份拒绝并点名是哪张表**(不让数据无声消失); - 小项名与组别名里**不允许出现 `@`**(它是表名里的分隔符):新建 / 编辑小项与组别时界面会当场拦下并说明原因;**已经带 `@` 的历史名字不影响报名与成绩**,但下载分工作表模板时会被点名要求先改名; @@ -811,6 +842,7 @@ | 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` | - 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导); +- **队名(同一个单位派两个队时写的那个,见 4.12)写进「单位」列**:分项成绩表 / 全部 / 全部(单页)/ 报名名单 / 单位奖牌明细总表 / 单位积分总明细总表里,写的是 **`第一分公司二队`** 这种「单位名 + 队名」(队名留空时仍然只写 `第一分公司`)。⚠️ **导出件里没有单独的「队名」列**——所以**不要拿导出的报名名单直接改完再导回去**(那会撞「同一个单位同一个队」的重复报名、报错拦下);要改队名或改名单,用**导入模板**那条路,或到录入页点「单位」格改队名; - **联队(一条报名挂多个单位)在这几份导出件里的写法**(见 4.11): - **「分项成绩表」「全部」「全部(单页)」的「单位」列**:写成联队全名 `第一分公司+第二分公司联队`(成员单位名写全,与导入表的 `F1+F2` 同构);**「备注」列**写清这条分给了谁、各多少,例如 `联队分拆:第一分公司 5 分(50%)、第二分公司 5 分(50%)`——「积分」列写的是**联队拿到的总分**(上例 10 分),每家的那一份看备注; - **「报名名单」**:「单位编码」列回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位)、「单位名」列写联队全名、「备注」列写 `联队分拆:第一分公司 50%、第二分公司 50%`(这张表没有积分列,所以只写份额比例); @@ -1053,6 +1085,8 @@ > **联队(两个单位合并参赛)在大屏上怎么写**(见 4.11):「分项成绩」与「实时赛况」的**单位列写联队全名**——`第一分公司+第二分公司联队`,并且**把两个单位的 logo 都放上**(单位列宽会为该行实际要画的 logo 数留位置,第二个 logo 不会把名字挤成省略号);底栏「最新完成项目」那一行同样写联队全名。**单位奖牌榜、总积分榜本来就是逐单位一行,服务端已按份额拆好**(例:A、B 各 1 枚金、各 7.5 分),屏上不需要另做处理。**没有联队的赛事,大屏画面与升级前逐像素一致**。 +> **队名(同一个单位派两个队时写的那个,见 4.12)同样写在大屏的单位名后面**:`第一分公司二队`——分项成绩、实时赛况、底栏「最新完成项目」三处都一样(同一个单位的两个队都进了名次时,两行只差队名,不写就分不出哪一行是谁);**单位奖牌榜、总积分榜上不写队名**。队名留空时画面与以前逐像素一致。 + > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 > 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 diff --git a/docs/升级说明.md b/docs/升级说明.md index d6b3860..ca79bb5 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,74 @@ --- -## dev_0.5.4(当前版本) +## dev_0.5.5(当前版本) + +### 一句话 + +**有一条数据迁移(`0025`),老数据一个字节都不变**:团体项目现在支持**一个单位在一个组别派多支队**(羽毛球双打那种「一队 / 二队」)——以前数据库的唯一索引只允许一个单位一条团体报名,第二支队录不进去。升级后:**现有的报名、成绩、名次、积分、奖牌、导出件与升级前完全一样**(老数据的队名都是空的,在新索引下仍然「一个单位一条」),**多队能力只有在真的给某个单位报了第二支队之后才会体现**;**报名表由 12 列变成 13 列**(最后一列新增「队伍」),**老 12 列表照常导入**。 + +### ① 数据迁移:一条(`0025`) + +- **它做什么**:给报名表加一列 `squad`(队伍,自由文本,**默认空串**),并把「同一个单位在同一个组别只能有一条团体报名」的那个唯一索引换成**带上队伍的版本**(`组别 + 单位 + 队伍`)。**带的运动员的报名不受影响**:它们仍然按「组别 + 单位 + 运动员」唯一,同一个运动员不会因为这次改动而在同一组别报两次。 +- **老数据零变化**:升级后所有旧报名的队伍都是**空串**,在新索引下等价于「一个单位一条」——**原来能跑的名单、成绩、榜单、导出件一个字节都不变**(对拍证据见下)。 +- **怎么看它跑过**:启动一次 Console,在运行日志或 `logs\operations\` 当天文件里能看到 `已应用迁移 … name=0025_entry_squad.sql`。 +- **对拍证据**:接口层把老数据 `GET /entries` 的 JSON 里新字段剥掉后**逐字节相同**;导出层把 9 份导出件的**全部单元格(共 750 行)dump 出来比对**,**md5 相同**(另带敏感度自检,确认这份对拍真的能发现差异)。 + +### ② 新能力:团体项目多队(只给团体项目) + +**怎么做**:一个单位在同一个组别报两支队时——两队**各写一行**,靠队伍列 / 队名区分: + +| 场景 | 怎么做 | +| --- | --- | +| **导入报名表** | 用**最后一列「队伍」**写队名(`一队` / `二队` / `青年队`,随便写);**只有一支队的单位这一列留空**。中文表头叫「队伍」,也认「队名」「队伍名」 | +| **补报名(勾选)** | 「成绩录入」页的「补报名」或「基础数据 → 报名导入」的**团体报名**页签:勾上单位后**那一行右侧出现「队伍(留空=不区分)」输入框**,填队名提交即可;**没勾的单位不显示这个框**;已报过的单位徽标写成 `已报名(一队、二队)` | +| **改队名** | 填错不用删掉重建:「成绩录入」页**点那一行的「单位」格** → 弹窗里的 **「队伍」输入框**(**只有纯团体报名才显示**)→ 保存(按钮会按改了啥写成 `保存队伍` / `保存成员单位` / `保存成员单位与队伍`,**没改动时是灰的**) | + +**口径**: + +- **队伍只对团体项目**:个人项目(按人报名的那些)**不受影响**,也没有「队」这回事;个人行即使填了队名也**不会写进库里**,导入结果里会给一条提示; +- **留空 = 不区分队伍**:绝大多数单位只有一支队,**这一列留空 / 整列不写 / 表头里没有它**都是一样的(= 升级前的行为); +- **同一个单位 + 同一个组别 + 同一个队名只能有一条**:要派两支队就写**两个不同的队名**;写重了会被**逐行拦下**并说清是哪个单位、哪个队,**其余行照常导入**; +- **一个单位两个队都进前三 → 这个单位拿 2 枚牌**(两支队各算各的名次,分与牌都记到同一个单位头上); +- **队伍不影响算分**:两队的成绩、名次、积分各算各的,最后都汇到同一个单位的总分里; +- **不涉及「按单位手工录名次」的项目**(篮球那种每个单位只有一支队):名次仍然按单位录,与队名无关。 + +### ③ 显示口径:`单位名 + 队名` + +- 写出来**不加括号、不加分隔符**:`第一分公司二队`;**队名留空时逐字保持原样**(`第一分公司`); +- **写队名的地方**:成绩录入页 / 成绩审核页的「单位」列、批量操作的确认框、报错里的行名、大屏的分项成绩 / 实时赛况 / 底栏「最新完成项目」,以及**导出件的「单位」列**(分项成绩表、全部成绩单页、报名名单、单位奖牌明细总表、单位积分总明细总表); +- **不写队名的地方**:**单位总分榜、单位奖牌榜、个人积分榜**——这三处是单位级汇总,分与奖牌本来就归到单位。 + +### ④ 报名表 12 列 → 13 列(老表照常导入) + +- 新增的是**最后一列「队伍」**(英文规范名 `squad`,中文别名「队伍」/「队名」/「队伍名」),**可选**:整列不写、格子留空、表头里没有它,三种都合法; +- 新列加在**表尾**,所以**手里原来的 12 列表一列都不会错位、照常导入**;下载的新模板是 13 列(分工作表模板也一样); +- 填法、报错文案与例子见《报名名单导入规范》第二节 / 第三节「队伍」/ 第五节的例 5。 + +### ⑤ 改完队名,记得顺手改报名表 + +⚠️ 导入是**按「小项 + 组别 + 单位 + 队名」认人**的:在界面上把「二队」改成「青年队」之后,如果以后再导一份**还写着「二队」**的报名表,系统会**新增出「二队」那一条报名**(不会静默合并,也不会报错)。所以**改队名时把报名表里的队名也改成一样**。 + +### ⑥ 升级后 5 分钟核对 + +1. 启动一次 Console,看运行日志 / `logs\operations\` 当天文件:应能看到 `0025` 这条迁移记录,没有别的报错; +2. 刷新管理端「积分榜」与大屏:**单位总分榜、单位奖牌榜、个人积分榜的每一个数字与升级前一致**(本版不改任何算分口径); +3. 打开「成绩录入」页:**「单位」那一列与升级前长得一样**(老报名的队名是空的,不会多出任何字);点某一行的「单位」格,**纯团体报名**的弹窗里应多出一个「队伍」输入框(**个人报名看不到这个框**,这是对的); +4. 打开「基础数据 → 报名导入」,下载一份模板:**表头最后一列应是「队伍」**(共 13 列);拿一份升级前的老 12 列表导一次,应与升级前一样正常导入; +5. 拿一个真要多队的项目试一次:同一个单位、同一个组别报两行、队伍列分别写 `一队` / `二队`,导入后到录入页确认**两支队各占一行、单位列写成 `第一分公司一队` / `第一分公司二队`**,录成绩发布后**两队各拿各的名次、单位的分数是两队之和**; +6. 用《上线检查清单》过一遍。 + +### ⑦ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 多队怎么录、显示成什么样、改队名在哪 | 《使用手册》4.12(以及 4.2、4.11、10.7、11.1、17.1 各处的衔接说明) | +| 导入表「队伍」列怎么写、报什么错 | 《报名名单导入规范》第二节、第三节「队伍」、第五节的例 5、第六节 | +| 迁移与唯一索引 | `server/migrations/event/0025_entry_squad.sql` | + +--- + +## dev_0.5.4 ### 一句话 @@ -67,7 +134,7 @@ 怎么用: - **配联队的两个入口**: - 1. **导入报名表**时把单位编码写成 `A+B`(默认均分;最多 5 个单位;`A+`、`+B`、`A++B`、`A+A` 这些写法会被**逐行拦下**并说清是第几行、错在哪,**其余行照常导入**);报名模板(分工作表 xlsx)的「单位编码」表头挂了一条**单元格批注**、该列还有**输入提示**,中英各一份,内容就是这套写法——**表头文字、列序、12 列、工作表命名一个字都没动**,老表格照常导入; + 1. **导入报名表**时把单位编码写成 `A+B`(默认均分;最多 5 个单位;`A+`、`+B`、`A++B`、`A+A` 这些写法会被**逐行拦下**并说清是第几行、错在哪,**其余行照常导入**);报名模板(分工作表 xlsx)的「单位编码」表头挂了一条**单元格批注**、该列还有**输入提示**,中英各一份,内容就是这套写法——**表头文字、列序、12 列、工作表命名一个字都没动**,老表格照常导入(**这一版是 12 列;`dev_0.5.5` 起表尾新增了第 13 列「队伍」,见那一节**); 2. 导入后到**「成绩录入」页点那一行的「单位」格**,打开「**成员单位与份额**」弹窗改(加成员单位、填权重、去掉成员)。⚠️ **非均分(例如 2:1)不能在导入表里写**,只能在弹窗里改。 - **谁能改**:份额编辑与录成绩同档,**录入员及以上**;访客只读时输入框置灰并写明原因。 - **改完当场生效**:保存立即重算该小项该组别并推送大屏。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index d8072a7..ac6fa0e 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -11,14 +11,17 @@ ## 一、先看懂三件事 1. **用什么软件做表**:Excel 或 WPS 都行,做完存成 `.xlsx` 或 `.csv` 都可以(推荐 `.csv`,系统兼容性最好,不用管编码)。 -2. **表格长什么样**:**第一行是表头**(固定的 12 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。 +2. **表格长什么样**:**第一行是表头**(固定的 13 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。 3. **编码不能自己编**:表里的「小项编码」「单位编码」「组别名称」必须是系统里已经配好的,**照抄系统里的写法**。系统里没有的编码,这一行会被跳过并报错。 --- ## 二、表头(第一行的列名) -一共 12 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。 +一共 13 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。 + +> **第 13 列「队伍」是新加的(2026 年 9 月)**:**同一个单位在同一个组别派两支队**(羽毛球双打的一队 / 二队那种)时才需要填它;**绝大多数表格整列留空就行**,填法见第三节的「队伍」。 +> **老表格不用改**:表头里没有这一列、整列不写、格子留空,三种情况都合法,系统按「不区分队伍」处理——**原来的 12 列一列都不会错位**(新的一列加在**最后**)。 下载模板时按手上表格的样子选: @@ -43,6 +46,9 @@ | `heat` | 批次(也认「批次号」) | 批次,可缺省 | | `status` | 状态(也认「报名状态」) | 报名状态 | | `member_names` | 队员名单 | 团体项目的队员名单 | +| `squad` | 队伍(也认「队名」「队伍名」) | **队名,可缺省**;同一个单位在同一组别派两支队时用它区分(留空 = 不区分队伍) | + +> **第 13 列 `squad`(队伍)永远排在最后**:它是 2026 年 9 月新增的,加在表尾是为了让**手里那些原来 12 列的老表格一列都不会错位**(表格少的正是最后这一列)。整列不写、整列留空、表头没有它,三种情况都合法。 > **同一列只能写一次**:一份表里同时出现 `event_code` 和「项目编码」(或者干脆把同一列写了两遍),会被整份拦下并提示「报名表表头重复」,删掉多余的那一列再导入即可——系统不会替你猜该信哪一列,免得静默丢掉另一列的数据。 > @@ -64,6 +70,7 @@ | `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` | | `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` | | `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` | +| `squad`(队伍) | 可选 | **队名**(自由写,例如 `一队`);**留空 = 不区分队伍**。**同一个单位在同一个组别派两支队就靠它区分**,见第三节 | `一队` | > 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。 @@ -183,6 +190,23 @@ F1+F2 - 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。 - 系统界面里会把这条名单显示成「张三、李四、王五」。 +### `squad` 队伍 —— 可选(表的最后一列) + +**这一列只在「同一个单位在同一个组别派了不止一支队」时才需要填**,比如羽毛球双打:一个单位报了一队、二队,两队可能各占一个名次。 + +``` +一队 +二队 +青年队 +``` + +- **队名随便写**(`一队` / `二队` / `青年队` / `A 组` 都行),就是给现场看的一个标签; +- **留空 = 不区分队伍**:一个单位在这个组别只有一支队(绝大多数情况)时,**整列留空、整列不写、表头里没有这一列**,三种都合法,与升级前的行为完全一样; +- **只对团体项目有效**:这一列只认「姓名那一格留空」的团体行。**个人行(填了姓名的行)如果也填了队名,这一格不会写进库里**,导入结果里会给你一条提示:`第 N 行填了队伍「一队」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里`; +- **同一个单位、同一个组别、同一个队名只能有一条**(要派两支队就写两个不同的队名)。撞上了会逐行拦下并说清是哪个单位、哪个队(见第六节); +- **队名只影响显示与查重,不影响成绩与算分**:两个队的成绩、名次、积分各算各的,最后都汇总到同一个单位的总分上;显示上写成「单位名 + 队名」,例如 `第一分公司二队`(队名留空时仍然只写 `第一分公司`)。**单位总分榜 / 单位奖牌榜 / 个人积分榜上不写队名**(分与牌本来就归到单位); +- 队名**改起来不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ 改完记得**把报名表里的队名也改成一样**——以后再导一份还写着旧队名的表,会因为认不出来而**新增出旧队名那一条**。 + ### 系统怎么认人(证件号 → 号码布 → 姓名 + 单位) 有姓名的行,系统按下面的顺序认人。**证件号是唯一标识,号码布是它的替补,最后才靠「姓名 + 单位」兜底回填**: @@ -196,7 +220,7 @@ F1+F2 --- -## 四、四个完整例子 +## 四、五个完整例子 ### 例 1:个人项目(七单位 × 若干人) @@ -246,11 +270,30 @@ F1+F2 - 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛)); - 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(可以直接拿去重导)。 +### 例 5:同一个单位派两个队(团体项目多队) + +羽毛球双打,**F1 派出「一队」和「二队」两支队伍**,两队都可能拿名次。`BD` 是羽毛球双打、`男子组`: + +| event_code | category | team_code | athlete_name | id_card | gender | age | bib | seed | heat | status | member_names | squad | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| BD | 男子组 | F1 | | | | | | | | registered | 张三/李四 | 一队 | +| BD | 男子组 | F1 | | | | | | | | registered | 王五/赵六 | 二队 | +| BD | 男子组 | F2 | | | | | | | | registered | 孙八/周九 | | +| BD | 男子组 | F3 | | | | | | | | registered | 吴十/郑十一 | | + +要点: + +- **同一个单位、同一个组别写两行**,靠**最后一列「队伍」**区分开:`一队`、`二队`。**一个单位两个队都进前三时,这个单位会拿 2 枚牌**(两支队各算各的名次); +- F2、F3 只有一支队,队伍列**留空**即可(也可以整列不填、或表头里干脆没有这一列); +- **列都写对了才有多队的效果**:两行的 `event_code` / `category` / `team_code` / `squad` 必须**都不一样的那一格正好是 squad**——如果两行队名也写成一样的(都写 `一队`),第二行会被拦下(「同一个单位同一个组别的同一个队只能报一条」);如果两行队名都留空,第二行也会被拦下(「一个单位在一个组别只能有一条不分队的报名」); +- 显示上写成 **`第一分公司一队` / `第一分公司二队`**(单位名后面直接跟队名,不加括号不加分隔符);**单位总分榜、单位奖牌榜、个人积分榜上只写 `第一分公司`**(两队的分数都已经算进这个单位的总分); +- 报了之后想改队名**不用删掉重建**:到「成绩录入」页点那一行的「单位」格,在弹窗里改(见[使用手册 · 4.12](./使用手册.md))。⚠️ **改完记得把这张表里的队名也改成一样**——以后再导一份还写着旧队名的表,会**新增出旧队名那一条报名**(导入是按「小项 + 组别 + 单位 + 队名」认人的)。 + --- ## 五、导入前请先检查(自检清单) -- [ ] 第一行是那 12 个列名(**英文一套、中文一套都行**,也可以中英混着写,见第二节),没有被合并单元格、**同一列没有写两遍** +- [ ] 第一行是那 13 个列名(**英文一套、中文一套都行**,也可以中英混着写,见第二节),没有被合并单元格、**同一列没有写两遍** - [ ] 表头上方没有多余的标题行或空行 - [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格 - [ ] `category` 与系统里的组别名一字不差 @@ -262,6 +305,7 @@ F1+F2 - [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空 - [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组) - [ ] **联队**(两个单位联合参赛)写的是 `F1+F2` 这种形状:加号两边都有编码、**同一个单位没写两遍**、成员不超过 5 个;**加号左边那个是主单位**(见第三节) +- [ ] **「队伍」列**(最后一列)只在**同一个单位派了不止一支队**时才填:两队写了**两个不一样的队名**(都写一样或都留空会被拦下);只有一支队的单位留空即可;**个人行(有姓名的行)没有填队名**(填了也不会写进库里,只给一条提示) - [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的) - [ ] 用的是**分工作表模板**时:每张工作表的名字都是「小项名称@组别名称」、没有多余的工作表;「项目编码 / 组别」两列要么整列留空、要么与表名一字不差(见第八节) @@ -284,6 +328,9 @@ F1+F2 | 第 N 行:单位编码 F1+ 里有一段是空的:联队要写成两个单位编码用 + 连起来(例如 A+B) | **加号有一边是空的**(`F1+`、`+F2`、`F1++F2` 都是这一条)。多半是填一半或多了个加号 | 补上缺的那个编码,或删掉多余的加号;联队至少两个单位 | | 第 N 行:单位编码 F1+F1 里同一个单位出现了两次:联队的每个单位只能写一次 | 同一个单位在联队里写了两遍(`F1+F1`) | 一个单位只写一次;两个单位联合参赛就是 `F1+F2` | | 第 N 行:单位编码 … 里写了 6 个单位:一个联队最多 5 个单位 | 加号太多、成员超过 5 个 | **一个联队最多 5 个单位**;现场最多见过三个单位合并,超过 5 个多半是这一格填错了东西(例如整格都是加号) | +| 第 N 行:单位 A 在本组别已经有一条队伍「一队」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队) | 同一个单位、同一个组别、**同一个队名**写了两遍(两支队要写两个不同的队名) | 把其中一行的「队伍」列改成另一个队名(`一队` / `二队`);如果本来就是同一支队、只是想把队员名单补全,改完重导**不会新增**(同一单位同一队名是更新) | +| 第 N 行:单位 A 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请在队伍列分别写清(例如 一队 / 二队) | **两行的队伍列都留空**(等于都说「这个单位在这个组别只有一支队」),或者库里以前报过一条不分队的、这行又留空 | 要派两支队就在两行的「队伍」列分别写 `一队` / `二队`;确实只有一支队,就把多余的那一行删掉 | +| 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 | | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | | 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 | | 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 | @@ -344,7 +391,7 @@ F1+F2 ### 8.2 数据怎么填 -- 每张工作表**第 1 行就是表头**(与单表模板同样的 12 列、中英两套都认),**从第 2 行开始填人**; +- 每张工作表**第 1 行就是表头**(与单表模板同样的 13 列、中英两套都认),**从第 2 行开始填人**; - **「项目编码」「组别」两列可以整列留空**——表名已经说明了是哪个小项、哪个组别; 一旦填了,就**必须与表名一致**,不一致的那一行会报错并跳过(系统不猜、也不会挑一个用); - 其余列与单表模板完全一样(第三节逐列说明照样适用); @@ -395,6 +442,7 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿 > 8. 填完先对着第五节的清单自查一遍,再发给收表人; > 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**; > 10. **两个单位联合参赛(联队)**:`team_code` 那一格写 `F1+F2`(加号左边是主单位,默认均分,最多 5 个单位),**不要**新建一个「联队单位」、也不要两个单位各写一行;非均分(例如 2:1)要在导入后到「成绩录入」页点那一行的「单位」格改; -> 11. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。 +> 11. **同一个单位派两支队**(羽毛球双打的一队 / 二队那种):在**最后一列「队伍」**里分别写队名(`一队` / `二队`);**只有一支队的单位这一列留空**,整列不写也行。**个人项目不用管这一列**; +> 12. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。 > > 一句话:**下载模板 → 只往下面粘数据 → 另存为 CSV UTF-8 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。 diff --git a/server/internal/export/entries.go b/server/internal/export/entries.go index a47208e..02c55ca 100644 --- a/server/internal/export/entries.go +++ b/server/internal/export/entries.go @@ -131,7 +131,7 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie query := ` SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''), - COALESCE(t.code, ''), COALESCE(t.name, ''), + COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''), COALESCE(a.name, ''), COALESCE(en.member_names, ''), COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat, COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, ''), en.id @@ -198,10 +198,11 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie heat sql.NullInt64 age sql.NullInt64 athleteName string + squad string entryID int64 ) if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName, - &athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat, + &squad, &athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat, &row.Gender, &age, &row.Status, &row.Remark, &entryID); err != nil { return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err) } @@ -222,6 +223,10 @@ func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (Entrie row.TeamName = joint.Name() row.Remark = appendNote(row.Remark, joint.shareRatioNote()) } + // 队名接在单位名后面(口径:单位名+队名,不加括号),空队名时一个字都不加: + // 老数据与单队单位的这份名单必须与升级前逐行一致。单位编码不动 —— 队名不是编码的一部分, + // 编码列照旧是可以直接拿去重导的写法。 + row.TeamName = withSquad(row.TeamName, squad) out = append(out, row) } if err := rows.Err(); err != nil { diff --git a/server/internal/export/load.go b/server/internal/export/load.go index c88fa71..5001599 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -542,7 +542,8 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo // 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。 query := ` SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), - COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''), + COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(en.squad, ''), + COALESCE(a.name, ''), COALESCE(r.result_text, ''), COALESCE(r.unit, ''), COALESCE((SELECT SUM(l.points) FROM score_ledger l WHERE l.entry_id = en.id AND l.voided = 0), 0), @@ -594,6 +595,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo row EventResultRow rank, isTie int memberJSON, athleteName string + squad string resultStat, resultState sql.NullString entryStat, entryRemark string resultNote string @@ -601,7 +603,7 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo required, published int entryID int64 ) - if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName, + if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &squad, &athleteName, &row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat, &entryRemark, &resultNote, &hasValue, &required, &published, &entryID); err != nil { return nil, fmt.Errorf("export: 扫描成绩: %w", err) @@ -616,6 +618,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool, jo row.TeamName = joint.Name() row.Remark = appendNote(row.Remark, joint.shareNote()) } + // 队名接在单位名后面(口径:单位名+队名,不加括号)。放在联队之后:联队名本身就是单位名, + // 「A+B联队二队」读起来才是「这个联队的第二支队」。空队名时这一行不动,导出件与升级前逐字一致。 + row.TeamName = withSquad(row.TeamName, squad) // results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。 // 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。 row.NoResult = !resultState.Valid @@ -724,7 +729,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([ COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''), COALESCE(r.unit, ''), l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0), - COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0) + COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0), COALESCE(en.squad, '') FROM score_ledger l LEFT JOIN events e ON e.id = l.event_id LEFT JOIN sports s ON s.id = e.sport_id @@ -759,7 +764,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64, joints map[int64]jointTeam) ([ var athleteID, eventID, entryID int64 var resultText, unit string if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, - &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID); err != nil { + &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID, + &r.squad); err != nil { return nil, fmt.Errorf("export: 扫描积分明细: %w", err) } // 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩 diff --git a/server/internal/export/medals.go b/server/internal/export/medals.go index 703458c..9337d94 100644 --- a/server/internal/export/medals.go +++ b/server/internal/export/medals.go @@ -36,6 +36,11 @@ type MedalDetailRow struct { // 口径是每个成员单位各记一整枚,各单位的奖牌数加起来会比实际颁发的多,这一格就是那句解释。 jointNote string + // squad 是这块奖牌对应报名项的队名(entries.squad),只有总表的「单位」列用它: + // 一个单位两个队各拿一块牌时,总表上要认得出来哪块是哪个队的。分表没有「单位」列, + // 表名本身就是单位(它是一整份单位级清单,不按队拆)。 + squad string + // 排序键,只在本包内用,不导出。 sportSort int eventSort int @@ -129,7 +134,9 @@ func medalMasterRows(sheets []MedalDetailSheet) [][]any { for _, s := range sheets { unit := displayName(s.Team.Name, s.Team.ShortName) for _, r := range s.Rows { - out = append(out, append([]any{unit}, medalRow(r)...)) + // 队名接在单位名后面(口径:单位名+队名):一个单位两个队各拿一块牌时, + // 总表上两块牌的单位列不是同一行字,读者才知道哪块是哪个队拿的。空队名时一个字都不加。 + out = append(out, append([]any{withSquad(unit, r.squad)}, medalRow(r)...)) } } return out @@ -213,9 +220,15 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err } orderTeamsByStandings(teams, order) + // 队名按报名项读一次给全部奖牌行复用(逐行去查库是白跑)。 + squads, err := loadEntrySquads(db) + if err != nil { + return MedalDetailWorkbook{}, err + } + sheets := make([]MedalDetailSheet, 0, len(teams)) for _, t := range teams { - rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints) + rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx, joints, squads) if len(rows) == 0 { continue } @@ -409,7 +422,8 @@ func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error // medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。 // 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。 -func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam) []MedalDetailRow { +// squads 是「报名 id → 队名」,用来给总表的单位列补上这块奖牌是哪个队拿的。 +func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext, joints map[int64]jointTeam, squads map[int64]string) []MedalDetailRow { list := hits[teamID] rows := make([]MedalDetailRow, 0, len(list)) for _, h := range list { @@ -430,6 +444,7 @@ func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]meda eventID: c.eventID, categoryID: c.categoryID, resultID: h.resultID, + squad: squads[h.entryID], } // 联队奖牌:把成员单位与「各单位各记 1 枚」写在奖牌那一格(口径 3)。 // 名次不带报名项时(聚合通道)不补:那种名次本来就不拆份额,说明只会误导。 diff --git a/server/internal/export/squad.go b/server/internal/export/squad.go new file mode 100644 index 0000000..cb5caf8 --- /dev/null +++ b/server/internal/export/squad.go @@ -0,0 +1,54 @@ +package export + +import ( + "database/sql" + "fmt" + "strings" +) + +// 本文件是「队名」(entries.squad,迁移 0025)在导出侧的读法。 +// +// 同一个单位在同一组别可以派多支队参赛(例如羽毛球双打,一个单位两个队各占一个名次), +// 报名上多出一列自由文本队名,空串 = 不区分队伍 —— 那是老数据与单队单位的默认值。 +// +// 显示口径(与大屏、管理端同一份实现):**单位名 + 队名**,不加括号也不加分隔符,例如「第一分公司二队」。 +// 现场就是把它当一个单位名念的,括号与空格只会白占列宽,还会让同一支队在两份材料里写法不一样。 + +// withSquad 把队名接在单位名后面;队名为空(或只有空白)时**一个字都不加**。 +// +// 空队名什么都不加是硬门槛:升级后导出的老赛事必须与升级前逐行一致。 +// 顺带去掉队名首尾空白:它由现场自由填写,带着空格会把「第一分公司 二队」这种读法印进导出件。 +func withSquad(name, squad string) string { + squad = strings.TrimSpace(squad) + if squad == "" { + return name + } + return name + squad +} + +// loadEntrySquads 读全部带队名的报名,按报名 id 归组。 +// +// 只取非空的行:绝大多数报名的 squad 都是空串,整表扫回来再逐行判空是白花内存。 +func loadEntrySquads(db *sql.DB) (map[int64]string, error) { + rows, err := db.Query(`SELECT id, squad FROM entries WHERE TRIM(COALESCE(squad, '')) <> ''`) + if err != nil { + return nil, fmt.Errorf("export: 读取报名队名: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64]string) + for rows.Next() { + var ( + entryID int64 + squad string + ) + if err := rows.Scan(&entryID, &squad); err != nil { + return nil, fmt.Errorf("export: 扫描报名队名: %w", err) + } + out[entryID] = squad + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历报名队名: %w", err) + } + return out, nil +} diff --git a/server/internal/export/squad_dump_test.go b/server/internal/export/squad_dump_test.go new file mode 100644 index 0000000..0b07e8b --- /dev/null +++ b/server/internal/export/squad_dump_test.go @@ -0,0 +1,183 @@ +package export + +import ( + "bytes" + "database/sql" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/xuri/excelize/v2" +) + +// 这份用例是「squad 为空时导出件逐行一致」这条硬门槛的对拍工具。 +// +// 做法:造一份**全部报名都没有队名**的赛事库(队名列留空 = 老数据与单队单位的原样), +// 把每一份导出件的每一张工作表、每一个单元格 dump 成文本;同一个用例文件放到改动前的代码上 +// 再跑一次(`git archive` 取一份 HEAD 的树,只补上这次迁移与这个用例文件),两份 dump +// `diff` 必须为空。 +// +// 落地路径由 EXPORT_SQUAD_DUMP 指定,不设时写进 t.TempDir() —— 也就是说平时它只当一次 +// 「全部导出件都能加载」的冒烟,不往仓库里落文件。 +func TestSquadEmptyExportDumpIsUnchanged(t *testing.T) { + db := openEventDB(t) + seedSquadDumpEvent(t, db) + + opts := Options{Meta: Meta{GeneratedAt: time.Unix(1700000000, 0)}} + empty := dumpAllWorkbooks(t, db, opts) + + // 先落盘:对拍要的是「改动前那份代码产出的文本」,它不能因为后面的自检失败而没写出来。 + path := os.Getenv("EXPORT_SQUAD_DUMP") + if path == "" { + path = filepath.Join(t.TempDir(), "squad-empty-dump.txt") + } + if err := os.WriteFile(path, []byte(empty), 0o644); err != nil { + t.Fatalf("写出 dump: %v", err) + } + t.Logf("空队名 dump:%s(%d 个单元格)", path, strings.Count(empty, "\n")) + + // 敏感度自检:给那条团体报名写上队名后重新 dump,两次必须不同。 + // 少了这一步,「两份 dump 一样」也可能只是因为 dump 根本没覆盖到单位列 —— 那就白对拍了。 + if _, err := db.Exec(`UPDATE entries SET squad = '二队' WHERE event_id = 2`); err != nil { + t.Fatalf("写入队名: %v", err) + } + withSquadDump := dumpAllWorkbooks(t, db, opts) + if withSquadDump == empty { + t.Fatal("给报名写上队名后 dump 没变:这份对拍看不见单位列,对拍结论无效" + + "(在对拍用的旧代码上这条自检本来就会红——旧代码根本不看 squad,正说明两份 dump 的差异只可能来自这次改动)") + } + if !strings.Contains(withSquadDump, "第一分公司二队") { + t.Fatal("带队名的 dump 里找不到「第一分公司二队」:队名没有写进导出件") + } +} + +// seedSquadDumpEvent 造一份覆盖面尽量宽的赛事库,且**所有报名的队名都留空**: +// 个人小项(含空批次、团体行回落成员名单)+ 一条纯团体报名 + 联队份额。 +// 个人路径与团体路径都要有,否则这次改动覆盖的两处读取点里会漏掉一处。 +func seedSquadDumpEvent(t *testing.T, db *sql.DB) { + t.Helper() + seedEvent(t, db) // T1/T2、100 米跑、两条已发布成绩与流水 + addBronzeEntry(t, db) // 第 3 名(一个单位两条报名) + stmts := []string{ + `INSERT INTO sports(code, name, sort_order) VALUES ('SP2', '羽毛球', 2)`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (2, 'E2', '男子双打', 'team', 'auto', 'score', 'desc', 'published')`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 2, 'team', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, member_names, bib, heat) + VALUES (2, 1, 1, '["王磊","赵雷"]', '201', 1)`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + SELECT 2, id, 1, '2:0', 2, 'pts', 'published' FROM entries WHERE event_id = 2`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points) + SELECT 1, 2, e.id, r.id, 'result', r.id, 10 + FROM entries e JOIN results r ON r.entry_id = e.id WHERE e.event_id = 2`, + // 联队:这条团体报名由两个单位合报(奖牌各记一枚),队名列仍然是空的。 + `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) + SELECT e.id, t.id, 1, 1, t.id - 1 FROM entries e CROSS JOIN teams t WHERE e.event_id = 2`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + seedChannelRanks(t, db) +} + +// dumpAllWorkbooks 把全部导出件 dump 成一份文本:先写每个工作簿的 sheet 列表(表名里也有单位名), +// 再逐单元格写 `工作簿|工作表|行|列|值`。用 Write*(生产走的是同一条流)而不是落盘再读, +// 保证 dump 的就是响应体里那份文件。 +func dumpAllWorkbooks(t *testing.T, db *sql.DB, opts Options) string { + t.Helper() + var sb strings.Builder + + dump := func(name string, write func(io.Writer) error) { + t.Helper() + var buf bytes.Buffer + if err := write(&buf); err != nil { + t.Fatalf("导出 %s: %v", name, err) + } + f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("打开 %s: %v", name, err) + } + defer func() { _ = f.Close() }() + sheets := f.GetSheetList() + sort.Strings(sheets) + fmt.Fprintf(&sb, "== %s sheets=%s\n", name, strings.Join(sheets, ",")) + for _, sheet := range sheets { + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取 %s!%s: %v", name, sheet, err) + } + for r, row := range rows { + for c, v := range row { + fmt.Fprintf(&sb, "%s|%s|%d|%d|%q\n", name, sheet, r+1, c+1, v) + } + } + } + } + + standings, err := LoadStandingsWorkbook(db, opts) + if err != nil { + t.Fatalf("读总表: %v", err) + } + dump("总表", func(w io.Writer) error { return WriteStandingsXLSX(w, standings) }) + + events, err := LoadEventWorkbook(db, 0, opts) + if err != nil { + t.Fatalf("读分项成绩表: %v", err) + } + dump("分项成绩表", func(w io.Writer) error { return WriteEventXLSX(w, events) }) + + flat, err := LoadFlatWorkbook(db, opts) + if err != nil { + t.Fatalf("读全部成绩单页: %v", err) + } + dump("全部成绩单页", func(w io.Writer) error { return WriteFlatXLSX(w, flat) }) + + all, err := LoadAllWorkbook(db, opts) + if err != nil { + t.Fatalf("读全部: %v", err) + } + dump("全部", func(w io.Writer) error { return WriteAllXLSX(w, all) }) + + entries, err := LoadEntriesWorkbook(db, EntriesFilter{}, opts) + if err != nil { + t.Fatalf("读报名名单: %v", err) + } + dump("报名名单", func(w io.Writer) error { return WriteEntriesXLSX(w, entries) }) + + medals, err := LoadMedalDetailWorkbook(db, opts) + if err != nil { + t.Fatalf("读单位奖牌明细: %v", err) + } + dump("单位奖牌明细", func(w io.Writer) error { return WriteMedalDetailXLSX(w, medals) }) + + athletes, err := LoadAthleteDetailWorkbook(db, opts) + if err != nil { + t.Fatalf("读个人得分明细: %v", err) + } + dump("个人得分明细", func(w io.Writer) error { return WriteAthleteDetailXLSX(w, athletes) }) + + details, err := LoadAllTeamDetailsWorkbook(db, opts) + if err != nil { + t.Fatalf("读总明细: %v", err) + } + dump("总明细", func(w io.Writer) error { return WriteAllTeamDetailsXLSX(w, details) }) + + for _, teamID := range []int64{1, 2} { + wb, err := LoadTeamDetailWorkbook(db, teamID, opts) + if err != nil { + t.Fatalf("读单位 %d 积分明细: %v", teamID, err) + } + name := fmt.Sprintf("单位%d积分明细", teamID) + dump(name, func(w io.Writer) error { return WriteTeamDetailXLSX(w, wb) }) + } + return sb.String() +} diff --git a/server/internal/export/squad_test.go b/server/internal/export/squad_test.go new file mode 100644 index 0000000..52cd30f --- /dev/null +++ b/server/internal/export/squad_test.go @@ -0,0 +1,210 @@ +package export + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// seedSquads 造一场「一个单位派两支队」的团体赛:羽毛球男子双打,第一分公司派一队 / 二队, +// 两队分别拿第 1、第 2 名(口径 3:一个单位两个队各占一个名次、各拿一块牌,不去重不合并), +// 第二分公司一支不区分队伍的队(squad 留空 = 老写法)拿第 3 名。 +func seedSquads(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`, + `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T2', '第二分公司', '二公司', 2)`, + `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', 'score', '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, 'team', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + // 同一个单位在同一组别的两条团体报名:靠 squad 区分,只有带上 squad 的唯一索引才放得下。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, bib) + VALUES (1, 1, 1, '["王磊","赵雷"]', '一队', '101')`, + `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, bib) + VALUES (1, 1, 1, '["陈刚","郑凯"]', '二队', '102')`, + `INSERT INTO entries(event_id, category_id, team_id, member_names, bib) + VALUES (1, 1, 2, '["孙七","周八"]', '103')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 1, 1, '2:0', 2, 'pts', 'published')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 2, 2, '2:1', 2, 'pts', 'published')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 3, 3, '2:1', 2, 'pts', 'published')`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 'result', 1, 10)`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 2, 2, 'result', 2, 8)`, + `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (2, 1, 3, 3, 'result', 3, 6)`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + `UPDATE settings SET value = '测试主办方' WHERE key = 'event.organizer'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// 分项成绩表:单位列在队名非空时写「单位名+队名」,一个单位的两个队各占一行。 +func TestEventSheetShowsSquadAfterUnitName(t *testing.T) { + db := openEventDB(t) + seedSquads(t, db) + seedChannelRanks(t, db) + + dst := filepath.Join(t.TempDir(), "成绩表.xlsx") + if err := ExportEventXLSX(db, 0, dst, Options{}); err != nil { + t.Fatalf("导出分项成绩表: %v", err) + } + rows := readSheet(t, dst, "羽毛球·男子双打·男子") + + // 4 行版式 + 3 条报名,按名次排:一队、二队、第二分公司。 + want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"} + for i, w := range want { + if got := cell(rows, 4+i, 2); got != w { + t.Errorf("第 %d 行单位 = %q,期望 %q", i+1, got, w) + } + } +} + +// 全部成绩(单页):与分项成绩表同源,单位列同样带上队名。 +func TestFlatSheetShowsSquadAfterUnitName(t *testing.T) { + db := openEventDB(t) + seedSquads(t, db) + seedChannelRanks(t, db) + + dst := filepath.Join(t.TempDir(), "单页.xlsx") + if err := ExportFlatXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出全部成绩(单页): %v", err) + } + rows := readSheet(t, dst, "全部成绩") + + // 单页表在「名次」前多了 大项/小项/组别 三列,单位列是第 6 列。 + want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"} + for i, w := range want { + if got := cell(rows, 4+i, 5); got != w { + t.Errorf("第 %d 行单位 = %q,期望 %q", i+1, got, w) + } + } +} + +// 报名名单:单位名列在队名非空时写「单位名+队名」,不区分队伍的报名照旧只写单位名。 +func TestEntriesSheetShowsSquadAfterUnitName(t *testing.T) { + db := openEventDB(t) + seedSquads(t, db) + + dst := filepath.Join(t.TempDir(), "报名名单.xlsx") + if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil { + t.Fatalf("导出报名名单: %v", err) + } + rows := readSheet(t, dst, "报名名单") + + // 列序:小项编码/小项名/组别/单位编码/单位名/…,单位名是第 5 列。 + want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"} + for i, w := range want { + if got := cell(rows, 4+i, 4); got != w { + t.Errorf("第 %d 行单位名 = %q,期望 %q", i+1, got, w) + } + } +} + +// 单位奖牌明细:总表的单位列按该奖牌对应报名项的队名写全;一个单位两个队各拿一块牌, +// 就是两块牌(口径 3),不合并、不去重。 +func TestMedalDetailShowsSquadAfterUnitName(t *testing.T) { + db := openEventDB(t) + seedSquads(t, db) + seedChannelRanks(t, db) + + dst := filepath.Join(t.TempDir(), "奖牌.xlsx") + if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出单位奖牌明细: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + wantSheets := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"} + if got := f.GetSheetList(); !equalStrings(got, wantSheets) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheets) + } + + master, err := f.GetRows("单位奖牌明细") + if err != nil { + t.Fatalf("读取总表: %v", err) + } + want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"} + for i, w := range want { + if got := cell(master, 4+i, 0); got != w { + t.Errorf("总表第 %d 行单位 = %q,期望 %q", i+1, got, w) + } + } + // 第一分公司是一枚金牌加一枚银牌(两个队各一块),分表的小计照实写两块。 + unit, err := f.GetRows("第一分公司奖牌明细") + if err != nil { + t.Fatalf("读取分表: %v", err) + } + if got := cell(unit, len(unit)-1, 4); got != "金 1 / 银 1 / 铜 0" { + t.Errorf("第一分公司小计 = %q,期望 金 1 / 银 1 / 铜 0", got) + } +} + +// 单位积分总明细:总表的单位列按该条流水对应报名项的队名写全(分表没有单位列,表名就是单位)。 +func TestAllTeamDetailsShowsSquadAfterUnitName(t *testing.T) { + db := openEventDB(t) + seedSquads(t, db) + seedChannelRanks(t, db) + + dst := filepath.Join(t.TempDir(), "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + rows := readSheet(t, dst, "积分总明细") + + // 列序:单位/序号/大项/小项/组别/运动员/名次/成绩/来源/积分/备注; + // 行序按单位总分榜(第一分公司在前),单位内按名次:一队 10 分、二队 8 分,第二分公司 6 分。 + want := []string{"第一分公司一队", "第一分公司二队", "第二分公司"} + for i, w := range want { + if got := cell(rows, 4+i, 0); got != w { + t.Errorf("总明细第 %d 行单位 = %q,期望 %q", i+1, got, w) + } + } + if got := cell(rows, 7, 0); got != "合计" { + t.Errorf("末行首列 = %q,期望 合计", got) + } +} + +// 队名为空(不区分队伍)时,五份导出件的单位名列一个字都不多:这是老数据与单队单位的默认, +// 导出件必须与升级前逐行一致。 +func TestEmptySquadKeepsPlainUnitName(t *testing.T) { + db := openEventDB(t) + seedSquads(t, db) + seedChannelRanks(t, db) + + dir := t.TempDir() + entriesDst := filepath.Join(dir, "报名名单.xlsx") + if err := ExportEntriesXLSX(db, entriesDst, EntriesFilter{}, Options{}); err != nil { + t.Fatalf("导出报名名单: %v", err) + } + // 第三条报名(第二分公司)没有队名。 + if got := cell(readSheet(t, entriesDst, "报名名单"), 6, 4); got != "第二分公司" { + t.Errorf("空队名的单位名 = %q,期望 第二分公司", got) + } + + medalDst := filepath.Join(dir, "奖牌.xlsx") + if err := ExportMedalDetailXLSX(db, medalDst, Options{}); err != nil { + t.Fatalf("导出单位奖牌明细: %v", err) + } + if got := cell(readSheet(t, medalDst, "单位奖牌明细"), 6, 0); got != "第二分公司" { + t.Errorf("空队名的奖牌总表单位 = %q,期望 第二分公司", got) + } + + detailDst := filepath.Join(dir, "总明细.xlsx") + if err := ExportAllTeamDetailsXLSX(db, detailDst, Options{}); err != nil { + t.Fatalf("导出总明细: %v", err) + } + if got := cell(readSheet(t, detailDst, "积分总明细"), 6, 0); got != "第二分公司" { + t.Errorf("空队名的总明细单位 = %q,期望 第二分公司", got) + } +} diff --git a/server/internal/export/team.go b/server/internal/export/team.go index 4fa1ca9..0e9d6c9 100644 --- a/server/internal/export/team.go +++ b/server/internal/export/team.go @@ -23,6 +23,10 @@ type TeamDetailRow struct { Remark string // individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。 individual bool + + // squad 是这条流水对应报名项的队名(entries.squad),只有总明细的「单位」列用它: + // 单位分表的表名就是单位(一份单位级清单),没有单位列可写,所以那里不展示队名。 + squad string } // TeamDetailWorkbook 是「单位积分明细」的内容。 diff --git a/server/internal/export/team_all.go b/server/internal/export/team_all.go index b5414da..581915f 100644 --- a/server/internal/export/team_all.go +++ b/server/internal/export/team_all.go @@ -94,8 +94,15 @@ func allTeamDetailRows(sheets []TeamDetailSheet, weight float64) [][]any { unit := displayName(s.Team.Name, s.Team.ShortName) rows, sub := teamDetailDataRows(s.Rows, weight) total += sub - for _, row := range rows { - out = append(out, append([]any{unit}, row...)) + for i, row := range rows { + // 队名接在单位名后面(口径:单位名+队名):一行分是哪个队拿的,总表上要认得出来。 + // 空队名时一个字都不加(老数据与单队单位的导出件与升级前逐行一致)。 + // dataRows 逐行按原序产出,索引与 s.Rows 一一对应。 + label := unit + if i < len(s.Rows) { + label = withSquad(unit, s.Rows[i].squad) + } + out = append(out, append([]any{label}, row...)) } } // 列序:单位 / 序号 / 大项 / 小项 / 组别 / 运动员 / 名次 / 成绩 / 来源 / 积分 / 备注。 diff --git a/server/internal/httpapi/channel_compare_guard_test.go b/server/internal/httpapi/channel_compare_guard_test.go new file mode 100644 index 0000000..a1a7b17 --- /dev/null +++ b/server/internal/httpapi/channel_compare_guard_test.go @@ -0,0 +1,90 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// seedCompareNoneEventNoCategory 造一个 compare=none 的个人小项,**不带任何组别**。 +// +// 这样的现场以前能把 basis=result 的通道存进去:recomputeLedgerTx 是逐组别算的 +// (scoring.go 里 `for _, cat := range cats`),没有组别就一轮都不跑,domain.ScoreEvent 的 +// ErrCompareRequired 根本到不了,通道配置就这么落库了。带组别的小项则会在这里撞 409,但那是 +// 原始领域错误(「domain: basis=result 的通道要求小项配置 compare=asc/desc」),操作员看不出该改什么。 +func seedCompareNoneEventNoCategory(t *testing.T, env *testEnv, ids seedIDs, code, name string) int64 { + t.Helper() + return env.insert(t, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, ?, ?, 'individual', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport, code, name) +} + +// compare=none 的小项不得配 basis=result / basis=aggregate 的通道——这条守卫以前只在规则包导入里 +// (rules/diff.go checkCompareCompatible),管理端手工配的这条路没人拦:现场形态就是一个台球小项 +// (compare=none,名次靠人工录入)被配上 basis=result 的通道,一直配到发布才 409。 +func TestReplaceChannelsRejectsResultBasisOnCompareNoneEvent(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + eventID := seedCompareNoneEventNoCategory(t, env, ids, "Z_CN", "台球") + + var profileID int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { + t.Fatalf("读取积分模板: %v", err) + } + channels := fmt.Sprintf("/api/events/%d/channels", eventID) + + // 两种 basis 都要拦,且提示要点名小项、说清原因是 compare=none、并给出可执行的改法。 + for _, basis := range []string{"result", "aggregate"} { + status, resp := env.do(client, http.MethodPut, channels, map[string]any{ + "channels": []map[string]any{{"scope": "athlete", "basis": basis, "profileId": profileID}}, + }, true) + if status != http.StatusBadRequest || resp.Error == nil { + t.Fatalf("compare=none 配 basis=%s 应 400,得到 %d %+v", basis, status, resp.Error) + } + for _, want := range []string{"台球", "compare=none", basis, "手动录名次"} { + if !strings.Contains(resp.Error.Message, want) { + t.Fatalf("basis=%s 的 400 文案应含 %q,得到 %q", basis, want, resp.Error.Message) + } + } + } + + // 整包拒收:一个字节都不写。 + status, resp := env.do(client, http.MethodGet, channels, nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error) + } + if list := decodeData[[]map[string]any](t, resp); len(list) != 0 { + t.Fatalf("被拒的保存不该写下任何通道,得到 %+v", list) + } + + // 合法的改法照常放行:改成 basis=manual。 + status, resp = env.do(client, http.MethodPut, channels, map[string]any{ + "channels": []map[string]any{{"scope": "athlete", "basis": "manual", "profileId": profileID}}, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("compare=none 配 basis=manual 应 200,得到 %d %+v", status, resp.Error) + } +} + +// 对照:有比较方向的小项照常可以配 basis=result / aggregate,这条守卫不能误伤正常配置。 +func TestReplaceChannelsAllowsResultBasisWhenCompareIsSet(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + + var profileID int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { + t.Fatalf("读取积分模板: %v", err) + } + status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event), map[string]any{ + "channels": []map[string]any{ + {"scope": "athlete", "basis": "result", "profileId": profileID}, + {"scope": "team", "basis": "aggregate", "profileId": profileID}, + }, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("compare=asc 的小项配 result/aggregate 应 200,得到 %d %+v", status, resp.Error) + } +} diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index 4854aaa..c4ce668 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -112,10 +112,15 @@ type profileDTO struct { } type entryDTO struct { - ID int64 `json:"id"` - EventID int64 `json:"eventId"` - CategoryID int64 `json:"categoryId"` - TeamID int64 `json:"teamId"` + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + TeamID int64 `json:"teamId"` + // Squad 是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。 + // 空串 = 不区分队伍:单队单位与升级前的老数据都是它,行为与升级前完全一致(见迁移 0025)。 + // 只对纯团体报名(athleteId 为空)有意义:带运动员的报名按 (组别, 单位, 运动员) 唯一, + // 同一个人在同一组别不会因为 squad 而多报一次。 + Squad string `json:"squad"` AthleteID *int64 `json:"athleteId"` MemberNames []string `json:"memberNames"` Bib *string `json:"bib"` diff --git a/server/internal/httpapi/entries_sheet_template.go b/server/internal/httpapi/entries_sheet_template.go index 451d240..57e0825 100644 --- a/server/internal/httpapi/entries_sheet_template.go +++ b/server/internal/httpapi/entries_sheet_template.go @@ -436,8 +436,8 @@ func entryTemplateHeader(language string) []string { // 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位, // 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。 // -// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、12 列 -// 一字不动),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表 +// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、列名与 +// importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表 // 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见) // 与这一列的**输入提示**(点中格子就弹出来)。 const ( @@ -505,6 +505,7 @@ var entryTemplateWidths = map[string]float64{ "heat": 8, "status": 12, "member_names": 26, + "squad": 12, } const defaultEntryTemplateWidth = 14 @@ -659,7 +660,7 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro // 整列一条输入提示(点中格子就弹出)。 // // 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是 -// 「第 1 行 12 列表头 + 数据行」,批注与输入提示不是数据。 +// 「第 1 行表头 + 数据行」(列名与 importColumns 一致),批注与输入提示不是数据。 func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error { column := entryTeamCodeColumn() if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" { diff --git a/server/internal/httpapi/entries_sheet_template_test.go b/server/internal/httpapi/entries_sheet_template_test.go index 1adc82c..705b203 100644 --- a/server/internal/httpapi/entries_sheet_template_test.go +++ b/server/internal/httpapi/entries_sheet_template_test.go @@ -187,16 +187,16 @@ func TestEntriesTemplateOneSheetPerEventCategory(t *testing.T) { } } -// sheetImportHeader 是分工作表模板的完整 12 列表头(含批次列),与服务端模板逐列一致。 +// sheetImportHeader 是分工作表模板的完整 13 列表头(含批次列与队伍列),与服务端模板逐列一致。 // 既有测试用的 importHeader 少了 heat 列,这里不能复用它,否则每一列都会串位。 -const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names" +const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names,squad" // entryTemplateHeaderForTest 是中文表头(与 importColumns 的中文别名、前端模板同源)。 func entryTemplateHeaderForTest(t *testing.T) []string { t.Helper() return []string{ "项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄", - "号码布", "种子", "批次", "状态", "队员名单", + "号码布", "种子", "批次", "状态", "队员名单", "队伍", } } @@ -214,7 +214,7 @@ func TestEntriesTemplateEnglishHeader(t *testing.T) { } want := []string{ "event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age", - "bib", "seed", "heat", "status", "member_names", + "bib", "seed", "heat", "status", "member_names", "squad", } if !equalStrings(rows[0], want) { t.Fatalf("英文模板表头应为规范列名,得到 %v", rows[0]) diff --git a/server/internal/httpapi/entry_heat_test.go b/server/internal/httpapi/entry_heat_test.go index 0b21abf..c9b39e8 100644 --- a/server/internal/httpapi/entry_heat_test.go +++ b/server/internal/httpapi/entry_heat_test.go @@ -7,7 +7,8 @@ import ( "testing" ) -// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。 +// importHeaderHeat 是带批次列的 12 列表头(升级前的列数,比现在少一个「队伍」列)。 +// 刻意写成字面量:它同时证明「现场手里列少的旧表格照常导入」,不跟着模板一起变。 const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names" // 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。 diff --git a/server/internal/httpapi/entry_squad.go b/server/internal/httpapi/entry_squad.go new file mode 100644 index 0000000..6d229d7 --- /dev/null +++ b/server/internal/httpapi/entry_squad.go @@ -0,0 +1,107 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "strings" +) + +// 本文件是「队名」的手工修改:PUT /api/entries/{id}/squad(队名本身见迁移 0025)。 +// +// 为什么需要这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。 +// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**(老那条仍挂在库里)—— +// 「填错一个字就得删掉重建」在现场是不能接受的。 + +// updateEntrySquad 改一条团体报名的队名,返回更新后的完整读数。 +// +// **不重算**,这是有意的:squad 只是给现场看的标签(榜单、导出件、大屏把「第一分公司」 +// 显示成「第一分公司青年队」)。它不参与名次 / 积分 / 奖牌的任何一个口径——全仓读 squad 的 +// 只有导出与报名读数这两处展示代码,引擎(internal/domain)与 score_ledger / results / +// channel_ranks 一个字都不看它。所以改它时算分输出的每个字节都不变,这里也就没有 +// recomputeLedgerTx:白算一遍不但慢,还会白白推一次榜(大屏会跟着闪一下没变的名次)。 +// 同档的另一个先例是批次 entries.heat,同样是纯展示字段,改它也不重算(见 setEntriesHeat)。 +func (s *server) updateEntrySquad(ctx context.Context, entryID int64, squad string) (entryDTO, error) { + // trim 统一收在这里:导入侧读单元格时已经 trim 过(handlers_config.go 的 cell()), + // 手工改走的是另一条路,不 trim 就会把「一队」与「一队 」当成两个队。 + squad = strings.TrimSpace(squad) + + entry, err := s.getEntry(ctx, entryID) + if err != nil { + return entryDTO{}, err + } + // 带运动员的报名按 (组别, 单位, 运动员) 唯一,队名根本不参与它的查重键 + // (0025 的部分索引带 WHERE athlete_id IS NULL)。写进去既不生效,又会让人以为 + // 「这个人报了两个队」,所以直接拒掉并说清原因。 + if entry.AthleteID != nil { + return entryDTO{}, badRequest("这条报名带着运动员,队名只对团体报名生效:队名分的是「同一个单位在同一个组别的第几支队」,带运动员的报名按(组别、单位、运动员)唯一,改队名变不出第二支队", nil) + } + + // 同组别同单位已经有这个队名时先拦下来:撞索引的报错是英文的约束原文, + // 现场看不出是哪个单位、哪个队,也不知道该改哪一格。 + var other int64 + err = s.ev.QueryRowContext(ctx, + `SELECT id FROM entries + WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ? AND id <> ?`, + entry.CategoryID, entry.TeamID, squad, entryID).Scan(&other) + switch { + case err == nil: + return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil) + case !errors.Is(err, sql.ErrNoRows): + return entryDTO{}, internal(err) + } + + if _, err := s.ev.ExecContext(ctx, `UPDATE entries SET squad = ? WHERE id = ?`, squad, entryID); err != nil { + // 预检与 UPDATE 之间不是原子的(两个录入员同时把两条报名改成同一个队名, + // 或导入正好在这两步之间落库):真撞上唯一索引时也要翻成同一句人话。 + if isUniqueViolation(err) { + return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil) + } + return entryDTO{}, internal(err) + } + return s.getEntry(ctx, entryID) +} + +// squadTakenMessage 是「这个队名在本组别已经被同单位占了」的现场文案。 +// +// 队名非空那一档导入侧也在用(importedEntryConflictError 的并发分支):两处说的若不是 +// 同一句话,操作员在导入里学到的那句到了改队名页面就不成立,只能重新猜系统在拦什么。 +// +// 队名留空那一档与导入侧**故意不同**:那边点的是报名表里的「队伍列」,这里没有那一格可点, +// 只能点「队名留空」。其余口径两边一致:空 = 不区分队伍,一个单位在一个组别只能有一条。 +func squadTakenMessage(teamCode, squad string) string { + if squad == "" { + return fmt.Sprintf("单位 %s 在本组别已经有一条不分队的团体报名:队名留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请把两支队分别写清队名(例如 一队 / 二队)", teamCode) + } + return fmt.Sprintf("单位 %s 在本组别已经有一条队伍「%s」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队)", teamCode, squad) +} + +// updateEntrySquadHandler 是 PUT /api/entries/{id}/squad:改一条团体报名的队名, +// 成功后返回这条报名的完整读数(与份额那条接口一样,前端拿它直接刷新那一行)。 +// +// 权限 / CSRF 与 PUT /api/entries/{id}/team-shares 同档:它改的也是报名。 +func (s *server) updateEntrySquadHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in entrySquadPayload + 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.updateEntrySquad(r.Context(), id, in.Squad) + if err != nil { + return nil, err + } + s.audit(r.Context(), "entry.squad.update", "entry", id, + map[string]any{"squad": before.Squad}, map[string]any{"squad": updated.Squad}) + return updated, nil + }) +} diff --git a/server/internal/httpapi/entry_squad_edit_test.go b/server/internal/httpapi/entry_squad_edit_test.go new file mode 100644 index 0000000..f153857 --- /dev/null +++ b/server/internal/httpapi/entry_squad_edit_test.go @@ -0,0 +1,325 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "testing" +) + +// 手工改队名:PUT /api/entries/{id}/squad。 +// +// 为什么要有这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。 +// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**,老那条还挂在库里—— +// 现场「填错一个字就得删掉重建」是不能接受的。 +// +// 口径(迁移 0025):队名是自由文本、首尾空白 trim 掉;空串 = 不区分队伍(合法值, +// 不是「把这条报名清掉」);只对纯团体报名(athlete_id 为空)有意义。 + +// updateSquad 调一次改队名接口。 +func updateSquad(t *testing.T, env *testEnv, client *http.Client, entryID int64, squad string, csrf bool) (int, envelope) { + t.Helper() + return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/squad", entryID), + map[string]any{"squad": squad}, csrf) +} + +// squadDumpThree 是「A 一队 / A 二队 / B 不分队」三条团体报名的落库事实。 +const squadDumpThree = "team_code=A squad=一队 \nteam_code=A squad=二队 \nteam_code=B squad= \n" + +// 现场场景:A 的「二队」填错了一个字,改成「青年队」——改的是**原来那一条**, +// 不是新增第二支队,老那条也不该留下。 +func TestUpdateEntrySquadRenamesInPlace(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"), + squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"), + squadImportRow("BADM", "男子", "B", "", "孙七/周八"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + if got := squadDump(t, env, eventID); got != squadDumpThree { + t.Fatalf("前置条件不成立:\n得到 %s期望 %s", got, squadDumpThree) + } + + entryID := entryIDOfSquad(t, env, eventID, "二队") + status, resp = updateSquad(t, env, client, entryID, "青年队", true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("改队名应 200,得到 %d %+v", status, resp.Error) + } + + // 返回更新后的完整读数(前端拿它直接刷新那一行)。 + updated := decodeData[map[string]any](t, resp) + if updated["id"] != float64(entryID) { + t.Fatalf("回的应是原来那一条报名 %d,得到 %+v", entryID, updated["id"]) + } + if updated["squad"] != "青年队" { + t.Fatalf("回显的队名 = %v,期望 青年队", updated["squad"]) + } + if updated["teamCode"] != "A" || updated["teamName"] != "第一分公司" { + t.Fatalf("回显应带单位编码与名称(前端要显示「A 青年队」),得到 %+v", updated) + } + if updated["memberNames"] == nil { + t.Fatalf("回显应是完整读数(含队员名单),得到 %+v", updated) + } + + // 落库:还是 3 条,队名换了,没有多出第四支队伍。 + want := "team_code=A squad=一队 \nteam_code=A squad=青年队 \nteam_code=B squad= \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("改完队名落库不对:\n得到 %s期望 %s", got, want) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 3 { + t.Fatalf("改队名不该新增报名,得到 %d 条", n) + } + + // 报名列表(录入页 / 大屏读的就是它)要带上新队名。 + board := getEntryBoard(t, env, client, eventID) + found := false + for _, entry := range board.Entries { + if entry["id"] == float64(entryID) { + found = true + if entry["squad"] != "青年队" { + t.Fatalf("报名列表里的队名 = %v,期望 青年队", entry["squad"]) + } + } + } + if !found { + t.Fatalf("报名列表里找不到报名 %d", entryID) + } + + // 审计:照份额那套记前后值,事后对账能看出改过哪一格。 + // waitAudit 取的是 after_json,改动前的队名在 before_json 里(同一行,等它落库后再读)。 + after := waitAudit(t, env, "entry.squad.update", entryID) + if !strings.Contains(after, "青年队") { + t.Fatalf("审计的 after 应记下新队名,得到 %s", after) + } + var before sql.NullString + if err := env.ev.QueryRow(`SELECT before_json FROM audit_logs WHERE action = ? AND entity_id = ?`, + "entry.squad.update", entryID).Scan(&before); err != nil { + t.Fatalf("读审计的 before: %v", err) + } + if !strings.Contains(before.String, "二队") { + t.Fatalf("审计的 before 应记下原队名,得到 %s", before.String) + } +} + +// 队名首尾空白一律 trim(导入侧读单元格时已经 trim 过,手工改走的是另一条路); +// 只留空白等于空串 = **不区分队伍**,是合法值,不是「把这条报名清掉」。 +func TestUpdateEntrySquadTrimsAndAcceptsBlank(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"), + squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + two := entryIDOfSquad(t, env, eventID, "二队") + + status, resp = updateSquad(t, env, client, two, " 青年队 ", true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("队名带空白应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[map[string]any](t, resp)["squad"]; got != "青年队" { + t.Fatalf("队名应 trim 首尾空白(含全角空格),得到 %q", got) + } + want := "team_code=A squad=一队 \nteam_code=A squad=青年队 \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("trim 后落库不对:\n得到 %s期望 %s", got, want) + } + + // 只留空白 → 空串(不区分队伍):报名还在,只是这条不再挂队名。 + status, resp = updateSquad(t, env, client, two, " ", true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("清空队名应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[map[string]any](t, resp)["squad"]; got != "" { + t.Fatalf("只留空白的队名应落成空串,得到 %q", got) + } + want = "team_code=A squad=一队 \nteam_code=A squad= \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("清空队名后落库不对:\n得到 %s期望 %s", got, want) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 2 { + t.Fatalf("空队名是「不区分队伍」,不是删报名:应仍是 2 条,得到 %d 条", n) + } +} + +// 拦下的请求必须给人话,且一个字节都不落库。 +func TestUpdateEntrySquadRejectsBadPayloads(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + // 第一个小项:A 报了「一队」与不分队各一条,B 也有一条「一队」。 + eventID, _ := seedDoublesEvent(t, env, ids) + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"), + squadImportRow("BADM", "男子", "A", "", "王五/赵六"), + squadImportRow("BADM", "男子", "B", "一队", "孙七/周八"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + baseline := squadDump(t, env, eventID) + soloEntry := entryIDOfSquad(t, env, eventID, "一队") + + // 第二个小项:A 只有「一队」与「二队」两条,没有不分队那条——用来试「改成不分队会不会撞」。 + soloEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, + compare, scored, multiplier, status) + VALUES (?, 'VOL2', '排球', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport) + env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '男子', 1, 0)`, soloEvent) + if status, resp := importCSVWithHeader(t, env, client, "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names,squad", + squadImportRow("VOL2", "男子", "A", "一队", "张伟/李强"), + squadImportRow("VOL2", "男子", "A", "二队", "王五/赵六")); status != http.StatusOK || !resp.OK { + t.Fatalf("第二个小项导入失败:%d %+v", status, resp.Error) + } + twoEntry := entryIDOfSquad(t, env, soloEvent, "二队") + + cases := []struct { + name string + id int64 + squad string + csrf bool + status int + wantMsg string + }{ + { + name: "撞上同单位同组别的另一个队", id: twoEntry, squad: "一队", csrf: true, + status: http.StatusBadRequest, + wantMsg: "单位 A 在本组别已经有一条队伍「一队」的报名", + }, + { + name: "改成不分队,但已经有一条不分队的", id: soloEntry, squad: " ", csrf: true, + status: http.StatusBadRequest, + wantMsg: "单位 A 在本组别已经有一条不分队的团体报名", + }, + { + // 队名只对纯团体报名生效:带运动员的报名按 (组别, 单位, 运动员) 唯一。 + name: "带运动员的报名", id: ids.entryB, squad: "一队", csrf: true, + status: http.StatusBadRequest, + wantMsg: "队名只对团体报名生效", + }, + { + name: "报名不存在", id: 999999, squad: "一队", csrf: true, + status: http.StatusNotFound, wantMsg: "报名项不存在", + }, + { + // 与 PUT /api/entries/{id}/team-shares 同档:改动报名必须带 CSRF。 + name: "缺 CSRF", id: twoEntry, squad: "青年队", csrf: false, + status: http.StatusForbidden, wantMsg: "CSRF 校验失败", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, resp := updateSquad(t, env, client, tc.id, tc.squad, tc.csrf) + if status != tc.status { + t.Fatalf("应 %d,得到 %d %+v", tc.status, status, resp) + } + if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.wantMsg) { + t.Fatalf("报错应含 %q,得到 %+v", tc.wantMsg, resp.Error) + } + // 报错里不该出现 SQLite 的英文原文:现场读不懂,照着它也找不到该改哪一格。 + if strings.Contains(resp.Error.Message, "UNIQUE constraint failed") { + t.Fatalf("不该把 SQLite 原文丢给现场:%s", resp.Error.Message) + } + }) + } + + if got := squadDump(t, env, eventID); got != baseline { + t.Fatalf("被拒的请求改动了报名表:\n之前 %s之后 %s", baseline, got) + } + wantSolo := "team_code=A squad=一队 \nteam_code=A squad=二队 \n" + if got := squadDump(t, env, soloEvent); got != wantSolo { + t.Fatalf("被拒的请求改动了第二个小项的报名表:\n得到 %s期望 %s", got, wantSolo) + } +} + +// 未登录一样进不来(与其它报名写接口同档)。 +func TestUpdateEntrySquadRequiresLogin(t *testing.T) { + env, _, client := recorderWithSeed(t) + anon, _ := env.client() + if status, resp := updateSquad(t, env, anon, 1, "一队", false); status != http.StatusUnauthorized { + t.Fatalf("未登录改队名应 401,得到 %d %+v", status, resp) + } + // 登录了但这条报名不存在 → 404(照本仓既有风格)。 + if status, resp := updateSquad(t, env, client, 999999, "一队", true); status != http.StatusNotFound { + t.Fatalf("改不存在的报名应 404,得到 %d %+v", status, resp) + } +} + +// 同一个 requestId 重复提交回放首次结果,不重复执行(与其它写接口同一套幂等)。 +func TestUpdateEntrySquadIdempotentByRequestID(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "二队", "张伟/李强"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + entryID := entryIDOfSquad(t, env, eventID, "二队") + path := fmt.Sprintf("/api/entries/%d/squad", entryID) + + if status, resp := env.do(client, http.MethodPut, path, + map[string]any{"squad": "青年队", "requestId": "squad-1"}, true); status != http.StatusOK { + t.Fatalf("第一次改队名应 200,得到 %d %+v", status, resp.Error) + } + // 同一个 requestId、换一个队名再来一次:必须是回放,库里仍是第一次的结果。 + status, resp = env.do(client, http.MethodPut, path, + map[string]any{"squad": "少年队", "requestId": "squad-1"}, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("重复提交应回放首次结果,得到 %d %+v", status, resp.Error) + } + if got := decodeData[map[string]any](t, resp)["squad"]; got != "青年队" { + t.Fatalf("回放的应是首次结果(青年队),得到 %v", got) + } + want := "team_code=A squad=青年队 \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("重复提交不该再改一次库:\n得到 %s期望 %s", got, want) + } +} + +// 队名只是显示用的标签:改名前后**一切算分输出**逐字节相同。 +// +// 这是「改队名不重算」这条决定的正面证据:积分流水、成绩名次、积分榜上的总分与金银铜 +// 一个都不该动。真要动了,说明队名混进了算分口径,那才是 bug。 +func TestUpdateEntrySquadDoesNotChangeScoring(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"), + squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"), + squadImportRow("BADM", "男子", "B", "", "孙七/周八"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + publishResults(t, env, client, eventID, "squad", + map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"}, + map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"}, + map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"}, + ) + assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0) + assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1) + + // 算分输出:积分流水(含作废标记)与成绩名次,逐字节对拍。 + scoring := func() string { + return tableDump(t, env, `SELECT id, entry_id, team_id, points, voided FROM score_ledger ORDER BY id`) + + tableDump(t, env, `SELECT entry_id, COALESCE(rank, 0), state FROM results ORDER BY entry_id`) + } + before := scoring() + + status, resp = updateSquad(t, env, client, entryIDOfSquad(t, env, eventID, "二队"), "青年队", true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("改队名应 200,得到 %d %+v", status, resp.Error) + } + + if after := scoring(); after != before { + t.Fatalf("改队名动了算分输出:\n之前 %s之后 %s", before, after) + } + assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0) + assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1) +} diff --git a/server/internal/httpapi/entry_squad_internal_test.go b/server/internal/httpapi/entry_squad_internal_test.go new file mode 100644 index 0000000..0c940fb --- /dev/null +++ b/server/internal/httpapi/entry_squad_internal_test.go @@ -0,0 +1,93 @@ +package httpapi + +import ( + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// 撞上唯一索引时给现场的必须是人话:SQLite 的原文是 +// 「UNIQUE constraint failed: entries.category_id, entries.team_id, entries.squad」, +// 里面没有单位、没有队伍,操作员照着它找不出该改哪一格。 +// +// 这条分支只有**并发导入同一份名单**(或库里那条报名正好在查重与落库之间建好)才会走到, +// HTTP 层造不稳定,所以在函数级别钉住文案:三种情形(同一个队 / 不分队 / 同一个人) +// 说的都得是「哪个单位、哪个队、怎么办」。 +func TestImportedEntryConflictErrorMessage(t *testing.T) { + db := openSquadTestDB(t) + s := &server{ev: db} + unique := errors.New("UNIQUE constraint failed: entries.category_id, entries.team_id, entries.squad") + + athleteID := int64(7) + cases := []struct { + name string + in entryDTO + want []string + }{ + { + name: "同一个单位的同一个队", + in: entryDTO{TeamID: 1, Squad: "一队"}, + want: []string{"单位 A", "队伍「一队」", "同一个单位同一个组别的同一个队", "二队"}, + }, + { + name: "同一个单位的不分队报名", + in: entryDTO{TeamID: 1}, + want: []string{"单位 A", "不分队", "队伍列留空", "一队 / 二队"}, + }, + { + name: "同一个人在同一组别", + in: entryDTO{TeamID: 1, AthleteID: &athleteID, Squad: "一队"}, + want: []string{"单位 A", "同一个人在同一组别只能报一条"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := s.importedEntryConflictError(t.Context(), tc.in, unique) + if err == nil { + t.Fatal("唯一键冲突应被翻译成人话,得到 nil") + } + msg := err.Error() + for _, want := range tc.want { + if !strings.Contains(msg, want) { + t.Errorf("报错里少了 %q:%s", want, msg) + } + } + // 英文的约束原文留给日志,不该出现在给现场看的这一句里。 + if strings.Contains(msg, "UNIQUE constraint failed") { + t.Errorf("报错不该把 SQLite 原文丢给现场:%s", msg) + } + }) + } + + // 不是唯一键冲突的错误(外键、CHECK、锁超时)原样返回: + // 把它们说成「重复报名」会把排查方向带偏。 + other := errors.New("FOREIGN KEY constraint failed") + if got := s.importedEntryConflictError(t.Context(), entryDTO{TeamID: 1}, other); got != other { + t.Fatalf("非唯一键冲突应原样返回,得到 %v", got) + } +} + +// openSquadTestDB 开一个空的赛事库(跑完全部迁移),造一个单位 A 供报错文案使用。 +func openSquadTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := "file:" + filepath.Join(t.TempDir(), "event.db") + "?_pragma=foreign_keys(1)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("打开赛事库: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("迁移赛事库: %v", err) + } + if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`); err != nil { + t.Fatalf("造单位: %v", err) + } + return db +} diff --git a/server/internal/httpapi/entry_squad_test.go b/server/internal/httpapi/entry_squad_test.go new file mode 100644 index 0000000..70899f1 --- /dev/null +++ b/server/internal/httpapi/entry_squad_test.go @@ -0,0 +1,331 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 队伍(squad):同一个单位在同一个组别可以派多个队参赛(羽毛球双打那种:一个单位两个队, +// 两个队各占一个名次)。 +// +// 口径(迁移 0025): +// - squad 是自由文本(一队 / 二队 / 青年队 都能写),**空串 = 不区分队伍**——老数据与单队单位的 +// 默认值,行为与升级前一模一样(一个单位在这个组别一条团体报名); +// - 只对**纯团体报名**(athlete_id 为空)放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一, +// 同一个人在同一组别不会因为这次改动而报两次; +// - 一个单位两个队都进前三就是 2 枚牌,引擎本来就按报名逐条记牌,不走特判。 + +// importHeaderSquad 是带「队伍」列的表头:新列**加在最后**,既有表格一列不错位。 +const importHeaderSquad = importHeader + ",squad" + +// importHeaderSquadZH 是中文别名表头(队伍 = importColumns 里 squad 的首选别名)。 +const importHeaderSquadZH = importHeaderZH + ",队伍" + +// squadImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeaderSquad 一致。 +func squadImportRow(eventCode, category, teamCode, squad, members string) string { + return strings.Join([]string{eventCode, category, teamCode, "", + "", "", "", "", "", "registered", members, squad}, ",") +} + +// importCSVWithHeader 用指定表头上传一份单表 CSV。 +func importCSVWithHeader(t *testing.T, env *testEnv, client *http.Client, header string, rows ...string) (int, envelope) { + t.Helper() + content := header + "\n" + strings.Join(rows, "\n") + "\n" + return env.postImport(t, client, "/api/entries/import", content, "") +} + +// seedDoublesEvent 建一个「羽毛球双打」形态的小项:team 类型 + 按成绩排名的团体通道 +// (团体报名项各录各的成绩、各占各的名次),返回小项与组别 id。 +func seedDoublesEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) { + t.Helper() + eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, + compare, scored, multiplier, status) + VALUES (?, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport) + categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '男子', 1, 0)`, eventID) + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6")) + return eventID, categoryID +} + +// squadDump 导出某小项「单位 → 队伍」的落库事实(按报名 id 排序,稳定可比)。 +// 刻意不带报名 id:id 由种子数据占位,断言里写死 id 只会让用例变脆。 +func squadDump(t *testing.T, env *testEnv, eventID int64) string { + t.Helper() + return tableDump(t, env, fmt.Sprintf( + `SELECT t.code AS team_code, e.squad + FROM entries e JOIN teams t ON t.id = e.team_id + WHERE e.event_id = %d ORDER BY e.id`, eventID)) +} + +// 现场场景:一个单位派两个队报名。升级前第二条会被唯一索引拦住(一条团体报名), +// 现在两条都要落库、各自有名次、各自拿牌。 +func TestImportSquadCreatesSecondTeamOfSameUnit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"), + squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"), + squadImportRow("BADM", "男子", "B", "", "孙七/周八"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if len(result.Errors) != 0 { + t.Fatalf("不该有报错行:%v", result.Errors) + } + if result.Created != 3 { + t.Fatalf("一个单位两个队应是 3 条报名(A 两条 + B 一条),得到 created=%d updated=%d:%s", + result.Created, result.Updated, squadDump(t, env, eventID)) + } + + // 落库:两个队的 squad 各自留着,空串那条就是老口径「不区分队伍」。 + rows, err := env.ev.Query(`SELECT t.code, e.squad FROM entries e JOIN teams t ON t.id = e.team_id + WHERE e.event_id = ? ORDER BY e.id`, eventID) + if err != nil { + t.Fatalf("读报名: %v", err) + } + defer func() { _ = rows.Close() }() + var got []string + for rows.Next() { + var code, squad string + if err := rows.Scan(&code, &squad); err != nil { + t.Fatalf("扫描报名: %v", err) + } + got = append(got, code+":"+squad) + } + want := "A:一队 A:二队 B:" + if joined := strings.Join(got, " "); joined != want { + t.Fatalf("落库的队伍不对:\n得到 %s\n期望 %s", joined, want) + } + + // 名次:一队第 1、二队第 2、B 第 3 —— 两个队都进前三,A 拿 2 枚牌(金 1 银 1)。 + publishResults(t, env, client, eventID, "squad", + map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"}, + map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"}, + map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"}, + ) + assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0) + assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1) + + // 报名列表要把队伍带出来(前端据此区分「A 一队 / A 二队」)。 + board := getEntryBoard(t, env, client, eventID) + byID := map[float64]string{} + for _, entry := range board.Entries { + id, _ := entry["id"].(float64) + squad, _ := entry["squad"].(string) + byID[id] = squad + } + for _, want := range []struct { + squad string + }{{"一队"}, {"二队"}, {""}} { + id := float64(entryIDOfSquad(t, env, eventID, want.squad)) + if byID[id] != want.squad { + t.Errorf("报名 %v 的队伍 = %q,期望 %q", id, byID[id], want.squad) + } + } +} + +// 老表零变化:表里没有队伍列(或整列留空)时,一个单位在一个组别仍然只有一条团体报名—— +// 第二条是**更新**那一条,不是新增(升级前的行为一字不改)。 +func TestImportSquadKeepsLegacyBehaviourWhenColumnEmpty(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + + // 先走升级前的老表头(连队伍列都没有)。 + status, resp := importCSV(t, env, client, jointImportRow("BADM", "男子", "A")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("不带队伍列的老表应照常导入,得到 %d %+v", status, resp.Error) + } + if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 { + t.Fatalf("老表应新建 1 条:%+v", result) + } + + // 同一份老表再传一次:更新那一条,不新增(老行为)。 + status, resp = importCSV(t, env, client, jointImportRow("BADM", "男子", "A")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("老表重传应照常更新,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 0 || result.Updated != 1 || result.Skipped != 0 { + t.Fatalf("同一份老表重传应是「新建 0 / 更新 1」,得到 %+v", result) + } + + // 带队伍列的表格、但那一格留空:同样是不区分队伍的老口径(B 与 A 各一条)。 + status, resp = importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "B", "", "孙七/周八")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("队伍留空应照常导入,得到 %d %+v", status, resp.Error) + } + want := "team_code=A squad= \nteam_code=B squad= \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("老口径落库不对:\n得到 %s期望 %s", got, want) + } +} + +// 同一个队重复导入是幂等的(后一行覆盖前一行);换一个队伍名才是第二个队。 +func TestImportSquadSameTeamTwiceIsIdempotent(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"), + squadImportRow("BADM", "男子", "A", "一队", "张伟/李强/王五"), + ) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 { + t.Fatalf("同一个队重传应是「新建 1 / 更新 1」(后一行覆盖前一行),得到 %+v", result) + } + if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 { + t.Fatalf("同一个队重传后库里应只有 1 条报名,得到 %d 条:%s", got, squadDump(t, env, eventID)) + } + // 后一行覆盖前一行:队员名单以最后一行写的为准。 + if got := tableDump(t, env, fmt.Sprintf(`SELECT member_names FROM entries WHERE event_id = %d`, eventID)); !strings.Contains(got, "王五") { + t.Fatalf("后一行应覆盖前一行的队员名单,得到 %s", got) + } + + // 换一个队伍名 = 第二个队:新增一条。 + status, resp = importCSVWithHeader(t, env, client, importHeaderSquad, + squadImportRow("BADM", "男子", "A", "二队", "李四/赵六")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入第二个队应 200,得到 %d %+v", status, resp.Error) + } + if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 { + t.Fatalf("第二个队应新增 1 条:%+v", result) + } +} + +// 中文别名:队伍 / 队名 / 队伍名 都认(现场拿到的是中文模板)。 +func TestImportSquadChineseAliases(t *testing.T) { + for _, alias := range []string{"队伍", "队名", "队伍名"} { + t.Run(alias, func(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + + header := strings.TrimSuffix(importHeaderSquadZH, "队伍") + alias + status, resp := importCSVWithHeader(t, env, client, header, + squadImportRow("BADM", "男子", "A", "青年队", "张伟/李强")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("中文表头 %s 应能导入,得到 %d %+v", alias, status, resp.Error) + } + if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 { + t.Fatalf("应新建 1 条:%+v", result) + } + want := "team_code=A squad=青年队 \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("中文表头落库不对:\n得到 %s期望 %s", got, want) + } + }) + } +} + +// 带运动员的报名不受队伍影响:同一个人在同一组别写两个队伍名,仍是同一条报名(不会报两次), +// 并且要有一条 notices 说清「队伍没写进库里」——那一格填了就不该悄悄消失。 +func TestImportSquadIgnoredForAthleteRows(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + // 另开一个个人小项:种子里的 M100 有两条报名,掺进来没法一眼看清新增了几条。 + eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, + compare, scored, multiplier, status) + VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport) + env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '男子', 1, 0)`, eventID) + + athleteRow := func(squad string) string { + return strings.Join([]string{"M200", "男子", "A", "张三", "110101199001011234", "男", "26", + "101", "", "registered", "", squad}, ",") + } + + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("一队")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 { + t.Fatalf("个人行应新建 1 条:%+v", result) + } + if joined := strings.Join(result.Notices, ";"); !strings.Contains(joined, "队伍只对团体报名") { + t.Fatalf("个人行填了队伍要给一条提示,得到 %v", result.Notices) + } + if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND squad <> ''`, eventID); got != 0 { + t.Fatalf("个人报名的 squad 必须是空串,得到 %d 条非空", got) + } + + // 同一个人换个队伍名重传:还是同一条(同一个人在同一组别不会因为队伍报两次)。 + status, resp = importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("二队")) + if status != http.StatusOK || !resp.OK { + t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error) + } + result = decodeData[entryImportResultBody](t, resp) + if result.Created != 0 || result.Updated != 1 { + t.Fatalf("同一个人换个队伍名重传应仍是那一条(新建 0 / 更新 1),得到 %+v", result) + } + if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 { + t.Fatalf("同一个人在同一组别应只有 1 条报名,得到 %d 条", got) + } +} + +// 模板要在最后多一列「队伍 / squad」:新列追加在表尾,现场手里少一列的旧表格一列都不会错位。 +func TestEntriesTemplatePutsSquadLast(t *testing.T) { + env, _, client := recorderWithSeed(t) + + for _, tc := range []struct{ lang, want string }{{"zh", "队伍"}, {"en", "squad"}} { + f, _ := env.fetchTemplate(t, client, tc.lang) + rows, err := f.GetRows(f.GetSheetList()[0]) + if err != nil { + t.Fatalf("[%s] 读模板: %v", tc.lang, err) + } + header := rows[0] + if len(header) != 13 { + t.Fatalf("[%s] 模板表头应是 13 列(原 12 列 + 队伍),得到 %d 列:%v", tc.lang, len(header), header) + } + if header[len(header)-1] != tc.want { + t.Fatalf("[%s] 队伍列必须在最后一列(%q),得到 %q", tc.lang, tc.want, header[len(header)-1]) + } + } +} + +// 老模板(12 列,没有队伍列)照常导入:这是「新列加在最后」的全部意义—— +// 现场手里那些列少的表格一列都不会错位,导入结果与升级前完全一样。 +func TestImportSheetTemplateWithoutSquadColumnStillWorks(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _ := seedDoublesEvent(t, env, ids) + + const legacyHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names" + data := buildTestXLSX(t, []testSheet{ + {name: "羽毛球双打@男子", rows: [][]string{ + strings.Split(legacyHeader, ","), + {"", "", "A", "", "", "", "", "301", "", "1", "registered", "张伟/李强"}, + }}, + }) + status, resp := env.uploadEntries(t, client, "t.xlsx", data) + if status != http.StatusOK || !resp.OK { + t.Fatalf("老模板应照常导入,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 || len(result.Errors) != 0 { + t.Fatalf("应新建 1 条且无报错:%+v", result) + } + want := "team_code=A squad= \n" + if got := squadDump(t, env, eventID); got != want { + t.Fatalf("老模板落库的队伍应是空串(不区分队伍):\n得到 %s期望 %s", got, want) + } +} + +// entryIDOfSquad 取某小项里某个队的报名 id(空串 = 不区分队伍的那条)。 +func entryIDOfSquad(t *testing.T, env *testEnv, eventID int64, squad string) int64 { + t.Helper() + var id int64 + if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND squad = ? ORDER BY id LIMIT 1`, + eventID, squad).Scan(&id); err != nil { + t.Fatalf("读队伍 %q 的报名: %v", squad, err) + } + return id +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 82d68af..9c4a0a9 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -1048,6 +1048,9 @@ var importColumns = []importColumn{ {name: "heat", aliases: []string{"批次", "批次号"}, optional: true}, {name: "status", aliases: []string{"状态", "报名状态"}}, {name: "member_names", aliases: []string{"队员名单"}}, + // squad(队伍)挂在**最后**:新列追加在表尾,现场手里那些少一列的旧表格一列都不会错位。 + // 空串 = 不区分队伍,与老口径一致;同一个单位在同一个组别派两个队就靠它区分(迁移 0025)。 + {name: "squad", aliases: []string{"队伍", "队名", "队伍名"}, optional: true}, } // label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列, @@ -1228,6 +1231,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de categoryName := cell("category") teamCode := cell("team_code") athleteName := cell("athlete_name") + // 队伍是自由文本(一队 / 二队 / 青年队),不做任何归一:现场怎么写就怎么存, + // 「一队」与「一 队」是两个队(系统没有立场替现场合并)。 + squad := cell("squad") if teamCode == "" || (table.scope == nil && eventCode == "") { result.Skipped++ if table.scope == nil { @@ -1432,11 +1438,21 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de teamShares = nil } + // 队伍只对**纯团体报名**(这一行没填姓名)生效。个人报名按 (组别, 单位, 运动员) 唯一, + // squad 不参与那条判断——填了就写不进库,所以要说一句,不能悄悄丢掉这一格。 + if athleteID != nil && squad != "" { + result.Notices = append(result.Notices, prefix+fmt.Sprintf( + "第 %d 行填了队伍「%s」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里", + line, squad)) + squad = "" + } + outcome, err := s.upsertImportedEntry(ctx, entryDTO{ EventID: eventID, CategoryID: categoryID, TeamID: teamID, TeamShares: teamShares, + Squad: squad, AthleteID: athleteID, MemberNames: splitNames(cell("member_names")), Bib: nilIfEmpty(bib), diff --git a/server/internal/httpapi/joint_team_template_test.go b/server/internal/httpapi/joint_team_template_test.go index 060317b..51aae4c 100644 --- a/server/internal/httpapi/joint_team_template_test.go +++ b/server/internal/httpapi/joint_team_template_test.go @@ -10,7 +10,7 @@ import ( // 积分平均分、奖牌两边各记一枚。 // // 说明只能挂在「不会变成数据」的载体上:表头行是导入器认模板的锚点(必须留在第 1 行、 -// 12 列一成不变),工作表名是「小项@组别」的范围锚点(也不能多出说明表), +// 列名与 importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出说明表), // 所以这里用**单元格批注**(表头「单位编码」那一格)+ **该列的输入提示**(选中即弹出)。 func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) { env, _, client := recorderWithSeed(t) diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index f54f0c4..ebaa088 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -53,6 +53,14 @@ type entryTeamSharesPayload struct { RequestID string `json:"requestId"` } +// entrySquadPayload 是 PUT /api/entries/{id}/squad 的请求体:改一条团体报名的队名。 +// 队名是自由文本,服务端只 trim 首尾空白;空串(或只有空白)是**合法值**, +// 表示「不区分队伍」,不是「清掉这条报名」(见迁移 0025 与 updateEntrySquad)。 +type entrySquadPayload struct { + Squad string `json:"squad"` + 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 b2b99b2..ab185df 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -712,10 +712,29 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe // 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时 // 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。 - if _, err := eventByIDQ(ctx, tx, eventID); err != nil { + event, err := eventByIDQ(ctx, tx, eventID) + if err != nil { return nil, err } + // compare=none 的小项排不出名次(名次靠人工录入),把 basis=result / aggregate 的通道存进去, + // 要等到发布时才会被 domain.ScoreEvent 以 ErrCompareRequired 拒绝(409 STATE_INVALID)—— + // 那时成绩已经录了一半,回退代价高。规则包导入侧早有这条守卫 + // (rules/diff.go checkCompareCompatible),手工在管理端配的这条路一直没人拦,在这里补上: + // 保存前整包拒收,一个字节都不写,并把「哪条通道、为什么」说清楚。 + if event.Compare == string(domain.CompareNone) { + for i, c := range in { + if c.Basis != string(domain.BasisResult) && c.Basis != string(domain.BasisAggregate) { + continue + } + return nil, badRequest(fmt.Sprintf( + "小项「%s」的比较方向是「不比较」(compare=none),排不出名次:第 %d 条通道的 basis=%s 要按成绩排名,"+ + "服务端算不出分、发布会失败。请把它改成「手动录名次」(basis=manual),"+ + "或先给小项设置比较方向(compare=asc/desc)", + event.Name, i+1, c.Basis), map[string]any{"index": i, "basis": c.Basis, "compare": event.Compare}) + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { return nil, internal(err) } @@ -777,8 +796,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe // ---------- entries ---------- +// squad 挂在最后:列的追加不该动到既有列的下标(scanEntry 按位置扫,加在中间会整列错位)。 const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, - e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name` + e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name, e.squad` func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { var ( @@ -794,7 +814,8 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { categoryName sql.NullString ) if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, - &bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil { + &bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName, + &e.Squad); err != nil { return e, err } e.AthleteID = i64Ptr(athleteID) diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index be5b02f..baeb38b 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -646,6 +646,10 @@ func staleScoredEntryError(item staleScopeEntry) error { // upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。 // +// 纯团体报名(athlete_id 为空)另加**队伍**这一段:同一个单位在同一个组别可以派多个队参赛 +// (迁移 0025),所以查重键是「组别 + 单位 + 队伍」,队伍为空串就是「不区分队伍」的老口径。 +// 带运动员的报名照旧只认 (组别, 单位, 运动员):同一个人在同一组别不会因为队伍多报一次。 +// // 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把 // 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。 // @@ -663,8 +667,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus) } else { err = s.ev.QueryRowContext(ctx, - `SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`, - in.CategoryID, in.TeamID).Scan(&existing, &oldStatus) + `SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ?`, + in.CategoryID, in.TeamID, entrySquad(in)).Scan(&existing, &oldStatus) } status := orDefault(in.Status, "registered") @@ -675,12 +679,12 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp return entryImportOutcome{}, err } res, err := s.ev.ExecContext(ctx, - `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, squad) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), - nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status) + nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, entrySquad(in)) if err != nil { - return entryImportOutcome{}, err + return entryImportOutcome{}, s.importedEntryConflictError(ctx, in, err) } newID, err := res.LastInsertId() if err != nil { @@ -726,6 +730,48 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp } } +// entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。 +// +// 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串, +// 两件事同时成立:同一个人换个队伍写法重传仍是同一条(UPDATE,不会撞唯一键), +// 库里也不会出现「个人报名带着一队 / 二队」这种没人会去看的字段值。 +func entrySquad(in entryDTO) string { + if in.AthleteID != nil { + return "" + } + return in.Squad +} + +// importedEntryConflictError 把 INSERT 撞上唯一索引翻译成人话。 +// +// 索引 idx_entries_team_squad_unique 的键是 (组别, 单位, 队伍) WHERE athlete_id IS NULL; +// 上面的查重与这次 INSERT 不是原子的,两个人同时导同一份名单、或库里那条报名正好在这两步之间 +// 落库,就会撞上它。SQLite 原文是「UNIQUE constraint failed: entries.category_id, +// entries.team_id, entries.squad」——现场看不出是哪个单位、哪个队,也不知道该改哪一格, +// 所以在这里换成点名单位与队伍的一句话。 +// +// 非唯一键冲突(外键、CHECK、锁超时)原样返回:那些不是「重复报名」,换成重复报名的说法会把人带偏。 +func (s *server) importedEntryConflictError(ctx context.Context, in entryDTO, err error) error { + if !isUniqueViolation(err) { + return err + } + teamCode, codeErr := s.teamCodeByID(ctx, in.TeamID) + if codeErr != nil { + // 连单位编码都查不出来时宁可回原始错误:猜一句「单位 X 重复」会把排查方向带错。 + return err + } + if in.AthleteID != nil { + return fmt.Errorf("单位 %s 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入", teamCode) + } + if in.Squad == "" { + return fmt.Errorf("单位 %s 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;"+ + "要派两个队请在队伍列分别写清(例如 一队 / 二队)", teamCode) + } + // 队名非空那一句与「手工改队名」接口共用(entry_squad.go):两处说的若不是同一句话, + // 操作员在导入里学到的那句到了改队名页面就不成立。 + return errors.New(squadTakenMessage(teamCode, in.Squad)) +} + // recomputeImportedScopes 重算报名导入动过的 (小项, 组别),并推小项与榜单。 // // 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径: diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 2ee8fcc..f6c600b 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -279,6 +279,9 @@ func (s *server) routes(mux *http.ServeMux) { // 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。 // 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。 mux.HandleFunc("PUT /api/entries/{id}/team-shares", s.write(auth.RoleRecorder, s.updateEntryTeamSharesHandler)) + // 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。 + // 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。 + mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler)) // 成绩 mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) diff --git a/server/internal/httpapi/team_manual_ranks_score_test.go b/server/internal/httpapi/team_manual_ranks_score_test.go new file mode 100644 index 0000000..b396bb2 --- /dev/null +++ b/server/internal/httpapi/team_manual_ranks_score_test.go @@ -0,0 +1,82 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "testing" +) + +// 现场那份数据的服务端侧:台球(纯团体、名次靠人工录入)六个单位的名次只填在录入页底部的 +// 「团体名次」卡片里(channel_manual_ranks),服务端要按规则包 team_top6 算出 15/10/8/4/2/1, +// 六个单位合计 40 分。 +// +// 与 web/src/admin/tests/scoring-team-manual.test.ts 的第一个用例同输入同期望:录入页的实时预览 +// 曾只读行上的 rank,这份数据在预览里显示成每个单位 0 分,而服务端一直算的是 40 分。 +func TestTeamManualRanksSixUnitsProduce40Points(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, + compare, scored, multiplier, status) + VALUES (?, 'BALL', '台球', 'team', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport) + categoryID := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '混合组', 1, 0)`, eventID) + addTeamManualChannel(t, env, eventID) + + // 六个参赛单位:名次不挂报名项,单位本身就是主体。 + units := make([]int64, 0, 6) + ranks := make([]map[string]any, 0, 6) + for i := 1; i <= 6; i++ { + teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) + VALUES (?, ?, ?, ?, 1)`, fmt.Sprintf("T%d", i), fmt.Sprintf("第 %d 单位", i), fmt.Sprintf("T%d", i), i) + units = append(units, teamID) + ranks = append(ranks, map[string]any{"teamId": teamID, "rank": i}) + } + + status, resp := putTeamRanks(t, env, client, eventID, categoryID, ranks) + if status != http.StatusOK || !resp.OK { + t.Fatalf("写六个单位的团体名次应 200,得到 %d %+v", status, resp.Error) + } + + got := pointsByTeamDB(t, env, eventID) + want := []float64{15, 10, 8, 4, 2, 1} + total := 0.0 + for i, teamID := range units { + total += got[teamID] + if got[teamID] != want[i] { + t.Fatalf("单位 %d(第 %d 名)= %v,期望 %v(全部:%v)", teamID, i+1, got[teamID], want[i], got) + } + } + if total != 40 { + t.Fatalf("六个单位合计 = %v,期望 40", total) + } + + // 团体名次的分不挂任何报名项(现场成绩表里一行 rank 都没有)。 + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger + WHERE voided = 0 AND (entry_id IS NOT NULL OR result_id IS NOT NULL)`); n != 0 { + t.Fatalf("团体名次的流水不该挂报名项,得到 %d 条", n) + } +} + +// pointsByTeamDB 读某小项未作废流水里各单位的分值合计。 +func pointsByTeamDB(t *testing.T, env *testEnv, eventID int64) map[int64]float64 { + t.Helper() + rows, err := env.ev.Query(`SELECT team_id, COALESCE(SUM(points), 0) FROM score_ledger + WHERE voided = 0 AND event_id = ? AND team_id IS NOT NULL GROUP BY team_id`, eventID) + if err != nil { + t.Fatalf("读流水: %v", err) + } + defer func() { _ = rows.Close() }() + out := make(map[int64]float64) + for rows.Next() { + var teamID int64 + var points float64 + if err := rows.Scan(&teamID, &points); err != nil { + t.Fatalf("扫流水: %v", err) + } + out[teamID] = points + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历流水: %v", err) + } + return out +} diff --git a/server/migrations/event/0025_entry_squad.sql b/server/migrations/event/0025_entry_squad.sql new file mode 100644 index 0000000..e849d7f --- /dev/null +++ b/server/migrations/event/0025_entry_squad.sql @@ -0,0 +1,20 @@ +-- 团体项目的「队伍」:同一个单位在同一组别可以派多个队参赛(例如羽毛球双打,一个单位两个队, +-- 名次可能出现「一个单位两个队各占一个名次」)。队伍的成绩、名次、积分各自记,最后都算到同一个 +-- 单位的总分上(两条报名各拿各的分,单位总分自然就是两队相加)。 +-- +-- 为什么加一列而不是新建「队伍单位」:那样榜单会多出单位、分数也归不到原单位头上。队伍只是 +-- 「这个单位在这一组别里的第几支队」,本质仍是同一个单位的报名。 +-- +-- squad 是**自由文本**(一队 / 二队 / 青年队 都能写),空串 = 不区分队伍 —— 这是老数据与 +-- 单队单位的默认值,行为与升级前完全一致。 +-- +-- 只对**纯团体报名**(athlete_id IS NULL)放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一, +-- 同一个运动员不会因为这次改动而在同一组别报两次。 +ALTER TABLE entries ADD COLUMN squad TEXT NOT NULL DEFAULT ''; + +-- 老的唯一索引只看 (组别, 单位),一个单位在一个组别只能有一条团体报名 —— 正是它拦住第二个队。 +-- 换成带上 squad 的版本:squad 为空的老数据在新索引下仍然「一个单位一条」,等价。 +DROP INDEX IF EXISTS idx_entries_team_unique; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_team_squad_unique + ON entries(category_id, team_id, squad) WHERE athlete_id IS NULL; diff --git a/server/migrations/event/migration_0025_test.go b/server/migrations/event/migration_0025_test.go new file mode 100644 index 0000000..df8c054 --- /dev/null +++ b/server/migrations/event/migration_0025_test.go @@ -0,0 +1,174 @@ +package event + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// 0025:团体项目的「队伍」(entries.squad)。 +// +// 三条底线: +// - 老数据零变化:升级后既有报名的 squad 一律是空串,一个单位在一个组别仍然只能有一条 +// 纯团体报名(唯一索引换成带 squad 的版本后,空串这一档的约束与升级前**等价**); +// - 只给纯团体报名放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,同一个人在同一组别 +// 不会因为这次改动多报一次(表级 UNIQUE + 新的部分索引都不覆盖 athlete_id 非空的行); +// - 幂等:重复执行迁移不报错、不改动任何一行。 +func TestMigration0025EntrySquad(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0025") // 回到「升级前」:库结构到 0024 为止 + + stmts := []string{ + `INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`, + `INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`, + `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)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('张伟', '男', 1)`, + // 团体报名(athlete_id 为空)与个人报名各一条,升级后都要一字不动。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status) + VALUES (1, 1, 1, NULL, '["张伟","李强"]', 'registered')`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, 1, 'registered')`, + } + + // 升级前:老索引拦住第二个队 —— 同一个单位在同一个组别只能有一条团体报名。 + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造老库数据 %q: %v", s, err) + } + } + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) + VALUES (1, 1, 1, NULL, 'registered')`); err == nil { + t.Fatal("升级前同一个单位的第二条团体报名应被唯一索引拦住") + } + if _, err := db.Exec(`SELECT squad FROM entries`); err == nil { + t.Fatal("升级前不该有 squad 列") + } else if !strings.Contains(err.Error(), "squad") { + t.Fatalf("查询报错应点明缺列 squad,实际 %v", err) + } + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("升级赛事库: %v", err) + } + + // 老数据零变化:既有报名一条不少、squad 一律空串。 + if got := countRows(t, db, "entries"); got != 2 { + t.Fatalf("升级后报名数 = %d,期望 2", got) + } + if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad <> ''`); got != 0 { + t.Errorf("老数据的 squad 必须是空串(不区分队伍),得到 %d 条非空", got) + } + if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id = 1 AND squad <> ''`); got != 0 { + t.Errorf("老数据的 squad 必须全部是空串,得到 %d 条非空", got) + } + + // 老索引没了,换成带 squad 的那条(部分索引:只管 athlete_id 为空的行)。 + if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_unique'`); got != 0 { + t.Errorf("老索引 idx_entries_team_unique 应已被删除,还剩 %d 条", got) + } + if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_squad_unique'`); got != 1 { + t.Errorf("应存在新索引 idx_entries_team_squad_unique,得到 %d 条", got) + } + + // 空串这一档的约束与升级前**等价**:同一单位再加一条不分队的团体报名仍然被拒绝。 + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) + VALUES (1, 1, 1, NULL, 'registered')`); err == nil { + t.Error("同一个单位同一个组别的第二条「不分队」团体报名应被拒绝(老口径不变)") + } + // 另一个单位的不分队报名照旧合法。 + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) + VALUES (1, 1, 2, NULL, 'registered')`); err != nil { + t.Fatalf("另一个单位的团体报名应合法: %v", err) + } + + // 这就是这次改动要放开的事:同一个单位派两个队。 + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) + VALUES (1, 1, 1, NULL, 'registered', '一队')`); err != nil { + t.Fatalf("同一个单位的第二个队应能报名: %v", err) + } + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) + VALUES (1, 1, 1, NULL, 'registered', '二队')`); err != nil { + t.Fatalf("同一个单位的第三个队应能报名: %v", err) + } + // 同一个单位的同一个队仍然只能一条。 + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) + VALUES (1, 1, 1, NULL, 'registered', '一队')`); err == nil { + t.Error("同一个单位同一个组别的同一个队重复应被唯一索引拒绝") + } + + // 带运动员的报名不受影响:同一个人换个单位也是同一个人,仍按 (组别, 单位, 运动员) 唯一。 + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) + VALUES (1, 1, 2, 1, 'registered', '一队')`); err == nil { + t.Error("同一个人在同一组别同一单位再报一次应被表级 UNIQUE 拒绝(squad 不参与个人报名)") + } + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) + VALUES (1, 1, 1, 1, 'registered', '一队')`); err != nil { + t.Fatalf("同一个人在另一个单位报名应合法: %v", err) + } + + // squad 是 NOT NULL DEFAULT '':老代码(或漏写这一列的语句)插入后仍是空串,不会变成 NULL。 + if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('C', '第三分公司')`); err != nil { + t.Fatalf("补一个单位: %v", err) + } + if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) + VALUES (1, 1, 3, NULL, 'registered')`); err != nil { + t.Fatalf("漏写 squad 的插入应照常成功(默认空串): %v", err) + } + if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad IS NULL`); got != 0 { + t.Errorf("squad 不允许为 NULL,得到 %d 条", got) + } + + // 幂等:再跑一次迁移,行数与内容都不变(这条 SQL 里 ADD COLUMN 不可重放,靠记账跳过)。 + before := dumpEntriesSquad(t, db) + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("重复升级应无副作用: %v", err) + } + if got := dumpEntriesSquad(t, db); got != before { + t.Fatalf("重复迁移改动了报名:\n之前 %s\n之后 %s", before, 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) + } +} + +// dumpEntriesSquad 把报名按稳定顺序导成文本(含 upgrade 后新增的队伍)。 +func dumpEntriesSquad(t *testing.T, db *sql.DB) string { + t.Helper() + var b strings.Builder + rows, err := db.Query(`SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(status, ''), squad + FROM entries ORDER BY id`) + if err != nil { + t.Fatalf("读报名: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var ( + id, teamID, athleteID int64 + status, squad string + ) + if err := rows.Scan(&id, &teamID, &athleteID, &status, &squad); err != nil { + t.Fatalf("扫描报名: %v", err) + } + fmt.Fprintf(&b, "%d:%d:%d:%s:%s\n", id, teamID, athleteID, status, squad) + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历报名: %v", err) + } + return b.String() +} diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts index fc133b4..1134441 100644 --- a/web/src/admin/api/scoring.ts +++ b/web/src/admin/api/scoring.ts @@ -9,6 +9,7 @@ import type { ResultStatus, ScoreChannel, ScoringProfile, + TeamRankEntry, TieMode, } from '@/shared' @@ -18,9 +19,29 @@ import type { * 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、 * 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加, * 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。 + * 「按单位录名次」的通道(scope=team + basis=manual)另有一条候选来源, + * 见 collectTeamManualCandidates——它读的是 channel_manual_ranks,不是行上的名次。 * 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。 */ +/** 通道「名次范围」的界面称谓;管理端下拉与预览报错文案共用这一份,免得同一件事两处措辞不同。 */ +export const CHANNEL_SCOPE_LABELS: Record = { + athlete: '个人名次', + team: '团体名次', +} + +/** 通道「名次来源」的界面称谓。 */ +export const CHANNEL_BASIS_LABELS: Record = { + result: '按成绩自动排名', + manual: '手动录名次', + aggregate: '按队内平均', +} + +/** 通道在界面上的全称,如「团体名次 / 手动录名次」。 */ +export function channelLabelOf(scope: ChannelScope, basis: ChannelBasis): string { + return `${CHANNEL_SCOPE_LABELS[scope]} / ${CHANNEL_BASIS_LABELS[basis]}` +} + export interface ScorableRow { entryId: number teamId: number @@ -107,8 +128,11 @@ function groupPoints( categoryMultiplier: number, ): number { if (startRank <= 0 || size <= 0) return 0 - // lastPlaceZero 覆盖「最后一名」;并列在末位的整组都按最后一名处理,保证并列同分。 - if (profile.lastPlaceZero === true && startRank <= lastRank && lastRank <= startRank + size - 1) return 0 + // lastPlaceZero 只抹「末位那一档」,判据是起始名次**正好落在这档**(scoring.go groupPoints)。 + // 这种组必然只有一个人(startRank == lastRank 意味着 size == 1,lastRank 是所有组占位名次的 + // 最大值)。判据若写成「区间跨过末位」,两人并列第 1(占 1、2,末位档 = 2)会被整组归零, + // 而服务端照发第 1 名的分——这就是现场「预览 0、服务端 10」的来历。 + if (profile.lastPlaceZero === true && startRank === lastRank) return 0 const mode = tieModeOf(profile) if (mode === 'average') { @@ -144,6 +168,91 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C return out } +/** 一行的归属份额是否构成联队(与 Go 的 jointTeamEntry 同一判据:至少两个单位)。 */ +function isJointShares(row: ScorableRow): boolean { + return row.shares !== undefined && row.shares.length >= 2 +} + +/** + * 找「主单位 teamID 在本组别的联队报名」(scoring.go jointTeamEntry)。 + * + * 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上, + * 所以录名次时得回头找这条报名,否则 A+B 的名次会整份记在 A 头上、B 一分没有。 + * 只认团体行(athleteId 为 0):个人报名的名次是个人名次,不进团体通道。 + */ +function jointTeamRow(rows: readonly ScorableRow[], teamId: number): ScorableRow | null { + let found: ScorableRow | null = null + for (const row of rows) { + if ((row.athleteId ?? 0) !== 0 || row.teamId !== teamId || !isJointShares(row)) continue + // 同一主单位出现份额不一致的两条时宁可退回独占,也不能随便挑一条把分拆错。 + if (found !== null && !sameShares(found, row)) return null + found = row + } + return found +} + +function sameShares(a: ScorableRow, b: ScorableRow): boolean { + const left = a.shares ?? [] + const right = b.shares ?? [] + if (left.length !== right.length) return false + return left.every((share, index) => share.teamId === right[index]?.teamId && share.weight === right[index]?.weight) +} + +/** + * 「按单位录名次」通道(scope=team + basis=manual)的候选来源(scoring.go collectTeamManualCandidates)。 + * + * 两个来源,且**只看团体行**(athleteId 为 0): + * 1. teamRanks(channel_manual_ranks,录入页底部的「团体名次」卡片):没有对应的报名项, + * 用合成行(entryId 0)承载,分直接记在单位头上; + * 2. 兜底:团体行上录的名次(results.rank),纯团体项目(篮球等)沿用原录入流程。 + * + * 同一单位两个来源都有时以 teamRanks 为准;rank <= 0 视为未填 / 清空,直接忽略。 + * 个人报名行(athleteId != 0)的名次绝不进团体通道——否则双通道项目第 2 次发布时会把 + * 刚写回 results.rank 的个人名次当成团体名次再发一次分。 + * 录入页的「团体名次」卡片此前完全没喂给预览,于是「名次填在卡片、预览全 0」成了现场的 P0。 + */ +function collectTeamManualCandidates( + rows: readonly ScorableRow[], + teamRanks: readonly TeamRankEntry[], +): Candidate[] { + const overrideOf = new Map() + const overrides: number[] = [] + for (const entry of teamRanks) { + if (entry.teamId === 0 || entry.rank <= 0) continue + if (overrideOf.has(entry.teamId)) continue + overrideOf.set(entry.teamId, entry.rank) + overrides.push(entry.teamId) + } + + const out: Candidate[] = [] + const taken = new Set() + for (const row of rows) { + if ((row.athleteId ?? 0) !== 0 || row.status !== 'ok') continue + if (row.rank === null || row.rank <= 0) continue + const rankIn = overrideOf.get(row.teamId) ?? row.rank + taken.add(row.teamId) + out.push({ row, value: 0, rankIn, subject: 0, teamId: row.teamId }) + } + for (const teamId of overrides) { + if (taken.has(teamId)) continue + const rankIn = overrideOf.get(teamId) ?? 0 + const joint = jointTeamRow(rows, teamId) + if (joint !== null) { + out.push({ row: joint, value: 0, rankIn, subject: 0, teamId }) + continue + } + // 合成行:entryId 0 表示「不对应任何一条报名项」,团体名次的主体是单位。 + out.push({ + row: { entryId: 0, teamId, athleteId: 0, resultValue: null, rank: rankIn, status: 'ok', isTie: false }, + value: 0, + rankIn, + subject: 0, + teamId, + }) + } + return out +} + function sortByValue(cands: Candidate[], compare: CompareMode): void { cands.sort((a, b) => { if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId @@ -249,9 +358,11 @@ function scoreChannel( event: Event, category: EventCategory | null, scoreEnabled: boolean, + teamRanks: readonly TeamRankEntry[], ): RankedRow[] { - // compare=none 无法按值排名,退回录入名次;服务端当前会拒绝该组合(见 P1-6)。 - const basis: ChannelBasis = channel.basis === 'manual' || event.compare === 'none' ? 'manual' : channel.basis + // compare=none 的降级(照旧按录入名次)已在这里去掉:服务端对这种配置直接返回 + // ErrCompareRequired(发布 409),调用方先拦下并报错,见 computeEventScore。 + const basis: ChannelBasis = channel.basis const eventMultiplier = effectiveMultiplier(event.multiplier) const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1) @@ -261,7 +372,9 @@ function scoreChannel( sortByValue(aggregated, event.compare) groups = groupByValue(aggregated) } else if (basis === 'manual') { - const cands = collectCandidates(rows, 'manual') + // 「按单位录名次」读 channel_manual_ranks(+ 团体行兜底),个人名次才读行上的 rank。 + const cands = + channel.scope === 'team' ? collectTeamManualCandidates(rows, teamRanks) : collectCandidates(rows, 'manual') sortManual(cands) groups = groupByManualRank(cands) } else { @@ -313,6 +426,22 @@ export interface RowTeamShare { points: number } +/** + * 预览与服务端**都算不出来**的通道配置。 + * + * 目前只有一种:compare=none 的小项配了 basis=result / basis=aggregate 的通道。 + * 服务端 domain.ScoreEvent 对这种配置直接返回 ErrCompareRequired,发布会 409(STATE_INVALID); + * 预览以前把它静默降级成「按录入名次」算出一份服务端根本拿不到的分,操作员照着这个数发布只会失败。 + */ +export interface ScoreConfigError { + /** 出问题的通道 id(通道还没入库时可能是 0)。 */ + channelId: number + /** 通道在界面上的称谓,如「团体名次 / 按成绩自动排名」。 */ + channelLabel: string + /** 人话原因,页面直接显示给操作员。 */ + message: string +} + export interface EventScoreResult { /** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */ rowPoints: Map @@ -329,8 +458,33 @@ export interface EventScoreResult { ranks: Map /** 按单位聚合的得分增量,用于预览面板。 */ teamPoints: Map + /** + * 不挂在任何报名项上的单位分(entryId = 0 的名次:按单位录的团体名次、按队内平均的团体成绩)。 + * + * 「已发布的分从单位总分里扣掉」时要把它一起扣:这些分已经随发布 / 保存团体名次进了榜单, + * 只扣行分会让预览把它们当成新增量报出去(现场那张预览正是发布确认框「最大分差」的来源)。 + */ + unitPoints: Map + /** 配置错误(非 null 时所有分都是空的,页面应显示 message 而不是数字)。 */ + error: ScoreConfigError | null } +/** compare=none 的小项排不出名次:basis=result / aggregate 的通道服务端一律拒绝(scoring.go 的两处 ErrCompareRequired)。 */ +function compareRequiredError(event: Event, channel: ScoreChannel): ScoreConfigError | null { + if (event.compare !== 'none') return null + if (channel.basis !== 'result' && channel.basis !== 'aggregate') return null + const label = channelLabelOf(channel.scope, channel.basis) + return { + channelId: channel.id, + channelLabel: label, + message: + `通道「${label}」要按成绩排名,而小项「${event.name}」是「不比较」(compare=none):` + + '服务端算不出名次、发布会直接拒绝(STATE_INVALID)。' + + '请到「基础数据 → 计分通道」把它改成「手动录名次」,或先给小项设置比较方向(升序 / 降序)。', + } +} + + /** * 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。 * @@ -358,6 +512,12 @@ export interface ScoreContext { profiles: readonly ScoringProfile[] /** standings.individualToTeamWeight,个人分折算到单位的系数。 */ weight: number + /** + * channel_manual_ranks:录入页「团体名次」卡片里按单位录的名次,只有 + * scope=team + basis=manual 的通道读它(服务端 EventScoreInput.TeamRanks 同口径)。 + * 不传 = 该组别还没读到团体名次(预览会按「一行都没有」算,而不是按行上的名次兜底)。 + */ + teamRanks?: readonly TeamRankEntry[] } /** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */ @@ -372,6 +532,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo const scoreEnabled = event.scored && !rankOnly const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels) const weight = context.weight > 0 ? context.weight : 1 + const teamRanks = context.teamRanks ?? [] const rowPoints = new Map() const rowTeamPoints = new Map() @@ -379,6 +540,17 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo const ranks = new Map() const rankScope = new Map() const teamPoints = new Map() + const unitPoints = new Map() + + // 配置本身就出不来分的(compare=none + basis=result/aggregate):与服务端一样停在这里, + // 一分不算、名次也不给,页面按 error 显示原因——绝不再降级算出一份服务端拿不到的分。 + for (const channel of channels) { + const error = compareRequiredError(event, channel) + if (error !== null) { + for (const row of rows) ranks.set(row.entryId, null) + return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error } + } + } const sharesByEntry = new Map() for (const row of rows) { @@ -388,7 +560,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo for (const channel of channels) { const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null // 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。 - const ranked = scoreChannel(rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null) + const ranked = scoreChannel( + rows, + channel, + profile ?? EMPTY_PROFILE, + event, + context.category, + scoreEnabled && profile !== null, + teamRanks, + ) // countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。 const countToTeam = normalizeCountToTeam(channel.countToTeam) for (const row of ranked) { @@ -424,6 +604,9 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo if (!countToTeam) continue for (const part of parts) { teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points) + // 不挂在报名项上的那一份单独留一份账:它已经随发布 / 保存团体名次进过榜单, + // 预览要扣已发布的分时得能把它扣掉(见 EventScoreResult.unitPoints)。 + if (row.entryId <= 0) unitPoints.set(part.teamId, (unitPoints.get(part.teamId) ?? 0) + part.points) } } } @@ -431,5 +614,5 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo for (const row of rows) { if (!ranks.has(row.entryId)) ranks.set(row.entryId, null) } - return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints } + return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error: null } } diff --git a/web/src/admin/components/baseData/ChannelsSection.vue b/web/src/admin/components/baseData/ChannelsSection.vue index eea7a8a..afd5687 100644 --- a/web/src/admin/components/baseData/ChannelsSection.vue +++ b/web/src/admin/components/baseData/ChannelsSection.vue @@ -11,22 +11,25 @@ import MxIcon from '../MxIcon.vue' import ConfirmDialog from '../ConfirmDialog.vue' import { useBaseData } from './useBaseData' import { useToastStore } from '../../stores/toast' +import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../../api/scoring' import type { BaseDataTabKey } from './tabs' -import { newChannelRow, type ChannelRow } from './channelRows' +import { newChannelRow, compareConflictMessage, type ChannelRow } from './channelRows' const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>() const data = useBaseData() const toast = useToastStore() +// 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts),两处措辞漂移会让操作员 +// 对不上「说的是哪条通道」。 const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [ - { value: 'athlete', label: '个人名次' }, - { value: 'team', label: '团体名次' }, + { value: 'athlete', label: CHANNEL_SCOPE_LABELS.athlete }, + { value: 'team', label: CHANNEL_SCOPE_LABELS.team }, ] const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [ - { value: 'result', label: '按成绩自动排名' }, - { value: 'manual', label: '手动录名次' }, - { value: 'aggregate', label: '按队内平均' }, + { value: 'result', label: CHANNEL_BASIS_LABELS.result }, + { value: 'manual', label: CHANNEL_BASIS_LABELS.manual }, + { value: 'aggregate', label: CHANNEL_BASIS_LABELS.aggregate }, ] /** @@ -40,6 +43,7 @@ const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint: interface RowError { scope?: string + basis?: string profile?: string } @@ -128,6 +132,11 @@ function validate(): boolean { if (row.profileId === '' || data.profileById(Number(row.profileId)) === undefined) { rowError.profile = '必须选择一个积分模板' } + // compare=none 的小项排不出名次(名次全靠人工录入):basis=result / aggregate 保存下去, + // 到发布时才会被服务端拒绝(409 STATE_INVALID),那时成绩已经录了一半。规则包导入早有这条 + // 守卫(rules/diff.go checkCompareCompatible),管理端手工配也得在保存前拦下。 + const conflict = compareConflictMessage(selectedEvent.value, row.basis) + if (conflict !== null) rowError.basis = conflict if (Object.keys(rowError).length > 0) next[row.key] = rowError }) errors.value = next @@ -258,12 +267,13 @@ async function save(): Promise { - 团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }} + {{ errors[row.key]?.basis }} {{ errors[row.key]?.scope }} diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index fef2494..c3e07b4 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -13,9 +13,11 @@ import { emptyTeamPick, registeredAthleteIds, registeredTeamIds, + registeredTeamSquads, selectableTeamIds, selectionAllowsSubmit, setPickedTeams, + setTeamPickSquad, teamSelectionAllowsSubmit, toggleMemberPick, toggleTeamPick, @@ -116,6 +118,8 @@ const filteredAthletes = computed(() => { const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id))) const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id)) +/** 各单位已报名的队名:徽标上写出来,操作员才知道第二支队该填哪个名字。 */ +const registeredSquads = computed(() => registeredTeamSquads(entries.value, currentCategory.value?.id)) const registeredAthletes = computed(() => registeredAthleteIds(entries.value, currentCategory.value?.id, currentTeam.value?.id), ) @@ -184,6 +188,23 @@ function memberCount(teamId: number): number { return teamPick.value.memberIds[teamId]?.length ?? 0 } +/** 某个单位输入框里的队名原文(没填过 = 空串 = 不区分队伍)。 */ +function squadTextOf(teamId: number): string { + return teamPick.value.squads?.[teamId] ?? '' +} + +function onSquadInput(teamId: number, event: Event): void { + const input = event.target + if (!(input instanceof HTMLInputElement)) return + teamPick.value = setTeamPickSquad(teamPick.value, teamId, input.value) +} + +/** 「已报名」徽标的文案:报过队名的写出来(`已报名(一队、二队)`),没队名的老报名只写「已报名」。 */ +function registeredSquadLabel(teamId: number): string { + const squads = registeredSquads.value.get(teamId) ?? [] + return squads.length === 0 ? '已报名' : `已报名(${squads.join('、')})` +} + function isExpanded(teamId: number): boolean { return expandedTeams.value.includes(teamId) } @@ -560,6 +581,8 @@ async function submitTeam(): Promise { 团体 / 对抗项目按单位报名:从已配置的参赛单位里勾选参加本组别的单位(默认一个都不勾, 部分单位不参加就不勾它)。每个单位生成一条报名,队员名单可选——展开某个单位即可登记队员, 一个队员都不勾则只报单位。「全选」只勾启用中且未报名的单位,已停用单位要单独勾。 + 同一个单位要派多支队(一队 / 二队):勾上它,在旁边的「队伍」格里填队名, + 每个队名各是一条报名、各占各的名次;不填就与过去完全一样。

@@ -580,8 +603,24 @@ async function submitTeam(): Promise { {{ team.name }}{{ team.active ? '' : '(已停用)' }} {{ team.code }} + +
- 已报名 + + {{ registeredSquadLabel(team.id) }} + 队员 {{ memberCount(team.id) }} 人 @@ -618,9 +657,10 @@ async function submitTeam(): Promise {

- 每个单位生成一条报名(同组别 + 单位只留一条),队员名单按列表顺序写入。 + 每个单位生成一条报名(同组别 + 单位 + 队伍只留一条),队员名单按列表顺序写入。 已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空, - 所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位。 + 所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位, + 队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。

队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。

@@ -714,6 +754,12 @@ async function submitTeam(): Promise { .team-check { min-width: 0; } +/* 队伍输入框:只在勾上单位后出现,固定一档宽度,不挤掉右侧的徽标与「登记队员」按钮。 + 输入框里是普通文本(不是数字),可以直接当字符串用。 */ +.team-squad-input { + flex: none; + width: 150px; +} .team-actions { display: flex; align-items: center; diff --git a/web/src/admin/components/baseData/channelRows.ts b/web/src/admin/components/baseData/channelRows.ts index 4a253b5..9c97e55 100644 --- a/web/src/admin/components/baseData/channelRows.ts +++ b/web/src/admin/components/baseData/channelRows.ts @@ -1,4 +1,4 @@ -import type { AggregateMode, ChannelBasis, ChannelScope, EventType } from '@/shared' +import type { AggregateMode, ChannelBasis, ChannelScope, CompareMode, EventType } from '@/shared' import { DEFAULT_AGGREGATE_MODE } from '@/shared' /** 计分通道编辑器里的一行:新建行或已存库通道的回填(已存库的行见 ChannelsSection.vue 的 toRow)。 */ @@ -51,3 +51,26 @@ export function newChannelRow(key: number, eventType: EventType, profileId: stri countToTeamMedals: true, } } + +/** + * 「compare=none 的小项不得配 basis=result / basis=aggregate 通道」的界面守卫,返回人话原因(合法时 null)。 + * + * 这两种通道都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算,服务端 + * domain.ScoreEvent 会直接返回 ErrCompareRequired:以前这条配置能存下去,一直到发布会 409 + * (STATE_INVALID),那时成绩已经录了一半。规则包导入侧早有同样的守卫 + * (server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧见 + * repo_events.go 的 replaceChannels;这里补的是手工在管理端点「保存全部」的那条路。 + * + * 抽成纯函数放在这里:校验规则要能被测试钉住,页面(ChannelsSection.vue)只负责显示。 + */ +export function compareConflictMessage( + event: { name: string; compare: CompareMode } | null, + basis: ChannelBasis, +): string | null { + if (event === null || event.compare !== 'none') return null + if (basis !== 'result' && basis !== 'aggregate') return null + return ( + `小项「${event.name}」是「不比较」(compare=none),排不出名次:` + + '这条通道要按成绩排名,服务端算不出分、发布会失败。请改成「手动录名次」,或先给小项设置比较方向。' + ) +} diff --git a/web/src/admin/components/baseData/entrySelection.ts b/web/src/admin/components/baseData/entrySelection.ts index bd6e191..fcf0d1e 100644 --- a/web/src/admin/components/baseData/entrySelection.ts +++ b/web/src/admin/components/baseData/entrySelection.ts @@ -3,7 +3,8 @@ * * 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口, * 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert - * 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。 + * (纯团体行是 (组别, 单位, 队伍),见迁移 0025)与行级报错回显都与文件导入完全一致, + * 不必为选择器新增服务端接口。 */ import type { @@ -12,11 +13,22 @@ import type { ImportConflictDecision, ImportConflictField, } from '@/shared' +import { teamSquadLabel } from '@/shared' + +/** + * 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025), + * 这一列就是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。 + * + * 声明在列清单之前:它要出现在清单里(列在最后),而清单是模块加载期求值的。 + */ +export const SQUAD_COLUMN = 'squad' /** * 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。 * * heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。 + * 队伍(squad)同样可选、同样在最后:留空 = 不区分队伍,老数据与单队单位都是它, + * 报名行为与升级前完全一致(服务端那一列也是 optional,缺列不报错)。 * 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名; * 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。 */ @@ -33,13 +45,14 @@ export const REQUIRED_COLUMNS = [ 'heat', 'status', 'member_names', + SQUAD_COLUMN, ] as const /** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */ export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number] /** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */ -export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const +export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat', SQUAD_COLUMN] as const /** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */ const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS @@ -69,6 +82,7 @@ export const COLUMN_ALIASES: Record = { heat: ['批次', '批次号'], status: ['状态', '报名状态'], member_names: ['队员名单'], + squad: ['队伍', '队名', '队伍名'], } /** @@ -177,11 +191,11 @@ export function atSignNameError(kind: string, name: string): string | null { export const TEMPLATE_SHEET_MOCK_HINT = '示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板' -/** 模板里的示例行:两种语言共用,只有表头不同。 */ +/** 模板里的示例行:两种语言共用,只有表头不同。队名列留空(示例行本来就该被填表人删掉)。 */ const TEMPLATE_ROWS: readonly string[][] = [ - ['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', ''], - ['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', ''], - ['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队'], + ['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', '', ''], + ['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', '', ''], + ['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队', ''], ] /** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */ @@ -201,14 +215,24 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number): return mode === 'team' || chosenCount > 0 } -/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */ +/** + * 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 + * + * 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字, + * 少了它,防呆报错与批量确认框上的两行会写成同一个名字。 + */ export function entryRowLabel(row: { athleteName?: string | null memberNames?: string[] | null teamShort?: string | null teamName?: string | null + squad?: string | null }): string { - return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || '' + return ( + row.athleteName || + row.memberNames?.[0] || + teamSquadLabel(row.teamShort || row.teamName || '', row.squad) + ) } export interface SelectionInput { @@ -227,7 +251,8 @@ export interface SelectionInput { * 生成与导入表头一致的二维数组。 * * 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。 - * 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。 + * 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名; + * 队名列留空——个人报名不带队伍(服务端也只对纯团体报名写它)。 */ export function buildSelectionRows(input: SelectionInput): string[][] { const status = input.status ?? 'registered' @@ -248,6 +273,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] { '', status, '', + // 队伍留空:个人报名按人走,没有「这个单位的第几支队」这回事。 + '', ]) } return rows @@ -266,6 +293,11 @@ export interface TeamSelection { teamCode: string /** 队员姓名;空数组表示只报单位,名单之后可以再补。 */ memberNames: string[] + /** + * 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。 + * 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。 + */ + squad?: string } export interface TeamSelectionInput { @@ -277,10 +309,12 @@ export interface TeamSelectionInput { } /** - * 多单位团体报名:每个单位一行,队员姓名进 member_names。 + * 多单位团体报名:每个单位一行,队员姓名进 member_names,队伍进最后一列。 * - * 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert, - * 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。 + * 服务端对 athlete_name 为空的行按 (组别, 单位, 队伍) upsert, + * 所以同组别 + 同单位 + 同队伍仍然只留一条报名(重复提交是更新队员名单而不是新增), + * 而同一个单位换个队名再提交就是**第二条报名** —— 一个单位派两个队正是这样报的。 + * 队伍留空 = 不区分队伍,与服务端那一列(optional)同口径。 */ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] { const status = input.status ?? 'registered' @@ -300,6 +334,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] { '', status, team.memberNames.join('/'), + // 队名两侧空白去掉再写:服务端读表时就 TrimSpace,这里先归一,免得「一队 」与「一队」成了两个队。 + (team.squad ?? '').trim(), ]) } return rows @@ -316,6 +352,13 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] { export interface TeamPickState { teamIds: number[] memberIds: Record + /** + * 每个单位填的队名(输入框原文):没填 / 不在表里 = 空串 = 不区分队伍。 + * + * 可选字段是为了让「不填队名」这条老路径原样保留:老代码构造的状态里根本没有它, + * 产出的报名行也就与改造前一模一样。 + */ + squads?: Record } export function emptyTeamPick(): TeamPickState { @@ -348,11 +391,21 @@ export function toggleMemberPick( return { ...state, memberIds: { ...state.memberIds, [teamId]: next } } } -/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */ +/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单与队名保留。 */ export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState { return { ...state, teamIds: [...teamIds] } } +/** + * 记下某个单位填的队名(输入框原文)。 + * + * 存原文不存处理过的值:界面上「空」与「填了一队」是两件事(前者 = 不区分队伍), + * 归一留给产出报名行的那一步(buildTeamSelections / buildTeamSelectionRows)。 + */ +export function setTeamPickSquad(state: TeamPickState, teamId: number, squad: string): TeamPickState { + return { ...state, squads: { ...state.squads, [teamId]: squad } } +} + /** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */ export function selectableTeamIds( teams: readonly { id: number; active: boolean }[], @@ -366,6 +419,7 @@ export function selectableTeamIds( * * 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人, * 保证同一份勾选每次生成的 member_names 完全一致。 + * 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。 */ export function buildTeamSelections( state: TeamPickState, @@ -380,7 +434,8 @@ export function buildTeamSelections( const memberNames = athletes .filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id)) .map((athlete) => athlete.name) - selections.push({ teamCode: team.code, memberNames }) + const squad = (state.squads?.[team.id] ?? '').trim() + selections.push(squad === '' ? { teamCode: team.code, memberNames } : { teamCode: team.code, memberNames, squad }) } return selections } @@ -403,6 +458,30 @@ export function registeredTeamIds( return ids } +/** + * 组别下各单位**已报名的队名**(去重、按报名表出现顺序)。 + * + * 「已报名」这一个徽标在有了队伍之后不够用了:同一个单位可以再报第二支队, + * 操作员得先看见它已经报过哪些队,才不会把「二队」又写成「一队」(那样是更新原来那条,不是新增)。 + * 空串(不区分队伍的老报名)不进表,那一行的徽标仍只说「已报名」,与升级前一样。 + */ +export function registeredTeamSquads( + entries: readonly { categoryId: number; teamId: number; squad?: string | null }[], + categoryId: number | undefined, +): Map { + const out = new Map() + if (categoryId === undefined) return out + for (const entry of entries) { + if (entry.categoryId !== categoryId) continue + const squad = (entry.squad ?? '').trim() + if (squad === '') continue + const list = out.get(entry.teamId) + if (list === undefined) out.set(entry.teamId, [squad]) + else if (!list.includes(squad)) list.push(squad) + } + return out +} + /** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */ export function registeredAthleteIds( entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[], diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue index 1896f45..450bf7b 100644 --- a/web/src/admin/pages/EntryPage.vue +++ b/web/src/admin/pages/EntryPage.vue @@ -7,9 +7,11 @@ import { hasAggregateChannel, isJointTeam, jointTeamLabel, + normalizeSquad, normalizeTeamShares, parseResultValue, shareText, + teamSquadLabel, type AvgMode, type DraftResultInput, type EntryStatus, @@ -17,6 +19,7 @@ import { type Event, type ResultState, type ResultStatus, + type TeamRankEntry, type TeamRanksView, type TeamShareView, } from '@/shared' @@ -36,6 +39,8 @@ import { import { entryProgress } from './entryProgress' import { hasTeamManualChannel, + mergeJointTeamRankRows, + mergedAwayRankNames, teamRankFlaggedTeams, teamRankRowsOf, teamRankWarnings, @@ -57,6 +62,12 @@ import { submitEntryTeamShares, type ShareDraftRow, } from './entryShares' +import { + entrySquadEditable, + squadDraftDirty, + squadDraftOf, + submitEntrySquad, +} from './entrySquad' import { entryBatchOutcome, entryBatchSizeError, @@ -110,6 +121,11 @@ interface EditableRow { athleteId: number | null teamName: string teamShort: string + /** + * 队名(空串 = 不区分队伍):同一个单位在同一组别可以派多个队,两条报名各占一个名次。 + * 只对团体行有意义(个人项目按人报名,服务端根本不给 squad)。 + */ + squad: string /** * 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。 * @@ -264,7 +280,20 @@ const teamRanksSaving = ref(false) /** 当前组别的团体名次行(表格直接绑它)。 */ const teamRankRows = computed(() => { const categoryId = selectedCategoryId.value - return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? []) + if (categoryId === null) return [] + // 联队(一条报名挂多个单位)在本组报名时,成员单位合并成主单位那一行:名次只录一处, + // 成员单位不再各占一行,也就没有地方给它们顺手录第二份名次(重复计分)。 + // 报名行就在录入页手里(categoryRows 的 teamShares),不为它多开一个接口。 + return mergeJointTeamRankRows(teamRanksByCategory.value.get(categoryId) ?? [], categoryRows.value) +}) +/** + * 被并进联队行、不再单独占行的成员单位原来录过的名次:保存(整组替换)后会被一起清掉, + * 界面上先点名说清——这是操作员库里本来就有的数,不能悄悄改。 + */ +const teamRankMergedAwayNames = computed(() => { + const categoryId = selectedCategoryId.value + if (categoryId === null) return [] + return mergedAwayRankNames(teamRanksByCategory.value.get(categoryId) ?? [], teamRankRows.value) }) const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value)) /** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */ @@ -385,7 +414,14 @@ function scorableOf(rows: EditableRow[]): ScorableRow[] { })) } -function scoreWith(rows: EditableRow[]) { +/** + * 一次算分。 + * + * teamRanks 必须显式给:这就是 channel_manual_ranks(录入页底部「团体名次」卡片里的名次)。 + * 以前预览从不喂它,「按单位录名次」的通道就永远算不出分——服务端算对了(六个单位合计 40 分), + * 预览却每个单位都显示 0,操作员据此以为没分。 + */ +function scoreWith(rows: EditableRow[], teamRanks: readonly TeamRankEntry[]) { const event = selectedEvent.value if (event === null) return null return computeEventScore(scorableOf(rows), { @@ -394,11 +430,27 @@ function scoreWith(rows: EditableRow[]) { channels: channels.value, profiles: data.profiles, weight: weight.value, + teamRanks, }) } +/** 预览用的团体名次:卡片里**当前填的**那一份(含未保存的编辑,与成绩的未保存编辑同口径)。 */ +const previewTeamRanks = computed(() => teamRanksPayload(teamRankRows.value).ranks) + +/** + * 榜单上已经在算的那一份团体名次(服务端存的值)。 + * + * 「已发布的分」基线要用它:卡片里改了名次还没点「保存团体名次」时,榜单并没有跟着变, + * 拿改后的名次去扣已发布的分,扣出来的差值就不是本次发布的真实影响。 + */ +const savedTeamRanks = computed(() => { + const categoryId = selectedCategoryId.value + if (categoryId === null) return [] + return embeddedTeamRanksFor(categoryId)?.ranks ?? [] +}) + /** 全量可见行(含未录行,但没成绩不参与排名)= 本次发布后服务端会算出的口径。 */ -const score = computed(() => scoreWith(visibleRows.value)) +const score = computed(() => scoreWith(visibleRows.value, previewTeamRanks.value)) /** * 只含「已发布」行的一次独立算分:这是该组此刻在榜上的口径。 @@ -406,7 +458,12 @@ const score = computed(() => scoreWith(visibleRows.value)) * 分批比赛时必须单独算:若拿全量口径里已发布行的分去扣,新一批会把老一批的名次挤下去, * 扣掉的却是老一批在新口径下的(可能更低的)分,差值就不等于本次发布对榜单的真实影响。 */ -const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) => row.resultState === 'published'))) +const publishedScore = computed(() => + scoreWith( + visibleRows.value.filter((row) => row.resultState === 'published'), + savedTeamRanks.value, + ), +) /** * 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。 @@ -416,17 +473,25 @@ const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) = */ const publishedContribution = computed(() => { const map = new Map() - const rowTeamShares = publishedScore.value?.rowTeamShares + const published = publishedScore.value for (const row of visibleRows.value) { if (row.resultState !== 'published') continue // 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。 - for (const part of rowTeamShares?.get(row.entryId) ?? []) { + for (const part of published?.rowTeamShares.get(row.entryId) ?? []) { map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points) } } + // 不挂在报名项上的那部分分(按单位录的团体名次、按队内平均的团体成绩)同样已经在榜上, + // 不一起扣掉就会被预览当成本次发布的新增量报出去(发布确认框的「最大分差」跟着虚高)。 + for (const [teamId, points] of published?.unitPoints ?? []) { + map.set(teamId, (map.get(teamId) ?? 0) + points) + } return map }) +/** 通道配置错误(compare=none 配了要按成绩排名的通道):服务端会拒绝发布,预览不报数字。 */ +const scoreConfigError = computed(() => score.value?.error ?? null) + const preview = computed(() => { const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints])) const next = score.value?.teamPoints ?? new Map() @@ -742,6 +807,11 @@ const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnin /** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */ const publishMessage = computed(() => { + // 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409), + // 先在这里把原因说清,别让操作员对着一份算不出来的预览按发布。 + if (scoreConfigError.value !== null) { + return `计分通道配置有问题,发布会失败:${scoreConfigError.value.message}` + } const pending = pendingRows.value.length const missing = unrecordedRows.value.length const head = @@ -766,6 +836,32 @@ function rowLabel(row: EditableRow): string { return entryRowLabel(row) } +/** + * 表格「单位」列的单位名:填了队名就拼在后面(`第一分公司二队`,不加括号——大屏列宽紧张), + * 队名为空时与改造前逐字一样。同一个单位的两个队只差队名,不写出来就分不出哪一行是谁。 + */ +function teamCellText(row: EditableRow): string { + return teamSquadLabel(row.teamShort || row.teamName, row.squad) +} + +/** + * 单位格的悬停提示:点开的弹窗除了份额,对团体行还多一个「队伍」输入框,提示里得说出来, + * 否则操作员根本不知道队名还能改。个人行没有这一项,文案与改造前逐字一样。 + */ +function teamCellTitle(row: EditableRow): string { + const name = teamSquadLabel(row.teamName, row.squad) + return entrySquadEditable(row) + ? `${name}|点击设置成员单位、份额与队伍(同一个单位派多个队时用)` + : `${name}|点击设置成员单位与份额(两个单位合并参赛时用)` +} + +function jointCellTitle(row: EditableRow): string { + const name = teamSquadLabel(jointTeamLabel(row.teamShares), row.squad) + return entrySquadEditable(row) + ? `${name}|点击修改成员单位、份额与队伍` + : `${name}|点击修改成员单位与份额` +} + /** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */ function issueLabel(row: EditableRow, index: number): string { const name = rowLabel(row) @@ -794,6 +890,8 @@ function toRow(entry: EntryWithResult): EditableRow { athleteId: entry.athleteId, teamName, teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '', + // 老服务端不下发 squad 时按空串(= 不区分队伍),与升级前的显示逐字一致。 + squad: normalizeSquad(entry.squad), // 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。 teamShares: normalizeTeamShares(entry.teamShares, { teamId: entry.teamId, @@ -839,6 +937,34 @@ function pointsOf(row: EditableRow): number { return round(score.value?.rowPoints.get(row.entryId) ?? 0) } +/** + * 本组的计分是不是「全按单位录名次、行上没有分」。 + * + * 「积分(自动)」这一列算的是**行**的积分(按成绩 / 个人名次),而按单位录的名次产生的分 + * 挂在单位上、不挂到任何一行报名:这一列在这个项目里永远是 0。现场操作员正是被这个 0 + * 误导,以为「没分」。所以这种情况不显示 0,直接说「见下方团体名次」。 + * + * 两条判据缺一不可: + * 1. 计分通道全是 scope=team + basis=manual; + * 2. 没有任何行是「带名次的团体行」——纯团体项目(篮球等)走 results.rank 兜底, + * 那种行上的分是真的,列照旧显示。 + */ +const pointsColumnUnrelated = computed(() => { + const list = channels.value + if (list.length === 0) return false + if (!list.every((channel) => channel.scope === 'team' && channel.basis === 'manual')) return false + return !visibleRows.value.some((row) => (row.athleteId ?? 0) === 0 && (row.rank ?? 0) > 0) +}) + +/** 表头与单元格共用的口径说明:这一列算的是什么、为什么这里没有它的事。 */ +const POINTS_COLUMN_TITLE = '「积分(自动)」是每一条报名行自己的分:按成绩自动排名或个人名次算出来的。' + +const pointsColumnHint = computed(() => + pointsColumnUnrelated.value + ? `${POINTS_COLUMN_TITLE}本组按单位录名次计分,分记在单位上、不挂到任何一行,所以这一列没有可显示的分——请见下方「团体名次」与右侧「实时积分预览」。` + : POINTS_COLUMN_TITLE, +) + function issueFor(entryId: number): Issue | undefined { return issues.value.find((issue) => issue.entryId === entryId) } @@ -1008,6 +1134,10 @@ async function onSupplementSubmitted(eventId: number, categoryId: number | null) * * `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换 * 发出去(服务端会照着做,只是白重算一遍本组)。 + * + * 团体行还多一个「队伍」输入框(迁移 0025):队名同样是这条报名的归属配置,与份额共用 + * 这一个弹窗与这一个保存按钮。改队名走的是另一个接口(只改 squad,不碰份额),所以 + * 保存时按「哪一块改了发哪一个请求」拆开,没改的那一块一个请求都不发。 */ interface ShareEditor { entryId: number @@ -1016,8 +1146,16 @@ interface ShareEditor { /** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */ primaryTeamId: number primaryName: string + /** 表格「单位」列用的单位名(短名优先),队名预览要拼在它后面,口径与 teamCellText 一致。 */ + teamCellBase: string original: ShareDraftRow[] draft: ShareDraftRow[] + /** 这条报名是不是纯团体报名:只有它才出现「队伍」输入框(个人报名服务端也会 400)。 */ + teamEntry: boolean + /** 队名输入框的原文;空串 = 不区分队伍。 */ + squad: string + /** 打开时的队名:判断改没改,与份额的 original 同理。 */ + originalSquad: string saving: boolean error: string | null } @@ -1034,6 +1172,18 @@ const shareChanged = computed(() => { const editor = shareEditor.value return editor === null ? false : shareDraftDirty(editor.draft, editor.original) }) +/** 队名改了没有:与份额一样按**归一后**的值比,多打一个空格不算改动。 */ +const squadChanged = computed(() => { + const editor = shareEditor.value + return editor === null ? false : squadDraftDirty(editor.squad, editor.originalSquad) +}) +/** 这个弹窗里有没有任何改动(份额或队名):决定保存按钮亮不亮、「关闭」是不是「放弃修改」。 */ +const shareEditorDirty = computed(() => shareChanged.value || squadChanged.value) +/** 队名会拼进单位列(`第一分公司青年队`),改之前先让操作员看见改完长什么样。 */ +const squadPreviewText = computed(() => { + const editor = shareEditor.value + return editor === null ? '' : teamSquadLabel(editor.teamCellBase, editor.squad) +}) const shareView = computed(() => { const editor = shareEditor.value return editor === null ? [] : shareDraftView(editor.draft) @@ -1050,9 +1200,20 @@ const canSaveShares = computed( canWrite.value && shareEditor.value !== null && !shareEditor.value.saving && - shareChanged.value && + shareEditorDirty.value && shareIssues.value.length === 0, ) +/** + * 保存按钮上的字:这一下到底会保存什么。改了什么就说什么,别让只改了队名的人 + * 面对一个「保存成员单位」按钮猜自己那一下会不会把份额也一起重发。 + */ +const shareSaveLabel = computed(() => { + const shares = shareChanged.value + const squad = squadChanged.value + if (shares && squad) return '保存成员单位与队伍' + if (squad) return '保存队伍' + return '保存成员单位' +}) function openShareEditor(row: EditableRow): void { pendingShareTeamId.value = '' @@ -1061,8 +1222,13 @@ function openShareEditor(row: EditableRow): void { label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`, primaryTeamId: row.teamId, primaryName: row.teamName, + teamCellBase: row.teamShort || row.teamName, original: shareDraftOf(row), draft: shareDraftOf(row), + // 个人报名连这个输入框都不渲染:队名对它没有意义,服务端也会 400。 + teamEntry: entrySquadEditable(row), + squad: squadDraftOf(row), + originalSquad: squadDraftOf(row), saving: false, error: null, } @@ -1095,12 +1261,24 @@ function editShareWeight(teamId: number, event: InputEvent): void { editor.draft = setShareDraftWeight(editor.draft, teamId, input.value) } +/** 队名输入框:存**原文**,归一留给「改没改」与提交那一步(输入到一半的队名不该被 trim 掉)。 */ +function editShareSquad(event: InputEvent): void { + const editor = shareEditor.value + if (editor === null) return + const input = event.target + if (!(input instanceof HTMLInputElement)) return + editor.squad = input.value +} + /** - * 保存份额:整组替换一条报名的成员单位。 + * 保存这个弹窗里的改动:份额(整组替换)与队名(只改 squad)是两个接口。 * - * 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉, - * 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。 - * 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。 + * 按「哪一块改了发哪一个」拆开——只改队名的人不该顺便把没动过的份额整组替换一遍, + * 服务端会照着做,只是白重算本组一次;两块都改了才各发一个。 + * + * 就地更新这一行的份额 / 队名并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑 + * 一并覆盖掉,而这两项只影响分记在谁头上、单位列怎么写,不影响任何一行的成绩与名次 + * (那些由本地预览算)。服务端的 400(单位重复 / 权重为负 / 队名重复…)原样展示在弹窗里,不吞。 */ async function saveShareEditor(): Promise { const editor = shareEditor.value @@ -1112,26 +1290,45 @@ async function saveShareEditor(): Promise { } editor.saving = true editor.error = null + const sharesDirty = shareChanged.value + const squadDirty = squadChanged.value const payload = shareDraftPayload(editor.draft) + const row = allRows.value.find((item) => item.entryId === editor.entryId) try { - const outcome = await submitEntryTeamShares(editor.entryId, payload) - const shares = outcome.teamShares ?? payload - const row = allRows.value.find((item) => item.entryId === editor.entryId) - if (row !== undefined) { - const fallbackName = row.teamName - row.teamShares = normalizeTeamShares(shares, { - teamId: row.teamId, - weight: 1, - teamCode: row.teamShares[0]?.teamCode ?? '', - teamName: fallbackName, - }) + if (sharesDirty) { + const outcome = await submitEntryTeamShares(editor.entryId, payload) + const shares = outcome.teamShares ?? payload + if (row !== undefined) { + row.teamShares = normalizeTeamShares(shares, { + teamId: row.teamId, + weight: 1, + teamCode: row.teamShares[0]?.teamCode ?? '', + teamName: row.teamName, + }) + } + // 份额这一份已经落库:把初值跟着推上去,队名那一步万一失败,再点保存不会把份额重发一遍。 + editor.original = editor.draft + } + if (squadDirty) { + const outcome = await submitEntrySquad(editor.entryId, editor.squad) + // 服务端回显优先(它 trim 过);响应里没有这个字段时按手里的草稿,界面照改不误。 + const saved = outcome.squad ?? editor.squad + if (row !== undefined) row.squad = saved + editor.squad = saved + editor.originalSquad = saved } closeShareEditor() void data.refreshStandings() - toast.success( - '成员单位已保存', - isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位', - ) + if (row !== undefined) { + const title = + sharesDirty && squadDirty ? '成员单位与队伍已保存' : sharesDirty ? '成员单位已保存' : '队伍已保存' + // 只改了队名时,提示里把改完的单位列原文念一遍——队名到底拼成了什么,一眼就能核对。 + let detail = `单位列已改成「${teamCellText(row)}」` + if (sharesDirty) { + detail = isJointTeam(row.teamShares) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位' + } + toast.success(title, detail) + } } catch (caught) { editor.error = caught instanceof Error ? caught.message : '保存成员单位失败' } finally { @@ -1560,7 +1757,8 @@ function batchRows(): EntryBatchRow[] { entryId: row.entryId, name: rowLabel(row), bib: row.bib, - team: row.teamShort || row.teamName, + // 单位带上队名:同一个单位的两个队都在这张表里时,确认框上要能分清删的是哪一条。 + team: teamCellText(row), categoryId: row.categoryId, entryStatus: row.entryStatus, })) @@ -2269,7 +2467,14 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i 成绩输入 名次 状态 - 积分(自动) + + 积分(自动) + + 见下方团体名次 + 备注 操作 @@ -2328,19 +2533,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i {{ row.teamShort || row.teamName }} + >{{ teamCellText(row) }} 联队 {{ shareText(member) }} + + 队伍 {{ row.squad }} @@ -2437,8 +2644,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i - - + + — + {{ formatPoints(pointsOf(row)) }} @@ -2501,21 +2709,39 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@@ -2553,11 +2779,29 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i

按单位录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道), 不影响个人名次;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。 + 配了联队的组别,成员单位合并成一行,名次录在主单位上(成员单位不再单独录,避免同一个名次重复计分)。

    -
  • +
  • {{ row.code }} {{ row.shortName || row.name }} + + 联队 + + {{ shareText(member) }} + void refreshSettlement(), { i {{ message }}(只提示,不阻止保存)
+ +

+ {{ teamRankMergedAwayNames.join('、') }} 已并入联队那一行,不再单独录入; + 原来单独录过的名次会在保存后清除(同一个名次不再重复计分)。 +

共 {{ teamRankRows.length }} 个参赛单位
@@ -2740,6 +2992,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i 当前比例:{{ shareRatioText }} {{ jointTeamLabel(shareView) }}

+ +
  • @@ -2750,17 +3023,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i {{ shareEditor.error }}

    - 保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的, - 点「关闭」不会发出任何请求。 + 保存成员单位后本组的名次与积分会当场重算,大屏榜单跟着刷新;队名是自由文本, + 改了当场反映到单位列与大屏。没改动的话保存按钮是灰的,点「关闭」不会发出任何请求。

    @@ -3209,6 +3482,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i font-size: 13px; color: var(--mx-t2); } +/* 队伍:一整行的「标签 + 输入框 + 单位列预览」,与上面的成员单位列表隔开一段。 */ +.share-squad { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 8px; + padding: 8px 10px; + border-radius: var(--mx-r-sm); + background: var(--mx-s3); +} +.share-squad-label { + font-size: 13px; + color: var(--mx-t2); + white-space: nowrap; +} +.squad-input { + flex: 1 1 auto; + min-width: 140px; +} +/* 预览是给眼睛核对的,单位名长的时候宁可省略号,也不要把输入框挤没。 */ +.share-squad-preview { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + font-size: 12px; + color: var(--mx-t3); + text-overflow: ellipsis; + white-space: nowrap; +} +.share-squad-preview strong { + color: var(--mx-t2); +} .share-readonly { display: flex; align-items: center; @@ -3292,6 +3597,19 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i .team-rank-name { min-width: 72px; } +/* 联队行:成员单位与份额并排列出(名字写全、比例看得见),与成绩表里那一格同一读法。 + 行本身就带边框,免得「A+B联队」和旁边那些普通单位行看上去一模一样。 */ +.team-rank-item.is-joint { + border: 1px solid var(--mx-primary-glow); +} +.team-rank-members { + display: inline-flex; + gap: 8px; +} +.team-rank-merged-note { + display: block; + margin-top: 8px; +} .team-rank-empty { font-size: 12px; color: var(--mx-t3); @@ -3411,6 +3729,34 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i color: var(--mx-t3); font-weight: 600; } +/* 「按单位录名次」的项目里行上没有分:不给数字,给一句「见下方团体名次」,免得看着像没分。 */ +.points-cell.unrelated { + color: var(--mx-t3); + font-weight: 600; + font-size: 12px; + white-space: nowrap; +} +/* 表头里的一句副标:「积分(自动)」这一列在本组没有可显示的分。 */ +.th-sub { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--mx-t3); +} +.preview-config-error { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: var(--mx-r-xs); + background: var(--mx-s3); + color: var(--mx-red); + font-size: 12px; + line-height: 1.7; +} +.preview-rank-dirty { + color: var(--mx-amber); +} .preview-card { position: sticky; top: 0; diff --git a/web/src/admin/pages/ReviewPage.vue b/web/src/admin/pages/ReviewPage.vue index 9d548e7..0dbe6cf 100644 --- a/web/src/admin/pages/ReviewPage.vue +++ b/web/src/admin/pages/ReviewPage.vue @@ -5,7 +5,9 @@ import { formatPoints, isJointTeam, jointTeamLabel, + normalizeSquad, normalizeTeamShares, + teamSquadLabel, type EntryWithResult, type Event, type EventCategory, @@ -29,6 +31,8 @@ interface DraftRow { teamId: number teamName: string teamShort: string + /** 队名(空串 = 不区分队伍):同一单位的两个队各占一个名次,复核时要分得清是哪一条。 */ + squad: string /** 归属份额(联队):复核时也要看得出这一条其实是几个单位合并报的(devdocs/19)。 */ teamShares: TeamShareView[] athleteName: string @@ -145,6 +149,14 @@ function teamLabel(teamId: number, fallback: string): string { return team?.shortName ?? team?.name ?? fallback } +/** + * 复核表「单位」列的单位名:填了队名就拼在后面(`第一分公司二队`,不加括号——大屏列宽紧张), + * 队名为空时与改造前逐字一样。 + */ +function teamCellText(row: DraftRow): string { + return teamSquadLabel(row.teamShort || row.teamName, row.squad) +} + /** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */ function isFallback(error: unknown): boolean { return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code) @@ -159,7 +171,10 @@ function unitOf(type: Event['resultType']): string { } function rowLabel(row: DraftRow, index: number): string { - const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}` + // 团体行带上队名:同一单位的两个队只差这几个字,否则两行的报错文案一模一样。 + // teamShort 为空时维持原来的 #id 占位(不拿全称顶上),老数据的文案一个字都不变。 + const team = row.teamShort === '' ? '' : teamSquadLabel(row.teamShort, row.squad) + const name = row.athleteName || row.memberNames?.join('、') || team || `#${row.entryId}` return `第 ${index + 1} 行 ${name}` } @@ -267,6 +282,8 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry teamId: entry.teamId, teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'), teamShort: entry.shortName ?? '', + // 老服务端不下发 squad 时按空串(= 不区分队伍),显示与升级前逐字一致。 + squad: normalizeSquad(entry.squad), teamShares: normalizeTeamShares(entry.teamShares, { teamId: entry.teamId, weight: 1, @@ -632,14 +649,16 @@ watch( 联队 {{ member.teamName }} {{ member.percent }}% + 队伍 {{ row.squad }} - {{ row.teamShort || row.teamName }} + + {{ teamCellText(row) }} {{ row.athleteName || row.memberNames?.join('、') || '—' }} {{ row.resultText || '—' }} diff --git a/web/src/admin/pages/entrySquad.ts b/web/src/admin/pages/entrySquad.ts new file mode 100644 index 0000000..855b7c7 --- /dev/null +++ b/web/src/admin/pages/entrySquad.ts @@ -0,0 +1,160 @@ +import { ApiError } from '@/shared' +import type { ApiErrorCode, Entry } from '@/shared' +import { normalizeSquad } from '@/shared' + +/** + * 「队伍」(squad)改名:`PUT /api/entries/{id}/squad`(见迁移 0025)。 + * + * 队名过去是报名时一次写死的(导入表 / 补报名),报完就再也改不了:同一个单位把「一队」 + * 写成「青年队」只能删了重报,而重报会把已经录进去的成绩一起丢掉。这个写请求把队名变成 + * 一件随时可改的归属配置,与成员单位、份额放在同一个弹窗里——那本来就是「这条报名归谁」的地方。 + * + * 网络层直接 fetch 而不是走 adminApi:与 entryShares.ts / entryBatch.ts 同一套理由(请求构造与收口 + * 都在这一个模块里,能直接单测),CSRF 与 X-Request-Id 的补法也必须与 shared/api.ts 的写请求一致, + * 否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。 + */ + +export function entrySquadPath(entryId: number): string { + return `/api/entries/${entryId}/squad` +} + +/** + * 这条报名的队名能不能改:只对**纯团体报名**(没有运动员的那种)放开。 + * + * 与服务端同口径——迁移 0025 的唯一索引 (组别, 单位, 队名) 只建在 `athlete_id IS NULL` 上: + * 个人项目按人报名,一个人在同一组别只能有一条,队名对它没有意义,服务端也会回 400。 + * 界面上先拦住,别让操作员对着一个注定失败的输入框白点一次保存。 + */ +export function entrySquadEditable(row: { athleteId: number | null }): boolean { + return row.athleteId === null +} + +/** + * 打开弹窗时输入框的初值:这条报名当前的队名。 + * + * 空串(老数据 / 单队单位)= 不区分队伍,也是这里不填时的值——与升级前的行为逐字一致。 + */ +export function squadDraftOf(row: { squad?: string | null }): string { + return normalizeSquad(row.squad) +} + +/** + * 队名有没有改动。 + * + * 比的是**归一后**的值:首尾空白服务端会 trim 掉,用户多打一个空格再删掉不算改动—— + * 算了改动会让保存按钮亮起来,点一下发一个什么都没改的请求出去。 + */ +export function squadDraftDirty(draft: string, original: string): boolean { + return normalizeSquad(draft) !== normalizeSquad(original) +} + +const KNOWN_CODES: readonly string[] = [ + 'AUTH_REQUIRED', + 'AUTH_FAILED', + 'FORBIDDEN', + 'VALIDATION_FAILED', + 'CONFLICT', + 'STATE_INVALID', + 'RATE_LIMITED', + 'INTERNAL', + 'NETWORK', + 'TIMEOUT', + 'PARSE_FAILED', +] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError { + const safe: ApiErrorCode = + typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL' + return new ApiError(safe, message, status, detail) +} + +function readCsrfToken(): string | null { + if (typeof document === 'undefined') return null + const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie) + return match?.[1] === undefined ? null : decodeURIComponent(match[1]) +} + +function defaultRequestId(): string { + const cryptoApi: Crypto | undefined = globalThis.crypto + if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID() + return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +interface EntrySquadResult { + ok: boolean + data?: unknown + error?: unknown +} + +export interface EntrySquadOutcome { + entry: Entry | null + /** + * 服务端回显的队名(已归一,空串 = 不区分队伍——用户清空了队名也是合法值)。 + * + * 响应里没有这个字段(老服务端)时为 null,调用方退回手里那份草稿。 + */ + squad: string | null +} + +/** + * 改名(空串 = 不区分队伍)。写操作不降级到示例数据:示例数据模式下的「保存成功」 + * 会让操作员以为队名已经改好了,页面必须先看 data.isMock 再决定是否调用这里。 + * + * 400 时服务端回的是**给人看的中文**(其中「队名与同组别同单位的另一条报名重复」那条会说清 + * 是哪个单位哪个队),这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。 + */ +export async function submitEntrySquad( + entryId: number, + squad: string, + requestId?: string, +): Promise { + // 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。 + // 两处各生成一个就永远命中不了缓存,超时重试会把同一次改动落两遍。 + const key = requestId ?? defaultRequestId() + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + 'X-Request-Id': key, + } + const token = readCsrfToken() + if (token !== null && token !== '') headers['X-CSRF-Token'] = token + + let response: Response + try { + response = await fetch(entrySquadPath(entryId), { + method: 'PUT', + headers, + credentials: 'include', + // 队名发归一后的值(首尾空白服务端也会 trim,先去掉就不会因为一个空格被当成改动)。 + body: JSON.stringify({ squad: normalizeSquad(squad), requestId: key }), + }) + } catch (caught) { + throw apiError('NETWORK', '无法连接服务端,队名没有改成功', 0, caught) + } + + let body: unknown + try { + body = await response.json() + } catch (caught) { + throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught) + } + if (!isRecord(body) || typeof body['ok'] !== 'boolean') { + throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body) + } + const payload = body as unknown as EntrySquadResult + if (payload.ok !== true) { + const error = isRecord(payload.error) ? payload.error : {} + const message = + typeof error['message'] === 'string' ? error['message'] : `保存队名失败(HTTP ${response.status})` + throw apiError(error['code'], message, response.status, error['detail']) + } + const data = isRecord(payload.data) ? (payload.data as unknown as Entry) : null + return { + entry: data, + squad: data !== null && typeof data.squad === 'string' ? normalizeSquad(data.squad) : null, + } +} diff --git a/web/src/admin/pages/teamRanks.ts b/web/src/admin/pages/teamRanks.ts index 5ca6c3f..05926de 100644 --- a/web/src/admin/pages/teamRanks.ts +++ b/web/src/admin/pages/teamRanks.ts @@ -1,4 +1,5 @@ -import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared' +import { isJointTeam, jointTeamLabel } from '@/shared' +import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView, TeamShareView } from '@/shared' /** * 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。 @@ -7,6 +8,9 @@ import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared * `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。 * 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」, * 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。 + * + * 联队(一条报名挂多个单位,devdocs/19)也在这里收口:该组别有联队报名时,成员单位 + * 合并成主单位那一行(见 mergeJointTeamRankRows),否则每个单位各占一行。 */ export interface TeamRankRow { @@ -23,6 +27,13 @@ export interface TeamRankRow { input: string | number /** 载入时的原文:用来判断这一行有没有被改过。 */ original: string + /** + * 联队行的成员单位(顺序与报名份额一致);普通单位行是空数组。 + * + * 非空即表示「这一行代表多个单位」,名次写到 `teamId`(联队主单位)上,成员单位不再 + * 单独占一行。空数组的普通行渲染与今天一字不差,页面据此分支。 + */ + members: TeamShareView[] } /** @@ -79,7 +90,8 @@ export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[ .map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName })) return source.map((team) => { const input = inputOfRank(ranks.get(team.teamId)) - return { ...team, input, original: input } + // 这里先按「一个单位一行」铺开,联队合并在 mergeJointTeamRankRows 里做(它还要看报名)。 + return { ...team, input, original: input, members: [] } }) } @@ -89,11 +101,124 @@ export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean { return rows.some((row) => String(row.input) !== row.original) } +// ── 联队(devdocs/19):一条报名挂多个单位时,把成员单位合并成一行 ───────────────── + +/** + * 联队判定要读的报名字段。 + * + * 录入页表格里的行(EditableRow)结构上就有这两个,所以页面直接把该组别的报名行 + * 传进来即可——份额早就在表格数据里,不必为它多开一个接口。 + */ +export interface TeamRankEntryRef { + /** 报名的主单位(entries.team_id):联队的名次录在这一行。 */ + teamId: number + /** 归属份额(读侧恒为至少一条):多于一条就是「一条报名挂多个单位」的联队报名。 */ + teamShares: readonly TeamShareView[] +} + +/** 联队行的编码:成员编码用 + 连起来(与导入 / 导出写 `A+B` 同一写法)。 */ +function jointTeamCode(members: readonly TeamShareView[]): string { + return members + .map((member) => member.teamCode.trim()) + .filter((code) => code !== '') + .join('+') +} + +/** 把主单位那一行改写成联队行:名字走 jointTeamLabel(与大屏 / 导出同一份实现)。 */ +function jointRankRow(row: TeamRankRow, members: readonly TeamShareView[]): TeamRankRow { + const code = jointTeamCode(members) + const line: TeamRankRow = { + ...row, + code: code === '' ? row.code : code, + // 联队名已经把成员单位写全,简称反而是少一家,所以这一行的名字一律走全名那一份。 + name: jointTeamLabel(members), + shortName: null, + members: [...members], + } + // input 必须**写回主单位原来那一行**,不能在副本上停着:v-model 直接绑它,未保存判断 + // (teamRanksDirty)与保存载荷(teamRanksPayload)读的也都是原行。副本上的输入只活在 + // 这一个对象里,行一重建(切组别、改份额、保存后回填)就没了,保存按钮也不会亮—— + // 现场看起来就是「填了名次却存不下去」。 + Object.defineProperty(line, 'input', { + get: () => row.input, + set: (value: string | number) => { + row.input = value + }, + enumerable: true, + configurable: true, + }) + return line +} + +/** + * 把「联队报名」的成员单位合并成一行。 + * + * 引擎的口径是**联队的名次录在主单位那一行**(`domain.collectTeamManualCandidates` 拿主单位 + * 那条名次按份额拆给各成员单位)。界面上把成员单位各列一行不只是显示问题:操作员顺手给 + * 成员单位也录一个名次,那个单位会再拿一份分(重复计分)。合并成一行正好把这个坑堵死—— + * 成员单位不再有属于自己的输入框,也就没有地方录第二份名次。 + * + * 只在**该组别真有联队报名**时才动列表:没有联队时按原数组返回(连对象都不换一个), + * 于是没有联队的组别与今天逐字段一致。 + * + * 成员单位自己的行在两种情况下照旧保留,免得静默吃掉一条报名: + * 1. 它以主单位身份另有报名——自己也是这个组别里的一条参赛单位,独立于联队那一条 + * (它同时还是另一个联队的主单位时也走这一条,那一行就是那个联队的名次); + * 2. 主单位那一行根本不在列表里(单位停用 / 假删除会被接口过滤掉):这时宁可不合并, + * 也不让成员单位凭空消失,否则这个组别的团体名次就没地方录了。 + */ +export function mergeJointTeamRankRows( + rows: readonly TeamRankRow[], + entries: readonly TeamRankEntryRef[], +): TeamRankRow[] { + const groups = new Map() + for (const entry of entries) { + if (!isJointTeam(entry.teamShares)) continue + // 同一主单位出现两条联队报名时只认第一条:引擎遇到份额不一致的两条会退回独占, + // 界面这边少合并一个顶多是不显示联队名,不会把分拆错。 + if (groups.has(entry.teamId)) continue + groups.set(entry.teamId, [...entry.teamShares]) + } + if (groups.size === 0) return [...rows] + + const byTeamId = new Set(rows.map((row) => row.teamId)) + const ownEntry = new Set(entries.map((entry) => entry.teamId)) + const swallowed = new Set() + for (const [primary, members] of groups) { + if (!byTeamId.has(primary)) continue + for (const member of members) { + if (member.teamId === primary || ownEntry.has(member.teamId)) continue + swallowed.add(member.teamId) + } + } + return rows + .filter((row) => !swallowed.has(row.teamId)) + .map((row) => { + const members = groups.get(row.teamId) + return members === undefined ? row : jointRankRow(row, members) + }) +} + +/** + * 被并进联队行、不再单独占行的成员单位里**原来录过名次**的那些名字。 + * + * 它们的名次会在下一次保存(整组替换)时随载荷一起被清掉——这正是要堵的重复计分, + * 但那是操作员的库里本来就有的数,界面上得说出来,不能悄悄改。 + */ +export function mergedAwayRankNames(base: readonly TeamRankRow[], shown: readonly TeamRankRow[]): string[] { + const kept = new Set(shown.map((row) => row.teamId)) + return base + .filter((row) => !kept.has(row.teamId) && parseTeamRankInput(row.input) >= 1) + .map((row) => row.shortName || row.name) +} + /** * 保存载荷:整组回传。 * - * 服务端按 (小项, 组别, scope, basis) 整组替换,所以每个单位都要出现, - * 没填的显式给 0(清空)——漏掉单位等于把它原来的名次留在库里,页面看着却像已经清掉了。 + * 服务端按 (小项, 组别, scope, basis) 整组替换,所以**页面上看到的每一行**都要出现, + * 没填的显式给 0(清空)——漏掉一行等于把它原来的名次留在库里,页面看着却像已经清掉了。 + * 行由 mergeJointTeamRankRows 给:联队只出主单位这一行,被并掉的成员单位**故意**不在载荷里, + * 它们原来那份名次会随之清掉(那正是要堵的重复计分)。 */ export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput { return { diff --git a/web/src/admin/tests/avg-mode.test.ts b/web/src/admin/tests/avg-mode.test.ts index 8def9f3..90260d1 100644 --- a/web/src/admin/tests/avg-mode.test.ts +++ b/web/src/admin/tests/avg-mode.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode' +import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../api/scoring' // 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。 const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') @@ -69,7 +70,11 @@ test('录入页只在存在 aggregate 通道时显示口径选择,并整体回 }) test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => { - assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/) + // 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts 的 CHANNEL_*_LABELS), + // 所以这里同时钉住两件事:页面用的是这份常量,常量就是现场认识的那句话。 + assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: CHANNEL_BASIS_LABELS\.aggregate \}/) + assert.equal(CHANNEL_BASIS_LABELS.aggregate, '按队内平均') + assert.equal(CHANNEL_SCOPE_LABELS.team, '团体名次') assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/) assert.match(CHANNELS_SOURCE, /计入单位总分/) assert.match(CHANNELS_SOURCE, /只记在个人头上/) diff --git a/web/src/admin/tests/channels-compare-guard.test.ts b/web/src/admin/tests/channels-compare-guard.test.ts new file mode 100644 index 0000000..b1a7a8a --- /dev/null +++ b/web/src/admin/tests/channels-compare-guard.test.ts @@ -0,0 +1,38 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { compareConflictMessage } from '../components/baseData/channelRows' + +/** + * 管理端保存计分通道前的守卫:compare=none 的小项不得配 basis=result / aggregate。 + * + * 规则包导入侧早有这条(server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧 + * 见 server/internal/httpapi/repo_events.go——手工在管理端配一直没人拦,一直配到发布才 409。 + */ + +const MANUAL_EVENT = { name: '台球', compare: 'none' as const } + +test('compare=none 配 basis=result / aggregate:给出人话原因并点名小项', () => { + for (const basis of ['result', 'aggregate'] as const) { + const message = compareConflictMessage(MANUAL_EVENT, basis) + assert.ok(message, `${basis} 必须被拦下`) + assert.match(message, /台球/) + assert.match(message, /不比较/) + assert.match(message, /手动录名次/) + } +}) + +test('compare=none 配 basis=manual:放行', () => { + assert.equal(compareConflictMessage(MANUAL_EVENT, 'manual'), null) +}) + +test('有比较方向的小项:三种 basis 一律放行', () => { + for (const compare of ['asc', 'desc'] as const) { + for (const basis of ['result', 'manual', 'aggregate'] as const) { + assert.equal(compareConflictMessage({ name: '100 米', compare }, basis), null) + } + } +}) + +test('还没选小项时不拦(页面显示的是空态)', () => { + assert.equal(compareConflictMessage(null, 'result'), null) +}) diff --git a/web/src/admin/tests/entry-selection.test.ts b/web/src/admin/tests/entry-selection.test.ts index db6eefb..b1450f8 100644 --- a/web/src/admin/tests/entry-selection.test.ts +++ b/web/src/admin/tests/entry-selection.test.ts @@ -6,6 +6,7 @@ import { MANDATORY_COLUMNS, OPTIONAL_COLUMNS, REQUIRED_COLUMNS, + SQUAD_COLUMN, buildSelectionRows, buildTeamSelectionRows, buildTemplateCsv, @@ -17,9 +18,11 @@ import { missingColumnMessage, registeredAthleteIds, registeredTeamIds, + registeredTeamSquads, selectableTeamIds, selectionAllowsSubmit, setPickedTeams, + setTeamPickSquad, teamSelectionAllowsSubmit, templateFileName, templateHeader, @@ -37,24 +40,26 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => { assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c') }) -test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => { +test('表头含证件号 / 性别 / 年龄 / 批次 / 队伍可选列,必填列不含这五列', () => { assert.deepEqual( [...REQUIRED_COLUMNS], [ 'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', - 'bib', 'seed', 'heat', 'status', 'member_names', + 'bib', 'seed', 'heat', 'status', 'member_names', 'squad', ], ) assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后') assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后') - assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat']) + assert.equal(REQUIRED_COLUMNS.at(-1), SQUAD_COLUMN, '队伍列在最后:老表格一列都不会错位') + assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat', 'squad']) assert.equal(MANDATORY_COLUMNS.includes('id_card'), false) assert.equal(MANDATORY_COLUMNS.includes('age'), false) assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批') + assert.equal(MANDATORY_COLUMNS.includes('squad'), false, '队伍列可缺省,缺省即不区分队伍') assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true) }) -test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => { +test('buildSelectionRows 个人项目:一人一行,共 13 列并带上证件号 / 号码布', () => { const rows = buildSelectionRows({ eventCode: 'M100', categoryName: '男子组', @@ -68,13 +73,14 @@ test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件 }) assert.equal(rows.length, 4, '表头 + 3 名运动员') assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头') - assert.equal(rows[1]?.length, 12) - // 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。 - assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '']) + assert.equal(rows[1]?.length, 13) + // 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排); + // 队伍列留空 = 个人报名没有「第几支队」这回事。 + assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '', '']) // 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。 - assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '']) + assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '', '']) // 只有证件号时 bib 列留空。 - assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '']) + assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '', '']) }) test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => { @@ -86,8 +92,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺 athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }], }) assert.equal(rows.length, 2, '表头 + 1 条团体报名') - assert.equal(rows[1]?.length, 12) - assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨']) + assert.equal(rows[1]?.length, 13) + assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨', '']) }) test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => { @@ -100,7 +106,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', athletes: [{ name: '王芳' }], memberNames: ['刘洋', '王芳'], }) - assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳']) + assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳', '']) }) test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => { @@ -111,7 +117,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列 mode: 'individual', athletes: [{ name: '无标识' }], }) - assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '']) + assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '', '']) }) // 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。 @@ -124,7 +130,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', () athletes: [], }) assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名') - assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '']) + assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', '']) }) test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => { @@ -147,13 +153,14 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接, }) assert.equal(rows.length, 4, '表头 + 3 个单位') assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头') - assert.equal(rows[1]?.length, 12) + assert.equal(rows[1]?.length, 13) for (const row of rows.slice(1)) { assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert') + assert.equal(row[12], '', '不填队名时队伍列留空 = 不区分队伍') } - assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋']) - assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '']) - assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟']) + assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', '']) + assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', '']) + assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟', '']) }) test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => { @@ -165,7 +172,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可 status: 'scratched', teams: [{ teamCode: 'F1', memberNames: [] }], }) - assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '']) + assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '', '']) }) test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => { @@ -283,8 +290,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行', const lines = toCsv(rows).split('\n') assert.equal(lines[0], REQUIRED_COLUMNS.join(',')) assert.equal(lines.length, 3, '表头 + 两个单位') - assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义') - assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空') + assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强",', '带逗号的姓名按 RFC 4180 转义') + assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,', '只报单位时 member_names 留空') }) test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => { @@ -303,6 +310,135 @@ test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只 assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], []) }) +// ── 队伍(迁移 0025):同一个单位在一个组别可以派多个队 ──────────────────────── + +test('不填队名时报名行为与改造前完全一致:队伍列留空、其余列一个字都没变', () => { + const rows = buildTeamSelectionRows({ + eventCode: 'BB', + categoryName: '混合组', + teams: [ + { teamCode: 'F1', memberNames: ['王芳'], squad: '' }, + { teamCode: 'F2', memberNames: [] }, + ], + }) + assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '表头就是导入表本身(队伍列在最后、可空)') + assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length) + assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳', '']) + assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', '']) + assert.equal(SQUAD_COLUMN, 'squad', '列名是服务端 importColumns 里的规范名') + assert.equal(lookupImportColumn('队伍'), SQUAD_COLUMN, '中文表头「队伍」也认') + assert.equal(lookupImportColumn('队名'), SQUAD_COLUMN) +}) + +test('填了队名:队名进最后一列,其它列一个字不动', () => { + const rows = buildTeamSelectionRows({ + eventCode: 'BB', + categoryName: '混合组', + teams: [ + { teamCode: 'F1', memberNames: ['王芳', '刘洋'], squad: '一队' }, + // 同一个单位换个队名 = 这个单位的第二支队(服务端按 组别 + 单位 + 队伍 upsert)。 + { teamCode: 'F1', memberNames: [], squad: '二队' }, + // 没填队名的单位照样一行,队名格留空。 + { teamCode: 'F2', memberNames: ['张伟'] }, + ], + }) + assert.deepEqual(rows[0], [...REQUIRED_COLUMNS]) + assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', '一队']) + assert.deepEqual(rows[2], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '', '二队']) + assert.deepEqual(rows[3], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '张伟', '']) + for (const line of toCsv(rows).split('\n')) { + assert.equal(line.split(',').length, REQUIRED_COLUMNS.length) + } +}) + +test('队名两侧空白去掉再上表:服务端存的就是去空白后的文本', () => { + const rows = buildTeamSelectionRows({ + eventCode: 'BB', + categoryName: '混合组', + teams: [{ teamCode: 'F1', memberNames: [], squad: ' 二队 ' }], + }) + assert.equal(rows[1]?.[REQUIRED_COLUMNS.indexOf(SQUAD_COLUMN)], '二队') +}) + +test('setTeamPickSquad 只改这一个单位的队名,逐个字输入都保留', () => { + let state = toggleTeamPick(emptyTeamPick(), 1, true) + state = toggleTeamPick(state, 2, true) + state = setTeamPickSquad(state, 1, '一') + state = setTeamPickSquad(state, 1, '一队') + assert.equal(state.squads?.[1], '一队') + assert.equal(state.squads?.[2], undefined, '另一个单位没填') + assert.deepEqual(state.teamIds, [1, 2], '填队名不动勾选') + // 清空输入框 = 改回不区分队伍,与从没填过一样。 + assert.equal(setTeamPickSquad(state, 1, '').squads?.[1], '') +}) + +test('buildTeamSelections:填了队名的单位带上 squad,没填的与改造前逐字段相同', () => { + const teams = [ + { id: 1, code: 'F1' }, + { id: 2, code: 'F2' }, + ] + const athletes = [{ id: 11, teamId: 1, name: '张伟', active: true }] + let state = toggleTeamPick(emptyTeamPick(), 1, true) + state = toggleTeamPick(state, 2, true) + state = toggleMemberPick(state, 1, 11, true) + assert.deepEqual(buildTeamSelections(state, teams, athletes), [ + { teamCode: 'F1', memberNames: ['张伟'] }, + { teamCode: 'F2', memberNames: [] }, + ]) + + const withSquad = setTeamPickSquad(state, 1, ' 二队 ') + assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [ + { teamCode: 'F1', memberNames: ['张伟'], squad: '二队' }, + { teamCode: 'F2', memberNames: [] }, + ]) +}) + +test('勾选状态 → CSV 端到端:一个单位两个队就是两行,队名在最后一列', () => { + const teams = [ + { id: 1, code: 'F1' }, + { id: 2, code: 'F2' }, + ] + let state = toggleTeamPick(emptyTeamPick(), 1, true) + state = toggleTeamPick(state, 2, true) + state = setTeamPickSquad(state, 1, '一队') + state = setTeamPickSquad(state, 2, '青年队') + + const rows = buildTeamSelectionRows({ + eventCode: 'BB', + categoryName: '混合组', + teams: buildTeamSelections(state, teams, []), + }) + const lines = toCsv(rows).split('\n') + assert.equal(lines[0], REQUIRED_COLUMNS.join(',')) + assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,,一队') + assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,青年队') +}) + +test('registeredTeamSquads:按组别列出各单位已报名的队名,老报名(空串)不进表', () => { + const entries = [ + { categoryId: 7, teamId: 1, squad: '一队' }, + { categoryId: 7, teamId: 1, squad: '二队' }, + { categoryId: 7, teamId: 1, squad: '一队' }, + { categoryId: 7, teamId: 2, squad: '' }, + { categoryId: 7, teamId: 3 }, + { categoryId: 8, teamId: 4, squad: '一队' }, + ] + const squads = registeredTeamSquads(entries, 7) + assert.deepEqual(squads.get(1), ['一队', '二队'], '按报名表出现顺序、去重') + assert.equal(squads.has(2), false, '不区分队伍的老报名不进表,徽标仍只说「已报名」') + assert.equal(squads.has(3), false, '缺字段(老服务端)同样当空串') + assert.equal(squads.has(4), false, '别的组别不算') + assert.deepEqual([...registeredTeamSquads(entries, undefined)], []) +}) + +test('entryRowLabel 团体行带上队名:同一单位的两个队在报错文案里也分得开', () => { + assert.equal(entryRowLabel({ teamShort: '一司', squad: '二队' }), '一司二队') + assert.equal(entryRowLabel({ teamShort: '一司', teamName: '第一分公司', squad: '' }), '一司', '没队名时不变') + assert.equal(entryRowLabel({ teamName: '第一分公司', squad: '青年队' }), '第一分公司青年队') + assert.equal(entryRowLabel({ athleteName: '张伟', teamShort: '一司', squad: '二队' }), '张伟', '个人行只写姓名') + assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳') +}) + // 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名, // 否则防呆校验的文案会变成「:缺少成绩」。 test('entryRowLabel 团体行回落到队员首人 / 单位', () => { @@ -321,8 +457,9 @@ test('两种语言的模板表头逐列对应:列数、顺序一致,只有 assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名') assert.equal(zh.length, en.length, '两种模板列数必须一样') assert.deepEqual(zh, [ - '项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', + '项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍', ]) + assert.equal(zh.at(-1), '队伍', '队伍列也列在模板最后,与服务端 importColumns 同序') // 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。 assert.deepEqual( zh, @@ -340,7 +477,7 @@ test('模板 CSV:只有表头语言不同,示例行与列数完全相同', ( assert.equal(en[0], templateHeader('en').join(',')) assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言') for (const line of zh) { - assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 12 列') + assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 13 列') } }) @@ -357,6 +494,7 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM / assert.equal(lookupImportColumn('项目编码'), 'event_code') assert.equal(lookupImportColumn('小项编码'), 'event_code') assert.equal(lookupImportColumn('队员名单'), 'member_names') + assert.equal(lookupImportColumn('队伍'), 'squad', '队伍列的中文表头') assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感') assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格') assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上') @@ -368,13 +506,21 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM / test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => { const index = importColumnIndex([ - 'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', + 'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍', ]) assert.equal(index.get('event_code'), 0) assert.equal(index.get('category'), 1) assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取') + assert.equal(index.get('squad'), 12, '队伍列在最后') assert.equal(index.size, REQUIRED_COLUMNS.length) + // 老表格(12 列、没有队伍列)照样认得:队伍是可选列,缺列 = 不区分队伍(老流程一个字不用改)。 + const legacy = importColumnIndex([ + 'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'heat', 'status', 'member_names', + ]) + assert.equal(legacy.size, REQUIRED_COLUMNS.length - 1) + assert.equal(legacy.has('squad'), false) + for (const header of [ ['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'], ['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'], diff --git a/web/src/admin/tests/entry-squad.test.ts b/web/src/admin/tests/entry-squad.test.ts new file mode 100644 index 0000000..70a8053 --- /dev/null +++ b/web/src/admin/tests/entry-squad.test.ts @@ -0,0 +1,257 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { ApiError, teamSquadLabel } from '@/shared' +import { + entrySquadEditable, + entrySquadPath, + squadDraftDirty, + squadDraftOf, + submitEntrySquad, +} from '../pages/entrySquad' + +/** + * 管理端的队伍展示、录入与改名(迁移 0025)。 + * + * SFC 挂不进 `node --test`,所以这里用源码契约钉住接线;口径本身(拼名字、空串不变)在 + * shared/tests/squad.test.ts 与大屏 joint-teams.test.ts 里逐条测。要守住的是四件事: + * 1. 录入页 / 复核页的「单位」列显示 **单位名 + 队名**(不加括号); + * 2. 队名为空时那几个表达式与改造前逐字相同(老数据与单队单位一个像素都不变); + * 3. 队名是在补报名(团体勾选)里填的,**勾上单位之后**才出现那个输入框, + * 不勾(常规流程)时界面上根本看不到它; + * 4. 已经报好的队名在「成员单位与份额」弹窗里能改(PUT /api/entries/{id}/squad), + * 但只对纯团体报名出现,改哪个才发哪个请求,服务端的中文 400 原样上屏。 + */ + +const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') +const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8') +const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8') + +test('录入页:单位列显示「单位名 + 队名」,队名来自报名行', () => { + assert.match( + ENTRY, + /function teamCellText\(row: EditableRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/, + '拼名字只有 shared/squad.ts 一份实现', + ) + assert.match(ENTRY, /\{\{ teamCellText\(row\) \}\}/, '单位格用带队名的文案') + assert.doesNotMatch(ENTRY, /\{\{ row\.teamShort \|\| row\.teamName \}\}/, '老写法不能再留在单位格上') + assert.match(ENTRY, /squad: normalizeSquad\(entry\.squad\)/, '老服务端不下发 squad 时按空串') +}) + +test('录入页:批量确认框里的单位也带上队名(同一个单位的两个队要分得清删的是哪条)', () => { + assert.match(ENTRY, /team: teamCellText\(row\),/) +}) + +test('录入页:联队那一格仍按份额逐行列,队名单独一行写出来', () => { + assert.match(ENTRY, /teamSquadLabel\(jointTeamLabel\(row\.teamShares\), row\.squad\)/, '联队的悬停文案带队名') + assert.match(ENTRY, /v-if="row\.squad !== ''"[\s\S]{0,80}队伍 \{\{ row\.squad \}\}/) +}) + +test('复核页:单位列同样显示「单位名 + 队名」', () => { + assert.match( + REVIEW, + /function teamCellText\(row: DraftRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/, + ) + assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/) + assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/) + assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/) + assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名') +}) + +test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => { + assert.match(PICKER, /v-if="isTeamPicked\(team\.id\)"/, '输入框跟着勾选出现') + assert.match(PICKER, /:value="squadTextOf\(team\.id\)"/, '回显输入框原文') + assert.match(PICKER, /@input="onSquadInput\(team\.id, \$event\)"/) + assert.match(PICKER, /teamPick\.value = setTeamPickSquad\(teamPick\.value, teamId, input\.value\)/) + assert.match(PICKER, /placeholder="队伍(留空=不区分)"/, '留空 = 不区分队伍,要写出来') + // 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。 + assert.doesNotMatch(PICKER, /team-squad-input[\s\S]{0,200}type="number"/) +}) + +test('补报名:已报名徽标写清已报过哪些队名,避免把二队又写成一队', () => { + assert.match(PICKER, /registeredTeamSquads\(entries\.value, currentCategory\.value\?\.id\)/) + assert.match(PICKER, /registeredSquadLabel\(team\.id\)/) +}) + +// ── 改队名:PUT /api/entries/{id}/squad(入口在「成员单位与份额」弹窗里) ────────────── + +interface Captured { + url?: string + method?: string + headers?: Record + body?: unknown + credentials?: string +} + +let lastRequest: Captured = {} +let reply: () => Response = () => jsonResponse({ ok: true, data: {} }) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + lastRequest = { + url: String(input), + method: init?.method ?? 'GET', + headers: (init?.headers ?? {}) as Record, + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + credentials: init?.credentials, + } + return reply() +}) as typeof fetch + +function respondWith(response: () => Response): void { + lastRequest = {} + reply = response +} + +function withCsrfCookie(cookie: string, run: () => Promise): Promise { + const original = (globalThis as { document?: unknown }).document + ;(globalThis as { document?: unknown }).document = { cookie } + return run().finally(() => { + if (original === undefined) delete (globalThis as { document?: unknown }).document + else (globalThis as { document?: unknown }).document = original + }) +} + +test('改队名只对纯团体报名开放:个人报名(有运动员)连输入框都不出现', () => { + assert.equal(entrySquadEditable({ athleteId: null }), true, '纯团体行才能改队名') + assert.equal(entrySquadEditable({ athleteId: 42 }), false, '个人报名按人报,服务端也会 400') + // 弹窗里的那一块跟着这个判断走:个人报名整块不渲染,不会让人白点一次保存。 + assert.match(ENTRY, /v-if="shareEditor\.teamEntry" class="share-squad"/) + assert.match(ENTRY, /placeholder="队伍(留空=不区分)"/) + assert.match(ENTRY, /teamEntry: entrySquadEditable\(row\),/) + assert.match(ENTRY, /squad: squadDraftOf\(row\),/) + assert.match(ENTRY, /originalSquad: squadDraftOf\(row\),/) + // 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。 + assert.doesNotMatch(ENTRY, /squad-input[\s\S]{0,300}type="number"/) +}) + +test('队名留空 = 不区分队伍:老数据读出来是空串,写回去也是空串(不是不发这个字段)', async () => { + assert.equal(squadDraftOf({ squad: undefined }), '', '老服务端不下发 squad') + assert.equal(squadDraftOf({ squad: null }), '') + assert.equal(squadDraftOf({ squad: ' ' }), '', '只有空白等于没填') + assert.equal(squadDraftOf({ squad: ' 青年队 ' }), '青年队') + // 留空时单位列的文案与改造前逐字一样(队名一个字符都不多)。 + assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司') + + respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '' } })) + const outcome = await submitEntrySquad(7, '') + const body = lastRequest.body as { squad?: unknown } + assert.equal(body.squad, '', '空串是合法值:字段要在,不能被省略成 undefined') + assert.equal(outcome.squad, '', '服务端回显空串时本地也按不区分队伍写回') +}) + +test('队名没改动时不发请求:比的是归一后的值,多一个空格不算改', () => { + assert.equal(squadDraftDirty('', ''), false, '两边都留空 = 没动过') + assert.equal(squadDraftDirty('一队', '一队'), false) + assert.equal(squadDraftDirty(' 一队 ', '一队'), false, '首尾空白服务端也会 trim,不该算成一次改动') + assert.equal(squadDraftDirty(' ', ''), false) + assert.equal(squadDraftDirty('一队', ''), true) + assert.equal(squadDraftDirty('', '一队'), true, '清空队名也是一次改动(改回不区分队伍)') + assert.equal(squadDraftDirty('二队', '一队'), true) + + // 保存按钮只在「份额或队名任一改了」时才亮(没改动就点不动,与既有做法一致)。 + assert.match(ENTRY, /const shareEditorDirty = computed\(\(\) => shareChanged\.value \|\| squadChanged\.value\)/) + assert.match(ENTRY, /if \(editor === null \|\| !canSaveShares\.value\) return/) + assert.match(ENTRY, /shareEditorDirty\.value &&\s*shareIssues\.value\.length === 0/) + // 改哪一块发哪一个:只改队名时不会顺手把没动过的份额整组重发一遍。 + assert.match(ENTRY, /const sharesDirty = shareChanged\.value\s*const squadDirty = squadChanged\.value/) + assert.match(ENTRY, /if \(sharesDirty\) \{[\s\S]{0,400}submitEntryTeamShares\(/) + assert.match(ENTRY, /if \(squadDirty\) \{[\s\S]{0,400}submitEntrySquad\(/) +}) + +test('保存走 PUT /api/entries/{id}/squad,队名 trim 后发出,带 CSRF 与同一个幂等键', async () => { + await withCsrfCookie('a=1; csrf_token=tok-9; b=2', async () => { + respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '青年队' } })) + + const outcome = await submitEntrySquad(7, ' 青年队 ') + + assert.equal(lastRequest.url, entrySquadPath(7)) + assert.equal(lastRequest.url, '/api/entries/7/squad') + assert.equal(lastRequest.method, 'PUT') + assert.equal(lastRequest.credentials, 'include') + assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9') + assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8') + assert.deepEqual(lastRequest.body, { + squad: '青年队', + requestId: lastRequest.headers?.['X-Request-Id'], + }) + assert.equal(outcome.entry?.id, 7) + assert.equal(outcome.squad, '青年队') + }) +}) + +test('幂等键由调用方给定时 header 与 body 用同一把(超时重试不会落两遍)', async () => { + respondWith(() => jsonResponse({ ok: true, data: { id: 7, squad: '二队' } })) + await submitEntrySquad(7, '二队', 'req-fixed-1') + assert.equal(lastRequest.headers?.['X-Request-Id'], 'req-fixed-1') + assert.equal((lastRequest.body as { requestId?: unknown }).requestId, 'req-fixed-1') +}) + +test('回显里没有 squad 字段(老服务端)时交给调用方退回草稿,不把队名抹成空', async () => { + respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1 } })) + const outcome = await submitEntrySquad(7, '青年队') + assert.equal(outcome.squad, null, 'null 而不是空串:空串是「用户清空了队名」这个合法值') + assert.match(ENTRY, /const saved = outcome\.squad \?\? editor\.squad/) +}) + +test('保存后当场改本地那一行:单位列文案从「第一分公司」变成「第一分公司青年队」', () => { + assert.match(ENTRY, /if \(row !== undefined\) row\.squad = saved/) + assert.match(ENTRY, /editor\.squad = saved\s*editor\.originalSquad = saved/, '保存过的值成为新的初值') + // 单位列的文案只认这一份实现:row.squad 一改,表格与大屏同时跟着变。 + assert.match(ENTRY, /return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/) + // 输入框旁边先念一遍改完长什么样(空串时就是单位名本身)。 + assert.match(ENTRY, /return editor === null \? '' : teamSquadLabel\(editor\.teamCellBase, editor\.squad\)/) + assert.match(ENTRY, /teamCellBase: row\.teamShort \|\| row\.teamName,/) + assert.equal(teamSquadLabel('第一分公司', '青年队'), '第一分公司青年队') + assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司') +}) + +test('服务端 400 的中文原文原样抛出(尤其「队名重复」那条,界面直接展示这句话)', async () => { + const message = '组别「男子双打」下单位「第一分公司」已经有「一队」了:同一个单位在同一组别里队名不能重复' + respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message } }, 400)) + await assert.rejects( + () => submitEntrySquad(7, '一队'), + (error: unknown) => { + assert.ok(error instanceof ApiError) + assert.equal(error.code, 'VALIDATION_FAILED') + assert.equal(error.message, message, '一个字都不能被前端改写') + return true + }, + ) + // 弹窗直接把这句话放到错误条里,不换成自己的措辞。 + assert.match(ENTRY, /editor\.error = caught instanceof Error \? caught\.message/) + assert.match(ENTRY, /\{\{ shareEditor\.error \}\}/) +}) + +test('报名不存在 / 网络不可达都说清「队名没有改成功」,不让人以为已经落库', async () => { + respondWith(() => + jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '报名不存在或已被移出' } }, 404), + ) + await assert.rejects( + () => submitEntrySquad(999, '一队'), + (error: unknown) => { + assert.ok(error instanceof ApiError) + assert.equal(error.message, '报名不存在或已被移出') + return true + }, + ) + + respondWith(() => { + throw new TypeError('fetch failed') + }) + await assert.rejects( + () => submitEntrySquad(7, '一队'), + (error: unknown) => { + assert.ok(error instanceof ApiError) + assert.equal(error.code, 'NETWORK') + assert.match(error.message, /队名没有改成功/) + return true + }, + ) +}) diff --git a/web/src/admin/tests/scoring-team-manual.test.ts b/web/src/admin/tests/scoring-team-manual.test.ts new file mode 100644 index 0000000..bee391d --- /dev/null +++ b/web/src/admin/tests/scoring-team-manual.test.ts @@ -0,0 +1,289 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import type { Event, ScoreChannel, ScoringProfile, TeamRankEntry } from '@/shared' +import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring' + +/** + * 「按单位录名次」(scope=team + basis=manual)的预览口径,逐条对齐服务端 + * domain/scoring.go 的 collectTeamManualCandidates 与 groupPoints。 + * + * 现场报的 bug:台球按单位在录入页底部的「团体名次」卡片里录名次(channel_manual_ranks), + * 服务端算对了(六个单位合计 40 分),录入页的实时预览却每个单位都是 0——预览只读行上的 + * rank,从不读 channel_manual_ranks。而那张预览又是发布确认框里「最大分差」的来源。 + */ + +function makeEvent(overrides: Partial = {}): Event { + return { + id: 1, + sportId: 1, + code: 'BALL', + name: '台球', + eventType: 'team', + resultMode: 'manual', + resultType: 'none', + compare: 'none', + recordEligible: false, + scored: true, + awardMedals: true, + multiplier: 1, + status: 'pending', + scheduledAt: null, + venue: null, + sortOrder: 1, + remark: null, + avgMode: 'valid', + ...overrides, + } +} + +function makeProfile(overrides: Partial = {}): ScoringProfile { + return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides } +} + +function makeChannel(overrides: Partial = {}): ScoreChannel { + return { id: 1, eventId: 1, scope: 'team', basis: 'manual', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides } +} + +function makeContext(overrides: Partial = {}): ScoreContext { + return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides } +} + +/** 团体名次分表:与规则包 team_top6 同一张表,第 1–6 名 15/10/8/4/2/1(六个单位合计 40 分,正是现场那组数)。 */ +const TEAM_PROFILE = makeProfile({ + rules: [ + { rank: 1, points: 15 }, + { rank: 2, points: 10 }, + { rank: 3, points: 8 }, + { rank: 4, points: 4 }, + { rank: 5, points: 2 }, + { rank: 6, points: 1 }, + ], +}) + +const SIX_UNITS: TeamRankEntry[] = [ + { teamId: 100, rank: 1 }, + { teamId: 101, rank: 2 }, + { teamId: 102, rank: 3 }, + { teamId: 103, rank: 4 }, + { teamId: 104, rank: 5 }, + { teamId: 105, rank: 6 }, +] + +function close(actual: number, expected: number): void { + assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`) +} + +test('名次只填在「团体名次」卡片(channel_manual_ranks):六个单位合计 40 分', () => { + // 与服务端 httpapi 侧 team_manual_ranks_score_test.go 同输入同期望:同一份 channel_manual_ranks, + // 预览与引擎必须给出同一组数字。 + // 团体名次不挂在任何报名项上:六个单位各录一个名次,成绩表里一行都没有 rank。 + const rows: ScorableRow[] = [100, 101, 102, 103, 104, 105].map((teamId, index) => ({ + entryId: index + 1, + teamId, + athleteId: index + 1, + resultValue: null, + rank: null, + status: 'ok', + isTie: false, + })) + const result = computeEventScore( + rows, + makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }), + ) + const expected = [15, 10, 8, 4, 2, 1] + let total = 0 + for (const [index, teamId] of [100, 101, 102, 103, 104, 105].entries()) { + const points = result.teamPoints.get(teamId) ?? 0 + close(points, expected[index] ?? 0) + total += points + } + close(total, 40) +}) + +test('团体手工通道只认按单位录的名次:填在个人行上的名次一律不进团体通道', () => { + // 服务端 collectTeamManualCandidates 只收 athlete_id == 0 的团体行; + // 预览若照旧读行上的 rank,就会出现「预览有分(10/9/8…)、服务端 0」。 + const rows: ScorableRow[] = [10, 9, 8, 6, 4, 3].map((rank, index) => ({ + entryId: index + 1, + teamId: 100 + index, + athleteId: index + 1, + resultValue: null, + rank, + status: 'ok', + isTie: false, + })) + const result = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] })) + assert.equal(result.teamPoints.size, 0) + assert.equal(result.rowPoints.size, 0) +}) + +test('两个团体行的名次照旧生效,且被卡片里的名次覆盖', () => { + // 纯团体项目(篮球等)的成绩表兜底:团体行(athleteId 为 null)上录的名次。 + const rows: ScorableRow[] = [ + { entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false }, + { entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false }, + ] + const fromRows = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] })) + close(fromRows.teamPoints.get(100) ?? 0, 15) + close(fromRows.teamPoints.get(101) ?? 0, 10) + assert.equal(fromRows.ranks.get(1), 1) + + // 同一单位两个来源都有时以 channel_manual_ranks 为准(服务端 overrideOf)。 + const overridden = computeEventScore( + rows, + makeContext({ + channels: [makeChannel()], + profiles: [TEAM_PROFILE], + teamRanks: [ + { teamId: 100, rank: 2 }, + { teamId: 101, rank: 1 }, + ], + }), + ) + close(overridden.teamPoints.get(100) ?? 0, 10) + close(overridden.teamPoints.get(101) ?? 0, 15) + assert.equal(overridden.ranks.get(1), 2) +}) + +test('联队:按单位录的名次按份额拆给各成员单位(与服务端 jointTeamEntry + ledgerFromRanked 同口径)', () => { + // 服务端 joint_team_test.go 的场景:联队报名(A、B 各半)+ TeamRanks{主单位 A 第 1 名}、15 分表 + // → A、B 各 7.5。名次挂在联队那条报名上(entryId 1),所以行分是整份 15,单位分才拆。 + const profile = makeProfile({ rules: [{ rank: 1, points: 15 }] }) + const result = computeEventScore( + [ + { + entryId: 1, + teamId: 100, + athleteId: 0, + resultValue: null, + rank: null, + status: 'ok', + isTie: false, + shares: [ + { teamId: 100, weight: 1 }, + { teamId: 200, weight: 1 }, + ], + }, + ], + makeContext({ channels: [makeChannel()], profiles: [profile], teamRanks: [{ teamId: 100, rank: 1 }] }), + ) + close(result.teamPoints.get(100) ?? 0, 7.5) + close(result.teamPoints.get(200) ?? 0, 7.5) + close(result.rowPoints.get(1) ?? 0, 15) +}) + +test('lastPlaceZero 只抹「起始名次正好落在末位」的那一组:两人并列第 1 不归零', () => { + // 服务端判据是 startRank == lastPlace(scoring.go groupPoints)。两人并列第 1 时 + // 占位名次是 1、2,末位档 = 2,起始名次 1 != 2 → 照发第 1 名的分。 + const profile = makeProfile({ + lastPlaceZero: true, + rules: [ + { rank: 1, points: 10 }, + { rank: 2, points: 6 }, + { rank: 3, points: 3 }, + ], + }) + const result = computeEventScore( + [makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 1)], + makeContext({ + event: makeEvent({ eventType: 'individual', resultType: 'none' }), + channels: [makeChannel({ scope: 'athlete', basis: 'manual' })], + profiles: [profile], + }), + ) + close(result.rowPoints.get(1) ?? 0, 10) + close(result.rowPoints.get(2) ?? 0, 10) + close(result.teamPoints.get(100) ?? 0, 10) + close(result.teamPoints.get(101) ?? 0, 10) +}) + +test('lastPlaceZero 仍要抹掉真正的末位那一组', () => { + const profile = makeProfile({ + lastPlaceZero: true, + rules: [ + { rank: 1, points: 10 }, + { rank: 2, points: 6 }, + { rank: 3, points: 3 }, + ], + }) + const result = computeEventScore( + [makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2), makeRowOf(3, null, 102, 3)], + makeContext({ + event: makeEvent({ eventType: 'individual', resultType: 'none' }), + channels: [makeChannel({ scope: 'athlete', basis: 'manual' })], + profiles: [profile], + }), + ) + close(result.rowPoints.get(1) ?? 0, 10) + close(result.rowPoints.get(2) ?? 0, 6) + close(result.rowPoints.get(3) ?? 0, 0) +}) + +test('compare=none 配 basis=result:预览报错,不静默按录入名次兜底', () => { + const profile = makeProfile({ + rules: [ + { rank: 1, points: 10 }, + { rank: 2, points: 6 }, + ], + }) + const result = computeEventScore( + [makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2)], + makeContext({ + event: makeEvent({ eventType: 'individual', resultMode: 'manual' }), + channels: [makeChannel({ scope: 'athlete', basis: 'result' })], + profiles: [profile], + }), + ) + assert.ok(result.error, '服务端 domain.ScoreEvent 对这个配置返回 ErrCompareRequired(发布 409),预览必须同样报错') + assert.match(result.error.message, /比较方向/) + assert.equal(result.error.channelLabel, '个人名次 / 按成绩自动排名') + // 报错时不再产出任何分:预览不该给一个服务端算不出来的配置算分。 + assert.equal(result.teamPoints.size, 0) + assert.equal(result.rowPoints.size, 0) +}) + +test('compare=none 配 basis=aggregate:同样报错', () => { + const result = computeEventScore( + [makeRowOf(1, 20, 100, null), makeRowOf(2, 16, 101, null)], + makeContext({ + event: makeEvent({ eventType: 'team', resultType: 'count' }), + channels: [makeChannel({ basis: 'aggregate' })], + profiles: [TEAM_PROFILE], + }), + ) + assert.ok(result.error, '聚合通道同样要先按成绩排名,compare=none 时服务端返回 ErrCompareRequired') + assert.match(result.error.message, /比较方向/) +}) + +test('compare=none 配 basis=manual:照常算分,不报错', () => { + const result = computeEventScore( + [], + makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }), + ) + assert.equal(result.error, null) + close(result.teamPoints.get(100) ?? 0, 15) +}) + +function makeRowOf(entryId: number, resultValue: number | null, teamId: number, rank: number | null): ScorableRow { + return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false } +} + +// 录入页没有组件测试工具,接线部分按仓库既有做法(avg-mode.test.ts)读源码断言: +// 纯函数再有分,页面没把「团体名次」卡片喂进去,现场看到的还是 0。 +const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') + +test('录入页把「团体名次」卡片喂进预览算分(接线掉了就等于没修)', () => { + assert.match( + ENTRY_SOURCE, + /const previewTeamRanks = computed\(\(\) => teamRanksPayload\(teamRankRows\.value\)\.ranks\)/, + ) + assert.match(ENTRY_SOURCE, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/) + assert.match(ENTRY_SOURCE, /const savedTeamRanks = computed\(\(\) => \{/) + assert.match(ENTRY_SOURCE, /teamRanks,\n \}\)/) +}) + +test('录入页把「不挂在行上的单位分」从已发布基线里扣掉,差值才算得对', () => { + assert.match(ENTRY_SOURCE, /for \(const \[teamId, points\] of published\?\.unitPoints \?\? \[\]\)/) + assert.match(ENTRY_SOURCE, /const scoreConfigError = computed\(\(\) => score\.value\?\.error \?\? null\)/) +}) diff --git a/web/src/admin/tests/scoring.test.ts b/web/src/admin/tests/scoring.test.ts index 926f008..b965e10 100644 --- a/web/src/admin/tests/scoring.test.ts +++ b/web/src/admin/tests/scoring.test.ts @@ -87,7 +87,10 @@ test('lastPlaceZero 让末位整组 0 分', () => { close(result.rowPoints.get(3) ?? 0, 0) }) -test('末位并列整组都按最后一名处理', () => { +test('跨末位的并列组不整组归零:只有起始名次正好落在末位的那一组才 0 分', () => { + // 与服务端 domain/scoring.go groupPoints 逐字同口径(Go 用例「lastPlaceZero 不牵连跨末位的并列组」): + // lastPlaceZero 的判据是 startRank == lastPlace。3 人里两人并列第 2(占 2、3),末位档 = 3, + // 起始名次 2 != 3 → 第 2 名的 6 分照发;整组归零会连带抹掉本可拿分的第 2 名。 const profile = makeProfile({ lastPlaceZero: true, rules: [ @@ -101,8 +104,8 @@ test('末位并列整组都按最后一名处理', () => { makeContext({ channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, 9) - close(result.rowPoints.get(2) ?? 0, 0) - close(result.rowPoints.get(3) ?? 0, 0) + close(result.rowPoints.get(2) ?? 0, 6) + close(result.rowPoints.get(3) ?? 0, 6) }) test('scored=false 只排名不计分', () => { diff --git a/web/src/admin/tests/team-ranks.test.ts b/web/src/admin/tests/team-ranks.test.ts index 139d922..144b95d 100644 --- a/web/src/admin/tests/team-ranks.test.ts +++ b/web/src/admin/tests/team-ranks.test.ts @@ -1,15 +1,18 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import type { ScoreChannel, Team, TeamRanksView } from '@/shared' +import { normalizeTeamShares, type ScoreChannel, type Team, type TeamRanksView } from '@/shared' import { hasTeamManualChannel, + mergeJointTeamRankRows, + mergedAwayRankNames, parseTeamRankInput, teamRankFlaggedTeams, teamRankRowsOf, teamRankWarnings, teamRanksDirty, teamRanksPayload, + type TeamRankEntryRef, } from '../pages/teamRanks' import { mockDb } from '../api/mock' @@ -48,6 +51,130 @@ function makeView(overrides: Partial = {}): TeamRanksView { } } +// ── 联队(devdocs/19):一条报名挂多个单位时,团体名次区块要把成员单位合并成一行 ────── + +/** 一个成员单位的名字与编码(拼联队名 / 编码用)。 */ +interface Member { + id: number + code: string + name: string +} + +const JG: Member = { id: 1, code: 'JG', name: '机关本部' } +const F1: Member = { id: 2, code: 'F1', name: '第一分公司' } +const F3: Member = { id: 3, code: 'F3', name: '第三分公司' } + +/** 一条报名:teamId 是主单位(entries.team_id),members 是份额里的成员单位(第一条即主单位)。 */ +function makeEntry(teamId: number, members: readonly Member[]): TeamRankEntryRef { + return { + teamId, + teamShares: normalizeTeamShares( + members.map((member) => ({ teamId: member.id, weight: 1, teamCode: member.code, teamName: member.name })), + ), + } +} + +test('联队:成员单位合并成一行,标签用大屏 / 导出同一口径的联队名', () => { + const rows = teamRankRowsOf(makeView(), []) + const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) + + assert.deepEqual(merged.map((row) => row.teamId), [1], '成员单位不再单独占一行') + assert.equal(merged[0]?.name, '机关本部+第一分公司联队') + assert.equal(merged[0]?.shortName, null) + assert.equal(merged[0]?.code, 'JG+F1', '编码用导入 / 导出的 A+B 写法') + assert.deepEqual(merged[0]?.members.map((member) => member.teamId), [1, 2]) +}) + +test('联队:名次落在主单位那一行,成员单位不再进保存载荷(重复计分就此堵死)', () => { + const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), []) + const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) + + assert.equal(merged.length, 1) + assert.equal(merged[0]?.input, '2', '主单位那一行原来录的名次回填到联队行') + assert.equal(teamRanksDirty(merged), false) + // 成员单位 2 库里那条名次 3 不再出现在载荷里:它不会被当成第二个名次主体。 + assert.deepEqual(teamRanksPayload(merged), { + scope: 'team', + basis: 'manual', + ranks: [{ teamId: 1, rank: 2 }], + }) +}) + +test('联队:输入框写回主单位那一行(副本会把草稿吞掉,保存按钮也不会亮)', () => { + const rows = teamRankRowsOf(makeView(), []) + const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) + + merged[0]!.input = 4 + assert.equal(rows[0]?.input, 4, '联队行的输入写回主单位那一行') + assert.equal(teamRanksDirty(merged), true) + assert.deepEqual(teamRanksPayload(merged).ranks, [{ teamId: 1, rank: 4 }]) +}) + +test('组别里没有联队报名时,行与现在一字不差', () => { + const rows = teamRankRowsOf(makeView(), []) + + // 单单位报名(份额只有一条)不是联队:连对象都不该换一个。 + assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])]), rows) + assert.equal(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])])[0], rows[0]) + // 没有报名时同理。 + assert.deepEqual(mergeJointTeamRankRows(rows, []), rows) +}) + +test('成员单位自己也有报名时两条都列出来,且分得清', () => { + const rows = teamRankRowsOf(makeView(), []) + const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1])]) + + assert.deepEqual(merged.map((row) => row.teamId), [1, 2], '别静默吞掉第一条') + assert.equal(merged[0]?.name, '机关本部+第一分公司联队') + assert.equal(merged[1]?.name, '第一分公司') + assert.deepEqual(merged[1]?.members, [], '它自己那条不是联队') + assert.equal(merged[1], rows[1], '普通行原样透传') +}) + +test('一个单位同时是两个联队的成员单位时,两个联队各占一行', () => { + const rows = teamRankRowsOf( + makeView({ + teams: [ + { teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' }, + { teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }, + { teamId: 3, code: 'F3', name: '第三分公司', shortName: '三分公司' }, + ], + }), + [], + ) + const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1, F3])]) + + assert.deepEqual(merged.map((row) => row.teamId), [1, 2]) + assert.equal(merged[0]?.name, '机关本部+第一分公司联队') + assert.equal(merged[1]?.name, '第一分公司+第三分公司联队') +}) + +test('主单位那一行不在列表里(单位停用 / 假删除)时不合并不吞', () => { + const rows = teamRankRowsOf( + makeView({ teams: [{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }] }), + [], + ) + assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]), rows) +}) + +test('参赛单位数按合并后的行数算', () => { + const rows = teamRankRowsOf(makeView(), []) + const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) + merged[0]!.input = '2' + + assert.match(teamRankWarnings(merged).join(';'), /超出参赛单位数 1/) + assert.deepEqual([...teamRankFlaggedTeams(merged)], [1]) +}) + +test('被并掉的成员单位原来录过的名次要点名(保存后会清掉,不静默改数)', () => { + const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), []) + assert.deepEqual(mergedAwayRankNames(rows, mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])), ['一分公司']) + + // 没录过名次的成员单位不点名。 + const clean = teamRankRowsOf(makeView(), []) + assert.deepEqual(mergedAwayRankNames(clean, mergeJointTeamRankRows(clean, [makeEntry(1, [JG, F1])])), []) +}) + test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => { assert.equal(hasTeamManualChannel([makeChannel()]), true) assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false) @@ -219,3 +346,14 @@ test('录入页按契约接线:区块可见性、随行数据优先、保存 assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/) assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/) }) + +test('录入页按契约接线:联队成员合并成一行、名次写到主单位', () => { + // 表格行由本组别的报名行(teamShares 现成)合并而来,不为它多开一个接口。 + assert.match(ENTRY_SOURCE, /mergeJointTeamRankRows\(teamRanksByCategory\.value\.get\(categoryId\) \?\? \[\], categoryRows\.value\)/) + // 联队行要能一眼认出来:徽标 + 成员单位与份额(与大屏 / 成绩表同一读法)。 + assert.match(ENTRY_SOURCE, /v-if="row\.members\.length > 0" class="mx-badge mx-badge-info">联队 (detail.value?.autoCloseSeconds ?? 0) > 0) {{ breakdownAthleteName(item, teamName) }} {{ item.rank ?? '—' }} - {{ item.resultText ?? '—' }} + {{ resultTextOrPlaceholder(item.resultText) }} {{ item.points }}