fix(import): 导入提示改成「能照着改」的话 + 模板给「姓名」「队员名单」加说明;版本 dev_0.5.9

## 起因(主人原话)
> 我应该如何用表格导入团体报名? 我现在不管怎么填的话,它都提示:
> 「第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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-14 23:40:31 +08:00
1 parent c5ce1878fc
commit 55051fd633
12 files changed
+364 -76

No files matched your search

@@ -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)
}
}
}
}
}
}
@@ -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
}
+10 -3
View File
@@ -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
}
@@ -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)