| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- package fixture
- import (
- "context"
- "database/sql"
- "fmt"
- "sort"
- "strconv"
- "strings"
- )
- // 本文件是第三批的**核对**:逐列 + 机构集合 + 引用完整性(孤儿=0)+ R17 + bit(1)。
- //
- // 期望值来源与前三批一致:机构/校区字段由 **MySQL 服务端**取值(`self_support+0`、
- // `CAST(...)`),不由映射代码产生;只有 `status` 的 active/disabled 是规则翻译,
- // 单独作为断言说明(并在映射里对非法值报错)。
- // OrgVerifyReport 是第三批核对结果。
- type OrgVerifyReport struct {
- Options OrgOptions
- Cols []ColumnAgg
- Assertions []Assertion
- RefStats []RefStat
- CrossOrg []string // R17:数据里 (schoolid, campusid) 与校区真实所属机构不一致的组合
- TargetInst int64
- TargetCamp int64
- Warnings []string
- }
- // FailCount 统计不一致列 + 失败断言。
- func (r *OrgVerifyReport) FailCount() int {
- n := 0
- for _, c := range r.Cols {
- if !c.OK() {
- n++
- }
- }
- for _, a := range r.Assertions {
- if !a.Pass {
- n++
- }
- }
- return n
- }
- // OK 表示整轮通过。
- func (r *OrgVerifyReport) OK() bool { return r.FailCount() == 0 }
- // VerifyOrg 执行第三批核对。
- func VerifyOrg(ctx context.Context, cfg Config, opts OrgOptions) (*OrgVerifyReport, error) {
- src, err := cfg.OpenOrgSource()
- if err != nil {
- return nil, err
- }
- defer src.Close()
- legacy, err := cfg.OpenLegacy() // finance_bk:引用完整性统计用
- if err != nil {
- return nil, err
- }
- defer legacy.Close()
- target, err := cfg.OpenTarget()
- if err != nil {
- return nil, err
- }
- defer target.Close()
- rep := &OrgVerifyReport{Options: opts}
- // ---------- 1. t_institution 逐列 ----------
- expSchools, err := extractSchools(ctx, src, opts.InstitutionIDs)
- if err != nil {
- return nil, err
- }
- cID := newCol("t_institution", "institution_id", "D1:与 t_school.id 完全相同")
- cName := newCol("t_institution", "institution_name", "t_school.name 原样")
- cCode := newCol("t_institution", "institution_code", "legacy 无此列 → 必须 NULL")
- cAddr := newCol("t_institution", "address", "legacy 无此列 → 必须 NULL")
- cPhone := newCol("t_institution", "phone", "legacy 无此列 → 必须 NULL")
- cStatus := newCol("t_institution", "status", "tinyint 1→active / 0→disabled(规则翻译)")
- cCreated := newCol("t_institution", "created_at", "legacy 无时间列 → epoch 占位")
- cUpdated := newCol("t_institution", "updated_at", "legacy 无时间列 → epoch 占位")
- var legacyIDs []int64
- for _, s := range expSchools {
- legacyIDs = append(legacyIDs, s.ID)
- rowKey := "id=" + strconv.FormatInt(s.ID, 10)
- var got struct {
- ID int64
- Name string
- Code sql.NullString
- Addr sql.NullString
- Phone sql.NullString
- Status string
- Created sql.NullString
- Updated sql.NullString
- }
- err := target.QueryRowContext(ctx, `
- SELECT institution_id, institution_name, institution_code, address, phone, status,
- DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
- FROM t_institution WHERE institution_id = ?`, s.ID).Scan(
- &got.ID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status, &got.Created, &got.Updated)
- if err == sql.ErrNoRows {
- cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), "<缺失>", false)
- continue
- }
- if err != nil {
- return nil, err
- }
- wantStatus := "active"
- if s.Status == 0 {
- wantStatus = "disabled"
- }
- cID.cmp(rowKey, strconv.FormatInt(s.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == s.ID)
- cName.cmp(rowKey, s.Name, got.Name, got.Name == s.Name)
- cCode.cmp(rowKey, "NULL", nullStrOrNull(got.Code), !got.Code.Valid)
- cAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
- cPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
- cStatus.cmp(rowKey, fmt.Sprintf("%d→%s", s.Status, wantStatus), got.Status, got.Status == wantStatus)
- cCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
- strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
- cUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
- strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
- }
- rep.Cols = append(rep.Cols, cID.done(), cName.done(), cCode.done(), cAddr.done(),
- cPhone.done(), cStatus.done(), cCreated.done(), cUpdated.done())
- // ---------- 2. 机构集合断言 ----------
- var gotIDs []int64
- rows, err := target.QueryContext(ctx, `SELECT institution_id FROM t_institution ORDER BY institution_id`)
- if err != nil {
- return nil, err
- }
- for rows.Next() {
- var id int64
- if err := rows.Scan(&id); err != nil {
- rows.Close()
- return nil, err
- }
- gotIDs = append(gotIDs, id)
- }
- rows.Close()
- rep.TargetInst = int64(len(gotIDs))
- want := append([]int64(nil), opts.InstitutionIDs...)
- sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
- rep.Assertions = append(rep.Assertions, Assertion{
- Name: "机构集合精确等于期望集合(不多不少)",
- Pass: int64SliceEqual(gotIDs, want),
- Detail: fmt.Sprintf("target t_institution = %v(%d 行);期望 = %v(%d 行)",
- gotIDs, len(gotIDs), want, len(want)),
- })
- // ---------- 3. t_campus 逐列 ----------
- expCampuses, err := extractCampuses(ctx, src, opts.InstitutionIDs)
- if err != nil {
- return nil, err
- }
- if !opts.WithAllCampuses {
- refd, err := extractReferencedCampuses(ctx, legacy)
- if err != nil {
- return nil, err
- }
- var kept []LegacyCampus
- for _, c := range expCampuses {
- if refd[c.ID] {
- kept = append(kept, c)
- }
- }
- expCampuses = kept
- }
- cCampusID := newCol("t_campus", "campus_id", "D1:与 t_school_campus.id 完全相同")
- cInstID := newCol("t_campus", "institution_id", "R17:如实保留 school_id(不修正跨机构校区)")
- cCampusName := newCol("t_campus", "campus_name", "name 原样")
- cCampusCode := newCol("t_campus", "campus_code", "code → campus_code(NULL 保留)")
- cCampusAddr := newCol("t_campus", "address", "legacy 无此列 → 必须 NULL")
- cCampusPhone := newCol("t_campus", "phone", "legacy 无此列 → 必须 NULL")
- cCampusStatus := newCol("t_campus", "status", "tinyint 1→active / 0→disabled")
- cSelfSupport := newCol("t_campus", "self_support", "bit(1) → TINYINT(1),逐行相等")
- cCampusCreated := newCol("t_campus", "created_at", "epoch 占位")
- cCampusUpdated := newCol("t_campus", "updated_at", "epoch 占位")
- campusOwner := map[int64]int64{}
- var gotCampusIDs []int64
- for _, c := range expCampuses {
- campusOwner[c.ID] = c.SchoolID
- rowKey := "id=" + strconv.FormatInt(c.ID, 10)
- var got struct {
- ID, InstID int64
- Name string
- Code sql.NullString
- Addr sql.NullString
- Phone sql.NullString
- Status string
- SelfSup int8
- Created sql.NullString
- Updated sql.NullString
- }
- err := target.QueryRowContext(ctx, `
- SELECT campus_id, institution_id, campus_name, campus_code, address, phone, status,
- self_support, DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(updated_at,'%Y-%m-%d %H:%i:%s')
- FROM t_campus WHERE campus_id = ?`, c.ID).Scan(
- &got.ID, &got.InstID, &got.Name, &got.Code, &got.Addr, &got.Phone, &got.Status,
- &got.SelfSup, &got.Created, &got.Updated)
- if err == sql.ErrNoRows {
- cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), "<缺失>", false)
- continue
- }
- if err != nil {
- return nil, err
- }
- gotCampusIDs = append(gotCampusIDs, got.ID)
- wantStatus := "active"
- if c.Status == 0 {
- wantStatus = "disabled"
- }
- cCampusID.cmp(rowKey, strconv.FormatInt(c.ID, 10), strconv.FormatInt(got.ID, 10), got.ID == c.ID)
- cInstID.cmp(rowKey, strconv.FormatInt(c.SchoolID, 10), strconv.FormatInt(got.InstID, 10), got.InstID == c.SchoolID)
- cCampusName.cmp(rowKey, c.Name, got.Name, got.Name == c.Name)
- cCampusCode.cmp(rowKey, nullStrOrNull(c.Code), nullStrOrNull(got.Code), nullStrOrNull(c.Code) == nullStrOrNull(got.Code))
- cCampusAddr.cmp(rowKey, "NULL", nullStrOrNull(got.Addr), !got.Addr.Valid)
- cCampusPhone.cmp(rowKey, "NULL", nullStrOrNull(got.Phone), !got.Phone.Valid)
- cCampusStatus.cmp(rowKey, fmt.Sprintf("%d→%s", c.Status, wantStatus), got.Status, got.Status == wantStatus)
- cSelfSupport.cmp(rowKey, strconv.Itoa(int(c.SelfSupport)), strconv.Itoa(int(got.SelfSup)),
- got.SelfSup == c.SelfSupport)
- cCampusCreated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Created),
- strings.HasPrefix(nullStrOrNull(got.Created), LegacyMissingTime[:10]))
- cCampusUpdated.cmp(rowKey, LegacyMissingTime[:10], nullStrOrNull(got.Updated),
- strings.HasPrefix(nullStrOrNull(got.Updated), LegacyMissingTime[:10]))
- }
- rep.Cols = append(rep.Cols, cCampusID.done(), cInstID.done(), cCampusName.done(),
- cCampusCode.done(), cCampusAddr.done(), cCampusPhone.done(), cCampusStatus.done(),
- cSelfSupport.done(), cCampusCreated.done(), cCampusUpdated.done())
- rep.TargetCamp = int64(len(gotCampusIDs))
- // ---------- 4. campus_id = 0 不得建行 ----------
- var zeroCampus int
- if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_campus WHERE campus_id = 0`).Scan(&zeroCampus); err != nil {
- return nil, err
- }
- rep.Assertions = append(rep.Assertions, Assertion{
- Name: "t_campus 不建 campus_id=0 的行(0 是机构级约定值)",
- Pass: zeroCampus == 0,
- Detail: fmt.Sprintf("t_campus 里 campus_id=0 的行数 = %d(期望 0)", zeroCampus),
- })
- // ---------- 5. R17 ----------
- r17OK := true
- var r17Detail []string
- for _, cid := range []int64{1, 2} {
- var inst sql.NullInt64
- err := target.QueryRowContext(ctx, `SELECT institution_id FROM t_campus WHERE campus_id = ?`, cid).Scan(&inst)
- if err != nil {
- r17OK = false
- r17Detail = append(r17Detail, fmt.Sprintf("campus %d 读不到:%v", cid, err))
- continue
- }
- ok := inst.Valid && inst.Int64 == 1
- if !ok {
- r17OK = false
- }
- r17Detail = append(r17Detail, fmt.Sprintf("campus %d institution_id=%d(期望 1)", cid, inst.Int64))
- }
- var inst1 int
- if err := target.QueryRowContext(ctx, `SELECT COUNT(*) FROM t_institution WHERE institution_id = 1`).Scan(&inst1); err != nil {
- return nil, err
- }
- rep.Assertions = append(rep.Assertions, Assertion{
- Name: "R17:campus 1/2 的 institution_id = 1,且 t_institution(1) 存在",
- Pass: r17OK && inst1 == 1,
- Detail: strings.Join(r17Detail, ";") + fmt.Sprintf(";t_institution(1) 行数=%d", inst1),
- })
- // ---------- 6. 引用完整性(每个来源逐个统计孤儿) ----------
- for _, s := range orgRefSources() {
- st := RefStat{Source: s.Table}
- if err := legacy.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM `%s`", s.Table)).Scan(&st.Rows); err != nil {
- return nil, err
- }
- if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
- "SELECT COUNT(DISTINCT `%s`) FROM `%s`", s.InstCol, s.Table)).Scan(&st.DistinctInst); err != nil {
- return nil, err
- }
- // 孤儿机构
- orphanInstSQL := fmt.Sprintf(`
- SELECT COUNT(*) FROM `+"`%s`"+` s
- LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
- WHERE i.institution_id IS NULL`, s.Table, cfg.TargetDB, s.InstCol)
- if err := legacy.QueryRowContext(ctx, orphanInstSQL).Scan(&st.OrphanInst); err != nil {
- return nil, fmt.Errorf("%s 孤儿机构统计失败:%w", s.Table, err)
- }
- if st.OrphanInst > 0 {
- rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
- SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
- LEFT JOIN %s.t_institution i ON i.institution_id = s.`+"`%s`"+`
- WHERE i.institution_id IS NULL ORDER BY 1`, s.InstCol, s.Table, cfg.TargetDB, s.InstCol))
- if err == nil {
- for rows.Next() {
- var v int64
- _ = rows.Scan(&v)
- st.OrphanInstList = append(st.OrphanInstList, strconv.FormatInt(v, 10))
- }
- rows.Close()
- }
- }
- if s.CampusCol != "" {
- if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
- "SELECT COUNT(DISTINCT `%s`) FROM `%s` WHERE `%s` <> 0", s.CampusCol, s.Table, s.CampusCol)).Scan(&st.DistinctCampus); err != nil {
- return nil, err
- }
- if err := legacy.QueryRowContext(ctx, fmt.Sprintf(
- "SELECT COUNT(*) FROM `%s` WHERE `%s` = 0", s.Table, s.CampusCol)).Scan(&st.CampusZeroRows); err != nil {
- return nil, err
- }
- orphanCampusSQL := fmt.Sprintf(`
- SELECT COUNT(*) FROM `+"`%s`"+` s
- LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
- WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL`, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol)
- if err := legacy.QueryRowContext(ctx, orphanCampusSQL).Scan(&st.OrphanCampus); err != nil {
- return nil, fmt.Errorf("%s 孤儿校区统计失败:%w", s.Table, err)
- }
- if st.OrphanCampus > 0 {
- rows, err := legacy.QueryContext(ctx, fmt.Sprintf(`
- SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
- LEFT JOIN %s.t_campus c ON c.campus_id = s.`+"`%s`"+`
- WHERE s.`+"`%s`"+` <> 0 AND c.campus_id IS NULL ORDER BY 1`, s.CampusCol, s.Table, cfg.TargetDB, s.CampusCol, s.CampusCol))
- if err == nil {
- for rows.Next() {
- var v int64
- _ = rows.Scan(&v)
- st.OrphanCampusList = append(st.OrphanCampusList, strconv.FormatInt(v, 10))
- }
- rows.Close()
- }
- }
- }
- rep.RefStats = append(rep.RefStats, st)
- }
- totalOrphanInst, totalOrphanCampus := int64(0), int64(0)
- var orphanDetail []string
- for _, st := range rep.RefStats {
- totalOrphanInst += st.OrphanInst
- totalOrphanCampus += st.OrphanCampus
- if st.OrphanInst > 0 {
- orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿机构 %d 行 %v", st.Source, st.OrphanInst, st.OrphanInstList))
- }
- if st.OrphanCampus > 0 {
- orphanDetail = append(orphanDetail, fmt.Sprintf("%s 孤儿校区 %d 行 %v", st.Source, st.OrphanCampus, st.OrphanCampusList))
- }
- }
- detail := fmt.Sprintf("8 个来源合计:孤儿机构 %d 行、孤儿校区 %d 行(期望都为 0)", totalOrphanInst, totalOrphanCampus)
- if len(orphanDetail) > 0 {
- detail += ";" + strings.Join(orphanDetail, ";")
- }
- rep.Assertions = append(rep.Assertions, Assertion{
- Name: "引用完整性:数据里出现过的每个 institution_id / campus_id 都能在 target 找到(孤儿 = 0)",
- Pass: totalOrphanInst == 0 && totalOrphanCampus == 0,
- Detail: detail,
- })
- // ---------- 7. cross-org-campus 标注(只报告,不判失败) ----------
- for _, s := range orgRefSources() {
- if s.CampusCol == "" {
- continue
- }
- rows, err := legacy.QueryContext(ctx, fmt.Sprintf(
- "SELECT DISTINCT `%s`, `%s`, COUNT(*) FROM `%s` WHERE `%s` <> 0 GROUP BY 1,2 ORDER BY 1,2",
- s.InstCol, s.CampusCol, s.Table, s.CampusCol))
- if err != nil {
- return nil, err
- }
- for rows.Next() {
- var inst, camp, n int64
- if err := rows.Scan(&inst, &camp, &n); err != nil {
- rows.Close()
- return nil, err
- }
- if IsCrossOrgCampus(inst, camp, campusOwner) {
- rep.CrossOrg = append(rep.CrossOrg, fmt.Sprintf(
- "%s: schoolid=%d campusid=%d(校区真实所属=%d)%d 行",
- s.Table, inst, camp, campusOwner[camp], n))
- }
- }
- rows.Close()
- }
- sort.Strings(rep.CrossOrg)
- return rep, nil
- }
- // SnapshotOrg 采集 target 两张表的 (id → 名称) 快照,用于幂等比对的「id 不变」判定。
- func SnapshotOrg(ctx context.Context, cfg Config) (map[int64]string, map[int64]string, error) {
- db, err := cfg.OpenTarget()
- if err != nil {
- return nil, nil, err
- }
- defer db.Close()
- inst := map[int64]string{}
- camp := map[int64]string{}
- rows, err := db.QueryContext(ctx, `SELECT institution_id, institution_name FROM t_institution`)
- if err != nil {
- return nil, nil, err
- }
- for rows.Next() {
- var id int64
- var n string
- if err := rows.Scan(&id, &n); err != nil {
- rows.Close()
- return nil, nil, err
- }
- inst[id] = n
- }
- rows.Close()
- rows2, err := db.QueryContext(ctx, `SELECT campus_id, campus_name FROM t_campus`)
- if err != nil {
- return nil, nil, err
- }
- for rows2.Next() {
- var id int64
- var n string
- if err := rows2.Scan(&id, &n); err != nil {
- rows2.Close()
- return nil, nil, err
- }
- camp[id] = n
- }
- rows2.Close()
- return inst, camp, nil
- }
|