payroll_plan_service.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. // 课酬方案 + 明细服务(端点 58)——AC-13d 追加。
  2. //
  3. // 依据:团队裁决 D17(两级形状 / `PUT` 按方案 id 整体替换明细 / 教龄区间两字段)。
  4. //
  5. // ---------------------------------------------------------------------------
  6. // 为什么新增而不是改 AC-10 的 `PayrollService`
  7. // ---------------------------------------------------------------------------
  8. //
  9. // AC-10 交付的 `PayrollService` 是**只读试算**(`QuoteTeacherReward` / `QuotePayroll`),
  10. // 没有任何写入面。端点 58 是"配置 CRUD",属**新能力**:
  11. //
  12. // 新能力 / 为什么 AC-10 没覆盖 / 是否与既有逻辑冲突
  13. // ListPlans / AC-10 只有"按机构+校区+日期取**一条**生效方案",没有列表 / 无(新查询,只读)
  14. // GetPlan + details / AC-10 不暴露方案实体(只回命中的规则) / 无(新查询,只读)
  15. // CreatePlan / AC-10 无任何写入 / 无(新事务)
  16. // UpdatePlan / 同上 / 无(新事务)
  17. // ReplaceDetails / 同上("整体替换明细"是 D17 新裁的口径) / 无(新事务)
  18. //
  19. // 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。
  20. //
  21. // ---------------------------------------------------------------------------
  22. // 两条校验口径(**不是可有可无的防呆**)
  23. // ---------------------------------------------------------------------------
  24. //
  25. // 1. **`teacherExpMin <= teacherExpMax`**:区间倒挂会让规则**永不命中**(`BETWEEN` 恒 false)
  26. // ⇒ 课酬静默变 0。必须在写入前拒(2002),不能靠"以后发现课酬不对"。
  27. // 2. **`percentage` 必须是 0~100 的百分数**:它是 `percentage/100` 的基数(PHP `:781`),
  28. // 传 `0.85` 会被当成 0.85% ⇒ 课酬少 100 倍。写入侧限 0~100 且 ≤1 位小数,
  29. // 与列 `decimal(5,1)` 一致。
  30. package service
  31. import (
  32. "context"
  33. "strings"
  34. "time"
  35. "gorm.io/gorm"
  36. "yuansiwei/unified-backend/internal/academic/model"
  37. "yuansiwei/unified-backend/internal/academic/money"
  38. "yuansiwei/unified-backend/internal/academic/repository"
  39. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  40. appscope "yuansiwei/unified-backend/internal/infra/scope"
  41. )
  42. // MaxPayrollPercentage 课酬比例上限(百分数,含)。列是 `decimal(5,1)`,业务上 0~100。
  43. const MaxPayrollPercentage = 100
  44. // PayrollPlanService 课酬方案配置服务(写入面 + 列表读面)。
  45. type PayrollPlanService struct {
  46. // repo 课酬方案/明细仓储(复用 AC-10 的 PayrollRepo,新增方法在同包新文件)。
  47. repo *repository.PayrollRepo
  48. // runner 事务运行器(方案与明细必须同一事务)。
  49. runner *repository.TxRunner
  50. // sc 请求作用域(机构维度越权断言)。
  51. sc *appscope.Scope
  52. // now 时钟(effect_date 缺省值用)。
  53. now func() time.Time
  54. }
  55. // NewPayrollPlanService 构造课酬方案服务。
  56. //
  57. // 入参:db;sc 请求作用域(nil = 系统任务视角,`assertInstitution` 放行)。
  58. // 出参:*PayrollPlanService。
  59. func NewPayrollPlanService(db *gorm.DB, sc *appscope.Scope) *PayrollPlanService {
  60. return &PayrollPlanService{
  61. repo: repository.NewPayrollRepo(db, sc),
  62. runner: repository.NewTxRunner(db),
  63. sc: sc,
  64. now: time.Now,
  65. }
  66. }
  67. // PayrollPlanQuery 端点 58 GET 的查询入参。
  68. //
  69. // 字段:InstitutionID 机构(**必填 >0**;非超管必须等于作用域机构,否则 1004);
  70. // CampusID 校区(nil 不筛);Actived 启用(nil 不筛);Keyword 名称模糊;Page/PageSize 分页。
  71. type PayrollPlanQuery struct {
  72. InstitutionID int64
  73. CampusID *int64
  74. Actived *bool
  75. Keyword string
  76. Page int
  77. PageSize int
  78. }
  79. // PayrollPlanDetail 方案下的一条明细(对外视图,**每个字段都能追到列**)。
  80. //
  81. // 字段来源:
  82. //
  83. // DetailID ← t_course_payment_detail.id
  84. // TeacherID ← teacher_id(**可空**;NULL 原样下发 `null`,不是空串)
  85. // ClassifyID ← classify_id
  86. // ClassType ← class_type
  87. // GradeID ← grade_id
  88. // SubjectID ← subject_id
  89. // TeacherCert ← teacher_cert
  90. // TeacherExpMin ← teacher_exp_min(教龄区间下界,**不是单值 teacherExp**;定点化到 3 位)
  91. // TeacherExpMax ← teacher_exp_max(定点化到 3 位)
  92. // Percentage ← percentage(**百分数**,"85.0" = 85%;定点化到 1 位)
  93. // Min ← min_payment(金额,2 位小数)
  94. // Max ← max_payment(金额,2 位小数)
  95. // Reward ← reward(金额,2 位小数)
  96. // DisplayName ← name(**明细的"计算方式名称"**;与方案名分开,见 D17)
  97. // DetailActived ← actived(**明细级**启用;方案的 actived 是另一层)
  98. //
  99. // ⚠ **`TeacherID` 用 `*string` 而不是 `string`**:`teacher_id` 可空,"通用规则"就是 NULL。
  100. // 下发 `""` 会让前端无法区分两件事:
  101. //
  102. // ① 这条规则**不限定老师**(NULL,通用档);
  103. // ② 这条规则限定了老师,但老师 ID 是空串(数据坏了,本不该存在)。
  104. //
  105. // 前者是正常业务,后者是脏数据,前端一个 `if (teacherId)` 就把两者混成一支——
  106. // 脏数据被当成"通用档"静默通过校验。`null` / `"ULID"` / 见不到第三种取值,才有排查价值。
  107. type PayrollPlanDetail struct {
  108. DetailID int64
  109. TeacherID *string
  110. ClassifyID int64
  111. ClassType int32
  112. GradeID int32
  113. SubjectID int32
  114. TeacherCert bool
  115. TeacherExpMin string
  116. TeacherExpMax string
  117. Percentage string
  118. Min money.Amount
  119. Max money.Amount
  120. Reward money.Amount
  121. DisplayName string
  122. DetailActived bool
  123. }
  124. // PayrollPlan 方案 + 明细(端点 58 的响应单元)。
  125. //
  126. // 字段来源:
  127. //
  128. // ID ← t_course_payment.id(**方案 id**,`PUT /plans/:id` 的 `:id`)
  129. // InstitutionID ← institution_id
  130. // CampusID ← campus_id(0 = 机构级方案)
  131. // SchemeName ← name(**方案名称**;与明细的 displayName 分开)
  132. // EffectDate ← effect_date(可空)
  133. // Actived ← actived(**方案级**启用)
  134. // Details ← t_course_payment_detail(按 id ASC)
  135. type PayrollPlan struct {
  136. ID int64
  137. InstitutionID int64
  138. CampusID int64
  139. SchemeName string
  140. EffectDate *time.Time
  141. Actived bool
  142. Details []PayrollPlanDetail
  143. }
  144. // ListPlans 分页查询方案(含明细)。
  145. //
  146. // 入参:ctx;q 查询入参。
  147. // 出参:([]PayrollPlan, total, error);空结果非 nil;1004(跨机构)/2001/5002。
  148. func (s *PayrollPlanService) ListPlans(ctx context.Context, q PayrollPlanQuery) ([]PayrollPlan, int64, error) {
  149. if q.InstitutionID <= 0 {
  150. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  151. errString("service: 课酬方案列表需要 institutionId(>0)"))
  152. }
  153. plans, total, err := s.repo.ListPlans(ctx, q.InstitutionID, repository.PayrollPlanFilter{
  154. CampusID: q.CampusID, Actived: q.Actived, Keyword: q.Keyword,
  155. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  156. if err != nil {
  157. return nil, 0, err
  158. }
  159. if len(plans) == 0 {
  160. return make([]PayrollPlan, 0), total, nil
  161. }
  162. ids := make([]int64, 0, len(plans))
  163. for i := range plans {
  164. ids = append(ids, plans[i].ID)
  165. }
  166. details, err := s.repo.ListDetailsByPlanIDs(ctx, ids)
  167. if err != nil {
  168. return nil, 0, err
  169. }
  170. byPlan := make(map[int64][]PayrollPlanDetail, len(ids))
  171. for i := range details {
  172. d := &details[i]
  173. view, perr := toPlanDetail(d)
  174. if perr != nil {
  175. return nil, 0, perr
  176. }
  177. byPlan[d.CoursePaymentID] = append(byPlan[d.CoursePaymentID], view)
  178. }
  179. out := make([]PayrollPlan, 0, len(plans))
  180. for i := range plans {
  181. p := &plans[i]
  182. items := byPlan[p.ID]
  183. if items == nil {
  184. items = make([]PayrollPlanDetail, 0)
  185. }
  186. out = append(out, PayrollPlan{
  187. ID: p.ID,
  188. InstitutionID: p.InstitutionID,
  189. CampusID: p.CampusID,
  190. SchemeName: strValue(p.Name),
  191. EffectDate: p.EffectDate,
  192. Actived: p.Actived,
  193. Details: items,
  194. })
  195. }
  196. return out, total, nil
  197. }
  198. // PayrollPlanDetailInput 明细写入入参(新建/整体替换共用)。
  199. //
  200. // 全部字段与列一一对应;金额用"元字符串"由 Handler 解析后传入(`money.Amount`)。
  201. type PayrollPlanDetailInput struct {
  202. TeacherID string
  203. ClassifyID int64
  204. ClassType int32
  205. GradeID int32
  206. SubjectID int32
  207. TeacherCert bool
  208. TeacherExpMin string
  209. TeacherExpMax string
  210. Percentage string
  211. Min money.Amount
  212. Max money.Amount
  213. Reward money.Amount
  214. DisplayName string
  215. Actived bool
  216. }
  217. // CreatePlanInput 端点 58 POST 入参(方案 + 明细,一次建完)。
  218. type CreatePlanInput struct {
  219. InstitutionID int64
  220. CampusID int64
  221. SchemeName string
  222. EffectDate *time.Time
  223. Actived bool
  224. Details []PayrollPlanDetailInput
  225. OperatorID string
  226. }
  227. // UpdatePlanInput 端点 58 PUT 入参(`:id` = **方案 id**,明细整体替换)。
  228. //
  229. // 指针语义:nil = 不修改(方案字段);`Details` nil = **不动明细**,
  230. // 非 nil(含空切片)= **整体替换**(空切片 = 清空明细)。
  231. type UpdatePlanInput struct {
  232. ID int64
  233. InstitutionID int64
  234. CampusID *int64
  235. SchemeName *string
  236. EffectDate *time.Time
  237. Actived *bool
  238. Details []PayrollPlanDetailInput
  239. ReplaceDetail bool
  240. }
  241. // CreatePlan 新建方案 + 明细(**同一事务**)。
  242. //
  243. // 入参:ctx;in 入参。
  244. // 出参:*PayrollPlan(含新 id 与明细);2001/2002(名称/比例/教龄区间/金额非法)/1004/5002。
  245. func (s *PayrollPlanService) CreatePlan(ctx context.Context, in CreatePlanInput) (*PayrollPlan, error) {
  246. name := strings.TrimSpace(in.SchemeName)
  247. if name == "" {
  248. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
  249. }
  250. if in.InstitutionID <= 0 {
  251. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  252. errString("service: 课酬方案需要 institutionId(>0)"))
  253. }
  254. details, err := buildPlanDetails(in.Details)
  255. if err != nil {
  256. return nil, err
  257. }
  258. row := &model.CoursePayment{
  259. InstitutionID: in.InstitutionID,
  260. CampusID: in.CampusID,
  261. Name: &name,
  262. EffectDate: in.EffectDate,
  263. Actived: in.Actived,
  264. }
  265. if uid := strings.TrimSpace(in.OperatorID); uid != "" {
  266. row.CreateUserID = &uid
  267. }
  268. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  269. txCtx := repository.WithTxContext(ctx, tx)
  270. if cerr := s.repo.CreatePlan(txCtx, tx, row); cerr != nil {
  271. return cerr
  272. }
  273. return s.repo.ReplaceDetails(txCtx, tx, row.ID, details)
  274. })
  275. if err != nil {
  276. return nil, err
  277. }
  278. return s.getPlan(ctx, in.InstitutionID, row.ID)
  279. }
  280. // UpdatePlan 更新方案(可选整体替换明细),**同一事务**。
  281. //
  282. // 入参:ctx;in 入参(ID = 方案 id)。
  283. // 出参:*PayrollPlan(更新后回读);2001(没有任何待更新字段)/2002/3001/1004/5002。
  284. func (s *PayrollPlanService) UpdatePlan(ctx context.Context, in UpdatePlanInput) (*PayrollPlan, error) {
  285. if in.ID <= 0 {
  286. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案 id 必须 > 0"))
  287. }
  288. if in.InstitutionID <= 0 {
  289. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  290. errString("service: 课酬方案需要 institutionId(>0)"))
  291. }
  292. row := &model.CoursePayment{ID: in.ID, InstitutionID: in.InstitutionID}
  293. columns := make([]string, 0, 4)
  294. if in.CampusID != nil {
  295. row.CampusID = *in.CampusID
  296. columns = append(columns, "campus_id")
  297. }
  298. if in.SchemeName != nil {
  299. name := strings.TrimSpace(*in.SchemeName)
  300. if name == "" {
  301. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
  302. }
  303. row.Name = &name
  304. columns = append(columns, "name")
  305. }
  306. if in.EffectDate != nil {
  307. row.EffectDate = in.EffectDate
  308. columns = append(columns, "effect_date")
  309. }
  310. if in.Actived != nil {
  311. row.Actived = *in.Actived
  312. columns = append(columns, "actived")
  313. }
  314. var details []*model.CoursePaymentDetail
  315. if in.ReplaceDetail {
  316. built, derr := buildPlanDetails(in.Details)
  317. if derr != nil {
  318. return nil, derr
  319. }
  320. details = built
  321. }
  322. if len(columns) == 0 && !in.ReplaceDetail {
  323. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  324. errString("service: 没有任何待更新字段(方案字段与明细都未提供)"))
  325. }
  326. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  327. txCtx := repository.WithTxContext(ctx, tx)
  328. if _, gerr := s.repo.GetPlanByID(txCtx, in.InstitutionID, in.ID); gerr != nil {
  329. return gerr // 3001(不存在/不在本机构)
  330. }
  331. if len(columns) > 0 {
  332. affected, uerr := s.repo.UpdatePlan(txCtx, tx, row, columns)
  333. if uerr != nil {
  334. return uerr
  335. }
  336. if affected == 0 {
  337. return infraerrors.WithCause(infraerrors.ErrNotFound,
  338. errString("service: 课酬方案不存在或不在本机构"))
  339. }
  340. }
  341. if in.ReplaceDetail {
  342. return s.repo.ReplaceDetails(txCtx, tx, in.ID, details)
  343. }
  344. return nil
  345. })
  346. if err != nil {
  347. return nil, err
  348. }
  349. return s.getPlan(ctx, in.InstitutionID, in.ID)
  350. }
  351. // getPlan 回读单个方案 + 明细(Create/Update 的出参装配)。
  352. //
  353. // 入参:ctx;institutionID;id。出参:*PayrollPlan;3001/1004/5002。
  354. func (s *PayrollPlanService) getPlan(ctx context.Context, institutionID, id int64) (*PayrollPlan, error) {
  355. row, err := s.repo.GetPlanByID(ctx, institutionID, id)
  356. if err != nil {
  357. return nil, err
  358. }
  359. details, err := s.repo.ListDetailsByPlanIDs(ctx, []int64{id})
  360. if err != nil {
  361. return nil, err
  362. }
  363. items := make([]PayrollPlanDetail, 0, len(details))
  364. for i := range details {
  365. view, perr := toPlanDetail(&details[i])
  366. if perr != nil {
  367. return nil, perr
  368. }
  369. items = append(items, view)
  370. }
  371. return &PayrollPlan{
  372. ID: row.ID,
  373. InstitutionID: row.InstitutionID,
  374. CampusID: row.CampusID,
  375. SchemeName: strValue(row.Name),
  376. EffectDate: row.EffectDate,
  377. Actived: row.Actived,
  378. Details: items,
  379. }, nil
  380. }
  381. // buildPlanDetails 校验并构造明细行(**唯一校验点**,Create/Update 共用)。
  382. //
  383. // 入参:inputs 明细入参。出参:[]*model.CoursePaymentDetail;2001/2002。
  384. //
  385. // 校验项(顺序固定):
  386. // 1. `percentage` 必须是 0~100 的百分数(≤1 位小数)——传 0.85 会让课酬少 100 倍;
  387. // 2. `teacherExpMin <= teacherExpMax`(区间倒挂 ⇒ 规则永不命中 ⇒ 课酬静默 0);
  388. // 3. 金额非负(`min/max/reward` 都是支出方向;`min/max` 为 0 = 不设限)。
  389. func buildPlanDetails(inputs []PayrollPlanDetailInput) ([]*model.CoursePaymentDetail, error) {
  390. out := make([]*model.CoursePaymentDetail, 0, len(inputs))
  391. for i := range inputs {
  392. in := &inputs[i]
  393. pct := strings.TrimSpace(in.Percentage)
  394. if pct == "" {
  395. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  396. errString("service: 课酬比例 percentage 不能为空(0~100 的百分数)"))
  397. }
  398. if pctValue, perr := parsePercentage(pct); perr != nil {
  399. return nil, perr
  400. } else if pctValue > MaxPayrollPercentage {
  401. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  402. errString("service: 课酬比例不得超过 100(收到 "+pct+";注意是百分数,85% 应传 85 或 85.0)"))
  403. }
  404. minExp := strings.TrimSpace(in.TeacherExpMin)
  405. maxExp := strings.TrimSpace(in.TeacherExpMax)
  406. if minExp == "" {
  407. minExp = "0.000"
  408. }
  409. if maxExp == "" {
  410. maxExp = "0.000"
  411. }
  412. minValue, minErr := parseDecimal3(minExp)
  413. if minErr != nil {
  414. return nil, minErr
  415. }
  416. maxValue, maxErr := parseDecimal3(maxExp)
  417. if maxErr != nil {
  418. return nil, maxErr
  419. }
  420. if minValue > maxValue {
  421. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  422. errString("service: 教龄区间倒挂(teacherExpMin "+minExp+" > teacherExpMax "+maxExp+
  423. "):该规则将永不命中,课酬会静默变 0"))
  424. }
  425. for _, amt := range []money.Amount{in.Min, in.Max, in.Reward} {
  426. if amt.IsNegative() {
  427. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  428. errString("service: 课酬金额不能为负"))
  429. }
  430. if err := amt.Validate(); err != nil {
  431. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  432. }
  433. }
  434. row := &model.CoursePaymentDetail{
  435. GradeID: in.GradeID,
  436. SubjectID: in.SubjectID,
  437. ClassType: in.ClassType,
  438. ClassifyID: in.ClassifyID,
  439. Percentage: pct,
  440. MinPayment: in.Min.String(),
  441. MaxPayment: in.Max.String(),
  442. Reward: in.Reward.String(),
  443. TeacherCert: in.TeacherCert,
  444. TeacherExpMin: minExp,
  445. TeacherExpMax: maxExp,
  446. Actived: in.Actived,
  447. }
  448. if tid := strings.TrimSpace(in.TeacherID); tid != "" {
  449. row.TeacherID = &tid
  450. }
  451. if name := strings.TrimSpace(in.DisplayName); name != "" {
  452. row.Name = &name
  453. }
  454. out = append(out, row)
  455. }
  456. return out, nil
  457. }
  458. // toPlanDetail 把明细行转成对外视图(**出口定点化 + 金额定点解析**;列写坏 → 5002)。
  459. //
  460. // 入参:d 明细行。出参:PayrollPlanDetail;5002(ErrDBError)。
  461. //
  462. // 三类字段的出口处理**必须都做**,且都不能省:
  463. //
  464. // percentage / teacherExp → `FormatFixed` 到列声明的位数(1 / 3 位)。
  465. // 不定点化 ⇒ SQLite 回 "85"、MySQL 回 "85.0",前端 diff 误判 + 单测随方言摇摆;
  466. // **也正因为定点化,"比例被当成金额(2 位)"这种量纲污染会立刻显形**。
  467. // 金额(min/max/reward) → `money.Amount.String()`,恒 2 位小数(定点整数域,无浮点)。
  468. // teacherId → NULL 保持 `nil`(下发 `null`),**不折叠成空串**(见字段注释)。
  469. func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
  470. minAmount, err := parseAmount(d.MinPayment)
  471. if err != nil {
  472. return PayrollPlanDetail{}, err
  473. }
  474. maxAmount, err := parseAmount(d.MaxPayment)
  475. if err != nil {
  476. return PayrollPlanDetail{}, err
  477. }
  478. reward, err := parseAmount(d.Reward)
  479. if err != nil {
  480. return PayrollPlanDetail{}, err
  481. }
  482. percentage, err := FormatPercentage(d.Percentage)
  483. if err != nil {
  484. return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
  485. }
  486. yearMin, err := FormatYears(d.TeacherExpMin)
  487. if err != nil {
  488. return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
  489. }
  490. yearMax, err := FormatYears(d.TeacherExpMax)
  491. if err != nil {
  492. return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
  493. }
  494. return PayrollPlanDetail{
  495. DetailID: d.ID,
  496. TeacherID: optionalTrimmedPtr(d.TeacherID),
  497. ClassifyID: d.ClassifyID,
  498. ClassType: d.ClassType,
  499. GradeID: d.GradeID,
  500. SubjectID: d.SubjectID,
  501. TeacherCert: d.TeacherCert,
  502. TeacherExpMin: yearMin,
  503. TeacherExpMax: yearMax,
  504. Percentage: percentage,
  505. Min: minAmount,
  506. Max: maxAmount,
  507. Reward: reward,
  508. DisplayName: strValue(d.Name),
  509. DetailActived: d.Actived,
  510. }, nil
  511. }
  512. // optionalTrimmedPtr 把"可空文本列"归一为"有值才有指针"(NULL / 空白 → nil)。
  513. //
  514. // 入参:v 列值指针。出参:去空白后非空串的指针;NULL 或纯空白 → nil。
  515. //
  516. // 使用点(两处,口径相同):`teacher_id`(课酬明细的"通用档")与 `create_user_id`(收支记账人)。
  517. //
  518. // 为什么空白也归 nil:两列都是 `varchar`,历史数据里存在 `''`(旧系统用空串代替 NULL)。
  519. // 若把 `''` 原样下发,前端 `if (x)` 得到 false —— 行为上等同 NULL,但**字节不同**,
  520. // 又回到"同一语义两种表示"的老坑。这里统一收口为 `null`,让出口只有三种取值:
  521. // `null`(不限定/未记录)/ 合法 ULID / 见不到别的。
  522. func optionalTrimmedPtr(v *string) *string {
  523. if v == nil {
  524. return nil
  525. }
  526. trimmed := strings.TrimSpace(*v)
  527. if trimmed == "" {
  528. return nil
  529. }
  530. return &trimmed
  531. }
  532. // parsePercentage 解析百分数字符串为浮点(写入校验用;**不参与金额计算**)。
  533. //
  534. // 入参:text 百分数("85" / "85.0")。出参:(数值, error);非数字/负/多于 1 位小数 → 2002。
  535. //
  536. // 说明:金额计算侧走 `applyPercentage` 的整数分路径;本函数只做**界内校验**,
  537. // 用 float 不会污染金额(结果只用于和 100 比较)。
  538. func parsePercentage(text string) (float64, error) {
  539. if strings.HasPrefix(text, "-") {
  540. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  541. errString("service: 课酬比例不能为负 "+text))
  542. }
  543. intPart, fracPart, hasDot := strings.Cut(text, ".")
  544. if intPart == "" || !allDigits(intPart) {
  545. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  546. errString("service: 课酬比例格式非法 "+text))
  547. }
  548. if hasDot {
  549. if fracPart == "" || len(fracPart) > 1 || !allDigits(fracPart) {
  550. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  551. errString("service: 课酬比例最多 1 位小数 "+text))
  552. }
  553. }
  554. var value float64
  555. for _, ch := range intPart {
  556. value = value*10 + float64(ch-'0')
  557. }
  558. if hasDot {
  559. value += float64(fracPart[0]-'0') / 10
  560. }
  561. return value, nil
  562. }
  563. // parseDecimal3 解析 3 位小数的教龄字符串(只用于比较大小)。
  564. //
  565. // 入参:text("1.000")。出参:(数值, error);非法 → 2002。
  566. func parseDecimal3(text string) (float64, error) {
  567. intPart, fracPart, hasDot := strings.Cut(text, ".")
  568. if intPart == "" || !allDigits(intPart) {
  569. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  570. errString("service: 教龄格式非法 "+text))
  571. }
  572. var value float64
  573. for _, ch := range intPart {
  574. value = value*10 + float64(ch-'0')
  575. }
  576. if hasDot && fracPart != "" {
  577. if len(fracPart) > 3 || !allDigits(fracPart) {
  578. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  579. errString("service: 教龄最多 3 位小数 "+text))
  580. }
  581. scale := 0.1
  582. for _, ch := range fracPart {
  583. value += float64(ch-'0') * scale
  584. scale /= 10
  585. }
  586. }
  587. return value, nil
  588. }
  589. // allDigits 判断是否全为数字(非空)。
  590. func allDigits(s string) bool {
  591. if s == "" {
  592. return false
  593. }
  594. for _, ch := range s {
  595. if ch < '0' || ch > '9' {
  596. return false
  597. }
  598. }
  599. return true
  600. }