payroll_plan_service.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  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 → 空串)
  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**)
  91. // TeacherExpMax ← teacher_exp_max
  92. // Percentage ← percentage(**百分数**,"85.0" = 85%)
  93. // Min ← min_payment
  94. // Max ← max_payment
  95. // Reward ← reward
  96. // DisplayName ← name(**明细的"计算方式名称"**;与方案名分开,见 D17)
  97. // DetailActived ← actived(**明细级**启用;方案的 actived 是另一层)
  98. type PayrollPlanDetail struct {
  99. DetailID int64
  100. TeacherID string
  101. ClassifyID int64
  102. ClassType int32
  103. GradeID int32
  104. SubjectID int32
  105. TeacherCert bool
  106. TeacherExpMin string
  107. TeacherExpMax string
  108. Percentage string
  109. Min money.Amount
  110. Max money.Amount
  111. Reward money.Amount
  112. DisplayName string
  113. DetailActived bool
  114. }
  115. // PayrollPlan 方案 + 明细(端点 58 的响应单元)。
  116. //
  117. // 字段来源:
  118. //
  119. // ID ← t_course_payment.id(**方案 id**,`PUT /plans/:id` 的 `:id`)
  120. // InstitutionID ← institution_id
  121. // CampusID ← campus_id(0 = 机构级方案)
  122. // SchemeName ← name(**方案名称**;与明细的 displayName 分开)
  123. // EffectDate ← effect_date(可空)
  124. // Actived ← actived(**方案级**启用)
  125. // Details ← t_course_payment_detail(按 id ASC)
  126. type PayrollPlan struct {
  127. ID int64
  128. InstitutionID int64
  129. CampusID int64
  130. SchemeName string
  131. EffectDate *time.Time
  132. Actived bool
  133. Details []PayrollPlanDetail
  134. }
  135. // ListPlans 分页查询方案(含明细)。
  136. //
  137. // 入参:ctx;q 查询入参。
  138. // 出参:([]PayrollPlan, total, error);空结果非 nil;1004(跨机构)/2001/5002。
  139. func (s *PayrollPlanService) ListPlans(ctx context.Context, q PayrollPlanQuery) ([]PayrollPlan, int64, error) {
  140. if q.InstitutionID <= 0 {
  141. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  142. errString("service: 课酬方案列表需要 institutionId(>0)"))
  143. }
  144. plans, total, err := s.repo.ListPlans(ctx, q.InstitutionID, repository.PayrollPlanFilter{
  145. CampusID: q.CampusID, Actived: q.Actived, Keyword: q.Keyword,
  146. }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
  147. if err != nil {
  148. return nil, 0, err
  149. }
  150. if len(plans) == 0 {
  151. return make([]PayrollPlan, 0), total, nil
  152. }
  153. ids := make([]int64, 0, len(plans))
  154. for i := range plans {
  155. ids = append(ids, plans[i].ID)
  156. }
  157. details, err := s.repo.ListDetailsByPlanIDs(ctx, ids)
  158. if err != nil {
  159. return nil, 0, err
  160. }
  161. byPlan := make(map[int64][]PayrollPlanDetail, len(ids))
  162. for i := range details {
  163. d := &details[i]
  164. view, perr := toPlanDetail(d)
  165. if perr != nil {
  166. return nil, 0, perr
  167. }
  168. byPlan[d.CoursePaymentID] = append(byPlan[d.CoursePaymentID], view)
  169. }
  170. out := make([]PayrollPlan, 0, len(plans))
  171. for i := range plans {
  172. p := &plans[i]
  173. items := byPlan[p.ID]
  174. if items == nil {
  175. items = make([]PayrollPlanDetail, 0)
  176. }
  177. out = append(out, PayrollPlan{
  178. ID: p.ID,
  179. InstitutionID: p.InstitutionID,
  180. CampusID: p.CampusID,
  181. SchemeName: strValue(p.Name),
  182. EffectDate: p.EffectDate,
  183. Actived: p.Actived,
  184. Details: items,
  185. })
  186. }
  187. return out, total, nil
  188. }
  189. // PayrollPlanDetailInput 明细写入入参(新建/整体替换共用)。
  190. //
  191. // 全部字段与列一一对应;金额用"元字符串"由 Handler 解析后传入(`money.Amount`)。
  192. type PayrollPlanDetailInput struct {
  193. TeacherID string
  194. ClassifyID int64
  195. ClassType int32
  196. GradeID int32
  197. SubjectID int32
  198. TeacherCert bool
  199. TeacherExpMin string
  200. TeacherExpMax string
  201. Percentage string
  202. Min money.Amount
  203. Max money.Amount
  204. Reward money.Amount
  205. DisplayName string
  206. Actived bool
  207. }
  208. // CreatePlanInput 端点 58 POST 入参(方案 + 明细,一次建完)。
  209. type CreatePlanInput struct {
  210. InstitutionID int64
  211. CampusID int64
  212. SchemeName string
  213. EffectDate *time.Time
  214. Actived bool
  215. Details []PayrollPlanDetailInput
  216. OperatorID string
  217. }
  218. // UpdatePlanInput 端点 58 PUT 入参(`:id` = **方案 id**,明细整体替换)。
  219. //
  220. // 指针语义:nil = 不修改(方案字段);`Details` nil = **不动明细**,
  221. // 非 nil(含空切片)= **整体替换**(空切片 = 清空明细)。
  222. type UpdatePlanInput struct {
  223. ID int64
  224. InstitutionID int64
  225. CampusID *int64
  226. SchemeName *string
  227. EffectDate *time.Time
  228. Actived *bool
  229. Details []PayrollPlanDetailInput
  230. ReplaceDetail bool
  231. }
  232. // CreatePlan 新建方案 + 明细(**同一事务**)。
  233. //
  234. // 入参:ctx;in 入参。
  235. // 出参:*PayrollPlan(含新 id 与明细);2001/2002(名称/比例/教龄区间/金额非法)/1004/5002。
  236. func (s *PayrollPlanService) CreatePlan(ctx context.Context, in CreatePlanInput) (*PayrollPlan, error) {
  237. name := strings.TrimSpace(in.SchemeName)
  238. if name == "" {
  239. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
  240. }
  241. if in.InstitutionID <= 0 {
  242. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  243. errString("service: 课酬方案需要 institutionId(>0)"))
  244. }
  245. details, err := buildPlanDetails(in.Details)
  246. if err != nil {
  247. return nil, err
  248. }
  249. row := &model.CoursePayment{
  250. InstitutionID: in.InstitutionID,
  251. CampusID: in.CampusID,
  252. Name: &name,
  253. EffectDate: in.EffectDate,
  254. Actived: in.Actived,
  255. }
  256. if uid := strings.TrimSpace(in.OperatorID); uid != "" {
  257. row.CreateUserID = &uid
  258. }
  259. err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  260. txCtx := repository.WithTxContext(ctx, tx)
  261. if cerr := s.repo.CreatePlan(txCtx, tx, row); cerr != nil {
  262. return cerr
  263. }
  264. return s.repo.ReplaceDetails(txCtx, tx, row.ID, details)
  265. })
  266. if err != nil {
  267. return nil, err
  268. }
  269. return s.getPlan(ctx, in.InstitutionID, row.ID)
  270. }
  271. // UpdatePlan 更新方案(可选整体替换明细),**同一事务**。
  272. //
  273. // 入参:ctx;in 入参(ID = 方案 id)。
  274. // 出参:*PayrollPlan(更新后回读);2001(没有任何待更新字段)/2002/3001/1004/5002。
  275. func (s *PayrollPlanService) UpdatePlan(ctx context.Context, in UpdatePlanInput) (*PayrollPlan, error) {
  276. if in.ID <= 0 {
  277. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案 id 必须 > 0"))
  278. }
  279. if in.InstitutionID <= 0 {
  280. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  281. errString("service: 课酬方案需要 institutionId(>0)"))
  282. }
  283. row := &model.CoursePayment{ID: in.ID, InstitutionID: in.InstitutionID}
  284. columns := make([]string, 0, 4)
  285. if in.CampusID != nil {
  286. row.CampusID = *in.CampusID
  287. columns = append(columns, "campus_id")
  288. }
  289. if in.SchemeName != nil {
  290. name := strings.TrimSpace(*in.SchemeName)
  291. if name == "" {
  292. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
  293. }
  294. row.Name = &name
  295. columns = append(columns, "name")
  296. }
  297. if in.EffectDate != nil {
  298. row.EffectDate = in.EffectDate
  299. columns = append(columns, "effect_date")
  300. }
  301. if in.Actived != nil {
  302. row.Actived = *in.Actived
  303. columns = append(columns, "actived")
  304. }
  305. var details []*model.CoursePaymentDetail
  306. if in.ReplaceDetail {
  307. built, derr := buildPlanDetails(in.Details)
  308. if derr != nil {
  309. return nil, derr
  310. }
  311. details = built
  312. }
  313. if len(columns) == 0 && !in.ReplaceDetail {
  314. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  315. errString("service: 没有任何待更新字段(方案字段与明细都未提供)"))
  316. }
  317. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  318. txCtx := repository.WithTxContext(ctx, tx)
  319. if _, gerr := s.repo.GetPlanByID(txCtx, in.InstitutionID, in.ID); gerr != nil {
  320. return gerr // 3001(不存在/不在本机构)
  321. }
  322. if len(columns) > 0 {
  323. affected, uerr := s.repo.UpdatePlan(txCtx, tx, row, columns)
  324. if uerr != nil {
  325. return uerr
  326. }
  327. if affected == 0 {
  328. return infraerrors.WithCause(infraerrors.ErrNotFound,
  329. errString("service: 课酬方案不存在或不在本机构"))
  330. }
  331. }
  332. if in.ReplaceDetail {
  333. return s.repo.ReplaceDetails(txCtx, tx, in.ID, details)
  334. }
  335. return nil
  336. })
  337. if err != nil {
  338. return nil, err
  339. }
  340. return s.getPlan(ctx, in.InstitutionID, in.ID)
  341. }
  342. // getPlan 回读单个方案 + 明细(Create/Update 的出参装配)。
  343. //
  344. // 入参:ctx;institutionID;id。出参:*PayrollPlan;3001/1004/5002。
  345. func (s *PayrollPlanService) getPlan(ctx context.Context, institutionID, id int64) (*PayrollPlan, error) {
  346. row, err := s.repo.GetPlanByID(ctx, institutionID, id)
  347. if err != nil {
  348. return nil, err
  349. }
  350. details, err := s.repo.ListDetailsByPlanIDs(ctx, []int64{id})
  351. if err != nil {
  352. return nil, err
  353. }
  354. items := make([]PayrollPlanDetail, 0, len(details))
  355. for i := range details {
  356. view, perr := toPlanDetail(&details[i])
  357. if perr != nil {
  358. return nil, perr
  359. }
  360. items = append(items, view)
  361. }
  362. return &PayrollPlan{
  363. ID: row.ID,
  364. InstitutionID: row.InstitutionID,
  365. CampusID: row.CampusID,
  366. SchemeName: strValue(row.Name),
  367. EffectDate: row.EffectDate,
  368. Actived: row.Actived,
  369. Details: items,
  370. }, nil
  371. }
  372. // buildPlanDetails 校验并构造明细行(**唯一校验点**,Create/Update 共用)。
  373. //
  374. // 入参:inputs 明细入参。出参:[]*model.CoursePaymentDetail;2001/2002。
  375. //
  376. // 校验项(顺序固定):
  377. // 1. `percentage` 必须是 0~100 的百分数(≤1 位小数)——传 0.85 会让课酬少 100 倍;
  378. // 2. `teacherExpMin <= teacherExpMax`(区间倒挂 ⇒ 规则永不命中 ⇒ 课酬静默 0);
  379. // 3. 金额非负(`min/max/reward` 都是支出方向;`min/max` 为 0 = 不设限)。
  380. func buildPlanDetails(inputs []PayrollPlanDetailInput) ([]*model.CoursePaymentDetail, error) {
  381. out := make([]*model.CoursePaymentDetail, 0, len(inputs))
  382. for i := range inputs {
  383. in := &inputs[i]
  384. pct := strings.TrimSpace(in.Percentage)
  385. if pct == "" {
  386. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  387. errString("service: 课酬比例 percentage 不能为空(0~100 的百分数)"))
  388. }
  389. if pctValue, perr := parsePercentage(pct); perr != nil {
  390. return nil, perr
  391. } else if pctValue > MaxPayrollPercentage {
  392. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  393. errString("service: 课酬比例不得超过 100(收到 "+pct+";注意是百分数,85% 应传 85 或 85.0)"))
  394. }
  395. minExp := strings.TrimSpace(in.TeacherExpMin)
  396. maxExp := strings.TrimSpace(in.TeacherExpMax)
  397. if minExp == "" {
  398. minExp = "0.000"
  399. }
  400. if maxExp == "" {
  401. maxExp = "0.000"
  402. }
  403. minValue, minErr := parseDecimal3(minExp)
  404. if minErr != nil {
  405. return nil, minErr
  406. }
  407. maxValue, maxErr := parseDecimal3(maxExp)
  408. if maxErr != nil {
  409. return nil, maxErr
  410. }
  411. if minValue > maxValue {
  412. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  413. errString("service: 教龄区间倒挂(teacherExpMin "+minExp+" > teacherExpMax "+maxExp+
  414. "):该规则将永不命中,课酬会静默变 0"))
  415. }
  416. for _, amt := range []money.Amount{in.Min, in.Max, in.Reward} {
  417. if amt.IsNegative() {
  418. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  419. errString("service: 课酬金额不能为负"))
  420. }
  421. if err := amt.Validate(); err != nil {
  422. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  423. }
  424. }
  425. row := &model.CoursePaymentDetail{
  426. GradeID: in.GradeID,
  427. SubjectID: in.SubjectID,
  428. ClassType: in.ClassType,
  429. ClassifyID: in.ClassifyID,
  430. Percentage: pct,
  431. MinPayment: in.Min.String(),
  432. MaxPayment: in.Max.String(),
  433. Reward: in.Reward.String(),
  434. TeacherCert: in.TeacherCert,
  435. TeacherExpMin: minExp,
  436. TeacherExpMax: maxExp,
  437. Actived: in.Actived,
  438. }
  439. if tid := strings.TrimSpace(in.TeacherID); tid != "" {
  440. row.TeacherID = &tid
  441. }
  442. if name := strings.TrimSpace(in.DisplayName); name != "" {
  443. row.Name = &name
  444. }
  445. out = append(out, row)
  446. }
  447. return out, nil
  448. }
  449. // toPlanDetail 把明细行转成对外视图(金额定点解析;列写坏 → 5002)。
  450. //
  451. // 入参:d 明细行。出参:PayrollPlanDetail;5002。
  452. func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
  453. minAmount, err := parseAmount(d.MinPayment)
  454. if err != nil {
  455. return PayrollPlanDetail{}, err
  456. }
  457. maxAmount, err := parseAmount(d.MaxPayment)
  458. if err != nil {
  459. return PayrollPlanDetail{}, err
  460. }
  461. reward, err := parseAmount(d.Reward)
  462. if err != nil {
  463. return PayrollPlanDetail{}, err
  464. }
  465. return PayrollPlanDetail{
  466. DetailID: d.ID,
  467. TeacherID: strValue(d.TeacherID),
  468. ClassifyID: d.ClassifyID,
  469. ClassType: d.ClassType,
  470. GradeID: d.GradeID,
  471. SubjectID: d.SubjectID,
  472. TeacherCert: d.TeacherCert,
  473. TeacherExpMin: d.TeacherExpMin,
  474. TeacherExpMax: d.TeacherExpMax,
  475. Percentage: d.Percentage,
  476. Min: minAmount,
  477. Max: maxAmount,
  478. Reward: reward,
  479. DisplayName: strValue(d.Name),
  480. DetailActived: d.Actived,
  481. }, nil
  482. }
  483. // parsePercentage 解析百分数字符串为浮点(写入校验用;**不参与金额计算**)。
  484. //
  485. // 入参:text 百分数("85" / "85.0")。出参:(数值, error);非数字/负/多于 1 位小数 → 2002。
  486. //
  487. // 说明:金额计算侧走 `applyPercentage` 的整数分路径;本函数只做**界内校验**,
  488. // 用 float 不会污染金额(结果只用于和 100 比较)。
  489. func parsePercentage(text string) (float64, error) {
  490. if strings.HasPrefix(text, "-") {
  491. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  492. errString("service: 课酬比例不能为负 "+text))
  493. }
  494. intPart, fracPart, hasDot := strings.Cut(text, ".")
  495. if intPart == "" || !allDigits(intPart) {
  496. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  497. errString("service: 课酬比例格式非法 "+text))
  498. }
  499. if hasDot {
  500. if fracPart == "" || len(fracPart) > 1 || !allDigits(fracPart) {
  501. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  502. errString("service: 课酬比例最多 1 位小数 "+text))
  503. }
  504. }
  505. var value float64
  506. for _, ch := range intPart {
  507. value = value*10 + float64(ch-'0')
  508. }
  509. if hasDot {
  510. value += float64(fracPart[0]-'0') / 10
  511. }
  512. return value, nil
  513. }
  514. // parseDecimal3 解析 3 位小数的教龄字符串(只用于比较大小)。
  515. //
  516. // 入参:text("1.000")。出参:(数值, error);非法 → 2002。
  517. func parseDecimal3(text string) (float64, error) {
  518. intPart, fracPart, hasDot := strings.Cut(text, ".")
  519. if intPart == "" || !allDigits(intPart) {
  520. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  521. errString("service: 教龄格式非法 "+text))
  522. }
  523. var value float64
  524. for _, ch := range intPart {
  525. value = value*10 + float64(ch-'0')
  526. }
  527. if hasDot && fracPart != "" {
  528. if len(fracPart) > 3 || !allDigits(fracPart) {
  529. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  530. errString("service: 教龄最多 3 位小数 "+text))
  531. }
  532. scale := 0.1
  533. for _, ch := range fracPart {
  534. value += float64(ch-'0') * scale
  535. scale /= 10
  536. }
  537. }
  538. return value, nil
  539. }
  540. // allDigits 判断是否全为数字(非空)。
  541. func allDigits(s string) bool {
  542. if s == "" {
  543. return false
  544. }
  545. for _, ch := range s {
  546. if ch < '0' || ch > '9' {
  547. return false
  548. }
  549. }
  550. return true
  551. }