payroll_service.go 19 KB

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