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 } } // 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 标记该字段的列有没有出现在表头里。 // // 这里的 Has / 空格之分是留给上层的原料,具体怎么解释按列的性质定(解析只负责如实报告): // - 文本列 / 年龄 / 布尔 / 权重 / 排序:列缺席 = 不动,列在而格子空 = 按该列口径处理 // (文本与年龄清空、布尔取「是」、权重取 1、排序保持原值)——与三处用户文档一致; // - 枚举列(小项的 类型/结果模式/结果类型/比较/状态/平均口径、组别的 性别): // 列缺席与格子空**都** = 不动,只有显式填值才覆盖。留空曾按「取系统默认」处理, // 一次误清格就会把已发布小项打回待录入、把团体项目类型改成个人 —— 见 planEvents。 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() // 认表用去空白后的名字(现场整理过的表常带尾随空格),取表必须用**原名**: // 之前拿 spec.Sheet 去 GetRows,表就在文件里却报「sheet 单位 does not exist」—— // 一句与事实相反的错,排障时最容易把人带偏。 actual := make(map[string]string, len(sheets)) for _, name := range sheets { actual[strings.TrimSpace(name)] = name } tables := make([]BaseDataTable, 0, len(baseDataSheets)) for _, spec := range BaseDataSpecs() { name, ok := actual[spec.Sheet] if !ok { continue } // 报错与行级提示一律用契约里的表名(spec.Sheet),不用带空格的原名, // 免得同一张表在错误信息里出现两种写法。 rows, err := f.GetRows(name) 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 }