| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- // 课酬方案 + 明细的数据访问(端点 58:`/admin/payroll/plans`)——AC-13d 追加。
- //
- // 依据:团队裁决 D17(两级形状)+ 目标态 DDL 两张表:
- //
- // t_course_payment : id, institution_id, campus_id, name, effect_date, actived,
- // create_user_id, create_time, memo
- // t_course_payment_detail : id, course_payment_id, grade_id, subject_id, class_type, classify_id,
- // teacher_id, name, percentage, min_payment, max_payment, reward,
- // teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo
- //
- // ---------------------------------------------------------------------------
- // 作用域(为什么不用 BaseRepo.Scoped)
- // ---------------------------------------------------------------------------
- //
- // `PayrollRepo` **不继承 BaseRepo**(见 `payroll_repo.go` 文件头:本包表混有无作用域列的表
- // `t_teacher_information`,`Scoped` 的裸列名会 `Unknown column`)。因此本文件的每个方法都
- // **显式接收 institutionID** 并 `WHERE institution_id = ?`,越权由 `assertInstitution` 统一拦:
- //
- // 非超管:institutionID 必须 == 作用域机构(否则 1004)
- // 超管 :可查任意机构(平台视角)
- //
- // ⚠ `t_course_payment_detail` **没有** institution_id 列 ⇒ 明细的作用域**只能经父方案**:
- // 本文件所有"按明细操作"的方法都要求调用方已用方案 id 校验过归属(Service 层保证)。
- //
- // ---------------------------------------------------------------------------
- // 事务纪律
- // ---------------------------------------------------------------------------
- //
- // 写方法一律接收 `tx *gorm.DB`(仓储不开事务);`ReplaceDetails` 的"先删后插"必须在
- // **同一事务**内(否则删完插失败 ⇒ 整案明细丢失)。
- package repository
- import (
- "context"
- "strings"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- )
- // PayrollPlanFilter 课酬方案列表过滤(端点 58 GET)。
- //
- // 字段:CampusID 校区(nil 不筛;0 = 只查机构级方案);Actived 启用(nil 不筛);
- // Keyword 方案名模糊(空不筛)。
- type PayrollPlanFilter struct {
- CampusID *int64
- Actived *bool
- Keyword string
- }
- // ListPlans 分页查询课酬方案(按 effect_date DESC, id DESC:最新生效在前)。
- //
- // 入参:ctx;institutionID 机构(**必须 >0**,越权 → 1004);f 过滤;pg 分页。
- // 出参:([]model.CoursePayment, total, error);空结果非 nil;ErrBadParam/ErrUnscoped/ErrDBError。
- func (r *PayrollRepo) ListPlans(ctx context.Context, institutionID int64, f PayrollPlanFilter, pg PageQuery) ([]model.CoursePayment, int64, error) {
- if err := r.assertInstitution(institutionID); err != nil {
- return nil, 0, err
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, 0, err
- }
- build := func() *gorm.DB {
- q := db.Model(&model.CoursePayment{}).Where("institution_id = ?", institutionID)
- if f.CampusID != nil {
- q = q.Where("campus_id = ?", *f.CampusID)
- }
- if f.Actived != nil {
- q = q.Where("actived = ?", *f.Actived)
- }
- if kw := strings.TrimSpace(f.Keyword); kw != "" {
- q = q.Where("name LIKE ?", "%"+kw+"%")
- }
- return q
- }
- var total int64
- if err := build().Count(&total).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- page, size := pg.Normalize()
- rows := make([]model.CoursePayment, 0, size)
- if err := build().
- Order("effect_date DESC, id DESC").
- Offset(PageOffset(page, size)).Limit(size).
- Find(&rows).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, total, nil
- }
- // GetPlanByID 按主键取课酬方案(**必须属于该机构**,否则 3001 —— 不泄露存在性)。
- //
- // 入参:ctx;institutionID(>0);id 方案 ID(>0)。
- // 出参:*model.CoursePayment;ErrBadParam / ErrUnscoped / ErrNotFound / ErrDBError。
- func (r *PayrollRepo) GetPlanByID(ctx context.Context, institutionID, id int64) (*model.CoursePayment, error) {
- if err := r.assertInstitution(institutionID); err != nil {
- return nil, err
- }
- if id <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 方案 id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursePayment
- qErr := db.Model(&model.CoursePayment{}).
- Where("id = ? AND institution_id = ?", id, institutionID).
- Take(&out).Error
- if qErr != nil {
- return nil, wrapQueryErr(qErr, "GetPlanByID(t_course_payment)")
- }
- return &out, nil
- }
- // ListDetailsByPlanIDs 批量取多个方案下的全部明细(按 id ASC,顺序稳定)。
- //
- // 入参:ctx;planIDs 方案 ID 集合(空 → 空切片,不发 SQL)。
- // 出参:[]model.CoursePaymentDetail;ErrDBError。
- //
- // 为什么批量:列表页一页最多 200 个方案,逐方案查 = 200 次往返。
- func (r *PayrollRepo) ListDetailsByPlanIDs(ctx context.Context, planIDs []int64) ([]model.CoursePaymentDetail, error) {
- out := make([]model.CoursePaymentDetail, 0, len(planIDs))
- ids := dedupPositive(planIDs)
- if len(ids) == 0 {
- return out, nil
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- if err := db.Model(&model.CoursePaymentDetail{}).
- Where("course_payment_id IN ?", ids).
- Order("course_payment_id ASC, id ASC").
- Find(&out).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return out, nil
- }
- // CreatePlan 新建课酬方案(回填自增 ID)。
- //
- // 入参:ctx;tx(**必传**);row 方案行(Name/InstitutionID 必填)。
- // 出参:nil 成功;ErrBadParam / ErrDBError。
- func (r *PayrollRepo) CreatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment) error {
- if err := requireTx(tx, "CreatePlan"); err != nil {
- return err
- }
- if row == nil || strings.TrimSpace(strValueOrEmpty(row.Name)) == "" || row.InstitutionID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: CreatePlan 需要 name 与 institution_id"))
- }
- if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // UpdatePlan 按列白名单更新方案(只改调用方显式给出的列)。
- //
- // 入参:ctx;tx(**必传**);row(ID 必填);columns 列名(非空,调用方用字面量)。
- // 出参:int64 受影响行数(0 = 不存在/不在机构);ErrBadParam / ErrDBError。
- //
- // 红线:**不含 institution_id**(作用域列创建时锁定,不允许改租户)。
- func (r *PayrollRepo) UpdatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment, columns []string) (int64, error) {
- if err := requireTx(tx, "UpdatePlan"); err != nil {
- return 0, err
- }
- if row == nil || row.ID <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要 id"))
- }
- if len(columns) == 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要显式列清单"))
- }
- res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursePayment{}).
- Where("id = ? AND institution_id = ?", row.ID, row.InstitutionID).
- Select(columns).Updates(row)
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // ReplaceDetails 整体替换某方案的明细(**先按 course_payment_id 删、再批量插**)。
- //
- // 入参:ctx;tx(**必传**,删与插必须同一事务);planID 方案 ID(>0);
- // details 新明细(为空 → 只删不插,即"清空明细")。
- // 出参:nil 成功;ErrBadParam / ErrDBError。
- //
- // 为什么"整体替换"而不是逐条 diff:前端提交的是整案(D17 裁决 `PUT /:id` = 方案 id 整体替换),
- // diff 会引入"哪条被谁改了"的对账难题,而明细是**配置**不是流水,重写终态即可。
- func (r *PayrollRepo) ReplaceDetails(ctx context.Context, tx *gorm.DB, planID int64, details []*model.CoursePaymentDetail) error {
- if err := requireTx(tx, "ReplaceDetails"); err != nil {
- return err
- }
- if planID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ReplaceDetails 需要 plan_id"))
- }
- db := tx.WithContext(ctxOrBackground(ctx))
- if err := db.Where("course_payment_id = ?", planID).Delete(&model.CoursePaymentDetail{}).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- if len(details) == 0 {
- return nil
- }
- for i := range details {
- details[i].CoursePaymentID = planID // 强制回填:防止调用方传错父 id
- }
- if err := db.Create(&details).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // strValueOrEmpty 取字符串指针的值(nil → 空串;仓储内部小工具)。
- func strValueOrEmpty(s *string) string {
- if s == nil {
- return ""
- }
- return *s
- }
|