// 教龄换算 + 教师档案读取(课酬试算的 `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) }