|
|
@@ -3,6 +3,7 @@ package stage2
|
|
|
import (
|
|
|
"context"
|
|
|
"fmt"
|
|
|
+ "sort"
|
|
|
"strings"
|
|
|
"time"
|
|
|
|
|
|
@@ -33,7 +34,53 @@ import (
|
|
|
//
|
|
|
// 语义:每一行 = 一条 `t_courses_student` + 它对应的 `t_courses_class` 排班行。
|
|
|
type ArrearsSeed struct {
|
|
|
- Rows []ArrearsSeedRow
|
|
|
+ // ByUser 是**唯一**真相源:user legacy id → 该用户的欠费行。
|
|
|
+ //
|
|
|
+ // 为什么不保留一个 `Rows` 兼容字段:两个字段能表达同一件事,
|
|
|
+ // 早晚有人只填一个、另一个空着,而且**不会有任何东西报错**
|
|
|
+ //(`ActivitySnapshot.Students` 那次就是有序/无序两个真相源,后来删掉了)。
|
|
|
+ //
|
|
|
+ // 为什么必须是"按用户"而不是"平铺 + 一个 LegacyStudentID":
|
|
|
+ // · 课酬核销(RA-1/RA-2)是**单用户**,`inputMoney` 只有一份;
|
|
|
+ // · 老带新(W4-C)**两个用户各调一次** `counteract_arrears_course`,
|
|
|
+ // 各自按各自的 `activityMoney` 上限核销 —— 平铺会把两个人的行混成一个候选集。
|
|
|
+ ByUser map[int64][]ArrearsSeedRow
|
|
|
+}
|
|
|
+
|
|
|
+// userIDs 返回声明过的用户(**升序**,保证遍历确定)。
|
|
|
+func (s *ArrearsSeed) userIDs() []int64 {
|
|
|
+ if s == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ out := make([]int64, 0, len(s.ByUser))
|
|
|
+ for u := range s.ByUser {
|
|
|
+ out = append(out, u)
|
|
|
+ }
|
|
|
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+// flatRows 把"用户 + 行"摊平,顺序 = 用户升序 × 该用户内的声明顺序。
|
|
|
+//
|
|
|
+// 摊平只用于"要遍历所有行"的场景(数条数、校验、落库);
|
|
|
+// **按用户的期望值**必须用 `ByUser[u]` 单独算 —— 否则两个人的 `inputMoney` 会串。
|
|
|
+func (s *ArrearsSeed) flatRows() []arrearsSeedEntry {
|
|
|
+ if s == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ var out []arrearsSeedEntry
|
|
|
+ for _, u := range s.userIDs() {
|
|
|
+ for _, r := range s.ByUser[u] {
|
|
|
+ out = append(out, arrearsSeedEntry{User: u, Row: r})
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+// arrearsSeedEntry 是"某用户的一行欠费"。
|
|
|
+type arrearsSeedEntry struct {
|
|
|
+ User int64
|
|
|
+ Row ArrearsSeedRow
|
|
|
}
|
|
|
|
|
|
// ArrearsSeedRow 是一条报课行(`t_courses_student` 一行)。
|
|
|
@@ -83,7 +130,7 @@ func arrearsDeclaredRows(c Case) int {
|
|
|
if c.Arrears == nil {
|
|
|
return 0
|
|
|
}
|
|
|
- return len(c.Arrears.Rows)
|
|
|
+ return len(c.Arrears.flatRows())
|
|
|
}
|
|
|
|
|
|
// arrearsExpectedSnapshotRows 返回**快照期望行数**(含 JOIN 扇出)。
|
|
|
@@ -97,8 +144,8 @@ func arrearsExpectedSnapshotRows(c Case) int {
|
|
|
return 0
|
|
|
}
|
|
|
n := 0
|
|
|
- for _, r := range c.Arrears.Rows {
|
|
|
- n += len(r.classStartTimes())
|
|
|
+ for _, e := range c.Arrears.flatRows() {
|
|
|
+ n += len(e.Row.classStartTimes())
|
|
|
}
|
|
|
return n
|
|
|
}
|
|
|
@@ -129,17 +176,43 @@ func validateArrearsSeed(c Case) error {
|
|
|
if c.Arrears == nil {
|
|
|
return nil
|
|
|
}
|
|
|
- if len(c.Arrears.Rows) == 0 {
|
|
|
+ if len(c.Arrears.flatRows()) == 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 {
|
|
|
+ // ByUser 的 key 必须是**这条 Op 的 CALL 会真正传出去的那个 id** ——
|
|
|
+ // 否则"种了一个谁都不读的用户的欠费行"会静默存在:行种进去了、快照也读到了,
|
|
|
+ // 但没有任何一次 CALL 会看它。
|
|
|
+ //
|
|
|
+ // 注意这里**不是**"随便哪个声明过的 user 都行":两个 Op 的第一个实参根本不是同一种东西 ——
|
|
|
+ // · `sp_courses_student_counteract_arrears_course(studentid, …)` → **学生** id(RA-1/RA-2 用 LegacyStudentID)
|
|
|
+ // · `sp_courses_activity_use_activity_recommend(userid, …)` → 它内部把这个值当 studentid 用(W4 用 LegacyUserID / RecommendUserID)
|
|
|
+ // 所以合法集合**按 Op 算**。我第一版写成"LegacyUserID 或 RecommendUserID",
|
|
|
+ // 当场把 RA-1 判红 —— 那条错正好证明这条校验在干活。
|
|
|
+ allowed := arrearsCallUserIDs(c)
|
|
|
+ for _, u := range c.Arrears.userIDs() {
|
|
|
+ ok := false
|
|
|
+ for _, a := range allowed {
|
|
|
+ if u == a && a > 0 {
|
|
|
+ ok = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if !ok {
|
|
|
+ return fmt.Errorf("case %q(Op=%s)的 Arrears.ByUser 含 id=%d,但本 case 的 CALL 只会传 %v —— "+
|
|
|
+ "种了也不会被任何一次 CALL 读到", c.Name, c.Op, u, allowed)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ seenBiz := make(map[string]bool, len(c.Arrears.flatRows()))
|
|
|
+ seenStart := make(map[string]string, len(c.Arrears.flatRows()))
|
|
|
+ // start_time 的升序**按用户各自校验**:游标带 `studentid=?`,两个用户的排班时间
|
|
|
+ // 互不影响,跨用户比较会报出假错。
|
|
|
+ prevStart := make(map[int64]time.Time, len(c.Arrears.ByUser))
|
|
|
+ for i, e := range c.Arrears.flatRows() {
|
|
|
+ r := e.Row
|
|
|
if strings.TrimSpace(r.BizNo) == "" {
|
|
|
return fmt.Errorf("case %q 第 %d 行 BizNo 为空(锚不能是空串)", c.Name, i)
|
|
|
}
|
|
|
@@ -169,12 +242,13 @@ func validateArrearsSeed(c Case) error {
|
|
|
}
|
|
|
// 声明顺序必须**严格升序**:`arrearsExpectedDeltas` 是按声明顺序模拟游标的,
|
|
|
// 声明顺序错了,期望值就会算到另一行头上 —— 而那种红看起来像实现错。
|
|
|
- if !prevStart.IsZero() && !t.After(prevStart) {
|
|
|
- return fmt.Errorf("case %q 第 %d 行 start_time=%q 没有严格晚于上一行的 %q:"+
|
|
|
+ prev := prevStart[e.User]
|
|
|
+ if !prev.IsZero() && !t.After(prev) {
|
|
|
+ return fmt.Errorf("case %q(user=%d)第 %d 行 start_time=%q 没有严格晚于上一行的 %q:"+
|
|
|
"种子必须按游标顺序(start_time ASC)声明,否则从声明推期望会算错行",
|
|
|
- c.Name, i, key, prevStart.Format("2006-01-02 15:04:05"))
|
|
|
+ c.Name, e.User, i, key, prev.Format("2006-01-02 15:04:05"))
|
|
|
}
|
|
|
- prevStart = t
|
|
|
+ prevStart[e.User] = t
|
|
|
seenStart[key] = fmt.Sprintf("第 %d 行 %s", i, r.BizNo)
|
|
|
}
|
|
|
}
|
|
|
@@ -185,17 +259,18 @@ func validateArrearsSeed(c Case) error {
|
|
|
// 它就**静默通过**了,而实际上我们**从未记录过**这一行的观测值。
|
|
|
// 这正是 §18.3 第 0 条的同族:断言键写错 ⇒ 断言块整体"成立"但毫无意义。
|
|
|
if c.LegacyObservedNetDelta != nil {
|
|
|
- for _, r := range c.Arrears.Rows {
|
|
|
+ for _, e := range c.Arrears.flatRows() {
|
|
|
+ r := e.Row
|
|
|
if _, ok := c.LegacyObservedNetDelta[r.BizNo]; !ok {
|
|
|
return fmt.Errorf("case %q 的 LegacyObservedNetDelta 缺少 biz_no=%q:"+
|
|
|
"tripwire 必须覆盖**全部**声明行(缺键会取到零值 0,等于静默把期望写成 0)",
|
|
|
c.Name, r.BizNo)
|
|
|
}
|
|
|
}
|
|
|
- if len(c.LegacyObservedNetDelta) != len(c.Arrears.Rows) {
|
|
|
+ if len(c.LegacyObservedNetDelta) != len(c.Arrears.flatRows()) {
|
|
|
return fmt.Errorf("case %q 的 LegacyObservedNetDelta 有 %d 个键,但声明了 %d 行报课"+
|
|
|
"(多出来的键说明有 case 没删干净,留着会让人以为还有一行没被覆盖)",
|
|
|
- c.Name, len(c.LegacyObservedNetDelta), len(c.Arrears.Rows))
|
|
|
+ c.Name, len(c.LegacyObservedNetDelta), len(c.Arrears.flatRows()))
|
|
|
}
|
|
|
}
|
|
|
return nil
|
|
|
@@ -236,7 +311,8 @@ func seedLegacyArrears(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
SELECT COUNT(*) FROM t_courses_class WHERE coursesid = ? AND classid = ?`); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- for _, r := range c.Arrears.Rows {
|
|
|
+ for _, e := range c.Arrears.flatRows() {
|
|
|
+ r := e.Row
|
|
|
for _, st := range r.classStartTimes() {
|
|
|
t, err := parseArrearsTime(st)
|
|
|
if err != nil {
|
|
|
@@ -262,7 +338,7 @@ INSERT INTO t_courses_student
|
|
|
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,
|
|
|
+ r.BizNo, r.CoursesID, r.ClassID, c.InstitutionID, c.CampusID, e.User,
|
|
|
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 {
|
|
|
@@ -285,7 +361,8 @@ SELECT COUNT(*) FROM t_courses_class WHERE courses_id = ? AND class_id = ?`); er
|
|
|
return err
|
|
|
}
|
|
|
studentUID := fixture.FixtureCanonicalStudentID(c.LegacyStudentID)
|
|
|
- for _, r := range c.Arrears.Rows {
|
|
|
+ for _, e := range c.Arrears.flatRows() {
|
|
|
+ r := e.Row
|
|
|
for _, st := range r.classStartTimes() {
|
|
|
t, err := parseArrearsTime(st)
|
|
|
if err != nil {
|
|
|
@@ -334,8 +411,9 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
//
|
|
|
// 参数: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 {
|
|
|
+ seen := make(map[[2]int64]bool, len(c.Arrears.flatRows()))
|
|
|
+ for _, e := range c.Arrears.flatRows() {
|
|
|
+ r := e.Row
|
|
|
pair := [2]int64{r.CoursesID, r.ClassID}
|
|
|
if seen[pair] {
|
|
|
continue // 同一对多条排班是 RA-2 的**声明意图**,由 validate 的 start_time 唯一性保证
|
|
|
@@ -354,3 +432,22 @@ func guardArrearsPairsUnused(ctx context.Context, tx *gorm.DB, c Case, side, cou
|
|
|
}
|
|
|
return nil
|
|
|
}
|
|
|
+
|
|
|
+// arrearsCallUserIDs 返回"这条 Op 的 CALL 会真正当作 `studentid` 传出去的那些 id"。
|
|
|
+//
|
|
|
+// 为什么要按 Op 分开:两个 SP 的第一个实参**不是同一种东西** ——
|
|
|
+// 课酬核销传的是学生 id,老带新传的是 userid(SP 内部把它当 studentid 用)。
|
|
|
+// 把它们并成一个集合,就会让"给课酬 case 种了一个 userid 的欠费行"这种错静默通过。
|
|
|
+func arrearsCallUserIDs(c Case) []int64 {
|
|
|
+ switch c.Op {
|
|
|
+ case OpUseActivityRecommend:
|
|
|
+ return []int64{c.LegacyUserID, c.RecommendUserID}
|
|
|
+ case OpCounteractArrears:
|
|
|
+ return []int64{c.LegacyStudentID}
|
|
|
+ default:
|
|
|
+ if c.Arrears != nil {
|
|
|
+ return []int64{c.LegacyStudentID, c.LegacyUserID, c.RecommendUserID}
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+}
|