소스 검색

feat(ac15): org 域补强——停用校区影响面 + 跨机构拆成三个来源统计

一、停用校区(status=0)单列成表 + 两条硬断言
- 29 个校区里 21 个 status=1、8 个 status=0([3,4,8,9,14,25,58,60])
- 8 个停用校区上挂着:课程 550 / 上课记录 7,734 / 校区账户 460 / 充值工单 890 / 收支记录 201
- 断言1:count(t_campus WHERE status='disabled') == 8 且 id 精确等于 [3,4,8,9,14,25,58,60]
- 断言2:这 8 个没有被改成非 disabled(不许为了"让数据可见"篡改 status)
- status 映射本来就如实写 0,本次只是把它变成可核对的事实

二、跨机构拆成三个来源(原先混成一个列表,读不出是哪一层不一致)
- A 课程级:t_courses.schoolid ↔ 校区真实所属
    总 3142 / 跳过 964(campusid=0) / 一致 1928 / **不一致 250**
- B 记录级:t_courses_record.schoolid ↔ 校区真实所属
    总 74870 / 跳过 1 / 一致 70801 / **不一致 4068**(含反向:记录挂机构 1、校区属 63317)
- C 记录 vs 课程:t_courses_record.schoolid ↔ 其 coursesid 的 schoolid
    总 74870 / 一致 74868 / **不一致 2**
    明细:f296ac36…(schoolid=1, campusid=0, 课程 2933 属 63317)
          f0abeebb…(schoolid=1, campusid=5, 课程 2934 属 63317)—— 同时也是 B 里那条反向记录
- 每类各带 TOP5 不一致组合样例(含行数)

三、README 补两节:停用校区影响面表(指向备忘 7)、跨机构三层对照表

四、核对:verify3 全绿(逐列 7×8 + 29×10、机构集合、孤儿=0、R17、campus_id=0、停用校区两条断言)
          idem3 7→7 / 29→29 id 集合不变;clean3 两表回 0 行
Q 8 시간 전
부모
커밋
8f280d0493

+ 40 - 0
internal/academic/dualrun/fixture/README.md

@@ -27,6 +27,46 @@
 
 最终集合 = `1, 63317, 63323, 63324, 63325, 63330, 63332`(7 个机构 / 29 个校区),孤儿引用 = 0。
 
+### 8 个停用校区上有 7,734 条记录(与备忘 7 同源)
+
+所选 29 个校区里 **21 个 status=1、8 个 status=0**(`[3,4,8,9,14,25,58,60]`)。那 8 个"停用"校区上挂着:
+
+| campus | 名称 | 课程 | 上课记录 | 校区账户 | 充值工单 | 收支记录 |
+|---|---|---|---|---|---|---|
+|3|普罗旺斯校区|299|**4,126**|209|462|52|
+|4|凤岭北校区|82|954|80|114|65|
+|8|华奥校区|0|15|4|4|12|
+|9|雅维思校区|2|128|17|43|32|
+|14|源思维B区|1|10|1|0|0|
+|25|作废|0|0|0|0|0|
+|58|普罗旺斯校区(新)|166|**2,460**|138|239|28|
+|60|新民校区|0|41|11|28|12|
+|**合计**| |**550**|**7,734**|**460**|**890**|**201**|
+
+迁移后这 8 个校区是 `disabled` → 挂在它们上面的 7,734 条上课记录在「按启用校区筛选」时看不到;
+写入侧 `assertInScope` 也会拒绝"给停用校区新增数据"(这是对的)。
+
+**两条硬约束**:
+1. **status 必须如实写 0**,不许为了"让数据可见"改成 1 —— 那是在用数据说谎;
+2. 断言:`count(t_campus WHERE status='disabled') == 8` 且 id 精确等于 `[3,4,8,9,14,25,58,60]`,
+   另加一条「这 8 个没有被改成非 disabled」。
+
+> 业务含义与处置方式见**迁移备忘 7**("按校区筛选看不到历史数据")。
+
+### 跨机构有三个来源,不是三个说法
+
+| 类 | 比较 | 总行数 | 一致 | 不一致 | 性质 |
+|---|---|---|---|---|---|
+|**A** 课程级|`t_courses.schoolid` ↔ 其 `campusid` 的校区真实所属|3142|1928|**250**|R17 主体(校区 1/2 属机构 1,课程普遍挂 63317)|
+|**B** 记录级|`t_courses_record.schoolid` ↔ 其 `campusid` 的校区真实所属|74870|70801|**4068**|含反向跨机构(记录挂机构 1、校区属 63317)|
+|**C** 记录 vs 课程|`t_courses_record.schoolid` ↔ 其 `coursesid` 的 `schoolid`|74870|74868|**2**|与校区无关,纯粹记录挂错机构|
+
+C 的 2 行明细:`f296ac36…`(record.schoolid=1 / campusid=0 / 课程 2933 属 63317)、
+`f0abeebb…`(record.schoolid=1 / campusid=5 / 课程 2934 属 63317)—— 后者同时也是 B 里那条反向记录。
+
+**为什么要分三层**:混在一起报时,"250 / 4068 / 2" 会被读成一个数,看不出到底是"课程挂错机构"
+还是"记录挂错机构"还是"记录与课程不一致"——而 SP 双跑时这三层的处理方式不同。
+
 三条 org 域口径:
 - **R17**:campus 1/2 的 `institution_id` 如实保留 `school_id=1`,**不修正、不反推、不特判**;
   数据里 `(schoolid=63317, campusid=1/2)` 的组合只在报告里标 `cross-org-campus`。

+ 38 - 0
internal/academic/dualrun/fixture/batch3_model.go

@@ -114,6 +114,44 @@ type RefSource struct {
 	Note      string
 }
 
+// DisabledCampusStat 是一个**停用校区**(status=0)的影响面。
+//
+// 这一块单列的理由:迁过去之后这些校区是 `disabled`,挂在它们上面的历史数据
+// 在"按启用校区筛选"时看不到;而 `assertInScope` 会拒绝"给停用校区新增数据"(正确)。
+// 两者叠加 = 业务侧会看到"历史数据消失",必须让业务知情,不能靠"把 status 改成 1"掩盖。
+type DisabledCampusStat struct {
+	CampusID       int64
+	Name           string
+	InstitutionID  int64
+	Courses        int64
+	Records        int64
+	ExpenseSchool  int64
+	BalanceRequest int64
+	ExpenditureLog int64
+}
+
+// CrossOrgStat 是**一类**跨机构统计。
+//
+// 跨机构有三个不同层次的来源,混在一起报会让人看不出"到底是哪一层不一致":
+//
+//	A 课程级:t_courses.schoolid          vs 该课程 campusid 的校区真实所属
+//	B 记录级:t_courses_record.schoolid   vs 该记录 campusid 的校区真实所属
+//	C 记录 vs 课程:t_courses_record.schoolid vs 其 coursesid 对应课程的 schoolid
+//
+// A/B 是"数据挂的机构 vs 校区真实所属",C 是"记录与课程的机构是否一致"(性质不同:
+// 即使校区所属一致,记录也可能挂到了另一个机构下)。
+type CrossOrgStat struct {
+	Key          string // A / B / C
+	Name         string
+	Source       string // 参与比较的两列
+	Total        int64
+	Skipped      int64 // campusid=0(机构级)或 join 不到,不参与判定
+	Consistent   int64
+	Inconsistent int64
+	Samples      []string
+	Note         string
+}
+
 // RefStat 是一个来源的引用统计结果。
 type RefStat struct {
 	Source           string

+ 31 - 4
internal/academic/dualrun/fixture/batch3_run.go

@@ -282,13 +282,40 @@ func PrintOrgVerify(w io.Writer, r *OrgVerifyReport) {
 		}
 	}
 
-	if len(r.CrossOrg) > 0 {
-		fmt.Fprintf(w, "\n== R17 标注:cross-org-campus(只标注,不修正)==\n")
-		for _, c := range r.CrossOrg {
-			fmt.Fprintf(w, "  · %s\n", c)
+	fmt.Fprintf(w, "\n== 跨机构:三个来源分别统计(只标注,不修正)==\n")
+	for _, c := range r.CrossOrg {
+		fmt.Fprintf(w, "\n  【%s】%s\n", c.Key, c.Name)
+		fmt.Fprintf(w, "     比较:%s\n", c.Source)
+		fmt.Fprintf(w, "     总行数 %d;跳过(campusid=0 / 校区不存在)%d;**一致 %d;不一致 %d**\n",
+			c.Total, c.Skipped, c.Consistent, c.Inconsistent)
+		if c.Note != "" {
+			fmt.Fprintf(w, "     说明:%s\n", c.Note)
+		}
+		for _, sp := range c.Samples {
+			fmt.Fprintf(w, "       · %s\n", sp)
 		}
 	}
 
+	fmt.Fprintf(w, "\n== 停用校区(status=0)影响面:迁移后这些校区是 disabled,历史数据在「按启用校区筛选」时看不到 ==\n")
+	fmt.Fprintf(w, "  %-6s %-20s %-12s %-8s %-10s %-12s %-10s %s\n",
+		"campus", "名称", "所属机构", "课程", "上课记录", "校区账户", "充值工单", "收支记录")
+	var tc, tr, te, tb, tl int64
+	for _, d := range r.Disabled {
+		fmt.Fprintf(w, "  %-6d %-20s %-12d %-8d %-10d %-12d %-10d %d\n",
+			d.CampusID, truncate(d.Name, 18), d.InstitutionID, d.Courses, d.Records,
+			d.ExpenseSchool, d.BalanceRequest, d.ExpenditureLog)
+		tc += d.Courses
+		tr += d.Records
+		te += d.ExpenseSchool
+		tb += d.BalanceRequest
+		tl += d.ExpenditureLog
+	}
+	fmt.Fprintf(w, "  %-6s %-20s %-12s %-8d %-10d %-12d %-10d %d\n",
+		"合计", fmt.Sprintf("(%d 个校区)", len(r.Disabled)), "", tc, tr, te, tb, tl)
+	fmt.Fprintf(w, "  ⚠ 这 %d 个校区迁移后 status=disabled;上面 %d 条上课记录在「按启用校区」筛选里看不到。\n",
+		len(r.Disabled), tr)
+	fmt.Fprintf(w, "  ⚠ 不允许为了「让数据可见」把它们的 status 改成 active(见断言)。\n")
+
 	fmt.Fprintf(w, "\n== 语义断言 ==\n")
 	for _, a := range r.Assertions {
 		st := markOK

+ 194 - 22
internal/academic/dualrun/fixture/batch3_verify.go

@@ -21,7 +21,8 @@ type OrgVerifyReport struct {
 	Cols       []ColumnAgg
 	Assertions []Assertion
 	RefStats   []RefStat
-	CrossOrg   []string // R17:数据里 (schoolid, campusid) 与校区真实所属机构不一致的组合
+	CrossOrg   []CrossOrgStat       // 跨机构三类统计(A 课程级 / B 记录级 / C 记录 vs 课程)
+	Disabled   []DisabledCampusStat // 停用校区(status=0)的影响面
 	TargetInst int64
 	TargetCamp int64
 	Warnings   []string
@@ -358,32 +359,59 @@ SELECT DISTINCT s.`+"`%s`"+` FROM `+"`%s`"+` s
 		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
 		}
-		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
 }
 
@@ -426,3 +454,147 @@ func SnapshotOrg(ctx context.Context, cfg Config) (map[int64]string, map[int64]s
 	rows2.Close()
 	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()
+}