| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895 |
- // 上课记录(排班实例)状态机 + 打卡/扣课时服务——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 生效判定)。
- type RewardQuoteInput struct {
- TeacherID string
- InstitutionID int64
- CampusID int64
- ClassifyID int64
- ClassType int32
- RecordDate time.Time
- }
- // 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
- }
- // ⑦ 课酬试算(一次:一节课一份课酬;nil quoter → 0.00)
- totalReward, qerr := s.quoteReward(txCtx, rec, endTime)
- 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)
- }
- // ③ 扣课时 + ④ 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 判定)。
- // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。
- func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time) (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,
- }
- 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
- }
|