package export import ( "database/sql" "fmt" "strings" ) // 本文件是「团体报名的队员」(devdocs/20)在导出侧的读法:一条团体报名可以关联多个运动员 // (`entry_athletes`),导出件上要看得出队里坐着谁。 // // 写法是 `姓名(号码布)`:号码布是运动员的唯一标识(迁移 0014、devdocs/20 §二), // 光写名字遇到重名就认不出是谁。多个队员用顿号连起来——与 member_names 原有的写法同形, // 拿导出件对账的人不必为两种名单学两套读法。 // // member_names **不因为挂了队员而消失**:它是显示用的文字名单,里面没被队员认走的那些名字 // (人没录档案、临时借调的)照旧接在队员后面,两者并存、互不覆盖。同一个口径也用在 // 大屏侧(web/src/display/members.ts),两处必须一致,否则同一场比赛的同一支队伍 // 在导出件与屏幕上会写成两份名单。 // // 队员**不改每一行的高度与条数**:一条报名仍然只占一行(护栏见 entry_members_rows_test.go), // 队员只让这一格更长。 // entryMember 是报名里一名队员的展示字段。身份是 athlete_id(引擎按它发个人分与个人奖牌), // 这里只读展示需要的两列:姓名与号码布。 type entryMember struct { Name string Bib string } // entryMembersReady 判断队员表在不在。 // // 表不在(库还没跑到 0026)= 库里没有任何队员关联 = 只报单位,与升级前一致, // 所以按「没有队员」处理是**精确**的,不是容错降级(与 entrySharesReady 同一判据)。 func entryMembersReady(db *sql.DB) (bool, error) { var n int if err := db.QueryRow( `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_athletes'`).Scan(&n); err != nil { return false, fmt.Errorf("export: 查询报名队员表: %w", err) } return n != 0, nil } // loadEntryMembers 读全库的队员关联,按报名 id 归组。 // // 顺序按 sort_order(写侧按请求给的顺序编号,老数据按文字名单的书写顺序回填): // 展示顺序稳定,导出件不会因为主键顺序而变。各导出件都读一次给全部工作表复用—— // 逐张表去查是白跑几十遍(与 loadJointTeams 同一个取舍)。 func loadEntryMembers(db *sql.DB) (map[int64][]entryMember, error) { ready, err := entryMembersReady(db) if err != nil { return nil, err } if !ready { return nil, nil } rows, err := db.Query(` SELECT ea.entry_id, COALESCE(a.name, ''), COALESCE(a.bib, '') FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`) if err != nil { return nil, fmt.Errorf("export: 读取报名队员: %w", err) } defer func() { _ = rows.Close() }() out := make(map[int64][]entryMember) for rows.Next() { var ( entryID int64 m entryMember ) if err := rows.Scan(&entryID, &m.Name, &m.Bib); err != nil { return nil, fmt.Errorf("export: 扫描报名队员: %w", err) } out[entryID] = append(out[entryID], m) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历报名队员: %w", err) } return out, nil } // memberLabel 把一名队员写成导出件上的字:`张伟(A001)`。 // // 姓名或号码布缺一个时只写有的那个(不留「()」这种空壳);两个都空的人在名单里一个字都不写。 func memberLabel(m entryMember) string { name := strings.TrimSpace(m.Name) bib := strings.TrimSpace(m.Bib) switch { case name != "" && bib != "": return name + "(" + bib + ")" case name != "": return name default: return bib } } // coveredByMember 判断文字名单里的一个名字是不是已经由某名队员写过了。 // // 判据与迁移 0026 的回填同源:文字名单里写的可能是**姓名**,也可能是**号码布** // (导入规范允许「队员名单」列填号码布)。认走之后不再重复写一遍,否则一个队员在导出件上 // 会出现两次(「王五(A001)、王五」这种)。 func coveredByMember(name string, members []entryMember) bool { for _, m := range members { if name == strings.TrimSpace(m.Name) || name == strings.TrimSpace(m.Bib) { return true } } return false } // participantLabel 是报名名单 / 分项成绩表 / 全部成绩单页 / 单位奖牌明细共用的 // 「姓名」「运动员 / 队」列取值。 // // 没有队员关联时**逐字**等于 participantName:个人写姓名、团体写文字名单,一个字都不多 // ——老数据(含迁移按文字名单回填不上人的那些)的导出件不许有任何变化。 func participantLabel(athleteName, memberJSON string, members []entryMember) string { if len(members) == 0 { return participantName(athleteName, memberJSON) } out := make([]string, 0, len(members)+1) for _, m := range members { if label := memberLabel(m); label != "" { out = append(out, label) } } seen := make(map[string]bool) for _, name := range memberNamesOf(memberJSON) { name = strings.TrimSpace(name) if name == "" || seen[name] || coveredByMember(name, members) { continue } seen[name] = true out = append(out, name) } if len(out) == 0 { // 队员既没姓名也没号码布、文字名单也是空的:退回原来的取值,不把这一格写空。 return participantName(athleteName, memberJSON) } return strings.Join(out, "、") }