ソースを参照

feat(academic): AC-10 查询服务(学生账单 get_bill + 课酬匹配 get_teacher_detail)

设计 §3.4 #25 / #26 交付物(4 个生产文件 + 4 个测试文件):

新增仓储
- repository/bill_repo.go:账单 4 张 MEMORY 临时表 → 4 次单查(流水 / 充值工单 / 报名 / used_log),
  时间窗与类型过滤全部搬进 Go 侧(与 dump 的 temp 表 + WHERE BETWEEN 等价);
  **不继承 BaseRepo**(流水/used_log 无作用域列,账单必须按 user 跨校区对账),
  越权断言由 BillService 经 port.MembershipChecker 承担(§1.7 例外一)。
- repository/payroll_repo.go:方案/明细/分类/教师资质单查(dump 763609~763664 逐行映射),
  **不调 Scoped 改用 assertInstitution**(Scoped 会隐藏 campus_id=0 的机构级方案 ⇒ 静默改变回退结果);
  通用档 `teacher_id IS NULL OR = ""`(不是 `= '0'`)、`grade_id/subject_id IN (x,-1)`、教龄 BETWEEN。

新增服务
- service/bill_service.go:GetBill(汇总 9 列 + 明细分页)。
  · **三种比较语义并存**(dump 既有不对称,禁止统一):当期发生额 BETWEEN(两端闭) /
    期末 `< to` / 期初 `< from`;显式暴露 FlowExclusiveEnd 与 FlowInclusiveEnd,二者之差
    == 落在 t=To 的流水;
  · 入参边界写死:to 是**不含**上界,要含当日必须传次日或 23:59:59;
  · 赠送额三段链(Gift + GivePrev − GiveEnd = GiveUsed),RIGHT JOIN 的两个后果照抄(未"修好");
  · `campusIds` = dump 的**死参数**(764094 声明、过程体零引用)→ 只解析/回显、不过滤。
- service/payroll_service.go:QuoteTeacherReward(+ QuotePayroll 别名,转调无第二份逻辑)。
  两级方案回退(校区→campus_id=0→3112)+ 三级规则回退(个人分类→个人年级科目→持证通用→无证通用);
  持证者第三档未命中仍走无证档(dump 763659 无 else);返回 detailId/percentage/min/max/reward + 溯源层级。

有意差异(1 处,已在文件头登记)
- dump 763640/763645/763653/763657 `IF coursePaymentDetailid>1` 为错字(应 >0):自增主键从 1 起,
  使 **id=1 的规则永不生效**。本次按 >0 实现,差异面仅 id=1 一行;用例
  TestQuotePayrollPersonalRuleWithIDOneMatches 改回 >1 即红。

验证(§5.1 纪律 4「横切优先」,全部经变异确认能自证失败)
- 账单恒等式:EndBalance−PrevBalance == FlowExclusiveEnd,且 EndBalance == t_user_expense.balance(跨表);
  变异"期末改含右端" → 2 个用例红(实得 630/期望 430、恒等式破)。
- 时间口径差一笔:FlowInclusiveEnd−FlowExclusiveEnd == 恰在 t=To 的那笔(200.00)。
- 期初/期末不随分页变化(page1 vs page2 Summary 全等,明细不同)。
- 课酬三级回退:个人档 detailId=31 vs 仅无证通用 detailId=32 两例;`-1` 年级/科目通配各一例;
  方案回退(校区缺失→机构级)/ 两级都无→3112;教龄负值钳 0、BETWEEN 两端闭(1.00 命中、1.01 落空)。
- 变异验证 3 组:期末口径统一 → 红;跳过个人档 → 红;`IN (x,-1)` 改等值 → 6 个用例红。还原即全绿。

测试:36 个用例全绿;go build / go vet / go test -short ./... 全绿。
Alex 16 時間 前
親
コミット
d25fb184e5

+ 279 - 0
internal/academic/repository/bill_repo.go

@@ -0,0 +1,279 @@
+// 学生账单对账仓储(sp_user_expense_get_bill 的 4 张 MEMORY 临时表 → 内存结构)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.4 #25 `sp_user_expense_get_bill`(dump **764094~764232**)
+//   - §3.8-**E2**(时间列三分类)/ §3.8-**A18**(`type=108` 归因,**只归因不裁剪**)
+//   - §5.1 纪律 4「横切优先」(账单恒等式:期末−期初 = Σ(窗口内流水))
+//
+// ---------------------------------------------------------------------------
+// dump 原文与 target 的逐项映射(4 张临时表 → 4 个查询)
+// ---------------------------------------------------------------------------
+//
+//	dump 764124-764130  temp_user_balance_request  ← LoadRechargeRequestNos
+//	  INSERT ... SELECT id FROM t_user_balance_request
+//	  WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN start AND end
+//	dump 764133-764143  temp_user_expense_log      ← LoadLogs / PageLogs / CountLogs
+//	  INSERT ... SELECT type,logid,money,create_time FROM t_user_expense_log WHERE userid=?
+//	  (**无时间过滤**:区间过滤在后续每次 SUM 的 WHERE 里现做)
+//	dump 764146-764153  temp_courses_activity_signed ← LoadActivitySigned
+//	  INSERT ... SELECT activityid,activity_money,create_time,requestid FROM .. WHERE userid=?
+//	dump 764155-764163  temp_courses_activity_used_log ← LoadActivityUsedLogs
+//	  INSERT ... SELECT activityid,activity_money_used,create_time FROM .. WHERE userid=?
+//
+// 四张临时表在 dump 里都是「该 user 的全量」——本仓储同样按 user 全量取回,
+// 时间窗与类型过滤**全部搬进 Go 侧**(与 dump 的 `temp_xxx` + `WHERE create_time BETWEEN` 等价,
+// 且避免把 `VARCHAR` 时间比较的歧义带进目标态的 `datetime(3)`)。
+//
+// ---------------------------------------------------------------------------
+// 作用域纪律(**本仓储不继承 BaseRepo**,属 §1.7 例外一)
+// ---------------------------------------------------------------------------
+//
+// 4 张来源表里 `t_user_expense_log` / `t_courses_activity_used_log` **没有** institution_id /
+// campus_id 列(DDL 见 model.UserExpenseLog / model.CoursesActivityUsedLog),
+// 而"学生账单"本身就是**跨校区账户对账**(期初/期末余额只能按 user 汇总 —— §3.8-f 约束 2
+// 「流水表无 campus 维度 ⇒ 恒等式只能按 user 做」)。
+//
+// 因此越权断言**不能**靠 BaseRepo.Scoped 完成,必须由 Service 层经 port.MembershipChecker
+// 完成(BillService.GetBill 已内置该断言)。这里刻意不继承 BaseRepo:
+// 若对 signed 表叠 `campus_id = ?`,同一学生跨校区的赠送额会被静默裁掉,
+// 反而把"账单恒等式"变成"某校区视图恒等式",与 dump 输出的口径不再一致。
+//
+// 金额纪律:**禁止 `SELECT SUM(money)`**(MySQL 把 decimal 求和转 REAL,浮点漂移)——
+// 本仓储只按行取原始 decimal 字符串,一律由 Service 用 money.Amount 定点累加(同 ExpenseRepo)。
+package repository
+
+import (
+	"context"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// BillRepo 学生账单对账仓储(只读;4 张来源表按 user 全量取回)。
+//
+// 字段:
+//   - DB *gorm.DB:连接池句柄(必填)。
+type BillRepo struct {
+	// DB 数据库句柄(nil 时所有方法返回 ErrInternal)。
+	DB *gorm.DB
+}
+
+// NewBillRepo 构造账单仓储。
+//
+// 入参:db *gorm.DB——连接池句柄(必填)。
+// 出参:*BillRepo(不继承 BaseRepo,理由见文件头"作用域纪律")。
+func NewBillRepo(db *gorm.DB) *BillRepo { return &BillRepo{DB: db} }
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务,保证读一致)。
+//
+// 入参:ctx 请求上下文(可 nil)。
+// 出参:(*gorm.DB, error);未装配 DB → ErrInternal。
+func (r *BillRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: BillRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}
+
+// LoadLogs 取该 user 的**全量**资金流水(dump `temp_user_expense_log` 的等价物,764139)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 用户(ULID;空 → ErrBadParam)。
+//
+// 出参:
+//   - []model.UserExpenseLog:按 `create_time ASC, id ASC` 稳定排序(dump 临时表无排序,
+//     Go 侧显式定序是为了让"同一毫秒多行"的累加结果可复现);**空结果返回长度 0 的非 nil 切片**;
+//   - ErrBadParam / ErrDBError。
+//
+// 只 Select 参与计算的 5 列(type / money / create_time / log_id / id):账单不对其余列做展示,
+// 少取列可避免把 remark 等自由文本带进日志/响应。
+func (r *BillRepo) LoadLogs(ctx context.Context, userID string) ([]model.UserExpenseLog, error) {
+	if userID == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadLogs 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.UserExpenseLog, 0, 32)
+	qErr := db.Model(&model.UserExpenseLog{}).
+		Select("id", "type", "money", "create_time", "log_id").
+		Where("user_id = ?", userID).
+		Order("create_time ASC").Order("id ASC").
+		Find(&rows).Error
+	if qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return rows, nil
+}
+
+// PageLogs 分页取账单明细流水(**时间倒序**,与 dump 的 `SELECT * FROM t_user_expense_log` 列表视图同序)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 用户(ULID;空 → ErrBadParam);
+//   - page 分页(PageQuery;内部 ClampPage:pageSize ∈ [1,200])。
+//
+// 出参:
+//   - []model.UserExpenseLog:`create_time DESC, id DESC`(同秒多行按 id 兜底,保证翻页不重不漏);
+//   - ErrBadParam / ErrDBError。
+//
+// 红线:**分页只影响明细**——期初/期末/各类当期发生额必须由 LoadLogs 的全量数据算出
+// (BillService 的"期初/期末不随分页变化"用例正是拦这条)。
+func (r *BillRepo) PageLogs(ctx context.Context, userID string, page PageQuery) ([]model.UserExpenseLog, error) {
+	if userID == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: PageLogs 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	pageSize := page.Limit()
+	rows := make([]model.UserExpenseLog, 0, pageSize)
+	qErr := db.Model(&model.UserExpenseLog{}).
+		Select("id", "type", "money", "create_time", "log_id").
+		Where("user_id = ?", userID).
+		Order("create_time DESC").Order("id DESC").
+		Limit(pageSize).Offset(page.Offset()).
+		Find(&rows).Error
+	if qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return rows, nil
+}
+
+// CountLogs 统计该 user 的流水条数(明细分页的 total)。
+//
+// 入参:ctx 请求上下文;userID 用户(空 → ErrBadParam)。
+// 出参:int64 条数(无数据为 0);ErrBadParam / ErrDBError。
+func (r *BillRepo) CountLogs(ctx context.Context, userID string) (int64, error) {
+	if userID == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountLogs 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return 0, err
+	}
+	var total int64
+	if cErr := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", userID).Count(&total).Error; cErr != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, cErr)
+	}
+	return total, nil
+}
+
+// LoadRechargeRequestNos 取区间内的**已通过普通充值工单**业务单号(dump `temp_user_balance_request`,764124)。
+//
+// dump 原文(764128-764130):
+//
+//	INSERT INTO temp_user_balance_request
+//	SELECT id FROM t_user_balance_request
+//	WHERE userid=? AND aproved=1 AND type=100 AND response_time BETWEEN inStartTime AND inEndTime
+//
+// 三参口径逐条对齐:
+//   - `aproved=1` → `approved = model.BalanceRequestApprovedPassed`(旧列名错拼已在 model 修正);
+//   - `type=100`  → `type = model.BalanceRequestTypeRecharge`;
+//   - `response_time BETWEEN inStartTime AND inEndTime` → **两端闭区间**(`>= from AND <= to`),
+//     ⚠ 这里**故意不跟随**"期末余额用 `< to`"的口径:dump 的 BETWEEN 就是含右端,
+//     两种比较语义并存是既有不对称,不得统一(见 BillService.GetBill 文档)。
+//
+// 入参:ctx;userID 用户(空 → ErrBadParam);from/to 审批时间窗(`from > to` → ErrBadParam)。
+// 出参:
+//   - []string:命中的 `biz_no` 列表(旧库 `t_user_balance_request.id` 是 UUID,与流水的
+//     `logid` 同一字符串空间;目标态对应关系是 `expense_log.log_id = balance_request.biz_no`);
+//   - ErrBadParam / ErrDBError。
+func (r *BillRepo) LoadRechargeRequestNos(
+	ctx context.Context, userID string, from, to time.Time,
+) ([]string, error) {
+	if userID == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadRechargeRequestNos 需要 user_id"))
+	}
+	if from.After(to) {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: from 不能晚于 to"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	nos := make([]string, 0, 8)
+	qErr := db.Model(&model.UserBalanceRequest{}).
+		Where("user_id = ? AND approved = ? AND type = ? AND response_time >= ? AND response_time <= ?",
+			userID, model.BalanceRequestApprovedPassed, model.BalanceRequestTypeRecharge, from, to).
+		Pluck("biz_no", &nos).Error
+	if qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return nos, nil
+}
+
+// LoadActivitySigned 取该 user 的活动报名行(dump `temp_courses_activity_signed`,764146)。
+//
+// dump 原文(764151-764153):
+//
+//	INSERT INTO temp_courses_activity_signed
+//	SELECT activityid,activity_money,create_time,requestid FROM t_courses_activity_signed WHERE userid=?
+//
+// **不过滤 status 与时间**:dump 把 `create_time` 一并搬进临时表却不参与任何过滤
+// (只有 `activity_money` 参与赠送额汇总),这里保持同口径。
+//
+// 入参:ctx;userID(空 → ErrBadParam)。
+// 出参:[]model.CoursesActivitySigned(按 `activity_id ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。
+func (r *BillRepo) LoadActivitySigned(ctx context.Context, userID string) ([]model.CoursesActivitySigned, error) {
+	if userID == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivitySigned 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.CoursesActivitySigned, 0, 8)
+	qErr := db.Model(&model.CoursesActivitySigned{}).
+		Select("id", "activity_id", "activity_money", "request_no", "create_time").
+		Where("user_id = ?", userID).
+		Order("activity_id ASC").Order("id ASC").
+		Find(&rows).Error
+	if qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return rows, nil
+}
+
+// LoadActivityUsedLogs 取该 user 的老带新获得/使用记录(dump `temp_courses_activity_used_log`,764155)。
+//
+// dump 原文(764160-764163):
+//
+//	INSERT INTO temp_courses_activity_used_log
+//	SELECT activityid,activity_money_used,create_time FROM t_courses_activity_used_log WHERE userid=?
+//
+// 同样**不带时间过滤**:两个 cutoff(`< inStartTime` / `< inEndTime`)在 Go 侧分别做。
+//
+// 入参:ctx;userID(空 → ErrBadParam)。
+// 出参:[]model.CoursesActivityUsedLog(按 `create_time ASC, id ASC` 稳定排序);ErrBadParam / ErrDBError。
+func (r *BillRepo) LoadActivityUsedLogs(ctx context.Context, userID string) ([]model.CoursesActivityUsedLog, error) {
+	if userID == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: LoadActivityUsedLogs 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.CoursesActivityUsedLog, 0, 8)
+	qErr := db.Model(&model.CoursesActivityUsedLog{}).
+		Select("id", "activity_id", "activity_money_used", "create_time").
+		Where("user_id = ?", userID).
+		Order("create_time ASC").Order("id ASC").
+		Find(&rows).Error
+	if qErr != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return rows, nil
+}

+ 234 - 0
internal/academic/repository/bill_repo_test.go

@@ -0,0 +1,234 @@
+// BillRepo 单测(AC-10):4 张来源表的取数口径 / `BETWEEN` 含右端 / 分页稳定性 / 参数守卫。
+//
+// 为什么仓储层要单独测"BETWEEN 含右端":这是 dump 里**最容易被"顺手统一"掉**的一处
+// (期末余额用 `< to`、充值工单用 `<= to`)。Service 用例只覆盖"有一条恰好落在 to 的流水"
+// 的单一路径,仓储用例把 `response_time = to` 这一格单独钉住——一旦有人把 `<=` 改成 `<`,
+// 赠送额会**静默少算**,而账单恒等式(只算 balance 类流水)依然成立,抓不到。
+package repository
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// newBillRepoDB 账单 5 表文件库(流水 / 工单 / 报名 / used_log / 全局钱包)。
+func newBillRepoDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_bill_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(账单文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	migrateTablesDroppingPlainIndexes(t, db,
+		&model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{},
+		&model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{},
+	)
+	return db
+}
+
+// TestBillRepoLoadRechargeRequestNosBetweenInclusive `response_time BETWEEN` 必须**含右端**,
+// 且只取 `approved=1 AND type=100`。
+func TestBillRepoLoadRechargeRequestNosBetweenInclusive(t *testing.T) {
+	db := newBillRepoDB(t)
+	ctx := context.Background()
+	userID := "01HUSER000000000000000901"
+	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
+
+	at := func(day int, hour int) *time.Time {
+		v := time.Date(2026, 3, day, hour, 0, 0, 0, time.UTC)
+		return &v
+	}
+	rows := []model.UserBalanceRequest{
+		{BizNo: "IN-FROM", Type: model.BalanceRequestTypeRecharge, UserID: userID,
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(1, 0)}, // 恰在 from(含)
+		{BizNo: "IN-TO", Type: model.BalanceRequestTypeRecharge, UserID: userID,
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(to)}, // 恰在 to(**必须含**)
+		{BizNo: "BEFORE", Type: model.BalanceRequestTypeRecharge, UserID: userID,
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(time.Date(2026, 2, 28, 23, 59, 0, 0, time.UTC))},
+		{BizNo: "AFTER", Type: model.BalanceRequestTypeRecharge, UserID: userID,
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: timePtr(to.Add(time.Second))},
+		{BizNo: "PENDING", Type: model.BalanceRequestTypeRecharge, UserID: userID,
+			Approved: model.BalanceRequestApprovedPending, ResponseTime: at(10, 8)}, // 未通过 → 排除
+		{BizNo: "BADDEBT", Type: model.BalanceRequestTypeBadDebt, UserID: userID,
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(10, 9)}, // 非 100 → 排除
+		{BizNo: "OTHER-USER", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER000000000000000902",
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: at(10, 10)}, // 他人 → 排除
+		{BizNo: "NO-TIME", Type: model.BalanceRequestTypeRecharge, UserID: userID,
+			Approved: model.BalanceRequestApprovedPassed, ResponseTime: nil}, // response_time 空 → 排除
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed 充值工单失败: %v", err)
+	}
+
+	repo := NewBillRepo(db)
+	nos, err := repo.LoadRechargeRequestNos(ctx, userID, from, to)
+	if err != nil {
+		t.Fatalf("LoadRechargeRequestNos 失败: %v", err)
+	}
+	got := map[string]bool{}
+	for _, no := range nos {
+		got[no] = true
+	}
+	want := []string{"IN-FROM", "IN-TO"}
+	if len(nos) != len(want) {
+		t.Fatalf("命中工单 = %v,期望 %v(BETWEEN 含两端)", nos, want)
+	}
+	for _, no := range want {
+		if !got[no] {
+			t.Fatalf("缺少工单 %s(命中 %v)", no, nos)
+		}
+	}
+	for _, no := range []string{"BEFORE", "AFTER", "PENDING", "BADDEBT", "OTHER-USER", "NO-TIME"} {
+		if got[no] {
+			t.Fatalf("工单 %s 不应命中(命中 %v)", no, nos)
+		}
+	}
+}
+
+// TestBillRepoLoadLogsAndPageOrdering 全量取数与分页一致性(分页只切明细,不改全量)。
+func TestBillRepoLoadLogsAndPageOrdering(t *testing.T) {
+	db := newBillRepoDB(t)
+	ctx := context.Background()
+	userID := "01HUSER000000000000000910"
+
+	// 同一秒两行:分页必须靠 id DESC 兜底(否则翻页会重复/漏行)
+	same := time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC)
+	logs := []model.UserExpenseLog{
+		{UserID: userID, Type: model.ExpenseLogTypeRecharge, Money: "100.00", CreateTime: same},
+		{UserID: userID, Type: model.ExpenseLogTypeRecommend, Money: "10.00", CreateTime: same},
+		{UserID: userID, Type: model.ExpenseLogTypeCourseCampus, Money: "-5.00",
+			CreateTime: time.Date(2026, 3, 6, 9, 0, 0, 0, time.UTC)},
+	}
+	if err := db.Create(&logs).Error; err != nil {
+		t.Fatalf("seed 流水失败: %v", err)
+	}
+
+	repo := NewBillRepo(db)
+	all, err := repo.LoadLogs(ctx, userID)
+	if err != nil {
+		t.Fatalf("LoadLogs 失败: %v", err)
+	}
+	if len(all) != 3 {
+		t.Fatalf("全量流水 = %d 条,期望 3(**不带时间/类型过滤**,同 dump 临时表)", len(all))
+	}
+	total, err := repo.CountLogs(ctx, userID)
+	if err != nil {
+		t.Fatalf("CountLogs 失败: %v", err)
+	}
+	if total != 3 {
+		t.Fatalf("CountLogs = %d,期望 3", total)
+	}
+
+	page1, err := repo.PageLogs(ctx, userID, PageQuery{Page: 1, PageSize: 2})
+	if err != nil {
+		t.Fatalf("PageLogs(1) 失败: %v", err)
+	}
+	page2, err := repo.PageLogs(ctx, userID, PageQuery{Page: 2, PageSize: 2})
+	if err != nil {
+		t.Fatalf("PageLogs(2) 失败: %v", err)
+	}
+	if len(page1) != 2 || len(page2) != 1 {
+		t.Fatalf("分页条数 = %d/%d,期望 2/1", len(page1), len(page2))
+	}
+	// 倒序:第 1 页首行是最新的 03-06 那笔
+	if !sameRepoAmount(t, page1[0].Money, "-5.00") {
+		t.Fatalf("第 1 页首行 money = %s,期望 -5.00(时间倒序)", page1[0].Money)
+	}
+	// 同一秒两行按 id DESC:第 1 页次行应是 id 更大的 107
+	if page1[1].Type != model.ExpenseLogTypeRecommend || page2[0].Type != model.ExpenseLogTypeRecharge {
+		t.Fatalf("同一秒内排序不稳定:page1=%+v page2=%+v", page1[1], page2[0])
+	}
+}
+
+// TestBillRepoActivityLoaders 报名 / used_log 均**不带时间过滤**(时间窗在 Service 侧做)。
+func TestBillRepoActivityLoaders(t *testing.T) {
+	db := newBillRepoDB(t)
+	ctx := context.Background()
+	userID := "01HUSER000000000000000920"
+	no := "REQ-X"
+
+	if err := db.Create(&model.CoursesActivitySigned{
+		ActivityID: 91, UserID: userID, ActivityMoney: "40.00", RequestNo: &no,
+		CreateTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+	}).Error; err != nil {
+		t.Fatalf("seed 报名失败: %v", err)
+	}
+	if err := db.Create(&model.CoursesActivityUsedLog{
+		ActivityID: 91, UserID: userID, ActivityMoneyUsed: "-15.00",
+		CreateTime: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
+	}).Error; err != nil {
+		t.Fatalf("seed used_log 失败: %v", err)
+	}
+
+	repo := NewBillRepo(db)
+	signed, err := repo.LoadActivitySigned(ctx, userID)
+	if err != nil {
+		t.Fatalf("LoadActivitySigned 失败: %v", err)
+	}
+	if len(signed) != 1 || !sameRepoAmount(t, signed[0].ActivityMoney, "40.00") {
+		t.Fatalf("报名行 = %+v,期望 1 条 40.00", signed)
+	}
+	if signed[0].RequestNo == nil || *signed[0].RequestNo != no {
+		t.Fatalf("报名行的 request_no 未取出:%+v", signed[0])
+	}
+	used, err := repo.LoadActivityUsedLogs(ctx, userID)
+	if err != nil {
+		t.Fatalf("LoadActivityUsedLogs 失败: %v", err)
+	}
+	if len(used) != 1 || !sameRepoAmount(t, used[0].ActivityMoneyUsed, "-15.00") {
+		t.Fatalf("used_log = %+v,期望 1 条 -15.00", used)
+	}
+}
+
+// TestBillRepoParamGuards 空 user / from>to 的守卫(禁止"看起来筛了其实没筛"的静默查询)。
+func TestBillRepoParamGuards(t *testing.T) {
+	db := newBillRepoDB(t)
+	ctx := context.Background()
+	repo := NewBillRepo(db)
+
+	_, err := repo.LoadLogs(ctx, "")
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadLogs 空 user")
+	_, err = repo.PageLogs(ctx, "", PageQuery{})
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "PageLogs 空 user")
+	_, err = repo.CountLogs(ctx, "")
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "CountLogs 空 user")
+	_, err = repo.LoadActivitySigned(ctx, "")
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivitySigned 空 user")
+	_, err = repo.LoadActivityUsedLogs(ctx, "")
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadActivityUsedLogs 空 user")
+	_, err = repo.LoadRechargeRequestNos(ctx, "", time.Now(), time.Now())
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "LoadRechargeRequestNos 空 user")
+	_, err = repo.LoadRechargeRequestNos(ctx, "01HUSER000000000000000930",
+		time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC))
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "from 晚于 to")
+}
+
+// timePtr 时间指针构造(time.Time 不可寻址,取址需经变量)。
+func timePtr(v time.Time) *time.Time { return &v }
+
+// sameRepoAmount 金额语义比较(sqlite 的 NUMERIC 亲和性会把 "40.00" 读回 "40",
+// MySQL 读回 "40.00"——两者定点值相同,故按 money.Amount 比较,不做字符串比)。
+func sameRepoAmount(t *testing.T, got, want string) bool {
+	t.Helper()
+	g, gerr := money.Parse(got)
+	w, werr := money.Parse(want)
+	if gerr != nil || werr != nil {
+		t.Fatalf("金额解析失败: got=%q(%v) want=%q(%v)", got, gerr, want, werr)
+	}
+	return g == w
+}

+ 444 - 0
internal/academic/repository/payroll_repo.go

@@ -0,0 +1,444 @@
+// 课酬方案与规则仓储(sp_course_payment_detail_get_teacher_detail 的 4 次单查)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.4 #26 `sp_course_payment_detail_get_teacher_detail`(dump **763575~763665**)
+//   - §10.1 `3112 课酬方案未配置`(本文件的"无方案"由 Service 翻译成 3112)
+//
+// ---------------------------------------------------------------------------
+// dump 原文与本仓储方法的映射(逐行号)
+// ---------------------------------------------------------------------------
+//
+//	763609-763613  游标 `SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
+//	               → GetClassify(**按主键单查**,替代游标)
+//	763621-763622  `IF inTeacherExp<0 THEN SET inTeacherExp=0`(教龄下限钳 0)→ Service 侧做
+//	763624         `SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid`
+//	               → TeacherHasCert(**无行 = 无证**,见方法注释)
+//	763627-763632  方案两级回退:
+//	                 ① `schoolid=? AND campusid=? AND actived=1 AND effect_date<=? ORDER BY effect_date DESC LIMIT 1`
+//	                 ② `coursePaymentid<1` → `schoolid=? AND campusid=0 ...`(机构级)
+//	               → FindActiveScheme(两次调用,回退顺序在 Service)
+//	763635-763647  个人配置两级:
+//	                 ① `COUNT(1)` where course_paymentid/actived/teacherid/class_type → oneself
+//	                 ② `classifyid=inClassifyid LIMIT 1`(**无 ORDER BY**,本实现补 id ASC 定序)
+//	                 ③ `gradeid IN (g,-1) AND subjectid IN (s,-1) ORDER BY gradeid DESC,subjectid DESC LIMIT 1`
+//	               → CountPersonalRules / FindPersonalByClassify / FindPersonalByGradeSubject
+//	763650-763664  通用两级:
+//	                 ④ `teacherid=0 AND teacher_cert=1 AND ... AND inTeacherExp BETWEEN exp_min AND exp_max`
+//	                 ⑤ `teacherid=0 AND teacher_cert=0 AND ...`(同上,无证)
+//	               → FindGeneralRule(cert 参数区分 ④/⑤)
+//
+// ---------------------------------------------------------------------------
+// 作用域口径(**本仓储不调 Scoped,改用 assertInstitution**,必须读)
+// ---------------------------------------------------------------------------
+//
+// `t_course_payment` 带 institution_id / campus_id,但 dump 的语义里 **`campusid=0` 的行必须可见**
+// (两级回退的第二级就是它)。`BaseRepo.Scoped` 在 `Sc.CampusID>0` 时会强制 `campus_id = ?`,
+// 把机构级方案整片隐藏 ⇒ 回退链退化成"要么本级命中、要么 3112",**静默改变业务结果**。
+// 所以本仓储只做**机构级**断言(assertInstitution):普通角色只能查本机构方案,
+// 超管/系统任务(Sc 为 nil)放行;校区维度由 Service 显式传参(对齐 dump 的 inCampusid)。
+//
+// `t_course_payment_detail` / `t_teacher_information` **没有** institution_id / campus_id 列
+// (见 model.CoursePaymentDetail / model.TeacherInformation),任何 Scoped 调用都会 Unknown column;
+// 隔离由"方案 id 已被机构断言保护"传导完成。
+//
+// 金额/比例纪律:`percentage` 是 `decimal(5,1)`(**百分比数字部分,不是金额**),
+// `min_payment/max_payment/reward` 是 `decimal(12,2)`;本仓储一律原样取字符串,不在此做算术。
+package repository
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// payrollDetailColumns 课酬明细参与匹配的列清单(少取 memo/longtext,避免把自由文本带进日志)。
+const payrollDetailColumns = "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"
+
+// PayrollRepo 课酬方案/规则仓储(只读;游标 → 单查)。
+//
+// 字段:
+//   - *BaseRepo:持有 DB + 作用域(**只借 DB/Sc,不调用 Scoped**,理由见文件头"作用域口径")。
+type PayrollRepo struct {
+	*BaseRepo
+}
+
+// NewPayrollRepo 构造课酬仓储。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(可 nil:系统任务/双跑脚本视角)。
+// 出参:*PayrollRepo(CampusScoped=false:`campus_id=0` 的机构级方案必须可见)。
+func NewPayrollRepo(db *gorm.DB, sc *appscope.Scope) *PayrollRepo {
+	r := &PayrollRepo{BaseRepo: NewBaseRepo(db, sc)}
+	r.CampusScoped = false
+	return r
+}
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务)。
+//
+// 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
+func (r *PayrollRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.BaseRepo == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: PayrollRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}
+
+// assertInstitution 机构级越权守卫(替代 Scoped;语义见文件头"作用域口径")。
+//
+// 入参:institutionID 本次查询的目标机构(<=0 → ErrBadParam:机构是必填维度,0 会让方案查询跨机构)。
+// 出参:nil 通过;ErrUnscoped(1004) 越界;ErrBadParam(2001)。
+//
+// 规则:Sc 为 nil(系统任务/双跑)→ 放行;超管 → 放行;普通角色 → 必须与 Sc.InstitutionID 相等。
+func (r *PayrollRepo) assertInstitution(institutionID int64) error {
+	if institutionID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: 课酬查询需要机构 id(>0)"))
+	}
+	if r.Sc == nil || r.Sc.SuperAdmin {
+		return nil
+	}
+	if r.Sc.InstitutionID != institutionID {
+		return infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("repository: 课酬查询机构与当前作用域不一致"))
+	}
+	return nil
+}
+
+// GetClassify 按主键取课程分类(dump 763609 游标的等价单查)。
+//
+// 入参:ctx;classifyID 分类 ID(<=0 → ErrBadParam)。
+// 出参:*model.CoursesClassify;ErrNotFound(3001) / ErrBadParam(2001) / ErrDBError(5002)。
+//
+// 与 dump 的一致性:dump `SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
+// **不带任何作用域条件**。本方法同样按主键取(分类只提供 grade/subject/class_type 三个字典值,
+// 机构隔离由后续的方案查询承担:越权者拿不到本机构方案 ⇒ 依然 3112)。
+//
+// **为什么不复用 `CourseRepo.GetClassifyByID`**(避免被判成重复实现,理由写在这里):
+// 那个方法走 `Scoped()` 且 `CourseRepo.CampusScoped=true` ⇒ 会强制 `campus_id = Sc.CampusID`,
+// 把 `campus_id=0`(机构级分类)与其它校区的分类整片隐藏。课酬试算是"老师跨校区上课"的场景,
+// 分类常挂在机构级(campus_id=0)或别的校区上;一旦叠上校区过滤,dump 本来能命中的规则会变 3112。
+// 两者**服务对象不同**(课程 CRUD 的强隔离 vs 课酬试算的 dump 语义),不是同一实现的两份拷贝。
+func (r *PayrollRepo) GetClassify(ctx context.Context, classifyID int64) (*model.CoursesClassify, error) {
+	if classifyID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursesClassify
+	qErr := db.Model(&model.CoursesClassify{}).
+		Select("id", "institution_id", "campus_id", "class_type", "grade_id", "subject_id").
+		Where("id = ?", classifyID).Take(&out).Error
+	if qErr != nil {
+		return nil, wrapQueryErr(qErr, "GetClassify(t_courses_classify)")
+	}
+	return &out, nil
+}
+
+// TeacherHasCert 判断老师是否持教师资格证(dump 763624)。
+//
+// dump 原文:
+//
+//	SELECT (SELECT `teacher_cert` FROM `t_teacher_information` WHERE teacherid=inUserid) INTO teacherCert;
+//	...
+//	IF teacherCert>0 THEN  -- 只有持证者才尝试"持证通用"档
+//
+// 语义细节(**极易写错**):标量子查询**无行时结果为 NULL**,而 `NULL>0` 为 NULL(假)——
+// 因此「老师没有资质档案」等价于「无证」,**不是错误**。本方法据此把"无行"归一为 false。
+//
+// 入参:ctx;teacherID 老师(ULID;空 → ErrBadParam)。
+// 出参:bool 是否持证;ErrBadParam / ErrDBError。
+func (r *PayrollRepo) TeacherHasCert(ctx context.Context, teacherID string) (bool, error) {
+	if strings.TrimSpace(teacherID) == "" {
+		return false, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: TeacherHasCert 需要 teacher_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return false, err
+	}
+	var row struct {
+		TeacherCert bool `gorm:"column:teacher_cert"`
+	}
+	qErr := db.Model(&model.TeacherInformation{}).
+		Select("teacher_cert").
+		Where("teacher_id = ?", teacherID).
+		Take(&row).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			// dump 语义:无行 ⇒ 标量子查询 NULL ⇒ IF NULL>0 不成立 ⇒ 按无证处理
+			return false, nil
+		}
+		return false, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return row.TeacherCert, nil
+}
+
+// FindActiveScheme 取生效的课酬方案(dump 763627-763632 的单次查询;两级回退由 Service 调用两次)。
+//
+// dump 原文(camupsid 为入参那一档):
+//
+//	SELECT (SELECT `id` FROM `t_course_payment`
+//	        WHERE `schoolid`=inSchoolid AND `campusid`=inCampusid AND `actived`=1 AND `effect_date`<=inDate
+//	        ORDER BY `effect_date` desc LIMIT 0,1) INTO coursePaymentid;
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - institutionID 机构 ID(旧 schoolid;**>0 且须与作用域一致**,见 assertInstitution);
+//   - campusID 校区 ID(旧 campusid;**0 = 机构级方案**,即第二级回退);
+//   - effectUntil 试算日期(旧 inDate varchar → 目标态 time.Time;**按日截断后含当日**,见下)。
+//
+// 出参:
+//   - *model.CoursePayment:命中行;**无命中返回 (nil, nil)**(不是 ErrNotFound——"没有方案"是
+//     回退链的正常分支,由 Service 决定继续回退还是 3112);
+//   - ErrBadParam / ErrUnscoped / ErrDBError。
+//
+// 边界口径(`effect_date <= inDate`):`effect_date` 是 DATE 列,dump 的 inDate 是 `YYYY-MM-DD` 字符串,
+// 因此**当日生效的方案当日可用**。目标态把 effectUntil 按"取年月日、时钟清零"处理后再比较,
+// 避免传入 `2026-11-09 15:04:05` 时把当日 00:00 生效的方案判掉(那是浮点/时钟误伤,不是业务差异)。
+//
+// 定序:`effect_date DESC` 同日并列时 dump 结果**不确定**(无第二排序键);
+// 目标态补 `id DESC` 作为确定性兜底(登记为 Go 增强,仅在"同一天建了两个方案"时才有可观察差别)。
+func (r *PayrollRepo) FindActiveScheme(
+	ctx context.Context, institutionID, campusID int64, effectUntil time.Time,
+) (*model.CoursePayment, error) {
+	if err := r.assertInstitution(institutionID); err != nil {
+		return nil, err
+	}
+	if campusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: campus_id 不能为负"))
+	}
+	if effectUntil.IsZero() {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindActiveScheme 需要试算日期"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	day := time.Date(effectUntil.Year(), effectUntil.Month(), effectUntil.Day(), 0, 0, 0, 0, effectUntil.Location())
+	var out model.CoursePayment
+	qErr := db.Model(&model.CoursePayment{}).
+		Select("id", "institution_id", "campus_id", "name", "effect_date", "actived").
+		Where("institution_id = ? AND campus_id = ? AND actived = ? AND effect_date <= ?",
+			institutionID, campusID, true, day).
+		Order("effect_date DESC").Order("id DESC").
+		Take(&out).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			return nil, nil // 该档无方案 → 交给 Service 走下一级回退 / 3112
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return &out, nil
+}
+
+// CountPersonalRules 统计"该老师在该方案下、该班型"的个人配置条数(dump 763636 的 `oneself`)。
+//
+// dump 原文:
+//
+//	SELECT(SELECT COUNT(1) FROM t_course_payment_detail
+//	       WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
+//	         AND `class_type`=classifyClassType) INTO oneself;
+//
+// 注意:`oneself` 只作为**闸门**(>0 才进入个人两级查询),**不带 classifyid 条件**——
+// 即"该老师该班型有任意一条个人规则"就开门。这个闸门看着冗余(下一级查询自己也会过滤),
+// 但它决定了"老师有个人规则但年级/科目都不匹配"时**会不会**继续往下走到持证/无证通用档:
+// 门开着就继续走,与"没有个人规则"结果相同,故行为等价;保留它只为与 dump 结构一致、便于双跑对账。
+//
+// 入参:ctx;schemeID 方案 ID(<=0 → ErrBadParam);teacherID 老师 ULID(空 → ErrBadParam);
+// classType 班型(可为 0,dump 无下限校验)。
+// 出参:int64 条数;ErrBadParam / ErrDBError。
+func (r *PayrollRepo) CountPersonalRules(
+	ctx context.Context, schemeID int64, teacherID string, classType int32,
+) (int64, error) {
+	if schemeID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
+	}
+	if strings.TrimSpace(teacherID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountPersonalRules 需要 teacher_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return 0, err
+	}
+	var total int64
+	qErr := db.Model(&model.CoursePaymentDetail{}).
+		Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ?",
+			schemeID, true, teacherID, classType).
+		Count(&total).Error
+	if qErr != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return total, nil
+}
+
+// FindPersonalByClassify 个人配置·第一档:精确分类命中(dump 763638-763641)。
+//
+// dump 原文:
+//
+//	SELECT (SELECT `id` FROM `t_course_payment_detail`
+//	        WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
+//	          AND `class_type`=classifyClassType AND `classifyid`=inClassifyid LIMIT 0,1) INTO coursePaymentDetailid;
+//
+// 边界:`classifyid` 在本档是**精确等值**(`0=不限定分类` 的行不会被本档命中,要落到第二档的
+// grade/subject 通配匹配),这是 dump 的既有行为,不得把 `classify_id=0` 也当通配。
+// dump 无 ORDER BY,本实现补 `id ASC`(同时机多行时取最早一条,保证可复现)。
+//
+// 入参:ctx;schemeID(<=0 → ErrBadParam);teacherID(空 → ErrBadParam);classType;
+// classifyID(<=0 → ErrBadParam:入参分类是本档的核心维度)。
+// 出参:*model.CoursePaymentDetail;**无命中 (nil, nil)**;ErrBadParam / ErrDBError。
+func (r *PayrollRepo) FindPersonalByClassify(
+	ctx context.Context, schemeID int64, teacherID string, classType int32, classifyID int64,
+) (*model.CoursePaymentDetail, error) {
+	if schemeID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
+	}
+	if strings.TrimSpace(teacherID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindPersonalByClassify 需要 teacher_id"))
+	}
+	if classifyID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursePaymentDetail
+	qErr := db.Model(&model.CoursePaymentDetail{}).
+		Select(payrollDetailColumns).
+		Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ? AND classify_id = ?",
+			schemeID, true, teacherID, classType, classifyID).
+		Order("id ASC").
+		Take(&out).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			return nil, nil
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return &out, nil
+}
+
+// FindPersonalByGradeSubject 个人配置·第二档:年级/科目通配命中(dump 763643-763647)。
+//
+// dump 原文:
+//
+//	SELECT (SELECT `id` FROM `t_course_payment_detail`
+//	        WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
+//	          AND `class_type`=classifyClassType
+//	          AND `gradeid` in (classifyGradeid,-1) AND `subjectid` in (classifySubjectid,-1)
+//	        ORDER BY `gradeid` DESC,`subjectid` DESC LIMIT 0,1) INTO coursePaymentDetailid;
+//
+// `-1` 是**通配符**(表侧取值),因此条件是 `grade_id IN (?, -1)`,**不是** `grade_id = ? OR grade_id = -1`
+// 之外的任何"归一化"(尤其不能把 `-1` 改写成"不限"再对 grade_id 做 `<>` 判断)。
+// `ORDER BY grade_id DESC, subject_id DESC` 让**更具体**的行(真实 grade/subject,数值大于 -1)排在
+// 通配行之前;本实现补 `id ASC` 作为第三排序键(dump 在同 (grade,subject) 多行时不确定)。
+//
+// 入参:ctx;schemeID(<=0 → ErrBadParam);teacherID(空 → ErrBadParam);classType;
+// gradeID/subjectID 分类上的年级/科目(可为 0 = 混合;`-1` 由表侧提供)。
+// 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
+func (r *PayrollRepo) FindPersonalByGradeSubject(
+	ctx context.Context, schemeID int64, teacherID string, classType, gradeID, subjectID int32,
+) (*model.CoursePaymentDetail, error) {
+	if schemeID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
+	}
+	if strings.TrimSpace(teacherID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindPersonalByGradeSubject 需要 teacher_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursePaymentDetail
+	qErr := db.Model(&model.CoursePaymentDetail{}).
+		Select(payrollDetailColumns).
+		Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ?",
+			schemeID, true, teacherID, classType).
+		Where("grade_id IN ?", []int32{gradeID, payrollWildcardID}).
+		Where("subject_id IN ?", []int32{subjectID, payrollWildcardID}).
+		Order("grade_id DESC").Order("subject_id DESC").Order("id ASC").
+		Take(&out).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			return nil, nil
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return &out, nil
+}
+
+// payrollWildcardID 课酬规则的年级/科目通配值(dump 里字面量 `-1`,表侧存 −1 表示"所有")。
+const payrollWildcardID int32 = -1
+
+// FindGeneralRule 通用档:持证(`teacher_cert=1`)或无证(`teacher_cert=0`)(dump 763650-763664)。
+//
+// dump 原文(持证档):
+//
+//	SELECT (SELECT `id` FROM `t_course_payment_detail`
+//	        WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=0 AND `teacher_cert`=1
+//	          AND `class_type`=classifyClassType
+//	          AND `gradeid` in (classifyGradeid,-1) AND `subjectid` in (classifySubjectid,-1)
+//	          AND inTeacherExp BETWEEN `teacher_exp_min` AND `teacher_exp_max`
+//	        ORDER BY `gradeid` DESC,`subjectid` DESC LIMIT 0,1) INTO coursePaymentDetailid;
+//
+// 无证档(763659-763664)逐字相同,只把 `teacher_cert` 换成 0。
+//
+// `teacherid=0` 在目标态的映射:`model.CoursePaymentDetail.TeacherID` 是 `*string`,
+// **NULL/"" = 通用规则**(旧库用 0 表示,ULID 化后没有"0 号老师"),故条件是
+// `(teacher_id IS NULL OR teacher_id = "")`——**不能**写成 `teacher_id = '0'`(那永远不命中)。
+//
+// `inTeacherExp BETWEEN exp_min AND exp_max`:两端**闭区间**;教龄以 `decimal(6,2)` 字符串入场
+// (`"3.50"`),由数据库按 NUMERIC 语义比较,**不在 Go 侧转 float64**(避免 3.50 vs 3.4999999 的边界抖动)。
+//
+// 入参:
+//   - ctx;schemeID(<=0 → ErrBadParam);
+//   - cert 是否持证档(true=② 持证通用 / false=③ 无证通用);
+//   - classType 班型;gradeID/subjectID 分类维度(`-1` 通配规则由表侧提供);
+//   - teacherExp 教龄(**已钳 0 的** decimal(6,2) 字符串,如 "0.00";空 → ErrBadParam)。
+//
+// 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
+func (r *PayrollRepo) FindGeneralRule(
+	ctx context.Context, schemeID int64, cert bool, classType, gradeID, subjectID int32, teacherExp string,
+) (*model.CoursePaymentDetail, error) {
+	if schemeID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
+	}
+	if strings.TrimSpace(teacherExp) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindGeneralRule 需要教龄"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursePaymentDetail
+	qErr := db.Model(&model.CoursePaymentDetail{}).
+		Select(payrollDetailColumns).
+		Where("course_payment_id = ? AND actived = ? AND (teacher_id IS NULL OR teacher_id = '') AND teacher_cert = ?",
+			schemeID, true, cert).
+		Where("class_type = ?", classType).
+		Where("grade_id IN ?", []int32{gradeID, payrollWildcardID}).
+		Where("subject_id IN ?", []int32{subjectID, payrollWildcardID}).
+		Where("teacher_exp_min <= ? AND teacher_exp_max >= ?", strings.TrimSpace(teacherExp), strings.TrimSpace(teacherExp)).
+		Order("grade_id DESC").Order("subject_id DESC").Order("id ASC").
+		Take(&out).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			return nil, nil
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	return &out, nil
+}

+ 406 - 0
internal/academic/repository/payroll_repo_test.go

@@ -0,0 +1,406 @@
+// PayrollRepo 单测(AC-10):机构级越权守卫 / 方案选取顺序 / 通用档的 NULL teacher 判定 /
+// `-1` 通配 / 教龄 BETWEEN / 无档案教师按"无证"处理。
+//
+// 为什么这些要在仓储层再测一遍(Service 层已覆盖回退链):
+//   - 回退链的**每一级 SQL 条件**都是"写错也不会报错、只是命中不了"的类型(`IS NULL OR = ""`、
+//     `IN (x,-1)`、`effect_date <= day`),Service 用例只能证明"某条路径通",
+//     无法定位"哪一级条件写错";仓储用例把每个条件单独钉住。
+//   - `assertInstitution` 是**替代 Scoped** 的越权防线(本仓储不调 Scoped 的理由见文件头),
+//     必须单独证明"跨机构会被 1004 拦住"——否则这条防线写不写都没人发现。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newPayrollRepoDB 课酬 4 表文件库。
+func newPayrollRepoDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_payroll_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(课酬文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	migrateTablesDroppingPlainIndexes(t, db,
+		&model.CoursePayment{}, &model.CoursePaymentDetail{},
+		&model.CoursesClassify{}, &model.TeacherInformation{},
+	)
+	return db
+}
+
+// migrateTablesDroppingPlainIndexes 逐表 AutoMigrate,并在每张表后删掉**非唯一**索引。
+//
+// 为什么需要它(与 service 包的 migrateFundTables 同因):SQLite 的索引名是**库级唯一**,
+// 而 academic model 多张表共用 `idx_payment` / `idx_inst_campus` 等名字,一次性 AutoMigrate
+// 会报 "index already exists"。唯一约束**必须保留**(业务 UK),故只删非 UNIQUE 索引。
+func migrateTablesDroppingPlainIndexes(t *testing.T, db *gorm.DB, tables ...any) {
+	t.Helper()
+	for _, tbl := range tables {
+		if err := db.AutoMigrate(tbl); err != nil {
+			t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
+		}
+		stmt := &gorm.Statement{DB: db}
+		if err := stmt.Parse(tbl); err != nil {
+			t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
+		}
+		var rows []struct {
+			Name string
+			SQL  *string
+		}
+		if err := db.Raw(
+			"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
+			stmt.Schema.Table).Scan(&rows).Error; err != nil {
+			t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
+		}
+		for _, row := range rows {
+			sqlText := ""
+			if row.SQL != nil {
+				sqlText = strings.ToUpper(*row.SQL)
+			}
+			if strings.Contains(sqlText, "UNIQUE") {
+				continue // 唯一约束必须保留(业务 UK / 幂等键)
+			}
+			if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
+				t.Fatalf("删除索引(%s) 失败: %v", row.Name, err)
+			}
+		}
+	}
+}
+
+// payrollRepoScope 普通角色作用域(机构 1 / 校区 10)。
+func payrollRepoScope() *appscope.Scope {
+	return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "academic"}
+}
+
+// insertScheme 落一行课酬方案。
+func insertScheme(t *testing.T, db *gorm.DB, id, inst, campus int64, effect time.Time, actived bool) {
+	t.Helper()
+	eff := effect
+	row := &model.CoursePayment{ID: id, InstitutionID: inst, CampusID: campus, EffectDate: &eff, Actived: actived}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 方案(%d) 失败: %v", id, err)
+	}
+}
+
+// insertDetail 落一行课酬明细(teacherID=nil 表示通用档)。
+func insertDetail(t *testing.T, db *gorm.DB, id, schemeID int64, teacherID *string, cert bool,
+	classType, gradeID, subjectID int32, expMin, expMax string) {
+	t.Helper()
+	row := &model.CoursePaymentDetail{
+		ID: id, CoursePaymentID: schemeID, TeacherID: teacherID, TeacherCert: cert,
+		ClassType: classType, GradeID: gradeID, SubjectID: subjectID,
+		Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		TeacherExpMin: expMin, TeacherExpMax: expMax, Actived: true,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 明细(%d) 失败: %v", id, err)
+	}
+}
+
+// TestPayrollRepoAssertInstitution 机构级越权守卫(替代 Scoped 的那道防线)。
+func TestPayrollRepoAssertInstitution(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	insertScheme(t, db, 1, 2, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), true)
+	day := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
+
+	// 普通角色查其它机构 → 1004(**不是** ErrNotFound,越权必须显式拒绝)
+	repo := NewPayrollRepo(db, payrollRepoScope())
+	_, err := repo.FindActiveScheme(ctx, 2, 10, day)
+	wantRepoErr(t, err, infraerrors.CodeScopeViolation, "跨机构查方案")
+
+	// 超管 → 放行(可查机构 2)
+	admin := &appscope.Scope{InstitutionID: 1, SuperAdmin: true, UserID: "01HADMIN0000000000000001"}
+	got, err := NewPayrollRepo(db, admin).FindActiveScheme(ctx, 2, 10, day)
+	if err != nil {
+		t.Fatalf("超管查机构 2 失败: %v", err)
+	}
+	if got == nil || got.ID != 1 {
+		t.Fatalf("超管应命中方案 1,实得 %+v", got)
+	}
+
+	// 系统任务视角(Sc 为 nil)→ 放行
+	if _, err := NewPayrollRepo(db, nil).FindActiveScheme(ctx, 2, 10, day); err != nil {
+		t.Fatalf("nil 作用域应放行: %v", err)
+	}
+
+	// 机构 <=0 → 2001(机构是必填维度,0 会让查询跨机构)
+	_, err = repo.FindActiveScheme(ctx, 0, 10, day)
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "机构为 0")
+}
+
+// TestPayrollRepoFindActiveScheme 方案选取:仅 actived、仅 `effect_date <= day`(含当日)、取最新。
+func TestPayrollRepoFindActiveScheme(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+
+	insertScheme(t, db, 11, 1, 10, base, true)                  // 生效
+	insertScheme(t, db, 12, 1, 10, base.AddDate(0, 3, 0), true) // 更晚生效
+	insertScheme(t, db, 13, 1, 10, base, false)                 // 停用 → 不可命中
+	insertScheme(t, db, 14, 1, 10, base.AddDate(1, 0, 0), true) // 未来生效 → 当日不可命中
+	insertScheme(t, db, 15, 1, 0, base, true)                   // 机构级方案
+
+	// 试算日 = 2026-05-20 → 校区方案里最新的(<=日) 是 12
+	got, err := repo.FindActiveScheme(ctx, 1, 10, time.Date(2026, 5, 20, 9, 30, 0, 0, time.UTC))
+	if err != nil {
+		t.Fatalf("查校区方案失败: %v", err)
+	}
+	if got == nil || got.ID != 12 {
+		t.Fatalf("命中方案 = %+v,期望 12(effect_date 最新且已生效)", got)
+	}
+
+	// **含当日**:试算日恰为生效日 00:00 → 命中 11/12 中较新的 12
+	got, err = repo.FindActiveScheme(ctx, 1, 10, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))
+	if err != nil {
+		t.Fatalf("查当日生效方案失败: %v", err)
+	}
+	if got == nil || got.ID != 12 {
+		t.Fatalf("当日生效应命中 12,实得 %+v", got)
+	}
+
+	// 早于任何方案生效日 → nil(不是 ErrNotFound)
+	got, err = repo.FindActiveScheme(ctx, 1, 10, time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC))
+	if err != nil {
+		t.Fatalf("早期查询不应报错: %v", err)
+	}
+	if got != nil {
+		t.Fatalf("应返回 nil(无生效方案),实得 %+v", got)
+	}
+
+	// 校区 20 无方案 → nil;机构级(campus 0)存在 → 命中 15
+	got, err = repo.FindActiveScheme(ctx, 1, 20, time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC))
+	if err != nil || got != nil {
+		t.Fatalf("校区 20 应无方案,实得 %+v / %v", got, err)
+	}
+	got, err = repo.FindActiveScheme(ctx, 1, 0, time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC))
+	if err != nil {
+		t.Fatalf("查机构级方案失败: %v", err)
+	}
+	if got == nil || got.ID != 15 {
+		t.Fatalf("机构级应命中 15,实得 %+v", got)
+	}
+}
+
+// TestPayrollRepoTeacherHasCert 无资质档案 = 无证(dump 的 `IF NULL>0` 不成立),**不是错误**。
+func TestPayrollRepoTeacherHasCert(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+
+	cert, err := repo.TeacherHasCert(ctx, "01HTEACHER000000000000001")
+	if err != nil {
+		t.Fatalf("无档案不应报错: %v", err)
+	}
+	if cert {
+		t.Fatal("无档案应判为无证")
+	}
+
+	if err := db.Create(&model.TeacherInformation{
+		TeacherID: "01HTEACHER000000000000002", TeacherCert: true,
+	}).Error; err != nil {
+		t.Fatalf("seed 教师资质失败: %v", err)
+	}
+	cert, err = repo.TeacherHasCert(ctx, "01HTEACHER000000000000002")
+	if err != nil {
+		t.Fatalf("持证教师查询失败: %v", err)
+	}
+	if !cert {
+		t.Fatal("持证教师应判为持证")
+	}
+
+	_, err = repo.TeacherHasCert(ctx, "")
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "空 teacher_id")
+}
+
+// TestPayrollRepoGeneralRuleMatchesNullTeacher 通用档必须匹配 `teacher_id IS NULL`,且不得匹配个人档。
+func TestPayrollRepoGeneralRuleMatchesNullTeacher(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+	teacher := "01HTEACHER000000000000010"
+
+	insertDetail(t, db, 21, 1, nil, true, 1000, -1, -1, "0.00", "5.00")       // 持证通用
+	insertDetail(t, db, 22, 1, &teacher, false, 1000, -1, -1, "0.00", "5.00") // 个人档(**不得**被通用查询命中)
+
+	cert, err := repo.FindGeneralRule(ctx, 1, true, 1000, 7, 3, "2.00")
+	if err != nil {
+		t.Fatalf("查持证通用档失败: %v", err)
+	}
+	if cert == nil || cert.ID != 21 {
+		t.Fatalf("持证通用档应命中 21,实得 %+v", cert)
+	}
+	// 无证通用档:没有对应行 → nil(不能因为"个人档存在"就误命中 22)
+	noCert, err := repo.FindGeneralRule(ctx, 1, false, 1000, 7, 3, "2.00")
+	if err != nil {
+		t.Fatalf("查无证通用档失败: %v", err)
+	}
+	if noCert != nil {
+		t.Fatalf("无证通用档不应命中个人档 22,实得 %+v", noCert)
+	}
+}
+
+// TestPayrollRepoGeneralRuleWildcardAndExp 年级/科目 `-1` 通配 + 教龄闭区间 + 具体行优先。
+func TestPayrollRepoGeneralRuleWildcardAndExp(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+
+	insertDetail(t, db, 31, 2, nil, false, 1000, -1, -1, "0.00", "1.00") // 全通配,教龄 0~1
+	insertDetail(t, db, 32, 2, nil, false, 1000, 7, -1, "0.00", "1.00")  // 年级精确 7,科目通配
+	insertDetail(t, db, 33, 2, nil, false, 1000, 7, 3, "3.00", "5.00")   // 年级+科目精确,教龄 3~5
+
+	// 分类 7/3、教龄 0.50(31/32 的 0~1 档都满足,33 的 3~5 档不满足)
+	// → `ORDER BY grade_id DESC` 让"年级精确 7"的 32 排在"年级 -1 通配"的 31 之前
+	got, err := repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "0.50")
+	if err != nil {
+		t.Fatalf("查通用档失败: %v", err)
+	}
+	if got == nil || got.ID != 32 {
+		t.Fatalf("应命中 32(年级精确优先于 -1 通配),实得 %+v", got)
+	}
+	// 分类 9/3(年级 9 不匹配 7,但匹配 -1)→ 命中 31
+	got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 9, 3, "0.50")
+	if err != nil {
+		t.Fatalf("查通配档失败: %v", err)
+	}
+	if got == nil || got.ID != 31 {
+		t.Fatalf("应命中 31(-1 通配),实得 %+v", got)
+	}
+	// 教龄 3.00 落在 3.00~5.00 → 命中 33(闭区间下界)
+	got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "3.00")
+	if err != nil {
+		t.Fatalf("查教龄下界失败: %v", err)
+	}
+	if got == nil || got.ID != 33 {
+		t.Fatalf("教龄 3.00 应命中 33,实得 %+v", got)
+	}
+	// 教龄 5.00(闭区间上界)→ 仍命中 33
+	got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "5.00")
+	if err != nil {
+		t.Fatalf("查教龄上界失败: %v", err)
+	}
+	if got == nil || got.ID != 33 {
+		t.Fatalf("教龄 5.00 应命中 33(闭区间),实得 %+v", got)
+	}
+	// 教龄 5.01 → 所有档都不满足 → nil
+	got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "5.01")
+	if err != nil {
+		t.Fatalf("查越界教龄失败: %v", err)
+	}
+	if got != nil {
+		t.Fatalf("教龄 5.01 不应命中,实得 %+v", got)
+	}
+}
+
+// TestPayrollRepoPersonalQueries 个人档两级的条件差异(分类精确 vs 年级/科目通配)。
+func TestPayrollRepoPersonalQueries(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+	teacher := "01HTEACHER000000000000020"
+
+	// 分类精确档(classify_id=7001)
+	insertDetail(t, db, 41, 3, &teacher, false, 1000, 7, 3, "0.00", "9.00")
+	if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", 41).
+		Update("classify_id", 7001).Error; err != nil {
+		t.Fatalf("设置 classify_id 失败: %v", err)
+	}
+
+	count, err := repo.CountPersonalRules(ctx, 3, teacher, 1000)
+	if err != nil {
+		t.Fatalf("统计个人规则失败: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("个人规则条数 = %d,期望 1", count)
+	}
+	// 班型不同 → 闸门关闭(count=0)
+	count, err = repo.CountPersonalRules(ctx, 3, teacher, 2000)
+	if err != nil {
+		t.Fatalf("统计个人规则(其它班型)失败: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("其它班型不应计入个人规则,实得 %d", count)
+	}
+
+	byClassify, err := repo.FindPersonalByClassify(ctx, 3, teacher, 1000, 7001)
+	if err != nil {
+		t.Fatalf("查分类档失败: %v", err)
+	}
+	if byClassify == nil || byClassify.ID != 41 {
+		t.Fatalf("分类精确档应命中 41,实得 %+v", byClassify)
+	}
+	// 分类不匹配 → nil(`classify_id=0` 不当通配,dump 是本档精确等值)
+	byOtherClassify, err := repo.FindPersonalByClassify(ctx, 3, teacher, 1000, 7002)
+	if err != nil {
+		t.Fatalf("查其它分类档失败: %v", err)
+	}
+	if byOtherClassify != nil {
+		t.Fatalf("其它分类不应命中,实得 %+v", byOtherClassify)
+	}
+	// 年级/科目档仍可命中(同一行同时满足 7/3)
+	byGrade, err := repo.FindPersonalByGradeSubject(ctx, 3, teacher, 1000, 7, 3)
+	if err != nil {
+		t.Fatalf("查年级/科目档失败: %v", err)
+	}
+	if byGrade == nil || byGrade.ID != 41 {
+		t.Fatalf("年级/科目档应命中 41,实得 %+v", byGrade)
+	}
+}
+
+// TestPayrollRepoGetClassify 分类单查:未命中 → 3001;id<=0 → 2001。
+func TestPayrollRepoGetClassify(t *testing.T) {
+	db := newPayrollRepoDB(t)
+	ctx := context.Background()
+	repo := NewPayrollRepo(db, payrollRepoScope())
+
+	if err := db.Create(&model.CoursesClassify{
+		ID: 7001, InstitutionID: 1, CampusID: 10, ClassType: 1000, GradeID: 7, SubjectID: 3, Name: "分类",
+	}).Error; err != nil {
+		t.Fatalf("seed 分类失败: %v", err)
+	}
+	got, err := repo.GetClassify(ctx, 7001)
+	if err != nil {
+		t.Fatalf("查分类失败: %v", err)
+	}
+	if got.GradeID != 7 || got.SubjectID != 3 || got.ClassType != 1000 {
+		t.Fatalf("分类维度 = (%d,%d,%d),期望 (7,3,1000)", got.GradeID, got.SubjectID, got.ClassType)
+	}
+	_, err = repo.GetClassify(ctx, 9999)
+	wantRepoErr(t, err, infraerrors.CodeNotFound, "分类不存在")
+	_, err = repo.GetClassify(ctx, 0)
+	wantRepoErr(t, err, infraerrors.CodeBadParam, "分类 id 为 0")
+}
+
+// wantRepoErr 仓储层错误码断言(仓储把 gorm 原错统一转 sentinel)。
+func wantRepoErr(t *testing.T, err error, code int, what string) {
+	t.Helper()
+	if err == nil {
+		t.Fatalf("%s:期望错误码 %d,实得 nil", what, code)
+	}
+	var appErr *infraerrors.AppError
+	if !stderrors.As(err, &appErr) {
+		t.Fatalf("%s:期望 *AppError(%d),实得 %T(%v)", what, code, err, err)
+	}
+	if appErr.Code != code {
+		t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg)
+	}
+}

+ 541 - 0
internal/academic/service/bill_service.go

@@ -0,0 +1,541 @@
+// 学生账单查询服务(sp_user_expense_get_bill → BillService.GetBill)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.4 #25 `sp_user_expense_get_bill`(dump **764094~764232**;4 张 MEMORY 临时表 → 内存结构)
+//   - §3.8-**E2**(`create_time` 属**业务时间**,参与对账、逐值 0 容差)
+//   - §3.8-**A18**(`type=108` 双来源:**只归因、不裁剪**——本服务的余额类汇总一律全类型计入)
+//   - §5.1 纪律 4「横切优先」(账单恒等式 + 期初/期末不随分页变化)
+//
+// ---------------------------------------------------------------------------
+// dump 骨架(764133~764180,**按原文,不做"优化"**)
+// ---------------------------------------------------------------------------
+//
+//	CREATE TEMPORARY TABLE temp_user_expense_log(type INT, logid VARCHAR(50), money DECIMAL(10,3), create_time VARCHAR(100));
+//	INSERT INTO temp_user_expense_log SELECT type,logid,money,create_time FROM t_user_expense_log WHERE userid=inUserid;
+//	-- 同法 temp_courses_activity_signed / temp_courses_activity_used_log / temp_user_balance_request
+//
+//	764170  SELECT SUM(money) FROM temp_user_expense_log WHERE type=107 AND create_time BETWEEN inStartTime AND inEndTime; -- 当期推荐额
+//	764172  SELECT SUM(money) FROM temp_user_expense_log WHERE type in(101,201,104,204,105,205) AND create_time BETWEEN ...; -- 当期消费
+//	764174  SELECT SUM(money) FROM temp_user_expense_log            WHERE create_time<inEndTime;    -- **期末余额**
+//	764176  SELECT SUM(money) FROM temp_user_expense_log            WHERE create_time<inStartTime;  -- **期初余额**
+//	764178-764192  赠送额结余(当前/上一次)→ 当期使用赠送 = 当期赠送 + 上期结余 − 当前结余(764194)
+//
+// ---------------------------------------------------------------------------
+// ★ 三种比较语义并存(**dump 的既有不对称,禁止统一**)
+// ---------------------------------------------------------------------------
+//
+//	「当期发生额」= `BETWEEN inStartTime AND inEndTime` → **两端闭**:start <= t <= end
+//	「期末余额」  = `create_time < inEndTime`            → **不含右端**:t < end
+//	「期初余额」  = `create_time < inStartTime`          → **不含右端**:t < start
+//
+// ⇒ 因此 **期末 − 期初 = Σ(start <= t < end)**,而**当期发生额 = Σ(start <= t <= end)**,
+// 二者在 `t = inEndTime` 处**恰好相差落在该时刻的那些流水**。
+// 这不是 bug,是 dump 把 `create_time` 当 `VARCHAR(100)` 直接字符串比较留下的历史口径:
+// 用户在 `end` 当天/当时刻的流水会进"当期发生额",但不进"期末余额"。
+// 本服务把这个差额**显式暴露**为 `FlowInclusiveEnd − FlowExclusiveEnd`(`BillSummary` 两字段),
+// 让调用方/对账脚本能看见它,而不是靠"统一成一种语义"把它抹掉(抹掉就与旧系统数字不一致)。
+//
+// ---------------------------------------------------------------------------
+// ★ 入参时间边界(前端必须知道的一句话)
+// ---------------------------------------------------------------------------
+//
+//	`from` 是**含**下界(>= from),`to` 是**不含**上界(< to)—— 对"余额/期初期末"而言。
+//	**若前端要"含当日",必须传次日或 `23:59:59`**(例如查 2026-03-01 当日流水,应传
+//	from=2026-03-01T00:00:00, to=2026-03-02T00:00:00;传 to=2026-03-01T00:00:00 会得到该日 0 条)。
+//	dump 用 `VARCHAR` 做 `BETWEEN`,语义随字符串格式漂移;目标态改用 `datetime(3)` 后,
+//	**入参语义按上述定死**(这是"目标态显式化",不是行为漂移:旧系统前端一直传 `YYYY-MM-DD`,
+//	dump 里 `BETWEEN '2026-03-01' AND '2026-03-31'` 同样不含 03-31 的时分秒部分)。
+//
+// ---------------------------------------------------------------------------
+// ★ `campusIds` 入参的真相(dump 里是**死参数**)
+// ---------------------------------------------------------------------------
+//
+// dump 764094 声明了 `IN inCampusids VARCHAR(1000)`,但 764095~764232 的**整段过程体一次都没引用它**
+// (在 dump 里 grep 结果只有声明行)。因此本服务的 `CampusIDs` **只做解析/回显,不做过滤**——
+// 与 dump 完全一致。绝不为此给 `t_user_expense_log` 加 campus 维度(§3.8-f 约束 2:
+// 流水表无 campus 列,账单恒等式只能按 user 做);若产品确需"校区视图",须另开"按
+// t_courses_student / t_user_expense_school 的 join 路径"需求,不能在这里偷偷过滤。
+package service
+
+import (
+	"context"
+	"strconv"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 账单汇总用到的流水类型集合(dump 764172 原文 `type in(101,201,104,204,105,205)`)。
+var (
+	// billSpendTypes 「当期消费」类型集合(逐值与 dump 764172 的 IN 列表一致)。
+	billSpendTypes = []int32{
+		model.ExpenseLogTypeCourseCampus,  // 101
+		model.ExpenseLogTypeCourseCampusB, // 201
+		model.ExpenseLogTypeCourseSchoolA, // 104
+		model.ExpenseLogTypeCourseSchoolC, // 204
+		model.ExpenseLogTypeCourseSchoolB, // 105
+		model.ExpenseLogTypeCourseSchoolD, // 205
+	}
+)
+
+// BillService 学生账单查询服务(只读;无事务)。
+//
+// 字段:
+//   - repo 账单仓储(4 张来源表按 user 全量取回);
+//   - member 成员校验器(可 nil;非 nil 时对"学生是否属于当前机构/校区"做越权预检);
+//   - sc 请求作用域(可 nil:系统任务/双跑脚本视角)。
+type BillService struct {
+	repo   *repository.BillRepo
+	member port.MembershipChecker
+	sc     *appscope.Scope
+}
+
+// NewBillService 构造账单服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域;系统任务可 nil)。
+// 出参:*BillService(成员校验需 `WithMembershipChecker` 注入后生效)。
+func NewBillService(db *gorm.DB, sc *appscope.Scope) *BillService {
+	return &BillService{repo: repository.NewBillRepo(db), sc: sc}
+}
+
+// WithMembershipChecker 注入成员校验器(返自身,便于链式装配)。
+//
+// 入参:member port.MembershipChecker(nil 表示不做越权预检——仅限双跑/回填脚本)。
+// 出参:*BillService(同一实例)。
+func (s *BillService) WithMembershipChecker(member port.MembershipChecker) *BillService {
+	s.member = member
+	return s
+}
+
+// GetBillInput 账单查询入参(对齐 dump 的 4 个入参 + 明细分页)。
+//
+// 字段:
+//   - UserID 学生(ULID;必填);
+//   - CampusIDs 校区逗号串(dump 第二入参 `inCampusids`;**只解析回显、不过滤**,见文件头);
+//   - From 起始时间(**含**下界);
+//   - To 结束时间(**不含**上界——"期末"口径;「当期发生额」按 dump 的 BETWEEN 含右端);
+//   - Page / PageSize 明细分页(<=0 由 ClampPage 归一;PageSize 上限 200)。
+type GetBillInput struct {
+	// UserID 学生(canonical_user_id / ULID 26 位)。
+	UserID string
+	// CampusIDs 校区逗号串(如 "1,2,3";"" 或不传 = 不加过滤;**dump 未使用,仅回显**)。
+	CampusIDs string
+	// From 起始时间(含)。
+	From time.Time
+	// To 结束时间(不含)。
+	To time.Time
+	// Page 页码(1 基)。
+	Page int
+	// PageSize 每页条数。
+	PageSize int
+}
+
+// BillSummary 账单汇总(dump 764218-764230 的 9 个 SELECT 列 + 3 个对账辅助列)。
+//
+// 全部金额为 `money.Amount`(分,定点),JSON 输出为"两位小数字符串"(如 "123.45")——
+// 与旧库 `DECIMAL(10,3)` 的显示习惯对齐到 2 位(目标态 DDL 为 `decimal(12,2)`)。
+type BillSummary struct {
+	// PrevBalance 期初余额 = Σ(money WHERE create_time < From)(dump 764176 `previousBalance`)。
+	PrevBalance money.Amount `json:"prevBalance"`
+	// EndBalance 期末余额 = Σ(money WHERE create_time < To)(dump 764174 `currentBalance`)。
+	EndBalance money.Amount `json:"endBalance"`
+	// Recharge 当期充值 = Σ(money WHERE type=100 AND From <= create_time <= To)(dump 764168 游标)。
+	Recharge money.Amount `json:"recharge"`
+	// Gift 当期赠送额 = Σ(signed.activity_money)(经"充值工单 × 报名"关联,dump 764168 第二列)。
+	Gift money.Amount `json:"gift"`
+	// RecommendAmount 当期老带新推荐额 = Σ(money WHERE type=107 AND 区间内)(dump 764170)。
+	RecommendAmount money.Amount `json:"recommendAmount"`
+	// Spend 当期消费 = Σ(money WHERE type IN (101,201,104,204,105,205) AND 区间内)(dump 764172)。
+	Spend money.Amount `json:"spend"`
+	// GiveUsed 当期使用赠送额(dump 764194 `currentUsedGiveCost` = Gift + GivePrevBalance − GiveEndBalance)。
+	GiveUsed money.Amount `json:"giveUsed"`
+	// GivePrevBalance 期初赠送结余(dump 764192 `previousGiveCost`;口径见 giveBalanceBefore)。
+	GivePrevBalance money.Amount `json:"givePrevBalance"`
+	// GiveEndBalance 期末赠送结余(dump 764180 `currentGiveCost`;口径同上)。
+	GiveEndBalance money.Amount `json:"giveEndBalance"`
+	// FlowExclusiveEnd 窗口内全类型流水合计(From <= t **<** To)——
+	// **恒等于 EndBalance − PrevBalance**(§5.1 第 4 条:账单恒等式的可判定形式)。
+	FlowExclusiveEnd money.Amount `json:"flowExclusiveEnd"`
+	// FlowInclusiveEnd 窗口内全类型流水合计(From <= t **<=** To)——
+	// 与「当期发生额」同口径(BETWEEN 含右端)。二者之差 == 落在 `t = To` 的那些流水。
+	FlowInclusiveEnd money.Amount `json:"flowInclusiveEnd"`
+	// CampusIDs dump 第二入参的解析结果(**仅回显**:dump 全程未引用该参数,本服务不过滤)。
+	CampusIDs []int64 `json:"campusIds"`
+}
+
+// BillDetail 账单明细行(分页;按 `create_time DESC, id DESC`)。
+type BillDetail struct {
+	// ID 流水 ID(同一时刻多行的稳定排序键)。
+	ID int64 `json:"id"`
+	// Type 流水类型(model.ExpenseLogType*)。
+	Type int32 `json:"type"`
+	// TypeTitle 类型中文名(只用于展示;未登记类型返回 "其它")。
+	TypeTitle string `json:"typeTitle"`
+	// LogID 业务单号(原 logid;可为空串)。
+	LogID string `json:"logId"`
+	// Money 变更金额(元;符号已表方向,见 model.ExpenseLogType* 注释)。
+	Money money.Amount `json:"money"`
+	// CreateTime 业务时间(§3.8-E2 ①类,参与对账、逐值 0 容差)。
+	CreateTime time.Time `json:"createTime"`
+}
+
+// BillResult 账单查询结果(汇总 + 明细分页两段,设计 §3.4 #25「两段返回」)。
+type BillResult struct {
+	// Summary 汇总(**不受分页影响**)。
+	Summary BillSummary `json:"summary"`
+	// Details 明细(当前页)。
+	Details []BillDetail `json:"details"`
+	// Total 明细总条数(分页总数)。
+	Total int64 `json:"total"`
+	// Page 当前页码(已钳制)。
+	Page int `json:"page"`
+	// PageSize 每页条数(已钳制)。
+	PageSize int `json:"pageSize"`
+	// From / To 回显查询窗口(审计用;语义见 GetBillInput)。
+	From time.Time `json:"from"`
+	To   time.Time `json:"to"`
+}
+
+// GetBill 查询学生账单(汇总 + 明细分页)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - in GetBillInput(UserID 必填;From/To 必填且 From <= To)。
+//
+// 出参:
+//   - *BillResult:汇总 + 明细;明细为空时 Details 为长度 0 的非 nil 切片;
+//   - ErrBadParam(2001) 缺参/时间窗非法;ErrUnscoped(1004) 越权(注入了 MembershipChecker 时);
+//     ErrDBError(5002) 数据库异常或金额字面量解析失败。
+//
+// **明细口径**:`Details` 是该账户的**全量流水**(`create_time DESC, id DESC` 分页),
+// **不按 from/to 过滤** —— dump 的 `get_bill` 只返回 9 个汇总列、没有明细列表;设计 §3.4 #25
+// 要求"Go 侧支持分页明细 + 汇总对象两段返回",明细即"账户流水账",配合汇总看当期发生额。
+// 若前端要"只看窗口内流水",按 `Details[].CreateTime` 自行过滤或另开参数(本期不加,
+// 免得"明细被窗口裁掉、汇总却是全量"这种两处口径不一的错觉)。
+//
+// 只读:本方法**不开事务**(dump 的 4 张 MEMORY 临时表是一次性快照,等价于"同一读视图";
+// 目标态在单连接上顺序执行 4 次查询,落库并发写入造成的瞬间不一致由"流水只增不改"兜底)。
+func (s *BillService) GetBill(ctx context.Context, in GetBillInput) (*BillResult, error) {
+	if strings.TrimSpace(in.UserID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: GetBill 需要 user_id"))
+	}
+	if in.From.IsZero() || in.To.IsZero() {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: GetBill 需要 from/to(账户区间对账的两个边界都是必填)"))
+	}
+	if in.From.After(in.To) {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: GetBill 的 from 不能晚于 to"))
+	}
+	campusIDs, err := parseCampusIDs(in.CampusIDs)
+	if err != nil {
+		return nil, err
+	}
+	// 越权预检:4 张来源表都无作用域列(或跨校区可见),必须在这里断"该学生属于当前机构/校区"
+	if s.member != nil && s.sc != nil {
+		if err := s.member.AssertInScope(ctx, in.UserID, s.sc.InstitutionID, s.sc.CampusID); err != nil {
+			return nil, err
+		}
+	}
+
+	logs, err := s.repo.LoadLogs(ctx, in.UserID)
+	if err != nil {
+		return nil, err
+	}
+	// 充值工单**只在"赠送额"这条链上用到**(dump 764168 游标的 LEFT JOIN),BETWEEN 含两端。
+	reqNos, err := s.repo.LoadRechargeRequestNos(ctx, in.UserID, in.From, in.To)
+	if err != nil {
+		return nil, err
+	}
+	signed, err := s.repo.LoadActivitySigned(ctx, in.UserID)
+	if err != nil {
+		return nil, err
+	}
+	used, err := s.repo.LoadActivityUsedLogs(ctx, in.UserID)
+	if err != nil {
+		return nil, err
+	}
+
+	summary, err := buildBillSummary(logs, reqNos, signed, used, in.From, in.To)
+	if err != nil {
+		return nil, err
+	}
+	summary.CampusIDs = campusIDs
+
+	page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
+	pageNo, pageSize := page.Normalize()
+	total, err := s.repo.CountLogs(ctx, in.UserID)
+	if err != nil {
+		return nil, err
+	}
+	rows, err := s.repo.PageLogs(ctx, in.UserID, page)
+	if err != nil {
+		return nil, err
+	}
+	details := make([]BillDetail, 0, len(rows))
+	for i := range rows {
+		amount, perr := money.Parse(rows[i].Money)
+		if perr != nil {
+			return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		logID := ""
+		if rows[i].LogID != nil {
+			logID = *rows[i].LogID
+		}
+		details = append(details, BillDetail{
+			ID:         rows[i].ID,
+			Type:       rows[i].Type,
+			TypeTitle:  billTypeTitle(rows[i].Type),
+			LogID:      logID,
+			Money:      amount,
+			CreateTime: rows[i].CreateTime,
+		})
+	}
+	return &BillResult{
+		Summary:  summary,
+		Details:  details,
+		Total:    total,
+		Page:     pageNo,
+		PageSize: pageSize,
+		From:     in.From,
+		To:       in.To,
+	}, nil
+}
+
+// buildBillSummary 由 4 份内存结构算出 dump 的 9 个汇总列 + 3 个对账辅助列(纯函数,便于单测)。
+//
+// 入参:
+//   - logs 该 user 全量流水(LoadLogs);
+//   - reqNos 区间内已通过的普通充值工单 biz_no 列表(LoadRechargeRequestNos,BETWEEN 含两端);
+//   - signed 该 user 全部活动报名行(LoadActivitySigned);
+//   - used 该 user 全部老带新获得/使用记录(LoadActivityUsedLogs);
+//   - from/to 查询窗口(from 含、to 不含;「当期发生额」用 BETWEEN 含 to)。
+//
+// 出参:BillSummary;金额字面量非法 → ErrDBError。
+//
+// 累加纪律:**全部在 Go 侧用 money.Amount 定点累加**(禁止 SQL SUM(decimal),见 BillRepo 头注释)。
+func buildBillSummary(
+	logs []model.UserExpenseLog,
+	reqNos []string,
+	signed []model.CoursesActivitySigned,
+	used []model.CoursesActivityUsedLog,
+	from, to time.Time,
+) (BillSummary, error) {
+	var out BillSummary
+
+	// dump 764168:充值工单参与集合(`temp_user_balance_request`)
+	rechargeReqNos := make(map[string]struct{}, len(reqNos))
+	for _, no := range reqNos {
+		if no == "" {
+			continue // 空串不参与匹配(旧库 requestid 可为空串,误配会凭空造出赠送额)
+		}
+		rechargeReqNos[no] = struct{}{}
+	}
+	// 实际参与赠送额汇总的工单号:dump 的 join 路径是 `log.logid = request.id`,
+	// 因此**还要有一条区间内的 type=100 流水**指向该工单,才可能把 signed.activity_money 计进来。
+	matchedReqNos := make(map[string]struct{}, len(rechargeReqNos))
+
+	for i := range logs {
+		row := logs[i]
+		amount, err := money.Parse(row.Money)
+		if err != nil {
+			return BillSummary{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+		}
+		// 期初 / 期末:`create_time < 边界`(不含右端,dump 764174 / 764176)
+		if row.CreateTime.Before(from) {
+			out.PrevBalance = out.PrevBalance.Add(amount)
+		}
+		if row.CreateTime.Before(to) {
+			out.EndBalance = out.EndBalance.Add(amount)
+		}
+		// 窗口内合计:两个口径各算一次(差额 == 落在 t = to 的流水,见 BillSummary 字段注释)
+		if !row.CreateTime.Before(from) {
+			if row.CreateTime.Before(to) {
+				out.FlowExclusiveEnd = out.FlowExclusiveEnd.Add(amount)
+			}
+			if !row.CreateTime.After(to) {
+				out.FlowInclusiveEnd = out.FlowInclusiveEnd.Add(amount)
+			}
+		}
+		// 三类「当期发生额」:BETWEEN = 两端闭(dump 764170 / 764172 / 764168)
+		if row.CreateTime.Before(from) || row.CreateTime.After(to) {
+			continue
+		}
+		switch {
+		case row.Type == model.ExpenseLogTypeRecharge:
+			out.Recharge = out.Recharge.Add(amount)
+			if row.LogID != nil {
+				if _, ok := rechargeReqNos[*row.LogID]; ok {
+					matchedReqNos[*row.LogID] = struct{}{}
+				}
+			}
+		case row.Type == model.ExpenseLogTypeRecommend:
+			out.RecommendAmount = out.RecommendAmount.Add(amount)
+		case isBillSpendType(row.Type):
+			out.Spend = out.Spend.Add(amount)
+		}
+	}
+
+	// 当期赠送额:Σ(signed.activity_money) where signed.request_no ∈ matchedReqNos
+	signedByActivity := make(map[int64]money.Amount, len(signed))
+	for i := range signed {
+		amount, err := money.Parse(signed[i].ActivityMoney)
+		if err != nil {
+			return BillSummary{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
+		}
+		signedByActivity[signed[i].ActivityID] = signedByActivity[signed[i].ActivityID].Add(amount)
+		if signed[i].RequestNo == nil {
+			continue
+		}
+		if _, ok := matchedReqNos[*signed[i].RequestNo]; ok {
+			out.Gift = out.Gift.Add(amount)
+		}
+	}
+
+	// 赠送结余(dump 764178-764192):RIGHT JOIN 使"**只有出现过 used_log 的活动**"才进入汇总,
+	// 且 used_log 的金额是**原值相加**(老带新结算写负值,重放写正值)——逐字复刻,不做符号归一。
+	giveEnd, err := giveBalanceBefore(used, signedByActivity, to)
+	if err != nil {
+		return BillSummary{}, err
+	}
+	givePrev, err := giveBalanceBefore(used, signedByActivity, from)
+	if err != nil {
+		return BillSummary{}, err
+	}
+	out.GiveEndBalance = giveEnd
+	out.GivePrevBalance = givePrev
+	// dump 764194:`currentUsedGiveCost = currentTotalActivityMoney + previousGiveCost - currentGiveCost`
+	out.GiveUsed = out.Gift.Add(givePrev).Sub(giveEnd)
+	return out, nil
+}
+
+// giveBalanceBefore 复刻 dump 764178-764192 的"某个 cutoff 之前的赠送金额结余"。
+//
+// dump 原文(原文嵌套三层;这里按语义等价拆解,**箭头右侧是本函数的实现**):
+//
+//	SELECT SUM(activity_money) , SUM(activity_money_used)
+//	FROM ( SELECT signed.activity_money, usedLog.activity_money_used
+//	       FROM temp_courses_activity_signed signed
+//	       RIGHT JOIN ( SELECT activityid, SUM(activity_money_used) AS activity_money_used
+//	                    FROM temp_courses_activity_used_log
+//	                    WHERE create_time < cutoff GROUP BY activityid ) usedLog
+//	       ON signed.activityid = usedLog.activityid ) t1
+//	→ 结果 = Σ(该 user 在"有 used_log(< cutoff)的活动"上的全部 signed.activity_money)
+//	       + Σ(used_log.activity_money_used where create_time < cutoff)
+//
+// **RIGHT JOIN 的两个后果(必须照抄,不得"修好")**:
+//  1. 某活动若从未产生 used_log(< cutoff),其 signed.activity_money **完全不计入**结余;
+//  2. 某活动只有 used_log、没有 signed 行时,signed 侧为 NULL,SUM 按 0 计(不报错)。
+//
+// 入参:
+//   - used 全量 used_log;
+//   - signedByActivity 按 activity_id 汇总的 signed.activity_money(**无时间过滤**);
+//   - cutoff 截止时间(严格小于:`create_time < cutoff`)。
+//
+// 出参:money.Amount 结余(可为负);金额字面量非法 → ErrDBError。
+func giveBalanceBefore(
+	used []model.CoursesActivityUsedLog,
+	signedByActivity map[int64]money.Amount,
+	cutoff time.Time,
+) (money.Amount, error) {
+	total := money.Amount(0)
+	activityHit := make(map[int64]struct{}, len(used))
+	usedSum := money.Amount(0)
+	for i := range used {
+		if !used[i].CreateTime.Before(cutoff) {
+			continue
+		}
+		amount, err := money.Parse(used[i].ActivityMoneyUsed)
+		if err != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+		}
+		usedSum = usedSum.Add(amount)
+		activityHit[used[i].ActivityID] = struct{}{}
+	}
+	total = total.Add(usedSum)
+	for activityID := range activityHit {
+		total = total.Add(signedByActivity[activityID])
+	}
+	return total, nil
+}
+
+// isBillSpendType 判断流水类型是否属于「当期消费」集合(dump 764172 的 IN 列表)。
+func isBillSpendType(logType int32) bool {
+	for _, t := range billSpendTypes {
+		if t == logType {
+			return true
+		}
+	}
+	return false
+}
+
+// parseCampusIDs 解析 dump 第二入参(逗号串 `"1,2,3"`;空串 → 空切片)。
+//
+// 入参:raw 原始字符串(可含空格)。
+// 出参:[]int64 解析结果(**仅回显**,不参与过滤);非法片段 → ErrBadParam。
+//
+// 说明:dump 该参数是**死参数**(764094 声明、过程体零引用),这里只做"格式合法"校验,
+// 让前端传错(如 "1,a")时立刻报错,而不是悄悄当作"没过滤"跑出一份"看起来对"的账单。
+func parseCampusIDs(raw string) ([]int64, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return []int64{}, nil
+	}
+	parts := strings.Split(trimmed, ",")
+	out := make([]int64, 0, len(parts))
+	for _, part := range parts {
+		item := strings.TrimSpace(part)
+		if item == "" {
+			continue
+		}
+		id, err := strconv.ParseInt(item, 10, 64)
+		if err != nil || id <= 0 {
+			return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+				errString("service: campusIds 必须是正整数逗号串(非法片段: "+item+")"))
+		}
+		out = append(out, id)
+	}
+	return out, nil
+}
+
+// billTypeTitle 流水类型的展示名(只用于明细展示;未登记类型返回 "其它")。
+//
+// 入参:logType 流水类型编码(model.ExpenseLogType*)。
+// 出参:中文标题常量。
+func billTypeTitle(logType int32) string {
+	switch logType {
+	case model.ExpenseLogTypeRecharge:
+		return "充值"
+	case model.ExpenseLogTypeCourseCampus, model.ExpenseLogTypeCourseCampusB:
+		return "课程消费(校区)"
+	case model.ExpenseLogTypeCourseSchoolA, model.ExpenseLogTypeCourseSchoolB,
+		model.ExpenseLogTypeCourseSchoolC, model.ExpenseLogTypeCourseSchoolD:
+		return "课程消费(学校)"
+	case model.ExpenseLogTypeTransferOut:
+		return "课费转结(转出)"
+	case model.ExpenseLogTypeTransferIn:
+		return "课费转结(转入)"
+	case model.ExpenseLogTypeRecommend:
+		return "老带新推荐"
+	case model.ExpenseLogTypeBadDebt:
+		return "坏账处理"
+	case model.ExpenseLogTypeHistoryDeduct:
+		return "历史扣费"
+	case model.ExpenseLogTypeRefund:
+		return "余额退费"
+	case model.ExpenseLogTypeClearBalance:
+		return "余额清零"
+	case model.ExpenseLogTypeCancelRechargeAct:
+		return "取消充值活动"
+	default:
+		return "其它"
+	}
+}

+ 406 - 0
internal/academic/service/bill_service_test.go

@@ -0,0 +1,406 @@
+// BillService 单测(AC-10):账单汇总 / 明细分页 / 三种比较语义并存 / 赠送额回退口径。
+//
+// 断言纪律(对照 §5.1 纪律 4「横切优先」):
+//  1. **账单恒等式**(跨字段):`EndBalance − PrevBalance == FlowExclusiveEnd`,
+//     且 `EndBalance` 必须与 **`t_user_expense.balance` 账户表**一致(跨表)——
+//     只断言"字段非空"或"条数对了"的实现,在"漏算某个 type / 符号写反"时依然全绿。
+//  2. **两种时间口径并存**(dump 的既有不对称,不得统一):
+//     `FlowInclusiveEnd − FlowExclusiveEnd == 恰好落在 t = To 的那一笔`,
+//     且该笔**计入**「当期发生额」(BETWEEN 含右端)而**不计入**「期末余额」(`< To`)。
+//  3. **期初/期末不随分页变化**:翻页只影响明细。
+//  4. **赠送额三段链**:`GiveUsed = Gift + GivePrevBalance − GiveEndBalance`(dump 764194)。
+package service
+
+import (
+	"context"
+	"path/filepath"
+	"reflect"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// ---------------------------------------------------------------- 辅助
+
+// newBillServiceDB 账单域测试库(流水 4 表 + 全局钱包)。
+func newBillServiceDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_bill_service_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(账单域文件库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+
+	migrateFundTables(t, db,
+		&model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{},
+		&model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{},
+	)
+	return db
+}
+
+// seedBillLog 落一行资金流水(logID 为 nil 表示旧库 `logid IS NULL`)。
+func seedBillLog(t *testing.T, db *gorm.DB, userID string, logType int32,
+	amount string, createTime time.Time, logID *string) {
+	t.Helper()
+	row := &model.UserExpenseLog{
+		UserID: userID, Type: logType, Money: amount, CreateTime: createTime, LogID: logID,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 流水(type=%d) 失败: %v", logType, err)
+	}
+}
+
+// seedBillAccount 落一行全局钱包(**余额必须与流水累计一致**,否则跨表恒等式用例失去意义)。
+func seedBillAccount(t *testing.T, db *gorm.DB, userID, balance string) {
+	t.Helper()
+	row := &model.UserExpense{UserID: userID, Balance: balance}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 全局钱包 失败: %v", err)
+	}
+}
+
+// seedBillRechargeRequest 落一行已通过的普通充值工单(赠送额链路的 join 起点)。
+func seedBillRechargeRequest(t *testing.T, db *gorm.DB, bizNo, userID string, responseTime time.Time) {
+	t.Helper()
+	approved := model.BalanceRequestApprovedPassed
+	respAt := responseTime
+	row := &model.UserBalanceRequest{
+		BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
+		Approved: approved, ResponseTime: &respAt,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 充值工单 失败: %v", err)
+	}
+}
+
+// seedBillSigned 落一行活动报名(赠送额来源)。
+func seedBillSigned(t *testing.T, db *gorm.DB, activityID int64, userID, activityMoney, requestNo string) {
+	t.Helper()
+	no := requestNo
+	row := &model.CoursesActivitySigned{
+		ActivityID: activityID, UserID: userID, ActivityMoney: activityMoney, RequestNo: &no,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 活动报名 失败: %v", err)
+	}
+}
+
+// seedBillUsed 落一行老带新获得/使用记录。
+func seedBillUsed(t *testing.T, db *gorm.DB, activityID int64, userID, used string, createTime time.Time) {
+	t.Helper()
+	row := &model.CoursesActivityUsedLog{
+		ActivityID: activityID, UserID: userID, ActivityMoneyUsed: used, CreateTime: createTime,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed used_log 失败: %v", err)
+	}
+}
+
+// billFixture 标准账单种子(返回值即"按 dump 手算"的期望,用例逐字段比对)。
+//
+// 时间轴(window: from=2026-03-01T00:00, to=2026-04-01T00:00):
+//
+//	02-20 +500.00  type=100  log=REQ-A   ← 只看得到"期初"
+//	03-05 -100.00  type=101              ← 当期消费
+//	03-10 used_log(9001) -20.00          ← 赠送额使用
+//	03-31  +30.00  type=107              ← 当期推荐额
+//	04-01 +200.00  type=100  log=REQ-B   ← **恰好落在 to**:进"当期发生额"、不进"期末"
+//
+// 请求工单:REQ-A response_time=03-02(区间内,但**没有区间内的 type=100 流水指向它**)→ 不参与赠送;
+//
+//	REQ-B response_time=04-01(BETWEEN 含右端,命中)→ signed(9001, 50.00) 计入当期赠送。
+func billFixture(t *testing.T) (*gorm.DB, *BillService, string, time.Time, time.Time) {
+	t.Helper()
+	db := newBillServiceDB(t)
+	userID := fundUserID(900)
+
+	reqA, reqB := "REQ-A", "REQ-B"
+	seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "500.00",
+		time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), &reqA)
+	seedBillLog(t, db, userID, model.ExpenseLogTypeCourseCampus, "-100.00",
+		time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC), nil)
+	seedBillLog(t, db, userID, model.ExpenseLogTypeRecommend, "30.00",
+		time.Date(2026, 3, 31, 18, 0, 0, 0, time.UTC), nil)
+	seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "200.00",
+		time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), &reqB)
+	// 账户余额 = Σ全部流水 = 500 − 100 + 30 + 200
+	seedBillAccount(t, db, userID, "630.00")
+
+	seedBillRechargeRequest(t, db, "REQ-A", userID, time.Date(2026, 3, 2, 8, 0, 0, 0, time.UTC))
+	seedBillRechargeRequest(t, db, "REQ-B", userID, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))
+	seedBillSigned(t, db, 9001, userID, "50.00", "REQ-B")
+	seedBillUsed(t, db, 9001, userID, "-20.00", time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC))
+
+	svc := NewBillService(db, fundSuperScope())
+	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
+	return db, svc, userID, from, to
+}
+
+// ---------------------------------------------------------------- 汇总口径(dump 764168-764194)
+
+// TestGetBillSummaryMatchesDump 主用例:9 个汇总列逐个对齐 dump 的 SELECT 顺序与口径。
+func TestGetBillSummaryMatchesDump(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+
+	res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	s := res.Summary
+
+	// 764176 期初 / 764174 期末(均 `< 边界`)
+	if got, want := s.PrevBalance.String(), "500.00"; got != want {
+		t.Errorf("期初余额 = %s,期望 %s", got, want)
+	}
+	if got, want := s.EndBalance.String(), "430.00"; got != want {
+		t.Errorf("期末余额 = %s,期望 %s(04-01 那笔因 `< to` 不计入)", got, want)
+	}
+	// 764170 当期推荐额 / 764172 当期消费 / 充值(764168 游标)
+	if got, want := s.RecommendAmount.String(), "30.00"; got != want {
+		t.Errorf("当期推荐额 = %s,期望 %s", got, want)
+	}
+	if got, want := s.Spend.String(), "-100.00"; got != want {
+		t.Errorf("当期消费 = %s,期望 %s", got, want)
+	}
+	if got, want := s.Recharge.String(), "200.00"; got != want {
+		t.Errorf("当期充值 = %s,期望 %s(BETWEEN 含右端,04-01 计入)", got, want)
+	}
+	// 赠送额三段链(764178-764194)
+	if got, want := s.Gift.String(), "50.00"; got != want {
+		t.Errorf("当期赠送额 = %s,期望 %s", got, want)
+	}
+	if got, want := s.GivePrevBalance.String(), "0.00"; got != want {
+		t.Errorf("期初赠送结余 = %s,期望 %s(RIGHT JOIN 使'无 used_log 的活动'不进结余)", got, want)
+	}
+	if got, want := s.GiveEndBalance.String(), "30.00"; got != want {
+		t.Errorf("期末赠送结余 = %s,期望 %s(signed 50 + used −20)", got, want)
+	}
+	if got, want := s.GiveUsed.String(), "20.00"; got != want {
+		t.Errorf("当期使用赠送 = %s,期望 %s(= 赠送 50 + 上期 0 − 期末 30)", got, want)
+	}
+}
+
+// TestGetBillIdentityAcrossAccountTable 横切断言 ①:期末余额 == 账户表余额(跨表自证)。
+//
+// 取 to 覆盖**全部流水**的窗口:此时"期末余额"就是账户快照。
+// 若实现漏算某个 type、或把某类流水符号写反,本用例立刻红(dump 的账户余额正是 Σ全部流水)。
+func TestGetBillIdentityAcrossAccountTable(t *testing.T) {
+	db, svc, userID, from, _ := billFixture(t)
+
+	var account model.UserExpense
+	if err := db.Where("user_id = ?", userID).Take(&account).Error; err != nil {
+		t.Fatalf("读全局钱包失败: %v", err)
+	}
+	to := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) // 覆盖 04-01 那笔
+	res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	if res.Summary.EndBalance != mustAmount(t, account.Balance) {
+		t.Fatalf("期末余额 %s != 账户表余额 %s", res.Summary.EndBalance.String(), account.Balance)
+	}
+	// 恒等式:期末 − 期初 == 窗口内(左闭右开)全类型流水合计
+	delta := res.Summary.EndBalance.Sub(res.Summary.PrevBalance)
+	if delta != res.Summary.FlowExclusiveEnd {
+		t.Fatalf("恒等式破:期末−期初 = %s,窗口内流水 = %s",
+			delta.String(), res.Summary.FlowExclusiveEnd.String())
+	}
+	if got, want := res.Summary.Gift.String(), "50.00"; got != want {
+		t.Errorf("当期赠送额 = %s,期望 %s", got, want)
+	}
+}
+
+// TestGetBillTimeBoundaryDiffersByExactlyOneFlow 横切断言 ②:两种比较语义并在 `t = To` 处差一笔。
+//
+// 断言三件事(缺任何一条,实现都可能"统一成一种语义"而全绿):
+//  1. `FlowInclusiveEnd − FlowExclusiveEnd` == 恰好那一笔(+200.00);
+//  2. `EndBalance − PrevBalance` == `FlowExclusiveEnd`(**不含**该笔);
+//  3. 该笔**计入**「当期充值」(BETWEEN),因此 Recharge 与 (期末−期初) 天然对不上。
+func TestGetBillTimeBoundaryDiffersByExactlyOneFlow(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+
+	res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	s := res.Summary
+
+	if got, want := s.FlowExclusiveEnd.String(), "-70.00"; got != want {
+		t.Errorf("窗口内(左闭右开)流水 = %s,期望 %s", got, want)
+	}
+	if got, want := s.FlowInclusiveEnd.String(), "130.00"; got != want {
+		t.Errorf("窗口内(两端闭)流水 = %s,期望 %s", got, want)
+	}
+	diff := s.FlowInclusiveEnd.Sub(s.FlowExclusiveEnd)
+	if got, want := diff.String(), "200.00"; got != want {
+		t.Fatalf("两种口径的差额 = %s,期望 %s(必须恰好等于落在 t=to 的那笔)", got, want)
+	}
+	if delta := s.EndBalance.Sub(s.PrevBalance); delta != s.FlowExclusiveEnd {
+		t.Fatalf("期末−期初(%s) != FlowExclusiveEnd(%s)", delta.String(), s.FlowExclusiveEnd.String())
+	}
+}
+
+// TestGetBillSummaryInvariantAcrossPagination 横切断言 ③:翻页只影响明细。
+func TestGetBillSummaryInvariantAcrossPagination(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+	ctx := context.Background()
+
+	page1, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 1, PageSize: 2})
+	if err != nil {
+		t.Fatalf("第 1 页失败: %v", err)
+	}
+	page2, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 2, PageSize: 2})
+	if err != nil {
+		t.Fatalf("第 2 页失败: %v", err)
+	}
+	if !reflect.DeepEqual(page1.Summary, page2.Summary) {
+		t.Fatalf("汇总随分页变化(必须不随分页变化):\n第1页 %+v\n第2页 %+v", page1.Summary, page2.Summary)
+	}
+	if page1.Total != 4 || page2.Total != 4 {
+		t.Fatalf("明细总数 = %d/%d,期望 4/4(账户区间对账覆盖全部流水)", page1.Total, page2.Total)
+	}
+	if len(page1.Details) != 2 || len(page2.Details) != 2 {
+		t.Fatalf("分页大小失效:len=%d/%d,期望 2/2", len(page1.Details), len(page2.Details))
+	}
+	// 明细按 `create_time DESC, id DESC`:第1页应是 04-01 与 03-31
+	if page1.Details[0].Type != model.ExpenseLogTypeRecharge || page1.Details[0].Money.String() != "200.00" {
+		t.Fatalf("第 1 页首行应为 04-01 的 100/+200.00,实得 %+v", page1.Details[0])
+	}
+	if page1.Details[1].Type != model.ExpenseLogTypeRecommend {
+		t.Fatalf("第 1 页次行应为 03-31 的 107,实得 %+v", page1.Details[1])
+	}
+}
+
+// TestGetBillCampusIDsIsParsedOnlyNotFiltered dump 第二入参是死参数:只解析回显、不过滤。
+//
+// 用 "1,2,3" 与 "" 两次查询比对汇总**完全一致**——若有人"顺手实现"了校区过滤,
+// 本用例立刻红(这会静默改变账单口径,见 bill_service.go 文件头)。
+func TestGetBillCampusIDsIsParsedOnlyNotFiltered(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+	ctx := context.Background()
+
+	plain, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to})
+	if err != nil {
+		t.Fatalf("无 campusIds 查询失败: %v", err)
+	}
+	withCampus, err := svc.GetBill(ctx, GetBillInput{
+		UserID: userID, CampusIDs: "10, 20", From: from, To: to,
+	})
+	if err != nil {
+		t.Fatalf("带 campusIds 查询失败: %v", err)
+	}
+	if !reflect.DeepEqual(withCampus.Summary.CampusIDs, []int64{10, 20}) {
+		t.Fatalf("campusIds 回显 = %v,期望 [10 20]", withCampus.Summary.CampusIDs)
+	}
+	withCampus.Summary.CampusIDs = plain.Summary.CampusIDs
+	if !reflect.DeepEqual(plain.Summary, withCampus.Summary) {
+		t.Fatalf("campusIds 影响了汇总(dump 里它是死参数,不得过滤):\n无 %+v\n有 %+v",
+			plain.Summary, withCampus.Summary)
+	}
+	if plain.Total != withCampus.Total {
+		t.Fatalf("campusIds 影响了明细条数:%d vs %d", plain.Total, withCampus.Total)
+	}
+}
+
+// TestGetBillEmptyAccount 无流水的学生:全部汇总为 0、明细为空切片(不是 null)。
+func TestGetBillEmptyAccount(t *testing.T) {
+	db := newBillServiceDB(t)
+	svc := NewBillService(db, fundSuperScope())
+	res, err := svc.GetBill(context.Background(), GetBillInput{
+		UserID: fundUserID(901),
+		From:   time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+		To:     time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
+	})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	if res.Details == nil {
+		t.Fatal("明细为 nil,应为长度 0 的切片")
+	}
+	if len(res.Details) != 0 || res.Total != 0 {
+		t.Fatalf("无流水却返回明细 %d 条 / total=%d", len(res.Details), res.Total)
+	}
+	if !res.Summary.EndBalance.IsZero() || !res.Summary.PrevBalance.IsZero() ||
+		!res.Summary.Gift.IsZero() || !res.Summary.GiveUsed.IsZero() {
+		t.Fatalf("无流水时汇总应全 0,实得 %+v", res.Summary)
+	}
+	if res.Page != 1 || res.PageSize != 10 {
+		t.Fatalf("分页默认值应为 (1,10),实得 (%d,%d)", res.Page, res.PageSize)
+	}
+}
+
+// TestGetBillRejectsBadParams 入参守卫(2001);非法 campusIds 必须报错而不是"当没传"。
+func TestGetBillRejectsBadParams(t *testing.T) {
+	db := newBillServiceDB(t)
+	svc := NewBillService(db, fundSuperScope())
+	ctx := context.Background()
+	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
+	to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
+
+	cases := []struct {
+		name string
+		in   GetBillInput
+	}{
+		{"缺 user_id", GetBillInput{From: from, To: to}},
+		{"缺 from", GetBillInput{UserID: fundUserID(1), To: to}},
+		{"缺 to", GetBillInput{UserID: fundUserID(1), From: from}},
+		{"from 晚于 to", GetBillInput{UserID: fundUserID(1), From: to, To: from}},
+		{"campusIds 非法片段", GetBillInput{UserID: fundUserID(1), CampusIDs: "1,a", From: from, To: to}},
+		{"campusIds 非正数", GetBillInput{UserID: fundUserID(1), CampusIDs: "0", From: from, To: to}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := svc.GetBill(ctx, tc.in)
+			wantAppErr(t, err, infraerrors.CodeBadParam, tc.name)
+		})
+	}
+}
+
+// TestGetBillDetailTitleAndMoney 明细展示字段:两位小数与类型名(金额不得输出浮点)。
+func TestGetBillDetailTitleAndMoney(t *testing.T) {
+	_, svc, userID, from, to := billFixture(t)
+	res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to, PageSize: 10})
+	if err != nil {
+		t.Fatalf("GetBill 失败: %v", err)
+	}
+	wantTitles := map[int32]string{
+		model.ExpenseLogTypeRecharge:     "充值",
+		model.ExpenseLogTypeCourseCampus: "课程消费(校区)",
+		model.ExpenseLogTypeRecommend:    "老带新推荐",
+	}
+	for _, d := range res.Details {
+		if want, ok := wantTitles[d.Type]; ok && d.TypeTitle != want {
+			t.Errorf("type=%d 标题 = %q,期望 %q", d.Type, d.TypeTitle, want)
+		}
+		if d.Money.String() != "200.00" && d.Money.String() != "-100.00" &&
+			d.Money.String() != "30.00" && d.Money.String() != "500.00" {
+			t.Errorf("明细金额格式异常: %s(应两位小数)", d.Money.String())
+		}
+	}
+	if res.Details[0].LogID != "REQ-B" {
+		t.Errorf("首行 logId = %q,期望 REQ-B(倒序)", res.Details[0].LogID)
+	}
+}
+
+// TestBillServiceErrorCodeContract DB 未装配时必须 5001(不能把配置错误伪装成业务错误)。
+func TestBillServiceErrorCodeContract(t *testing.T) {
+	svc := NewBillService(nil, fundSuperScope())
+	_, err := svc.GetBill(context.Background(), GetBillInput{
+		UserID: fundUserID(1),
+		From:   time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+		To:     time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
+	})
+	wantAppErr(t, err, infraerrors.CodeInternal, "DB 未装配")
+}

+ 409 - 0
internal/academic/service/payroll_service.go

@@ -0,0 +1,409 @@
+// 课酬试算服务(sp_course_payment_detail_get_teacher_detail → PayrollService.QuoteTeacherReward)。
+//
+// 依据:unified/03-migration/academic-domain-design.md
+//   - §3.4 #26 `sp_course_payment_detail_get_teacher_detail`(dump **763575~763665**)
+//   - §10.1 `3112 课酬方案未配置`("无方案"与"无任何规则命中"两个出口都归 3112,见方法注释)
+//
+// ---------------------------------------------------------------------------
+// dump 骨架(763577 参数 → 763665 `SELECT * FROM t_course_payment_detail WHERE id=coursePaymentDetailid`)
+// ---------------------------------------------------------------------------
+//
+//	763609  游标:`SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
+//	        —— **分类不存在 ⇒ 游标 0 行 ⇒ 循环体一次都不执行** ⇒ 末尾 `WHERE id=0` 返回空结果集。
+//	763621  `IF inTeacherExp<0 THEN SET inTeacherExp=0`(教龄负值钳 0,在循环体内但只依赖入参)
+//	763624  老师持证:`SELECT (SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid)`
+//	        —— **无行 ⇒ NULL ⇒ `IF teacherCert>0` 不成立 ⇒ 按无证走**
+//	763627  方案①:`schoolid+inCampusid+actived=1+effect_date<=inDate ORDER BY effect_date DESC LIMIT 1`
+//	763629  方案②(回退):`coursePaymentid<1` → 同条件但 `campusid=0`(机构级)
+//	763632  `IF coursePaymentid<1 THEN LEAVE readLoop` → 无任何方案 ⇒ **3112**
+//	763636  个人配置闸门:`COUNT(1) WHERE course_paymentid+actived+teacherid+class_type`
+//	763638  个人档 ①:+ `classifyid=inClassifyid`
+//	763643  个人档 ②:+ `gradeid IN (g,-1)`、`subjectid IN (s,-1)`,`ORDER BY gradeid DESC,subjectid DESC`
+//	763650  通用档 ③(持证):`teacherid=0 AND teacher_cert=1` + 年级/科目通配 + 教龄 BETWEEN
+//	763659  通用档 ④(无证):同上但 `teacher_cert=0`
+//	763665  收尾:按命中的 detail id 取整行(**未命中 ⇒ id=0 ⇒ 空结果集**)
+//
+// ---------------------------------------------------------------------------
+// ★ 一处**有意差异**(必读):dump 的 `IF coursePaymentDetailid>1`
+// ---------------------------------------------------------------------------
+//
+// dump 763640 / 763645 / 763653 / 763657 判断的是 `coursePaymentDetailid **> 1**` 而不是 `> 0`。
+// `t_course_payment_detail.id` 是自增主键、从 **1** 开始 ⇒ **id=1 那条规则永远"不算命中"**:
+// 该档查到 id=1 后不 LEAVE,被后续**更低优先级**的查询覆盖(后级无命中时覆盖成 NULL)→
+// 最终 `WHERE id=NULL` 返回空 ⇒ "配了规则却试算不出课酬"。
+//
+// 这是 dump 的既有缺陷(错字级:`>1` 应为 `>0`),本次实现按 `>0`(即"找到即命中"),
+// 差异面**恰好只有 id=1 这一行**(id>=2 时 `>1` 与 `>0` 等价),登记为**有意差异(修正型)**:
+//   - 业务影响:仅影响历史第一条课酬规则;旧系统里它从未生效过,新系统会生效。
+//     割接前须核对该行内容——若它是一条脏数据/占位行,应在数据清洗阶段停用(actived=0),
+//     而不是靠这个错字把它藏起来。
+//   - 自证失败用例:`TestQuotePayrollPersonalRuleWithIDOneMatches`(把实现改回 `>1` 立刻红)。
+//
+// ---------------------------------------------------------------------------
+// 层级编码(供前端展示与对账时区分"命中哪一档")
+// ---------------------------------------------------------------------------
+//
+// 方案层级:`campus`(校区方案)→ `institution`(`campus_id=0` 机构级方案)。
+// 规则层级:`personal_classify` → `personal_grade_subject` → `cert_general` → `nocert_general`。
+package service
+
+import (
+	"context"
+	stderrors "errors"
+	"fmt"
+	"math"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 课酬方案层级(两级回退的命中级别)。
+const (
+	// SchemeScopeCampus 校区级方案(dump 763627,第一个查询)。
+	SchemeScopeCampus = "campus"
+	// SchemeScopeInstitution 机构级方案(dump 763629,`campusid=0` 的第二级回退)。
+	SchemeScopeInstitution = "institution"
+)
+
+// 课酬规则层级(三级回退的命中级别)。
+const (
+	// RuleTierPersonalClassify 个人配置·精确分类(dump 763638)。
+	RuleTierPersonalClassify = "personal_classify"
+	// RuleTierPersonalGradeSubject 个人配置·年级/科目通配(dump 763643)。
+	RuleTierPersonalGradeSubject = "personal_grade_subject"
+	// RuleTierCertGeneral 持证通用(dump 763650)。
+	RuleTierCertGeneral = "cert_general"
+	// RuleTierNoCertGeneral 无证通用(dump 763659)。
+	RuleTierNoCertGeneral = "nocert_general"
+)
+
+// PayrollService 课酬试算服务(只读;无事务)。
+//
+// 字段:
+//   - repo 课酬方案/规则仓储;
+//   - sc 请求作用域(可 nil:系统任务/双跑脚本视角)。
+type PayrollService struct {
+	repo *repository.PayrollRepo
+	sc   *appscope.Scope
+}
+
+// NewPayrollService 构造课酬试算服务。
+//
+// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域;nil = 系统任务视角)。
+// 出参:*PayrollService。
+func NewPayrollService(db *gorm.DB, sc *appscope.Scope) *PayrollService {
+	return &PayrollService{repo: repository.NewPayrollRepo(db, sc), sc: sc}
+}
+
+// QuotePayrollInput 课酬试算入参(对齐 dump 的 6 个入参)。
+//
+// 字段:
+//   - TeacherID 老师(ULID;旧 inUserid);
+//   - TeacherExp 教龄(年;旧 inTeacherExp FLOAT;**负数按 0 处理**,dump 763621);
+//   - InstitutionID 机构 ID(旧 inSchoolid;<=0 时取作用域机构);
+//   - CampusID 校区 ID(旧 inCampusid;方案两级回退的第一级);
+//   - ClassifyID 课程分类 ID(旧 inClassifyid;提供 grade/subject/class_type 三个维度);
+//   - Date 试算日期(旧 inDate;方案 `effect_date <= Date`,**含当日**)。
+type QuotePayrollInput struct {
+	// TeacherID 老师(ULID 26 位)。
+	TeacherID string
+	// TeacherExp 教龄(年;可含小数,负数当 0)。
+	TeacherExp float64
+	// InstitutionID 机构 ID(旧 schoolid;<=0 → 用作用域机构)。
+	InstitutionID int64
+	// CampusID 校区 ID(旧 campusid;0 = 只看机构级方案)。
+	CampusID int64
+	// ClassifyID 课程分类 ID(>0)。
+	ClassifyID int64
+	// Date 试算日期(零值 → ErrBadParam)。
+	Date time.Time
+}
+
+// PayrollQuote 课酬试算结果(dump 763665 `SELECT *` 的 4 个业务列 + 溯源列)。
+//
+// 金额口径:`Min` / `Max` / `Reward` 来自 `decimal(12,2)` 列 → `money.Amount`(两位小数字符串输出)。
+//
+// **`Percentage` 不是金额**:源列是 `decimal(5,1)`(百分比数字部分,如 `65.5` 表示 65.5%),
+// 因此保留**原值字符串**,绝不用 `money.Amount` 承载(否则 65.5 → "65.50 元" 的口径污染)。
+type PayrollQuote struct {
+	// DetailID 命中的课酬明细规则 ID(旧 coursePaymentDetailid)。
+	DetailID int64 `json:"detailId"`
+	// SchemeID 命中的课酬方案 ID(旧 coursePaymentid)。
+	SchemeID int64 `json:"schemeId"`
+	// SchemeCampusID 命中方案所属校区(0 = 机构级方案)。
+	SchemeCampusID int64 `json:"schemeCampusId"`
+	// SchemeScope 方案层级(SchemeScopeCampus / SchemeScopeInstitution)。
+	SchemeScope string `json:"schemeScope"`
+	// RuleTier 规则层级(RuleTier* 四值之一)。
+	RuleTier string `json:"ruleTier"`
+	// Percentage 课酬比例(decimal(5,1) 原值字符串,如 "65.5";**非金额**)。
+	Percentage string `json:"percentage"`
+	// Min 最低课酬标准(元;0 = 不限制)。
+	Min money.Amount `json:"min"`
+	// Max 最高课酬标准(元;0 = 不限制)。
+	Max money.Amount `json:"max"`
+	// Reward 额外课酬(元;在比例基础上追加)。
+	Reward money.Amount `json:"reward"`
+}
+
+// QuoteTeacherReward 课酬试算(设计 §3.4 #26 的正式方法名)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - in QuotePayrollInput(TeacherID / ClassifyID / Date 必填)。
+//
+// 出参:
+//   - *PayrollQuote:命中规则(含溯源层级);
+//   - `apperr.ErrPayrollPlanMissing`(3112):**两级方案都没有**,或**四级规则一条都没命中**;
+//   - ErrBadParam(2001) 缺参(含试算日期零值);ErrUnscoped(1004) 跨机构;ErrDBError(5002)。
+//
+// 为什么"分类不存在"也归 3112:dump 在分类不存在时游标 0 行 → 末尾按 id=0 取行 → **返回空结果集**,
+// 旧 PHP 调用方对空结果的处理与"没有规则"完全一致(拿不到课酬)。Go 侧把这两种"拿不到课酬"
+// 统一成 3112 并挂不同 cause,既不改变可观察类别,又能在日志里区分成因。
+func (s *PayrollService) QuoteTeacherReward(ctx context.Context, in QuotePayrollInput) (*PayrollQuote, error) {
+	if strings.TrimSpace(in.TeacherID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: QuoteTeacherReward 需要 teacher_id"))
+	}
+	if in.ClassifyID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: QuoteTeacherReward 需要 classify_id(>0)"))
+	}
+	if in.Date.IsZero() {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: QuoteTeacherReward 需要试算日期"))
+	}
+	if in.CampusID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: campus_id 不能为负"))
+	}
+	institutionID := in.InstitutionID
+	if institutionID <= 0 && s.sc != nil {
+		institutionID = s.sc.InstitutionID
+	}
+	if institutionID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: QuoteTeacherReward 需要机构 id(入参或作用域)"))
+	}
+
+	// 763609 分类(游标等价单查):不存在 ⇒ 与 dump 的"空结果集"同类 → 3112
+	classify, err := s.repo.GetClassify(ctx, in.ClassifyID)
+	if err != nil {
+		if isNotFoundErr(err) {
+			return nil, infraerrors.WithCause(apperr.ErrPayrollPlanMissing,
+				errString("service: 课程分类不存在(classify_id="+
+					fmt.Sprintf("%d", in.ClassifyID)+"),无可匹配的课酬规则"))
+		}
+		return nil, err
+	}
+
+	// 763621 教龄负值钳 0(dump 在循环体内做,但只依赖入参,提出循环等价)
+	teacherExp := formatTeacherExp(in.TeacherExp)
+
+	// 763624 持证判定(无行 = 无证)
+	hasCert, err := s.repo.TeacherHasCert(ctx, in.TeacherID)
+	if err != nil {
+		return nil, err
+	}
+
+	// 763627-763632 方案两级回退
+	scheme, scope, err := s.resolveScheme(ctx, institutionID, in.CampusID, in.Date)
+	if err != nil {
+		return nil, err
+	}
+
+	// 763636-763664 规则三级回退(个人配置内部再分两档)
+	detail, tier, err := s.resolveDetail(ctx, scheme.ID, in.TeacherID, hasCert,
+		classify, teacherExp)
+	if err != nil {
+		return nil, err
+	}
+	if detail == nil {
+		// 763665:dump 返回空结果集 ⇒ Go 显式化 3112(与"无方案"同一个对外类别)
+		return nil, infraerrors.WithCause(apperr.ErrPayrollPlanMissing,
+			errString("service: 该老师/分类/教龄未命中任何课酬规则(方案 "+fmt.Sprintf("%d", scheme.ID)+")"))
+	}
+
+	quote, err := buildPayrollQuote(detail)
+	if err != nil {
+		return nil, err
+	}
+	quote.SchemeID = scheme.ID
+	quote.SchemeCampusID = scheme.CampusID
+	quote.SchemeScope = scope
+	quote.RuleTier = tier
+	return quote, nil
+}
+
+// QuotePayroll 课酬试算(AC-10 派单口径的方法名;**转调** QuoteTeacherReward,无第二份逻辑)。
+//
+// 入参/出参见 QuoteTeacherReward。
+func (s *PayrollService) QuotePayroll(ctx context.Context, in QuotePayrollInput) (*PayrollQuote, error) {
+	return s.QuoteTeacherReward(ctx, in)
+}
+
+// resolveScheme 方案两级回退(dump 763627-763632)。
+//
+// 入参:ctx;institutionID(>0);campusID 入参校区;date 试算日期。
+// 出参:
+//   - *model.CoursePayment 命中方案(**保证非 nil**);
+//   - string 命中层级(SchemeScopeCampus / SchemeScopeInstitution);
+//   - 3112(两级都没有)/ ErrBadParam / ErrUnscoped / ErrDBError。
+//
+// 顺序**不可交换**:先校区级、再机构级(`campus_id=0`);校区级存在时**绝不**因为
+// "机构级更匹配"而回退——dump 只在 `coursePaymentid<1` 时才走第二级(763629)。
+func (s *PayrollService) resolveScheme(
+	ctx context.Context, institutionID, campusID int64, date time.Time,
+) (*model.CoursePayment, string, error) {
+	scheme, err := s.repo.FindActiveScheme(ctx, institutionID, campusID, date)
+	if err != nil {
+		return nil, "", err
+	}
+	if scheme != nil {
+		return scheme, SchemeScopeCampus, nil
+	}
+	// 763629:`campusid=0` 机构级方案(**campusID=0 时不必重查**:上面那次就是它)
+	if campusID != 0 {
+		scheme, err = s.repo.FindActiveScheme(ctx, institutionID, 0, date)
+		if err != nil {
+			return nil, "", err
+		}
+		if scheme != nil {
+			return scheme, SchemeScopeInstitution, nil
+		}
+	}
+	// 763632:`IF coursePaymentid<1 THEN LEAVE readLoop` → 无方案
+	return nil, "", infraerrors.WithCause(apperr.ErrPayrollPlanMissing,
+		errString("service: 机构 "+fmt.Sprintf("%d", institutionID)+" 校区 "+
+			fmt.Sprintf("%d", campusID)+" 无生效课酬方案(含机构级回退)"))
+}
+
+// resolveDetail 规则三级回退(dump 763636-763664)。
+//
+// 入参:ctx;schemeID;teacherID;hasCert 是否持证;classify 分类维度;teacherExp 已钳 0 的 decimal 字符串。
+// 出参:
+//   - *model.CoursePaymentDetail 命中规则(**未命中返回 nil**,由调用方翻译 3112);
+//   - string 命中层级(RuleTier*);
+//   - ErrBadParam / ErrDBError。
+//
+// 回退顺序(与 dump 逐行一致,**任何一步命中即停**):
+//
+//	个人档闸门(763636 COUNT>0)→ ① 精确分类(763638)→ ② 年级/科目通配(763643)
+//	→ 持证时 ③ 持证通用(763650)→ ④ 无证通用(763659)
+//
+// 注意两处"看起来可以省掉、其实不能改"的细节:
+//  1. 个人档闸门是 `COUNT>0`(任意一条个人规则)而不是"命中某条"——它的作用只是决定
+//     "要不要尝试 ①/②",因此闸门开着但 ①② 都不命中时,**继续往 ③/④ 走**(不是返回空);
+//  2. 持证者若 ③ 未命中,**仍要**试 ④(无证通用档也对持证老师开放),dump 763659 无 else 分支。
+func (s *PayrollService) resolveDetail(
+	ctx context.Context, schemeID int64, teacherID string, hasCert bool,
+	classify *model.CoursesClassify, teacherExp string,
+) (*model.CoursePaymentDetail, string, error) {
+	personalCount, err := s.repo.CountPersonalRules(ctx, schemeID, teacherID, classify.ClassType)
+	if err != nil {
+		return nil, "", err
+	}
+	if personalCount > 0 {
+		detail, err := s.repo.FindPersonalByClassify(ctx, schemeID, teacherID, classify.ClassType, classify.ID)
+		if err != nil {
+			return nil, "", err
+		}
+		if detail != nil {
+			return detail, RuleTierPersonalClassify, nil
+		}
+		detail, err = s.repo.FindPersonalByGradeSubject(ctx, schemeID, teacherID, classify.ClassType,
+			classify.GradeID, classify.SubjectID)
+		if err != nil {
+			return nil, "", err
+		}
+		if detail != nil {
+			return detail, RuleTierPersonalGradeSubject, nil
+		}
+	}
+
+	if hasCert {
+		detail, err := s.repo.FindGeneralRule(ctx, schemeID, true, classify.ClassType,
+			classify.GradeID, classify.SubjectID, teacherExp)
+		if err != nil {
+			return nil, "", err
+		}
+		if detail != nil {
+			return detail, RuleTierCertGeneral, nil
+		}
+	}
+
+	detail, err := s.repo.FindGeneralRule(ctx, schemeID, false, classify.ClassType,
+		classify.GradeID, classify.SubjectID, teacherExp)
+	if err != nil {
+		return nil, "", err
+	}
+	if detail != nil {
+		return detail, RuleTierNoCertGeneral, nil
+	}
+	return nil, "", nil
+}
+
+// buildPayrollQuote 把命中的规则行转成 DTO(金额定点解析 + 比例原值保留)。
+//
+// 入参:detail 命中的规则行(非 nil)。
+// 出参:*PayrollQuote(DetailID/Percentage/Min/Max/Reward 已填);金额字面量非法 → ErrDBError。
+func buildPayrollQuote(detail *model.CoursePaymentDetail) (*PayrollQuote, error) {
+	minAmount, err := money.Parse(detail.MinPayment)
+	if err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	maxAmount, err := money.Parse(detail.MaxPayment)
+	if err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	reward, err := money.Parse(detail.Reward)
+	if err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return &PayrollQuote{
+		DetailID:   detail.ID,
+		Percentage: strings.TrimSpace(detail.Percentage),
+		Min:        minAmount,
+		Max:        maxAmount,
+		Reward:     reward,
+	}, nil
+}
+
+// formatTeacherExp 把教龄(年)格式化成 `decimal(6,2)` 字符串(供 SQL 做 BETWEEN 比较)。
+//
+// 入参:exp 教龄(年;dump 为 FLOAT)。负数 → 按 0(dump 763621)。
+// 出参:形如 "3.50" 的定点字符串(**不经 float 参与累加**:只做一次"×100 取整"换算,
+// 之后全部由数据库按 decimal 语义比较,避免 3.4999999 之类的二进制尾差把边界行判掉)。
+func formatTeacherExp(exp float64) string {
+	if math.IsNaN(exp) || exp < 0 {
+		exp = 0
+	}
+	if math.IsInf(exp, 0) || exp > 9999 {
+		// decimal(6,2) 上限 9999.99;超界直接夹到上限(教龄超过一万年在业务上无意义)
+		exp = 9999
+	}
+	hundredths := int64(math.Round(exp * 100))
+	return fmt.Sprintf("%d.%02d", hundredths/100, hundredths%100)
+}
+
+// isNotFoundErr 判断错误是否为"记录不存在"(ErrNotFound / code 3001)。
+//
+// 入参:err 待判定错误(可 nil)。出参:true=不存在。
+func isNotFoundErr(err error) bool {
+	if err == nil {
+		return false
+	}
+	var appErr *infraerrors.AppError
+	if stderrors.As(err, &appErr) {
+		return appErr.Code == infraerrors.CodeNotFound
+	}
+	return false
+}

+ 599 - 0
internal/academic/service/payroll_service_test.go

@@ -0,0 +1,599 @@
+// PayrollService 单测(AC-10):两级方案回退 + 三级规则回退 + `-1` 通配 + 教龄 BETWEEN。
+//
+// 断言纪律(对照 §5.1 纪律 4):每条用例都断言**命中的 `detailId` 与层级标签**,
+// 而不只是"返回成功"——回退链写错(少一级/顺序颠倒/通配写成等值)时 `detailId` 必然不同。
+//
+// 覆盖的"能自证失败"点:
+//  1. **个人配置 vs 仅无证通用**:两例的 `detailId` 不同、`ruleTier` 不同;
+//  2. **`-1` 通配各一例**:年级通配(subject 精确)与科目通配(grade 精确)分别命中;
+//     把 `IN (g,-1)` 写成 `= g` 的实现,两个用例全红;
+//  3. **dump 的 `>1` 错字**:个人规则 id=1 必须能命中(见 payroll_service.go 文件头差异说明);
+//  4. **方案回退**:校区方案缺失但机构级存在 → 用机构级;都不存在 → 3112;
+//  5. **教龄负值钳 0**:exp=-3 的老师必须命中 0~1 年档(不钳则落空 → 3112)。
+package service
+
+import (
+	"context"
+	"path/filepath"
+	"strconv"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// ---------------------------------------------------------------- 辅助
+
+// newPayrollServiceDB 课酬域测试库(方案 / 明细 / 分类 / 教师资质 4 表)。
+func newPayrollServiceDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_payroll_service_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(课酬域文件库) 失败: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("取 sql.DB 失败: %v", err)
+	}
+	sqlDB.SetMaxOpenConns(1)
+	t.Cleanup(func() { _ = sqlDB.Close() })
+
+	migrateFundTables(t, db,
+		&model.CoursePayment{}, &model.CoursePaymentDetail{},
+		&model.CoursesClassify{}, &model.TeacherInformation{},
+	)
+	return db
+}
+
+// payrollClassType 测试用班型(对应 t_courses_classify.class_type)。
+const payrollClassType int32 = 1000
+
+// payrollDay 试算日(各用例共用)。
+var payrollDay = time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
+
+// seedPayrollClassify 落一行课程分类(提供 grade/subject/class_type 三个维度)。
+func seedPayrollClassify(t *testing.T, db *gorm.DB, id int64, gradeID, subjectID int32) {
+	t.Helper()
+	row := &model.CoursesClassify{
+		ID: id, InstitutionID: 1, CampusID: 10, ClassType: payrollClassType,
+		GradeID: gradeID, SubjectID: subjectID, Name: "测试分类",
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 分类(%d) 失败: %v", id, err)
+	}
+}
+
+// seedPayrollScheme 落一行课酬方案(campusID=0 即机构级方案)。
+func seedPayrollScheme(t *testing.T, db *gorm.DB, id, institutionID, campusID int64, effectDate time.Time) {
+	t.Helper()
+	effect := effectDate
+	row := &model.CoursePayment{
+		ID: id, InstitutionID: institutionID, CampusID: campusID, EffectDate: &effect, Actived: true,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 方案(%d) 失败: %v", id, err)
+	}
+}
+
+// payrollDetail 课酬明细的简化入参(避免 15 个位置参数)。
+type payrollDetail struct {
+	ID         int64
+	SchemeID   int64
+	TeacherID  *string
+	Cert       bool
+	ClassType  int32
+	GradeID    int32
+	SubjectID  int32
+	ClassifyID int64
+	Percentage string
+	MinPayment string
+	MaxPayment string
+	Reward     string
+	ExpMin     string
+	ExpMax     string
+}
+
+// seedPayrollDetail 落一行课酬明细规则。
+func seedPayrollDetail(t *testing.T, db *gorm.DB, d payrollDetail) {
+	t.Helper()
+	row := &model.CoursePaymentDetail{
+		ID: d.ID, CoursePaymentID: d.SchemeID, TeacherID: d.TeacherID, TeacherCert: d.Cert,
+		ClassType: d.ClassType, GradeID: d.GradeID, SubjectID: d.SubjectID, ClassifyID: d.ClassifyID,
+		Percentage: d.Percentage, MinPayment: d.MinPayment, MaxPayment: d.MaxPayment, Reward: d.Reward,
+		TeacherExpMin: d.ExpMin, TeacherExpMax: d.ExpMax, Actived: true,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 课酬明细(%d) 失败: %v", d.ID, err)
+	}
+}
+
+// seedTeacherCert 落一行教师资质(不落 = 无资质档案 = 无证)。
+func seedTeacherCert(t *testing.T, db *gorm.DB, teacherID string, cert bool) {
+	t.Helper()
+	row := &model.TeacherInformation{TeacherID: teacherID, TeacherCert: cert}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 教师资质 失败: %v", err)
+	}
+}
+
+// payrollInput 标准试算入参(机构 1 / 校区 10 / 分类 7001)。
+func payrollInput(teacherID string, teacherExp float64) QuotePayrollInput {
+	return QuotePayrollInput{
+		TeacherID: teacherID, TeacherExp: teacherExp,
+		InstitutionID: 1, CampusID: 10, ClassifyID: 7001, Date: payrollDay,
+	}
+}
+
+// ---------------------------------------------------------------- 规则回退(dump 763636-763664)
+
+// TestQuotePayrollPersonalRuleWithIDOneMatches **有意差异**用例:dump `IF id>1` 的错字已修正为 `>0`。
+//
+// 个人规则 id=1(自增主键的第一行)必须命中 `personal_classify`。
+// 把实现改回 dump 的 `>1` 口径,本例立刻红(实得 3112)——这正是该差异的"自证失败"形式。
+func TestQuotePayrollPersonalRuleWithIDOneMatches(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(700)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 1, SchemeID: 61, TeacherID: &teacher, ClassType: payrollClassType,
+		GradeID: 7, SubjectID: 3, ClassifyID: 7001,
+		Percentage: "80.0", MinPayment: "100.00", MaxPayment: "300.00", Reward: "20.00",
+		ExpMin: "0.00", ExpMax: "10.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("个人规则(id=1) 应命中,实得失: %v", err)
+	}
+	if quote.DetailID != 1 {
+		t.Fatalf("detailId = %d,期望 1", quote.DetailID)
+	}
+	if quote.RuleTier != RuleTierPersonalClassify {
+		t.Fatalf("ruleTier = %q,期望 %q", quote.RuleTier, RuleTierPersonalClassify)
+	}
+}
+
+// TestQuotePayrollPersonalBeatsGeneral 个人配置存在时不得落到通用档(两例 detailId 不同)。
+func TestQuotePayrollPersonalBeatsGeneral(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(701)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	// 个人档(精确分类)id=31
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 31, SchemeID: 61, TeacherID: &teacher, ClassType: payrollClassType,
+		GradeID: 7, SubjectID: 3, ClassifyID: 7001,
+		Percentage: "70.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+	// 无证通用档 id=32(若实现漏了"个人优先",会命中这条 → detailId 断言立刻红)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 32, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	svc := NewPayrollService(db, fundSuperScope())
+	withPersonal, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("个人配置应命中: %v", err)
+	}
+	if withPersonal.DetailID != 31 || withPersonal.RuleTier != RuleTierPersonalClassify {
+		t.Fatalf("命中 detail=%d tier=%q,期望 31/%q",
+			withPersonal.DetailID, withPersonal.RuleTier, RuleTierPersonalClassify)
+	}
+	// 比例是 `decimal(5,1)` 的**数字部分**,必须原值透传:sqlite 的 NUMERIC 亲和性会把 "70.0"
+	// 读回成 "70",MySQL 会读回 "70.0" —— 两者语义等价,故按数值断言(真正的红线是"别当金额")。
+	if pct, perr := strconv.ParseFloat(withPersonal.Percentage, 64); perr != nil || pct != 70.0 {
+		t.Fatalf("percentage = %q,期望数值 70.0(比例原值,不得当金额格式化)", withPersonal.Percentage)
+	}
+
+	// 另一位**没有个人配置**的老师:只能落到无证通用档 → 必须命中 32(两例 detailId 不同)
+	other := fundUserID(702)
+	withoutPersonal, err := svc.QuoteTeacherReward(context.Background(), payrollInput(other, 3))
+	if err != nil {
+		t.Fatalf("无证通用档应命中: %v", err)
+	}
+	if withoutPersonal.DetailID != 32 || withoutPersonal.RuleTier != RuleTierNoCertGeneral {
+		t.Fatalf("命中 detail=%d tier=%q,期望 32/%q",
+			withoutPersonal.DetailID, withoutPersonal.RuleTier, RuleTierNoCertGeneral)
+	}
+	if withPersonal.DetailID == withoutPersonal.DetailID {
+		t.Fatal("个人配置与通用档命中了同一条规则(回退链未生效)")
+	}
+}
+
+// TestQuotePayrollCertGeneralBeforeNoCert 持证老师优先命中"持证通用"档(dump 763650 在 763659 之前)。
+func TestQuotePayrollCertGeneralBeforeNoCert(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(703)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedTeacherCert(t, db, teacher, true)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 41, SchemeID: 61, Cert: true, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "60.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 42, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "40.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("持证老师应命中持证通用档: %v", err)
+	}
+	if quote.DetailID != 41 || quote.RuleTier != RuleTierCertGeneral {
+		t.Fatalf("命中 detail=%d tier=%q,期望 41/%q", quote.DetailID, quote.RuleTier, RuleTierCertGeneral)
+	}
+}
+
+// TestQuotePayrollCertTeacherFallsBackToNoCert 持证老师若"持证通用"档缺失,**仍要**落到无证通用档。
+//
+// dump 763659 没有 else 分支:持证老师的第二级未命中时继续查第三级。
+// 写成 `if cert { 只查持证档 } else { 查无证档 }` 的实现会让本用例红(3112)。
+func TestQuotePayrollCertTeacherFallsBackToNoCert(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(704)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedTeacherCert(t, db, teacher, true)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 51, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "40.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("持证老师应回退到无证通用档: %v", err)
+	}
+	if quote.DetailID != 51 || quote.RuleTier != RuleTierNoCertGeneral {
+		t.Fatalf("命中 detail=%d tier=%q,期望 51/%q", quote.DetailID, quote.RuleTier, RuleTierNoCertGeneral)
+	}
+}
+
+// TestQuotePayrollGradeSubjectWildcard `-1` 通配:年级通配、科目通配各一例。
+//
+// 若把 `grade_id IN (g,-1)` 写成 `grade_id = g`,两例都会落空(3112)。
+func TestQuotePayrollGradeSubjectWildcard(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(705)
+	seedPayrollClassify(t, db, 7001, 7, 3) // 分类:年级 7 / 科目 3
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	// 年级通配(-1)+ 科目精确(3)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 61, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: 3,
+		Percentage: "55.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("年级 -1 通配应命中: %v", err)
+	}
+	if quote.DetailID != 61 {
+		t.Fatalf("detailId = %d,期望 61(年级通配)", quote.DetailID)
+	}
+
+	// 科目通配(-1)+ 年级精确(7),且与上一条**同优先级**——
+	// 用另一位老师 + 只留该行,确保断言的是"通配命中"而不是"排序"。
+	db2 := newPayrollServiceDB(t)
+	seedPayrollClassify(t, db2, 7001, 7, 3)
+	seedPayrollScheme(t, db2, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db2, payrollDetail{
+		ID: 62, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: 7, SubjectID: -1,
+		Percentage: "45.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+	quote2, err := NewPayrollService(db2, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("科目 -1 通配应命中: %v", err)
+	}
+	if quote2.DetailID != 62 {
+		t.Fatalf("detailId = %d,期望 62(科目通配)", quote2.DetailID)
+	}
+}
+
+// TestQuotePayrollWildcardOrderSpecificWins `ORDER BY grade_id DESC, subject_id DESC`:具体行优先于通配行。
+func TestQuotePayrollWildcardOrderSpecificWins(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(706)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	// 通配行(id 更小,若实现 ORDER BY id ASC 就会错命中它)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 71, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "30.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+	// 精确行(id 更大)
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 72, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: 7, SubjectID: 3,
+		Percentage: "90.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 3))
+	if err != nil {
+		t.Fatalf("应命中: %v", err)
+	}
+	if quote.DetailID != 72 {
+		t.Fatalf("detailId = %d,期望 72(具体年级/科目优先于 -1 通配)", quote.DetailID)
+	}
+}
+
+// TestQuotePayrollTeacherExpBetween 教龄 BETWEEN 两端闭 + 负值钳 0(dump 763621 / 763657)。
+func TestQuotePayrollTeacherExpBetween(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(707)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 81, SchemeID: 61, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
+		ExpMin: "0.00", ExpMax: "1.00",
+	})
+	svc := NewPayrollService(db, fundSuperScope())
+
+	// 负教龄 → 钳 0 → 命中 0.00~1.00
+	quote, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, -3))
+	if err != nil {
+		t.Fatalf("教龄 -3 应钳 0 后命中: %v", err)
+	}
+	if quote.DetailID != 81 {
+		t.Fatalf("detailId = %d,期望 81", quote.DetailID)
+	}
+	// 上界闭区间:教龄恰为 1.00 仍命中
+	if _, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 1)); err != nil {
+		t.Fatalf("教龄 1.00(上界)应命中: %v", err)
+	}
+	// 出界:1.01 落空 → 3112
+	_, err = svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 1.01))
+	wantAppErr(t, err, apperr.CodePayrollPlanMissing, "教龄 1.01 超出区间")
+}
+
+// ---------------------------------------------------------------- 方案回退(dump 763627-763632)
+
+// TestQuotePayrollSchemeFallbackToInstitutionLevel 校区方案缺失 → 用 `campus_id=0` 机构级方案。
+func TestQuotePayrollSchemeFallbackToInstitutionLevel(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(708)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	// 只有机构级方案(campus_id=0)
+	seedPayrollScheme(t, db, 91, 1, 0, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 92, SchemeID: 91, Cert: false, ClassType: payrollClassType,
+		GradeID: -1, SubjectID: -1,
+		Percentage: "50.0", MinPayment: "10.00", MaxPayment: "20.00", Reward: "5.00",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 2))
+	if err != nil {
+		t.Fatalf("机构级方案应被回退命中: %v", err)
+	}
+	if quote.SchemeScope != SchemeScopeInstitution || quote.SchemeCampusID != 0 {
+		t.Fatalf("schemeScope/campusId = %q/%d,期望 %q/0",
+			quote.SchemeScope, quote.SchemeCampusID, SchemeScopeInstitution)
+	}
+	if quote.SchemeID != 91 || quote.DetailID != 92 {
+		t.Fatalf("命中 scheme=%d detail=%d,期望 91/92", quote.SchemeID, quote.DetailID)
+	}
+}
+
+// TestQuotePayrollCampusSchemeWinsOverInstitution 两级同时存在时,**校区级优先**(不得反向)。
+func TestQuotePayrollCampusSchemeWinsOverInstitution(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(709)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 101, 1, 0, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))  // 机构级(更老)
+	seedPayrollScheme(t, db, 102, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) // 校区级
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 103, SchemeID: 101, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
+		Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
+	})
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 104, SchemeID: 102, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
+		Percentage: "20.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
+	})
+
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 2))
+	if err != nil {
+		t.Fatalf("校区级方案应命中: %v", err)
+	}
+	if quote.SchemeID != 102 || quote.DetailID != 104 {
+		t.Fatalf("命中 scheme=%d detail=%d,期望 102/104(校区级优先)", quote.SchemeID, quote.DetailID)
+	}
+	if quote.SchemeScope != SchemeScopeCampus {
+		t.Fatalf("schemeScope = %q,期望 %q", quote.SchemeScope, SchemeScopeCampus)
+	}
+}
+
+// TestQuotePayrollSchemeByEffectDate 同校区多方案:取 `effect_date <= 试算日` 里**最新**的一条。
+func TestQuotePayrollSchemeByEffectDate(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(710)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 111, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollScheme(t, db, 112, 1, 10, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 113, SchemeID: 111, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
+		Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
+	})
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 114, SchemeID: 112, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
+		Percentage: "20.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
+	})
+	svc := NewPayrollService(db, fundSuperScope())
+
+	// 试算日 2026-05-20:只有 111 生效
+	quote, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 2))
+	if err != nil {
+		t.Fatalf("2026-05-20 应命中 111: %v", err)
+	}
+	if quote.SchemeID != 111 {
+		t.Fatalf("schemeId = %d,期望 111(112 尚未生效)", quote.SchemeID)
+	}
+	// 试算日 2026-07-01:112 生效且更新 → 命中 112
+	later := payrollInput(teacher, 2)
+	later.Date = time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
+	quoteLater, err := svc.QuoteTeacherReward(context.Background(), later)
+	if err != nil {
+		t.Fatalf("2026-07-01 应命中 112: %v", err)
+	}
+	if quoteLater.SchemeID != 112 {
+		t.Fatalf("schemeId = %d,期望 112(effect_date 最新)", quoteLater.SchemeID)
+	}
+}
+
+// TestQuotePayrollNoSchemeReturns3112 两级方案都不存在 → 3112(dump 763632 LEAVE)。
+func TestQuotePayrollNoSchemeReturns3112(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	_, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(fundUserID(711), 2))
+	wantAppErr(t, err, apperr.CodePayrollPlanMissing, "无任何方案")
+}
+
+// TestQuotePayrollNoRuleMatchReturns3112 方案在、但四级规则全不命中 → 3112(dump 763665 空结果集)。
+func TestQuotePayrollNoRuleMatchReturns3112(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(712)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 121, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	// 班型不同(class_type=9999)→ 任何一档都不该命中
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 122, SchemeID: 121, Cert: false, ClassType: 9999, GradeID: -1, SubjectID: -1,
+		Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
+	})
+	_, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 2))
+	wantAppErr(t, err, apperr.CodePayrollPlanMissing, "方案在但无规则命中")
+}
+
+// TestQuotePayrollClassifyMissingReturns3112 分类不存在 → 3112(dump 游标 0 行 ⇒ 空结果集)。
+func TestQuotePayrollClassifyMissingReturns3112(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	seedPayrollScheme(t, db, 131, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	_, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(fundUserID(713), 2))
+	wantAppErr(t, err, apperr.CodePayrollPlanMissing, "分类不存在")
+}
+
+// ---------------------------------------------------------------- 金额/比例/入参契约
+
+// TestQuotePayrollPercentageIsNotAmount 比例原值返回、金额两位小数、三个金额列独立解析。
+func TestQuotePayrollPercentageIsNotAmount(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(714)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 141, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 142, SchemeID: 141, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
+		Percentage: "65.5", MinPayment: "120.00", MaxPayment: "480.50", Reward: "12.30",
+		ExpMin: "0.00", ExpMax: "99.00",
+	})
+	quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
+		payrollInput(teacher, 2))
+	if err != nil {
+		t.Fatalf("应命中: %v", err)
+	}
+	if quote.Percentage != "65.5" {
+		t.Fatalf("percentage = %q,期望原值 65.5(decimal(5,1),不得格式化成金额)", quote.Percentage)
+	}
+	if got, want := quote.Min.String(), "120.00"; got != want {
+		t.Errorf("min = %s,期望 %s", got, want)
+	}
+	if got, want := quote.Max.String(), "480.50"; got != want {
+		t.Errorf("max = %s,期望 %s", got, want)
+	}
+	if got, want := quote.Reward.String(), "12.30"; got != want {
+		t.Errorf("reward = %s,期望 %s", got, want)
+	}
+}
+
+// TestQuotePayrollRejectsBadParams 入参守卫:缺老师 / 分类 <=0 / 试算日期零值 / 负校区。
+func TestQuotePayrollRejectsBadParams(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	svc := NewPayrollService(db, fundSuperScope())
+	ctx := context.Background()
+
+	base := payrollInput(fundUserID(715), 2)
+	cases := []struct {
+		name string
+		in   QuotePayrollInput
+	}{
+		{"缺 teacher_id", QuotePayrollInput{InstitutionID: 1, ClassifyID: 7001, Date: payrollDay}},
+		{"classify_id=0", QuotePayrollInput{TeacherID: fundUserID(715), InstitutionID: 1, Date: payrollDay}},
+		{"缺试算日期", QuotePayrollInput{TeacherID: fundUserID(715), InstitutionID: 1, ClassifyID: 7001}},
+		{"负校区", QuotePayrollInput{TeacherID: fundUserID(715), InstitutionID: 1, ClassifyID: 7001,
+			CampusID: -1, Date: payrollDay}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := svc.QuoteTeacherReward(ctx, tc.in)
+			wantAppErr(t, err, infraerrors.CodeBadParam, tc.name)
+		})
+	}
+	// 缺机构(入参 0 且作用域 nil)→ 2001
+	noScope := NewPayrollService(db, nil)
+	_, err := noScope.QuoteTeacherReward(ctx, QuotePayrollInput{
+		TeacherID: fundUserID(715), ClassifyID: 7001, Date: payrollDay,
+	})
+	wantAppErr(t, err, infraerrors.CodeBadParam, "缺机构")
+	// 正常入参(有机构)应进入业务链路:这里无方案 → 3112(证明不是被 2001 拦掉)
+	_, err = noScope.QuoteTeacherReward(ctx, base)
+	wantAppErr(t, err, apperr.CodePayrollPlanMissing, "无方案")
+}
+
+// TestQuotePayrollAliasDelegates QuotePayroll 是 QuoteTeacherReward 的别名(同一逻辑,无双实现)。
+func TestQuotePayrollAliasDelegates(t *testing.T) {
+	db := newPayrollServiceDB(t)
+	teacher := fundUserID(716)
+	seedPayrollClassify(t, db, 7001, 7, 3)
+	seedPayrollScheme(t, db, 151, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+	seedPayrollDetail(t, db, payrollDetail{
+		ID: 152, SchemeID: 151, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
+		Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
+	})
+	svc := NewPayrollService(db, fundSuperScope())
+	in := payrollInput(teacher, 2)
+	viaAlias, err := svc.QuotePayroll(context.Background(), in)
+	if err != nil {
+		t.Fatalf("QuotePayroll 失败: %v", err)
+	}
+	viaMain, err := svc.QuoteTeacherReward(context.Background(), in)
+	if err != nil {
+		t.Fatalf("QuoteTeacherReward 失败: %v", err)
+	}
+	if *viaAlias != *viaMain {
+		t.Fatalf("别名与主方法结果不一致:%+v vs %+v", *viaAlias, *viaMain)
+	}
+}