|
@@ -21,7 +21,8 @@ type OrgVerifyReport struct {
|
|
|
Cols []ColumnAgg
|
|
Cols []ColumnAgg
|
|
|
Assertions []Assertion
|
|
Assertions []Assertion
|
|
|
RefStats []RefStat
|
|
RefStats []RefStat
|
|
|
- CrossOrg []string // R17:数据里 (schoolid, campusid) 与校区真实所属机构不一致的组合
|
|
|
|
|
|
|
+ CrossOrg []CrossOrgStat // 跨机构三类统计(A 课程级 / B 记录级 / C 记录 vs 课程)
|
|
|
|
|
+ Disabled []DisabledCampusStat // 停用校区(status=0)的影响面
|
|
|
TargetInst int64
|
|
TargetInst int64
|
|
|
TargetCamp int64
|
|
TargetCamp int64
|
|
|
Warnings []string
|
|
Warnings []string
|
|
@@ -358,32 +359,59 @@ SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
|
|
|
Detail: detail,
|
|
Detail: detail,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- // ---------- 7. cross-org-campus 标注(只报告,不判失败) ----------
|
|
|
|
|
- for _, s := range orgRefSources() {
|
|
|
|
|
- if s.CampusCol == "" {
|
|
|
|
|
- continue
|
|
|
|
|
|
|
+ // ---------- 7. 跨机构:三个来源分别统计 ----------
|
|
|
|
|
+ rep.CrossOrg = append(rep.CrossOrg, crossOrgCourseLevel(ctx, legacy)...)
|
|
|
|
|
+ rep.CrossOrg = append(rep.CrossOrg, crossOrgRecordLevel(ctx, legacy)...)
|
|
|
|
|
+ rep.CrossOrg = append(rep.CrossOrg, crossOrgRecordVsCourse(ctx, legacy)...)
|
|
|
|
|
+
|
|
|
|
|
+ // ---------- 8. 停用校区(status=0)的影响面 + 断言 ----------
|
|
|
|
|
+ disabled, err := disabledCampusStats(ctx, legacy, opts)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ rep.Disabled = disabled
|
|
|
|
|
+
|
|
|
|
|
+ var wantDisabled []int64
|
|
|
|
|
+ var gotDisabled []int64
|
|
|
|
|
+ for _, d := range disabled {
|
|
|
|
|
+ wantDisabled = append(wantDisabled, d.CampusID)
|
|
|
|
|
+ }
|
|
|
|
|
+ rowsD, err := target.QueryContext(ctx,
|
|
|
|
|
+ `SELECT campus_id FROM t_campus WHERE status = 'disabled' ORDER BY campus_id`)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ for rowsD.Next() {
|
|
|
|
|
+ var id int64
|
|
|
|
|
+ if err := rowsD.Scan(&id); err != nil {
|
|
|
|
|
+ rowsD.Close()
|
|
|
|
|
+ return nil, err
|
|
|
}
|
|
}
|
|
|
- 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 {
|
|
|
|
|
|
|
+ gotDisabled = append(gotDisabled, id)
|
|
|
|
|
+ }
|
|
|
|
|
+ rowsD.Close()
|
|
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
|
|
+ Name: "停用校区数量与 id 精确匹配(status=disabled 的必须恰好是 legacy status=0 的那批)",
|
|
|
|
|
+ Pass: int64SliceEqual(gotDisabled, wantDisabled) && len(wantDisabled) > 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("target t_campus WHERE status='disabled' → %v(%d 个);legacy status=0 → %v(%d 个)",
|
|
|
|
|
+ gotDisabled, len(gotDisabled), wantDisabled, len(wantDisabled)),
|
|
|
|
|
+ })
|
|
|
|
|
+ // 逐行再确认一次:这 8 个校区的 status 没有被"为了方便"改成 active
|
|
|
|
|
+ badActive := 0
|
|
|
|
|
+ for _, id := range wantDisabled {
|
|
|
|
|
+ var st string
|
|
|
|
|
+ if err := target.QueryRowContext(ctx, `SELECT status FROM t_campus WHERE campus_id = ?`, id).Scan(&st); err != nil {
|
|
|
return nil, err
|
|
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))
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if st != "disabled" {
|
|
|
|
|
+ badActive++
|
|
|
}
|
|
}
|
|
|
- rows.Close()
|
|
|
|
|
}
|
|
}
|
|
|
- sort.Strings(rep.CrossOrg)
|
|
|
|
|
|
|
+ rep.Assertions = append(rep.Assertions, Assertion{
|
|
|
|
|
+ Name: "停用校区未被改成 active(不允许为了\"让数据可见\"篡改 status)",
|
|
|
|
|
+ Pass: badActive == 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("%d 个 legacy status=0 的校区里,target 落成非 disabled 的个数 = %d(期望 0)", len(wantDisabled), badActive),
|
|
|
|
|
+ })
|
|
|
return rep, nil
|
|
return rep, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -426,3 +454,147 @@ func SnapshotOrg(ctx context.Context, cfg Config) (map[int64]string, map[int64]s
|
|
|
rows2.Close()
|
|
rows2.Close()
|
|
|
return inst, camp, nil
|
|
return inst, camp, nil
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 跨机构三类统计 ----------
|
|
|
|
|
+
|
|
|
|
|
+// crossOrgCourseLevel(A)课程级:t_courses.schoolid vs 该课程 campusid 的校区真实所属。
|
|
|
|
|
+func crossOrgCourseLevel(ctx context.Context, db *sql.DB) []CrossOrgStat {
|
|
|
|
|
+ st := CrossOrgStat{
|
|
|
|
|
+ Key: "A", Name: "课程级:课程挂的机构 vs 校区的真实所属",
|
|
|
|
|
+ Source: "finance_bk.t_courses.schoolid ↔ yuansiwei_i.t_school_campus.school_id(campusid)",
|
|
|
|
|
+ Note: "R17 的主体:校区 1/2 属机构 1,但课程普遍挂在 63317 下",
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = db.QueryRowContext(ctx, `
|
|
|
|
|
+SELECT COUNT(*),
|
|
|
|
|
+ SUM(sc.id IS NULL),
|
|
|
|
|
+ SUM(sc.id IS NOT NULL AND sc.school_id = c.schoolid),
|
|
|
|
|
+ SUM(sc.id IS NOT NULL AND sc.school_id <> c.schoolid)
|
|
|
|
|
+ FROM t_courses c LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = c.campusid`).
|
|
|
|
|
+ Scan(&st.Total, &st.Skipped, &st.Consistent, &st.Inconsistent)
|
|
|
|
|
+ st.Samples = topCombos(ctx, db, `
|
|
|
|
|
+SELECT c.schoolid, c.campusid, ifnull(sc.school_id,0), COUNT(*)
|
|
|
|
|
+ FROM t_courses c LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = c.campusid
|
|
|
|
|
+ WHERE sc.id IS NOT NULL AND sc.school_id <> c.schoolid
|
|
|
|
|
+ GROUP BY 1,2,3 ORDER BY 4 DESC LIMIT 5`, "课程挂机构=%d 校区=%d(真实所属=%d) → %d 行")
|
|
|
|
|
+ return []CrossOrgStat{st}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// crossOrgRecordLevel(B)记录级:t_courses_record.schoolid vs 该记录 campusid 的校区真实所属。
|
|
|
|
|
+func crossOrgRecordLevel(ctx context.Context, db *sql.DB) []CrossOrgStat {
|
|
|
|
|
+ st := CrossOrgStat{
|
|
|
|
|
+ Key: "B", Name: "记录级:上课记录挂的机构 vs 校区的真实所属",
|
|
|
|
|
+ Source: "finance_bk.t_courses_record.schoolid ↔ yuansiwei_i.t_school_campus.school_id(campusid)",
|
|
|
|
|
+ Note: "含反向跨机构(记录挂机构 1、校区属 63317)",
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = db.QueryRowContext(ctx, `
|
|
|
|
|
+SELECT COUNT(*),
|
|
|
|
|
+ SUM(sc.id IS NULL),
|
|
|
|
|
+ SUM(sc.id IS NOT NULL AND sc.school_id = r.schoolid),
|
|
|
|
|
+ SUM(sc.id IS NOT NULL AND sc.school_id <> r.schoolid)
|
|
|
|
|
+ FROM t_courses_record r LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = r.campusid`).
|
|
|
|
|
+ Scan(&st.Total, &st.Skipped, &st.Consistent, &st.Inconsistent)
|
|
|
|
|
+ st.Samples = topCombos(ctx, db, `
|
|
|
|
|
+SELECT r.schoolid, r.campusid, ifnull(sc.school_id,0), COUNT(*)
|
|
|
|
|
+ FROM t_courses_record r LEFT JOIN yuansiwei_i.t_school_campus sc ON sc.id = r.campusid
|
|
|
|
|
+ WHERE sc.id IS NOT NULL AND sc.school_id <> r.schoolid
|
|
|
|
|
+ GROUP BY 1,2,3 ORDER BY 4 DESC LIMIT 5`, "记录挂机构=%d 校区=%d(真实所属=%d) → %d 行")
|
|
|
|
|
+ return []CrossOrgStat{st}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// crossOrgRecordVsCourse(C)记录 vs 课程:t_courses_record.schoolid vs 其 coursesid 的 schoolid。
|
|
|
|
|
+//
|
|
|
|
|
+// 与 A/B 性质不同:这里两个都是"数据自己声明的机构",不一致说明记录挂错了机构,
|
|
|
|
|
+// 与校区归属无关。
|
|
|
|
|
+func crossOrgRecordVsCourse(ctx context.Context, db *sql.DB) []CrossOrgStat {
|
|
|
|
|
+ st := CrossOrgStat{
|
|
|
|
|
+ Key: "C", Name: "记录 vs 课程:记录的机构 vs 其所属课程的机构",
|
|
|
|
|
+ Source: "finance_bk.t_courses_record.schoolid ↔ finance_bk.t_courses.schoolid(coursesid)",
|
|
|
|
|
+ Note: "与校区归属无关,纯粹是记录挂错机构",
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = db.QueryRowContext(ctx, `
|
|
|
|
|
+SELECT COUNT(*), SUM(r.schoolid = c.schoolid), SUM(r.schoolid <> c.schoolid)
|
|
|
|
|
+ FROM t_courses_record r JOIN t_courses c ON c.id = r.coursesid`).
|
|
|
|
|
+ Scan(&st.Total, &st.Consistent, &st.Inconsistent)
|
|
|
|
|
+ st.Samples = topCombos3(ctx, db, `
|
|
|
|
|
+SELECT r.schoolid, c.schoolid, COUNT(*)
|
|
|
|
|
+ FROM t_courses_record r JOIN t_courses c ON c.id = r.coursesid
|
|
|
|
|
+ WHERE r.schoolid <> c.schoolid
|
|
|
|
|
+ GROUP BY 1,2 ORDER BY 3 DESC LIMIT 5`, "记录挂机构=%d 课程属机构=%d → %d 行")
|
|
|
|
|
+ return []CrossOrgStat{st}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// topCombos 执行「不一致组合 TOP N」查询并格式化成样例行。
|
|
|
|
|
+func topCombos(ctx context.Context, db *sql.DB, q, format string) []string {
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, q)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return []string{"<查询失败:" + err.Error() + ">"}
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []string
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var a, b, c, n int64
|
|
|
|
|
+ if err := rows.Scan(&a, &b, &c, &n); err != nil {
|
|
|
|
|
+ return out
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, fmt.Sprintf(format, a, b, c, n))
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// topCombos3 是 topCombos 的 3 列版本(格式串 verb 数与列数必须严格一致)。
|
|
|
|
|
+func topCombos3(ctx context.Context, db *sql.DB, q, format string) []string {
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, q)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return []string{"<查询失败:" + err.Error() + ">"}
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []string
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var a, b, n int64
|
|
|
|
|
+ if err := rows.Scan(&a, &b, &n); err != nil {
|
|
|
|
|
+ return out
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, fmt.Sprintf(format, a, b, n))
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 停用校区影响面 ----------
|
|
|
|
|
+
|
|
|
|
|
+// disabledCampusStats 统计「所选机构里 status=0 的校区」各自挂了多少历史数据。
|
|
|
|
|
+//
|
|
|
|
|
+// 这不是为了"好看":迁过去之后这些校区是 disabled,历史数据在"按启用校区筛选"时看不到,
|
|
|
|
|
+// 而写入侧 assertInScope 会拒绝给停用校区新增数据(这是对的)——
|
|
|
|
|
+// 业务侧会看到"历史数据消失",必须让业务知情。
|
|
|
|
|
+func disabledCampusStats(ctx context.Context, db *sql.DB, opts OrgOptions) ([]DisabledCampusStat, error) {
|
|
|
|
|
+ ph := placeholders(len(opts.InstitutionIDs))
|
|
|
|
|
+ args := make([]any, len(opts.InstitutionIDs))
|
|
|
|
|
+ for i, v := range opts.InstitutionIDs {
|
|
|
|
|
+ args[i] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ q := fmt.Sprintf(`
|
|
|
|
|
+SELECT sc.id, sc.name, sc.school_id,
|
|
|
|
|
+ (SELECT COUNT(*) FROM t_courses x WHERE x.campusid = sc.id),
|
|
|
|
|
+ (SELECT COUNT(*) FROM t_courses_record x WHERE x.campusid = sc.id),
|
|
|
|
|
+ (SELECT COUNT(*) FROM t_user_expense_school x WHERE x.campusid = sc.id),
|
|
|
|
|
+ (SELECT COUNT(*) FROM t_user_balance_request x WHERE x.campusid = sc.id),
|
|
|
|
|
+ (SELECT COUNT(*) FROM t_expenditure_log x WHERE x.campusid = sc.id)
|
|
|
|
|
+ FROM yuansiwei_i.t_school_campus sc
|
|
|
|
|
+ WHERE sc.school_id IN (%s) AND sc.status = 0
|
|
|
|
|
+ ORDER BY sc.id`, ph)
|
|
|
|
|
+ rows, err := db.QueryContext(ctx, q, args...)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("停用校区统计失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []DisabledCampusStat
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var d DisabledCampusStat
|
|
|
|
|
+ if err := rows.Scan(&d.CampusID, &d.Name, &d.InstitutionID, &d.Courses, &d.Records,
|
|
|
|
|
+ &d.ExpenseSchool, &d.BalanceRequest, &d.ExpenditureLog); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, d)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|