Răsfoiți Sursa

feat(academic): AC-13d 第二批——端点58 课酬方案(两级形状/明细整体替换)+ 端点63 拆 2001/2002

Q 9 ore în urmă
părinte
comite
0680aa366b

+ 141 - 2
internal/academic/handler/dto_payroll.go

@@ -21,6 +21,8 @@
 // 下面四个端点的字段全部可逐列溯源,故先行落地。
 package handler
 
+import "time"
+
 // ---------------------------------------------------------------- 端点 59:课酬试算
 
 // PayrollQuoteReq 端点 59 课酬试算请求体(`POST /admin/payroll/quote`)。
@@ -99,6 +101,143 @@ type MaintenanceResultResp struct {
 // 契约 §2.5 行 63:`mode` ∈ {`used-log`, `signed`};缺省/非法 → **2001**(契约明确写 2001,
 // 不是 2002——因为"缺参"与"枚举非法"在这里都按"必须指定同步模式"处理)。
 type ActivitySyncReq struct {
-	// Mode 同步模式(必填:`used-log` → SP30 重建 used_log;`signed` → SP31 重放 signed)。
-	Mode string `json:"mode" validate:"required"`
+	// Mode 同步模式(`used-log` → SP30 重建 used_log;`signed` → SP31 重放 signed)。
+	//
+	// ⚠ **不写 `validate:"required"`**:`required` 失败统一归 2001,而本端点要把
+	// "缺失"(2001)与"传了但非法"(2002)分成两个码 ⇒ 缺失由 Handler 显式判。
+	Mode string `json:"mode"`
+}
+
+// ---------------------------------------------------------------- 端点 58:课酬方案 + 明细
+
+// PayrollPlanDetailReq 明细入参(POST / PUT 共用,**两级形状的内层**)。
+//
+// 字段 → 目标列(`t_course_payment_detail`):
+//
+//	teacherId→teacher_id / classifyId→classify_id / classType→class_type / gradeId→grade_id /
+//	subjectId→subject_id / teacherCert→teacher_cert / teacherExpMin→teacher_exp_min /
+//	teacherExpMax→teacher_exp_max / percentage→percentage / min→min_payment / max→max_payment /
+//	reward→reward / displayName→name / detailActived→actived
+//
+// ⚠ 三处命名**故意与契约 mock 不同**(D17 裁决,因两表同名列会互相覆盖):
+//
+//	`displayName` ← 明细 `name`(计算方式名);方案名走 `schemeName`;
+//	`detailActived` ← 明细 `actived`;方案级启用走 `actived`;
+//	教龄是**区间两字段** `teacherExpMin/teacherExpMax`(单值装不下区间,且前端改档会把 max 写坏)。
+type PayrollPlanDetailReq struct {
+	// TeacherID 老师 ULID(空 = 通用规则;个人规则才填)。
+	TeacherID string `json:"teacherId" validate:"max=26"`
+	// ClassifyID 课程分类(0 = 不限定分类;个人规则精确档用)。
+	ClassifyID int64 `json:"classifyId"`
+	// ClassType 班级类型(对应 t_courses_classify.class_type;必填 >0)。
+	ClassType int32 `json:"classType" validate:"required,gt=0"`
+	// GradeID 年级(-1 = 通配;0 = 混合)。
+	GradeID int32 `json:"gradeId"`
+	// SubjectID 科目(-1 = 通配;0 = 混合)。
+	SubjectID int32 `json:"subjectId"`
+	// TeacherCert 是否持证档(true = 持证通用档;false = 通用/无证档)。
+	TeacherCert bool `json:"teacherCert"`
+	// TeacherExpMin 教龄区间下界("1.000";空 → "0.000")。
+	TeacherExpMin string `json:"teacherExpMin" validate:"max=10"`
+	// TeacherExpMax 教龄区间上界("5.000";空 → "0.000";**不得小于 Min**,否则 2002)。
+	TeacherExpMax string `json:"teacherExpMax" validate:"max=10"`
+	// Percentage 课酬比例(**百分数** "85.0" = 85%;0~100,最多 1 位小数;必填)。
+	Percentage string `json:"percentage" validate:"required,max=10"`
+	// Min 最低课酬标准(元字符串;可空 → "0.00" = 不限制)。
+	Min string `json:"min"`
+	// Max 最高课酬标准(元字符串;可空 → "0.00" = 不限制)。
+	Max string `json:"max"`
+	// Reward 额外课酬(元字符串;可空 → "0.00")。
+	Reward string `json:"reward"`
+	// DisplayName 计算方式名称(源列 `name`;与方案名 schemeName 分开)。
+	DisplayName string `json:"displayName" validate:"max=300"`
+	// DetailActived 明细级启用(源列 `actived`;与方案级 actived 分开)。
+	DetailActived bool `json:"detailActived"`
+}
+
+// CreatePayrollPlanReq 端点 58 新建请求体(`POST /admin/payroll/plans`)。
+type CreatePayrollPlanReq struct {
+	// InstitutionID 机构(非超管须与作用域一致,否则 1004)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区(0 = 机构级方案)。
+	CampusID int64 `json:"campusId"`
+	// SchemeName 方案名称(必填;源列 `t_course_payment.name`)。
+	SchemeName string `json:"schemeName" validate:"required,max=200"`
+	// EffectDate 生效日期(`2006-01-02` 或 RFC3339;可空)。
+	EffectDate string `json:"effectDate"`
+	// Actived 方案级启用(源列 `t_course_payment.actived`)。
+	Actived bool `json:"actived"`
+	// Details 明细(可空 = 只建空方案;通常至少一条)。
+	Details []PayrollPlanDetailReq `json:"details"`
+}
+
+// UpdatePayrollPlanReq 端点 58 更新请求体(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
+//
+// 语义:方案字段 nil = 不改;`details` 缺省(nil)= **不动明细**,给了(哪怕空数组)= **整体替换**。
+// 用 `*[]T` 表达这个区别——只有指针能区分"没传"与"传了空数组"。
+type UpdatePayrollPlanReq struct {
+	// InstitutionID 机构(非超管须与作用域一致)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区(nil = 不改)。
+	CampusID *int64 `json:"campusId"`
+	// SchemeName 方案名称(nil = 不改)。
+	SchemeName *string `json:"schemeName"`
+	// EffectDate 生效日期(nil = 不改)。
+	EffectDate *string `json:"effectDate"`
+	// Actived 方案级启用(nil = 不改)。
+	Actived *bool `json:"actived"`
+	// Details 明细(nil = 不动;`[]` = 清空;非空 = 整体替换)。
+	Details *[]PayrollPlanDetailReq `json:"details"`
+}
+
+// payrollPlanDetailView 端点 58 响应的明细(契约 D 表 + D17 裁决字段)。
+type payrollPlanDetailView struct {
+	// DetailID 明细 ID(源列 `t_course_payment_detail.id`)。
+	DetailID int64 `json:"detailId"`
+	// TeacherID 老师 ULID(可空 → 空串)。
+	TeacherID string `json:"teacherId"`
+	// ClassifyID 课程分类 ID。
+	ClassifyID int64 `json:"classifyId"`
+	// ClassType 班级类型。
+	ClassType int32 `json:"classType"`
+	// GradeID 年级(-1 通配)。
+	GradeID int32 `json:"gradeId"`
+	// SubjectID 科目(-1 通配)。
+	SubjectID int32 `json:"subjectId"`
+	// TeacherCert 是否持证档。
+	TeacherCert bool `json:"teacherCert"`
+	// TeacherExpMin 教龄下界(源列 `teacher_exp_min`)。
+	TeacherExpMin string `json:"teacherExpMin"`
+	// TeacherExpMax 教龄上界(源列 `teacher_exp_max`)。
+	TeacherExpMax string `json:"teacherExpMax"`
+	// Percentage 课酬比例(**百分数**,非金额)。
+	Percentage string `json:"percentage"`
+	// Min 最低课酬(源列 `min_payment`)。
+	Min string `json:"min"`
+	// Max 最高课酬(源列 `max_payment`)。
+	Max string `json:"max"`
+	// Reward 额外课酬(源列 `reward`)。
+	Reward string `json:"reward"`
+	// DisplayName 计算方式名(源列 `name`)。
+	DisplayName string `json:"displayName"`
+	// DetailActived 明细级启用(源列 `actived`)。
+	DetailActived bool `json:"detailActived"`
+}
+
+// payrollPlanView 端点 58 响应(方案 + 明细两级)。
+type payrollPlanView struct {
+	// ID 方案 ID(= `PUT /plans/:id` 的 `:id`)。
+	ID int64 `json:"id"`
+	// InstitutionID 机构(源列 `institution_id`)。
+	InstitutionID int64 `json:"institutionId"`
+	// CampusID 校区(源列 `campus_id`;0 = 机构级)。
+	CampusID int64 `json:"campusId"`
+	// SchemeName 方案名称(源列 `t_course_payment.name`)。
+	SchemeName string `json:"schemeName"`
+	// EffectDate 生效日期(源列 `effect_date`;可空 → null)。
+	EffectDate *time.Time `json:"effectDate"`
+	// Actived 方案级启用(源列 `t_course_payment.actived`)。
+	Actived bool `json:"actived"`
+	// Details 明细(空 → [])。
+	Details []payrollPlanDetailView `json:"details"`
 }

+ 3 - 0
internal/academic/handler/handler.go

@@ -176,6 +176,8 @@ type scopedServices struct {
 	maintenance *service.MaintenanceService
 	// payroll 课酬试算(端点 59;SP26)。**与结课共用同一算法规格**(见 service.computeRecordReward)。
 	payroll *service.PayrollService
+	// payrollPlan 课酬方案配置(端点 58:方案 + 明细 CRUD;**写入面**,AC-10 未覆盖)。
+	payrollPlan *service.PayrollPlanService
 }
 
 // servicesFor 按请求作用域装配一套服务。
@@ -212,6 +214,7 @@ func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
 		recordView:       service.NewRecordViewService(h.db, sc, payroll),
 		maintenance:      service.NewMaintenanceService(h.db, sc),
 		payroll:          payroll,
+		payrollPlan:      service.NewPayrollPlanService(h.db, sc),
 	}
 }
 

+ 263 - 7
internal/academic/handler/payroll_handler.go

@@ -41,6 +41,10 @@ import (
 // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
 
 const (
+	// PathPayrollPlans 端点 58:课酬方案列表 / 新建(`/admin/payroll/plans`)。
+	PathPayrollPlans = "/admin/payroll/plans"
+	// PathPayrollPlanDetail 端点 58:更新方案(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
+	PathPayrollPlanDetail = "/admin/payroll/plans/:id"
 	// PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。
 	PathPayrollQuote = "/admin/payroll/quote"
 	// PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。
@@ -57,6 +61,247 @@ const activitySyncModeUsedLog = "used-log"
 // activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。
 const activitySyncModeSigned = "signed"
 
+// ---------------------------------------------------------------- 端点 58:课酬方案
+
+// ListPayrollPlans 课酬方案列表(端点 58 `GET /admin/payroll/plans`)。
+//
+// 入参(query):`institutionId`(必填 >0)、`campusId?`、`actived?`(0/1)、`keyword?`、分页。
+// 出参(成功):`{list,total,page,pageSize,pages}`,行为**两级形状**(方案 + `details[]`)。
+// 出参(失败):1001/2001(缺 institutionId)/2002(campusId/actived 非法)/**1004**(跨机构)/5002。
+//
+// 为什么要显式 `institutionId`:`t_course_payment_detail` 没有机构列、`PayrollRepo` 又不走 `Scoped`
+// ⇒ 机构维度只能由调用方给出,并由 `assertInstitution` 校验(非超管必须等于作用域机构)。
+func (h *Handler) ListPayrollPlans(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	institutionID, err := parseIntQuery(c, "institutionId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if institutionID <= 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("institutionId 必填且必须 > 0")))
+	}
+	campusID, err := parseIntQuery(c, "campusId")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	if campusID < 0 {
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("campusId 不得为负")))
+	}
+	var campusPtr *int64
+	if strings.TrimSpace(c.Query("campusId")) != "" {
+		v := campusID
+		campusPtr = &v
+	}
+	activedFilter, _, err := parseEnumQuery(c, "actived", map[int64]struct{}{0: {}, 1: {}})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var activedPtr *bool
+	if activedFilter != nil {
+		v := *activedFilter == 1
+		activedPtr = &v
+	}
+	page, pageSize := parsePage(c)
+	svc := h.servicesFor(sc)
+	plans, total, err := svc.payrollPlan.ListPlans(c.Context(), service.PayrollPlanQuery{
+		InstitutionID: institutionID,
+		CampusID:      campusPtr,
+		Actived:       activedPtr,
+		Keyword:       c.Query("keyword"),
+		Page:          page,
+		PageSize:      pageSize,
+	})
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	views := make([]payrollPlanView, 0, len(plans))
+	for i := range plans {
+		views = append(views, toPayrollPlanView(&plans[i]))
+	}
+	return response.Page(c, views, page, pageSize, total)
+}
+
+// CreatePayrollPlan 新建课酬方案(端点 58 `POST /admin/payroll/plans`)。
+//
+// 入参(body):见 `CreatePayrollPlanReq`(`schemeName` 必填,`details[]` 可空)。
+// 出参(成功):`payrollPlanView`(含新方案 id 与回读的明细)。
+// 出参(失败):1001/2001/2002(比例非 0~100、教龄区间倒挂、金额非法)/1004/5002。
+//
+// 事务:方案 + 全部明细在**同一事务**内写入(明细写失败 ⇒ 方案不落库,不会留下"空方案")。
+func (h *Handler) CreatePayrollPlan(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req CreatePayrollPlanReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	effectDate, hasEffect, err := parseTimeParam(req.EffectDate, "effectDate")
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	details, ok := h.parsePayrollPlanDetails(c, req.Details)
+	if !ok {
+		return nil
+	}
+	in := service.CreatePlanInput{
+		InstitutionID: institutionID,
+		CampusID:      req.CampusID,
+		SchemeName:    req.SchemeName,
+		Actived:       req.Actived,
+		Details:       details,
+		OperatorID:    sc.UserID,
+	}
+	if hasEffect {
+		in.EffectDate = &effectDate
+	}
+	svc := h.servicesFor(sc)
+	plan, err := svc.payrollPlan.CreatePlan(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, toPayrollPlanView(plan))
+}
+
+// UpdatePayrollPlan 更新课酬方案(端点 58 `PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
+//
+// 入参(body):见 `UpdatePayrollPlanReq`(nil = 不改;`details` 给了就**整体替换**)。
+// 出参(成功):`payrollPlanView`(更新后回读)。
+// 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/1004/5002。
+//
+// **整体替换明细**(D17 裁决):先按 `course_payment_id` 删除、再插入,同事务。
+// 不做逐条 diff —— 明细是**配置**不是流水,重写终态即可;diff 会引入"哪条被谁改了"的对账难题。
+func (h *Handler) UpdatePayrollPlan(c *fiber.Ctx) error {
+	sc, err := mustScope(c)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	var req UpdatePayrollPlanReq
+	if !validateReq(c, &req) {
+		return nil
+	}
+	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	in := service.UpdatePlanInput{
+		ID:            parseIDParam(c.Params("id")),
+		InstitutionID: institutionID,
+		CampusID:      req.CampusID,
+		SchemeName:    req.SchemeName,
+		Actived:       req.Actived,
+	}
+	if req.EffectDate != nil {
+		at, has, perr := parseTimeParam(*req.EffectDate, "effectDate")
+		if perr != nil {
+			return response.Fail(c, perr)
+		}
+		if has {
+			in.EffectDate = &at
+		}
+	}
+	if req.Details != nil {
+		details, ok := h.parsePayrollPlanDetails(c, *req.Details)
+		if !ok {
+			return nil
+		}
+		in.Details = details
+		in.ReplaceDetail = true // 给了 details(哪怕是 `[]`)= 整体替换 / 清空
+	}
+	svc := h.servicesFor(sc)
+	plan, err := svc.payrollPlan.UpdatePlan(c.Context(), in)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, toPayrollPlanView(plan))
+}
+
+// parsePayrollPlanDetails 解析明细入参(金额字符串 → money.Amount;其余原样透传给 Service 校验)。
+//
+// 入参:c(仅用于写失败响应);raw 明细入参。出参:(服务入参, 是否成功);失败时 body code=2002。
+//
+// 金额用 `parseMoneyOrZero`(空串 = 0.00 = 不限制),**不用** `parseMoney`:
+// `min`/`max`/`reward` 都是可空字段,空串不等于"填错"。
+func (h *Handler) parsePayrollPlanDetails(c *fiber.Ctx, raw []PayrollPlanDetailReq) ([]service.PayrollPlanDetailInput, bool) {
+	out := make([]service.PayrollPlanDetailInput, 0, len(raw))
+	for i := range raw {
+		item := &raw[i]
+		minAmount, ok := parseMoneyOrZero(c, item.Min, "details.min")
+		if !ok {
+			return nil, false
+		}
+		maxAmount, ok := parseMoneyOrZero(c, item.Max, "details.max")
+		if !ok {
+			return nil, false
+		}
+		reward, ok := parseMoneyOrZero(c, item.Reward, "details.reward")
+		if !ok {
+			return nil, false
+		}
+		out = append(out, service.PayrollPlanDetailInput{
+			TeacherID:     strings.TrimSpace(item.TeacherID),
+			ClassifyID:    item.ClassifyID,
+			ClassType:     item.ClassType,
+			GradeID:       item.GradeID,
+			SubjectID:     item.SubjectID,
+			TeacherCert:   item.TeacherCert,
+			TeacherExpMin: item.TeacherExpMin,
+			TeacherExpMax: item.TeacherExpMax,
+			Percentage:    item.Percentage,
+			Min:           minAmount,
+			Max:           maxAmount,
+			Reward:        reward,
+			DisplayName:   item.DisplayName,
+			Actived:       item.DetailActived,
+		})
+	}
+	return out, true
+}
+
+// toPayrollPlanView 把服务视图转成响应(金额两位小数字符串;明细空 → [])。
+func toPayrollPlanView(plan *service.PayrollPlan) payrollPlanView {
+	details := make([]payrollPlanDetailView, 0, len(plan.Details))
+	for i := range plan.Details {
+		d := &plan.Details[i]
+		details = append(details, payrollPlanDetailView{
+			DetailID:      d.DetailID,
+			TeacherID:     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:           d.Min.String(),
+			Max:           d.Max.String(),
+			Reward:        d.Reward.String(),
+			DisplayName:   d.DisplayName,
+			DetailActived: d.DetailActived,
+		})
+	}
+	return payrollPlanView{
+		ID:            plan.ID,
+		InstitutionID: plan.InstitutionID,
+		CampusID:      plan.CampusID,
+		SchemeName:    plan.SchemeName,
+		EffectDate:    plan.EffectDate,
+		Actived:       plan.Actived,
+		Details:       details,
+	}
+}
+
 // QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。
 //
 // 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。
@@ -176,9 +421,9 @@ func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error {
 
 // SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。
 //
-// 入参(body):`mode` ∈ {`used-log`, `signed`};**缺省/非法 → 2001**(契约 §3.4 D 表原文)。
+// 入参(body):`mode` ∈ {`used-log`, `signed`};**缺失 → 2001;传了非法 → 2002**(团队裁决,与端点 25 一致)。
 // 出参(成功):`{affected, message}`。
-// 出参(失败):1001/2001(mode 缺失或非法)/**1004**(非超管)/5002。
+// 出参(失败):1001/2001(mode 缺失)/2002(mode 非法)/**1004**(非超管)/5002。
 //
 // 两个模式各自幂等:
 //
@@ -195,6 +440,18 @@ func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
 		return nil // body code=2001 已写出
 	}
 	mode := strings.TrimSpace(req.Mode)
+	if mode == "" {
+		// **缺失 → 2001**(未提供必要参数)
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("mode 不能为空(used-log 或 signed)")))
+	}
+	if mode != activitySyncModeUsedLog && mode != activitySyncModeSigned {
+		// **传了但不在白名单 → 2002**(枚举越界;与端点 25 的 `numberSign` 同一口径)。
+		// 与"缺失"分开的理由:前端提示不同("请选择同步模式" vs "取值非法"),且全局规则是
+		// "枚举越界 = 2002"——同一错误类别不该在两个端点用不同码。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
+			errString("mode 取值非法 "+mode+"(仅支持 used-log / signed)")))
+	}
 	svc := h.servicesFor(sc)
 	switch mode {
 	case activitySyncModeUsedLog:
@@ -219,10 +476,9 @@ func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
 		}
 		return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg})
 	default:
-		// 契约 §3.4 D 表:mode 缺省/非法 → **2001**(不是 2002)。
-		// 理由:这里的语义是"必须指定同步哪种数据",属**未提供必要参数**,与"提供了非法值"在
-		// 前端提示上是同一动作(去选一个模式),而契约明文写了 2001 ⇒ 按契约,不按 2002 通则。
-		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
-			errString("mode 必须是 used-log 或 signed(收到 "+mode+")")))
+		// 不可达:上面已把"缺失/非法"分别拦成 2001/2002。保留兜底是为了让"新增 mode 却忘了
+		// 加 case"变成显式错误,而不是静默落进某个分支(尤其不能静默走 used-log —— 那会清表)。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrInternal,
+			errString("mode 分支未实现 "+mode)))
 	}
 }

+ 200 - 2
internal/academic/handler/payroll_handler_test.go

@@ -51,6 +51,9 @@ func newPayrollTestApp(t *testing.T, db *gorm.DB) *fiber.App {
 		}
 		return c.Next()
 	})
+	app.Get(PathPayrollPlans, h.ListPayrollPlans)
+	app.Post(PathPayrollPlans, h.CreatePayrollPlan)
+	app.Put(PathPayrollPlanDetail, h.UpdatePayrollPlan)
 	app.Post(PathPayrollQuote, h.QuotePayroll)
 	app.Post(PathMaintenanceCoursesMaxPeople, h.BackfillCoursesMaxPeople)
 	app.Post(PathMaintenanceCoursesCampus, h.BackfillCoursesCampus)
@@ -58,6 +61,179 @@ func newPayrollTestApp(t *testing.T, db *gorm.DB) *fiber.App {
 	return app
 }
 
+// ---------------------------------------------------------------- 端点 58:课酬方案
+
+// TestPayrollPlanCreateAndList 端点 58:两级形状(方案 + details)、字段名不与方案撞车、列表回读。
+//
+// 断言重点(都是 D17 裁决里"命名必须分开"的具体后果):
+//
+//	`schemeName` ≠ 明细 `displayName`:两者在库里都叫 `name`,若实现里同名字段,第二个会覆盖第一个;
+//	`actived`(方案级)≠ `detailActived`(明细级):两级启用状态必须能看到差异;
+//	教龄是**区间两字段** `teacherExpMin/teacherExpMax`,不是单值。
+func TestPayrollPlanCreateAndList(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	body := map[string]any{
+		"schemeName": "2026 集体班方案",
+		"campusId":   10,
+		"effectDate": "2026-01-01",
+		"actived":    true,
+		"details": []map[string]any{{
+			"classType": 1000, "gradeId": -1, "subjectId": -1,
+			"teacherExpMin": "1.000", "teacherExpMax": "5.000",
+			"percentage": "85.0", "min": "0.00", "max": "300.00", "reward": "20.00",
+			"displayName": "集体班-无证档", "detailActived": true,
+		}, {
+			"classType": 1000, "gradeId": -1, "subjectId": -1,
+			"teacherExpMin": "0.000", "teacherExpMax": "0.999",
+			"percentage": "60.0", "min": "0.00", "max": "0.00", "reward": "0.00",
+			"displayName": "集体班-新人档", "detailActived": false,
+		}},
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, body, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("新建课酬方案 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if name, _ := payload["schemeName"].(string); name != "2026 集体班方案" {
+		t.Fatalf("schemeName = %v,期望 2026 集体班方案(与明细 displayName 分开)", payload["schemeName"])
+	}
+	if got, _ := payload["campusId"].(float64); got != 10 {
+		t.Fatalf("campusId = %v,期望 10", payload["campusId"])
+	}
+	planID, _ := payload["id"].(float64)
+	if planID <= 0 {
+		t.Fatalf("方案 id = %v,期望 >0", payload["id"])
+	}
+	details, _ := payload["details"].([]any)
+	if len(details) != 2 {
+		t.Fatalf("details 条数 = %d,期望 2", len(details))
+	}
+	first, _ := details[0].(map[string]any)
+	if name, _ := first["displayName"].(string); name != "集体班-无证档" {
+		t.Fatalf("明细 displayName = %v,期望 集体班-无证档(被 schemeName 覆盖就是同名字段)", first["displayName"])
+	}
+	// ⚠ 教龄区间同样不比较字面量(SQLite 无 DECIMAL:"1.000" 读回是 "1")——比数值,
+	// 真正要钉住的是"两个界分别下发且各自正确",不是填充位数。
+	if got := mustDecimal(t, first["teacherExpMin"]); got != 1.0 {
+		t.Fatalf("teacherExpMin = %v,期望 1.0(区间下界必须单独下发)", first["teacherExpMin"])
+	}
+	if got := mustDecimal(t, first["teacherExpMax"]); got != 5.0 {
+		t.Fatalf("teacherExpMax = %v,期望 5.0(区间上界必须单独下发,否则前端改档会写坏)", first["teacherExpMax"])
+	}
+	if got := mustDecimal(t, first["percentage"]); got != 85 {
+		t.Fatalf("percentage = %v(数值 %v),期望 85(百分数);0.85 说明被当比率、8500 说明被 ×100",
+			first["percentage"], got)
+	}
+	second, _ := details[1].(map[string]any)
+	if v, _ := second["detailActived"].(bool); v {
+		t.Fatalf("第二条 detailActived = true,期望 false(明细级启用与方案级 actived 必须能不同)")
+	}
+
+	// 列表:两级形状回读(GET 必须把 details 一并带回,否则前端要 N+1)
+	code, msg, data = doJSON(t, app, fiber.MethodGet,
+		PathPayrollPlans+"?institutionId=1&pageSize=10", nil, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("方案列表 = %d(%s),期望 200", code, msg)
+	}
+	items := pageList(t, data)
+	if len(items) != 1 {
+		t.Fatalf("方案条数 = %d,期望 1", len(items))
+	}
+	row, _ := items[0].(map[string]any)
+	if got, _ := row["id"].(float64); got != planID {
+		t.Fatalf("列表首行 id = %v,期望 %v", row["id"], planID)
+	}
+	listDetails, _ := row["details"].([]any)
+	if len(listDetails) != 2 {
+		t.Fatalf("列表行的 details = %d 条,期望 2(列表必须带明细,否则前端要逐行再查)", len(listDetails))
+	}
+}
+
+// TestPayrollPlanUpdateReplacesDetails 端点 58 PUT:`:id` = 方案 id,**明细整体替换**(先删后插)。
+//
+// 变红条件:
+//   - 把 `:id` 当明细 id(按明细删改)→ 方案字段改不动 / 明细条数不对;
+//   - "整体替换"写成"追加" → 明细变 3 条(旧 2 + 新 1)。
+func TestPayrollPlanUpdateReplacesDetails(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	create := map[string]any{
+		"schemeName": "替换测试方案", "campusId": 10, "actived": true,
+		"details": []map[string]any{
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "80.0", "displayName": "旧档A"},
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "70.0", "displayName": "旧档B"},
+		},
+	}
+	code, msg, data := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, create, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("建方案 = %d(%s),期望 200", code, msg)
+	}
+	planID, _ := jsonMap(t, data)["id"].(float64)
+
+	update := map[string]any{
+		"schemeName": "替换后方案名",
+		"details": []map[string]any{
+			{"classType": 1000, "gradeId": -1, "subjectId": -1, "percentage": "90.0", "displayName": "新档"},
+		},
+	}
+	code, msg, data = doJSON(t, app, fiber.MethodPut,
+		"/admin/payroll/plans/"+itoa(int64(planID)), update, "X-Test-Token", "t")
+	if code != 200 {
+		t.Fatalf("更新方案 = %d(%s),期望 200", code, msg)
+	}
+	payload := jsonMap(t, data)
+	if name, _ := payload["schemeName"].(string); name != "替换后方案名" {
+		t.Fatalf("schemeName = %v,期望 替换后方案名", payload["schemeName"])
+	}
+	details, _ := payload["details"].([]any)
+	if len(details) != 1 {
+		t.Fatalf("替换后明细 = %d 条,期望 1(整体替换;3 条说明写成了追加)", len(details))
+	}
+	only, _ := details[0].(map[string]any)
+	if name, _ := only["displayName"].(string); name != "新档" {
+		t.Fatalf("明细 displayName = %v,期望 新档", only["displayName"])
+	}
+	// 库里确认旧明细已删(不是只改了响应)
+	var count int64
+	db.Model(&model.CoursePaymentDetail{}).Where("course_payment_id = ?", int64(planID)).Count(&count)
+	if count != 1 {
+		t.Fatalf("库中明细条数 = %d,期望 1(旧 2 条必须被删掉)", count)
+	}
+}
+
+// TestPayrollPlanValidation 端点 58 的写入校验:比例越界 / 教龄区间倒挂 → 2002。
+//
+// 两条都是"不报错但规则永不命中/课酬少 100 倍"的静默错法,必须在写入时拒。
+func TestPayrollPlanValidation(t *testing.T) {
+	db := newCourseTestDB(t)
+	app := newPayrollTestApp(t, db)
+	base := func(detail map[string]any) map[string]any {
+		return map[string]any{"schemeName": "校验方案", "campusId": 10, "details": []map[string]any{detail}}
+	}
+	cases := []struct {
+		name   string
+		detail map[string]any
+	}{
+		{"比例 > 100(把 0.85 当比率传成 85 反了)", map[string]any{"classType": 1000, "percentage": "850.0"}},
+		{"比例 2 位小数(列是 decimal(5,1))", map[string]any{"classType": 1000, "percentage": "85.25"}},
+		{"教龄区间倒挂(规则永不命中)", map[string]any{"classType": 1000, "percentage": "85.0",
+			"teacherExpMin": "5.000", "teacherExpMax": "1.000"}},
+	}
+	for _, tc := range cases {
+		code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, base(tc.detail), "X-Test-Token", "t")
+		if code != infraerrors.ErrBadParam.Code {
+			t.Errorf("%s = %d(%s),期望 2002", tc.name, code, msg)
+		}
+	}
+	// 对照:合法明细必须 200
+	ok := base(map[string]any{"classType": 1000, "percentage": "85.0",
+		"teacherExpMin": "1.000", "teacherExpMax": "2.000"})
+	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathPayrollPlans, ok, "X-Test-Token", "t"); code != 200 {
+		t.Fatalf("合法明细 = %d(%s),期望 200", code, msg)
+	}
+}
+
 // ---------------------------------------------------------------- 端点 59:课酬试算
 
 // TestPayrollQuoteReturnsRuleTraceableFields 端点 59 必须回 9 个**可溯源**字段(不能裁掉 4 个溯源字段)。
@@ -367,9 +543,10 @@ func TestActivitySyncModes(t *testing.T) {
 
 	// ---- 非法 / 缺失 mode → 2001(**绝不能落进任何分支**:落进 used-log 会清表)
 	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
-		map[string]any{"mode": "drop-all"}, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
-		t.Fatalf("非法 mode = %d(%s),期望 2001", code, msg)
+		map[string]any{"mode": "drop-all"}, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
+		t.Fatalf("非法 mode = %d(%s),期望 2002", code, msg)
 	}
+	// 缺失 mode → **2001**(未提供必要参数);传了非法值 → **2002**(枚举越界,与端点 25 一致)
 	if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathMaintenanceActivitySync,
 		map[string]any{}, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
 		t.Fatalf("缺失 mode = %d(%s),期望 2001", code, msg)
@@ -413,3 +590,24 @@ func TestMaintenanceRequiresSuperAdmin(t *testing.T) {
 
 // ptr 返回字符串指针(测试夹具的可空列用)。
 func ptr(v string) *string { return &v }
+
+// mustDecimal 把 JSON 里的数值字段(字符串或数字)转 float(不比较字面量填充位数)。
+//
+// 为什么需要:SQLite 没有 DECIMAL 类型,`"1.000"` 读回来是 `"1"`;生产 MySQL 是 `"1.000"`。
+// 断言要跨方言成立,只能比数值。
+func mustDecimal(t *testing.T, v any) float64 {
+	t.Helper()
+	switch value := v.(type) {
+	case string:
+		parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
+		if err != nil {
+			t.Fatalf("%v 不是合法数值: %v", v, err)
+		}
+		return parsed
+	case float64:
+		return value
+	default:
+		t.Fatalf("%v 既不是字符串也不是数字(%T)", v, v)
+		return 0
+	}
+}

+ 12 - 2
internal/academic/handler/route.go

@@ -360,8 +360,18 @@ func Routes(h *Handler) []Route {
 
 		// ---------------------------------------------------------------- 课酬 / 维护(端点 59、61~63)
 		//
-		// 端点 58(课酬方案 CRUD)与 60(收支字典/记录)**本批不挂**:契约把它们各自压成
-		// 一行("方案+明细" / "字典+记录"),字段无法逐列溯源,已报偏差等裁决(见 dto_payroll.go 文件头)。
+		// 端点 58 已按 D17 裁决落地(两级形状);端点 60(收支)待下一批(3 条路由,JOIN 字典)。
+		// 端点 58 是 GET/POST(`/admin/payroll/plans`)+ PUT(`/admin/payroll/plans/:id`)**三条**
+		// method+path:`:id` 指**方案 id**(D17 裁决),明细随方案整体替换。
+		{No: 58, Method: fiber.MethodGet, Path: PathPayrollPlans, Name: "课酬方案列表",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.ListPayrollPlans},
+		{No: 58, Method: fiber.MethodPost, Path: PathPayrollPlans, Name: "新建课酬方案",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.CreatePayrollPlan},
+		{No: 58, Method: fiber.MethodPut, Path: PathPayrollPlanDetail, Name: "更新课酬方案",
+			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
+			Handler: h.UpdatePayrollPlan},
 		{No: 59, Method: fiber.MethodPost, Path: PathPayrollQuote, Name: "课酬试算",
 			Roles:   []string{RoleSuperAdmin, RoleAcademic, RoleFinance},
 			Handler: h.QuotePayroll},

+ 7 - 4
internal/academic/handler/route_e2e_test.go

@@ -184,6 +184,9 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		{56, "POST", "/admin/courses/arrears/counteract", "super_admin,academic,finance"},
 		{57, "GET", "/student/courses/schedule", "student,parent,teacher"},
 		// ---- AC-13d 第一批:课酬试算 + 维护(端点 59、61~63;58/60 待契约裁决,本批不挂)
+		{58, "GET", "/admin/payroll/plans", "super_admin,academic,finance"},
+		{58, "POST", "/admin/payroll/plans", "super_admin,academic,finance"},
+		{58, "PUT", "/admin/payroll/plans/:id", "super_admin,academic,finance"},
 		{59, "POST", "/admin/payroll/quote", "super_admin,academic,finance"},
 		{61, "POST", "/admin/maintenance/courses-max-people", "super_admin"},
 		{62, "POST", "/admin/maintenance/courses-campus", "super_admin"},
@@ -191,7 +194,7 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 	}
 
 	if len(routes) != len(want) {
-		t.Fatalf("路由条数 = %d,期望 %d(AC-14 40 + AC-13c 15 + AC-13d 4 = 59 条 method+path)", len(routes), len(want))
+		t.Fatalf("路由条数 = %d,期望 %d(AC-14 40 + AC-13c 15 + AC-13d 7 = 62 条 method+path)", len(routes), len(want))
 	}
 
 	// 逐条对齐**用集合比**而不是按下标比:注册顺序是"静态段优先"(≠ 契约端点号顺序),
@@ -223,7 +226,7 @@ func TestRouteRegistryMatchesContract(t *testing.T) {
 		delete(gotByKey, key)
 	}
 	for key := range gotByKey {
-		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 59 条)", key)
+		t.Errorf("注册了契约外的路由 %s(当前只挂已交付的 62 条)", key)
 	}
 
 	// 反向断言:18/19/20(支付)**本批不得挂**——handler 尚未实现,挂空壳会让"未实现"
@@ -331,8 +334,8 @@ func TestAssertUniqueMethodPath(t *testing.T) {
 	if err := AssertUniqueMethodPath(app.GetRoutes()); err != nil {
 		t.Fatalf("40 条路由不该有重复:%v", err)
 	}
-	if len(reg.Routes) != 59 {
-		t.Fatalf("注册条数 = %d,期望 59(40 + 15 + 4)", len(reg.Routes))
+	if len(reg.Routes) != 62 {
+		t.Fatalf("注册条数 = %d,期望 62(40 + 15 + 7)", len(reg.Routes))
 	}
 
 	// 正例:同 path 不同 method 是**合法**的(端点 12/13 共用 /admin/finance/requests)

+ 223 - 0
internal/academic/repository/payroll_plan_repo.go

@@ -0,0 +1,223 @@
+// 课酬方案 + 明细的数据访问(端点 58:`/admin/payroll/plans`)——AC-13d 追加。
+//
+// 依据:团队裁决 D17(两级形状)+ 目标态 DDL 两张表:
+//
+//	t_course_payment        : id, institution_id, campus_id, name, effect_date, actived,
+//	                          create_user_id, create_time, memo
+//	t_course_payment_detail : id, course_payment_id, grade_id, subject_id, class_type, classify_id,
+//	                          teacher_id, name, percentage, min_payment, max_payment, reward,
+//	                          teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo
+//
+// ---------------------------------------------------------------------------
+// 作用域(为什么不用 BaseRepo.Scoped)
+// ---------------------------------------------------------------------------
+//
+// `PayrollRepo` **不继承 BaseRepo**(见 `payroll_repo.go` 文件头:本包表混有无作用域列的表
+// `t_teacher_information`,`Scoped` 的裸列名会 `Unknown column`)。因此本文件的每个方法都
+// **显式接收 institutionID** 并 `WHERE institution_id = ?`,越权由 `assertInstitution` 统一拦:
+//
+//	非超管:institutionID 必须 == 作用域机构(否则 1004)
+//	超管 :可查任意机构(平台视角)
+//
+// ⚠ `t_course_payment_detail` **没有** institution_id 列 ⇒ 明细的作用域**只能经父方案**:
+// 本文件所有"按明细操作"的方法都要求调用方已用方案 id 校验过归属(Service 层保证)。
+//
+// ---------------------------------------------------------------------------
+// 事务纪律
+// ---------------------------------------------------------------------------
+//
+// 写方法一律接收 `tx *gorm.DB`(仓储不开事务);`ReplaceDetails` 的"先删后插"必须在
+// **同一事务**内(否则删完插失败 ⇒ 整案明细丢失)。
+package repository
+
+import (
+	"context"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// PayrollPlanFilter 课酬方案列表过滤(端点 58 GET)。
+//
+// 字段:CampusID 校区(nil 不筛;0 = 只查机构级方案);Actived 启用(nil 不筛);
+// Keyword 方案名模糊(空不筛)。
+type PayrollPlanFilter struct {
+	CampusID *int64
+	Actived  *bool
+	Keyword  string
+}
+
+// ListPlans 分页查询课酬方案(按 effect_date DESC, id DESC:最新生效在前)。
+//
+// 入参:ctx;institutionID 机构(**必须 >0**,越权 → 1004);f 过滤;pg 分页。
+// 出参:([]model.CoursePayment, total, error);空结果非 nil;ErrBadParam/ErrUnscoped/ErrDBError。
+func (r *PayrollRepo) ListPlans(ctx context.Context, institutionID int64, f PayrollPlanFilter, pg PageQuery) ([]model.CoursePayment, int64, error) {
+	if err := r.assertInstitution(institutionID); err != nil {
+		return nil, 0, err
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, 0, err
+	}
+	build := func() *gorm.DB {
+		q := db.Model(&model.CoursePayment{}).Where("institution_id = ?", institutionID)
+		if f.CampusID != nil {
+			q = q.Where("campus_id = ?", *f.CampusID)
+		}
+		if f.Actived != nil {
+			q = q.Where("actived = ?", *f.Actived)
+		}
+		if kw := strings.TrimSpace(f.Keyword); kw != "" {
+			q = q.Where("name LIKE ?", "%"+kw+"%")
+		}
+		return q
+	}
+	var total int64
+	if err := build().Count(&total).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	page, size := pg.Normalize()
+	rows := make([]model.CoursePayment, 0, size)
+	if err := build().
+		Order("effect_date DESC, id DESC").
+		Offset(PageOffset(page, size)).Limit(size).
+		Find(&rows).Error; err != nil {
+		return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, total, nil
+}
+
+// GetPlanByID 按主键取课酬方案(**必须属于该机构**,否则 3001 —— 不泄露存在性)。
+//
+// 入参:ctx;institutionID(>0);id 方案 ID(>0)。
+// 出参:*model.CoursePayment;ErrBadParam / ErrUnscoped / ErrNotFound / ErrDBError。
+func (r *PayrollRepo) GetPlanByID(ctx context.Context, institutionID, id int64) (*model.CoursePayment, error) {
+	if err := r.assertInstitution(institutionID); err != nil {
+		return nil, err
+	}
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 方案 id 必须 > 0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursePayment
+	qErr := db.Model(&model.CoursePayment{}).
+		Where("id = ? AND institution_id = ?", id, institutionID).
+		Take(&out).Error
+	if qErr != nil {
+		return nil, wrapQueryErr(qErr, "GetPlanByID(t_course_payment)")
+	}
+	return &out, nil
+}
+
+// ListDetailsByPlanIDs 批量取多个方案下的全部明细(按 id ASC,顺序稳定)。
+//
+// 入参:ctx;planIDs 方案 ID 集合(空 → 空切片,不发 SQL)。
+// 出参:[]model.CoursePaymentDetail;ErrDBError。
+//
+// 为什么批量:列表页一页最多 200 个方案,逐方案查 = 200 次往返。
+func (r *PayrollRepo) ListDetailsByPlanIDs(ctx context.Context, planIDs []int64) ([]model.CoursePaymentDetail, error) {
+	out := make([]model.CoursePaymentDetail, 0, len(planIDs))
+	ids := dedupPositive(planIDs)
+	if len(ids) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if err := db.Model(&model.CoursePaymentDetail{}).
+		Where("course_payment_id IN ?", ids).
+		Order("course_payment_id ASC, id ASC").
+		Find(&out).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return out, nil
+}
+
+// CreatePlan 新建课酬方案(回填自增 ID)。
+//
+// 入参:ctx;tx(**必传**);row 方案行(Name/InstitutionID 必填)。
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+func (r *PayrollRepo) CreatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment) error {
+	if err := requireTx(tx, "CreatePlan"); err != nil {
+		return err
+	}
+	if row == nil || strings.TrimSpace(strValueOrEmpty(row.Name)) == "" || row.InstitutionID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CreatePlan 需要 name 与 institution_id"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// UpdatePlan 按列白名单更新方案(只改调用方显式给出的列)。
+//
+// 入参:ctx;tx(**必传**);row(ID 必填);columns 列名(非空,调用方用字面量)。
+// 出参:int64 受影响行数(0 = 不存在/不在机构);ErrBadParam / ErrDBError。
+//
+// 红线:**不含 institution_id**(作用域列创建时锁定,不允许改租户)。
+func (r *PayrollRepo) UpdatePlan(ctx context.Context, tx *gorm.DB, row *model.CoursePayment, columns []string) (int64, error) {
+	if err := requireTx(tx, "UpdatePlan"); err != nil {
+		return 0, err
+	}
+	if row == nil || row.ID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要 id"))
+	}
+	if len(columns) == 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdatePlan 需要显式列清单"))
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursePayment{}).
+		Where("id = ? AND institution_id = ?", row.ID, row.InstitutionID).
+		Select(columns).Updates(row)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// ReplaceDetails 整体替换某方案的明细(**先按 course_payment_id 删、再批量插**)。
+//
+// 入参:ctx;tx(**必传**,删与插必须同一事务);planID 方案 ID(>0);
+// details 新明细(为空 → 只删不插,即"清空明细")。
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+//
+// 为什么"整体替换"而不是逐条 diff:前端提交的是整案(D17 裁决 `PUT /:id` = 方案 id 整体替换),
+// diff 会引入"哪条被谁改了"的对账难题,而明细是**配置**不是流水,重写终态即可。
+func (r *PayrollRepo) ReplaceDetails(ctx context.Context, tx *gorm.DB, planID int64, details []*model.CoursePaymentDetail) error {
+	if err := requireTx(tx, "ReplaceDetails"); err != nil {
+		return err
+	}
+	if planID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ReplaceDetails 需要 plan_id"))
+	}
+	db := tx.WithContext(ctxOrBackground(ctx))
+	if err := db.Where("course_payment_id = ?", planID).Delete(&model.CoursePaymentDetail{}).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	if len(details) == 0 {
+		return nil
+	}
+	for i := range details {
+		details[i].CoursePaymentID = planID // 强制回填:防止调用方传错父 id
+	}
+	if err := db.Create(&details).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// strValueOrEmpty 取字符串指针的值(nil → 空串;仓储内部小工具)。
+func strValueOrEmpty(s *string) string {
+	if s == nil {
+		return ""
+	}
+	return *s
+}

+ 572 - 0
internal/academic/service/payroll_plan_service.go

@@ -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
+}