batch3_verify.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  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 []CrossOrgStat // 跨机构三类统计(A 课程级 / B 记录级 / C 记录 vs 课程)
  22. Disabled []DisabledCampusStat // 停用校区(status=0)的影响面
  23. TargetInst int64
  24. TargetCamp int64
  25. Warnings []string
  26. }
  27. // FailCount 统计不一致列 + 失败断言。
  28. func (r *OrgVerifyReport) FailCount() int {
  29. n := 0
  30. for _, c := range r.Cols {
  31. if !c.OK() {
  32. n++
  33. }
  34. }
  35. for _, a := range r.Assertions {
  36. if !a.Pass {
  37. n++
  38. }
  39. }
  40. return n
  41. }
  42. // OK 表示整轮通过。
  43. func (r *OrgVerifyReport) OK() bool { return r.FailCount() == 0 }
  44. // VerifyOrg 执行第三批核对。
  45. func VerifyOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgVerifyReport, error) {
  46. src, err := cfg.OpenOrgSource()
  47. if err != nil {
  48. return nil, err
  49. }
  50. defer src.Close()
  51. legacy, err := cfg.OpenLegacy() // finance_bk:引用完整性统计用
  52. if err != nil {
  53. return nil, err
  54. }
  55. defer legacy.Close()
  56. target, err := cfg.OpenTarget()
  57. if err != nil {
  58. return nil, err
  59. }
  60. defer target.Close()
  61. rep := &OrgVerifyReport{Options: opts}
  62. // ---------- 1. t_institution 逐列 ----------
  63. expSchools, err := extractSchools(ctx, src, opts.InstitutionIDs)
  64. if err != nil {
  65. return nil, err
  66. }
  67. cID := newCol("t_institution", "institution_id", "D1:与 t_school.id 完全相同")
  68. cName := newCol("t_institution", "institution_name", "t_school.name 原样")
  69. cCode := newCol("t_institution", "institution_code", "legacy 无此列 → 必须 NULL")
  70. cAddr := newCol("t_institution", "address", "legacy 无此列 → 必须 NULL")
  71. cPhone := newCol("t_institution", "phone", "legacy 无此列 → 必须 NULL")
  72. cStatus := newCol("t_institution", "status", "tinyint 1→active / 0→disabled(规则翻译)")
  73. cCreated := newCol("t_institution", "created_at", "legacy 无时间列 → epoch 占位")
  74. cUpdated := newCol("t_institution", "updated_at", "legacy 无时间列 → epoch 占位")
  75. var legacyIDs []int64
  76. for _, s := range expSchools {
  77. legacyIDs = append(legacyIDs, s.ID)
  78. rowKey := "id=" + strconv.FormatInt(s.ID, 10)
  79. var got struct {
  80. ID int64
  81. Name string
  82. Code sql.NullString
  83. Addr sql.NullString
  84. Phone sql.NullString
  85. Status string
  86. Created sql.NullString
  87. Updated sql.NullString
  88. }
  89. err := target.QueryRowContext(ctx, `
  90. SELECT institution_id, institution_name, institution_code, address, phone, status,
  91. DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
  92. FROM t_institution WHERE institution_id = ?`, s.ID).Scan(
  93. &got.ID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status, &got.Created, &got.Updated)
  94. if err == sql.ErrNoRows {
  95. cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), "<缺失>", false)
  96. continue
  97. }
  98. if err != nil {
  99. return nil, err
  100. }
  101. wantStatus := "active"
  102. if s.Status == 0 {
  103. wantStatus = "disabled"
  104. }
  105. cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == s.ID)
  106. cName.cmp(rowKey, s.Name, got.Name, got.Name == s.Name)
  107. cCode.cmp(rowKey, "NULL", nullStrOrNull(got.Code), !got.Code.Valid)
  108. cAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
  109. cPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
  110. cStatus.cmp(rowKey, fmt.Sprintf("%d→%s", s.Status, wantStatus), got.Status, got.Status == wantStatus)
  111. cCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
  112. strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
  113. cUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
  114. strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
  115. }
  116. rep.Cols = append(rep.Cols, cID.done(), cName.done(), cCode.done(), cAddr.done(),
  117. cPhone.done(), cStatus.done(), cCreated.done(), cUpdated.done())
  118. // ---------- 2. 机构集合断言 ----------
  119. var gotIDs []int64
  120. rows, err := target.QueryContext(ctx, `SELECT institution_id FROM t_institution ORDER BY institution_id`)
  121. if err != nil {
  122. return nil, err
  123. }
  124. for rows.Next() {
  125. var id int64
  126. if err := rows.Scan(&id); err != nil {
  127. rows.Close()
  128. return nil, err
  129. }
  130. gotIDs = append(gotIDs, id)
  131. }
  132. rows.Close()
  133. rep.TargetInst = int64(len(gotIDs))
  134. want := append([]int64(nil), opts.InstitutionIDs...)
  135. sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
  136. rep.Assertions = append(rep.Assertions, Assertion{
  137. Name: "机构集合精确等于期望集合(不多不少)",
  138. Pass: int64SliceEqual(gotIDs, want),
  139. Detail: fmt.Sprintf("target t_institution = %v(%d 行);期望 = %v(%d 行)",
  140. gotIDs, len(gotIDs), want, len(want)),
  141. })
  142. // ---------- 3. t_campus 逐列 ----------
  143. expCampuses, err := extractCampuses(ctx, src, opts.InstitutionIDs)
  144. if err != nil {
  145. return nil, err
  146. }
  147. if !opts.WithAllCampuses {
  148. refd, err := extractReferencedCampuses(ctx, legacy)
  149. if err != nil {
  150. return nil, err
  151. }
  152. var kept []LegacyCampus
  153. for _, c := range expCampuses {
  154. if refd[c.ID] {
  155. kept = append(kept, c)
  156. }
  157. }
  158. expCampuses = kept
  159. }
  160. cCampusID := newCol("t_campus", "campus_id", "D1:与 t_school_campus.id 完全相同")
  161. cInstID := newCol("t_campus", "institution_id", "R17:如实保留 school_id(不修正跨机构校区)")
  162. cCampusName := newCol("t_campus", "campus_name", "name 原样")
  163. cCampusCode := newCol("t_campus", "campus_code", "code → campus_code(NULL 保留)")
  164. cCampusAddr := newCol("t_campus", "address", "legacy 无此列 → 必须 NULL")
  165. cCampusPhone := newCol("t_campus", "phone", "legacy 无此列 → 必须 NULL")
  166. cCampusStatus := newCol("t_campus", "status", "tinyint 1→active / 0→disabled")
  167. cSelfSupport := newCol("t_campus", "self_support", "bit(1) → TINYINT(1),逐行相等")
  168. cCampusCreated := newCol("t_campus", "created_at", "epoch 占位")
  169. cCampusUpdated := newCol("t_campus", "updated_at", "epoch 占位")
  170. campusOwner := map[int64]int64{}
  171. var gotCampusIDs []int64
  172. for _, c := range expCampuses {
  173. campusOwner[c.ID] = c.SchoolID
  174. rowKey := "id=" + strconv.FormatInt(c.ID, 10)
  175. var got struct {
  176. ID, InstID int64
  177. Name string
  178. Code sql.NullString
  179. Addr sql.NullString
  180. Phone sql.NullString
  181. Status string
  182. SelfSup int8
  183. Created sql.NullString
  184. Updated sql.NullString
  185. }
  186. err := target.QueryRowContext(ctx, `
  187. SELECT campus_id, institution_id, campus_name, campus_code, address, phone, status,
  188. self_support, DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
  189. FROM t_campus WHERE campus_id = ?`, c.ID).Scan(
  190. &got.ID, &got.InstID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status,
  191. &got.SelfSup, &got.Created, &got.Updated)
  192. if err == sql.ErrNoRows {
  193. cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), "<缺失>", false)
  194. continue
  195. }
  196. if err != nil {
  197. return nil, err
  198. }
  199. gotCampusIDs = append(gotCampusIDs, got.ID)
  200. wantStatus := "active"
  201. if c.Status == 0 {
  202. wantStatus = "disabled"
  203. }
  204. cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == c.ID)
  205. cInstID.cmp(rowKey, strconv.FormatInt(c.SchoolID, 10), strconv.FormatInt(got.InstID, 10), got.InstID == c.SchoolID)
  206. cCampusName.cmp(rowKey, c.Name, got.Name, got.Name == c.Name)
  207. cCampusCode.cmp(rowKey, nullStrOrNull(c.Code), nullStrOrNull(got.Code), nullStrOrNull(c.Code) == nullStrOrNull(got.Code))
  208. cCampusAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
  209. cCampusPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
  210. cCampusStatus.cmp(rowKey, fmt.Sprintf("%d→%s", c.Status, wantStatus), got.Status, got.Status == wantStatus)
  211. cSelfSupport.cmp(rowKey, strconv.Itoa(int(c.SelfSupport)), strconv.Itoa(int(got.SelfSup)),
  212. got.SelfSup == c.SelfSupport)
  213. cCampusCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
  214. strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
  215. cCampusUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
  216. strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
  217. }
  218. rep.Cols = append(rep.Cols, cCampusID.done(), cInstID.done(), cCampusName.done(),
  219. cCampusCode.done(), cCampusAddr.done(), cCampusPhone.done(), cCampusStatus.done(),
  220. cSelfSupport.done(), cCampusCreated.done(), cCampusUpdated.done())
  221. rep.TargetCamp = int64(len(gotCampusIDs))
  222. // ---------- 4. campus_id = 0 不得建行 ----------
  223. var zeroCampus int
  224. if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_campus WHERE campus_id = 0`).Scan(&zeroCampus); err != nil {
  225. return nil, err
  226. }
  227. rep.Assertions = append(rep.Assertions, Assertion{
  228. Name: "t_campus 不建 campus_id=0 的行(0 是机构级约定值)",
  229. Pass: zeroCampus == 0,
  230. Detail: fmt.Sprintf("t_campus 里 campus_id=0 的行数 = %d(期望 0)", zeroCampus),
  231. })
  232. // ---------- 5. R17 ----------
  233. r17OK := true
  234. var r17Detail []string
  235. for _, cid := range []int64{1, 2} {
  236. var inst sql.NullInt64
  237. err := target.QueryRowContext(ctx, `SELECT institution_id FROM t_campus WHERE campus_id = ?`, cid).Scan(&inst)
  238. if err != nil {
  239. r17OK = false
  240. r17Detail = append(r17Detail, fmt.Sprintf("campus %d 读不到:%v", cid, err))
  241. continue
  242. }
  243. ok := inst.Valid && inst.Int64 == 1
  244. if !ok {
  245. r17OK = false
  246. }
  247. r17Detail = append(r17Detail, fmt.Sprintf("campus %d institution_id=%d(期望 1)", cid, inst.Int64))
  248. }
  249. var inst1 int
  250. if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_institution WHERE institution_id = 1`).Scan(&inst1); err != nil {
  251. return nil, err
  252. }
  253. rep.Assertions = append(rep.Assertions, Assertion{
  254. Name: "R17:campus 1/2 的 institution_id = 1,且 t_institution(1) 存在",
  255. Pass: r17OK && inst1 == 1,
  256. Detail: strings.Join(r17Detail, ";") + fmt.Sprintf(";t_institution(1) 行数=%d", inst1),
  257. })
  258. // ---------- 6. 引用完整性(每个来源逐个统计孤儿) ----------
  259. for _, s := range orgRefSources() {
  260. st := RefStat{Source: s.Table}
  261. if err := legacy.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM `%s`", s.Table)).Scan(&st.Rows); err != nil {
  262. return nil, err
  263. }
  264. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  265. "SELECT COUNT(DISTINCT `%s`) FROM `%s`", s.InstCol, s.Table)).Scan(&st.DistinctInst); err != nil {
  266. return nil, err
  267. }
  268. // 孤儿机构
  269. orphanInstSQL := fmt.Sprintf(`
  270. SELECT COUNT(*) FROM `+"`%s`"+` s
  271. LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
  272. WHERE i.institution_id IS NULL`, s.Table, cfg.TargetDB, s.InstCol)
  273. if err := legacy.QueryRowContext(ctx, orphanInstSQL).Scan(&st.OrphanInst); err != nil {
  274. return nil, fmt.Errorf("%s 孤儿机构统计失败:%w", s.Table, err)
  275. }
  276. if st.OrphanInst > 0 {
  277. rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
  278. SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
  279. LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
  280. WHERE i.institution_id IS NULL ORDER BY 1`, s.InstCol, s.Table, cfg.TargetDB, s.InstCol))
  281. if err == nil {
  282. for rows.Next() {
  283. var v int64
  284. _ = rows.Scan(&v)
  285. st.OrphanInstList = append(st.OrphanInstList, strconv.FormatInt(v, 10))
  286. }
  287. rows.Close()
  288. }
  289. }
  290. if s.CampusCol != "" {
  291. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  292. "SELECT COUNT(DISTINCT `%s`) FROM `%s` WHERE `%s` <> 0", s.CampusCol, s.Table, s.CampusCol)).Scan(&st.DistinctCampus); err != nil {
  293. return nil, err
  294. }
  295. if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
  296. "SELECT COUNT(*) FROM `%s` WHERE `%s` = 0", s.Table, s.CampusCol)).Scan(&st.CampusZeroRows); err != nil {
  297. return nil, err
  298. }
  299. orphanCampusSQL := fmt.Sprintf(`
  300. SELECT COUNT(*) FROM `+"`%s`"+` s
  301. LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
  302. WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL`, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol)
  303. if err := legacy.QueryRowContext(ctx, orphanCampusSQL).Scan(&st.OrphanCampus); err != nil {
  304. return nil, fmt.Errorf("%s 孤儿校区统计失败:%w", s.Table, err)
  305. }
  306. if st.OrphanCampus > 0 {
  307. rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
  308. SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
  309. LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
  310. WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL ORDER BY 1`, s.CampusCol, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol))
  311. if err == nil {
  312. for rows.Next() {
  313. var v int64
  314. _ = rows.Scan(&v)
  315. st.OrphanCampusList = append(st.OrphanCampusList, strconv.FormatInt(v, 10))
  316. }
  317. rows.Close()
  318. }
  319. }
  320. }
  321. rep.RefStats = append(rep.RefStats, st)
  322. }
  323. totalOrphanInst, totalOrphanCampus := int64(0), int64(0)
  324. var orphanDetail []string
  325. for _, st := range rep.RefStats {
  326. totalOrphanInst += st.OrphanInst
  327. totalOrphanCampus += st.OrphanCampus
  328. if st.OrphanInst > 0 {
  329. orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿机构 %d 行 %v", st.Source, st.OrphanInst, st.OrphanInstList))
  330. }
  331. if st.OrphanCampus > 0 {
  332. orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿校区 %d 行 %v", st.Source, st.OrphanCampus, st.OrphanCampusList))
  333. }
  334. }
  335. detail := fmt.Sprintf("8 个来源合计:孤儿机构 %d 行、孤儿校区 %d 行(期望都为 0)", totalOrphanInst, totalOrphanCampus)
  336. if len(orphanDetail) > 0 {
  337. detail += ";" + strings.Join(orphanDetail, ";")
  338. }
  339. rep.Assertions = append(rep.Assertions, Assertion{
  340. Name: "引用完整性:数据里出现过的每个 institution_id / campus_id 都能在 target 找到(孤儿 = 0)",
  341. Pass: totalOrphanInst == 0 && totalOrphanCampus == 0,
  342. Detail: detail,
  343. })
  344. // ---------- 7. 跨机构:三个来源分别统计 ----------
  345. rep.CrossOrg = append(rep.CrossOrg, crossOrgCourseLevel(ctx, legacy)...)
  346. rep.CrossOrg = append(rep.CrossOrg, crossOrgRecordLevel(ctx, legacy)...)
  347. rep.CrossOrg = append(rep.CrossOrg, crossOrgRecordVsCourse(ctx, legacy)...)
  348. // ---------- 8. 停用校区(status=0)的影响面 + 断言 ----------
  349. disabled, err := disabledCampusStats(ctx, legacy, opts)
  350. if err != nil {
  351. return nil, err
  352. }
  353. rep.Disabled = disabled
  354. var wantDisabled []int64
  355. var gotDisabled []int64
  356. for _, d := range disabled {
  357. wantDisabled = append(wantDisabled, d.CampusID)
  358. }
  359. rowsD, err := target.QueryContext(ctx,
  360. `SELECT campus_id FROM t_campus WHERE status = 'disabled' ORDER BY campus_id`)
  361. if err != nil {
  362. return nil, err
  363. }
  364. for rowsD.Next() {
  365. var id int64
  366. if err := rowsD.Scan(&id); err != nil {
  367. rowsD.Close()
  368. return nil, err
  369. }
  370. gotDisabled = append(gotDisabled, id)
  371. }
  372. rowsD.Close()
  373. rep.Assertions = append(rep.Assertions, Assertion{
  374. Name: "停用校区数量与 id 精确匹配(status=disabled 的必须恰好是 legacy status=0 的那批)",
  375. Pass: int64SliceEqual(gotDisabled, wantDisabled) && len(wantDisabled) > 0,
  376. Detail: fmt.Sprintf("target t_campus WHERE status='disabled' → %v(%d 个);legacy status=0 → %v(%d 个)",
  377. gotDisabled, len(gotDisabled), wantDisabled, len(wantDisabled)),
  378. })
  379. // 逐行再确认一次:这 8 个校区的 status 没有被"为了方便"改成 active
  380. badActive := 0
  381. for _, id := range wantDisabled {
  382. var st string
  383. if err := target.QueryRowContext(ctx, `SELECT status FROM t_campus WHERE campus_id = ?`, id).Scan(&st); err != nil {
  384. return nil, err
  385. }
  386. if st != "disabled" {
  387. badActive++
  388. }
  389. }
  390. rep.Assertions = append(rep.Assertions, Assertion{
  391. Name: "停用校区未被改成 active(不允许为了\"让数据可见\"篡改 status)",
  392. Pass: badActive == 0,
  393. Detail: fmt.Sprintf("%d 个 legacy status=0 的校区里,target 落成非 disabled 的个数 = %d(期望 0)", len(wantDisabled), badActive),
  394. })
  395. return rep, nil
  396. }
  397. // SnapshotOrg 采集 target 两张表的 (id → 名称) 快照,用于幂等比对的「id 不变」判定。
  398. func SnapshotOrg(ctx context.Context, cfg Config) (map[int64]string, map[int64]string, error) {
  399. db, err := cfg.OpenTarget()
  400. if err != nil {
  401. return nil, nil, err
  402. }
  403. defer db.Close()
  404. inst := map[int64]string{}
  405. camp := map[int64]string{}
  406. rows, err := db.QueryContext(ctx, `SELECT institution_id, institution_name FROM t_institution`)
  407. if err != nil {
  408. return nil, nil, err
  409. }
  410. for rows.Next() {
  411. var id int64
  412. var n string
  413. if err := rows.Scan(&id, &n); err != nil {
  414. rows.Close()
  415. return nil, nil, err
  416. }
  417. inst[id] = n
  418. }
  419. rows.Close()
  420. rows2, err := db.QueryContext(ctx, `SELECT campus_id, campus_name FROM t_campus`)
  421. if err != nil {
  422. return nil, nil, err
  423. }
  424. for rows2.Next() {
  425. var id int64
  426. var n string
  427. if err := rows2.Scan(&id, &n); err != nil {
  428. rows2.Close()
  429. return nil, nil, err
  430. }
  431. camp[id] = n
  432. }
  433. rows2.Close()
  434. return inst, camp, nil
  435. }
  436. // ---------- 跨机构三类统计 ----------
  437. // crossOrgCourseLevel(A)课程级:t_courses.schoolid vs 该课程 campusid 的校区真实所属。
  438. func crossOrgCourseLevel(ctx context.Context, db *sql.DB) []CrossOrgStat {
  439. st := CrossOrgStat{
  440. Key: "A", Name: "课程级:课程挂的机构 vs 校区的真实所属",
  441. Source: "finance_bk.t_courses.schoolid ↔ yuansiwei_i.t_school_campus.school_id(campusid)",
  442. Note: "R17 的主体:校区 1/2 属机构 1,但课程普遍挂在 63317 下",
  443. }
  444. _ = db.QueryRowContext(ctx, `
  445. SELECT COUNT(*),
  446. SUM(sc.id IS NULL),
  447. SUM(sc.id IS NOT NULL AND sc.school_id = c.schoolid),
  448. SUM(sc.id IS NOT NULL AND sc.school_id <> c.schoolid)
  449. FROM t_courses c LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = c.campusid`).
  450. Scan(&st.Total, &st.Skipped, &st.Consistent, &st.Inconsistent)
  451. st.Samples = topCombos(ctx, db, `
  452. SELECT c.schoolid, c.campusid, ifnull(sc.school_id,0), COUNT(*)
  453. FROM t_courses c LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = c.campusid
  454. WHERE sc.id IS NOT NULL AND sc.school_id <> c.schoolid
  455. GROUP BY 1,2,3 ORDER BY 4 DESC LIMIT 5`, "课程挂机构=%d 校区=%d(真实所属=%d) → %d 行")
  456. return []CrossOrgStat{st}
  457. }
  458. // crossOrgRecordLevel(B)记录级:t_courses_record.schoolid vs 该记录 campusid 的校区真实所属。
  459. func crossOrgRecordLevel(ctx context.Context, db *sql.DB) []CrossOrgStat {
  460. st := CrossOrgStat{
  461. Key: "B", Name: "记录级:上课记录挂的机构 vs 校区的真实所属",
  462. Source: "finance_bk.t_courses_record.schoolid ↔ yuansiwei_i.t_school_campus.school_id(campusid)",
  463. Note: "含反向跨机构(记录挂机构 1、校区属 63317)",
  464. }
  465. _ = db.QueryRowContext(ctx, `
  466. SELECT COUNT(*),
  467. SUM(sc.id IS NULL),
  468. SUM(sc.id IS NOT NULL AND sc.school_id = r.schoolid),
  469. SUM(sc.id IS NOT NULL AND sc.school_id <> r.schoolid)
  470. FROM t_courses_record r LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = r.campusid`).
  471. Scan(&st.Total, &st.Skipped, &st.Consistent, &st.Inconsistent)
  472. st.Samples = topCombos(ctx, db, `
  473. SELECT r.schoolid, r.campusid, ifnull(sc.school_id,0), COUNT(*)
  474. FROM t_courses_record r LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = r.campusid
  475. WHERE sc.id IS NOT NULL AND sc.school_id <> r.schoolid
  476. GROUP BY 1,2,3 ORDER BY 4 DESC LIMIT 5`, "记录挂机构=%d 校区=%d(真实所属=%d) → %d 行")
  477. return []CrossOrgStat{st}
  478. }
  479. // crossOrgRecordVsCourse(C)记录 vs 课程:t_courses_record.schoolid vs 其 coursesid 的 schoolid。
  480. //
  481. // 与 A/B 性质不同:这里两个都是"数据自己声明的机构",不一致说明记录挂错了机构,
  482. // 与校区归属无关。
  483. func crossOrgRecordVsCourse(ctx context.Context, db *sql.DB) []CrossOrgStat {
  484. st := CrossOrgStat{
  485. Key: "C", Name: "记录 vs 课程:记录的机构 vs 其所属课程的机构",
  486. Source: "finance_bk.t_courses_record.schoolid ↔ finance_bk.t_courses.schoolid(coursesid)",
  487. Note: "与校区归属无关,纯粹是记录挂错机构",
  488. }
  489. _ = db.QueryRowContext(ctx, `
  490. SELECT COUNT(*), SUM(r.schoolid = c.schoolid), SUM(r.schoolid <> c.schoolid)
  491. FROM t_courses_record r JOIN t_courses c ON c.id = r.coursesid`).
  492. Scan(&st.Total, &st.Consistent, &st.Inconsistent)
  493. st.Samples = topCombos3(ctx, db, `
  494. SELECT r.schoolid, c.schoolid, COUNT(*)
  495. FROM t_courses_record r JOIN t_courses c ON c.id = r.coursesid
  496. WHERE r.schoolid <> c.schoolid
  497. GROUP BY 1,2 ORDER BY 3 DESC LIMIT 5`, "记录挂机构=%d 课程属机构=%d → %d 行")
  498. return []CrossOrgStat{st}
  499. }
  500. // topCombos 执行「不一致组合 TOP N」查询并格式化成样例行。
  501. func topCombos(ctx context.Context, db *sql.DB, q, format string) []string {
  502. rows, err := db.QueryContext(ctx, q)
  503. if err != nil {
  504. return []string{"<查询失败:" + err.Error() + ">"}
  505. }
  506. defer rows.Close()
  507. var out []string
  508. for rows.Next() {
  509. var a, b, c, n int64
  510. if err := rows.Scan(&a, &b, &c, &n); err != nil {
  511. return out
  512. }
  513. out = append(out, fmt.Sprintf(format, a, b, c, n))
  514. }
  515. return out
  516. }
  517. // topCombos3 是 topCombos 的 3 列版本(格式串 verb 数与列数必须严格一致)。
  518. func topCombos3(ctx context.Context, db *sql.DB, q, format string) []string {
  519. rows, err := db.QueryContext(ctx, q)
  520. if err != nil {
  521. return []string{"<查询失败:" + err.Error() + ">"}
  522. }
  523. defer rows.Close()
  524. var out []string
  525. for rows.Next() {
  526. var a, b, n int64
  527. if err := rows.Scan(&a, &b, &n); err != nil {
  528. return out
  529. }
  530. out = append(out, fmt.Sprintf(format, a, b, n))
  531. }
  532. return out
  533. }
  534. // ---------- 停用校区影响面 ----------
  535. // disabledCampusStats 统计「所选机构里 status=0 的校区」各自挂了多少历史数据。
  536. //
  537. // 这不是为了"好看":迁过去之后这些校区是 disabled,历史数据在"按启用校区筛选"时看不到,
  538. // 而写入侧 assertInScope 会拒绝给停用校区新增数据(这是对的)——
  539. // 业务侧会看到"历史数据消失",必须让业务知情。
  540. func disabledCampusStats(ctx context.Context, db *sql.DB, opts OrgOptions) ([]DisabledCampusStat, error) {
  541. ph := placeholders(len(opts.InstitutionIDs))
  542. args := make([]any, len(opts.InstitutionIDs))
  543. for i, v := range opts.InstitutionIDs {
  544. args[i] = v
  545. }
  546. q := fmt.Sprintf(`
  547. SELECT sc.id, sc.name, sc.school_id,
  548. (SELECT COUNT(*) FROM t_courses x WHERE x.campusid = sc.id),
  549. (SELECT COUNT(*) FROM t_courses_record x WHERE x.campusid = sc.id),
  550. (SELECT COUNT(*) FROM t_user_expense_school x WHERE x.campusid = sc.id),
  551. (SELECT COUNT(*) FROM t_user_balance_request x WHERE x.campusid = sc.id),
  552. (SELECT COUNT(*) FROM t_expenditure_log x WHERE x.campusid = sc.id)
  553. FROM yuansiwei_i.t_school_campus sc
  554. WHERE sc.school_id IN (%s) AND sc.status = 0
  555. ORDER BY sc.id`, ph)
  556. rows, err := db.QueryContext(ctx, q, args...)
  557. if err != nil {
  558. return nil, fmt.Errorf("停用校区统计失败:%w", err)
  559. }
  560. defer rows.Close()
  561. var out []DisabledCampusStat
  562. for rows.Next() {
  563. var d DisabledCampusStat
  564. if err := rows.Scan(&d.CampusID, &d.Name, &d.InstitutionID, &d.Courses, &d.Records,
  565. &d.ExpenseSchool, &d.BalanceRequest, &d.ExpenditureLog); err != nil {
  566. return nil, err
  567. }
  568. out = append(out, d)
  569. }
  570. return out, rows.Err()
  571. }