payroll_plan_repo.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. // 课酬方案 + 明细的数据访问(端点 58:`/admin/payroll/plans`)——AC-13d 追加。
  2. //
  3. // 依据:团队裁决 D17(两级形状)+ 目标态 DDL 两张表:
  4. //
  5. // t_course_payment : id, institution_id, campus_id, name, effect_date, actived,
  6. // create_user_id, create_time, memo
  7. // t_course_payment_detail : id, course_payment_id, grade_id, subject_id, class_type, classify_id,
  8. // teacher_id, name, percentage, min_payment, max_payment, reward,
  9. // teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo
  10. //
  11. // ---------------------------------------------------------------------------
  12. // 作用域(为什么不用 BaseRepo.Scoped)
  13. // ---------------------------------------------------------------------------
  14. //
  15. // `PayrollRepo` **不继承 BaseRepo**(见 `payroll_repo.go` 文件头:本包表混有无作用域列的表
  16. // `t_teacher_information`,`Scoped` 的裸列名会 `Unknown column`)。因此本文件的每个方法都
  17. // **显式接收 institutionID** 并 `WHERE institution_id = ?`,越权由 `assertInstitution` 统一拦:
  18. //
  19. // 非超管:institutionID 必须 == 作用域机构(否则 1004)
  20. // 超管 :可查任意机构(平台视角)
  21. //
  22. // ⚠ `t_course_payment_detail` **没有** institution_id 列 ⇒ 明细的作用域**只能经父方案**:
  23. // 本文件所有"按明细操作"的方法都要求调用方已用方案 id 校验过归属(Service 层保证)。
  24. //
  25. // ---------------------------------------------------------------------------
  26. // 事务纪律
  27. // ---------------------------------------------------------------------------
  28. //
  29. // 写方法一律接收 `tx *gorm.DB`(仓储不开事务);`ReplaceDetails` 的"先删后插"必须在
  30. // **同一事务**内(否则删完插失败 ⇒ 整案明细丢失)。
  31. package repository
  32. import (
  33. "context"
  34. "strings"
  35. "gorm.io/gorm"
  36. "yuansiwei/unified-backend/internal/academic/model"
  37. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  38. )
  39. // PayrollPlanFilter 课酬方案列表过滤(端点 58 GET)。
  40. //
  41. // 字段:CampusID 校区(nil 不筛;0 = 只查机构级方案);Actived 启用(nil 不筛);
  42. // Keyword 方案名模糊(空不筛)。
  43. type PayrollPlanFilter struct {
  44. CampusID *int64
  45. Actived *bool
  46. Keyword string
  47. }
  48. // ListPlans 分页查询课酬方案(按 effect_date DESC, id DESC:最新生效在前)。
  49. //
  50. // 入参:ctx;institutionID 机构(**必须 >0**,越权 → 1004);f 过滤;pg 分页。
  51. // 出参:([]model.CoursePayment, total, error);空结果非 nil;ErrBadParam/ErrUnscoped/ErrDBError。
  52. func (r *PayrollRepo) ListPlans(ctx context.Context, institutionID int64, f PayrollPlanFilter, pg PageQuery) ([]model.CoursePayment, int64, error) {
  53. if err := r.assertInstitution(institutionID); err != nil {
  54. return nil, 0, err
  55. }
  56. db, err := r.db(ctx)
  57. if err != nil {
  58. return nil, 0, err
  59. }
  60. build := func() *gorm.DB {
  61. q := db.Model(&model.CoursePayment{}).Where("institution_id = ?", institutionID)
  62. if f.CampusID != nil {
  63. q = q.Where("campus_id = ?", *f.CampusID)
  64. }
  65. if f.Actived != nil {
  66. q = q.Where("actived = ?", *f.Actived)
  67. }
  68. if kw := strings.TrimSpace(f.Keyword); kw != "" {
  69. q = q.Where("name LIKE ?", "%"+kw+"%")
  70. }
  71. return q
  72. }
  73. var total int64
  74. if err := build().Count(&total).Error; err != nil {
  75. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  76. }
  77. page, size := pg.Normalize()
  78. rows := make([]model.CoursePayment, 0, size)
  79. if err := build().
  80. Order("effect_date DESC, id DESC").
  81. Offset(PageOffset(page, size)).Limit(size).
  82. Find(&rows).Error; err != nil {
  83. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  84. }
  85. return rows, total, nil
  86. }
  87. // GetPlanByID 按主键取课酬方案(**必须属于该机构**,否则 3001 —— 不泄露存在性)。
  88. //
  89. // 入参:ctx;institutionID(>0);id 方案 ID(>0)。
  90. // 出参:*model.CoursePayment;ErrBadParam / ErrUnscoped / ErrNotFound / ErrDBError。
  91. func (r *PayrollRepo) GetPlanByID(ctx context.Context, institutionID, id int64) (*model.CoursePayment, error) {
  92. if err := r.assertInstitution(institutionID); err != nil {
  93. return nil, err
  94. }
  95. if id <= 0 {
  96. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 方案 id 必须 > 0"))
  97. }
  98. db, err := r.db(ctx)
  99. if err != nil {
  100. return nil, err
  101. }
  102. var out model.CoursePayment
  103. qErr := db.Model(&model.CoursePayment{}).
  104. Where("id = ? AND institution_id = ?", id, institutionID).
  105. Take(&out).Error
  106. if qErr != nil {
  107. return nil, wrapQueryErr(qErr, "GetPlanByID(t_course_payment)")
  108. }
  109. return &out, nil
  110. }
  111. // ListDetailsByPlanIDs 批量取多个方案下的全部明细(按 id ASC,顺序稳定)。
  112. //
  113. // 入参:ctx;planIDs 方案 ID 集合(空 → 空切片,不发 SQL)。
  114. // 出参:[]model.CoursePaymentDetail;ErrDBError。
  115. //
  116. // 为什么批量:列表页一页最多 200 个方案,逐方案查 = 200 次往返。
  117. func (r *PayrollRepo) ListDetailsByPlanIDs(ctx context.Context, planIDs []int64) ([]model.CoursePaymentDetail, error) {
  118. out := make([]model.CoursePaymentDetail, 0, len(planIDs))
  119. ids := dedupPositive(planIDs)
  120. if len(ids) == 0 {
  121. return out, nil
  122. }
  123. db, err := r.db(ctx)
  124. if err != nil {
  125. return nil, err
  126. }
  127. if err := db.Model(&model.CoursePaymentDetail{}).
  128. Where("course_payment_id IN ?", ids).
  129. Order("course_payment_id ASC, id ASC").
  130. Find(&out).Error; err != nil {
  131. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  132. }
  133. return out, nil
  134. }
  135. // CreatePlan 新建课酬方案(回填自增 ID)。
  136. //
  137. // 入参:ctx;tx(**必传**);row 方案行(Name/InstitutionID 必填)。
  138. // 出参:nil 成功;ErrBadParam / ErrDBError。
  139. func (r *PayrollRepo) CreatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment) error {
  140. if err := requireTx(tx, "CreatePlan"); err != nil {
  141. return err
  142. }
  143. if row == nil || strings.TrimSpace(strValueOrEmpty(row.Name)) == "" || row.InstitutionID <= 0 {
  144. return infraerrors.WithCause(infraerrors.ErrBadParam,
  145. errString("repository: CreatePlan 需要 name 与 institution_id"))
  146. }
  147. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  148. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  149. }
  150. return nil
  151. }
  152. // UpdatePlan 按列白名单更新方案(只改调用方显式给出的列)。
  153. //
  154. // 入参:ctx;tx(**必传**);row(ID 必填);columns 列名(非空,调用方用字面量)。
  155. // 出参:int64 受影响行数(0 = 不存在/不在机构);ErrBadParam / ErrDBError。
  156. //
  157. // 红线:**不含 institution_id**(作用域列创建时锁定,不允许改租户)。
  158. func (r *PayrollRepo) UpdatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment, columns []string) (int64, error) {
  159. if err := requireTx(tx, "UpdatePlan"); err != nil {
  160. return 0, err
  161. }
  162. if row == nil || row.ID <= 0 {
  163. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要 id"))
  164. }
  165. if len(columns) == 0 {
  166. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要显式列清单"))
  167. }
  168. res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursePayment{}).
  169. Where("id = ? AND institution_id = ?", row.ID, row.InstitutionID).
  170. Select(columns).Updates(row)
  171. if res.Error != nil {
  172. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  173. }
  174. return res.RowsAffected, nil
  175. }
  176. // ReplaceDetails 整体替换某方案的明细(**先按 course_payment_id 删、再批量插**)。
  177. //
  178. // 入参:ctx;tx(**必传**,删与插必须同一事务);planID 方案 ID(>0);
  179. // details 新明细(为空 → 只删不插,即"清空明细")。
  180. // 出参:nil 成功;ErrBadParam / ErrDBError。
  181. //
  182. // 为什么"整体替换"而不是逐条 diff:前端提交的是整案(D17 裁决 `PUT /:id` = 方案 id 整体替换),
  183. // diff 会引入"哪条被谁改了"的对账难题,而明细是**配置**不是流水,重写终态即可。
  184. func (r *PayrollRepo) ReplaceDetails(ctx context.Context, tx *gorm.DB, planID int64, details []*model.CoursePaymentDetail) error {
  185. if err := requireTx(tx, "ReplaceDetails"); err != nil {
  186. return err
  187. }
  188. if planID <= 0 {
  189. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ReplaceDetails 需要 plan_id"))
  190. }
  191. db := tx.WithContext(ctxOrBackground(ctx))
  192. if err := db.Where("course_payment_id = ?", planID).Delete(&model.CoursePaymentDetail{}).Error; err != nil {
  193. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  194. }
  195. if len(details) == 0 {
  196. return nil
  197. }
  198. for i := range details {
  199. details[i].CoursePaymentID = planID // 强制回填:防止调用方传错父 id
  200. }
  201. if err := db.Create(&details).Error; err != nil {
  202. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  203. }
  204. return nil
  205. }
  206. // strValueOrEmpty 取字符串指针的值(nil → 空串;仓储内部小工具)。
  207. func strValueOrEmpty(s *string) string {
  208. if s == nil {
  209. return ""
  210. }
  211. return *s
  212. }