Pārlūkot izejas kodu

feat(ac15): RS-3 课酬明细结果集双跑(整行 / 部分映射,17 = 6 + 11)

dump `SELECT * FROM t_course_payment_detail` 回整行 17 列,target 端点 59 只回 9 个
派生字段 ⇒ 这条**不可能全覆盖**,所以把「未覆盖」显式登记成带理由的豁免:

- 认领 6 列:id→DetailID、course_paymentid→SchemeID、percentage→Percentage
  (**非金额**,按文本比)、min_payment/min_payment→Min/Max、reward→Reward
- 豁免 11 列,理由分三类(不合并成一句「target 没下发」):
  A 命中判据(classifyid/gradeid/subjectid/class_type/teacher_cert/teacher_exp_min/max/actived)
    —— target 折成 ruleTier 下发,逐列回传等于把内部判据暴露成业务字段
  B 身份归属(teacherid)—— legacy int vs target ULID,表示不同
  C 展示性文本(name/memo)—— 契约不下发,对「算得对不对」无信息量
- target 独有 3 个派生字段(SchemeCampusID/SchemeScope/RuleTier)单独对声明断言,
  侧向覆盖 9/9;`ruleTier=personal_classify` 是**分级回退顺序**的可观测形式
- 种了一条通用档诱饵(id/percentage/min/max 全不同),分级回退走错会同时红 3 列
- 精度口径**不套 bill 的 3→2**:这几列是 float(10,1) → decimal(12,2),方向是扩位;
  单列断言说明,并指出「反过来收敛回 1 位会静默抹掉 20.05」
- Cell.String 把 bit(1) 的 `\x00/\x01` 渲染成 `b'0'/b'1'`(原来在报告里是不可见字符)

实测:dump 17 列 = 认领 6 + 豁免 11(Skipped 11);target 字段 9 个全部被看过;
id=990002 / percentage=65.5 / min=100.00 / max=500.00 / reward=20.00 两侧相等且 = 声明期望;
业务断言 17 条、基建 4 条;PASS。
全量 3 条 case:3 PASS / 0 FAIL(业务 44 条、基建 12 条、Skipped 11、未分类 0)。

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 全绿。
Q 5 stundas atpakaļ
vecāks
revīzija
a79b950acb

+ 9 - 0
internal/academic/dualrun/stage2/resultset_compare.go

@@ -62,10 +62,19 @@ type Cell struct {
 }
 
 // String 渲染成报告里能看的形态(NULL 显式打出来,不折成 0)。
+//
+// `bit(1)` 列驱动读回是 `\x00`/`\x01` —— 直接打印是**不可见字符**,报告里会变成
+// 「legacy= 」这种看起来像空值的假象。这里显式渲染成 MySQL 字面量形态。
 func (c Cell) String() string {
 	if c.Null {
 		return "NULL"
 	}
+	switch c.Raw {
+	case "\x00":
+		return "b'0'"
+	case "\x01":
+		return "b'1'"
+	}
 	return c.Raw
 }
 

+ 1 - 0
internal/academic/dualrun/stage2/rs_cases.go

@@ -11,5 +11,6 @@ func ResultSetCases() []ResultSetCase {
 	return []ResultSetCase{
 		rsRefundableCase(),
 		rsBillCase(),
+		rsTeacherDetailCase(),
 	}
 }

+ 42 - 0
internal/academic/dualrun/stage2/rs_cases_test.go

@@ -46,3 +46,45 @@ func TestBillExpected(t *testing.T) {
 		t.Error("GiveUsed 恰好等于 Gift:说明种子没把「不同集合」这件事显出来,这条断言变成恒等式")
 	}
 }
+
+func TestTeacherDetailMappingCoverage(t *testing.T) {
+	cols := rsTeacherDetailColumns()
+	claimed, exempt := 0, 0
+	for _, c := range cols {
+		if c.Exempt {
+			exempt++
+			if c.Reason == "" {
+				t.Errorf("豁免列 %s 没有理由 —— 「豁免」会变成「静默不比」", c.Legacy)
+			}
+			continue
+		}
+		claimed++
+	}
+	// dump 的 `SELECT *` 是 17 列(`spdump columns t_course_payment_detail` 实测 17 行)。
+	if len(cols) != 17 || claimed != 6 || exempt != 11 {
+		t.Fatalf("映射声明 %d 条(认领 %d + 豁免 %d),应为 17 = 6 + 11", len(cols), claimed, exempt)
+	}
+	// target 独有字段:PayrollQuote 的 9 个字段里,除了 6 个被认领的,剩下 3 个必须显式列出。
+	named := map[string]bool{}
+	for _, c := range cols {
+		if !c.Exempt {
+			named[c.Target] = true
+		}
+	}
+	for _, want := range []string{"SchemeCampusID", "SchemeScope", "RuleTier"} {
+		if named[want] {
+			t.Errorf("%s 不该被当作 dump 列认领(dump 整行里没有它)", want)
+		}
+	}
+}
+
+func TestPayrollSeedDecoyIsDistinguishable(t *testing.T) {
+	d := rsPayDetails()
+	if len(d) != 2 {
+		t.Fatalf("声明 %d 条规则,应为 2 条(个人 + 通用诱饵)", len(d))
+	}
+	if d[0].ID == d[1].ID || d[0].Percentage == d[1].Percentage ||
+		d[0].MinPayment == d[1].MinPayment || d[0].MaxPayment == d[1].MaxPayment {
+		t.Fatal("诱饵与个人规则在 id/percentage/min/max 上有重合 —— 分级回退走错时不会红")
+	}
+}

+ 410 - 0
internal/academic/dualrun/stage2/rs_teacher_detail.go

@@ -0,0 +1,410 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/service"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 本文件是 **端点 59 试算的底层 `sp_course_payment_detail_get_teacher_detail` ↔
+// `PayrollService.QuoteTeacherReward`** 的结果集双跑。
+//
+// 取法(可复现):`go run ./cmd/spdump proc sp_course_payment_detail_get_teacher_detail`
+// 过程体要点(原文摘录):
+//
+//	游标:SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid
+//	      —— 按主键 ⇒ **最多 1 行**;分类不存在 ⇒ 循环不进 ⇒ 末尾 `WHERE id=0` 返回空结果集
+//	IF inTeacherExp<0 THEN SET inTeacherExp = 0
+//	持证:SELECT (SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid)
+//	方案①:schoolid=inSchoolid AND campusid=inCampusid AND actived=1 AND effect_date<=inDate
+//	       ORDER BY effect_date DESC LIMIT 0,1;方案②:campusid=0(机构级回退)
+//	个人档闸门 COUNT>0 → ① classifyid=inClassifyid → ② gradeid IN (g,-1) AND subjectid IN (s,-1)
+//	持证通用档 ③ / 无证通用档 ④(都带 `inTeacherExp BETWEEN teacher_exp_min AND teacher_exp_max`)
+//	末尾:`SELECT * FROM t_course_payment_detail WHERE id=coursePaymentDetailid;`  ← **整行 17 列**
+//
+// ⚠ 与另外两支的根本差别:**target 只回 9 个派生字段,dump 回整行**。
+// 所以这条是**部分映射**:17 列里 6 列被认领,11 列显式豁免(每条都带理由,见 `rsTeacherDetailColumns`),
+// 而 target 独有的 3 个派生字段(schemeCampusId / schemeScope / ruleTier)单独对声明断言。
+//
+// ⚠ 已登记的有意差异(不在本 case 覆盖范围):dump 的三处 `IF coursePaymentDetailid>1` 是
+// 「≥2 才短路」,即 **id=1 的规则永远不生效**;Go 按 `>0`(找到即命中)。本 case 的规则 id=990002,
+// 两侧行为一致 —— 这条差异必须**另开 case 用 id=1 专门验**,见报告「未覆盖」。
+
+// 课酬种子的合成 ID(与用户/机构同一套:全新、两侧一致)。
+const (
+	rsPaySchemeID   int64 = 990001
+	rsPayDetailID   int64 = 990002
+	rsPayGeneralID  int64 = 990003 // 通用档诱饵:若分级回退走错,会命中它
+	rsPayClassifyID int64 = 990201
+)
+
+// 课酬种子的**单一声明**(两侧同值)。
+type rsPayDetailSeed struct {
+	ID          int64
+	TeacherID   int64 // legacy int;target 侧换 ULID;0 = 通用规则
+	ClassifyID  int64
+	GradeID     int32
+	SubjectID   int32
+	ClassType   int32
+	Percentage  string
+	MinPayment  string
+	MaxPayment  string
+	Reward      string
+	TeacherCert int
+	ExpMin      string
+	ExpMax      string
+	Actived     int
+	Name        string
+	Memo        string
+}
+
+// rsPayDetails 声明方案下的三条规则:
+//
+//	990002 个人·精确分类(teacherid=880001、classifyid=990201)→ **应命中这一条**
+//	990003 通用·无证(teacherid=0、teacher_cert=0、年级/科目通配)→ 诱饵
+//
+// 为什么要有诱饵:只种一条命中规则时,"命中正确的那条"和"随便返回唯一一条"无法区分。
+// 诱饵的金额与百分比都不同,一旦分级回退走错,`id` / `percentage` / `min` 三列会同时红。
+//
+// 教龄区间:输入 3.5 年,个人档不看教龄,通用档 `[0.000, 10.000]` 覆盖 —— 两侧同构。
+func rsPayDetails() []rsPayDetailSeed {
+	return []rsPayDetailSeed{
+		{
+			ID: rsPayDetailID, TeacherID: rsUserID, ClassifyID: rsPayClassifyID,
+			GradeID: 7, SubjectID: 3, ClassType: 1000,
+			Percentage: "65.5", MinPayment: "100.00", MaxPayment: "500.00", Reward: "20.00",
+			TeacherCert: 0, ExpMin: "0.000", ExpMax: "10.000", Actived: 1,
+			Name: "RS 个人·精确分类", Memo: "rs-payroll-personal",
+		},
+		{
+			ID: rsPayGeneralID, TeacherID: 0, ClassifyID: 0,
+			GradeID: -1, SubjectID: -1, ClassType: 1000,
+			Percentage: "30.0", MinPayment: "50.00", MaxPayment: "200.00", Reward: "5.00",
+			TeacherCert: 0, ExpMin: "0.000", ExpMax: "10.000", Actived: 1,
+			Name: "RS 通用·无证", Memo: "rs-payroll-general",
+		},
+	}
+}
+
+// rsPayTeacherExp 是入参教龄(年)。个人档不看它;通用档靠它进区间。
+const rsPayTeacherExp = 3.5
+
+// rsPayDate 是试算日期(方案 effect_date=2020-01-01 <= 它)。
+const rsPayDate = "2026-03-01"
+
+// seedLegacyTeacherDetail 种 legacy 侧(分类 + 方案 + 两条规则)。
+func seedLegacyTeacherDetail(ctx context.Context, tx *gorm.DB) error {
+	if err := tx.Exec(`DELETE FROM t_course_payment_detail WHERE id IN (?)`,
+		[]int64{rsPayDetailID, rsPayGeneralID}).Error; err != nil {
+		return fmt.Errorf("清理 legacy 课酬规则残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_course_payment WHERE id = ?`, rsPaySchemeID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 课酬方案残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_courses_classify WHERE id = ?`, rsPayClassifyID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 课程分类残留失败:%w", err)
+	}
+
+	if err := requireRows("种 legacy 课程分类", tx.Exec(`
+INSERT INTO t_courses_classify
+  (id, schoolid, campusid, class_type, course_paymentid, gradeid, subjectid, name, unit_price, min_people, max_people)
+VALUES (?, ?, 0, 1000, ?, 7, 3, 'RS 分类', 100.000, 1, 1000)`,
+		rsPayClassifyID, rsInstID, rsPaySchemeID), 1); err != nil {
+		return err
+	}
+	if err := requireRows("种 legacy 课酬方案", tx.Exec(`
+INSERT INTO t_course_payment (id, schoolid, campusid, name, effect_date, actived, create_userid, create_time)
+VALUES (?, ?, ?, 'RS 方案', '2020-01-01 00:00:00', 1, 0, '2020-01-01 00:00:00')`,
+		rsPaySchemeID, rsInstID, rsCampusID), 1); err != nil {
+		return err
+	}
+	for _, d := range rsPayDetails() {
+		if err := requireRows(fmt.Sprintf("种 legacy 课酬规则 %d", d.ID), tx.Exec(`
+INSERT INTO t_course_payment_detail
+  (id, course_paymentid, gradeid, subjectid, class_type, classifyid, teacherid, name,
+   percentage, min_payment, max_payment, reward, teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+			d.ID, rsPaySchemeID, d.GradeID, d.SubjectID, d.ClassType, d.ClassifyID, d.TeacherID, d.Name,
+			d.Percentage, d.MinPayment, d.MaxPayment, d.Reward, d.TeacherCert, d.ExpMin, d.ExpMax, d.Actived, d.Memo), 1); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// seedTargetTeacherDetail 种 target 侧(列名是目标态命名;teacher_id 是 ULID)。
+func seedTargetTeacherDetail(ctx context.Context, tx *gorm.DB, uid string) error {
+	if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
+		return err
+	}
+	if err := tx.Exec(`DELETE FROM t_course_payment_detail WHERE id IN (?)`,
+		[]int64{rsPayDetailID, rsPayGeneralID}).Error; err != nil {
+		return fmt.Errorf("清理 target 课酬规则残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_course_payment WHERE id = ?`, rsPaySchemeID).Error; err != nil {
+		return fmt.Errorf("清理 target 课酬方案残留失败:%w", err)
+	}
+	if err := tx.Exec(`DELETE FROM t_courses_classify WHERE id = ?`, rsPayClassifyID).Error; err != nil {
+		return fmt.Errorf("清理 target 课程分类残留失败:%w", err)
+	}
+
+	if err := requireRows("种 target 课程分类", tx.Exec(`
+INSERT INTO t_courses_classify
+  (id, institution_id, campus_id, class_type, course_payment_id, grade_id, subject_id, name, unit_price, min_people, max_people)
+VALUES (?, ?, 0, 1000, ?, 7, 3, 'RS 分类', 100.00, 1, 1000)`,
+		rsPayClassifyID, rsInstID, rsPaySchemeID), 1); err != nil {
+		return err
+	}
+	if err := requireRows("种 target 课酬方案", tx.Exec(`
+INSERT INTO t_course_payment (id, institution_id, campus_id, name, effect_date, actived, create_user_id, create_time)
+VALUES (?, ?, ?, 'RS 方案', '2020-01-01 00:00:00', 1, NULL, '2020-01-01 00:00:00')`,
+		rsPaySchemeID, rsInstID, rsCampusID), 1); err != nil {
+		return err
+	}
+	for _, d := range rsPayDetails() {
+		var teacher any
+		if d.TeacherID != 0 {
+			teacher = uid
+		}
+		if err := requireRows(fmt.Sprintf("种 target 课酬规则 %d", d.ID), tx.Exec(`
+INSERT INTO t_course_payment_detail
+  (id, course_payment_id, grade_id, subject_id, class_type, classify_id, teacher_id, name,
+   percentage, min_payment, max_payment, reward, teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+			d.ID, rsPaySchemeID, d.GradeID, d.SubjectID, d.ClassType, d.ClassifyID, teacher, d.Name,
+			d.Percentage, d.MinPayment, d.MaxPayment, d.Reward, d.TeacherCert, d.ExpMin, d.ExpMax, d.Actived, d.Memo), 1); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// verifyLegacyTeacherDetailSeed / verifyTargetTeacherDetailSeed:读回规则条数与命中行。
+func verifyLegacyTeacherDetailSeed(ctx context.Context, tx *gorm.DB) (string, error) {
+	return verifyTeacherDetailSeed(ctx, tx, "legacy", false, "")
+}
+
+func verifyTargetTeacherDetailSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
+	return verifyTeacherDetailSeed(ctx, tx, "target", true, uid)
+}
+
+// verifyTeacherDetailSeed 读回:分类存在、方案生效、两条规则都在,且个人规则挂在正确的人身上。
+func verifyTeacherDetailSeed(
+	ctx context.Context, tx *gorm.DB, side string, target bool, uid string,
+) (string, error) {
+	detailTable := "t_course_payment_detail"
+	paymentTable := "t_course_payment"
+	classifyTable := "t_courses_classify"
+	paymentCond, classifyCond := "schoolid = ? AND campusid = ?", "schoolid = ? AND id = ?"
+	if target {
+		paymentCond, classifyCond = "institution_id = ? AND campus_id = ?", "institution_id = ? AND id = ?"
+	}
+	var n int
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s`, paymentTable, paymentCond),
+		rsInstID, rsCampusID).Row().Scan(&n); err != nil {
+		return "", fmt.Errorf("[%s] 读回课酬方案失败:%w", side, err)
+	}
+	if n != 1 {
+		return "", fmt.Errorf("[%s] 本机构+校区下方案 %d 条,声明 1 条(其他方案会让 ORDER BY effect_date 选错)", side, n)
+	}
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s`, classifyTable, classifyCond),
+		rsInstID, rsPayClassifyID).Row().Scan(&n); err != nil {
+		return "", fmt.Errorf("[%s] 读回课程分类失败:%w", side, err)
+	}
+	if n != 1 {
+		return "", fmt.Errorf("[%s] 课程分类 %d 条,声明 1 条", side, n)
+	}
+	var cnt int
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id IN (?, ?)`, detailTable),
+		rsPayDetailID, rsPayGeneralID).Row().Scan(&cnt); err != nil {
+		return "", fmt.Errorf("[%s] 读回课酬规则失败:%w", side, err)
+	}
+	if cnt != len(rsPayDetails()) {
+		return "", fmt.Errorf("[%s] 课酬规则 %d 条,声明 %d 条", side, cnt, len(rsPayDetails()))
+	}
+	// 个人规则必须挂在"这个人"身上:target 侧是 ULID,legacy 是 int。
+	var gotTeacher string
+	ownerCol, ownerVal := "teacherid", any(rsUserID)
+	if target {
+		ownerCol, ownerVal = "teacher_id", any(uid)
+	}
+	if err := tx.Raw(fmt.Sprintf(`SELECT CAST(%s AS CHAR) FROM %s WHERE id = ?`, ownerCol, detailTable),
+		rsPayDetailID).Row().Scan(&gotTeacher); err != nil {
+		return "", fmt.Errorf("[%s] 读回个人规则的 teacher 归属失败:%w", side, err)
+	}
+	if strings.TrimSpace(gotTeacher) != fmt.Sprint(ownerVal) {
+		return "", fmt.Errorf("[%s] 个人规则 teacher=%q,声明 %v", side, gotTeacher, ownerVal)
+	}
+	return fmt.Sprintf("方案 1 条 / 分类 1 条 / 规则 %d 条 / 个人规则归属=%s", cnt, gotTeacher), nil
+}
+
+// rsTeacherDetailColumns 是 **17 列 → 6 认领 + 11 豁免** 的映射。
+//
+// 认领的 6 列(`BillSummary` 之外的 4 个业务列 + 2 个 id):
+//
+//	id               → PayrollQuote.DetailID
+//	course_paymentid → PayrollQuote.SchemeID
+//	percentage       → PayrollQuote.Percentage(**非金额**:decimal(5,1) 的百分数,不能按钱归一)
+//	min_payment      → PayrollQuote.Min
+//	max_payment      → PayrollQuote.Max
+//	reward           → PayrollQuote.Reward
+//
+// 豁免的 11 列分三类(理由各不同,**不要合并成一句"target 没下发"**):
+//
+//	A. 命中判据(gradeid / subjectid / class_type / classifyid / teacher_cert / teacher_exp_min /
+//	   teacher_exp_max / actived):它们**唯一的作用**是"决定哪条规则被命中"。
+//	   target 的契约把它们折成 `ruleTier`(命中层级)下发,逐列回传等于把内部判据暴露成业务字段。
+//	   这不是"做不到",是**契约不打算下发** —— 所以豁免,并由 target 独有字段 `ruleTier` 承担可观测性。
+//	B. 身份归属(teacherid):legacy 是 int、target 是 ULID,**两侧表示不同**,
+//	   即使都下发也不能直接比(要经 `t_legacy_mapping` 换名),且 target 契约未下发。
+//	C. 展示性文本(name / memo):契约未下发;比对它们对"课酬算得对不对"没有信息量。
+func rsTeacherDetailColumns() []ResultColumn {
+	return []ResultColumn{
+		{Legacy: "id", Target: "DetailID", Kind: CellInt},
+		{Legacy: "course_paymentid", Target: "SchemeID", Kind: CellInt},
+		{Legacy: "percentage", Target: "Percentage", Kind: CellText},
+		{Legacy: "min_payment", Target: "Min", Kind: CellMoney},
+		{Legacy: "max_payment", Target: "Max", Kind: CellMoney},
+		{Legacy: "reward", Target: "Reward", Kind: CellMoney},
+		{Legacy: "classifyid", Exempt: true, Reason: "命中判据(个人档①的等值条件);target 契约折成 ruleTier 下发,不下发本列"},
+		{Legacy: "gradeid", Exempt: true, Reason: "命中判据(个人档②/通用档的 IN(g,-1));target 契约不下发"},
+		{Legacy: "subjectid", Exempt: true, Reason: "命中判据(同上);target 契约不下发"},
+		{Legacy: "class_type", Exempt: true, Reason: "命中判据(三档都按班型过滤);target 契约不下发"},
+		{Legacy: "teacherid", Exempt: true, Reason: "身份归属:legacy int vs target ULID(表示不同,需经映射换名),且契约不下发"},
+		{Legacy: "teacher_cert", Exempt: true, Reason: "命中判据(决定是否进持证通用档);target 契约不下发"},
+		{Legacy: "teacher_exp_min", Exempt: true, Reason: "命中判据(BETWEEN 下界);target 契约不下发"},
+		{Legacy: "teacher_exp_max", Exempt: true, Reason: "命中判据(BETWEEN 上界);target 契约不下发"},
+		{Legacy: "actived", Exempt: true, Reason: "命中判据(=1 才参与匹配);target 契约不下发"},
+		{Legacy: "name", Exempt: true, Reason: "展示性文本(计算方式名称),契约不下发;对「算得对不对」无信息量"},
+		{Legacy: "memo", Exempt: true, Reason: "展示性文本(备注),契约不下发;同上"},
+	}
+}
+
+// rsTeacherDetailCase 组装课酬详情 case。
+func rsTeacherDetailCase() ResultSetCase {
+	return ResultSetCase{
+		Name: "RS-3 课酬明细 sp_course_payment_detail_get_teacher_detail(整行 / 部分映射)",
+		Note: "dump 回 17 列、target 只回 9 个派生字段 ⇒ 6 列认领 + 11 列显式豁免(逐条带理由);" +
+			"种了一条通用档诱饵,分级回退走错会同时红 3 列",
+		LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
+		SeedLegacy:   seedLegacyTeacherDetail,
+		SeedTarget:   seedTargetTeacherDetail,
+		VerifyLegacy: verifyLegacyTeacherDetailSeed,
+		VerifyTarget: verifyTargetTeacherDetailSeed,
+		LegacyCall:   "CALL sp_course_payment_detail_get_teacher_detail(?, ?, ?, ?, ?, ?)",
+		LegacyArgs:   []any{rsUserID, rsPayTeacherExp, rsInstID, rsCampusID, rsPayClassifyID, rsPayDate},
+		CallTarget: func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error) {
+			svc := service.NewPayrollService(tx, sc)
+			q, err := svc.QuoteTeacherReward(ctx, service.QuotePayrollInput{
+				TeacherID: uid, TeacherExp: rsPayTeacherExp,
+				InstitutionID: rsInstID, CampusID: rsCampusID,
+				ClassifyID: rsPayClassifyID, Date: mustParseDate(rsPayDate),
+			})
+			if err != nil {
+				return TargetPayload{}, err
+			}
+			return TargetPayload{
+				Label: "PayrollService.QuoteTeacherReward",
+				Values: map[string]Cell{
+					"DetailID":       IntCell(q.DetailID),
+					"SchemeID":       IntCell(q.SchemeID),
+					"SchemeCampusID": IntCell(q.SchemeCampusID),
+					"SchemeScope":    TextCell(q.SchemeScope),
+					"RuleTier":       TextCell(q.RuleTier),
+					"Percentage":     TextCell(q.Percentage),
+					"Min":            TextCell(q.Min.String()),
+					"Max":            TextCell(q.Max.String()),
+					"Reward":         TextCell(q.Reward.String()),
+				},
+				Evidence: fmt.Sprintf("detailId=%d schemeId=%d scope=%s tier=%s",
+					q.DetailID, q.SchemeID, q.SchemeScope, q.RuleTier),
+			}, nil
+		},
+		Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
+			personal := rsPayDetails()[0]
+			return ResultSetInput{
+				CaseName:   "RS-3",
+				Legacy:     lg,
+				ExpectRows: 1,
+				Target:     tg,
+				Columns:    rsTeacherDetailColumns(),
+				Expect: map[string]string{
+					"id":               fmt.Sprintf("%d", personal.ID),
+					"course_paymentid": fmt.Sprintf("%d", rsPaySchemeID),
+					"percentage":       personal.Percentage,
+					"min_payment":      personal.MinPayment,
+					"max_payment":      personal.MaxPayment,
+					"reward":           personal.Reward,
+				},
+				// 不启用 PrecisionCheck:dump 这四列是 `float(10,1)`(1 位小数),target 是
+				// `decimal(12,2)` —— 这是**扩大**精度,不是 3→2 收敛。理由单列在 ExtraAssertions。
+				PrecisionCheck: false,
+				TargetOnly: []TargetOnlyColumn{
+					{Target: "SchemeCampusID", Kind: CellInt, Expect: IntCell(rsCampusID),
+						Note: "target 独有:命中方案所属校区(dump 整行里没有这列,只能对声明断言)"},
+					{Target: "SchemeScope", Kind: CellText, Expect: TextCell(service.SchemeScopeCampus),
+						Note: "target 独有:方案层级 = campus(本 case 的方案挂在本校区,不走 campus_id=0 回退)"},
+					{Target: "RuleTier", Kind: CellText, Expect: TextCell(service.RuleTierPersonalClassify),
+						Note: "target 独有:规则层级 = personal_classify(个人档①精确分类);" +
+							"这条断言是**分级回退顺序**的可观测形式"},
+				},
+				ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
+					// 精度口径(与 bill 不同,必须分开写):dump 是 float(10,1),target 是 decimal(12,2)。
+					var floats []string
+					for _, col := range []string{"min_payment", "max_payment", "reward"} {
+						if c, ok := RawCellOf(lg, 0, col); ok && !c.Null {
+							floats = append(floats, col+"="+c.Raw)
+						}
+					}
+					var pct string
+					if c, ok := RawCellOf(lg, 0, "percentage"); ok {
+						pct = c.Raw
+					}
+					exact := true
+					for _, s := range floats {
+						raw := strings.SplitN(s, "=", 2)[1]
+						if c, err := centsOf(raw); err != nil || float64(c)/100 != mustFloat(raw) {
+							exact = false
+						}
+					}
+					return []Assertion{
+						{
+							Kind: AssertionBusiness,
+							Name: "精度口径:dump 三列金额是 float(10,1)(1 位小数)→ target decimal(12,2),方向是**扩位**而非收敛",
+							Pass: len(floats) == 3 && exact,
+							Detail: fmt.Sprintf("dump raw %v | percentage raw=%q(**非金额**,按文本比,不参与金额归一)。"+
+								"本种子取值都在 1 位小数内,扩到 2 位无损;反之若把 target 收敛回 1 位,`reward=20.05` 这类值会被静默抹掉", floats, pct),
+						},
+						{
+							Kind: AssertionBusiness,
+							Name: "分级回退:命中的是**个人·精确分类**那条(不是通用诱饵)",
+							Pass: mustCell(lg, 0, "id").Raw == fmt.Sprintf("%d", rsPayDetailID),
+							Detail: fmt.Sprintf("dump id=%s | 个人规则 %d | 通用诱饵 %d(诱饵金额 %s/%s/%s,命中它会同时红 3 列)",
+								mustCell(lg, 0, "id").Raw, rsPayDetailID, rsPayGeneralID,
+								rsPayDetails()[1].MinPayment, rsPayDetails()[1].MaxPayment, rsPayDetails()[1].Reward),
+						},
+					}
+				},
+			}
+		},
+	}
+}
+
+// mustParseDate 解析 `YYYY-MM-DD`(试算日期;时钟清零,与 `FindActiveScheme` 的按日截断同口径)。
+func mustParseDate(s string) time.Time { return mustParse(s + " 00:00:00") }
+
+// mustFloat 把金额字面量转成 float64(只用于"1 位小数是否被扩展无损"的判定)。
+func mustFloat(s string) float64 {
+	f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
+	if err != nil {
+		return -1
+	}
+	return f
+}