PayrollManagementSystem/backend/tests/unit/test_requirement_completion.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

321 lines
12 KiB
Python

"""需求补齐的业务金额回归,覆盖配置实际消费而非页面存在性。"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
import pytest
from openpyxl import load_workbook
from app.core.exceptions import BadRequestError
from app.schemas.business import BusinessItemIn
from app.schemas.coefficient import CoefficientRowIn
from app.services import coefficient_service
from app.services.param_service import ParamSnapshot
from app.services.rate_rules import decide
from app.services.tplus.calculation_preview import _parameters
from tests.unit.test_formal_payroll import imported
from tests.unit.test_formal_payroll import payroll_case as payroll_case
from tests.unit.test_tplus_fifo import inputs, run
from tests.unit.test_tplus_workbench import work_session as work_session
def test_企业户分类及信息员金额(monkeypatch):
snapshot = ParamSnapshot.from_rows(
date(2026, 11, 2),
[
("enterprise.rate", "", "3", date(2026, 8, 13)),
("enterprise.rate", "municipal_water", "2", date(2026, 8, 13)),
("enterprise.rate", "rail_transit", "5", date(2026, 8, 13)),
],
)
monkeypatch.setattr(
"app.services.tplus.calculation_preview.param_service.resolve_snapshot",
lambda *args: snapshot,
)
params = _parameters(None, {"2026-11-02"})["2026-11-02"]
assert decide(
params,
category="municipal_water",
customer_type="enterprise",
role_code="salesperson",
days=1,
)[0] == Fraction(2, 1000)
assert decide(
params, category="rail_transit", customer_type="enterprise", role_code="salesperson", days=1
)[0] == Fraction(5, 1000)
group, ship, cash, facts, values = inputs()
ship.update(amount="10000", cost_total="5000", customer_type="enterprise")
cash["amount"] = "10000"
facts["personnel"]["001"]["role_code"] = "info_officer"
values["2026-03-10"]["info"] = "1"
result = run(group, [ship], [cash], facts, values)
assert result["lines"][0]["performance"] == "10.00"
assert result["lines"][0]["calculation_base"] == "10000.00"
def test_指导单位成本自动换算且冲突拒绝():
data = {
"product_name": "测试",
"product_category": "municipal_water",
"quantity": "10",
"sale_price": "100",
"guided_price": "50",
}
assert BusinessItemIn(**data).cost_amount == "500.00"
with pytest.raises(ValueError, match="不一致"):
BusinessItemIn(**data, cost_amount="501")
def test_系数工作簿往返与派生冲突(monkeypatch):
snapshot = ParamSnapshot.from_rows(date.today(), [])
monkeypatch.setattr(
coefficient_service.param_service, "resolve_snapshot", lambda *args: snapshot
)
content = coefficient_service.workbook(None)
preview = coefficient_service.import_preview(None, content)
assert preview["can_apply"], preview["errors"]
assert len(preview["rows"]) == 8
book = load_workbook(BytesIO(content))
book["系数参数"].cell(2, 7, "999999")
output = BytesIO()
book.save(output)
assert not coefficient_service.import_preview(None, output.getvalue())["can_apply"]
def test_非法系数整批拒绝():
row = CoefficientRowIn(
product_category="municipal_water",
customer_type="personal",
stage="linear",
value="1",
slope="1",
boundary=360,
)
with pytest.raises(BadRequestError):
coefficient_service.items_for([row])
def test_分界整数小数原文及预收优先级():
params = {"start": "10", "first": "11", "slope": "4/360", "floor": "4", "boundary": "360.0", "enterprise": "3"}
rate, _, _ = decide(params, category="municipal_water", customer_type="personal", role_code="salesperson", days=360)
assert rate == Fraction(6, 1000)
rate, _, _ = decide(params, category="municipal_water", customer_type="enterprise", role_code="salesperson", days=-8, prepayment=True)
assert rate == Fraction(11, 1000)
rate, _, _ = decide(params, category="municipal_water", customer_type="enterprise", role_code="sales_manager", days=1)
assert rate == 0
def test_基本工资仅计一次及试用期年度结清(payroll_case):
from sqlalchemy import select
from app.models import MonthlySettlement, PerformanceLedger, Personnel
from app.services import payroll_calculation, probation_service, salary_completion
session, user, _ = payroll_case
business = imported(payroll_case)
payroll_calculation.calculate(session, business.id, "2026-03", operator=user)
row = session.scalar(select(PerformanceLedger))
person = session.get(Personnel, row.personnel_id)
person.salary_standard = Decimal("3000")
person.probation_start_date = date(2026, 3, 1)
person.regularization_date = date(2027, 1, 1)
row.is_probation = 1
row.performance_amount = Decimal("20000")
row.payable_amount = Decimal("1000")
session.flush()
before, _ = salary_completion.build_items(session, [row], "2026-11")
item = next(i for i in before if i.personnel_id == person.id)
assert item.basic_amount == Decimal("3000")
assert item.net_amount == Decimal("3000")
assert item.payable_sum == 0
assert item.deferred_sum == Decimal("1000")
december, _ = salary_completion.build_items(session, [row], "2026-12")
item = next(i for i in december if i.personnel_id == person.id)
assert item.probation_payment_sum == Decimal("1000")
assert item.net_amount == Decimal("4000")
settlement = MonthlySettlement(
settlement_month="2026-12", status=3, submitter_id=user.id, submitter_name=user.username
)
session.add(settlement)
session.flush()
probation_service.persist_payments(session, settlement, december)
session.flush()
assert probation_service.preview(session, "2027-01")["rows"][0]["delta"] == "0.00"
row.payable_amount = Decimal("3000")
session.flush()
assert Decimal(probation_service.preview(session, "2027-01")["rows"][0]["delta"]) == Decimal(
"1000"
)
row.performance_amount = Decimal("18000")
session.flush()
assert Decimal(probation_service.preview(session, "2027-01")["rows"][0]["delta"]) == Decimal(
"-1000"
)
def test_无绩效在职人员有基本工资(payroll_case):
from sqlalchemy import select
from app.models import Personnel
from app.services import salary_completion
session, _, _ = payroll_case
person = session.scalar(select(Personnel).where(Personnel.source_no == "001"))
if person is None:
person = session.scalar(select(Personnel))
person.salary_standard = Decimal("3000")
person.is_probation = 1
session.flush()
items, ids = salary_completion.build_items(session, [], "2026-11")
item = next(i for i in items if i.personnel_id == person.id)
assert item.net_amount == Decimal("3000")
assert ids == []
def test_最近有效发货顺序与人工保护(work_session):
from app.models import Product
from app.services.product_completion import recent_trades
from app.services.product_service import _to_out
from tests.unit.test_tplus_workbench import source
source(
work_session,
"shipments",
[
{"id": 20, "code": "SH-20", "voucherdate": "2026-11-01", "voucherState": 189},
{"id": 21, "code": "SH-21", "voucherdate": "2026-11-01", "voucherState": 189},
{
"id": 22,
"code": "SH-22",
"voucherdate": "2026-11-02",
"voucherState": 189,
"isCounteractDelivery": 1,
},
],
)
source(
work_session,
"shipment_items",
[
{
"id": 1,
"source_parent_id": 20,
"idinventory": 9,
"quantity": "2",
"taxPrice": "12.1234",
},
{
"id": 2,
"source_parent_id": 21,
"idinventory": 9,
"quantity": "3",
"taxPrice": "22.1234",
},
{"id": 3, "source_parent_id": 22, "idinventory": 9, "quantity": "4", "taxPrice": "99"},
],
)
trade = recent_trades(work_session, "test_source", ["9"])["9"]
assert trade["source_no"] == "SH-21"
assert trade["quantity"] == "3"
product = Product(
product_name="测试来源",
spec_model="",
product_category="municipal_water",
source_trade_json=trade,
stock_quantity=Decimal("7"),
sale_price=Decimal("88"),
quantity_price_manual=1,
status=1,
)
work_session.add(product)
work_session.flush()
assert _to_out(product).stock_quantity == "7"
product.quantity_price_manual = 0
assert _to_out(product).sale_price == "22.1234"
def test_产品提取冲突不覆盖已有档案(payroll_case):
from sqlalchemy import select
from app.models import BusinessItem, Product
from app.services.product_completion import extraction_plan
session, _, _ = payroll_case
business = imported(payroll_case)
item = session.scalar(select(BusinessItem).where(BusinessItem.business_id == business.id))
target = session.get(Product, item.product_id)
item.product_id = None
target.unit = "不同单位"
session.flush()
plan = extraction_plan(session, [item.id])
assert plan["rows"][0]["action"] == "CONFLICT"
assert target.unit == "不同单位"
def test_历史工资标准及离职按目标日期解析(work_session):
from types import SimpleNamespace
from app.services import personnel_salary
person = SimpleNamespace(
salary_standard=Decimal("9000"),
business_role_id=1,
hire_date=date(2026, 1, 1),
resignation_date=date(2026, 10, 1),
probation_start_date=date(2026, 1, 1),
regularization_date=date(2026, 4, 1),
is_probation=0,
status=0,
salary_history_json=[
{
"effective_date": "2026-01-01",
"salary_standard": "3000",
"business_role_id": "1",
"hire_date": "2026-01-01",
"resignation_date": "2026-10-01",
"probation_start_date": "2026-01-01",
"regularization_date": "2026-04-01",
"is_probation": "1",
}
],
)
profile = personnel_salary.resolve(person, date(2026, 3, 31))
assert profile.salary_standard == Decimal("3000")
assert personnel_salary.employed(person, profile, date(2026, 3, 31))
assert not personnel_salary.employed(person, profile, date(2026, 10, 31))
def test_驳回的后续结清不阻塞早月且待审占用分列(payroll_case):
from sqlalchemy import select
from app.models import AdjustmentOrder, PerformanceLedger, ProbationPayment
from app.services import payroll_calculation, probation_service, salary_completion
session, user, _ = payroll_case
business = imported(payroll_case)
payroll_calculation.calculate(session, business.id, "2026-03", operator=user)
ledger = session.scalar(select(PerformanceLedger))
ledger.is_probation = 1
ledger.performance_amount = Decimal("20000")
ledger.payable_amount = Decimal("3000")
order = AdjustmentOrder(order_no="TZ-test-probation", personnel_id=ledger.personnel_id,
personnel_name=ledger.personnel_name, settlement_month="2027-02", amount=Decimal("1000"),
direction=1, reason="测试驳回释放", status=3, submitter_id=user.id, submitter_name=user.username)
session.add(order)
session.flush()
session.add(ProbationPayment(adjustment_order_id=order.id, personnel_id=ledger.personnel_id,
year="2026", month="2027-02", amount=Decimal("1000"), snapshot_json={}))
session.flush()
items, _ = salary_completion.build_items(session, [], "2027-01")
assert next(i for i in items if i.personnel_id == ledger.personnel_id).probation_payment_sum == Decimal("2000")
order.status = 1
session.flush()
data = probation_service.preview(session, "2027-02")["rows"][0]
assert Decimal(data["paid"]) == 0
assert Decimal(data["reserved"]) == Decimal("1000")
assert Decimal(data["delta"]) == Decimal("1000")
with pytest.raises(BadRequestError, match="后续月份"):
salary_completion.build_items(session, [], "2027-01")