batch3_verify.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. package fixture
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "sort"
  7. "strconv"
  8. "strings"
  9. )
  10. // 本文件是第三批的**核对**:逐列 + 机构集合 + 引用完整性(孤儿=0)+ R17 + bit(1)。
  11. //
  12. // 期望值来源与前三批一致:机构/校区字段由 **MySQL 服务端**取值(`self_support+0`、
  13. // `CAST(...)`),不由映射代码产生;只有 `status` 的 active/disabled 是规则翻译,
  14. // 单独作为断言说明(并在映射里对非法值报错)。
  15. // OrgVerifyReport 是第三批核对结果。
  16. type OrgVerifyReport struct {
  17. Options OrgOptions
  18. Cols []ColumnAgg
  19. Assertions []Assertion
  20. RefStats []RefStat
  21. CrossOrg []string // R17:数据里 (schoolid, campusid) 与校区真实所属机构不一致的组合
  22. TargetInst int64
  23. TargetCamp int64
  24. Warnings []string
  25. }
  26. // FailCount 统计不一致列 + 失败断言。
  27. func (r *OrgVerifyReport) FailCount() int {
  28. n := 0
  29. for _, c := range r.Cols {
  30. if !c.OK() {
  31. n++
  32. }
  33. }
  34. for _, a := range r.Assertions {
  35. if !a.Pass {
  36. n++
  37. }
  38. }
  39. return n
  40. }
  41. // OK 表示整轮通过。
  42. func (r *OrgVerifyReport) OK() bool { return r.FailCount() == 0 }
  43. // VerifyOrg 执行第三批核对。
  44. func VerifyOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgVerifyReport, error) {
  45. src, err := cfg.OpenOrgSource()
  46. if err != nil {
  47. return nil, err
  48. }
  49. defer src.Close()
  50. legacy, err := cfg.OpenLegacy() // finance_bk:引用完整性统计用
  51. if err != nil {
  52. return nil, err
  53. }
  54. defer legacy.Close()
  55. target, err := cfg.OpenTarget()
  56. if err != nil {
  57. return nil, err
  58. }
  59. defer target.Close()
  60. rep := &OrgVerifyReport{Options: opts}
  61. // ---------- 1. t_institution 逐列 ----------
  62. expSchools, err := extractSchools(ctx, src, opts.InstitutionIDs)
  63. if err != nil {
  64. return nil, err
  65. }
  66. cID := newCol("t_institution", "institution_id", "D1:与 t_school.id 完全相同")
  67. cName := newCol("t_institution", "institution_name", "t_school.name 原样")
  68. cCode := newCol("t_institution", "institution_code", "legacy 无此列 → 必须 NULL")
  69. cAddr := newCol("t_institution", "address", "legacy 无此列 → 必须 NULL")
  70. cPhone := newCol("t_institution", "phone", "legacy 无此列 → 必须 NULL")
  71. cStatus := newCol("t_institution", "status", "tinyint 1→active / 0→disabled(规则翻译)")
  72. cCreated := newCol("t_institution", "created_at", "legacy 无时间列 → epoch 占位")
  73. cUpdated := newCol("t_institution", "updated_at", "legacy 无时间列 → epoch 占位")
  74. var legacyIDs []int64
  75. for _, s := range expSchools {
  76. legacyIDs = append(legacyIDs, s.ID)
  77. rowKey := "id=" + strconv.FormatInt(s.ID, 10)
  78. var got struct {
  79. ID int64
  80. Name string
  81. Code sql.NullString
  82. Addr sql.NullString
  83. Phone sql.NullString
  84. Status string
  85. Created sql.NullString
  86. Updated sql.NullString
  87. }
  88. err := target.QueryRowContext(ctx, `
  89. SELECT institution_id, institution_name, institution_code, address, phone, status,
  90. DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
  91. FROM t_institution WHERE institution_id = ?`, s.ID).Scan(
  92. &got.ID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status, &got.Created, &got.Updated)
  93. if err == sql.ErrNoRows {
  94. cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), "<缺失>", false)
  95. continue
  96. }
  97. if err != nil {
  98. return nil, err
  99. }
  100. wantStatus := "active"
  101. if s.Status == 0 {
  102. wantStatus = "disabled"
  103. }
  104. cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == s.ID)
  105. cName.cmp(rowKey, s.Name, got.Name, got.Name == s.Name)
  106. cCode.cmp(rowKey, "NULL", nullStrOrNull(got.Code), !got.Code.Valid)
  107. cAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
  108. cPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
  109. cStatus.cmp(rowKey, fmt.Sprintf("%d→%s", s.Status, wantStatus), got.Status, got.Status == wantStatus)
  110. cCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
  111. strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
  112. cUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
  113. strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
  114. }
  115. rep.Cols = append(rep.Cols, cID.done(), cName.done(), cCode.done(), cAddr.done(),
  116. cPhone.done(), cStatus.done(), cCreated.done(), cUpdated.done())
  117. // ---------- 2. 机构集合断言 ----------
  118. var gotIDs []int64
  119. rows, err := target.QueryContext(ctx, `SELECT institution_id FROM t_institution ORDER BY institution_id`)
  120. if err != nil {
  121. return nil, err
  122. }
  123. for rows.Next() {
  124. var id int64
  125. if err := rows.Scan(&id); err != nil {
  126. rows.Close()
  127. return nil, err
  128. }
  129. gotIDs = append(gotIDs, id)
  130. }
  131. rows.Close()
  132. rep.TargetInst = int64(len(gotIDs))
  133. want := append([]int64(nil), opts.InstitutionIDs...)
  134. sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
  135. rep.Assertions = append(rep.Assertions, Assertion{
  136. Name: "机构集合精确等于期望集合(不多不少)",
  137. Pass: int64SliceEqual(gotIDs, want),
  138. Detail: fmt.Sprintf("target t_institution = %v(%d 行);期望 = %v(%d 行)",
  139. gotIDs, len(gotIDs), want, len(want)),
  140. })
  141. // ---------- 3. t_campus 逐列 ----------
  142. expCampuses, err := extractCampuses(ctx, src, opts.InstitutionIDs)
  143. if err != nil {
  144. return nil, err
  145. }
  146. if !opts.WithAllCampuses {
  147. refd, err := extractReferencedCampuses(ctx, legacy)
  148. if err != nil {
  149. return nil, err
  150. }
  151. var kept []LegacyCampus
  152. for _, c := range expCampuses {
  153. if refd[c.ID] {
  154. kept = append(kept, c)
  155. }
  156. }
  157. expCampuses = kept
  158. }
  159. cCampusID := newCol("t_campus", "campus_id", "D1:与 t_school_campus.id 完全相同")
  160. cInstID := newCol("t_campus", "institution_id", "R17:如实保留 school_id(不修正跨机构校区)")
  161. cCampusName := newCol("t_campus", "campus_name", "name 原样")
  162. cCampusCode := newCol("t_campus", "campus_code", "code → campus_code(NULL 保留)")
  163. cCampusAddr := newCol("t_campus", "address", "legacy 无此列 → 必须 NULL")
  164. cCampusPhone := newCol("t_campus", "phone", "legacy 无此列 → 必须 NULL")
  165. cCampusStatus := newCol("t_campus", "status", "tinyint 1→active / 0→disabled")
  166. cSelfSupport := newCol("t_campus", "self_support", "bit(1) → TINYINT(1),逐行相等")
  167. cCampusCreated := newCol("t_campus", "created_at", "epoch 占位")
  168. cCampusUpdated := newCol("t_campus", "updated_at", "epoch 占位")
  169. campusOwner := map[int64]int64{}
  170. var gotCampusIDs []int64
  171. for _, c := range expCampuses {
  172. campusOwner[c.ID] = c.SchoolID
  173. rowKey := "id=" + strconv.FormatInt(c.ID, 10)
  174. var got struct {
  175. ID, InstID int64
  176. Name string
  177. Code sql.NullString
  178. Addr sql.NullString
  179. Phone sql.NullString
  180. Status string
  181. SelfSup int8
  182. Created sql.NullString
  183. Updated sql.NullString
  184. }
  185. err := target.QueryRowContext(ctx, `
  186. SELECT campus_id, institution_id, campus_name, campus_code, address, phone, status,
  187. self_support, DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
  188. FROM t_campus WHERE campus_id = ?`, c.ID).Scan(
  189. &got.ID, &got.InstID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status,
  190. &got.SelfSup, &got.Created, &got.Updated)
  191. if err == sql.ErrNoRows {
  192. cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), "<缺失>", false)
  193. continue
  194. }
  195. if err != nil {
  196. return nil, err
  197. }
  198. gotCampusIDs = append(gotCampusIDs, got.ID)
  199. wantStatus := "active"
  200. if c.Status == 0 {
  201. wantStatus = "disabled"
  202. }
  203. cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == c.ID)
  204. cInstID.cmp(rowKey, strconv.FormatInt(c.SchoolID, 10), strconv.FormatInt(got.InstID, 10), got.InstID == c.SchoolID)
  205. cCampusName.cmp(rowKey, c.Name, got.Name, got.Name == c.Name)
  206. cCampusCode.cmp(rowKey, nullStrOrNull(c.Code), nullStrOrNull(got.Code), nullStrOrNull(c.Code) == nullStrOrNull(got.Code))
  207. cCampusAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
  208. cCampusPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
  209. cCampusStatus.cmp(rowKey, fmt.Sprintf("%d→%s", c.Status, wantStatus), got.Status, got.Status == wantStatus)
  210. cSelfSupport.cmp(rowKey, strconv.Itoa(int(c.SelfSupport)), strconv.Itoa(int(got.SelfSup)),
  211. got.SelfSup == c.SelfSupport)
  212. cCampusCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
  213. strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
  214. cCampusUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
  215. strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
  216. }
  217. rep.Cols = append(rep.Cols, cCampusID.done(), cInstID.done(), cCampusName.done(),
  218. cCampusCode.done(), cCampusAddr.done(), cCampusPhone.done(), cCampusStatus.done(),
  219. cSelfSupport.done(), cCampusCreated.done(), cCampusUpdated.done())
  220. rep.TargetCamp = int64(len(gotCampusIDs))
  221. // ---------- 4. campus_id = 0 不得建行 ----------
  222. var zeroCampus int
  223. if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_campus WHERE campus_id = 0`).Scan(&zeroCampus); err != nil {
  224. return nil, err
  225. }
  226. rep.Assertions = append(rep.Assertions, Assertion{
  227. Name: "t_campus 不建 campus_id=0 的行(0 是机构级约定值)",
  228. Pass: zeroCampus == 0,
  229. Detail: fmt.Sprintf("t_campus 里 campus_id=0 的行数 = %d(期望 0)", zeroCampus),
  230. })
  231. // ---------- 5. R17 ----------
  232. r17OK := true
  233. var r17Detail []string
  234. for _, cid := range []int64{1, 2} {
  235. var inst sql.NullInt64
  236. err := target.QueryRowContext(ctx, `SELECT institution_id FROM t_campus WHERE campus_id = ?`, cid).Scan(&inst)
  237. if err != nil {
  238. r17OK = false
  239. r17Detail = append(r17Detail, fmt.Sprintf("campus %d 读不到:%v", cid, err))
  240. continue
  241. }
  242. ok := inst.Valid && inst.Int64 == 1
  243. if !ok {
  244. r17OK = false
  245. }
  246. r17Detail = append(r17Detail, fmt.Sprintf("campus %d institution_id=%d(期望 1)", cid, inst.Int64))
  247. }
  248. var inst1 int
  249. if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_institution WHERE institution_id = 1`).Scan(&inst1); err != nil {
  250. return nil, err
  251. }
  252. rep.Assertions = append(rep.Assertions, Assertion{
  253. Name: "R17:campus 1/2 的 institution_id = 1,且 t_institution(1) 存在",
  254. Pass: r17OK && inst1 == 1,
  255. Detail: strings.Join(r17Detail, ";") + fmt.Sprintf(";t_institution(1) 行数=%d", inst1),
  256. })
  257. // ---------- 6. 引用完整性(每个来源逐个统计孤儿) ----------
  258. for _, s := range orgRefSources() {
  259. st := RefStat{Source: s.Table}
  260. if err := legacy.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM `%s`", s.Table)).Scan(&st.Rows); err != nil {
  261. return nil, err
  262. }
  263. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  264. "SELECT COUNT(DISTINCT `%s`) FROM `%s`", s.InstCol, s.Table)).Scan(&st.DistinctInst); err != nil {
  265. return nil, err
  266. }
  267. // 孤儿机构
  268. orphanInstSQL := fmt.Sprintf(`
  269. SELECT COUNT(*) FROM `+"`%s`"+` s
  270. LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
  271. WHERE i.institution_id IS NULL`, s.Table, cfg.TargetDB, s.InstCol)
  272. if err := legacy.QueryRowContext(ctx, orphanInstSQL).Scan(&st.OrphanInst); err != nil {
  273. return nil, fmt.Errorf("%s 孤儿机构统计失败:%w", s.Table, err)
  274. }
  275. if st.OrphanInst > 0 {
  276. rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
  277. SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
  278. LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
  279. WHERE i.institution_id IS NULL ORDER BY 1`, s.InstCol, s.Table, cfg.TargetDB, s.InstCol))
  280. if err == nil {
  281. for rows.Next() {
  282. var v int64
  283. _ = rows.Scan(&v)
  284. st.OrphanInstList = append(st.OrphanInstList, strconv.FormatInt(v, 10))
  285. }
  286. rows.Close()
  287. }
  288. }
  289. if s.CampusCol != "" {
  290. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  291. "SELECT COUNT(DISTINCT `%s`) FROM `%s` WHERE `%s` <> 0", s.CampusCol, s.Table, s.CampusCol)).Scan(&st.DistinctCampus); err != nil {
  292. return nil, err
  293. }
  294. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  295. "SELECT COUNT(*) FROM `%s` WHERE `%s` = 0", s.Table, s.CampusCol)).Scan(&st.CampusZeroRows); err != nil {
  296. return nil, err
  297. }
  298. orphanCampusSQL := fmt.Sprintf(`
  299. SELECT COUNT(*) FROM `+"`%s`"+` s
  300. LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
  301. WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL`, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol)
  302. if err := legacy.QueryRowContext(ctx, orphanCampusSQL).Scan(&st.OrphanCampus); err != nil {
  303. return nil, fmt.Errorf("%s 孤儿校区统计失败:%w", s.Table, err)
  304. }
  305. if st.OrphanCampus > 0 {
  306. rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
  307. SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
  308. LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
  309. WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL ORDER BY 1`, s.CampusCol, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol))
  310. if err == nil {
  311. for rows.Next() {
  312. var v int64
  313. _ = rows.Scan(&v)
  314. st.OrphanCampusList = append(st.OrphanCampusList, strconv.FormatInt(v, 10))
  315. }
  316. rows.Close()
  317. }
  318. }
  319. }
  320. rep.RefStats = append(rep.RefStats, st)
  321. }
  322. totalOrphanInst, totalOrphanCampus := int64(0), int64(0)
  323. var orphanDetail []string
  324. for _, st := range rep.RefStats {
  325. totalOrphanInst += st.OrphanInst
  326. totalOrphanCampus += st.OrphanCampus
  327. if st.OrphanInst > 0 {
  328. orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿机构 %d 行 %v", st.Source, st.OrphanInst, st.OrphanInstList))
  329. }
  330. if st.OrphanCampus > 0 {
  331. orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿校区 %d 行 %v", st.Source, st.OrphanCampus, st.OrphanCampusList))
  332. }
  333. }
  334. detail := fmt.Sprintf("8 个来源合计:孤儿机构 %d 行、孤儿校区 %d 行(期望都为 0)", totalOrphanInst, totalOrphanCampus)
  335. if len(orphanDetail) > 0 {
  336. detail += ";" + strings.Join(orphanDetail, ";")
  337. }
  338. rep.Assertions = append(rep.Assertions, Assertion{
  339. Name: "引用完整性:数据里出现过的每个 institution_id / campus_id 都能在 target 找到(孤儿 = 0)",
  340. Pass: totalOrphanInst == 0 && totalOrphanCampus == 0,
  341. Detail: detail,
  342. })
  343. // ---------- 7. cross-org-campus 标注(只报告,不判失败) ----------
  344. for _, s := range orgRefSources() {
  345. if s.CampusCol == "" {
  346. continue
  347. }
  348. rows, err := legacy.QueryContext(ctx, fmt.Sprintf(
  349. "SELECT DISTINCT `%s`, `%s`, COUNT(*) FROM `%s` WHERE `%s` <> 0 GROUP BY 1,2 ORDER BY 1,2",
  350. s.InstCol, s.CampusCol, s.Table, s.CampusCol))
  351. if err != nil {
  352. return nil, err
  353. }
  354. for rows.Next() {
  355. var inst, camp, n int64
  356. if err := rows.Scan(&inst, &camp, &n); err != nil {
  357. rows.Close()
  358. return nil, err
  359. }
  360. if IsCrossOrgCampus(inst, camp, campusOwner) {
  361. rep.CrossOrg = append(rep.CrossOrg, fmt.Sprintf(
  362. "%s: schoolid=%d campusid=%d(校区真实所属=%d)%d 行",
  363. s.Table, inst, camp, campusOwner[camp], n))
  364. }
  365. }
  366. rows.Close()
  367. }
  368. sort.Strings(rep.CrossOrg)
  369. return rep, nil
  370. }
  371. // SnapshotOrg 采集 target 两张表的 (id → 名称) 快照,用于幂等比对的「id 不变」判定。
  372. func SnapshotOrg(ctx context.Context, cfg Config) (map[int64]string, map[int64]string, error) {
  373. db, err := cfg.OpenTarget()
  374. if err != nil {
  375. return nil, nil, err
  376. }
  377. defer db.Close()
  378. inst := map[int64]string{}
  379. camp := map[int64]string{}
  380. rows, err := db.QueryContext(ctx, `SELECT institution_id, institution_name FROM t_institution`)
  381. if err != nil {
  382. return nil, nil, err
  383. }
  384. for rows.Next() {
  385. var id int64
  386. var n string
  387. if err := rows.Scan(&id, &n); err != nil {
  388. rows.Close()
  389. return nil, nil, err
  390. }
  391. inst[id] = n
  392. }
  393. rows.Close()
  394. rows2, err := db.QueryContext(ctx, `SELECT campus_id, campus_name FROM t_campus`)
  395. if err != nil {
  396. return nil, nil, err
  397. }
  398. for rows2.Next() {
  399. var id int64
  400. var n string
  401. if err := rows2.Scan(&id, &n); err != nil {
  402. rows2.Close()
  403. return nil, nil, err
  404. }
  405. camp[id] = n
  406. }
  407. rows2.Close()
  408. return inst, camp, nil
  409. }