PayrollManagementSystem/backend/app/services/formula.py
liuwenxin a5c86640e2 feat(migration): V7 核算台账/月结/调整单四表与核算纯函数
- V7 四表:performance_ledgers(append-only 版本行,uk(receipt_id,calc_version)
  库级防重兜底)/monthly_settlements(月份 uk,通过即锁定终态)/
  settlement_items(按人固化可负工资,uk 三列)/adjustment_orders(TZ 单号)
- registry +1:approval.adjustment.enabled(19 项,BOOL 首组扩至三开关)
- formula 补核算纯函数:clamp_weight_r(从预览内联提取共用)/monthly_salary
  三口径/probation_annual_note/manager_team_share(#12 暂口径:基数不含
  经理个人户业绩,聚合集中一处答复后只改)
- 迁移 downgrade 只保留 drop_table+alter 回滚+种子行反向 DELETE;
  performance_ledgers 表名复数(test_models 红线断言)
- 断言同步:EXPECTED_TABLES 四新表/registry 19/BOOL 三开关/conftest
  清理四新表与 ParamValue 边界 22
- schema_sync+全量单测 460 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 16:32:32 +08:00

158 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""计提公式纯函数(无 IO、无会话)。
契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决):
- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60)
- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20)
- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在
分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位)
- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现)
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
def to_decimal(value: Fraction) -> Decimal:
"""Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。"""
return Decimal(value.numerator) / Decimal(value.denominator)
# 模块内沿用私有名;公开别名供 service 层金额换算复用
_to_decimal = to_decimal
def payment_days(ship_date: date, receipt_date: date) -> int:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
return (receipt_date - ship_date).days + 1
def salesperson_rate(
days: int,
*,
start: Fraction,
slope: Fraction,
floor: Fraction,
boundary_days: int,
) -> Fraction:
"""业务员计提比例(纯比例)。
start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
"""
days = max(days, 0)
if days > boundary_days:
return floor / 1000
return (start - days * slope) / 1000
def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction:
"""毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。
销售额为 0 时返回 0(防除零;折算额随之为 0)。
"""
if revenue == 0:
return Fraction(0)
return Fraction(profit) / Fraction(revenue)
def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal:
"""折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
业务确认负值正常计算、不归零。
"""
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
"""绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
return _to_decimal(Fraction(base) * rate)
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
return _to_decimal(Fraction(amount) * discount / 100)
def payment_weight(r: int) -> Fraction:
"""支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。
60 → 36/100;100 → 1(全额)。
"""
return Fraction(r, 100) * Fraction(r, 100)
def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool:
"""起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。"""
if revenue == 0:
return False
return Fraction(received) / Fraction(revenue) >= ratio / 100
def clamp_weight_r(received: Decimal, revenue: Decimal) -> int:
"""支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。
received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款)
接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。
"""
if revenue <= 0:
return 0
return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100)))
def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None:
"""月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。
- INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资)
- DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除)
- EXCLUDE:仅绩效,基本工资不参与
basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。
"""
basic_value = basic if basic is not None else Decimal("0")
if mode == "INCLUDE":
return performance + basic_value
if mode == "DEDUCT":
return performance - basic_value
if mode == "EXCLUDE":
return performance
raise ValueError(f"未知的月工资口径:{mode}")
def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None:
"""试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。
未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。
"""
if year_total <= threshold:
return None
excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP")
return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)"
def manager_team_share(
team_performance: Decimal,
company_performance: Decimal,
*,
team_pct: Fraction,
company_pct: Fraction,
) -> Decimal:
"""经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。
**#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人
名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账
rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。
团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和;
全公司口径:全部业务员(非经理岗)绩效行之和。
"""
return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal(
Fraction(company_performance) * company_pct / 100
)