Ver código fonte

feat(academic): D10 课酬端口扩 Income/TeacherExp,按比例提成落地(percentage 百分数)

Q 11 horas atrás
pai
commit
16618fe307

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

@@ -1221,6 +1221,77 @@ func TestFinishRecordCrossTableLinkage(t *testing.T) {
 	}
 }
 
+// TestFinishRecordPercentageReward 端点 51 的**按比例提成**(D10:端口扩 `Income`/`TeacherExp` 后必须能算)。
+//
+// 三组夹具(各自独立库):
+//
+//	A 纯比例:percentage=85.0 / min=max=0 / reward=0,课费 100.00 → 课酬 **85.00**
+//	B 下限夹取:同上但 min=90.00 → 课酬 **90.00**(比例算出 85 被抬到下限)
+//	C 上限夹取:percentage=200.0 / max=150.00 → 课酬 **150.00**(比例算出 200 被压到上限)
+//
+// 变红条件:
+//   - 适配器只返回 `reward` 列(D10 修复前的行为)→ A 得 0.00、B 得 90.00 但 A 红;
+//   - 漏掉 min/max 夹取 → B/C 红;
+//   - 把 `percentage` 当 0~1 比率(85.0 → 8500%)→ A 得 8500.00,红。
+func TestFinishRecordPercentageReward(t *testing.T) {
+	cases := []struct {
+		name       string
+		percentage string
+		minPayment string
+		maxPayment string
+		reward     string
+		want       string
+	}{
+		{"纯比例 85%", "85.0", "0.00", "0.00", "0.00", "85.00"},
+		{"下限抬到 90", "85.0", "90.00", "0.00", "0.00", "90.00"},
+		{"上限压到 150", "200.0", "0.00", "150.00", "0.00", "150.00"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			db := newCourseTestDB(t)
+			fx := seedFinishFixture(t, db)
+			// 改写课酬规则:比例型方案(reward 置 0,避免固定额掩盖比例部分)
+			if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", fx.DetailID).
+				Updates(map[string]any{
+					"percentage":  tc.percentage,
+					"min_payment": tc.minPayment,
+					"max_payment": tc.maxPayment,
+					"reward":      tc.reward,
+				}).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 != tc.want {
+				t.Fatalf("payroll.reward = %v,期望 %s(percentage=%s min=%s max=%s,课收入 100.00)",
+					payroll["reward"], tc.want, tc.percentage, tc.minPayment, tc.maxPayment)
+			}
+			// 落库一致性:record.teacher_reward 与打卡行 teacher_reward 都必须等于该值
+			var rec struct {
+				TeacherReward string `gorm:"column:teacher_reward"`
+			}
+			if err := db.Raw("SELECT teacher_reward FROM t_courses_record WHERE id = ?", fx.Record.ID).
+				Scan(&rec).Error; err != nil {
+				t.Fatalf("回读实例失败: %v", err)
+			}
+			requireMoney(t, "record.teacher_reward", rec.TeacherReward, tc.want)
+			var stu struct {
+				TeacherReward string `gorm:"column:teacher_reward"`
+			}
+			if err := db.Raw(`SELECT teacher_reward FROM t_courses_record_student
+				WHERE record_id = ? AND student_id = ?`, fx.Record.ID, courseTestStudent).Scan(&stu).Error; err != nil {
+				t.Fatalf("回读打卡行失败: %v", err)
+			}
+			requireMoney(t, "打卡行 teacher_reward", stu.TeacherReward, tc.want)
+		})
+	}
+}
+
 // TestFinishRecordRollsBackWhenDivideFails 端点 51 的**回滚变异**:让第 3 步(分账)失败,
 // 断言前两步(扣课时 / spend / 打卡行)**全部回滚**。
 //

+ 133 - 13
internal/academic/service/course_query_service.go

@@ -1028,33 +1028,39 @@ func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *mode
 	return quote.DetailID
 }
 
-// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)。
+// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)——
+// **D10 裁决后已补齐"按比例提成"**。
 //
-// ⚠ **两处能力缺口(AC-11 端口签名所致,已登记偏差,不在本批改端口)**:
+// 单节课课酬的完整算法(dump `sp_course_payment_detail_get_teacher_detail` 口径):
 //
-//  1. `RewardQuoteInput` **没有"本次课收入"字段** ⇒ 无法计算"按比例提成"部分
-//     (真实口径是 `percentage × 课收入`,再按 `min/max` 截断后加 `reward`)。
-//     本适配返回方案里的 **`reward`(额外课酬)列**,即"固定课酬"型方案可用、纯比例型方案算不出。
-//  2. `RewardQuoteInput` **没有"教龄"字段** ⇒ 统一按 0 年(dump 对负数也是按 0 兜底,
-//     因此只影响"教龄 > 0 才命中的档位",不影响 0 年档)。
+//	① base  = round(Income × percentage / 100)     ← 比例部分(percentage 是**百分数**:"85.0" = 85%)
+//	② base  = clamp(base, Min, Max)                ← Min/Max 为 0 表示"不限制"
+//	③ total = base + Reward                        ← Reward 是"额外课酬"(在比例基础上追加)
 //
-// 结论:本适配是**占位接线**,让端点 51 的课酬链路(试算 → 分发 → 落库 → 汇总校验)
-// 真正跑通并可断言;口径补齐需 AC-11 扩端口(加 `Income` / `TeacherExp`),
-// 那属于服务层改造,本批按纪律**不改**。
+// 三条实现红线:
+//   - **全程整数分运算**(`money.Amount` 是分),不在任何一步引入 float;
+//   - 比例用"千分之一"整数承载(`"85.0"` → `850`),`round` 用 `+500 后整除 1000`(四舍五入到分);
+//   - `percentage` 列写坏(非数字、负数)→ **ErrDBError 整体回滚**,不静默按 0 算
+//     ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
+//
+// 已知口径缺口(**不是静默正确**):`TeacherExp` 目前恒 0(AC-11 未接教师档案的年数换算),
+// 因此**只会命中 `teacher_exp_min = 0` 的档位**;若某机构的规则全从 `teacher_exp_min > 0` 起,
+// 会走"无规则命中 ⇒ 3112 ⇒ 课酬 0.00"。补齐需接 `t_teacher_information.teacher_exp`(起算日 → 年数)。
 type recordRewardQuoter struct {
 	svc *PayrollService
 }
 
 // QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
 //
-// 入参:ctx;in 端口入参。出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬)。
+// 入参:ctx;in 端口入参(含 `Income` 比例基数、`TeacherExp` 教龄)。
+// 出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬);列写坏 → 5002 整体回滚。
 func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
 	if q.svc == nil {
 		return 0, nil
 	}
 	quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
 		TeacherID:     in.TeacherID,
-		TeacherExp:    0, // 端口无教龄,见类型注释
+		TeacherExp:    in.TeacherExp,
 		InstitutionID: in.InstitutionID,
 		CampusID:      in.CampusID,
 		ClassifyID:    in.ClassifyID,
@@ -1066,7 +1072,121 @@ func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuot
 	if quote == nil {
 		return 0, nil
 	}
-	return quote.Reward, nil
+	return computeRecordReward(quote, in.Income)
+}
+
+// computeRecordReward 由"命中的课酬规则 + 本次课收入"算出单节课课酬(`recordRewardQuoter` 的算法体)。
+//
+// 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入。
+// 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。
+func computeRecordReward(quote *PayrollQuote, income money.Amount) (money.Amount, error) {
+	if quote == nil {
+		return 0, nil
+	}
+	if income.IsNegative() {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("service: 课酬试算的收入不能为负"))
+	}
+	base, perr := applyPercentage(quote.Percentage, income)
+	if perr != nil {
+		return 0, perr
+	}
+	// Min/Max 为 0 表示"不限制"(dump 语义),**不能**当成"下限 0/上限 0"去夹
+	if quote.Min.GreaterThan(0) && base.LessThan(quote.Min) {
+		base = quote.Min
+	}
+	if quote.Max.GreaterThan(0) && base.GreaterThan(quote.Max) {
+		base = quote.Max
+	}
+	total := base.Add(quote.Reward)
+	if total.IsNegative() {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)"))
+	}
+	return total, nil
+}
+
+// percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100;
+// 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。
+const percentageDenom = 1000
+
+// applyPercentage 计算"收入 × 比例"的金额(整数分运算,四舍五入到分)。
+//
+// 入参:pct 百分比字符串(`decimal(5,1)`,如 `"85.0"`;空串按 0);income 本次课收入(分)。
+// 出参:金额(分);`ErrDBError`(列写坏:非数字 / 多于 2 位小数 / 负数)。
+//
+// 算法:`pctMilli = 百分比 × 10`(`"85.0"` → 850),`base = round(income × pctMilli / 1000)`。
+// 为什么不用 `money.Parse` 再乘 float:比例列是 `decimal(5,1)`,用 float 会在 `×0.85` 这类
+// 运算里带进二进制尾差(如 0.1+0.2 级),资金口径不允许。
+func applyPercentage(pct string, income money.Amount) (money.Amount, error) {
+	text := strings.TrimSpace(pct)
+	if text == "" {
+		return 0, nil // 未配置比例:只算 min/max/reward 部分(dump 的 0 值语义)
+	}
+	if strings.HasPrefix(text, "-") {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬比例不能为负 "+text))
+	}
+	intPart, fracPart, _ := strings.Cut(text, ".")
+	if intPart == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬比例格式非法 "+text))
+	}
+	whole, perr := parseUintDigits(intPart)
+	if perr != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+			errString("service: 课酬比例格式非法 "+text))
+	}
+	// 小数位:只取 1 位(列是 decimal(5,1));第 2 位 >=5 时进位
+	tenth := int64(0)
+	if fracPart != "" {
+		if len(fracPart) > 2 {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+				errString("service: 课酬比例小数位过多 "+text))
+		}
+		first, ferr := parseUintDigits(fracPart[:1])
+		if ferr != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+				errString("service: 课酬比例格式非法 "+text))
+		}
+		tenth = first
+		if len(fracPart) == 2 {
+			second, serr := parseUintDigits(fracPart[1:])
+			if serr != nil {
+				return 0, infraerrors.WithCause(infraerrors.ErrDBError,
+					errString("service: 课酬比例格式非法 "+text))
+			}
+			if second >= 5 {
+				tenth++
+			}
+		}
+	}
+	pctMilli := whole*10 + tenth
+	if pctMilli == 0 {
+		return 0, nil
+	}
+	cents := income.Cents()
+	// 溢出兜底:decimal(12,2) 上限 ≈ 1e10 分,pctMilli ≤ 1000 ⇒ 乘积 ≤ 1e13,int64 安全
+	base := (cents*pctMilli + percentageDenom/2) / percentageDenom
+	return money.FromCents(base), nil
+}
+
+// parseUintDigits 把纯数字串转 int64(比例解析用;非数字 → error)。
+func parseUintDigits(digits string) (int64, error) {
+	if digits == "" {
+		return 0, errString("empty")
+	}
+	var out int64
+	for _, ch := range digits {
+		if ch < '0' || ch > '9' {
+			return 0, errString("not a digit")
+		}
+		out = out*10 + int64(ch-'0')
+		if out > 1_000_000 { // 比例列 decimal(5,1) 不会超过这个量级
+			return 0, errString("too large")
+		}
+	}
+	return out, nil
 }
 
 // ---------------------------------------------------------------------------

+ 28 - 9
internal/academic/service/record_service.go

@@ -65,7 +65,14 @@ type DivideLogGranter interface {
 //   - InstitutionID/CampusID int64:方案两级回退的校区维度(校区 → 机构级 campus_id=0);
 //   - ClassifyID int64:课程分类(决定 grade/subject/class_type 维度);
 //   - ClassType int32:班级类型(1000 集体班…);
-//   - RecordDate time.Time:上课日期(方案 effect_date 生效判定)。
+//   - RecordDate time.Time:上课日期(方案 effect_date 生效判定);
+//   - Income money.Amount:**本次课收入**(= Σ record_student.cost)——按比例提成的计算基数;
+//   - TeacherExp float64:教龄(年)——课酬规则的 `teacher_exp_min..max` 区间命中依据。
+//
+// ⚠ **v0.1.34 增补(D10 裁决):Income / TeacherExp 是后加的两个字段,既有字段语义一个未改**
+// (Go 结构体加字段对既有调用向后兼容)。为什么必须加:没有 Income 就算不出"按比例提成"
+// (旧适配只能返回方案里的 `reward` 固定列 ⇒ 纯比例型方案课酬恒 0 = **少发钱**);
+// 没有 TeacherExp 就永远命中 0 年档。
 type RewardQuoteInput struct {
 	TeacherID     string
 	InstitutionID int64
@@ -73,6 +80,10 @@ type RewardQuoteInput struct {
 	ClassifyID    int64
 	ClassType     int32
 	RecordDate    time.Time
+	// Income 本次课收入(元;Σ 打卡明细 cost)。按比例提成的基数;0 表示无收入(比例部分算 0)。
+	Income money.Amount
+	// TeacherExp 教龄(年;**0 = 未取到**,见 RewardsQuoteExpKnown 注释里的口径缺口)。
+	TeacherExp float64
 }
 
 // TeacherRewardQuoter 课酬试算端口(AC-10 PayrollService 实现;本服务只声明契约)。
@@ -435,18 +446,21 @@ func (s *RecordService) FinishRecord(ctx context.Context, in FinishRecordInput)
 			return rerr
 		}
 
+		// 先算本次课收入(Σ cost):它是"按比例提成"的基数 ⇒ **必须早于试算**。
+		// 顺序调整(D10):旧代码先试算、后累加 income,导致比例型方案拿不到基数。
+		income := money.Amount(0)
+		for i := range resolved {
+			income = income.Add(resolved[i].Cost)
+		}
+
 		// ⑦ 课酬试算(一次:一节课一份课酬;nil quoter → 0.00)
-		totalReward, qerr := s.quoteReward(txCtx, rec, endTime)
+		totalReward, qerr := s.quoteReward(txCtx, rec, endTime, income)
 		if qerr != nil {
 			return qerr
 		}
 		rewards := distributeAmount(totalReward, len(resolved))
-
-		income := money.Amount(0)
 		for i := range resolved {
-			cur := &resolved[i]
-			cur.TeacherReward = rewards[i]
-			income = income.Add(cur.Cost)
+			resolved[i].TeacherReward = rewards[i]
 		}
 
 		// ③ 扣课时 + ④ spend 累加 + ② 打卡明细落库(逐学生,顺序固定 → 锁序可复现)
@@ -727,9 +741,9 @@ func (s *RecordService) upsertRecordStudent(txCtx context.Context, tx *gorm.DB,
 
 // quoteReward 课酬试算(未注入 quoter → 0.00;未配置方案 3112 → 0.00 且不阻断结课)。
 //
-// 入参:txCtx;rec 实例;at 上课时间(用于方案 effect_date 判定)。
+// 入参:txCtx;rec 实例;at 上课时间(用于方案 effect_date 判定);income 本次课收入(比例提成基数)。
 // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。
-func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time) (money.Amount, error) {
+func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time, income money.Amount) (money.Amount, error) {
 	if s.quoter == nil {
 		return 0, nil
 	}
@@ -753,6 +767,11 @@ func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRec
 		ClassifyID:    course.ClassifyID,
 		ClassType:     classType,
 		RecordDate:    at,
+		Income:        income,
+		// TeacherExp:AC-11 未接教师档案(`t_teacher_information.teacher_exp` 是"起算日",
+		// 年数要按试算日期算差值)⇒ 本批固定 0。⚠ **0 会漏掉 `teacher_exp_min > 0` 的档位**,
+		// 属已知口径缺口(已在交付报告登记),不是"静默正确"。
+		TeacherExp: 0,
 	}
 	reward, qerr := s.quoter.QuoteRecordReward(txCtx, in)
 	if qerr != nil {