| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725 |
- 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),
- }, " ")
- }
|