Переглянути джерело

fix(academic): 课酬公式改为 PHP 权威顺序(先加 reward 再夹 min/max,上限优先)+ 补 teacher_reward_type=2 固定课酬表

Q 10 годин тому
батько
коміт
4349997ea9

+ 27 - 19
internal/academic/handler/course_handler_test.go

@@ -1355,36 +1355,43 @@ func TestFinishRecordCrossTableLinkage(t *testing.T) {
 	}
 }
 
-// TestFinishRecordPercentageReward 端点 51 的**按比例提成**(D10:端口扩 `Income`/`TeacherExp` 后必须能算)。
+// TestFinishRecordRewardFormula 端点 51 的课酬公式 —— **逐条对齐 PHP 原文**
+// (`CoursesRecordHelper.php:781-821`,权威在应用层而非 dump)。
 //
-// 三组夹具(各自独立库):
+// 六个子例,每个对应一条"写错就错钱"的顺序/口径:
 //
-//	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 被压到上限)
+//	① 纯比例:pct=85.0 → 100×85% = **85.00**
+//	② 下限:85% 算出 85 < min 90 → **90.00**
+//	③ 上限:pct=200 → 200 > max 150 → **150.00**
+//	④ **顺序(核心)**:pct=100 + reward 50 = 150,先加再加夹 max 120 → **120.00**
+//	   (错误顺序"先夹 min/max 再加 reward"会得 100+50 = 150 ⇒ 多发 30)
+//	⑤ **顺序(下限侧)**:pct=0 + reward 50 = 50,夹到 min 80 → **80.00**
+//	   (错误顺序会得 clamp(0)=80 + 50 = 130 ⇒ 多发 50)
+//	⑥ **固定课酬 `teacher_reward_type=2`**:忽略 pct/reward,1 人 → makeupCost[1] = **70.00**
 //
-// 变红条件:
-//   - 适配器只返回 `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) {
+// 变红条件:把顺序写反(④⑤)、漏 elseif 上限优先(③)、漏 type=2 分支(⑥)、
+// 把 percentage 当 0~1 比率(①)—— 任一条都会红。
+func TestFinishRecordRewardFormula(t *testing.T) {
 	cases := []struct {
 		name       string
 		percentage string
 		minPayment string
 		maxPayment string
 		reward     string
+		rewardType int8
 		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"},
+		{"纯比例 85%", "85.0", "0.00", "0.00", "0.00", 1, "85.00"},
+		{"下限抬到 90", "85.0", "90.00", "0.00", "0.00", 1, "90.00"},
+		{"上限压到 150", "200.0", "0.00", "150.00", "0.00", 1, "150.00"},
+		{"顺序:先加 reward 再夹上限(reward 被吃掉)", "100.0", "0.00", "120.00", "50.00", 1, "120.00"},
+		{"顺序:先加 reward 再夹下限", "0.0", "80.00", "0.00", "50.00", 1, "80.00"},
+		{"固定课酬 type=2:1 人 → 70", "85.0", "0.00", "0.00", "999.00", 2, "70.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,
@@ -1396,14 +1403,15 @@ func TestFinishRecordPercentageReward(t *testing.T) {
 			}
 			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")
+				"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
+				map[string]any{"rewardType": tc.rewardType}, "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)
+				t.Fatalf("payroll.reward = %v,期望 %s(percentage=%s min=%s max=%s reward=%s type=%d,课收入 100.00/1 人)",
+					payroll["reward"], tc.want, tc.percentage, tc.minPayment, tc.maxPayment, tc.reward, tc.rewardType)
 			}
 			// 落库一致性:record.teacher_reward 与打卡行 teacher_reward 都必须等于该值
 			var rec struct {
@@ -1417,8 +1425,8 @@ func TestFinishRecordPercentageReward(t *testing.T) {
 			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 {
+			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)

+ 93 - 24
internal/academic/service/course_query_service.go

@@ -1028,19 +1028,41 @@ func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *mode
 	return quote.DetailID
 }
 
-// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)——
-// **D10 裁决后已补齐"按比例提成"**。
+// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)。
 //
-// 单节课课酬的完整算法(dump `sp_course_payment_detail_get_teacher_detail` 口径):
-//
-//	① base  = round(Income × percentage / 100)     ← 比例部分(percentage 是**百分数**:"85.0" = 85%)
-//	② base  = clamp(base, Min, Max)                ← Min/Max 为 0 表示"不限制"
-//	③ total = base + Reward                        ← Reward 是"额外课酬"(在比例基础上追加)
+// ---------------------------------------------------------------------------
+// 权威口径:**PHP 应用层**,不是 dump(`min_payment` 在存储过程里零命中)
+// ---------------------------------------------------------------------------
 //
-// 三条实现红线:
-//   - **全程整数分运算**(`money.Amount` 是分),不在任何一步引入 float;
-//   - 比例用"千分之一"整数承载(`"85.0"` → `850`),`round` 用 `+500 后整除 1000`(四舍五入到分);
-//   - `percentage` 列写坏(非数字、负数)→ **ErrDBError 整体回滚**,不静默按 0 算
+// 逐行照抄 `E://study//php//ysw-finance//yuansiwei-finance//modules//courses//helpers//CoursesRecordHelper.php:781-821`
+// (方法 `settlementExpenses`,正常结课与补签共用):
+//
+//	$percentage = $coursePaymentDetail->percentage/100;      // 百分数 → 比率("85.0" → 0.85)
+//	$addedCost  = $coursePaymentDetail->reward;              // 额外课酬
+//	$income     = round(sum(cost), 3);                       // 本课全部学生 cost 合计
+//	$teacherReward = 0;
+//	if ($income>0 || $attendClassStudentNumber) {            // 守卫:有学生即使收入 0 也算
+//	    $teacherReward = round($income*$percentage,3) + $addedCost;   // ① 先算提成、② 再加 reward
+//	    if ($maxPayment>0 && $teacherReward>$maxPayment)      $teacherReward = $maxPayment;   // ③ 先夹上限
+//	    elseif ($minPayment>0 && $teacherReward<$minPayment)  $teacherReward = $minPayment;   // ④ 再夹下限
+//	}
+//	if ($teacherRewardType==2) {                             // 固定课酬:**不看比例**
+//	    $teacherReward = self::$makeupCost[$stuCount] ?? self::$makeupCost['max'];
+//	}
+//	if ($attendClassStudentNumber<=0) $teacherReward = 0;    // 无人上课 → 课酬 0
+//
+// ⚠ **顺序是「加 reward → 再夹 [min,max]」,不是「夹完再加 reward」**:
+// 当"提成 + reward"超过 `max_payment` 时,结果是 **max(reward 被吃掉)**;
+// 反过来写会得到 `max + reward` = **多发钱**。这条顺序差有真实金额影响,改动前先回 PHP 原文。
+//
+// 另外三条照抄语义:
+//   - `max`/`min` 为 **0 = 不设限**(不是"上限为 0");
+//   - **`elseif` = 上限优先**:两者同时触发时取 `max`;
+//   - `percentage` 是**百分数**:`"85.0"` 表示 85%,不是 0.85,也不是 8500。
+//
+// 实现红线:
+//   - **全程整数分运算**,比例用"千分之一"整数承载(`"85.0"` → `850`),`+500 后整除 1000` = 四舍五入到分;
+//   - `percentage` 列写坏(非数字/负数/小数位过多)→ **ErrDBError 整体回滚**,不静默按 0 算
 //     ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
 //
 // 已知口径缺口(**不是静默正确**):`TeacherExp` 目前恒 0(AC-11 未接教师档案的年数换算),
@@ -1072,14 +1094,19 @@ func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuot
 	if quote == nil {
 		return 0, nil
 	}
-	return computeRecordReward(quote, in.Income)
+	return computeRecordReward(quote, in.Income, in.StudentCount, in.RewardType)
 }
 
-// computeRecordReward 由"命中的课酬规则 + 本次课收入"算出单节课课酬(`recordRewardQuoter` 的算法体)。
+// computeRecordReward 由"命中的课酬规则 + 本次课收入/人数/结算方式"算出单节课课酬。
 //
-// 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入。
+// 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入;
+// studentCount 上课人数;rewardType 结算方式(1 正常 / 2 固定课酬)。
 // 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。
-func computeRecordReward(quote *PayrollQuote, income money.Amount) (money.Amount, error) {
+//
+// 顺序**逐行对齐 PHP**(`CoursesRecordHelper.php:794-821`),见 `recordRewardQuoter` 注释:
+//
+//	守卫 → income×pct + reward → 夹上限 → 否则夹下限 → (type=2 覆盖为固定课酬)→ 无人则 0
+func computeRecordReward(quote *PayrollQuote, income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
 	if quote == nil {
 		return 0, nil
 	}
@@ -1087,18 +1114,30 @@ func computeRecordReward(quote *PayrollQuote, income money.Amount) (money.Amount
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
 			errString("service: 课酬试算的收入不能为负"))
 	}
-	base, perr := applyPercentage(quote.Percentage, income)
-	if perr != nil {
-		return 0, perr
+	total := money.Amount(0)
+	// 守卫:`if ($income>0 || $attendClassStudentNumber)` —— 收入 0 但有学生仍要算(0 + reward 再夹)
+	if income.GreaterThan(0) || studentCount > 0 {
+		base, perr := applyPercentage(quote.Percentage, income)
+		if perr != nil {
+			return 0, perr
+		}
+		// ① 先加 reward,② 再夹 —— 顺序不可交换(见类型注释)
+		total = base.Add(quote.Reward)
+		// ③ 上限优先(PHP 是 elseif:上限命中就不看下限,两者同时触发取 max)
+		if quote.Max.GreaterThan(0) && total.GreaterThan(quote.Max) {
+			total = quote.Max
+		} else if quote.Min.GreaterThan(0) && total.LessThan(quote.Min) {
+			total = quote.Min
+		}
 	}
-	// Min/Max 为 0 表示"不限制"(dump 语义),**不能**当成"下限 0/上限 0"去夹
-	if quote.Min.GreaterThan(0) && base.LessThan(quote.Min) {
-		base = quote.Min
+	// 固定课酬(`teacher_reward_type == 2`):**不看比例**,按人数查 PHP 硬编码表
+	if rewardType == model.CoursesRecordRewardTypeNoMinimum && studentCount > 0 {
+		total = makeupCostFor(studentCount)
 	}
-	if quote.Max.GreaterThan(0) && base.GreaterThan(quote.Max) {
-		base = quote.Max
+	// 无人上课 ⇒ 不产生课酬(PHP `:821`;即使上面算出 reward 也要归零)
+	if studentCount <= 0 {
+		total = 0
 	}
-	total := base.Add(quote.Reward)
 	if total.IsNegative() {
 		return 0, infraerrors.WithCause(infraerrors.ErrDBError,
 			errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)"))
@@ -1106,6 +1145,36 @@ func computeRecordReward(quote *PayrollQuote, income money.Amount) (money.Amount
 	return total, nil
 }
 
+// makeupCostTable 固定课酬表(`teacher_reward_type == 2`):人数 → 单节课课酬(元)。
+//
+// **逐字复刻 PHP 魔法常量**(`CoursesRecordHelper.php:33`,注释原文"单独给学生补课上课的课酬,人数=>课酬"):
+//
+//	static public $makeupCost = [1=>70,2=>100,3=>120,4=>150,5=>170,6=>200,'max'=>200];
+//
+// ⚠ **这张表在数据库里没有**(没有字典表、没有配置项)⇒ 只能按常量复刻;
+// 改表必须同时改 PHP 与 Go 两处,否则结课与双跑会分叉。D7 双跑也只能从 PHP 行为反推。
+var makeupCostTable = map[int32]money.Amount{
+	1: money.FromCents(7000),
+	2: money.FromCents(10000),
+	3: money.FromCents(12000),
+	4: money.FromCents(15000),
+	5: money.FromCents(17000),
+	6: money.FromCents(20000),
+}
+
+// makeupCostMax 超出表区间时的兜底值(PHP `'max' => 200`)。
+const makeupCostMaxCents = int64(20000)
+
+// makeupCostFor 按人数取固定课酬(人数 > 6 → 200.00,与 PHP `?? $makeupCost['max']` 一致)。
+//
+// 入参:studentCount(>0,由调用方保证)。出参:单节课课酬。
+func makeupCostFor(studentCount int32) money.Amount {
+	if v, ok := makeupCostTable[studentCount]; ok {
+		return v
+	}
+	return money.FromCents(makeupCostMaxCents)
+}
+
 // percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100;
 // 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。
 const percentageDenom = 1000

+ 17 - 5
internal/academic/service/record_service.go

@@ -82,8 +82,16 @@ type RewardQuoteInput struct {
 	RecordDate    time.Time
 	// Income 本次课收入(元;Σ 打卡明细 cost)。按比例提成的基数;0 表示无收入(比例部分算 0)。
 	Income money.Amount
-	// TeacherExp 教龄(年;**0 = 未取到**,见 RewardsQuoteExpKnown 注释里的口径缺口)。
+	// TeacherExp 教龄(年;**0 = 未取到**,见 `recordRewardQuoter` 注释里的口径缺口)。
 	TeacherExp float64
+	// StudentCount 本次上课人数(= 打卡行数)。
+	//
+	// 两个用途,都来自 PHP 原文(`CoursesRecordHelper.php:794` / `:806-814`):
+	//   - 计算守卫:`if ($income>0 || $attendClassStudentNumber)` —— 收入 0 但有学生时仍要算(0+reward 再夹);
+	//   - 固定课酬:`teacher_reward_type==2` 时按人数查 `makeupCost` 表(不看比例)。
+	StudentCount int32
+	// RewardType 课酬结算方式(`t_courses_record.teacher_reward_type`:1 正常 / 2 固定课酬)。
+	RewardType int8
 }
 
 // TeacherRewardQuoter 课酬试算端口(AC-10 PayrollService 实现;本服务只声明契约)。
@@ -454,7 +462,7 @@ func (s *RecordService) FinishRecord(ctx context.Context, in FinishRecordInput)
 		}
 
 		// ⑦ 课酬试算(一次:一节课一份课酬;nil quoter → 0.00)
-		totalReward, qerr := s.quoteReward(txCtx, rec, endTime, income)
+		totalReward, qerr := s.quoteReward(txCtx, rec, endTime, income, int32(len(resolved)), in.RewardType)
 		if qerr != nil {
 			return qerr
 		}
@@ -741,9 +749,11 @@ func (s *RecordService) upsertRecordStudent(txCtx context.Context, tx *gorm.DB,
 
 // quoteReward 课酬试算(未注入 quoter → 0.00;未配置方案 3112 → 0.00 且不阻断结课)。
 //
-// 入参:txCtx;rec 实例;at 上课时间(用于方案 effect_date 判定);income 本次课收入(比例提成基数)。
+// 入参:txCtx;rec 实例;at 上课时间(方案 effect_date 判定);income 本次课收入(比例提成基数);
+// studentCount 上课人数(计算守卫 + 固定课酬查表);rewardType 结算方式(1 正常 / 2 固定课酬)。
 // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。
-func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time, income money.Amount) (money.Amount, error) {
+func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time,
+	income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
 	if s.quoter == nil {
 		return 0, nil
 	}
@@ -771,7 +781,9 @@ func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRec
 		// TeacherExp:AC-11 未接教师档案(`t_teacher_information.teacher_exp` 是"起算日",
 		// 年数要按试算日期算差值)⇒ 本批固定 0。⚠ **0 会漏掉 `teacher_exp_min > 0` 的档位**,
 		// 属已知口径缺口(已在交付报告登记),不是"静默正确"。
-		TeacherExp: 0,
+		TeacherExp:   0,
+		StudentCount: studentCount,
+		RewardType:   rewardType,
 	}
 	reward, qerr := s.quoter.QuoteRecordReward(txCtx, in)
 	if qerr != nil {