// 课酬方案 + 明细服务(端点 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 }