record_service.go 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895
  1. // 上课记录(排班实例)状态机 + 打卡/扣课时服务——AC-11。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §4.4(端点 50~52)/ §7.3 流程外延
  4. // / §1.4(事务模板 + 锁序)/ §10.1(3121 状态非法)/ §10.3(事务内调用下游必须透传 tx)
  5. // / §11-3(最小闭环范围)。
  6. //
  7. // 三个动作:
  8. //
  9. // StartRecord 开始上课:status 0 → 1 + actual_start_time(CAS,重复开始 → 3121)
  10. // MarkAttendance 打卡/考勤:逐学生 upsert 打卡明细(cost/give_cost/pay/type),刷新汇总列
  11. // FinishRecord 结束上课:**单事务联动**(本文件的核心,AC-11 ②)
  12. //
  13. // FinishRecord 的联动链(全部同一事务,任一步失败整体回滚):
  14. //
  15. // ① record 行锁 + 状态机判定(已结束 → 幂等成功;非法迁移 → 3121)
  16. // ② 逐学生打卡明细落库(t_courses_record_student:cost / give_cost / teacher_reward / end_time)
  17. // ③ 扣课时(t_courses_student:finished_number+1 / remainder_number-1 / remainder_cost-cost)
  18. // ④ spend 累加(t_user_expense(.school).spend += cost,统计口径列,**不动 balance**)
  19. // ⑤ record 汇总落库(status=2 + actual_end_time + student_number + income + teacher_reward)
  20. // ⑥ 分账(DivideService.AddLog:从推荐人的上级起逐级入账,两级幂等)
  21. // ⑦ 课酬(TeacherRewardQuoter 试算,按学生行均分以满足 Σ record_student.teacher_reward = record.teacher_reward)
  22. //
  23. // **锁序(§8 AC-07 基线)**:record 行锁 → 账户锁(全局 → 校区,由 ExpenseRepo 固定顺序实现)
  24. // → 报课行 → 分账表。活动/优惠池行锁本流程不涉及;若将来在结束时核销活动赠送,
  25. // 必须排在账户锁**之后**(禁止"先锁活动再锁账户",与充值并发即成环)。
  26. //
  27. // **tx 透传(§10.3 红线)**:事务内调 DivideService 一律传 `repository.WithTxContext(ctx, tx)`,
  28. // 否则下游 WithinTx 会新开独立事务 —— 出现"课时已扣、分账未给"的静默不一致。
  29. package service
  30. import (
  31. "context"
  32. "errors"
  33. "strconv"
  34. "strings"
  35. "time"
  36. "gorm.io/gorm"
  37. "yuansiwei/unified-backend/internal/academic/apperr"
  38. "yuansiwei/unified-backend/internal/academic/model"
  39. "yuansiwei/unified-backend/internal/academic/money"
  40. "yuansiwei/unified-backend/internal/academic/repository"
  41. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  42. appscope "yuansiwei/unified-backend/internal/infra/scope"
  43. )
  44. // divideGrantRemark 结束上课触发分账时写入明细的备注(与 divide_into_log_delete 改绑文案一致)。
  45. const divideGrantRemark = "推广分成"
  46. // DivideLogGranter 分账上行入账入口(AC-09 DivideService 结构性满足本接口)。
  47. //
  48. // 为什么用接口而不是直接依赖 *DivideService:
  49. // - 单测可注入 fake(断言"事务内只调用一次""入参 orderID 正确");
  50. // - 避免 service 内部同类耦合(后续若要换实现/加装饰器不必改 RecordService)。
  51. type DivideLogGranter interface {
  52. // AddLog 从 findUserID 的上级起逐级入账;实现须自行保证幂等与深度上限。
  53. AddLog(ctx context.Context, findUserID, stuID string, recordID int64, remark string) error
  54. }
  55. // RewardQuoteInput 课酬试算入参(对齐 dump `sp_course_payment_detail_get_teacher_detail` 的 6 参语义)。
  56. //
  57. // 字段:
  58. // - TeacherID string:老师 ULID(排班规则上的 teacher_id);
  59. // - InstitutionID/CampusID int64:方案两级回退的校区维度(校区 → 机构级 campus_id=0);
  60. // - ClassifyID int64:课程分类(决定 grade/subject/class_type 维度);
  61. // - ClassType int32:班级类型(1000 集体班…);
  62. // - RecordDate time.Time:上课日期(方案 effect_date 生效判定)。
  63. type RewardQuoteInput struct {
  64. TeacherID string
  65. InstitutionID int64
  66. CampusID int64
  67. ClassifyID int64
  68. ClassType int32
  69. RecordDate time.Time
  70. }
  71. // TeacherRewardQuoter 课酬试算端口(AC-10 PayrollService 实现;本服务只声明契约)。
  72. //
  73. // 契约:
  74. // - 返回 (reward, nil):单节课课酬(元,money.Amount);
  75. // - 返回 apperr.ErrPayrollPlanMissing(3112):**未配置方案** —— 本服务按"课酬 0"继续结课
  76. // (结束上课是考勤事实的落库,不能因为课酬没配就整单回滚;3112 留给端点 59 的试算入口);
  77. // - 其他 error:视为基础设施/数据异常,**整体回滚**(不允许"扣了课时没记课酬")。
  78. //
  79. // 未注入(nil)时:课酬按 0.00 落库(装配期由 AC-10 注入;已登记交付说明)。
  80. type TeacherRewardQuoter interface {
  81. QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error)
  82. }
  83. // RecordServiceOptions RecordService 的可选协作者(全部可 nil,nil 时降级见各字段注释)。
  84. //
  85. // 字段:
  86. // - Divide DivideLogGranter:分账入口(nil → 不产生分成,仅记录日志标记);
  87. // - Quoter TeacherRewardQuoter:课酬试算(nil → 课酬 0.00)。
  88. type RecordServiceOptions struct {
  89. Divide DivideLogGranter
  90. Quoter TeacherRewardQuoter
  91. }
  92. // RecordService 上课记录服务。
  93. type RecordService struct {
  94. // records 排班实例 + 打卡明细仓储。
  95. records *repository.RecordRepo
  96. // courses 课程/报课账户仓储(扣课时、推荐人来源)。
  97. courses *repository.CourseRepo
  98. // expenses 资金账户仓储(spend 统计列累加)。
  99. expenses *repository.ExpenseRepo
  100. // runner 事务运行器。
  101. runner *repository.TxRunner
  102. // divide 分账入口(可 nil)。
  103. divide DivideLogGranter
  104. // quoter 课酬试算(可 nil)。
  105. quoter TeacherRewardQuoter
  106. // now 时钟(单测注入固定时刻)。
  107. now func() time.Time
  108. }
  109. // NewRecordService 构造上课记录服务。
  110. //
  111. // 入参:
  112. // - db *gorm.DB:数据库句柄(必填);
  113. // - sc *appscope.Scope:请求作用域;
  114. // - opts 可选协作者(分账 / 课酬)。
  115. //
  116. // 出参:*RecordService。
  117. func NewRecordService(db *gorm.DB, sc *appscope.Scope, opts RecordServiceOptions) *RecordService {
  118. return &RecordService{
  119. records: repository.NewRecordRepo(db, sc),
  120. courses: repository.NewCourseRepo(db, sc),
  121. expenses: repository.NewExpenseRepo(db, sc),
  122. runner: repository.NewTxRunner(db),
  123. divide: opts.Divide,
  124. quoter: opts.Quoter,
  125. now: time.Now,
  126. }
  127. }
  128. // List 分页查询课表(端点 48 / 57 的读侧)。
  129. //
  130. // 入参:ctx;f 过滤;pg 分页。
  131. // 出参:([]model.CoursesRecord, total int64, error)。
  132. func (s *RecordService) List(ctx context.Context, f repository.RecordFilter, pg repository.PageQuery) ([]model.CoursesRecord, int64, error) {
  133. return s.records.ListRecords(ctx, f, pg)
  134. }
  135. // Get 取单条排班实例(含打卡明细)。
  136. //
  137. // 入参:ctx;recordID(<=0 → 2001)。
  138. // 出参:(*model.CoursesRecord, []model.CoursesRecordStudent);3001 / 5002。
  139. func (s *RecordService) Get(ctx context.Context, recordID int64) (*model.CoursesRecord, []model.CoursesRecordStudent, error) {
  140. if recordID <= 0 {
  141. return nil, nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0"))
  142. }
  143. rec, err := s.records.GetRecordByID(ctx, recordID)
  144. if err != nil {
  145. return nil, nil, err
  146. }
  147. items, err := s.records.ListRecordStudents(ctx, recordID)
  148. if err != nil {
  149. return nil, nil, err
  150. }
  151. return rec, items, nil
  152. }
  153. // StartRecordResult 开始上课结果。
  154. //
  155. // 字段:RecordID 实例 ID;Status 新状态(=1);ActualStartTime 实际上课时间;Repeated 幂等命中。
  156. type StartRecordResult struct {
  157. RecordID int64
  158. Status int32
  159. ActualStartTime time.Time
  160. Repeated bool
  161. }
  162. // StartRecord 开始上课(端点 50):`status 0 → 1` + `actual_start_time`。
  163. //
  164. // 入参:ctx;recordID 上课记录 ID。
  165. // 出参:*StartRecordResult;3001 记录不存在;3121 状态非法(重复开始/已结束)。
  166. //
  167. // 状态机(模型红线 actual_start_time 只写一次):
  168. //
  169. // status=0 → 1(成功,写 actual_start_time)
  170. // status=1 → 3121(重复开始)
  171. // status=2 → 3121(已结束不可回到上课中)
  172. //
  173. // CAS 兜底:并发双击时只有一个 RowsAffected=1,另一个 3121(不会写出两个不同的开课时间)。
  174. func (s *RecordService) StartRecord(ctx context.Context, recordID int64) (*StartRecordResult, error) {
  175. if recordID <= 0 {
  176. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0"))
  177. }
  178. out := &StartRecordResult{RecordID: recordID}
  179. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  180. txCtx := repository.WithTxContext(ctx, tx)
  181. rec, err := s.records.GetRecordForUpdate(txCtx, tx, recordID)
  182. if err != nil {
  183. return err // 3001
  184. }
  185. switch rec.Status {
  186. case model.CoursesRecordStatusOngoing:
  187. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  188. errString("service: 该课已在上课中(重复开始上课)"))
  189. case model.CoursesRecordStatusFinished:
  190. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  191. errString("service: 该课已结束,不能重新开始"))
  192. case model.CoursesRecordStatusNotStarted:
  193. // 预期分支:继续
  194. default:
  195. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  196. errString("service: 未知上课状态 "+strconv.FormatInt(int64(rec.Status), 10)))
  197. }
  198. at := s.now()
  199. affected, err := s.records.MarkRecordStarted(txCtx, tx, recordID, at)
  200. if err != nil {
  201. return err
  202. }
  203. if affected == 0 {
  204. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  205. errString("service: 上课状态已被并发修改(CAS 未命中)"))
  206. }
  207. out.Status = model.CoursesRecordStatusOngoing
  208. out.ActualStartTime = at
  209. return nil
  210. })
  211. if err != nil {
  212. return nil, err
  213. }
  214. return out, nil
  215. }
  216. // AttendanceItem 打卡明细入参(端点 52 与 51 共用)。
  217. //
  218. // 字段(指针用于区分"未传"与"传 0"):
  219. // - StudentID string:学生 ULID(必填);
  220. // - Type int32:上课类型 1 正常 / 2 插班 / 3 赠送(0 → 1);
  221. // - Pay *int8:是否已支付(nil → 打卡时不改、结课时按 1 处理);
  222. // - Cost *money.Amount:本次课费(nil → 结课时取报课记录的 lesson_cost 快照);
  223. // - GiveCost *money.Amount:本次使用赠送额(nil → 0;赠送行不动余额);
  224. // - Remark *string:备注。
  225. type AttendanceItem struct {
  226. StudentID string
  227. Type int32
  228. Pay *int8
  229. Cost *money.Amount
  230. GiveCost *money.Amount
  231. Remark *string
  232. }
  233. // MarkAttendanceInput 打卡入参(端点 52)。
  234. type MarkAttendanceInput struct {
  235. RecordID int64
  236. Items []AttendanceItem
  237. }
  238. // MarkAttendanceResult 打卡结果。
  239. //
  240. // 字段:RecordID 实例;StudentNumber 打卡行数;Income 当前 Σcost;Items 明细回显。
  241. type MarkAttendanceResult struct {
  242. RecordID int64
  243. StudentNumber int32
  244. Income money.Amount
  245. Items []FinishedStudent
  246. }
  247. // FinishedStudent 单学生打卡/结算明细(回显用)。
  248. //
  249. // 字段:StudentID 学生;Type 上课类型;Cost 本次课费;GiveCost 本次赠送额;
  250. // TeacherReward 本次分摊课酬;Paid 是否已支付;StudentCourseID 报课记录(0 = 未报课);
  251. // DivideGranted 本次是否触发了分账入账。
  252. type FinishedStudent struct {
  253. StudentID string
  254. Type int32
  255. Cost money.Amount
  256. GiveCost money.Amount
  257. TeacherReward money.Amount
  258. Paid bool
  259. StudentCourseID int64
  260. DivideGranted bool
  261. }
  262. // MarkAttendance 打卡/考勤(端点 52):逐学生 upsert 打卡明细并刷新汇总列。
  263. //
  264. // 入参:ctx;in 入参(RecordID 必填,Items 非空)。
  265. // 出参:*MarkAttendanceResult;3001 记录不存在;3121 已结束/状态非法;2001 入参错。
  266. //
  267. // 语义:
  268. // - 只写 cost/give_cost/pay/type,**不扣课时、不扣余额、不算课酬**(那是结束上课的联动);
  269. // - 幂等:同一 (record, student) 已存在 → 覆盖该行(重复打卡不产生第二行);
  270. // - 已结束的课不可再打卡(3121)——否则会出现"结课后追加的考勤不计入 income"的对账缺口。
  271. func (s *RecordService) MarkAttendance(ctx context.Context, in MarkAttendanceInput) (*MarkAttendanceResult, error) {
  272. if in.RecordID <= 0 {
  273. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0"))
  274. }
  275. items, err := normalizeAttendanceItems(in.Items, false)
  276. if err != nil {
  277. return nil, err
  278. }
  279. out := &MarkAttendanceResult{RecordID: in.RecordID, Items: make([]FinishedStudent, 0, len(items))}
  280. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  281. txCtx := repository.WithTxContext(ctx, tx)
  282. rec, rerr := s.records.GetRecordForUpdate(txCtx, tx, in.RecordID)
  283. if rerr != nil {
  284. return rerr // 3001
  285. }
  286. if rec.Status == model.CoursesRecordStatusFinished {
  287. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  288. errString("service: 该课已结束,不能补打卡(请走结课撤销流程)"))
  289. }
  290. for i := range items {
  291. item := &items[i]
  292. cost := money.Amount(0)
  293. if item.Cost != nil {
  294. cost = *item.Cost
  295. }
  296. giveCost := money.Amount(0)
  297. if item.GiveCost != nil {
  298. giveCost = *item.GiveCost
  299. }
  300. pay := model.CoursesRecordStudentPayUnpaid
  301. if item.Pay != nil {
  302. pay = *item.Pay
  303. }
  304. typ := item.Type
  305. if typ == 0 {
  306. typ = model.CoursesRecordStudentTypeNormal
  307. }
  308. if uerr := s.upsertRecordStudent(txCtx, tx, rec, item.StudentID, typ, pay, cost, giveCost, 0, nil, item.Remark); uerr != nil {
  309. return uerr
  310. }
  311. out.Items = append(out.Items, FinishedStudent{
  312. StudentID: item.StudentID, Type: typ, Cost: cost, GiveCost: giveCost,
  313. Paid: pay == model.CoursesRecordStudentPayPaid,
  314. })
  315. }
  316. rows, lerr := s.records.ListRecordStudents(txCtx, in.RecordID)
  317. if lerr != nil {
  318. return lerr
  319. }
  320. income, serr := sumRecordStudentCost(rows)
  321. if serr != nil {
  322. return serr
  323. }
  324. if _, uerr := s.records.UpdateRecordSummary(txCtx, tx, in.RecordID, int32(len(rows)), income); uerr != nil {
  325. return uerr
  326. }
  327. out.StudentNumber = int32(len(rows))
  328. out.Income = income
  329. return nil
  330. })
  331. if err != nil {
  332. return nil, err
  333. }
  334. return out, nil
  335. }
  336. // FinishRecordInput 结束上课入参(端点 51)。
  337. //
  338. // 字段:
  339. // - RecordID int64:上课记录 ID(必填);
  340. // - ActualEndTime *time.Time:实际下课时间(nil → 服务端当前时刻);
  341. // - Items []AttendanceItem:本次打卡明细(**空则按"在读学生全勤"自动生成**);
  342. // - RewardType int8:课酬结算方式(0 → 1 正常);
  343. // - Remark *string:结课备注。
  344. type FinishRecordInput struct {
  345. RecordID int64
  346. ActualEndTime *time.Time
  347. Items []AttendanceItem
  348. RewardType int8
  349. Remark *string
  350. }
  351. // FinishRecordResult 结束上课结果。
  352. //
  353. // 字段:RecordID;Status(=2);StudentNumber;Income(Σcost);TeacherReward(课酬合计);
  354. // ActualEndTime;Repeated(幂等命中:该课此前已结束);Students 明细;DividesGranted 触发分账的学生数。
  355. type FinishRecordResult struct {
  356. RecordID int64
  357. Status int32
  358. StudentNumber int32
  359. Income money.Amount
  360. TeacherReward money.Amount
  361. ActualEndTime time.Time
  362. Repeated bool
  363. Students []FinishedStudent
  364. DividesGranted int
  365. }
  366. // FinishRecord 结束上课(端点 51)—— **单事务联动**(AC-11 ②)。
  367. //
  368. // 入参:ctx;in 入参。
  369. // 出参:*FinishRecordResult;3001 记录不存在;3121 状态非法;2002 入参错;5002 DB(整体回滚)。
  370. //
  371. // 幂等(硬要点 6):`actual_end_time` 非空或 status=2 → **直接返回成功**(Repeated=true,
  372. // 按已落库的明细回读汇总)。理由:dump 无 finish SP,旧 PHP 的结课按钮可重复点击;
  373. // 对已结束课重复提交应视为no-op(不重复扣课时/不重复分账,这才是真正的幂等)。
  374. //
  375. // 分账(⑥)只在"该学生本次真扣费 cost>0 且报课记录带推荐人"时触发;
  376. // type=1/2 的两级幂等在 DivideService 内部完成(同学生同订单不会重复入账)。
  377. func (s *RecordService) FinishRecord(ctx context.Context, in FinishRecordInput) (*FinishRecordResult, error) {
  378. if in.RecordID <= 0 {
  379. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: recordId 必须 > 0"))
  380. }
  381. items, err := normalizeAttendanceItems(in.Items, true)
  382. if err != nil {
  383. return nil, err
  384. }
  385. out := &FinishRecordResult{RecordID: in.RecordID, Students: make([]FinishedStudent, 0, len(items))}
  386. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  387. txCtx := repository.WithTxContext(ctx, tx)
  388. // ① record 行锁 + 状态机
  389. rec, rerr := s.records.GetRecordForUpdate(txCtx, tx, in.RecordID)
  390. if rerr != nil {
  391. return rerr
  392. }
  393. if rec.ActualEndTime != nil || rec.Status == model.CoursesRecordStatusFinished {
  394. return s.fillFinishedResult(txCtx, rec, out)
  395. }
  396. if rec.Status != model.CoursesRecordStatusNotStarted && rec.Status != model.CoursesRecordStatusOngoing {
  397. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  398. errString("service: 未知上课状态 "+strconv.FormatInt(int64(rec.Status), 10)))
  399. }
  400. endTime := s.now()
  401. if in.ActualEndTime != nil && !in.ActualEndTime.IsZero() {
  402. endTime = *in.ActualEndTime
  403. }
  404. if rec.StartTime != nil && endTime.Before(*rec.StartTime) {
  405. return infraerrors.WithCause(infraerrors.ErrBadParam,
  406. errString("service: 实际下课时间不得早于排班上课时间"))
  407. }
  408. // ② 解析本次打卡名单(空 → 在读学生全勤)
  409. resolved, rerr := s.resolveAttendance(txCtx, rec, items)
  410. if rerr != nil {
  411. return rerr
  412. }
  413. // ⑦ 课酬试算(一次:一节课一份课酬;nil quoter → 0.00)
  414. totalReward, qerr := s.quoteReward(txCtx, rec, endTime)
  415. if qerr != nil {
  416. return qerr
  417. }
  418. rewards := distributeAmount(totalReward, len(resolved))
  419. income := money.Amount(0)
  420. for i := range resolved {
  421. cur := &resolved[i]
  422. cur.TeacherReward = rewards[i]
  423. income = income.Add(cur.Cost)
  424. }
  425. // ③ 扣课时 + ④ spend 累加 + ② 打卡明细落库(逐学生,顺序固定 → 锁序可复现)
  426. for i := range resolved {
  427. cur := &resolved[i]
  428. if cur.StudentCourseID > 0 && cur.Cost.GreaterThan(0) {
  429. if _, derr := s.courses.ApplyLessonDeduction(txCtx, tx, cur.StudentCourseID, cur.Cost); derr != nil {
  430. return derr
  431. }
  432. }
  433. if cur.Cost.GreaterThan(0) {
  434. // spend 是**统计口径列**(不动 balance):账户未建户时跳过并继续(旧 SP 的 UPDATE 命中 0 行同理),
  435. // 不让"学生没开过户"阻断整节课的结课落库。
  436. if _, serr := s.expenses.AddSpend(txCtx, tx, cur.StudentID, rec.CampusID, cur.Cost); serr != nil {
  437. if !errors.Is(serr, repository.ErrAccountNotFound) {
  438. return serr
  439. }
  440. }
  441. }
  442. if uerr := s.upsertRecordStudent(txCtx, tx, rec, cur.StudentID, cur.Type, cur.Pay,
  443. cur.Cost, cur.GiveCost, cur.TeacherReward, &endTime, cur.Remark); uerr != nil {
  444. return uerr
  445. }
  446. }
  447. // ⑤ record 汇总落库(CAS:并发重复结课只有一个成功)
  448. affected, merr := s.records.MarkRecordFinished(txCtx, tx, in.RecordID, repository.RecordFinishPatch{
  449. ActualEndTime: endTime,
  450. StudentNumber: int32(len(resolved)),
  451. Income: income,
  452. TeacherReward: totalReward,
  453. TeacherRewardType: in.RewardType,
  454. })
  455. if merr != nil {
  456. return merr
  457. }
  458. if affected == 0 {
  459. // 并发下已被另一请求结束:本事务已扣的课时/spend 必须回滚(返回错误即回滚)。
  460. // 归 3121(状态非法)而非 5002:这是"状态机竞争",不是 DB 故障。
  461. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  462. errString("service: 结课状态已被并发修改(CAS 未命中),本次回滚,请重试查询"))
  463. }
  464. // ⑥ 分账(资金变动之后执行;tx 透传,禁止裸 ctx)
  465. for i := range resolved {
  466. cur := &resolved[i]
  467. if s.divide == nil || !cur.Cost.GreaterThan(0) {
  468. continue
  469. }
  470. if strings.TrimSpace(cur.RecommenderID) == "" {
  471. continue
  472. }
  473. if gerr := s.divide.AddLog(txCtx, cur.RecommenderID, cur.StudentID, in.RecordID, divideGrantRemark); gerr != nil {
  474. return gerr
  475. }
  476. cur.DivideGranted = true
  477. out.DividesGranted++
  478. }
  479. out.Status = model.CoursesRecordStatusFinished
  480. out.StudentNumber = int32(len(resolved))
  481. out.Income = income
  482. out.TeacherReward = totalReward
  483. out.ActualEndTime = endTime
  484. out.Students = toFinishedStudents(resolved)
  485. return nil
  486. })
  487. if err != nil {
  488. return nil, err
  489. }
  490. return out, nil
  491. }
  492. // fillFinishedResult 幂等分支:按已落库数据回读结课结果(Repeated=true)。
  493. //
  494. // 入参:txCtx;rec 已结束的实例;out 结果载体。
  495. // 出参:nil(成功)或 DB 错误。**不写任何表**。
  496. func (s *RecordService) fillFinishedResult(txCtx context.Context, rec *model.CoursesRecord, out *FinishRecordResult) error {
  497. rows, err := s.records.ListRecordStudents(txCtx, rec.ID)
  498. if err != nil {
  499. return err
  500. }
  501. income, err := sumRecordStudentCost(rows)
  502. if err != nil {
  503. return err
  504. }
  505. reward, err := parseAmount(rec.TeacherReward)
  506. if err != nil {
  507. return err
  508. }
  509. out.Repeated = true
  510. out.Status = model.CoursesRecordStatusFinished
  511. out.StudentNumber = int32(len(rows))
  512. out.Income = income
  513. out.TeacherReward = reward
  514. if rec.ActualEndTime != nil {
  515. out.ActualEndTime = *rec.ActualEndTime
  516. }
  517. out.Students = make([]FinishedStudent, 0, len(rows))
  518. for i := range rows {
  519. row := &rows[i]
  520. cost, cerr := parseAmount(row.Cost)
  521. if cerr != nil {
  522. return cerr
  523. }
  524. giveCost, cerr := parseAmount(row.GiveCost)
  525. if cerr != nil {
  526. return cerr
  527. }
  528. rowReward, cerr := parseAmount(row.TeacherReward)
  529. if cerr != nil {
  530. return cerr
  531. }
  532. out.Students = append(out.Students, FinishedStudent{
  533. StudentID: row.StudentID,
  534. Type: row.Type,
  535. Cost: cost,
  536. GiveCost: giveCost,
  537. TeacherReward: rowReward,
  538. Paid: row.Pay == model.CoursesRecordStudentPayPaid,
  539. })
  540. }
  541. return nil
  542. }
  543. // resolvedAttendance 结课用的单学生解析结果(含扣课时/分账所需的锚点信息)。
  544. //
  545. // 字段:StudentID;Type;Pay;Cost;GiveCost;Remark;StudentCourseID(0 = 未报课);
  546. // RecommenderID(报课记录上的推荐人 ULID,空表示无推荐关系)。
  547. type resolvedAttendance struct {
  548. StudentID string
  549. Type int32
  550. Pay int8
  551. Cost money.Amount
  552. GiveCost money.Amount
  553. TeacherReward money.Amount
  554. Remark *string
  555. StudentCourseID int64
  556. RecommenderID string
  557. DivideGranted bool
  558. }
  559. // resolveAttendance 解析结课名单(显式打卡优先;为空则按在读学生全勤)。
  560. //
  561. // 入参:txCtx;rec 上课实例;items 显式打卡明细(可为空)。
  562. // 出参:[]resolvedAttendance(顺序:显式名单按入参序;全勤名单按报课记录 id 升序);3001/2002/5002。
  563. //
  564. // 说明:
  565. // - 未报课的学生(插班/试听)允许存在于打卡明细,但**不扣课时**(StudentCourseID=0);
  566. // - Cost 缺省(nil)时取报课记录的 lesson_cost 快照(模型红线:扣课时不得改 lesson_cost)。
  567. func (s *RecordService) resolveAttendance(txCtx context.Context, rec *model.CoursesRecord, items []AttendanceItem) ([]resolvedAttendance, error) {
  568. if len(items) == 0 {
  569. students, err := s.courses.ListActiveStudentsByCourses(txCtx, rec.CoursesID)
  570. if err != nil {
  571. return nil, err
  572. }
  573. if len(students) == 0 {
  574. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  575. errString("service: 该课程没有在读学生,且未提供打卡名单"))
  576. }
  577. out := make([]resolvedAttendance, 0, len(students))
  578. for i := range students {
  579. stu := &students[i]
  580. lessonCost, perr := parseAmount(stu.LessonCost)
  581. if perr != nil {
  582. return nil, perr
  583. }
  584. out = append(out, resolvedAttendance{
  585. StudentID: stu.StudentID,
  586. Type: model.CoursesRecordStudentTypeNormal,
  587. Pay: model.CoursesRecordStudentPayPaid,
  588. Cost: lessonCost,
  589. GiveCost: 0,
  590. StudentCourseID: stu.ID,
  591. RecommenderID: strValue(stu.RecommenderUserID),
  592. })
  593. }
  594. return out, nil
  595. }
  596. out := make([]resolvedAttendance, 0, len(items))
  597. for i := range items {
  598. item := &items[i]
  599. stuCourse, err := s.courses.GetStudentCourse(txCtx, rec.CoursesID, item.StudentID)
  600. hasCourse := true
  601. if err != nil {
  602. if !errors.Is(err, infraerrors.ErrNotFound) {
  603. return nil, err
  604. }
  605. hasCourse = false
  606. }
  607. cur := resolvedAttendance{
  608. StudentID: item.StudentID,
  609. Type: item.Type,
  610. Remark: normalizeOptString(item.Remark),
  611. }
  612. if cur.Type == 0 {
  613. cur.Type = model.CoursesRecordStudentTypeNormal
  614. }
  615. cur.Pay = model.CoursesRecordStudentPayUnpaid
  616. if item.Pay != nil {
  617. cur.Pay = *item.Pay
  618. } else if hasCourse {
  619. cur.Pay = model.CoursesRecordStudentPayPaid
  620. }
  621. if hasCourse {
  622. cur.StudentCourseID = stuCourse.ID
  623. cur.RecommenderID = strValue(stuCourse.RecommenderUserID)
  624. lessonCost, perr := parseAmount(stuCourse.LessonCost)
  625. if perr != nil {
  626. return nil, perr
  627. }
  628. cur.Cost = lessonCost
  629. }
  630. if item.Cost != nil {
  631. cur.Cost = *item.Cost
  632. }
  633. if item.GiveCost != nil {
  634. cur.GiveCost = *item.GiveCost
  635. }
  636. out = append(out, cur)
  637. }
  638. return out, nil
  639. }
  640. // upsertRecordStudent 写入/覆盖一条打卡明细(按 record+student 幂等)。
  641. //
  642. // 入参:txCtx;tx;rec 实例;studentID;typ;pay;cost;giveCost;reward;endTime(nil=未完成);remark。
  643. // 出参:nil 或 2001/5002。
  644. //
  645. // 幂等:同一 (record_id, student_id) 已存在 → 覆盖(重复打卡/重算不产生第二行)。
  646. // 单号(biz_no)只在首次插入时生成(模型红线:biz_no 不可变)。
  647. func (s *RecordService) upsertRecordStudent(txCtx context.Context, tx *gorm.DB, rec *model.CoursesRecord,
  648. studentID string, typ int32, pay int8, cost, giveCost, reward money.Amount, endTime *time.Time, remark *string) error {
  649. existing, err := s.records.GetRecordStudent(txCtx, rec.ID, studentID)
  650. if err != nil && !errors.Is(err, infraerrors.ErrNotFound) {
  651. return err
  652. }
  653. if err == nil {
  654. _, uerr := s.records.UpdateRecordStudent(txCtx, tx, existing.ID, repository.RecordStudentPatch{
  655. Type: typ,
  656. Pay: pay,
  657. Cost: cost,
  658. GiveCost: giveCost,
  659. TeacherReward: reward,
  660. EndTime: endTime,
  661. Remark: normalizeOptString(remark),
  662. })
  663. return uerr
  664. }
  665. bizNo, berr := newBizNo()
  666. if berr != nil {
  667. return berr
  668. }
  669. startTime := s.now()
  670. if rec.StartTime != nil {
  671. startTime = *rec.StartTime
  672. }
  673. row := &model.CoursesRecordStudent{
  674. BizNo: bizNo,
  675. CoursesID: rec.CoursesID,
  676. ClassID: rec.ClassID,
  677. InstitutionID: rec.InstitutionID,
  678. CampusID: rec.CampusID,
  679. RecordID: rec.ID,
  680. StudentID: studentID,
  681. Type: typ,
  682. StartTime: startTime,
  683. EndTime: endTime,
  684. Cost: cost.String(),
  685. GiveCost: giveCost.String(),
  686. TeacherReward: reward.String(),
  687. Pay: pay,
  688. Remark: normalizeOptString(remark),
  689. }
  690. return s.records.CreateRecordStudent(txCtx, tx, row)
  691. }
  692. // quoteReward 课酬试算(未注入 quoter → 0.00;未配置方案 3112 → 0.00 且不阻断结课)。
  693. //
  694. // 入参:txCtx;rec 实例;at 上课时间(用于方案 effect_date 判定)。
  695. // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。
  696. func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time) (money.Amount, error) {
  697. if s.quoter == nil {
  698. return 0, nil
  699. }
  700. course, err := s.courses.GetCourseByID(txCtx, rec.CoursesID)
  701. if err != nil {
  702. return 0, err
  703. }
  704. classType := int32(0)
  705. if course.ClassifyID > 0 {
  706. classify, cerr := s.courses.GetClassifyByID(txCtx, course.ClassifyID)
  707. if cerr == nil {
  708. classType = classify.ClassType
  709. } else if !errors.Is(cerr, infraerrors.ErrNotFound) {
  710. return 0, cerr
  711. }
  712. }
  713. in := RewardQuoteInput{
  714. TeacherID: strValue(rec.TeacherID),
  715. InstitutionID: rec.InstitutionID,
  716. CampusID: rec.CampusID,
  717. ClassifyID: course.ClassifyID,
  718. ClassType: classType,
  719. RecordDate: at,
  720. }
  721. reward, qerr := s.quoter.QuoteRecordReward(txCtx, in)
  722. if qerr != nil {
  723. if errors.Is(qerr, apperr.ErrPayrollPlanMissing) {
  724. // 无方案:课酬记 0.00(结课是考勤事实,不能因课酬未配置而回滚)
  725. return 0, nil
  726. }
  727. return 0, qerr
  728. }
  729. if reward.IsNegative() {
  730. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  731. errString("service: 课酬试算返回负值(数据异常)"))
  732. }
  733. return reward, nil
  734. }
  735. // normalizeAttendanceItems 打卡明细入参归一 + 校验(去重、空行过滤、负金额拒绝)。
  736. //
  737. // 入参:items 原始明细;allowEmptyCost true 表示 Cost 可缺省(结课走报课快照)。
  738. // 出参:[]AttendanceItem(保持入参顺序);2001/2002。
  739. func normalizeAttendanceItems(items []AttendanceItem, allowEmptyCost bool) ([]AttendanceItem, error) {
  740. out := make([]AttendanceItem, 0, len(items))
  741. seen := make(map[string]struct{}, len(items))
  742. for i := range items {
  743. item := items[i]
  744. item.StudentID = strings.TrimSpace(item.StudentID)
  745. if item.StudentID == "" {
  746. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 打卡明细缺少 studentId"))
  747. }
  748. if _, dup := seen[item.StudentID]; dup {
  749. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  750. errString("service: 打卡明细中同一学生出现多次 "+item.StudentID))
  751. }
  752. seen[item.StudentID] = struct{}{}
  753. if item.Type == 0 {
  754. item.Type = model.CoursesRecordStudentTypeNormal
  755. }
  756. switch item.Type {
  757. case model.CoursesRecordStudentTypeNormal, model.CoursesRecordStudentTypeTransfer, model.CoursesRecordStudentTypeGive:
  758. // 合法枚举:1 正常 / 2 插班 / 3 赠送
  759. default:
  760. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  761. errString("service: 非法打卡类型 "+strconv.FormatInt(int64(item.Type), 10)))
  762. }
  763. if item.Cost != nil {
  764. if item.Cost.IsNegative() {
  765. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 打卡课费不能为负"))
  766. }
  767. if err := item.Cost.Validate(); err != nil {
  768. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  769. }
  770. } else if !allowEmptyCost {
  771. zero := money.Amount(0)
  772. item.Cost = &zero
  773. }
  774. if item.GiveCost != nil {
  775. if item.GiveCost.IsNegative() {
  776. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 赠送金额不能为负"))
  777. }
  778. if err := item.GiveCost.Validate(); err != nil {
  779. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  780. }
  781. }
  782. out = append(out, item)
  783. }
  784. return out, nil
  785. }
  786. // sumRecordStudentCost 汇总打卡明细的课费(校验收款恒等式用)。
  787. //
  788. // 入参:rows 打卡明细。出参:Σcost;金额列被写坏 → ErrDBError(不静默归零)。
  789. func sumRecordStudentCost(rows []model.CoursesRecordStudent) (money.Amount, error) {
  790. total := money.Amount(0)
  791. for i := range rows {
  792. amt, err := parseAmount(rows[i].Cost)
  793. if err != nil {
  794. return 0, err
  795. }
  796. total = total.Add(amt)
  797. }
  798. return total, nil
  799. }
  800. // distributeAmount 把总额按行数均分(整数分运算,余数全部给第一行)。
  801. //
  802. // 入参:total 总额(分);n 行数(<=0 → 返回空切片)。
  803. // 出参:[]money.Amount(长度 n;Σ == total,保证 Σ record_student.teacher_reward = record.teacher_reward)。
  804. //
  805. // 为什么均分:dump 无 finish SP(课酬分摊无权威口径)。选择"均分"的唯一硬理由是对账恒等式
  806. // (模型注释红线 Σ = record.teacher_reward);余数入首行避免金额丢失(分位不可整除时)。
  807. func distributeAmount(total money.Amount, n int) []money.Amount {
  808. out := make([]money.Amount, 0, n)
  809. if n <= 0 {
  810. return out
  811. }
  812. if total.IsZero() {
  813. for i := 0; i < n; i++ {
  814. out = append(out, 0)
  815. }
  816. return out
  817. }
  818. per := money.FromCents(total.Cents() / int64(n))
  819. remainder := total.Cents() - per.Cents()*int64(n)
  820. for i := 0; i < n; i++ {
  821. out = append(out, per)
  822. }
  823. out[0] = money.FromCents(per.Cents() + remainder)
  824. return out
  825. }
  826. // strValue 取字符串指针的值(nil → 空串)。
  827. //
  828. // 入参:s *string。出参:去空白后的字符串(nil 或空 → "")。
  829. func strValue(s *string) string {
  830. if s == nil {
  831. return ""
  832. }
  833. return strings.TrimSpace(*s)
  834. }
  835. // toFinishedStudents 把内部解析结果转为对外回显结构。
  836. //
  837. // 入参:resolved 结课解析结果。出参:[]FinishedStudent(同序;空入参 → 非 nil 空切片)。
  838. func toFinishedStudents(resolved []resolvedAttendance) []FinishedStudent {
  839. out := make([]FinishedStudent, 0, len(resolved))
  840. for i := range resolved {
  841. cur := &resolved[i]
  842. out = append(out, FinishedStudent{
  843. StudentID: cur.StudentID,
  844. Type: cur.Type,
  845. Cost: cur.Cost,
  846. GiveCost: cur.GiveCost,
  847. TeacherReward: cur.TeacherReward,
  848. Paid: cur.Pay == model.CoursesRecordStudentPayPaid,
  849. StudentCourseID: cur.StudentCourseID,
  850. DivideGranted: cur.DivideGranted,
  851. })
  852. }
  853. return out
  854. }