batch3_mapping.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. package fixture
  2. import (
  3. "database/sql"
  4. "fmt"
  5. "time"
  6. )
  7. // 本文件是第三批的**纯映射规则**(不连库、不取当前时间)。
  8. //
  9. // 三条需要写清楚、否则会被当成"随手填"的判断:
  10. //
  11. // 1. `status`:legacy 是 `tinyint(1)`(1=正常 / 0=停用),target 是 `VARCHAR(16)`(active/disabled)。
  12. // 这是**值域翻译**,不是占位:1→active、0→disabled;出现 1/0 之外的值直接报错,
  13. // 不猜(更不默认 active —— 那会把已停用的机构/校区悄悄"复活")。
  14. // 2. `institution_code` / `address` / `phone`:legacy **根本没有这三列**
  15. // (t_school 只有 province_id/city_id/district_id)→ 一律 NULL,不编造。
  16. // `uk_institution_code` 是 UNIQUE 但允许 NULL(MySQL 唯一键对 NULL 放行多行),不会撞键。
  17. // 3. `created_at` / `updated_at`:legacy 无时间列 → **epoch 哨兵**(与 t_user_divide_into 同口径)。
  18. // 这两列虽然已有 `DEFAULT CURRENT_TIMESTAMP(3)`,但取默认值会让同一份数据每次迁移结果不同,
  19. // 双跑/回滚就失去可比性。审计列也不需要"迁移时刻"以外的语义。
  20. // legacyStatusToTarget 把 legacy 的 tinyint 状态翻译成 target 的 active/disabled。
  21. //
  22. // 只接受 1 和 0:其它值说明这一列在旧系统里有我们没搞懂的含义,报错比猜安全。
  23. func legacyStatusToTarget(v int8, what string) (string, error) {
  24. switch v {
  25. case 1:
  26. return "active", nil
  27. case 0:
  28. return "disabled", nil
  29. default:
  30. return "", fmt.Errorf("%s.status = %d,超出可解释范围(1=active/0=disabled),拒绝猜测", what, v)
  31. }
  32. }
  33. // MapInstitution 映射一个机构(D1:institution_id 原值)。
  34. func MapInstitution(l LegacySchool) (TargetInstitution, []string, error) {
  35. var warns []string
  36. status, err := legacyStatusToTarget(l.Status, fmt.Sprintf("t_school(id=%d)", l.ID))
  37. if err != nil {
  38. return TargetInstitution{}, warns, err
  39. }
  40. if l.Name == "" {
  41. return TargetInstitution{}, warns, fmt.Errorf("t_school(id=%d).name 为空,目标态 institution_name NOT NULL,拒绝填占位值", l.ID)
  42. }
  43. // 省市区有值但 target 无对应列(address 是自由文本)—— 记录一条告警,不编造地址
  44. if l.ProvinceID > 0 || l.CityID > 0 || l.DistrictID > 0 {
  45. warns = append(warns, fmt.Sprintf(
  46. "t_school(id=%d) 有省市区 id(%d/%d/%d)但 target t_institution 无对应列,address 落 NULL",
  47. l.ID, l.ProvinceID, l.CityID, l.DistrictID))
  48. }
  49. return TargetInstitution{
  50. InstitutionID: l.ID, // D1
  51. InstitutionName: l.Name,
  52. InstitutionCode: sql.NullString{}, // legacy 无此列
  53. Address: sql.NullString{}, // 同上
  54. Phone: sql.NullString{}, // 同上
  55. Status: status,
  56. CreatedAt: epochSentinel(),
  57. UpdatedAt: epochSentinel(),
  58. }, warns, nil
  59. }
  60. // MapCampus 映射一个校区(D1:campus_id 原值)。
  61. //
  62. // `institution_id` **如实保留** legacy `school_id`(R17):campus 1/2 的 school_id=1,
  63. // 而数据里它们常与 schoolid=63317 一起出现 —— 这是旧系统的跨机构校区,
  64. // 这里不修正、不反推、不特判,只在报告里标 cross-org-campus。
  65. func MapCampus(l LegacyCampus) (TargetCampus, []string, error) {
  66. var warns []string
  67. if l.ID == 0 {
  68. return TargetCampus{}, warns, fmt.Errorf("campus_id=0 不建行(0 是机构级约定值),调用方应过滤")
  69. }
  70. if l.SchoolID == 0 {
  71. return TargetCampus{}, warns, fmt.Errorf(
  72. "t_school_campus(id=%d).school_id=0,无法确定所属机构,拒绝建孤儿校区", l.ID)
  73. }
  74. if l.Name == "" {
  75. return TargetCampus{}, warns, fmt.Errorf(
  76. "t_school_campus(id=%d).name 为空,目标态 campus_name NOT NULL,拒绝填占位值", l.ID)
  77. }
  78. status, err := legacyStatusToTarget(l.Status, fmt.Sprintf("t_school_campus(id=%d)", l.ID))
  79. if err != nil {
  80. return TargetCampus{}, warns, err
  81. }
  82. if l.SelfSupport != 0 && l.SelfSupport != 1 {
  83. return TargetCampus{}, warns, fmt.Errorf(
  84. "t_school_campus(id=%d).self_support = %d,bit(1) 只应取 0/1,拒绝猜测", l.ID, l.SelfSupport)
  85. }
  86. if l.ProvinceID > 0 || l.CityID > 0 || l.DistrictID > 0 {
  87. warns = append(warns, fmt.Sprintf(
  88. "t_school_campus(id=%d) 有省市区 id(%d/%d/%d)但 target t_campus 无对应列,address 落 NULL",
  89. l.ID, l.ProvinceID, l.CityID, l.DistrictID))
  90. }
  91. return TargetCampus{
  92. CampusID: l.ID, // D1
  93. InstitutionID: l.SchoolID,
  94. CampusName: l.Name,
  95. CampusCode: truncateTo(l.Code, 32, fmt.Sprintf("t_school_campus(id=%d).code", l.ID), &warns),
  96. Address: sql.NullString{},
  97. Phone: sql.NullString{},
  98. Status: status,
  99. SelfSupport: l.SelfSupport,
  100. CreatedAt: epochSentinel(),
  101. UpdatedAt: epochSentinel(),
  102. }, warns, nil
  103. }
  104. // IsCrossOrgCampus 判定「数据里的 (schoolid, campusid) 与校区的真实所属机构不一致」。
  105. //
  106. // 只用于报告标注(R17),不参与任何修正。campusOwner 是 campus_id → 真实 school_id 的索引。
  107. func IsCrossOrgCampus(schoolID, campusID int64, campusOwner map[int64]int64) bool {
  108. if campusID == 0 {
  109. return false
  110. }
  111. owner, ok := campusOwner[campusID]
  112. if !ok {
  113. return false
  114. }
  115. return owner != schoolID
  116. }
  117. // orgEpoch 便于单测断言哨兵值。
  118. func orgEpoch() time.Time { return epochSentinel() }