course_repo.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. // 课程/排班规则/报课账户仓储(t_courses / t_courses_classify / t_courses_class / t_courses_student)
  2. // ——AC-11 课程域最小闭环。
  3. //
  4. // 依据:unified/03-migration/academic-domain-design.md §3.5(课程类 3 SP)/ §11-3(排班/打卡盘点结论)
  5. // / §1.4(事务模型)/ §1.7(scoping)/ §10.3(仓储不开事务)/ §10.4(游标 → 集合)。
  6. //
  7. // 表语义分层(详见 model/models_course.go 文件头):
  8. //
  9. // t_courses(课程模板:周规则 week + 次数 number + 单价 + 时长)
  10. // → t_courses_class(排班规则:某课在某班由某老师按 week + 起止时分 排课)
  11. // → t_courses_record(排班实例,见 record_repo.go)
  12. // → t_courses_student(报课订单 = 学费账户 / 课次账户,欠费核销与扣课时的作用对象)
  13. //
  14. // 覆盖的 dump 存储过程映射(**逐条对齐 dump 原文,行号为 yuansiwei_finance.sql**):
  15. //
  16. // sp_courses_update() → ListCoursesNeedingMaxPeopleBackfill + BatchSetMaxPeople(SP28,763490)
  17. // sp_courses_update_campusid() → ListCourseIDsWithZeroCampus + ListClassCampusIDs + SetCourseCampus(SP29,763533)
  18. // sp_courses_student_counteract_arrears_course() → ListArrearsCandidates
  19. // + IncNetReceiptsAndDecRemainderReceivable(SP27,763435)
  20. //
  21. // 作用域(设计 §10.8 A 类):四张表均带 institution_id/campus_id(NOT NULL 默认 0),
  22. // 因此统一继承 BaseRepo 并**每条查询**经 `r.Scoped(db)` 注入机构/校区过滤。
  23. // 特例说明:`t_courses.campus_id = 0` 表示"尚未归属单一校区"(由 SP29 回填),
  24. // 普通角色(scope.CampusID > 0)的 `campus_id = ?` 严格过滤会看不到这些待回填行——
  25. // 这是**有意的**(校区角色不该看到未归属课程),SP29 只允许 super_admin 执行,
  26. // 其 scope.SuperAdmin=true 时 Scoped 不加过滤,故回填不受影响(见 MaintenanceService 断言)。
  27. //
  28. // 事务纪律:写方法一律接收 `tx *gorm.DB`(**仓储不开事务**,边界在 Service);
  29. // 读方法经 db(ctx) 取句柄,ctx 内已注入事务(WithContext)时自动复用同一 tx。
  30. package repository
  31. import (
  32. "context"
  33. "strings"
  34. "gorm.io/gorm"
  35. "yuansiwei/unified-backend/internal/academic/model"
  36. "yuansiwei/unified-backend/internal/academic/money"
  37. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  38. appscope "yuansiwei/unified-backend/internal/infra/scope"
  39. )
  40. // CourseRepo 课程模板 + 分类 + 排班规则 + 报课账户仓储(多租户,继承 BaseRepo)。
  41. type CourseRepo struct {
  42. *BaseRepo
  43. }
  44. // NewCourseRepo 构造课程仓储。
  45. //
  46. // 入参:
  47. // - db *gorm.DB:连接池句柄(必填)。
  48. // - sc *appscope.Scope:请求作用域(系统任务可 nil,但一旦查询即 panic fail-fast,见 BaseRepo.Scoped)。
  49. //
  50. // 出参:*CourseRepo(CampusScoped=true:四张表都带 campus_id)。
  51. func NewCourseRepo(db *gorm.DB, sc *appscope.Scope) *CourseRepo {
  52. r := &CourseRepo{BaseRepo: NewBaseRepo(db, sc)}
  53. r.CampusScoped = true
  54. return r
  55. }
  56. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  57. //
  58. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  59. func (r *CourseRepo) db(ctx context.Context) (*gorm.DB, error) {
  60. if r == nil || r.DB == nil {
  61. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: CourseRepo.DB 为 nil"))
  62. }
  63. if tx := TxFromContext(ctx); tx != nil {
  64. return tx, nil
  65. }
  66. if ctx == nil {
  67. return r.DB, nil
  68. }
  69. return r.DB.WithContext(ctx), nil
  70. }
  71. // ---------------------------------------------------------------------------
  72. // 课程模板(t_courses)
  73. // ---------------------------------------------------------------------------
  74. // GetCourseByID 按主键取课程模板(**作用域强制**:跨机构/校区一律 ErrNotFound,不泄露存在性)。
  75. //
  76. // 入参:ctx 请求上下文;id 课程 ID(<=0 → ErrBadParam,避免无意义查询)。
  77. // 出参:*model.Courses;ErrNotFound(3001) / ErrBadParam(2002) / ErrDBError(5002)。
  78. func (r *CourseRepo) GetCourseByID(ctx context.Context, id int64) (*model.Courses, error) {
  79. if id <= 0 {
  80. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: course id 必须 > 0"))
  81. }
  82. db, err := r.db(ctx)
  83. if err != nil {
  84. return nil, err
  85. }
  86. var out model.Courses
  87. q := r.Scoped(db.Model(&model.Courses{}))
  88. if err := q.Where("id = ?", id).First(&out).Error; err != nil {
  89. return nil, wrapQueryErr(err, "GetCourseByID(t_courses)")
  90. }
  91. return &out, nil
  92. }
  93. // CourseFilter 课程列表过滤条件(端点 44:classify/status/keyword + 分页)。
  94. //
  95. // 字段:
  96. // - ClassifyID int64:分类 ID(<=0 不筛);
  97. // - Status *int32:状态(nil 不筛;用指针以区分"未传"与"传了 0=未发布");
  98. // - Keyword string:名称模糊匹配(trim 后空串不筛)。
  99. type CourseFilter struct {
  100. ClassifyID int64
  101. Status *int32
  102. Keyword string
  103. }
  104. // ListCourses 分页查询课程模板(按 id 倒序,新建在前)。
  105. //
  106. // 入参:ctx;f CourseFilter;pg PageQuery(内部钳制 pageSize ∈ [1,200])。
  107. // 出参:([]model.Courses, total int64, error);空结果为**非 nil 空切片**(设计 §10.6)。
  108. func (r *CourseRepo) ListCourses(ctx context.Context, f CourseFilter, pg PageQuery) ([]model.Courses, int64, error) {
  109. db, err := r.db(ctx)
  110. if err != nil {
  111. return nil, 0, err
  112. }
  113. build := func() *gorm.DB {
  114. q := r.Scoped(db.Model(&model.Courses{}))
  115. if f.ClassifyID > 0 {
  116. q = q.Where("classify_id = ?", f.ClassifyID)
  117. }
  118. if f.Status != nil {
  119. q = q.Where("status = ?", *f.Status)
  120. }
  121. if kw := strings.TrimSpace(f.Keyword); kw != "" {
  122. // 名称模糊匹配:LIKE 通配符由参数承载(GORM 参数化,无注入面)
  123. q = q.Where("name LIKE ?", "%"+kw+"%")
  124. }
  125. return q
  126. }
  127. var total int64
  128. if err := build().Count(&total).Error; err != nil {
  129. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  130. }
  131. page, size := pg.Normalize()
  132. rows := make([]model.Courses, 0, size)
  133. if err := build().Order("id DESC").Offset(PageOffset(page, size)).Limit(size).Find(&rows).Error; err != nil {
  134. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  135. }
  136. return rows, total, nil
  137. }
  138. // CreateCourse 新建课程模板(作用域列由 Service 用 GetScopedID 补齐后传入)。
  139. //
  140. // 入参:ctx;tx(**必传**);row 课程行(Name 非空;金额列已由 Service 归一为 "0.00" 形式)。
  141. // 出参:nil 成功(回填自增 ID);ErrBadParam / ErrDBError。
  142. func (r *CourseRepo) CreateCourse(ctx context.Context, tx *gorm.DB, row *model.Courses) error {
  143. if err := requireTx(tx, "CreateCourse"); err != nil {
  144. return err
  145. }
  146. if row == nil || strings.TrimSpace(row.Name) == "" {
  147. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateCourse 需要 name"))
  148. }
  149. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  150. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  151. }
  152. return nil
  153. }
  154. // UpdateCourse 按**列白名单**更新课程模板(只更新调用方显式给出的列)。
  155. //
  156. // 入参:
  157. // - ctx;tx(**必传**);
  158. // - row 课程行(ID 必填,其余字段按 columns 选择性落库);
  159. // - columns 列名集合(**调用方必须用 model 列名**,非空)。
  160. //
  161. // 出参:int64 受影响行数(0 = 不存在或不在作用域内);ErrBadParam / ErrDBError。
  162. //
  163. // 说明:`Select(columns).Updates(row)` 是"部分更新"的唯一安全形状——不带 Select 会写入零值
  164. // (把未传字段清空),带 map 则丢失类型信息(decimal 字符串无法校验)。列名由 Service 以字面量传入,
  165. // 不接受外部字符串(无注入面)。
  166. func (r *CourseRepo) UpdateCourse(ctx context.Context, tx *gorm.DB, row *model.Courses, columns []string) (int64, error) {
  167. if err := requireTx(tx, "UpdateCourse"); err != nil {
  168. return 0, err
  169. }
  170. if row == nil || row.ID <= 0 {
  171. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateCourse 需要 id"))
  172. }
  173. if len(columns) == 0 {
  174. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateCourse 需要显式列清单"))
  175. }
  176. // 作用域过滤:tx 上再叠机构/校区(防跨租户改)
  177. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{}))
  178. res := q.Where("id = ?", row.ID).Select(columns).Updates(row)
  179. if res.Error != nil {
  180. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  181. }
  182. return res.RowsAffected, nil
  183. }
  184. // ---------------------------------------------------------------------------
  185. // 课程分类(t_courses_classify)
  186. // ---------------------------------------------------------------------------
  187. // GetClassifyByID 按主键取课程分类(SP28 回填 max_people 的来源;课酬试算的输入)。
  188. //
  189. // 入参:ctx;id 分类 ID(<=0 → ErrBadParam)。
  190. // 出参:*model.CoursesClassify;ErrNotFound / ErrBadParam / ErrDBError。
  191. func (r *CourseRepo) GetClassifyByID(ctx context.Context, id int64) (*model.CoursesClassify, error) {
  192. if id <= 0 {
  193. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
  194. }
  195. db, err := r.db(ctx)
  196. if err != nil {
  197. return nil, err
  198. }
  199. var out model.CoursesClassify
  200. q := r.Scoped(db.Model(&model.CoursesClassify{}))
  201. if err := q.Where("id = ?", id).First(&out).Error; err != nil {
  202. return nil, wrapQueryErr(err, "GetClassifyByID(t_courses_classify)")
  203. }
  204. return &out, nil
  205. }
  206. // ---------------------------------------------------------------------------
  207. // 排班规则(t_courses_class)——「班级」在 API 层即本表
  208. // ---------------------------------------------------------------------------
  209. // ListClassesByCourses 取某课程的全部排班规则(按 id 升序,稳定顺序便于排班展开复现)。
  210. //
  211. // 入参:
  212. // - ctx 请求上下文;
  213. // - coursesID 课程 ID(<=0 → ErrBadParam);
  214. // - onlyActive true 表示只取 status=1(进行中)的规则(排班生成专用)。
  215. //
  216. // 出参:[]model.CoursesClass(空结果为非 nil 空切片);ErrBadParam / ErrDBError。
  217. func (r *CourseRepo) ListClassesByCourses(ctx context.Context, coursesID int64, onlyActive bool) ([]model.CoursesClass, error) {
  218. if coursesID <= 0 {
  219. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListClassesByCourses 需要 courses_id"))
  220. }
  221. db, err := r.db(ctx)
  222. if err != nil {
  223. return nil, err
  224. }
  225. q := r.Scoped(db.Model(&model.CoursesClass{})).Where("courses_id = ?", coursesID)
  226. if onlyActive {
  227. q = q.Where("status = ?", model.CoursesClassStatusOngoing)
  228. }
  229. rows := make([]model.CoursesClass, 0, 8)
  230. if err := q.Order("id ASC").Find(&rows).Error; err != nil {
  231. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  232. }
  233. return rows, nil
  234. }
  235. // GetClassByID 按主键取排班规则(打卡/结束上课时取 lesson_cost / number 快照)。
  236. //
  237. // 入参:ctx;id 规则 ID(<=0 → ErrBadParam)。
  238. // 出参:*model.CoursesClass;ErrNotFound / ErrBadParam / ErrDBError。
  239. func (r *CourseRepo) GetClassByID(ctx context.Context, id int64) (*model.CoursesClass, error) {
  240. if id <= 0 {
  241. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: class id 必须 > 0"))
  242. }
  243. db, err := r.db(ctx)
  244. if err != nil {
  245. return nil, err
  246. }
  247. var out model.CoursesClass
  248. q := r.Scoped(db.Model(&model.CoursesClass{}))
  249. if err := q.Where("id = ?", id).First(&out).Error; err != nil {
  250. return nil, wrapQueryErr(err, "GetClassByID(t_courses_class)")
  251. }
  252. return &out, nil
  253. }
  254. // CreateClass 新建排班规则(week + 起止时分 + 老师 + 课费)。
  255. //
  256. // 入参:ctx;tx(**必传**);row 规则行(CoursesID>0;Week 已由 Service 校验)。
  257. // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。
  258. func (r *CourseRepo) CreateClass(ctx context.Context, tx *gorm.DB, row *model.CoursesClass) error {
  259. if err := requireTx(tx, "CreateClass"); err != nil {
  260. return err
  261. }
  262. if row == nil || row.CoursesID <= 0 {
  263. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateClass 需要 courses_id"))
  264. }
  265. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  266. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  267. }
  268. return nil
  269. }
  270. // ListClassCampusIDs 取某课程排班规则涉及的**去重校区**列表(SP29 判"是否只属于单一校区")。
  271. //
  272. // 入参:ctx;coursesID 课程 ID(<=0 → ErrBadParam)。
  273. // 出参:[]int64 去重校区 ID(升序;空切片表示该课程尚无排班规则);ErrBadParam / ErrDBError。
  274. //
  275. // dump 原文(763546):`SELECT COUNT(*) FROM (SELECT campusid FROM t_courses_class WHERE coursesid=logId
  276. // GROUP BY campusid) t` → 只关心"去重后的校区个数",故等价改写为 DISTINCT 列表(集合化,§10.4)。
  277. func (r *CourseRepo) ListClassCampusIDs(ctx context.Context, coursesID int64) ([]int64, error) {
  278. if coursesID <= 0 {
  279. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListClassCampusIDs 需要 courses_id"))
  280. }
  281. db, err := r.db(ctx)
  282. if err != nil {
  283. return nil, err
  284. }
  285. var ids []int64
  286. q := r.Scoped(db.Model(&model.CoursesClass{})).Where("courses_id = ?", coursesID)
  287. if err := q.Distinct().Order("campus_id ASC").Pluck("campus_id", &ids).Error; err != nil {
  288. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  289. }
  290. if ids == nil {
  291. ids = make([]int64, 0)
  292. }
  293. return ids, nil
  294. }
  295. // ---------------------------------------------------------------------------
  296. // SP28 数据修复:max_people 从分类继承(dump 763490 sp_courses_update)
  297. // ---------------------------------------------------------------------------
  298. // MaxPeopleBackfillRow 待回填行(课程 ID + 其分类的 max_people)。
  299. //
  300. // 字段:CourseID 课程 ID;MaxPeople 分类表的目标值(0 表示分类也未配置 → 回填后仍为 0,无实际变化)。
  301. type MaxPeopleBackfillRow struct {
  302. CourseID int64 `gorm:"column:course_id"`
  303. MaxPeople int32 `gorm:"column:max_people"`
  304. }
  305. // ListCoursesNeedingMaxPeopleBackfill 取 `max_people = 0` 的课程及其分类的 max_people。
  306. //
  307. // 入参:ctx 请求上下文。
  308. // 出参:[]MaxPeopleBackfillRow(按 course_id 升序 —— 对齐 dump 游标 `ORDER BY t1.id`);ErrDBError。
  309. //
  310. // dump 原文(763492):
  311. //
  312. // SELECT t1.id, t2.max_people FROM t_courses t1 LEFT JOIN t_courses_classify t2
  313. // ON t1.classifyid = t2.id WHERE t1.max_people = 0 ORDER BY t1.id;
  314. //
  315. // ⚠ LEFT JOIN 语义(**不可改 INNER JOIN**):分类缺失时 t2.max_people 为 NULL → dump 的
  316. // `UPDATE t_courses SET max_people = NULL` 在有 NOT NULL 约束的新表上会失败;目标态该列
  317. // `NOT NULL DEFAULT 0`,故分类缺失归为 0("无分类可继承"= 保持 0,与 INNER JOIN 的跳过等价,
  318. // 但**行仍计入扫描数**,便于运维核对)。
  319. //
  320. // 实现说明(**两查而非 SQL JOIN**):两张表都带 institution_id/campus_id,JOIN 后
  321. // `Scoped` 注入的裸列名 `institution_id` 会变成 ambiguous column(MySQL/SQLite 均报错),
  322. // 而 Scoped 不接受表别名。改为"先取课程行 → 再按 classify_id 批量取分类"(2 条索引查询、
  323. // 无 N+1),结果与 LEFT JOIN 等价(缺失分类 → 0)。
  324. func (r *CourseRepo) ListCoursesNeedingMaxPeopleBackfill(ctx context.Context) ([]MaxPeopleBackfillRow, error) {
  325. db, err := r.db(ctx)
  326. if err != nil {
  327. return nil, err
  328. }
  329. type courseRow struct {
  330. CourseID int64 `gorm:"column:id"`
  331. ClassifyID int64 `gorm:"column:classify_id"`
  332. }
  333. courses := make([]courseRow, 0, 32)
  334. q := r.Scoped(db.Model(&model.Courses{})).Select("id, classify_id").Where("max_people = 0").Order("id ASC")
  335. if err := q.Find(&courses).Error; err != nil {
  336. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  337. }
  338. classifyIDs := make([]int64, 0, len(courses))
  339. for i := range courses {
  340. if courses[i].ClassifyID > 0 {
  341. classifyIDs = append(classifyIDs, courses[i].ClassifyID)
  342. }
  343. }
  344. classifyMax := make(map[int64]int32, len(classifyIDs))
  345. if len(classifyIDs) > 0 {
  346. type classifyRow struct {
  347. ID int64 `gorm:"column:id"`
  348. MaxPeople int32 `gorm:"column:max_people"`
  349. }
  350. rows := make([]classifyRow, 0, len(classifyIDs))
  351. cq := r.Scoped(db.Model(&model.CoursesClassify{})).Select("id, max_people").Where("id IN ?", dedupPositive(classifyIDs))
  352. if err := cq.Find(&rows).Error; err != nil {
  353. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  354. }
  355. for i := range rows {
  356. classifyMax[rows[i].ID] = rows[i].MaxPeople
  357. }
  358. }
  359. out := make([]MaxPeopleBackfillRow, 0, len(courses))
  360. for i := range courses {
  361. target := classifyMax[courses[i].ClassifyID] // 缺失/无分类 → 0(保持 0,等价 dump 的 NULL 归一)
  362. out = append(out, MaxPeopleBackfillRow{CourseID: courses[i].CourseID, MaxPeople: target})
  363. }
  364. return out, nil
  365. }
  366. // BatchSetMaxPeople 批量回填 max_people(同一目标值的课程一次 UPDATE,`WHERE id IN (...)`)。
  367. //
  368. // 入参:ctx;tx(**必传**);maxPeople 目标值(>0 才有意义,Service 保证);courseIDs 课程 ID 集合。
  369. // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
  370. //
  371. // 集合化说明(§10.4):dump 是游标逐行 `UPDATE ... WHERE id = curId`;同一 max_people 值的一组
  372. // 课程终态与逐行更新等价,故按值分组一次 UPDATE(组内 ORDER BY 已在外层 SELECT 保证,
  373. // **分配顺序不变**)。`max_people = 0` 的组由 Service 跳过(写 0 等于不改,RowsAffected 会误导运维)。
  374. func (r *CourseRepo) BatchSetMaxPeople(ctx context.Context, tx *gorm.DB, maxPeople int32, courseIDs []int64) (int64, error) {
  375. if err := requireTx(tx, "BatchSetMaxPeople"); err != nil {
  376. return 0, err
  377. }
  378. if maxPeople <= 0 {
  379. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: BatchSetMaxPeople 需要 max_people > 0"))
  380. }
  381. ids := dedupPositive(courseIDs)
  382. if len(ids) == 0 {
  383. return 0, nil
  384. }
  385. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{}))
  386. res := q.Where("id IN ?", ids).UpdateColumn("max_people", maxPeople)
  387. if res.Error != nil {
  388. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  389. }
  390. return res.RowsAffected, nil
  391. }
  392. // ---------------------------------------------------------------------------
  393. // SP29 数据修复:campus_id 回填(dump 763533 sp_courses_update_campusid)
  394. // ---------------------------------------------------------------------------
  395. // ListCourseIDsWithZeroCampus 取 `campus_id = 0`(未归属单一校区)的课程 ID。
  396. //
  397. // 入参:ctx 请求上下文。
  398. // 出参:[]int64(升序;本方法**刻意加** ORDER BY —— dump 游标无 ORDER BY,行序不保证;
  399. // 回填结果与行序无关(每行独立判定),加排序只为让重跑日志可比对、可复现);ErrDBError。
  400. func (r *CourseRepo) ListCourseIDsWithZeroCampus(ctx context.Context) ([]int64, error) {
  401. db, err := r.db(ctx)
  402. if err != nil {
  403. return nil, err
  404. }
  405. var ids []int64
  406. q := r.Scoped(db.Model(&model.Courses{})).Where("campus_id = 0")
  407. if err := q.Order("id ASC").Pluck("id", &ids).Error; err != nil {
  408. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  409. }
  410. if ids == nil {
  411. ids = make([]int64, 0)
  412. }
  413. return ids, nil
  414. }
  415. // SetCourseCampus 回填课程归属校区(仅当 Service 判定"该课程只属于 1 个校区"时调用)。
  416. //
  417. // 入参:ctx;tx(**必传**);id 课程 ID(>0);campusID 目标校区(>0)。
  418. // 出参:int64 受影响行数(0 = 并发下已被回填或不在作用域);ErrBadParam / ErrDBError。
  419. //
  420. // 红线:**不得**在 campus_count != 1 时调用(多校区课程保持 0,dump 763552 的 IF 分支);
  421. // WHERE 同时限 `campus_id = 0` 以防并发回填覆盖人工指定值(幂等)。
  422. func (r *CourseRepo) SetCourseCampus(ctx context.Context, tx *gorm.DB, id, campusID int64) (int64, error) {
  423. if err := requireTx(tx, "SetCourseCampus"); err != nil {
  424. return 0, err
  425. }
  426. if id <= 0 || campusID <= 0 {
  427. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetCourseCampus 需要 id 与 campus_id 均 > 0"))
  428. }
  429. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{}))
  430. res := q.Where("id = ? AND campus_id = 0", id).UpdateColumn("campus_id", campusID)
  431. if res.Error != nil {
  432. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  433. }
  434. return res.RowsAffected, nil
  435. }
  436. // ---------------------------------------------------------------------------
  437. // 报课账户(t_courses_student):SP27 欠费核销 + 结束上课扣课时
  438. // ---------------------------------------------------------------------------
  439. // ArrearsCandidate 欠费核销候选行(报课记录 ID + 剩余应收)。
  440. //
  441. // 字段:
  442. // - StudentCourseID int64:t_courses_student.id;
  443. // - RemainderReceivable string:剩余应收(**元字符串**;sqlite NUMERIC 回读不能直接落 money.Amount,
  444. // 由 Service 用 money.Parse 转分,口径与全仓一致)。
  445. type ArrearsCandidate struct {
  446. StudentCourseID int64 `gorm:"column:student_course_id"`
  447. RemainderReceivable string `gorm:"column:remainder_receivable"`
  448. }
  449. // ListArrearsCandidates 取某学生在本机构/校区的欠费报课列表(SP27 游标集合化)。
  450. //
  451. // 入参:ctx;studentID 学生(ULID);institutionID/campusID 作用域(均 >0,Service 已校验)。
  452. //
  453. // 出参:[]ArrearsCandidate(按"课程开课时分升序,其次报课 id 升序");ErrBadParam / ErrDBError。
  454. //
  455. // dump 原文(763446,**逐字对齐**):
  456. //
  457. // SELECT t1.id, t1.remainder_receivable FROM t_courses_student t1
  458. // INNER JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid
  459. // WHERE t1.studentid=inputStudentid AND t1.schoolid=inputSchoolid AND t1.campusid=inputCampusid
  460. // AND t1.remainder_receivable>0 AND t1.confirm_discount_userid>0
  461. // ORDER BY t2.start_time ASC;
  462. //
  463. // 两处**有意归一**(均已登记交付说明,评审重点):
  464. // 1. `confirm_discount_userid > 0`:新模型该列是 ULID 字符串(*string),"有一个确认操作人"的
  465. // 等价判据是 `IS NOT NULL AND <> ”`;**NULL/空串 = 不可充值**(模型红线),故不能用 0 替代。
  466. // 2. `INNER JOIN t_courses_class` 是 1:N —— 同一 (courses_id, class_id) 有多条排班规则时会把
  467. // **同一报课行**重复取出;旧 SP 游标物化快照后逐行核销,第二行会按"旧剩余应收"再核销一次
  468. // → net_receipts 被重复累加(dump 缺陷,同类于 A9 的不递减类问题)。Go 侧改为
  469. // `MIN(start_time)` 聚合子查询:**同一报课行只出现一次**,排序依据仍是课程开课时分(顺序不变)。
  470. func (r *CourseRepo) ListArrearsCandidates(ctx context.Context, studentID string, institutionID, campusID int64) ([]ArrearsCandidate, error) {
  471. if strings.TrimSpace(studentID) == "" {
  472. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListArrearsCandidates 需要 student_id"))
  473. }
  474. if institutionID <= 0 || campusID <= 0 {
  475. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  476. errString("repository: ListArrearsCandidates 需要 institution_id 与 campus_id 均 > 0"))
  477. }
  478. db, err := r.db(ctx)
  479. if err != nil {
  480. return nil, err
  481. }
  482. const firstStartSub = "SELECT courses_id, class_id, MIN(start_time) AS first_start " +
  483. "FROM t_courses_class GROUP BY courses_id, class_id"
  484. rows := make([]ArrearsCandidate, 0, 8)
  485. q := r.Scoped(db.Table("t_courses_student AS t1")).
  486. Select("t1.id AS student_course_id, t1.remainder_receivable AS remainder_receivable").
  487. Joins("INNER JOIN ("+firstStartSub+") AS t2 ON t1.courses_id = t2.courses_id AND t1.class_id = t2.class_id").
  488. Where("t1.student_id = ?", strings.TrimSpace(studentID)).
  489. Where("t1.institution_id = ? AND t1.campus_id = ?", institutionID, campusID).
  490. Where("t1.remainder_receivable > 0").
  491. Where("t1.confirm_discount_user_id IS NOT NULL AND t1.confirm_discount_user_id <> ''").
  492. Order("t2.first_start ASC, t1.id ASC")
  493. if err := q.Find(&rows).Error; err != nil {
  494. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  495. }
  496. return rows, nil
  497. }
  498. // IncNetReceiptsAndDecRemainderReceivable 欠费核销落账(`net_receipts + amount`、`remainder_receivable - amount`)。
  499. //
  500. // 入参:ctx;tx(**必传**);studentCourseID 报课记录 ID(>0);amount 本次核销金额(>0)。
  501. // 出参:int64 受影响行数(0 = 行不存在/不在作用域);ErrBadParam / ErrDBError。
  502. //
  503. // dump 原文(763476):
  504. //
  505. // UPDATE t_courses_student SET net_receipts = net_receipts + remainderReceivable,
  506. // remainder_receivable = remainder_receivable - remainderReceivable WHERE id = stuCourseid;
  507. //
  508. // 红线:两列**同一条 UPDATE**(拆两条会出现"实收加了、应收没减"的中间态);
  509. // `unpaid` 列 dump **不动**(§3.5 表格文字写 unpaid>0,与 dump 原文不一致,以 dump 为准)。
  510. func (r *CourseRepo) IncNetReceiptsAndDecRemainderReceivable(ctx context.Context, tx *gorm.DB, studentCourseID int64, amount money.Amount) (int64, error) {
  511. if err := requireTx(tx, "IncNetReceiptsAndDecRemainderReceivable"); err != nil {
  512. return 0, err
  513. }
  514. if studentCourseID <= 0 {
  515. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 核销需要 t_courses_student.id"))
  516. }
  517. if !amount.GreaterThan(0) {
  518. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 核销金额必须 > 0"))
  519. }
  520. if err := amount.Validate(); err != nil {
  521. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  522. }
  523. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesStudent{}))
  524. res := q.Where("id = ?", studentCourseID).Updates(map[string]any{
  525. "net_receipts": gorm.Expr("net_receipts + ?", amount.String()),
  526. "remainder_receivable": gorm.Expr("remainder_receivable - ?", amount.String()),
  527. })
  528. if res.Error != nil {
  529. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  530. }
  531. return res.RowsAffected, nil
  532. }
  533. // ListActiveStudentsByCourses 取某课程**在读**报课学生(结束上课时"未显式打卡则按在读名单全勤")。
  534. //
  535. // 入参:ctx;coursesID 课程 ID(<=0 → ErrBadParam)。
  536. // 出参:[]model.CoursesStudent(按 id 升序 —— 固定顺序保证扣课时与分账的**锁序可复现**);
  537. // ErrBadParam / ErrDBError。
  538. func (r *CourseRepo) ListActiveStudentsByCourses(ctx context.Context, coursesID int64) ([]model.CoursesStudent, error) {
  539. if coursesID <= 0 {
  540. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListActiveStudentsByCourses 需要 courses_id"))
  541. }
  542. db, err := r.db(ctx)
  543. if err != nil {
  544. return nil, err
  545. }
  546. rows := make([]model.CoursesStudent, 0, 16)
  547. q := r.Scoped(db.Model(&model.CoursesStudent{})).
  548. Where("courses_id = ?", coursesID).
  549. Where("status = ?", model.CoursesStudentStatusNormal)
  550. if err := q.Order("id ASC").Find(&rows).Error; err != nil {
  551. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  552. }
  553. return rows, nil
  554. }
  555. // GetStudentCourse 按(课程 + 学生)取在读报课记录(扣课时前锁定扣减基数)。
  556. //
  557. // 入参:ctx;coursesID 课程 ID(>0);studentID 学生(ULID,非空)。
  558. // 出参:*model.CoursesStudent;ErrNotFound(未报课)/ ErrBadParam / ErrDBError。
  559. //
  560. // 说明:同一学生对同一课程理论上一条在读记录;按 id 升序取第一条(多行脏数据不阻断流程,
  561. // 由运维按报表修复)。
  562. func (r *CourseRepo) GetStudentCourse(ctx context.Context, coursesID int64, studentID string) (*model.CoursesStudent, error) {
  563. if coursesID <= 0 || strings.TrimSpace(studentID) == "" {
  564. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetStudentCourse 需要 courses_id/student_id"))
  565. }
  566. db, err := r.db(ctx)
  567. if err != nil {
  568. return nil, err
  569. }
  570. var out model.CoursesStudent
  571. q := r.Scoped(db.Model(&model.CoursesStudent{})).Where("courses_id = ? AND student_id = ?", coursesID, studentID)
  572. if err := q.Order("id ASC").First(&out).Error; err != nil {
  573. return nil, wrapQueryErr(err, "GetStudentCourse(t_courses_student)")
  574. }
  575. return &out, nil
  576. }
  577. // ApplyLessonDeduction 扣课时:`finished_number + 1`、`remainder_number - 1`(下限 0)、`remainder_cost - cost`。
  578. //
  579. // 入参:ctx;tx(**必传**);studentCourseID 报课记录 ID(>0);cost 本次课费(>=0;0 表示赠送/免费)。
  580. // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
  581. //
  582. // 语义(无 dump 依据 —— 本期为 PHP 逻辑的 Go 化最小闭环,设计 §11-3):
  583. // - `finished_number` 只增(打卡计数);
  584. // - `remainder_number` 用 `CASE WHEN > 0 THEN -1 ELSE 0` 兜底,避免脏数据把剩余课次刷成负数;
  585. // - `remainder_cost` **不做下限截断**:负数即"课时已用超"的账实不符信号,必须暴露而非静默抹平
  586. // (与 remainder_number 的取舍不同:课次是计数、费用是金额,金额静默截断会破坏对账恒等式)。
  587. func (r *CourseRepo) ApplyLessonDeduction(ctx context.Context, tx *gorm.DB, studentCourseID int64, cost money.Amount) (int64, error) {
  588. if err := requireTx(tx, "ApplyLessonDeduction"); err != nil {
  589. return 0, err
  590. }
  591. if studentCourseID <= 0 {
  592. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ApplyLessonDeduction 需要 t_courses_student.id"))
  593. }
  594. if cost.IsNegative() {
  595. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 扣课金额不能为负"))
  596. }
  597. if err := cost.Validate(); err != nil {
  598. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  599. }
  600. fields := map[string]any{
  601. "finished_number": gorm.Expr("finished_number + 1"),
  602. "remainder_number": gorm.Expr("CASE WHEN remainder_number > 0 THEN remainder_number - 1 ELSE 0 END"),
  603. }
  604. if !cost.IsZero() {
  605. fields["remainder_cost"] = gorm.Expr("remainder_cost - ?", cost.String())
  606. }
  607. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesStudent{}))
  608. res := q.Where("id = ?", studentCourseID).Updates(fields)
  609. if res.Error != nil {
  610. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  611. }
  612. return res.RowsAffected, nil
  613. }
  614. // dedupPositive 去重并剔除非正数(批量 UPDATE 的 IN 列表净化)。
  615. //
  616. // 入参:ids int64 集合(可含重复/0/负数)。出参:去重后的正数切片(保持首次出现顺序)。
  617. func dedupPositive(ids []int64) []int64 {
  618. out := make([]int64, 0, len(ids))
  619. seen := make(map[int64]struct{}, len(ids))
  620. for _, id := range ids {
  621. if id <= 0 {
  622. continue
  623. }
  624. if _, ok := seen[id]; ok {
  625. continue
  626. }
  627. seen[id] = struct{}{}
  628. out = append(out, id)
  629. }
  630. return out
  631. }