record_service.go 34 KB

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