payroll_service.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. // 课酬试算服务(sp_course_payment_detail_get_teacher_detail → PayrollService.QuoteTeacherReward)。
  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 课酬方案未配置`("无方案"与"无任何规则命中"两个出口都归 3112,见方法注释)
  6. //
  7. // ---------------------------------------------------------------------------
  8. // dump 骨架(763577 CREATE PROCEDURE → 763663 收尾 SELECT;行号已逐行 awk 核对)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 763594 游标:`DECLARE cur CURSOR FOR SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid`
  12. // —— **按主键过滤 ⇒ 最多 1 行**;分类不存在 ⇒ 0 行 ⇒ 循环体一次都不执行 ⇒ 末尾 `WHERE id=0` 返回空结果集
  13. // 763596 `DECLARE CONTINUE HANDLER FOR NOT found SET done = 1`
  14. // 763603 `FETCH cur INTO classifyGradeid,classifySubjectid,classifyClassType`
  15. // 763605 `IF done=1 THEN LEAVE readLoop`
  16. // 763609 `IF inTeacherExp<0 THEN SET inTeacherExp = 0`(教龄负值钳 0)
  17. // 763613 老师持证:`SELECT (SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid)`
  18. // —— **无行 ⇒ NULL ⇒ `IF teacherCert>0` 不成立 ⇒ 按无证走**
  19. // 763616 方案①:`schoolid+inCampusid+actived=1+effect_date<=inDate ORDER BY effect_date DESC LIMIT 1`
  20. // 763617-763619 方案②(回退):`coursePaymentid<1 || IS NULL` → 同条件但 `campusid=0`(机构级)
  21. // 763621-763623 `IF coursePaymentid<1 THEN LEAVE readLoop` → 无任何方案 ⇒ **3112**
  22. // 763627 个人配置闸门:`SELECT(SELECT COUNT(1) ... course_paymentid+actived+teacherid+class_type) INTO oneself`
  23. // 763628-763642 `IF oneself>0 THEN` … 个人档 ① `classifyid=inClassifyid`(763630-763631)
  24. // 与个人档 ② `gradeid IN (g,-1)`+`subjectid IN (s,-1)`(763636-763637)
  25. // 763645-763652 `IF teacherCert>0 THEN` … 通用档 ③(持证,`teacherid=0 AND teacher_cert=1`,763646-763648)
  26. // 763654-763656 通用档 ④(无证,`teacher_cert=0`)—— **本档没有 `IF ... LEAVE` 判据**(见下节)
  27. // 763659 `END LOOP`;763663 `SELECT * FROM t_course_payment_detail WHERE id=coursePaymentDetailid`
  28. //
  29. // ---------------------------------------------------------------------------
  30. // ★ 一处**有意差异**(必读):dump 的 `IF coursePaymentDetailid>1`
  31. // ---------------------------------------------------------------------------
  32. //
  33. // `IF coursePaymentDetailid>1 THEN LEAVE readLoop` 在 dump 里出现在 **3 处**:
  34. // **763632 / 763638 / 763649**(分别对应个人档 ①、个人档 ②、通用档 ③)。
  35. //
  36. // **第四档(无证通用,763654-763656)没有这个判据**:它只有三行
  37. // `SELECT (SELECT ... ) INTO coursePaymentDetailid`,后面直接到 763657 注释与 763659 `END LOOP`,
  38. // 没有 `IF coursePaymentDetailid>1 THEN LEAVE readLoop`。
  39. //
  40. // **这个缺省为什么无害(务必写清,否则下一个人会来"补"它)**:
  41. // 游标 763594 是 `WHERE id=inClassifyid` —— **按主键过滤,最多 1 行**。第四档之后回到
  42. // `readLoop` 顶部 `FETCH` 撞上 763596 的 `CONTINUE HANDLER FOR NOT found` ⇒ `done=1` ⇒
  43. // 763605 `LEAVE readLoop` 正常退出,`coursePaymentDetailid` 保留第四档查到的值。
  44. // 即"缺 LEAVE 但循环本来就要结束" ⇒ 与 Go 侧"首命中即返回"**等价**,不存在"被后级覆盖"。
  45. //
  46. // 而**有**判据的前三档,判的是 `**> 1**` 而不是 `> 0`:
  47. // `t_course_payment_detail.id` 是自增主键、从 **1** 开始 ⇒ **id=1 那条规则永远"不算命中"**:
  48. // 该档查到 id=1 后不 LEAVE,控制流继续往下走,被后续**更低优先级**的查询覆盖
  49. // (后级无命中时覆盖成 NULL)→ 最终 `WHERE id=NULL`(763663)返回空 ⇒ "配了规则却试算不出课酬"。
  50. //
  51. // **口径定名(评审用一句话概括)**:这条 `> 1` 实际是「**≥ 2 才短路**」的阈值判据,
  52. // 而不是"有没有查到"的判据。Go 侧按 `> 0` 实现 = 「命中即短路」,与它的差别**恰好只有 id=1 一行**
  53. // (id ≥ 2 时两者等价);因此"真实数据 0 差异"只在**不含 id=1 命中**的前提下成立,
  54. // 割接前必须核对 id=1 那行(见下),不能把"0 差异"当成无条件结论。
  55. //
  56. // 这是 dump 的既有缺陷(错字级:`>1` 应为 `>0`),本次实现按 `>0`(即"找到即命中"),
  57. // 差异面**恰好只有 id=1 这一行**(id>=2 时 `>1` 与 `>0` 等价),登记为**有意差异(修正型)**:
  58. // - 业务影响:仅影响历史第一条课酬规则;旧系统里它从未生效过,新系统会生效。
  59. // 割接前须核对该行内容——若它是一条脏数据/占位行,应在数据清洗阶段停用(actived=0),
  60. // 而不是靠这个错字把它藏起来。
  61. // - 自证失败用例:`TestQuotePayrollPersonalRuleWithIDOneMatches`(把实现改回 `>1` 立刻红)。
  62. //
  63. // ---------------------------------------------------------------------------
  64. // 层级编码(供前端展示与对账时区分"命中哪一档")
  65. // ---------------------------------------------------------------------------
  66. //
  67. // 方案层级:`campus`(校区方案)→ `institution`(`campus_id=0` 机构级方案)。
  68. // 规则层级:`personal_classify` → `personal_grade_subject` → `cert_general` → `nocert_general`。
  69. package service
  70. import (
  71. "context"
  72. stderrors "errors"
  73. "fmt"
  74. "math"
  75. "strings"
  76. "time"
  77. "gorm.io/gorm"
  78. "yuansiwei/unified-backend/internal/academic/apperr"
  79. "yuansiwei/unified-backend/internal/academic/model"
  80. "yuansiwei/unified-backend/internal/academic/money"
  81. "yuansiwei/unified-backend/internal/academic/repository"
  82. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  83. appscope "yuansiwei/unified-backend/internal/infra/scope"
  84. )
  85. // 课酬方案层级(两级回退的命中级别)。
  86. const (
  87. // SchemeScopeCampus 校区级方案(dump 763616,第一个查询)。
  88. SchemeScopeCampus = "campus"
  89. // SchemeScopeInstitution 机构级方案(dump 763617-763619,`campusid=0` 的第二级回退)。
  90. SchemeScopeInstitution = "institution"
  91. )
  92. // 课酬规则层级(三级回退的命中级别)。
  93. const (
  94. // RuleTierPersonalClassify 个人配置·精确分类(dump 763630-763631)。
  95. RuleTierPersonalClassify = "personal_classify"
  96. // RuleTierPersonalGradeSubject 个人配置·年级/科目通配(dump 763636-763637)。
  97. RuleTierPersonalGradeSubject = "personal_grade_subject"
  98. // RuleTierCertGeneral 持证通用(dump 763646-763648)。
  99. RuleTierCertGeneral = "cert_general"
  100. // RuleTierNoCertGeneral 无证通用(dump 763654-763656)。
  101. RuleTierNoCertGeneral = "nocert_general"
  102. )
  103. // PayrollService 课酬试算服务(只读;无事务)。
  104. //
  105. // 字段:
  106. // - repo 课酬方案/规则仓储;
  107. // - sc 请求作用域(可 nil:系统任务/双跑脚本视角)。
  108. type PayrollService struct {
  109. repo *repository.PayrollRepo
  110. sc *appscope.Scope
  111. }
  112. // NewPayrollService 构造课酬试算服务。
  113. //
  114. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域;nil = 系统任务视角)。
  115. // 出参:*PayrollService。
  116. func NewPayrollService(db *gorm.DB, sc *appscope.Scope) *PayrollService {
  117. return &PayrollService{repo: repository.NewPayrollRepo(db, sc), sc: sc}
  118. }
  119. // QuotePayrollInput 课酬试算入参(对齐 dump 的 6 个入参)。
  120. //
  121. // 字段:
  122. // - TeacherID 老师(ULID;旧 inUserid);
  123. // - TeacherExp 教龄(年;旧 inTeacherExp FLOAT;**负数按 0 处理**,dump 763609);
  124. // - InstitutionID 机构 ID(旧 inSchoolid;<=0 时取作用域机构);
  125. // - CampusID 校区 ID(旧 inCampusid;方案两级回退的第一级);
  126. // - ClassifyID 课程分类 ID(旧 inClassifyid;提供 grade/subject/class_type 三个维度);
  127. // - Date 试算日期(旧 inDate;方案 `effect_date <= Date`,**含当日**)。
  128. type QuotePayrollInput struct {
  129. // TeacherID 老师(ULID 26 位)。
  130. TeacherID string
  131. // TeacherExp 教龄(年;可含小数,负数当 0)。
  132. TeacherExp float64
  133. // InstitutionID 机构 ID(旧 schoolid;<=0 → 用作用域机构)。
  134. InstitutionID int64
  135. // CampusID 校区 ID(旧 campusid;0 = 只看机构级方案)。
  136. CampusID int64
  137. // ClassifyID 课程分类 ID(>0)。
  138. ClassifyID int64
  139. // Date 试算日期(零值 → ErrBadParam)。
  140. Date time.Time
  141. }
  142. // PayrollQuote 课酬试算结果(dump 763663 `SELECT *` 的 4 个业务列 + 溯源列)。
  143. //
  144. // 金额口径:`Min` / `Max` / `Reward` 来自 `decimal(12,2)` 列 → `money.Amount`(两位小数字符串输出)。
  145. //
  146. // **`Percentage` 不是金额**:源列是 `decimal(5,1)`(百分比数字部分,如 `65.5` 表示 65.5%),
  147. // 因此保留**原值字符串**,绝不用 `money.Amount` 承载(否则 65.5 → "65.50 元" 的口径污染)。
  148. type PayrollQuote struct {
  149. // DetailID 命中的课酬明细规则 ID(旧 coursePaymentDetailid)。
  150. DetailID int64 `json:"detailId"`
  151. // SchemeID 命中的课酬方案 ID(旧 coursePaymentid)。
  152. SchemeID int64 `json:"schemeId"`
  153. // SchemeCampusID 命中方案所属校区(0 = 机构级方案)。
  154. SchemeCampusID int64 `json:"schemeCampusId"`
  155. // SchemeScope 方案层级(SchemeScopeCampus / SchemeScopeInstitution)。
  156. SchemeScope string `json:"schemeScope"`
  157. // RuleTier 规则层级(RuleTier* 四值之一)。
  158. RuleTier string `json:"ruleTier"`
  159. // Percentage 课酬比例(decimal(5,1) 原值字符串,如 "65.5";**非金额**)。
  160. Percentage string `json:"percentage"`
  161. // Min 最低课酬标准(元;0 = 不限制)。
  162. Min money.Amount `json:"min"`
  163. // Max 最高课酬标准(元;0 = 不限制)。
  164. Max money.Amount `json:"max"`
  165. // Reward 额外课酬(元;在比例基础上追加)。
  166. Reward money.Amount `json:"reward"`
  167. }
  168. // QuoteTeacherReward 课酬试算(设计 §3.4 #26 的正式方法名)。
  169. //
  170. // 入参:
  171. // - ctx 请求上下文;
  172. // - in QuotePayrollInput(TeacherID / ClassifyID / Date 必填)。
  173. //
  174. // 出参:
  175. // - *PayrollQuote:命中规则(含溯源层级);
  176. // - `apperr.ErrPayrollPlanMissing`(3112):**两级方案都没有**,或**四级规则一条都没命中**;
  177. // - ErrBadParam(2001) 缺参(含试算日期零值);ErrUnscoped(1004) 跨机构;ErrDBError(5002)。
  178. //
  179. // 为什么"分类不存在"也归 3112:dump 在分类不存在时游标 0 行 → 末尾按 id=0 取行 → **返回空结果集**,
  180. // 旧 PHP 调用方对空结果的处理与"没有规则"完全一致(拿不到课酬)。Go 侧把这两种"拿不到课酬"
  181. // 统一成 3112 并挂不同 cause,既不改变可观察类别,又能在日志里区分成因。
  182. func (s *PayrollService) QuoteTeacherReward(ctx context.Context, in QuotePayrollInput) (*PayrollQuote, error) {
  183. if strings.TrimSpace(in.TeacherID) == "" {
  184. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  185. errString("service: QuoteTeacherReward 需要 teacher_id"))
  186. }
  187. if in.ClassifyID <= 0 {
  188. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  189. errString("service: QuoteTeacherReward 需要 classify_id(>0)"))
  190. }
  191. if in.Date.IsZero() {
  192. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  193. errString("service: QuoteTeacherReward 需要试算日期"))
  194. }
  195. if in.CampusID < 0 {
  196. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  197. errString("service: campus_id 不能为负"))
  198. }
  199. institutionID := in.InstitutionID
  200. if institutionID <= 0 && s.sc != nil {
  201. institutionID = s.sc.InstitutionID
  202. }
  203. if institutionID <= 0 {
  204. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  205. errString("service: QuoteTeacherReward 需要机构 id(入参或作用域)"))
  206. }
  207. // 763594 分类(游标等价单查):不存在 ⇒ 与 dump 的"空结果集"同类 → 3112
  208. classify, err := s.repo.GetClassify(ctx, in.ClassifyID)
  209. if err != nil {
  210. if isNotFoundErr(err) {
  211. return nil, infraerrors.WithCause(apperr.ErrPayrollPlanMissing,
  212. errString("service: 课程分类不存在(classify_id="+
  213. fmt.Sprintf("%d", in.ClassifyID)+"),无可匹配的课酬规则"))
  214. }
  215. return nil, err
  216. }
  217. // 763609 教龄负值钳 0(dump 在循环体内做,但只依赖入参,提出循环等价)
  218. teacherExp := formatTeacherExp(in.TeacherExp)
  219. // 763613 持证判定(无行 = 无证)
  220. hasCert, err := s.repo.TeacherHasCert(ctx, in.TeacherID)
  221. if err != nil {
  222. return nil, err
  223. }
  224. // 763616-763623 方案两级回退
  225. scheme, scope, err := s.resolveScheme(ctx, institutionID, in.CampusID, in.Date)
  226. if err != nil {
  227. return nil, err
  228. }
  229. // 763627-763656 规则三级回退(个人配置内部再分两档)
  230. detail, tier, err := s.resolveDetail(ctx, scheme.ID, in.TeacherID, hasCert,
  231. classify, teacherExp)
  232. if err != nil {
  233. return nil, err
  234. }
  235. if detail == nil {
  236. // 763663:dump 返回空结果集 ⇒ Go 显式化 3112(与"无方案"同一个对外类别)
  237. return nil, infraerrors.WithCause(apperr.ErrPayrollPlanMissing,
  238. errString("service: 该老师/分类/教龄未命中任何课酬规则(方案 "+fmt.Sprintf("%d", scheme.ID)+")"))
  239. }
  240. quote, err := buildPayrollQuote(detail)
  241. if err != nil {
  242. return nil, err
  243. }
  244. quote.SchemeID = scheme.ID
  245. quote.SchemeCampusID = scheme.CampusID
  246. quote.SchemeScope = scope
  247. quote.RuleTier = tier
  248. return quote, nil
  249. }
  250. // QuotePayroll 课酬试算(AC-10 派单口径的方法名;**转调** QuoteTeacherReward,无第二份逻辑)。
  251. //
  252. // 入参/出参见 QuoteTeacherReward。
  253. func (s *PayrollService) QuotePayroll(ctx context.Context, in QuotePayrollInput) (*PayrollQuote, error) {
  254. return s.QuoteTeacherReward(ctx, in)
  255. }
  256. // resolveScheme 方案两级回退(dump 763616-763623)。
  257. //
  258. // 入参:ctx;institutionID(>0);campusID 入参校区;date 试算日期。
  259. // 出参:
  260. // - *model.CoursePayment 命中方案(**保证非 nil**);
  261. // - string 命中层级(SchemeScopeCampus / SchemeScopeInstitution);
  262. // - 3112(两级都没有)/ ErrBadParam / ErrUnscoped / ErrDBError。
  263. //
  264. // 顺序**不可交换**:先校区级、再机构级(`campus_id=0`);校区级存在时**绝不**因为
  265. // "机构级更匹配"而回退——dump 只在 `coursePaymentid<1` 时才走第二级(763617-763619)。
  266. func (s *PayrollService) resolveScheme(
  267. ctx context.Context, institutionID, campusID int64, date time.Time,
  268. ) (*model.CoursePayment, string, error) {
  269. scheme, err := s.repo.FindActiveScheme(ctx, institutionID, campusID, date)
  270. if err != nil {
  271. return nil, "", err
  272. }
  273. if scheme != nil {
  274. return scheme, SchemeScopeCampus, nil
  275. }
  276. // 763618:`campusid=0` 机构级方案(**campusID=0 时不必重查**:上面那次就是它)
  277. if campusID != 0 {
  278. scheme, err = s.repo.FindActiveScheme(ctx, institutionID, 0, date)
  279. if err != nil {
  280. return nil, "", err
  281. }
  282. if scheme != nil {
  283. return scheme, SchemeScopeInstitution, nil
  284. }
  285. }
  286. // 763621:`IF coursePaymentid<1 THEN LEAVE readLoop` → 无方案
  287. return nil, "", infraerrors.WithCause(apperr.ErrPayrollPlanMissing,
  288. errString("service: 机构 "+fmt.Sprintf("%d", institutionID)+" 校区 "+
  289. fmt.Sprintf("%d", campusID)+" 无生效课酬方案(含机构级回退)"))
  290. }
  291. // resolveDetail 规则三级回退(dump 763627-763656)。
  292. //
  293. // 入参:ctx;schemeID;teacherID;hasCert 是否持证;classify 分类维度;teacherExp 已钳 0 的 decimal 字符串。
  294. // 出参:
  295. // - *model.CoursePaymentDetail 命中规则(**未命中返回 nil**,由调用方翻译 3112);
  296. // - string 命中层级(RuleTier*);
  297. // - ErrBadParam / ErrDBError。
  298. //
  299. // 回退顺序(与 dump 逐行一致,**任何一步命中即停**):
  300. //
  301. // 个人档闸门(763627 COUNT>0)→ ① 精确分类(763630)→ ② 年级/科目通配(763636)
  302. // → 持证时 ③ 持证通用(763646)→ ④ 无证通用(763654)
  303. //
  304. // 注意两处"看起来可以省掉、其实不能改"的细节:
  305. // 1. 个人档闸门是 `COUNT>0`(任意一条个人规则)而不是"命中某条"——它的作用只是决定
  306. // "要不要尝试 ①/②",因此闸门开着但 ①② 都不命中时,**继续往 ③/④ 走**(不是返回空);
  307. // 2. 持证者若 ③ 未命中,**仍要**试 ④(无证通用档也对持证老师开放):
  308. // dump 763652 是 `END IF`(结束 `IF teacherCert>0`),紧随其后的 763654 就是无证档——
  309. // **没有 else 分支**,所以持证老师的路径必然经过 ④。
  310. func (s *PayrollService) resolveDetail(
  311. ctx context.Context, schemeID int64, teacherID string, hasCert bool,
  312. classify *model.CoursesClassify, teacherExp string,
  313. ) (*model.CoursePaymentDetail, string, error) {
  314. personalCount, err := s.repo.CountPersonalRules(ctx, schemeID, teacherID, classify.ClassType)
  315. if err != nil {
  316. return nil, "", err
  317. }
  318. if personalCount > 0 {
  319. detail, err := s.repo.FindPersonalByClassify(ctx, schemeID, teacherID, classify.ClassType, classify.ID)
  320. if err != nil {
  321. return nil, "", err
  322. }
  323. if detail != nil {
  324. return detail, RuleTierPersonalClassify, nil
  325. }
  326. detail, err = s.repo.FindPersonalByGradeSubject(ctx, schemeID, teacherID, classify.ClassType,
  327. classify.GradeID, classify.SubjectID)
  328. if err != nil {
  329. return nil, "", err
  330. }
  331. if detail != nil {
  332. return detail, RuleTierPersonalGradeSubject, nil
  333. }
  334. }
  335. if hasCert {
  336. detail, err := s.repo.FindGeneralRule(ctx, schemeID, true, classify.ClassType,
  337. classify.GradeID, classify.SubjectID, teacherExp)
  338. if err != nil {
  339. return nil, "", err
  340. }
  341. if detail != nil {
  342. return detail, RuleTierCertGeneral, nil
  343. }
  344. }
  345. detail, err := s.repo.FindGeneralRule(ctx, schemeID, false, classify.ClassType,
  346. classify.GradeID, classify.SubjectID, teacherExp)
  347. if err != nil {
  348. return nil, "", err
  349. }
  350. if detail != nil {
  351. return detail, RuleTierNoCertGeneral, nil
  352. }
  353. return nil, "", nil
  354. }
  355. // buildPayrollQuote 把命中的规则行转成 DTO(金额定点解析 + 比例原值保留)。
  356. //
  357. // 入参:detail 命中的规则行(非 nil)。
  358. // 出参:*PayrollQuote(DetailID/Percentage/Min/Max/Reward 已填);金额字面量非法 → ErrDBError。
  359. func buildPayrollQuote(detail *model.CoursePaymentDetail) (*PayrollQuote, error) {
  360. minAmount, err := money.Parse(detail.MinPayment)
  361. if err != nil {
  362. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  363. }
  364. maxAmount, err := money.Parse(detail.MaxPayment)
  365. if err != nil {
  366. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  367. }
  368. reward, err := money.Parse(detail.Reward)
  369. if err != nil {
  370. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  371. }
  372. return &PayrollQuote{
  373. DetailID: detail.ID,
  374. Percentage: strings.TrimSpace(detail.Percentage),
  375. Min: minAmount,
  376. Max: maxAmount,
  377. Reward: reward,
  378. }, nil
  379. }
  380. // formatTeacherExp 把教龄(年)格式化成 `decimal(6,3)` 字符串(供 SQL 做 BETWEEN 比较)。
  381. //
  382. // 入参:exp 教龄(年;dump 为 FLOAT)。负数 → 按 0(dump 763609)。
  383. // 出参:形如 "3.500" 的定点字符串(**不经 float 参与累加**:只做一次"×1000 取整"换算,
  384. // 之后全部由数据库按 decimal 语义比较,避免 3.4999999 之类的二进制尾差把边界行判掉)。
  385. //
  386. // ⚠ **小数位数必须与列 scale 一致 = 3**(列是 `decimal(6,3)`,源 `float(10,3)`):
  387. // 这不是格式美观问题 —— 教龄区间靠 `BETWEEN exp_min AND exp_max` 判定,
  388. // 若列被收敛成 2 位,`0.999` 会存成 `1.00`,使 `[0.000,1.000]` 与 `[1.000,1.999]` **并列命中**,
  389. // 课酬按错档位算(D7 双跑必红)。P0 修复记录见 model.CoursePaymentDetail 的列注释。
  390. func formatTeacherExp(exp float64) string {
  391. if math.IsNaN(exp) || exp < 0 {
  392. exp = 0
  393. }
  394. if math.IsInf(exp, 0) || exp > 999 {
  395. // decimal(6,3) 上限 999.999;超界直接夹到上限(教龄超过一千年在业务上无意义)
  396. exp = 999
  397. }
  398. thousandths := int64(math.Round(exp * 1000))
  399. return fmt.Sprintf("%d.%03d", thousandths/1000, thousandths%1000)
  400. }
  401. // isNotFoundErr 判断错误是否为"记录不存在"(ErrNotFound / code 3001)。
  402. //
  403. // 入参:err 待判定错误(可 nil)。出参:true=不存在。
  404. func isNotFoundErr(err error) bool {
  405. if err == nil {
  406. return false
  407. }
  408. var appErr *infraerrors.AppError
  409. if stderrors.As(err, &appErr) {
  410. return appErr.Code == infraerrors.CodeNotFound
  411. }
  412. return false
  413. }