feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
—— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。
## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。
## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。
## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。
## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。
## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。
## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
This commit is contained in:
1 parent
9663d5bfb0
commit
7a3e4ad365
67 files changed
+4658
-228
No files matched your search
@@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是「队名」的手工修改:PUT /api/entries/{id}/squad(队名本身见迁移 0025)。
|
||||
//
|
||||
// 为什么需要这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。
|
||||
// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**(老那条仍挂在库里)——
|
||||
// 「填错一个字就得删掉重建」在现场是不能接受的。
|
||||
|
||||
// updateEntrySquad 改一条团体报名的队名,返回更新后的完整读数。
|
||||
//
|
||||
// **不重算**,这是有意的:squad 只是给现场看的标签(榜单、导出件、大屏把「第一分公司」
|
||||
// 显示成「第一分公司青年队」)。它不参与名次 / 积分 / 奖牌的任何一个口径——全仓读 squad 的
|
||||
// 只有导出与报名读数这两处展示代码,引擎(internal/domain)与 score_ledger / results /
|
||||
// channel_ranks 一个字都不看它。所以改它时算分输出的每个字节都不变,这里也就没有
|
||||
// recomputeLedgerTx:白算一遍不但慢,还会白白推一次榜(大屏会跟着闪一下没变的名次)。
|
||||
// 同档的另一个先例是批次 entries.heat,同样是纯展示字段,改它也不重算(见 setEntriesHeat)。
|
||||
func (s *server) updateEntrySquad(ctx context.Context, entryID int64, squad string) (entryDTO, error) {
|
||||
// trim 统一收在这里:导入侧读单元格时已经 trim 过(handlers_config.go 的 cell()),
|
||||
// 手工改走的是另一条路,不 trim 就会把「一队」与「一队 」当成两个队。
|
||||
squad = strings.TrimSpace(squad)
|
||||
|
||||
entry, err := s.getEntry(ctx, entryID)
|
||||
if err != nil {
|
||||
return entryDTO{}, err
|
||||
}
|
||||
// 带运动员的报名按 (组别, 单位, 运动员) 唯一,队名根本不参与它的查重键
|
||||
// (0025 的部分索引带 WHERE athlete_id IS NULL)。写进去既不生效,又会让人以为
|
||||
// 「这个人报了两个队」,所以直接拒掉并说清原因。
|
||||
if entry.AthleteID != nil {
|
||||
return entryDTO{}, badRequest("这条报名带着运动员,队名只对团体报名生效:队名分的是「同一个单位在同一个组别的第几支队」,带运动员的报名按(组别、单位、运动员)唯一,改队名变不出第二支队", nil)
|
||||
}
|
||||
|
||||
// 同组别同单位已经有这个队名时先拦下来:撞索引的报错是英文的约束原文,
|
||||
// 现场看不出是哪个单位、哪个队,也不知道该改哪一格。
|
||||
var other int64
|
||||
err = s.ev.QueryRowContext(ctx,
|
||||
`SELECT id FROM entries
|
||||
WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ? AND id <> ?`,
|
||||
entry.CategoryID, entry.TeamID, squad, entryID).Scan(&other)
|
||||
switch {
|
||||
case err == nil:
|
||||
return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
|
||||
if _, err := s.ev.ExecContext(ctx, `UPDATE entries SET squad = ? WHERE id = ?`, squad, entryID); err != nil {
|
||||
// 预检与 UPDATE 之间不是原子的(两个录入员同时把两条报名改成同一个队名,
|
||||
// 或导入正好在这两步之间落库):真撞上唯一索引时也要翻成同一句人话。
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil)
|
||||
}
|
||||
return entryDTO{}, internal(err)
|
||||
}
|
||||
return s.getEntry(ctx, entryID)
|
||||
}
|
||||
|
||||
// squadTakenMessage 是「这个队名在本组别已经被同单位占了」的现场文案。
|
||||
//
|
||||
// 队名非空那一档导入侧也在用(importedEntryConflictError 的并发分支):两处说的若不是
|
||||
// 同一句话,操作员在导入里学到的那句到了改队名页面就不成立,只能重新猜系统在拦什么。
|
||||
//
|
||||
// 队名留空那一档与导入侧**故意不同**:那边点的是报名表里的「队伍列」,这里没有那一格可点,
|
||||
// 只能点「队名留空」。其余口径两边一致:空 = 不区分队伍,一个单位在一个组别只能有一条。
|
||||
func squadTakenMessage(teamCode, squad string) string {
|
||||
if squad == "" {
|
||||
return fmt.Sprintf("单位 %s 在本组别已经有一条不分队的团体报名:队名留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请把两支队分别写清队名(例如 一队 / 二队)", teamCode)
|
||||
}
|
||||
return fmt.Sprintf("单位 %s 在本组别已经有一条队伍「%s」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队)", teamCode, squad)
|
||||
}
|
||||
|
||||
// updateEntrySquadHandler 是 PUT /api/entries/{id}/squad:改一条团体报名的队名,
|
||||
// 成功后返回这条报名的完整读数(与份额那条接口一样,前端拿它直接刷新那一行)。
|
||||
//
|
||||
// 权限 / CSRF 与 PUT /api/entries/{id}/team-shares 同档:它改的也是报名。
|
||||
func (s *server) updateEntrySquadHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in entrySquadPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
before, err := s.getEntry(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateEntrySquad(r.Context(), id, in.Squad)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entry.squad.update", "entry", id,
|
||||
map[string]any{"squad": before.Squad}, map[string]any{"squad": updated.Squad})
|
||||
return updated, nil
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user