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 } add := func(c Case) { out = append(out, c) } // ---- 类 1:四级兜底,**每级各 perCat 条**(默认 5 × 4 = 20 条)---- // // 为什么要每级 5 条而不是凑 5 条:四级是"最担心的未验证路径"(真实数据里两级 0 命中), // 造数成本几乎为零;而 1~2 条测不出「有 min 无 max / 无 min 无 max / 有 max 无 min」这类组合差异 —— // `min_payment=0 表示不设限` 这个语义正是 D10 踩过的坑。 tierSpecs := []struct { tier string pred func(f caseFacts) bool note string }{ {TierTeacherGradeSubject, func(f caseFacts) bool { return baseOK(f) && f.tier == TierTeacherGradeSubject }, "现成记录即命中 teacher-grade-subject(无需造数)"}, {TierGeneric, func(f caseFacts) bool { return baseOK(f) && f.tier == TierGeneric }, "现成记录即命中 generic-tier(无需造数)"}, {TierTeacherClassify, func(f caseFacts) bool { return baseOK(f) && f.tier == TierGeneric && f.cls != nil && f.rec.teacherid > 0 }, "造一条 teacherid=该老师 且 classifyid=该分类 的规则 ⇒ 应命中 teacher-classify"}, {TierCert, func(f caseFacts) bool { return baseOK(f) && f.tier == TierGeneric && f.teacher.cert == 0 && f.cls != nil }, "老师置持证 + 造 teacher_cert=1 且教龄 0~99 的通用规则 ⇒ 应命中 cert-tier"}, } for _, spec := range tierSpecs { pool := r.collect(records, now, spec.pred) got := take(pool, perCat) if len(got) < perCat { notes = append(notes, fmt.Sprintf("tier-%s:候选只有 %d 条(要求 %d)", spec.tier, len(got), perCat)) } for i, f := range got { c := Case{ ID: fmt.Sprintf("syn-tier-%s-%d", strings.ReplaceAll(spec.tier, "-", "_"), i+1), Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id, ExpectTier: spec.tier, Expectation: spec.note, Note: "四级兜底之一(P0 §13:classify / cert 真实命中数为 0,必须造数)", } payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime) switch spec.tier { case TierTeacherClassify: // 三种 min/max 组合轮转,覆盖「只设 min」「只设 max」「都不设」 minPay, maxPay := 0.0, 0.0 switch i % 3 { case 0: minPay = 80.0 case 1: maxPay = 260.0 case 2: minPay, maxPay = 90.0, 240.0 } c.Mutations = []string{insertRuleSQL(payid, f.cls.classType, f.cls.id, f.rec.teacherid, 0, 33.0, minPay, maxPay, 0, 0, 99, "ac15-syn-classify")} c.Expectation += fmt.Sprintf("(min=%.0f max=%.0f)", minPay, maxPay) case TierCert: minPay, maxPay := 0.0, 0.0 switch i % 3 { case 0: minPay = 70.0 case 1: maxPay = 300.0 case 2: minPay, maxPay = 60.0, 280.0 } c.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, minPay, maxPay, 0, 0, 99, "ac15-syn-cert"), } c.Expectation += fmt.Sprintf("(min=%.0f max=%.0f)", minPay, maxPay) } add(c) } } // ---- 类 2:income 带 3 位小数,**五种尾数各一条**---- // // 五个尾数分别对应五种收敛方向,缺一种就等于没测: // .333 舍 / .667 入 / .999 跨整数进位 / .005 恰好半分 / .347 保留原样 tails := []struct { total float64 tail string why string }{ {100.333, "333", "舍(100.333 → 100.33)"}, {200.667, "667", "入(200.667 → 200.67)"}, {299.999, "999", "跨整数进位(299.999 → 300.00),Δincome=+0.001 最易漏"}, {300.005, "005", "恰好半分(300.005 → 300.01),四舍五入边界"}, {349.347, "347", "保留原样(349.347 → 349.35)"}, } pool3dpBase := r.collect(records, now, func(f caseFacts) bool { return baseOK(f) && f.agg.elig >= 1 }) got3dp := take(pool3dpBase, len(tails)) if len(got3dp) < len(tails) { notes = append(notes, fmt.Sprintf("income-3dp:候选只有 %d 条(要求 %d)", len(got3dp), len(tails))) } for i, f := range got3dp { t := tails[i] add(Case{ ID: fmt.Sprintf("syn-income-3dp-%s", t.tail), Kind: KindSynthetic, Category: CatIncome3DP, RecordID: f.rec.id, ExpectTier: f.tier, Mutations: []string{ fmt.Sprintf("UPDATE t_courses_record_student SET cost = 0 WHERE recordid = '%s'", f.rec.id), fmt.Sprintf("UPDATE t_courses_record_student SET cost = %.3f WHERE recordid = '%s' AND type IN (1,2) ORDER BY studentid LIMIT 1", t.total, f.rec.id), }, Expectation: fmt.Sprintf("事务内把 income 钉成 %.3f(尾数 .%s):%s ⇒ 迁移后 ΣROUND(cost,2) 与 legacy round(Σ,3) 必不同", t.total, t.tail, t.why), Note: "专门验证 PRECISION-CONVERGENCE 的五个收敛方向", }) } // ---- 类 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), }, " ") }