payroll_service.go 18 KB

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