course_query_service.go 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333
  1. // 课程域**补充服务**(端点 44~57 的读装配 / 报名写入 / 结课响应装配)——AC-13c 追加。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/
  4. // §3.4 C 表(44~57 字段级定义)/ §3.4 总原则 ③「字段准入条件」/ §3.8-E4(`unpaid` 废弃列);
  5. // 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §4.4(端点清单)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 本文件与 AC-11 的边界(**只新增,不改既有逻辑**)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // AC-11 交付的 `course_service.go` / `record_service.go` / `maintenance_service.go` **一行未改**。
  12. // 契约要求的下列能力在 AC-11 没有对应方法,故在此**新增**:
  13. //
  14. // 端点 44 ListCourseRows teacherIds(t_courses_class)+ recordCount(t_courses_record)
  15. // 端点 46 ListClassRows week 原值下发(**不改写成 1~7 数字**,理由见 ClassRow.Week)
  16. // 端点 47 AssertNoClassConflict 冲突 → 3122(AC-11 的 CreateClass 无冲突判定)
  17. // 端点 53 ListStudentRows 报课学生分页(AC-11 只有"取全部在读")
  18. // 端点 54 Enroll 报课写入(AC-11 无报课入口)
  19. // 端点 55 ListArrearsRows 跨课程欠费分页(AC-11 只有"按单学生取候选")
  20. // 端点 57 ListScheduleRows 学生课表(record ⨝ courses_student)
  21. // 端点 51 RecordViewService 结课响应装配(divide[] / payroll 溯源列,**只读、不进结账事务**)
  22. //
  23. // ⚠ 这些是"补 handler 所需的数据能力",不是"修 AC-11 的 bug":AC-11 的服务方法本身
  24. // 在其声明范围内行为正确(已由 `course_service_test.go` / `record_service_test.go` 锁定)。
  25. //
  26. // ---------------------------------------------------------------------------
  27. // 三条量纲/语义红线(本文件逐条落地)
  28. // ---------------------------------------------------------------------------
  29. //
  30. // 1. 金额一律 `money.Amount` 或两位小数字符串,**不出现 float64**;
  31. // 2. 欠费判据**只有** `remainder_receivable`(`unpaid` 是 2018-05 停更的僵尸列,禁读禁判);
  32. // 3. 时间窗 `from` 左闭 / `to` 右开(契约 §1.3)。
  33. package service
  34. import (
  35. "context"
  36. "strconv"
  37. "strings"
  38. "time"
  39. "gorm.io/gorm"
  40. "yuansiwei/unified-backend/internal/academic/apperr"
  41. "yuansiwei/unified-backend/internal/academic/model"
  42. "yuansiwei/unified-backend/internal/academic/money"
  43. "yuansiwei/unified-backend/internal/academic/repository"
  44. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  45. appscope "yuansiwei/unified-backend/internal/infra/scope"
  46. )
  47. // ---------------------------------------------------------------------------
  48. // 端点 44:课程列表(含 teacherIds / recordCount)
  49. // ---------------------------------------------------------------------------
  50. // CourseRow 端点 44 的行(契约 §3.4 C 行 44:`id/institutionId/name/classify/status/teacherIds/recordCount`)。
  51. //
  52. // 字段来源(**逐字段列溯源**,契约 §3.4 ③):
  53. //
  54. // ID ← t_courses.id
  55. // InstitutionID ← t_courses.institution_id
  56. // Name ← t_courses.name
  57. // ClassifyID ← t_courses.classify_id(契约名 `classify`)
  58. // Status ← t_courses.status
  59. // TeacherIDs ← t_courses_class.teacher_id(去重、升序;空 → [])
  60. // RecordCount ← COUNT(t_courses_record)(全状态,见 repository.CountRecordsByCourses 注释)
  61. type CourseRow struct {
  62. // ID 课程 ID。
  63. ID int64
  64. // InstitutionID 机构 ID(作用域列)。
  65. InstitutionID int64
  66. // Name 课程名称。
  67. Name string
  68. // ClassifyID 课程分类 ID(契约 JSON 名 `classify`)。
  69. ClassifyID int64
  70. // Status 课程状态(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
  71. Status int32
  72. // TeacherIDs 该课程排班规则涉及的老师(去重升序;无规则 → 空切片)。
  73. TeacherIDs []string
  74. // RecordCount 该课程的排班实例条数(全状态)。
  75. RecordCount int64
  76. }
  77. // CourseListQuery 端点 44 的查询入参(**Handler 只依赖本层类型**,不直接看到 repository 类型)。
  78. //
  79. // 字段:ClassifyID 分类过滤(<=0 不筛);Status 状态过滤(nil 不筛);Keyword 名称模糊;
  80. // Page/PageSize 分页(服务层钳制,上限 200)。
  81. type CourseListQuery struct {
  82. // ClassifyID 课程分类 ID(<=0 不筛)。
  83. ClassifyID int64
  84. // Status 课程状态(nil 不筛;0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
  85. Status *int32
  86. // Keyword 课程名称模糊匹配(空不筛)。
  87. Keyword string
  88. // Page 页码(1 基;<1 → 1)。
  89. Page int
  90. // PageSize 每页条数(<1 → 10;>200 → 200)。
  91. PageSize int
  92. }
  93. // ListCourseRows 分页查询课程列表并补齐 `teacherIds` / `recordCount`(端点 44)。
  94. //
  95. // 入参:ctx;q 查询入参。
  96. // 出参:([]CourseRow, total, error);空结果为**非 nil 空切片**;5002。
  97. //
  98. // 两次批量查询而不是逐行查(一页最多 200 门课程,逐行 = 400 次往返)。
  99. func (s *CourseService) ListCourseRows(ctx context.Context, q CourseListQuery) ([]CourseRow, int64, error) {
  100. courses, total, err := s.courses.ListCourses(ctx, repository.CourseFilter{
  101. ClassifyID: q.ClassifyID, Status: q.Status, Keyword: q.Keyword,
  102. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  103. if err != nil {
  104. return nil, 0, err
  105. }
  106. rows := make([]CourseRow, 0, len(courses))
  107. if len(courses) == 0 {
  108. return rows, total, nil
  109. }
  110. ids := make([]int64, 0, len(courses))
  111. for i := range courses {
  112. ids = append(ids, courses[i].ID)
  113. }
  114. teachers, err := s.courses.ListTeacherIDsByCourses(ctx, ids)
  115. if err != nil {
  116. return nil, 0, err
  117. }
  118. counts, err := s.courses.CountRecordsByCourses(ctx, ids)
  119. if err != nil {
  120. return nil, 0, err
  121. }
  122. for i := range courses {
  123. c := &courses[i]
  124. tids := teachers[c.ID]
  125. if tids == nil {
  126. tids = make([]string, 0)
  127. }
  128. rows = append(rows, CourseRow{
  129. ID: c.ID,
  130. InstitutionID: c.InstitutionID,
  131. Name: c.Name,
  132. ClassifyID: c.ClassifyID,
  133. Status: c.Status,
  134. TeacherIDs: tids,
  135. RecordCount: counts[c.ID],
  136. })
  137. }
  138. return rows, total, nil
  139. }
  140. // ---------------------------------------------------------------------------
  141. // 端点 46 / 47:排班规则(t_courses_class)
  142. // ---------------------------------------------------------------------------
  143. // ClassRow 端点 46 的行(契约 §3.4 C 行 46)。
  144. //
  145. // 字段来源:
  146. //
  147. // ClassID ← t_courses_class.class_id(**不是主键 id**:契约名是 `classId`,且旧库该列是外部班级 id)
  148. // CourseID ← t_courses_class.courses_id
  149. // TeacherID ← t_courses_class.teacher_id(NULL → 空串,契约 §1.5 用户 ID 是字符串)
  150. // Week ← t_courses_class.week(**原值字符串**)
  151. // Start ← t_courses_class.start_time(时分语义,1970-01-01 + 时分)
  152. // End ← t_courses_class.end_time(同上;可空)
  153. // CampusID ← t_courses_class.campus_id
  154. //
  155. // ⚠ **两处契约冲突(已登记,本实现按"列口径"落地,报告见交付说明)**:
  156. //
  157. // 1. 契约写 `week:number`「周日序(1~7)」。该列实际是**字符串**,取值 `"-1"`(全部周)/
  158. // `"0"`..`"6"` / 逗号多值(`"1,3,5"`)。数字 1~7 **表达不了**「全部周」与「多值」——
  159. // 按契约发 number 会让最常见的 `-1` 与任意多值规则**静默失真**(前端把"每天"显示成某一天)。
  160. // 因此下发**原值字符串**;若前端确需数字,须先在契约里定义 `-1`/多值如何编码。
  161. // 2. 契约写 `room:string`。**`t_courses_class` 没有 room 列**(只有 `address` / `remark`)。
  162. // 按 §3.4 ③「找不到列的一律不写」⇒ **不下发 `room`**,也**不用 `address` 顶替**
  163. // (地址 ≠ 教室,顶替会让前端把"上课地点"当"教室号"展示)。
  164. type ClassRow struct {
  165. // ClassID 班级 ID(`t_courses_class.class_id`)。
  166. ClassID int64
  167. // CourseID 课程 ID。
  168. CourseID int64
  169. // TeacherID 老师 ULID(NULL → 空串)。
  170. TeacherID string
  171. // Week 周规则原值("-1" 全部周 / "0" 周日 / "1".."6" / 逗号多值)。
  172. Week string
  173. // Start 上课时分(date 部分无业务含义,恒 1970-01-01)。
  174. Start *time.Time
  175. // End 下课时分(可空 = 未配置)。
  176. End *time.Time
  177. // CampusID 校区 ID(0 = 机构级)。
  178. CampusID int64
  179. }
  180. // ListClassRows 取某课程的全部排班规则(端点 46,**不分页**——见 Handler 的分页说明)。
  181. //
  182. // 入参:ctx;coursesID(<=0 → 2001)。
  183. // 出参:[]ClassRow(非 nil);3001(课程不存在/不在作用域)/5002。
  184. func (s *CourseService) ListClassRows(ctx context.Context, coursesID int64) ([]ClassRow, error) {
  185. classes, err := s.ListClasses(ctx, coursesID)
  186. if err != nil {
  187. return nil, err
  188. }
  189. rows := make([]ClassRow, 0, len(classes))
  190. for i := range classes {
  191. c := &classes[i]
  192. rows = append(rows, ClassRow{
  193. ClassID: c.ClassID,
  194. CourseID: c.CoursesID,
  195. TeacherID: strValue(c.TeacherID),
  196. Week: c.Week,
  197. Start: c.StartTime,
  198. End: c.EndTime,
  199. CampusID: c.CampusID,
  200. })
  201. }
  202. return rows, nil
  203. }
  204. // classSlot 排班时间槽(冲突判定的比较单位)。
  205. //
  206. // 字段:teacher 老师 ULID;campusID 校区;week 周规则;startMin/endMin 当日起止分钟数。
  207. type classSlot struct {
  208. teacher string
  209. campusID int64
  210. week weekRule
  211. startMin int
  212. endMin int
  213. }
  214. // AssertNoClassConflict 新建排班规则前的冲突判定(端点 47,冲突 → **3122**)。
  215. //
  216. // - 入参:ctx;in 与 `CreateClass` 同形状的入参(只读用:coursesID/teacherID/week/start/end)。
  217. // - 出参:nil 无冲突;apperr.ErrScheduleConflict(3122) 冲突;2002 时分格式非法;5002。
  218. //
  219. // 校区来源(**不取入参**):`CreateClassInput` 没有 `CampusID` 字段——新建规则的校区恒取
  220. // **作用域校区**(`repository.GetScopedID`)。因此冲突判定也必须以作用域校区为准,
  221. // 否则一条"给校区 10 排课"的请求会按"校区 0"去比、把真冲突漏掉。
  222. // 超管(作用域校区 = 0,可跨校区排课)**不做校区收敛**:保守地在全部校区里查同一老师的冲突
  223. // (宁可报一次 3122 让人确认,也不放过"同一老师同一时刻在两个校区"这种多半是误操作的情形)。
  224. func (s *CourseService) AssertNoClassConflict(ctx context.Context, in CreateClassInput) error {
  225. teacher := strings.TrimSpace(in.TeacherID)
  226. if teacher == "" {
  227. return nil
  228. }
  229. _, scopeCampusID := s.courses.GetScopedID()
  230. target, err := buildClassSlot(teacher, scopeCampusID, in.Week, in.StartTime, in.EndTime)
  231. if err != nil {
  232. return err
  233. }
  234. existing, err := s.courses.ListClassesByCourses(ctx, in.CoursesID, false)
  235. if err != nil {
  236. return err
  237. }
  238. for i := range existing {
  239. cur := &existing[i]
  240. if strValue(cur.TeacherID) != teacher {
  241. continue
  242. }
  243. slot, serr := buildClassSlot(teacher, cur.CampusID, cur.Week, hmString(cur.StartTime), hmString(cur.EndTime))
  244. if serr != nil {
  245. // 存量规则的 week/时分写坏:**不阻断新建**(否则一个脏行会让该课程永久无法排班),
  246. // 但也不能静默当作"无冲突"——用 5002 暴露给运维(脏数据必须被看见)。
  247. return serr
  248. }
  249. if slot.campusID != target.campusID && target.campusID != 0 {
  250. continue // 作用域校区 >0:只与本校区比;=0(超管):跨校区全查
  251. }
  252. if !weekRulesIntersect(slot.week, target.week) {
  253. continue
  254. }
  255. if !slotsOverlap(slot, target) {
  256. continue
  257. }
  258. return infraerrors.WithCause(apperr.ErrScheduleConflict,
  259. errString("service: 老师在该时段已有排班规则(classId="+strconv.FormatInt(cur.ClassID, 10)+")"))
  260. }
  261. return nil
  262. }
  263. // buildClassSlot 把一条排班规则(或其入参)转成可比较的时间槽。
  264. //
  265. // 入参:teacher;campusID;week 周规则;startRaw/endRaw 时分("HH:MM",start 必填)。
  266. // 出参:classSlot;2002(week 非法 / 时分格式非法)。
  267. func buildClassSlot(teacher string, campusID int64, week, startRaw, endRaw string) (classSlot, error) {
  268. rule, err := parseWeekRule(week)
  269. if err != nil {
  270. return classSlot{}, err
  271. }
  272. start, err := parseHHMM(startRaw, "上课时间")
  273. if err != nil {
  274. return classSlot{}, err
  275. }
  276. slot := classSlot{
  277. teacher: strings.TrimSpace(teacher),
  278. campusID: campusID,
  279. week: rule,
  280. startMin: start.Hour()*60 + start.Minute(),
  281. }
  282. if strings.TrimSpace(endRaw) != "" {
  283. end, eerr := parseHHMM(endRaw, "下课时间")
  284. if eerr != nil {
  285. return classSlot{}, eerr
  286. }
  287. slot.endMin = end.Hour()*60 + end.Minute()
  288. }
  289. return slot, nil
  290. }
  291. // weekRulesIntersect 判断两条周规则是否有交集(`-1` 表示全部周)。
  292. //
  293. // 入参:a/b weekRule。出参:true 有交集。
  294. func weekRulesIntersect(a, b weekRule) bool {
  295. if a.all || b.all {
  296. return true
  297. }
  298. for day := 0; day < 7; day++ {
  299. if a.days[day] && b.days[day] {
  300. return true
  301. }
  302. }
  303. return false
  304. }
  305. // slotsOverlap 判断两个时间槽是否重叠(`[start,end)` 半开区间;end 缺失按"点"处理)。
  306. //
  307. // 入参:a/b classSlot(调用方已保证同老师、同校区、周规则有交集)。
  308. // 出参:true 重叠。
  309. func slotsOverlap(a, b classSlot) bool {
  310. if a.endMin <= a.startMin && b.endMin <= b.startMin {
  311. return a.startMin == b.startMin // 双方都没有下课时间:只能按"同一时刻"判
  312. }
  313. aEnd := a.endMin
  314. if aEnd <= a.startMin {
  315. aEnd = a.startMin + 1 // 单侧缺失:按 1 分钟占位,避免"缺 end 就永不冲突"
  316. }
  317. bEnd := b.endMin
  318. if bEnd <= b.startMin {
  319. bEnd = b.startMin + 1
  320. }
  321. return a.startMin < bEnd && b.startMin < aEnd
  322. }
  323. // hmString 把时分列转 "HH:MM"(nil → 空串)。
  324. //
  325. // 入参:t 时分列(1970-01-01 + 时分)。出参:`"09:30"` 形态。
  326. func hmString(t *time.Time) string {
  327. if t == nil {
  328. return ""
  329. }
  330. return twoDigits(t.Hour()) + ":" + twoDigits(t.Minute())
  331. }
  332. // twoDigits 两位左补零(避免引入 fmt 开销与格式串分散)。
  333. func twoDigits(v int) string {
  334. if v < 10 {
  335. return "0" + strconv.Itoa(v)
  336. }
  337. return strconv.Itoa(v)
  338. }
  339. // ---------------------------------------------------------------------------
  340. // 端点 53:报课学生列表
  341. // ---------------------------------------------------------------------------
  342. // StudentRow 端点 53 的行(契约 §3.4 C 行 53)。
  343. //
  344. // 字段来源:
  345. //
  346. // UserID ← t_courses_student.student_id
  347. // RealName ← **不下发**(姓名在 identity 域,academic 不持有 t_user;契约 §3.4 C 写
  348. // `realName:string`,但跨域只读须经 port ⇒ 本批留空不下发,登记偏差)
  349. // Pay ← t_courses_student.receivable(应收)
  350. // Cost ← t_courses_student.net_receipts(实收)
  351. // GiveCost ← t_courses_student.give_cost(已用赠送额)
  352. // RemainderReceivable ← t_courses_student.remainder_receivable(剩余应收 = **欠费真值**)
  353. // Status ← t_courses_student.status(1 正常 / 2 结束,数字码)
  354. type StudentRow struct {
  355. // StudentCourseID 报课行 ID(`t_courses_student.id`;契约未列,内部对账用,不下发)。
  356. StudentCourseID int64
  357. // UserID 学生 ULID。
  358. UserID string
  359. // Pay 应收(`receivable`)。
  360. Pay money.Amount
  361. // Cost 实收(`net_receipts`)。
  362. Cost money.Amount
  363. // GiveCost 已用赠送额(`give_cost`)。
  364. GiveCost money.Amount
  365. // RemainderReceivable 剩余应收(`remainder_receivable`,欠费真值)。
  366. RemainderReceivable money.Amount
  367. // Status 报课状态(1 正常 / 2 结束)。
  368. Status int32
  369. }
  370. // StudentListQuery 端点 53 的查询入参。
  371. //
  372. // 字段:CoursesID 课程(必填 >0);Status 报课状态(nil 不筛);Page/PageSize 分页。
  373. type StudentListQuery struct {
  374. // CoursesID 课程 ID(必填 >0)。
  375. CoursesID int64
  376. // Status 报课状态(nil 不筛;1 正常 / 2 结束)。
  377. Status *int32
  378. // Page 页码(1 基)。
  379. Page int
  380. // PageSize 每页条数(上限 200)。
  381. PageSize int
  382. }
  383. // ListStudentRows 分页查询报课学生(端点 53)。
  384. //
  385. // 入参:ctx;q 查询入参。
  386. // 出参:([]StudentRow, total, error);3001(课程不存在/不在作用域)/5002。
  387. // 空结果返回 `[]`(不是 3001:课程存在但没人报名是正常空态)。
  388. func (s *CourseService) ListStudentRows(ctx context.Context, q StudentListQuery) ([]StudentRow, int64, error) {
  389. coursesID := q.CoursesID
  390. if coursesID <= 0 {
  391. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  392. }
  393. if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil {
  394. return nil, 0, err // 3001
  395. }
  396. var st *int8
  397. if q.Status != nil {
  398. v := int8(*q.Status)
  399. st = &v
  400. }
  401. rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{
  402. CoursesID: coursesID, Status: st,
  403. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  404. if err != nil {
  405. return nil, 0, err
  406. }
  407. out := make([]StudentRow, 0, len(rows))
  408. for i := range rows {
  409. row := &rows[i]
  410. pay, err := parseAmount(row.Receivable)
  411. if err != nil {
  412. return nil, 0, err
  413. }
  414. cost, err := parseAmount(row.NetReceipts)
  415. if err != nil {
  416. return nil, 0, err
  417. }
  418. give, err := parseAmount(row.GiveCost)
  419. if err != nil {
  420. return nil, 0, err
  421. }
  422. remainder, err := parseAmount(row.RemainderReceivable)
  423. if err != nil {
  424. return nil, 0, err
  425. }
  426. out = append(out, StudentRow{
  427. StudentCourseID: row.ID,
  428. UserID: row.StudentID,
  429. Pay: pay,
  430. Cost: cost,
  431. GiveCost: give,
  432. RemainderReceivable: remainder, // ⚠ 欠费列:**只读本列**,不读 unpaid(§3.8-E4)
  433. Status: int32(row.Status),
  434. })
  435. }
  436. return out, total, nil
  437. }
  438. // ---------------------------------------------------------------------------
  439. // 端点 54:报名 / 补报名
  440. // ---------------------------------------------------------------------------
  441. // EnrollInput 报名入参(端点 54)。
  442. //
  443. // ⚠ **契约 §2.4 端点 54 只给了响应字段(`enrolled/courseId/userId`),没有入参形状** ⇒
  444. // 本入参是 AC-13c 自定义的**最小可对账集合**,已在交付报告登记为「契约缺口待补」。
  445. //
  446. // 金额口径(**只做快照,不动任何资金账户**):
  447. //
  448. // net_receipts = Pay(本次实收)
  449. // receivable = Pay + RemainderReceivable(应收恒等式:应收 = 实收 + 剩余应收)
  450. // remainder_receivable= RemainderReceivable(默认为 0 ⇒ 全额缴清)
  451. // remainder_cost = Pay(尚未消耗)
  452. // remainder_number = Number + GiveNumber - 0
  453. // lesson_cost = LessonCost(单节课费快照,扣课时依据)
  454. //
  455. // **为什么不写资金流水**:报课行是"应收/实收快照",真实收款走财务域端点 3(充值)/4(消费),
  456. // 本端点若凭空写流水会产生"没有来源的余额变动",破坏 AC-17 的余额恒等式。
  457. type EnrollInput struct {
  458. // CoursesID 课程 ID(路径 `:id`)。
  459. CoursesID int64
  460. // StudentID 学生 ULID(必填)。
  461. StudentID string
  462. // ClassID 班级 ID(0 = 不指定)。
  463. ClassID int64
  464. // CampusID 校区(0 = 用作用域校区;>0 且与作用域校区不一致 → 1004)。
  465. CampusID int64
  466. // Pay 本次实收(元字符串,必填 ≥0)。
  467. Pay string
  468. // RemainderReceivable 剩余应收(元字符串,可空 → 0)。
  469. RemainderReceivable string
  470. // LessonCost 单节课费(元字符串,必填;扣课时依据)。
  471. LessonCost string
  472. // Number 课次(>=0)。
  473. Number int32
  474. // GiveNumber 赠送课次(>=0,可空 → 0)。
  475. GiveNumber int32
  476. // DiscountCash 现金折扣(元字符串,可空 → 0;**允许负**,模型红线)。
  477. DiscountCash string
  478. // GiveCost 已用赠送额(元字符串,可空 → 0)。
  479. GiveCost string
  480. // RecommenderUserID 推荐人(ULID,可空;分账上行起点)。
  481. RecommenderUserID string
  482. // Remark 备注。
  483. Remark string
  484. // OperatorID 操作人(作用域用户;写 confirm_discount_user_id —— 见 Enroll 注释)。
  485. OperatorID string
  486. }
  487. // Enroll 报名 / 补报名(端点 54)。
  488. //
  489. // 入参:ctx;in 入参。
  490. // 出参:*model.CoursesStudent(含自增 ID);2001/2002(金额/课次非法)/
  491. // 3001(课程不存在或不在作用域)/1004(跨校区)/**3143**(该学生已在本课程在读)/
  492. // 5002(DB)。
  493. //
  494. // 三条与"资金"相关的强制约束:
  495. // 1. **不写流水、不动余额**(见 EnrollInput 注释);
  496. // 2. `confirm_discount_user_id` 写**操作人**(模型红线:该列为空 = 该报课行不可充值/不可核销,
  497. // §3.8-A17 的 `confirm_discount_userid > 0` 判据在目标态即"本列非空")——
  498. // 漏写会让这行**永远进不了欠费核销候选**,且没有任何报错;
  499. // 3. 重复报名(同课程 + 同学生 + status=1 已存在)**拒绝**(3140)而不是静默成功:
  500. // 静默成功会让前端以为建了两行,而实际只有一行(后续扣课时只扣一行,账对不上)。
  501. func (s *CourseService) Enroll(ctx context.Context, in EnrollInput) (*model.CoursesStudent, error) {
  502. studentID := strings.TrimSpace(in.StudentID)
  503. if in.CoursesID <= 0 {
  504. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0"))
  505. }
  506. if studentID == "" {
  507. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 studentId"))
  508. }
  509. if in.Number < 0 || in.GiveNumber < 0 {
  510. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课次不能为负"))
  511. }
  512. pay, err := parseCourseMoney(in.Pay, "实收", false)
  513. if err != nil {
  514. return nil, err
  515. }
  516. if pay.IsZero() && strings.TrimSpace(in.Pay) == "" {
  517. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 pay(本次实收)"))
  518. }
  519. remainder, err := parseCourseMoney(in.RemainderReceivable, "剩余应收", false)
  520. if err != nil {
  521. return nil, err
  522. }
  523. lessonCost, err := parseCourseMoney(in.LessonCost, "单节课费", false)
  524. if err != nil {
  525. return nil, err
  526. }
  527. if strings.TrimSpace(in.LessonCost) == "" {
  528. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 lessonCost(单节课费)"))
  529. }
  530. discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true)
  531. if err != nil {
  532. return nil, err
  533. }
  534. giveCost, err := parseCourseMoney(in.GiveCost, "已用赠送额", false)
  535. if err != nil {
  536. return nil, err
  537. }
  538. institutionID, scopeCampusID := s.courses.GetScopedID()
  539. if institutionID <= 0 {
  540. return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)"))
  541. }
  542. campusID := in.CampusID
  543. if campusID <= 0 {
  544. campusID = scopeCampusID
  545. } else if scopeCampusID > 0 && campusID != scopeCampusID {
  546. // 跨校区报名:作用域校区角色只能给本校区报名(1004,不是 3001——这是**作用域越界**)
  547. return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
  548. errString("service: campusId 与当前作用域校区不一致"))
  549. }
  550. bizNo, berr := newBizNo()
  551. if berr != nil {
  552. return nil, berr
  553. }
  554. row := &model.CoursesStudent{
  555. BizNo: bizNo,
  556. CoursesID: in.CoursesID,
  557. ClassID: in.ClassID,
  558. InstitutionID: institutionID,
  559. CampusID: campusID,
  560. StudentID: studentID,
  561. Type: model.CoursesStudentTypeNormal,
  562. Status: model.CoursesStudentStatusNormal,
  563. Receivable: pay.Add(remainder).String(),
  564. NetReceipts: pay.String(),
  565. Unpaid: "0.00", // ⚠ 废弃列:**有意写 0 且永不重算**(§3.8-E4 目标态不维护)
  566. LessonCost: lessonCost.String(),
  567. Number: in.Number,
  568. FinishedNumber: 0,
  569. DiscountCash: discountCash.String(),
  570. GiveNumber: in.GiveNumber,
  571. GiveCost: giveCost.String(),
  572. RemainderNumber: in.Number + in.GiveNumber,
  573. RemainderCost: pay.String(),
  574. RemainderReceivable: remainder.String(),
  575. BadDebt: "0.00",
  576. Remark: normalizeOptString(&in.Remark),
  577. }
  578. if uid := strings.TrimSpace(in.OperatorID); uid != "" {
  579. row.ConfirmDiscountUserID = &uid
  580. }
  581. if rid := strings.TrimSpace(in.RecommenderUserID); rid != "" {
  582. row.RecommenderUserID = &rid
  583. now := s.now()
  584. row.RecommenderTime = &now
  585. }
  586. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  587. txCtx := repository.WithTxContext(ctx, tx)
  588. if _, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID); cerr != nil {
  589. return cerr // 3001:课程不存在 / 不在作用域
  590. }
  591. existing, cerr := s.courses.CountActiveEnrollments(txCtx, in.CoursesID, studentID)
  592. if cerr != nil {
  593. return cerr
  594. }
  595. if existing > 0 {
  596. return infraerrors.WithCause(apperr.ErrEnrollmentDuplicate,
  597. errString("service: 该学生已在本课程在读报课,请勿重复报名"))
  598. }
  599. return s.courses.CreateCoursesStudent(txCtx, tx, row)
  600. })
  601. if err != nil {
  602. return nil, err
  603. }
  604. return row, nil
  605. }
  606. // ---------------------------------------------------------------------------
  607. // 端点 55:欠费列表
  608. // ---------------------------------------------------------------------------
  609. // ArrearsRow 端点 55 的行(契约 §3.4 C 行 55)。
  610. //
  611. // 字段来源:
  612. //
  613. // StudentID ← t_courses_student.student_id
  614. // CourseID ← t_courses_student.courses_id
  615. // RemainderReceivable ← t_courses_student.remainder_receivable(**唯一欠费判据**)
  616. // Counteracted ← `net_receipts > 0`(该报课行发生过实收/核销)
  617. //
  618. // ⚠ `counteracted` 的语义澄清:契约只写 `counteracted:bool`,未定义判据;表里**没有**
  619. // "已核销"标记列,可追溯到列的最近语义是"该行发生过实收/核销"= `net_receipts > 0`。
  620. // 另一种读法(`remainder_receivable <= 0`)在本列表里**恒 false**(列表本身就是 >0 的行),
  621. // 恒 false 的布尔字段等于没下发,故不采用。已登记偏差待契约确认。
  622. type ArrearsRow struct {
  623. // StudentID 学生 ULID。
  624. StudentID string
  625. // CourseID 课程 ID。
  626. CourseID int64
  627. // RemainderReceivable 剩余应收(> 0)。
  628. RemainderReceivable money.Amount
  629. // Counteracted 该报课行是否发生过实收/核销(`net_receipts > 0`)。
  630. Counteracted bool
  631. }
  632. // ArrearsListQuery 端点 55 的查询入参。
  633. //
  634. // 字段:StudentID 学生过滤(空 = 全部欠费);Page/PageSize 分页。
  635. type ArrearsListQuery struct {
  636. // StudentID 学生 ULID(空 = 不按学生筛)。
  637. StudentID string
  638. // Page 页码(1 基)。
  639. Page int
  640. // PageSize 每页条数(上限 200)。
  641. PageSize int
  642. }
  643. // ListArrearsRows 分页查询欠费报课行(端点 55)。
  644. //
  645. // 入参:ctx;q 查询入参。
  646. // 出参:([]ArrearsRow, total, error);空 → `[]`;5002。
  647. //
  648. // **筛选条件恒为 `remainder_receivable > 0`**(设计 §3.8-E4 / 契约 §2.4 端点 55 注)。
  649. // 绝不能用 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0 ⇒ 用它筛"看起来
  650. // 正常、实际把所有欠费漏掉"(这正是本端点最容易写错、且**没有任何报错**的一处)。
  651. func (s *CourseService) ListArrearsRows(ctx context.Context, q ArrearsListQuery) ([]ArrearsRow, int64, error) {
  652. rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{
  653. StudentID: q.StudentID,
  654. OnlyArrears: true,
  655. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  656. if err != nil {
  657. return nil, 0, err
  658. }
  659. out := make([]ArrearsRow, 0, len(rows))
  660. for i := range rows {
  661. row := &rows[i]
  662. remainder, err := parseAmount(row.RemainderReceivable)
  663. if err != nil {
  664. return nil, 0, err
  665. }
  666. netReceipts, err := parseAmount(row.NetReceipts)
  667. if err != nil {
  668. return nil, 0, err
  669. }
  670. out = append(out, ArrearsRow{
  671. StudentID: row.StudentID,
  672. CourseID: row.CoursesID,
  673. RemainderReceivable: remainder,
  674. Counteracted: netReceipts.GreaterThan(0),
  675. })
  676. }
  677. return out, total, nil
  678. }
  679. // ---------------------------------------------------------------------------
  680. // 端点 57:学生课表
  681. // ---------------------------------------------------------------------------
  682. // ScheduleRow 端点 57 的行(**字段名待移动端契约定稿**——契约 §3.4 C 行 57 原文
  683. // 「字段名由移动端契约定(本轮 PC mock 未实现)」⇒ 本结构是 AC-13c 的**最小列溯源集合**,
  684. // 每个字段都能追到列,已在交付报告登记为「待移动端契约确认」)。
  685. //
  686. // 字段来源:
  687. //
  688. // RecordID ← t_courses_record.id
  689. // CourseID ← t_courses_record.courses_id
  690. // CourseName ← t_courses.name(批量回查)
  691. // TeacherID ← t_courses_record.teacher_id(NULL → 空串)
  692. // CampusID ← t_courses_record.campus_id
  693. // StartTime ← t_courses_record.start_time
  694. // EndTime ← t_courses_record.end_time
  695. // Status ← t_courses_record.status(0 未开始 / 1 上课中 / 2 已结束)
  696. // Cost ← t_courses_record_student.cost(无打卡行 → 0.00)
  697. // GiveCost ← t_courses_record_student.give_cost(无打卡行 → 0.00)
  698. // Pay ← t_courses_record_student.pay(无打卡行 → false)
  699. // Checked ← 是否存在该学生的打卡行(有行即 true,不看 cost)
  700. type ScheduleRow struct {
  701. // RecordID 排班实例 ID。
  702. RecordID int64
  703. // CourseID 课程 ID。
  704. CourseID int64
  705. // CourseName 课程名称(回查 t_courses;查不到 → 空串)。
  706. CourseName string
  707. // TeacherID 老师 ULID(可空 → 空串)。
  708. TeacherID string
  709. // CampusID 校区 ID。
  710. CampusID int64
  711. // StartTime 上课时间。
  712. StartTime *time.Time
  713. // EndTime 下课时间。
  714. EndTime *time.Time
  715. // Status 上课状态(0/1/2)。
  716. Status int32
  717. // Cost 本次课费(该学生的打卡行)。
  718. Cost money.Amount
  719. // GiveCost 本次使用赠送额。
  720. GiveCost money.Amount
  721. // Pay 是否已支付。
  722. Pay bool
  723. // Checked 是否已有该学生的打卡行。
  724. Checked bool
  725. }
  726. // ScheduleQuery 端点 57 的查询入参。
  727. //
  728. // 字段:StudentID 学生(必填);From/To 上课时间窗(`from <= start_time < to`,右开;nil 不限);
  729. // Page/PageSize 分页。
  730. type ScheduleQuery struct {
  731. // StudentID 学生 ULID(必填)。
  732. StudentID string
  733. // From 窗口起点(含;nil 不限)。
  734. From *time.Time
  735. // To 窗口终点(**不含**;nil 不限)。
  736. To *time.Time
  737. // Page 页码(1 基)。
  738. Page int
  739. // PageSize 每页条数(上限 200)。
  740. PageSize int
  741. }
  742. // ListScheduleRows 查询某学生的课表(端点 57)。
  743. //
  744. // 入参:ctx;q 查询入参。
  745. // 出参:([]ScheduleRow, total, error);2001(缺 studentId)/5002。
  746. func (s *CourseService) ListScheduleRows(ctx context.Context, q ScheduleQuery) ([]ScheduleRow, int64, error) {
  747. sid := strings.TrimSpace(q.StudentID)
  748. if sid == "" {
  749. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课表需要 studentId"))
  750. }
  751. records, total, err := s.courses.ListStudentSchedule(ctx, sid, q.From, q.To,
  752. repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  753. if err != nil {
  754. return nil, 0, err
  755. }
  756. out := make([]ScheduleRow, 0, len(records))
  757. if len(records) == 0 {
  758. return out, total, nil
  759. }
  760. recordIDs := make([]int64, 0, len(records))
  761. courseIDs := make([]int64, 0, len(records))
  762. for i := range records {
  763. recordIDs = append(recordIDs, records[i].ID)
  764. courseIDs = append(courseIDs, records[i].CoursesID)
  765. }
  766. courses, err := s.courses.ListCoursesByIDs(ctx, courseIDs)
  767. if err != nil {
  768. return nil, 0, err
  769. }
  770. names := make(map[int64]string, len(courses))
  771. for i := range courses {
  772. names[courses[i].ID] = courses[i].Name
  773. }
  774. checked, err := s.records.ListRecordStudentsOfStudent(ctx, sid, recordIDs)
  775. if err != nil {
  776. return nil, 0, err
  777. }
  778. for i := range records {
  779. rec := &records[i]
  780. row := ScheduleRow{
  781. RecordID: rec.ID,
  782. CourseID: rec.CoursesID,
  783. CourseName: names[rec.CoursesID],
  784. TeacherID: strValue(rec.TeacherID),
  785. CampusID: rec.CampusID,
  786. StartTime: rec.StartTime,
  787. EndTime: rec.EndTime,
  788. Status: rec.Status,
  789. }
  790. if att, ok := checked[rec.ID]; ok {
  791. cost, cerr := parseAmount(att.Cost)
  792. if cerr != nil {
  793. return nil, 0, cerr
  794. }
  795. give, cerr := parseAmount(att.GiveCost)
  796. if cerr != nil {
  797. return nil, 0, cerr
  798. }
  799. row.Checked = true
  800. row.Cost = cost
  801. row.GiveCost = give
  802. row.Pay = att.Pay == model.CoursesRecordStudentPayPaid
  803. }
  804. out = append(out, row)
  805. }
  806. return out, total, nil
  807. }
  808. // ---------------------------------------------------------------------------
  809. // 端点 51:结课响应装配(只读,不进结账事务)
  810. // ---------------------------------------------------------------------------
  811. // DivideGrantRow 单条分账入账明细(端点 51 的 `divide[]`)——**team-lead 裁决后的最终形状**
  812. // `[{userId, type, amount}]`。
  813. //
  814. // 字段来源(**逐字段列溯源**,契约 §3.4 ③):
  815. //
  816. // UserID ← t_user_divide_into_log.user_id(这笔上课的钱分给了谁)
  817. // Type ← t_user_divide_into_log.type(分成类型 1 一次付清 / 2 按课次)
  818. // Amount ← t_user_divide_into_log.money(本次入账金额)
  819. //
  820. // ⚠ **契约原 mock 的 `level` 与 `quota` 均不下发(团队裁决,2026-11-09)**:
  821. //
  822. // `level` —— `t_user_divide_into_log` **没有层级列**;`t_user_divide_into.layer` 是**当前**层级,
  823. // 不是"入账当时"的层级,拿当前值顶替历史值 = 「事后调整树结构 ⇒ 历史记录显示错」,
  824. // 属静默错,比字段缺失更难查。
  825. // `quota` —— 同上:节点 quota 事后可被 `update_quota` 改,日志里没留"当时额度";
  826. // 用当前值顶替即是伪造历史。
  827. //
  828. // 为什么**不是**把整个数组删掉:它承载"这笔钱分给了谁、各多少"这个核心信息;
  829. // `level`/`quota` 只是附加维度,缺了不影响主信息。因此改为**补 `type`(有列)**、删两个无列字段。
  830. // 若产品确需"当时层级/当时额度":需在日志表加列并回填 = 迁移变更,另立需求(本批不做)。
  831. //
  832. // ⚠ 反模式提醒(团队明确禁止):为了让前端 mock 好看而给 `level` 填常量 1。
  833. // "字段有值、永远等于 1、没人能发现它是假的"是本类缺陷里最难查的一种。
  834. type DivideGrantRow struct {
  835. // UserID 获得分成的用户(ULID,源列 `user_id`)。
  836. UserID string
  837. // Type 分成类型(源列 `type`:1 一次付清 / 2 按课次)。
  838. Type int32
  839. // Amount 本次入账金额(源列 `money`)。
  840. Amount money.Amount
  841. }
  842. // PayrollTrace 课酬溯源(端点 51 的 `payroll`)。
  843. //
  844. // 字段来源:
  845. //
  846. // DetailID ← t_course_payment_detail.id(命中的课酬规则;未配置方案 → 0)
  847. // Reward ← t_courses_record.teacher_reward(本课课酬合计,**落库值**,不是重新试算值)
  848. type PayrollTrace struct {
  849. // DetailID 命中的课酬明细规则 ID(0 = 未配置方案 / 无法定位)。
  850. DetailID int64
  851. // Reward 本课课酬合计(= Σ record_student.teacher_reward)。
  852. Reward money.Amount
  853. }
  854. // RecordFinishView 端点 51 的响应(契约 §3.4 C 行 51 的字段全集,去掉无列字段)。
  855. //
  856. // 字段来源:
  857. //
  858. // RecordID ← t_courses_record.id
  859. // CourseID ← t_courses_record.courses_id
  860. // Status ← t_courses_record.status(打完课恒 2)
  861. // Changed ← 由 `Repeated` 取反(幂等命中 ⇒ false,本次未写任何表)
  862. // TotalSpend ← Σ t_courses_record_student.cost == t_courses_record.income
  863. // Divide ← t_user_divide_into_log(按 student+order_no 回读)
  864. // Payroll ← 见 PayrollTrace
  865. // PerStudent ← t_courses_record_student(本次打卡行)
  866. type RecordFinishView struct {
  867. // RecordID 上课记录 ID。
  868. RecordID int64
  869. // CourseID 课程 ID。
  870. CourseID int64
  871. // Status 上课状态(2 = 已结束)。
  872. Status int32
  873. // Changed 本次是否真的写了库(false = 幂等命中,未扣课时/未分账)。
  874. Changed bool
  875. // TotalSpend 本次扣课时合计。
  876. TotalSpend money.Amount
  877. // Divide 本次触发的分账入账明细(空 → [])。
  878. Divide []DivideGrantRow
  879. // Payroll 课酬溯源。
  880. Payroll PayrollTrace
  881. // PerStudent 逐学生打卡明细。
  882. PerStudent []FinishedStudent
  883. }
  884. // RecordViewService 结课响应装配服务(**只读**;不参与结账事务、不写任何表)。
  885. //
  886. // 为什么单独一个服务:`RecordService.FinishRecord` 的返回值只带"结账需要的数据"
  887. // (金额合计、逐学生明细),而契约还要求 `divide[]` 与 `payroll.detailId` 两个**溯源**字段。
  888. // 把它们塞进 FinishRecord(AC-11)会让结账事务多做两次无关查询、并在事务内做展示拼装;
  889. // 因此改为"事务提交后再只读装配"——即使装配失败也不影响已落库的结账结果(装配失败只报 5002)。
  890. type RecordViewService struct {
  891. records *repository.RecordRepo
  892. courses *repository.CourseRepo
  893. divide *repository.DivideRepo
  894. payroll *PayrollService
  895. sc *appscope.Scope
  896. }
  897. // NewRecordViewService 构造结课响应装配服务。
  898. //
  899. // 入参:db(必填);sc 作用域;payroll 课酬试算服务(**可 nil**:nil 时 `payroll.detailId` 恒 0,
  900. // 仍下发落库课酬 `reward`,前端"课酬金额"不受影响)。
  901. // 出参:*RecordViewService。
  902. func NewRecordViewService(db *gorm.DB, sc *appscope.Scope, payroll *PayrollService) *RecordViewService {
  903. return &RecordViewService{
  904. records: repository.NewRecordRepo(db, sc),
  905. courses: repository.NewCourseRepo(db, sc),
  906. divide: repository.NewDivideRepo(db),
  907. payroll: payroll,
  908. sc: sc,
  909. }
  910. }
  911. // FinishView 把 `FinishRecordResult` 装配成契约要求的响应(端点 51)。
  912. //
  913. // 入参:ctx;res 结账结果(非 nil)。
  914. // 出参:*RecordFinishView;5002(回读失败)。**任一溯源信息取不到都不阻断**:
  915. // `divide[]` 为空时给 `[]`、`detailId` 取不到给 0——结账已经提交,展示缺项不该变成失败。
  916. func (s *RecordViewService) FinishView(ctx context.Context, res *FinishRecordResult) (*RecordFinishView, error) {
  917. if res == nil {
  918. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FinishView 需要结课结果"))
  919. }
  920. view := &RecordFinishView{
  921. RecordID: res.RecordID,
  922. Status: res.Status,
  923. Changed: !res.Repeated,
  924. TotalSpend: res.Income,
  925. Divide: make([]DivideGrantRow, 0, 2),
  926. Payroll: PayrollTrace{Reward: res.TeacherReward},
  927. PerStudent: res.Students,
  928. }
  929. rec, err := s.records.GetRecordByID(ctx, res.RecordID)
  930. if err != nil {
  931. return nil, err // 3001/5002:刚结完的课读不到 = 环境异常,按原码上抛
  932. }
  933. view.CourseID = rec.CoursesID
  934. // 分账明细:按 (学生, orderNo=recordId) 回读(与 DivideService.AddLog 的幂等键同源)
  935. for i := range res.Students {
  936. studentID := res.Students[i].StudentID
  937. logs, lerr := s.divide.ListLogsByStudentOrder(ctx, studentID, strconv.FormatInt(res.RecordID, 10))
  938. if lerr != nil {
  939. return nil, lerr
  940. }
  941. for j := range logs {
  942. amount, perr := parseAmount(logs[j].Money)
  943. if perr != nil {
  944. return nil, perr
  945. }
  946. view.Divide = append(view.Divide, DivideGrantRow{
  947. UserID: logs[j].UserID,
  948. Type: int32(logs[j].Type),
  949. Amount: amount,
  950. })
  951. }
  952. }
  953. view.Payroll.DetailID = s.lookupPayrollDetailID(ctx, rec, res.ActualEndTime)
  954. return view, nil
  955. }
  956. // lookupPayrollDetailID 回读本课命中的课酬规则 ID(只为**溯源展示**,不影响落库金额)。
  957. //
  958. // 入参:ctx;rec 上课记录;at 上课时间(方案 effect_date 判定)。
  959. // 出参:命中的 `t_course_payment_detail.id`;未配置方案 / 无老师 / 无分类 / 任何错误 → **0**。
  960. //
  961. // 三条"取不到就返回 0、不冒泡错误"的理由:
  962. // 1. 结账已提交,展示缺项不该让整个请求失败(`payroll.reward` 仍有值);
  963. // 2. 3112(无方案)在结账侧已被显式降级为 0 课酬,溯源侧同理;
  964. // 3. 教龄与结课侧同源(教师档案 → `calculatingAge` 口径),保证"金额档位"与"detailId"永远一致。
  965. func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *model.CoursesRecord, at time.Time) int64 {
  966. if s.payroll == nil || rec == nil {
  967. return 0
  968. }
  969. teacherID := strValue(rec.TeacherID)
  970. if teacherID == "" {
  971. return 0
  972. }
  973. course, err := s.courses.GetCourseByID(ctx, rec.CoursesID)
  974. if err != nil || course.ClassifyID <= 0 {
  975. return 0
  976. }
  977. // ⚠ 教龄必须与结课侧**同一来源、同一口径**(否则"金额取了档 B、detailId 显示档 A"):
  978. quote, qerr := s.payroll.QuoteTeacherReward(ctx, QuotePayrollInput{
  979. TeacherID: teacherID,
  980. TeacherExp: s.payroll.TeacherExpYears(ctx, teacherID, at),
  981. InstitutionID: rec.InstitutionID,
  982. CampusID: rec.CampusID,
  983. ClassifyID: course.ClassifyID,
  984. Date: at,
  985. })
  986. if qerr != nil || quote == nil {
  987. return 0
  988. }
  989. return quote.DetailID
  990. }
  991. // recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)。
  992. //
  993. // ---------------------------------------------------------------------------
  994. // 权威口径:**PHP 应用层**,不是 dump(`min_payment` 在存储过程里零命中)
  995. // ---------------------------------------------------------------------------
  996. //
  997. // 逐行照抄 `E://study//php//ysw-finance//yuansiwei-finance//modules//courses//helpers//CoursesRecordHelper.php:781-821`
  998. // (方法 `settlementExpenses`,正常结课与补签共用):
  999. //
  1000. // $percentage = $coursePaymentDetail->percentage/100; // 百分数 → 比率("85.0" → 0.85)
  1001. // $addedCost = $coursePaymentDetail->reward; // 额外课酬
  1002. // $income = round(sum(cost), 3); // 本课全部学生 cost 合计
  1003. // $teacherReward = 0;
  1004. // if ($income>0 || $attendClassStudentNumber) { // 守卫:有学生即使收入 0 也算
  1005. // $teacherReward = round($income*$percentage,3) + $addedCost; // ① 先算提成、② 再加 reward
  1006. // if ($maxPayment>0 && $teacherReward>$maxPayment) $teacherReward = $maxPayment; // ③ 先夹上限
  1007. // elseif ($minPayment>0 && $teacherReward<$minPayment) $teacherReward = $minPayment; // ④ 再夹下限
  1008. // }
  1009. // if ($teacherRewardType==2) { // 固定课酬:**不看比例**
  1010. // $teacherReward = self::$makeupCost[$stuCount] ?? self::$makeupCost['max'];
  1011. // }
  1012. // if ($attendClassStudentNumber<=0) $teacherReward = 0; // 无人上课 → 课酬 0
  1013. //
  1014. // ⚠ **顺序是「加 reward → 再夹 [min,max]」,不是「夹完再加 reward」**:
  1015. // 当"提成 + reward"超过 `max_payment` 时,结果是 **max(reward 被吃掉)**;
  1016. // 反过来写会得到 `max + reward` = **多发钱**。这条顺序差有真实金额影响,改动前先回 PHP 原文。
  1017. //
  1018. // 另外三条照抄语义:
  1019. // - `max`/`min` 为 **0 = 不设限**(不是"上限为 0");
  1020. // - **`elseif` = 上限优先**:两者同时触发时取 `max`;
  1021. // - `percentage` 是**百分数**:`"85.0"` 表示 85%,不是 0.85,也不是 8500。
  1022. //
  1023. // 实现红线:
  1024. // - **全程整数分运算**,比例用"千分之一"整数承载(`"85.0"` → `850`),`+500 后整除 1000` = 四舍五入到分;
  1025. // - `percentage` 列写坏(非数字/负数/小数位过多)→ **ErrDBError 整体回滚**,不静默按 0 算
  1026. // ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
  1027. //
  1028. // 教龄(v0.1.35 已接上):入参 `TeacherExp` 为 0 时,本适配器**回落到教师档案**
  1029. // (`t_teacher_information.teacher_exp` 起算日 → 年数),换算按 PHP `calculatingAge()` 口径:
  1030. // **固定 365 天/年**、**四舍五入 1 位小数**、空/非法/未来 → 0(见 `payroll_age.go`)。
  1031. // 两者都没有(老师未录教龄)⇒ 0 ⇒ 只命中 `teacher_exp_min = 0` 的档——**这是旧系统既有行为**
  1032. // (PHP 对空日期同样返回 0),不是缺陷;但会造成"少发钱",监控上看 warn 日志与课酬 0 的比例。
  1033. type recordRewardQuoter struct {
  1034. svc *PayrollService
  1035. }
  1036. // QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
  1037. //
  1038. // 入参:ctx;in 端口入参(含 `Income` 比例基数、`TeacherExp` 教龄)。
  1039. // 出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬);列写坏 → 5002 整体回滚。
  1040. func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
  1041. if q.svc == nil {
  1042. return 0, nil
  1043. }
  1044. teacherExp := in.TeacherExp
  1045. if teacherExp == 0 && strings.TrimSpace(in.TeacherID) != "" {
  1046. // 结课链路拿不到教龄(AC-11 端口无此入参的来源)⇒ 回落到教师档案,口径见 `payroll_age.go`。
  1047. // 档案缺失/未录 → 0(旧系统同)。降级不报错:教龄只影响档位,不该把整节课打回。
  1048. teacherExp = q.svc.TeacherExpYears(ctx, in.TeacherID, in.RecordDate)
  1049. }
  1050. quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
  1051. TeacherID: in.TeacherID,
  1052. TeacherExp: teacherExp,
  1053. InstitutionID: in.InstitutionID,
  1054. CampusID: in.CampusID,
  1055. ClassifyID: in.ClassifyID,
  1056. Date: in.RecordDate,
  1057. })
  1058. if err != nil {
  1059. return 0, err
  1060. }
  1061. if quote == nil {
  1062. return 0, nil
  1063. }
  1064. return computeRecordReward(quote, in.Income, in.StudentCount, in.RewardType)
  1065. }
  1066. // computeRecordReward 由"命中的课酬规则 + 本次课收入/人数/结算方式"算出单节课课酬。
  1067. //
  1068. // 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入;
  1069. // studentCount 上课人数;rewardType 结算方式(1 正常 / 2 固定课酬)。
  1070. // 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。
  1071. //
  1072. // 顺序**逐行对齐 PHP**(`CoursesRecordHelper.php:794-821`),见 `recordRewardQuoter` 注释:
  1073. //
  1074. // 守卫 → income×pct + reward → 夹上限 → 否则夹下限 → (type=2 覆盖为固定课酬)→ 无人则 0
  1075. func computeRecordReward(quote *PayrollQuote, income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
  1076. if quote == nil {
  1077. return 0, nil
  1078. }
  1079. if income.IsNegative() {
  1080. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  1081. errString("service: 课酬试算的收入不能为负"))
  1082. }
  1083. total := money.Amount(0)
  1084. // 守卫:`if ($income>0 || $attendClassStudentNumber)` —— 收入 0 但有学生仍要算(0 + reward 再夹)
  1085. if income.GreaterThan(0) || studentCount > 0 {
  1086. base, perr := applyPercentage(quote.Percentage, income)
  1087. if perr != nil {
  1088. return 0, perr
  1089. }
  1090. // ① 先加 reward,② 再夹 —— 顺序不可交换(见类型注释)
  1091. total = base.Add(quote.Reward)
  1092. // ③ 上限优先(PHP 是 elseif:上限命中就不看下限,两者同时触发取 max)
  1093. if quote.Max.GreaterThan(0) && total.GreaterThan(quote.Max) {
  1094. total = quote.Max
  1095. } else if quote.Min.GreaterThan(0) && total.LessThan(quote.Min) {
  1096. total = quote.Min
  1097. }
  1098. }
  1099. // 固定课酬(`teacher_reward_type == 2`):**不看比例**,按人数查 PHP 硬编码表
  1100. if rewardType == model.CoursesRecordRewardTypeNoMinimum && studentCount > 0 {
  1101. total = makeupCostFor(studentCount)
  1102. }
  1103. // 无人上课 ⇒ 不产生课酬(PHP `:821`;即使上面算出 reward 也要归零)
  1104. if studentCount <= 0 {
  1105. total = 0
  1106. }
  1107. if total.IsNegative() {
  1108. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1109. errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)"))
  1110. }
  1111. return total, nil
  1112. }
  1113. // makeupCostTable 固定课酬表(`teacher_reward_type == 2`):人数 → 单节课课酬(元)。
  1114. //
  1115. // **逐字复刻 PHP 魔法常量**(`CoursesRecordHelper.php:33`,注释原文"单独给学生补课上课的课酬,人数=>课酬"):
  1116. //
  1117. // static public $makeupCost = [1=>70,2=>100,3=>120,4=>150,5=>170,6=>200,'max'=>200];
  1118. //
  1119. // ⚠ **这张表在数据库里没有**(没有字典表、没有配置项)⇒ 只能按常量复刻;
  1120. // 改表必须同时改 PHP 与 Go 两处,否则结课与双跑会分叉。D7 双跑也只能从 PHP 行为反推。
  1121. var makeupCostTable = map[int32]money.Amount{
  1122. 1: money.FromCents(7000),
  1123. 2: money.FromCents(10000),
  1124. 3: money.FromCents(12000),
  1125. 4: money.FromCents(15000),
  1126. 5: money.FromCents(17000),
  1127. 6: money.FromCents(20000),
  1128. }
  1129. // makeupCostMax 超出表区间时的兜底值(PHP `'max' => 200`)。
  1130. const makeupCostMaxCents = int64(20000)
  1131. // makeupCostFor 按人数取固定课酬(人数 > 6 → 200.00,与 PHP `?? $makeupCost['max']` 一致)。
  1132. //
  1133. // 入参:studentCount(>0,由调用方保证)。出参:单节课课酬。
  1134. func makeupCostFor(studentCount int32) money.Amount {
  1135. if v, ok := makeupCostTable[studentCount]; ok {
  1136. return v
  1137. }
  1138. return money.FromCents(makeupCostMaxCents)
  1139. }
  1140. // percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100;
  1141. // 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。
  1142. const percentageDenom = 1000
  1143. // applyPercentage 计算"收入 × 比例"的金额(整数分运算,四舍五入到分)。
  1144. //
  1145. // 入参:pct 百分比字符串(`decimal(5,1)`,如 `"85.0"`;空串按 0);income 本次课收入(分)。
  1146. // 出参:金额(分);`ErrDBError`(列写坏:非数字 / 多于 2 位小数 / 负数)。
  1147. //
  1148. // 算法:`pctMilli = 百分比 × 10`(`"85.0"` → 850),`base = round(income × pctMilli / 1000)`。
  1149. // 为什么不用 `money.Parse` 再乘 float:比例列是 `decimal(5,1)`,用 float 会在 `×0.85` 这类
  1150. // 运算里带进二进制尾差(如 0.1+0.2 级),资金口径不允许。
  1151. func applyPercentage(pct string, income money.Amount) (money.Amount, error) {
  1152. text := strings.TrimSpace(pct)
  1153. if text == "" {
  1154. return 0, nil // 未配置比例:只算 min/max/reward 部分(dump 的 0 值语义)
  1155. }
  1156. if strings.HasPrefix(text, "-") {
  1157. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1158. errString("service: 课酬比例不能为负 "+text))
  1159. }
  1160. intPart, fracPart, _ := strings.Cut(text, ".")
  1161. if intPart == "" {
  1162. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1163. errString("service: 课酬比例格式非法 "+text))
  1164. }
  1165. whole, perr := parseUintDigits(intPart)
  1166. if perr != nil {
  1167. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1168. errString("service: 课酬比例格式非法 "+text))
  1169. }
  1170. // 小数位:只取 1 位(列是 decimal(5,1));第 2 位 >=5 时进位
  1171. tenth := int64(0)
  1172. if fracPart != "" {
  1173. if len(fracPart) > 2 {
  1174. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1175. errString("service: 课酬比例小数位过多 "+text))
  1176. }
  1177. first, ferr := parseUintDigits(fracPart[:1])
  1178. if ferr != nil {
  1179. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1180. errString("service: 课酬比例格式非法 "+text))
  1181. }
  1182. tenth = first
  1183. if len(fracPart) == 2 {
  1184. second, serr := parseUintDigits(fracPart[1:])
  1185. if serr != nil {
  1186. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  1187. errString("service: 课酬比例格式非法 "+text))
  1188. }
  1189. if second >= 5 {
  1190. tenth++
  1191. }
  1192. }
  1193. }
  1194. pctMilli := whole*10 + tenth
  1195. if pctMilli == 0 {
  1196. return 0, nil
  1197. }
  1198. cents := income.Cents()
  1199. // 溢出兜底:decimal(12,2) 上限 ≈ 1e10 分,pctMilli ≤ 1000 ⇒ 乘积 ≤ 1e13,int64 安全
  1200. base := (cents*pctMilli + percentageDenom/2) / percentageDenom
  1201. return money.FromCents(base), nil
  1202. }
  1203. // parseUintDigits 把纯数字串转 int64(比例解析用;非数字 → error)。
  1204. func parseUintDigits(digits string) (int64, error) {
  1205. if digits == "" {
  1206. return 0, errString("empty")
  1207. }
  1208. var out int64
  1209. for _, ch := range digits {
  1210. if ch < '0' || ch > '9' {
  1211. return 0, errString("not a digit")
  1212. }
  1213. out = out*10 + int64(ch-'0')
  1214. if out > 1_000_000 { // 比例列 decimal(5,1) 不会超过这个量级
  1215. return 0, errString("too large")
  1216. }
  1217. }
  1218. return out, nil
  1219. }
  1220. // ---------------------------------------------------------------------------
  1221. // 端点 48:课表实例列表
  1222. // ---------------------------------------------------------------------------
  1223. // RecordListQuery 端点 48 的查询入参(把 AC-11 的 `repository.RecordFilter` 包一层,
  1224. // 使 Handler 不必 import repository —— 本包依赖方向:handler → service → repository)。
  1225. //
  1226. // 字段:CoursesID 课程(<=0 不筛);TeacherID 老师(空不筛);Status 状态(nil 不筛);
  1227. // From/To 上课时间窗(`start_time` 闭区间,与 AC-11 `RecordFilter` 语义一致);Page/PageSize 分页。
  1228. type RecordListQuery struct {
  1229. // CoursesID 课程 ID(<=0 不筛)。
  1230. CoursesID int64
  1231. // TeacherID 老师 ULID(空不筛)。
  1232. TeacherID string
  1233. // Status 上课状态(nil 不筛;0 未开始 / 1 上课中 / 2 已结束)。
  1234. Status *int32
  1235. // From 上课时间下限(含;nil 不限)。
  1236. From *time.Time
  1237. // To 上课时间上限(含;nil 不限)。
  1238. To *time.Time
  1239. // Page 页码(1 基)。
  1240. Page int
  1241. // PageSize 每页条数(上限 200)。
  1242. PageSize int
  1243. }
  1244. // ListRecordsByQuery 分页查询排班实例(端点 48)。
  1245. //
  1246. // 入参:ctx;q 查询入参。
  1247. // 出参:([]model.CoursesRecord, total, error);5002。
  1248. //
  1249. // 时间窗口径**沿用 AC-11**(`start_time` 闭区间,两端都含):课表是"看某段时间有哪些课",
  1250. // 与契约 §1.3 的 `from` 闭 / `to` 开**不同**——这里是有意的差异(契约 §1.3 约束的是
  1251. // "区间筛选类查询"的通用规则,课表按"日期区间"展示,前端传 `to=2026-11-30` 期望含 30 号当天)。
  1252. // 该差异已在交付报告登记。
  1253. func (s *RecordService) ListRecordsByQuery(ctx context.Context, q RecordListQuery) ([]model.CoursesRecord, int64, error) {
  1254. return s.List(ctx, repository.RecordFilter{
  1255. CoursesID: q.CoursesID,
  1256. TeacherID: q.TeacherID,
  1257. Status: q.Status,
  1258. From: q.From,
  1259. To: q.To,
  1260. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  1261. }
  1262. // NewRecordRewardQuoter 构造课酬端口适配器(装配层用)。
  1263. //
  1264. // 入参:svc 课酬试算服务(可 nil → 适配器返回 0 课酬)。
  1265. // 出参:TeacherRewardQuoter 实现。
  1266. func NewRecordRewardQuoter(svc *PayrollService) TeacherRewardQuoter {
  1267. return recordRewardQuoter{svc: svc}
  1268. }
  1269. // NewDivideServiceWithRecordRepo 构造分账服务并注入"首次扣费记录"端口(AC-14 装配用)。
  1270. //
  1271. // 入参:db;sc 请求作用域(仅用于构造 RecordRepo;分账域本身不做租户裁剪)。
  1272. // 出参:*DivideService(`firstCharged` 已注入 = `repository.RecordRepo`,结构化满足端口)。
  1273. //
  1274. // 为什么放在 service 包而不是装配层 new 两个对象再拼:`repository.RecordRepo` 必须按
  1275. // **请求作用域**构造(它继承 BaseRepo),把这个约束关在本包内,装配层就不必 import repository
  1276. // (handler 层至今对 repository 零 import,这条依赖方向要保持)。
  1277. func NewDivideServiceWithRecordRepo(db *gorm.DB, sc *appscope.Scope) *DivideService {
  1278. return NewDivideService(db, repository.NewRecordRepo(db, sc))
  1279. }