|
@@ -3,6 +3,7 @@ package stage2
|
|
|
import (
|
|
import (
|
|
|
"context"
|
|
"context"
|
|
|
"database/sql"
|
|
"database/sql"
|
|
|
|
|
+ "errors"
|
|
|
"fmt"
|
|
"fmt"
|
|
|
"sort"
|
|
"sort"
|
|
|
"strings"
|
|
"strings"
|
|
@@ -74,8 +75,8 @@ type ActivitySnapshot struct {
|
|
|
Discounts map[ActivitySignedKey]DiscountState
|
|
Discounts map[ActivitySignedKey]DiscountState
|
|
|
// DiscountLog 是赠送额度流水**整表行集**。
|
|
// DiscountLog 是赠送额度流水**整表行集**。
|
|
|
DiscountLog []DiscountLogRow
|
|
DiscountLog []DiscountLogRow
|
|
|
- // Students 是报课欠费(key = `biz_no`,即旧 `t_courses_student.id`)。
|
|
|
|
|
- Students map[string]StudentArrears
|
|
|
|
|
|
|
+ // 课酬应收**不放在这里**:它是 `SideSnapshot.Arrears`(有序切片),
|
|
|
|
|
+ // 因为"核销顺序"是被验对象,map 会把它抹平。
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ActivityState 是 `t_courses_activity` 里本 case 关心的列。
|
|
// ActivityState 是 `t_courses_activity` 里本 case 关心的列。
|
|
@@ -206,7 +207,6 @@ func readActivitySnapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (*Acti
|
|
|
Activities: map[int64]ActivityState{},
|
|
Activities: map[int64]ActivityState{},
|
|
|
Signed: map[ActivitySignedKey]SignedState{},
|
|
Signed: map[ActivitySignedKey]SignedState{},
|
|
|
Discounts: map[ActivitySignedKey]DiscountState{},
|
|
Discounts: map[ActivitySignedKey]DiscountState{},
|
|
|
- Students: map[string]StudentArrears{},
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ① 活动行
|
|
// ① 活动行
|
|
@@ -373,36 +373,6 @@ SELECT d.activityid, d.userid, IFNULL(l.courses_studentid,''),
|
|
|
_ = rows.Close()
|
|
_ = rows.Close()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ⑥ 报课欠费(按 biz_no;legacy 的 `t_courses_student.id` 就是旧 UUID)
|
|
|
|
|
- if len(c.Activity.StudentBizNos) > 0 {
|
|
|
|
|
- rows, err := tx.Raw(`
|
|
|
|
|
-SELECT id, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
|
|
|
|
|
- IF(IFNULL(confirm_discount_userid,0) > 0, 1, 0)
|
|
|
|
|
- FROM t_courses_student WHERE id IN (?)`, c.Activity.StudentBizNos).Rows()
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- return nil, fmt.Errorf("读 legacy 报课欠费失败:%w", err)
|
|
|
|
|
- }
|
|
|
|
|
- for rows.Next() {
|
|
|
|
|
- var biz string
|
|
|
|
|
- var st StudentArrears
|
|
|
|
|
- var net, rem sql.NullString
|
|
|
|
|
- var confirm int64
|
|
|
|
|
- if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
|
|
|
|
|
- _ = rows.Close()
|
|
|
|
|
- return nil, err
|
|
|
|
|
- }
|
|
|
|
|
- st.NetReceipts, _ = centsOrNull(net)
|
|
|
|
|
- st.RemainderReceivable, _ = centsOrNull(rem)
|
|
|
|
|
- st.ConfirmDiscountSet = confirm == 1
|
|
|
|
|
- st.Rows++
|
|
|
|
|
- out.Students[biz] = st
|
|
|
|
|
- }
|
|
|
|
|
- if err := rows.Err(); err != nil {
|
|
|
|
|
- _ = rows.Close()
|
|
|
|
|
- return nil, err
|
|
|
|
|
- }
|
|
|
|
|
- _ = rows.Close()
|
|
|
|
|
- }
|
|
|
|
|
return out, nil
|
|
return out, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -429,7 +399,6 @@ func readActivitySnapshotTarget(ctx context.Context, tx *gorm.DB, c Case, uid st
|
|
|
Activities: map[int64]ActivityState{},
|
|
Activities: map[int64]ActivityState{},
|
|
|
Signed: map[ActivitySignedKey]SignedState{},
|
|
Signed: map[ActivitySignedKey]SignedState{},
|
|
|
Discounts: map[ActivitySignedKey]DiscountState{},
|
|
Discounts: map[ActivitySignedKey]DiscountState{},
|
|
|
- Students: map[string]StudentArrears{},
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ① 活动行
|
|
// ① 活动行
|
|
@@ -608,36 +577,6 @@ SELECT d.activity_id, d.user_id,
|
|
|
_ = rows.Close()
|
|
_ = rows.Close()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ⑥ 报课欠费(按 biz_no 定位)
|
|
|
|
|
- if len(c.Activity.StudentBizNos) > 0 {
|
|
|
|
|
- rows, err := tx.Raw(`
|
|
|
|
|
-SELECT biz_no, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
|
|
|
|
|
- IF(confirm_discount_user_id IS NULL OR confirm_discount_user_id = '', 0, 1)
|
|
|
|
|
- FROM t_courses_student WHERE biz_no IN (?)`, c.Activity.StudentBizNos).Rows()
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- return nil, fmt.Errorf("读 target 报课欠费失败:%w", err)
|
|
|
|
|
- }
|
|
|
|
|
- for rows.Next() {
|
|
|
|
|
- var biz string
|
|
|
|
|
- var st StudentArrears
|
|
|
|
|
- var net, rem sql.NullString
|
|
|
|
|
- var confirm int64
|
|
|
|
|
- if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
|
|
|
|
|
- _ = rows.Close()
|
|
|
|
|
- return nil, err
|
|
|
|
|
- }
|
|
|
|
|
- st.NetReceipts, _ = centsOrNull(net)
|
|
|
|
|
- st.RemainderReceivable, _ = centsOrNull(rem)
|
|
|
|
|
- st.ConfirmDiscountSet = confirm == 1
|
|
|
|
|
- st.Rows++
|
|
|
|
|
- out.Students[biz] = st
|
|
|
|
|
- }
|
|
|
|
|
- if err := rows.Err(); err != nil {
|
|
|
|
|
- _ = rows.Close()
|
|
|
|
|
- return nil, err
|
|
|
|
|
- }
|
|
|
|
|
- _ = rows.Close()
|
|
|
|
|
- }
|
|
|
|
|
return out, nil
|
|
return out, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -877,3 +816,154 @@ func fmtSnapN(n int) string {
|
|
|
}
|
|
}
|
|
|
return fmt.Sprint(n)
|
|
return fmt.Sprint(n)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// ArrearsRow 是一条课酬应收(`t_courses_student`),**按核销 cursor 顺序**排列后的元素。
|
|
|
|
|
+//
|
|
|
|
|
+// 两侧的锚:legacy 是 `t_courses_student.id`(旧 UUID);target 是 `biz_no`(同一个 UUID)。
|
|
|
|
|
+// **不能用 target 的 `id`(bigint 自增)**,也不能用 `courses_student_no`(那是活动域那张表的列)。
|
|
|
|
|
+type ArrearsRow struct {
|
|
|
|
|
+ AnchorBizNo string // 两侧同一锚(旧 UUID)
|
|
|
|
|
+ CoursesStudentID int64 // target 的数字 id;legacy 恒 0(两侧不可比,仅供报告)
|
|
|
|
|
+ RemainderReceivable int64
|
|
|
|
|
+ NetReceipts int64
|
|
|
|
|
+ ConfirmDiscountSet bool
|
|
|
|
|
+ // StartTime 是 cursor 的排序键(`t_courses_class.start_time`),已格式化成可比较字符串。
|
|
|
|
|
+ StartTime string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// readArrearsLegacy 读 legacy 侧的课酬应收,**按核销 cursor 的顺序**。
|
|
|
|
|
+//
|
|
|
|
|
+// cursor(`sp_courses_student_counteract_arrears_course`):
|
|
|
|
|
+//
|
|
|
|
|
+// SELECT t1.id, t1.remainder_receivable FROM t_courses_student t1
|
|
|
|
|
+// JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid
|
|
|
|
|
+// WHERE t1.studentid=? AND t1.schoolid=? AND t1.campusid=?
|
|
|
|
|
+// AND t1.remainder_receivable>0 AND t1.confirm_discount_userid>0
|
|
|
|
|
+// ORDER BY t2.start_time ASC;
|
|
|
|
|
+//
|
|
|
|
|
+// 这里**故意不加后两个过滤**:把"该进候选却没进"的两种行也读出来(`remainder<=0` / `confirm=0`),
|
|
|
|
|
+// 否则"过滤条件写错"这类错永远测不到 —— 过滤条件没有反例输入,等于没测(§18.3 第 0 条同一思路)。
|
|
|
|
|
+// 排序仍照 cursor 的 `start_time ASC`,并在末尾用 anchor 兜底保证确定性。
|
|
|
|
|
+func readArrearsLegacy(ctx context.Context, tx *gorm.DB, c Case) ([]ArrearsRow, error) {
|
|
|
|
|
+ if len(studentBizNos(c)) == 0 {
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ rows, err := tx.Raw(`
|
|
|
|
|
+SELECT t1.id,
|
|
|
|
|
+ `+readRound2("t1.remainder_receivable")+`,
|
|
|
|
|
+ `+readRound2("t1.net_receipts")+`,
|
|
|
|
|
+ IF(IFNULL(t1.confirm_discount_userid,0) > 0, 1, 0),
|
|
|
|
|
+ IFNULL(DATE_FORMAT(t2.start_time,'%Y-%m-%d %H:%i:%s'), '0000-00-00 00:00:00')
|
|
|
|
|
+ FROM t_courses_student t1
|
|
|
|
|
+ LEFT JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid
|
|
|
|
|
+ WHERE t1.id IN (?)
|
|
|
|
|
+ ORDER BY t2.start_time ASC, t1.id ASC`, studentBizNos(c)).Rows()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读 legacy 课酬应收失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = rows.Close() }()
|
|
|
|
|
+ var out []ArrearsRow
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var r ArrearsRow
|
|
|
|
|
+ var rem, net sql.NullString
|
|
|
|
|
+ var confirm int64
|
|
|
|
|
+ if err := rows.Scan(&r.AnchorBizNo, &rem, &net, &confirm, &r.StartTime); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ r.RemainderReceivable, _ = centsOrNull(rem)
|
|
|
|
|
+ r.NetReceipts, _ = centsOrNull(net)
|
|
|
|
|
+ r.ConfirmDiscountSet = confirm == 1
|
|
|
|
|
+ out = append(out, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// readArrearsTarget 同上(target 侧)。锚用 `biz_no`,**不用 `id`**(两侧自增起点不同)。
|
|
|
|
|
+func readArrearsTarget(ctx context.Context, tx *gorm.DB, c Case) ([]ArrearsRow, error) {
|
|
|
|
|
+ if len(studentBizNos(c)) == 0 {
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ rows, err := tx.Raw(`
|
|
|
|
|
+SELECT t1.biz_no, t1.id,
|
|
|
|
|
+ `+readRound2("t1.remainder_receivable")+`,
|
|
|
|
|
+ `+readRound2("t1.net_receipts")+`,
|
|
|
|
|
+ IF(t1.confirm_discount_user_id IS NULL OR t1.confirm_discount_user_id='', 0, 1),
|
|
|
|
|
+ IFNULL(DATE_FORMAT(t2.start_time,'%Y-%m-%d %H:%i:%s'), '0000-00-00 00:00:00')
|
|
|
|
|
+ FROM t_courses_student t1
|
|
|
|
|
+ LEFT JOIN t_courses_class t2 ON t1.courses_id=t2.courses_id AND t1.class_id=t2.class_id
|
|
|
|
|
+ WHERE t1.biz_no IN (?)
|
|
|
|
|
+ ORDER BY t2.start_time ASC, t1.biz_no ASC`, studentBizNos(c)).Rows()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("读 target 课酬应收失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = rows.Close() }()
|
|
|
|
|
+ var out []ArrearsRow
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var r ArrearsRow
|
|
|
|
|
+ var rem, net sql.NullString
|
|
|
|
|
+ var confirm int64
|
|
|
|
|
+ if err := rows.Scan(&r.AnchorBizNo, &r.CoursesStudentID, &rem, &net, &confirm, &r.StartTime); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ r.RemainderReceivable, _ = centsOrNull(rem)
|
|
|
|
|
+ r.NetReceipts, _ = centsOrNull(net)
|
|
|
|
|
+ r.ConfirmDiscountSet = confirm == 1
|
|
|
|
|
+ out = append(out, r)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// readRecommenderAccount 读"推荐人"的资金账户(全局 + 本校区)。
|
|
|
|
|
+//
|
|
|
|
|
+// 只在 `c.RecommendUserID > 0` 时读 —— 其余 case 不付额外查询代价(报告尾部的"代价"行会体现)。
|
|
|
|
|
+func readRecommenderAccount(ctx context.Context, tx *gorm.DB, c Case, target bool) (Account, bool, error) {
|
|
|
|
|
+ if c.RecommendUserID <= 0 {
|
|
|
|
|
+ return Account{}, false, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if target {
|
|
|
|
|
+ uid := CanonicalUIDFor(c.RecommendUserID)
|
|
|
|
|
+ var g accountNS
|
|
|
|
|
+ if err := tx.Raw(`
|
|
|
|
|
+SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE user_id = ?`, uid).
|
|
|
|
|
+ Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended); err != nil {
|
|
|
|
|
+ if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
+ return Account{}, false, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return Account{}, false, fmt.Errorf("读 target 推荐人全局账户失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return g.account(), true, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ var g accountNS
|
|
|
|
|
+ if err := tx.Raw(`
|
|
|
|
|
+SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
+ CAST(ROUND(CAST(total_recommended_amount AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE userid = ?`, c.RecommendUserID).
|
|
|
|
|
+ Row().Scan(&g.Balance, &g.Spend, &g.Income, &g.Expenditure, &g.Special, &g.Recommended); err != nil {
|
|
|
|
|
+ if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
+ return Account{}, false, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return Account{}, false, fmt.Errorf("读 legacy 推荐人全局账户失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return g.account(), true, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// studentBizNos 安全地取 case 声明的报课单号(`c.Activity` 为 nil 时返回 nil)。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么要这个包装:`c.Activity` 只对"涉及活动域的 case"非 nil,
|
|
|
|
|
+// 直接写 `c.Activity.StudentBizNos` 会对另外 30 多条 case **空指针崩**(本轮真踩了一次)。
|
|
|
|
|
+func studentBizNos(c Case) []string {
|
|
|
|
|
+ if c.Activity == nil {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return c.Activity.StudentBizNos
|
|
|
|
|
+}
|