// 课酬方案与规则仓储(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 } // TeacherExpDate 取老师的**教龄起算日**(`t_teacher_information.teacher_exp`,DATE 可空)。 // // 入参:ctx;teacherID 老师 ULID(空 → ErrBadParam)。 // 出参:(起算日, 是否查到, error);**行不存在或该列为 NULL → (nil,false,nil)**(不是错误: // 旧库早期老师大量未录教龄,PHP `calculatingAge(empty)` 同样返回 0)。 // // 为什么返回"日期"而不是"年数":换算必须在 Service 层按 PHP `calculatingAge()` 的 // 口径做(固定 365 天/年、round 1 位),仓储只负责取**原值列**——仓储做换算会让 // "同一列在两处用不同口径算"成为可能(贷方/报表各算一遍是最典型的漂移来源)。 func (r *PayrollRepo) TeacherExpDate(ctx context.Context, teacherID string) (*time.Time, bool, error) { id := strings.TrimSpace(teacherID) if id == "" { return nil, false, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: TeacherExpDate 需要 teacher_id")) } db, err := r.db(ctx) if err != nil { return nil, false, err } var row struct { TeacherExp *time.Time `gorm:"column:teacher_exp"` } qErr := db.Model(&model.TeacherInformation{}). Select("teacher_exp"). Where("teacher_id = ?", id). Take(&row).Error if qErr != nil { if isNotFound(qErr) { return nil, false, nil // 无档案:教龄按 0(与 TeacherHasCert 的无行语义一致) } return nil, false, infraerrors.WithCause(infraerrors.ErrDBError, qErr) } if row.TeacherExp == nil || row.TeacherExp.IsZero() { return nil, false, nil // 列为 NULL:未录教龄 → 0 } return row.TeacherExp, true, 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 }