payroll_repo.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. // 课酬方案与规则仓储(sp_course_payment_detail_get_teacher_detail 的 4 次单查)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §3.4 #26 `sp_course_payment_detail_get_teacher_detail`(dump **763575~763665**)
  5. // - §10.1 `3112 课酬方案未配置`(本文件的"无方案"由 Service 翻译成 3112)
  6. //
  7. // ---------------------------------------------------------------------------
  8. // dump 原文与本仓储方法的映射(逐行号)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 763609-763613 游标 `SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
  12. // → GetClassify(**按主键单查**,替代游标)
  13. // 763621-763622 `IF inTeacherExp<0 THEN SET inTeacherExp=0`(教龄下限钳 0)→ Service 侧做
  14. // 763624 `SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid`
  15. // → TeacherHasCert(**无行 = 无证**,见方法注释)
  16. // 763627-763632 方案两级回退:
  17. // ① `schoolid=? AND campusid=? AND actived=1 AND effect_date<=? ORDER BY effect_date DESC LIMIT 1`
  18. // ② `coursePaymentid<1` → `schoolid=? AND campusid=0 ...`(机构级)
  19. // → FindActiveScheme(两次调用,回退顺序在 Service)
  20. // 763635-763647 个人配置两级:
  21. // ① `COUNT(1)` where course_paymentid/actived/teacherid/class_type → oneself
  22. // ② `classifyid=inClassifyid LIMIT 1`(**无 ORDER BY**,本实现补 id ASC 定序)
  23. // ③ `gradeid IN (g,-1) AND subjectid IN (s,-1) ORDER BY gradeid DESC,subjectid DESC LIMIT 1`
  24. // → CountPersonalRules / FindPersonalByClassify / FindPersonalByGradeSubject
  25. // 763650-763664 通用两级:
  26. // ④ `teacherid=0 AND teacher_cert=1 AND ... AND inTeacherExp BETWEEN exp_min AND exp_max`
  27. // ⑤ `teacherid=0 AND teacher_cert=0 AND ...`(同上,无证)
  28. // → FindGeneralRule(cert 参数区分 ④/⑤)
  29. //
  30. // ---------------------------------------------------------------------------
  31. // 作用域口径(**本仓储不调 Scoped,改用 assertInstitution**,必须读)
  32. // ---------------------------------------------------------------------------
  33. //
  34. // `t_course_payment` 带 institution_id / campus_id,但 dump 的语义里 **`campusid=0` 的行必须可见**
  35. // (两级回退的第二级就是它)。`BaseRepo.Scoped` 在 `Sc.CampusID>0` 时会强制 `campus_id = ?`,
  36. // 把机构级方案整片隐藏 ⇒ 回退链退化成"要么本级命中、要么 3112",**静默改变业务结果**。
  37. // 所以本仓储只做**机构级**断言(assertInstitution):普通角色只能查本机构方案,
  38. // 超管/系统任务(Sc 为 nil)放行;校区维度由 Service 显式传参(对齐 dump 的 inCampusid)。
  39. //
  40. // `t_course_payment_detail` / `t_teacher_information` **没有** institution_id / campus_id 列
  41. // (见 model.CoursePaymentDetail / model.TeacherInformation),任何 Scoped 调用都会 Unknown column;
  42. // 隔离由"方案 id 已被机构断言保护"传导完成。
  43. //
  44. // 金额/比例纪律:`percentage` 是 `decimal(5,1)`(**百分比数字部分,不是金额**),
  45. // `min_payment/max_payment/reward` 是 `decimal(12,2)`;本仓储一律原样取字符串,不在此做算术。
  46. package repository
  47. import (
  48. "context"
  49. "strings"
  50. "time"
  51. "gorm.io/gorm"
  52. "yuansiwei/unified-backend/internal/academic/model"
  53. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  54. appscope "yuansiwei/unified-backend/internal/infra/scope"
  55. )
  56. // payrollDetailColumns 课酬明细参与匹配的列清单(少取 memo/longtext,避免把自由文本带进日志)。
  57. const payrollDetailColumns = "id,course_payment_id,grade_id,subject_id,class_type,classify_id," +
  58. "teacher_id,name,percentage,min_payment,max_payment,reward,teacher_cert,teacher_exp_min,teacher_exp_max,actived"
  59. // PayrollRepo 课酬方案/规则仓储(只读;游标 → 单查)。
  60. //
  61. // 字段:
  62. // - *BaseRepo:持有 DB + 作用域(**只借 DB/Sc,不调用 Scoped**,理由见文件头"作用域口径")。
  63. type PayrollRepo struct {
  64. *BaseRepo
  65. }
  66. // NewPayrollRepo 构造课酬仓储。
  67. //
  68. // 入参:db *gorm.DB(必填);sc *appscope.Scope(可 nil:系统任务/双跑脚本视角)。
  69. // 出参:*PayrollRepo(CampusScoped=false:`campus_id=0` 的机构级方案必须可见)。
  70. func NewPayrollRepo(db *gorm.DB, sc *appscope.Scope) *PayrollRepo {
  71. r := &PayrollRepo{BaseRepo: NewBaseRepo(db, sc)}
  72. r.CampusScoped = false
  73. return r
  74. }
  75. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  76. //
  77. // 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  78. func (r *PayrollRepo) db(ctx context.Context) (*gorm.DB, error) {
  79. if r == nil || r.BaseRepo == nil || r.DB == nil {
  80. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: PayrollRepo.DB 为 nil"))
  81. }
  82. if tx := TxFromContext(ctx); tx != nil {
  83. return tx, nil
  84. }
  85. if ctx == nil {
  86. return r.DB, nil
  87. }
  88. return r.DB.WithContext(ctx), nil
  89. }
  90. // assertInstitution 机构级越权守卫(替代 Scoped;语义见文件头"作用域口径")。
  91. //
  92. // 入参:institutionID 本次查询的目标机构(<=0 → ErrBadParam:机构是必填维度,0 会让方案查询跨机构)。
  93. // 出参:nil 通过;ErrUnscoped(1004) 越界;ErrBadParam(2001)。
  94. //
  95. // 规则:Sc 为 nil(系统任务/双跑)→ 放行;超管 → 放行;普通角色 → 必须与 Sc.InstitutionID 相等。
  96. func (r *PayrollRepo) assertInstitution(institutionID int64) error {
  97. if institutionID <= 0 {
  98. return infraerrors.WithCause(infraerrors.ErrBadParam,
  99. errString("repository: 课酬查询需要机构 id(>0)"))
  100. }
  101. if r.Sc == nil || r.Sc.SuperAdmin {
  102. return nil
  103. }
  104. if r.Sc.InstitutionID != institutionID {
  105. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  106. errString("repository: 课酬查询机构与当前作用域不一致"))
  107. }
  108. return nil
  109. }
  110. // GetClassify 按主键取课程分类(dump 763609 游标的等价单查)。
  111. //
  112. // 入参:ctx;classifyID 分类 ID(<=0 → ErrBadParam)。
  113. // 出参:*model.CoursesClassify;ErrNotFound(3001) / ErrBadParam(2001) / ErrDBError(5002)。
  114. //
  115. // 与 dump 的一致性:dump `SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
  116. // **不带任何作用域条件**。本方法同样按主键取(分类只提供 grade/subject/class_type 三个字典值,
  117. // 机构隔离由后续的方案查询承担:越权者拿不到本机构方案 ⇒ 依然 3112)。
  118. //
  119. // **为什么不复用 `CourseRepo.GetClassifyByID`**(避免被判成重复实现,理由写在这里):
  120. // 那个方法走 `Scoped()` 且 `CourseRepo.CampusScoped=true` ⇒ 会强制 `campus_id = Sc.CampusID`,
  121. // 把 `campus_id=0`(机构级分类)与其它校区的分类整片隐藏。课酬试算是"老师跨校区上课"的场景,
  122. // 分类常挂在机构级(campus_id=0)或别的校区上;一旦叠上校区过滤,dump 本来能命中的规则会变 3112。
  123. // 两者**服务对象不同**(课程 CRUD 的强隔离 vs 课酬试算的 dump 语义),不是同一实现的两份拷贝。
  124. func (r *PayrollRepo) GetClassify(ctx context.Context, classifyID int64) (*model.CoursesClassify, error) {
  125. if classifyID <= 0 {
  126. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
  127. }
  128. db, err := r.db(ctx)
  129. if err != nil {
  130. return nil, err
  131. }
  132. var out model.CoursesClassify
  133. qErr := db.Model(&model.CoursesClassify{}).
  134. Select("id", "institution_id", "campus_id", "class_type", "grade_id", "subject_id").
  135. Where("id = ?", classifyID).Take(&out).Error
  136. if qErr != nil {
  137. return nil, wrapQueryErr(qErr, "GetClassify(t_courses_classify)")
  138. }
  139. return &out, nil
  140. }
  141. // TeacherHasCert 判断老师是否持教师资格证(dump 763624)。
  142. //
  143. // dump 原文:
  144. //
  145. // SELECT (SELECT `teacher_cert` FROM `t_teacher_information` WHERE teacherid=inUserid) INTO teacherCert;
  146. // ...
  147. // IF teacherCert>0 THEN -- 只有持证者才尝试"持证通用"档
  148. //
  149. // 语义细节(**极易写错**):标量子查询**无行时结果为 NULL**,而 `NULL>0` 为 NULL(假)——
  150. // 因此「老师没有资质档案」等价于「无证」,**不是错误**。本方法据此把"无行"归一为 false。
  151. //
  152. // 入参:ctx;teacherID 老师(ULID;空 → ErrBadParam)。
  153. // 出参:bool 是否持证;ErrBadParam / ErrDBError。
  154. func (r *PayrollRepo) TeacherHasCert(ctx context.Context, teacherID string) (bool, error) {
  155. if strings.TrimSpace(teacherID) == "" {
  156. return false, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: TeacherHasCert 需要 teacher_id"))
  157. }
  158. db, err := r.db(ctx)
  159. if err != nil {
  160. return false, err
  161. }
  162. var row struct {
  163. TeacherCert bool `gorm:"column:teacher_cert"`
  164. }
  165. qErr := db.Model(&model.TeacherInformation{}).
  166. Select("teacher_cert").
  167. Where("teacher_id = ?", teacherID).
  168. Take(&row).Error
  169. if qErr != nil {
  170. if isNotFound(qErr) {
  171. // dump 语义:无行 ⇒ 标量子查询 NULL ⇒ IF NULL>0 不成立 ⇒ 按无证处理
  172. return false, nil
  173. }
  174. return false, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  175. }
  176. return row.TeacherCert, nil
  177. }
  178. // FindActiveScheme 取生效的课酬方案(dump 763627-763632 的单次查询;两级回退由 Service 调用两次)。
  179. //
  180. // dump 原文(camupsid 为入参那一档):
  181. //
  182. // SELECT (SELECT `id` FROM `t_course_payment`
  183. // WHERE `schoolid`=inSchoolid AND `campusid`=inCampusid AND `actived`=1 AND `effect_date`<=inDate
  184. // ORDER BY `effect_date` desc LIMIT 0,1) INTO coursePaymentid;
  185. //
  186. // 入参:
  187. // - ctx 请求上下文;
  188. // - institutionID 机构 ID(旧 schoolid;**>0 且须与作用域一致**,见 assertInstitution);
  189. // - campusID 校区 ID(旧 campusid;**0 = 机构级方案**,即第二级回退);
  190. // - effectUntil 试算日期(旧 inDate varchar → 目标态 time.Time;**按日截断后含当日**,见下)。
  191. //
  192. // 出参:
  193. // - *model.CoursePayment:命中行;**无命中返回 (nil, nil)**(不是 ErrNotFound——"没有方案"是
  194. // 回退链的正常分支,由 Service 决定继续回退还是 3112);
  195. // - ErrBadParam / ErrUnscoped / ErrDBError。
  196. //
  197. // 边界口径(`effect_date <= inDate`):`effect_date` 是 DATE 列,dump 的 inDate 是 `YYYY-MM-DD` 字符串,
  198. // 因此**当日生效的方案当日可用**。目标态把 effectUntil 按"取年月日、时钟清零"处理后再比较,
  199. // 避免传入 `2026-11-09 15:04:05` 时把当日 00:00 生效的方案判掉(那是浮点/时钟误伤,不是业务差异)。
  200. //
  201. // 定序:`effect_date DESC` 同日并列时 dump 结果**不确定**(无第二排序键);
  202. // 目标态补 `id DESC` 作为确定性兜底(登记为 Go 增强,仅在"同一天建了两个方案"时才有可观察差别)。
  203. func (r *PayrollRepo) FindActiveScheme(
  204. ctx context.Context, institutionID, campusID int64, effectUntil time.Time,
  205. ) (*model.CoursePayment, error) {
  206. if err := r.assertInstitution(institutionID); err != nil {
  207. return nil, err
  208. }
  209. if campusID < 0 {
  210. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: campus_id 不能为负"))
  211. }
  212. if effectUntil.IsZero() {
  213. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindActiveScheme 需要试算日期"))
  214. }
  215. db, err := r.db(ctx)
  216. if err != nil {
  217. return nil, err
  218. }
  219. day := time.Date(effectUntil.Year(), effectUntil.Month(), effectUntil.Day(), 0, 0, 0, 0, effectUntil.Location())
  220. var out model.CoursePayment
  221. qErr := db.Model(&model.CoursePayment{}).
  222. Select("id", "institution_id", "campus_id", "name", "effect_date", "actived").
  223. Where("institution_id = ? AND campus_id = ? AND actived = ? AND effect_date <= ?",
  224. institutionID, campusID, true, day).
  225. Order("effect_date DESC").Order("id DESC").
  226. Take(&out).Error
  227. if qErr != nil {
  228. if isNotFound(qErr) {
  229. return nil, nil // 该档无方案 → 交给 Service 走下一级回退 / 3112
  230. }
  231. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  232. }
  233. return &out, nil
  234. }
  235. // CountPersonalRules 统计"该老师在该方案下、该班型"的个人配置条数(dump 763636 的 `oneself`)。
  236. //
  237. // dump 原文:
  238. //
  239. // SELECT(SELECT COUNT(1) FROM t_course_payment_detail
  240. // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
  241. // AND `class_type`=classifyClassType) INTO oneself;
  242. //
  243. // 注意:`oneself` 只作为**闸门**(>0 才进入个人两级查询),**不带 classifyid 条件**——
  244. // 即"该老师该班型有任意一条个人规则"就开门。这个闸门看着冗余(下一级查询自己也会过滤),
  245. // 但它决定了"老师有个人规则但年级/科目都不匹配"时**会不会**继续往下走到持证/无证通用档:
  246. // 门开着就继续走,与"没有个人规则"结果相同,故行为等价;保留它只为与 dump 结构一致、便于双跑对账。
  247. //
  248. // 入参:ctx;schemeID 方案 ID(<=0 → ErrBadParam);teacherID 老师 ULID(空 → ErrBadParam);
  249. // classType 班型(可为 0,dump 无下限校验)。
  250. // 出参:int64 条数;ErrBadParam / ErrDBError。
  251. func (r *PayrollRepo) CountPersonalRules(
  252. ctx context.Context, schemeID int64, teacherID string, classType int32,
  253. ) (int64, error) {
  254. if schemeID <= 0 {
  255. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
  256. }
  257. if strings.TrimSpace(teacherID) == "" {
  258. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountPersonalRules 需要 teacher_id"))
  259. }
  260. db, err := r.db(ctx)
  261. if err != nil {
  262. return 0, err
  263. }
  264. var total int64
  265. qErr := db.Model(&model.CoursePaymentDetail{}).
  266. Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ?",
  267. schemeID, true, teacherID, classType).
  268. Count(&total).Error
  269. if qErr != nil {
  270. return 0, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  271. }
  272. return total, nil
  273. }
  274. // FindPersonalByClassify 个人配置·第一档:精确分类命中(dump 763638-763641)。
  275. //
  276. // dump 原文:
  277. //
  278. // SELECT (SELECT `id` FROM `t_course_payment_detail`
  279. // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
  280. // AND `class_type`=classifyClassType AND `classifyid`=inClassifyid LIMIT 0,1) INTO coursePaymentDetailid;
  281. //
  282. // 边界:`classifyid` 在本档是**精确等值**(`0=不限定分类` 的行不会被本档命中,要落到第二档的
  283. // grade/subject 通配匹配),这是 dump 的既有行为,不得把 `classify_id=0` 也当通配。
  284. // dump 无 ORDER BY,本实现补 `id ASC`(同时机多行时取最早一条,保证可复现)。
  285. //
  286. // 入参:ctx;schemeID(<=0 → ErrBadParam);teacherID(空 → ErrBadParam);classType;
  287. // classifyID(<=0 → ErrBadParam:入参分类是本档的核心维度)。
  288. // 出参:*model.CoursePaymentDetail;**无命中 (nil, nil)**;ErrBadParam / ErrDBError。
  289. func (r *PayrollRepo) FindPersonalByClassify(
  290. ctx context.Context, schemeID int64, teacherID string, classType int32, classifyID int64,
  291. ) (*model.CoursePaymentDetail, error) {
  292. if schemeID <= 0 {
  293. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
  294. }
  295. if strings.TrimSpace(teacherID) == "" {
  296. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindPersonalByClassify 需要 teacher_id"))
  297. }
  298. if classifyID <= 0 {
  299. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0"))
  300. }
  301. db, err := r.db(ctx)
  302. if err != nil {
  303. return nil, err
  304. }
  305. var out model.CoursePaymentDetail
  306. qErr := db.Model(&model.CoursePaymentDetail{}).
  307. Select(payrollDetailColumns).
  308. Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ? AND classify_id = ?",
  309. schemeID, true, teacherID, classType, classifyID).
  310. Order("id ASC").
  311. Take(&out).Error
  312. if qErr != nil {
  313. if isNotFound(qErr) {
  314. return nil, nil
  315. }
  316. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  317. }
  318. return &out, nil
  319. }
  320. // FindPersonalByGradeSubject 个人配置·第二档:年级/科目通配命中(dump 763643-763647)。
  321. //
  322. // dump 原文:
  323. //
  324. // SELECT (SELECT `id` FROM `t_course_payment_detail`
  325. // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=inUserid
  326. // AND `class_type`=classifyClassType
  327. // AND `gradeid` in (classifyGradeid,-1) AND `subjectid` in (classifySubjectid,-1)
  328. // ORDER BY `gradeid` DESC,`subjectid` DESC LIMIT 0,1) INTO coursePaymentDetailid;
  329. //
  330. // `-1` 是**通配符**(表侧取值),因此条件是 `grade_id IN (?, -1)`,**不是** `grade_id = ? OR grade_id = -1`
  331. // 之外的任何"归一化"(尤其不能把 `-1` 改写成"不限"再对 grade_id 做 `<>` 判断)。
  332. // `ORDER BY grade_id DESC, subject_id DESC` 让**更具体**的行(真实 grade/subject,数值大于 -1)排在
  333. // 通配行之前;本实现补 `id ASC` 作为第三排序键(dump 在同 (grade,subject) 多行时不确定)。
  334. //
  335. // 入参:ctx;schemeID(<=0 → ErrBadParam);teacherID(空 → ErrBadParam);classType;
  336. // gradeID/subjectID 分类上的年级/科目(可为 0 = 混合;`-1` 由表侧提供)。
  337. // 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
  338. func (r *PayrollRepo) FindPersonalByGradeSubject(
  339. ctx context.Context, schemeID int64, teacherID string, classType, gradeID, subjectID int32,
  340. ) (*model.CoursePaymentDetail, error) {
  341. if schemeID <= 0 {
  342. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
  343. }
  344. if strings.TrimSpace(teacherID) == "" {
  345. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindPersonalByGradeSubject 需要 teacher_id"))
  346. }
  347. db, err := r.db(ctx)
  348. if err != nil {
  349. return nil, err
  350. }
  351. var out model.CoursePaymentDetail
  352. qErr := db.Model(&model.CoursePaymentDetail{}).
  353. Select(payrollDetailColumns).
  354. Where("course_payment_id = ? AND actived = ? AND teacher_id = ? AND class_type = ?",
  355. schemeID, true, teacherID, classType).
  356. Where("grade_id IN ?", []int32{gradeID, payrollWildcardID}).
  357. Where("subject_id IN ?", []int32{subjectID, payrollWildcardID}).
  358. Order("grade_id DESC").Order("subject_id DESC").Order("id ASC").
  359. Take(&out).Error
  360. if qErr != nil {
  361. if isNotFound(qErr) {
  362. return nil, nil
  363. }
  364. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  365. }
  366. return &out, nil
  367. }
  368. // payrollWildcardID 课酬规则的年级/科目通配值(dump 里字面量 `-1`,表侧存 −1 表示"所有")。
  369. const payrollWildcardID int32 = -1
  370. // FindGeneralRule 通用档:持证(`teacher_cert=1`)或无证(`teacher_cert=0`)(dump 763650-763664)。
  371. //
  372. // dump 原文(持证档):
  373. //
  374. // SELECT (SELECT `id` FROM `t_course_payment_detail`
  375. // WHERE `course_paymentid`=coursePaymentid AND `actived`=1 AND `teacherid`=0 AND `teacher_cert`=1
  376. // AND `class_type`=classifyClassType
  377. // AND `gradeid` in (classifyGradeid,-1) AND `subjectid` in (classifySubjectid,-1)
  378. // AND inTeacherExp BETWEEN `teacher_exp_min` AND `teacher_exp_max`
  379. // ORDER BY `gradeid` DESC,`subjectid` DESC LIMIT 0,1) INTO coursePaymentDetailid;
  380. //
  381. // 无证档(763659-763664)逐字相同,只把 `teacher_cert` 换成 0。
  382. //
  383. // `teacherid=0` 在目标态的映射:`model.CoursePaymentDetail.TeacherID` 是 `*string`,
  384. // **NULL/"" = 通用规则**(旧库用 0 表示,ULID 化后没有"0 号老师"),故条件是
  385. // `(teacher_id IS NULL OR teacher_id = "")`——**不能**写成 `teacher_id = '0'`(那永远不命中)。
  386. //
  387. // `inTeacherExp BETWEEN exp_min AND exp_max`:两端**闭区间**;教龄以 `decimal(6,2)` 字符串入场
  388. // (`"3.50"`),由数据库按 NUMERIC 语义比较,**不在 Go 侧转 float64**(避免 3.50 vs 3.4999999 的边界抖动)。
  389. //
  390. // 入参:
  391. // - ctx;schemeID(<=0 → ErrBadParam);
  392. // - cert 是否持证档(true=② 持证通用 / false=③ 无证通用);
  393. // - classType 班型;gradeID/subjectID 分类维度(`-1` 通配规则由表侧提供);
  394. // - teacherExp 教龄(**已钳 0 的** decimal(6,2) 字符串,如 "0.00";空 → ErrBadParam)。
  395. //
  396. // 出参:*model.CoursePaymentDetail;无命中 (nil, nil);ErrBadParam / ErrDBError。
  397. func (r *PayrollRepo) FindGeneralRule(
  398. ctx context.Context, schemeID int64, cert bool, classType, gradeID, subjectID int32, teacherExp string,
  399. ) (*model.CoursePaymentDetail, error) {
  400. if schemeID <= 0 {
  401. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: scheme id 必须 > 0"))
  402. }
  403. if strings.TrimSpace(teacherExp) == "" {
  404. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindGeneralRule 需要教龄"))
  405. }
  406. db, err := r.db(ctx)
  407. if err != nil {
  408. return nil, err
  409. }
  410. var out model.CoursePaymentDetail
  411. qErr := db.Model(&model.CoursePaymentDetail{}).
  412. Select(payrollDetailColumns).
  413. Where("course_payment_id = ? AND actived = ? AND (teacher_id IS NULL OR teacher_id = '') AND teacher_cert = ?",
  414. schemeID, true, cert).
  415. Where("class_type = ?", classType).
  416. Where("grade_id IN ?", []int32{gradeID, payrollWildcardID}).
  417. Where("subject_id IN ?", []int32{subjectID, payrollWildcardID}).
  418. Where("teacher_exp_min <= ? AND teacher_exp_max >= ?", strings.TrimSpace(teacherExp), strings.TrimSpace(teacherExp)).
  419. Order("grade_id DESC").Order("subject_id DESC").Order("id ASC").
  420. Take(&out).Error
  421. if qErr != nil {
  422. if isNotFound(qErr) {
  423. return nil, nil
  424. }
  425. return nil, infraerrors.WithCause(infraerrors.ErrDBError, qErr)
  426. }
  427. return &out, nil
  428. }