payroll_service_test.go 30 KB

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