|
@@ -0,0 +1,410 @@
|
|
|
|
|
+package stage2
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "gorm.io/gorm"
|
|
|
|
|
+
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/service"
|
|
|
|
|
+ appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件是 **端点 59 试算的底层 `sp_course_payment_detail_get_teacher_detail` ↔
|
|
|
|
|
+// `PayrollService.QuoteTeacherReward`** 的结果集双跑。
|
|
|
|
|
+//
|
|
|
|
|
+// 取法(可复现):`go run ./cmd/spdump proc sp_course_payment_detail_get_teacher_detail`
|
|
|
|
|
+// 过程体要点(原文摘录):
|
|
|
|
|
+//
|
|
|
|
|
+// 游标:SELECT gradeid,subjectid,class_type FROM t_courses_classify WHERE id=inClassifyid
|
|
|
|
|
+// —— 按主键 ⇒ **最多 1 行**;分类不存在 ⇒ 循环不进 ⇒ 末尾 `WHERE id=0` 返回空结果集
|
|
|
|
|
+// IF inTeacherExp<0 THEN SET inTeacherExp = 0
|
|
|
|
|
+// 持证:SELECT (SELECT teacher_cert FROM t_teacher_information WHERE teacherid=inUserid)
|
|
|
|
|
+// 方案①:schoolid=inSchoolid AND campusid=inCampusid AND actived=1 AND effect_date<=inDate
|
|
|
|
|
+// ORDER BY effect_date DESC LIMIT 0,1;方案②:campusid=0(机构级回退)
|
|
|
|
|
+// 个人档闸门 COUNT>0 → ① classifyid=inClassifyid → ② gradeid IN (g,-1) AND subjectid IN (s,-1)
|
|
|
|
|
+// 持证通用档 ③ / 无证通用档 ④(都带 `inTeacherExp BETWEEN teacher_exp_min AND teacher_exp_max`)
|
|
|
|
|
+// 末尾:`SELECT * FROM t_course_payment_detail WHERE id=coursePaymentDetailid;` ← **整行 17 列**
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 与另外两支的根本差别:**target 只回 9 个派生字段,dump 回整行**。
|
|
|
|
|
+// 所以这条是**部分映射**:17 列里 6 列被认领,11 列显式豁免(每条都带理由,见 `rsTeacherDetailColumns`),
|
|
|
|
|
+// 而 target 独有的 3 个派生字段(schemeCampusId / schemeScope / ruleTier)单独对声明断言。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 已登记的有意差异(不在本 case 覆盖范围):dump 的三处 `IF coursePaymentDetailid>1` 是
|
|
|
|
|
+// 「≥2 才短路」,即 **id=1 的规则永远不生效**;Go 按 `>0`(找到即命中)。本 case 的规则 id=990002,
|
|
|
|
|
+// 两侧行为一致 —— 这条差异必须**另开 case 用 id=1 专门验**,见报告「未覆盖」。
|
|
|
|
|
+
|
|
|
|
|
+// 课酬种子的合成 ID(与用户/机构同一套:全新、两侧一致)。
|
|
|
|
|
+const (
|
|
|
|
|
+ rsPaySchemeID int64 = 990001
|
|
|
|
|
+ rsPayDetailID int64 = 990002
|
|
|
|
|
+ rsPayGeneralID int64 = 990003 // 通用档诱饵:若分级回退走错,会命中它
|
|
|
|
|
+ rsPayClassifyID int64 = 990201
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 课酬种子的**单一声明**(两侧同值)。
|
|
|
|
|
+type rsPayDetailSeed struct {
|
|
|
|
|
+ ID int64
|
|
|
|
|
+ TeacherID int64 // legacy int;target 侧换 ULID;0 = 通用规则
|
|
|
|
|
+ ClassifyID int64
|
|
|
|
|
+ GradeID int32
|
|
|
|
|
+ SubjectID int32
|
|
|
|
|
+ ClassType int32
|
|
|
|
|
+ Percentage string
|
|
|
|
|
+ MinPayment string
|
|
|
|
|
+ MaxPayment string
|
|
|
|
|
+ Reward string
|
|
|
|
|
+ TeacherCert int
|
|
|
|
|
+ ExpMin string
|
|
|
|
|
+ ExpMax string
|
|
|
|
|
+ Actived int
|
|
|
|
|
+ Name string
|
|
|
|
|
+ Memo string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// rsPayDetails 声明方案下的三条规则:
|
|
|
|
|
+//
|
|
|
|
|
+// 990002 个人·精确分类(teacherid=880001、classifyid=990201)→ **应命中这一条**
|
|
|
|
|
+// 990003 通用·无证(teacherid=0、teacher_cert=0、年级/科目通配)→ 诱饵
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么要有诱饵:只种一条命中规则时,"命中正确的那条"和"随便返回唯一一条"无法区分。
|
|
|
|
|
+// 诱饵的金额与百分比都不同,一旦分级回退走错,`id` / `percentage` / `min` 三列会同时红。
|
|
|
|
|
+//
|
|
|
|
|
+// 教龄区间:输入 3.5 年,个人档不看教龄,通用档 `[0.000, 10.000]` 覆盖 —— 两侧同构。
|
|
|
|
|
+func rsPayDetails() []rsPayDetailSeed {
|
|
|
|
|
+ return []rsPayDetailSeed{
|
|
|
|
|
+ {
|
|
|
|
|
+ ID: rsPayDetailID, TeacherID: rsUserID, ClassifyID: rsPayClassifyID,
|
|
|
|
|
+ GradeID: 7, SubjectID: 3, ClassType: 1000,
|
|
|
|
|
+ Percentage: "65.5", MinPayment: "100.00", MaxPayment: "500.00", Reward: "20.00",
|
|
|
|
|
+ TeacherCert: 0, ExpMin: "0.000", ExpMax: "10.000", Actived: 1,
|
|
|
|
|
+ Name: "RS 个人·精确分类", Memo: "rs-payroll-personal",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ ID: rsPayGeneralID, TeacherID: 0, ClassifyID: 0,
|
|
|
|
|
+ GradeID: -1, SubjectID: -1, ClassType: 1000,
|
|
|
|
|
+ Percentage: "30.0", MinPayment: "50.00", MaxPayment: "200.00", Reward: "5.00",
|
|
|
|
|
+ TeacherCert: 0, ExpMin: "0.000", ExpMax: "10.000", Actived: 1,
|
|
|
|
|
+ Name: "RS 通用·无证", Memo: "rs-payroll-general",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// rsPayTeacherExp 是入参教龄(年)。个人档不看它;通用档靠它进区间。
|
|
|
|
|
+const rsPayTeacherExp = 3.5
|
|
|
|
|
+
|
|
|
|
|
+// rsPayDate 是试算日期(方案 effect_date=2020-01-01 <= 它)。
|
|
|
|
|
+const rsPayDate = "2026-03-01"
|
|
|
|
|
+
|
|
|
|
|
+// seedLegacyTeacherDetail 种 legacy 侧(分类 + 方案 + 两条规则)。
|
|
|
|
|
+func seedLegacyTeacherDetail(ctx context.Context, tx *gorm.DB) error {
|
|
|
|
|
+ if err := tx.Exec(`DELETE FROM t_course_payment_detail WHERE id IN (?)`,
|
|
|
|
|
+ []int64{rsPayDetailID, rsPayGeneralID}).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("清理 legacy 课酬规则残留失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Exec(`DELETE FROM t_course_payment WHERE id = ?`, rsPaySchemeID).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("清理 legacy 课酬方案残留失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Exec(`DELETE FROM t_courses_classify WHERE id = ?`, rsPayClassifyID).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("清理 legacy 课程分类残留失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if err := requireRows("种 legacy 课程分类", tx.Exec(`
|
|
|
|
|
+INSERT INTO t_courses_classify
|
|
|
|
|
+ (id, schoolid, campusid, class_type, course_paymentid, gradeid, subjectid, name, unit_price, min_people, max_people)
|
|
|
|
|
+VALUES (?, ?, 0, 1000, ?, 7, 3, 'RS 分类', 100.000, 1, 1000)`,
|
|
|
|
|
+ rsPayClassifyID, rsInstID, rsPaySchemeID), 1); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := requireRows("种 legacy 课酬方案", tx.Exec(`
|
|
|
|
|
+INSERT INTO t_course_payment (id, schoolid, campusid, name, effect_date, actived, create_userid, create_time)
|
|
|
|
|
+VALUES (?, ?, ?, 'RS 方案', '2020-01-01 00:00:00', 1, 0, '2020-01-01 00:00:00')`,
|
|
|
|
|
+ rsPaySchemeID, rsInstID, rsCampusID), 1); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, d := range rsPayDetails() {
|
|
|
|
|
+ if err := requireRows(fmt.Sprintf("种 legacy 课酬规则 %d", d.ID), tx.Exec(`
|
|
|
|
|
+INSERT INTO t_course_payment_detail
|
|
|
|
|
+ (id, course_paymentid, gradeid, subjectid, class_type, classifyid, teacherid, name,
|
|
|
|
|
+ percentage, min_payment, max_payment, reward, teacher_cert, teacher_exp_min, teacher_exp_max, actived, memo)
|
|
|
|
|
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
+ d.ID, rsPaySchemeID, d.GradeID, d.SubjectID, d.ClassType, d.ClassifyID, d.TeacherID, d.Name,
|
|
|
|
|
+ d.Percentage, d.MinPayment, d.MaxPayment, d.Reward, d.TeacherCert, d.ExpMin, d.ExpMax, d.Actived, d.Memo), 1); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// seedTargetTeacherDetail 种 target 侧(列名是目标态命名;teacher_id 是 ULID)。
|
|
|
|
|
+func seedTargetTeacherDetail(ctx context.Context, tx *gorm.DB, uid string) error {
|
|
|
|
|
+ if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Exec(`DELETE FROM t_course_payment_detail WHERE id IN (?)`,
|
|
|
|
|
+ []int64{rsPayDetailID, rsPayGeneralID}).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("清理 target 课酬规则残留失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Exec(`DELETE FROM t_course_payment WHERE id = ?`, rsPaySchemeID).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("清理 target 课酬方案残留失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Exec(`DELETE FROM t_courses_classify WHERE id = ?`, rsPayClassifyID).Error; err != nil {
|
|
|
|
|
+ return fmt.Errorf("清理 target 课程分类残留失败:%w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if err := requireRows("种 target 课程分类", tx.Exec(`
|
|
|
|
|
+INSERT INTO t_courses_classify
|
|
|
|
|
+ (id, institution_id, campus_id, class_type, course_payment_id, grade_id, subject_id, name, unit_price, min_people, max_people)
|
|
|
|
|
+VALUES (?, ?, 0, 1000, ?, 7, 3, 'RS 分类', 100.00, 1, 1000)`,
|
|
|
|
|
+ rsPayClassifyID, rsInstID, rsPaySchemeID), 1); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := requireRows("种 target 课酬方案", tx.Exec(`
|
|
|
|
|
+INSERT INTO t_course_payment (id, institution_id, campus_id, name, effect_date, actived, create_user_id, create_time)
|
|
|
|
|
+VALUES (?, ?, ?, 'RS 方案', '2020-01-01 00:00:00', 1, NULL, '2020-01-01 00:00:00')`,
|
|
|
|
|
+ rsPaySchemeID, rsInstID, rsCampusID), 1); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, d := range rsPayDetails() {
|
|
|
|
|
+ var teacher any
|
|
|
|
|
+ if d.TeacherID != 0 {
|
|
|
|
|
+ teacher = uid
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := requireRows(fmt.Sprintf("种 target 课酬规则 %d", d.ID), tx.Exec(`
|
|
|
|
|
+INSERT INTO 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)
|
|
|
|
|
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
+ d.ID, rsPaySchemeID, d.GradeID, d.SubjectID, d.ClassType, d.ClassifyID, teacher, d.Name,
|
|
|
|
|
+ d.Percentage, d.MinPayment, d.MaxPayment, d.Reward, d.TeacherCert, d.ExpMin, d.ExpMax, d.Actived, d.Memo), 1); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// verifyLegacyTeacherDetailSeed / verifyTargetTeacherDetailSeed:读回规则条数与命中行。
|
|
|
|
|
+func verifyLegacyTeacherDetailSeed(ctx context.Context, tx *gorm.DB) (string, error) {
|
|
|
|
|
+ return verifyTeacherDetailSeed(ctx, tx, "legacy", false, "")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func verifyTargetTeacherDetailSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
|
|
|
|
|
+ return verifyTeacherDetailSeed(ctx, tx, "target", true, uid)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// verifyTeacherDetailSeed 读回:分类存在、方案生效、两条规则都在,且个人规则挂在正确的人身上。
|
|
|
|
|
+func verifyTeacherDetailSeed(
|
|
|
|
|
+ ctx context.Context, tx *gorm.DB, side string, target bool, uid string,
|
|
|
|
|
+) (string, error) {
|
|
|
|
|
+ detailTable := "t_course_payment_detail"
|
|
|
|
|
+ paymentTable := "t_course_payment"
|
|
|
|
|
+ classifyTable := "t_courses_classify"
|
|
|
|
|
+ paymentCond, classifyCond := "schoolid = ? AND campusid = ?", "schoolid = ? AND id = ?"
|
|
|
|
|
+ if target {
|
|
|
|
|
+ paymentCond, classifyCond = "institution_id = ? AND campus_id = ?", "institution_id = ? AND id = ?"
|
|
|
|
|
+ }
|
|
|
|
|
+ var n int
|
|
|
|
|
+ if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s`, paymentTable, paymentCond),
|
|
|
|
|
+ rsInstID, rsCampusID).Row().Scan(&n); err != nil {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 读回课酬方案失败:%w", side, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if n != 1 {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 本机构+校区下方案 %d 条,声明 1 条(其他方案会让 ORDER BY effect_date 选错)", side, n)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s`, classifyTable, classifyCond),
|
|
|
|
|
+ rsInstID, rsPayClassifyID).Row().Scan(&n); err != nil {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 读回课程分类失败:%w", side, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if n != 1 {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 课程分类 %d 条,声明 1 条", side, n)
|
|
|
|
|
+ }
|
|
|
|
|
+ var cnt int
|
|
|
|
|
+ if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id IN (?, ?)`, detailTable),
|
|
|
|
|
+ rsPayDetailID, rsPayGeneralID).Row().Scan(&cnt); err != nil {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 读回课酬规则失败:%w", side, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if cnt != len(rsPayDetails()) {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 课酬规则 %d 条,声明 %d 条", side, cnt, len(rsPayDetails()))
|
|
|
|
|
+ }
|
|
|
|
|
+ // 个人规则必须挂在"这个人"身上:target 侧是 ULID,legacy 是 int。
|
|
|
|
|
+ var gotTeacher string
|
|
|
|
|
+ ownerCol, ownerVal := "teacherid", any(rsUserID)
|
|
|
|
|
+ if target {
|
|
|
|
|
+ ownerCol, ownerVal = "teacher_id", any(uid)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Raw(fmt.Sprintf(`SELECT CAST(%s AS CHAR) FROM %s WHERE id = ?`, ownerCol, detailTable),
|
|
|
|
|
+ rsPayDetailID).Row().Scan(&gotTeacher); err != nil {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 读回个人规则的 teacher 归属失败:%w", side, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.TrimSpace(gotTeacher) != fmt.Sprint(ownerVal) {
|
|
|
|
|
+ return "", fmt.Errorf("[%s] 个人规则 teacher=%q,声明 %v", side, gotTeacher, ownerVal)
|
|
|
|
|
+ }
|
|
|
|
|
+ return fmt.Sprintf("方案 1 条 / 分类 1 条 / 规则 %d 条 / 个人规则归属=%s", cnt, gotTeacher), nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// rsTeacherDetailColumns 是 **17 列 → 6 认领 + 11 豁免** 的映射。
|
|
|
|
|
+//
|
|
|
|
|
+// 认领的 6 列(`BillSummary` 之外的 4 个业务列 + 2 个 id):
|
|
|
|
|
+//
|
|
|
|
|
+// id → PayrollQuote.DetailID
|
|
|
|
|
+// course_paymentid → PayrollQuote.SchemeID
|
|
|
|
|
+// percentage → PayrollQuote.Percentage(**非金额**:decimal(5,1) 的百分数,不能按钱归一)
|
|
|
|
|
+// min_payment → PayrollQuote.Min
|
|
|
|
|
+// max_payment → PayrollQuote.Max
|
|
|
|
|
+// reward → PayrollQuote.Reward
|
|
|
|
|
+//
|
|
|
|
|
+// 豁免的 11 列分三类(理由各不同,**不要合并成一句"target 没下发"**):
|
|
|
|
|
+//
|
|
|
|
|
+// A. 命中判据(gradeid / subjectid / class_type / classifyid / teacher_cert / teacher_exp_min /
|
|
|
|
|
+// teacher_exp_max / actived):它们**唯一的作用**是"决定哪条规则被命中"。
|
|
|
|
|
+// target 的契约把它们折成 `ruleTier`(命中层级)下发,逐列回传等于把内部判据暴露成业务字段。
|
|
|
|
|
+// 这不是"做不到",是**契约不打算下发** —— 所以豁免,并由 target 独有字段 `ruleTier` 承担可观测性。
|
|
|
|
|
+// B. 身份归属(teacherid):legacy 是 int、target 是 ULID,**两侧表示不同**,
|
|
|
|
|
+// 即使都下发也不能直接比(要经 `t_legacy_mapping` 换名),且 target 契约未下发。
|
|
|
|
|
+// C. 展示性文本(name / memo):契约未下发;比对它们对"课酬算得对不对"没有信息量。
|
|
|
|
|
+func rsTeacherDetailColumns() []ResultColumn {
|
|
|
|
|
+ return []ResultColumn{
|
|
|
|
|
+ {Legacy: "id", Target: "DetailID", Kind: CellInt},
|
|
|
|
|
+ {Legacy: "course_paymentid", Target: "SchemeID", Kind: CellInt},
|
|
|
|
|
+ {Legacy: "percentage", Target: "Percentage", Kind: CellText},
|
|
|
|
|
+ {Legacy: "min_payment", Target: "Min", Kind: CellMoney},
|
|
|
|
|
+ {Legacy: "max_payment", Target: "Max", Kind: CellMoney},
|
|
|
|
|
+ {Legacy: "reward", Target: "Reward", Kind: CellMoney},
|
|
|
|
|
+ {Legacy: "classifyid", Exempt: true, Reason: "命中判据(个人档①的等值条件);target 契约折成 ruleTier 下发,不下发本列"},
|
|
|
|
|
+ {Legacy: "gradeid", Exempt: true, Reason: "命中判据(个人档②/通用档的 IN(g,-1));target 契约不下发"},
|
|
|
|
|
+ {Legacy: "subjectid", Exempt: true, Reason: "命中判据(同上);target 契约不下发"},
|
|
|
|
|
+ {Legacy: "class_type", Exempt: true, Reason: "命中判据(三档都按班型过滤);target 契约不下发"},
|
|
|
|
|
+ {Legacy: "teacherid", Exempt: true, Reason: "身份归属:legacy int vs target ULID(表示不同,需经映射换名),且契约不下发"},
|
|
|
|
|
+ {Legacy: "teacher_cert", Exempt: true, Reason: "命中判据(决定是否进持证通用档);target 契约不下发"},
|
|
|
|
|
+ {Legacy: "teacher_exp_min", Exempt: true, Reason: "命中判据(BETWEEN 下界);target 契约不下发"},
|
|
|
|
|
+ {Legacy: "teacher_exp_max", Exempt: true, Reason: "命中判据(BETWEEN 上界);target 契约不下发"},
|
|
|
|
|
+ {Legacy: "actived", Exempt: true, Reason: "命中判据(=1 才参与匹配);target 契约不下发"},
|
|
|
|
|
+ {Legacy: "name", Exempt: true, Reason: "展示性文本(计算方式名称),契约不下发;对「算得对不对」无信息量"},
|
|
|
|
|
+ {Legacy: "memo", Exempt: true, Reason: "展示性文本(备注),契约不下发;同上"},
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// rsTeacherDetailCase 组装课酬详情 case。
|
|
|
|
|
+func rsTeacherDetailCase() ResultSetCase {
|
|
|
|
|
+ return ResultSetCase{
|
|
|
|
|
+ Name: "RS-3 课酬明细 sp_course_payment_detail_get_teacher_detail(整行 / 部分映射)",
|
|
|
|
|
+ Note: "dump 回 17 列、target 只回 9 个派生字段 ⇒ 6 列认领 + 11 列显式豁免(逐条带理由);" +
|
|
|
|
|
+ "种了一条通用档诱饵,分级回退走错会同时红 3 列",
|
|
|
|
|
+ LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
|
|
|
|
|
+ SeedLegacy: seedLegacyTeacherDetail,
|
|
|
|
|
+ SeedTarget: seedTargetTeacherDetail,
|
|
|
|
|
+ VerifyLegacy: verifyLegacyTeacherDetailSeed,
|
|
|
|
|
+ VerifyTarget: verifyTargetTeacherDetailSeed,
|
|
|
|
|
+ LegacyCall: "CALL sp_course_payment_detail_get_teacher_detail(?, ?, ?, ?, ?, ?)",
|
|
|
|
|
+ LegacyArgs: []any{rsUserID, rsPayTeacherExp, rsInstID, rsCampusID, rsPayClassifyID, rsPayDate},
|
|
|
|
|
+ CallTarget: func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error) {
|
|
|
|
|
+ svc := service.NewPayrollService(tx, sc)
|
|
|
|
|
+ q, err := svc.QuoteTeacherReward(ctx, service.QuotePayrollInput{
|
|
|
|
|
+ TeacherID: uid, TeacherExp: rsPayTeacherExp,
|
|
|
|
|
+ InstitutionID: rsInstID, CampusID: rsCampusID,
|
|
|
|
|
+ ClassifyID: rsPayClassifyID, Date: mustParseDate(rsPayDate),
|
|
|
|
|
+ })
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return TargetPayload{}, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return TargetPayload{
|
|
|
|
|
+ Label: "PayrollService.QuoteTeacherReward",
|
|
|
|
|
+ Values: map[string]Cell{
|
|
|
|
|
+ "DetailID": IntCell(q.DetailID),
|
|
|
|
|
+ "SchemeID": IntCell(q.SchemeID),
|
|
|
|
|
+ "SchemeCampusID": IntCell(q.SchemeCampusID),
|
|
|
|
|
+ "SchemeScope": TextCell(q.SchemeScope),
|
|
|
|
|
+ "RuleTier": TextCell(q.RuleTier),
|
|
|
|
|
+ "Percentage": TextCell(q.Percentage),
|
|
|
|
|
+ "Min": TextCell(q.Min.String()),
|
|
|
|
|
+ "Max": TextCell(q.Max.String()),
|
|
|
|
|
+ "Reward": TextCell(q.Reward.String()),
|
|
|
|
|
+ },
|
|
|
|
|
+ Evidence: fmt.Sprintf("detailId=%d schemeId=%d scope=%s tier=%s",
|
|
|
|
|
+ q.DetailID, q.SchemeID, q.SchemeScope, q.RuleTier),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ },
|
|
|
|
|
+ Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
|
|
|
|
|
+ personal := rsPayDetails()[0]
|
|
|
|
|
+ return ResultSetInput{
|
|
|
|
|
+ CaseName: "RS-3",
|
|
|
|
|
+ Legacy: lg,
|
|
|
|
|
+ ExpectRows: 1,
|
|
|
|
|
+ Target: tg,
|
|
|
|
|
+ Columns: rsTeacherDetailColumns(),
|
|
|
|
|
+ Expect: map[string]string{
|
|
|
|
|
+ "id": fmt.Sprintf("%d", personal.ID),
|
|
|
|
|
+ "course_paymentid": fmt.Sprintf("%d", rsPaySchemeID),
|
|
|
|
|
+ "percentage": personal.Percentage,
|
|
|
|
|
+ "min_payment": personal.MinPayment,
|
|
|
|
|
+ "max_payment": personal.MaxPayment,
|
|
|
|
|
+ "reward": personal.Reward,
|
|
|
|
|
+ },
|
|
|
|
|
+ // 不启用 PrecisionCheck:dump 这四列是 `float(10,1)`(1 位小数),target 是
|
|
|
|
|
+ // `decimal(12,2)` —— 这是**扩大**精度,不是 3→2 收敛。理由单列在 ExtraAssertions。
|
|
|
|
|
+ PrecisionCheck: false,
|
|
|
|
|
+ TargetOnly: []TargetOnlyColumn{
|
|
|
|
|
+ {Target: "SchemeCampusID", Kind: CellInt, Expect: IntCell(rsCampusID),
|
|
|
|
|
+ Note: "target 独有:命中方案所属校区(dump 整行里没有这列,只能对声明断言)"},
|
|
|
|
|
+ {Target: "SchemeScope", Kind: CellText, Expect: TextCell(service.SchemeScopeCampus),
|
|
|
|
|
+ Note: "target 独有:方案层级 = campus(本 case 的方案挂在本校区,不走 campus_id=0 回退)"},
|
|
|
|
|
+ {Target: "RuleTier", Kind: CellText, Expect: TextCell(service.RuleTierPersonalClassify),
|
|
|
|
|
+ Note: "target 独有:规则层级 = personal_classify(个人档①精确分类);" +
|
|
|
|
|
+ "这条断言是**分级回退顺序**的可观测形式"},
|
|
|
|
|
+ },
|
|
|
|
|
+ ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
|
|
|
|
|
+ // 精度口径(与 bill 不同,必须分开写):dump 是 float(10,1),target 是 decimal(12,2)。
|
|
|
|
|
+ var floats []string
|
|
|
|
|
+ for _, col := range []string{"min_payment", "max_payment", "reward"} {
|
|
|
|
|
+ if c, ok := RawCellOf(lg, 0, col); ok && !c.Null {
|
|
|
|
|
+ floats = append(floats, col+"="+c.Raw)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ var pct string
|
|
|
|
|
+ if c, ok := RawCellOf(lg, 0, "percentage"); ok {
|
|
|
|
|
+ pct = c.Raw
|
|
|
|
|
+ }
|
|
|
|
|
+ exact := true
|
|
|
|
|
+ for _, s := range floats {
|
|
|
|
|
+ raw := strings.SplitN(s, "=", 2)[1]
|
|
|
|
|
+ if c, err := centsOf(raw); err != nil || float64(c)/100 != mustFloat(raw) {
|
|
|
|
|
+ exact = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return []Assertion{
|
|
|
|
|
+ {
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "精度口径:dump 三列金额是 float(10,1)(1 位小数)→ target decimal(12,2),方向是**扩位**而非收敛",
|
|
|
|
|
+ Pass: len(floats) == 3 && exact,
|
|
|
|
|
+ Detail: fmt.Sprintf("dump raw %v | percentage raw=%q(**非金额**,按文本比,不参与金额归一)。"+
|
|
|
|
|
+ "本种子取值都在 1 位小数内,扩到 2 位无损;反之若把 target 收敛回 1 位,`reward=20.05` 这类值会被静默抹掉", floats, pct),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ Kind: AssertionBusiness,
|
|
|
|
|
+ Name: "分级回退:命中的是**个人·精确分类**那条(不是通用诱饵)",
|
|
|
|
|
+ Pass: mustCell(lg, 0, "id").Raw == fmt.Sprintf("%d", rsPayDetailID),
|
|
|
|
|
+ Detail: fmt.Sprintf("dump id=%s | 个人规则 %d | 通用诱饵 %d(诱饵金额 %s/%s/%s,命中它会同时红 3 列)",
|
|
|
|
|
+ mustCell(lg, 0, "id").Raw, rsPayDetailID, rsPayGeneralID,
|
|
|
|
|
+ rsPayDetails()[1].MinPayment, rsPayDetails()[1].MaxPayment, rsPayDetails()[1].Reward),
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// mustParseDate 解析 `YYYY-MM-DD`(试算日期;时钟清零,与 `FindActiveScheme` 的按日截断同口径)。
|
|
|
|
|
+func mustParseDate(s string) time.Time { return mustParse(s + " 00:00:00") }
|
|
|
|
|
+
|
|
|
|
|
+// mustFloat 把金额字面量转成 float64(只用于"1 位小数是否被扩展无损"的判定)。
|
|
|
|
|
+func mustFloat(s string) float64 {
|
|
|
|
|
+ f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return -1
|
|
|
|
|
+ }
|
|
|
|
|
+ return f
|
|
|
|
|
+}
|