// 上课记录(排班实例)状态机 + 打卡/扣课时服务——AC-11。 // // 依据:unified/03-migration/academic-domain-design.md §4.4(端点 50~52)/ §7.3 流程外延 // / §1.4(事务模板 + 锁序)/ §10.1(3121 状态非法)/ §10.3(事务内调用下游必须透传 tx) // / §11-3(最小闭环范围)。 // // 三个动作: // // StartRecord 开始上课:status 0 → 1 + actual_start_time(CAS,重复开始 → 3121) // MarkAttendance 打卡/考勤:逐学生 upsert 打卡明细(cost/give_cost/pay/type),刷新汇总列 // FinishRecord 结束上课:**单事务联动**(本文件的核心,AC-11 ②) // // FinishRecord 的联动链(全部同一事务,任一步失败整体回滚): // // ① record 行锁 + 状态机判定(已结束 → 幂等成功;非法迁移 → 3121) // ② 逐学生打卡明细落库(t_courses_record_student:cost / give_cost / teacher_reward / end_time) // ③ 扣课时(t_courses_student:finished_number+1 / remainder_number-1 / remainder_cost-cost) // ④ 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) // // **锁序(§8 AC-07 基线)**:record 行锁 → 账户锁(全局 → 校区,由 ExpenseRepo 固定顺序实现) // → 报课行 → 分账表。活动/优惠池行锁本流程不涉及;若将来在结束时核销活动赠送, // 必须排在账户锁**之后**(禁止"先锁活动再锁账户",与充值并发即成环)。 // // **tx 透传(§10.3 红线)**:事务内调 DivideService 一律传 `repository.WithTxContext(ctx, tx)`, // 否则下游 WithinTx 会新开独立事务 —— 出现"课时已扣、分账未给"的静默不一致。 package service import ( "context" "errors" "strconv" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // divideGrantRemark 结束上课触发分账时写入明细的备注(与 divide_into_log_delete 改绑文案一致)。 const divideGrantRemark = "推广分成" // DivideLogGranter 分账上行入账入口(AC-09 DivideService 结构性满足本接口)。 // // 为什么用接口而不是直接依赖 *DivideService: // - 单测可注入 fake(断言"事务内只调用一次""入参 orderID 正确"); // - 避免 service 内部同类耦合(后续若要换实现/加装饰器不必改 RecordService)。 type DivideLogGranter interface { // AddLog 从 findUserID 的上级起逐级入账;实现须自行保证幂等与深度上限。 AddLog(ctx context.Context, findUserID, stuID string, recordID int64, remark string) error } // RewardQuoteInput 课酬试算入参(对齐 dump `sp_course_payment_detail_get_teacher_detail` 的 6 参语义)。 // // 字段: // - TeacherID string:老师 ULID(排班规则上的 teacher_id); // - InstitutionID/CampusID int64:方案两级回退的校区维度(校区 → 机构级 campus_id=0); // - ClassifyID int64:课程分类(决定 grade/subject/class_type 维度); // - ClassType int32:班级类型(1000 集体班…); // - 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 CampusID int64 ClassifyID int64 ClassType int32 RecordDate time.Time // Income 本次课收入(元;Σ 打卡明细 cost)。按比例提成的基数;0 表示无收入(比例部分算 0)。 Income money.Amount // TeacherExp 教龄(年;**0 = 未取到**,见 RewardsQuoteExpKnown 注释里的口径缺口)。 TeacherExp float64 } // TeacherRewardQuoter 课酬试算端口(AC-10 PayrollService 实现;本服务只声明契约)。 // // 契约: // - 返回 (reward, nil):单节课课酬(元,money.Amount); // - 返回 apperr.ErrPayrollPlanMissing(3112):**未配置方案** —— 本服务按"课酬 0"继续结课 // (结束上课是考勤事实的落库,不能因为课酬没配就整单回滚;3112 留给端点 59 的试算入口); // - 其他 error:视为基础设施/数据异常,**整体回滚**(不允许"扣了课时没记课酬")。 // // 未注入(nil)时:课酬按 0.00 落库(装配期由 AC-10 注入;已登记交付说明)。 type TeacherRewardQuoter interface { QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) } // RecordServiceOptions RecordService 的可选协作者(全部可 nil,nil 时降级见各字段注释)。 // // 字段: // - Divide DivideLogGranter:分账入口(nil → 不产生分成,仅记录日志标记); // - Quoter TeacherRewardQuoter:课酬试算(nil → 课酬 0.00)。 type RecordServiceOptions struct { Divide DivideLogGranter Quoter TeacherRewardQuoter } // RecordService 上课记录服务。 type RecordService struct { // records 排班实例 + 打卡明细仓储。 records *repository.RecordRepo // courses 课程/报课账户仓储(扣课时、推荐人来源)。 courses *repository.CourseRepo // expenses 资金账户仓储(spend 统计列累加)。 expenses *repository.ExpenseRepo // runner 事务运行器。 runner *repository.TxRunner // divide 分账入口(可 nil)。 divide DivideLogGranter // quoter 课酬试算(可 nil)。 quoter TeacherRewardQuoter // now 时钟(单测注入固定时刻)。 now func() time.Time } // NewRecordService 构造上课记录服务。 // // 入参: // - db *gorm.DB:数据库句柄(必填); // - sc *appscope.Scope:请求作用域; // - opts 可选协作者(分账 / 课酬)。 // // 出参:*RecordService。 func NewRecordService(db *gorm.DB, sc *appscope.Scope, opts RecordServiceOptions) *RecordService { return &RecordService{ records: repository.NewRecordRepo(db, sc), courses: repository.NewCourseRepo(db, sc), expenses: repository.NewExpenseRepo(db, sc), runner: repository.NewTxRunner(db), divide: opts.Divide, quoter: opts.Quoter, now: time.Now, } } // List 分页查询课表(端点 48 / 57 的读侧)。 // // 入参:ctx;f 过滤;pg 分页。 // 出参:([]model.CoursesRecord, total int64, error)。 func (s *RecordService) List(ctx context.Context, f repository.RecordFilter, pg repository.PageQuery) ([]model.CoursesRecord, int64, error) { return s.records.ListRecords(ctx, f, pg) } // Get 取单条排班实例(含打卡明细)。 // // 入参:ctx;recordID(<=0 → 2001)。 // 出参:(*model.CoursesRecord, []model.CoursesRecordStudent);3001 / 5002。 func (s *RecordService) Get(ctx context.Context, recordID int64) (*model.CoursesRecord, []model.CoursesRecordStudent, error) { if recordID <= 0 { return nil, nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0")) } rec, err := s.records.GetRecordByID(ctx, recordID) if err != nil { return nil, nil, err } items, err := s.records.ListRecordStudents(ctx, recordID) if err != nil { return nil, nil, err } return rec, items, nil } // StartRecordResult 开始上课结果。 // // 字段:RecordID 实例 ID;Status 新状态(=1);ActualStartTime 实际上课时间;Repeated 幂等命中。 type StartRecordResult struct { RecordID int64 Status int32 ActualStartTime time.Time Repeated bool } // StartRecord 开始上课(端点 50):`status 0 → 1` + `actual_start_time`。 // // 入参:ctx;recordID 上课记录 ID。 // 出参:*StartRecordResult;3001 记录不存在;3121 状态非法(重复开始/已结束)。 // // 状态机(模型红线 actual_start_time 只写一次): // // status=0 → 1(成功,写 actual_start_time) // status=1 → 3121(重复开始) // status=2 → 3121(已结束不可回到上课中) // // CAS 兜底:并发双击时只有一个 RowsAffected=1,另一个 3121(不会写出两个不同的开课时间)。 func (s *RecordService) StartRecord(ctx context.Context, recordID int64) (*StartRecordResult, error) { if recordID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0")) } out := &StartRecordResult{RecordID: recordID} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) rec, err := s.records.GetRecordForUpdate(txCtx, tx, recordID) if err != nil { return err // 3001 } switch rec.Status { case model.CoursesRecordStatusOngoing: return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 该课已在上课中(重复开始上课)")) case model.CoursesRecordStatusFinished: return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 该课已结束,不能重新开始")) case model.CoursesRecordStatusNotStarted: // 预期分支:继续 default: return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 未知上课状态 "+strconv.FormatInt(int64(rec.Status), 10))) } at := s.now() affected, err := s.records.MarkRecordStarted(txCtx, tx, recordID, at) if err != nil { return err } if affected == 0 { return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 上课状态已被并发修改(CAS 未命中)")) } out.Status = model.CoursesRecordStatusOngoing out.ActualStartTime = at return nil }) if err != nil { return nil, err } return out, nil } // AttendanceItem 打卡明细入参(端点 52 与 51 共用)。 // // 字段(指针用于区分"未传"与"传 0"): // - StudentID string:学生 ULID(必填); // - Type int32:上课类型 1 正常 / 2 插班 / 3 赠送(0 → 1); // - Pay *int8:是否已支付(nil → 打卡时不改、结课时按 1 处理); // - Cost *money.Amount:本次课费(nil → 结课时取报课记录的 lesson_cost 快照); // - GiveCost *money.Amount:本次使用赠送额(nil → 0;赠送行不动余额); // - Remark *string:备注。 type AttendanceItem struct { StudentID string Type int32 Pay *int8 Cost *money.Amount GiveCost *money.Amount Remark *string } // MarkAttendanceInput 打卡入参(端点 52)。 type MarkAttendanceInput struct { RecordID int64 Items []AttendanceItem } // MarkAttendanceResult 打卡结果。 // // 字段:RecordID 实例;StudentNumber 打卡行数;Income 当前 Σcost;Items 明细回显。 type MarkAttendanceResult struct { RecordID int64 StudentNumber int32 Income money.Amount Items []FinishedStudent } // FinishedStudent 单学生打卡/结算明细(回显用)。 // // 字段:StudentID 学生;Type 上课类型;Cost 本次课费;GiveCost 本次赠送额; // TeacherReward 本次分摊课酬;Paid 是否已支付;StudentCourseID 报课记录(0 = 未报课); // DivideGranted 本次是否触发了分账入账。 type FinishedStudent struct { StudentID string Type int32 Cost money.Amount GiveCost money.Amount TeacherReward money.Amount Paid bool StudentCourseID int64 DivideGranted bool } // MarkAttendance 打卡/考勤(端点 52):逐学生 upsert 打卡明细并刷新汇总列。 // // 入参:ctx;in 入参(RecordID 必填,Items 非空)。 // 出参:*MarkAttendanceResult;3001 记录不存在;3121 已结束/状态非法;2001 入参错。 // // 语义: // - 只写 cost/give_cost/pay/type,**不扣课时、不扣余额、不算课酬**(那是结束上课的联动); // - 幂等:同一 (record, student) 已存在 → 覆盖该行(重复打卡不产生第二行); // - 已结束的课不可再打卡(3121)——否则会出现"结课后追加的考勤不计入 income"的对账缺口。 func (s *RecordService) MarkAttendance(ctx context.Context, in MarkAttendanceInput) (*MarkAttendanceResult, error) { if in.RecordID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0")) } items, err := normalizeAttendanceItems(in.Items, false) if err != nil { return nil, err } out := &MarkAttendanceResult{RecordID: in.RecordID, Items: make([]FinishedStudent, 0, len(items))} err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) rec, rerr := s.records.GetRecordForUpdate(txCtx, tx, in.RecordID) if rerr != nil { return rerr // 3001 } if rec.Status == model.CoursesRecordStatusFinished { return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 该课已结束,不能补打卡(请走结课撤销流程)")) } for i := range items { item := &items[i] cost := money.Amount(0) if item.Cost != nil { cost = *item.Cost } giveCost := money.Amount(0) if item.GiveCost != nil { giveCost = *item.GiveCost } pay := model.CoursesRecordStudentPayUnpaid if item.Pay != nil { pay = *item.Pay } typ := item.Type if typ == 0 { typ = model.CoursesRecordStudentTypeNormal } if uerr := s.upsertRecordStudent(txCtx, tx, rec, item.StudentID, typ, pay, cost, giveCost, 0, nil, item.Remark); uerr != nil { return uerr } out.Items = append(out.Items, FinishedStudent{ StudentID: item.StudentID, Type: typ, Cost: cost, GiveCost: giveCost, Paid: pay == model.CoursesRecordStudentPayPaid, }) } rows, lerr := s.records.ListRecordStudents(txCtx, in.RecordID) if lerr != nil { return lerr } income, serr := sumRecordStudentCost(rows) if serr != nil { return serr } if _, uerr := s.records.UpdateRecordSummary(txCtx, tx, in.RecordID, int32(len(rows)), income); uerr != nil { return uerr } out.StudentNumber = int32(len(rows)) out.Income = income return nil }) if err != nil { return nil, err } return out, nil } // FinishRecordInput 结束上课入参(端点 51)。 // // 字段: // - RecordID int64:上课记录 ID(必填); // - ActualEndTime *time.Time:实际下课时间(nil → 服务端当前时刻); // - Items []AttendanceItem:本次打卡明细(**空则按"在读学生全勤"自动生成**); // - RewardType int8:课酬结算方式(0 → 1 正常); // - Remark *string:结课备注。 type FinishRecordInput struct { RecordID int64 ActualEndTime *time.Time Items []AttendanceItem RewardType int8 Remark *string } // FinishRecordResult 结束上课结果。 // // 字段:RecordID;Status(=2);StudentNumber;Income(Σcost);TeacherReward(课酬合计); // ActualEndTime;Repeated(幂等命中:该课此前已结束);Students 明细;DividesGranted 触发分账的学生数。 type FinishRecordResult struct { RecordID int64 Status int32 StudentNumber int32 Income money.Amount TeacherReward money.Amount ActualEndTime time.Time Repeated bool Students []FinishedStudent DividesGranted int } // FinishRecord 结束上课(端点 51)—— **单事务联动**(AC-11 ②)。 // // 入参:ctx;in 入参。 // 出参:*FinishRecordResult;3001 记录不存在;3121 状态非法;2002 入参错;5002 DB(整体回滚)。 // // 幂等(硬要点 6):`actual_end_time` 非空或 status=2 → **直接返回成功**(Repeated=true, // 按已落库的明细回读汇总)。理由:dump 无 finish SP,旧 PHP 的结课按钮可重复点击; // 对已结束课重复提交应视为no-op(不重复扣课时/不重复分账,这才是真正的幂等)。 // // 分账(⑥)只在"该学生本次真扣费 cost>0 且报课记录带推荐人"时触发; // type=1/2 的两级幂等在 DivideService 内部完成(同学生同订单不会重复入账)。 func (s *RecordService) FinishRecord(ctx context.Context, in FinishRecordInput) (*FinishRecordResult, error) { if in.RecordID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0")) } items, err := normalizeAttendanceItems(in.Items, true) if err != nil { return nil, err } out := &FinishRecordResult{RecordID: in.RecordID, Students: make([]FinishedStudent, 0, len(items))} err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) // ① record 行锁 + 状态机 rec, rerr := s.records.GetRecordForUpdate(txCtx, tx, in.RecordID) if rerr != nil { return rerr } if rec.ActualEndTime != nil || rec.Status == model.CoursesRecordStatusFinished { return s.fillFinishedResult(txCtx, rec, out) } if rec.Status != model.CoursesRecordStatusNotStarted && rec.Status != model.CoursesRecordStatusOngoing { return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 未知上课状态 "+strconv.FormatInt(int64(rec.Status), 10))) } endTime := s.now() if in.ActualEndTime != nil && !in.ActualEndTime.IsZero() { endTime = *in.ActualEndTime } if rec.StartTime != nil && endTime.Before(*rec.StartTime) { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 实际下课时间不得早于排班上课时间")) } // ② 解析本次打卡名单(空 → 在读学生全勤) resolved, rerr := s.resolveAttendance(txCtx, rec, items) if rerr != nil { 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, income) if qerr != nil { return qerr } rewards := distributeAmount(totalReward, len(resolved)) for i := range resolved { resolved[i].TeacherReward = rewards[i] } // ③ 扣课时 + ④ spend 累加 + ② 打卡明细落库(逐学生,顺序固定 → 锁序可复现) for i := range resolved { cur := &resolved[i] if cur.StudentCourseID > 0 && cur.Cost.GreaterThan(0) { if _, derr := s.courses.ApplyLessonDeduction(txCtx, tx, cur.StudentCourseID, cur.Cost); derr != nil { return derr } } if cur.Cost.GreaterThan(0) { // spend 是**统计口径列**(不动 balance):账户未建户时跳过并继续(旧 SP 的 UPDATE 命中 0 行同理), // 不让"学生没开过户"阻断整节课的结课落库。 if _, serr := s.expenses.AddSpend(txCtx, tx, cur.StudentID, rec.CampusID, cur.Cost); serr != nil { if !errors.Is(serr, repository.ErrAccountNotFound) { return serr } } } if uerr := s.upsertRecordStudent(txCtx, tx, rec, cur.StudentID, cur.Type, cur.Pay, cur.Cost, cur.GiveCost, cur.TeacherReward, &endTime, cur.Remark); uerr != nil { return uerr } } // ⑤ record 汇总落库(CAS:并发重复结课只有一个成功) affected, merr := s.records.MarkRecordFinished(txCtx, tx, in.RecordID, repository.RecordFinishPatch{ ActualEndTime: endTime, StudentNumber: int32(len(resolved)), Income: income, TeacherReward: totalReward, TeacherRewardType: in.RewardType, }) if merr != nil { return merr } if affected == 0 { // 并发下已被另一请求结束:本事务已扣的课时/spend 必须回滚(返回错误即回滚)。 // 归 3121(状态非法)而非 5002:这是"状态机竞争",不是 DB 故障。 return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: 结课状态已被并发修改(CAS 未命中),本次回滚,请重试查询")) } // ⑥ 分账(资金变动之后执行;tx 透传,禁止裸 ctx) for i := range resolved { cur := &resolved[i] if s.divide == nil || !cur.Cost.GreaterThan(0) { continue } if strings.TrimSpace(cur.RecommenderID) == "" { continue } if gerr := s.divide.AddLog(txCtx, cur.RecommenderID, cur.StudentID, in.RecordID, divideGrantRemark); gerr != nil { return gerr } cur.DivideGranted = true out.DividesGranted++ } out.Status = model.CoursesRecordStatusFinished out.StudentNumber = int32(len(resolved)) out.Income = income out.TeacherReward = totalReward out.ActualEndTime = endTime out.Students = toFinishedStudents(resolved) return nil }) if err != nil { return nil, err } return out, nil } // fillFinishedResult 幂等分支:按已落库数据回读结课结果(Repeated=true)。 // // 入参:txCtx;rec 已结束的实例;out 结果载体。 // 出参:nil(成功)或 DB 错误。**不写任何表**。 func (s *RecordService) fillFinishedResult(txCtx context.Context, rec *model.CoursesRecord, out *FinishRecordResult) error { rows, err := s.records.ListRecordStudents(txCtx, rec.ID) if err != nil { return err } income, err := sumRecordStudentCost(rows) if err != nil { return err } reward, err := parseAmount(rec.TeacherReward) if err != nil { return err } out.Repeated = true out.Status = model.CoursesRecordStatusFinished out.StudentNumber = int32(len(rows)) out.Income = income out.TeacherReward = reward if rec.ActualEndTime != nil { out.ActualEndTime = *rec.ActualEndTime } out.Students = make([]FinishedStudent, 0, len(rows)) for i := range rows { row := &rows[i] cost, cerr := parseAmount(row.Cost) if cerr != nil { return cerr } giveCost, cerr := parseAmount(row.GiveCost) if cerr != nil { return cerr } rowReward, cerr := parseAmount(row.TeacherReward) if cerr != nil { return cerr } out.Students = append(out.Students, FinishedStudent{ StudentID: row.StudentID, Type: row.Type, Cost: cost, GiveCost: giveCost, TeacherReward: rowReward, Paid: row.Pay == model.CoursesRecordStudentPayPaid, }) } return nil } // resolvedAttendance 结课用的单学生解析结果(含扣课时/分账所需的锚点信息)。 // // 字段:StudentID;Type;Pay;Cost;GiveCost;Remark;StudentCourseID(0 = 未报课); // RecommenderID(报课记录上的推荐人 ULID,空表示无推荐关系)。 type resolvedAttendance struct { StudentID string Type int32 Pay int8 Cost money.Amount GiveCost money.Amount TeacherReward money.Amount Remark *string StudentCourseID int64 RecommenderID string DivideGranted bool } // resolveAttendance 解析结课名单(显式打卡优先;为空则按在读学生全勤)。 // // 入参:txCtx;rec 上课实例;items 显式打卡明细(可为空)。 // 出参:[]resolvedAttendance(顺序:显式名单按入参序;全勤名单按报课记录 id 升序);3001/2002/5002。 // // 说明: // - 未报课的学生(插班/试听)允许存在于打卡明细,但**不扣课时**(StudentCourseID=0); // - Cost 缺省(nil)时取报课记录的 lesson_cost 快照(模型红线:扣课时不得改 lesson_cost)。 func (s *RecordService) resolveAttendance(txCtx context.Context, rec *model.CoursesRecord, items []AttendanceItem) ([]resolvedAttendance, error) { if len(items) == 0 { students, err := s.courses.ListActiveStudentsByCourses(txCtx, rec.CoursesID) if err != nil { return nil, err } if len(students) == 0 { return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 该课程没有在读学生,且未提供打卡名单")) } out := make([]resolvedAttendance, 0, len(students)) for i := range students { stu := &students[i] lessonCost, perr := parseAmount(stu.LessonCost) if perr != nil { return nil, perr } out = append(out, resolvedAttendance{ StudentID: stu.StudentID, Type: model.CoursesRecordStudentTypeNormal, Pay: model.CoursesRecordStudentPayPaid, Cost: lessonCost, GiveCost: 0, StudentCourseID: stu.ID, RecommenderID: strValue(stu.RecommenderUserID), }) } return out, nil } out := make([]resolvedAttendance, 0, len(items)) for i := range items { item := &items[i] stuCourse, err := s.courses.GetStudentCourse(txCtx, rec.CoursesID, item.StudentID) hasCourse := true if err != nil { if !errors.Is(err, infraerrors.ErrNotFound) { return nil, err } hasCourse = false } cur := resolvedAttendance{ StudentID: item.StudentID, Type: item.Type, Remark: normalizeOptString(item.Remark), } if cur.Type == 0 { cur.Type = model.CoursesRecordStudentTypeNormal } cur.Pay = model.CoursesRecordStudentPayUnpaid if item.Pay != nil { cur.Pay = *item.Pay } else if hasCourse { cur.Pay = model.CoursesRecordStudentPayPaid } if hasCourse { cur.StudentCourseID = stuCourse.ID cur.RecommenderID = strValue(stuCourse.RecommenderUserID) lessonCost, perr := parseAmount(stuCourse.LessonCost) if perr != nil { return nil, perr } cur.Cost = lessonCost } if item.Cost != nil { cur.Cost = *item.Cost } if item.GiveCost != nil { cur.GiveCost = *item.GiveCost } out = append(out, cur) } return out, nil } // upsertRecordStudent 写入/覆盖一条打卡明细(按 record+student 幂等)。 // // 入参:txCtx;tx;rec 实例;studentID;typ;pay;cost;giveCost;reward;endTime(nil=未完成);remark。 // 出参:nil 或 2001/5002。 // // 幂等:同一 (record_id, student_id) 已存在 → 覆盖(重复打卡/重算不产生第二行)。 // 单号(biz_no)只在首次插入时生成(模型红线:biz_no 不可变)。 func (s *RecordService) upsertRecordStudent(txCtx context.Context, tx *gorm.DB, rec *model.CoursesRecord, studentID string, typ int32, pay int8, cost, giveCost, reward money.Amount, endTime *time.Time, remark *string) error { existing, err := s.records.GetRecordStudent(txCtx, rec.ID, studentID) if err != nil && !errors.Is(err, infraerrors.ErrNotFound) { return err } if err == nil { _, uerr := s.records.UpdateRecordStudent(txCtx, tx, existing.ID, repository.RecordStudentPatch{ Type: typ, Pay: pay, Cost: cost, GiveCost: giveCost, TeacherReward: reward, EndTime: endTime, Remark: normalizeOptString(remark), }) return uerr } bizNo, berr := newBizNo() if berr != nil { return berr } startTime := s.now() if rec.StartTime != nil { startTime = *rec.StartTime } row := &model.CoursesRecordStudent{ BizNo: bizNo, CoursesID: rec.CoursesID, ClassID: rec.ClassID, InstitutionID: rec.InstitutionID, CampusID: rec.CampusID, RecordID: rec.ID, StudentID: studentID, Type: typ, StartTime: startTime, EndTime: endTime, Cost: cost.String(), GiveCost: giveCost.String(), TeacherReward: reward.String(), Pay: pay, Remark: normalizeOptString(remark), } return s.records.CreateRecordStudent(txCtx, tx, row) } // quoteReward 课酬试算(未注入 quoter → 0.00;未配置方案 3112 → 0.00 且不阻断结课)。 // // 入参:txCtx;rec 实例;at 上课时间(用于方案 effect_date 判定);income 本次课收入(比例提成基数)。 // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。 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 } course, err := s.courses.GetCourseByID(txCtx, rec.CoursesID) if err != nil { return 0, err } classType := int32(0) if course.ClassifyID > 0 { classify, cerr := s.courses.GetClassifyByID(txCtx, course.ClassifyID) if cerr == nil { classType = classify.ClassType } else if !errors.Is(cerr, infraerrors.ErrNotFound) { return 0, cerr } } in := RewardQuoteInput{ TeacherID: strValue(rec.TeacherID), InstitutionID: rec.InstitutionID, CampusID: rec.CampusID, 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 { if errors.Is(qerr, apperr.ErrPayrollPlanMissing) { // 无方案:课酬记 0.00(结课是考勤事实,不能因课酬未配置而回滚) return 0, nil } return 0, qerr } if reward.IsNegative() { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬试算返回负值(数据异常)")) } return reward, nil } // normalizeAttendanceItems 打卡明细入参归一 + 校验(去重、空行过滤、负金额拒绝)。 // // 入参:items 原始明细;allowEmptyCost true 表示 Cost 可缺省(结课走报课快照)。 // 出参:[]AttendanceItem(保持入参顺序);2001/2002。 func normalizeAttendanceItems(items []AttendanceItem, allowEmptyCost bool) ([]AttendanceItem, error) { out := make([]AttendanceItem, 0, len(items)) seen := make(map[string]struct{}, len(items)) for i := range items { item := items[i] item.StudentID = strings.TrimSpace(item.StudentID) if item.StudentID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 打卡明细缺少 studentId")) } if _, dup := seen[item.StudentID]; dup { return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 打卡明细中同一学生出现多次 "+item.StudentID)) } seen[item.StudentID] = struct{}{} if item.Type == 0 { item.Type = model.CoursesRecordStudentTypeNormal } switch item.Type { case model.CoursesRecordStudentTypeNormal, model.CoursesRecordStudentTypeTransfer, model.CoursesRecordStudentTypeGive: // 合法枚举:1 正常 / 2 插班 / 3 赠送 default: return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 非法打卡类型 "+strconv.FormatInt(int64(item.Type), 10))) } if item.Cost != nil { if item.Cost.IsNegative() { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 打卡课费不能为负")) } if err := item.Cost.Validate(); err != nil { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err) } } else if !allowEmptyCost { zero := money.Amount(0) item.Cost = &zero } if item.GiveCost != nil { if item.GiveCost.IsNegative() { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 赠送金额不能为负")) } if err := item.GiveCost.Validate(); err != nil { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err) } } out = append(out, item) } return out, nil } // sumRecordStudentCost 汇总打卡明细的课费(校验收款恒等式用)。 // // 入参:rows 打卡明细。出参:Σcost;金额列被写坏 → ErrDBError(不静默归零)。 func sumRecordStudentCost(rows []model.CoursesRecordStudent) (money.Amount, error) { total := money.Amount(0) for i := range rows { amt, err := parseAmount(rows[i].Cost) if err != nil { return 0, err } total = total.Add(amt) } return total, nil } // distributeAmount 把总额按行数均分(整数分运算,余数全部给第一行)。 // // 入参:total 总额(分);n 行数(<=0 → 返回空切片)。 // 出参:[]money.Amount(长度 n;Σ == total,保证 Σ record_student.teacher_reward = record.teacher_reward)。 // // 为什么均分:dump 无 finish SP(课酬分摊无权威口径)。选择"均分"的唯一硬理由是对账恒等式 // (模型注释红线 Σ = record.teacher_reward);余数入首行避免金额丢失(分位不可整除时)。 func distributeAmount(total money.Amount, n int) []money.Amount { out := make([]money.Amount, 0, n) if n <= 0 { return out } if total.IsZero() { for i := 0; i < n; i++ { out = append(out, 0) } return out } per := money.FromCents(total.Cents() / int64(n)) remainder := total.Cents() - per.Cents()*int64(n) for i := 0; i < n; i++ { out = append(out, per) } out[0] = money.FromCents(per.Cents() + remainder) return out } // strValue 取字符串指针的值(nil → 空串)。 // // 入参:s *string。出参:去空白后的字符串(nil 或空 → "")。 func strValue(s *string) string { if s == nil { return "" } return strings.TrimSpace(*s) } // toFinishedStudents 把内部解析结果转为对外回显结构。 // // 入参:resolved 结课解析结果。出参:[]FinishedStudent(同序;空入参 → 非 nil 空切片)。 func toFinishedStudents(resolved []resolvedAttendance) []FinishedStudent { out := make([]FinishedStudent, 0, len(resolved)) for i := range resolved { cur := &resolved[i] out = append(out, FinishedStudent{ StudentID: cur.StudentID, Type: cur.Type, Cost: cur.Cost, GiveCost: cur.GiveCost, TeacherReward: cur.TeacherReward, Paid: cur.Pay == model.CoursesRecordStudentPayPaid, StudentCourseID: cur.StudentCourseID, DivideGranted: cur.DivideGranted, }) } return out }