|
|
@@ -0,0 +1,572 @@
|
|
|
+// 课酬方案 + 明细服务(端点 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 → 空串)
|
|
|
+// ClassifyID ← classify_id
|
|
|
+// ClassType ← class_type
|
|
|
+// GradeID ← grade_id
|
|
|
+// SubjectID ← subject_id
|
|
|
+// TeacherCert ← teacher_cert
|
|
|
+// TeacherExpMin ← teacher_exp_min(教龄区间下界,**不是单值 teacherExp**)
|
|
|
+// TeacherExpMax ← teacher_exp_max
|
|
|
+// Percentage ← percentage(**百分数**,"85.0" = 85%)
|
|
|
+// Min ← min_payment
|
|
|
+// Max ← max_payment
|
|
|
+// Reward ← reward
|
|
|
+// DisplayName ← name(**明细的"计算方式名称"**;与方案名分开,见 D17)
|
|
|
+// DetailActived ← actived(**明细级**启用;方案的 actived 是另一层)
|
|
|
+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。
|
|
|
+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
|
|
|
+ }
|
|
|
+ return PayrollPlanDetail{
|
|
|
+ DetailID: d.ID,
|
|
|
+ TeacherID: strValue(d.TeacherID),
|
|
|
+ ClassifyID: d.ClassifyID,
|
|
|
+ ClassType: d.ClassType,
|
|
|
+ GradeID: d.GradeID,
|
|
|
+ SubjectID: d.SubjectID,
|
|
|
+ TeacherCert: d.TeacherCert,
|
|
|
+ TeacherExpMin: d.TeacherExpMin,
|
|
|
+ TeacherExpMax: d.TeacherExpMax,
|
|
|
+ Percentage: d.Percentage,
|
|
|
+ Min: minAmount,
|
|
|
+ Max: maxAmount,
|
|
|
+ Reward: reward,
|
|
|
+ DisplayName: strValue(d.Name),
|
|
|
+ DetailActived: d.Actived,
|
|
|
+ }, nil
|
|
|
+}
|
|
|
+
|
|
|
+// 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
|
|
|
+}
|