소스 검색

fix(academic): D16 课酬只分给 type∈{1,2}(除数+写入范围按 PHP),恒等式改条件式

Q 10 시간 전
부모
커밋
32dcb50419
3개의 변경된 파일과 171개의 추가작업 그리고 12개의 파일을 삭제
  1. 93 0
      internal/academic/handler/course_handler_test.go
  2. 2 2
      internal/academic/model/models_course.go
  3. 76 10
      internal/academic/service/record_service.go

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

@@ -1073,6 +1073,99 @@ func TestStudentScheduleIsPerStudent(t *testing.T) {
 	}
 }
 
+// ---------------------------------------------------------------- 端点 51:课酬分摊口径(D16)
+
+// TestFinishRecordRewardDistributionSkipsGiveRows **D16:课酬只分给 `type∈{1,2}`,赠送行恒 0**。
+//
+// PHP 原文(`CoursesRecordHelper.php:806-818`):`$stuCount = count(type IN (1,2))`,
+// 平摊与写回**都只针对 `type IN (1,2)`**,赠送行**永远保持 0**。
+//
+// 夹具:1 条 type=1(正常)+ 1 条 type=3(赠送),单节课课酬合计 = 100.00。
+//
+//	正确(PHP):正常行 **100.00**、赠送行 **0.00**、Σ(type∈{1,2}) == 100.00
+//	错误(除数用全部行数):正常行 50.00、赠送行 50.00 ⇒ **多条断言同时红**
+func TestFinishRecordRewardDistributionSkipsGiveRows(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	const giveStudent = "01HSTUDENT0000000000000G"
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
+		map[string]any{"items": []map[string]any{
+			{"userId": courseTestStudent, "type": 1, "cost": "100.00"},
+			{"userId": giveStudent, "type": 3, "cost": "0.00"},
+		}}, "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 != "100.00" {
+		t.Fatalf("payroll.reward = %v,期望 100.00(单节课课酬合计,与分摊无关)", payroll["reward"])
+	}
+	requireMoney(t, "正常行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, courseTestStudent), "100.00")
+	requireMoney(t, "赠送行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, giveStudent), "0.00")
+	requireMoney(t, "Σ(type∈{1,2})", readTeacherRewardSumExcludingGive(t, db, fx.Record.ID), "100.00")
+	// student_number 是**全部行数**(含赠送),与平摊人数是两个数
+	var studentNumber int32
+	db.Raw("SELECT student_number FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&studentNumber)
+	if studentNumber != 2 {
+		t.Fatalf("student_number = %d,期望 2(全部打卡行数,含赠送生)", studentNumber)
+	}
+}
+
+// TestFinishRecordAllGiveClassKeepsRecordReward **D16 边界:全班赠送(stuCount=0)**。
+//
+// PHP 的 `if($stuCount)` 不进入 ⇒ **一行都不写**(学生行全 0),但 record 上**仍写课酬**。
+// ⇒ 恒等式在这一情形**不成立**;断言只能是"所有行 0",**不得**断言 Σ == record
+// (无条件断言会让"全赠送班"恒红,最后被人改成跳过 = 把防线删掉)。
+func TestFinishRecordAllGiveClassKeepsRecordReward(t *testing.T) {
+	db := newCourseTestDB(t)
+	fx := seedFinishFixture(t, db)
+	app := newCourseTestApp(t, db)
+	code, msg, data := doJSON(t, app, fiber.MethodPost,
+		"/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
+		map[string]any{"items": []map[string]any{
+			{"userId": courseTestStudent, "type": 3, "cost": "0.00"},
+		}}, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("结束上课(全班赠送)= %d(%s),期望 200", code, msg)
+	}
+	requireMoney(t, "赠送行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, courseTestStudent), "0.00")
+	payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
+	if got, _ := payroll["reward"].(string); got != "100.00" {
+		t.Fatalf("payroll.reward = %v,期望 100.00(全班赠送照常算课酬,只是不分摊)", payroll["reward"])
+	}
+	var recReward string
+	db.Raw("SELECT teacher_reward FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&recReward)
+	requireMoney(t, "record.teacher_reward", recReward, "100.00")
+}
+
+// readTeacherReward 读某学生在该课的 `teacher_reward`(无行 → 空串,断言会红)。
+func readTeacherReward(t *testing.T, db *gorm.DB, recordID int64, studentID string) string {
+	t.Helper()
+	var out struct {
+		Reward string `gorm:"column:teacher_reward"`
+	}
+	if err := db.Raw("SELECT teacher_reward FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
+		recordID, studentID).Scan(&out).Error; err != nil {
+		t.Fatalf("读 teacher_reward 失败: %v", err)
+	}
+	return out.Reward
+}
+
+// readTeacherRewardSumExcludingGive 求和非赠送行的 `teacher_reward`(D16 条件式恒等式)。
+func readTeacherRewardSumExcludingGive(t *testing.T, db *gorm.DB, recordID int64) string {
+	t.Helper()
+	var out struct {
+		Sum string `gorm:"column:sum_reward"`
+	}
+	if err := db.Raw("SELECT COALESCE(SUM(teacher_reward),0) AS sum_reward FROM t_courses_record_student "+
+		"WHERE record_id = ? AND type IN (1,2)", recordID).Scan(&out).Error; err != nil {
+		t.Fatalf("求和非赠送行 teacher_reward 失败: %v", err)
+	}
+	return out.Sum
+}
+
 // ---------------------------------------------------------------- 端点 51:结束上课(跨 5 表)
 
 // courseFinishFixture 端点 51 的完整夹具(结课链路的五张表全部就位)。

+ 2 - 2
internal/academic/model/models_course.go

@@ -227,7 +227,7 @@ type CoursesRecord struct {
 	ActualEndTime         *time.Time `gorm:"column:actual_end_time" json:"actualEndTime,omitempty"`                                                              // 实际下课时间(datetime(3))|枚举:无|写入:结束上课|FK:无|红线:结束上课的幂等判据(非空即已结束)
 	StudentNumber         int32      `gorm:"column:student_number;not null;default:0" json:"studentNumber"`                                                      // 上课人数|枚举:无|写入:结束上课汇总|FK:无|红线:与 record_student 行数一致
 	Income                string     `gorm:"column:income;type:decimal(12,2);not null;default:0.00" json:"income"`                                               // 课程收入(元)|枚举:无|写入:结束上课|FK:无|红线:= Σ record_student.cost(对账)
-	TeacherReward         string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`                                // 老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:= Σ record_student.teacher_reward(对账)
+	TeacherReward         string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`                                // 老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:**条件式**恒等 —— 有非赠送生时 = Σ(record_student.teacher_reward WHERE type∈{1,2});全班赠送(type 全为 3)时学生行全 0、本列仍写课酬(PHP `if($stuCount)` 语义,见 `applyTeacherRewards`)
 	TeacherRewardType     int8       `gorm:"column:teacher_reward_type;not null;default:1" json:"teacherRewardType"`                                             // 课酬结算方式(1 正常/2 按原课程老师课酬无最低课酬)|枚举:CoursesRecordRewardType*|写入:结束上课|FK:无|红线:影响试算分支
 	ExpectStudentIDs      *string    `gorm:"column:expect_student_ids;size:500" json:"expectStudentIds,omitempty"`                                               // 要求上课的学生 id 列表(逗号分隔;原 expect_studentids)|枚举:ULID 列表串|写入:排班生成|FK:弱关联 t_user|红线:字符串保留,勿当数组入 SQL
 	Remark                *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                // 备注|枚举:无|写入:编辑|FK:无|红线:脱敏
@@ -255,7 +255,7 @@ type CoursesRecordStudent struct {
 	EndTime       *time.Time `gorm:"column:end_time" json:"endTime,omitempty"`                                                       // 下课时间(datetime(3))|枚举:无|写入:结束上课|FK:无|红线:非空 = 已完成(分成归属判据)
 	Cost          string     `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                               // 本次课程费用(元;扣课时金额)|枚举:无|写入:结束上课|FK:无|红线:Σ cost = record.income(对账)
 	GiveCost      string     `gorm:"column:give_cost;type:decimal(12,2);not null;default:0.00" json:"giveCost"`                      // 本次使用的赠送金额(元)|枚举:无|写入:结束上课|FK:无|红线:与 discount_log 累计一致
-	TeacherReward string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`            // 本次课老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:Σ = record.teacher_reward(对账)
+	TeacherReward string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`            // 本次课老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:**`type=3`(赠送)行恒为 0**(PHP 只写 `type IN (1,2)`);非赠送行之和 = record.teacher_reward
 	Pay           int8       `gorm:"column:pay;not null;default:0" json:"pay"`                                                       // 是否已支付(0 未/1 已支付)|枚举:CoursesRecordStudentPay*|写入:扣费成功/欠费核销|FK:无|红线:未支付即欠费候选
 	Remark        *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                            // 备注|枚举:无|写入:打卡/结束|FK:无|红线:脱敏
 }

+ 76 - 10
internal/academic/service/record_service.go

@@ -18,7 +18,7 @@
 //	④ spend 累加(t_user_expense(.school).spend += cost,统计口径列,**不动 balance**)
 //	⑤ record 汇总落库(status=2 + actual_end_time + student_number + income + teacher_reward)
 //	⑥ 分账(DivideService.AddLog:从推荐人的上级起逐级入账,两级幂等)
-//	⑦ 课酬(TeacherRewardQuoter 试算,按学生行均分以满足 Σ record_student.teacher_reward = record.teacher_reward)
+//	⑦ 课酬(TeacherRewardQuoter 试算;**按 PHP 口径只分给 type∈{1,2} 的行**,见 `applyTeacherRewards`)
 //
 // **锁序(§8 AC-07 基线)**:record 行锁 → 账户锁(全局 → 校区,由 ExpenseRepo 固定顺序实现)
 // → 报课行 → 分账表。活动/优惠池行锁本流程不涉及;若将来在结束时核销活动赠送,
@@ -466,10 +466,13 @@ func (s *RecordService) FinishRecord(ctx context.Context, in FinishRecordInput)
 		if qerr != nil {
 			return qerr
 		}
-		rewards := distributeAmount(totalReward, len(resolved))
-		for i := range resolved {
-			resolved[i].TeacherReward = rewards[i]
-		}
+		// 课酬分发**按 PHP 口径**(`CoursesRecordHelper.php:806-818`):
+		//   除数 = `count(type IN (1,2))`(**不含 type=3 赠送生**)
+		//   写入范围 = **只有 type∈{1,2} 的行**;type=3 行 `teacher_reward` **恒为 0**
+		// ⇒ 恒等式是**条件式**:`stuCount>0` 时 `Σ(type∈{1,2}) == record.teacher_reward`;
+		//   `stuCount==0`(全班赠送)时**一行都不写**(PHP 的 `if($stuCount)` 不进入),
+		//   此时 record 上仍可能有课酬、学生行全 0 —— **恒等式不成立,不得按无条件断言校验**。
+		applyTeacherRewards(resolved, totalReward)
 
 		// ③ 扣课时 + ④ spend 累加 + ② 打卡明细落库(逐学生,顺序固定 → 锁序可复现)
 		for i := range resolved {
@@ -867,13 +870,76 @@ func sumRecordStudentCost(rows []model.CoursesRecordStudent) (money.Amount, erro
 	return total, nil
 }
 
-// distributeAmount 把总额按行数均分(整数分运算,余数全部给第一行)。
+// applyTeacherRewards 把单节课课酬分发到各打卡行(**按 PHP 口径**,`CoursesRecordHelper.php:806-818`)。
+//
+// 入参:rows 结课名单(会被就地改写 `TeacherReward`);total 单节课课酬合计。
+// 出参:无(就地写)。
+//
+// PHP 原文(逐行照抄):
+//
+//	$stuCount = CoursesRecordStudent::find()->where(['recordid'=>$recordid,'type'=>[1,2]])->count();
+//	if($stuCount){ $reward = round(($teacherReward/$stuCount),3);
+//	              CoursesRecordStudent::updateAll(['teacher_reward'=>$reward],
+//	                  ['recordid'=>$recordid,'type'=>[1,2]]); }
+//
+// 三条照抄要点(**任一写错都会让账与旧系统分叉**):
+//  1. **除数是 `count(type∈{1,2})`**,不是"全部打卡行数"——赠送生(type=3)不参与分摊;
+//  2. **只写 `type∈{1,2}` 的行**,赠送行的 `teacher_reward` **保持 0**;
+//  3. `stuCount == 0`(全班赠送)时 PHP 的 `if($stuCount)` **不进入** ⇒ 一行都不写:
+//     此时 record 上仍写课酬(考勤事实),学生行全 0 —— **这不是 bug,是旧系统语义**。
+//
+// ⚠ 恒等式因此是**条件式**(AC-17 一致性检查必须照此写,勿写成无条件):
+//
+//	stuCount > 0  ⇒  Σ(teacher_reward WHERE type∈{1,2}) == record.teacher_reward
+//	stuCount == 0 ⇒  所有行 teacher_reward == 0(**不要求** Σ == record)
+//
+// 为什么不能无条件:无条件断言在"全赠送班"上恒红,维护者最后一定会把它改成"跳过",
+// 那等于把这条防线删掉(与 AC-14 断言②"不得加过滤"同一条纪律)。
+func applyTeacherRewards(rows []resolvedAttendance, total money.Amount) {
+	eligible := 0
+	for i := range rows {
+		if isRewardEligibleType(rows[i].Type) {
+			eligible++
+		}
+	}
+	if eligible == 0 {
+		// 全班赠送:PHP 不进入 `if($stuCount)` ⇒ 所有行课酬保持 0
+		for i := range rows {
+			rows[i].TeacherReward = 0
+		}
+		return
+	}
+	shares := distributeAmount(total, eligible)
+	next := 0
+	for i := range rows {
+		if !isRewardEligibleType(rows[i].Type) {
+			rows[i].TeacherReward = 0 // 赠送行恒 0(即便入参给了 reward 也不写)
+			continue
+		}
+		rows[i].TeacherReward = shares[next]
+		next++
+	}
+}
+
+// isRewardEligibleType 判断该打卡类型是否参与课酬分摊(PHP `type IN (1,2)`:1 正常 / 2 插班)。
+//
+// 入参:typ 打卡类型。出参:true = 参与分摊。
+func isRewardEligibleType(typ int32) bool {
+	switch typ {
+	case model.CoursesRecordStudentTypeNormal, model.CoursesRecordStudentTypeTransfer:
+		return true
+	default:
+		return false // type=3 赠送(以及任何未知类型):不分摊课酬
+	}
+}
+
+// distributeAmount 把总额按"参与分摊的行数"均分(整数分运算,余数全部给第一行)。
 //
-// 入参:total 总额(分);n 行数(<=0 → 返回空切片)。
-// 出参:[]money.Amount(长度 n;Σ == total,保证 Σ record_student.teacher_reward = record.teacher_reward)。
+// 入参:total 总额(分);n 参与分摊的行数(<=0 → 返回空切片)。
+// 出参:[]money.Amount(长度 n;Σ == total)。
 //
-// 为什么均分:dump 无 finish SP(课酬分摊无权威口径)。选择"均分"的唯一硬理由是对账恒等式
-// (模型注释红线 Σ = record.teacher_reward);余数入首行避免金额丢失(分位不可整除时)。
+// 调用约定:`n` **必须**是 `count(type∈{1,2})`(见 `applyTeacherRewards`),**不是**全部行数。
+// 余数入首行避免金额丢失(分位不可整除时)。
 func distributeAmount(total money.Amount, n int) []money.Amount {
 	out := make([]money.Amount, 0, n)
 	if n <= 0 {