PayrollManagementSystem/backend/tests/unit/test_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

209 lines
8.2 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.

"""公式纯函数测试。
守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算,
360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。
断言一律用 Fraction/Decimal 精确值,禁近似比较。
"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
manager_team_share,
monthly_salary,
payment_days,
payment_weight,
performance_amount,
probation_annual_note,
profit_rate,
salesperson_rate,
start_payment_ok,
)
# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天)
SP_START = Fraction(10)
SP_SLOPE = Fraction(4, 360)
SP_FLOOR = Fraction(4)
SP_BOUNDARY = 360
def _sp_rate(days: int) -> Fraction:
return salesperson_rate(
days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY
)
class TestPaymentDays:
"""回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。"""
def test_发货日当天回款为第1天(self) -> None:
d = date(2026, 1, 1)
assert payment_days(d, d) == 1
def test_12月15日发货1月10日回款为27天(self) -> None:
assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27
def test_跨年按自然日连续累计(self) -> None:
assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2
class TestSalespersonRate:
"""业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。"""
def test_第360天恰为千分之6_线性段终点(self) -> None:
assert _sp_rate(360) == Fraction(6, 1000)
def test_第361天固定下限千分之4_分界线(self) -> None:
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
assert _sp_rate(361) == Fraction(4, 1000)
def test_第1天按公式为899比90000_非取整为10(self) -> None:
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
assert _sp_rate(1) == Fraction(899, 90000)
def test_第180天线性段中点千分之8(self) -> None:
assert _sp_rate(180) == Fraction(8, 1000)
def test_返回值为纯比例_非千分数(self) -> None:
# 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6
assert _sp_rate(360) == Fraction(6, 1000)
assert _sp_rate(360) != Fraction(6)
def test_负天数钳制为0天_按起始比例计提(self) -> None:
"""先款后货(payment_days 为负)不线性外插:钳到 0 天取起始比例。
修复前 days=-5 会算出 (10 + 5×4/360)‰ —— 高于起始值,多算绩效。
"""
assert _sp_rate(-5) == _sp_rate(0)
assert _sp_rate(-1) == Fraction(10, 1000)
class TestProfitRate:
"""毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。"""
def test_毛利润率_不含运费(self) -> None:
assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20)
def test_销售额为零_返回零_防除零(self) -> None:
assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0)
class TestConvertedProfit:
"""折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。"""
def test_化简式结果_市政水利30点阈值(self) -> None:
# 100 × (9/20 − 30/100) = 15
assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15")
def test_化简式与定义式等价(self) -> None:
# 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15
revenue, profit = Decimal("100"), Decimal("45")
rate = profit_rate(profit, revenue)
assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15")
def test_毛利润额为零_折算为零_不除零(self) -> None:
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
class TestPerformanceAmount:
"""绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。"""
def test_乘法方向_折算额乘比例(self) -> None:
# 15000 × 97/10000 = 145.5
assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5")
def test_负折算额_绩效为负不归零(self) -> None:
assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12")
class TestModifiers:
"""修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。"""
def test_以物抵顶_按80点折算(self) -> None:
assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4")
def test_支付权重_R为60时0点36(self) -> None:
assert payment_weight(60) == Fraction(36, 100)
def test_支付权重_R为100时全额(self) -> None:
assert payment_weight(100) == Fraction(1)
def test_起付判定_达到60点(self) -> None:
assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True
def test_起付判定_未达到60点(self) -> None:
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
class TestClampWeightR:
"""R 钳制:向下取整 + [0,100] 封顶封底(从预览内联提取的共用实现)。"""
def test_向下取整(self) -> None:
assert clamp_weight_r(Decimal("5999.99"), Decimal("10000")) == 59
def test_封顶100_封底0(self) -> None:
assert clamp_weight_r(Decimal("20000"), Decimal("10000")) == 100
assert clamp_weight_r(Decimal("0"), Decimal("10000")) == 0
def test_销售额0_防御0(self) -> None:
assert clamp_weight_r(Decimal("100"), Decimal("0")) == 0
class TestMonthlySalary:
"""月工资分岗口径三档(对照表 #8)。"""
def test_INCLUDE_绩效加基本(self) -> None:
assert monthly_salary(Decimal("1000"), Decimal("3000"), "INCLUDE") == Decimal("4000")
def test_DEDUCT_绩效减基本_可为负(self) -> None:
assert monthly_salary(Decimal("1000"), Decimal("3000"), "DEDUCT") == Decimal("-2000")
def test_EXCLUDE_仅绩效(self) -> None:
assert monthly_salary(Decimal("1000"), Decimal("3000"), "EXCLUDE") == Decimal("1000")
def test_基本缺失按0(self) -> None:
assert monthly_salary(Decimal("1000"), None, "INCLUDE") == Decimal("1000")
def test_未知口径报错(self) -> None:
import pytest
with pytest.raises(ValueError):
monthly_salary(Decimal("1"), Decimal("1"), "UNKNOWN")
class TestProbationAnnualNote:
"""试用期年累计说明:超阈值才有 note,金额精确到分。"""
def test_未超阈值_无说明(self) -> None:
assert probation_annual_note(Decimal("18000"), Decimal("18000")) is None
assert probation_annual_note(Decimal("100"), Decimal("18000")) is None
def test_超额_给出精确差额(self) -> None:
note = probation_annual_note(Decimal("20000.5"), Decimal("18000"))
assert note is not None and "2000.50" in note
class TestManagerTeamShare:
"""经理 12.5%+5% 聚合(#12 暂口径:基数不含经理个人户业绩——由调用方
传入的基数集合保证,本函数只做乘法与求和)。"""
def test_两段乘加_精确分数(self) -> None:
# 入参为百分数(与种子一致:12.5 表示 12.5%):10000×12.5%=1250、50000×5%=2500
got = manager_team_share(
Decimal("10000"), Decimal("50000"), team_pct=Fraction(25, 2), company_pct=Fraction(5)
)
assert got == Decimal("3750")
def test_零基数_零结果(self) -> None:
assert manager_team_share(
Decimal("0"), Decimal("0"), team_pct=Fraction(0), company_pct=Fraction(0)
) == Decimal("0")