|
@@ -1028,33 +1028,39 @@ func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *mode
|
|
|
return quote.DetailID
|
|
return quote.DetailID
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)。
|
|
|
|
|
|
|
+// recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)——
|
|
|
|
|
+// **D10 裁决后已补齐"按比例提成"**。
|
|
|
//
|
|
//
|
|
|
-// ⚠ **两处能力缺口(AC-11 端口签名所致,已登记偏差,不在本批改端口)**:
|
|
|
|
|
|
|
+// 单节课课酬的完整算法(dump `sp_course_payment_detail_get_teacher_detail` 口径):
|
|
|
//
|
|
//
|
|
|
-// 1. `RewardQuoteInput` **没有"本次课收入"字段** ⇒ 无法计算"按比例提成"部分
|
|
|
|
|
-// (真实口径是 `percentage × 课收入`,再按 `min/max` 截断后加 `reward`)。
|
|
|
|
|
-// 本适配返回方案里的 **`reward`(额外课酬)列**,即"固定课酬"型方案可用、纯比例型方案算不出。
|
|
|
|
|
-// 2. `RewardQuoteInput` **没有"教龄"字段** ⇒ 统一按 0 年(dump 对负数也是按 0 兜底,
|
|
|
|
|
-// 因此只影响"教龄 > 0 才命中的档位",不影响 0 年档)。
|
|
|
|
|
|
|
+// ① base = round(Income × percentage / 100) ← 比例部分(percentage 是**百分数**:"85.0" = 85%)
|
|
|
|
|
+// ② base = clamp(base, Min, Max) ← Min/Max 为 0 表示"不限制"
|
|
|
|
|
+// ③ total = base + Reward ← Reward 是"额外课酬"(在比例基础上追加)
|
|
|
//
|
|
//
|
|
|
-// 结论:本适配是**占位接线**,让端点 51 的课酬链路(试算 → 分发 → 落库 → 汇总校验)
|
|
|
|
|
-// 真正跑通并可断言;口径补齐需 AC-11 扩端口(加 `Income` / `TeacherExp`),
|
|
|
|
|
-// 那属于服务层改造,本批按纪律**不改**。
|
|
|
|
|
|
|
+// 三条实现红线:
|
|
|
|
|
+// - **全程整数分运算**(`money.Amount` 是分),不在任何一步引入 float;
|
|
|
|
|
+// - 比例用"千分之一"整数承载(`"85.0"` → `850`),`round` 用 `+500 后整除 1000`(四舍五入到分);
|
|
|
|
|
+// - `percentage` 列写坏(非数字、负数)→ **ErrDBError 整体回滚**,不静默按 0 算
|
|
|
|
|
+// ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。
|
|
|
|
|
+//
|
|
|
|
|
+// 已知口径缺口(**不是静默正确**):`TeacherExp` 目前恒 0(AC-11 未接教师档案的年数换算),
|
|
|
|
|
+// 因此**只会命中 `teacher_exp_min = 0` 的档位**;若某机构的规则全从 `teacher_exp_min > 0` 起,
|
|
|
|
|
+// 会走"无规则命中 ⇒ 3112 ⇒ 课酬 0.00"。补齐需接 `t_teacher_information.teacher_exp`(起算日 → 年数)。
|
|
|
type recordRewardQuoter struct {
|
|
type recordRewardQuoter struct {
|
|
|
svc *PayrollService
|
|
svc *PayrollService
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
|
|
// QuoteRecordReward 实现 `service.TeacherRewardQuoter`。
|
|
|
//
|
|
//
|
|
|
-// 入参:ctx;in 端口入参。出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬)。
|
|
|
|
|
|
|
+// 入参:ctx;in 端口入参(含 `Income` 比例基数、`TeacherExp` 教龄)。
|
|
|
|
|
+// 出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬);列写坏 → 5002 整体回滚。
|
|
|
func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
|
|
func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) {
|
|
|
if q.svc == nil {
|
|
if q.svc == nil {
|
|
|
return 0, nil
|
|
return 0, nil
|
|
|
}
|
|
}
|
|
|
quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
|
|
quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{
|
|
|
TeacherID: in.TeacherID,
|
|
TeacherID: in.TeacherID,
|
|
|
- TeacherExp: 0, // 端口无教龄,见类型注释
|
|
|
|
|
|
|
+ TeacherExp: in.TeacherExp,
|
|
|
InstitutionID: in.InstitutionID,
|
|
InstitutionID: in.InstitutionID,
|
|
|
CampusID: in.CampusID,
|
|
CampusID: in.CampusID,
|
|
|
ClassifyID: in.ClassifyID,
|
|
ClassifyID: in.ClassifyID,
|
|
@@ -1066,7 +1072,121 @@ func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuot
|
|
|
if quote == nil {
|
|
if quote == nil {
|
|
|
return 0, nil
|
|
return 0, nil
|
|
|
}
|
|
}
|
|
|
- return quote.Reward, nil
|
|
|
|
|
|
|
+ return computeRecordReward(quote, in.Income)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// computeRecordReward 由"命中的课酬规则 + 本次课收入"算出单节课课酬(`recordRewardQuoter` 的算法体)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入。
|
|
|
|
|
+// 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。
|
|
|
|
|
+func computeRecordReward(quote *PayrollQuote, income money.Amount) (money.Amount, error) {
|
|
|
|
|
+ if quote == nil {
|
|
|
|
|
+ return 0, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if income.IsNegative() {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
|
|
|
|
|
+ errString("service: 课酬试算的收入不能为负"))
|
|
|
|
|
+ }
|
|
|
|
|
+ base, perr := applyPercentage(quote.Percentage, income)
|
|
|
|
|
+ if perr != nil {
|
|
|
|
|
+ return 0, perr
|
|
|
|
|
+ }
|
|
|
|
|
+ // Min/Max 为 0 表示"不限制"(dump 语义),**不能**当成"下限 0/上限 0"去夹
|
|
|
|
|
+ if quote.Min.GreaterThan(0) && base.LessThan(quote.Min) {
|
|
|
|
|
+ base = quote.Min
|
|
|
|
|
+ }
|
|
|
|
|
+ if quote.Max.GreaterThan(0) && base.GreaterThan(quote.Max) {
|
|
|
|
|
+ base = quote.Max
|
|
|
|
|
+ }
|
|
|
|
|
+ total := base.Add(quote.Reward)
|
|
|
|
|
+ if total.IsNegative() {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)"))
|
|
|
|
|
+ }
|
|
|
|
|
+ return total, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100;
|
|
|
|
|
+// 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。
|
|
|
|
|
+const percentageDenom = 1000
|
|
|
|
|
+
|
|
|
|
|
+// applyPercentage 计算"收入 × 比例"的金额(整数分运算,四舍五入到分)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:pct 百分比字符串(`decimal(5,1)`,如 `"85.0"`;空串按 0);income 本次课收入(分)。
|
|
|
|
|
+// 出参:金额(分);`ErrDBError`(列写坏:非数字 / 多于 2 位小数 / 负数)。
|
|
|
|
|
+//
|
|
|
|
|
+// 算法:`pctMilli = 百分比 × 10`(`"85.0"` → 850),`base = round(income × pctMilli / 1000)`。
|
|
|
|
|
+// 为什么不用 `money.Parse` 再乘 float:比例列是 `decimal(5,1)`,用 float 会在 `×0.85` 这类
|
|
|
|
|
+// 运算里带进二进制尾差(如 0.1+0.2 级),资金口径不允许。
|
|
|
|
|
+func applyPercentage(pct string, income money.Amount) (money.Amount, error) {
|
|
|
|
|
+ text := strings.TrimSpace(pct)
|
|
|
|
|
+ if text == "" {
|
|
|
|
|
+ return 0, nil // 未配置比例:只算 min/max/reward 部分(dump 的 0 值语义)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.HasPrefix(text, "-") {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬比例不能为负 "+text))
|
|
|
|
|
+ }
|
|
|
|
|
+ intPart, fracPart, _ := strings.Cut(text, ".")
|
|
|
|
|
+ if intPart == "" {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬比例格式非法 "+text))
|
|
|
|
|
+ }
|
|
|
|
|
+ whole, perr := parseUintDigits(intPart)
|
|
|
|
|
+ if perr != nil {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬比例格式非法 "+text))
|
|
|
|
|
+ }
|
|
|
|
|
+ // 小数位:只取 1 位(列是 decimal(5,1));第 2 位 >=5 时进位
|
|
|
|
|
+ tenth := int64(0)
|
|
|
|
|
+ if fracPart != "" {
|
|
|
|
|
+ if len(fracPart) > 2 {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬比例小数位过多 "+text))
|
|
|
|
|
+ }
|
|
|
|
|
+ first, ferr := parseUintDigits(fracPart[:1])
|
|
|
|
|
+ if ferr != nil {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬比例格式非法 "+text))
|
|
|
|
|
+ }
|
|
|
|
|
+ tenth = first
|
|
|
|
|
+ if len(fracPart) == 2 {
|
|
|
|
|
+ second, serr := parseUintDigits(fracPart[1:])
|
|
|
|
|
+ if serr != nil {
|
|
|
|
|
+ return 0, infraerrors.WithCause(infraerrors.ErrDBError,
|
|
|
|
|
+ errString("service: 课酬比例格式非法 "+text))
|
|
|
|
|
+ }
|
|
|
|
|
+ if second >= 5 {
|
|
|
|
|
+ tenth++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ pctMilli := whole*10 + tenth
|
|
|
|
|
+ if pctMilli == 0 {
|
|
|
|
|
+ return 0, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ cents := income.Cents()
|
|
|
|
|
+ // 溢出兜底:decimal(12,2) 上限 ≈ 1e10 分,pctMilli ≤ 1000 ⇒ 乘积 ≤ 1e13,int64 安全
|
|
|
|
|
+ base := (cents*pctMilli + percentageDenom/2) / percentageDenom
|
|
|
|
|
+ return money.FromCents(base), nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// parseUintDigits 把纯数字串转 int64(比例解析用;非数字 → error)。
|
|
|
|
|
+func parseUintDigits(digits string) (int64, error) {
|
|
|
|
|
+ if digits == "" {
|
|
|
|
|
+ return 0, errString("empty")
|
|
|
|
|
+ }
|
|
|
|
|
+ var out int64
|
|
|
|
|
+ for _, ch := range digits {
|
|
|
|
|
+ if ch < '0' || ch > '9' {
|
|
|
|
|
+ return 0, errString("not a digit")
|
|
|
|
|
+ }
|
|
|
|
|
+ out = out*10 + int64(ch-'0')
|
|
|
|
|
+ if out > 1_000_000 { // 比例列 decimal(5,1) 不会超过这个量级
|
|
|
|
|
+ return 0, errString("too large")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|