payroll_repo.go 22 KB

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