record_service.go 35 KB

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