| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 |
- // 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)
- }
- }
- }
|