payroll_handler.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. // 课酬试算 + 维护型端点处理器(端点 59、61、62、63)——AC-13d 第一批。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 / §3.4 D 表;
  4. // 设计 `academic-domain-design.md` §4.5(角色白名单)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)/
  5. // §4.6(`PayrollQuote` 9 字段,**不得裁掉溯源字段**)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 端点 → 服务方法对照
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 59 POST /admin/payroll/quote PayrollService.QuoteTeacherReward(AC-10,SP26)
  12. // 61 POST /admin/maintenance/courses-max-people MaintenanceService.BackfillCoursesMaxPeople(SP28)
  13. // 62 POST /admin/maintenance/courses-campus MaintenanceService.BackfillCoursesCampus(SP29)
  14. // 63 POST /admin/maintenance/activity-sync RebuildActivityUsedLog(SP30)/ ReplayActivitySigned(SP31)
  15. //
  16. // 本批**不含** 58(课酬方案 CRUD)与 60(收支字典/记录):契约把"方案+明细"、"字典+记录"
  17. // 各自压成一行,字段无法逐列溯源(详见 `dto_payroll.go` 文件头),已报偏差等裁决。
  18. //
  19. // ---------------------------------------------------------------------------
  20. // 三条纪律(与 course_handler.go 同源)
  21. // ---------------------------------------------------------------------------
  22. //
  23. // 1. **61~63 仅 `super_admin`**:路由白名单是第一道(契约 §2.5),Service 层 `assertSuperAdmin`
  24. // 是第二道(AC-11 已交付)。**两层都要有**——白名单写错时第二层仍拦得住。
  25. // 2. **维护端点是"全表级"动作**(SP30 会清 `used_log` 整表):Handler 不提供任何"部分执行"参数,
  26. // 也没有 dry-run;要重跑就再调一次(三个 SP 都幂等)。
  27. // 3. 维护类不做作用域过滤(跨机构修数据是**目的**),因此**越权防线只有白名单 + 超管断言**两条。
  28. package handler
  29. import (
  30. "strconv"
  31. "strings"
  32. "github.com/gofiber/fiber/v2"
  33. "yuansiwei/unified-backend/internal/academic/service"
  34. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  35. "yuansiwei/unified-backend/internal/infra/response"
  36. )
  37. // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
  38. const (
  39. // PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。
  40. PathPayrollQuote = "/admin/payroll/quote"
  41. // PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。
  42. PathMaintenanceCoursesMaxPeople = "/admin/maintenance/courses-max-people"
  43. // PathMaintenanceCoursesCampus 端点 62:修复课程 campus_id(SP29)。
  44. PathMaintenanceCoursesCampus = "/admin/maintenance/courses-campus"
  45. // PathMaintenanceActivitySync 端点 63:活动数据同步(SP30/SP31)。
  46. PathMaintenanceActivitySync = "/admin/maintenance/activity-sync"
  47. )
  48. // activitySyncModeUsedLog 端点 63 的 mode 取值:重建 `t_courses_activity_used_log`(SP30)。
  49. const activitySyncModeUsedLog = "used-log"
  50. // activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。
  51. const activitySyncModeSigned = "signed"
  52. // QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。
  53. //
  54. // 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。
  55. // 出参(成功):`payrollQuoteView`(9 字段:4 业务列 + 4 溯源字段 + detailId)。
  56. // 出参(失败):1001/2001(缺参/日期格式非法)/1004(schoolId 与作用域机构不一致)/
  57. // **3112**(两级方案都没有 or 四级规则一条都没命中)/5002。
  58. //
  59. // **与端点 51 的关系(重要)**:本端点返回的是**规则**(percentage/min/max/reward),
  60. // **不是最终课酬金额**——最终金额还需要 `income`/`studentCount`/`teacherRewardType`,
  61. // 契约没给这三个入参。二者共用同一 `computeRecordReward`(结课侧),本端点**不重复实现公式**,
  62. // 因此不会出现"试算一套、结课另一套"。
  63. func (h *Handler) QuotePayroll(c *fiber.Ctx) error {
  64. sc, err := mustScope(c)
  65. if err != nil {
  66. return response.Fail(c, err)
  67. }
  68. var req PayrollQuoteReq
  69. if !validateReq(c, &req) {
  70. return nil // body code=2001 已写出
  71. }
  72. date, hasDate, derr := parseTimeParam(req.Date, "date")
  73. if derr != nil {
  74. return response.Fail(c, derr)
  75. }
  76. if !hasDate {
  77. // `validate:"required"` 已挡住空串;这里只兜"传了空白串被 trim 掉"的情形
  78. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  79. errString("date 不能为空")))
  80. }
  81. if req.TeacherExp < 0 {
  82. // dump 763609:负数按 0 兜底(不报错)。此处**不拒绝**,只归一,保持与旧系统一致。
  83. req.TeacherExp = 0
  84. }
  85. svc := h.servicesFor(sc)
  86. quote, err := svc.payroll.QuoteTeacherReward(c.Context(), service.QuotePayrollInput{
  87. TeacherID: strings.TrimSpace(req.TeacherID),
  88. TeacherExp: req.TeacherExp,
  89. InstitutionID: req.SchoolID,
  90. CampusID: req.CampusID,
  91. ClassifyID: req.ClassifyID,
  92. Date: date,
  93. })
  94. if err != nil {
  95. return response.Fail(c, err) // 2001/1004/3112/5002
  96. }
  97. return response.OK(c, payrollQuoteView{
  98. DetailID: quote.DetailID,
  99. SchemeID: quote.SchemeID,
  100. SchemeCampusID: quote.SchemeCampusID,
  101. SchemeScope: quote.SchemeScope,
  102. RuleTier: quote.RuleTier,
  103. Percentage: quote.Percentage,
  104. Min: quote.Min.String(),
  105. Max: quote.Max.String(),
  106. Reward: quote.Reward.String(),
  107. })
  108. }
  109. // BackfillCoursesMaxPeople 修复课程 `max_people`(端点 61 `POST /admin/maintenance/courses-max-people`,SP28)。
  110. //
  111. // 入参:无 body。
  112. // 出参(成功):`{affected, message}`(**可重跑**:第二次 `affected=0`)。
  113. // 出参(失败):1001/**1004**(非超管)/5002。
  114. //
  115. // 幂等判据(AC-11 已实现):只处理 `max_people = 0` 的行;分类也没有值时**跳过不写 0**
  116. // (写 0 等于不改,却会让运维以为"已修复")。
  117. func (h *Handler) BackfillCoursesMaxPeople(c *fiber.Ctx) error {
  118. sc, err := mustScope(c)
  119. if err != nil {
  120. return response.Fail(c, err)
  121. }
  122. svc := h.servicesFor(sc)
  123. res, err := svc.maintenance.BackfillCoursesMaxPeople(c.Context())
  124. if err != nil {
  125. return response.Fail(c, err) // 1004 非超管 / 5002
  126. }
  127. msg := "已回填 " + strconv.FormatInt(int64(res.Filled), 10) + " 门课程的 max_people"
  128. if res.Filled == 0 {
  129. msg = "无待回填课程(可重跑:已是最新)"
  130. }
  131. if res.Skipped > 0 {
  132. msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 门(分类未配置,未写 0)"
  133. }
  134. return response.OK(c, MaintenanceResultResp{Affected: res.Affected, Message: msg})
  135. }
  136. // BackfillCoursesCampus 修复课程 `campus_id`(端点 62 `POST /admin/maintenance/courses-campus`,SP29)。
  137. //
  138. // 入参:无 body。
  139. // 出参(成功):`{affected, message}`(**可重跑**)。
  140. // 出参(失败):1001/**1004**(非超管)/5002。
  141. //
  142. // **反直觉口径(必须保留)**:多校区课程(去重校区数 ≥ 2)**不回填、保持 0**("机构级课程");
  143. // 无排班规则的课程(计数 = 0)同样不回填(0 ≠ 1)。
  144. func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error {
  145. sc, err := mustScope(c)
  146. if err != nil {
  147. return response.Fail(c, err)
  148. }
  149. svc := h.servicesFor(sc)
  150. res, err := svc.maintenance.BackfillCoursesCampus(c.Context())
  151. if err != nil {
  152. return response.Fail(c, err)
  153. }
  154. msg := "已回填 " + strconv.FormatInt(int64(res.Updated), 10) + " 门课程的 campus_id"
  155. if res.Updated == 0 {
  156. msg = "无待回填课程(可重跑:已是最新)"
  157. }
  158. if res.SkippedMultiCampus > 0 {
  159. msg += ";多校区课程 " + strconv.FormatInt(int64(res.SkippedMultiCampus), 10) + " 门保持 0(机构级,不填)"
  160. }
  161. if res.SkippedNoRule > 0 {
  162. msg += ";无排班规则 " + strconv.FormatInt(int64(res.SkippedNoRule), 10) + " 门未动"
  163. }
  164. return response.OK(c, MaintenanceResultResp{Affected: int64(res.Updated), Message: msg})
  165. }
  166. // SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。
  167. //
  168. // 入参(body):`mode` ∈ {`used-log`, `signed`};**缺省/非法 → 2001**(契约 §3.4 D 表原文)。
  169. // 出参(成功):`{affected, message}`。
  170. // 出参(失败):1001/2001(mode 缺失或非法)/**1004**(非超管)/5002。
  171. //
  172. // 两个模式各自幂等:
  173. //
  174. // used-log → `RebuildActivityUsedLog`:**同事务 DELETE + 批量 INSERT**(不用 TRUNCATE —— DDL 隐式提交
  175. // 会"清空成功、重建失败"时数据全丢,见 §3.8-BUG#3);重跑结果一致。
  176. // signed → `ReplayActivitySigned`:只更新 `signed.status > 0` 的行(已取消报名不得被历史工单"复活")。
  177. func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
  178. sc, err := mustScope(c)
  179. if err != nil {
  180. return response.Fail(c, err)
  181. }
  182. var req ActivitySyncReq
  183. if !validateReq(c, &req) {
  184. return nil // body code=2001 已写出
  185. }
  186. mode := strings.TrimSpace(req.Mode)
  187. svc := h.servicesFor(sc)
  188. switch mode {
  189. case activitySyncModeUsedLog:
  190. res, serr := svc.maintenance.RebuildActivityUsedLog(c.Context())
  191. if serr != nil {
  192. return response.Fail(c, serr)
  193. }
  194. msg := "used_log 已重建:" + strconv.FormatInt(res.Inserted, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) +
  195. ",删除旧行 " + strconv.FormatInt(res.Deleted, 10)
  196. if res.Skipped > 0 {
  197. msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 行(活动/用户为空,目标态 NOT NULL 无法表达)"
  198. }
  199. return response.OK(c, MaintenanceResultResp{Affected: res.Inserted, Message: msg + ")"})
  200. case activitySyncModeSigned:
  201. res, serr := svc.maintenance.ReplayActivitySigned(c.Context())
  202. if serr != nil {
  203. return response.Fail(c, serr)
  204. }
  205. msg := "signed 已重放:更新 " + strconv.FormatInt(res.Updated, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ")"
  206. if res.SkippedCanceled > 0 {
  207. msg += ";跳过 " + strconv.FormatInt(res.SkippedCanceled, 10) + " 行(已取消报名,不得复活)"
  208. }
  209. return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg})
  210. default:
  211. // 契约 §3.4 D 表:mode 缺省/非法 → **2001**(不是 2002)。
  212. // 理由:这里的语义是"必须指定同步哪种数据",属**未提供必要参数**,与"提供了非法值"在
  213. // 前端提示上是同一动作(去选一个模式),而契约明文写了 2001 ⇒ 按契约,不按 2002 通则。
  214. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  215. errString("mode 必须是 used-log 或 signed(收到 "+mode+")")))
  216. }
  217. }