| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- // 课酬试算服务(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)返回空 ⇒ "配了规则却试算不出课酬"。
- //
- // 这是 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
- }
|