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