| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- // 课酬试算 + 维护型端点处理器(端点 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 (
- // PathPayrollPlans 端点 58:课酬方案列表 / 新建(`/admin/payroll/plans`)。
- PathPayrollPlans = "/admin/payroll/plans"
- // PathPayrollPlanDetail 端点 58:更新方案(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
- PathPayrollPlanDetail = "/admin/payroll/plans/:id"
- // 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"
- // ---------------------------------------------------------------- 端点 58:课酬方案
- // ListPayrollPlans 课酬方案列表(端点 58 `GET /admin/payroll/plans`)。
- //
- // 入参(query):`institutionId`(必填 >0)、`campusId?`、`actived?`(0/1)、`keyword?`、分页。
- // 出参(成功):`{list,total,page,pageSize,pages}`,行为**两级形状**(方案 + `details[]`)。
- // 出参(失败):1001/2001(缺 institutionId)/2002(campusId/actived 非法)/**1004**(跨机构)/5002。
- //
- // 为什么要显式 `institutionId`:`t_course_payment_detail` 没有机构列、`PayrollRepo` 又不走 `Scoped`
- // ⇒ 机构维度只能由调用方给出,并由 `assertInstitution` 校验(非超管必须等于作用域机构)。
- func (h *Handler) ListPayrollPlans(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- institutionID, err := parseIntQuery(c, "institutionId")
- if err != nil {
- return response.Fail(c, err)
- }
- if institutionID <= 0 {
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("institutionId 必填且必须 > 0")))
- }
- campusID, err := parseIntQuery(c, "campusId")
- if err != nil {
- return response.Fail(c, err)
- }
- if campusID < 0 {
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
- errString("campusId 不得为负")))
- }
- var campusPtr *int64
- if strings.TrimSpace(c.Query("campusId")) != "" {
- v := campusID
- campusPtr = &v
- }
- activedFilter, _, err := parseEnumQuery(c, "actived", map[int64]struct{}{0: {}, 1: {}})
- if err != nil {
- return response.Fail(c, err)
- }
- var activedPtr *bool
- if activedFilter != nil {
- v := *activedFilter == 1
- activedPtr = &v
- }
- page, pageSize := parsePage(c)
- svc := h.servicesFor(sc)
- plans, total, err := svc.payrollPlan.ListPlans(c.Context(), service.PayrollPlanQuery{
- InstitutionID: institutionID,
- CampusID: campusPtr,
- Actived: activedPtr,
- Keyword: c.Query("keyword"),
- Page: page,
- PageSize: pageSize,
- })
- if err != nil {
- return response.Fail(c, err)
- }
- views := make([]payrollPlanView, 0, len(plans))
- for i := range plans {
- views = append(views, toPayrollPlanView(&plans[i]))
- }
- return response.Page(c, views, page, pageSize, total)
- }
- // CreatePayrollPlan 新建课酬方案(端点 58 `POST /admin/payroll/plans`)。
- //
- // 入参(body):见 `CreatePayrollPlanReq`(`schemeName` 必填,`details[]` 可空)。
- // 出参(成功):`payrollPlanView`(含新方案 id 与回读的明细)。
- // 出参(失败):1001/2001/2002(比例非 0~100、教龄区间倒挂、金额非法)/1004/5002。
- //
- // 事务:方案 + 全部明细在**同一事务**内写入(明细写失败 ⇒ 方案不落库,不会留下"空方案")。
- func (h *Handler) CreatePayrollPlan(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- var req CreatePayrollPlanReq
- if !validateReq(c, &req) {
- return nil
- }
- institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
- if err != nil {
- return response.Fail(c, err)
- }
- effectDate, hasEffect, err := parseTimeParam(req.EffectDate, "effectDate")
- if err != nil {
- return response.Fail(c, err)
- }
- details, ok := h.parsePayrollPlanDetails(c, req.Details)
- if !ok {
- return nil
- }
- in := service.CreatePlanInput{
- InstitutionID: institutionID,
- CampusID: req.CampusID,
- SchemeName: req.SchemeName,
- Actived: req.Actived,
- Details: details,
- OperatorID: sc.UserID,
- }
- if hasEffect {
- in.EffectDate = &effectDate
- }
- svc := h.servicesFor(sc)
- plan, err := svc.payrollPlan.CreatePlan(c.Context(), in)
- if err != nil {
- return response.Fail(c, err)
- }
- return response.OK(c, toPayrollPlanView(plan))
- }
- // UpdatePayrollPlan 更新课酬方案(端点 58 `PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
- //
- // 入参(body):见 `UpdatePayrollPlanReq`(nil = 不改;`details` 给了就**整体替换**)。
- // 出参(成功):`payrollPlanView`(更新后回读)。
- // 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/1004/5002。
- //
- // **整体替换明细**(D17 裁决):先按 `course_payment_id` 删除、再插入,同事务。
- // 不做逐条 diff —— 明细是**配置**不是流水,重写终态即可;diff 会引入"哪条被谁改了"的对账难题。
- func (h *Handler) UpdatePayrollPlan(c *fiber.Ctx) error {
- sc, err := mustScope(c)
- if err != nil {
- return response.Fail(c, err)
- }
- var req UpdatePayrollPlanReq
- if !validateReq(c, &req) {
- return nil
- }
- institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
- if err != nil {
- return response.Fail(c, err)
- }
- in := service.UpdatePlanInput{
- ID: parseIDParam(c.Params("id")),
- InstitutionID: institutionID,
- CampusID: req.CampusID,
- SchemeName: req.SchemeName,
- Actived: req.Actived,
- }
- if req.EffectDate != nil {
- at, has, perr := parseTimeParam(*req.EffectDate, "effectDate")
- if perr != nil {
- return response.Fail(c, perr)
- }
- if has {
- in.EffectDate = &at
- }
- }
- if req.Details != nil {
- details, ok := h.parsePayrollPlanDetails(c, *req.Details)
- if !ok {
- return nil
- }
- in.Details = details
- in.ReplaceDetail = true // 给了 details(哪怕是 `[]`)= 整体替换 / 清空
- }
- svc := h.servicesFor(sc)
- plan, err := svc.payrollPlan.UpdatePlan(c.Context(), in)
- if err != nil {
- return response.Fail(c, err)
- }
- return response.OK(c, toPayrollPlanView(plan))
- }
- // parsePayrollPlanDetails 解析明细入参(金额字符串 → money.Amount;其余原样透传给 Service 校验)。
- //
- // 入参:c(仅用于写失败响应);raw 明细入参。出参:(服务入参, 是否成功);失败时 body code=2002。
- //
- // 金额用 `parseMoneyOrZero`(空串 = 0.00 = 不限制),**不用** `parseMoney`:
- // `min`/`max`/`reward` 都是可空字段,空串不等于"填错"。
- func (h *Handler) parsePayrollPlanDetails(c *fiber.Ctx, raw []PayrollPlanDetailReq) ([]service.PayrollPlanDetailInput, bool) {
- out := make([]service.PayrollPlanDetailInput, 0, len(raw))
- for i := range raw {
- item := &raw[i]
- minAmount, ok := parseMoneyOrZero(c, item.Min, "details.min")
- if !ok {
- return nil, false
- }
- maxAmount, ok := parseMoneyOrZero(c, item.Max, "details.max")
- if !ok {
- return nil, false
- }
- reward, ok := parseMoneyOrZero(c, item.Reward, "details.reward")
- if !ok {
- return nil, false
- }
- out = append(out, service.PayrollPlanDetailInput{
- TeacherID: strings.TrimSpace(item.TeacherID),
- ClassifyID: item.ClassifyID,
- ClassType: item.ClassType,
- GradeID: item.GradeID,
- SubjectID: item.SubjectID,
- TeacherCert: item.TeacherCert,
- TeacherExpMin: item.TeacherExpMin,
- TeacherExpMax: item.TeacherExpMax,
- Percentage: item.Percentage,
- Min: minAmount,
- Max: maxAmount,
- Reward: reward,
- DisplayName: item.DisplayName,
- Actived: item.DetailActived,
- })
- }
- return out, true
- }
- // toPayrollPlanView 把服务视图转成响应(金额两位小数字符串;明细空 → [])。
- func toPayrollPlanView(plan *service.PayrollPlan) payrollPlanView {
- details := make([]payrollPlanDetailView, 0, len(plan.Details))
- for i := range plan.Details {
- d := &plan.Details[i]
- details = append(details, payrollPlanDetailView{
- DetailID: d.DetailID,
- TeacherID: d.TeacherID,
- ClassifyID: d.ClassifyID,
- ClassType: d.ClassType,
- GradeID: d.GradeID,
- SubjectID: d.SubjectID,
- TeacherCert: d.TeacherCert,
- TeacherExpMin: d.TeacherExpMin,
- TeacherExpMax: d.TeacherExpMax,
- Percentage: d.Percentage,
- Min: d.Min.String(),
- Max: d.Max.String(),
- Reward: d.Reward.String(),
- DisplayName: d.DisplayName,
- DetailActived: d.DetailActived,
- })
- }
- return payrollPlanView{
- ID: plan.ID,
- InstitutionID: plan.InstitutionID,
- CampusID: plan.CampusID,
- SchemeName: plan.SchemeName,
- EffectDate: plan.EffectDate,
- Actived: plan.Actived,
- Details: details,
- }
- }
- // 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;传了非法 → 2002**(团队裁决,与端点 25 一致)。
- // 出参(成功):`{affected, message}`。
- // 出参(失败):1001/2001(mode 缺失)/2002(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)
- if mode == "" {
- // **缺失 → 2001**(未提供必要参数)
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("mode 不能为空(used-log 或 signed)")))
- }
- if mode != activitySyncModeUsedLog && mode != activitySyncModeSigned {
- // **传了但不在白名单 → 2002**(枚举越界;与端点 25 的 `numberSign` 同一口径)。
- // 与"缺失"分开的理由:前端提示不同("请选择同步模式" vs "取值非法"),且全局规则是
- // "枚举越界 = 2002"——同一错误类别不该在两个端点用不同码。
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
- errString("mode 取值非法 "+mode+"(仅支持 used-log / signed)")))
- }
- 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:
- // 不可达:上面已把"缺失/非法"分别拦成 2001/2002。保留兜底是为了让"新增 mode 却忘了
- // 加 case"变成显式错误,而不是静默落进某个分支(尤其不能静默走 used-log —— 那会清表)。
- return response.Fail(c, infraerrors.WithCause(infraerrors.ErrInternal,
- errString("mode 分支未实现 "+mode)))
- }
- }
|