// 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" "math" "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) } } // ---------------------------------------------------------------- P0:教龄列精度(decimal(6,3)) // TestQuotePayrollTeacherExpBoundaryOneYearHitsUpperBand 教龄恰好为整数时只能命中**上**一档。 // // 背景(P0,架构师-2 AC-10 审计):源列 `teacher_exp_min/max` 是 `float(10,3)`, // 目标态原写 `DECIMAL(6,2)` —— 会把 `0.999` 存成 `1.00`,使 // `[0.000, 0.999]` 与 `[1.000, 1.999]` 在教龄 = 1.0 处**并列命中**, // 再靠 `ORDER BY grade_id DESC, subject_id DESC, id ASC` 取到**低档**(id=1)⇒ 课酬按错档位算。 // 修法是把两列改为 `DECIMAL(6,3)`(保留 3 位),本用例锁定"1.0 只命中 1.000~1.999 档"。 // // ⚠ **诚实说明(别把这条当成 (6,2)→(6,3) 的自证)**:本仓单测栈是 sqlite,它的 NUMERIC 亲和性 // **不强制 decimal 的 scale**(插入 0.999 不会因声明 (6,2) 而被舍成 1.00)。 // 因此这条用例守的是**比较语义**(`BETWEEN` 两端闭 + 边界归属), // 真正守"列精度不能被收敛"的是 schema 门禁 `model.TestModelsColumnByColumn` // (struct tag ↔ DDL 逐列比对,实测:只改 DDL 不改 tag 时它立刻红)。 func TestQuotePayrollTeacherExpBoundaryOneYearHitsUpperBand(t *testing.T) { db := newPayrollServiceDB(t) teacher := fundUserID(730) seedPayrollClassify(t, db, 7001, 7, 3) seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) // 低档:0.000 ~ 0.999(dump 实测首行形态) seedPayrollDetail(t, db, payrollDetail{ ID: 1, SchemeID: 61, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1, Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.000", ExpMax: "0.999", }) // 高档:1.000 ~ 1.999 seedPayrollDetail(t, db, payrollDetail{ ID: 91, SchemeID: 61, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1, Percentage: "20.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "1.000", ExpMax: "1.999", }) svc := NewPayrollService(db, fundSuperScope()) cases := []struct { exp float64 want int64 why string }{ {1.0, 91, "教龄恰为 1.0 ⇒ 只命中 1.000~1.999 档(若命中 1 则说明低档上限被舍成 1.00)"}, {0.999, 1, "上界闭区间:0.999 仍属低档"}, {1.999, 91, "上界闭区间:1.999 仍属高档"}, {0.0, 1, "下界闭区间:0 属低档"}, } for _, tc := range cases { quote, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, tc.exp)) if err != nil { t.Fatalf("教龄 %.3f 应命中:%v(%s)", tc.exp, err, tc.why) } if quote.DetailID != tc.want { t.Fatalf("教龄 %.3f 命中 detail=%d,期望 %d(%s)", tc.exp, quote.DetailID, tc.want, tc.why) } } // 出界:2.0 不属任何档 → 3112(证明区间下界不是 `<=` 放宽) _, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 2.0)) wantAppErr(t, err, apperr.CodePayrollPlanMissing, "教龄 2.0 出界") } // TestFormatTeacherExpThreeDecimals 教龄格式化必须输出**3 位**小数(与列 scale `decimal(6,3)` 对齐)。 // // 自证失败形式:把实现改回 2 位(`%d.%02d`)→ 本用例立刻红。 // 为什么位数要对齐列 scale:教龄参与 `BETWEEN exp_min AND exp_max` 的数值比较, // 2 位格式化会把 1.234 送成 1.23,边界值(如 exp_max=1.230)附近的可判性直接丢失。 func TestFormatTeacherExpThreeDecimals(t *testing.T) { cases := []struct { in float64 want string }{ {1, "1.000"}, {1.2349, "1.235"}, {0, "0.000"}, {-3, "0.000"}, // 负值钳 0(dump 763609) {0.999, "0.999"}, {1000, "999.000"}, // 超 decimal(6,3) 上限 → 夹到 999.999 以内 {math.NaN(), "0.000"}, } for _, tc := range cases { if got := formatTeacherExp(tc.in); got != tc.want { t.Errorf("formatTeacherExp(%v) = %q,期望 %q", tc.in, got, tc.want) } } }