record_service.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992
  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 试算;**按 PHP 口径只分给 type∈{1,2} 的行**,见 `applyTeacherRewards`)
  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. // 课酬分发**按 PHP 口径**(`CoursesRecordHelper.php:806-818`):
  444. // 除数 = `count(type IN (1,2))`(**不含 type=3 赠送生**)
  445. // 写入范围 = **只有 type∈{1,2} 的行**;type=3 行 `teacher_reward` **恒为 0**
  446. // ⇒ 恒等式是**条件式**:`stuCount>0` 时 `Σ(type∈{1,2}) == record.teacher_reward`;
  447. // `stuCount==0`(全班赠送)时**一行都不写**(PHP 的 `if($stuCount)` 不进入),
  448. // 此时 record 上仍可能有课酬、学生行全 0 —— **恒等式不成立,不得按无条件断言校验**。
  449. applyTeacherRewards(resolved, totalReward)
  450. // ③ 扣课时 + ④ spend 累加 + ② 打卡明细落库(逐学生,顺序固定 → 锁序可复现)
  451. for i := range resolved {
  452. cur := &resolved[i]
  453. if cur.StudentCourseID > 0 && cur.Cost.GreaterThan(0) {
  454. if _, derr := s.courses.ApplyLessonDeduction(txCtx, tx, cur.StudentCourseID, cur.Cost); derr != nil {
  455. return derr
  456. }
  457. }
  458. if cur.Cost.GreaterThan(0) {
  459. // spend 是**统计口径列**(不动 balance):账户未建户时跳过并继续(旧 SP 的 UPDATE 命中 0 行同理),
  460. // 不让"学生没开过户"阻断整节课的结课落库。
  461. if _, serr := s.expenses.AddSpend(txCtx, tx, cur.StudentID, rec.CampusID, cur.Cost); serr != nil {
  462. if !errors.Is(serr, repository.ErrAccountNotFound) {
  463. return serr
  464. }
  465. }
  466. }
  467. if uerr := s.upsertRecordStudent(txCtx, tx, rec, cur.StudentID, cur.Type, cur.Pay,
  468. cur.Cost, cur.GiveCost, cur.TeacherReward, &endTime, cur.Remark); uerr != nil {
  469. return uerr
  470. }
  471. }
  472. // ⑤ record 汇总落库(CAS:并发重复结课只有一个成功)
  473. affected, merr := s.records.MarkRecordFinished(txCtx, tx, in.RecordID, repository.RecordFinishPatch{
  474. ActualEndTime: endTime,
  475. StudentNumber: int32(len(resolved)),
  476. Income: income,
  477. TeacherReward: totalReward,
  478. TeacherRewardType: in.RewardType,
  479. })
  480. if merr != nil {
  481. return merr
  482. }
  483. if affected == 0 {
  484. // 并发下已被另一请求结束:本事务已扣的课时/spend 必须回滚(返回错误即回滚)。
  485. // 归 3121(状态非法)而非 5002:这是"状态机竞争",不是 DB 故障。
  486. return infraerrors.WithCause(apperr.ErrRecordStateInvalid,
  487. errString("service: 结课状态已被并发修改(CAS 未命中),本次回滚,请重试查询"))
  488. }
  489. // ⑥ 分账(资金变动之后执行;tx 透传,禁止裸 ctx)
  490. for i := range resolved {
  491. cur := &resolved[i]
  492. if s.divide == nil || !cur.Cost.GreaterThan(0) {
  493. continue
  494. }
  495. if strings.TrimSpace(cur.RecommenderID) == "" {
  496. continue
  497. }
  498. if gerr := s.divide.AddLog(txCtx, cur.RecommenderID, cur.StudentID, in.RecordID, divideGrantRemark); gerr != nil {
  499. return gerr
  500. }
  501. cur.DivideGranted = true
  502. out.DividesGranted++
  503. }
  504. out.Status = model.CoursesRecordStatusFinished
  505. out.StudentNumber = int32(len(resolved))
  506. out.Income = income
  507. out.TeacherReward = totalReward
  508. out.ActualEndTime = endTime
  509. out.Students = toFinishedStudents(resolved)
  510. return nil
  511. })
  512. if err != nil {
  513. return nil, err
  514. }
  515. return out, nil
  516. }
  517. // fillFinishedResult 幂等分支:按已落库数据回读结课结果(Repeated=true)。
  518. //
  519. // 入参:txCtx;rec 已结束的实例;out 结果载体。
  520. // 出参:nil(成功)或 DB 错误。**不写任何表**。
  521. func (s *RecordService) fillFinishedResult(txCtx context.Context, rec *model.CoursesRecord, out *FinishRecordResult) error {
  522. rows, err := s.records.ListRecordStudents(txCtx, rec.ID)
  523. if err != nil {
  524. return err
  525. }
  526. income, err := sumRecordStudentCost(rows)
  527. if err != nil {
  528. return err
  529. }
  530. reward, err := parseAmount(rec.TeacherReward)
  531. if err != nil {
  532. return err
  533. }
  534. out.Repeated = true
  535. out.Status = model.CoursesRecordStatusFinished
  536. out.StudentNumber = int32(len(rows))
  537. out.Income = income
  538. out.TeacherReward = reward
  539. if rec.ActualEndTime != nil {
  540. out.ActualEndTime = *rec.ActualEndTime
  541. }
  542. out.Students = make([]FinishedStudent, 0, len(rows))
  543. for i := range rows {
  544. row := &rows[i]
  545. cost, cerr := parseAmount(row.Cost)
  546. if cerr != nil {
  547. return cerr
  548. }
  549. giveCost, cerr := parseAmount(row.GiveCost)
  550. if cerr != nil {
  551. return cerr
  552. }
  553. rowReward, cerr := parseAmount(row.TeacherReward)
  554. if cerr != nil {
  555. return cerr
  556. }
  557. out.Students = append(out.Students, FinishedStudent{
  558. StudentID: row.StudentID,
  559. Type: row.Type,
  560. Cost: cost,
  561. GiveCost: giveCost,
  562. TeacherReward: rowReward,
  563. Paid: row.Pay == model.CoursesRecordStudentPayPaid,
  564. })
  565. }
  566. return nil
  567. }
  568. // resolvedAttendance 结课用的单学生解析结果(含扣课时/分账所需的锚点信息)。
  569. //
  570. // 字段:StudentID;Type;Pay;Cost;GiveCost;Remark;StudentCourseID(0 = 未报课);
  571. // RecommenderID(报课记录上的推荐人 ULID,空表示无推荐关系)。
  572. type resolvedAttendance struct {
  573. StudentID string
  574. Type int32
  575. Pay int8
  576. Cost money.Amount
  577. GiveCost money.Amount
  578. TeacherReward money.Amount
  579. Remark *string
  580. StudentCourseID int64
  581. RecommenderID string
  582. DivideGranted bool
  583. }
  584. // resolveAttendance 解析结课名单(显式打卡优先;为空则按在读学生全勤)。
  585. //
  586. // 入参:txCtx;rec 上课实例;items 显式打卡明细(可为空)。
  587. // 出参:[]resolvedAttendance(顺序:显式名单按入参序;全勤名单按报课记录 id 升序);3001/2002/5002。
  588. //
  589. // 说明:
  590. // - 未报课的学生(插班/试听)允许存在于打卡明细,但**不扣课时**(StudentCourseID=0);
  591. // - Cost 缺省(nil)时取报课记录的 lesson_cost 快照(模型红线:扣课时不得改 lesson_cost)。
  592. func (s *RecordService) resolveAttendance(txCtx context.Context, rec *model.CoursesRecord, items []AttendanceItem) ([]resolvedAttendance, error) {
  593. if len(items) == 0 {
  594. students, err := s.courses.ListActiveStudentsByCourses(txCtx, rec.CoursesID)
  595. if err != nil {
  596. return nil, err
  597. }
  598. if len(students) == 0 {
  599. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  600. errString("service: 该课程没有在读学生,且未提供打卡名单"))
  601. }
  602. out := make([]resolvedAttendance, 0, len(students))
  603. for i := range students {
  604. stu := &students[i]
  605. lessonCost, perr := parseAmount(stu.LessonCost)
  606. if perr != nil {
  607. return nil, perr
  608. }
  609. out = append(out, resolvedAttendance{
  610. StudentID: stu.StudentID,
  611. Type: model.CoursesRecordStudentTypeNormal,
  612. Pay: model.CoursesRecordStudentPayPaid,
  613. Cost: lessonCost,
  614. GiveCost: 0,
  615. StudentCourseID: stu.ID,
  616. RecommenderID: strValue(stu.RecommenderUserID),
  617. })
  618. }
  619. return out, nil
  620. }
  621. out := make([]resolvedAttendance, 0, len(items))
  622. for i := range items {
  623. item := &items[i]
  624. stuCourse, err := s.courses.GetStudentCourse(txCtx, rec.CoursesID, item.StudentID)
  625. hasCourse := true
  626. if err != nil {
  627. if !errors.Is(err, infraerrors.ErrNotFound) {
  628. return nil, err
  629. }
  630. hasCourse = false
  631. }
  632. cur := resolvedAttendance{
  633. StudentID: item.StudentID,
  634. Type: item.Type,
  635. Remark: normalizeOptString(item.Remark),
  636. }
  637. if cur.Type == 0 {
  638. cur.Type = model.CoursesRecordStudentTypeNormal
  639. }
  640. cur.Pay = model.CoursesRecordStudentPayUnpaid
  641. if item.Pay != nil {
  642. cur.Pay = *item.Pay
  643. } else if hasCourse {
  644. cur.Pay = model.CoursesRecordStudentPayPaid
  645. }
  646. if hasCourse {
  647. cur.StudentCourseID = stuCourse.ID
  648. cur.RecommenderID = strValue(stuCourse.RecommenderUserID)
  649. lessonCost, perr := parseAmount(stuCourse.LessonCost)
  650. if perr != nil {
  651. return nil, perr
  652. }
  653. cur.Cost = lessonCost
  654. }
  655. if item.Cost != nil {
  656. cur.Cost = *item.Cost
  657. }
  658. if item.GiveCost != nil {
  659. cur.GiveCost = *item.GiveCost
  660. }
  661. out = append(out, cur)
  662. }
  663. return out, nil
  664. }
  665. // upsertRecordStudent 写入/覆盖一条打卡明细(按 record+student 幂等)。
  666. //
  667. // 入参:txCtx;tx;rec 实例;studentID;typ;pay;cost;giveCost;reward;endTime(nil=未完成);remark。
  668. // 出参:nil 或 2001/5002。
  669. //
  670. // 幂等:同一 (record_id, student_id) 已存在 → 覆盖(重复打卡/重算不产生第二行)。
  671. // 单号(biz_no)只在首次插入时生成(模型红线:biz_no 不可变)。
  672. func (s *RecordService) upsertRecordStudent(txCtx context.Context, tx *gorm.DB, rec *model.CoursesRecord,
  673. studentID string, typ int32, pay int8, cost, giveCost, reward money.Amount, endTime *time.Time, remark *string) error {
  674. existing, err := s.records.GetRecordStudent(txCtx, rec.ID, studentID)
  675. if err != nil && !errors.Is(err, infraerrors.ErrNotFound) {
  676. return err
  677. }
  678. if err == nil {
  679. _, uerr := s.records.UpdateRecordStudent(txCtx, tx, existing.ID, repository.RecordStudentPatch{
  680. Type: typ,
  681. Pay: pay,
  682. Cost: cost,
  683. GiveCost: giveCost,
  684. TeacherReward: reward,
  685. EndTime: endTime,
  686. Remark: normalizeOptString(remark),
  687. })
  688. return uerr
  689. }
  690. bizNo, berr := newBizNo()
  691. if berr != nil {
  692. return berr
  693. }
  694. startTime := s.now()
  695. if rec.StartTime != nil {
  696. startTime = *rec.StartTime
  697. }
  698. row := &model.CoursesRecordStudent{
  699. BizNo: bizNo,
  700. CoursesID: rec.CoursesID,
  701. ClassID: rec.ClassID,
  702. InstitutionID: rec.InstitutionID,
  703. CampusID: rec.CampusID,
  704. RecordID: rec.ID,
  705. StudentID: studentID,
  706. Type: typ,
  707. StartTime: startTime,
  708. EndTime: endTime,
  709. Cost: cost.String(),
  710. GiveCost: giveCost.String(),
  711. TeacherReward: reward.String(),
  712. Pay: pay,
  713. Remark: normalizeOptString(remark),
  714. }
  715. return s.records.CreateRecordStudent(txCtx, tx, row)
  716. }
  717. // quoteReward 课酬试算(未注入 quoter → 0.00;未配置方案 3112 → 0.00 且不阻断结课)。
  718. //
  719. // 入参:txCtx;rec 实例;at 上课时间(方案 effect_date 判定);income 本次课收入(比例提成基数);
  720. // studentCount 上课人数(计算守卫 + 固定课酬查表);rewardType 结算方式(1 正常 / 2 固定课酬)。
  721. // 出参:单节课课酬(money.Amount);非 3112 的错误原样上抛(整体回滚)。
  722. func (s *RecordService) quoteReward(txCtx context.Context, rec *model.CoursesRecord, at time.Time,
  723. income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
  724. if s.quoter == nil {
  725. return 0, nil
  726. }
  727. course, err := s.courses.GetCourseByID(txCtx, rec.CoursesID)
  728. if err != nil {
  729. return 0, err
  730. }
  731. classType := int32(0)
  732. if course.ClassifyID > 0 {
  733. classify, cerr := s.courses.GetClassifyByID(txCtx, course.ClassifyID)
  734. if cerr == nil {
  735. classType = classify.ClassType
  736. } else if !errors.Is(cerr, infraerrors.ErrNotFound) {
  737. return 0, cerr
  738. }
  739. }
  740. in := RewardQuoteInput{
  741. TeacherID: strValue(rec.TeacherID),
  742. InstitutionID: rec.InstitutionID,
  743. CampusID: rec.CampusID,
  744. ClassifyID: course.ClassifyID,
  745. ClassType: classType,
  746. RecordDate: at,
  747. Income: income,
  748. // TeacherExp:AC-11 未接教师档案(`t_teacher_information.teacher_exp` 是"起算日",
  749. // 年数要按试算日期算差值)⇒ 本批固定 0。⚠ **0 会漏掉 `teacher_exp_min > 0` 的档位**,
  750. // 属已知口径缺口(已在交付报告登记),不是"静默正确"。
  751. TeacherExp: 0,
  752. StudentCount: studentCount,
  753. RewardType: rewardType,
  754. }
  755. reward, qerr := s.quoter.QuoteRecordReward(txCtx, in)
  756. if qerr != nil {
  757. if errors.Is(qerr, apperr.ErrPayrollPlanMissing) {
  758. // 无方案:课酬记 0.00(结课是考勤事实,不能因课酬未配置而回滚)
  759. return 0, nil
  760. }
  761. return 0, qerr
  762. }
  763. if reward.IsNegative() {
  764. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  765. errString("service: 课酬试算返回负值(数据异常)"))
  766. }
  767. return reward, nil
  768. }
  769. // normalizeAttendanceItems 打卡明细入参归一 + 校验(去重、空行过滤、负金额拒绝)。
  770. //
  771. // 入参:items 原始明细;allowEmptyCost true 表示 Cost 可缺省(结课走报课快照)。
  772. // 出参:[]AttendanceItem(保持入参顺序);2001/2002。
  773. func normalizeAttendanceItems(items []AttendanceItem, allowEmptyCost bool) ([]AttendanceItem, error) {
  774. out := make([]AttendanceItem, 0, len(items))
  775. seen := make(map[string]struct{}, len(items))
  776. for i := range items {
  777. item := items[i]
  778. item.StudentID = strings.TrimSpace(item.StudentID)
  779. if item.StudentID == "" {
  780. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 打卡明细缺少 studentId"))
  781. }
  782. if _, dup := seen[item.StudentID]; dup {
  783. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  784. errString("service: 打卡明细中同一学生出现多次 "+item.StudentID))
  785. }
  786. seen[item.StudentID] = struct{}{}
  787. if item.Type == 0 {
  788. item.Type = model.CoursesRecordStudentTypeNormal
  789. }
  790. switch item.Type {
  791. case model.CoursesRecordStudentTypeNormal, model.CoursesRecordStudentTypeTransfer, model.CoursesRecordStudentTypeGive:
  792. // 合法枚举:1 正常 / 2 插班 / 3 赠送
  793. default:
  794. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  795. errString("service: 非法打卡类型 "+strconv.FormatInt(int64(item.Type), 10)))
  796. }
  797. if item.Cost != nil {
  798. if item.Cost.IsNegative() {
  799. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 打卡课费不能为负"))
  800. }
  801. if err := item.Cost.Validate(); err != nil {
  802. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  803. }
  804. } else if !allowEmptyCost {
  805. zero := money.Amount(0)
  806. item.Cost = &zero
  807. }
  808. if item.GiveCost != nil {
  809. if item.GiveCost.IsNegative() {
  810. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 赠送金额不能为负"))
  811. }
  812. if err := item.GiveCost.Validate(); err != nil {
  813. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  814. }
  815. }
  816. out = append(out, item)
  817. }
  818. return out, nil
  819. }
  820. // sumRecordStudentCost 汇总打卡明细的课费(校验收款恒等式用)。
  821. //
  822. // 入参:rows 打卡明细。出参:Σcost;金额列被写坏 → ErrDBError(不静默归零)。
  823. func sumRecordStudentCost(rows []model.CoursesRecordStudent) (money.Amount, error) {
  824. total := money.Amount(0)
  825. for i := range rows {
  826. amt, err := parseAmount(rows[i].Cost)
  827. if err != nil {
  828. return 0, err
  829. }
  830. total = total.Add(amt)
  831. }
  832. return total, nil
  833. }
  834. // applyTeacherRewards 把单节课课酬分发到各打卡行(**按 PHP 口径**,`CoursesRecordHelper.php:806-818`)。
  835. //
  836. // 入参:rows 结课名单(会被就地改写 `TeacherReward`);total 单节课课酬合计。
  837. // 出参:无(就地写)。
  838. //
  839. // PHP 原文(逐行照抄):
  840. //
  841. // $stuCount = CoursesRecordStudent::find()->where(['recordid'=>$recordid,'type'=>[1,2]])->count();
  842. // if($stuCount){ $reward = round(($teacherReward/$stuCount),3);
  843. // CoursesRecordStudent::updateAll(['teacher_reward'=>$reward],
  844. // ['recordid'=>$recordid,'type'=>[1,2]]); }
  845. //
  846. // 三条照抄要点(**任一写错都会让账与旧系统分叉**):
  847. // 1. **除数是 `count(type∈{1,2})`**,不是"全部打卡行数"——赠送生(type=3)不参与分摊;
  848. // 2. **只写 `type∈{1,2}` 的行**,赠送行的 `teacher_reward` **保持 0**;
  849. // 3. `stuCount == 0`(全班赠送)时 PHP 的 `if($stuCount)` **不进入** ⇒ 一行都不写:
  850. // 此时 record 上仍写课酬(考勤事实),学生行全 0 —— **这不是 bug,是旧系统语义**。
  851. //
  852. // ⚠ 恒等式因此是**条件式**(AC-17 一致性检查必须照此写,勿写成无条件):
  853. //
  854. // stuCount > 0 ⇒ Σ(teacher_reward WHERE type∈{1,2}) == record.teacher_reward
  855. // stuCount == 0 ⇒ 所有行 teacher_reward == 0(**不要求** Σ == record)
  856. //
  857. // 为什么不能无条件:无条件断言在"全赠送班"上恒红,维护者最后一定会把它改成"跳过",
  858. // 那等于把这条防线删掉(与 AC-14 断言②"不得加过滤"同一条纪律)。
  859. func applyTeacherRewards(rows []resolvedAttendance, total money.Amount) {
  860. eligible := 0
  861. for i := range rows {
  862. if isRewardEligibleType(rows[i].Type) {
  863. eligible++
  864. }
  865. }
  866. if eligible == 0 {
  867. // 全班赠送:PHP 不进入 `if($stuCount)` ⇒ 所有行课酬保持 0
  868. for i := range rows {
  869. rows[i].TeacherReward = 0
  870. }
  871. return
  872. }
  873. shares := distributeAmount(total, eligible)
  874. next := 0
  875. for i := range rows {
  876. if !isRewardEligibleType(rows[i].Type) {
  877. rows[i].TeacherReward = 0 // 赠送行恒 0(即便入参给了 reward 也不写)
  878. continue
  879. }
  880. rows[i].TeacherReward = shares[next]
  881. next++
  882. }
  883. }
  884. // isRewardEligibleType 判断该打卡类型是否参与课酬分摊(PHP `type IN (1,2)`:1 正常 / 2 插班)。
  885. //
  886. // 入参:typ 打卡类型。出参:true = 参与分摊。
  887. func isRewardEligibleType(typ int32) bool {
  888. switch typ {
  889. case model.CoursesRecordStudentTypeNormal, model.CoursesRecordStudentTypeTransfer:
  890. return true
  891. default:
  892. return false // type=3 赠送(以及任何未知类型):不分摊课酬
  893. }
  894. }
  895. // distributeAmount 把总额按"参与分摊的行数"均分(整数分运算,余数全部给第一行)。
  896. //
  897. // 入参:total 总额(分);n 参与分摊的行数(<=0 → 返回空切片)。
  898. // 出参:[]money.Amount(长度 n;Σ == total)。
  899. //
  900. // 调用约定:`n` **必须**是 `count(type∈{1,2})`(见 `applyTeacherRewards`),**不是**全部行数。
  901. // 余数入首行避免金额丢失(分位不可整除时)。
  902. func distributeAmount(total money.Amount, n int) []money.Amount {
  903. out := make([]money.Amount, 0, n)
  904. if n <= 0 {
  905. return out
  906. }
  907. if total.IsZero() {
  908. for i := 0; i < n; i++ {
  909. out = append(out, 0)
  910. }
  911. return out
  912. }
  913. per := money.FromCents(total.Cents() / int64(n))
  914. remainder := total.Cents() - per.Cents()*int64(n)
  915. for i := 0; i < n; i++ {
  916. out = append(out, per)
  917. }
  918. out[0] = money.FromCents(per.Cents() + remainder)
  919. return out
  920. }
  921. // strValue 取字符串指针的值(nil → 空串)。
  922. //
  923. // 入参:s *string。出参:去空白后的字符串(nil 或空 → "")。
  924. func strValue(s *string) string {
  925. if s == nil {
  926. return ""
  927. }
  928. return strings.TrimSpace(*s)
  929. }
  930. // toFinishedStudents 把内部解析结果转为对外回显结构。
  931. //
  932. // 入参:resolved 结课解析结果。出参:[]FinishedStudent(同序;空入参 → 非 nil 空切片)。
  933. func toFinishedStudents(resolved []resolvedAttendance) []FinishedStudent {
  934. out := make([]FinishedStudent, 0, len(resolved))
  935. for i := range resolved {
  936. cur := &resolved[i]
  937. out = append(out, FinishedStudent{
  938. StudentID: cur.StudentID,
  939. Type: cur.Type,
  940. Cost: cur.Cost,
  941. GiveCost: cur.GiveCost,
  942. TeacherReward: cur.TeacherReward,
  943. Paid: cur.Pay == model.CoursesRecordStudentPayPaid,
  944. StudentCourseID: cur.StudentCourseID,
  945. DivideGranted: cur.DivideGranted,
  946. })
  947. }
  948. return out
  949. }