## 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 例 + 语法检查。
565 lines
18 KiB
Go
565 lines
18 KiB
Go
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()
|
|
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
|
|
}
|