From ee19b92182a68c62bc927c202b40966ccf4e8557 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 17 Sep 2026 04:03:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(export):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=A0=BC=E5=BC=8F=E3=80=8D=E6=8A=A5=E5=90=8D?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E5=AF=BC=E5=87=BA=EF=BC=88=E6=B1=87=E6=80=BB?= =?UTF-8?q?=20/=20=E5=88=86=E5=B0=8F=E9=A1=B9=EF=BC=89=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF=E9=80=90=E5=88=97=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E3=80=81=E5=8F=AF=E7=9B=B4=E6=8E=A5=E9=87=8D=E5=AF=BC?= =?UTF-8?q?=EF=BC=9B=E7=89=88=E6=9C=AC=20dev=5F0.5.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人反馈:导出页「报名名单」卡片导出的表格与导入模板对不上,没法拿回去重导。 核对属实(旧导出是给人看的报表):列序与 importColumns 不同、缺「种子 / 队员名单 / 队伍」 三列、姓名列被拼成「姓名(号码布)」、队名拼在单位名后面、状态列写「已退赛」而 normalizeImportStatus 不认 —— 整体错位 + 状态列报错,重导必炸。 - 新增 GET /api/export/entries-import.xlsx?format=flat|sheets&lang=zh|en&<四个筛选> · flat(缺省)= 所有行汇总在一张工作表;表名不含 @(导入侧见到带 @ 的表名就整份按 分工作表模板解析,汇总表定不出「小项@组别」会被拒) · sheets = 按「小项@组别」分工作表,表名走 entrySheetName(与模板、导入解析三处同源, 31 字符截断与撞名去重都跟着一起走) · 权限与筛选参数与 /api/export/entries.xlsx 完全一致(访客可读);空结果回 200 + 只有表头 · 列序不写常量:按 importColumns 逐列取值,表头走 entryTemplateHeader(lang), 加列/改序时测试当场红(TestEntryImportRecordCoversImportColumns) - 取值口径(重导的关键):团体行的「姓名」留空、队员写「队员名单」列(中文逗号分隔; 以库里 token 为底,再补上 entry_athletes 里后加的队员号码布,只增不减)、 队伍独立成列、状态写导入器认的四个中文说法(正常参赛/退赛/取消资格/未出场)、 联队照旧写 A+B、未分批留空、证件号/性别/年龄来自运动员档案(团体行空) - 新增 internal/export/entries_import.go(读库层,与报表导出同源 SQL、取值口径刻意不同) 与 internal/httpapi/entries_import_export.go(工作簿组装 + handler) - 原有报表导出(/api/export/entries.xlsx 与 internal/export/entries.go)一个字没动; 导出页新增两个按钮与中文/英文表头选择(只影响这两个导入格式) 测试:往返测试把导出件直接喂回报名导入解析器,flat 与 sheets 各断言四条(解析零错误 / 行数与 13 列逐项一致 / 团体行没被变成个人报名 / 两种格式都跑);另做变异验证 —— 状态改回 「已退赛」、给团体行填姓名,守卫当场变红。四套自检 ×2 全绿(finalcheck_0514.log: 前端 996 例 / Console 96 例 / 服务端 16 包,EXIT=0)。 无数据库迁移;已有成绩、名次与榜单不变。 文档:使用手册 §11.1(并订正「不要拿导出件重导」的旧话)、报名名单导入规范 新增第九节、 升级说明 新增 dev_0.5.14、常见问题 新增 Q104。 --- 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 | 26 +- docs/升级说明.md | 41 ++ docs/常见问题.md | 31 +- docs/报名名单导入规范.md | 39 +- server/internal/export/entries_import.go | 247 ++++++++ server/internal/export/entries_import_test.go | 247 ++++++++ .../internal/httpapi/entries_import_export.go | 315 +++++++++++ .../entries_import_export_internal_test.go | 106 ++++ .../httpapi/entries_import_export_test.go | 534 ++++++++++++++++++ server/internal/httpapi/export_handlers.go | 3 + web/src/admin/pages/ExportPage.vue | 120 +++- web/src/admin/pages/exportEntriesImport.ts | 132 +++++ .../admin/tests/entries-import-export.test.ts | 243 ++++++++ web/src/shared/basedata.ts | 79 ++- 19 files changed, 2135 insertions(+), 34 deletions(-) create mode 100644 server/internal/export/entries_import.go create mode 100644 server/internal/export/entries_import_test.go create mode 100644 server/internal/httpapi/entries_import_export.go create mode 100644 server/internal/httpapi/entries_import_export_internal_test.go create mode 100644 server/internal/httpapi/entries_import_export_test.go create mode 100644 web/src/admin/pages/exportEntriesImport.ts create mode 100644 web/src/admin/tests/entries-import-export.test.ts diff --git a/VERSION b/VERSION index b08fbd9..548b0df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.13 +dev_0.5.14 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 72f478721c69eadfa2ed39c39249b26f86fd0d42..48e0736340310dee48c64d3d07e0d83aac9ae499 100644 GIT binary patch delta 431 zcmdnCT6o)P;SE|jo4pt{m?j71E#>#q1|feSsm14~&9^y>QH*J_OyM#SV+n{9L^eoI zC`exjBKxLzsdQVihrg~MP%c1M&|R6mKGv}^+}d4*Jy2I*a}1*Z)8sv6DB|Z1%?_AT6}PZHIoDf%vuD)~CTUMK zE^iGUpm=?Zqn8?YN2*s-f{U*fZ-5T}=AKqbrpc!D^Cf(=_<{{ZXO>3;{ZJR}P!nnE zrpyX)2GA!=O^YM~^n~11IEwsDI@7&tqwEmQ=xGJ2xYROV+C!BS=vE-|QsZh(c3;$x zS{`EIp~?X=rl)l)*d-7JK>vgqiY{(UD+x05RO5sw0J@~KYrZHXynyO_HF+W71q#qe vMxeA)FD#@3bOhibEzdMrbi#_wzQDL>embfB=_E!VW&&d7?N2AMTyz2et^0mv delta 436 zcmdnCT6o)P;SE|joc=lj0bsP*IOhlxKalIK!Q-jQ>8;KkpewLBj8TkfvP|JJ5fceN zZGKNRt|$wc98cW@8wGC-Ziwug;-%8yfsmF(@Ue;l09Zu#x^B5`)KljjEG?r zV4A$AY=tz?fJ`^-5JORSW%f`*(S>y>iS|lBV*+&rH^+k%^;a&E@K6Og31|_>87dq= zXUwfmNO4r%>{+#gNg5*StHl>#B2nmP)S2ep80X}x#T%f*zqzMXl4-JO{d@_aexPGw zt>k*L{N~jp#@on)EdjX%=ozM_MNkz$8(bAwGF`Q1mPh+(^Fho2s<_lLUmEBDpe@lB zG7+YdF7nKYwu%etl7sYxfa-u2ZDo@1Qs)M`E!$IfMrl+_lH1&>_{uOVFLkcXovkuV zlS{ki3jv)0R1|J12@J0|Ymgg&;RTd)>Rk#7W}p*+!R!MIW_hN`q7zoILE@tM@Pzim S6BvP*35c1uAD+PS*#!XjeSLBO diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp index aee48112f04836c800c1d1dbaef87aef6863f8a4..21708e00a7e58355537a0877ce81f58ae224e16c 100644 GIT binary patch delta 1007 zcmZ8gO-~b16wRAX3vGvfpkfH3ZD~9Ghk`5&{GPks6?Yf`tZ9#1K#d z*nlSDQWFhgL<`IxaN&v#D_3q@=|&QEZamY$!I<1LdC8o6@44r`x4+HpZ*z0Ir1SH+ zMyFPcCZ|@6s7uF3ODGS`zx_g@q(EZeATk;qny^*HIhgb~b(Q8D4@ig@|D~Zsm{s>w z8($bML4ioVYjtE@P|01=-0g3IE`L7-h zyq=GZapmz)@o1!UDBzmys6ldSv}L%#9rN4JlAk#u2|1hTmJKR~NVIzDF3BP`#%G|V zv3v#!efWH{u$R`~Xku=1X+x|!HPW)PHUS=I&p{5XgHFjhF=6kRs?GR}?Q~o&z&M+e zU07wvqKwtEA|HF<^URc&<~J0Sj~JtFebAB(kM`E5W;?t-gY34?E>VSnlSkwV%~vo~ zkX+2HMe}^T9piO*E{L+BLfrNV^-vFL0Y-)F8`lh=t`0Z%dQDQ*69E(FAwVdJ5RJLt zc^z!H_wMn?Vt=qf(t5U#@)EO1J^eZ~817}VJF}&@AcOro+^$Km;*^@{fcuFn@qT%1 zZzK667CB+h%h%f}CKsjPEA}_nT{jqT3R@6;I(Ab5vffQ8as{1Etzf8C6)eiatHAq# zlc@@d6BvvF|8TX1*H=p~mQ_)J3=qz8KmlX{*?|!vFvP delta 1024 zcmZ8gNl#N@6wUW+ftJ!irpOcvr7aK2*Z>uRpanY6fhk2=iUL7~FeAud3NosQOc8@a zq6Q>NB+Bsq01H>RVCBk7vdozx-|1#@1>EKr;v{kS*Q%A65=AyvTA3D#uqB0<6Gir9sZ%H48K6x2 zA+Zo8I5)DR#=O^`4|z8xTh}LAo(;H1T=_^TOOjCX5R)n3ejSlg!j>h4pkTKxV{^KF zXK~=w=&kv7=R!xt`ef_w(%{;l$EA<7u^}{Tlt~kCkG>HbQN0+hN7-&fRLwE zXGVB(V{RL@Gfn1CPx@c`8a;-Xvzjp~dLvc8QB%n)zzM6@?s`Dop6}aw*mbY=YI&-n zjFoT^DyBfNcgp-n?ytz6ZZxg*y57%rEO%FX{EB%pw-Ue@$*TK+h@x@h*aRrV!er$<#xbrc+oo z!ClsLSTWmdv1q8@96w`!B!sBI_U2v2fM&IrU*KY;5CdW*CT<$dYSG_>uvn&muqbTN z1UD9{wjcM-wc0&~SS$oowVP5lCR*@g^L35&-#)wX)E;!G$_SroD20U}z40|++|LVl zF!xAJK30#~b6=>$6+%gUzS37$wA59#*jYL5z1C2WfH823^dc{*m^=Y@s$)tC{rn&X zKJwcPH^JkM^-Pg~Tg+l?WYyag7>y4nPcWl5Fu( 这份是**给人看的报表,不能导回去**。同一张卡片下面还有「**导出(汇总·导入格式)**」「**导出(分小项·导入格式)**」两个按钮,导出的 13 列与报名导入模板逐列一致、可以直接重新导入(见 Q104)。 + **Q88:基础数据(单位 / 大项 / 小项 / 组别)能批量导入导出吗?** 能。到「导出 → 基础数据」页: @@ -732,3 +734,30 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - 升级前的旧版本遇到这个现象时,**挪一下盖在它上面的窗口**即可立刻恢复显示,**不需要重新发布成绩、也不需要重算**。详见《升级说明》的 `dev_0.5.13` 一节。 > 顺带分清另一件事:「**撤销发布**」**保留**成绩内容(只把分收回来,改完重新发布即可);要**连成绩内容一起清掉**,那是工具栏上另一对按钮「**清空本组 / 清空本批**」(危险操作,要勾选确认,清整组时还会一并清掉按单位录的团体名次)。两者的对照见使用手册 4.7。 + +**Q104:导出的报名名单为什么和导入模板不一样?能不能直接导回去?** + +**先说结论:以前导出的那份不能,现在报名名单卡片里多了两个能导回去的按钮——挑对按钮就行。** + +原来「导出报名名单」导出的是一张**给人看的报表**:额外有小项名称、单位名称与备注三列,**姓名列拼了队员**、状态写「已退赛」、单位名后面还拼着队名。**导入器不认这份表**(列对不上,状态那个写法也不认),拿它去「基础数据 → 报名导入」重导会被拦下——所以那份只能看、不能导回去。 + +现在同一个**报名名单卡片**里多了一组「**导入格式**」导出,与「基础数据 → 报名导入」里下载的模板**逐列一致**,可以直接导回去: + +| 按钮 | 导出来是什么 | 文件名 | +| --- | --- | --- | +| 导出(汇总·导入格式) | 13 列汇总在**一张工作表**里 | `报名名单(导入格式).xlsx` | +| 导出(分小项·导入格式) | 13 列,按「小项名称@组别名称」**分成多张工作表** | `报名名单(导入格式·分小项).xlsx` | + +- 13 列是:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍(顺序就是这个,没有小项名称 / 单位名称 / 备注那三列); +- **团体报名的「姓名」列是空的**、队员写在「队员名单」列(「姓名」列填了就会被当成个人报名);「**队伍**」是**独立的一列**(报表件里是拼在单位名后面的);「状态」写的是导入器认的四个中文说法:`正常参赛` / `退赛` / `取消资格` / `未出场`; +- 筛选与权限跟报表一样:小项 / 组别 / 单位 / 批次都可留空(留空 = 不筛),**访客也能导**; +- 上方的**表头语言**(中文表头 / 英文表头)**只管这两个导出**,改它不会动上面那份报表(界面上写明了); +- **往返已经测过**:这两种导出件原样喂回导入接口,零错误、行数一致、13 列逐项一致,**团体行不会被变成个人报名**(队员关联还在)。想自己验一遍:导出 → **一个字都不改** → 在「基础数据 → 报名导入」原样上传,结果应当是「新增 0 / 更新 N / 错误 0」。 + +**重导前要知道的三条限制**(它不是"完全无损"): + +1. **号码布可能进冲突面板**:导出件写的是**报名上登记的号码布**,导入时对个人行会拿它跟**运动员档案里的号码布**比;两边都非空且不同,这一行就进「冲突」列表等你逐条决定(库里两者一致是常态,那时是静默幂等); +2. **联队的份额会变成均分**:13 列里联队只能写 `A+B`,重导回去是**各 50%**;当初设成 `2:1` 这种非均分的会变均分——表格本身表达不了,要保权重只能在界面上改; +3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后这支队的队员**不会少**,但个别名单的**顺序 / 写法**可能与最初上传的那张表不完全一样。 + +**一句话**:给人看的用「导出报名名单」;**要拿回去重导,用「导出(汇总·导入格式)」或「导出(分小项·导入格式)」**,导出的文件**不要改表头、不要插删列**。详见[使用手册 §11.1](./使用手册.md)与[报名名单导入规范 · 第九节](./报名名单导入规范.md#九导出的名单怎么导回去)。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index 1f5563b..cfb603d 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -125,7 +125,7 @@ F1+F2 **导入之后长什么样** - 联队仍是一条报名,但**分与奖牌都按份额记给各成员单位**:单位总分榜、单位奖牌榜上 A、B 各有一份; -- 大屏和导出件里写的是**联队的名字**:`第一分公司+第二分公司联队`,报名名单导出的「单位编码」列也回写成 `F1+F2`(**导出的名单可以直接拿去重导**,不用手工改回单个单位); +- 大屏和导出件里写的是**联队的名字**:`第一分公司+第二分公司联队`,报名名单导出的「单位编码」列也回写成 `F1+F2`(**这一列本身可以直接重导**,不用手工改回单个单位;但那两份「报名名单」导出的口径不同——**报表件整份不能导回,要重导用卡片里的两个「导入格式」按钮**,见第九节); - 用成员单位筛「报名名单」导出时,**也能看到这条联队报名**(否则 B 导出自己的名单会以为没派人上场)。 ### `athlete_name` 运动员姓名 @@ -314,7 +314,7 @@ A001,A002 - `F2` 在**加号左边**,所以 **F2 是主单位**:这一条的名次与榜单以它为主体。写成 `F5+F2` 是另一条报名(主单位变成 F5),同一组别里只写一种; - 默认**均分**:这条成绩拿到的分,F2 与 F5 **各一半**(团体第 1 名 15 分 → 各 7.5 分);奖牌**两边各记一枚整枚**(一块金牌,F2 榜上 1 枚、F5 榜上 1 枚); - 要改成 **2:1** 这类非均分:**不能在这张表里写**,导入后到「成绩录入」页点那一行的「单位」格改(见[使用手册 · 4.11](./使用手册.md#411-联队两个单位合并参赛)); -- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(可以直接拿去重导)。 +- 大屏与导出件里写的是 `第二分公司+第五分公司联队`,报名名单导出的「单位编码」列回写成 `F2+F5`(**这一列本身可以直接重导**;报表件整份不能导回,要重导用报名名单卡片里的两个「导入格式」按钮,见第九节)。 ### 例 5:同一个单位派两个队(团体项目多队) @@ -503,7 +503,40 @@ Excel 对工作表名有硬限制:**最长 31 个字符**,同一份工作簿 --- -## 九、给填写人的最短提示(一页版) +## 九、导出的名单怎么导回去 + +系统里一共能导出**三份**报名名单,**只有标着「导入格式」的那两份是给导入器看的**(三份都在「导出 → 结果」页签的**报名名单卡片**里,四个筛选完全一样): + +| 卡片上的按钮 | 导出来是什么 | 文件名 | 对应哪种模板 | +|---|---|---|---| +| 导出报名名单 | 给人看的**报表**:另含小项名称、单位名称与备注,姓名列拼了队员,状态写「已退赛」 | `报名名单.xlsx` | **不能导回**(导入器不认它) | +| 导出(汇总·导入格式) | 13 列,所有行**汇总在一张工作表** | `报名名单(导入格式).xlsx` | **单表模板**(第二、三节) | +| 导出(分小项·导入格式) | 13 列,按「小项名称@组别名称」**分成多张工作表** | `报名名单(导入格式·分小项).xlsx` | **分工作表模板**(第八节) | + +- 两个「导入格式」导出的 **13 列与第二节的模板逐列一致,顺序也一致**:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍。**没有小项名称、单位名称与备注列**——重导用不着它们(「项目编码 + 组别」已经足够定位); +- 卡片上的**「表头语言」两个按钮(中文表头 / 英文表头)只影响这两个导出**,与第二节的两套表头同一口径(选英文走规范列名 `event_code` / `category` / `team_code` / `athlete_name` / `id_card` / `gender` / `age` / `bib` / `seed` / `heat` / `status` / `member_names` / `squad`;两条路都能导回来); +- 筛选(小项 / 组别 / 单位 / 批次)与报表**完全一样**,都可留空(留空 = 不筛这一维度);**访客也能导**; +- 导出的文件**原样传回「基础数据 → 报名导入」就能导回去**:**不要改表头、不要插列删列、不要把列名换成别的写法**;要改内容就在格子里改; +- 分小项那种格式的**工作表名仍然是「小项名称@组别名称」**,与模板一字不差(8.1、8.3 的规矩照样适用)——**不要手工改工作表名**,改了就对不上号。本来没人报名的组合不会出一张空表; +- 两种格式都把**团体报名的姓名列留空**、队员写进「队员名单」列(「姓名」列填了就是个人报名,见第三节)——这正是它与报表件最关键的区别; +- 「状态」列写的是导入器认的**四个中文说法**:`正常参赛` / `退赛` / `取消资格` / `未出场`。报表件写的是「已退赛」,**那个导入器不认**(会报「status 无法识别」),所以报表件不能重导; +- 「单位编码」列照旧:联队写 `A+B`(加号左边是主单位);按成员单位筛也能筛出这条报名。 + +### 9.1 往返验证过的(现场也可以自己验一遍) + +把这两种导出件**原样喂回导入接口**各跑一遍,结论是:**零错误、行数一致、13 列逐项一致、团体行没有被变成个人报名**(队员与那支队的关联还在)。现场自验的办法:导出 → **一个字都不改** → 在「基础数据 → 报名导入」原样上传 → 结果应当是「新增 0 / 更新 N / 错误 0」,名单看不出变化。 + +> ⚠️ 但它**不是"完全无损"的往返**:重导前先看下面三条限制。 + +### 9.2 三条要知道的限制 + +1. **「号码布」列可能撞冲突面板**:导出件里写的是**报名上登记的号码布**;导入时对**个人行**会拿它跟**运动员档案里的号码布**比对,两边都非空且不一样,这一行就进「冲突」列表,等你在导入结果里逐条选「保留已有数据 / 使用新数据」(见第六、七节)。库里这两者通常是**一致**的(一致时是静默幂等,什么都不用做);不一致时重导**不是**静默幂等,会走这套既有的冲突流程; +2. **联队的份额权重不会被带回来**:13 列里联队只能写成 `A+B`,重导回去是**平均分(各 50%)**。当初设成 `2:1` 这种非均分的联队,重导之后会变成均分——**表格格式本身表达不了这件事**,要保权重只能在界面上改(到「成绩录入」点那一行的「单位」格); +3. **「队员名单」只增不减**:导出时会**以库里存的名单为底,再把界面上后来补进这支队的队员号码布补上**(否则那些人在重导后就没了)。所以重导后这支队的队员**不会少**,但个别名单的**顺序或写法**可能与最初上传的那张表不完全一样——以系统里现在的名单为准,是正常的。 + +--- + +## 十、给填写人的最短提示(一页版) > 1. 用系统里下载的模板(**中文表头 / 英文表头都能用**,见第二节),**第一行不要动**、同一列不要写两遍; > 2. **一个人一行**;一个人报几项就写几行; diff --git a/server/internal/export/entries_import.go b/server/internal/export/entries_import.go new file mode 100644 index 0000000..527c92b --- /dev/null +++ b/server/internal/export/entries_import.go @@ -0,0 +1,247 @@ +package export + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 本文件是「报名名单(导入格式)」的读库层:**逐列与报名导入模板一致**,导出来的表可以直接 +// 拿去重新导入(往返口径由 httpapi/entries_import_export.go 组装工作簿, +// 端到端往返用例见 httpapi/entries_import_export_test.go)。 +// +// 它与 entries.go 的报表导出读的是同一份数据,但**取值口径刻意不同**,两处不能互相套用: +// - 报表(LoadEntriesWorkbook)是给人看的:团体报名的姓名那一格拼上队员、状态写「已退赛」、 +// 单位名列写全成员并带队名; +// - 本格式是给导入器看的:姓名那一格**留空**才算团体报名(填了姓名就会被当成个人报名)、 +// 状态只能写导入器认的四个中文口径、列必须与 importColumns 逐列对得上。 +// +// 两处一致的是「怎么读」:同一段 JOIN、同一套筛选、同一个 ORDER BY(未分批排最前)、 +// 同样的联队回写(A+B)与队员关联。现场拿两份导出件对账时,行序与行数不会对不上。 + +// EntryImportRow 是「报名名单(导入格式)」的一行:13 个取值对应报名导入模板的 13 列 +// (列序不在这里——由 httpapi 侧按 importColumns 的列序取值,见 entryImportRecord)。 +// +// 除这 13 列外另带 (小项, 组别) 的身份字段:EventID / CategoryID 用于把行分到工作表上, +// EventName / CategoryName 用于拼「小项名称@组别名称」的工作表名(entrySheetName)。 +type EntryImportRow struct { + EventID int64 + EventCode string + EventName string + CategoryID int64 + CategoryName string + + TeamCode string + AthleteName string + IDCard string + Gender string + Age string + Bib string + Seed string + Heat string + Status string + MemberNames string + Squad string +} + +// LoadEntriesImportRows 读报名名单,取值口径按「与导入模板逐列一致」来(见 EntryImportRow)。 +// +// 筛选条件(EntriesFilter)与报表导出完全一样:某一项为零值即该维度不筛选;按单位筛选时 +// **连同联队里的成员单位一起算**(与 LoadEntriesWorkbook 同一条:成员单位同样在这条报名上 +// 拿分拿牌,按 B 导出时看不到它,B 就不知道自己派了谁上场)。 +// +// opts 只为与其它 Load* 同形:本格式没有抬头(第一行就是列名),Meta 一概不用。 +func LoadEntriesImportRows(db *sql.DB, filter EntriesFilter, opts Options) ([]EntryImportRow, error) { + if db == nil { + return nil, ErrNoDatabase + } + // 联队配置:单位编码回写成导入表的 A+B 写法,否则重导会把联队丢掉、只剩主单位那条份额。 + joints, err := loadJointTeams(db) + if err != nil { + return nil, err + } + // 队员关联:整份名单读一次,逐行按 entry_id 取(与报表导出同一个 loader)。 + members, err := loadEntryMembers(db) + if err != nil { + return nil, err + } + + // 这段查询与 entries.go 的 LoadEntriesWorkbook **同源**:同样的 JOIN、同样的筛选、 + // 同样的 ORDER BY。刻意没有抽成公共函数——两个导出件读的数据一样、取值口径不同, + // 共用一个函数会让人误以为取值也一样。**改这里要同步改那里**。 + query := ` + SELECT ev.id, COALESCE(ev.code, ''), COALESCE(ev.name, ''), + COALESCE(c.id, 0), COALESCE(c.name, ''), + COALESCE(t.code, ''), COALESCE(en.squad, ''), + COALESCE(a.name, ''), COALESCE(a.id_card, ''), COALESCE(a.gender, ''), a.age, + COALESCE(en.bib, ''), COALESCE(en.seed, ''), en.heat, en.status, + COALESCE(en.member_names, ''), en.athlete_id, en.id + FROM entries en + JOIN events ev ON ev.id = en.event_id + JOIN teams t ON t.id = en.team_id + LEFT JOIN event_categories c ON c.id = en.category_id + LEFT JOIN athletes a ON a.id = en.athlete_id` + var ( + conds []string + args []any + ) + for _, f := range []struct { + column string + value int64 + }{ + {"en.event_id", filter.EventID}, + {"en.category_id", filter.CategoryID}, + {"en.heat", filter.Heat}, + } { + if f.value != 0 { + conds = append(conds, f.column+" = ?") + args = append(args, f.value) + } + } + // 单位筛选要连「联队里的成员单位」一起算:entries.team_id 只是主单位(加号左边那个), + // 但成员单位同样在这条报名上拿分拿牌——按 B 导出时看不到它,B 就不知道自己派了谁上场。 + // 主单位那一半条件照旧留着:份额行缺失的报名(手工造的老数据)不能因此从筛选结果里掉出去。 + // 份额表不在(库还没跑到 0024)时不加这段:那种库里根本没有联队。 + if filter.TeamID != 0 { + teamCond := "en.team_id = ?" + args = append(args, filter.TeamID) + ready, err := entrySharesReady(db) + if err != nil { + return nil, err + } + if ready { + teamCond += ` OR EXISTS (SELECT 1 FROM entry_team_shares s + WHERE s.entry_id = en.id AND s.team_id = ?)` + args = append(args, filter.TeamID) + } + conds = append(conds, "("+teamCond+")") + } + if len(conds) > 0 { + query += " WHERE " + strings.Join(conds, " AND ") + } + // 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。 + // 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。 + query += ` + ORDER BY ev.sort_order, ev.code, c.sort_order, c.id, + en.heat IS NOT NULL, en.heat, t.sort_order, t.code, en.id` + + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("export: 读取报名名单(导入格式): %w", err) + } + defer func() { _ = rows.Close() }() + + var out []EntryImportRow + for rows.Next() { + var ( + row EntryImportRow + memberJSON string + heat sql.NullInt64 + age sql.NullInt64 + athleteID sql.NullInt64 + athleteName string + entryID int64 + ) + if err := rows.Scan(&row.EventID, &row.EventCode, &row.EventName, + &row.CategoryID, &row.CategoryName, + &row.TeamCode, &row.Squad, + &athleteName, &row.IDCard, &row.Gender, &age, + &row.Bib, &row.Seed, &heat, &row.Status, + &memberJSON, &athleteID, &entryID); err != nil { + return nil, fmt.Errorf("export: 扫描报名名单(导入格式): %w", err) + } + // 联队:单位编码回写成 A+B(与报表导出同一个 joint.Code()),重导回去才认得出这条联队。 + if joint, ok := joints[entryID]; ok { + row.TeamCode = joint.Code() + } + if heat.Valid { + row.Heat = strconv.FormatInt(heat.Int64, 10) + } + if athleteID.Valid { + // 个人报名:姓名 / 证件号 / 性别 / 年龄都取这位运动员档案上的值。 + // 团体报名(athlete_id 为空)这几列全留空——姓名一旦填了,导入侧就当成个人报名了。 + row.AthleteName = athleteName + if age.Valid { + row.Age = strconv.FormatInt(age.Int64, 10) + } + } else { + // 团体报名:队员名单才是这一行的内容(导入侧按号码布认人)。 + row.MemberNames = importMemberNames(memberJSON, members[entryID]) + } + // 状态写**导入器认的**中文口径(正常参赛 / 退赛 / 取消资格 / 未出场)。 + // 报表导出的「已退赛」导入器不认,照抄会让整份表每一条退赛行都报「status 无法识别」。 + row.Status = entryImportStatusLabel(row.Status) + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历报名名单(导入格式): %w", err) + } + return out, nil +} + +// entryImportStatusLabel 把库里的 status 写成导入器认的中文口径。 +// +// 取值必须是 normalizeImportStatus 认得的那几个(见 entry_status.go):正常参赛 / 退赛 / +// 取消资格 / 未出场。**不要**改成录入页徽标的「已退赛」——那是给人看的,导入器不认。 +// 认不出的历史值原样写出(库里被 CHECK 约束守着,正常走不到这一支), +// 宁可让导入当场报「status 无法识别」,也不静默把一条状态改写成别的意思。 +func entryImportStatusLabel(status string) string { + switch domain.EntryStatus(status) { + case domain.EntryStatusRegistered, "": + return "正常参赛" + case domain.EntryStatusScratched: + return "退赛" + case domain.EntryStatusDQ: + return "取消资格" + case domain.EntryStatusDNS: + return "未出场" + default: + return status + } +} + +// importMemberNames 拼团体报名的「队员名单」一格:多个队员用中文逗号「,」分隔 +// (导入侧 splitNames 认 , , / 三种分隔符,中文逗号在 Excel 里也最不容易被当成数字分隔符)。 +// +// 取值以库里 member_names 存着的 token 为底,再把 entry_athletes 里已关联、但不在 token 里的 +// 队员补上(有号码布写号码布,没有号码布的退回姓名)。两头都不能省: +// - 只导 token 会丢掉界面上后补的队员(他们只活在 entry_athletes 里,一个字都不在 token 中); +// - 只导号码布会把纯文字名单丢掉(人没录档案、临时借调的,token 里只有名字)。 +// +// 号码布优先是因为导入侧就是按号码布认人的:写了号码布这一格重导回去仍关联得到人, +// 只写姓名则退化成文字(与导出件其它地方的 memberLabel 同一口径)。去重、顺序稳定: +// token 按原书写顺序在前,补上的队员按 entry_athletes 的顺序在后。 +func importMemberNames(memberJSON string, members []entryMember) string { + tokens := memberNamesOf(memberJSON) + if tokens == nil { + // 空值、坏 JSON:整格当一段原样带过去。历史上 member_names 被手工写成过别的样子, + // 那些字在这个格式里同样不能丢。 + if raw := strings.TrimSpace(memberJSON); raw != "" { + tokens = []string{raw} + } + } + out := make([]string, 0, len(tokens)+len(members)) + seen := make(map[string]bool, len(tokens)+len(members)) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + return + } + seen[value] = true + out = append(out, value) + } + for _, token := range tokens { + add(token) + } + for _, member := range members { + if bib := strings.TrimSpace(member.Bib); bib != "" { + add(bib) + continue + } + add(member.Name) + } + return strings.Join(out, ",") +} diff --git a/server/internal/export/entries_import_test.go b/server/internal/export/entries_import_test.go new file mode 100644 index 0000000..25d5654 --- /dev/null +++ b/server/internal/export/entries_import_test.go @@ -0,0 +1,247 @@ +package export + +import ( + "database/sql" + "testing" +) + +// seedEntriesImport 造一份覆盖导入格式全部取值口径的报名名单: +// 个人行(含同一人两个组别)/ 团体行(姓名必须留空)/ 联队(T1+T2)/ 多队(squad)/ +// 未分批(heat 为空)/ 四种状态 / 文字名单与队员关联并存。 +// +// 与 seedEntries(报表导出的造数)刻意分开:那份的数据是按「给人看的报表」挑的 +// (团体行的姓名要回落成员名单、状态要写「已退赛」),两边的期望值不一样。 +func seedEntriesImport(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一分公司', '一公司', 1, 1)`, + `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T2', '第二分公司', '二公司', 2, 1)`, + `INSERT INTO athletes(name, gender, team_id, id_card, bib, age) VALUES ('张伟', '男', 1, '110101199001011234', 'A001', 35)`, + `INSERT INTO athletes(name, gender, team_id, id_card, bib, age) VALUES ('李强', '男', 2, '110101199202022345', 'B001', 33)`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王磊', '男', 1, 'A002')`, + `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵雷', '男', 1, 'A003')`, + `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`, + `INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E1', '100米跑', 'ongoing', 1)`, + `INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E2', '4×100米接力', 'ongoing', 2)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`, + // 1 个人行:号码布带前导零、有种子、已分批、正常参赛。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, seed, heat, status) + VALUES (1, 1, 1, 1, '007', '3', 2, 'registered')`, + // 2 个人行:未分批(heat 为 NULL)、退赛。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status) + VALUES (1, 1, 2, 2, 'B001', 'scratched')`, + // 3 团体行 + 一队:文字名单里既有一个已关联的号码布,也有一段认不出人的文字。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status) + VALUES (1, 1, 1, '["A002","预备队员"]', '一队', 'dq')`, + // 4 团体行 + 二队:文字名单里只有文字,队员是界面上后补的(只活在 entry_athletes 里)。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status) + VALUES (1, 1, 1, '["临时队员"]', '二队', 'dns')`, + // 5 个人行:同一个人报第二个组别(号码布重复出现是合法的)。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status) + VALUES (1, 2, 1, 1, '007', 'registered')`, + // 6 联队团体行:T1 + T2 各占一半,队员名单里写的是姓名(要补上号码布才认得出人)。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, status) + VALUES (2, 3, 1, '["王磊","赵雷"]', 'registered')`, + `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (6, 1, 1, 1, 0)`, + `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (6, 2, 1, 1, 1)`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 0)`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (4, 4, 0)`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (6, 3, 0)`, + `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (6, 4, 1)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// importKey 是测试里给每行起的键:小项编码 + 组别 + 队伍 + 号码布(四者合起来唯一)。 +func importKey(r EntryImportRow) string { + return r.EventCode + "\x00" + r.CategoryName + "\x00" + r.Squad + "\x00" + r.Bib +} + +func loadImportRowsByKey(t *testing.T, db *sql.DB, filter EntriesFilter) map[string]EntryImportRow { + t.Helper() + rows, err := LoadEntriesImportRows(db, filter, Options{}) + if err != nil { + t.Fatalf("读取报名名单(导入格式): %v", err) + } + out := make(map[string]EntryImportRow, len(rows)) + for _, r := range rows { + key := importKey(r) + if _, dup := out[key]; dup { + t.Fatalf("同一份名单里出现两行同样的键 %q:测试数据的键不够唯一", key) + } + out[key] = r + } + return out +} + +func TestLoadEntriesImportRows(t *testing.T) { + db := openEventDB(t) + seedEntriesImport(t, db) + + rows, err := LoadEntriesImportRows(db, EntriesFilter{}, Options{}) + if err != nil { + t.Fatalf("读取报名名单(导入格式): %v", err) + } + if len(rows) != 6 { + t.Fatalf("行数 = %d,期望 6", len(rows)) + } + got := loadImportRowsByKey(t, db, EntriesFilter{}) + + // 个人行:姓名 / 证件号 / 性别 / 年龄取运动员档案,号码布 / 种子 / 批次 / 状态取报名上的值。 + personal := got["E1\x00男子\x00\x00007"] + if personal.AthleteName != "张伟" || personal.IDCard != "110101199001011234" || + personal.Gender != "男" || personal.Age != "35" { + t.Errorf("个人行的人员字段 = %+v,期望张伟 / 110101199001011234 / 男 / 35", personal) + } + if personal.TeamCode != "T1" || personal.Seed != "3" || personal.Heat != "2" || personal.Status != "正常参赛" { + t.Errorf("个人行的报名字段 = %+v,期望 T1 / 种子 3 / 批次 2 / 正常参赛", personal) + } + if personal.MemberNames != "" { + t.Errorf("个人行的队员名单 = %q,期望留空(队员只对团体报名生效)", personal.MemberNames) + } + if personal.EventID != 1 || personal.CategoryID != 1 || personal.EventName != "100米跑" || personal.CategoryName != "男子" { + t.Errorf("个人行的 (小项, 组别) 身份字段 = %+v,组装工作表名要用它", personal) + } + + // 未分批:heat 留空;四种状态的中文口径逐个钉住。 + unbatched := got["E1\x00男子\x00\x00B001"] + if unbatched.Heat != "" { + t.Errorf("未分批行的批次 = %q,期望留空", unbatched.Heat) + } + if unbatched.Status != "退赛" { + t.Errorf("scratched 的状态 = %q,期望 退赛(导入器认的中文口径)", unbatched.Status) + } + if s := got["E1\x00男子\x00一队\x00"].Status; s != "取消资格" { + t.Errorf("dq 的状态 = %q,期望 取消资格", s) + } + if s := got["E1\x00男子\x00二队\x00"].Status; s != "未出场" { + t.Errorf("dns 的状态 = %q,期望 未出场", s) + } + for _, key := range []string{"E1\x00男子\x00\x00007", "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"} { + if s := got[key].Status; s != "正常参赛" { + t.Errorf("%s 的状态 = %q,期望 正常参赛", key, s) + } + } + // 号码布带前导零:它是字符串,导出件里不能变成 7。 + if got["E1\x00男子\x00\x00007"].Bib != "007" { + t.Errorf("号码布 = %q,期望 007", got["E1\x00男子\x00\x00007"].Bib) + } + + // 团体行:姓名必须留空(填了姓名导入侧就当成个人报名了)。 + teamOne := got["E1\x00男子\x00一队\x00"] + if teamOne.AthleteName != "" || teamOne.IDCard != "" || teamOne.Gender != "" || teamOne.Age != "" { + t.Errorf("团体行的人员字段 = %+v,期望全部留空(姓名一填就变成个人报名)", teamOne) + } + // 队员名单:文字 token 原样保留,已关联的队员不重复写(A002 与王磊是同一个人)。 + if teamOne.MemberNames != "A002,预备队员" { + t.Errorf("团体行的队员名单 = %q,期望 A002,预备队员(token 去重在前)", teamOne.MemberNames) + } + + // 界面上后补的队员(只活在 entry_athletes 里)必须按号码布补进队员名单,否则重导就丢了这个队员。 + teamTwo := got["E1\x00男子\x00二队\x00"] + if teamTwo.MemberNames != "临时队员,A003" { + t.Errorf("团体行的队员名单 = %q,期望 临时队员,A003(后补的队员按号码布补上)", teamTwo.MemberNames) + } + + // 联队:单位编码回写成 A+B(与报表导出同一个 joint.Code()),重导才认得回这条联队。 + joins := got["E2\x00男子\x00\x00"] + if joins.TeamCode != "T1+T2" { + t.Errorf("联队的单位编码 = %q,期望 T1+T2", joins.TeamCode) + } + if joins.MemberNames != "王磊,赵雷,A002,A003" { + t.Errorf("联队的队员名单 = %q,期望 王磊,赵雷,A002,A003(文字名单 + 补上的号码布)", joins.MemberNames) + } +} + +// 筛选条件与报表导出同一套:按单位筛选要连联队里的成员单位一起算, +// 否则 B 按自己导出时看不到「A+B 那条联队里有我派的人」。 +func TestLoadEntriesImportRowsFilters(t *testing.T) { + db := openEventDB(t) + seedEntriesImport(t, db) + + cases := []struct { + name string + filter EntriesFilter + want []string + }{ + {"全部", EntriesFilter{}, []string{ + "E1\x00男子\x00\x00B001", "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00", + "E1\x00男子\x00二队\x00", "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"}}, + {"按小项", EntriesFilter{EventID: 1}, []string{ + "E1\x00男子\x00\x00B001", "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00", + "E1\x00男子\x00二队\x00", "E1\x00女子\x00\x00007"}}, + {"按组别", EntriesFilter{CategoryID: 3}, []string{"E2\x00男子\x00\x00"}}, + {"按批次", EntriesFilter{Heat: 2}, []string{"E1\x00男子\x00\x00007"}}, + // T2 是联队成员:那条联队报名必须出现(主单位是 T1)。 + {"按单位(含联队成员单位)", EntriesFilter{TeamID: 2}, []string{"E1\x00男子\x00\x00B001", "E2\x00男子\x00\x00"}}, + {"按单位(联队主单位)", EntriesFilter{TeamID: 1}, []string{ + "E1\x00男子\x00\x00007", "E1\x00男子\x00一队\x00", "E1\x00男子\x00二队\x00", + "E1\x00女子\x00\x00007", "E2\x00男子\x00\x00"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := loadImportRowsByKey(t, db, tc.filter) + if len(got) != len(tc.want) { + t.Fatalf("筛选后行数 = %d,期望 %d(%v)", len(got), len(tc.want), keysOf(got)) + } + for _, key := range tc.want { + if _, ok := got[key]; !ok { + t.Errorf("筛选结果里缺少 %q(实际 %v)", key, keysOf(got)) + } + } + }) + } + + // 没有数据不是错误:批次 99 没人报名,返回空表(导出件仍是一张带表头的表)。 + rows, err := LoadEntriesImportRows(db, EntriesFilter{Heat: 99}, Options{}) + if err != nil { + t.Fatalf("读取空结果: %v", err) + } + if len(rows) != 0 { + t.Errorf("批次 99 应为空,得到 %d 行", len(rows)) + } + // 没有库时必须报错,不能返回一份空名单(那会被当成「这次没人报名」)。 + if _, err := LoadEntriesImportRows(nil, EntriesFilter{}, Options{}); err != ErrNoDatabase { + t.Errorf("空库连接的错误 = %v,期望 ErrNoDatabase", err) + } +} + +func keysOf(rows map[string]EntryImportRow) []string { + out := make([]string, 0, len(rows)) + for key := range rows { + out = append(out, key) + } + return out +} + +// 状态列必须写**导入器认的**中文口径:报表那份写的「已退赛」导入器不认, +// 照抄过去会让整份表每一条退赛行都报「status 无法识别」。 +func TestEntryImportStatusLabel(t *testing.T) { + cases := map[string]string{ + "": "正常参赛", + "registered": "正常参赛", + "scratched": "退赛", + "dq": "取消资格", + "dns": "未出场", + } + for status, want := range cases { + if got := entryImportStatusLabel(status); got != want { + t.Errorf("状态 %q 的标签 = %q,期望 %q", status, got, want) + } + } + for _, label := range cases { + if label == "已退赛" { + t.Errorf("「已退赛」是报表口径,导入器认的是「退赛」") + } + } + // 认不出的历史值原样写出:宁可让导入当场报「status 无法识别」,也不静默改成别的意思。 + if got := entryImportStatusLabel("unknown"); got != "unknown" { + t.Errorf("认不出的状态 = %q,期望原样写出", got) + } +} diff --git a/server/internal/httpapi/entries_import_export.go b/server/internal/httpapi/entries_import_export.go new file mode 100644 index 0000000..267f7d0 --- /dev/null +++ b/server/internal/httpapi/entries_import_export.go @@ -0,0 +1,315 @@ +package httpapi + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" +) + +// 报名名单(导入格式)导出:**逐列与报名导入模板一致**,导出来的文件可以直接拿去重新导入。 +// +// 与报表导出(GET /api/export/entries.xlsx,internal/export/entries.go)是两份东西, +// 各自的口径不能互相套用: +// - 报表是给人看的:姓名列拼上队员、状态写「已退赛」、表头是中文名,导入器不认它; +// - 这份按 importColumns 逐列写(表头就是导入模板的列名),团体报名的姓名列留空、 +// 状态写导入器认的中文口径、联队写 A+B —— 重导回去一行都不会错位。 +// +// 两种布局: +// - flat:所有行汇总在一张工作表里,**表名不带 @**(导入侧只要见到一张表名带 @ 就整份 +// 按分工作表模板解析,这张汇总表定不出 (小项, 组别) 就会被拒绝); +// - sheets:按「小项@组别」分工作表,表名由 entrySheetName 算,与导入模板、导入解析三处同源。 +// **不能多出任何一张工作表**(一张说明表就会让整份文件按分工作表口径解析对不上号)。 + +// 两种布局的 format 取值。 +const ( + entryImportFormatFlat = "flat" + entryImportFormatSheets = "sheets" +) + +// entryImportFlatSheet 是 flat 布局唯一那张工作表的名字(与报表导出的表名一致)。 +// 它是**普通表名**:绝不能让 entrySheetName 的结果在这里出现,否则这份文件会被当成 +// 分工作表模板解析,而它定不出小项与组别 → 整份被拒绝。 +const entryImportFlatSheet = "报名名单" + +// exportEntriesImportXLSX 导出「与报名导入模板逐列一致」的报名名单(xlsx)。 +// +// 筛选参数(eventId / categoryId / teamId / heat)与报表导出完全一样,都可省; +// format 缺省 flat;lang 只有 "en" 走英文规范列名,其余一律中文(与模板下载的两个按钮同一口径)。 +func (s *server) exportEntriesImportXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if format == "" { + format = entryImportFormatFlat + } + if format != entryImportFormatFlat && format != entryImportFormatSheets { + return nil, badRequest("format 只能是 flat(一张表汇总)或 sheets(按小项@组别分工作表)", format) + } + language := "zh" + if strings.ToLower(strings.TrimSpace(r.URL.Query().Get("lang"))) == "en" { + language = "en" + } + + // 与报表导出(exportEntriesXLSX)同一套筛选参数:留空 = 不筛,填了必须是正整数。 + filter := export.EntriesFilter{} + for _, f := range []struct { + key string + dst *int64 + what string + }{ + {"eventId", &filter.EventID, "小项"}, + {"categoryId", &filter.CategoryID, "组别"}, + {"teamId", &filter.TeamID, "单位"}, + {"heat", &filter.Heat, "批次"}, + } { + id, err := filterIDQuery(r, f.key, f.what) + if err != nil { + return nil, err + } + *f.dst = id + } + + rows, err := export.LoadEntriesImportRows(s.ev, filter, export.Options{}) + if err != nil { + return nil, internal(err) + } + sheets, err := s.entryImportSheets(r.Context(), format, rows) + if err != nil { + return nil, err + } + + filename, ascii := entryImportFilename(format) + return s.streamDownload(w, ascii, filename, xlsxMIME, func(out io.Writer) error { + return writeEntriesImportXLSX(out, language, sheets) + }) +} + +// entryImportSheets 把导出的行分到工作表上。 +// +// flat(或一条报名都没有)永远只有一张「报名名单」表:空名单也要给一张带表头的表, +// 拿到手的人一眼看得出是「这次没有数据」,而不是遇到一个莫名其妙的错误。 +func (s *server) entryImportSheets(ctx context.Context, format string, rows []export.EntryImportRow) ([]entryImportSheet, error) { + if format != entryImportFormatSheets || len(rows) == 0 { + return []entryImportSheet{{Name: entryImportFlatSheet, Rows: rows}}, nil + } + // 工作表名必须与**导入侧**算出来的完全一致,所以这里重算同一份计划(同一个 + // loadEntrySheetScopes + newEntrySheetPlan,下载模板、导入解析、本导出三处同源)。 + // 自己拼「小项@组别」在撞名去重与 31 字符截断上会与导入侧分叉,分叉的后果是整份表导不回去。 + scopes, err := s.loadEntrySheetScopes(ctx) + if err != nil { + return nil, err + } + plan := newEntrySheetPlan(scopes) + nameOf := make(map[[2]int64]string, len(plan.sheets)) + for _, planned := range plan.sheets { + nameOf[[2]int64{planned.Scope.EventID, planned.Scope.CategoryID}] = planned.Name + } + + grouped := make(map[[2]int64][]export.EntryImportRow, len(plan.sheets)) + unnameable := make([]string, 0, 2) + seenUnnameable := make(map[string]bool, 2) + for _, row := range rows { + key := [2]int64{row.EventID, row.CategoryID} + if _, ok := nameOf[key]; !ok { + // 只有名字里带 @ 的小项 / 组别做不出工作表名(entrySheetName 那里就被排除了)。 + // 静默丢掉这些行等于现场少了几十条报名而毫不知情,所以整次拒绝并点名。 + label := row.EventName + entrySheetSeparator + row.CategoryName + if !seenUnnameable[label] { + seenUnnameable[label] = true + unnameable = append(unnameable, label) + } + continue + } + grouped[key] = append(grouped[key], row) + } + if len(unnameable) > 0 { + if len(plan.excluded) > 0 { + return nil, conflict(entrySheetExcludedMessage(plan.excluded)) + } + return nil, conflict("这几个小项 / 组别做不出「小项名称" + entrySheetSeparator + "组别名称」的工作表名:" + + strings.Join(unnameable, "、") + "。请到「基础数据 → 大项 / 小项」「组别」检查它们的名字," + + "或改用 format=flat(一张表汇总)导出") + } + + // 工作表顺序照计划来(与模板的先后一致),表内行序照导出的 ORDER BY: + // 同一份数据每次导出的样子都一样,现场两张表对着看时不用重新找行。 + out := make([]entryImportSheet, 0, len(plan.sheets)) + for _, planned := range plan.sheets { + key := [2]int64{planned.Scope.EventID, planned.Scope.CategoryID} + if len(grouped[key]) == 0 { + // 这个小项组别这次没有人报名:不出空表。空表是模板用来教人填的,数据导出件里 + // 几十张空表只会让人以为名单丢了。 + continue + } + out = append(out, entryImportSheet{Name: planned.Name, Rows: grouped[key]}) + } + return out, nil +} + +// entryImportFilename 是两种布局的文件名:中文名给浏览器,ASCII 名给不认 filename* 的老客户端。 +func entryImportFilename(format string) (filename, ascii string) { + if format == entryImportFormatSheets { + return "报名名单(导入格式·分小项).xlsx", "entries-import-sheets.xlsx" + } + return "报名名单(导入格式).xlsx", "entries-import.xlsx" +} + +// entryImportSheet 是一张待写的工作表:名字 + 该表的数据行(行序即写入顺序)。 +type entryImportSheet struct { + Name string + Rows []export.EntryImportRow +} + +// writeEntriesImportXLSX 写整份「导入格式」工作簿。 +// +// 版式与报名模板刻意一致:**表头就在第 1 行**,没有标题行 / 副标题行——导入器认第 1 行为表头, +// 在它上面加一行标题,用户把这份表填回来时会被判成「报名表缺少列」。 +// 列宽照模板给(entryTemplateWidths),但**不挂批注与输入提示**:那是教人填空白模板的东西, +// 数据导出件里挂上去只会挡住看数据的人。 +func writeEntriesImportXLSX(out io.Writer, language string, sheets []entryImportSheet) error { + f := excelize.NewFile() + defer func() { _ = f.Close() }() + + // 文档属性照导出件那一套(internal/export 的 setDocProps):Excel「文件 → 信息」里 + // 不该出现第三方库的作者名(xuri)与一个假日期——这份表是要发给现场、发出去对账的。 + now := time.Now().Format(time.RFC3339) + if err := f.SetDocProps(&excelize.DocProperties{ + Creator: entryTemplateAuthor, LastModifiedBy: entryTemplateAuthor, Created: now, Modified: now, + }); err != nil { + return fmt.Errorf("httpapi: 设置报名名单文档属性: %w", err) + } + if err := f.SetAppProps(&excelize.AppProperties{Application: entryTemplateAuthor}); err != nil { + return fmt.Errorf("httpapi: 设置报名名单应用属性: %w", err) + } + + headerStyle, err := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 11, Family: "微软雅黑", Color: "FFFFFF"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"3482FF"}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + }) + if err != nil { + return fmt.Errorf("httpapi: 创建报名名单表头样式: %w", err) + } + + // 表头与列宽都按 importColumns 的列序来,与模板共用同一份实现(改 importColumns 三处一起变)。 + headers := entryTemplateHeader(language) + for i, sheet := range sheets { + if i == 0 { + if err := f.SetSheetName("Sheet1", sheet.Name); err != nil { + return fmt.Errorf("httpapi: 重命名报名名单工作表 %s: %w", sheet.Name, err) + } + } else if _, err := f.NewSheet(sheet.Name); err != nil { + return fmt.Errorf("httpapi: 新建报名名单工作表 %s: %w", sheet.Name, err) + } + if err := writeEntryImportHeader(f, sheet.Name, headers, headerStyle); err != nil { + return err + } + for n, row := range sheet.Rows { + for column, value := range entryImportRecord(row) { + cell, err := excelize.CoordinatesToCellName(column+1, n+2) + if err != nil { + return err + } + if err := f.SetCellValue(sheet.Name, cell, value); err != nil { + return err + } + } + } + // 冻结表头:一份名单几十行,往下翻的时候列名得一直看得见(与模板同一版式)。 + if err := f.SetPanes(sheet.Name, &excelize.Panes{ + Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft", + }); err != nil { + return err + } + } + if err := f.Write(out); err != nil { + return fmt.Errorf("httpapi: 写出报名名单(导入格式): %w", err) + } + return nil +} + +// writeEntryImportHeader 写第 1 行表头并设各列列宽。 +// 列宽取自模板同一张表(entryTemplateWidths),没登记的列用 defaultEntryTemplateWidth。 +func writeEntryImportHeader(f *excelize.File, sheet string, headers []string, style int) error { + for column, title := range headers { + cell, err := excelize.CoordinatesToCellName(column+1, 1) + if err != nil { + return err + } + if err := f.SetCellValue(sheet, cell, title); err != nil { + return err + } + name, err := excelize.ColumnNumberToName(column + 1) + if err != nil { + return err + } + width := float64(defaultEntryTemplateWidth) + if column < len(importColumns) { + if custom, ok := entryTemplateWidths[importColumns[column].name]; ok { + width = custom + } + } + if err := f.SetColWidth(sheet, name, name, width); err != nil { + return err + } + } + if len(headers) == 0 { + return nil + } + last, err := excelize.ColumnNumberToName(len(headers)) + if err != nil { + return err + } + if err := f.SetCellStyle(sheet, "A1", last+"1", style); err != nil { + return err + } + return f.SetRowHeight(sheet, 1, 24) +} + +// entryImportRecord 按 importColumns 的列序把一行导成格子值。 +// +// **列序只有一个来源**:这里绝不另写一份列序常量,而是逐列按 importColumns 里的列名取值—— +// importColumns 增删列或调顺序,这份导出件跟着变,两边的列序不可能分叉。 +// importColumns 将来加了新列而这里没接上,会写出一列空格子; +// entries_import_export_internal_test.go 的 TestEntryImportRecordCoversImportColumns 会在那时当场红。 +func entryImportRecord(row export.EntryImportRow) []any { + cells := make([]any, 0, len(importColumns)) + for _, column := range importColumns { + switch column.name { + case "event_code": + cells = append(cells, row.EventCode) + case "category": + cells = append(cells, row.CategoryName) + case "team_code": + cells = append(cells, row.TeamCode) + case "athlete_name": + cells = append(cells, row.AthleteName) + case "id_card": + cells = append(cells, row.IDCard) + case "gender": + cells = append(cells, row.Gender) + case "age": + cells = append(cells, row.Age) + case "bib": + cells = append(cells, row.Bib) + case "seed": + cells = append(cells, row.Seed) + case "heat": + cells = append(cells, row.Heat) + case "status": + cells = append(cells, row.Status) + case "member_names": + cells = append(cells, row.MemberNames) + case "squad": + cells = append(cells, row.Squad) + default: + cells = append(cells, "") + } + } + return cells +} diff --git a/server/internal/httpapi/entries_import_export_internal_test.go b/server/internal/httpapi/entries_import_export_internal_test.go new file mode 100644 index 0000000..c763403 --- /dev/null +++ b/server/internal/httpapi/entries_import_export_internal_test.go @@ -0,0 +1,106 @@ +package httpapi + +import ( + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" +) + +// 导出件的列序与列名只有一个来源:importColumns。 +// +// 这条用例把 entryImportRecord 的每一列都钉住:importColumns 新增一列而这里没接上, +// 那一列会写出一整列空格子——现场拿到的是「报名表缺少列」或悄悄丢数据; +// 顺序被调换则会让姓名写进证件号那一格。两种都必须在这里当场红。 +func TestEntryImportRecordCoversImportColumns(t *testing.T) { + row := export.EntryImportRow{ + EventID: 1, + EventCode: "E1", + EventName: "100米跑", + CategoryID: 1, + CategoryName: "男子", + TeamCode: "T1", + AthleteName: "张伟", + IDCard: "110101199001011234", + Gender: "男", + Age: "35", + Bib: "007", + Seed: "3", + Heat: "2", + Status: "正常参赛", + MemberNames: "A002", + Squad: "一队", + } + // 每个字段都给一个互不相同的值:列序错了(取了别的字段)也看得出来。 + want := map[string]any{ + "event_code": "E1", + "category": "男子", + "team_code": "T1", + "athlete_name": "张伟", + "id_card": "110101199001011234", + "gender": "男", + "age": "35", + "bib": "007", + "seed": "3", + "heat": "2", + "status": "正常参赛", + "member_names": "A002", + "squad": "一队", + } + if len(want) != len(importColumns) { + t.Fatalf("importColumns 有 %d 列,本用例的期望表有 %d 条:加了列就要在这里补一条期望值", + len(importColumns), len(want)) + } + + cells := entryImportRecord(row) + if len(cells) != len(importColumns) { + t.Fatalf("导出的格子数 = %d,期望与 importColumns 一致的 %d 列", len(cells), len(importColumns)) + } + for i, column := range importColumns { + expected, ok := want[column.name] + if !ok { + t.Errorf("importColumns 的 %s 列在 entryImportRecord 里没有接上取值(会写出一列空格子)", column.name) + continue + } + if cells[i] != expected { + t.Errorf("第 %d 列(%s)= %v,期望 %v", i+1, column.name, cells[i], expected) + } + } +} + +// 表头必须是 entryTemplateHeader 给的那一份(中英各一份,列序与 importColumns 一致)。 +// 这里只确认「导出用的就是模板那一份」,不是另写一遍列名——列名再写一遍就会分叉。 +func TestEntryImportHeaderComesFromTemplate(t *testing.T) { + for _, language := range []string{"zh", "en"} { + want := entryTemplateHeader(language) + if len(want) != len(importColumns) { + t.Fatalf("%s 表头有 %d 列,importColumns 有 %d 列", language, len(want), len(importColumns)) + } + record := entryImportRecord(export.EntryImportRow{}) + if len(record) != len(want) { + t.Fatalf("%s 表头与导出格子的列数不一致:%d vs %d", language, len(want), len(record)) + } + } + if entryTemplateHeader("zh")[0] != importColumns[0].aliases[0] { + t.Errorf("中文表头第 1 列 = %q,期望 %q", entryTemplateHeader("zh")[0], importColumns[0].aliases[0]) + } + if entryTemplateHeader("en")[0] != importColumns[0].name { + t.Errorf("英文表头第 1 列 = %q,期望 %q", entryTemplateHeader("en")[0], importColumns[0].name) + } +} + +// flat 布局那张汇总表的表名里**不能有 @**:导入侧只要见到一张表名带 @ 就整份按分工作表口径解析, +// 而汇总表定不出 (小项, 组别),整份会被拒绝。 +func TestEntryImportFlatSheetNameHasNoSeparator(t *testing.T) { + if strings.Contains(entryImportFlatSheet, entrySheetSeparator) { + t.Fatalf("flat 布局的表名 %q 里不能有 %q", entryImportFlatSheet, entrySheetSeparator) + } + name, ascii := entryImportFilename(entryImportFormatFlat) + if name != "报名名单(导入格式).xlsx" || ascii != "entries-import.xlsx" { + t.Errorf("flat 的文件名 = %q / %q", name, ascii) + } + name, ascii = entryImportFilename(entryImportFormatSheets) + if name != "报名名单(导入格式·分小项).xlsx" || ascii != "entries-import-sheets.xlsx" { + t.Errorf("sheets 的文件名 = %q / %q", name, ascii) + } +} diff --git a/server/internal/httpapi/entries_import_export_test.go b/server/internal/httpapi/entries_import_export_test.go new file mode 100644 index 0000000..1eab82c --- /dev/null +++ b/server/internal/httpapi/entries_import_export_test.go @@ -0,0 +1,534 @@ +package httpapi_test + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +// 报名名单(导入格式)导出:列名与列序与报名导入模板**逐列一致**,导出来的文件可以直接重导。 +// +// 本文件的核心是往返(round-trip):把导出的字节**原样喂给现成的报名导入解析器** +// (POST /api/entries/import,与现场上传的是同一条链),再查库。 +// 这是「格式对得上」唯一算数的证明——列序错位、团体行姓名被填上、状态写「已退赛」, +// 任何一处都会在这里当场红,而不是等现场导入时才发现。 + +// importExportHeader 是导出件的表头(中文口径,缺省语言)。 +// +// 刻意在这里**逐字写死**:验的就是「列序与 importColumns 逐列一致」这个对外契约, +// 写成 entryTemplateHeader(language) 的断言等于什么都没验(两边一起改会一起错)。 +var importExportHeader = []string{ + "项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄", + "号码布", "种子", "批次", "状态", "队员名单", "队伍", +} + +// importExportEnglishHeader 是 lang=en 的英文规范列名。 +var importExportEnglishHeader = []string{ + "event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age", + "bib", "seed", "heat", "status", "member_names", "squad", +} + +// importRoundTripResult 是导入响应的读数(只取本用例要断言的字段)。 +type importRoundTripResult struct { + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Errors []string `json:"errors"` + Notices []string `json:"notices"` + Conflicts []json.RawMessage `json:"conflicts"` +} + +// seedImportRoundTrip 造一份覆盖两种布局与全部取值口径的报名名单: +// 个人行(含同一人两个组别)/ 团体行(姓名留空)/ 联队(T1+T2)/ 多队(squad)/ +// 未分批(heat 为空)/ 四种状态 / 文字名单与队员关联并存。 +func seedImportRoundTrip(t *testing.T, env *testEnv) { + t.Helper() + + t1 := env.insert(t, `INSERT INTO teams(code, name, short_name, sort_order, active) + VALUES ('T1', '第一分公司', '一公司', 1, 1)`) + t2 := env.insert(t, `INSERT INTO teams(code, name, short_name, sort_order, active) + VALUES ('T2', '第二分公司', '二公司', 2, 1)`) + athZhang := env.insert(t, `INSERT INTO athletes(name, gender, team_id, id_card, bib, age) + VALUES ('张伟', '男', ?, '110101199001011234', '007', 35)`, t1) + athLi := env.insert(t, `INSERT INTO athletes(name, gender, team_id, id_card, bib, age) + VALUES ('李强', '男', ?, '110101199202022345', 'B001', 33)`, t2) + athWang := env.insert(t, `INSERT INTO athletes(name, gender, team_id, bib) + VALUES ('王磊', '男', ?, 'A002')`, t1) + athZhao := env.insert(t, `INSERT INTO athletes(name, gender, team_id, bib) + VALUES ('赵雷', '男', ?, 'A003')`, t1) + + sport := env.insert(t, `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`) + e1 := env.insert(t, `INSERT INTO events(sport_id, code, name, status, sort_order) + VALUES (?, 'E1', '100米跑', 'ongoing', 1)`, sport) + e2 := env.insert(t, `INSERT INTO events(sport_id, code, name, status, sort_order) + VALUES (?, 'E2', '4×100米接力', 'ongoing', 2)`, sport) + c1 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '男子', 1)`, e1) + c2 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '女子', 2)`, e1) + c3 := env.insert(t, `INSERT INTO event_categories(event_id, name, sort_order) VALUES (?, '男子', 1)`, e2) + + // 个人行:号码布带前导零、有种子、已分批、正常参赛。 + // 号码布刻意取运动员档案上那个(导入侧的号码布列既写报名也认人,两边不同会被当成冲突)。 + env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, seed, heat, status) + VALUES (?, ?, ?, ?, '007', '3', 2, 'registered')`, e1, c1, t1, athZhang) + // 个人行:未分批(heat 为 NULL)、退赛。 + env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status) + VALUES (?, ?, ?, ?, 'B001', 'scratched')`, e1, c1, t2, athLi) + // 团体行 + 一队:文字名单里既有一个已关联的号码布,也有一段认不出人的文字。 + env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status) + VALUES (?, ?, ?, '["A002","预备队员"]', '一队', 'dq')`, e1, c1, t1) + // 团体行 + 二队:文字名单里只有文字,队员是界面上后补的(只活在 entry_athletes 里)。 + env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, squad, status) + VALUES (?, ?, ?, '["临时队员"]', '二队', 'dns')`, e1, c1, t1) + // 个人行:同一个人报第二个组别(号码布重复出现是合法的)。 + env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status) + VALUES (?, ?, ?, ?, '007', 'registered')`, e1, c2, t1, athZhang) + // 联队团体行:T1 + T2 各占一半,队员名单里写的是姓名(要补上号码布才认得出人)。 + joint := env.insert(t, `INSERT INTO entries(event_id, category_id, team_id, member_names, status) + VALUES (?, ?, ?, '["王磊","赵雷"]', 'registered')`, e2, c3, t1) + + env.exec(t, `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) + VALUES (?, ?, 1, 1, 0)`, joint, t1) + env.exec(t, `INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) + VALUES (?, ?, 1, 1, 1)`, joint, t2) + + // 团体行的队员关联:一条与文字名单里的号码布重复(去重),一条只存在于关联里(必须补进名单)。 + env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) + SELECT id, ?, 0 FROM entries WHERE event_id = ? AND squad = '一队'`, athWang, e1) + env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) + SELECT id, ?, 0 FROM entries WHERE event_id = ? AND squad = '二队'`, athZhao, e1) + env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (?, ?, 0)`, joint, athWang) + env.exec(t, `INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (?, ?, 1)`, joint, athZhao) +} + +// importExportRow 是导出件里的一行(按表头列序读)。 +type importExportRow struct { + eventCode, category, teamCode, athleteName, idCard, gender, age string + bib, seed, heat, status, memberNames, squad string +} + +// label 是测试里的行标识:小项 / 组别 / 队伍 / 号码布(四者合起来唯一)。 +func (r importExportRow) label() string { + return r.eventCode + "/" + r.category + "/" + r.squad + "/" + r.bib +} + +// readImportExportRows 读出一份导出件里的全部数据行,并逐张表核对表头与列宽。 +func readImportExportRows(t *testing.T, f *excelize.File) []importExportRow { + t.Helper() + var out []importExportRow + for _, sheet := range f.GetSheetList() { + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取工作表 %s: %v", sheet, err) + } + if len(rows) == 0 { + t.Fatalf("工作表 %s 是空的:第 1 行必须是表头", sheet) + } + for i, want := range importExportHeader { + if got := cellAt(rows, 0, i); got != want { + t.Errorf("工作表 %s 表头第 %d 列 = %q,期望 %q", sheet, i+1, got, want) + } + } + for r := 1; r < len(rows); r++ { + out = append(out, importExportRow{ + eventCode: cellAt(rows, r, 0), category: cellAt(rows, r, 1), + teamCode: cellAt(rows, r, 2), athleteName: cellAt(rows, r, 3), + idCard: cellAt(rows, r, 4), gender: cellAt(rows, r, 5), age: cellAt(rows, r, 6), + bib: cellAt(rows, r, 7), seed: cellAt(rows, r, 8), heat: cellAt(rows, r, 9), + status: cellAt(rows, r, 10), memberNames: cellAt(rows, r, 11), squad: cellAt(rows, r, 12), + }) + } + // 列宽照模板给:号码布那列窄、证件号那列宽,导出即可打印(与下载的模板同一观感)。 + if width, err := f.GetColWidth(sheet, "A"); err == nil && width != 14 { + t.Errorf("工作表 %s 的 A 列宽 = %v,期望 14(与模板一致)", sheet, width) + } + if width, err := f.GetColWidth(sheet, "F"); err == nil && width != 8 { + t.Errorf("工作表 %s 的 F 列宽 = %v,期望 8(与模板一致)", sheet, width) + } + // 数据导出件不挂批注与输入提示:那是空模板教填表人的东西。 + if comments, err := f.GetComments(sheet); err == nil && len(comments) != 0 { + t.Errorf("工作表 %s 挂了 %d 条批注,数据导出件不该有", sheet, len(comments)) + } + } + return out +} + +// wantImportExportRows 是这份导出件应当逐字写出来的内容(列序、取值口径全钉在这里)。 +func wantImportExportRows(t *testing.T) map[string]importExportRow { + t.Helper() + rows := []importExportRow{ + // 个人行:人员字段取运动员档案。 + {eventCode: "E1", category: "男子", teamCode: "T1", athleteName: "张伟", + idCard: "110101199001011234", gender: "男", age: "35", + bib: "007", seed: "3", heat: "2", status: "正常参赛"}, + // 未分批:批次留空;退赛写导入器认的中文口径。 + {eventCode: "E1", category: "男子", teamCode: "T2", athleteName: "李强", + idCard: "110101199202022345", gender: "男", age: "33", + bib: "B001", status: "退赛"}, + // 团体行:姓名必须留空(填了就会被当成个人报名),队员名单是这一行的内容。 + {eventCode: "E1", category: "男子", teamCode: "T1", bib: "", squad: "一队", + status: "取消资格", memberNames: "A002,预备队员"}, + {eventCode: "E1", category: "男子", teamCode: "T1", squad: "二队", + status: "未出场", memberNames: "临时队员,A003"}, + // 同一个人报第二个组别:号码布照样写出来(导入侧按证件号认得出是一个人)。 + {eventCode: "E1", category: "女子", teamCode: "T1", athleteName: "张伟", + idCard: "110101199001011234", gender: "男", age: "35", bib: "007", status: "正常参赛"}, + // 联队:单位编码写 A+B(与导入表的写法逐字一致),文字名单后面补上队员的号码布。 + {eventCode: "E2", category: "男子", teamCode: "T1+T2", status: "正常参赛", + memberNames: "王磊,赵雷,A002,A003"}, + } + out := make(map[string]importExportRow, len(rows)) + for _, r := range rows { + out[r.label()] = r + } + return out +} + +func TestEntriesImportExportRoundTrip(t *testing.T) { + for _, format := range []string{"flat", "sheets"} { + t.Run(format, func(t *testing.T) { + env := newTestEnv(t) + seedImportRoundTrip(t, env) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + before := snapshotEntries(t, env) + + status, respHeader, body := env.getRaw(t, client, "/api/export/entries-import.xlsx?format="+format) + if status != http.StatusOK { + t.Fatalf("导出报名名单(导入格式)应 200,得到 %d %s", status, body) + } + if format == "sheets" { + assertContentDisposition(t, respHeader.Get("Content-Disposition"), + "entries-import-sheets.xlsx", "报名名单(导入格式·分小项).xlsx") + } else { + assertContentDisposition(t, respHeader.Get("Content-Disposition"), + "entries-import.xlsx", "报名名单(导入格式).xlsx") + } + + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + assertImportExportSheets(t, f, format) + + exported := readImportExportRows(t, f) + want := wantImportExportRows(t) + if len(exported) != len(want) { + t.Fatalf("导出行数 = %d,期望 %d(%+v)", len(exported), len(want), exported) + } + for _, row := range exported { + expected, ok := want[row.label()] + if !ok { + t.Errorf("导出件里多出一行 %s:%+v", row.label(), row) + continue + } + if row != expected { + t.Errorf("导出件里 %s = %+v,期望 %+v", row.label(), row, expected) + } + } + + // 把导出的字节原样喂给报名导入解析器(与现场上传的是同一条链)。 + status, resp := env.uploadEntries(t, client, "entries.xlsx", body) + if status != http.StatusOK || !resp.OK { + t.Fatalf("重新导入导出件应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[importRoundTripResult](t, resp) + // 断言一:解析零错误(状态列写「已退赛」这类对不上的口径会在这里现形)。 + if len(result.Errors) != 0 { + t.Fatalf("重新导入必须零错误,得到 %v", result.Errors) + } + if len(result.Conflicts) != 0 { + t.Fatalf("重新导入不该产生冲突(逐行都该命中库里那条报名),得到 %+v", result.Conflicts) + } + if result.Skipped != 0 || result.Created != 0 { + t.Fatalf("重新导入应逐行更新已有报名:created=%d skipped=%d(不能新建、不能跳过)", + result.Created, result.Skipped) + } + // 断言二:导入出来的行数与导出行数一致。 + if result.Updated != len(exported) { + t.Fatalf("更新行数 = %d,期望 %d(与导出行数一致)", result.Updated, len(exported)) + } + // 断言二(续):逐行核对库里的报名与原库逐项一致,条数一条不差。 + assertEntriesUnchanged(t, before, snapshotEntries(t, env)) + // 断言三:团体行没有被变成个人报名。 + assertTeamRowsStayTeam(t, env) + }) + } +} + +// assertImportExportSheets 核对两种布局的工作表结构与文档属性。 +func assertImportExportSheets(t *testing.T, f *excelize.File, format string) { + t.Helper() + // 文档属性不能留第三方库的作者名(excelize 缺省写的是 xuri)与一个假日期。 + props, err := f.GetDocProps() + if err == nil { + if props.Creator != "赛事积分榜系统" { + t.Errorf("文档属性里的作者 = %q,期望 赛事积分榜系统", props.Creator) + } + } else { + t.Errorf("读文档属性: %v", err) + } + sheets := f.GetSheetList() + if format == "flat" { + // 汇总表的表名里**不能有 @**:导入侧只要见到一张表名带 @ 就整份按分工作表口径解析。 + if len(sheets) != 1 || sheets[0] != "报名名单" { + t.Fatalf("flat 布局的工作表 = %v,期望只有一张「报名名单」", sheets) + } + return + } + want := []string{"100米跑@男子", "100米跑@女子", "4×100米接力@男子"} + if len(sheets) != len(want) { + t.Fatalf("sheets 布局的工作表 = %v,期望 %v(不能多出说明表)", sheets, want) + } + for i, name := range want { + if sheets[i] != name { + t.Errorf("第 %d 张工作表 = %q,期望 %q(表名由 entrySheetName 算,与导入侧同源)", i+1, sheets[i], name) + } + if !strings.Contains(sheets[i], "@") { + t.Errorf("分小项布局的表名必须带 @:%q", sheets[i]) + } + } +} + +// assertTeamRowsStayTeam 断言团体行重导后仍是团体报名:athlete_id 为空、队员关联一个不少。 +// 这是本格式最容易踩坏的一处——姓名那一格一旦被填上,导入侧就会把它当个人报名。 +func assertTeamRowsStayTeam(t *testing.T, env *testEnv) { + t.Helper() + cases := []struct { + eventCode, category, squad string + members int + }{ + {"E1", "男子", "一队", 1}, + {"E1", "男子", "二队", 1}, + {"E2", "男子", "", 2}, + } + for _, tc := range cases { + var ( + entryID int64 + athleteID sql.NullInt64 + ) + if err := env.ev.QueryRow(` + SELECT en.id, en.athlete_id + FROM entries en + JOIN events ev ON ev.id = en.event_id + JOIN event_categories c ON c.id = en.category_id + WHERE ev.code = ? AND c.name = ? AND en.squad = ?`, + tc.eventCode, tc.category, tc.squad).Scan(&entryID, &athleteID); err != nil { + t.Fatalf("查 %s %s %q 的报名: %v", tc.eventCode, tc.category, tc.squad, err) + } + if athleteID.Valid { + t.Errorf("%s %s %q 被当成了个人报名:athlete_id = %d(团体行的姓名列必须是空的)", + tc.eventCode, tc.category, tc.squad, athleteID.Int64) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entry_athletes WHERE entry_id = ?`, entryID); n != tc.members { + t.Errorf("%s %s %q 的队员关联 = %d 名,期望 %d 名(队员不能因为重导而掉队)", + tc.eventCode, tc.category, tc.squad, n, tc.members) + } + } +} + +// entryState 是一条报名在库里的全部可比字段(往返前后逐项比对)。 +// +// 覆盖的正是本格式承载的东西:小项 / 组别 / 单位编码 / 人员 / 性别 / 年龄 / 号码布 / 种子 / +// 批次 / 状态 / 队伍,外加是否个人报名、联队份额与队员关联。 +type entryState struct { + eventCode, category, teamCode string + athleteName, gender, age string + bib, seed, heat, status, squad string + memberNames string + personal bool + shares, members string +} + +// snapshotEntries 读全库的报名状态,按报名 id 归组(新建出重复报名会让条数变多)。 +// +// 份额那一列有意把「没有份额行」折算成主单位独占:库里的约定是两者等价 +// (迁移 0024:没有行 = 主单位 100%),而导入侧整组重写份额时会把这一条补上。 +// 不折算的话,一次语义上什么都没改的重导会看起来像改过库。 +func snapshotEntries(t *testing.T, env *testEnv) map[int64]entryState { + t.Helper() + rows, err := env.ev.Query(` + SELECT en.id, COALESCE(ev.code, ''), COALESCE(c.name, ''), COALESCE(t.code, ''), + COALESCE(a.name, ''), COALESCE(a.gender, ''), COALESCE(CAST(a.age AS TEXT), ''), + COALESCE(en.bib, ''), COALESCE(en.seed, ''), COALESCE(CAST(en.heat AS TEXT), ''), + en.status, COALESCE(en.member_names, ''), COALESCE(en.squad, ''), + en.athlete_id IS NOT NULL, + COALESCE(NULLIF((SELECT group_concat(team_id) FROM (SELECT s.team_id FROM entry_team_shares s + WHERE s.entry_id = en.id ORDER BY s.sort_order, s.team_id)), ''), CAST(en.team_id AS TEXT)), + COALESCE((SELECT group_concat(athlete_id) FROM (SELECT ea.athlete_id FROM entry_athletes ea + WHERE ea.entry_id = en.id ORDER BY ea.athlete_id)), '') + FROM entries en + JOIN events ev ON ev.id = en.event_id + JOIN teams t ON t.id = en.team_id + LEFT JOIN event_categories c ON c.id = en.category_id + LEFT JOIN athletes a ON a.id = en.athlete_id + ORDER BY en.id`) + if err != nil { + t.Fatalf("读报名快照: %v", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64]entryState) + for rows.Next() { + var ( + id int64 + state entryState + ) + if err := rows.Scan(&id, &state.eventCode, &state.category, &state.teamCode, + &state.athleteName, &state.gender, &state.age, + &state.bib, &state.seed, &state.heat, &state.status, + &state.memberNames, &state.squad, &state.personal, + &state.shares, &state.members); err != nil { + t.Fatalf("扫描报名快照: %v", err) + } + out[id] = state + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历报名快照: %v", err) + } + return out +} + +// assertEntriesUnchanged 断言重导后库里的报名与原库逐项一致(条数一条不差)。 +// +// 唯一允许的两处差异,都是这个格式**有意**带来的、不丢信息的差异: +// - 队员名单只增不减:导出时把 entry_athletes 里已关联的队员补进了那一格 +// (否则界面后补的队员在重导后就没了),导入后会多出这些号码布; +// - 份额行会被补成「主单位独占 100%」:库里没有份额行本来就等价于它。 +func assertEntriesUnchanged(t *testing.T, before, after map[int64]entryState) { + t.Helper() + if len(after) != len(before) { + t.Fatalf("重新导入后报名条数 = %d,期望 %d(不能新建、不能删除)", len(after), len(before)) + } + for id, want := range before { + got, ok := after[id] + if !ok { + t.Errorf("报名 %d 在重新导入后不见了(往返必须逐行命中已有报名)", id) + continue + } + for _, diff := range []string{ + diffText("小项", want.eventCode, got.eventCode), + diffText("组别", want.category, got.category), + diffText("单位编码", want.teamCode, got.teamCode), + diffText("姓名", want.athleteName, got.athleteName), + diffText("性别", want.gender, got.gender), + diffText("年龄", want.age, got.age), + diffText("号码布", want.bib, got.bib), + diffText("种子", want.seed, got.seed), + diffText("批次", want.heat, got.heat), + diffText("状态", want.status, got.status), + diffText("队伍", want.squad, got.squad), + diffText("份额", want.shares, got.shares), + diffText("队员关联", want.members, got.members), + } { + if diff != "" { + t.Errorf("报名 %d:%s", id, diff) + } + } + if want.personal != got.personal { + t.Errorf("报名 %d 的报名类型变了:个人 = %v → %v(团体行被当成个人报名 / 反过来都不行)", + id, want.personal, got.personal) + } + // 队员名单只增不减:原来的每一段(文字或号码布)都必须还在。 + for _, token := range memberTokensOf(want.memberNames) { + if !containsToken(memberTokensOf(got.memberNames), token) { + t.Errorf("报名 %d 的队员名单丢了 %q:%q → %q", id, token, want.memberNames, got.memberNames) + } + } + } +} + +// diffText 拼一条字段差异;两边一样时返回空串。 +func diffText(field, before, after string) string { + if before == after { + return "" + } + return fmt.Sprintf("%s 变了:%q → %q", field, before, after) +} + +// memberTokensOf 把库里的队员名单拆成一段段:正常是 JSON 数组文本, +// 历史上被手工写成别的样子时整格当一段(与导出侧的读法同口径)。 +func memberTokensOf(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var tokens []string + if err := json.Unmarshal([]byte(raw), &tokens); err != nil { + return []string{raw} + } + return tokens +} + +func containsToken(tokens []string, want string) bool { + for _, token := range tokens { + if strings.TrimSpace(token) == want { + return true + } + } + return false +} + +// format / lang 的口径,以及空名单时仍然给一张带表头的表。 +func TestEntriesImportExportFormatAndLanguage(t *testing.T) { + env := newTestEnv(t) + seedImportRoundTrip(t, env) + // guest 即可下载:权限与报表导出(entries.xlsx)同档。 + client, _ := env.client() + + status, _, body := env.getRaw(t, client, "/api/export/entries-import.xlsx?format=csv") + if status != http.StatusBadRequest { + t.Fatalf("非法 format 应 400,得到 %d %s", status, body) + } + if msg := string(body); !strings.Contains(msg, "flat") || !strings.Contains(msg, "sheets") { + t.Errorf("非法 format 的提示要写清只认 flat / sheets,得到 %s", msg) + } + + // lang 只有 en 走英文表头,其余一律中文(与模板下载的两个按钮同一口径)。 + status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?lang=en") + if status != http.StatusOK { + t.Fatalf("lang=en 应 200,得到 %d", status) + } + assertHeaderRow(t, openWorkbook(t, body), "报名名单", importExportEnglishHeader) + status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?lang=fr") + if status != http.StatusOK { + t.Fatalf("lang=fr 应 200(其余一律中文),得到 %d", status) + } + assertHeaderRow(t, openWorkbook(t, body), "报名名单", importExportHeader) + + // 筛出来是空的也要给一张带表头的表:拿到手的人一眼看得出是「这次没有数据」。 + for _, format := range []string{"flat", "sheets"} { + status, _, body = env.getRaw(t, client, "/api/export/entries-import.xlsx?format="+format+"&heat=99") + if status != http.StatusOK { + t.Fatalf("%s 空名单应 200,得到 %d %s", format, status, body) + } + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + assertHeaderRow(t, f, "报名名单", importExportHeader) + if sheets := f.GetSheetList(); len(sheets) != 1 || sheets[0] != "报名名单" { + t.Errorf("%s 空名单的工作表 = %v,期望只有一张「报名名单」", format, sheets) + } + } +} + +// assertHeaderRow 读一张表的第 1 行并逐列核对(同时确认没有标题行占位)。 +func assertHeaderRow(t *testing.T, f *excelize.File, sheet string, want []string) { + t.Helper() + defer func() { _ = f.Close() }() + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取工作表 %s: %v", sheet, err) + } + if len(rows) == 0 { + t.Fatalf("工作表 %s 没有表头行", sheet) + } + for i, header := range want { + if got := cellAt(rows, 0, i); got != header { + t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header) + } + } +} diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index 09ab756..7c25253 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -62,6 +62,9 @@ func (s *server) exportRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX)) // 报名名单:按小项 / 组别 / 单位 / 批次筛选,四者可单传也可全不传(= 全部)。 mux.HandleFunc("GET /api/export/entries.xlsx", s.read(auth.RoleGuest, s.exportEntriesXLSX)) + // 报名名单(导入格式):列序与列名与报名导入模板逐列一致,导出的文件可以直接重新导入。 + // 筛选参数与上面那份报表完全一样;format=flat(一张表汇总,缺省)| sheets(按小项@组别分表)。 + mux.HandleFunc("GET /api/export/entries-import.xlsx", s.read(auth.RoleGuest, s.exportEntriesImportXLSX)) // 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 sheet 工作簿,导入按编码 upsert。 // 导出与其它导出同档(基础数据本身在 /api/teams、/api/sports 等接口就是公开可读的); // 导入是批量写、影响面比录入大,权限收到 admin。 diff --git a/web/src/admin/pages/ExportPage.vue b/web/src/admin/pages/ExportPage.vue index 582a1a7..f1b30b0 100644 --- a/web/src/admin/pages/ExportPage.vue +++ b/web/src/admin/pages/ExportPage.vue @@ -23,10 +23,22 @@ import { useEventStore } from '../stores/event' import { useToastStore } from '../stores/toast' import { downloadBaseDataXLSX, + downloadEntriesImportXLSX, downloadEntriesXLSX, importBaseDataXLSX, type BaseDataImportResult, + type EntryExportFilter, } from '@/shared/basedata' +import { + ENTRIES_IMPORT_ACTIONS, + ENTRIES_IMPORT_LANG_NOTE, + ENTRIES_IMPORT_LANG_OPTIONS, + ENTRIES_IMPORT_NOTE, + entriesImportRequest, + parseEntryFilter, + type EntriesImportFormat, + type EntriesImportLanguage, +} from './exportEntriesImport' import type { EventCategory } from '@/shared' /** @@ -135,6 +147,10 @@ const entryTeamId = ref(null) const entryHeat = ref('') const entryCategories = ref([]) const entryExporting = ref(false) +/** 正在导出的那张导入格式表;null = 两个导入格式按钮都没在跑。 */ +const entryImportExporting = ref(null) +/** 两个导入格式导出的表头语言,默认中文;报表导出没有这个参数。 */ +const entryImportLang = ref('zh') /** * 换小项就把组别重置并重新拉取:组别 id 只在小项内有意义, @@ -151,25 +167,27 @@ watch(entryEventId, async (id) => { } }) -async function exportEntries(): Promise { - let heat: number | undefined - const rawHeat = entryHeat.value.trim() - if (rawHeat !== '') { - const parsed = Number(rawHeat) - if (!Number.isInteger(parsed) || parsed <= 0) { - toast.warning('批次需要是正整数(留空表示不筛)') - return - } - heat = parsed +/** 三个导出按钮共用同一套筛选与批次校验;批次写错时提示后返回 null(这条不发出去)。 */ +function entryFilterOrWarn(): EntryExportFilter | null { + const parsed = parseEntryFilter({ + eventId: entryEventId.value, + categoryId: entryCategoryId.value, + teamId: entryTeamId.value, + heat: entryHeat.value, + }) + if (!parsed.ok) { + toast.warning(parsed.message) + return null } + return parsed.filter +} + +async function exportEntries(): Promise { + const filter = entryFilterOrWarn() + if (filter === null) return entryExporting.value = true try { - const file = await downloadEntriesXLSX({ - eventId: entryEventId.value ?? undefined, - categoryId: entryCategoryId.value ?? undefined, - teamId: entryTeamId.value ?? undefined, - heat, - }) + const file = await downloadEntriesXLSX(filter) adminApi.client.exports.save(file.blob, file.filename) toast.success(`已导出 ${file.filename}`) } catch (caught) { @@ -179,6 +197,30 @@ async function exportEntries(): Promise { } } +/** + * 导出「导入格式」的报名名单:与报表同一个筛选,只多带 format / lang。 + * + * 口径与报表导出一样是只读下载(访客也能用),成功 / 失败的提示也同一套; + * 只是产出的列与导入模板逐列一致,能直接拿去重新导入。 + */ +async function exportEntriesImport(format: EntriesImportFormat): Promise { + if (entryImportExporting.value !== null) return + const filter = entryFilterOrWarn() + if (filter === null) return + entryImportExporting.value = format + try { + const file = await downloadEntriesImportXLSX( + entriesImportRequest(filter, { format, lang: entryImportLang.value }), + ) + adminApi.client.exports.save(file.blob, file.filename) + toast.success(`已导出 ${file.filename}`, '列与报名导入模板逐列一致,可直接拿去重新导入') + } catch (caught) { + toast.error('导出失败', describeError(caught)) + } finally { + entryImportExporting.value = null + } +} + // ── 基础数据包(单位 / 大项 / 小项 / 组别) ────────────────────────────────── const baseDataFile = ref(null) const baseDataInput = ref(null) @@ -699,6 +741,38 @@ async function applyRules(): Promise { {{ entryExporting ? '导出中…' : '导出报名名单' }} + + +
+

{{ ENTRIES_IMPORT_NOTE }}

+
+ 表头语言 + +
+

{{ ENTRIES_IMPORT_LANG_NOTE }}

+ +
@@ -1245,6 +1319,20 @@ async function applyRules(): Promise { .entries-card { margin-top: 16px; } +/* 导入格式与上面的报表是两种口径:用一条分隔线 + 一段说明把两处按钮分开。 */ +.entries-import-block { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--mx-border); +} +.lang-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} /* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */ .basedata-grid { display: grid; diff --git a/web/src/admin/pages/exportEntriesImport.ts b/web/src/admin/pages/exportEntriesImport.ts new file mode 100644 index 0000000..05fee09 --- /dev/null +++ b/web/src/admin/pages/exportEntriesImport.ts @@ -0,0 +1,132 @@ +/** + * 导出页「报名名单」卡片上三个导出按钮共用的纯逻辑与文案。 + * + * 抽出来的原因:`.vue` 跑不进 `node --test`,而这张卡片最容易出错的两处恰恰是纯逻辑—— + * 「四个筛选只在该维度选了 / 填了才拼进 query」(批次 0 / 空串 / 没填都不算筛) + * 与「表头语言只作用于两个导入格式的导出,报表那条调用不许带 lang」。 + * + * 三种导出同一套筛选、同一句批次提示,只是口径不同: + * - 报表(GET /export/entries.xlsx):给人看,含小项名称、单位名称、备注; + * - 导入格式(GET /export/entries-import.xlsx):13 列与报名导入模板逐列一致,可重新导入, + * 分 flat(所有行汇总一张表)与 sheets(按「小项名称@组别名称」分工作表)两种。 + */ + +import type { + EntriesImportFormat, + EntriesImportLanguage, + EntriesImportRequest, + EntryExportFilter, +} from '@/shared/basedata' + +export type { EntriesImportFormat, EntriesImportLanguage, EntriesImportRequest, EntryExportFilter } + +/** 卡片上的筛选状态:下拉未选是 null,批次是输入框原文(空串 = 不筛)。 */ +export interface EntryFilterState { + eventId: number | null + categoryId: number | null + teamId: number | null + heat: string +} + +/** 批次写错时的提示(三个按钮共用;现有报表导出用的是同一句话)。 */ +export const ENTRY_HEAT_MESSAGE = '批次需要是正整数(留空表示不筛)' + +/** 解析结果:要么给出可用的筛选条件,要么给出要给用户看的那句话。 */ +export type EntryFilterParse = + | { ok: true; filter: EntryExportFilter } + | { ok: false; message: string } + +/** + * 页面筛选 → 请求参数。 + * + * 只有真的选了 / 填了才带这个维度:下拉没选(null)与批次留空(空串)都让这个键整个不出现 + * (= 不筛);批次必须是非零正整数,写错就整条不发(由调用方提示),免得把「批次」筛成一张空表。 + */ +export function parseEntryFilter(state: EntryFilterState): EntryFilterParse { + const rawHeat = state.heat.trim() + let heat: number | undefined + if (rawHeat !== '') { + const parsed = Number(rawHeat) + if (!Number.isInteger(parsed) || parsed <= 0) return { ok: false, message: ENTRY_HEAT_MESSAGE } + heat = parsed + } + // 没值的维度整个键都不出现(而不是留一个 undefined):拼 query 时不必再判断一次。 + const filter: EntryExportFilter = {} + if (state.eventId !== null) filter.eventId = state.eventId + if (state.categoryId !== null) filter.categoryId = state.categoryId + if (state.teamId !== null) filter.teamId = state.teamId + if (heat !== undefined) filter.heat = heat + return { ok: true, filter } +} + +/** + * 在报表的筛选条件上补 format / lang,得到导入格式导出的请求参数。 + * + * 两个导出走的是同一份 filter(同一个筛选、同一个批次口径),报表那条永远不带 lang: + * 表头语言是导入格式专有的参数,服务端的 entries.xlsx 根本不认它。 + */ +export function entriesImportRequest( + filter: EntryExportFilter, + options: { format?: EntriesImportFormat; lang?: EntriesImportLanguage } = {}, +): EntriesImportRequest { + // 原样带上 filter 里出现过的维度,再补 format / lang —— 两个导出共用同一份筛选。 + return { + ...filter, + format: options.format ?? 'flat', + lang: options.lang ?? 'zh', + } +} + +export interface EntriesImportAction { + format: EntriesImportFormat + /** 按钮上的字。 */ + label: string + /** 悬停说明:既要说清「能拿去重新导入」,也要和上面的报表按钮分得开。 */ + title: string +} + +/** 两个导入格式导出的按钮。措辞与基础数据页的模板下载(中文表头 / 英文表头)同一套口径。 */ +export const ENTRIES_IMPORT_ACTIONS: readonly EntriesImportAction[] = [ + { + format: 'flat', + label: '导出(汇总·导入格式)', + title: + '汇总·导入格式:所有行放在一张工作表里,列与报名导入模板逐列一致(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍 共 13 列),文件名「报名名单(导入格式).xlsx」,可以直接拿去重新导入。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。', + }, + { + format: 'sheets', + label: '导出(分小项·导入格式)', + title: + '分小项·导入格式:按「小项名称@组别名称」分成多张工作表,每张表的列与报名导入模板逐列一致(共 13 列),文件名「报名名单(导入格式·分小项).xlsx」,整份可以直接拿去重新导入;小项与组别多的时候,一个小项组别一张表核对起来不容易串行。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。', + }, +] + +export interface EntriesImportLangOption { + lang: EntriesImportLanguage + label: string + title: string +} + +/** 表头语言选项,默认中文;与模板下载的两个按钮同一套措辞。 */ +export const ENTRIES_IMPORT_LANG_OPTIONS: readonly EntriesImportLangOption[] = [ + { + lang: 'zh', + label: '中文表头', + title: + '两个导入格式导出的表头用中文列名(项目编码 / 组别 / 单位编码…);只影响下面两个导入格式的导出,上面的「导出报名名单」是报表,没有这个参数。', + }, + { + lang: 'en', + label: '英文表头', + title: + '两个导入格式导出的表头用英文规范列名(event_code / category / team_code…);只影响下面两个导入格式的导出,上面的「导出报名名单」是报表,没有这个参数。', + }, +] + +/** 卡片正文里对「报表 / 导入格式」两种口径的说明。 */ +export const ENTRIES_IMPORT_NOTE = + '下面两个按钮导出的是「导入格式」:13 列与报名导入模板逐列一致(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍),导出的文件可以直接拿去重新导入。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。' + +/** 「表头语言」那一行的说明:说清它管谁、不管谁。 */ +export const ENTRIES_IMPORT_LANG_NOTE = + '表头语言只影响下面两个「导入格式」的导出,默认中文;上面的「导出报名名单」是给人看的报表,没有这个参数,改这里不会动它。' diff --git a/web/src/admin/tests/entries-import-export.test.ts b/web/src/admin/tests/entries-import-export.test.ts new file mode 100644 index 0000000..1a9c680 --- /dev/null +++ b/web/src/admin/tests/entries-import-export.test.ts @@ -0,0 +1,243 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { ApiError } from '@/shared' +import { + downloadEntriesImportXLSX, + entriesExportPath, + entriesImportPath, + entriesImportXlsxFilename, + ENTRIES_IMPORT_SHEETS_XLSX_FILENAME, + ENTRIES_IMPORT_XLSX_FILENAME, + type DownloadFile, +} from '@/shared/basedata' +import { + ENTRIES_IMPORT_ACTIONS, + ENTRIES_IMPORT_LANG_NOTE, + ENTRIES_IMPORT_LANG_OPTIONS, + ENTRIES_IMPORT_NOTE, + ENTRY_HEAT_MESSAGE, + entriesImportRequest, + parseEntryFilter, + type EntryFilterState, +} from '../pages/exportEntriesImport' + +/** + * 「导入格式」的报名名单导出(GET /api/export/entries-import.xlsx)在前端这一侧的四件事: + * 请求路径与筛选参数、两种格式各自的文件名、按钮 / 说明文案、以及「表头语言只作用于 + * 两个导入格式、报表那条调用不带 lang」这条口径。 + * + * 现场会踩的坑:筛了却导出全量(参数没拼上)、把 0 / 空串当批次发出去(筛成一张空表)、 + * 点错按钮拿到一份不能导回的报表(文件名与文案分不清)。 + * 服务端那一半(工作簿怎么生成)由另一个代理实现,这里只钉前端发出去的请求。 + */ + +interface Captured { + url?: string + method?: string +} + +function xlsxResponse(): Response { + return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), { + status: 200, + headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + }) +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +let reply: () => Response = () => xlsxResponse() +let lastRequest: Captured = {} + +globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + lastRequest = { url: String(input), method: init?.method ?? 'GET' } + return reply() +}) as typeof fetch + +function respondWith(next: () => Response): void { + lastRequest = {} + reply = next +} + +/** 页面上的筛选状态:什么都没选 / 没填。 */ +function emptyState(): EntryFilterState { + return { eventId: null, categoryId: null, teamId: null, heat: '' } +} + +// ── 参数拼装:缺省值、筛选、批次 ───────────────────────────────────────────── + +test('导入格式缺省 flat + zh:两个参数总会出现在 query 里', () => { + assert.equal(entriesImportPath(), '/export/entries-import.xlsx?format=flat&lang=zh') + assert.equal( + entriesImportPath({ format: 'sheets', lang: 'en' }), + '/export/entries-import.xlsx?format=sheets&lang=en', + ) + + const request = entriesImportRequest({}, {}) + assert.equal(request.format, 'flat') + assert.equal(request.lang, 'zh') +}) + +test('四个筛选只在有值时才拼进 query,顺序与报表导出一致', () => { + const parsed = parseEntryFilter({ eventId: 3, categoryId: 9, teamId: 1, heat: '2' }) + assert.equal(parsed.ok, true) + assert.ok(parsed.ok) + assert.deepEqual(parsed.filter, { eventId: 3, categoryId: 9, teamId: 1, heat: 2 }) + + assert.equal( + entriesImportPath(entriesImportRequest(parsed.filter, { format: 'sheets', lang: 'zh' })), + '/export/entries-import.xlsx?format=sheets&lang=zh&eventId=3&categoryId=9&teamId=1&heat=2', + ) + + // 一个维度都没选:query 里只剩 format / lang,不能出现 eventId= 这类空参数。 + const empty = parseEntryFilter(emptyState()) + assert.ok(empty.ok) + assert.deepEqual(Object.keys(empty.filter), []) + assert.equal( + entriesImportPath(entriesImportRequest(empty.filter)), + '/export/entries-import.xlsx?format=flat&lang=zh', + ) +}) + +test('批次留空 / 只有空格 = 不筛,不进 query', () => { + for (const raw of ['', ' ']) { + const parsed = parseEntryFilter({ ...emptyState(), eventId: 5, heat: raw }) + assert.ok(parsed.ok) + assert.deepEqual(parsed.filter, { eventId: 5 }) + } +}) + +test('批次 0 / 负数 / 小数 / 非数字一律拦下并给同一句提示,不发请求', () => { + for (const raw of ['0', '-1', '1.5', 'abc', '2 批次']) { + const parsed = parseEntryFilter({ ...emptyState(), heat: raw }) + assert.equal(parsed.ok, false, `批次「${raw}」应当被拦下`) + assert.ok(!parsed.ok) + assert.equal(parsed.message, ENTRY_HEAT_MESSAGE) + assert.match(parsed.message, /正整数/) + } + + // 正整数照旧放行,并且去掉两侧空格。 + const parsed = parseEntryFilter({ ...emptyState(), heat: ' 12 ' }) + assert.ok(parsed.ok) + assert.deepEqual(parsed.filter, { heat: 12 }) +}) + +test('报表导出那条调用不带 lang:筛选条件里根本没有这两个键', () => { + const parsed = parseEntryFilter({ eventId: 3, categoryId: null, teamId: null, heat: '2' }) + assert.ok(parsed.ok) + + const reportPath = entriesExportPath(parsed.filter) + assert.equal(reportPath, '/export/entries.xlsx?eventId=3&heat=2') + assert.doesNotMatch(reportPath, /lang|format/) + + // 同一份筛选交给导入格式导出,才补上 format / lang。 + const importRequest = entriesImportRequest(parsed.filter, { format: 'flat', lang: 'en' }) + assert.deepEqual(importRequest, { eventId: 3, heat: 2, format: 'flat', lang: 'en' }) + assert.equal( + entriesImportPath(importRequest), + '/export/entries-import.xlsx?format=flat&lang=en&eventId=3&heat=2', + ) +}) + +// ── 文件名与真正发出去的请求 ───────────────────────────────────────────────── + +test('两种格式的文件名分得清,且与按钮文案一一对应', () => { + assert.equal(entriesImportXlsxFilename('flat'), '报名名单(导入格式).xlsx') + assert.equal(entriesImportXlsxFilename('sheets'), '报名名单(导入格式·分小项).xlsx') + assert.equal(entriesImportXlsxFilename(), '报名名单(导入格式).xlsx') + assert.equal(ENTRIES_IMPORT_XLSX_FILENAME, '报名名单(导入格式).xlsx') + assert.equal(ENTRIES_IMPORT_SHEETS_XLSX_FILENAME, '报名名单(导入格式·分小项).xlsx') + // 与报表的文件名不能撞在一起,否则现场下载完分不清哪份能导回。 + assert.notEqual(ENTRIES_IMPORT_XLSX_FILENAME, ENTRIES_IMPORT_SHEETS_XLSX_FILENAME) +}) + +test('汇总格式:GET /api/export/entries-import.xlsx?format=flat&lang=zh,文件名带「导入格式」', async () => { + respondWith(() => xlsxResponse()) + + const filter = parseEntryFilter({ ...emptyState(), eventId: 3 }) + assert.ok(filter.ok) + const file: DownloadFile = await downloadEntriesImportXLSX(entriesImportRequest(filter.filter)) + + assert.equal(lastRequest.url, '/api/export/entries-import.xlsx?format=flat&lang=zh&eventId=3') + assert.equal(lastRequest.method, 'GET') + assert.equal(file.filename, '报名名单(导入格式).xlsx') + assert.equal(file.blob.size, 4) +}) + +test('分小项格式带 format=sheets,文件名里写清「分小项」', async () => { + respondWith(() => xlsxResponse()) + + const file = await downloadEntriesImportXLSX({ format: 'sheets', lang: 'en', heat: 2 }) + + assert.equal(lastRequest.url, '/api/export/entries-import.xlsx?format=sheets&lang=en&heat=2') + assert.equal(file.filename, '报名名单(导入格式·分小项).xlsx') +}) + +test('服务端 400(参数不对/格式不合法)原样抛出,不产出空文件', async () => { + respondWith(() => + jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: 'format 只能是 flat 或 sheets' } }, 400), + ) + + await assert.rejects( + () => downloadEntriesImportXLSX({ format: 'flat' }), + (error: unknown) => { + assert.ok(error instanceof ApiError) + assert.equal(error.status, 400) + assert.equal(error.message, 'format 只能是 flat 或 sheets') + return true + }, + ) +}) + +// ── 界面文案:能不能一眼看出「这两个是能导回去的」 ─────────────────────────── + +test('两个导入格式按钮的文案点明「导入格式 / 可重新导入」,并说清与报表的区别', () => { + assert.deepEqual( + ENTRIES_IMPORT_ACTIONS.map((action) => action.format), + ['flat', 'sheets'], + ) + + const [flat, sheets] = ENTRIES_IMPORT_ACTIONS + assert.equal(flat.label, '导出(汇总·导入格式)') + assert.equal(sheets.label, '导出(分小项·导入格式)') + + for (const action of ENTRIES_IMPORT_ACTIONS) { + assert.match(action.title, /导入模板/) + assert.match(action.title, /逐列一致/) + assert.match(action.title, /重新导入/) + assert.match(action.title, /13 列/) + // 报表按钮在旁边:区别必须写出来(报表含小项名称 / 单位名称 / 备注,不能直接导回)。 + assert.match(action.title, /报表/) + assert.match(action.title, /备注/) + } + assert.match(flat.title, /一张工作表/) + assert.match(sheets.title, /多张工作表/) + assert.match(sheets.title, /小项名称@组别名称/) + + assert.match(ENTRIES_IMPORT_NOTE, /报表/) + assert.match(ENTRIES_IMPORT_NOTE, /不能直接导回/) + assert.match(ENTRIES_IMPORT_NOTE, /13 列/) +}) + +test('表头语言两个选项默认中文,说明里写清只管两个导入格式、不管报表', () => { + assert.deepEqual( + ENTRIES_IMPORT_LANG_OPTIONS.map((option) => option.lang), + ['zh', 'en'], + ) + assert.equal(ENTRIES_IMPORT_LANG_OPTIONS[0].label, '中文表头') + assert.equal(ENTRIES_IMPORT_LANG_OPTIONS[1].label, '英文表头') + + for (const option of ENTRIES_IMPORT_LANG_OPTIONS) { + assert.match(option.title, /只影响/) + assert.match(option.title, /导出报名名单/) + } + assert.match(ENTRIES_IMPORT_LANG_OPTIONS[1].title, /event_code/) + + assert.match(ENTRIES_IMPORT_LANG_NOTE, /只影响/) + assert.match(ENTRIES_IMPORT_LANG_NOTE, /默认中文/) + assert.match(ENTRIES_IMPORT_LANG_NOTE, /没有这个参数/) +}) diff --git a/web/src/shared/basedata.ts b/web/src/shared/basedata.ts index 9c4034c..4db2487 100644 --- a/web/src/shared/basedata.ts +++ b/web/src/shared/basedata.ts @@ -34,6 +34,10 @@ const API_ERROR_CODES: readonly string[] = [ export const ENTRIES_XLSX_FILENAME = '报名名单.xlsx' export const BASEDATA_XLSX_FILENAME = '基础数据包.xlsx' +/** 导入格式(汇总)导出:所有行在一张工作表里。 */ +export const ENTRIES_IMPORT_XLSX_FILENAME = '报名名单(导入格式).xlsx' +/** 导入格式(分小项)导出:按「小项名称@组别名称」分成多张工作表。 */ +export const ENTRIES_IMPORT_SHEETS_XLSX_FILENAME = '报名名单(导入格式·分小项).xlsx' export interface DownloadFile { blob: Blob @@ -48,6 +52,22 @@ export interface EntryExportFilter { heat?: number } +/** 导出组织方式:flat = 所有行汇总在一张工作表;sheets = 按「小项名称@组别名称」分工作表。 */ +export type EntriesImportFormat = 'flat' | 'sheets' + +/** 表头语言:只有导入格式导出认它(报表导出没有这个参数)。 */ +export type EntriesImportLanguage = 'zh' | 'en' + +/** + * 导入格式导出的请求参数:四个筛选维度与报表导出完全相同,另加 format / lang。 + * + * 缺省 format=flat、lang=zh(与服务端同一口径):调用方拿不准时不必显式写全。 + */ +export interface EntriesImportRequest extends EntryExportFilter { + format?: EntriesImportFormat + lang?: EntriesImportLanguage +} + export interface BaseDataFieldDiff { field: string from: string @@ -79,13 +99,33 @@ export interface BaseDataImportResult { /** 报名名单导出的请求路径(筛选参数为空就不带 query)。 */ export function entriesExportPath(filter: EntryExportFilter = {}): string { - const params = new URLSearchParams() - for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) { - const value = filter[key] - if (value !== undefined) params.append(key, String(value)) - } - const query = params.toString() - return `/export/entries.xlsx${query === '' ? '' : `?${query}`}` + const search = appendEntryFilter(new URLSearchParams(), filter).toString() + return `/export/entries.xlsx${search === '' ? '' : `?${search}`}` +} + +/** + * 导入格式导出的请求路径:`GET /api/export/entries-import.xlsx?format=…&lang=…&<四个筛选>`。 + * + * format / lang 从缺省值补齐后再拼,所以这两个参数总会出现在 query 里; + * 四个筛选维度与报表导出共用 appendEntryFilter,两条路径的筛选口径不会走偏。 + * 前端不产生非法 format(界面上只有两个按钮),服务端收到别的值会回 400。 + */ +export function entriesImportPath(request: EntriesImportRequest = {}): string { + const params = appendEntryFilter( + new URLSearchParams({ format: request.format ?? 'flat', lang: request.lang ?? 'zh' }), + request, + ) + return `/export/entries-import.xlsx?${params.toString()}` +} + +/** + * 导入格式导出的文件名。 + * + * 分小项那一种在线名里就写清「分小项」:文件名是现场分清「汇总表 / 分小项表」、 + * 以及「导入格式 / 给人看的报表」的第一眼线索。 + */ +export function entriesImportXlsxFilename(format: EntriesImportFormat = 'flat'): string { + return format === 'sheets' ? ENTRIES_IMPORT_SHEETS_XLSX_FILENAME : ENTRIES_IMPORT_XLSX_FILENAME } /** 下载报名名单(xlsx)。 */ @@ -93,6 +133,16 @@ export function downloadEntriesXLSX(filter: EntryExportFilter = {}): Promise { + return download(entriesImportPath(request), entriesImportXlsxFilename(request.format)) +} + /** 下载基础数据包(多 sheet 工作簿)。 */ export function downloadBaseDataXLSX(): Promise { return download('/export/basedata.xlsx', BASEDATA_XLSX_FILENAME) @@ -142,6 +192,21 @@ export async function importBaseDataXLSX( // 底层 // --------------------------------------------------------------------------- +/** + * 把四个筛选维度里「有值」的那些拼进 query:未选(undefined)不拼。 + * + * 两条导出路径共用这一份,避免出现「报表按 A 口径拼、导入格式按 B 口径拼」这种 + * 只在某个维度上悄悄不同的偏差。heat = 0 照旧拼进去(口径是「不传 = 不筛」, + * 页面在拼参数前就把空 / 0 / 非法批次换成 undefined,见 pages/exportEntriesImport.ts)。 + */ +function appendEntryFilter(params: URLSearchParams, filter: EntryExportFilter): URLSearchParams { + for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) { + const value = filter[key] + if (value !== undefined) params.append(key, String(value)) + } + return params +} + async function download(path: string, filename: string): Promise { const response = await request(path, { method: 'GET' }) return { blob: await response.blob(), filename }