From 55051fd6334fc99122c782e771d6fd24a246eb18 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 14 Sep 2026 23:40:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(import):=20=E5=AF=BC=E5=85=A5=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=94=B9=E6=88=90=E3=80=8C=E8=83=BD=E7=85=A7=E7=9D=80?= =?UTF-8?q?=E6=94=B9=E3=80=8D=E7=9A=84=E8=AF=9D=20+=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E7=BB=99=E3=80=8C=E5=A7=93=E5=90=8D=E3=80=8D=E3=80=8C=E9=98=9F?= =?UTF-8?q?=E5=91=98=E5=90=8D=E5=8D=95=E3=80=8D=E5=8A=A0=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=EF=BC=9B=E7=89=88=E6=9C=AC=20dev=5F0.5.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 起因(主人原话) > 我应该如何用表格导入团体报名? 我现在不管怎么填的话,它都提示: > 「第2行填写了队伍 但是这一行有运动员姓名 按个人报名处理」。但实际上我们程序里,团体报名是可以包含运动员姓名的。 ## 核实结论:不是功能 bug,是提示没用 系统用「**`姓名` 列有没有填**」区分个人 / 团体报名(数据模型决定的:一条报名要么属于某个人、要么属于一支队)。 **团体报名的队员要填在「队员名单」列**(写号码布),`姓名` 列留空;队员就是这条报名的成员。 原来那条提示**只说了后果、没说动作**,所以现场只能反复换填法重试。 ## 改了什么 1. **两条行级提示改成「后果 + 两步动作」**(保留旧文案里的关键词,既有断言一字未改仍通过): - 队伍那条:`第 N 行填了队伍「一队」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,这一格没有写进库里。要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列;这一行确实是个人报名,把「队伍」这一格删掉即可` - 队员名单那条:同口径(`…队员只对团体报名生效,这一格只作文字名单保留、没有关联到人。要报团体请把姓名列留空、队员的号码布就填在「队员名单」列…`) 2. **导入模板加说明**:原来只有「单位编码」表头挂了批注;现在给 **`姓名`**(填了就是个人报名、团体请留空)与 **`队员名单`**(团体报名的队员写这里、**写号码布**才会关联到人)两个表头**各加一条批注 + 输入提示**,中英文两套。 3. ⚠️ **模板的表头文字、列序、13 列、工作表命名一个字没动**(动了模板就导不回去,有测试逐字钉着)。 ## 文档 `docs/报名名单导入规范.md`(新增「个人报名 vs 团体报名」对照表 + 两条提示原文收进常见报错 + 队员名单分隔符说明)、 `docs/使用手册.md`(在工作台导入那节点明判据)、`docs/升级说明.md`(新增 `dev_0.5.9` 一节)。 ## 验证 - **先红后绿**;全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。 ## 版本 `dev_0.5.8` → **`dev_0.5.9`**(**无数据迁移**);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。 --- VERSION | 2 +- console/installer/art/1x/brand.bmp | Bin 316854 -> 316854 bytes console/installer/art/2x/brand.bmp | Bin 1267254 -> 1267254 bytes console/installer/art/version.txt | 2 +- console/package.json | 2 +- docs/使用手册.md | 3 + docs/升级说明.md | 34 +++- docs/报名名单导入规范.md | 33 +++- .../httpapi/entries_fill_help_test.go | 172 ++++++++++++++++++ .../httpapi/entries_sheet_template.go | 150 +++++++++------ server/internal/httpapi/handlers_config.go | 13 +- .../httpapi/joint_team_template_test.go | 29 ++- 12 files changed, 364 insertions(+), 76 deletions(-) create mode 100644 server/internal/httpapi/entries_fill_help_test.go diff --git a/VERSION b/VERSION index 1ac8a0d..d7e8679 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.8 +dev_0.5.9 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index a05560d7bb85a2894dc1e5ddb375d7ede4a63a10..e6105b1fb8f68bdd6e752ccc8669932e5405320b 100644 GIT binary patch delta 410 zcmdnCT6o)P;SF**o9!4)m?j71P3H8~;`dPHa#!I53h6OTmMNSj8r)(qshD3g;9fP@|)u6BJwQtu}%~7L;A7InfAY<6cOq7MJ zmpV_li6oG;mtpKk^=gQ9a#LaV*Aej3=HHxKzKKcNQ=JEBKG4cQJ>ejIkw`PCsl^dM zb3h7KHK{XAPORyd))53c3+RC9rBPiOzK!wDGs~hw48?$s@!z}}q{yg#ve@j3*lZ77 z7bW&Wf0IQGsUe1q29YJpm9-w7F^(oz(tx S5+e{Z0WtITr;}JNIspL7fPA3< delta 428 zcmdnCT6o)P;SF**%>Ft8n;jVqnI;G2P3G~_7VuK%aZ~2-Qs>_MuveRDvP|JLAspy*O(Hkemyn2Gis>#nZ**n2Q6=W|T$OMmtO`3a^Q>1ImJo^kP(En!KlM zqJo8NgqgIzj^M0{*eG)uCwZ12eUZ7<36W+}-kLlhmAU1cm}ETExC8ZsrWVa zM3_ne)r1;}`DpU`Z(iM`#xyyxre8{1z(bX*(BGs!#xciBe_l;uQGf~5zEw>?2N~5* z7WLKQpIa5*mmScT6Bus;axBC-Kxw9?UTL5Yf!+hUwk65kPMRUfUb!zPz*CJ2q-a&s zRwj@FS0(n2H1DzyODl1P6i3x5Md98Wz@Xs=Dgc_YsC|M2Fpz)&F}*aZG0quC=6dPF zjR2}B>YBz6^an7sVk~8$4sd4#2BuT*G)`{~UZ4Yj1_HzPI1?w(xj-91wmUMKFf~7& V)c$l5BM>tIG4uAPlUOb~0RY(yf+_$2 diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index 96819a7fee7cc757023e7ae3a54d04b0eabfd389..95d5c5b0f0a6d960fb9af349ff810f56f2952aa3 100644 GIT binary patch delta 1023 zcmYjQNla5w6wUkm7FsB@g^GYA3>|2hBuoK=q984W(v~S@77Aq$P-c)YDaaHkgRxOb z6bBqYBPsA!?%1%w7+0>`xY9(45#oY{=eLBIyz}$(|2^;AbI*G_8-|??!{`=C-F2kz z5|M|-ufTDzM*^0s89uGuC0#t3LF1=W2y>O*rWlDi&Oa|oq*Vx(jf~7$&6rqPuuqia ztX}2Qn5|xDFM2j+dvT}9(No@OOv+b_9QI=(bJj9?mj3umB1yPbF2GjZ=9KN_{x?%? zi#LkrTg_|Z&09--Q#L&qP9syv(%CQARSvDH3t>~Xyt0%Kom_x6iq6YW5AHx|x=tZ5 z#)zFIOessFzjF0x6%>@Aoq}kwF-8Kd!uWuvqYY@PBr%ZNzl14c>HaqoMl`5*FBG3H zQ>;`g_ZqZhK&8W&c%owASRLQNQk_zu0y{a8UQpC2h1N{P`}wYETVAeGh{ovOBLa@( zfP@l9`!NxOpsYOgT&F2{uF=)12ZeSNxp z)874hvi0rk&BsG^)=c@zKsC%D27|_z6X@k>P>qe7bD~j-qJ}FfIJP7QPgr#?Ct9B0 zZbT%-2{Ksc2$k&iA10VMeo7g>9C@K*TUJzd7!R)CCp(NG?sr-8)jsr)>yLhtfa8*im2y%e^=>I}kSOwK&e1p*d&jD?+}XHo(e6M_3Zs~7 zF=7<;mz7r|JVAg6QnI(1VYTj3q+aEH5)PySLg6Z$o44X5zLi@w$jE(uhAJ|*miiGW zwSVLqTaRGWgi8^}_uX74ru3-43J1%%)VI)92;UT^y@930QA{KWLfy5Di19k@a!2V* zlc7x?kFg*HBVAB4Gdw9GMqjAH6E_Njbo|A>&cEcQ@2)!n?~#Nt!d{Na}25DFb7{Lno{d%KF%{I%RF5b+e?El?m5OA z3-8xlUA%4mJm0@I+FX|tU8WSmg3m@Q9A@SlPFEDi2V&38(%_^+SEdx<5FIVh(E%<~ zI#^4tDuYVovyv(flk^??)sTSvtMOL&g=@&+G9^h$6N9|{_VoYNOtkwJ?`(Pc=DRA`=KZ3{P0r3A5U$c5~Y4GN&T~ zES#JO^UFMJSHlGoq9tPu1-r{b?v@g)AWl&n?@?M-VZmcxC|HRoQ%zPBJ|nH3=KGrngKdQ>-@?GK{mQ8lIA^2%W#B2mTX0sQO5Fwx zj6tqCBYb739-VK **用表格导入报团体时注意「姓名」列**:报名表里的**「姓名」列填了就是个人报名**——这一格是系统区分个人 / 团体报名的**唯一判据**。想报一支队却把队里某个人的姓名填进这一列,导入后会落成**他的个人报名**,那一行的「队伍」与「队员名单」都不生效(导入结果里会给出可照改的中文提示)。**团体报名请把「姓名」列留空,队员的号码布写进「队员名单」列**(写号码布才会关联到人;只写姓名只当文字名单)。两种填法的逐列对照见[报名名单导入规范](./报名名单导入规范.md)。 + **表格导入** > 报名表的完整格式、每一列怎么填、个人 / 团体 / 「个人+团体」三种项目的填法和常见报错对照,见单独一份文档 [报名名单导入规范](./报名名单导入规范.md)——**可以直接把那份文件发给各单位填表人**。 @@ -812,6 +814,7 @@ - **两个单位联合参赛(联队)**:把「单位编码」那一格写成 `F1+F2`(加号左边是主单位,默认均分,见 4.11)。**分工作表模板**里,「单位编码」表头那一格挂了一条**单元格批注**(鼠标停上去就弹出来)、这一列还有**输入提示**(点中该列的格子就弹出来),中英各一份,写的正是这套写法——表头文字、列顺序与工作表名一个字都没动,**老表格照常导入**; 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); + - `athlete_name`(运动员姓名)这一格**填了就是个人报名**(这一行属于这个人);**团体报名请留空**,队员的号码布写进 `member_names`(队员名单)列——两格都填的行会按**个人报名**导入,队伍与队员名单不生效(导入结果里会给可照改的中文提示),详见[报名名单导入规范](./报名名单导入规范.md); - `squad`(队伍)是**最后一列**、**新加的(2026 年 9 月)**:同一个单位在同一个组别派两支队时(羽毛球双打的一队 / 二队)用它区分,写队名即可(中文表头里叫「队伍」,也认「队名」「队伍名」);**只有一支队的单位整列留空**、整列不写、表头里没有它,三种都合法。**老 12 列表照常导入**(新列加在最后,一列都不会错位),详见 4.12 与[报名名单导入规范 · 第三节「队伍」](./报名名单导入规范.md); - 用**分工作表模板**时,`event_code`、`category` 两列也可以整列不要或整列留空(范围由工作表名决定);一旦填了就必须与工作表名一致,不一致的那一行会报错并跳过; - 工作表名必须是「小项名称@组别名称」;工作簿里只要有**一张**表名带 `@`,整份文件就按分工作表模板处理,别的表名不合规会**整份拒绝并点名是哪张表**(不让数据无声消失); diff --git a/docs/升级说明.md b/docs/升级说明.md index d637eed..eee29d2 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,39 @@ --- -## dev_0.5.8(当前版本) +## dev_0.5.9(当前版本) + +### 一句话 + +**没有数据迁移,升级后不用重配任何东西**:只改**提示与模板上的说明**。以前在团体项目的报名表里把队员姓名填进「姓名」列,导入时只回一句「填了队伍 / 队员名单,但这一行有运动员姓名,按个人报名处理」——**没告诉你该怎么改**,于是现场反复换填法重试。现在这两条提示会**直接写出两步动作**(要报团体就把姓名列留空、号码布填到「队员名单」列;确实是个人报名就把那一格删掉),下载的模板里**「姓名」与「队员名单」两列表头也各挂了一条说明**(鼠标停上去或点中该列的格子就能看到)。**导入表、模板结构、已有数据一个字都没动。** + +### ① 两条导入提示改成「能照着改」的话 + +- 以前现场看到的那句(**问题就出在这里**)只说清后果,没说出改法:`第 N 行填了队伍「一队」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里`; +- 现在改成(**原文**,队员名单那条同理): + > 第 N 行填了队伍「一队」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,这一格没有写进库里。**要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列**;**这一行确实是个人报名,把「队伍」这一格删掉即可** +- **判据本身没变**(这也是没把它当 bug 的原因):**「姓名」那一格填了,这一行就是个人报名;团体报名要把它留空**,队员写进「队员名单」列(写号码布才会关联到人)。系统一直是这样区分的,这次只是**把话说明白**。 +- ⚠️ 一行提示现在约 120 字(含两步动作):一次导入几十行时看着会长一点,但**每行只说一次**;嫌吵的话可以跟我们要一版更短的。 + +### ② 模板里的「姓名」与「队员名单」加了说明 + +- 下载的模板(分工作表 xlsx)现在有三列表头挂了**单元格批注 + 该列的输入提示**(中英各一份):原来的「**单位编码**」(联队怎么写),加上新的「**姓名**」(填了姓名就是个人报名;团体报名请把姓名留空,队员写进「队员名单」列)与「**队员名单**」(团体报名的队员写这里,**写号码布**才会关联到人、参与个人算分); +- **模板的表头文字、列序、13 列、工作表命名一个字都没动**(有测试逐字钉着)——**手里已有的表格照常导入**。 + +### ③ 顺带写清:「队员名单」那一列怎么分隔 + +- 多人之间用 **`,`、`,`(中文逗号)或 `/`** 分开,**三种都认、可以混用**;**空格不是分隔符**(`张三 李四` 会被当成一段); +- **每一段要单独是一个号码布**:`A001`、`A002/101` 都行;**`张三(A001)` 这种「姓名 + 号码布写在同一段」认不出人**——那是**导出件**的显示格式,不是导入接受的写法。要「名字也看得见」,写号码布就行(导出件与录入页会显示成 `张三(A001)`)。 + +### ④ 升级后 5 分钟核对 + +1. 拿一份**含团体报名**的报名表(原来会触发那两条提示的那种)试着导入一次:报错里应能看清**两步动作**,而不是只有「没写进库里」; +2. 打开「基础数据 → 报名导入」下载一份「**分工作表 · 中文表头**」,鼠标停在「**姓名**」和「**队员名单**」表头上,应各弹出一条说明; +3. 其余照旧:**不需要重新导入规则包,也不需要重配任何东西**;已有报名、成绩、榜单与导出件**一个字不变**。 + +--- + +## dev_0.5.8 ### 一句话 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index 49f3c63..1f5563b 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -133,6 +133,22 @@ F1+F2 - **团体项目**:**这一列留空**,表示"这是某个单位的一条团体报名",队员写在 `member_names` 里。 - 姓名为空的行,系统只建一条单位报名,不会去建运动员档案。 +> ⚠️ **这一格「填了 / 没填」就是系统区分「个人报名」和「团体报名」的唯一判据**——最容易踩的坑:把团体报名的队员姓名填在了这一列。填了就按**个人报名**处理,那一行的「队伍」「队员名单」都**不会生效**(只会得到一条提示)。**团体报名请把这一列留空**,队员写进[「队员名单」](#member_names-队员名单)。 + +#### 个人报名 vs 团体报名:同一条表里怎么填 + +同一份表里两种报名**可以混着写**(一人一行),差别只在下面这几格: + +| 列 | **个人报名**(一个人一条) | **团体报名**(一个单位一支队一条) | +| --- | --- | --- | +| `athlete_name` 姓名 | **必填**(这一段报名的归属人) | **留空**(填了就变成个人报名) | +| `id_card` 证件号 / `bib` 号码布 | **至少填一个**(认人用;证件号优先) | 可以不填(这一行没有"人") | +| `member_names` 队员名单 | **留空**(个人报名没有队员) | 写队员的**号码布**(`A001/A002`);写姓名只当文字、不关联人 | +| `squad` 队伍 | **留空**(个人报名没有队) | 同一个单位派两支队时写队名(一支队留空) | +| `team_code` 单位编码 | 这个人所属单位 | 这支队的单位(联队写 `A+B`) | + +> **一句话**:**姓名列填了 = 个人报名,姓名列留空 = 团体报名**。现场最常错的是「想报一支队,却把队里最出名的那个人填进了姓名列」——那样只会落成他的个人报名,队名与队员名单被忽略(导入结果里会给提示,见第六节)。 + ### `id_card` 证件号 / `bib` 号码布 —— 有姓名就至少填一个 只要有姓名,**证件号与号码布至少要填一个**,否则无法确认是哪一个人,这一行会被跳过,提示「填写了 athlete_name 就必须填 id_card 或 bib」。 @@ -181,13 +197,17 @@ F1+F2 ### `member_names` 队员名单 -团体项目用这一列写队员,多人之间用 `/`、`,` 或 `,` 分开: +团体项目用这一列写队员,**多人之间用 `/`、`,`(英文逗号)或 `,`(中文逗号)分开,三种都认、可以混用**: ``` -张三/李四/王五 -张三,李四,王五 +A001/A002 ← 推荐:写号码布,队员会被关联到人 +A001,A002 +A001,A002 +张三/李四/王五 ← 只写姓名:名单照常显示,但拿不到个人分 ``` +- ⚠️ **空格不是分隔符**:`A001 A002` 会被当成**一段**(认不出人); +- ⚠️ **每一段要单独是一个号码布**:`张三(A001)` 这种「姓名 + 号码布写在同一段」**认不出人**——那是**导出件**的显示格式,不是导入接受的写法(要名字也看得见,写号码布就行); - 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。 - 系统界面里会把这条名单显示成「张三、李四、王五」。 @@ -212,7 +232,7 @@ F1+F2 - **号码布是运动员的唯一标识,不看单位**:号码布认到的是**别的单位**的人时,**照常关联**,并在导入结果里给一条提示让你核对号码布是不是写错了(不是冲突、不拦截); - **认不出人的段不报错**:没录档案的临时队员、写错的名字,都只当文字留在名单里(这条报名仍然只有单位、没有队员身份); - **同一格里同一个号码布写两次**:只关联一次,并给一条提示(同一条报名里一个人只占一个位置); -- **个人行(填了姓名的那几行)这一列只当文字**:个人报名自己就是那个人的身份,不存在「队员」。这一格里写了号码布也不关联,只会给一条提示; +- **个人行(填了姓名的那几行)这一列只当文字**:个人报名自己就是那个人的身份,不存在「队员」。这一格里写了号码布也不关联,只会给一条提示(提示里会直接告诉你团体该怎么填,见第六节); - **一条报名最多关联 30 个人**:超过的那一行会被**整行拦下**(多半是把号码布写错了位置); - **老数据的人要补挂**:升级前已经报好的团体报名,如果当时只写了姓名(或连姓名都没写),**升级时会尽力按「号码布 → 姓名 + 单位」回填一次**;**回填不上的,要重新导入一份写着号码布的名单,或到录入页手动挂人**(见[使用手册 · 4.13](./使用手册.md))。 @@ -342,6 +362,8 @@ F1+F2 - [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格 - [ ] `category` 与系统里的组别名一字不差 - [ ] 个人行都有姓名,且都有证件号或号码布 +- [ ] **团体报名的那几行「姓名」列是空的**(填了姓名就会被当成个人报名,那一行的「队伍」「队员名单」都不生效——见第三节的对照表) +- [ ] 「队员名单」列里写的是**单独的号码布**(多人用 `/`、`,`、`,` 分开,**不要用空格**;`张三(A001)` 这种整段认不出人) - [ ] 证件号优先填、填准(**它是系统认人的第一依据**,填错最容易认错人) - [ ] 一份表里**没有把同一个号码布给两个不同的人**(同一个人报多个组别时可以重复) - [ ] 同一小项同一个组别里没有把同一个人写两遍(真重复会进「冲突」列表等决策) @@ -378,7 +400,8 @@ F1+F2 | 第 N 行:单位 A 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入 | **个人行**(填了姓名的行)在同一个小项同一个组别里写了两次。个人报名按「人」唯一,队伍列不参与(也不影响)这件事 | 把两行合并成一行;同一个人要参加两个项目就写在**不同小项**里,不能靠队名分成两条 | | 第 N 行:队员名单里认出了 31 名运动员,超过一条报名最多 30 人:请检查这一格是不是把号码布写错了位置 | 这一格里能对上号码布的段**超过 30 个**(多半是把别的内容写进去了) | 核对这一格:一条报名最多 30 个队员;只有一支队时这一格留空即可。**这一行整行未导入** | | 第 N 行:队员名单里的号码布 101 写了两次,只关联一个人(同一条报名里一个人只占一个位置) | 同一格里同一个号码布写了两次 | 不算错误(照常导入、只关联一次);把这一格里重复的那个号码布删掉即可 | -| 第 N 行的队员名单里有 2 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人 | **个人行**(有姓名的行)里写了号码布。个人报名自己就是那个人的身份,不存在「队员」 | 不算错误(这一行照常导入,只是**没有**建立队员关联);团体报名的那一行才需要写号码布 | +| 第 N 行填了队伍「一队」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,这一格没有写进库里。**要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列**;**这一行确实是个人报名,把「队伍」这一格删掉即可** | **「姓名」列填了**(= 系统按个人报名处理)却还填了「队伍」。团体报名要**把姓名列留空** | 按提示二选一:**要报这支团体**,把这一行的姓名(以及证件号 / 号码布)**删掉**、队员的号码布写进「队员名单」列,队名留在「队伍」列;**这一行本来就是个人报名**,把「队伍」这一格删掉。⚠️ **注意别把这一行整行删掉了再重报**——按上面改完重传即可(同一小项同组别同单位同队名会更新原报名) | +| 第 N 行的队员名单里有 2 个号码布认得出人,但这一行也填了运动员姓名,系统按个人报名处理:队员只对团体报名生效,这一格只作文字名单保留、没有关联到人。**要报团体请把姓名列留空、队员的号码布就填在「队员名单」列**;**这一行确实是个人报名,把「队员名单」这一格删掉即可** | **「姓名」列填了**(= 个人报名)却还在「队员名单」里写了号码布。个人报名自己就是那个人的身份,不存在「队员」 | 同样二选一:**要报团体**就把姓名列清空(队员的号码布留在原处就行);**这一行是个人报名**就把「队员名单」这一格删掉 | | 第 N 行:队员名单里的号码布 205 是单位 F3 的 李四,本行报名挂在单位 F1 名下 —— 号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错 | 号码布认到的是**别的单位**的人。号码布是唯一标识,系统**照它认、不看单位** | 先核对号码布有没有抄错:抄错了改成正确的号码布;**确实就是借调/跨单位的人**,这条提示可以忽略(关联已经生效) | | **(没有报错,但名单里的人上不了个人榜)** | 队员名单里写的是**姓名**(例如 `张三/李四`),或号码布写成了 `张三(101)` 这种「姓名 + 号码布写在同一段」的形式 —— 系统拿**整段**去比号码布,认不出人 | 这是**正常行为**(不报错):把这一段改成**单独的号码布**(`101/102`),队员才会被关联、才会拿到个人分。要「姓名 + 号码布」都显示,直接写号码布即可(导出件与录入页会显示成 `张三(101)`) | | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | diff --git a/server/internal/httpapi/entries_fill_help_test.go b/server/internal/httpapi/entries_fill_help_test.go new file mode 100644 index 0000000..6576947 --- /dev/null +++ b/server/internal/httpapi/entries_fill_help_test.go @@ -0,0 +1,172 @@ +package httpapi_test + +import ( + "net/http" + "strings" + "testing" +) + +// 现场填表人手上的信息只有「提示」与「模板」两处,所以这两处必须能**照着改**。 +// +// 老提示只说「队伍这一格没有写进库里」——说了后果,没说改哪里,于是「团体报名能不能填姓名」 +// 这种问题只能反复试。现在两条提示给的都是下一步动作(要报团体怎么改 / 确实是个人怎么改), +// 模板上「姓名」「队员名单」两列表头也各挂一条同口径的说明。 + +// 个人行填了队伍:提示要给出改法,而不是只说「没写进库里」。 +func TestImportSquadNoticeTellsHowToFix(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) + + row := strings.Join([]string{"M200", "男子", "A", "张三", "110101199001011234", "男", "26", + "101", "", "registered", "", "一队"}, ",") + status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, row) + 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) + } + + got := noticesText(result) + for _, want := range []struct{ fragment, why string }{ + {"一队", "要点名是哪一行的哪支队伍"}, + {"个人报名", "要说清系统按什么处理了"}, + {"队伍只对团体报名", "要说清队伍只对团体生效"}, + {"姓名", "要指出该改的是姓名列"}, + {"留空", "要给出「团体报名」的改法"}, + {"队员名单", "要指出队员写在哪一列"}, + {"删掉", "要给出「确实是个人报名」的改法"}, + } { + if !strings.Contains(got, want.fragment) { + t.Errorf("提示缺 %q(%s),得到:%s", want.fragment, want.why, got) + } + } +} + +// 个人行的队员名单认出了人:第二条同款提示也要给出改法。 +func TestImportMemberNamesNoticeTellsHowToFix(t *testing.T) { + env, ids, client := recorderWithSeed(t) + seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) + seedBib(t, env, ids.athleteA, "A001") + + row := strings.Join([]string{"DBL", "男子", "A", "张三", "110101199001011234", "男", "26", "A101", + "", "registered", "A001"}, ",") + status, resp := importCSVWithHeader(t, env, client, importHeader, row) + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[entryImportResultBody](t, resp) + if result.Created != 1 { + t.Fatalf("个人行应照常导入,得到 %+v", result) + } + + got := noticesText(result) + for _, want := range []struct{ fragment, why string }{ + {"个人报名", "要说清系统按什么处理了"}, + {"姓名", "要指出该改的是姓名列"}, + {"留空", "要给出「团体报名」的改法"}, + {"号码布", "要说清团体报名时队员写号码布"}, + {"删掉", "要给出「确实是个人报名」的改法"}, + } { + if !strings.Contains(got, want.fragment) { + t.Errorf("提示缺 %q(%s),得到:%s", want.fragment, want.why, got) + } + } +} + +// 模板的两列表头要挂说明:姓名(填了就是个人报名、团体留空)与队员名单(团体报名写这里、写号码布)。 +// 说明挂在批注 + 该列的输入提示上,中英文各一份。 +func TestEntriesTemplateExplainsIndividualAndTeamRows(t *testing.T) { + env, _, client := recorderWithSeed(t) + + // D = athlete_name(姓名),L = member_names(队员名单),列序与模板表头逐列一致。 + cases := []struct { + lang string + nameCell string + memberCell string + nameWords []string + memberWords []string + }{ + { + lang: "zh", nameCell: "D1", memberCell: "L1", + nameWords: []string{"个人报名", "留空"}, + memberWords: []string{"团体", "号码布"}, + }, + { + lang: "en", nameCell: "D1", memberCell: "L1", + nameWords: []string{"individual", "blank"}, + memberWords: []string{"bib", "team"}, + }, + } + + for _, tc := range cases { + f, _ := env.fetchTemplate(t, client, tc.lang) + sheets := f.GetSheetList() + if len(sheets) == 0 { + t.Fatalf("[%s] 模板没有工作表", tc.lang) + } + for _, sheet := range sheets { + comments, err := f.GetComments(sheet) + if err != nil { + t.Fatalf("[%s] 读工作表 %s 的批注: %v", tc.lang, sheet, err) + } + byCell := map[string]string{} + for _, comment := range comments { + byCell[comment.Cell] = comment.Text + } + for _, want := range []struct { + cell string + words []string + }{ + {tc.nameCell, tc.nameWords}, + {tc.memberCell, tc.memberWords}, + } { + text, ok := byCell[want.cell] + if !ok { + t.Errorf("[%s] 工作表 %s 的 %s 表头没有批注(已有:%v)", tc.lang, sheet, want.cell, comments) + continue + } + for _, word := range want.words { + if !strings.Contains(strings.ToLower(text), strings.ToLower(word)) { + t.Errorf("[%s] %s 的批注缺 %q:%q", tc.lang, want.cell, word, text) + } + } + } + + validations, err := f.GetDataValidations(sheet) + if err != nil { + t.Fatalf("[%s] 读工作表 %s 的输入提示: %v", tc.lang, sheet, err) + } + for _, want := range []struct { + prefix string + words []string + }{ + {"D2", tc.nameWords}, + {"L2", tc.memberWords}, + } { + prompt := "" + for _, dv := range validations { + if dv.Sqref != "" && strings.HasPrefix(dv.Sqref, want.prefix) && dv.Prompt != nil { + prompt = *dv.Prompt + } + } + if prompt == "" { + t.Errorf("[%s] %s 列没有输入提示", tc.lang, want.prefix) + continue + } + for _, word := range want.words { + if !strings.Contains(prompt, word) { + t.Errorf("[%s] %s 列的输入提示缺 %q:%q", tc.lang, want.prefix, word, prompt) + } + } + } + } + } +} diff --git a/server/internal/httpapi/entries_sheet_template.go b/server/internal/httpapi/entries_sheet_template.go index 57e0825..b173b60 100644 --- a/server/internal/httpapi/entries_sheet_template.go +++ b/server/internal/httpapi/entries_sheet_template.go @@ -438,7 +438,7 @@ func entryTemplateHeader(language string) []string { // // 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、列名与 // importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表 -// 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见) +// 口径解析时会对不上号)。所以这里用两处:表头的**单元格批注**(红三角一眼能看见) // 与这一列的**输入提示**(点中格子就弹出来)。 const ( entryJointHintZH = "单位编码怎么写:\n" + @@ -457,6 +457,33 @@ const ( entryJointPromptEN = "Joint team: write A+B (left one is the primary unit). " + "Points split evenly, each unit gets one full medal" entryJointTitleEN = "How to fill the team code" + + // 「姓名」与「队员名单」这两列决定了这一行是个人报名还是团体报名(服务端用姓名那一格 + // 有没有填来判),填错的下场是「队伍 / 队员这一格没写进库里」。模板是现场唯一没被提示 + // 打扰过的地方,所以这两列的说明要正面写清「团体怎么填」——现场就是在这里反复试的。 + entryNameHintZH = "姓名怎么写:\n" + + "· 填了姓名 = 个人报名:这一行是这位运动员的报名(同一个人在同一组别只有一条)。\n" + + "· 团体报名(一支队)请把姓名留空,队员的号码布填到「队员名单」列。" + entryNamePromptZH = "填了姓名就是个人报名;团体报名请把姓名留空,队员写进「队员名单」列" + entryNameTitleZH = "姓名怎么写" + entryNameHintEN = "How to fill the athlete name:\n" + + "- A name here means an individual entry: this row belongs to that athlete.\n" + + "- For a team entry (one squad), leave the athlete name blank and write the members' bibs in member_names." + entryNamePromptEN = "A name here means an individual entry. For a team entry, leave the name blank" + entryNameTitleEN = "How to fill the athlete name" + + entryMemberHintZH = "队员名单怎么写:\n" + + "· 团体报名的队员写在这一格(姓名那一格留空)。\n" + + "· 写号码布(例如 A001)才会关联到人、参与个人算分;只写姓名只当文字。" + entryMemberPromptZH = "团体报名的队员写在这里,写号码布才会关联到人" + entryMemberTitleZH = "队员名单怎么写" + entryMemberHintEN = "How to fill the member list:\n" + + "- Team entries (athlete name left blank): write the members here.\n" + + "- Write their bibs (for example A001) to link them to athletes; names alone stay text only " + + "and score no individual points." + entryMemberPromptEN = "Members of a team entry go here; write bibs to link them to athletes" + entryMemberTitleEN = "How to fill the member list" + // entryTemplateAuthor 是批注作者。写系统名(与导出件的文档属性同一套), // 绝不写构建期注入的内部署名。 entryTemplateAuthor = "赛事积分榜系统" @@ -464,19 +491,36 @@ const ( entryTemplateHintRows = 500 ) -// entryTemplateJointHint 按表头语言取「联队怎么写」的说明(批注正文 + 输入提示的标题与正文)。 -func entryTemplateJointHint(language string) (hint, title, prompt string) { - if language == "en" { - return entryJointHintEN, entryJointTitleEN, entryJointPromptEN - } - return entryJointHintZH, entryJointTitleZH, entryJointPromptZH +// entryColumnHint 是一列上的填表说明:表头那一格一条批注(红三角),整列一条输入提示。 +type entryColumnHint struct { + // Column 是 importColumns 里的规范列名,列号由它现算——改了列序也不会挂错格子。 + Column string + // Hint 是批注正文(可以长一点,写清怎么写),Prompt / Title 是输入提示。 + Hint string + Prompt string + Title string } -// entryTeamCodeColumn 是 team_code 在表头里的列号(0 起)。模板的说明挂在这一列上, -// 所以它从 importColumns 现算,改了列序也不会挂错格子。 -func entryTeamCodeColumn() int { +// entryColumnHints 按表头语言给出要挂说明的列;顺序只决定写进文件里的先后,各挂各的格子。 +func entryColumnHints(language string) []entryColumnHint { + if language == "en" { + return []entryColumnHint{ + {Column: "team_code", Hint: entryJointHintEN, Prompt: entryJointPromptEN, Title: entryJointTitleEN}, + {Column: "athlete_name", Hint: entryNameHintEN, Prompt: entryNamePromptEN, Title: entryNameTitleEN}, + {Column: "member_names", Hint: entryMemberHintEN, Prompt: entryMemberPromptEN, Title: entryMemberTitleEN}, + } + } + return []entryColumnHint{ + {Column: "team_code", Hint: entryJointHintZH, Prompt: entryJointPromptZH, Title: entryJointTitleZH}, + {Column: "athlete_name", Hint: entryNameHintZH, Prompt: entryNamePromptZH, Title: entryNameTitleZH}, + {Column: "member_names", Hint: entryMemberHintZH, Prompt: entryMemberPromptZH, Title: entryMemberTitleZH}, + } +} + +// entryColumnIndex 是规范列名在表头里的列号(0 起),没这列返回 -1。 +func entryColumnIndex(name string) int { for i, column := range importColumns { - if column.name == "team_code" { + if column.name == name { return i } } @@ -539,12 +583,11 @@ func (s *server) entriesTemplateXLSX(w http.ResponseWriter, r *http.Request) (an } headers := entryTemplateHeader(language) - hint, hintTitle, prompt := entryTemplateJointHint(language) + hints := entryColumnHints(language) sheets := make([]entriesTemplateSheet, 0, len(plan.sheets)) for _, planned := range plan.sheets { sheets = append(sheets, entriesTemplateSheet{ - Name: planned.Name, Headers: headers, - JointHint: hint, JointHintTitle: hintTitle, JointPrompt: prompt, + Name: planned.Name, Headers: headers, Hints: hints, }) } name := entryTemplateFilename(language) @@ -573,11 +616,8 @@ func entrySheetExcludedMessage(problems []string) string { type entriesTemplateSheet struct { Name string Headers []string - // JointHint / JointHintTitle / JointPrompt 是「单位编码写 A+B」的说明(按表头语言取), - // 挂在该列的批注与输入提示上。 - JointHint string - JointHintTitle string - JointPrompt string + // Hints 是挂在各列表头上的填表说明(按表头语言取),写进该列的批注与输入提示。 + Hints []entryColumnHint } // writeEntriesTemplateXLSX 写整份模板工作簿。 @@ -640,7 +680,7 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro if err := f.SetRowHeight(sheet.Name, 1, 24); err != nil { return err } - if err := writeEntryJointTeamHint(f, sheet); err != nil { + if err := writeEntryColumnHints(f, sheet); err != nil { return err } // 冻结表头:一张表几十行,往下翻的时候列名得一直看得见。 @@ -656,44 +696,46 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro return nil } -// writeEntryJointTeamHint 把「联队怎么写」挂在单位编码列上:表头那一格一条批注(有红三角), +// writeEntryColumnHints 把各列的填表说明挂上去:表头那一格一条批注(有红三角), // 整列一条输入提示(点中格子就弹出)。 // // 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是 // 「第 1 行表头 + 数据行」(列名与 importColumns 一致),批注与输入提示不是数据。 -func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error { - column := entryTeamCodeColumn() - if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" { - return nil - } - name, err := excelize.ColumnNumberToName(column + 1) - if err != nil { - return err - } - if err := f.AddComment(sheet.Name, excelize.Comment{ - Cell: name + "1", - Author: entryTemplateAuthor, - Text: sheet.JointHint, - Width: 360, - Height: 170, - }); err != nil { - return fmt.Errorf("httpapi: 写模板批注 %s!%s1: %w", sheet.Name, name, err) - } - if sheet.JointPrompt == "" { - return nil - } - // 刻意用 type="none":这里只要一条提示,不要任何取值限制——现场的表千奇百怪, - // 模板不该因为一条校验规则把好好的编码挡在门外。 - dv := excelize.NewDataValidation(true) - dv.SetSqref(fmt.Sprintf("%s2:%s%d", name, name, entryTemplateHintRows)) - dv.Type = "none" - title := sheet.JointHintTitle - if title == "" { - title = "单位编码" - } - dv.SetInput(title, sheet.JointPrompt) - if err := f.AddDataValidation(sheet.Name, dv); err != nil { - return fmt.Errorf("httpapi: 写模板输入提示 %s!%s: %w", sheet.Name, name, err) +func writeEntryColumnHints(f *excelize.File, sheet entriesTemplateSheet) error { + for _, hint := range sheet.Hints { + column := entryColumnIndex(hint.Column) + if column < 0 || column >= len(sheet.Headers) || hint.Hint == "" { + continue + } + name, err := excelize.ColumnNumberToName(column + 1) + if err != nil { + return err + } + if err := f.AddComment(sheet.Name, excelize.Comment{ + Cell: name + "1", + Author: entryTemplateAuthor, + Text: hint.Hint, + Width: 360, + Height: 170, + }); err != nil { + return fmt.Errorf("httpapi: 写模板批注 %s!%s1: %w", sheet.Name, name, err) + } + if hint.Prompt == "" { + continue + } + // 刻意用 type="none":这里只要一条提示,不要任何取值限制——现场的表千奇百怪, + // 模板不该因为一条校验规则把好好的编码挡在门外。 + dv := excelize.NewDataValidation(true) + dv.SetSqref(fmt.Sprintf("%s2:%s%d", name, name, entryTemplateHintRows)) + dv.Type = "none" + title := hint.Title + if title == "" { + title = hint.Column + } + dv.SetInput(title, hint.Prompt) + if err := f.AddDataValidation(sheet.Name, dv); err != nil { + return fmt.Errorf("httpapi: 写模板输入提示 %s!%s: %w", sheet.Name, name, err) + } } return nil } diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 1af0fd9..2f604fd 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -1445,9 +1445,13 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de // 队伍只对**纯团体报名**(这一行没填姓名)生效。个人报名按 (组别, 单位, 运动员) 唯一, // squad 不参与那条判断——填了就写不进库,所以要说一句,不能悄悄丢掉这一格。 + // 说清「改哪一格」比说清后果更要紧:现场看到「没写进库里」只会换个填法再试一遍 + // (「团体报名到底能不能写姓名」就是这么来的)。 if athleteID != nil && squad != "" { result.Notices = append(result.Notices, prefix+fmt.Sprintf( - "第 %d 行填了队伍「%s」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里", + "第 %d 行填了队伍「%s」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,"+ + "这一格没有写进库里。要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列;"+ + "这一行确实是个人报名,把「队伍」这一格删掉即可", line, squad)) squad = "" } @@ -1470,10 +1474,13 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de // 队员只对**纯团体报名**生效:带运动员姓名的行按个人报名处理,那种报名自己就是那个人的 // 身份(见 PUT /api/entries/{id}/athletes 的同一条口径),所以这一格只留文字、不关联。 // 认得出人时才说一句——这一格在个人行上本来就只是备注,空着或只写姓名都不必打扰现场。 + // 与上面队伍那条同一口径:给出「团体怎么填 / 个人怎么填」两条动作,不停在「这一格没关联」。 if athleteID != nil && len(link.Members) > 0 { result.Notices = append(result.Notices, prefix+fmt.Sprintf( - "第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行有运动员姓名、按个人报名处理:"+ - "队员只对团体报名(不填姓名那几行)生效,这一格只作文字名单保留,没有关联到人", + "第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行也填了运动员姓名,系统按个人报名处理:"+ + "队员只对团体报名生效,这一格只作文字名单保留、没有关联到人。"+ + "要报团体请把姓名列留空、队员的号码布就填在「队员名单」列;这一行确实是个人报名,"+ + "把「队员名单」这一格删掉即可", line, len(link.Members))) link.Members = nil } diff --git a/server/internal/httpapi/joint_team_template_test.go b/server/internal/httpapi/joint_team_template_test.go index 51aae4c..47ec921 100644 --- a/server/internal/httpapi/joint_team_template_test.go +++ b/server/internal/httpapi/joint_team_template_test.go @@ -4,6 +4,8 @@ import ( "net/http" "strings" "testing" + + "github.com/xuri/excelize/v2" ) // 报名模板要让填表人知道联队怎么写(devdocs/19):单位编码写 `A+B`,加号左边那个是主单位, @@ -26,19 +28,26 @@ func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) { if err != nil { t.Fatalf("[%s] 读工作表 %s 的批注: %v", lang, sheet, err) } - if len(comments) != 1 { - t.Fatalf("[%s] 工作表 %s 应有且仅有 1 条批注(挂在单位编码表头),得到 %d 条", lang, sheet, len(comments)) + byCell := map[string]string{} + for _, comment := range comments { + if _, row, err := excelize.CellNameToCoordinates(comment.Cell); err != nil || row != 1 { + t.Errorf("[%s] 批注只准挂在表头行(表头行是导入器认模板的锚点,其余行是数据区),"+ + "得到 %s:%q", lang, comment.Cell, comment.Text) + } + byCell[comment.Cell] = comment.Text } - comment := comments[0] - if comment.Cell != "C1" { - t.Errorf("[%s] 批注应挂在单位编码表头 C1,得到 %s", lang, comment.Cell) + text, ok := byCell["C1"] + if !ok { + t.Fatalf("[%s] 工作表 %s 的单位编码表头 C1 要有批注,得到 %v", lang, sheet, comments) } - if !strings.Contains(comment.Text, "A+B") { - t.Errorf("[%s] 批注没写清联队怎么写:%q", lang, comment.Text) + for _, comment := range comments { + // 批注作者写系统名(与导出件的文档属性同一套),不是构建期注入的内部署名。 + if comment.Author != "赛事积分榜系统" { + t.Errorf("[%s] 批注作者 = %q,期望系统名", lang, comment.Author) + } } - // 批注作者写系统名(与导出件的文档属性同一套),不是构建期注入的内部署名。 - if comment.Author != "赛事积分榜系统" { - t.Errorf("[%s] 批注作者 = %q,期望系统名", lang, comment.Author) + if !strings.Contains(text, "A+B") { + t.Errorf("[%s] 批注没写清联队怎么写:%q", lang, text) } validations, err := f.GetDataValidations(sheet)