payroll_age.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. // 教龄换算 + 教师档案读取(课酬试算的 `TeacherExp` 入参来源)——AC-13d 追加。
  2. //
  3. // ---------------------------------------------------------------------------
  4. // 权威口径:PHP `components/Utils.php:924-935` 的 `calculatingAge()`
  5. // ---------------------------------------------------------------------------
  6. //
  7. // 逐行照抄:
  8. //
  9. // if (empty($ageDate)) return 0;
  10. // $strtotime = strtotime($ageDate);
  11. // if (!$strtotime) return 0;
  12. // if ($strtotime < 0) return 0;
  13. // $error = time() - $strtotime;
  14. // $age = $error / (60*60*24) / 365; // ← 固定 365 天/年,**不处理闰年**
  15. // $age = round($age, 1); // ← 四舍五入到 **1 位小数**
  16. // return $age;
  17. //
  18. // 三条必须照抄的点(任一写错都会**稳定地跨错档且不报错**):
  19. //
  20. // 1. **除数固定 365**(不是 365.25、不按日历年份相减)。`time.Year()-exp.Year()` 与
  21. // "秒差/365" 在跨年边界会差出整整 1 岁(例:2025-12-31 起算、2026-11-09 试算 ⇒
  22. // 日历相减 = 1 年,而真实天数 313 ⇒ 0.9 年);
  23. // 2. **四舍五入到 1 位小数**(`round(0.5479,1)` = 0.5)。教龄要和
  24. // `teacher_exp_min/max`(`DECIMAL(6,3)`)做 `BETWEEN` 比较,而**整数年附近最密集**
  25. // (此前修过 `0.999` 那种端点)⇒ 精度错一位就可能跨档;
  26. // 3. **空 / 非法 / 负值 → 0**(也覆盖"老师没录教龄"的存量数据,旧系统同样返回 0)。
  27. //
  28. // 为什么单独成文件:`payroll_service.go` 是 AC-10 的交付物,本批**不改既有实现**;
  29. // 新增能力按团队纪律放新文件(同包、同类型方法,Go 允许跨文件定义)。
  30. package service
  31. import (
  32. "context"
  33. "math"
  34. "strings"
  35. "time"
  36. )
  37. // AgeDecimalPlaces 教龄的小数位(PHP `round($age, 1)`)。
  38. const AgeDecimalPlaces = 1
  39. // calculatingAgeYears 按 PHP `calculatingAge()` 口径把"起算日"换算成"年数"。
  40. //
  41. // 入参:
  42. // - expDate *time.Time:教龄起算日(`t_teacher_information.teacher_exp`,可 nil = 未录);
  43. // - now time.Time:试算时刻(单测注入固定值,生产传 `time.Now()`)。
  44. //
  45. // 出参:教龄年数(保留 1 位小数);nil / 零值 / 未来日期 → 0。
  46. //
  47. // 实现要点:全程用**秒差**(`now.Sub(exp)`),不经 `AddDate` / 年份相减;
  48. // 负数(未来日期)按 0(PHP 的 `$strtotime < 0` 分支对应"时间戳为负",业务上等价于非法值)。
  49. func calculatingAgeYears(expDate *time.Time, now time.Time) float64 {
  50. if expDate == nil || expDate.IsZero() || now.IsZero() {
  51. return 0
  52. }
  53. seconds := now.Sub(*expDate).Seconds()
  54. if seconds <= 0 {
  55. return 0
  56. }
  57. years := seconds / 86400 / 365
  58. if math.IsNaN(years) || math.IsInf(years, 0) || years <= 0 {
  59. return 0
  60. }
  61. return roundToDecimal(years, AgeDecimalPlaces)
  62. }
  63. // roundToDecimal 四舍五入到指定小数位(等价 PHP `round($v, $places)`)。
  64. //
  65. // 入参:v 原值;places 小数位(0~6)。出参:舍入结果。
  66. //
  67. // 为什么不用 `strconv.FormatFloat` + 再 parse:那会把"舍入"变成"格式化",在 0.5 边界上
  68. // 依赖二进制表示(0.15 → "0.1" 或 "0.2" 取决于实现),而 `math.Round` 是显式语义。
  69. func roundToDecimal(v float64, places int) float64 {
  70. if places < 0 {
  71. places = 0
  72. }
  73. if places > 6 {
  74. places = 6
  75. }
  76. scale := math.Pow(10, float64(places))
  77. return math.Round(v*scale) / scale
  78. }
  79. // TeacherExpYears 取某老师的教龄(年),供结课侧课酬试算使用。
  80. //
  81. // 入参:ctx;teacherID 老师 ULID(空 → 0,不报错:排班规则允许没有老师);
  82. // at 试算时刻(= 上课时间;零值 → 当前时刻)。
  83. // 出参:教龄年数(1 位小数);**档案不存在 / 未录教龄 / DB 错误 → 0**(旧系统同口径)。
  84. //
  85. // 为什么 DB 错误也返回 0 而不冒泡:教龄只影响"命中哪一档课酬",而**结课是考勤事实的落库**;
  86. // 让它把整节课打回会变成"某张辅助表抖动 ⇒ 全班结不了课"(与 3112 置零继续同一取舍)。
  87. // 代价是少发钱——这条已在交付报告登记为"教龄解析失败降级 0",需要监控时看 warn 日志。
  88. func (s *PayrollService) TeacherExpYears(ctx context.Context, teacherID string, at time.Time) float64 {
  89. if s == nil || s.repo == nil || strings.TrimSpace(teacherID) == "" {
  90. return 0
  91. }
  92. expDate, found, err := s.repo.TeacherExpDate(ctx, teacherID)
  93. if err != nil || !found {
  94. return 0
  95. }
  96. now := at
  97. if now.IsZero() {
  98. now = time.Now()
  99. }
  100. return calculatingAgeYears(expDate, now)
  101. }