cases.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. package dualrun
  2. import (
  3. "database/sql"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. "time"
  8. )
  9. // 兜底层级(与 SP 的四级兜底一一对应)。
  10. const (
  11. TierTeacherClassify = "teacher-classify" // 老师 + 具体分类
  12. TierTeacherGradeSubject = "teacher-grade-subject" // 老师 + 年级/科目
  13. TierCert = "cert-tier" // 持证老师 + 教龄档
  14. TierGeneric = "generic-tier" // 通用 + 教龄档
  15. TierDegraded = "degraded" // SP 无命中 ⇒ PHP 走 legacy 退化分支
  16. TierNone = "none"
  17. )
  18. // 用例类别。
  19. const (
  20. CatBaseline = "baseline"
  21. CatTier = "tier"
  22. CatIncome3DP = "income-3dp"
  23. CatIncomeZeroMin = "income-zero-min"
  24. CatSupplement = "supplement-both"
  25. CatNonDivisible = "nondivisible"
  26. CatRewardType2 = "rewardtype2"
  27. )
  28. // ---------- 内存参照表(小表全量装载,避免 74,870 次相关子查询)----------
  29. type payRow struct {
  30. id int64
  31. schoolid int64
  32. campusid int64
  33. actived int64
  34. effect time.Time
  35. }
  36. type clsRow struct {
  37. id int64
  38. gradeid int64
  39. subjectid int64
  40. classType int64
  41. }
  42. type teacherRow struct {
  43. exp time.Time
  44. cert int64
  45. hasExp bool
  46. }
  47. type detailRow struct {
  48. id int64
  49. paymentid int64
  50. teacherid int64
  51. cert int64
  52. classType int64
  53. gradeid int64
  54. subjectid int64
  55. expMin float64
  56. expMax float64
  57. classifyid int64
  58. percentage float64
  59. minPay float64
  60. maxPay float64
  61. reward float64
  62. }
  63. type aggRow struct {
  64. sumCost float64
  65. elig int
  66. rows int
  67. }
  68. type refs struct {
  69. pays []payRow
  70. classifies map[int64]*clsRow
  71. courseClass map[int64]int64
  72. teachers map[int64]teacherRow
  73. details map[int64][]detailRow // paymentid → 规则
  74. detailByID map[int64]detailRow
  75. studentAgg map[string]aggRow // recordid → 打卡聚合
  76. }
  77. func loadRefs(db *sql.DB, schema string) (*refs, error) {
  78. r := &refs{
  79. classifies: map[int64]*clsRow{},
  80. courseClass: map[int64]int64{},
  81. teachers: map[int64]teacherRow{},
  82. details: map[int64][]detailRow{},
  83. detailByID: map[int64]detailRow{},
  84. studentAgg: map[string]aggRow{},
  85. }
  86. rows, err := db.Query("SELECT id, schoolid, IFNULL(campusid,0), CAST(IFNULL(actived,0) AS UNSIGNED), " +
  87. "DATE_FORMAT(IFNULL(effect_date,'1970-01-01'),'%Y-%m-%d %H:%i:%s') FROM " + schema + ".t_course_payment")
  88. if err != nil {
  89. return nil, fmt.Errorf("load payments: %w", err)
  90. }
  91. for rows.Next() {
  92. var p payRow
  93. var eff string
  94. if err := rows.Scan(&p.id, &p.schoolid, &p.campusid, &p.actived, &eff); err == nil {
  95. p.effect, _ = time.ParseInLocation("2006-01-02 15:04:05", eff, time.Local)
  96. r.pays = append(r.pays, p)
  97. }
  98. }
  99. rows.Close()
  100. if rows, err = db.Query("SELECT id, IFNULL(gradeid,-1), IFNULL(subjectid,-1), IFNULL(class_type,0) FROM " + schema + ".t_courses_classify"); err == nil {
  101. for rows.Next() {
  102. var c clsRow
  103. if err := rows.Scan(&c.id, &c.gradeid, &c.subjectid, &c.classType); err == nil {
  104. cc := c
  105. r.classifies[c.id] = &cc
  106. }
  107. }
  108. rows.Close()
  109. } else {
  110. return nil, fmt.Errorf("load classify: %w", err)
  111. }
  112. if rows, err = db.Query("SELECT id, IFNULL(classifyid,0) FROM " + schema + ".t_courses"); err == nil {
  113. for rows.Next() {
  114. var id, cid int64
  115. if err := rows.Scan(&id, &cid); err == nil {
  116. r.courseClass[id] = cid
  117. }
  118. }
  119. rows.Close()
  120. } else {
  121. return nil, fmt.Errorf("load courses: %w", err)
  122. }
  123. if rows, err = db.Query("SELECT teacherid, IFNULL(DATE_FORMAT(teacher_exp,'%Y-%m-%d %H:%i:%s'),''), " +
  124. "CAST(IFNULL(teacher_cert,0) AS UNSIGNED) FROM " + schema + ".t_teacher_information"); err == nil {
  125. for rows.Next() {
  126. var id int64
  127. var expStr string
  128. var t teacherRow
  129. if err := rows.Scan(&id, &expStr, &t.cert); err == nil {
  130. if expStr != "" {
  131. t.exp, _ = time.ParseInLocation("2006-01-02 15:04:05", expStr, time.Local)
  132. t.hasExp = true
  133. }
  134. r.teachers[id] = t
  135. }
  136. }
  137. rows.Close()
  138. } else {
  139. return nil, fmt.Errorf("load teachers: %w", err)
  140. }
  141. rows, err = db.Query("SELECT id, course_paymentid, IFNULL(teacherid,0), CAST(IFNULL(teacher_cert,0) AS UNSIGNED), " +
  142. "IFNULL(class_type,0), IFNULL(gradeid,-1), IFNULL(subjectid,-1), IFNULL(teacher_exp_min,0), IFNULL(teacher_exp_max,0), " +
  143. "IFNULL(classifyid,0), IFNULL(percentage,0), IFNULL(min_payment,0), IFNULL(max_payment,0), IFNULL(reward,0) FROM " +
  144. schema + ".t_course_payment_detail")
  145. if err != nil {
  146. return nil, fmt.Errorf("load rules: %w", err)
  147. }
  148. for rows.Next() {
  149. var d detailRow
  150. if err := rows.Scan(&d.id, &d.paymentid, &d.teacherid, &d.cert, &d.classType, &d.gradeid, &d.subjectid,
  151. &d.expMin, &d.expMax, &d.classifyid, &d.percentage, &d.minPay, &d.maxPay, &d.reward); err == nil {
  152. r.details[d.paymentid] = append(r.details[d.paymentid], d)
  153. r.detailByID[d.id] = d
  154. }
  155. }
  156. rows.Close()
  157. rows, err = db.Query("SELECT recordid, IFNULL(SUM(cost),0), SUM(type IN (1,2)), COUNT(*) FROM " +
  158. schema + ".t_courses_record_student GROUP BY recordid")
  159. if err != nil {
  160. return nil, fmt.Errorf("load student agg: %w", err)
  161. }
  162. for rows.Next() {
  163. var rid string
  164. var a aggRow
  165. if err := rows.Scan(&rid, &a.sumCost, &a.elig, &a.rows); err == nil {
  166. r.studentAgg[rid] = a
  167. }
  168. }
  169. rows.Close()
  170. return r, nil
  171. }
  172. // resolvePayment 复刻 SP 的两级找方案:先 (school,campus),再 (school,0)。
  173. func (r *refs) resolvePayment(schoolid, campusid int64, at time.Time) int64 {
  174. best, varEff := int64(0), time.Time{}
  175. for _, p := range r.pays {
  176. if p.schoolid != schoolid || p.actived != 1 || p.campusid != campusid || p.effect.After(at) {
  177. continue
  178. }
  179. if best == 0 || p.effect.After(varEff) {
  180. best, varEff = p.id, p.effect
  181. }
  182. }
  183. if best > 0 {
  184. return best
  185. }
  186. for _, p := range r.pays {
  187. if p.schoolid != schoolid || p.actived != 1 || p.campusid != 0 || p.effect.After(at) {
  188. continue
  189. }
  190. if best == 0 || p.effect.After(varEff) {
  191. best, varEff = p.id, p.effect
  192. }
  193. }
  194. return best
  195. }
  196. // hitRule 复刻 sp_course_payment_detail_get_teacher_detail 的判定顺序,返回 (规则, 层级)。
  197. //
  198. // 顺序(与 SP 一致,不可换):
  199. // 1. teacher-classify 老师专属 + classifyid 精确匹配
  200. // 2. teacher-grade-subject 老师专属 + 年级/科目(含 -1 通配)
  201. // 3. cert-tier 持证 + 教龄档
  202. // 4. generic-tier 通用 + 教龄档
  203. //
  204. // 注意 SP 用 `id>1` 才采纳(历史怪癖),这里照抄。
  205. func (r *refs) hitRule(schoolid, campusid, teacherid int64, at time.Time, cls *clsRow, teacherExp float64, teacherCert int64) (detailRow, string, bool) {
  206. if cls == nil {
  207. return detailRow{}, TierNone, false
  208. }
  209. payid := r.resolvePayment(schoolid, campusid, at)
  210. if payid <= 0 {
  211. return detailRow{}, TierDegraded, false
  212. }
  213. if teacherExp < 0 {
  214. teacherExp = 0
  215. }
  216. oneself := 0
  217. for _, d := range r.details[payid] {
  218. if d.teacherid == teacherid && d.classType == cls.classType {
  219. oneself++
  220. }
  221. }
  222. find := func(match func(d detailRow) bool) (detailRow, bool) {
  223. var best detailRow
  224. found := false
  225. for _, d := range r.details[payid] {
  226. if d.classType != cls.classType || !match(d) {
  227. continue
  228. }
  229. if !found || d.id < best.id {
  230. best, found = d, true
  231. }
  232. }
  233. if found && best.id > 1 {
  234. return best, true
  235. }
  236. return detailRow{}, false
  237. }
  238. if oneself > 0 {
  239. if d, ok := find(func(d detailRow) bool { return d.teacherid == teacherid && d.classifyid == cls.id }); ok {
  240. return d, TierTeacherClassify, true
  241. }
  242. if d, ok := find(func(d detailRow) bool {
  243. return d.teacherid == teacherid &&
  244. (d.gradeid == cls.gradeid || d.gradeid == -1) &&
  245. (d.subjectid == cls.subjectid || d.subjectid == -1)
  246. }); ok {
  247. return d, TierTeacherGradeSubject, true
  248. }
  249. }
  250. if teacherCert > 0 {
  251. if d, ok := find(func(d detailRow) bool {
  252. return d.teacherid == 0 && d.cert == 1 &&
  253. (d.gradeid == cls.gradeid || d.gradeid == -1) &&
  254. (d.subjectid == cls.subjectid || d.subjectid == -1) &&
  255. teacherExp >= d.expMin && teacherExp <= d.expMax
  256. }); ok {
  257. return d, TierCert, true
  258. }
  259. }
  260. if d, ok := find(func(d detailRow) bool {
  261. return d.teacherid == 0 && d.cert == 0 &&
  262. (d.gradeid == cls.gradeid || d.gradeid == -1) &&
  263. (d.subjectid == cls.subjectid || d.subjectid == -1) &&
  264. teacherExp >= d.expMin && teacherExp <= d.expMax
  265. }); ok {
  266. return d, TierGeneric, true
  267. }
  268. return detailRow{}, TierDegraded, false
  269. }
  270. // teacherExpYears 与 PHP `Utils::calculatingAge()` 同口径:固定 365 天/年、四舍五入 1 位。
  271. //
  272. // ⚠ 这里用 runner 自己的 `now` 计算,只用于**选 case 与命中预判**;
  273. // 真正参与 oracle 计算的教龄由 PHP 侧 `time()` 决定(A22:PHP 时钟不受 SET timestamp 影响)。
  274. func teacherExpYears(t teacherRow, now time.Time) float64 {
  275. if !t.hasExp || t.exp.Year() <= 1970 {
  276. return 0
  277. }
  278. years := now.Sub(t.exp).Hours() / 24 / 365
  279. return float64(int(years*10+0.5)) / 10
  280. }
  281. // recordRow 是挑选 case 时需要的 record 字段。
  282. type recordRow struct {
  283. id string
  284. schoolid int64
  285. campusid int64
  286. teacherid int64
  287. coursesid int64
  288. rewardType int8
  289. stuNumber int
  290. status int
  291. income float64
  292. startTime time.Time
  293. }
  294. // loadRecords 流式扫描 record(只取必要列)。
  295. func loadRecords(db *sql.DB, schema string) ([]recordRow, error) {
  296. rows, err := db.Query("SELECT id, schoolid, IFNULL(campusid,0), teacherid, coursesid, " +
  297. "IFNULL(teacher_reward_type,1), IFNULL(student_number,0), IFNULL(status,0), IFNULL(income,0), " +
  298. "DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') FROM " + schema + ".t_courses_record " +
  299. "WHERE teacherid>0 AND actual_start_time IS NOT NULL AND coursesid IS NOT NULL ORDER BY id")
  300. if err != nil {
  301. return nil, err
  302. }
  303. defer rows.Close()
  304. var out []recordRow
  305. for rows.Next() {
  306. var rec recordRow
  307. var st string
  308. if err := rows.Scan(&rec.id, &rec.schoolid, &rec.campusid, &rec.teacherid, &rec.coursesid,
  309. &rec.rewardType, &rec.stuNumber, &rec.status, &rec.income, &st); err == nil {
  310. rec.startTime, _ = time.ParseInLocation("2006-01-02 15:04:05", st, time.Local)
  311. out = append(out, rec)
  312. }
  313. }
  314. return out, rows.Err()
  315. }
  316. // caseFacts 是一条 record 在"选 case"阶段算出的全部判定依据。
  317. type caseFacts struct {
  318. rec recordRow
  319. cls *clsRow
  320. teacher teacherRow
  321. expYears float64
  322. agg aggRow
  323. rule detailRow
  324. tier string
  325. hit bool
  326. }
  327. // buildFacts 给一条 record 补全判定依据。
  328. func (r *refs) buildFacts(rec recordRow, now time.Time) caseFacts {
  329. cls := r.classifies[r.courseClass[rec.coursesid]]
  330. t := r.teachers[rec.teacherid]
  331. exp := teacherExpYears(t, now)
  332. rule, tier, ok := r.hitRule(rec.schoolid, rec.campusid, rec.teacherid, rec.startTime, cls, exp, t.cert)
  333. return caseFacts{
  334. rec: rec, cls: cls, teacher: t, expYears: exp,
  335. agg: r.studentAgg[rec.id], rule: rule, tier: tier, hit: ok,
  336. }
  337. }
  338. // ---------- 真实基线:分层抽样 ----------
  339. func pctBand(d detailRow) string {
  340. switch {
  341. case d.percentage <= 25:
  342. return "pct<=25"
  343. case d.percentage <= 50:
  344. return "pct26-50"
  345. default:
  346. return "pct>50"
  347. }
  348. }
  349. func sizeBand(elig int) string {
  350. switch {
  351. case elig <= 1:
  352. return "n1"
  353. case elig <= 5:
  354. return "n2-5"
  355. default:
  356. return "n6+"
  357. }
  358. }
  359. // BuildRealCases 按「percentage 区间 × 人数区间」分层抽样 n 条真实基线。
  360. //
  361. // 分层而不是取前 n 条:前 n 条集中在某个校区/某位老师,会给出"看起来很整齐"的假结论。
  362. func BuildRealCases(records []recordRow, r *refs, n int, now time.Time) []Case {
  363. buckets := map[string][]caseFacts{}
  364. var keys []string
  365. for _, rec := range records {
  366. if rec.status != 2 {
  367. continue // 已结课
  368. }
  369. f := r.buildFacts(rec, now)
  370. if !f.hit || f.agg.sumCost <= 0 || f.agg.elig <= 0 {
  371. continue // 无命中 / 无收入 / 无有效学生 ⇒ 不进基线
  372. }
  373. k := pctBand(f.rule) + "|" + sizeBand(f.agg.elig)
  374. if _, ok := buckets[k]; !ok {
  375. keys = append(keys, k)
  376. }
  377. buckets[k] = append(buckets[k], f)
  378. }
  379. sort.Strings(keys)
  380. // 桶内按 record id 排序(稳定),桶间轮转取数 ⇒ 分层且可复现
  381. for _, k := range keys {
  382. sort.Slice(buckets[k], func(i, j int) bool { return buckets[k][i].rec.id < buckets[k][j].rec.id })
  383. }
  384. var out []Case
  385. idx := make([]int, len(keys))
  386. for len(out) < n {
  387. progressed := false
  388. for bi, k := range keys {
  389. if idx[bi] >= len(buckets[k]) {
  390. continue
  391. }
  392. f := buckets[k][idx[bi]]
  393. idx[bi]++
  394. progressed = true
  395. out = append(out, Case{
  396. ID: fmt.Sprintf("real-%04d", len(out)+1),
  397. Kind: KindReal,
  398. Category: CatBaseline,
  399. RecordID: f.rec.id,
  400. ExpectTier: f.tier,
  401. Expectation: fmt.Sprintf("分层桶 %s,期望 %s 命中;income=%.3f,人数=%d",
  402. k, f.tier, f.agg.sumCost, f.agg.elig),
  403. Note: "真实基线:status=2 且 income>0 且有规则命中",
  404. })
  405. if len(out) >= n {
  406. break
  407. }
  408. }
  409. if !progressed {
  410. break
  411. }
  412. }
  413. return out
  414. }
  415. // ---------- 合成边界 ----------
  416. // synPool 收集某一特征下的候选 record(按 id 升序,保证可复现)。
  417. type synPool struct {
  418. facts []caseFacts
  419. }
  420. func (r *refs) collect(records []recordRow, now time.Time, pred func(caseFacts) bool) []caseFacts {
  421. var out []caseFacts
  422. for _, rec := range records {
  423. f := r.buildFacts(rec, now)
  424. if pred(f) {
  425. out = append(out, f)
  426. }
  427. }
  428. sort.Slice(out, func(i, j int) bool { return out[i].rec.id < out[j].rec.id })
  429. return out
  430. }
  431. // insertRuleSQL 造一条规则(事务内插入,随回滚消失)。
  432. func insertRuleSQL(paymentID int64, classType, classifyID, teacherID int64, teacherCert int, pct, minPay, maxPay, reward float64, expMin, expMax float64, name string) string {
  433. return fmt.Sprintf("INSERT INTO t_course_payment_detail "+
  434. "(course_paymentid, gradeid, subjectid, class_type, classifyid, teacherid, name, percentage, min_payment, max_payment, reward, teacher_cert, teacher_exp_min, teacher_exp_max, actived) "+
  435. "VALUES (%d, -1, -1, %d, %d, %d, '%s', %.1f, %.1f, %.1f, %.1f, b'%d', %.3f, %.3f, b'1')",
  436. paymentID, classType, classifyID, teacherID, name, pct, minPay, maxPay, reward, teacherCert, expMin, expMax)
  437. }
  438. // BuildSyntheticCases 造 6 类 × perCat 条合成边界。
  439. //
  440. // 每类 5 条(perCat=5),其中 tier 类覆盖四级兜底:teacher-grade-subject / generic-tier 直接选现成记录,
  441. // teacher-classify / cert-tier 真实命中数为 0,**必须靠事务内造规则**才可能命中(见 P0 §13)。
  442. func BuildSyntheticCases(records []recordRow, r *refs, perCat int, now time.Time) ([]Case, []string) {
  443. var out []Case
  444. var notes []string
  445. used := map[string]bool{}
  446. baseOK := func(f caseFacts) bool {
  447. return f.hit && f.agg.sumCost > 0 && f.agg.elig > 0 && f.rec.status == 2 && !used[f.rec.id]
  448. }
  449. take := func(pool []caseFacts, n int) []caseFacts {
  450. var got []caseFacts
  451. for _, f := range pool {
  452. if len(got) >= n {
  453. break
  454. }
  455. if used[f.rec.id] {
  456. continue
  457. }
  458. got = append(got, f)
  459. used[f.rec.id] = true
  460. }
  461. return got
  462. }
  463. add := func(c Case) { out = append(out, c) }
  464. // ---- 类 1:四级兜底,**每级各 perCat 条**(默认 5 × 4 = 20 条)----
  465. //
  466. // 为什么要每级 5 条而不是凑 5 条:四级是"最担心的未验证路径"(真实数据里两级 0 命中),
  467. // 造数成本几乎为零;而 1~2 条测不出「有 min 无 max / 无 min 无 max / 有 max 无 min」这类组合差异 ——
  468. // `min_payment=0 表示不设限` 这个语义正是 D10 踩过的坑。
  469. tierSpecs := []struct {
  470. tier string
  471. pred func(f caseFacts) bool
  472. note string
  473. }{
  474. {TierTeacherGradeSubject, func(f caseFacts) bool {
  475. return baseOK(f) && f.tier == TierTeacherGradeSubject
  476. }, "现成记录即命中 teacher-grade-subject(无需造数)"},
  477. {TierGeneric, func(f caseFacts) bool {
  478. return baseOK(f) && f.tier == TierGeneric
  479. }, "现成记录即命中 generic-tier(无需造数)"},
  480. {TierTeacherClassify, func(f caseFacts) bool {
  481. return baseOK(f) && f.tier == TierGeneric && f.cls != nil && f.rec.teacherid > 0
  482. }, "造一条 teacherid=该老师 且 classifyid=该分类 的规则 ⇒ 应命中 teacher-classify"},
  483. {TierCert, func(f caseFacts) bool {
  484. return baseOK(f) && f.tier == TierGeneric && f.teacher.cert == 0 && f.cls != nil
  485. }, "老师置持证 + 造 teacher_cert=1 且教龄 0~99 的通用规则 ⇒ 应命中 cert-tier"},
  486. }
  487. for _, spec := range tierSpecs {
  488. pool := r.collect(records, now, spec.pred)
  489. got := take(pool, perCat)
  490. if len(got) < perCat {
  491. notes = append(notes, fmt.Sprintf("tier-%s:候选只有 %d 条(要求 %d)", spec.tier, len(got), perCat))
  492. }
  493. for i, f := range got {
  494. c := Case{
  495. ID: fmt.Sprintf("syn-tier-%s-%d", strings.ReplaceAll(spec.tier, "-", "_"), i+1),
  496. Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
  497. ExpectTier: spec.tier, Expectation: spec.note,
  498. Note: "四级兜底之一(P0 §13:classify / cert 真实命中数为 0,必须造数)",
  499. }
  500. payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime)
  501. switch spec.tier {
  502. case TierTeacherClassify:
  503. // 三种 min/max 组合轮转,覆盖「只设 min」「只设 max」「都不设」
  504. minPay, maxPay := 0.0, 0.0
  505. switch i % 3 {
  506. case 0:
  507. minPay = 80.0
  508. case 1:
  509. maxPay = 260.0
  510. case 2:
  511. minPay, maxPay = 90.0, 240.0
  512. }
  513. c.Mutations = []string{insertRuleSQL(payid, f.cls.classType, f.cls.id, f.rec.teacherid, 0,
  514. 33.0, minPay, maxPay, 0, 0, 99, "ac15-syn-classify")}
  515. c.Expectation += fmt.Sprintf("(min=%.0f max=%.0f)", minPay, maxPay)
  516. case TierCert:
  517. minPay, maxPay := 0.0, 0.0
  518. switch i % 3 {
  519. case 0:
  520. minPay = 70.0
  521. case 1:
  522. maxPay = 300.0
  523. case 2:
  524. minPay, maxPay = 60.0, 280.0
  525. }
  526. c.Mutations = []string{
  527. fmt.Sprintf("UPDATE t_teacher_information SET teacher_cert = b'1' WHERE teacherid = %d", f.rec.teacherid),
  528. insertRuleSQL(payid, f.cls.classType, 0, 0, 1, 44.0, minPay, maxPay, 0, 0, 99, "ac15-syn-cert"),
  529. }
  530. c.Expectation += fmt.Sprintf("(min=%.0f max=%.0f)", minPay, maxPay)
  531. }
  532. add(c)
  533. }
  534. }
  535. // ---- 类 2:income 带 3 位小数,**五种尾数各一条**----
  536. //
  537. // 五个尾数分别对应五种收敛方向,缺一种就等于没测:
  538. // .333 舍 / .667 入 / .999 跨整数进位 / .005 恰好半分 / .347 保留原样
  539. tails := []struct {
  540. total float64
  541. tail string
  542. why string
  543. }{
  544. {100.333, "333", "舍(100.333 → 100.33)"},
  545. {200.667, "667", "入(200.667 → 200.67)"},
  546. {299.999, "999", "跨整数进位(299.999 → 300.00),Δincome=+0.001 最易漏"},
  547. {300.005, "005", "恰好半分(300.005 → 300.01),四舍五入边界"},
  548. {349.347, "347", "保留原样(349.347 → 349.35)"},
  549. }
  550. pool3dpBase := r.collect(records, now, func(f caseFacts) bool {
  551. return baseOK(f) && f.agg.elig >= 1
  552. })
  553. got3dp := take(pool3dpBase, len(tails))
  554. if len(got3dp) < len(tails) {
  555. notes = append(notes, fmt.Sprintf("income-3dp:候选只有 %d 条(要求 %d)", len(got3dp), len(tails)))
  556. }
  557. for i, f := range got3dp {
  558. t := tails[i]
  559. add(Case{
  560. ID: fmt.Sprintf("syn-income-3dp-%s", t.tail), Kind: KindSynthetic, Category: CatIncome3DP,
  561. RecordID: f.rec.id, ExpectTier: f.tier,
  562. Mutations: []string{
  563. fmt.Sprintf("UPDATE t_courses_record_student SET cost = 0 WHERE recordid = '%s'", f.rec.id),
  564. 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),
  565. },
  566. Expectation: fmt.Sprintf("事务内把 income 钉成 %.3f(尾数 .%s):%s ⇒ 迁移后 ΣROUND(cost,2) 与 legacy round(Σ,3) 必不同", t.total, t.tail, t.why),
  567. Note: "专门验证 PRECISION-CONVERGENCE 的五个收敛方向",
  568. })
  569. }
  570. // ---- 类 3:income=0 但 student_number>0(min_payment 夹取)----
  571. poolZero := r.collect(records, now, func(f caseFacts) bool {
  572. return baseOK(f) && f.rule.minPay > 0 && f.agg.elig > 0
  573. })
  574. for i, f := range take(poolZero, perCat) {
  575. add(Case{ID: fmt.Sprintf("syn-income-zero-min-%d", i+1), Kind: KindSynthetic, Category: CatIncomeZeroMin,
  576. RecordID: f.rec.id,
  577. Mutations: []string{fmt.Sprintf("UPDATE t_courses_record_student SET cost = 0 WHERE recordid = '%s'", f.rec.id)},
  578. ExpectTier: f.tier,
  579. Expectation: fmt.Sprintf("事务内把 cost 全置 0 ⇒ income=0 但人数=%d>0 ⇒ 应被 min_payment=%.1f 夹取", f.agg.elig, f.rule.minPay),
  580. Note: "验证 min 夹取分支;真实数据里 status!=2 的 income=0 记录都走退化分支,不可比"})
  581. }
  582. if len(poolZero) < perCat {
  583. notes = append(notes, fmt.Sprintf("income-zero-min:候选只有 %d 条", len(poolZero)))
  584. }
  585. // ---- 类 4:isSupplement 两值都跑 ----
  586. poolSup := r.collect(records, now, func(f caseFacts) bool {
  587. return baseOK(f) && f.agg.elig >= 2
  588. })
  589. for i, f := range take(poolSup, perCat) {
  590. add(Case{ID: fmt.Sprintf("syn-supplement-both-%d", i+1), Kind: KindSynthetic, Category: CatSupplement,
  591. RecordID: f.rec.id, BothSupplement: true, ExpectTier: f.tier,
  592. Expectation: "同一 record 跑 isSupplement=false / true 各一次;record 级应相同,逐行级应如 R13 所述不同",
  593. Note: "验证 isSupplement 只控制「是否重写逐行」"})
  594. }
  595. if len(poolSup) < perCat {
  596. notes = append(notes, fmt.Sprintf("supplement-both:候选只有 %d 条", len(poolSup)))
  597. }
  598. // ---- 类 5:人数不整除(R13)----
  599. poolNonDiv := r.collect(records, now, func(f caseFacts) bool {
  600. return baseOK(f) && f.agg.elig >= 2 && nondivisible(f.agg.sumCost, f.rule, f.agg.elig)
  601. })
  602. for i, f := range take(poolNonDiv, perCat) {
  603. add(Case{ID: fmt.Sprintf("syn-nondivisible-%d", i+1), Kind: KindSynthetic, Category: CatNonDivisible,
  604. RecordID: f.rec.id, ExpectTier: f.tier,
  605. Expectation: fmt.Sprintf("总额/人数 不整除(人数=%d)⇒ PHP 每行 round(,3) 与 Go 分位分配必然不同", f.agg.elig),
  606. Note: "专门验证 R13,逐行不参与通过判定"})
  607. }
  608. if len(poolNonDiv) < perCat {
  609. notes = append(notes, fmt.Sprintf("nondivisible:候选只有 %d 条", len(poolNonDiv)))
  610. }
  611. // ---- 类 6:rewardType=2 固定课酬(人数 1/6/9 + 2 条补充)----
  612. wantSizes := []int{1, 6, 9, 3, 7}
  613. for _, want := range wantSizes {
  614. pool := r.collect(records, now, func(f caseFacts) bool {
  615. return f.rec.status == 2 && f.agg.elig == want && f.hit && !used[f.rec.id]
  616. })
  617. got := take(pool, 1)
  618. if len(got) != 1 {
  619. notes = append(notes, fmt.Sprintf("rewardtype2 人数=%d:无法造数(找不到 elig=%d 且有命中的记录)", want, want))
  620. continue
  621. }
  622. f := got[0]
  623. add(Case{ID: fmt.Sprintf("syn-rewardtype2-n%d", want), Kind: KindSynthetic, Category: CatRewardType2,
  624. RecordID: f.rec.id,
  625. Mutations: []string{fmt.Sprintf("UPDATE t_courses_record SET teacher_reward_type = 2 WHERE id = '%s'", f.rec.id)},
  626. ExpectTier: f.tier,
  627. Expectation: fmt.Sprintf("置 teacher_reward_type=2(人数=%d)⇒ 按 makeupCost 查表,>6 人应取 'max'=>200", f.agg.elig),
  628. Note: "验证固定课酬查表与超表兜底"})
  629. }
  630. return out, notes
  631. }
  632. // hasThirdDecimal 判断金额是否有第三位小数(即迁移 3→2 位会变)。
  633. func hasThirdDecimal(v float64) bool {
  634. milli := int64(v*1000 + 0.5*signOf(v))
  635. return milli%10 != 0
  636. }
  637. func signOf(v float64) float64 {
  638. if v < 0 {
  639. return -1
  640. }
  641. return 1
  642. }
  643. // nondivisible 判断"课酬总额 / 人数"在分位是否除不尽(R13 的触发条件)。
  644. func nondivisible(sumCost float64, rule detailRow, n int) bool {
  645. if n <= 0 || rule.percentage == 0 {
  646. return false
  647. }
  648. reward := sumCost*rule.percentage/100 + rule.reward
  649. if rule.maxPay > 0 && reward > rule.maxPay {
  650. reward = rule.maxPay
  651. } else if rule.minPay > 0 && reward < rule.minPay {
  652. reward = rule.minPay
  653. }
  654. cents := int64(reward*100 + 0.5)
  655. return cents%int64(n) != 0
  656. }
  657. // tierLabel 把 refs 的内部层级名转成报告用词(保持与 SP 一致)。
  658. func tierLabel(t string) string {
  659. switch t {
  660. case TierTeacherClassify, TierTeacherGradeSubject, TierCert, TierGeneric, TierDegraded:
  661. return t
  662. case "":
  663. return TierNone
  664. default:
  665. return t
  666. }
  667. }
  668. // String 便于调试输出。
  669. func (f caseFacts) String() string {
  670. return strings.Join([]string{
  671. f.rec.id,
  672. fmt.Sprintf("tier=%s", f.tier),
  673. fmt.Sprintf("income=%.3f", f.agg.sumCost),
  674. fmt.Sprintf("elig=%d", f.agg.elig),
  675. }, " ")
  676. }