|
|
@@ -0,0 +1,336 @@
|
|
|
+package stage2
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "fmt"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
+
|
|
|
+ "yuansiwei/unified-backend/internal/academic/dualrun/fixture"
|
|
|
+)
|
|
|
+
|
|
|
+// 本文件是**课酬应收核销**(`sp_courses_student_counteract_arrears_course`)的两侧种子。
|
|
|
+//
|
|
|
+// 为什么要单独一个文件、而不是并进 `c1_activity.go`:
|
|
|
+// 活动域的种子是"一张活动矩阵 + 报名行",这里的种子是"两条**互为键**的行"
|
|
|
+// (`t_courses_student` 靠 `(coursesid,classid)` JOIN `t_courses_class`)。
|
|
|
+// 两者唯一的共同点是"都要在 before 快照之前种下去",逻辑没有交集。
|
|
|
+//
|
|
|
+// 三个硬约束(都来自 §21 的实测,不是约定):
|
|
|
+//
|
|
|
+// 1. **两侧都要种**:target 库整库为空,比对锚是 `biz_no`。只种一侧 ⇒ 另一侧读不到 ⇒
|
|
|
+// `mustCover` FAIL(这是对的,见 §21.3)。
|
|
|
+// 2. **同一学生下 `start_time` 必须两两不同**:legacy 的 `ORDER BY t2.start_time ASC`
|
|
|
+// **没有 tie-break**,target 是 `first_start, t1.id`。并列时两侧的循环顺序可能不同,
|
|
|
+// 而顺序决定"钱落到哪一行" ⇒ 并列等于不可复现(§21.3 实测真实数据 87.5% 并列)。
|
|
|
+// 3. **`(coursesid, classid)` 必须与 legacy 真实数据不撞**:撞了 JOIN 会扇出,
|
|
|
+// 而扇出正好是 RA-2 要单独验的形态 —— 不能在别的 case 里偷偷发生。
|
|
|
+// 所以种子用 9xxxxx 高位号段,并在插入前**显式查一次**(撞了就报错,不静默)。
|
|
|
+
|
|
|
+// ArrearsSeed 是一组"欠费报课行"的声明。
|
|
|
+//
|
|
|
+// 语义:每一行 = 一条 `t_courses_student` + 它对应的 `t_courses_class` 排班行。
|
|
|
+type ArrearsSeed struct {
|
|
|
+ Rows []ArrearsSeedRow
|
|
|
+}
|
|
|
+
|
|
|
+// ArrearsSeedRow 是一条报课行(`t_courses_student` 一行)。
|
|
|
+type ArrearsSeedRow struct {
|
|
|
+ // BizNo 是 `t_courses_student` 的业务号,两侧**同一个字符串**。
|
|
|
+ //
|
|
|
+ // legacy 落 `id`(varchar(32),旧 UUID 空间),target 落 `biz_no`。
|
|
|
+ // 快照比对就以它为锚(§19:不能用 target 的自增 `id`,两侧不可比)。
|
|
|
+ BizNo string
|
|
|
+ // CoursesID / ClassID 是 JOIN 键(**两个都要相等**才算同一门课的排班)。
|
|
|
+ //
|
|
|
+ // ⚠ 它们不是 `t_courses_class.id`:SP 的 JOIN 条件是
|
|
|
+ // `t1.coursesid=t2.coursesid AND t1.classid=t2.classid`,所以两侧的 class 行
|
|
|
+ // 自增 id 不需要对齐(§21.2)。
|
|
|
+ CoursesID int64
|
|
|
+ ClassID int64
|
|
|
+ // StartTime 是首条排班的上课时间,格式 `2006-01-02 15:04:05`(整秒,两侧同精度读回)。
|
|
|
+ StartTime string
|
|
|
+ // ExtraStartTimes 是**同一 `(CoursesID, ClassID)` 的额外排班行**(RA-2 专用)。
|
|
|
+ //
|
|
|
+ // 非空 ⇒ 该 `(courses,class)` 对在 `t_courses_class` 里有多行 ⇒ legacy 游标会
|
|
|
+ // **把同一条报课行取出多次**(JOIN 扇出)。target 用 `MIN(start_time)` 折平,只取一次。
|
|
|
+ // 这是 `defensive flattening`(证据强度 B:schema 合法、真实数据 0/6173),
|
|
|
+ // 不是"已知缺陷"—— 见 §21.6 与 F21。
|
|
|
+ ExtraStartTimes []string
|
|
|
+ // RemainderReceivableCents 是剩余应收(分)。SP 只减不加。
|
|
|
+ RemainderReceivableCents int64
|
|
|
+ // NetReceiptsCents 是已实收(分)。SP 只加不减。
|
|
|
+ NetReceiptsCents int64
|
|
|
+ // ConfirmDiscountUserID 是"确认学生折扣的用户"(legacy int / target ULID)。
|
|
|
+ //
|
|
|
+ // **0 = 没有确认人**:legacy 落 0,target 落 NULL。
|
|
|
+ // 游标的 `confirm_discount_userid>0` 会把这种行排除掉 —— 这是本 case 要测的两个过滤条件之一。
|
|
|
+ ConfirmDiscountUserID int64
|
|
|
+}
|
|
|
+
|
|
|
+// classStartTimes 返回该行要插的**全部**排班时间(首条 + 额外)。
|
|
|
+func (r ArrearsSeedRow) classStartTimes() []string {
|
|
|
+ out := make([]string, 0, 1+len(r.ExtraStartTimes))
|
|
|
+ out = append(out, r.StartTime)
|
|
|
+ out = append(out, r.ExtraStartTimes...)
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsDeclaredRows 返回声明的报课行数(`t_courses_student` 行数)。
|
|
|
+func arrearsDeclaredRows(c Case) int {
|
|
|
+ if c.Arrears == nil {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ return len(c.Arrears.Rows)
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsExpectedSnapshotRows 返回**快照期望行数**(含 JOIN 扇出)。
|
|
|
+//
|
|
|
+// ⚠ 它和 `arrearsDeclaredRows` 在 RA-2 上**不相等**:`readArrears*` 是
|
|
|
+// `LEFT JOIN t_courses_class`,同一报课行有几条排班就出现几次。
|
|
|
+// `mustCover` 要用这个数,否则 RA-2 会因为"多出来两行"而假红 ——
|
|
|
+// 而如果为了消红把判据放宽成 `n > 0`,就等于放弃了"条数对得上"这条检出能力。
|
|
|
+func arrearsExpectedSnapshotRows(c Case) int {
|
|
|
+ if c.Arrears == nil {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ n := 0
|
|
|
+ for _, r := range c.Arrears.Rows {
|
|
|
+ n += len(r.classStartTimes())
|
|
|
+ }
|
|
|
+ return n
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsSnapshotRowsOrDefault 返回 `mustCover` 该用的期望条数:
|
|
|
+// 课酬 case 用"含扇出的快照行数",其它走 `studentBizNos`(活动链沿用旧口径,不改)。
|
|
|
+func arrearsSnapshotRowsOrDefault(c Case) int {
|
|
|
+ if c.Arrears != nil {
|
|
|
+ return arrearsExpectedSnapshotRows(c)
|
|
|
+ }
|
|
|
+ return len(studentBizNos(c))
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsDeclaredRowsOrDefault 返回报告文案里的"声明条数"(不含扇出)。
|
|
|
+func arrearsDeclaredRowsOrDefault(c Case) int {
|
|
|
+ if c.Arrears != nil {
|
|
|
+ return arrearsDeclaredRows(c)
|
|
|
+ }
|
|
|
+ return len(studentBizNos(c))
|
|
|
+}
|
|
|
+
|
|
|
+// validateArrearsSeed 在**动库之前**把种子的自洽性查一遍。
|
|
|
+//
|
|
|
+// 为什么要在 Go 里查、不靠 MySQL 报错:
|
|
|
+// - `start_time` 并列**不会**报错,只会让顺序不确定 ⇒ 必须自己拒绝;
|
|
|
+// - `BizNo` 空会让两侧的锚都变成空串,快照照样"相等"(都是空)⇒ 必须自己拒绝。
|
|
|
+func validateArrearsSeed(c Case) error {
|
|
|
+ if c.Arrears == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if len(c.Arrears.Rows) == 0 {
|
|
|
+ return fmt.Errorf("case %q 声明了 Arrears 但没有任何行", c.Name)
|
|
|
+ }
|
|
|
+ if c.LegacyStudentID <= 0 {
|
|
|
+ return fmt.Errorf("case %q 声明了 Arrears 但 LegacyStudentID=%d(必须 >0)",
|
|
|
+ c.Name, c.LegacyStudentID)
|
|
|
+ }
|
|
|
+ seenBiz := make(map[string]bool, len(c.Arrears.Rows))
|
|
|
+ seenStart := make(map[string]string, len(c.Arrears.Rows))
|
|
|
+ var prevStart time.Time
|
|
|
+ for i, r := range c.Arrears.Rows {
|
|
|
+ if strings.TrimSpace(r.BizNo) == "" {
|
|
|
+ return fmt.Errorf("case %q 第 %d 行 BizNo 为空(锚不能是空串)", c.Name, i)
|
|
|
+ }
|
|
|
+ if seenBiz[r.BizNo] {
|
|
|
+ return fmt.Errorf("case %q 第 %d 行 BizNo=%q 重复", c.Name, i, r.BizNo)
|
|
|
+ }
|
|
|
+ seenBiz[r.BizNo] = true
|
|
|
+ if r.CoursesID <= 0 || r.ClassID <= 0 {
|
|
|
+ return fmt.Errorf("case %q 第 %d 行 (coursesid,classid)=(%d,%d) 必须都 >0",
|
|
|
+ c.Name, i, r.CoursesID, r.ClassID)
|
|
|
+ }
|
|
|
+ if r.RemainderReceivableCents < 0 {
|
|
|
+ return fmt.Errorf("case %q 第 %d 行 remainder=%d 为负;legacy 列是 "+
|
|
|
+ "decimal(10,3) **unsigned**,存不下负数", c.Name, i, r.RemainderReceivableCents)
|
|
|
+ }
|
|
|
+ for _, st := range r.classStartTimes() {
|
|
|
+ t, err := parseArrearsTime(st)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("case %q 第 %d 行 start_time=%q 解析失败:%w", c.Name, i, st, err)
|
|
|
+ }
|
|
|
+ key := t.Format("2006-01-02 15:04:05")
|
|
|
+ if prev, dup := seenStart[key]; dup {
|
|
|
+ // 并列 ⇒ legacy 侧顺序由执行计划决定 ⇒ 本 case 不可复现(§21.3)。
|
|
|
+ return fmt.Errorf("case %q 里 start_time=%q 出现两次(%s 与第 %d 行):"+
|
|
|
+ "legacy `ORDER BY t2.start_time ASC` **没有 tie-break**,并列时顺序不确定 ⇒ 不可复现",
|
|
|
+ c.Name, key, prev, i)
|
|
|
+ }
|
|
|
+ // 声明顺序必须**严格升序**:`arrearsExpectedDeltas` 是按声明顺序模拟游标的,
|
|
|
+ // 声明顺序错了,期望值就会算到另一行头上 —— 而那种红看起来像实现错。
|
|
|
+ if !prevStart.IsZero() && !t.After(prevStart) {
|
|
|
+ return fmt.Errorf("case %q 第 %d 行 start_time=%q 没有严格晚于上一行的 %q:"+
|
|
|
+ "种子必须按游标顺序(start_time ASC)声明,否则从声明推期望会算错行",
|
|
|
+ c.Name, i, key, prevStart.Format("2006-01-02 15:04:05"))
|
|
|
+ }
|
|
|
+ prevStart = t
|
|
|
+ seenStart[key] = fmt.Sprintf("第 %d 行 %s", i, r.BizNo)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// parseArrearsTime 解析排班时间(整秒,两侧都按 `%Y-%m-%d %H:%i:%s` 读回)。
|
|
|
+func parseArrearsTime(s string) (time.Time, error) {
|
|
|
+ return time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(s), time.Local)
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsMoney 把分转成两位小数字符串。
|
|
|
+//
|
|
|
+// 为什么是 2 位而不是 3 位:target 那两列是 `decimal(12,2)`,写 3 位会被 MySQL 静默收敛;
|
|
|
+// 而我们的种子全是整分,2 位已无损。**这与"读回用 2 位"是同一件事的两端**,见
|
|
|
+// `activity_snapshot.go` 的 `PRECISION-CONVERGENCE` 注释。
|
|
|
+func arrearsMoney(cents int64) string {
|
|
|
+ sign := ""
|
|
|
+ if cents < 0 {
|
|
|
+ sign, cents = "-", -cents
|
|
|
+ }
|
|
|
+ return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsCreateTime 是种子的 `create_time`(固定值,保证两侧与多次运行逐列可比)。
|
|
|
+const arrearsCreateTime = "2001-01-01 00:00:00"
|
|
|
+
|
|
|
+// seedLegacyArrears 在 legacy 事务内种 `t_courses_class` + `t_courses_student`。
|
|
|
+//
|
|
|
+// 必须在 before 快照**之前**调用,否则 before 读不到这些行,"变化量"会算错。
|
|
|
+func seedLegacyArrears(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
+ if c.Arrears == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if err := validateArrearsSeed(c); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := guardArrearsPairsUnused(ctx, tx, c, "legacy", `
|
|
|
+SELECT COUNT(*) FROM t_courses_class WHERE coursesid = ? AND classid = ?`); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ for _, r := range c.Arrears.Rows {
|
|
|
+ for _, st := range r.classStartTimes() {
|
|
|
+ t, err := parseArrearsTime(st)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ // `end_time` 在 legacy 是 NOT NULL 无默认 ⇒ 必须给;「上课 1.5 小时」只是个
|
|
|
+ // 不会参与任何判据的占位值(游标只读 start_time)。
|
|
|
+ if err := tx.WithContext(ctx).Exec(
|
|
|
+ `INSERT INTO t_courses_class (coursesid, classid, start_time, end_time) VALUES (?, ?, ?, ?)`,
|
|
|
+ r.CoursesID, r.ClassID, t.Format("2006-01-02 15:04:05"),
|
|
|
+ t.Add(90*time.Minute).Format("2006-01-02 15:04:05")).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 legacy t_courses_class 失败(course=%d class=%d start=%s):%w",
|
|
|
+ r.CoursesID, r.ClassID, st, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // legacy 的金额列是 NOT NULL **无默认**(receivable/net_receipts/unpaid/lesson_cost/
|
|
|
+ // discount_cash/remainder_cost/remainder_receivable 共 7 列)⇒ 必须逐个给值。
|
|
|
+ // `unpaid` 与 `lesson_cost` / `discount_cash` / `remainder_cost` SP 都不碰,给 0 即可
|
|
|
+ // —— 但它们仍要写,否则 INSERT 报 1364。
|
|
|
+ if err := tx.WithContext(ctx).Exec(`
|
|
|
+INSERT INTO t_courses_student
|
|
|
+ (id, coursesid, classid, schoolid, campusid, studentid, receivable, net_receipts, unpaid,
|
|
|
+ lesson_cost, discount_cash, remainder_cost, remainder_receivable, confirm_discount_userid,
|
|
|
+ number, status, type, create_time)
|
|
|
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
+ r.BizNo, r.CoursesID, r.ClassID, c.InstitutionID, c.CampusID, c.LegacyStudentID,
|
|
|
+ arrearsMoney(r.RemainderReceivableCents), arrearsMoney(r.NetReceiptsCents),
|
|
|
+ "0.00", "0.00", "0.00", "0.00", arrearsMoney(r.RemainderReceivableCents),
|
|
|
+ r.ConfirmDiscountUserID, 0, 1, 1, arrearsCreateTime).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 legacy t_courses_student 失败(biz_no=%s):%w", r.BizNo, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// seedTargetArrears 在 target 事务内种同一批行(`biz_no` 两侧同值)。
|
|
|
+func seedTargetArrears(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
+ if c.Arrears == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if err := validateArrearsSeed(c); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := guardArrearsPairsUnused(ctx, tx, c, "target", `
|
|
|
+SELECT COUNT(*) FROM t_courses_class WHERE courses_id = ? AND class_id = ?`); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ studentUID := fixture.FixtureCanonicalStudentID(c.LegacyStudentID)
|
|
|
+ for _, r := range c.Arrears.Rows {
|
|
|
+ for _, st := range r.classStartTimes() {
|
|
|
+ t, err := parseArrearsTime(st)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := tx.WithContext(ctx).Exec(
|
|
|
+ `INSERT INTO t_courses_class (courses_id, class_id, start_time, end_time) VALUES (?, ?, ?, ?)`,
|
|
|
+ r.CoursesID, r.ClassID, t.Format("2006-01-02 15:04:05"),
|
|
|
+ t.Add(90*time.Minute).Format("2006-01-02 15:04:05")).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 target t_courses_class 失败(course=%d class=%d start=%s):%w",
|
|
|
+ r.CoursesID, r.ClassID, st, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // `confirm_discount_user_id` 是 **ULID 字符串且可空**:
|
|
|
+ // 0(legacy 的"没有确认人")⇒ target 落 **NULL**(不是空串、不是 "0")。
|
|
|
+ // 空串与 NULL 在 target 的查询里都被判成"无确认人",但落库值不同,
|
|
|
+ // 用 NULL 与迁移口径一致(迁移器对 0 的处理就是 NULL)。
|
|
|
+ var confirm any
|
|
|
+ if r.ConfirmDiscountUserID > 0 {
|
|
|
+ confirm = fixture.FixtureCanonicalUserID(r.ConfirmDiscountUserID)
|
|
|
+ }
|
|
|
+ if err := tx.WithContext(ctx).Exec(`
|
|
|
+INSERT INTO t_courses_student
|
|
|
+ (biz_no, courses_id, class_id, institution_id, campus_id, student_id, receivable, net_receipts,
|
|
|
+ unpaid, lesson_cost, discount_cash, remainder_cost, remainder_receivable,
|
|
|
+ confirm_discount_user_id, number, status, type, create_time)
|
|
|
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
+ r.BizNo, r.CoursesID, r.ClassID, c.InstitutionID, c.CampusID, studentUID,
|
|
|
+ arrearsMoney(r.RemainderReceivableCents), arrearsMoney(r.NetReceiptsCents),
|
|
|
+ "0.00", "0.00", "0.00", "0.00", arrearsMoney(r.RemainderReceivableCents),
|
|
|
+ confirm, 0, 1, 1, arrearsCreateTime).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 target t_courses_student 失败(biz_no=%s):%w", r.BizNo, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// guardArrearsPairsUnused 确认本 case 要用的 `(coursesid, classid)` 在库里**还不存在**。
|
|
|
+//
|
|
|
+// 为什么必须查(不能只靠"用高位号段"这个约定):
|
|
|
+//
|
|
|
+// `t_courses_class` 没有 `(coursesid,classid)` 唯一索引(§21.6 实测:6173 行 0 组重复,
|
|
|
+// 是**数据**层面的唯一,不是 schema 层面的)。一旦撞上真实行,JOIN 立刻扇出,
|
|
|
+// 而扇出正是 RA-2 要单独验的形态 —— 撞在别的 case 里会让那条 case 悄悄变成第二条 RA-2,
|
|
|
+// 并且没人知道为什么钱多核了一份。
|
|
|
+//
|
|
|
+// 参数:countSQL 必须是 `SELECT COUNT(*) ... WHERE <pair>` 的单条语句(两侧列名不同)。
|
|
|
+func guardArrearsPairsUnused(ctx context.Context, tx *gorm.DB, c Case, side, countSQL string) error {
|
|
|
+ seen := make(map[[2]int64]bool, len(c.Arrears.Rows))
|
|
|
+ for _, r := range c.Arrears.Rows {
|
|
|
+ pair := [2]int64{r.CoursesID, r.ClassID}
|
|
|
+ if seen[pair] {
|
|
|
+ continue // 同一对多条排班是 RA-2 的**声明意图**,由 validate 的 start_time 唯一性保证
|
|
|
+ }
|
|
|
+ seen[pair] = true
|
|
|
+ var n int64
|
|
|
+ if err := tx.WithContext(ctx).Raw(countSQL, r.CoursesID, r.ClassID).Scan(&n).Error; err != nil {
|
|
|
+ return fmt.Errorf("查 %s 侧 (course=%d class=%d) 是否已存在失败:%w",
|
|
|
+ side, r.CoursesID, r.ClassID, err)
|
|
|
+ }
|
|
|
+ if n > 0 {
|
|
|
+ return fmt.Errorf("%s 侧 (coursesid,classid)=(%d,%d) 已有 %d 行排班:"+
|
|
|
+ "JOIN 会扇出,本 case 的期望值不再成立。换一对号段,或删掉这条 case",
|
|
|
+ side, r.CoursesID, r.ClassID, n)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|