Browse Source

feat(academic): 教龄接上教师档案(calculatingAge 口径:365 天/年 + round 1 位)

Q 10 giờ trước cách đây
mục cha
commit
b29d1fdbfa

+ 59 - 0
internal/academic/handler/course_handler_test.go

@@ -1073,6 +1073,65 @@ func TestStudentScheduleIsPerStudent(t *testing.T) {
 	}
 }
 
+// TestFinishRecordResolvesTeacherExpFromProfile **教龄必须从教师档案解析,且按 365 天口径**。
+//
+// 夹具:老师在 `t_teacher_information.teacher_exp` 的起算日 = **365 天前**(教龄 = 1.0 年),
+// 两档规则:
+//
+//	档 A:[0.000, 0.999] reward=10.00   ← 教龄解析成 0(未接档案)时会落到这里
+//	档 B:[1.000, 5.000] reward=20.00   ← 正确解析(1.0 年)应命中这里
+//
+// 变红条件:
+//   - 适配器没有回落档案(`TeacherExp` 恒 0)→ 得 10.00;
+//   - 用"年份相减"算教龄(跨年边界差整整 1 岁)→ 也会落到错误档;
+//   - 教龄没按 1 位小数舍入 → 0.999/1.000 这类端点会跨档。
+func TestFinishRecordResolvesTeacherExpFromProfile(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	// 两档规则:把原夹具那条改成"档 A",再加一条"档 B"
+	if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", fx.DetailID).
+		Updates(map[string]any{
+			"percentage": "0.0", "reward": "10.00",
+			"teacher_exp_min": "0.000", "teacher_exp_max": "0.999",
+		}).Error; err != nil {
+		t.Fatalf("改写档 A 失败: %v", err)
+	}
+	higher := &model.CoursePaymentDetail{
+		CoursePaymentID: fx.DetailID, // 占位,下面改成真 scheme id
+		GradeID:         -1, SubjectID: -1, ClassType: 1000, ClassifyID: 0,
+		Percentage: "0.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "20.00",
+		TeacherCert: false, TeacherExpMin: "1.000", TeacherExpMax: "5.000", Actived: true,
+	}
+	// 取回原 detail 的 scheme id
+	var schemeID int64
+	db.Raw("SELECT course_payment_id FROM t_course_payment_detail WHERE id = ?", fx.DetailID).Scan(&schemeID)
+	higher.CoursePaymentID = schemeID
+	if err := db.Create(higher).Error; err != nil {
+		t.Fatalf("建档 B 失败: %v", err)
+	}
+	// 教师档案:起算日 = 365 天前 ⇒ 教龄 1.0 年 ⇒ 命中档 B
+	expDate := time.Now().AddDate(0, 0, -365)
+	if err := db.Create(&model.TeacherInformation{
+		TeacherID: courseTestTeacher, TeacherExp: &expDate,
+	}).Error; err != nil {
+		t.Fatalf("建教师档案失败: %v", err)
+	}
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
+	}
+	payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
+	if got, _ := payroll["reward"].(string); got != "20.00" {
+		t.Fatalf("payroll.reward = %v,期望 20.00(教龄 1.0 年命中 [1.000,5.000] 档);"+
+			"10.00 说明教龄解析成 0(没接档案或用错换算口径)", payroll["reward"])
+	}
+	if got, _ := payroll["detailId"].(float64); got != float64(higher.ID) {
+		t.Fatalf("payroll.detailId = %v,期望 %d(档 B)", payroll["detailId"], higher.ID)
+	}
+}
+
 // ---------------------------------------------------------------- 端点 51:课酬分摊口径(D16)
 
 // TestFinishRecordRewardDistributionSkipsGiveRows **D16:课酬只分给 `type∈{1,2}`,赠送行恒 0**。

+ 38 - 0
internal/academic/repository/payroll_repo.go

@@ -190,6 +190,44 @@ func (r *PayrollRepo) TeacherHasCert(ctx context.Context, teacherID string) (boo
 	return row.TeacherCert, nil
 }
 
+// TeacherExpDate 取老师的**教龄起算日**(`t_teacher_information.teacher_exp`,DATE 可空)。
+//
+// 入参:ctx;teacherID 老师 ULID(空 → ErrBadParam)。
+// 出参:(起算日, 是否查到, error);**行不存在或该列为 NULL → (nil,false,nil)**(不是错误:
+// 旧库早期老师大量未录教龄,PHP `calculatingAge(empty)` 同样返回 0)。
+//
+// 为什么返回"日期"而不是"年数":换算必须在 Service 层按 PHP `calculatingAge()` 的
+// 口径做(固定 365 天/年、round 1 位),仓储只负责取**原值列**——仓储做换算会让
+// "同一列在两处用不同口径算"成为可能(贷方/报表各算一遍是最典型的漂移来源)。
+func (r *PayrollRepo) TeacherExpDate(ctx context.Context, teacherID string) (*time.Time, bool, error) {
+	id := strings.TrimSpace(teacherID)
+	if id == "" {
+		return nil, false, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: TeacherExpDate 需要 teacher_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, false, err
+	}
+	var row struct {
+		TeacherExp *time.Time `gorm:"column:teacher_exp"`
+	}
+	qErr := db.Model(&model.TeacherInformation{}).
+		Select("teacher_exp").
+		Where("teacher_id = ?", id).
+		Take(&row).Error
+	if qErr != nil {
+		if isNotFound(qErr) {
+			return nil, false, nil // 无档案:教龄按 0(与 TeacherHasCert 的无行语义一致)
+		}
+		return nil, false, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
+	}
+	if row.TeacherExp == nil || row.TeacherExp.IsZero() {
+		return nil, false, nil // 列为 NULL:未录教龄 → 0
+	}
+	return row.TeacherExp, true, nil
+}
+
 // FindActiveScheme 取生效的课酬方案(dump 763616-763619 的单次查询;两级回退由 Service 调用两次)。
 //
 // dump 原文(camupsid 为入参那一档):

+ 15 - 6
internal/academic/service/course_query_service.go

@@ -1001,7 +1001,7 @@ func (s *RecordViewService) FinishView(ctx context.Context, res *FinishRecordRes
 // 三条"取不到就返回 0、不冒泡错误"的理由:
 //  1. 结账已提交,展示缺项不该让整个请求失败(`payroll.reward` 仍有值);
 //  2. 3112(无方案)在结账侧已被显式降级为 0 课酬,溯源侧同理;
-//  3. 这里的试算**不带教龄**(见 `recordRewardQuoter` 的偏差注释),只用于定位规则 id。
+//  3. 教龄与结课侧同源(教师档案 → `calculatingAge` 口径),保证"金额档位"与"detailId"永远一致。
 func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *model.CoursesRecord, at time.Time) int64 {
 	if s.payroll == nil || rec == nil {
 		return 0
@@ -1014,9 +1014,10 @@ func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *mode
 	if err != nil || course.ClassifyID <= 0 {
 		return 0
 	}
+	// ⚠ 教龄必须与结课侧**同一来源、同一口径**(否则"金额取了档 B、detailId 显示档 A"):
 	quote, qerr := s.payroll.QuoteTeacherReward(ctx, QuotePayrollInput{
 		TeacherID:     teacherID,
-		TeacherExp:    0, // ⚠ AC-11 端口不携带教龄,统一按 0(偏差已登记)
+		TeacherExp:    s.payroll.TeacherExpYears(ctx, teacherID, at),
 		InstitutionID: rec.InstitutionID,
 		CampusID:      rec.CampusID,
 		ClassifyID:    course.ClassifyID,
@@ -1065,9 +1066,11 @@ func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *mode
 //   - `percentage` 列写坏(非数字/负数/小数位过多)→ **ErrDBError 整体回滚**,不静默按 0 算
 //     ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
 //
-// 已知口径缺口(**不是静默正确**):`TeacherExp` 目前恒 0(AC-11 未接教师档案的年数换算),
-// 因此**只会命中 `teacher_exp_min = 0` 的档位**;若某机构的规则全从 `teacher_exp_min > 0` 起,
-// 会走"无规则命中 ⇒ 3112 ⇒ 课酬 0.00"。补齐需接 `t_teacher_information.teacher_exp`(起算日 → 年数)。
+// 教龄(v0.1.35 已接上):入参 `TeacherExp` 为 0 时,本适配器**回落到教师档案**
+// (`t_teacher_information.teacher_exp` 起算日 → 年数),换算按 PHP `calculatingAge()` 口径:
+// **固定 365 天/年**、**四舍五入 1 位小数**、空/非法/未来 → 0(见 `payroll_age.go`)。
+// 两者都没有(老师未录教龄)⇒ 0 ⇒ 只命中 `teacher_exp_min = 0` 的档——**这是旧系统既有行为**
+// (PHP 对空日期同样返回 0),不是缺陷;但会造成"少发钱",监控上看 warn 日志与课酬 0 的比例。
 type recordRewardQuoter struct {
 	svc *PayrollService
 }
@@ -1080,9 +1083,15 @@ func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuot
 	if q.svc == nil {
 		return 0, nil
 	}
+	teacherExp := in.TeacherExp
+	if teacherExp == 0 && strings.TrimSpace(in.TeacherID) != "" {
+		// 结课链路拿不到教龄(AC-11 端口无此入参的来源)⇒ 回落到教师档案,口径见 `payroll_age.go`。
+		// 档案缺失/未录 → 0(旧系统同)。降级不报错:教龄只影响档位,不该把整节课打回。
+		teacherExp = q.svc.TeacherExpYears(ctx, in.TeacherID, in.RecordDate)
+	}
 	quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
 		TeacherID:     in.TeacherID,
-		TeacherExp:    in.TeacherExp,
+		TeacherExp:    teacherExp,
 		InstitutionID: in.InstitutionID,
 		CampusID:      in.CampusID,
 		ClassifyID:    in.ClassifyID,

+ 106 - 0
internal/academic/service/payroll_age.go

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

+ 94 - 0
internal/academic/service/payroll_age_test.go

@@ -0,0 +1,94 @@
+// 教龄换算单测(PHP `utils.php:924-935` `calculatingAge()` 口径)。
+//
+// 三条口径各有一组断言,**任一条写错都会让课酬"稳定地跨错档且不报错"**:
+//
+//	① 除数固定 365(不是 365.25、不是年份相减)
+//	② 四舍五入到 1 位小数(不是 3 位、不是截断)
+//	③ 空 / 非法 / 未来日期 → 0
+//
+// 为什么用固定 `now`:真实 `time.Now()` 会让断言依赖运行时刻(跨零点跑两次结果不同),
+// 而教龄恰好要在"整数年边界"上稳定 —— 边界值必须可复现。
+package service
+
+import (
+	"testing"
+	"time"
+)
+
+// dateUTC 构造 UTC 日期(只取年月日,与 `t_teacher_information.teacher_exp` 的 DATE 语义一致)。
+func dateUTC(year int, month time.Month, day int) time.Time {
+	return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
+}
+
+// ptrTime 时间指针(可空列用)。
+func ptrTime(t time.Time) *time.Time { return &t }
+
+// TestCalculatingAgeYearsMatchesPHP 教龄换算逐值断言(固定 now,边界可复现)。
+func TestCalculatingAgeYearsMatchesPHP(t *testing.T) {
+	now := dateUTC(2026, time.November, 9)
+
+	cases := []struct {
+		name string
+		exp  *time.Time
+		want float64
+	}{
+		{"nil 起算日 → 0", nil, 0},
+		{"零值起算日 → 0", ptrTime(time.Time{}), 0},
+		{"恰好 365 天 → 1.0", ptrTime(now.AddDate(0, 0, -365)), 1.0},
+		{"恰好 730 天 → 2.0", ptrTime(now.AddDate(0, 0, -730)), 2.0},
+		// ① 除数口径:313 天 = 0.8575 年 → 0.9。若按"年份相减"会得 **1.0**(2025-12-31 → 2026-11-09)
+		{"313 天(跨年但不足一年)→ 0.9 而非 1.0", ptrTime(dateUTC(2025, time.December, 31)), 0.9},
+		// ② 小数位口径:10 天 = 0.0274 年 → **0.0**(按 3 位会是 0.027,按截断也是 0.0 但 100 天会分叉)
+		{"10 天 → 0.0(1 位小数舍入)", ptrTime(now.AddDate(0, 0, -10)), 0},
+		{"100 天 → 0.3(0.274 → 0.3)", ptrTime(now.AddDate(0, 0, -100)), 0.3},
+		{"400 天 → 1.1(1.0959 → 1.1)", ptrTime(now.AddDate(0, 0, -400)), 1.1},
+		// ③ 未来日期 → 0(PHP 的负时间戳分支)
+		{"未来日期 → 0", ptrTime(now.AddDate(0, 0, 30)), 0},
+	}
+	for _, tc := range cases {
+		got := calculatingAgeYears(tc.exp, now)
+		if got != tc.want {
+			t.Errorf("%s:calculatingAgeYears = %v,期望 %v", tc.name, got, tc.want)
+		}
+	}
+}
+
+// ⚠ **本测试覆盖不到的一处,写在最前面免得被误解为"已覆盖"**:
+// 除数取 `365` 还是 `365.25`,在 `round(x,1)` 之下**对现实教龄区间不可观测**——
+// 两者每天只差 1.88e-6 年,要拉出 0.05 年的差异需 26000+ 天(70 年以上)才可能跨舍入边界。
+// 我们仍写 `365` 的唯一理由是**逐字照抄 PHP**(`Utils.php:930`),不是因为有断言能抓到;
+// 若有人改成 365.25,现有用例**不会红**——记在这里,不假装有防线。
+// 真正可观测、且已被用例钉住的是:① 不得用年份相减(见下一条)、② 必须舍入到 1 位、③ 空/未来 → 0。
+// TestCalculatingAgeYearsUses365NotCalendar 专门钉住"不得用年份相减"。
+//
+// 同一个起算日,两种算法给出**不同档位**(0.9 vs 1.0),而 `teacher_exp_min=1.000` 的规则
+// 只在后者命中 ⇒ 写错口径会**稳定地跨错档**(且课酬算错、无任何报错)。
+func TestCalculatingAgeYearsUses365NotCalendar(t *testing.T) {
+	now := dateUTC(2026, time.November, 9)
+	exp := dateUTC(2025, time.December, 31)
+	// 日历相减:2026-2025 = 1
+	calendarYears := float64(now.Year() - exp.Year())
+	got := calculatingAgeYears(ptrTime(exp), now)
+	if got == calendarYears {
+		t.Fatalf("教龄 = %v,与「年份相减」结果相同:说明没按 365 天口径算(本例应为 0.9,年份相减是 1.0)", got)
+	}
+	if got != 0.9 {
+		t.Fatalf("教龄 = %v,期望 0.9(313 天 / 365,四舍五入 1 位)", got)
+	}
+}
+
+// TestRoundToDecimal 舍入辅助的边界(0.5 进位按 PHP round 的四舍五入)。
+func TestRoundToDecimal(t *testing.T) {
+	cases := []struct{ in, want float64 }{
+		{0.5479, 0.5},
+		{0.55, 0.6},
+		{1.0959, 1.1},
+		{2.04, 2.0},
+		{9.96, 10.0},
+	}
+	for _, tc := range cases {
+		if got := roundToDecimal(tc.in, 1); got != tc.want {
+			t.Errorf("roundToDecimal(%v,1) = %v,期望 %v", tc.in, got, tc.want)
+		}
+	}
+}

+ 2 - 3
internal/academic/service/record_service.go

@@ -781,9 +781,8 @@ func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRec
 		ClassType:     classType,
 		RecordDate:    at,
 		Income:        income,
-		// TeacherExp:AC-11 未接教师档案(`t_teacher_information.teacher_exp` 是"起算日",
-		// 年数要按试算日期算差值)⇒ 本批固定 0。⚠ **0 会漏掉 `teacher_exp_min > 0` 的档位**,
-		// 属已知口径缺口(已在交付报告登记),不是"静默正确"。
+		// TeacherExp 置 0 表示"本层不认识教龄":由课酬适配器(`recordRewardQuoter`)回落到教师档案,
+		// 换算按 PHP `calculatingAge()`(固定 365 天/年、round 1 位,见 `payroll_age.go`)。
 		TeacherExp:   0,
 		StudentCount: studentCount,
 		RewardType:   rewardType,