| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- // 课酬方案 + 明细服务(端点 58)——AC-13d 追加。
- //
- // 依据:团队裁决 D17(两级形状 / `PUT` 按方案 id 整体替换明细 / 教龄区间两字段)。
- //
- // ---------------------------------------------------------------------------
- // 为什么新增而不是改 AC-10 的 `PayrollService`
- // ---------------------------------------------------------------------------
- //
- // AC-10 交付的 `PayrollService` 是**只读试算**(`QuoteTeacherReward` / `QuotePayroll`),
- // 没有任何写入面。端点 58 是"配置 CRUD",属**新能力**:
- //
- // 新能力 / 为什么 AC-10 没覆盖 / 是否与既有逻辑冲突
- // ListPlans / AC-10 只有"按机构+校区+日期取**一条**生效方案",没有列表 / 无(新查询,只读)
- // GetPlan + details / AC-10 不暴露方案实体(只回命中的规则) / 无(新查询,只读)
- // CreatePlan / AC-10 无任何写入 / 无(新事务)
- // UpdatePlan / 同上 / 无(新事务)
- // ReplaceDetails / 同上("整体替换明细"是 D17 新裁的口径) / 无(新事务)
- //
- // 三条硬约束(团队批准):不改既有方法签名与行为、新增放新文件、逐条登记(上表)。
- //
- // ---------------------------------------------------------------------------
- // 两条校验口径(**不是可有可无的防呆**)
- // ---------------------------------------------------------------------------
- //
- // 1. **`teacherExpMin <= teacherExpMax`**:区间倒挂会让规则**永不命中**(`BETWEEN` 恒 false)
- // ⇒ 课酬静默变 0。必须在写入前拒(2002),不能靠"以后发现课酬不对"。
- // 2. **`percentage` 必须是 0~100 的百分数**:它是 `percentage/100` 的基数(PHP `:781`),
- // 传 `0.85` 会被当成 0.85% ⇒ 课酬少 100 倍。写入侧限 0~100 且 ≤1 位小数,
- // 与列 `decimal(5,1)` 一致。
- package service
- import (
- "context"
- "strings"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- "yuansiwei/unified-backend/internal/academic/repository"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // MaxPayrollPercentage 课酬比例上限(百分数,含)。列是 `decimal(5,1)`,业务上 0~100。
- const MaxPayrollPercentage = 100
- // PayrollPlanService 课酬方案配置服务(写入面 + 列表读面)。
- type PayrollPlanService struct {
- // repo 课酬方案/明细仓储(复用 AC-10 的 PayrollRepo,新增方法在同包新文件)。
- repo *repository.PayrollRepo
- // runner 事务运行器(方案与明细必须同一事务)。
- runner *repository.TxRunner
- // sc 请求作用域(机构维度越权断言)。
- sc *appscope.Scope
- // now 时钟(effect_date 缺省值用)。
- now func() time.Time
- }
- // NewPayrollPlanService 构造课酬方案服务。
- //
- // 入参:db;sc 请求作用域(nil = 系统任务视角,`assertInstitution` 放行)。
- // 出参:*PayrollPlanService。
- func NewPayrollPlanService(db *gorm.DB, sc *appscope.Scope) *PayrollPlanService {
- return &PayrollPlanService{
- repo: repository.NewPayrollRepo(db, sc),
- runner: repository.NewTxRunner(db),
- sc: sc,
- now: time.Now,
- }
- }
- // PayrollPlanQuery 端点 58 GET 的查询入参。
- //
- // 字段:InstitutionID 机构(**必填 >0**;非超管必须等于作用域机构,否则 1004);
- // CampusID 校区(nil 不筛);Actived 启用(nil 不筛);Keyword 名称模糊;Page/PageSize 分页。
- type PayrollPlanQuery struct {
- InstitutionID int64
- CampusID *int64
- Actived *bool
- Keyword string
- Page int
- PageSize int
- }
- // PayrollPlanDetail 方案下的一条明细(对外视图,**每个字段都能追到列**)。
- //
- // 字段来源:
- //
- // DetailID ← t_course_payment_detail.id
- // TeacherID ← teacher_id(**可空**;NULL 原样下发 `null`,不是空串)
- // ClassifyID ← classify_id
- // ClassType ← class_type
- // GradeID ← grade_id
- // SubjectID ← subject_id
- // TeacherCert ← teacher_cert
- // TeacherExpMin ← teacher_exp_min(教龄区间下界,**不是单值 teacherExp**;定点化到 3 位)
- // TeacherExpMax ← teacher_exp_max(定点化到 3 位)
- // Percentage ← percentage(**百分数**,"85.0" = 85%;定点化到 1 位)
- // Min ← min_payment(金额,2 位小数)
- // Max ← max_payment(金额,2 位小数)
- // Reward ← reward(金额,2 位小数)
- // DisplayName ← name(**明细的"计算方式名称"**;与方案名分开,见 D17)
- // DetailActived ← actived(**明细级**启用;方案的 actived 是另一层)
- //
- // ⚠ **`TeacherID` 用 `*string` 而不是 `string`**:`teacher_id` 可空,"通用规则"就是 NULL。
- // 下发 `""` 会让前端无法区分两件事:
- //
- // ① 这条规则**不限定老师**(NULL,通用档);
- // ② 这条规则限定了老师,但老师 ID 是空串(数据坏了,本不该存在)。
- //
- // 前者是正常业务,后者是脏数据,前端一个 `if (teacherId)` 就把两者混成一支——
- // 脏数据被当成"通用档"静默通过校验。`null` / `"ULID"` / 见不到第三种取值,才有排查价值。
- type PayrollPlanDetail struct {
- DetailID int64
- TeacherID *string
- ClassifyID int64
- ClassType int32
- GradeID int32
- SubjectID int32
- TeacherCert bool
- TeacherExpMin string
- TeacherExpMax string
- Percentage string
- Min money.Amount
- Max money.Amount
- Reward money.Amount
- DisplayName string
- DetailActived bool
- }
- // PayrollPlan 方案 + 明细(端点 58 的响应单元)。
- //
- // 字段来源:
- //
- // ID ← t_course_payment.id(**方案 id**,`PUT /plans/:id` 的 `:id`)
- // InstitutionID ← institution_id
- // CampusID ← campus_id(0 = 机构级方案)
- // SchemeName ← name(**方案名称**;与明细的 displayName 分开)
- // EffectDate ← effect_date(可空)
- // Actived ← actived(**方案级**启用)
- // Details ← t_course_payment_detail(按 id ASC)
- type PayrollPlan struct {
- ID int64
- InstitutionID int64
- CampusID int64
- SchemeName string
- EffectDate *time.Time
- Actived bool
- Details []PayrollPlanDetail
- }
- // ListPlans 分页查询方案(含明细)。
- //
- // 入参:ctx;q 查询入参。
- // 出参:([]PayrollPlan, total, error);空结果非 nil;1004(跨机构)/2001/5002。
- func (s *PayrollPlanService) ListPlans(ctx context.Context, q PayrollPlanQuery) ([]PayrollPlan, int64, error) {
- if q.InstitutionID <= 0 {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬方案列表需要 institutionId(>0)"))
- }
- plans, total, err := s.repo.ListPlans(ctx, q.InstitutionID, repository.PayrollPlanFilter{
- CampusID: q.CampusID, Actived: q.Actived, Keyword: q.Keyword,
- }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize})
- if err != nil {
- return nil, 0, err
- }
- if len(plans) == 0 {
- return make([]PayrollPlan, 0), total, nil
- }
- ids := make([]int64, 0, len(plans))
- for i := range plans {
- ids = append(ids, plans[i].ID)
- }
- details, err := s.repo.ListDetailsByPlanIDs(ctx, ids)
- if err != nil {
- return nil, 0, err
- }
- byPlan := make(map[int64][]PayrollPlanDetail, len(ids))
- for i := range details {
- d := &details[i]
- view, perr := toPlanDetail(d)
- if perr != nil {
- return nil, 0, perr
- }
- byPlan[d.CoursePaymentID] = append(byPlan[d.CoursePaymentID], view)
- }
- out := make([]PayrollPlan, 0, len(plans))
- for i := range plans {
- p := &plans[i]
- items := byPlan[p.ID]
- if items == nil {
- items = make([]PayrollPlanDetail, 0)
- }
- out = append(out, PayrollPlan{
- ID: p.ID,
- InstitutionID: p.InstitutionID,
- CampusID: p.CampusID,
- SchemeName: strValue(p.Name),
- EffectDate: p.EffectDate,
- Actived: p.Actived,
- Details: items,
- })
- }
- return out, total, nil
- }
- // PayrollPlanDetailInput 明细写入入参(新建/整体替换共用)。
- //
- // 全部字段与列一一对应;金额用"元字符串"由 Handler 解析后传入(`money.Amount`)。
- type PayrollPlanDetailInput struct {
- TeacherID string
- ClassifyID int64
- ClassType int32
- GradeID int32
- SubjectID int32
- TeacherCert bool
- TeacherExpMin string
- TeacherExpMax string
- Percentage string
- Min money.Amount
- Max money.Amount
- Reward money.Amount
- DisplayName string
- Actived bool
- }
- // CreatePlanInput 端点 58 POST 入参(方案 + 明细,一次建完)。
- type CreatePlanInput struct {
- InstitutionID int64
- CampusID int64
- SchemeName string
- EffectDate *time.Time
- Actived bool
- Details []PayrollPlanDetailInput
- OperatorID string
- }
- // UpdatePlanInput 端点 58 PUT 入参(`:id` = **方案 id**,明细整体替换)。
- //
- // 指针语义:nil = 不修改(方案字段);`Details` nil = **不动明细**,
- // 非 nil(含空切片)= **整体替换**(空切片 = 清空明细)。
- type UpdatePlanInput struct {
- ID int64
- InstitutionID int64
- CampusID *int64
- SchemeName *string
- EffectDate *time.Time
- Actived *bool
- Details []PayrollPlanDetailInput
- ReplaceDetail bool
- }
- // CreatePlan 新建方案 + 明细(**同一事务**)。
- //
- // 入参:ctx;in 入参。
- // 出参:*PayrollPlan(含新 id 与明细);2001/2002(名称/比例/教龄区间/金额非法)/1004/5002。
- func (s *PayrollPlanService) CreatePlan(ctx context.Context, in CreatePlanInput) (*PayrollPlan, error) {
- name := strings.TrimSpace(in.SchemeName)
- if name == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
- }
- if in.InstitutionID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬方案需要 institutionId(>0)"))
- }
- details, err := buildPlanDetails(in.Details)
- if err != nil {
- return nil, err
- }
- row := &model.CoursePayment{
- InstitutionID: in.InstitutionID,
- CampusID: in.CampusID,
- Name: &name,
- EffectDate: in.EffectDate,
- Actived: in.Actived,
- }
- if uid := strings.TrimSpace(in.OperatorID); uid != "" {
- row.CreateUserID = &uid
- }
- err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- if cerr := s.repo.CreatePlan(txCtx, tx, row); cerr != nil {
- return cerr
- }
- return s.repo.ReplaceDetails(txCtx, tx, row.ID, details)
- })
- if err != nil {
- return nil, err
- }
- return s.getPlan(ctx, in.InstitutionID, row.ID)
- }
- // UpdatePlan 更新方案(可选整体替换明细),**同一事务**。
- //
- // 入参:ctx;in 入参(ID = 方案 id)。
- // 出参:*PayrollPlan(更新后回读);2001(没有任何待更新字段)/2002/3001/1004/5002。
- func (s *PayrollPlanService) UpdatePlan(ctx context.Context, in UpdatePlanInput) (*PayrollPlan, error) {
- if in.ID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案 id 必须 > 0"))
- }
- if in.InstitutionID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬方案需要 institutionId(>0)"))
- }
- row := &model.CoursePayment{ID: in.ID, InstitutionID: in.InstitutionID}
- columns := make([]string, 0, 4)
- if in.CampusID != nil {
- row.CampusID = *in.CampusID
- columns = append(columns, "campus_id")
- }
- if in.SchemeName != nil {
- name := strings.TrimSpace(*in.SchemeName)
- if name == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 方案名称不能为空"))
- }
- row.Name = &name
- columns = append(columns, "name")
- }
- if in.EffectDate != nil {
- row.EffectDate = in.EffectDate
- columns = append(columns, "effect_date")
- }
- if in.Actived != nil {
- row.Actived = *in.Actived
- columns = append(columns, "actived")
- }
- var details []*model.CoursePaymentDetail
- if in.ReplaceDetail {
- built, derr := buildPlanDetails(in.Details)
- if derr != nil {
- return nil, derr
- }
- details = built
- }
- if len(columns) == 0 && !in.ReplaceDetail {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 没有任何待更新字段(方案字段与明细都未提供)"))
- }
- err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- if _, gerr := s.repo.GetPlanByID(txCtx, in.InstitutionID, in.ID); gerr != nil {
- return gerr // 3001(不存在/不在本机构)
- }
- if len(columns) > 0 {
- affected, uerr := s.repo.UpdatePlan(txCtx, tx, row, columns)
- if uerr != nil {
- return uerr
- }
- if affected == 0 {
- return infraerrors.WithCause(infraerrors.ErrNotFound,
- errString("service: 课酬方案不存在或不在本机构"))
- }
- }
- if in.ReplaceDetail {
- return s.repo.ReplaceDetails(txCtx, tx, in.ID, details)
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return s.getPlan(ctx, in.InstitutionID, in.ID)
- }
- // getPlan 回读单个方案 + 明细(Create/Update 的出参装配)。
- //
- // 入参:ctx;institutionID;id。出参:*PayrollPlan;3001/1004/5002。
- func (s *PayrollPlanService) getPlan(ctx context.Context, institutionID, id int64) (*PayrollPlan, error) {
- row, err := s.repo.GetPlanByID(ctx, institutionID, id)
- if err != nil {
- return nil, err
- }
- details, err := s.repo.ListDetailsByPlanIDs(ctx, []int64{id})
- if err != nil {
- return nil, err
- }
- items := make([]PayrollPlanDetail, 0, len(details))
- for i := range details {
- view, perr := toPlanDetail(&details[i])
- if perr != nil {
- return nil, perr
- }
- items = append(items, view)
- }
- return &PayrollPlan{
- ID: row.ID,
- InstitutionID: row.InstitutionID,
- CampusID: row.CampusID,
- SchemeName: strValue(row.Name),
- EffectDate: row.EffectDate,
- Actived: row.Actived,
- Details: items,
- }, nil
- }
- // buildPlanDetails 校验并构造明细行(**唯一校验点**,Create/Update 共用)。
- //
- // 入参:inputs 明细入参。出参:[]*model.CoursePaymentDetail;2001/2002。
- //
- // 校验项(顺序固定):
- // 1. `percentage` 必须是 0~100 的百分数(≤1 位小数)——传 0.85 会让课酬少 100 倍;
- // 2. `teacherExpMin <= teacherExpMax`(区间倒挂 ⇒ 规则永不命中 ⇒ 课酬静默 0);
- // 3. 金额非负(`min/max/reward` 都是支出方向;`min/max` 为 0 = 不设限)。
- func buildPlanDetails(inputs []PayrollPlanDetailInput) ([]*model.CoursePaymentDetail, error) {
- out := make([]*model.CoursePaymentDetail, 0, len(inputs))
- for i := range inputs {
- in := &inputs[i]
- pct := strings.TrimSpace(in.Percentage)
- if pct == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬比例 percentage 不能为空(0~100 的百分数)"))
- }
- if pctValue, perr := parsePercentage(pct); perr != nil {
- return nil, perr
- } else if pctValue > MaxPayrollPercentage {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬比例不得超过 100(收到 "+pct+";注意是百分数,85% 应传 85 或 85.0)"))
- }
- minExp := strings.TrimSpace(in.TeacherExpMin)
- maxExp := strings.TrimSpace(in.TeacherExpMax)
- if minExp == "" {
- minExp = "0.000"
- }
- if maxExp == "" {
- maxExp = "0.000"
- }
- minValue, minErr := parseDecimal3(minExp)
- if minErr != nil {
- return nil, minErr
- }
- maxValue, maxErr := parseDecimal3(maxExp)
- if maxErr != nil {
- return nil, maxErr
- }
- if minValue > maxValue {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 教龄区间倒挂(teacherExpMin "+minExp+" > teacherExpMax "+maxExp+
- "):该规则将永不命中,课酬会静默变 0"))
- }
- for _, amt := range []money.Amount{in.Min, in.Max, in.Reward} {
- if amt.IsNegative() {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬金额不能为负"))
- }
- if err := amt.Validate(); err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
- }
- }
- row := &model.CoursePaymentDetail{
- GradeID: in.GradeID,
- SubjectID: in.SubjectID,
- ClassType: in.ClassType,
- ClassifyID: in.ClassifyID,
- Percentage: pct,
- MinPayment: in.Min.String(),
- MaxPayment: in.Max.String(),
- Reward: in.Reward.String(),
- TeacherCert: in.TeacherCert,
- TeacherExpMin: minExp,
- TeacherExpMax: maxExp,
- Actived: in.Actived,
- }
- if tid := strings.TrimSpace(in.TeacherID); tid != "" {
- row.TeacherID = &tid
- }
- if name := strings.TrimSpace(in.DisplayName); name != "" {
- row.Name = &name
- }
- out = append(out, row)
- }
- return out, nil
- }
- // toPlanDetail 把明细行转成对外视图(**出口定点化 + 金额定点解析**;列写坏 → 5002)。
- //
- // 入参:d 明细行。出参:PayrollPlanDetail;5002(ErrDBError)。
- //
- // 三类字段的出口处理**必须都做**,且都不能省:
- //
- // percentage / teacherExp → `FormatFixed` 到列声明的位数(1 / 3 位)。
- // 不定点化 ⇒ SQLite 回 "85"、MySQL 回 "85.0",前端 diff 误判 + 单测随方言摇摆;
- // **也正因为定点化,"比例被当成金额(2 位)"这种量纲污染会立刻显形**。
- // 金额(min/max/reward) → `money.Amount.String()`,恒 2 位小数(定点整数域,无浮点)。
- // teacherId → NULL 保持 `nil`(下发 `null`),**不折叠成空串**(见字段注释)。
- func toPlanDetail(d *model.CoursePaymentDetail) (PayrollPlanDetail, error) {
- minAmount, err := parseAmount(d.MinPayment)
- if err != nil {
- return PayrollPlanDetail{}, err
- }
- maxAmount, err := parseAmount(d.MaxPayment)
- if err != nil {
- return PayrollPlanDetail{}, err
- }
- reward, err := parseAmount(d.Reward)
- if err != nil {
- return PayrollPlanDetail{}, err
- }
- percentage, err := FormatPercentage(d.Percentage)
- if err != nil {
- return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- yearMin, err := FormatYears(d.TeacherExpMin)
- if err != nil {
- return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- yearMax, err := FormatYears(d.TeacherExpMax)
- if err != nil {
- return PayrollPlanDetail{}, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return PayrollPlanDetail{
- DetailID: d.ID,
- TeacherID: optionalTrimmedPtr(d.TeacherID),
- ClassifyID: d.ClassifyID,
- ClassType: d.ClassType,
- GradeID: d.GradeID,
- SubjectID: d.SubjectID,
- TeacherCert: d.TeacherCert,
- TeacherExpMin: yearMin,
- TeacherExpMax: yearMax,
- Percentage: percentage,
- Min: minAmount,
- Max: maxAmount,
- Reward: reward,
- DisplayName: strValue(d.Name),
- DetailActived: d.Actived,
- }, nil
- }
- // optionalTrimmedPtr 把"可空文本列"归一为"有值才有指针"(NULL / 空白 → nil)。
- //
- // 入参:v 列值指针。出参:去空白后非空串的指针;NULL 或纯空白 → nil。
- //
- // 使用点(两处,口径相同):`teacher_id`(课酬明细的"通用档")与 `create_user_id`(收支记账人)。
- //
- // 为什么空白也归 nil:两列都是 `varchar`,历史数据里存在 `''`(旧系统用空串代替 NULL)。
- // 若把 `''` 原样下发,前端 `if (x)` 得到 false —— 行为上等同 NULL,但**字节不同**,
- // 又回到"同一语义两种表示"的老坑。这里统一收口为 `null`,让出口只有三种取值:
- // `null`(不限定/未记录)/ 合法 ULID / 见不到别的。
- func optionalTrimmedPtr(v *string) *string {
- if v == nil {
- return nil
- }
- trimmed := strings.TrimSpace(*v)
- if trimmed == "" {
- return nil
- }
- return &trimmed
- }
- // parsePercentage 解析百分数字符串为浮点(写入校验用;**不参与金额计算**)。
- //
- // 入参:text 百分数("85" / "85.0")。出参:(数值, error);非数字/负/多于 1 位小数 → 2002。
- //
- // 说明:金额计算侧走 `applyPercentage` 的整数分路径;本函数只做**界内校验**,
- // 用 float 不会污染金额(结果只用于和 100 比较)。
- func parsePercentage(text string) (float64, error) {
- if strings.HasPrefix(text, "-") {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬比例不能为负 "+text))
- }
- intPart, fracPart, hasDot := strings.Cut(text, ".")
- if intPart == "" || !allDigits(intPart) {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬比例格式非法 "+text))
- }
- if hasDot {
- if fracPart == "" || len(fracPart) > 1 || !allDigits(fracPart) {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 课酬比例最多 1 位小数 "+text))
- }
- }
- var value float64
- for _, ch := range intPart {
- value = value*10 + float64(ch-'0')
- }
- if hasDot {
- value += float64(fracPart[0]-'0') / 10
- }
- return value, nil
- }
- // parseDecimal3 解析 3 位小数的教龄字符串(只用于比较大小)。
- //
- // 入参:text("1.000")。出参:(数值, error);非法 → 2002。
- func parseDecimal3(text string) (float64, error) {
- intPart, fracPart, hasDot := strings.Cut(text, ".")
- if intPart == "" || !allDigits(intPart) {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 教龄格式非法 "+text))
- }
- var value float64
- for _, ch := range intPart {
- value = value*10 + float64(ch-'0')
- }
- if hasDot && fracPart != "" {
- if len(fracPart) > 3 || !allDigits(fracPart) {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 教龄最多 3 位小数 "+text))
- }
- scale := 0.1
- for _, ch := range fracPart {
- value += float64(ch-'0') * scale
- scale /= 10
- }
- }
- return value, nil
- }
- // allDigits 判断是否全为数字(非空)。
- func allDigits(s string) bool {
- if s == "" {
- return false
- }
- for _, ch := range s {
- if ch < '0' || ch > '9' {
- return false
- }
- }
- return true
- }
|