| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- // 课酬试算 + 维护型端点处理器(端点 59、61、62、63)——AC-13d 第一批。
- //
- // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 / §3.4 D 表;
- // 设计 `academic-domain-design.md` §4.5(角色白名单)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)/
- // §4.6(`PayrollQuote` 9 字段,**不得裁掉溯源字段**)。
- //
- // ---------------------------------------------------------------------------
- // 端点 → 服务方法对照
- // ---------------------------------------------------------------------------
- //
- // 59 POST /admin/payroll/quote PayrollService.QuoteTeacherReward(AC-10,SP26)
- // 61 POST /admin/maintenance/courses-max-people MaintenanceService.BackfillCoursesMaxPeople(SP28)
- // 62 POST /admin/maintenance/courses-campus MaintenanceService.BackfillCoursesCampus(SP29)
- // 63 POST /admin/maintenance/activity-sync RebuildActivityUsedLog(SP30)/ ReplayActivitySigned(SP31)
- //
- // 本批**不含** 58(课酬方案 CRUD)与 60(收支字典/记录):契约把"方案+明细"、"字典+记录"
- // 各自压成一行,字段无法逐列溯源(详见 `dto_payroll.go` 文件头),已报偏差等裁决。
- //
- // ---------------------------------------------------------------------------
- // 三条纪律(与 course_handler.go 同源)
- // ---------------------------------------------------------------------------
- //
- // 1. **61~63 仅 `super_admin`**:路由白名单是第一道(契约 §2.5),Service 层 `assertSuperAdmin`
- // 是第二道(AC-11 已交付)。**两层都要有**——白名单写错时第二层仍拦得住。
- // 2. **维护端点是"全表级"动作**(SP30 会清 `used_log` 整表):Handler 不提供任何"部分执行"参数,
- // 也没有 dry-run;要重跑就再调一次(三个 SP 都幂等)。
- // 3. 维护类不做作用域过滤(跨机构修数据是**目的**),因此**越权防线只有白名单 + 超管断言**两条。
- package handler
- import (
- "strconv"
- "strings"
- "github.com/gofiber/fiber/v2"
- "yuansiwei/unified-backend/internal/academic/service"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- "yuansiwei/unified-backend/internal/infra/response"
- )
- // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
- const (
- // PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。
- PathPayrollQuote = "/admin/payroll/quote"
- // PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。
- PathMaintenanceCoursesMaxPeople = "/admin/maintenance/courses-max-people"
- // PathMaintenanceCoursesCampus 端点 62:修复课程 campus_id(SP29)。
- PathMaintenanceCoursesCampus = "/admin/maintenance/courses-campus"
- // PathMaintenanceActivitySync 端点 63:活动数据同步(SP30/SP31)。
- PathMaintenanceActivitySync = "/admin/maintenance/activity-sync"
- )
- // activitySyncModeUsedLog 端点 63 的 mode 取值:重建 `t_courses_activity_used_log`(SP30)。
- const activitySyncModeUsedLog = "used-log"
- // activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。
- const activitySyncModeSigned = "signed"
- // QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。
- //
- // 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。
- // 出参(成功):`payrollQuoteView`(9 字段:4 业务列 + 4 溯源字段 + detailId)。
- // 出参(失败):1001/2001(缺参/日期格式非法)/1004(schoolId 与作用域机构不一致)/
- // **3112**(两级方案都没有 or 四级规则一条都没命中)/5002。
- //
- // **与端点 51 的关系(重要)**:本端点返回的是**规则**(percentage/min/max/reward),
- // **不是最终课酬金额**——最终金额还需要 `income`/`studentCount`/`teacherRewardType`,
- // 契约没给这三个入参。二者共用同一 `computeRecordReward`(结课侧),本端点**不重复实现公式**,
- // 因此不会出现"试算一套、结课另一套"。
- func (h *Handler) QuotePayroll(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- var req PayrollQuoteReq
- if !validateReq(c, &req) {
- return nil // body code=2001 已写出
- }
- date, hasDate, derr := parseTimeParam(req.Date, "date")
- if derr != nil {
- return response.Fail(c, derr)
- }
- if !hasDate {
- // `validate:"required"` 已挡住空串;这里只兜"传了空白串被 trim 掉"的情形
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("date 不能为空")))
- }
- if req.TeacherExp < 0 {
- // dump 763609:负数按 0 兜底(不报错)。此处**不拒绝**,只归一,保持与旧系统一致。
- req.TeacherExp = 0
- }
- svc := h.servicesFor(sc)
- quote, err := svc.payroll.QuoteTeacherReward(c.Context(), service.QuotePayrollInput{
- TeacherID: strings.TrimSpace(req.TeacherID),
- TeacherExp: req.TeacherExp,
- InstitutionID: req.SchoolID,
- CampusID: req.CampusID,
- ClassifyID: req.ClassifyID,
- Date: date,
- })
- if err != nil {
- return response.Fail(c, err) // 2001/1004/3112/5002
- }
- return response.OK(c, payrollQuoteView{
- DetailID: quote.DetailID,
- SchemeID: quote.SchemeID,
- SchemeCampusID: quote.SchemeCampusID,
- SchemeScope: quote.SchemeScope,
- RuleTier: quote.RuleTier,
- Percentage: quote.Percentage,
- Min: quote.Min.String(),
- Max: quote.Max.String(),
- Reward: quote.Reward.String(),
- })
- }
- // BackfillCoursesMaxPeople 修复课程 `max_people`(端点 61 `POST /admin/maintenance/courses-max-people`,SP28)。
- //
- // 入参:无 body。
- // 出参(成功):`{affected, message}`(**可重跑**:第二次 `affected=0`)。
- // 出参(失败):1001/**1004**(非超管)/5002。
- //
- // 幂等判据(AC-11 已实现):只处理 `max_people = 0` 的行;分类也没有值时**跳过不写 0**
- // (写 0 等于不改,却会让运维以为"已修复")。
- func (h *Handler) BackfillCoursesMaxPeople(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- svc := h.servicesFor(sc)
- res, err := svc.maintenance.BackfillCoursesMaxPeople(c.Context())
- if err != nil {
- return response.Fail(c, err) // 1004 非超管 / 5002
- }
- msg := "已回填 " + strconv.FormatInt(int64(res.Filled), 10) + " 门课程的 max_people"
- if res.Filled == 0 {
- msg = "无待回填课程(可重跑:已是最新)"
- }
- if res.Skipped > 0 {
- msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 门(分类未配置,未写 0)"
- }
- return response.OK(c, MaintenanceResultResp{Affected: res.Affected, Message: msg})
- }
- // BackfillCoursesCampus 修复课程 `campus_id`(端点 62 `POST /admin/maintenance/courses-campus`,SP29)。
- //
- // 入参:无 body。
- // 出参(成功):`{affected, message}`(**可重跑**)。
- // 出参(失败):1001/**1004**(非超管)/5002。
- //
- // **反直觉口径(必须保留)**:多校区课程(去重校区数 ≥ 2)**不回填、保持 0**("机构级课程");
- // 无排班规则的课程(计数 = 0)同样不回填(0 ≠ 1)。
- func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- svc := h.servicesFor(sc)
- res, err := svc.maintenance.BackfillCoursesCampus(c.Context())
- if err != nil {
- return response.Fail(c, err)
- }
- msg := "已回填 " + strconv.FormatInt(int64(res.Updated), 10) + " 门课程的 campus_id"
- if res.Updated == 0 {
- msg = "无待回填课程(可重跑:已是最新)"
- }
- if res.SkippedMultiCampus > 0 {
- msg += ";多校区课程 " + strconv.FormatInt(int64(res.SkippedMultiCampus), 10) + " 门保持 0(机构级,不填)"
- }
- if res.SkippedNoRule > 0 {
- msg += ";无排班规则 " + strconv.FormatInt(int64(res.SkippedNoRule), 10) + " 门未动"
- }
- return response.OK(c, MaintenanceResultResp{Affected: int64(res.Updated), Message: msg})
- }
- // SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。
- //
- // 入参(body):`mode` ∈ {`used-log`, `signed`};**缺省/非法 → 2001**(契约 §3.4 D 表原文)。
- // 出参(成功):`{affected, message}`。
- // 出参(失败):1001/2001(mode 缺失或非法)/**1004**(非超管)/5002。
- //
- // 两个模式各自幂等:
- //
- // used-log → `RebuildActivityUsedLog`:**同事务 DELETE + 批量 INSERT**(不用 TRUNCATE —— DDL 隐式提交
- // 会"清空成功、重建失败"时数据全丢,见 §3.8-BUG#3);重跑结果一致。
- // signed → `ReplayActivitySigned`:只更新 `signed.status > 0` 的行(已取消报名不得被历史工单"复活")。
- func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- var req ActivitySyncReq
- if !validateReq(c, &req) {
- return nil // body code=2001 已写出
- }
- mode := strings.TrimSpace(req.Mode)
- svc := h.servicesFor(sc)
- switch mode {
- case activitySyncModeUsedLog:
- res, serr := svc.maintenance.RebuildActivityUsedLog(c.Context())
- if serr != nil {
- return response.Fail(c, serr)
- }
- msg := "used_log 已重建:" + strconv.FormatInt(res.Inserted, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) +
- ",删除旧行 " + strconv.FormatInt(res.Deleted, 10)
- if res.Skipped > 0 {
- msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 行(活动/用户为空,目标态 NOT NULL 无法表达)"
- }
- return response.OK(c, MaintenanceResultResp{Affected: res.Inserted, Message: msg + ")"})
- case activitySyncModeSigned:
- res, serr := svc.maintenance.ReplayActivitySigned(c.Context())
- if serr != nil {
- return response.Fail(c, serr)
- }
- msg := "signed 已重放:更新 " + strconv.FormatInt(res.Updated, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ")"
- if res.SkippedCanceled > 0 {
- msg += ";跳过 " + strconv.FormatInt(res.SkippedCanceled, 10) + " 行(已取消报名,不得复活)"
- }
- return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg})
- default:
- // 契约 §3.4 D 表:mode 缺省/非法 → **2001**(不是 2002)。
- // 理由:这里的语义是"必须指定同步哪种数据",属**未提供必要参数**,与"提供了非法值"在
- // 前端提示上是同一动作(去选一个模式),而契约明文写了 2001 ⇒ 按契约,不按 2002 通则。
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("mode 必须是 used-log 或 signed(收到 "+mode+")")))
- }
- }
|