// 课酬方案 + 明细的数据访问(端点 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 }