package export import ( "database/sql" "fmt" "math" "strconv" "strings" ) // 本文件是「联队 + 份额分成」(devdocs/19)在导出侧的读法。 // // 一条报名配了多个成员单位时(现场有两个单位因人数不足合并参赛),导出件要做两件事: // - 把联队的名字写出来,**成员单位名写全**,形如「第一分公司+第二分公司联队」; // - 讲清这条分拆给了哪几个单位、各多少(份额比例 + 各单位实得积分)。 // // 两个口径必须分开(已拍板,见 domain.TeamShare):积分按份额拆(允许 0.5 分), // 奖牌**每个成员单位各记一枚整枚**。后者会让「各单位的奖牌数加起来」大于实际颁发的奖牌数, // 那是定稿口径、不是 bug,所以联队奖牌那一格自己要把「各单位各记 1 枚」写出来, // 含联队的榜单类导出件另在副标题里写一句口径说明(见 jointMedalNote)。 // jointMedalNote 是含联队的榜单类导出件(总表 / 单位奖牌明细)副标题上的口径说明。 // 奖牌数之和会大于实际颁发数是定稿口径(每个成员单位各记一整枚), // 不说这一句,拿导出件对账的人会以为多印了奖牌。 const jointMedalNote = "注:本赛事含联队,奖牌按「每个成员单位各记 1 枚」统计,各单位奖牌数之和会大于实际颁发数" // jointMember 是联队里的一个成员单位:展示字段 + 份额 + 本单位实得积分。 type jointMember struct { TeamID int64 Code string // Name 是单位全称(缺全称时退简称)——现场看的就是这个,不能用编码凑合。 Name string Weight float64 // CountsMedal 为 false 时这枚奖牌不记给本单位(缺省 true = 整枚各记)。 CountsMedal bool // Points 是本单位从这条成绩实得的积分(按流水统计:引擎已按份额拆好;没出分前是 0)。 Points float64 // Primary 标记这是报名的主单位(entries.team_id):显示名与导出的编码都以它打头。 Primary bool } // jointTeam 是一条报名配的联队:成员单位按份额顺序(主单位排第一),至少两个。 type jointTeam struct { members []jointMember } // Name 是联队的显示名:成员单位名写全,用 + 连起来再加「联队」——「第一分公司+第二分公司联队」。 // 写法与导入表 team_code 的 `A+B` 同构,现场拿着一份表能一眼对上另一份。 func (t jointTeam) Name() string { names := make([]string, 0, len(t.members)) for _, m := range t.members { names = append(names, m.Name) } return strings.Join(names, "+") + "联队" } // Code 是联队的单位编码:`T1+T2`,与导入表的 team_code 写法逐字一致, // 于是导出的报名名单可以直接拿去重导(round-trip),不必手工改回单单位。 func (t jointTeam) Code() string { codes := make([]string, 0, len(t.members)) for _, m := range t.members { codes = append(codes, m.Code) } return strings.Join(codes, "+") } // shareRatioNote 是只讲份额的说明:「联队分拆:第一分公司 50%、第二分公司 50%」。 // 报名名单用它:那张表是报名侧的数据(号码布 / 批次 / 状态),没有积分列, // 凭空冒出「5 分」反而让人以为这里也在算分。 func (t jointTeam) shareRatioNote() string { return "联队分拆:" + strings.Join(t.shareItems(false), "、") } // shareNote 是份额说明:「联队分拆:第一分公司 7.5 分(50%)、第二分公司 7.5 分(50%)」。 // // 已经出分(成员里有非零积分)时把各单位的实得积分一并写出来——「各多少」问的就是它, // 而导出件上的「积分」列只写了联队拿到的总分。还没出分时退化成只写份额比例。 func (t jointTeam) shareNote() string { return "联队分拆:" + strings.Join(t.shareItems(true), "、") } // shareItems 拼各成员单位的份额描述,顺序与份额一致(主单位在前)。 func (t jointTeam) shareItems(withPoints bool) []string { sum := 0.0 for _, m := range t.members { sum += shareWeightOf(m.Weight) } if withPoints { withPoints = false for _, m := range t.members { if m.Points != 0 { withPoints = true } } } items := make([]string, 0, len(t.members)) for _, m := range t.members { percent := formatPercent(shareWeightOf(m.Weight), sum) if withPoints { items = append(items, fmt.Sprintf("%s %s 分(%s)", m.Name, formatPoints(m.Points), percent)) continue } items = append(items, m.Name+" "+percent) } return items } // medalCell 把一枚联队奖牌写全:「金牌(联队 第一分公司+第二分公司,各单位各记 1 枚)」。 // // 括注必须留在奖牌这一格:奖牌明细里非联队行只有「金牌」两个字,读者把各单位的奖牌数加起来 // 会比实际颁发的多(口径如此),这一句就是给那个数字的解释。有成员被显式关掉记牌时改写成 // 「记给 甲、乙」——那种写法现在只有改份额的接口做得出来,但照样不能让人猜。 func (t jointTeam) medalCell(medal string) string { all := make([]string, 0, len(t.members)) counting := make([]string, 0, len(t.members)) for _, m := range t.members { all = append(all, m.Name) if m.CountsMedal { counting = append(counting, m.Name) } } if len(counting) == len(all) { return fmt.Sprintf("%s(联队 %s,各单位各记 1 枚)", medal, strings.Join(all, "+")) } return fmt.Sprintf("%s(联队 %s,记给 %s)", medal, strings.Join(all, "+"), strings.Join(counting, "、")) } // shareWeightOf 与引擎的 shareWeight 同一口径:0 与负数按 1 处理(份额是相对权重,不填即均分)。 func shareWeightOf(weight float64) float64 { if weight <= 0 { return 1 } return weight } // formatPercent 把份额写成占比(weight / Σweight):1+1 → 50%、2:1 → 66.7% / 33.3%。 // 保留一位小数并去掉多余的 0:现场看的是一眼能懂的比例,不是浮点数的原貌。 func formatPercent(weight, sum float64) string { if sum <= 0 { sum = 1 } return strconv.FormatFloat(math.Round(weight/sum*1000)/10, 'f', -1, 64) + "%" } // formatPoints 把积分写成文本:整数不带小数点,小数最多两位(引擎按份额拆出的 7.5 照实写)。 func formatPoints(v float64) string { return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64) } // appendNote 把一句说明补进一格文本:原有内容(报名备注、修订痕迹)排在前面,用分号隔开。 func appendNote(existing, note string) string { if strings.TrimSpace(note) == "" { return existing } if strings.TrimSpace(existing) == "" { return note } return existing + ";" + note } // entrySharesReady 判断份额表在不在。 // // 读路径要能读「还没跑到 0024 的库」:没有那张表就没有任何联队配置,显示照旧(主单位独占)。 // 与 httpapi.entryTeamSharesReady 同一判据,只是这边没有 context 参数。 func entrySharesReady(db *sql.DB) (bool, error) { var n int if err := db.QueryRow( `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil { return false, fmt.Errorf("export: 查询报名份额表: %w", err) } return n != 0, nil } // loadJointTeams 读全库的联队配置,按报名 id 归组;只有一条份额的报名不算联队,不进结果。 // // 「只有一条」这一档必须排掉:迁移会把每条老报名回填成一条「主单位独占 100%」, // 导入也给普通报名写一条,把它们当成联队会让**所有**导出件的单位名列变成「XX联队」—— // 那是老数据逐字节不变这条硬门槛要挡的事。 // // 各单位的实得积分一并读出来(分数按份额拆、末位吞余数,只在库里才准), // 用它拼「联队分拆:A 7.5 分、B 7.5 分」。表不在时返回空:没有份额表就没有联队配置。 func loadJointTeams(db *sql.DB) (map[int64]jointTeam, error) { ready, err := entrySharesReady(db) if err != nil { return nil, err } if !ready { return nil, nil } rows, err := db.Query(` SELECT s.entry_id, s.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), s.points_share, s.medal_share, e.team_id, COALESCE((SELECT SUM(l.points) FROM score_ledger l WHERE l.entry_id = s.entry_id AND l.team_id = s.team_id AND l.voided = 0), 0) FROM entry_team_shares s JOIN teams t ON t.id = s.team_id JOIN entries e ON e.id = s.entry_id ORDER BY s.entry_id, s.sort_order, s.team_id`) if err != nil { return nil, fmt.Errorf("export: 读取联队份额: %w", err) } defer func() { _ = rows.Close() }() grouped := make(map[int64][]jointMember) for rows.Next() { var ( entryID int64 m jointMember name string short string medal int primary int64 ) if err := rows.Scan(&entryID, &m.TeamID, &m.Code, &name, &short, &m.Weight, &medal, &primary, &m.Points); err != nil { return nil, fmt.Errorf("export: 扫描联队份额: %w", err) } m.Name = displayName(name, short) if m.Name == "" { m.Name = m.Code } m.CountsMedal = medal != 0 m.Primary = primary == m.TeamID grouped[entryID] = append(grouped[entryID], m) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历联队份额: %w", err) } out := make(map[int64]jointTeam, len(grouped)) for entryID, members := range grouped { if len(members) < 2 { continue } out[entryID] = jointTeam{members: primaryFirst(members)} } return out, nil } // primaryFirst 把主单位挪到成员列表最前面,其余保持份额表的顺序。 // // 显示名与导出的 `A+B` 都以主单位打头(口径:加号左边那个是主单位),而份额表里主单位 // 不一定排第一——改份额的接口只要求主单位在成员里,没要求它排头。不归位的话, // 一份导出件里的 `B+A` 重导回去会把主单位换成 B,报名身份(唯一键)跟着变。 func primaryFirst(members []jointMember) []jointMember { out := make([]jointMember, 0, len(members)) for _, m := range members { if m.Primary { out = append(out, m) } } for _, m := range members { if !m.Primary { out = append(out, m) } } return out }