// 课酬试算服务(sp_course_payment_detail_get_teacher_detail → PayrollService.QuoteTeacherReward)。 // // 依据:unified/03-migration/academic-domain-design.md // - §3.4 #26 `sp_course_payment_detail_get_teacher_detail`(dump **763575~763665**) // - §10.1 `3112 课酬方案未配置`("无方案"与"无任何规则命中"两个出口都归 3112,见方法注释) // // --------------------------------------------------------------------------- // dump 骨架(763577 CREATE PROCEDURE → 763663 收尾 SELECT;行号已逐行 awk 核对) // --------------------------------------------------------------------------- // // 763594 游标:`DECLARE cur CURSOR FOR SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid` // —— **按主键过滤 ⇒ 最多 1 行**;分类不存在 ⇒ 0 行 ⇒ 循环体一次都不执行 ⇒ 末尾 `WHERE id=0` 返回空结果集 // 763596 `DECLARE CONTINUE HANDLER FOR NOT found SET done = 1` // 763603 `FETCH cur INTO classifyGradeid,classifySubjectid,classifyClassType` // 763605 `IF done=1 THEN LEAVE readLoop` // 763609 `IF inTeacherExp<0 THEN SET inTeacherExp = 0`(教龄负值钳 0) // 763613 老师持证:`SELECT (SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid)` // —— **无行 ⇒ NULL ⇒ `IF teacherCert>0` 不成立 ⇒ 按无证走** // 763616 方案①:`schoolid+inCampusid+actived=1+effect_date<=inDate ORDER BY effect_date DESC LIMIT 1` // 763617-763619 方案②(回退):`coursePaymentid<1 || IS NULL` → 同条件但 `campusid=0`(机构级) // 763621-763623 `IF coursePaymentid<1 THEN LEAVE readLoop` → 无任何方案 ⇒ **3112** // 763627 个人配置闸门:`SELECT(SELECT COUNT(1) ... course_paymentid+actived+teacherid+class_type) INTO oneself` // 763628-763642 `IF oneself>0 THEN` … 个人档 ① `classifyid=inClassifyid`(763630-763631) // 与个人档 ② `gradeid IN (g,-1)`+`subjectid IN (s,-1)`(763636-763637) // 763645-763652 `IF teacherCert>0 THEN` … 通用档 ③(持证,`teacherid=0 AND teacher_cert=1`,763646-763648) // 763654-763656 通用档 ④(无证,`teacher_cert=0`)—— **本档没有 `IF ... LEAVE` 判据**(见下节) // 763659 `END LOOP`;763663 `SELECT * FROM t_course_payment_detail WHERE id=coursePaymentDetailid` // // --------------------------------------------------------------------------- // ★ 一处**有意差异**(必读):dump 的 `IF coursePaymentDetailid>1` // --------------------------------------------------------------------------- // // `IF coursePaymentDetailid>1 THEN LEAVE readLoop` 在 dump 里出现在 **3 处**: // **763632 / 763638 / 763649**(分别对应个人档 ①、个人档 ②、通用档 ③)。 // // **第四档(无证通用,763654-763656)没有这个判据**:它只有三行 // `SELECT (SELECT ... ) INTO coursePaymentDetailid`,后面直接到 763657 注释与 763659 `END LOOP`, // 没有 `IF coursePaymentDetailid>1 THEN LEAVE readLoop`。 // // **这个缺省为什么无害(务必写清,否则下一个人会来"补"它)**: // 游标 763594 是 `WHERE id=inClassifyid` —— **按主键过滤,最多 1 行**。第四档之后回到 // `readLoop` 顶部 `FETCH` 撞上 763596 的 `CONTINUE HANDLER FOR NOT found` ⇒ `done=1` ⇒ // 763605 `LEAVE readLoop` 正常退出,`coursePaymentDetailid` 保留第四档查到的值。 // 即"缺 LEAVE 但循环本来就要结束" ⇒ 与 Go 侧"首命中即返回"**等价**,不存在"被后级覆盖"。 // // 而**有**判据的前三档,判的是 `**> 1**` 而不是 `> 0`: // `t_course_payment_detail.id` 是自增主键、从 **1** 开始 ⇒ **id=1 那条规则永远"不算命中"**: // 该档查到 id=1 后不 LEAVE,控制流继续往下走,被后续**更低优先级**的查询覆盖 // (后级无命中时覆盖成 NULL)→ 最终 `WHERE id=NULL`(763663)返回空 ⇒ "配了规则却试算不出课酬"。 // // **口径定名(评审用一句话概括)**:这条 `> 1` 实际是「**≥ 2 才短路**」的阈值判据, // 而不是"有没有查到"的判据。Go 侧按 `> 0` 实现 = 「命中即短路」,与它的差别**恰好只有 id=1 一行** // (id ≥ 2 时两者等价);因此"真实数据 0 差异"只在**不含 id=1 命中**的前提下成立, // 割接前必须核对 id=1 那行(见下),不能把"0 差异"当成无条件结论。 // // 这是 dump 的既有缺陷(错字级:`>1` 应为 `>0`),本次实现按 `>0`(即"找到即命中"), // 差异面**恰好只有 id=1 这一行**(id>=2 时 `>1` 与 `>0` 等价),登记为**有意差异(修正型)**: // - 业务影响:仅影响历史第一条课酬规则;旧系统里它从未生效过,新系统会生效。 // 割接前须核对该行内容——若它是一条脏数据/占位行,应在数据清洗阶段停用(actived=0), // 而不是靠这个错字把它藏起来。 // - 自证失败用例:`TestQuotePayrollPersonalRuleWithIDOneMatches`(把实现改回 `>1` 立刻红)。 // // --------------------------------------------------------------------------- // 层级编码(供前端展示与对账时区分"命中哪一档") // --------------------------------------------------------------------------- // // 方案层级:`campus`(校区方案)→ `institution`(`campus_id=0` 机构级方案)。 // 规则层级:`personal_classify` → `personal_grade_subject` → `cert_general` → `nocert_general`。 package service import ( "context" stderrors "errors" "fmt" "math" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "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" ) // 课酬方案层级(两级回退的命中级别)。 const ( // SchemeScopeCampus 校区级方案(dump 763616,第一个查询)。 SchemeScopeCampus = "campus" // SchemeScopeInstitution 机构级方案(dump 763617-763619,`campusid=0` 的第二级回退)。 SchemeScopeInstitution = "institution" ) // 课酬规则层级(三级回退的命中级别)。 const ( // RuleTierPersonalClassify 个人配置·精确分类(dump 763630-763631)。 RuleTierPersonalClassify = "personal_classify" // RuleTierPersonalGradeSubject 个人配置·年级/科目通配(dump 763636-763637)。 RuleTierPersonalGradeSubject = "personal_grade_subject" // RuleTierCertGeneral 持证通用(dump 763646-763648)。 RuleTierCertGeneral = "cert_general" // RuleTierNoCertGeneral 无证通用(dump 763654-763656)。 RuleTierNoCertGeneral = "nocert_general" ) // PayrollService 课酬试算服务(只读;无事务)。 // // 字段: // - repo 课酬方案/规则仓储; // - sc 请求作用域(可 nil:系统任务/双跑脚本视角)。 type PayrollService struct { repo *repository.PayrollRepo sc *appscope.Scope } // NewPayrollService 构造课酬试算服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域;nil = 系统任务视角)。 // 出参:*PayrollService。 func NewPayrollService(db *gorm.DB, sc *appscope.Scope) *PayrollService { return &PayrollService{repo: repository.NewPayrollRepo(db, sc), sc: sc} } // QuotePayrollInput 课酬试算入参(对齐 dump 的 6 个入参)。 // // 字段: // - TeacherID 老师(ULID;旧 inUserid); // - TeacherExp 教龄(年;旧 inTeacherExp FLOAT;**负数按 0 处理**,dump 763609); // - InstitutionID 机构 ID(旧 inSchoolid;<=0 时取作用域机构); // - CampusID 校区 ID(旧 inCampusid;方案两级回退的第一级); // - ClassifyID 课程分类 ID(旧 inClassifyid;提供 grade/subject/class_type 三个维度); // - Date 试算日期(旧 inDate;方案 `effect_date <= Date`,**含当日**)。 type QuotePayrollInput struct { // TeacherID 老师(ULID 26 位)。 TeacherID string // TeacherExp 教龄(年;可含小数,负数当 0)。 TeacherExp float64 // InstitutionID 机构 ID(旧 schoolid;<=0 → 用作用域机构)。 InstitutionID int64 // CampusID 校区 ID(旧 campusid;0 = 只看机构级方案)。 CampusID int64 // ClassifyID 课程分类 ID(>0)。 ClassifyID int64 // Date 试算日期(零值 → ErrBadParam)。 Date time.Time } // PayrollQuote 课酬试算结果(dump 763663 `SELECT *` 的 4 个业务列 + 溯源列)。 // // 金额口径:`Min` / `Max` / `Reward` 来自 `decimal(12,2)` 列 → `money.Amount`(两位小数字符串输出)。 // // **`Percentage` 不是金额**:源列是 `decimal(5,1)`(百分比数字部分,如 `65.5` 表示 65.5%), // 因此保留**原值字符串**,绝不用 `money.Amount` 承载(否则 65.5 → "65.50 元" 的口径污染)。 type PayrollQuote struct { // DetailID 命中的课酬明细规则 ID(旧 coursePaymentDetailid)。 DetailID int64 `json:"detailId"` // SchemeID 命中的课酬方案 ID(旧 coursePaymentid)。 SchemeID int64 `json:"schemeId"` // SchemeCampusID 命中方案所属校区(0 = 机构级方案)。 SchemeCampusID int64 `json:"schemeCampusId"` // SchemeScope 方案层级(SchemeScopeCampus / SchemeScopeInstitution)。 SchemeScope string `json:"schemeScope"` // RuleTier 规则层级(RuleTier* 四值之一)。 RuleTier string `json:"ruleTier"` // Percentage 课酬比例(decimal(5,1) 原值字符串,如 "65.5";**非金额**)。 Percentage string `json:"percentage"` // Min 最低课酬标准(元;0 = 不限制)。 Min money.Amount `json:"min"` // Max 最高课酬标准(元;0 = 不限制)。 Max money.Amount `json:"max"` // Reward 额外课酬(元;在比例基础上追加)。 Reward money.Amount `json:"reward"` } // QuoteTeacherReward 课酬试算(设计 §3.4 #26 的正式方法名)。 // // 入参: // - ctx 请求上下文; // - in QuotePayrollInput(TeacherID / ClassifyID / Date 必填)。 // // 出参: // - *PayrollQuote:命中规则(含溯源层级); // - `apperr.ErrPayrollPlanMissing`(3112):**两级方案都没有**,或**四级规则一条都没命中**; // - ErrBadParam(2001) 缺参(含试算日期零值);ErrUnscoped(1004) 跨机构;ErrDBError(5002)。 // // 为什么"分类不存在"也归 3112:dump 在分类不存在时游标 0 行 → 末尾按 id=0 取行 → **返回空结果集**, // 旧 PHP 调用方对空结果的处理与"没有规则"完全一致(拿不到课酬)。Go 侧把这两种"拿不到课酬" // 统一成 3112 并挂不同 cause,既不改变可观察类别,又能在日志里区分成因。 func (s *PayrollService) QuoteTeacherReward(ctx context.Context, in QuotePayrollInput) (*PayrollQuote, error) { if strings.TrimSpace(in.TeacherID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: QuoteTeacherReward 需要 teacher_id")) } if in.ClassifyID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: QuoteTeacherReward 需要 classify_id(>0)")) } if in.Date.IsZero() { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: QuoteTeacherReward 需要试算日期")) } if in.CampusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: campus_id 不能为负")) } institutionID := in.InstitutionID if institutionID <= 0 && s.sc != nil { institutionID = s.sc.InstitutionID } if institutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: QuoteTeacherReward 需要机构 id(入参或作用域)")) } // 763594 分类(游标等价单查):不存在 ⇒ 与 dump 的"空结果集"同类 → 3112 classify, err := s.repo.GetClassify(ctx, in.ClassifyID) if err != nil { if isNotFoundErr(err) { return nil, infraerrors.WithCause(apperr.ErrPayrollPlanMissing, errString("service: 课程分类不存在(classify_id="+ fmt.Sprintf("%d", in.ClassifyID)+"),无可匹配的课酬规则")) } return nil, err } // 763609 教龄负值钳 0(dump 在循环体内做,但只依赖入参,提出循环等价) teacherExp := formatTeacherExp(in.TeacherExp) // 763613 持证判定(无行 = 无证) hasCert, err := s.repo.TeacherHasCert(ctx, in.TeacherID) if err != nil { return nil, err } // 763616-763623 方案两级回退 scheme, scope, err := s.resolveScheme(ctx, institutionID, in.CampusID, in.Date) if err != nil { return nil, err } // 763627-763656 规则三级回退(个人配置内部再分两档) detail, tier, err := s.resolveDetail(ctx, scheme.ID, in.TeacherID, hasCert, classify, teacherExp) if err != nil { return nil, err } if detail == nil { // 763663:dump 返回空结果集 ⇒ Go 显式化 3112(与"无方案"同一个对外类别) return nil, infraerrors.WithCause(apperr.ErrPayrollPlanMissing, errString("service: 该老师/分类/教龄未命中任何课酬规则(方案 "+fmt.Sprintf("%d", scheme.ID)+")")) } quote, err := buildPayrollQuote(detail) if err != nil { return nil, err } quote.SchemeID = scheme.ID quote.SchemeCampusID = scheme.CampusID quote.SchemeScope = scope quote.RuleTier = tier return quote, nil } // QuotePayroll 课酬试算(AC-10 派单口径的方法名;**转调** QuoteTeacherReward,无第二份逻辑)。 // // 入参/出参见 QuoteTeacherReward。 func (s *PayrollService) QuotePayroll(ctx context.Context, in QuotePayrollInput) (*PayrollQuote, error) { return s.QuoteTeacherReward(ctx, in) } // resolveScheme 方案两级回退(dump 763616-763623)。 // // 入参:ctx;institutionID(>0);campusID 入参校区;date 试算日期。 // 出参: // - *model.CoursePayment 命中方案(**保证非 nil**); // - string 命中层级(SchemeScopeCampus / SchemeScopeInstitution); // - 3112(两级都没有)/ ErrBadParam / ErrUnscoped / ErrDBError。 // // 顺序**不可交换**:先校区级、再机构级(`campus_id=0`);校区级存在时**绝不**因为 // "机构级更匹配"而回退——dump 只在 `coursePaymentid<1` 时才走第二级(763617-763619)。 func (s *PayrollService) resolveScheme( ctx context.Context, institutionID, campusID int64, date time.Time, ) (*model.CoursePayment, string, error) { scheme, err := s.repo.FindActiveScheme(ctx, institutionID, campusID, date) if err != nil { return nil, "", err } if scheme != nil { return scheme, SchemeScopeCampus, nil } // 763618:`campusid=0` 机构级方案(**campusID=0 时不必重查**:上面那次就是它) if campusID != 0 { scheme, err = s.repo.FindActiveScheme(ctx, institutionID, 0, date) if err != nil { return nil, "", err } if scheme != nil { return scheme, SchemeScopeInstitution, nil } } // 763621:`IF coursePaymentid<1 THEN LEAVE readLoop` → 无方案 return nil, "", infraerrors.WithCause(apperr.ErrPayrollPlanMissing, errString("service: 机构 "+fmt.Sprintf("%d", institutionID)+" 校区 "+ fmt.Sprintf("%d", campusID)+" 无生效课酬方案(含机构级回退)")) } // resolveDetail 规则三级回退(dump 763627-763656)。 // // 入参:ctx;schemeID;teacherID;hasCert 是否持证;classify 分类维度;teacherExp 已钳 0 的 decimal 字符串。 // 出参: // - *model.CoursePaymentDetail 命中规则(**未命中返回 nil**,由调用方翻译 3112); // - string 命中层级(RuleTier*); // - ErrBadParam / ErrDBError。 // // 回退顺序(与 dump 逐行一致,**任何一步命中即停**): // // 个人档闸门(763627 COUNT>0)→ ① 精确分类(763630)→ ② 年级/科目通配(763636) // → 持证时 ③ 持证通用(763646)→ ④ 无证通用(763654) // // 注意两处"看起来可以省掉、其实不能改"的细节: // 1. 个人档闸门是 `COUNT>0`(任意一条个人规则)而不是"命中某条"——它的作用只是决定 // "要不要尝试 ①/②",因此闸门开着但 ①② 都不命中时,**继续往 ③/④ 走**(不是返回空); // 2. 持证者若 ③ 未命中,**仍要**试 ④(无证通用档也对持证老师开放): // dump 763652 是 `END IF`(结束 `IF teacherCert>0`),紧随其后的 763654 就是无证档—— // **没有 else 分支**,所以持证老师的路径必然经过 ④。 func (s *PayrollService) resolveDetail( ctx context.Context, schemeID int64, teacherID string, hasCert bool, classify *model.CoursesClassify, teacherExp string, ) (*model.CoursePaymentDetail, string, error) { personalCount, err := s.repo.CountPersonalRules(ctx, schemeID, teacherID, classify.ClassType) if err != nil { return nil, "", err } if personalCount > 0 { detail, err := s.repo.FindPersonalByClassify(ctx, schemeID, teacherID, classify.ClassType, classify.ID) if err != nil { return nil, "", err } if detail != nil { return detail, RuleTierPersonalClassify, nil } detail, err = s.repo.FindPersonalByGradeSubject(ctx, schemeID, teacherID, classify.ClassType, classify.GradeID, classify.SubjectID) if err != nil { return nil, "", err } if detail != nil { return detail, RuleTierPersonalGradeSubject, nil } } if hasCert { detail, err := s.repo.FindGeneralRule(ctx, schemeID, true, classify.ClassType, classify.GradeID, classify.SubjectID, teacherExp) if err != nil { return nil, "", err } if detail != nil { return detail, RuleTierCertGeneral, nil } } detail, err := s.repo.FindGeneralRule(ctx, schemeID, false, classify.ClassType, classify.GradeID, classify.SubjectID, teacherExp) if err != nil { return nil, "", err } if detail != nil { return detail, RuleTierNoCertGeneral, nil } return nil, "", nil } // buildPayrollQuote 把命中的规则行转成 DTO(金额定点解析 + 比例原值保留)。 // // 入参:detail 命中的规则行(非 nil)。 // 出参:*PayrollQuote(DetailID/Percentage/Min/Max/Reward 已填);金额字面量非法 → ErrDBError。 func buildPayrollQuote(detail *model.CoursePaymentDetail) (*PayrollQuote, error) { minAmount, err := money.Parse(detail.MinPayment) if err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } maxAmount, err := money.Parse(detail.MaxPayment) if err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } reward, err := money.Parse(detail.Reward) if err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return &PayrollQuote{ DetailID: detail.ID, Percentage: strings.TrimSpace(detail.Percentage), Min: minAmount, Max: maxAmount, Reward: reward, }, nil } // formatTeacherExp 把教龄(年)格式化成 `decimal(6,3)` 字符串(供 SQL 做 BETWEEN 比较)。 // // 入参:exp 教龄(年;dump 为 FLOAT)。负数 → 按 0(dump 763609)。 // 出参:形如 "3.500" 的定点字符串(**不经 float 参与累加**:只做一次"×1000 取整"换算, // 之后全部由数据库按 decimal 语义比较,避免 3.4999999 之类的二进制尾差把边界行判掉)。 // // ⚠ **小数位数必须与列 scale 一致 = 3**(列是 `decimal(6,3)`,源 `float(10,3)`): // 这不是格式美观问题 —— 教龄区间靠 `BETWEEN exp_min AND exp_max` 判定, // 若列被收敛成 2 位,`0.999` 会存成 `1.00`,使 `[0.000,1.000]` 与 `[1.000,1.999]` **并列命中**, // 课酬按错档位算(D7 双跑必红)。P0 修复记录见 model.CoursePaymentDetail 的列注释。 func formatTeacherExp(exp float64) string { if math.IsNaN(exp) || exp < 0 { exp = 0 } if math.IsInf(exp, 0) || exp > 999 { // decimal(6,3) 上限 999.999;超界直接夹到上限(教龄超过一千年在业务上无意义) exp = 999 } thousandths := int64(math.Round(exp * 1000)) return fmt.Sprintf("%d.%03d", thousandths/1000, thousandths%1000) } // isNotFoundErr 判断错误是否为"记录不存在"(ErrNotFound / code 3001)。 // // 入参:err 待判定错误(可 nil)。出参:true=不存在。 func isNotFoundErr(err error) bool { if err == nil { return false } var appErr *infraerrors.AppError if stderrors.As(err, &appErr) { return appErr.Code == infraerrors.CodeNotFound } return false }