fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -186,20 +186,52 @@ func LoadAthleteDetailWorkbook(db *sql.DB, opts Options) (AthleteDetailWorkbook,
|
||||
return AthleteDetailWorkbook{Meta: meta, Sheets: sheets}, nil
|
||||
}
|
||||
|
||||
// athleteDetailRankExpr 取一条个人流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
|
||||
//
|
||||
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
|
||||
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
|
||||
// - entry_id / result_id 都为空的流水(个人聚合名次):写入侧把 source_id 退化为通道 id,就拿它 + 运动员关联。
|
||||
//
|
||||
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
|
||||
const athleteDetailRankExpr = `COALESCE((
|
||||
SELECT cr.rank
|
||||
FROM channel_ranks cr
|
||||
JOIN event_score_channels ch ON ch.id = cr.channel_id
|
||||
WHERE cr.event_id = l.event_id
|
||||
AND cr.category_id = COALESCE(c.id, ca.id)
|
||||
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
|
||||
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
AND cr.channel_id = l.source_id
|
||||
AND cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))
|
||||
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
|
||||
LIMIT 1), COALESCE(r.rank, 0))`
|
||||
|
||||
func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
|
||||
// 聚合流水的组别回推与单位明细同一条思路:某个运动员在本小项只参加了一个组别时,那个组别就是答案;
|
||||
// 多个组别时回推不出来,宁可留空也不猜(回推只对 entry_id / result_id 都为空的聚合流水生效)。
|
||||
rows, err := db.Query(`
|
||||
SELECT l.team_id, COALESCE(a.name, ''),
|
||||
COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
|
||||
COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
|
||||
` + athleteDetailRankExpr + `, COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
|
||||
l.points, l.source_type,
|
||||
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, 0),
|
||||
COALESCE(l.event_id, 0), COALESCE(c.id, 0), l.id
|
||||
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, ca.sort_order, 0),
|
||||
COALESCE(l.event_id, 0), COALESCE(c.id, ca.id, 0), l.id
|
||||
FROM score_ledger l
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
LEFT JOIN events e ON e.id = l.event_id
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
LEFT JOIN entries en ON en.id = l.entry_id
|
||||
LEFT JOIN event_categories c ON c.id = en.category_id
|
||||
LEFT JOIN (
|
||||
SELECT en2.athlete_id, en2.event_id, MIN(en2.category_id) AS category_id
|
||||
FROM entries en2
|
||||
WHERE en2.athlete_id IS NOT NULL
|
||||
GROUP BY en2.athlete_id, en2.event_id
|
||||
HAVING COUNT(DISTINCT en2.category_id) = 1
|
||||
) aac ON aac.event_id = l.event_id AND aac.athlete_id = l.athlete_id
|
||||
AND l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
LEFT JOIN event_categories ca ON ca.id = aac.category_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
WHERE l.athlete_id IS NOT NULL AND l.athlete_id <> 0 AND l.voided = 0`)
|
||||
if err != nil {
|
||||
|
||||
@@ -371,7 +371,13 @@ type BaseDataRow struct {
|
||||
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
|
||||
Line int
|
||||
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
|
||||
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
|
||||
//
|
||||
// 这里的 Has / 空格之分是留给上层的原料,具体怎么解释按列的性质定(解析只负责如实报告):
|
||||
// - 文本列 / 年龄 / 布尔 / 权重 / 排序:列缺席 = 不动,列在而格子空 = 按该列口径处理
|
||||
// (文本与年龄清空、布尔取「是」、权重取 1、排序保持原值)——与三处用户文档一致;
|
||||
// - 枚举列(小项的 类型/结果模式/结果类型/比较/状态/平均口径、组别的 性别):
|
||||
// 列缺席与格子空**都** = 不动,只有显式填值才覆盖。留空曾按「取系统默认」处理,
|
||||
// 一次误清格就会把已发布小项打回待录入、把团体项目类型改成个人 —— 见 planEvents。
|
||||
Cells map[string]string
|
||||
Has map[string]bool
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestColumnsFitContent(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedTieAndRelay(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -67,6 +67,30 @@ func seedEvent(t *testing.T, db *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedChannelRanks 按已发布成绩补一份 channel_ranks:它是发布 / 重算时由计分引擎全量重建的
|
||||
// 派生数据(devdocs 17 §五),而导出测试直接造库、不走发布流程,不补这一份就读不到任何名次。
|
||||
// 口径与迁移前的读取口径一致:一条已发布且有排名的成绩 × 该小项每条适用通道(scope=team 或
|
||||
// 报名的是个人)各出一行,subject_key 用报名项('e:<entryId>',与引擎一致)。
|
||||
//
|
||||
// 注意:TestExportStandingsMatchesSnapshot 不调用它——那份用例要求导出与 WS 快照同源,
|
||||
// 而快照侧(internal/realtime)本轮尚未改读 channel_ranks,两边都还没有奖牌数据才对得上。
|
||||
func seedChannelRanks(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points,
|
||||
result_value, status)
|
||||
SELECT r.event_id, en.category_id, c.id, c.basis, c.scope, 'e:' || en.id,
|
||||
en.id, r.id, en.athlete_id, en.team_id, r.rank, 0, r.result_value, r.status
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
|
||||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)`); err != nil {
|
||||
t.Fatalf("补 channel_ranks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
|
||||
@@ -276,23 +276,24 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
|
||||
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
// loadRanked 读取发布产出的通道名次(channel_ranks),供 domain 统计奖牌。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同口径:每个计分通道各自一行名次,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌(devdocs 17 §五)。
|
||||
// 三个奖牌开关随行携带(events.award_medals + 通道的 count_to_medals / count_to_team_medals),
|
||||
// domain 的榜单函数不必回查库;积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
|
||||
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND ev.scored = 1
|
||||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
|
||||
ORDER BY r.id, c.sort_order, c.id`)
|
||||
SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank,
|
||||
cr.result_value, cr.status,
|
||||
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
|
||||
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
|
||||
ev.award_medals, c.count_to_medals, c.count_to_team_medals
|
||||
FROM channel_ranks cr
|
||||
JOIN events ev ON ev.id = cr.event_id
|
||||
JOIN event_score_channels c ON c.id = cr.channel_id
|
||||
LEFT JOIN athletes a ON a.id = cr.athlete_id
|
||||
WHERE ev.scored = 1 AND cr.rank > 0
|
||||
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取名次: %w", err)
|
||||
}
|
||||
@@ -301,22 +302,32 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
status sql.NullString
|
||||
awardMedals int
|
||||
countToMedals int
|
||||
countToTeamMed int
|
||||
)
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
|
||||
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
|
||||
&rr.Basis, &rr.SubjectKey,
|
||||
&awardMedals, &countToMedals, &countToTeamMed); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||||
}
|
||||
rr.Rank = rank
|
||||
rr.Status = domain.ResultStatus(status.String)
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
// 归属份额沿用「主单位独占 100%」:team_id 为空(聚合 / 团体手工名次理论上不会)时不造份额,
|
||||
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
|
||||
if rr.TeamID != 0 {
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
}
|
||||
rr.AwardMedals = awardMedals != 0
|
||||
rr.CountToMedals = countToMedals != 0
|
||||
rr.CountToTeamMedals = countToTeamMed != 0
|
||||
out = append(out, rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -445,8 +456,12 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
||||
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
|
||||
// 连成绩行都没有的报名项同样占位(NoResult)。
|
||||
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
|
||||
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
|
||||
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
|
||||
// 落不了库)回落到 results.rank,保持名次列不空白。
|
||||
query := `
|
||||
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
||||
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
||||
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
|
||||
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
|
||||
COALESCE((SELECT SUM(l.points) FROM score_ledger l
|
||||
@@ -456,6 +471,13 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
||||
JOIN teams t ON t.id = en.team_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
LEFT JOIN results r ON r.entry_id = en.id
|
||||
LEFT JOIN event_score_channels mc ON mc.id = (
|
||||
SELECT c2.id FROM event_score_channels c2
|
||||
WHERE c2.event_id = en.event_id
|
||||
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
|
||||
LIMIT 1)
|
||||
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id
|
||||
AND cr.category_id = en.category_id
|
||||
WHERE en.event_id = ?`
|
||||
args := []any{eventID}
|
||||
if categoryID != 0 {
|
||||
@@ -548,6 +570,29 @@ func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// teamDetailRankExpr 取一条单位流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
|
||||
//
|
||||
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
|
||||
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
|
||||
// - entry_id / result_id 都为空的流水(聚合名次与团体手工名次):写入侧把 source_id 退化为通道 id,
|
||||
// 就拿它 + 主体(单位 / 运动员)关联。
|
||||
//
|
||||
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
|
||||
const teamDetailRankExpr = `COALESCE((
|
||||
SELECT cr.rank
|
||||
FROM channel_ranks cr
|
||||
JOIN event_score_channels ch ON ch.id = cr.channel_id
|
||||
WHERE cr.event_id = l.event_id
|
||||
AND cr.category_id = COALESCE(c.id, ca.id)
|
||||
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
|
||||
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
AND cr.channel_id = l.source_id
|
||||
AND ((cr.scope = 'team' AND cr.team_id = l.team_id)
|
||||
OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))))
|
||||
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
|
||||
LIMIT 1), COALESCE(r.rank, 0))`
|
||||
|
||||
func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
// 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空,
|
||||
// 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定;
|
||||
@@ -556,7 +601,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
// 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
|
||||
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
|
||||
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
|
||||
FROM score_ledger l
|
||||
LEFT JOIN events e ON e.id = l.event_id
|
||||
@@ -575,7 +620,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`,
|
||||
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order,
|
||||
COALESCE(NULLIF(`+teamDetailRankExpr+`, 0), 999999), l.id`,
|
||||
teamID, teamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
|
||||
|
||||
@@ -66,6 +66,9 @@ func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
// scored=0 的小项照样有通道名次(引擎只排名不计分也会产出名次),
|
||||
// 靠 loadRanked 的 scored 过滤把它们挡在奖牌之外。
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
ranked, err := loadRanked(db)
|
||||
if err != nil {
|
||||
@@ -77,6 +80,17 @@ func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if countRows(t, db, "channel_ranks") == 0 {
|
||||
t.Fatal("用例前提不成立:channel_ranks 是空的,过滤没被真正考验")
|
||||
}
|
||||
var event2Ranks int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM channel_ranks WHERE event_id = 2`).Scan(&event2Ranks); err != nil {
|
||||
t.Fatalf("统计小项 2 的通道名次: %v", err)
|
||||
}
|
||||
if event2Ranks == 0 {
|
||||
t.Fatal("用例前提不成立:只排名的小项没有通道名次行,scored 过滤没被真正考验")
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
@@ -101,6 +115,7 @@ func TestLoadStandingsHonoursTieBreak(t *testing.T) {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
@@ -263,6 +278,190 @@ func findAggregateRow(rows []TeamDetailRow) (TeamDetailRow, bool) {
|
||||
return TeamDetailRow{}, false
|
||||
}
|
||||
|
||||
// 三个奖牌开关随名次行从 events / event_score_channels 带出来(devdocs 17 §4.5):
|
||||
// 关掉小项的 award_medals、或通道的 count_to_medals,奖牌就该消失;
|
||||
// 只关 count_to_team_medals 时个人榜仍有牌、单位榜没有。
|
||||
func TestLoadRankedCarriesMedalSwitches(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
ranked, err := loadRanked(db)
|
||||
if err != nil {
|
||||
t.Fatalf("读取名次: %v", err)
|
||||
}
|
||||
if len(ranked) == 0 {
|
||||
t.Fatal("用例前提不成立:读不到任何名次")
|
||||
}
|
||||
for _, rr := range ranked {
|
||||
if !rr.AwardMedals || !rr.CountToMedals || !rr.CountToTeamMedals {
|
||||
t.Fatalf("三列默认全为 1,开关应全为 true: %+v", rr)
|
||||
}
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 1 {
|
||||
t.Fatalf("基线 T1 金牌 = %d,期望 1", gold)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("关闭小项奖牌结算: %v", err)
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 0 {
|
||||
t.Errorf("award_medals=0 时 T1 金牌 = %d,期望 0", gold)
|
||||
}
|
||||
// 奖牌明细与榜单同口径(medals.go 走 domain.BuildMedalStandings),关掉开关后也不该再出牌。
|
||||
if sheets := medalSheetsOf(t, db); len(sheets) != 0 {
|
||||
t.Errorf("award_medals=0 时奖牌明细仍出分表: %v", sheets)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 1 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("恢复小项奖牌结算: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("关闭通道奖牌: %v", err)
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 0 {
|
||||
t.Errorf("count_to_medals=0 时 T1 金牌 = %d,期望 0", gold)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 1 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("恢复通道奖牌: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("关闭个人奖牌并入单位: %v", err)
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 0 {
|
||||
t.Errorf("count_to_team_medals=0 时 T1 单位金牌 = %d,期望 0", gold)
|
||||
}
|
||||
if gold := athleteGold(t, db, 1); gold != 1 {
|
||||
t.Errorf("count_to_team_medals=0 时张三个人金牌 = %d,期望 1(只影响单位榜)", gold)
|
||||
}
|
||||
}
|
||||
|
||||
// medalSheetsOf 取奖牌明细里出了分表的单位 id(没有奖牌时为空)。
|
||||
func medalSheetsOf(t *testing.T, db *sql.DB) []int64 {
|
||||
t.Helper()
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取奖牌明细: %v", err)
|
||||
}
|
||||
out := make([]int64, 0, len(wb.Sheets))
|
||||
for _, s := range wb.Sheets {
|
||||
out = append(out, s.Team.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// standingsGold / athleteGold 取导出总表里某个单位 / 运动员的金牌数。
|
||||
func standingsGold(t *testing.T, db *sql.DB, teamID int64) int {
|
||||
t.Helper()
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取总表: %v", err)
|
||||
}
|
||||
for _, row := range wb.Team {
|
||||
if row.TeamID == teamID {
|
||||
return row.Gold
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func athleteGold(t *testing.T, db *sql.DB, athleteID int64) int {
|
||||
t.Helper()
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取总表: %v", err)
|
||||
}
|
||||
for _, row := range wb.Athlete {
|
||||
if row.AthleteID == athleteID {
|
||||
return row.Gold
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 分项成绩表的名次列取「主通道」名次(devdocs 17 §11.3):双通道项目里 scope=athlete 的通道优先,
|
||||
// 一张报名项只显示一个名次;channel_ranks 里没有对应行时回落到 results.rank。
|
||||
func TestEventRowsUseMainChannelRank(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
// 补一条团体通道,让「100米跑」变成双通道项目(与 TEAM_PULLUP 同形)。
|
||||
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
|
||||
t.Fatalf("补团体通道: %v", err)
|
||||
}
|
||||
seedChannelRanks(t, db)
|
||||
// 个人名次只认 channel_ranks:把 results.rank 改坏,成绩表仍要显示个人通道的 1。
|
||||
if _, err := db.Exec(`UPDATE results SET rank = 4 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("改坏 results.rank: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadEventWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出分项成绩: %v", err)
|
||||
}
|
||||
rows := eventRows(wb.Sheets[0].Rows)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("行数 = %d,期望 2(一条报名项一行,不按通道复制)", len(rows))
|
||||
}
|
||||
// 行序按 results.rank 排(T1 被改坏成 4),所以按号码布找那一行。
|
||||
byBib := make(map[string][]any, len(rows))
|
||||
for _, r := range rows {
|
||||
byBib[r[1].(string)] = r
|
||||
}
|
||||
if got := byBib["101"][0]; got != "1" {
|
||||
t.Errorf("101 的名次列 = %v,期望主通道(个人通道)的 1", got)
|
||||
}
|
||||
if got := byBib["102"][0]; got != "2" {
|
||||
t.Errorf("102 的名次列 = %v,期望 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 团体手工 / 聚合这类没有成绩行的流水也要显示 channel_ranks 里的团体名次
|
||||
// (迁移前这类流水的 rank 恒为 0,devdocs 17 §11.3)。
|
||||
func TestTeamDetailShowsSubjectRank(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
// 团体手工通道:名次按单位录入,流水没有 entry_id / result_id,写入侧把 source_id 退化为通道 id。
|
||||
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
|
||||
t.Fatalf("补团体手工通道: %v", err)
|
||||
}
|
||||
var channelID int64
|
||||
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
|
||||
t.Fatalf("读通道 id: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (1, 1, ?, 'manual', 'team', 't:1', NULL, NULL, NULL, 1, 2, 0)`, channelID); err != nil {
|
||||
t.Fatalf("造团体手工名次: %v", err)
|
||||
}
|
||||
// 流水与写入侧一致:source_id 退化为通道 id(见 domain.ledgerFromRanked)。
|
||||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, count_to_team)
|
||||
VALUES (1, NULL, 1, NULL, NULL, 'result', ?, 6, 1)`, channelID); err != nil {
|
||||
t.Fatalf("造团体手工流水: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取积分明细: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, r := range wb.Rows {
|
||||
if r.Points != 6 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Rank != 2 {
|
||||
t.Errorf("团体手工流水的名次 = %d,期望 2(来自 channel_ranks)", r.Rank)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("团体手工流水没出现在明细里: %+v", wb.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分,
|
||||
// 导出侧与 REST / WS 快照必须同口径。
|
||||
func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
|
||||
|
||||
@@ -176,6 +176,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
// 聚合通道与团体手工名次没有对应的成绩(channel_ranks.result_id 为空),按成绩 id 查不到展示字段,
|
||||
// 按小项 + 组别补一份兜底。
|
||||
eventCtx, err := loadMedalEventContext(db)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
teams, err := loadTeams(db, false)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
@@ -188,7 +194,7 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
|
||||
sheets := make([]MedalDetailSheet, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
rows := medalRowsOfTeam(t.ID, hits, ctx)
|
||||
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx)
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -197,11 +203,14 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
return MedalDetailWorkbook{Meta: meta, Sheets: sheets}, nil
|
||||
}
|
||||
|
||||
// medalHit 是一枚奖牌:来自哪条成绩、什么名次、什么牌。
|
||||
// medalHit 是一枚奖牌:来自哪个小项 / 组别的哪条成绩、什么名次、什么牌。
|
||||
// 聚合名次与团体手工名次没有单条成绩(resultID 为 0),小项 + 组别是展示字段的兜底来源。
|
||||
type medalHit struct {
|
||||
resultID int64
|
||||
rank int
|
||||
medal string
|
||||
eventID int64
|
||||
categoryID int64
|
||||
resultID int64
|
||||
rank int
|
||||
medal string
|
||||
}
|
||||
|
||||
// medalHits 逐「报名项」判定获奖单位。
|
||||
@@ -222,13 +231,16 @@ func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medal
|
||||
rr := group[0]
|
||||
for _, row := range rows {
|
||||
for i := 0; i < row.Gold; i++ {
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 1, medalGold})
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{
|
||||
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 1, medal: medalGold})
|
||||
}
|
||||
for i := 0; i < row.Silver; i++ {
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 2, medalSilver})
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{
|
||||
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 2, medal: medalSilver})
|
||||
}
|
||||
for i := 0; i < row.Bronze; i++ {
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 3, medalBronze})
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{
|
||||
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 3, medal: medalBronze})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,12 +339,57 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// eventCategoryKey 是「小项 + 组别」的键。
|
||||
type eventCategoryKey struct {
|
||||
eventID int64
|
||||
categoryID int64
|
||||
}
|
||||
|
||||
// loadMedalEventContext 按 (小项, 组别) 建一份展示字段,给没有单条成绩的奖牌兜底:
|
||||
// 聚合通道与团体手工名次在 channel_ranks 里 result_id 为空,按成绩 id 查不到来源,
|
||||
// 只能回到小项 + 组别上取大项 / 小项 / 组别名(运动员/队留空——聚合名次不对应单个人)。
|
||||
func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT e.id, COALESCE(c.id, 0), COALESCE(s.name, ''), e.name, COALESCE(c.name, ''),
|
||||
COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0)
|
||||
FROM events e
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
LEFT JOIN event_categories c ON c.event_id = e.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取奖牌小项来源: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[eventCategoryKey]medalContext)
|
||||
for rows.Next() {
|
||||
var (
|
||||
key eventCategoryKey
|
||||
c medalContext
|
||||
)
|
||||
if err := rows.Scan(&key.eventID, &key.categoryID, &c.sportName, &c.eventName, &c.category,
|
||||
&c.sportSort, &c.eventSort, &c.catSort); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描奖牌小项来源: %w", err)
|
||||
}
|
||||
c.eventID = key.eventID
|
||||
c.categoryID = key.categoryID
|
||||
out[key] = c
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历奖牌小项来源: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
|
||||
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext) []MedalDetailRow {
|
||||
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
|
||||
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext) []MedalDetailRow {
|
||||
list := hits[teamID]
|
||||
rows := make([]MedalDetailRow, 0, len(list))
|
||||
for _, h := range list {
|
||||
c := ctx[h.resultID]
|
||||
c, ok := ctx[h.resultID]
|
||||
if !ok {
|
||||
c = eventCtx[eventCategoryKey{eventID: h.eventID, categoryID: h.categoryID}]
|
||||
}
|
||||
rows = append(rows, MedalDetailRow{
|
||||
SportName: c.sportName,
|
||||
EventName: c.eventName,
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestExportMedalDetailXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
|
||||
addBronzeEntry(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
|
||||
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
|
||||
@@ -127,6 +128,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
@@ -173,6 +175,58 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出
|
||||
// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。
|
||||
func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
|
||||
t.Fatalf("补团体手工通道: %v", err)
|
||||
}
|
||||
var channelID int64
|
||||
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
|
||||
t.Fatalf("读通道 id: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (1, 1, ?, 'manual', 'team', 't:2', NULL, NULL, NULL, 2, 1, 0)`, channelID); err != nil {
|
||||
t.Fatalf("造团体手工名次: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取奖牌明细: %v", err)
|
||||
}
|
||||
board, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取团体奖牌榜: %v", err)
|
||||
}
|
||||
for _, row := range board.Medal {
|
||||
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
|
||||
if gold != row.Gold || silver != row.Silver || bronze != row.Bronze {
|
||||
t.Errorf("单位 %d 奖牌明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d",
|
||||
row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze)
|
||||
}
|
||||
}
|
||||
|
||||
subjectOnly := 0
|
||||
for _, s := range wb.Sheets {
|
||||
for _, r := range s.Rows {
|
||||
if r.SportName == "" || r.EventName == "" || r.Category == "" {
|
||||
t.Errorf("奖牌明细缺大项 / 小项 / 组别: %+v", r)
|
||||
}
|
||||
if r.Participant == "" {
|
||||
subjectOnly++
|
||||
}
|
||||
}
|
||||
}
|
||||
if subjectOnly != 1 {
|
||||
t.Errorf("没有成绩的奖牌行数 = %d,期望 1(团体手工名次),明细 = %+v", subjectOnly, wb.Sheets)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有奖牌(甚至没有小项)时不报错,返回只有表头的总表。
|
||||
func TestExportMedalDetailEmptyEvent(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
@@ -204,6 +258,7 @@ func TestExportMedalDetailEmptyEvent(t *testing.T) {
|
||||
func TestMedalDetailKeepsHiddenTeamWithMedals(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
silent := insertTeam(t, db, "H2", "被隐藏且无牌", "隐藏二", 4, false)
|
||||
hideTeam(t, db, silent)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
func TestExportStandingsXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "总表.xlsx")
|
||||
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
|
||||
|
||||
Reference in new issue
Block a user