payroll_service_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. // PayrollService 单测(AC-10):两级方案回退 + 三级规则回退 + `-1` 通配 + 教龄 BETWEEN。
  2. //
  3. // 断言纪律(对照 §5.1 纪律 4):每条用例都断言**命中的 `detailId` 与层级标签**,
  4. // 而不只是"返回成功"——回退链写错(少一级/顺序颠倒/通配写成等值)时 `detailId` 必然不同。
  5. //
  6. // 覆盖的"能自证失败"点:
  7. // 1. **个人配置 vs 仅无证通用**:两例的 `detailId` 不同、`ruleTier` 不同;
  8. // 2. **`-1` 通配各一例**:年级通配(subject 精确)与科目通配(grade 精确)分别命中;
  9. // 把 `IN (g,-1)` 写成 `= g` 的实现,两个用例全红;
  10. // 3. **dump 的 `>1` 错字**:个人规则 id=1 必须能命中(见 payroll_service.go 文件头差异说明);
  11. // 4. **方案回退**:校区方案缺失但机构级存在 → 用机构级;都不存在 → 3112;
  12. // 5. **教龄负值钳 0**:exp=-3 的老师必须命中 0~1 年档(不钳则落空 → 3112)。
  13. package service
  14. import (
  15. "context"
  16. "path/filepath"
  17. "strconv"
  18. "testing"
  19. "time"
  20. gormsqlite "github.com/glebarez/sqlite"
  21. "gorm.io/gorm"
  22. "yuansiwei/unified-backend/internal/academic/apperr"
  23. "yuansiwei/unified-backend/internal/academic/model"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. )
  26. // ---------------------------------------------------------------- 辅助
  27. // newPayrollServiceDB 课酬域测试库(方案 / 明细 / 分类 / 教师资质 4 表)。
  28. func newPayrollServiceDB(t *testing.T) *gorm.DB {
  29. t.Helper()
  30. dsn := filepath.Join(t.TempDir(), "academic_payroll_service_test.db")
  31. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  32. if err != nil {
  33. t.Fatalf("gorm.Open(课酬域文件库) 失败: %v", err)
  34. }
  35. sqlDB, err := db.DB()
  36. if err != nil {
  37. t.Fatalf("取 sql.DB 失败: %v", err)
  38. }
  39. sqlDB.SetMaxOpenConns(1)
  40. t.Cleanup(func() { _ = sqlDB.Close() })
  41. migrateFundTables(t, db,
  42. &model.CoursePayment{}, &model.CoursePaymentDetail{},
  43. &model.CoursesClassify{}, &model.TeacherInformation{},
  44. )
  45. return db
  46. }
  47. // payrollClassType 测试用班型(对应 t_courses_classify.class_type)。
  48. const payrollClassType int32 = 1000
  49. // payrollDay 试算日(各用例共用)。
  50. var payrollDay = time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
  51. // seedPayrollClassify 落一行课程分类(提供 grade/subject/class_type 三个维度)。
  52. func seedPayrollClassify(t *testing.T, db *gorm.DB, id int64, gradeID, subjectID int32) {
  53. t.Helper()
  54. row := &model.CoursesClassify{
  55. ID: id, InstitutionID: 1, CampusID: 10, ClassType: payrollClassType,
  56. GradeID: gradeID, SubjectID: subjectID, Name: "测试分类",
  57. }
  58. if err := db.Create(row).Error; err != nil {
  59. t.Fatalf("seed 分类(%d) 失败: %v", id, err)
  60. }
  61. }
  62. // seedPayrollScheme 落一行课酬方案(campusID=0 即机构级方案)。
  63. func seedPayrollScheme(t *testing.T, db *gorm.DB, id, institutionID, campusID int64, effectDate time.Time) {
  64. t.Helper()
  65. effect := effectDate
  66. row := &model.CoursePayment{
  67. ID: id, InstitutionID: institutionID, CampusID: campusID, EffectDate: &effect, Actived: true,
  68. }
  69. if err := db.Create(row).Error; err != nil {
  70. t.Fatalf("seed 方案(%d) 失败: %v", id, err)
  71. }
  72. }
  73. // payrollDetail 课酬明细的简化入参(避免 15 个位置参数)。
  74. type payrollDetail struct {
  75. ID int64
  76. SchemeID int64
  77. TeacherID *string
  78. Cert bool
  79. ClassType int32
  80. GradeID int32
  81. SubjectID int32
  82. ClassifyID int64
  83. Percentage string
  84. MinPayment string
  85. MaxPayment string
  86. Reward string
  87. ExpMin string
  88. ExpMax string
  89. }
  90. // seedPayrollDetail 落一行课酬明细规则。
  91. func seedPayrollDetail(t *testing.T, db *gorm.DB, d payrollDetail) {
  92. t.Helper()
  93. row := &model.CoursePaymentDetail{
  94. ID: d.ID, CoursePaymentID: d.SchemeID, TeacherID: d.TeacherID, TeacherCert: d.Cert,
  95. ClassType: d.ClassType, GradeID: d.GradeID, SubjectID: d.SubjectID, ClassifyID: d.ClassifyID,
  96. Percentage: d.Percentage, MinPayment: d.MinPayment, MaxPayment: d.MaxPayment, Reward: d.Reward,
  97. TeacherExpMin: d.ExpMin, TeacherExpMax: d.ExpMax, Actived: true,
  98. }
  99. if err := db.Create(row).Error; err != nil {
  100. t.Fatalf("seed 课酬明细(%d) 失败: %v", d.ID, err)
  101. }
  102. }
  103. // seedTeacherCert 落一行教师资质(不落 = 无资质档案 = 无证)。
  104. func seedTeacherCert(t *testing.T, db *gorm.DB, teacherID string, cert bool) {
  105. t.Helper()
  106. row := &model.TeacherInformation{TeacherID: teacherID, TeacherCert: cert}
  107. if err := db.Create(row).Error; err != nil {
  108. t.Fatalf("seed 教师资质 失败: %v", err)
  109. }
  110. }
  111. // payrollInput 标准试算入参(机构 1 / 校区 10 / 分类 7001)。
  112. func payrollInput(teacherID string, teacherExp float64) QuotePayrollInput {
  113. return QuotePayrollInput{
  114. TeacherID: teacherID, TeacherExp: teacherExp,
  115. InstitutionID: 1, CampusID: 10, ClassifyID: 7001, Date: payrollDay,
  116. }
  117. }
  118. // ---------------------------------------------------------------- 规则回退(dump 763636-763664)
  119. // TestQuotePayrollPersonalRuleWithIDOneMatches **有意差异**用例:dump `IF id>1` 的错字已修正为 `>0`。
  120. //
  121. // 个人规则 id=1(自增主键的第一行)必须命中 `personal_classify`。
  122. // 把实现改回 dump 的 `>1` 口径,本例立刻红(实得 3112)——这正是该差异的"自证失败"形式。
  123. func TestQuotePayrollPersonalRuleWithIDOneMatches(t *testing.T) {
  124. db := newPayrollServiceDB(t)
  125. teacher := fundUserID(700)
  126. seedPayrollClassify(t, db, 7001, 7, 3)
  127. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  128. seedPayrollDetail(t, db, payrollDetail{
  129. ID: 1, SchemeID: 61, TeacherID: &teacher, ClassType: payrollClassType,
  130. GradeID: 7, SubjectID: 3, ClassifyID: 7001,
  131. Percentage: "80.0", MinPayment: "100.00", MaxPayment: "300.00", Reward: "20.00",
  132. ExpMin: "0.00", ExpMax: "10.00",
  133. })
  134. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  135. payrollInput(teacher, 3))
  136. if err != nil {
  137. t.Fatalf("个人规则(id=1) 应命中,实得失: %v", err)
  138. }
  139. if quote.DetailID != 1 {
  140. t.Fatalf("detailId = %d,期望 1", quote.DetailID)
  141. }
  142. if quote.RuleTier != RuleTierPersonalClassify {
  143. t.Fatalf("ruleTier = %q,期望 %q", quote.RuleTier, RuleTierPersonalClassify)
  144. }
  145. }
  146. // TestQuotePayrollPersonalBeatsGeneral 个人配置存在时不得落到通用档(两例 detailId 不同)。
  147. func TestQuotePayrollPersonalBeatsGeneral(t *testing.T) {
  148. db := newPayrollServiceDB(t)
  149. teacher := fundUserID(701)
  150. seedPayrollClassify(t, db, 7001, 7, 3)
  151. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  152. // 个人档(精确分类)id=31
  153. seedPayrollDetail(t, db, payrollDetail{
  154. ID: 31, SchemeID: 61, TeacherID: &teacher, ClassType: payrollClassType,
  155. GradeID: 7, SubjectID: 3, ClassifyID: 7001,
  156. Percentage: "70.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  157. ExpMin: "0.00", ExpMax: "99.00",
  158. })
  159. // 无证通用档 id=32(若实现漏了"个人优先",会命中这条 → detailId 断言立刻红)
  160. seedPayrollDetail(t, db, payrollDetail{
  161. ID: 32, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  162. GradeID: -1, SubjectID: -1,
  163. Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  164. ExpMin: "0.00", ExpMax: "99.00",
  165. })
  166. svc := NewPayrollService(db, fundSuperScope())
  167. withPersonal, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 3))
  168. if err != nil {
  169. t.Fatalf("个人配置应命中: %v", err)
  170. }
  171. if withPersonal.DetailID != 31 || withPersonal.RuleTier != RuleTierPersonalClassify {
  172. t.Fatalf("命中 detail=%d tier=%q,期望 31/%q",
  173. withPersonal.DetailID, withPersonal.RuleTier, RuleTierPersonalClassify)
  174. }
  175. // 比例是 `decimal(5,1)` 的**数字部分**,必须原值透传:sqlite 的 NUMERIC 亲和性会把 "70.0"
  176. // 读回成 "70",MySQL 会读回 "70.0" —— 两者语义等价,故按数值断言(真正的红线是"别当金额")。
  177. if pct, perr := strconv.ParseFloat(withPersonal.Percentage, 64); perr != nil || pct != 70.0 {
  178. t.Fatalf("percentage = %q,期望数值 70.0(比例原值,不得当金额格式化)", withPersonal.Percentage)
  179. }
  180. // 另一位**没有个人配置**的老师:只能落到无证通用档 → 必须命中 32(两例 detailId 不同)
  181. other := fundUserID(702)
  182. withoutPersonal, err := svc.QuoteTeacherReward(context.Background(), payrollInput(other, 3))
  183. if err != nil {
  184. t.Fatalf("无证通用档应命中: %v", err)
  185. }
  186. if withoutPersonal.DetailID != 32 || withoutPersonal.RuleTier != RuleTierNoCertGeneral {
  187. t.Fatalf("命中 detail=%d tier=%q,期望 32/%q",
  188. withoutPersonal.DetailID, withoutPersonal.RuleTier, RuleTierNoCertGeneral)
  189. }
  190. if withPersonal.DetailID == withoutPersonal.DetailID {
  191. t.Fatal("个人配置与通用档命中了同一条规则(回退链未生效)")
  192. }
  193. }
  194. // TestQuotePayrollCertGeneralBeforeNoCert 持证老师优先命中"持证通用"档(dump 763650 在 763659 之前)。
  195. func TestQuotePayrollCertGeneralBeforeNoCert(t *testing.T) {
  196. db := newPayrollServiceDB(t)
  197. teacher := fundUserID(703)
  198. seedPayrollClassify(t, db, 7001, 7, 3)
  199. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  200. seedTeacherCert(t, db, teacher, true)
  201. seedPayrollDetail(t, db, payrollDetail{
  202. ID: 41, SchemeID: 61, Cert: true, ClassType: payrollClassType,
  203. GradeID: -1, SubjectID: -1,
  204. Percentage: "60.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  205. ExpMin: "0.00", ExpMax: "99.00",
  206. })
  207. seedPayrollDetail(t, db, payrollDetail{
  208. ID: 42, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  209. GradeID: -1, SubjectID: -1,
  210. Percentage: "40.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  211. ExpMin: "0.00", ExpMax: "99.00",
  212. })
  213. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  214. payrollInput(teacher, 3))
  215. if err != nil {
  216. t.Fatalf("持证老师应命中持证通用档: %v", err)
  217. }
  218. if quote.DetailID != 41 || quote.RuleTier != RuleTierCertGeneral {
  219. t.Fatalf("命中 detail=%d tier=%q,期望 41/%q", quote.DetailID, quote.RuleTier, RuleTierCertGeneral)
  220. }
  221. }
  222. // TestQuotePayrollCertTeacherFallsBackToNoCert 持证老师若"持证通用"档缺失,**仍要**落到无证通用档。
  223. //
  224. // dump 763659 没有 else 分支:持证老师的第二级未命中时继续查第三级。
  225. // 写成 `if cert { 只查持证档 } else { 查无证档 }` 的实现会让本用例红(3112)。
  226. func TestQuotePayrollCertTeacherFallsBackToNoCert(t *testing.T) {
  227. db := newPayrollServiceDB(t)
  228. teacher := fundUserID(704)
  229. seedPayrollClassify(t, db, 7001, 7, 3)
  230. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  231. seedTeacherCert(t, db, teacher, true)
  232. seedPayrollDetail(t, db, payrollDetail{
  233. ID: 51, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  234. GradeID: -1, SubjectID: -1,
  235. Percentage: "40.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  236. ExpMin: "0.00", ExpMax: "99.00",
  237. })
  238. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  239. payrollInput(teacher, 3))
  240. if err != nil {
  241. t.Fatalf("持证老师应回退到无证通用档: %v", err)
  242. }
  243. if quote.DetailID != 51 || quote.RuleTier != RuleTierNoCertGeneral {
  244. t.Fatalf("命中 detail=%d tier=%q,期望 51/%q", quote.DetailID, quote.RuleTier, RuleTierNoCertGeneral)
  245. }
  246. }
  247. // TestQuotePayrollGradeSubjectWildcard `-1` 通配:年级通配、科目通配各一例。
  248. //
  249. // 若把 `grade_id IN (g,-1)` 写成 `grade_id = g`,两例都会落空(3112)。
  250. func TestQuotePayrollGradeSubjectWildcard(t *testing.T) {
  251. db := newPayrollServiceDB(t)
  252. teacher := fundUserID(705)
  253. seedPayrollClassify(t, db, 7001, 7, 3) // 分类:年级 7 / 科目 3
  254. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  255. // 年级通配(-1)+ 科目精确(3)
  256. seedPayrollDetail(t, db, payrollDetail{
  257. ID: 61, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  258. GradeID: -1, SubjectID: 3,
  259. Percentage: "55.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  260. ExpMin: "0.00", ExpMax: "99.00",
  261. })
  262. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  263. payrollInput(teacher, 3))
  264. if err != nil {
  265. t.Fatalf("年级 -1 通配应命中: %v", err)
  266. }
  267. if quote.DetailID != 61 {
  268. t.Fatalf("detailId = %d,期望 61(年级通配)", quote.DetailID)
  269. }
  270. // 科目通配(-1)+ 年级精确(7),且与上一条**同优先级**——
  271. // 用另一位老师 + 只留该行,确保断言的是"通配命中"而不是"排序"。
  272. db2 := newPayrollServiceDB(t)
  273. seedPayrollClassify(t, db2, 7001, 7, 3)
  274. seedPayrollScheme(t, db2, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  275. seedPayrollDetail(t, db2, payrollDetail{
  276. ID: 62, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  277. GradeID: 7, SubjectID: -1,
  278. Percentage: "45.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  279. ExpMin: "0.00", ExpMax: "99.00",
  280. })
  281. quote2, err := NewPayrollService(db2, fundSuperScope()).QuoteTeacherReward(context.Background(),
  282. payrollInput(teacher, 3))
  283. if err != nil {
  284. t.Fatalf("科目 -1 通配应命中: %v", err)
  285. }
  286. if quote2.DetailID != 62 {
  287. t.Fatalf("detailId = %d,期望 62(科目通配)", quote2.DetailID)
  288. }
  289. }
  290. // TestQuotePayrollWildcardOrderSpecificWins `ORDER BY grade_id DESC, subject_id DESC`:具体行优先于通配行。
  291. func TestQuotePayrollWildcardOrderSpecificWins(t *testing.T) {
  292. db := newPayrollServiceDB(t)
  293. teacher := fundUserID(706)
  294. seedPayrollClassify(t, db, 7001, 7, 3)
  295. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  296. // 通配行(id 更小,若实现 ORDER BY id ASC 就会错命中它)
  297. seedPayrollDetail(t, db, payrollDetail{
  298. ID: 71, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  299. GradeID: -1, SubjectID: -1,
  300. Percentage: "30.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  301. ExpMin: "0.00", ExpMax: "99.00",
  302. })
  303. // 精确行(id 更大)
  304. seedPayrollDetail(t, db, payrollDetail{
  305. ID: 72, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  306. GradeID: 7, SubjectID: 3,
  307. Percentage: "90.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  308. ExpMin: "0.00", ExpMax: "99.00",
  309. })
  310. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  311. payrollInput(teacher, 3))
  312. if err != nil {
  313. t.Fatalf("应命中: %v", err)
  314. }
  315. if quote.DetailID != 72 {
  316. t.Fatalf("detailId = %d,期望 72(具体年级/科目优先于 -1 通配)", quote.DetailID)
  317. }
  318. }
  319. // TestQuotePayrollTeacherExpBetween 教龄 BETWEEN 两端闭 + 负值钳 0(dump 763621 / 763657)。
  320. func TestQuotePayrollTeacherExpBetween(t *testing.T) {
  321. db := newPayrollServiceDB(t)
  322. teacher := fundUserID(707)
  323. seedPayrollClassify(t, db, 7001, 7, 3)
  324. seedPayrollScheme(t, db, 61, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  325. seedPayrollDetail(t, db, payrollDetail{
  326. ID: 81, SchemeID: 61, Cert: false, ClassType: payrollClassType,
  327. GradeID: -1, SubjectID: -1,
  328. Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  329. ExpMin: "0.00", ExpMax: "1.00",
  330. })
  331. svc := NewPayrollService(db, fundSuperScope())
  332. // 负教龄 → 钳 0 → 命中 0.00~1.00
  333. quote, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, -3))
  334. if err != nil {
  335. t.Fatalf("教龄 -3 应钳 0 后命中: %v", err)
  336. }
  337. if quote.DetailID != 81 {
  338. t.Fatalf("detailId = %d,期望 81", quote.DetailID)
  339. }
  340. // 上界闭区间:教龄恰为 1.00 仍命中
  341. if _, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 1)); err != nil {
  342. t.Fatalf("教龄 1.00(上界)应命中: %v", err)
  343. }
  344. // 出界:1.01 落空 → 3112
  345. _, err = svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 1.01))
  346. wantAppErr(t, err, apperr.CodePayrollPlanMissing, "教龄 1.01 超出区间")
  347. }
  348. // ---------------------------------------------------------------- 方案回退(dump 763627-763632)
  349. // TestQuotePayrollSchemeFallbackToInstitutionLevel 校区方案缺失 → 用 `campus_id=0` 机构级方案。
  350. func TestQuotePayrollSchemeFallbackToInstitutionLevel(t *testing.T) {
  351. db := newPayrollServiceDB(t)
  352. teacher := fundUserID(708)
  353. seedPayrollClassify(t, db, 7001, 7, 3)
  354. // 只有机构级方案(campus_id=0)
  355. seedPayrollScheme(t, db, 91, 1, 0, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  356. seedPayrollDetail(t, db, payrollDetail{
  357. ID: 92, SchemeID: 91, Cert: false, ClassType: payrollClassType,
  358. GradeID: -1, SubjectID: -1,
  359. Percentage: "50.0", MinPayment: "10.00", MaxPayment: "20.00", Reward: "5.00",
  360. ExpMin: "0.00", ExpMax: "99.00",
  361. })
  362. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  363. payrollInput(teacher, 2))
  364. if err != nil {
  365. t.Fatalf("机构级方案应被回退命中: %v", err)
  366. }
  367. if quote.SchemeScope != SchemeScopeInstitution || quote.SchemeCampusID != 0 {
  368. t.Fatalf("schemeScope/campusId = %q/%d,期望 %q/0",
  369. quote.SchemeScope, quote.SchemeCampusID, SchemeScopeInstitution)
  370. }
  371. if quote.SchemeID != 91 || quote.DetailID != 92 {
  372. t.Fatalf("命中 scheme=%d detail=%d,期望 91/92", quote.SchemeID, quote.DetailID)
  373. }
  374. }
  375. // TestQuotePayrollCampusSchemeWinsOverInstitution 两级同时存在时,**校区级优先**(不得反向)。
  376. func TestQuotePayrollCampusSchemeWinsOverInstitution(t *testing.T) {
  377. db := newPayrollServiceDB(t)
  378. teacher := fundUserID(709)
  379. seedPayrollClassify(t, db, 7001, 7, 3)
  380. seedPayrollScheme(t, db, 101, 1, 0, time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) // 机构级(更老)
  381. seedPayrollScheme(t, db, 102, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) // 校区级
  382. seedPayrollDetail(t, db, payrollDetail{
  383. ID: 103, SchemeID: 101, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
  384. Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
  385. })
  386. seedPayrollDetail(t, db, payrollDetail{
  387. ID: 104, SchemeID: 102, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
  388. Percentage: "20.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
  389. })
  390. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  391. payrollInput(teacher, 2))
  392. if err != nil {
  393. t.Fatalf("校区级方案应命中: %v", err)
  394. }
  395. if quote.SchemeID != 102 || quote.DetailID != 104 {
  396. t.Fatalf("命中 scheme=%d detail=%d,期望 102/104(校区级优先)", quote.SchemeID, quote.DetailID)
  397. }
  398. if quote.SchemeScope != SchemeScopeCampus {
  399. t.Fatalf("schemeScope = %q,期望 %q", quote.SchemeScope, SchemeScopeCampus)
  400. }
  401. }
  402. // TestQuotePayrollSchemeByEffectDate 同校区多方案:取 `effect_date <= 试算日` 里**最新**的一条。
  403. func TestQuotePayrollSchemeByEffectDate(t *testing.T) {
  404. db := newPayrollServiceDB(t)
  405. teacher := fundUserID(710)
  406. seedPayrollClassify(t, db, 7001, 7, 3)
  407. seedPayrollScheme(t, db, 111, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  408. seedPayrollScheme(t, db, 112, 1, 10, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
  409. seedPayrollDetail(t, db, payrollDetail{
  410. ID: 113, SchemeID: 111, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
  411. Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
  412. })
  413. seedPayrollDetail(t, db, payrollDetail{
  414. ID: 114, SchemeID: 112, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
  415. Percentage: "20.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
  416. })
  417. svc := NewPayrollService(db, fundSuperScope())
  418. // 试算日 2026-05-20:只有 111 生效
  419. quote, err := svc.QuoteTeacherReward(context.Background(), payrollInput(teacher, 2))
  420. if err != nil {
  421. t.Fatalf("2026-05-20 应命中 111: %v", err)
  422. }
  423. if quote.SchemeID != 111 {
  424. t.Fatalf("schemeId = %d,期望 111(112 尚未生效)", quote.SchemeID)
  425. }
  426. // 试算日 2026-07-01:112 生效且更新 → 命中 112
  427. later := payrollInput(teacher, 2)
  428. later.Date = time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
  429. quoteLater, err := svc.QuoteTeacherReward(context.Background(), later)
  430. if err != nil {
  431. t.Fatalf("2026-07-01 应命中 112: %v", err)
  432. }
  433. if quoteLater.SchemeID != 112 {
  434. t.Fatalf("schemeId = %d,期望 112(effect_date 最新)", quoteLater.SchemeID)
  435. }
  436. }
  437. // TestQuotePayrollNoSchemeReturns3112 两级方案都不存在 → 3112(dump 763632 LEAVE)。
  438. func TestQuotePayrollNoSchemeReturns3112(t *testing.T) {
  439. db := newPayrollServiceDB(t)
  440. seedPayrollClassify(t, db, 7001, 7, 3)
  441. _, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  442. payrollInput(fundUserID(711), 2))
  443. wantAppErr(t, err, apperr.CodePayrollPlanMissing, "无任何方案")
  444. }
  445. // TestQuotePayrollNoRuleMatchReturns3112 方案在、但四级规则全不命中 → 3112(dump 763665 空结果集)。
  446. func TestQuotePayrollNoRuleMatchReturns3112(t *testing.T) {
  447. db := newPayrollServiceDB(t)
  448. teacher := fundUserID(712)
  449. seedPayrollClassify(t, db, 7001, 7, 3)
  450. seedPayrollScheme(t, db, 121, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  451. // 班型不同(class_type=9999)→ 任何一档都不该命中
  452. seedPayrollDetail(t, db, payrollDetail{
  453. ID: 122, SchemeID: 121, Cert: false, ClassType: 9999, GradeID: -1, SubjectID: -1,
  454. Percentage: "10.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
  455. })
  456. _, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  457. payrollInput(teacher, 2))
  458. wantAppErr(t, err, apperr.CodePayrollPlanMissing, "方案在但无规则命中")
  459. }
  460. // TestQuotePayrollClassifyMissingReturns3112 分类不存在 → 3112(dump 游标 0 行 ⇒ 空结果集)。
  461. func TestQuotePayrollClassifyMissingReturns3112(t *testing.T) {
  462. db := newPayrollServiceDB(t)
  463. seedPayrollScheme(t, db, 131, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  464. _, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  465. payrollInput(fundUserID(713), 2))
  466. wantAppErr(t, err, apperr.CodePayrollPlanMissing, "分类不存在")
  467. }
  468. // ---------------------------------------------------------------- 金额/比例/入参契约
  469. // TestQuotePayrollPercentageIsNotAmount 比例原值返回、金额两位小数、三个金额列独立解析。
  470. func TestQuotePayrollPercentageIsNotAmount(t *testing.T) {
  471. db := newPayrollServiceDB(t)
  472. teacher := fundUserID(714)
  473. seedPayrollClassify(t, db, 7001, 7, 3)
  474. seedPayrollScheme(t, db, 141, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  475. seedPayrollDetail(t, db, payrollDetail{
  476. ID: 142, SchemeID: 141, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
  477. Percentage: "65.5", MinPayment: "120.00", MaxPayment: "480.50", Reward: "12.30",
  478. ExpMin: "0.00", ExpMax: "99.00",
  479. })
  480. quote, err := NewPayrollService(db, fundSuperScope()).QuoteTeacherReward(context.Background(),
  481. payrollInput(teacher, 2))
  482. if err != nil {
  483. t.Fatalf("应命中: %v", err)
  484. }
  485. if quote.Percentage != "65.5" {
  486. t.Fatalf("percentage = %q,期望原值 65.5(decimal(5,1),不得格式化成金额)", quote.Percentage)
  487. }
  488. if got, want := quote.Min.String(), "120.00"; got != want {
  489. t.Errorf("min = %s,期望 %s", got, want)
  490. }
  491. if got, want := quote.Max.String(), "480.50"; got != want {
  492. t.Errorf("max = %s,期望 %s", got, want)
  493. }
  494. if got, want := quote.Reward.String(), "12.30"; got != want {
  495. t.Errorf("reward = %s,期望 %s", got, want)
  496. }
  497. }
  498. // TestQuotePayrollRejectsBadParams 入参守卫:缺老师 / 分类 <=0 / 试算日期零值 / 负校区。
  499. func TestQuotePayrollRejectsBadParams(t *testing.T) {
  500. db := newPayrollServiceDB(t)
  501. svc := NewPayrollService(db, fundSuperScope())
  502. ctx := context.Background()
  503. base := payrollInput(fundUserID(715), 2)
  504. cases := []struct {
  505. name string
  506. in QuotePayrollInput
  507. }{
  508. {"缺 teacher_id", QuotePayrollInput{InstitutionID: 1, ClassifyID: 7001, Date: payrollDay}},
  509. {"classify_id=0", QuotePayrollInput{TeacherID: fundUserID(715), InstitutionID: 1, Date: payrollDay}},
  510. {"缺试算日期", QuotePayrollInput{TeacherID: fundUserID(715), InstitutionID: 1, ClassifyID: 7001}},
  511. {"负校区", QuotePayrollInput{TeacherID: fundUserID(715), InstitutionID: 1, ClassifyID: 7001,
  512. CampusID: -1, Date: payrollDay}},
  513. }
  514. for _, tc := range cases {
  515. t.Run(tc.name, func(t *testing.T) {
  516. _, err := svc.QuoteTeacherReward(ctx, tc.in)
  517. wantAppErr(t, err, infraerrors.CodeBadParam, tc.name)
  518. })
  519. }
  520. // 缺机构(入参 0 且作用域 nil)→ 2001
  521. noScope := NewPayrollService(db, nil)
  522. _, err := noScope.QuoteTeacherReward(ctx, QuotePayrollInput{
  523. TeacherID: fundUserID(715), ClassifyID: 7001, Date: payrollDay,
  524. })
  525. wantAppErr(t, err, infraerrors.CodeBadParam, "缺机构")
  526. // 正常入参(有机构)应进入业务链路:这里无方案 → 3112(证明不是被 2001 拦掉)
  527. _, err = noScope.QuoteTeacherReward(ctx, base)
  528. wantAppErr(t, err, apperr.CodePayrollPlanMissing, "无方案")
  529. }
  530. // TestQuotePayrollAliasDelegates QuotePayroll 是 QuoteTeacherReward 的别名(同一逻辑,无双实现)。
  531. func TestQuotePayrollAliasDelegates(t *testing.T) {
  532. db := newPayrollServiceDB(t)
  533. teacher := fundUserID(716)
  534. seedPayrollClassify(t, db, 7001, 7, 3)
  535. seedPayrollScheme(t, db, 151, 1, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
  536. seedPayrollDetail(t, db, payrollDetail{
  537. ID: 152, SchemeID: 151, Cert: false, ClassType: payrollClassType, GradeID: -1, SubjectID: -1,
  538. Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", ExpMin: "0.00", ExpMax: "99.00",
  539. })
  540. svc := NewPayrollService(db, fundSuperScope())
  541. in := payrollInput(teacher, 2)
  542. viaAlias, err := svc.QuotePayroll(context.Background(), in)
  543. if err != nil {
  544. t.Fatalf("QuotePayroll 失败: %v", err)
  545. }
  546. viaMain, err := svc.QuoteTeacherReward(context.Background(), in)
  547. if err != nil {
  548. t.Fatalf("QuoteTeacherReward 失败: %v", err)
  549. }
  550. if *viaAlias != *viaMain {
  551. t.Fatalf("别名与主方法结果不一致:%+v vs %+v", *viaAlias, *viaMain)
  552. }
  553. }