feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
@@ -0,0 +1,571 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 基础数据包的四个工作表名。导入按这几个中文名认表:缺哪张就整张跳过,
|
||||
// 一张都没有才报错——「没写的表」代表不改,绝不代表删除。
|
||||
const (
|
||||
BaseDataSheetTeams = "单位"
|
||||
BaseDataSheetSports = "大项"
|
||||
BaseDataSheetEvents = "小项"
|
||||
BaseDataSheetCategories = "组别"
|
||||
)
|
||||
|
||||
// BaseDataHeader 是一列表头与字段的对应关系。
|
||||
// Header 是导出写进单元格的中文表头,Field 是导入按它取值的规范字段名。
|
||||
// 导出与导入共用这一份定义:表头文案只有一处,往返(导出→原样导入)天然对得上。
|
||||
type BaseDataHeader struct {
|
||||
Header string
|
||||
Field string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// BaseDataSheetSpec 是一张基础数据表的导入契约:表名 + 表头集合。
|
||||
type BaseDataSheetSpec struct {
|
||||
Sheet string
|
||||
Headers []BaseDataHeader
|
||||
}
|
||||
|
||||
// baseDataColumn 在表头之外带上版式(列宽 / 对齐),仅供导出用。
|
||||
type baseDataColumn struct {
|
||||
BaseDataHeader
|
||||
width float64
|
||||
kind cellKind
|
||||
}
|
||||
|
||||
type baseDataSheet struct {
|
||||
sheet string
|
||||
columns []baseDataColumn
|
||||
}
|
||||
|
||||
// baseDataSheets 是基础数据包的全部内容定义,顺序即导入顺序:
|
||||
// 单位 → 大项 → 小项 → 组别,后者依赖前者的编码(组别依赖小项,小项依赖大项)。
|
||||
var baseDataSheets = []baseDataSheet{
|
||||
{
|
||||
sheet: BaseDataSheetTeams,
|
||||
columns: []baseDataColumn{
|
||||
{BaseDataHeader{"单位编码", "code", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"单位名称", "name", true}, 24, kindText},
|
||||
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
|
||||
{BaseDataHeader{"颜色", "color", false}, 12, kindCenter},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
|
||||
},
|
||||
},
|
||||
{
|
||||
sheet: BaseDataSheetSports,
|
||||
columns: []baseDataColumn{
|
||||
{BaseDataHeader{"大项编码", "code", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"大项名称", "name", true}, 24, kindText},
|
||||
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
|
||||
},
|
||||
},
|
||||
{
|
||||
sheet: BaseDataSheetEvents,
|
||||
columns: []baseDataColumn{
|
||||
{BaseDataHeader{"大项编码", "sportCode", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"小项编码", "code", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"小项名称", "name", true}, 22, kindText},
|
||||
{BaseDataHeader{"类型", "eventType", false}, 14, kindCenter},
|
||||
{BaseDataHeader{"结果模式", "resultMode", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"结果类型", "resultType", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"比较", "compare", false}, 8, kindCenter},
|
||||
{BaseDataHeader{"计分", "scored", false}, 8, kindCenter},
|
||||
{BaseDataHeader{"计破纪录", "recordEligible", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
|
||||
{BaseDataHeader{"状态", "status", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"平均口径", "avgMode", false}, 10, kindCenter},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
{BaseDataHeader{"场地", "venue", false}, 16, kindText},
|
||||
{BaseDataHeader{"备注", "remark", false}, 20, kindText},
|
||||
},
|
||||
},
|
||||
{
|
||||
sheet: BaseDataSheetCategories,
|
||||
columns: []baseDataColumn{
|
||||
// 组别必须带所属小项编码,否则导回来时对不上是哪个体项目的组别。
|
||||
{BaseDataHeader{"小项编码", "eventCode", true}, 14, kindCenter},
|
||||
{BaseDataHeader{"组别名", "name", true}, 16, kindText},
|
||||
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
|
||||
{BaseDataHeader{"性别", "gender", false}, 8, kindCenter},
|
||||
{BaseDataHeader{"年龄下限", "ageMin", false}, 10, kindInt},
|
||||
{BaseDataHeader{"年龄上限", "ageMax", false}, 10, kindInt},
|
||||
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// BaseDataSpecs 返回基础数据包的导入契约(表名与表头),顺序即导入顺序。
|
||||
// 调用方(httpapi)拿它做表头识别与行级校验,不再自己写一份中文表头。
|
||||
func BaseDataSpecs() []BaseDataSheetSpec {
|
||||
out := make([]BaseDataSheetSpec, 0, len(baseDataSheets))
|
||||
for _, s := range baseDataSheets {
|
||||
spec := BaseDataSheetSpec{Sheet: s.sheet, Headers: make([]BaseDataHeader, 0, len(s.columns))}
|
||||
for _, c := range s.columns {
|
||||
spec.Headers = append(spec.Headers, c.BaseDataHeader)
|
||||
}
|
||||
out = append(out, spec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BaseDataRecord 是一行待写出的基础数据:字段名 → 单元格值。
|
||||
type BaseDataRecord map[string]any
|
||||
|
||||
// BaseDataWorkbook 是基础数据包的内容,一张表一份记录列表。
|
||||
type BaseDataWorkbook struct {
|
||||
Meta Meta
|
||||
Teams []BaseDataRecord
|
||||
Sports []BaseDataRecord
|
||||
Events []BaseDataRecord
|
||||
Categories []BaseDataRecord
|
||||
}
|
||||
|
||||
// records 按工作表名取记录,顺序与 baseDataSheets 一致。
|
||||
func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord {
|
||||
switch sheet {
|
||||
case BaseDataSheetTeams:
|
||||
return wb.Teams
|
||||
case BaseDataSheetSports:
|
||||
return wb.Sports
|
||||
case BaseDataSheetEvents:
|
||||
return wb.Events
|
||||
case BaseDataSheetCategories:
|
||||
return wb.Categories
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExportBaseDataXLSX 导出基础数据包到 dst。
|
||||
func ExportBaseDataXLSX(db *sql.DB, dst string, opts Options) error {
|
||||
wb, err := LoadBaseDataWorkbook(db, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := baseDataFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。
|
||||
func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error {
|
||||
f, err := baseDataFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func baseDataFile(wb BaseDataWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook(BaseDataSheetTeams, func(f *excelize.File, st *styleSet) error {
|
||||
meta := wb.Meta.normalized()
|
||||
namer := &sheetNamer{}
|
||||
for _, s := range baseDataSheets {
|
||||
name := namer.add(s.sheet)
|
||||
if err := addSheet(f, name); err != nil {
|
||||
return err
|
||||
}
|
||||
cols := make([]column, 0, len(s.columns))
|
||||
for _, c := range s.columns {
|
||||
cols = append(cols, column{c.Header, c.width, c.kind})
|
||||
}
|
||||
rows := baseDataRows(s, wb.records(s.sheet))
|
||||
if err := writeSheet(f, st, name, meta, sheetTitle(meta, s.sheet), cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// baseDataRows 按列定义把记录摊平成行;记录里缺的字段写空,不写 "0" / "false" 这类噪声。
|
||||
func baseDataRows(s baseDataSheet, records []BaseDataRecord) [][]any {
|
||||
out := make([][]any, 0, len(records))
|
||||
for _, rec := range records {
|
||||
row := make([]any, 0, len(s.columns))
|
||||
for _, c := range s.columns {
|
||||
v, ok := rec[c.Field]
|
||||
if !ok || v == nil {
|
||||
v = ""
|
||||
}
|
||||
row = append(row, v)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// boolCell 把布尔值写成「是 / 否」:中文表头配中文取值,现场改表时不用查英文枚举。
|
||||
func boolCell(v bool) string {
|
||||
if v {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
|
||||
// LoadBaseDataWorkbook 读取全部基础数据(单位 / 大项 / 小项 / 组别)。
|
||||
//
|
||||
// 单位排除「彻底删除」(deleted_at 非空)的行:它们对界面不可见,也不该被重新印进导出件;
|
||||
// 停用(active=0)的单位照常导出,启用列写「否」,导入回来才不会把它误改回启用。
|
||||
func LoadBaseDataWorkbook(db *sql.DB, opts Options) (BaseDataWorkbook, error) {
|
||||
if db == nil {
|
||||
return BaseDataWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
|
||||
teams, err := loadBaseDataTeams(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
sports, err := loadBaseDataSports(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
events, err := loadBaseDataEvents(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
categories, err := loadBaseDataCategories(db)
|
||||
if err != nil {
|
||||
return BaseDataWorkbook{}, err
|
||||
}
|
||||
return BaseDataWorkbook{Meta: meta, Teams: teams, Sports: sports, Events: events, Categories: categories}, nil
|
||||
}
|
||||
|
||||
func loadBaseDataTeams(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT code, name, COALESCE(short_name, ''), COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE deleted_at IS NULL ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
code, name, shortName, color string
|
||||
sortOrder, active int
|
||||
)
|
||||
if err := rows.Scan(&code, &name, &shortName, &color, &sortOrder, &active); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描单位: %w", err)
|
||||
}
|
||||
out = append(out, BaseDataRecord{
|
||||
"code": code, "name": name, "shortName": shortName, "color": color,
|
||||
"sortOrder": sortOrder, "active": boolCell(active != 0),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadBaseDataSports(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT code, name, COALESCE(short_name, ''), sort_order, active
|
||||
FROM sports ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取大项: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
code, name, shortName string
|
||||
sortOrder, active int
|
||||
)
|
||||
if err := rows.Scan(&code, &name, &shortName, &sortOrder, &active); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描大项: %w", err)
|
||||
}
|
||||
out = append(out, BaseDataRecord{
|
||||
"code": code, "name": name, "shortName": shortName,
|
||||
"sortOrder": sortOrder, "active": boolCell(active != 0),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadBaseDataEvents(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type,
|
||||
e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode,
|
||||
e.sort_order, COALESCE(e.venue, ''), COALESCE(e.remark, '')
|
||||
FROM events e
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
ORDER BY COALESCE(s.sort_order, 0), e.sort_order, e.code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取小项: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
sportCode, code, name string
|
||||
eventType, resultMode, resultType, cmpare string
|
||||
status, avgMode, venue, remark string
|
||||
scored, recordEligible, sortOrder int
|
||||
multiplier float64
|
||||
)
|
||||
if err := rows.Scan(&sportCode, &code, &name, &eventType, &resultMode, &resultType,
|
||||
&cmpare, &scored, &recordEligible, &multiplier, &status, &avgMode,
|
||||
&sortOrder, &venue, &remark); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描小项: %w", err)
|
||||
}
|
||||
out = append(out, BaseDataRecord{
|
||||
"sportCode": sportCode, "code": code, "name": name,
|
||||
"eventType": eventType, "resultMode": resultMode, "resultType": resultType,
|
||||
"compare": cmpare, "scored": boolCell(scored != 0), "recordEligible": boolCell(recordEligible != 0),
|
||||
"multiplier": multiplier, "status": status, "avgMode": avgMode,
|
||||
"sortOrder": sortOrder, "venue": venue, "remark": remark,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadBaseDataCategories(db *sql.DB) ([]BaseDataRecord, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(e.code, ''), c.name, c.multiplier, COALESCE(c.gender, ''),
|
||||
c.age_min, c.age_max, c.sort_order
|
||||
FROM event_categories c
|
||||
LEFT JOIN events e ON e.id = c.event_id
|
||||
ORDER BY COALESCE(e.sort_order, 0), e.code, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取组别: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []BaseDataRecord
|
||||
for rows.Next() {
|
||||
var (
|
||||
eventCode, name, gender string
|
||||
multiplier float64
|
||||
ageMin, ageMax sql.NullInt64
|
||||
sortOrder int
|
||||
)
|
||||
if err := rows.Scan(&eventCode, &name, &multiplier, &gender, &ageMin, &ageMax, &sortOrder); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描组别: %w", err)
|
||||
}
|
||||
rec := BaseDataRecord{
|
||||
"eventCode": eventCode, "name": name, "multiplier": multiplier,
|
||||
"gender": gender, "sortOrder": sortOrder,
|
||||
}
|
||||
rec["ageMin"], rec["ageMax"] = "", ""
|
||||
if ageMin.Valid {
|
||||
rec["ageMin"] = int(ageMin.Int64)
|
||||
}
|
||||
if ageMax.Valid {
|
||||
rec["ageMax"] = int(ageMax.Int64)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 导入侧:读取工作簿 ----------
|
||||
|
||||
// BaseDataRow 是基础数据表里的一行数据。
|
||||
type BaseDataRow struct {
|
||||
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
|
||||
Line int
|
||||
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
|
||||
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
|
||||
Cells map[string]string
|
||||
Has map[string]bool
|
||||
}
|
||||
|
||||
// BaseDataTable 是从工作簿里解析出的一张表。
|
||||
type BaseDataTable struct {
|
||||
Sheet string
|
||||
HeaderLine int
|
||||
Rows []BaseDataRow
|
||||
}
|
||||
|
||||
// maxHeaderScanRows 是表头检测的扫描范围。
|
||||
// 本系统的导出件在表头上方有标题与副标题(表头在第 4 行),手工整理的表通常表头在第 1 行,
|
||||
// 再往上加两三行说明也有可能;扫前 20 行足够,又不会被中间某行数据误判成表头。
|
||||
const maxHeaderScanRows = 20
|
||||
|
||||
// ReadBaseDataPackage 解析基础数据包工作簿,按工作表返回表头位置与数据行。
|
||||
// 只认 baseDataSheets 里的四个中文表名,别的工作表一律忽略;一张都没找到时返回错误。
|
||||
func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) {
|
||||
f, err := excelize.OpenReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 打开基础数据包: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
sheets := f.GetSheetList()
|
||||
present := make(map[string]bool, len(sheets))
|
||||
for _, name := range sheets {
|
||||
present[strings.TrimSpace(name)] = true
|
||||
}
|
||||
|
||||
tables := make([]BaseDataTable, 0, len(baseDataSheets))
|
||||
for _, spec := range BaseDataSpecs() {
|
||||
if !present[spec.Sheet] {
|
||||
continue
|
||||
}
|
||||
rows, err := f.GetRows(spec.Sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err)
|
||||
}
|
||||
table, err := parseBaseDataSheet(spec, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, table)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("export: 基础数据包缺少可识别的工作表(应为 %s)", baseDataSheetNames())
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func baseDataSheetNames() string {
|
||||
names := make([]string, 0, len(baseDataSheets))
|
||||
for _, s := range baseDataSheets {
|
||||
names = append(names, s.sheet)
|
||||
}
|
||||
return strings.Join(names, " / ")
|
||||
}
|
||||
|
||||
// parseBaseDataSheet 在表头行定位成功后,把其后的数据行逐行摊成「字段 → 原文」。
|
||||
// 整行为空的行(用户在表尾留的空行)直接跳过,不产生「必填」误报。
|
||||
func parseBaseDataSheet(spec BaseDataSheetSpec, rows [][]string) (BaseDataTable, error) {
|
||||
headerLine, index := findBaseDataHeader(spec, rows)
|
||||
if headerLine < 0 {
|
||||
return BaseDataTable{}, fmt.Errorf("export: 工作表 %s 找不到表头行:应有一行同时包含 %s(本系统导出的包把表头放在标题行下方)",
|
||||
spec.Sheet, strings.Join(requiredHeaders(spec), "、"))
|
||||
}
|
||||
|
||||
table := BaseDataTable{Sheet: spec.Sheet, HeaderLine: headerLine + 1}
|
||||
for i := headerLine + 1; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
cells := make(map[string]string, len(index))
|
||||
has := make(map[string]bool, len(index))
|
||||
blank := true
|
||||
for _, h := range spec.Headers {
|
||||
col, ok := index[h.Field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
has[h.Field] = true
|
||||
v := ""
|
||||
if col < len(row) {
|
||||
v = strings.TrimSpace(row[col])
|
||||
}
|
||||
cells[h.Field] = v
|
||||
if v != "" {
|
||||
blank = false
|
||||
}
|
||||
}
|
||||
if blank {
|
||||
continue
|
||||
}
|
||||
table.Rows = append(table.Rows, BaseDataRow{Line: i + 1, Cells: cells, Has: has})
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// findBaseDataHeader 找出表头行:返回行下标(0 起)与「字段 → 列下标」。
|
||||
// 判据是该行必须同时出现全部必填表头,避免把标题行或数据行认成表头。
|
||||
func findBaseDataHeader(spec BaseDataSheetSpec, rows [][]string) (int, map[string]int) {
|
||||
limit := len(rows)
|
||||
if limit > maxHeaderScanRows {
|
||||
limit = maxHeaderScanRows
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
byHeader := make(map[string]int, len(rows[i]))
|
||||
for j, cell := range rows[i] {
|
||||
text := strings.TrimSpace(cell)
|
||||
if _, dup := byHeader[text]; !dup {
|
||||
byHeader[text] = j
|
||||
}
|
||||
}
|
||||
index := make(map[string]int, len(spec.Headers))
|
||||
missing := false
|
||||
for _, h := range spec.Headers {
|
||||
col, ok := byHeader[h.Header]
|
||||
if !ok {
|
||||
if h.Required {
|
||||
missing = true
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
index[h.Field] = col
|
||||
}
|
||||
if !missing {
|
||||
return i, index
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func requiredHeaders(spec BaseDataSheetSpec) []string {
|
||||
out := make([]string, 0, len(spec.Headers))
|
||||
for _, h := range spec.Headers {
|
||||
if h.Required {
|
||||
out = append(out, h.Header)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 导入侧用的小工具 ----------
|
||||
|
||||
// ParseBaseDataBool 解析「是 / 否」型单元格:空值取 fallback,认不出的写法返回 false。
|
||||
func ParseBaseDataBool(raw string, fallback bool) (bool, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "":
|
||||
return fallback, true
|
||||
case "是", "1", "true", "yes", "y", "启用", "on":
|
||||
return true, true
|
||||
case "否", "0", "false", "no", "n", "停用", "off":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBaseDataFloat 解析数值列;空值取 fallback。
|
||||
func ParseBaseDataFloat(raw string, fallback float64) (float64, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return fallback, true
|
||||
}
|
||||
v, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ParseBaseDataInt 解析整数列;空值返回 nil。返回的第二个值表示是否合法。
|
||||
func ParseBaseDataInt(raw string) (*int, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return nil, true
|
||||
}
|
||||
v, err := strconv.Atoi(text)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &v, true
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。
|
||||
// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项,
|
||||
// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。
|
||||
func seedBaseData(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
|
||||
VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`,
|
||||
`INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`,
|
||||
`INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`,
|
||||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, sort_order, venue, remark)
|
||||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`,
|
||||
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`,
|
||||
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
|
||||
VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`,
|
||||
`INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。
|
||||
func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if row.Cells[field] == value {
|
||||
return row
|
||||
}
|
||||
}
|
||||
t.Fatalf("找不到 %s = %q 的行", field, value)
|
||||
return BaseDataRow{}
|
||||
}
|
||||
|
||||
// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、
|
||||
// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。
|
||||
func TestBaseDataPackageRoundTrip(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedBaseData(t, db)
|
||||
|
||||
wb, err := LoadBaseDataWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取基础数据: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteBaseDataXLSX(&buf, wb); err != nil {
|
||||
t.Fatalf("写出基础数据包: %v", err)
|
||||
}
|
||||
|
||||
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("读回基础数据包: %v", err)
|
||||
}
|
||||
if len(tables) != 4 {
|
||||
t.Fatalf("工作表数 = %d,期望 4", len(tables))
|
||||
}
|
||||
wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories}
|
||||
wantRows := []int{2, 2, 3, 4}
|
||||
for i, table := range tables {
|
||||
if table.Sheet != wantSheets[i] {
|
||||
t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i])
|
||||
}
|
||||
if table.HeaderLine != rowHeader {
|
||||
t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader)
|
||||
}
|
||||
if len(table.Rows) != wantRows[i] {
|
||||
t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i])
|
||||
}
|
||||
// 表头必须与导出契约逐字一致,否则导入端认不出列。
|
||||
spec := BaseDataSpecs()[i]
|
||||
for _, h := range spec.Headers {
|
||||
found := false
|
||||
for _, row := range table.Rows {
|
||||
if row.Has[h.Field] {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(table.Rows) > 0 {
|
||||
t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teams := tables[0]
|
||||
if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" {
|
||||
t.Errorf("单位编码 = %q,期望 T1", got)
|
||||
}
|
||||
t1 := findRecord(t, teams.Rows, "code", "T1")
|
||||
if got := t1.Cells["active"]; got != "是" {
|
||||
t.Errorf("T1 启用 = %q,期望 是", got)
|
||||
}
|
||||
if got := t1.Cells["color"]; got != "#3482FF" {
|
||||
t.Errorf("T1 颜色 = %q", got)
|
||||
}
|
||||
if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" {
|
||||
t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got)
|
||||
}
|
||||
|
||||
events := tables[2]
|
||||
e1 := findRecord(t, events.Rows, "code", "E1")
|
||||
if got := e1.Cells["sportCode"]; got != "SP1" {
|
||||
t.Errorf("小项大项编码 = %q,期望 SP1", got)
|
||||
}
|
||||
if got := e1.Cells["multiplier"]; got != "2" {
|
||||
t.Errorf("E1 权重 = %q,期望 2", got)
|
||||
}
|
||||
if got := e1.Cells["venue"]; got != "主体育场" {
|
||||
t.Errorf("E1 场地 = %q", got)
|
||||
}
|
||||
if got := e1.Cells["resultType"]; got != "time" {
|
||||
t.Errorf("E1 结果类型 = %q,期望 time", got)
|
||||
}
|
||||
if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" {
|
||||
t.Errorf("E3 大项编码 = %q,期望 SP2", got)
|
||||
}
|
||||
|
||||
cats := tables[3]
|
||||
findRecord(t, cats.Rows, "eventCode", "E1")
|
||||
c1 := findRecord(t, cats.Rows, "name", "男子青年组")
|
||||
if got := c1.Cells["eventCode"]; got != "E1" {
|
||||
t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got)
|
||||
}
|
||||
if got := c1.Cells["ageMin"]; got != "18" {
|
||||
t.Errorf("年龄下限 = %q,期望 18", got)
|
||||
}
|
||||
if got := c1.Cells["gender"]; got != "男" {
|
||||
t.Errorf("性别 = %q,期望 男", got)
|
||||
}
|
||||
if got := c1.Cells["multiplier"]; got != "1.5" {
|
||||
t.Errorf("组别权重 = %q,期望 1.5", got)
|
||||
}
|
||||
|
||||
// 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。
|
||||
raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("打开基础数据包: %v", err)
|
||||
}
|
||||
defer func() { _ = raw.Close() }()
|
||||
headerRow, err := raw.GetRows(BaseDataSheetCategories)
|
||||
if err != nil {
|
||||
t.Fatalf("读取组别表: %v", err)
|
||||
}
|
||||
for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} {
|
||||
if got := cell(headerRow, rowHeader-1, i); got != h {
|
||||
t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。
|
||||
func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||||
t.Fatalf("重命名工作表: %v", err)
|
||||
}
|
||||
rows := [][]string{
|
||||
{"单位编码", "单位名称", "启用"},
|
||||
{"T9", "第九分公司", "是"},
|
||||
{"T8", "第八分公司", "否"},
|
||||
}
|
||||
for r, row := range rows {
|
||||
for c, v := range row {
|
||||
name, _ := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err := f.SetCellValue("单位", name, v); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
|
||||
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("读回: %v", err)
|
||||
}
|
||||
if len(tables) != 1 || tables[0].Sheet != "单位" {
|
||||
t.Fatalf("只应认出单位表,得到 %+v", tables)
|
||||
}
|
||||
if tables[0].HeaderLine != 1 {
|
||||
t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine)
|
||||
}
|
||||
if len(tables[0].Rows) != 2 {
|
||||
t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows))
|
||||
}
|
||||
if got := tables[0].Rows[0].Line; got != 2 {
|
||||
t.Errorf("首行行号 = %d,期望 2", got)
|
||||
}
|
||||
if got := tables[0].Rows[1].Line; got != 3 {
|
||||
t.Errorf("次行行号 = %d,期望 3", got)
|
||||
}
|
||||
// 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。
|
||||
if tables[0].Rows[0].Has["shortName"] {
|
||||
t.Errorf("文件里没有简称列,Has[shortName] 不该为 true")
|
||||
}
|
||||
if !tables[0].Rows[0].Has["active"] {
|
||||
t.Errorf("文件里有启用列,Has[active] 应为 true")
|
||||
}
|
||||
if tables[0].Rows[0].Cells["name"] != "第九分公司" {
|
||||
t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBaseDataPackageErrors(t *testing.T) {
|
||||
// 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
|
||||
t.Fatalf("重命名工作表: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil {
|
||||
t.Fatal("找不到表头应报错")
|
||||
} else if !strings.Contains(err.Error(), "单位编码") {
|
||||
t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error())
|
||||
}
|
||||
|
||||
// 一张可识别的表都没有。
|
||||
f2 := excelize.NewFile()
|
||||
defer func() { _ = f2.Close() }()
|
||||
if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
var buf2 bytes.Buffer
|
||||
if err := f2.Write(&buf2); err != nil {
|
||||
t.Fatalf("写出测试工作簿: %v", err)
|
||||
}
|
||||
if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil {
|
||||
t.Fatal("没有任何可识别工作表应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBaseDataHelpers(t *testing.T) {
|
||||
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
|
||||
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
|
||||
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} {
|
||||
if v, ok := ParseBaseDataBool(raw, true); !ok || v {
|
||||
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok)
|
||||
}
|
||||
}
|
||||
if v, ok := ParseBaseDataBool("", true); !ok || !v {
|
||||
t.Errorf("空值应取 fallback,得到 %v,%v", v, ok)
|
||||
}
|
||||
if _, ok := ParseBaseDataBool("也许", true); ok {
|
||||
t.Error("认不出的写法应报不合法")
|
||||
}
|
||||
|
||||
if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 {
|
||||
t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok)
|
||||
}
|
||||
if _, ok := ParseBaseDataFloat("两倍", 1); ok {
|
||||
t.Error("非数字权重应报不合法")
|
||||
}
|
||||
if v, ok := ParseBaseDataInt(""); !ok || v != nil {
|
||||
t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok)
|
||||
}
|
||||
if _, ok := ParseBaseDataInt("三"); ok {
|
||||
t.Error("非整数应报不合法")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
// entriesSheet 是「报名名单」唯一那张表的表名。
|
||||
const entriesSheet = "报名名单"
|
||||
|
||||
// EntriesFilter 是报名名单的筛选条件;某一项为零值即该维度不筛选(全部)。
|
||||
// heat 的合法值从 1 起,所以 0 可以安全地表示「不按批次筛选」。
|
||||
type EntriesFilter struct {
|
||||
EventID int64
|
||||
CategoryID int64
|
||||
TeamID int64
|
||||
Heat int64
|
||||
}
|
||||
|
||||
// EntryExportRow 是报名名单的一行。
|
||||
// 批次(heat)属于报名数据,是用户点名要看到的列:未分批时留空,也要占着一列。
|
||||
type EntryExportRow struct {
|
||||
EventCode string
|
||||
EventName string
|
||||
Category string
|
||||
TeamCode string
|
||||
TeamName string
|
||||
Name string
|
||||
IDCard string
|
||||
Bib string
|
||||
Heat string
|
||||
Gender string
|
||||
Age any
|
||||
Status string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// EntriesWorkbook 是「报名名单」的内容:一张平铺表。
|
||||
type EntriesWorkbook struct {
|
||||
Meta Meta
|
||||
Rows []EntryExportRow
|
||||
}
|
||||
|
||||
// entriesColumns 的列序与用户点名要的口径一致:批次紧跟在号码布之后。
|
||||
// 号码布用 kindText:它是带前导零的字符串("007"),任何数字格式都会把它吃掉。
|
||||
var entriesColumns = []column{
|
||||
{"小项编码", 12, kindCenter},
|
||||
{"小项名", 18, kindText},
|
||||
{"组别", 14, kindText},
|
||||
{"单位编码", 12, kindCenter},
|
||||
{"单位名", 22, kindText},
|
||||
{"姓名", 16, kindText},
|
||||
{"证件号", 22, kindCenter},
|
||||
{"号码布", 10, kindText},
|
||||
{"批次", 8, kindCenter},
|
||||
{"性别", 8, kindCenter},
|
||||
{"年龄", 8, kindInt},
|
||||
{"状态", 12, kindCenter},
|
||||
{"备注", 20, kindText},
|
||||
}
|
||||
|
||||
// ExportEntriesXLSX 导出报名名单到 dst。
|
||||
func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error {
|
||||
wb, err := LoadEntriesWorkbook(db, filter, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := entriesFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteEntriesXLSX 把报名名单写成 xlsx 流,供 HTTP 直接响应下载。
|
||||
func WriteEntriesXLSX(w io.Writer, wb EntriesWorkbook) error {
|
||||
f, err := entriesFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func entriesFile(wb EntriesWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook(entriesSheet, func(f *excelize.File, st *styleSet) error {
|
||||
meta := wb.Meta.normalized()
|
||||
name := sanitizeSheetName(entriesSheet)
|
||||
if err := addSheet(f, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSheet(f, st, name, meta, sheetTitle(meta, entriesSheet), entriesColumns, entriesDataRows(wb.Rows))
|
||||
})
|
||||
}
|
||||
|
||||
func entriesDataRows(rows []EntryExportRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, []any{
|
||||
r.EventCode, r.EventName, r.Category, r.TeamCode, r.TeamName,
|
||||
r.Name, r.IDCard, r.Bib, r.Heat, r.Gender, r.Age, r.Status, r.Remark,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadEntriesWorkbook 读取报名名单;筛选条件为空表示导出全部。
|
||||
//
|
||||
// 读的是报名表本身(entries),不是成绩表:批次、号码布、状态都是报名侧的数据。
|
||||
// 姓名取运动员姓名,团体行(athlete_id 为空)回落到成员名单,与分项成绩表同口径。
|
||||
func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (EntriesWorkbook, error) {
|
||||
if db == nil {
|
||||
return EntriesWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return EntriesWorkbook{}, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(t.code, ''), COALESCE(t.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(en.member_names, ''),
|
||||
COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat,
|
||||
COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, '')
|
||||
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.team_id", filter.TeamID},
|
||||
{"en.heat", filter.Heat},
|
||||
} {
|
||||
if f.value != 0 {
|
||||
conds = append(conds, f.column+" = ?")
|
||||
args = append(args, f.value)
|
||||
}
|
||||
}
|
||||
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 EntriesWorkbook{}, fmt.Errorf("export: 读取报名名单: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []EntryExportRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
row EntryExportRow
|
||||
memberJSON string
|
||||
heat sql.NullInt64
|
||||
age sql.NullInt64
|
||||
athleteName string
|
||||
)
|
||||
if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName,
|
||||
&athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat,
|
||||
&row.Gender, &age, &row.Status, &row.Remark); err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err)
|
||||
}
|
||||
row.Name = participantName(athleteName, memberJSON)
|
||||
if heat.Valid {
|
||||
row.Heat = strconv.FormatInt(heat.Int64, 10)
|
||||
}
|
||||
if age.Valid {
|
||||
row.Age = int(age.Int64)
|
||||
} else {
|
||||
row.Age = ""
|
||||
}
|
||||
row.Status = entryExportStatusLabel(row.Status)
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return EntriesWorkbook{}, fmt.Errorf("export: 遍历报名名单: %w", err)
|
||||
}
|
||||
return EntriesWorkbook{Meta: meta, Rows: out}, nil
|
||||
}
|
||||
|
||||
// entryExportStatusLabel 与录入页报名状态徽标同口径(registered 正常参赛 / scratched 已退赛 /
|
||||
// dq 取消资格 / dns 未出场),导出件上的字与现场在界面上看到的一致。
|
||||
func entryExportStatusLabel(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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// seedEntries 造一份覆盖筛选维度的报名名单:两个单位、两个小项、两个组别,
|
||||
// 含批次(有无各一)、中文姓名、带前导零的号码布,以及一条团体报名(无运动员)。
|
||||
func seedEntries(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, age) VALUES ('张伟', '男', 1, '110101199001011234', 35)`,
|
||||
`INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('李强', '男', 2, '110101199202022345', 33)`,
|
||||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`,
|
||||
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 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 events(sport_id, code, name, status, sort_order) VALUES (2, 'E3', '100米自由泳', 'pending', 3)`,
|
||||
`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)`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
|
||||
// 批次 2、号码布带前导零。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, heat, status, remark)
|
||||
VALUES (1, 1, 1, 1, '007', 2, 'registered', '卫冕冠军')`,
|
||||
// 未分批,号码布 0012。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status)
|
||||
VALUES (1, 1, 2, 2, '0012', 'scratched')`,
|
||||
// 团体报名:没有运动员,姓名回落成员名单。
|
||||
`INSERT INTO entries(event_id, category_id, team_id, member_names, heat, status)
|
||||
VALUES (2, 3, 1, '["王磊","赵雷"]', 1, 'registered')`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportEntriesXLSXColumnsAndRows(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntries(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "报名名单.xlsx")
|
||||
if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil {
|
||||
t.Fatalf("导出报名名单: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("重新打开 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
wantSheet := []string{"报名名单"}
|
||||
if got := f.GetSheetList(); !equalStrings(got, wantSheet) {
|
||||
t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheet)
|
||||
}
|
||||
rows, err := f.GetRows("报名名单")
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
// 4 行版式 + 3 条报名。
|
||||
if len(rows) != 7 {
|
||||
t.Fatalf("行数 = %d,期望 7(4 行版式 + 3 条报名)", len(rows))
|
||||
}
|
||||
|
||||
want := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名", "证件号",
|
||||
"号码布", "批次", "性别", "年龄", "状态", "备注"}
|
||||
for i, header := range want {
|
||||
if got := cell(rows, 3, i); got != header {
|
||||
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序:未分批的排最前(heat IS NOT NULL 升序 = false < true)。
|
||||
if got := cell(rows, 4, 5); got != "李强" {
|
||||
t.Fatalf("第一行姓名 = %q,期望 李强(未分批排最前)", got)
|
||||
}
|
||||
if got := cell(rows, 4, 7); got != "0012" {
|
||||
t.Errorf("未分批行号码布 = %q,期望 0012(前导零不能丢)", got)
|
||||
}
|
||||
if got := cell(rows, 4, 8); got != "" {
|
||||
t.Errorf("未分批行批次 = %q,期望留空", got)
|
||||
}
|
||||
if got := cell(rows, 4, 11); got != "已退赛" {
|
||||
t.Errorf("scratched 状态 = %q,期望 已退赛", got)
|
||||
}
|
||||
if got := cell(rows, 4, 2); got != "男子" {
|
||||
t.Errorf("组别 = %q,期望 男子", got)
|
||||
}
|
||||
if got := cell(rows, 4, 10); got != "33" {
|
||||
t.Errorf("年龄 = %q,期望 33", got)
|
||||
}
|
||||
|
||||
if got := cell(rows, 5, 5); got != "张伟" {
|
||||
t.Fatalf("第二行姓名 = %q,期望 张伟", got)
|
||||
}
|
||||
if got := cell(rows, 5, 7); got != "007" {
|
||||
t.Errorf("号码布 = %q,期望 007", got)
|
||||
}
|
||||
if got := cell(rows, 5, 8); got != "2" {
|
||||
t.Errorf("批次 = %q,期望 2", got)
|
||||
}
|
||||
if got := cell(rows, 5, 11); got != "正常参赛" {
|
||||
t.Errorf("registered 状态 = %q,期望 正常参赛", got)
|
||||
}
|
||||
if got := cell(rows, 5, 12); got != "卫冕冠军" {
|
||||
t.Errorf("备注 = %q,期望 卫冕冠军", got)
|
||||
}
|
||||
|
||||
// 团体行:姓名取成员名单,批次 1。
|
||||
if got := cell(rows, 6, 5); got != "王磊、赵雷" {
|
||||
t.Errorf("团体行姓名 = %q,期望 王磊、赵雷", got)
|
||||
}
|
||||
if got := cell(rows, 6, 1); got != "4×100米接力" {
|
||||
t.Errorf("团体行小项名 = %q,期望 4×100米接力", got)
|
||||
}
|
||||
|
||||
// 号码布必须是文本单元格:写成数字会把 007 变成 7。
|
||||
if typ, err := f.GetCellType("报名名单", "H5"); err != nil || typ != excelize.CellTypeSharedString {
|
||||
t.Errorf("号码布单元格类型 = %v (err=%v),期望共享字符串(保住前导零)", typ, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportEntriesXLSXFilters(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntries(t, db)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
filter EntriesFilter
|
||||
want int
|
||||
first string
|
||||
}{
|
||||
{"全部", EntriesFilter{}, 3, "李强"},
|
||||
{"按批次", EntriesFilter{Heat: 2}, 1, "张伟"},
|
||||
{"按单位", EntriesFilter{TeamID: 1}, 2, "张伟"},
|
||||
{"按小项", EntriesFilter{EventID: 1}, 2, "李强"},
|
||||
{"按组别", EntriesFilter{CategoryID: 3}, 1, "王磊、赵雷"},
|
||||
{"组合筛选", EntriesFilter{EventID: 1, TeamID: 1, Heat: 2}, 1, "张伟"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wb, err := LoadEntriesWorkbook(db, tc.filter, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
if len(wb.Rows) != tc.want {
|
||||
t.Fatalf("行数 = %d,期望 %d", len(wb.Rows), tc.want)
|
||||
}
|
||||
if got := wb.Rows[0].Name; got != tc.first {
|
||||
t.Errorf("首行姓名 = %q,期望 %q", got, tc.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 不存在的批次号不是错误,只是没有数据。
|
||||
wb, err := LoadEntriesWorkbook(db, EntriesFilter{Heat: 99}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取空结果: %v", err)
|
||||
}
|
||||
if len(wb.Rows) != 0 {
|
||||
t.Errorf("批次 99 应为空,得到 %d 行", len(wb.Rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEntriesXLSXStream(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEntries(t, db)
|
||||
|
||||
wb, err := LoadEntriesWorkbook(db, EntriesFilter{}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteEntriesXLSX(&buf, wb); err != nil {
|
||||
t.Fatalf("流式导出: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("打开流式导出的 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
rows, err := f.GetRows("报名名单")
|
||||
if err != nil {
|
||||
t.Fatalf("读取报名名单: %v", err)
|
||||
}
|
||||
if len(rows) != 7 {
|
||||
t.Fatalf("行数 = %d,期望 7", len(rows))
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ type EventResultRow struct {
|
||||
Points float64
|
||||
Status string
|
||||
Remark string
|
||||
// Unpublished 表示这行有草稿成绩但还没发布:成绩值不写出来,
|
||||
// 名次与积分对未发布成绩没有意义,一并换成占位符,见 eventRows。
|
||||
Unpublished bool
|
||||
// NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。
|
||||
// 与 Unpublished 互斥——有成绩行才会有发布状态。
|
||||
NoResult bool
|
||||
}
|
||||
|
||||
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
|
||||
@@ -117,12 +123,36 @@ func eventSheetTitle(meta Meta, sh EventSheet) string {
|
||||
return sheetTitle(meta, base)
|
||||
}
|
||||
|
||||
// eventCellBlank 是「这一格还没有数值」的占位符。留着空格子会让整行读起来像缺数据,
|
||||
// 占位符才读得出「这是一条完整记录,只是还没发布 / 还没录」。
|
||||
const eventCellBlank = "—"
|
||||
|
||||
// eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。
|
||||
const eventCellUnpublished = "分数未录入"
|
||||
|
||||
// eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。
|
||||
//
|
||||
// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值,
|
||||
// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。
|
||||
func eventRows(rows []EventResultRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var (
|
||||
rank any = r.Rank
|
||||
result any = displayResult(r.ResultText, r.Unit)
|
||||
points any = r.Points
|
||||
)
|
||||
switch {
|
||||
case r.NoResult:
|
||||
// 连成绩行都没有:三格都没值,状态列才是「未录入」。
|
||||
rank, result, points = eventCellBlank, eventCellBlank, eventCellBlank
|
||||
case r.Unpublished:
|
||||
// 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。
|
||||
rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank
|
||||
}
|
||||
out = append(out, []any{
|
||||
r.Rank, r.Bib, r.TeamName, r.Participant,
|
||||
displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark,
|
||||
rank, r.Bib, r.TeamName, r.Participant,
|
||||
result, points, r.Status, r.Remark,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -30,7 +30,8 @@ func (m Meta) normalized() Meta {
|
||||
// Options 是导出选项;零值即「正式成绩」口径。
|
||||
type Options struct {
|
||||
Meta Meta
|
||||
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。
|
||||
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出;
|
||||
// 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。
|
||||
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
|
||||
IncludeDrafts bool
|
||||
}
|
||||
@@ -441,6 +442,8 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
||||
|
||||
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
|
||||
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
|
||||
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
|
||||
// 连成绩行都没有的报名项同样占位(NoResult)。
|
||||
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
|
||||
query := `
|
||||
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
||||
@@ -459,9 +462,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
||||
query += ` AND en.category_id = ?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
if !includeDrafts {
|
||||
query += ` AND (r.state IS NULL OR r.state = 'published')`
|
||||
}
|
||||
// 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。
|
||||
// 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行,
|
||||
// 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。
|
||||
query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id`
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
@@ -489,6 +492,11 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
||||
row.Rank = rankLabel(rank, isTie != 0)
|
||||
row.Status = statusLabel(resultStat, resultState, entryStat)
|
||||
row.Remark = joinRemark(entryRemark, resultNote)
|
||||
// results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。
|
||||
// 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。
|
||||
row.NoResult = !resultState.Valid
|
||||
row.Unpublished = !includeDrafts && resultState.Valid &&
|
||||
domain.ResultState(resultState.String) == domain.ResultStateDraft
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
@@ -293,3 +294,127 @@ func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedDraftAndBlank 在 seedEvent 之上补两类「还没发布」的报名:
|
||||
// 王五有一条草稿成绩(录了没发布),赵六一条成绩都没有。种子里的两条是已发布成绩。
|
||||
func seedDraftAndBlank(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
|
||||
// rank 留空:草稿还没参与排名,也让行序稳定落在已发布成绩之后。
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 3, NULL, '12.10', 12.10, 's', 'draft')`,
|
||||
`INSERT INTO athletes(name, gender, team_id) VALUES ('赵六', '男', 2)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 4, '104')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 草稿行不能从表上消失,但要按「未发布」呈现:成绩列写「分数未录入」,名次与积分填「—」;
|
||||
// 连成绩行都没有的行三格一律占位;已发布的格子一个都不许变。
|
||||
func TestEventRowsShowUnpublishedDraftWithoutDroppingIt(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
// 先取一份只有已发布成绩的导出,等下用它证明已发布行没被新行挤动。
|
||||
before, err := LoadEventWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出已发布成绩: %v", err)
|
||||
}
|
||||
if len(before.Sheets) != 1 {
|
||||
t.Fatalf("组别数 = %d,期望 1", len(before.Sheets))
|
||||
}
|
||||
publishedRows := eventRows(before.Sheets[0].Rows)
|
||||
|
||||
seedDraftAndBlank(t, db)
|
||||
after, err := LoadEventWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出含草稿的成绩: %v", err)
|
||||
}
|
||||
rows := eventRows(after.Sheets[0].Rows)
|
||||
if len(rows) != 4 {
|
||||
t.Fatalf("行数 = %d,期望 4(已发布 2 + 草稿 1 + 没成绩 1): %v", len(rows), rows)
|
||||
}
|
||||
|
||||
// 已发布的两行:每个格子与「没有那两条未发布报名时」完全一致。
|
||||
for i, want := range publishedRows {
|
||||
if !equalCells(rows[i], want) {
|
||||
t.Errorf("第 %d 行被改动了:得到 %v,期望 %v", i+1, rows[i], want)
|
||||
}
|
||||
}
|
||||
// 再钉一遍字面值:这两行是现场打印件上的样子,不能因为新增占位逻辑而漂移。
|
||||
wantPublished := [][]any{
|
||||
{"1", "101", "第一分公司", "张三", "11.58 秒", 10.0, "有效", ""},
|
||||
{"2", "102", "第二分公司", "李四", "11.90 秒", 8.0, "有效", ""},
|
||||
}
|
||||
for i, want := range wantPublished {
|
||||
if !equalCells(rows[i], want) {
|
||||
t.Errorf("已发布第 %d 行 = %v,期望 %v", i+1, rows[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// 草稿行(王五):占一行,成绩列是「分数未录入」,名次与积分是「—」。
|
||||
if got := rows[2]; !equalCells(got, []any{
|
||||
"—", "103", "第一分公司", "王五", "分数未录入", "—", "草稿", "",
|
||||
}) {
|
||||
t.Errorf("草稿行 = %v,期望名次 / 积分占位、成绩写「分数未录入」", got)
|
||||
}
|
||||
|
||||
// 完全没成绩的行(赵六):三个成绩格子都不留空白也不写 0,状态仍是未录入。
|
||||
if got := rows[3]; !equalCells(got, []any{
|
||||
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
|
||||
}) {
|
||||
t.Errorf("没成绩的行 = %v,期望名次 / 成绩 / 积分都是「—」、状态未录入", got)
|
||||
}
|
||||
// 逐列再钉一次:这三格的列序是 名次(0) / 成绩(4) / 积分(5),改列序时这里会先炸。
|
||||
for _, col := range []int{0, 4, 5} {
|
||||
if got := rows[3][col]; got != "—" {
|
||||
t.Errorf("没成绩的行第 %d 列 = %v,期望「—」", col, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// includeDrafts 打开时(草稿也导出)草稿行的真实值照常写出;
|
||||
// 连成绩行都没有的行仍然三格占位——它本来就没有值可写。
|
||||
func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedDraftAndBlank(t, db)
|
||||
|
||||
wb, err := LoadEventWorkbook(db, 1, Options{IncludeDrafts: true})
|
||||
if err != nil {
|
||||
t.Fatalf("带草稿导出: %v", err)
|
||||
}
|
||||
rows := eventRows(wb.Sheets[0].Rows)
|
||||
if len(rows) != 4 {
|
||||
t.Fatalf("行数 = %d,期望 4", len(rows))
|
||||
}
|
||||
if got := rows[2]; !equalCells(got, []any{
|
||||
"", "103", "第一分公司", "王五", "12.10 秒", 0.0, "草稿", "",
|
||||
}) {
|
||||
t.Errorf("带草稿导出时草稿行 = %v,期望写出草稿成绩值", got)
|
||||
}
|
||||
if got := rows[3]; !equalCells(got, []any{
|
||||
"—", "104", "第二分公司", "赵六", "—", "—", "未录入", "",
|
||||
}) {
|
||||
t.Errorf("带草稿导出时没成绩的行 = %v,期望三格仍是「—」", got)
|
||||
}
|
||||
}
|
||||
|
||||
// equalCells 逐格比较两行;float64 与 string 都按值比。
|
||||
func equalCells(got, want []any) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user