|
@@ -0,0 +1,694 @@
|
|
|
|
|
+package dualrun
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "database/sql"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 兜底层级(与 SP 的四级兜底一一对应)。
|
|
|
|
|
+const (
|
|
|
|
|
+ TierTeacherClassify = "teacher-classify" // 老师 + 具体分类
|
|
|
|
|
+ TierTeacherGradeSubject = "teacher-grade-subject" // 老师 + 年级/科目
|
|
|
|
|
+ TierCert = "cert-tier" // 持证老师 + 教龄档
|
|
|
|
|
+ TierGeneric = "generic-tier" // 通用 + 教龄档
|
|
|
|
|
+ TierDegraded = "degraded" // SP 无命中 ⇒ PHP 走 legacy 退化分支
|
|
|
|
|
+ TierNone = "none"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 用例类别。
|
|
|
|
|
+const (
|
|
|
|
|
+ CatBaseline = "baseline"
|
|
|
|
|
+ CatTier = "tier"
|
|
|
|
|
+ CatIncome3DP = "income-3dp"
|
|
|
|
|
+ CatIncomeZeroMin = "income-zero-min"
|
|
|
|
|
+ CatSupplement = "supplement-both"
|
|
|
|
|
+ CatNonDivisible = "nondivisible"
|
|
|
|
|
+ CatRewardType2 = "rewardtype2"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 内存参照表(小表全量装载,避免 74,870 次相关子查询)----------
|
|
|
|
|
+
|
|
|
|
|
+type payRow struct {
|
|
|
|
|
+ id int64
|
|
|
|
|
+ schoolid int64
|
|
|
|
|
+ campusid int64
|
|
|
|
|
+ actived int64
|
|
|
|
|
+ effect time.Time
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type clsRow struct {
|
|
|
|
|
+ id int64
|
|
|
|
|
+ gradeid int64
|
|
|
|
|
+ subjectid int64
|
|
|
|
|
+ classType int64
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type teacherRow struct {
|
|
|
|
|
+ exp time.Time
|
|
|
|
|
+ cert int64
|
|
|
|
|
+ hasExp bool
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type detailRow struct {
|
|
|
|
|
+ id int64
|
|
|
|
|
+ paymentid int64
|
|
|
|
|
+ teacherid int64
|
|
|
|
|
+ cert int64
|
|
|
|
|
+ classType int64
|
|
|
|
|
+ gradeid int64
|
|
|
|
|
+ subjectid int64
|
|
|
|
|
+ expMin float64
|
|
|
|
|
+ expMax float64
|
|
|
|
|
+ classifyid int64
|
|
|
|
|
+ percentage float64
|
|
|
|
|
+ minPay float64
|
|
|
|
|
+ maxPay float64
|
|
|
|
|
+ reward float64
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type aggRow struct {
|
|
|
|
|
+ sumCost float64
|
|
|
|
|
+ elig int
|
|
|
|
|
+ rows int
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type refs struct {
|
|
|
|
|
+ pays []payRow
|
|
|
|
|
+ classifies map[int64]*clsRow
|
|
|
|
|
+ courseClass map[int64]int64
|
|
|
|
|
+ teachers map[int64]teacherRow
|
|
|
|
|
+ details map[int64][]detailRow // paymentid → 规则
|
|
|
|
|
+ detailByID map[int64]detailRow
|
|
|
|
|
+ studentAgg map[string]aggRow // recordid → 打卡聚合
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func loadRefs(db *sql.DB, schema string) (*refs, error) {
|
|
|
|
|
+ r := &refs{
|
|
|
|
|
+ classifies: map[int64]*clsRow{},
|
|
|
|
|
+ courseClass: map[int64]int64{},
|
|
|
|
|
+ teachers: map[int64]teacherRow{},
|
|
|
|
|
+ details: map[int64][]detailRow{},
|
|
|
|
|
+ detailByID: map[int64]detailRow{},
|
|
|
|
|
+ studentAgg: map[string]aggRow{},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rows, err := db.Query("SELECT id, schoolid, IFNULL(campusid,0), CAST(IFNULL(actived,0) AS UNSIGNED), " +
|
|
|
|
|
+ "DATE_FORMAT(IFNULL(effect_date,'1970-01-01'),'%Y-%m-%d %H:%i:%s') FROM " + schema + ".t_course_payment")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("load payments: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var p payRow
|
|
|
|
|
+ var eff string
|
|
|
|
|
+ if err := rows.Scan(&p.id, &p.schoolid, &p.campusid, &p.actived, &eff); err == nil {
|
|
|
|
|
+ p.effect, _ = time.ParseInLocation("2006-01-02 15:04:05", eff, time.Local)
|
|
|
|
|
+ r.pays = append(r.pays, p)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+
|
|
|
|
|
+ if rows, err = db.Query("SELECT id, IFNULL(gradeid,-1), IFNULL(subjectid,-1), IFNULL(class_type,0) FROM " + schema + ".t_courses_classify"); err == nil {
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var c clsRow
|
|
|
|
|
+ if err := rows.Scan(&c.id, &c.gradeid, &c.subjectid, &c.classType); err == nil {
|
|
|
|
|
+ cc := c
|
|
|
|
|
+ r.classifies[c.id] = &cc
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return nil, fmt.Errorf("load classify: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if rows, err = db.Query("SELECT id, IFNULL(classifyid,0) FROM " + schema + ".t_courses"); err == nil {
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var id, cid int64
|
|
|
|
|
+ if err := rows.Scan(&id, &cid); err == nil {
|
|
|
|
|
+ r.courseClass[id] = cid
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return nil, fmt.Errorf("load courses: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if rows, err = db.Query("SELECT teacherid, IFNULL(DATE_FORMAT(teacher_exp,'%Y-%m-%d %H:%i:%s'),''), " +
|
|
|
|
|
+ "CAST(IFNULL(teacher_cert,0) AS UNSIGNED) FROM " + schema + ".t_teacher_information"); err == nil {
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var id int64
|
|
|
|
|
+ var expStr string
|
|
|
|
|
+ var t teacherRow
|
|
|
|
|
+ if err := rows.Scan(&id, &expStr, &t.cert); err == nil {
|
|
|
|
|
+ if expStr != "" {
|
|
|
|
|
+ t.exp, _ = time.ParseInLocation("2006-01-02 15:04:05", expStr, time.Local)
|
|
|
|
|
+ t.hasExp = true
|
|
|
|
|
+ }
|
|
|
|
|
+ r.teachers[id] = t
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return nil, fmt.Errorf("load teachers: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rows, err = db.Query("SELECT id, course_paymentid, IFNULL(teacherid,0), CAST(IFNULL(teacher_cert,0) AS UNSIGNED), " +
|
|
|
|
|
+ "IFNULL(class_type,0), IFNULL(gradeid,-1), IFNULL(subjectid,-1), IFNULL(teacher_exp_min,0), IFNULL(teacher_exp_max,0), " +
|
|
|
|
|
+ "IFNULL(classifyid,0), IFNULL(percentage,0), IFNULL(min_payment,0), IFNULL(max_payment,0), IFNULL(reward,0) FROM " +
|
|
|
|
|
+ schema + ".t_course_payment_detail")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("load rules: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var d detailRow
|
|
|
|
|
+ if err := rows.Scan(&d.id, &d.paymentid, &d.teacherid, &d.cert, &d.classType, &d.gradeid, &d.subjectid,
|
|
|
|
|
+ &d.expMin, &d.expMax, &d.classifyid, &d.percentage, &d.minPay, &d.maxPay, &d.reward); err == nil {
|
|
|
|
|
+ r.details[d.paymentid] = append(r.details[d.paymentid], d)
|
|
|
|
|
+ r.detailByID[d.id] = d
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+
|
|
|
|
|
+ rows, err = db.Query("SELECT recordid, IFNULL(SUM(cost),0), SUM(type IN (1,2)), COUNT(*) FROM " +
|
|
|
|
|
+ schema + ".t_courses_record_student GROUP BY recordid")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("load student agg: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var rid string
|
|
|
|
|
+ var a aggRow
|
|
|
|
|
+ if err := rows.Scan(&rid, &a.sumCost, &a.elig, &a.rows); err == nil {
|
|
|
|
|
+ r.studentAgg[rid] = a
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.Close()
|
|
|
|
|
+
|
|
|
|
|
+ return r, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// resolvePayment 复刻 SP 的两级找方案:先 (school,campus),再 (school,0)。
|
|
|
|
|
+func (r *refs) resolvePayment(schoolid, campusid int64, at time.Time) int64 {
|
|
|
|
|
+ best, varEff := int64(0), time.Time{}
|
|
|
|
|
+ for _, p := range r.pays {
|
|
|
|
|
+ if p.schoolid != schoolid || p.actived != 1 || p.campusid != campusid || p.effect.After(at) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if best == 0 || p.effect.After(varEff) {
|
|
|
|
|
+ best, varEff = p.id, p.effect
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if best > 0 {
|
|
|
|
|
+ return best
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, p := range r.pays {
|
|
|
|
|
+ if p.schoolid != schoolid || p.actived != 1 || p.campusid != 0 || p.effect.After(at) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if best == 0 || p.effect.After(varEff) {
|
|
|
|
|
+ best, varEff = p.id, p.effect
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return best
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// hitRule 复刻 sp_course_payment_detail_get_teacher_detail 的判定顺序,返回 (规则, 层级)。
|
|
|
|
|
+//
|
|
|
|
|
+// 顺序(与 SP 一致,不可换):
|
|
|
|
|
+// 1. teacher-classify 老师专属 + classifyid 精确匹配
|
|
|
|
|
+// 2. teacher-grade-subject 老师专属 + 年级/科目(含 -1 通配)
|
|
|
|
|
+// 3. cert-tier 持证 + 教龄档
|
|
|
|
|
+// 4. generic-tier 通用 + 教龄档
|
|
|
|
|
+//
|
|
|
|
|
+// 注意 SP 用 `id>1` 才采纳(历史怪癖),这里照抄。
|
|
|
|
|
+func (r *refs) hitRule(schoolid, campusid, teacherid int64, at time.Time, cls *clsRow, teacherExp float64, teacherCert int64) (detailRow, string, bool) {
|
|
|
|
|
+ if cls == nil {
|
|
|
|
|
+ return detailRow{}, TierNone, false
|
|
|
|
|
+ }
|
|
|
|
|
+ payid := r.resolvePayment(schoolid, campusid, at)
|
|
|
|
|
+ if payid <= 0 {
|
|
|
|
|
+ return detailRow{}, TierDegraded, false
|
|
|
|
|
+ }
|
|
|
|
|
+ if teacherExp < 0 {
|
|
|
|
|
+ teacherExp = 0
|
|
|
|
|
+ }
|
|
|
|
|
+ oneself := 0
|
|
|
|
|
+ for _, d := range r.details[payid] {
|
|
|
|
|
+ if d.teacherid == teacherid && d.classType == cls.classType {
|
|
|
|
|
+ oneself++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ find := func(match func(d detailRow) bool) (detailRow, bool) {
|
|
|
|
|
+ var best detailRow
|
|
|
|
|
+ found := false
|
|
|
|
|
+ for _, d := range r.details[payid] {
|
|
|
|
|
+ if d.classType != cls.classType || !match(d) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if !found || d.id < best.id {
|
|
|
|
|
+ best, found = d, true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if found && best.id > 1 {
|
|
|
|
|
+ return best, true
|
|
|
|
|
+ }
|
|
|
|
|
+ return detailRow{}, false
|
|
|
|
|
+ }
|
|
|
|
|
+ if oneself > 0 {
|
|
|
|
|
+ if d, ok := find(func(d detailRow) bool { return d.teacherid == teacherid && d.classifyid == cls.id }); ok {
|
|
|
|
|
+ return d, TierTeacherClassify, true
|
|
|
|
|
+ }
|
|
|
|
|
+ if d, ok := find(func(d detailRow) bool {
|
|
|
|
|
+ return d.teacherid == teacherid &&
|
|
|
|
|
+ (d.gradeid == cls.gradeid || d.gradeid == -1) &&
|
|
|
|
|
+ (d.subjectid == cls.subjectid || d.subjectid == -1)
|
|
|
|
|
+ }); ok {
|
|
|
|
|
+ return d, TierTeacherGradeSubject, true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if teacherCert > 0 {
|
|
|
|
|
+ if d, ok := find(func(d detailRow) bool {
|
|
|
|
|
+ return d.teacherid == 0 && d.cert == 1 &&
|
|
|
|
|
+ (d.gradeid == cls.gradeid || d.gradeid == -1) &&
|
|
|
|
|
+ (d.subjectid == cls.subjectid || d.subjectid == -1) &&
|
|
|
|
|
+ teacherExp >= d.expMin && teacherExp <= d.expMax
|
|
|
|
|
+ }); ok {
|
|
|
|
|
+ return d, TierCert, true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if d, ok := find(func(d detailRow) bool {
|
|
|
|
|
+ return d.teacherid == 0 && d.cert == 0 &&
|
|
|
|
|
+ (d.gradeid == cls.gradeid || d.gradeid == -1) &&
|
|
|
|
|
+ (d.subjectid == cls.subjectid || d.subjectid == -1) &&
|
|
|
|
|
+ teacherExp >= d.expMin && teacherExp <= d.expMax
|
|
|
|
|
+ }); ok {
|
|
|
|
|
+ return d, TierGeneric, true
|
|
|
|
|
+ }
|
|
|
|
|
+ return detailRow{}, TierDegraded, false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// teacherExpYears 与 PHP `Utils::calculatingAge()` 同口径:固定 365 天/年、四舍五入 1 位。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 这里用 runner 自己的 `now` 计算,只用于**选 case 与命中预判**;
|
|
|
|
|
+// 真正参与 oracle 计算的教龄由 PHP 侧 `time()` 决定(A22:PHP 时钟不受 SET timestamp 影响)。
|
|
|
|
|
+func teacherExpYears(t teacherRow, now time.Time) float64 {
|
|
|
|
|
+ if !t.hasExp || t.exp.Year() <= 1970 {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+ years := now.Sub(t.exp).Hours() / 24 / 365
|
|
|
|
|
+ return float64(int(years*10+0.5)) / 10
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// recordRow 是挑选 case 时需要的 record 字段。
|
|
|
|
|
+type recordRow struct {
|
|
|
|
|
+ id string
|
|
|
|
|
+ schoolid int64
|
|
|
|
|
+ campusid int64
|
|
|
|
|
+ teacherid int64
|
|
|
|
|
+ coursesid int64
|
|
|
|
|
+ rewardType int8
|
|
|
|
|
+ stuNumber int
|
|
|
|
|
+ status int
|
|
|
|
|
+ income float64
|
|
|
|
|
+ startTime time.Time
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// loadRecords 流式扫描 record(只取必要列)。
|
|
|
|
|
+func loadRecords(db *sql.DB, schema string) ([]recordRow, error) {
|
|
|
|
|
+ rows, err := db.Query("SELECT id, schoolid, IFNULL(campusid,0), teacherid, coursesid, " +
|
|
|
|
|
+ "IFNULL(teacher_reward_type,1), IFNULL(student_number,0), IFNULL(status,0), IFNULL(income,0), " +
|
|
|
|
|
+ "DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') FROM " + schema + ".t_courses_record " +
|
|
|
|
|
+ "WHERE teacherid>0 AND actual_start_time IS NOT NULL AND coursesid IS NOT NULL ORDER BY id")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer rows.Close()
|
|
|
|
|
+ var out []recordRow
|
|
|
|
|
+ for rows.Next() {
|
|
|
|
|
+ var rec recordRow
|
|
|
|
|
+ var st string
|
|
|
|
|
+ if err := rows.Scan(&rec.id, &rec.schoolid, &rec.campusid, &rec.teacherid, &rec.coursesid,
|
|
|
|
|
+ &rec.rewardType, &rec.stuNumber, &rec.status, &rec.income, &st); err == nil {
|
|
|
|
|
+ rec.startTime, _ = time.ParseInLocation("2006-01-02 15:04:05", st, time.Local)
|
|
|
|
|
+ out = append(out, rec)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, rows.Err()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// caseFacts 是一条 record 在"选 case"阶段算出的全部判定依据。
|
|
|
|
|
+type caseFacts struct {
|
|
|
|
|
+ rec recordRow
|
|
|
|
|
+ cls *clsRow
|
|
|
|
|
+ teacher teacherRow
|
|
|
|
|
+ expYears float64
|
|
|
|
|
+ agg aggRow
|
|
|
|
|
+ rule detailRow
|
|
|
|
|
+ tier string
|
|
|
|
|
+ hit bool
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// buildFacts 给一条 record 补全判定依据。
|
|
|
|
|
+func (r *refs) buildFacts(rec recordRow, now time.Time) caseFacts {
|
|
|
|
|
+ cls := r.classifies[r.courseClass[rec.coursesid]]
|
|
|
|
|
+ t := r.teachers[rec.teacherid]
|
|
|
|
|
+ exp := teacherExpYears(t, now)
|
|
|
|
|
+ rule, tier, ok := r.hitRule(rec.schoolid, rec.campusid, rec.teacherid, rec.startTime, cls, exp, t.cert)
|
|
|
|
|
+ return caseFacts{
|
|
|
|
|
+ rec: rec, cls: cls, teacher: t, expYears: exp,
|
|
|
|
|
+ agg: r.studentAgg[rec.id], rule: rule, tier: tier, hit: ok,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 真实基线:分层抽样 ----------
|
|
|
|
|
+
|
|
|
|
|
+func pctBand(d detailRow) string {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case d.percentage <= 25:
|
|
|
|
|
+ return "pct<=25"
|
|
|
|
|
+ case d.percentage <= 50:
|
|
|
|
|
+ return "pct26-50"
|
|
|
|
|
+ default:
|
|
|
|
|
+ return "pct>50"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func sizeBand(elig int) string {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case elig <= 1:
|
|
|
|
|
+ return "n1"
|
|
|
|
|
+ case elig <= 5:
|
|
|
|
|
+ return "n2-5"
|
|
|
|
|
+ default:
|
|
|
|
|
+ return "n6+"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// BuildRealCases 按「percentage 区间 × 人数区间」分层抽样 n 条真实基线。
|
|
|
|
|
+//
|
|
|
|
|
+// 分层而不是取前 n 条:前 n 条集中在某个校区/某位老师,会给出"看起来很整齐"的假结论。
|
|
|
|
|
+func BuildRealCases(records []recordRow, r *refs, n int, now time.Time) []Case {
|
|
|
|
|
+ buckets := map[string][]caseFacts{}
|
|
|
|
|
+ var keys []string
|
|
|
|
|
+ for _, rec := range records {
|
|
|
|
|
+ if rec.status != 2 {
|
|
|
|
|
+ continue // 已结课
|
|
|
|
|
+ }
|
|
|
|
|
+ f := r.buildFacts(rec, now)
|
|
|
|
|
+ if !f.hit || f.agg.sumCost <= 0 || f.agg.elig <= 0 {
|
|
|
|
|
+ continue // 无命中 / 无收入 / 无有效学生 ⇒ 不进基线
|
|
|
|
|
+ }
|
|
|
|
|
+ k := pctBand(f.rule) + "|" + sizeBand(f.agg.elig)
|
|
|
|
|
+ if _, ok := buckets[k]; !ok {
|
|
|
|
|
+ keys = append(keys, k)
|
|
|
|
|
+ }
|
|
|
|
|
+ buckets[k] = append(buckets[k], f)
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(keys)
|
|
|
|
|
+
|
|
|
|
|
+ // 桶内按 record id 排序(稳定),桶间轮转取数 ⇒ 分层且可复现
|
|
|
|
|
+ for _, k := range keys {
|
|
|
|
|
+ sort.Slice(buckets[k], func(i, j int) bool { return buckets[k][i].rec.id < buckets[k][j].rec.id })
|
|
|
|
|
+ }
|
|
|
|
|
+ var out []Case
|
|
|
|
|
+ idx := make([]int, len(keys))
|
|
|
|
|
+ for len(out) < n {
|
|
|
|
|
+ progressed := false
|
|
|
|
|
+ for bi, k := range keys {
|
|
|
|
|
+ if idx[bi] >= len(buckets[k]) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ f := buckets[k][idx[bi]]
|
|
|
|
|
+ idx[bi]++
|
|
|
|
|
+ progressed = true
|
|
|
|
|
+ out = append(out, Case{
|
|
|
|
|
+ ID: fmt.Sprintf("real-%04d", len(out)+1),
|
|
|
|
|
+ Kind: KindReal,
|
|
|
|
|
+ Category: CatBaseline,
|
|
|
|
|
+ RecordID: f.rec.id,
|
|
|
|
|
+ ExpectTier: f.tier,
|
|
|
|
|
+ Expectation: fmt.Sprintf("分层桶 %s,期望 %s 命中;income=%.3f,人数=%d",
|
|
|
|
|
+ k, f.tier, f.agg.sumCost, f.agg.elig),
|
|
|
|
|
+ Note: "真实基线:status=2 且 income>0 且有规则命中",
|
|
|
|
|
+ })
|
|
|
|
|
+ if len(out) >= n {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if !progressed {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------- 合成边界 ----------
|
|
|
|
|
+
|
|
|
|
|
+// synPool 收集某一特征下的候选 record(按 id 升序,保证可复现)。
|
|
|
|
|
+type synPool struct {
|
|
|
|
|
+ facts []caseFacts
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (r *refs) collect(records []recordRow, now time.Time, pred func(caseFacts) bool) []caseFacts {
|
|
|
|
|
+ var out []caseFacts
|
|
|
|
|
+ for _, rec := range records {
|
|
|
|
|
+ f := r.buildFacts(rec, now)
|
|
|
|
|
+ if pred(f) {
|
|
|
|
|
+ out = append(out, f)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Slice(out, func(i, j int) bool { return out[i].rec.id < out[j].rec.id })
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// insertRuleSQL 造一条规则(事务内插入,随回滚消失)。
|
|
|
|
|
+func insertRuleSQL(paymentID int64, classType, classifyID, teacherID int64, teacherCert int, pct, minPay, maxPay, reward float64, expMin, expMax float64, name string) string {
|
|
|
|
|
+ return fmt.Sprintf("INSERT INTO t_course_payment_detail "+
|
|
|
|
|
+ "(course_paymentid, gradeid, subjectid, class_type, classifyid, teacherid, name, percentage, min_payment, max_payment, reward, teacher_cert, teacher_exp_min, teacher_exp_max, actived) "+
|
|
|
|
|
+ "VALUES (%d, -1, -1, %d, %d, %d, '%s', %.1f, %.1f, %.1f, %.1f, b'%d', %.3f, %.3f, b'1')",
|
|
|
|
|
+ paymentID, classType, classifyID, teacherID, name, pct, minPay, maxPay, reward, teacherCert, expMin, expMax)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// BuildSyntheticCases 造 6 类 × perCat 条合成边界。
|
|
|
|
|
+//
|
|
|
|
|
+// 每类 5 条(perCat=5),其中 tier 类覆盖四级兜底:teacher-grade-subject / generic-tier 直接选现成记录,
|
|
|
|
|
+// teacher-classify / cert-tier 真实命中数为 0,**必须靠事务内造规则**才可能命中(见 P0 §13)。
|
|
|
|
|
+func BuildSyntheticCases(records []recordRow, r *refs, perCat int, now time.Time) ([]Case, []string) {
|
|
|
|
|
+ var out []Case
|
|
|
|
|
+ var notes []string
|
|
|
|
|
+ used := map[string]bool{}
|
|
|
|
|
+
|
|
|
|
|
+ baseOK := func(f caseFacts) bool {
|
|
|
|
|
+ return f.hit && f.agg.sumCost > 0 && f.agg.elig > 0 && f.rec.status == 2 && !used[f.rec.id]
|
|
|
|
|
+ }
|
|
|
|
|
+ take := func(pool []caseFacts, n int) []caseFacts {
|
|
|
|
|
+ var got []caseFacts
|
|
|
|
|
+ for _, f := range pool {
|
|
|
|
|
+ if len(got) >= n {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ if used[f.rec.id] {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ got = append(got, f)
|
|
|
|
|
+ used[f.rec.id] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ return got
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 类 1:四级兜底(5 条,覆盖四级)----
|
|
|
|
|
+ // teacher-grade-subject ×1 / generic-tier ×1 / teacher-classify ×2(造数)/ cert-tier ×1(造数)
|
|
|
|
|
+ gsPool := r.collect(records, now, func(f caseFacts) bool { return baseOK(f) && f.tier == TierTeacherGradeSubject })
|
|
|
|
|
+ genPool := r.collect(records, now, func(f caseFacts) bool { return baseOK(f) && f.tier == TierGeneric })
|
|
|
|
|
+ // 造 classify 级:需要该方案下无老师专属规则(oneself==0 也可,但插入后即 >0),
|
|
|
|
|
+ // 用 generic 池的记录最稳(说明当前没有老师专属规则能命中它)
|
|
|
|
|
+ classifyPool := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return baseOK(f) && f.tier == TierGeneric && f.cls != nil && f.rec.teacherid > 0
|
|
|
|
|
+ })
|
|
|
|
|
+ certPool := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return baseOK(f) && f.tier == TierGeneric && f.teacher.cert == 0 && f.cls != nil
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ add := func(c Case) { out = append(out, c) }
|
|
|
|
|
+
|
|
|
|
|
+ if got := take(gsPool, 1); len(got) == 1 {
|
|
|
|
|
+ f := got[0]
|
|
|
|
|
+ add(Case{ID: "syn-tier-grade-subject-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
|
|
|
|
|
+ ExpectTier: TierTeacherGradeSubject,
|
|
|
|
|
+ Expectation: "现成记录即命中 teacher-grade-subject(无需造数)",
|
|
|
|
|
+ Note: "四级兜底之一"})
|
|
|
|
|
+ } else {
|
|
|
|
|
+ notes = append(notes, "tier-teacher-grade-subject:无法造数(无现成命中记录)")
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := take(genPool, 1); len(got) == 1 {
|
|
|
|
|
+ f := got[0]
|
|
|
|
|
+ add(Case{ID: "syn-tier-generic-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
|
|
|
|
|
+ ExpectTier: TierGeneric,
|
|
|
|
|
+ Expectation: "现成记录即命中 generic-tier(无需造数)",
|
|
|
|
|
+ Note: "四级兜底之一"})
|
|
|
|
|
+ } else {
|
|
|
|
|
+ notes = append(notes, "tier-generic:无法造数(无现成命中记录)")
|
|
|
|
|
+ }
|
|
|
|
|
+ // teacher-classify ×2:插入一条 classifyid=当前分类 的老师专属规则
|
|
|
|
|
+ for i, f := range take(classifyPool, 2) {
|
|
|
|
|
+ payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime)
|
|
|
|
|
+ add(Case{
|
|
|
|
|
+ ID: fmt.Sprintf("syn-tier-teacher-classify-%d", i+1), Kind: KindSynthetic, Category: CatTier,
|
|
|
|
|
+ RecordID: f.rec.id, ExpectTier: TierTeacherClassify,
|
|
|
|
|
+ Mutations: []string{insertRuleSQL(payid, f.cls.classType, f.cls.id, f.rec.teacherid, 0, 33.0, 0, 0, 0, 0, 99, "ac15-syn-classify")},
|
|
|
|
|
+ Expectation: "造一条 teacherid=该老师 且 classifyid=该分类 的规则 ⇒ 应命中 teacher-classify",
|
|
|
|
|
+ Note: "真实数据里该级命中数为 0,必须造数(P0 §13)",
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(classifyPool) < 2 {
|
|
|
|
|
+ notes = append(notes, fmt.Sprintf("tier-teacher-classify:候选只有 %d 条,不足 2 条", len(classifyPool)))
|
|
|
|
|
+ }
|
|
|
|
|
+ // cert-tier ×1:把老师置为持证 + 插入 teacher_cert=1 的通用档规则
|
|
|
|
|
+ if got := take(certPool, 1); len(got) == 1 {
|
|
|
|
|
+ f := got[0]
|
|
|
|
|
+ payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime)
|
|
|
|
|
+ add(Case{
|
|
|
|
|
+ ID: "syn-tier-cert-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
|
|
|
|
|
+ ExpectTier: TierCert,
|
|
|
|
|
+ Mutations: []string{
|
|
|
|
|
+ fmt.Sprintf("UPDATE t_teacher_information SET teacher_cert = b'1' WHERE teacherid = %d", f.rec.teacherid),
|
|
|
|
|
+ insertRuleSQL(payid, f.cls.classType, 0, 0, 1, 44.0, 0, 0, 0, 0, 99, "ac15-syn-cert"),
|
|
|
|
|
+ },
|
|
|
|
|
+ Expectation: "老师置持证 + 造 teacher_cert=1 且教龄 0~99 的通用规则 ⇒ 应命中 cert-tier",
|
|
|
|
|
+ Note: "真实数据里该级命中数为 0,必须造数(P0 §13)",
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ notes = append(notes, "tier-cert:无法造数(找不到「generic 命中 + 老师未持证」的记录)")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 类 2:income 带 3 位小数 ----
|
|
|
|
|
+ pool3dp := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return baseOK(f) && hasThirdDecimal(f.agg.sumCost)
|
|
|
|
|
+ })
|
|
|
|
|
+ for i, f := range take(pool3dp, perCat) {
|
|
|
|
|
+ add(Case{ID: fmt.Sprintf("syn-income-3dp-%d", i+1), Kind: KindSynthetic, Category: CatIncome3DP,
|
|
|
|
|
+ RecordID: f.rec.id, ExpectTier: f.tier,
|
|
|
|
|
+ Expectation: fmt.Sprintf("Σcost=%.3f 有第三位小数 ⇒ 迁移后 ΣROUND(cost,2) 会变,用于验证 Δincome 归因", f.agg.sumCost),
|
|
|
|
|
+ Note: "专门验证 PRECISION-CONVERGENCE"})
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(pool3dp) < perCat {
|
|
|
|
|
+ notes = append(notes, fmt.Sprintf("income-3dp:候选只有 %d 条", len(pool3dp)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 类 3:income=0 但 student_number>0(min_payment 夹取)----
|
|
|
|
|
+ poolZero := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return baseOK(f) && f.rule.minPay > 0 && f.agg.elig > 0
|
|
|
|
|
+ })
|
|
|
|
|
+ for i, f := range take(poolZero, perCat) {
|
|
|
|
|
+ add(Case{ID: fmt.Sprintf("syn-income-zero-min-%d", i+1), Kind: KindSynthetic, Category: CatIncomeZeroMin,
|
|
|
|
|
+ RecordID: f.rec.id,
|
|
|
|
|
+ Mutations: []string{fmt.Sprintf("UPDATE t_courses_record_student SET cost = 0 WHERE recordid = '%s'", f.rec.id)},
|
|
|
|
|
+ ExpectTier: f.tier,
|
|
|
|
|
+ Expectation: fmt.Sprintf("事务内把 cost 全置 0 ⇒ income=0 但人数=%d>0 ⇒ 应被 min_payment=%.1f 夹取", f.agg.elig, f.rule.minPay),
|
|
|
|
|
+ Note: "验证 min 夹取分支;真实数据里 status!=2 的 income=0 记录都走退化分支,不可比"})
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(poolZero) < perCat {
|
|
|
|
|
+ notes = append(notes, fmt.Sprintf("income-zero-min:候选只有 %d 条", len(poolZero)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 类 4:isSupplement 两值都跑 ----
|
|
|
|
|
+ poolSup := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return baseOK(f) && f.agg.elig >= 2
|
|
|
|
|
+ })
|
|
|
|
|
+ for i, f := range take(poolSup, perCat) {
|
|
|
|
|
+ add(Case{ID: fmt.Sprintf("syn-supplement-both-%d", i+1), Kind: KindSynthetic, Category: CatSupplement,
|
|
|
|
|
+ RecordID: f.rec.id, BothSupplement: true, ExpectTier: f.tier,
|
|
|
|
|
+ Expectation: "同一 record 跑 isSupplement=false / true 各一次;record 级应相同,逐行级应如 R13 所述不同",
|
|
|
|
|
+ Note: "验证 isSupplement 只控制「是否重写逐行」"})
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(poolSup) < perCat {
|
|
|
|
|
+ notes = append(notes, fmt.Sprintf("supplement-both:候选只有 %d 条", len(poolSup)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 类 5:人数不整除(R13)----
|
|
|
|
|
+ poolNonDiv := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return baseOK(f) && f.agg.elig >= 2 && nondivisible(f.agg.sumCost, f.rule, f.agg.elig)
|
|
|
|
|
+ })
|
|
|
|
|
+ for i, f := range take(poolNonDiv, perCat) {
|
|
|
|
|
+ add(Case{ID: fmt.Sprintf("syn-nondivisible-%d", i+1), Kind: KindSynthetic, Category: CatNonDivisible,
|
|
|
|
|
+ RecordID: f.rec.id, ExpectTier: f.tier,
|
|
|
|
|
+ Expectation: fmt.Sprintf("总额/人数 不整除(人数=%d)⇒ PHP 每行 round(,3) 与 Go 分位分配必然不同", f.agg.elig),
|
|
|
|
|
+ Note: "专门验证 R13,逐行不参与通过判定"})
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(poolNonDiv) < perCat {
|
|
|
|
|
+ notes = append(notes, fmt.Sprintf("nondivisible:候选只有 %d 条", len(poolNonDiv)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 类 6:rewardType=2 固定课酬(人数 1/6/9 + 2 条补充)----
|
|
|
|
|
+ wantSizes := []int{1, 6, 9, 3, 7}
|
|
|
|
|
+ for _, want := range wantSizes {
|
|
|
|
|
+ pool := r.collect(records, now, func(f caseFacts) bool {
|
|
|
|
|
+ return f.rec.status == 2 && f.agg.elig == want && f.hit && !used[f.rec.id]
|
|
|
|
|
+ })
|
|
|
|
|
+ got := take(pool, 1)
|
|
|
|
|
+ if len(got) != 1 {
|
|
|
|
|
+ notes = append(notes, fmt.Sprintf("rewardtype2 人数=%d:无法造数(找不到 elig=%d 且有命中的记录)", want, want))
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ f := got[0]
|
|
|
|
|
+ add(Case{ID: fmt.Sprintf("syn-rewardtype2-n%d", want), Kind: KindSynthetic, Category: CatRewardType2,
|
|
|
|
|
+ RecordID: f.rec.id,
|
|
|
|
|
+ Mutations: []string{fmt.Sprintf("UPDATE t_courses_record SET teacher_reward_type = 2 WHERE id = '%s'", f.rec.id)},
|
|
|
|
|
+ ExpectTier: f.tier,
|
|
|
|
|
+ Expectation: fmt.Sprintf("置 teacher_reward_type=2(人数=%d)⇒ 按 makeupCost 查表,>6 人应取 'max'=>200", f.agg.elig),
|
|
|
|
|
+ Note: "验证固定课酬查表与超表兜底"})
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return out, notes
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// hasThirdDecimal 判断金额是否有第三位小数(即迁移 3→2 位会变)。
|
|
|
|
|
+func hasThirdDecimal(v float64) bool {
|
|
|
|
|
+ milli := int64(v*1000 + 0.5*signOf(v))
|
|
|
|
|
+ return milli%10 != 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func signOf(v float64) float64 {
|
|
|
|
|
+ if v < 0 {
|
|
|
|
|
+ return -1
|
|
|
|
|
+ }
|
|
|
|
|
+ return 1
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// nondivisible 判断"课酬总额 / 人数"在分位是否除不尽(R13 的触发条件)。
|
|
|
|
|
+func nondivisible(sumCost float64, rule detailRow, n int) bool {
|
|
|
|
|
+ if n <= 0 || rule.percentage == 0 {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ reward := sumCost*rule.percentage/100 + rule.reward
|
|
|
|
|
+ if rule.maxPay > 0 && reward > rule.maxPay {
|
|
|
|
|
+ reward = rule.maxPay
|
|
|
|
|
+ } else if rule.minPay > 0 && reward < rule.minPay {
|
|
|
|
|
+ reward = rule.minPay
|
|
|
|
|
+ }
|
|
|
|
|
+ cents := int64(reward*100 + 0.5)
|
|
|
|
|
+ return cents%int64(n) != 0
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// tierLabel 把 refs 的内部层级名转成报告用词(保持与 SP 一致)。
|
|
|
|
|
+func tierLabel(t string) string {
|
|
|
|
|
+ switch t {
|
|
|
|
|
+ case TierTeacherClassify, TierTeacherGradeSubject, TierCert, TierGeneric, TierDegraded:
|
|
|
|
|
+ return t
|
|
|
|
|
+ case "":
|
|
|
|
|
+ return TierNone
|
|
|
|
|
+ default:
|
|
|
|
|
+ return t
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// String 便于调试输出。
|
|
|
|
|
+func (f caseFacts) String() string {
|
|
|
|
|
+ return strings.Join([]string{
|
|
|
|
|
+ f.rec.id,
|
|
|
|
|
+ fmt.Sprintf("tier=%s", f.tier),
|
|
|
|
|
+ fmt.Sprintf("income=%.3f", f.agg.sumCost),
|
|
|
|
|
+ fmt.Sprintf("elig=%d", f.agg.elig),
|
|
|
|
|
+ }, " ")
|
|
|
|
|
+}
|