| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- // 课酬方案与规则仓储(sp_course_payment_detail_get_teacher_detail 的 4 次单查)。
- //
- // 依据:unified/03-migration/academic-domain-design.md
- // - §3.4 #26 `sp_course_payment_detail_get_teacher_detail`(dump **763575~763665**)
- // - §10.1 `3112 课酬方案未配置`(本文件的"无方案"由 Service 翻译成 3112)
- //
- // ---------------------------------------------------------------------------
- // dump 原文与本仓储方法的映射(逐行号)
- // ---------------------------------------------------------------------------
- //
- // 763594 游标 `DECLARE cur CURSOR FOR SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
- // → GetClassify(**按主键单查**,替代游标;按主键过滤 ⇒ 最多 1 行)
- // 763609-763611 `IF inTeacherExp<0 THEN SET inTeacherExp=0`(教龄下限钳 0)→ Service 侧做
- // 763613 `SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid`
- // → TeacherHasCert(**无行 = 无证**,见方法注释)
- // 763616-763623 方案两级回退:
- // ① 763616 `schoolid=? AND campusid=? AND actived=1 AND effect_date<=? ORDER BY effect_date DESC LIMIT 1`
- // ② 763617-763619 `coursePaymentid<1 || IS NULL` → `schoolid=? AND campusid=0 ...`(机构级)
- // ③ 763621-763623 仍 <1 → `LEAVE readLoop`(无方案 ⇒ 3112)
- // → FindActiveScheme(两次调用,回退顺序在 Service)
- // 763627-763642 个人配置两级:
- // ① 763627 `SELECT(SELECT COUNT(1) ... course_paymentid/actived/teacherid/class_type) INTO oneself`(闸门)
- // ② 763630-763631 `classifyid=inClassifyid LIMIT 1`(**无 ORDER BY**,本实现补 id ASC 定序)
- // ③ 763636-763637 `gradeid IN (g,-1) AND subjectid IN (s,-1) ORDER BY gradeid DESC,subjectid DESC LIMIT 1`
- // → CountPersonalRules / FindPersonalByClassify / FindPersonalByGradeSubject
- // 763645-763656 通用两级:
- // ④ 763646-763648 `teacherid=0 AND teacher_cert=1 AND ... AND inTeacherExp BETWEEN exp_min AND exp_max`
- // ⑤ 763654-763656 `teacherid=0 AND teacher_cert=0 AND ...`(同上,无证)
- // → FindGeneralRule(cert 参数区分 ④/⑤)
- // ⚠ `IF coursePaymentDetailid>1 THEN LEAVE readLoop` 只在 **763632 / 763638 / 763649** 三处出现;
- // 第四档(763654-763656)**没有**该判据——因游标按主键过滤最多 1 行,缺 LEAVE 不会造成覆盖,
- // 与"首命中即返回"等价。详见 service/payroll_service.go 文件头"有意差异"一节。
- //
- // ---------------------------------------------------------------------------
- // 作用域口径(**本仓储不调 Scoped,改用 assertInstitution**,必须读)
- // ---------------------------------------------------------------------------
- //
- // `t_course_payment` 带 institution_id / campus_id,但 dump 的语义里 **`campusid=0` 的行必须可见**
- // (两级回退的第二级就是它)。`BaseRepo.Scoped` 在 `Sc.CampusID>0` 时会强制 `campus_id = ?`,
- // 把机构级方案整片隐藏 ⇒ 回退链退化成"要么本级命中、要么 3112",**静默改变业务结果**。
- // 所以本仓储只做**机构级**断言(assertInstitution):普通角色只能查本机构方案,
- // 超管/系统任务(Sc 为 nil)放行;校区维度由 Service 显式传参(对齐 dump 的 inCampusid)。
- //
- // `t_course_payment_detail` / `t_teacher_information` **没有** institution_id / campus_id 列
- // (见 model.CoursePaymentDetail / model.TeacherInformation),任何 Scoped 调用都会 Unknown column;
- // 隔离由"方案 id 已被机构断言保护"传导完成。
- //
- // 金额/比例纪律:`percentage` 是 `decimal(5,1)`(**百分比数字部分,不是金额**),
- // `min_payment/max_payment/reward` 是 `decimal(12,2)`;本仓储一律原样取字符串,不在此做算术。
- package repository
- import (
- "context"
- "strings"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // payrollDetailColumns 课酬明细参与匹配的列清单(少取 memo/longtext,避免把自由文本带进日志)。
- const payrollDetailColumns = "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"
- // PayrollRepo 课酬方案/规则仓储(只读;游标 → 单查)。
- //
- // 字段:
- // - *BaseRepo:持有 DB + 作用域(**只借 DB/Sc,不调用 Scoped**,理由见文件头"作用域口径")。
- type PayrollRepo struct {
- *BaseRepo
- }
- // NewPayrollRepo 构造课酬仓储。
- //
- // 入参:db *gorm.DB(必填);sc *appscope.Scope(可 nil:系统任务/双跑脚本视角)。
- // 出参:*PayrollRepo(CampusScoped=false:`campus_id=0` 的机构级方案必须可见)。
- func NewPayrollRepo(db *gorm.DB, sc *appscope.Scope) *PayrollRepo {
- r := &PayrollRepo{BaseRepo: NewBaseRepo(db, sc)}
- r.CampusScoped = false
- return r
- }
- // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
- //
- // 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
- func (r *PayrollRepo) db(ctx context.Context) (*gorm.DB, error) {
- if r == nil || r.BaseRepo == nil || r.DB == nil {
- return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: PayrollRepo.DB 为 nil"))
- }
- if tx := TxFromContext(ctx); tx != nil {
- return tx, nil
- }
- if ctx == nil {
- return r.DB, nil
- }
- return r.DB.WithContext(ctx), nil
- }
- // assertInstitution 机构级越权守卫(替代 Scoped;语义见文件头"作用域口径")。
- //
- // 入参:institutionID 本次查询的目标机构(<=0 → ErrBadParam:机构是必填维度,0 会让方案查询跨机构)。
- // 出参:nil 通过;ErrUnscoped(1004) 越界;ErrBadParam(2001)。
- //
- // 规则:Sc 为 nil(系统任务/双跑)→ 放行;超管 → 放行;普通角色 → 必须与 Sc.InstitutionID 相等。
- func (r *PayrollRepo) assertInstitution(institutionID int64) error {
- if institutionID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: 课酬查询需要机构 id(>0)"))
- }
- if r.Sc == nil || r.Sc.SuperAdmin {
- return nil
- }
- if r.Sc.InstitutionID != institutionID {
- return infraerrors.WithCause(infraerrors.ErrUnscoped,
- errString("repository: 课酬查询机构与当前作用域不一致"))
- }
- return nil
- }
- // GetClassify 按主键取课程分类(dump 763594 游标的等价单查)。
- //
- // 入参:ctx;classifyID 分类 ID(<=0 → ErrBadParam)。
- // 出参:*model.CoursesClassify;ErrNotFound(3001) / ErrBadParam(2001) / ErrDBError(5002)。
- //
- // 与 dump 的一致性:dump `SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
- // **不带任何作用域条件**。本方法同样按主键取(分类只提供 grade/subject/class_type 三个字典值,
- // 机构隔离由后续的方案查询承担:越权者拿不到本机构方案 ⇒ 依然 3112)。
- //
- // **为什么不复用 `CourseRepo.GetClassifyByID`**(避免被判成重复实现,理由写在这里):
- // 那个方法走 `Scoped()` 且 `CourseRepo.CampusScoped=true` ⇒ 会强制 `campus_id = Sc.CampusID`,
- // 把 `campus_id=0`(机构级分类)与其它校区的分类整片隐藏。课酬试算是"老师跨校区上课"的场景,
- // 分类常挂在机构级(campus_id=0)或别的校区上;一旦叠上校区过滤,dump 本来能命中的规则会变 3112。
- // 两者**服务对象不同**(课程 CRUD 的强隔离 vs 课酬试算的 dump 语义),不是同一实现的两份拷贝。
- func (r *PayrollRepo) GetClassify(ctx context.Context, classifyID int64) (*model.CoursesClassify, error) {
- if classifyID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursesClassify
- qErr := db.Model(&model.CoursesClassify{}).
- Select("id", "institution_id", "campus_id", "class_type", "grade_id", "subject_id").
- Where("id = ?", classifyID).Take(&out).Error
- if qErr != nil {
- return nil, wrapQueryErr(qErr, "GetClassify(t_courses_classify)")
- }
- return &out, nil
- }
- // TeacherHasCert 判断老师是否持教师资格证(dump 763613)。
- //
- // dump 原文:
- //
- // SELECT (SELECT `teacher_cert` FROM `t_teacher_information` WHERE teacherid=inUserid) INTO teacherCert;
- // ...
- // IF teacherCert>0 THEN -- 只有持证者才尝试"持证通用"档
- //
- // 语义细节(**极易写错**):标量子查询**无行时结果为 NULL**,而 `NULL>0` 为 NULL(假)——
- // 因此「老师没有资质档案」等价于「无证」,**不是错误**。本方法据此把"无行"归一为 false。
- //
- // 入参:ctx;teacherID 老师(ULID;空 → ErrBadParam)。
- // 出参:bool 是否持证;ErrBadParam / ErrDBError。
- func (r *PayrollRepo) TeacherHasCert(ctx context.Context, teacherID string) (bool, error) {
- if strings.TrimSpace(teacherID) == "" {
- return false, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: TeacherHasCert 需要 teacher_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return false, err
- }
- var row struct {
- TeacherCert bool `gorm:"column:teacher_cert"`
- }
- qErr := db.Model(&model.TeacherInformation{}).
- Select("teacher_cert").
- Where("teacher_id = ?", teacherID).
- Take(&row).Error
- if qErr != nil {
- if isNotFound(qErr) {
- // dump 语义:无行 ⇒ 标量子查询 NULL ⇒ IF NULL>0 不成立 ⇒ 按无证处理
- return false, nil
- }
- return false, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
- }
- return row.TeacherCert, nil
- }
- // FindActiveScheme 取生效的课酬方案(dump 763616-763619 的单次查询;两级回退由 Service 调用两次)。
- //
- // dump 原文(camupsid 为入参那一档):
- //
- // SELECT (SELECT `id` FROM `t_course_payment`
- // WHERE `schoolid`=inSchoolid AND `campusid`=inCampusid AND `actived`=1 AND `effect_date`<=inDate
- // ORDER BY `effect_date` desc LIMIT 0,1) INTO coursePaymentid;
- //
- // 入参:
- // - ctx 请求上下文;
- // - institutionID 机构 ID(旧 schoolid;**>0 且须与作用域一致**,见 assertInstitution);
- // - campusID 校区 ID(旧 campusid;**0 = 机构级方案**,即第二级回退);
- // - effectUntil 试算日期(旧 inDate varchar → 目标态 time.Time;**按日截断后含当日**,见下)。
- //
- // 出参:
- // - *model.CoursePayment:命中行;**无命中返回 (nil, nil)**(不是 ErrNotFound——"没有方案"是
- // 回退链的正常分支,由 Service 决定继续回退还是 3112);
- // - ErrBadParam / ErrUnscoped / ErrDBError。
- //
- // 边界口径(`effect_date <= inDate`):`effect_date` 是 DATE 列,dump 的 inDate 是 `YYYY-MM-DD` 字符串,
- // 因此**当日生效的方案当日可用**。目标态把 effectUntil 按"取年月日、时钟清零"处理后再比较,
- // 避免传入 `2026-11-09 15:04:05` 时把当日 00:00 生效的方案判掉(那是浮点/时钟误伤,不是业务差异)。
- //
- // 定序:`effect_date DESC` 同日并列时 dump 结果**不确定**(无第二排序键);
- // 目标态补 `id DESC` 作为确定性兜底(登记为 Go 增强,仅在"同一天建了两个方案"时才有可观察差别)。
- func (r *PayrollRepo) FindActiveScheme(
- ctx context.Context, institutionID, campusID int64, effectUntil time.Time,
- ) (*model.CoursePayment, error) {
- if err := r.assertInstitution(institutionID); err != nil {
- return nil, err
- }
- if campusID < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: campus_id 不能为负"))
- }
- if effectUntil.IsZero() {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindActiveScheme 需要试算日期"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- day := time.Date(effectUntil.Year(), effectUntil.Month(), effectUntil.Day(), 0, 0, 0, 0, effectUntil.Location())
- var out model.CoursePayment
- qErr := db.Model(&model.CoursePayment{}).
- Select("id", "institution_id", "campus_id", "name", "effect_date", "actived").
- Where("institution_id = ? AND campus_id = ? AND actived = ? AND effect_date <= ?",
- institutionID, campusID, true, day).
- Order("effect_date DESC").Order("id DESC").
- Take(&out).Error
- if qErr != nil {
- if isNotFound(qErr) {
- return nil, nil // 该档无方案 → 交给 Service 走下一级回退 / 3112
- }
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
- }
- return &out, nil
- }
- // CountPersonalRules 统计"该老师在该方案下、该班型"的个人配置条数(dump 763627 的 `oneself`)。
- //
- // dump 原文:
- //
- // SELECT(SELECT COUNT(1) FROM t_course_payment_detail
- // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
- // AND `class_type`=classifyClassType) INTO oneself;
- //
- // 注意:`oneself` 只作为**闸门**(>0 才进入个人两级查询),**不带 classifyid 条件**——
- // 即"该老师该班型有任意一条个人规则"就开门。这个闸门看着冗余(下一级查询自己也会过滤),
- // 但它决定了"老师有个人规则但年级/科目都不匹配"时**会不会**继续往下走到持证/无证通用档:
- // 门开着就继续走,与"没有个人规则"结果相同,故行为等价;保留它只为与 dump 结构一致、便于双跑对账。
- //
- // 入参:ctx;schemeID 方案 ID(<=0 → ErrBadParam);teacherID 老师 ULID(空 → ErrBadParam);
- // classType 班型(可为 0,dump 无下限校验)。
- // 出参:int64 条数;ErrBadParam / ErrDBError。
- func (r *PayrollRepo) CountPersonalRules(
- ctx context.Context, schemeID int64, teacherID string, classType int32,
- ) (int64, error) {
- if schemeID <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
- }
- if strings.TrimSpace(teacherID) == "" {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountPersonalRules 需要 teacher_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return 0, err
- }
- var total int64
- qErr := db.Model(&model.CoursePaymentDetail{}).
- Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ?",
- schemeID, true, teacherID, classType).
- Count(&total).Error
- if qErr != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
- }
- return total, nil
- }
- // FindPersonalByClassify 个人配置·第一档:精确分类命中(dump 763630-763634)。
- //
- // dump 原文:
- //
- // SELECT (SELECT `id` FROM `t_course_payment_detail`
- // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
- // AND `class_type`=classifyClassType AND `classifyid`=inClassifyid LIMIT 0,1) INTO coursePaymentDetailid;
- //
- // 边界:`classifyid` 在本档是**精确等值**(`0=不限定分类` 的行不会被本档命中,要落到第二档的
- // grade/subject 通配匹配),这是 dump 的既有行为,不得把 `classify_id=0` 也当通配。
- // dump 无 ORDER BY,本实现补 `id ASC`(同时机多行时取最早一条,保证可复现)。
- //
- // 入参:ctx;schemeID(<=0 → ErrBadParam);teacherID(空 → ErrBadParam);classType;
- // classifyID(<=0 → ErrBadParam:入参分类是本档的核心维度)。
- // 出参:*model.CoursePaymentDetail;**无命中 (nil, nil)**;ErrBadParam / ErrDBError。
- func (r *PayrollRepo) FindPersonalByClassify(
- ctx context.Context, schemeID int64, teacherID string, classType int32, classifyID int64,
- ) (*model.CoursePaymentDetail, error) {
- if schemeID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
- }
- if strings.TrimSpace(teacherID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindPersonalByClassify 需要 teacher_id"))
- }
- if classifyID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursePaymentDetail
- qErr := db.Model(&model.CoursePaymentDetail{}).
- Select(payrollDetailColumns).
- Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ? AND classify_id = ?",
- schemeID, true, teacherID, classType, classifyID).
- Order("id ASC").
- Take(&out).Error
- if qErr != nil {
- if isNotFound(qErr) {
- return nil, nil
- }
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
- }
- return &out, nil
- }
- // FindPersonalByGradeSubject 个人配置·第二档:年级/科目通配命中(dump 763636-763640)。
- //
- // dump 原文:
- //
- // SELECT (SELECT `id` FROM `t_course_payment_detail`
- // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
- // AND `class_type`=classifyClassType
- // AND `gradeid` in (classifyGradeid,-1) AND `subjectid` in (classifySubjectid,-1)
- // ORDER BY `gradeid` DESC,`subjectid` DESC LIMIT 0,1) INTO coursePaymentDetailid;
- //
- // `-1` 是**通配符**(表侧取值),因此条件是 `grade_id IN (?, -1)`,**不是** `grade_id = ? OR grade_id = -1`
- // 之外的任何"归一化"(尤其不能把 `-1` 改写成"不限"再对 grade_id 做 `<>` 判断)。
- // `ORDER BY grade_id DESC, subject_id DESC` 让**更具体**的行(真实 grade/subject,数值大于 -1)排在
- // 通配行之前;本实现补 `id ASC` 作为第三排序键(dump 在同 (grade,subject) 多行时不确定)。
- //
- // 入参:ctx;schemeID(<=0 → ErrBadParam);teacherID(空 → ErrBadParam);classType;
- // gradeID/subjectID 分类上的年级/科目(可为 0 = 混合;`-1` 由表侧提供)。
- // 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
- func (r *PayrollRepo) FindPersonalByGradeSubject(
- ctx context.Context, schemeID int64, teacherID string, classType, gradeID, subjectID int32,
- ) (*model.CoursePaymentDetail, error) {
- if schemeID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
- }
- if strings.TrimSpace(teacherID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindPersonalByGradeSubject 需要 teacher_id"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursePaymentDetail
- qErr := db.Model(&model.CoursePaymentDetail{}).
- Select(payrollDetailColumns).
- Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ?",
- schemeID, true, teacherID, classType).
- Where("grade_id IN ?", []int32{gradeID, payrollWildcardID}).
- Where("subject_id IN ?", []int32{subjectID, payrollWildcardID}).
- Order("grade_id DESC").Order("subject_id DESC").Order("id ASC").
- Take(&out).Error
- if qErr != nil {
- if isNotFound(qErr) {
- return nil, nil
- }
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
- }
- return &out, nil
- }
- // payrollWildcardID 课酬规则的年级/科目通配值(dump 里字面量 `-1`,表侧存 −1 表示"所有")。
- const payrollWildcardID int32 = -1
- // FindGeneralRule 通用档:持证(`teacher_cert=1`)或无证(`teacher_cert=0`)(dump 763646-763656)。
- //
- // dump 原文(持证档):
- //
- // SELECT (SELECT `id` FROM `t_course_payment_detail`
- // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=0 AND `teacher_cert`=1
- // AND `class_type`=classifyClassType
- // AND `gradeid` in (classifyGradeid,-1) AND `subjectid` in (classifySubjectid,-1)
- // AND inTeacherExp BETWEEN `teacher_exp_min` AND `teacher_exp_max`
- // ORDER BY `gradeid` DESC,`subjectid` DESC LIMIT 0,1) INTO coursePaymentDetailid;
- //
- // 无证档(763654-763656)逐字相同,只把 `teacher_cert` 换成 0;**且它没有 `IF ... LEAVE` 判据**
- // (前三档的判据在 763632/763638/763649,理由见文件头映射块末注)。
- //
- // `teacherid=0` 在目标态的映射:`model.CoursePaymentDetail.TeacherID` 是 `*string`,
- // **NULL/"" = 通用规则**(旧库用 0 表示,ULID 化后没有"0 号老师"),故条件是
- // `(teacher_id IS NULL OR teacher_id = "")`——**不能**写成 `teacher_id = '0'`(那永远不命中)。
- //
- // `inTeacherExp BETWEEN exp_min AND exp_max`:两端**闭区间**;教龄以 `decimal(6,3)` 字符串入场
- // (`"3.50"`),由数据库按 NUMERIC 语义比较,**不在 Go 侧转 float64**(避免 3.50 vs 3.4999999 的边界抖动)。
- //
- // 入参:
- // - ctx;schemeID(<=0 → ErrBadParam);
- // - cert 是否持证档(true=② 持证通用 / false=③ 无证通用);
- // - classType 班型;gradeID/subjectID 分类维度(`-1` 通配规则由表侧提供);
- // - teacherExp 教龄(**已钳 0 的** decimal(6,3) 字符串,如 "0.000";空 → ErrBadParam)。
- //
- // 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
- func (r *PayrollRepo) FindGeneralRule(
- ctx context.Context, schemeID int64, cert bool, classType, gradeID, subjectID int32, teacherExp string,
- ) (*model.CoursePaymentDetail, error) {
- if schemeID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
- }
- if strings.TrimSpace(teacherExp) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindGeneralRule 需要教龄"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursePaymentDetail
- qErr := db.Model(&model.CoursePaymentDetail{}).
- Select(payrollDetailColumns).
- Where("course_payment_id = ? AND actived = ? AND (teacher_id IS NULL OR teacher_id = '') AND teacher_cert = ?",
- schemeID, true, cert).
- Where("class_type = ?", classType).
- Where("grade_id IN ?", []int32{gradeID, payrollWildcardID}).
- Where("subject_id IN ?", []int32{subjectID, payrollWildcardID}).
- Where("teacher_exp_min <= ? AND teacher_exp_max >= ?", strings.TrimSpace(teacherExp), strings.TrimSpace(teacherExp)).
- Order("grade_id DESC").Order("subject_id DESC").Order("id ASC").
- Take(&out).Error
- if qErr != nil {
- if isNotFound(qErr) {
- return nil, nil
- }
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
- }
- return &out, nil
- }
|