|
@@ -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)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|