PayrollManagementSystem/backend/app/services/coefficient_service.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

331 lines
13 KiB
Python
Raw Permalink 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.

"""系数表是参数版本的编辑视图,不保留第二套工资公式或独立系数数据。"""
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from io import BytesIO
from zipfile import ZipFile
from openpyxl import Workbook, load_workbook
from pydantic import ValidationError
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.schemas.coefficient import CoefficientRowIn
from app.schemas.param import ParamChangeIn, ParamChangeItemIn
from app.services import param_service
from app.services.param_registry import get_spec, validate_and_normalize
from app.services.rate_rules import decide, personal_values
from app.services.tplus.workbench import digest
CATEGORIES = ("municipal_water", "rail_transit")
HEADERS = ("product_category", "customer_type", "stage", "value", "slope", "boundary")
def _coefficient(rate):
return str(1 / rate) if rate else None
def _average_text(value):
if value is None:
return None
return format(
param_service.to_decimal(value).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP), "f"
)
def _revision(session, lock=False):
if session is None:
return []
from app.models import ParamChangeBatch
query = select(
ParamChangeBatch.id, ParamChangeBatch.effective_date, ParamChangeBatch.version_no
).order_by(ParamChangeBatch.id)
if lock:
query = query.with_for_update()
return [tuple(row) for row in session.execute(query)]
def catalog(session, as_of=None, revision=None):
day = as_of or date.today()
snap = param_service.resolve_snapshot(session, day)
rows = []
for category in CATEGORIES:
values = personal_values(snap, category)
params = {**values, "enterprise": snap.get("enterprise.rate", product_category=category)}
rates = [
decide(
params, category=category, customer_type="personal", role_code="salesperson", days=d
)[0]
for d in range(1, 361)
]
average = sum((1 / r for r in rates if r), Fraction(0)) / 360 if all(rates) else None
for stage, field, days in (
("first", "first", 1),
("linear", "start", 2),
("after", "floor", int(Fraction(values["boundary"])) + 1),
):
rate = decide(
params,
category=category,
customer_type="personal",
role_code="salesperson",
days=days,
)[0]
rows.append(
{
"product_category": category,
"customer_type": "personal",
"stage": stage,
"value": values[field],
"slope": values["slope"] if stage == "linear" else None,
"boundary": int(Fraction(values["boundary"])) if stage == "linear" else None,
"rate": str(rate),
"system_coefficient": _coefficient(rate),
"average_coefficient_360": _average_text(average),
}
)
rate = Fraction(params["enterprise"]) / 1000
rows.append(
{
"product_category": category,
"customer_type": "enterprise",
"stage": "fixed",
"value": params["enterprise"],
"slope": None,
"boundary": None,
"rate": str(rate),
"system_coefficient": _coefficient(rate),
"average_coefficient_360": _average_text(1 / rate if rate else None),
}
)
rows.append(
{
"product_category": category,
"customer_type": "manager_personal",
"stage": "excluded",
"value": "0",
"rate": "0",
"system_coefficient": None,
"average_coefficient_360": None,
"readonly": True,
}
)
return {
"as_of": str(day),
"rows": rows,
"fingerprint": digest(
{
"as_of": day,
"rows": rows,
"revision": _revision(session) if revision is None else revision,
}
),
"average_rule": "第1—360天每日系统系数的算术平均,展示保留6位小数;存在零计提日时不适用。经理个人户不单笔计提。",
}
def items_for(rows):
items = []
seen = set()
for row in rows:
key = (row.product_category, row.customer_type, row.stage)
if key in seen:
raise BadRequestError("存在重复的产品/户型/阶段")
seen.add(key)
if row.customer_type == "enterprise":
if row.stage != "fixed":
raise BadRequestError("企业户使用固定阶段")
fields = [("enterprise.rate", row.value)]
elif row.stage == "fixed":
raise BadRequestError("个人户请选择首日、线性或超期阶段")
elif row.stage == "linear":
if row.slope is None or row.boundary is None:
raise BadRequestError("线性阶段须填写日斜率和截止天数")
try:
endpoint = Fraction(row.value) - row.boundary * Fraction(row.slope)
except (ValueError, ZeroDivisionError) as exc:
raise BadRequestError("比例及日斜率须为有效数值或分数") from exc
if endpoint < 0:
raise BadRequestError("线性阶段末比例不能为负数")
fields = [
("coefficient.personal.start", row.value),
("coefficient.personal.slope", row.slope),
("coefficient.personal.boundary", str(row.boundary)),
]
else:
fields = [
(
"coefficient.personal.first"
if row.stage == "first"
else "coefficient.personal.floor",
row.value,
)
]
for code, value in fields:
normalized = validate_and_normalize(get_spec(code), value)
items.append(
ParamChangeItemIn(
param_code=code, dimension_value=row.product_category, value=normalized
)
)
return items
def save(session, payload, operator, meta=None):
from app.models import ParamDefinition
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
revision = _revision(session, lock=True)
if payload.fingerprint != catalog(session, payload.base_date, revision)["fingerprint"]:
raise BadRequestError("系数表已变化,请重新查询或重新导入预览")
return param_service.submit_change(
session,
ParamChangeIn(
change_type=2,
effective_date=payload.effective_date,
remark=payload.remark,
items=items_for(payload.rows),
),
operator=operator,
meta=meta,
)
def workbook(session, as_of=None):
data = catalog(session, as_of)
book = Workbook()
sheet = book.active
sheet.title = "系数参数"
sheet.append([*HEADERS, "system_coefficient", "average_coefficient_360"])
for row in data["rows"]:
if row.get("readonly"):
continue
sheet.append(
[row.get(k) for k in (*HEADERS, "system_coefficient", "average_coefficient_360")]
)
note = book.create_sheet("填写说明")
note.append(["基准日期", data["as_of"]])
for line in (
"value为‰数;线性阶段value为公式起点,slope为每日递减‰数,可填写分数。",
"产品:municipal_water市政水利/rail_transit轨道交通;户型personal个人户/enterprise企业户。",
"阶段:first首日/linear线性/after超期/fixed企业固定;经理个人户不单笔计提,不导入。",
data["average_rule"],
"系统系数及平均系数是只读派生列,导入时可留空,填写则必须与参数一致。",
):
note.append([line])
for column in sheet.columns:
sheet.column_dimensions[column[0].column_letter].width = 28
sheet.freeze_panes = "A2"
output = BytesIO()
book.save(output)
return output.getvalue()
def import_preview(session, content):
if len(content) > 2 * 1024 * 1024:
raise BadRequestError("系数文件不能超过2MB")
try:
with ZipFile(BytesIO(content)) as archive:
if sum(item.file_size for item in archive.infolist()) > 20 * 1024 * 1024:
raise BadRequestError("工作簿解压后超过20MB,请使用系数模板")
book = load_workbook(BytesIO(content), read_only=True, data_only=False)
sheet = book["系数参数"]
if sheet.max_row > 30 or sheet.max_column > 8:
raise BadRequestError("系数表最多16条数据")
values = list(sheet.iter_rows(values_only=True))
if tuple(values[0][:6]) != HEADERS:
raise BadRequestError("请使用系数表模板,保留前六列列名")
rows = []
errors = []
for number, value in enumerate(values[1:], 2):
if not any(v is not None for v in value):
continue
try:
raw = {
k: str(v) if v is not None else None
for k, v in zip(HEADERS, value[:6], strict=True)
}
raw["boundary"] = int(raw["boundary"]) if raw["boundary"] else None
row = CoefficientRowIn.model_validate(raw)
items_for([row])
rows.append(row)
except (ValidationError, ValueError, BadRequestError) as exc:
errors.append({"row": number, "message": str(exc)[:180]})
if not rows and not errors:
errors.append({"row": 2, "message": "文件没有可导入数据"})
if rows:
items_for(rows)
# 派生列只做校验,不作为第二套输入。用临时参数快照复算。
base = date.fromisoformat(str(book["填写说明"].cell(1, 2).value))
snap = param_service.resolve_snapshot(session, base)
temporary = {code: dict(dims) for code, dims in snap._values.items()}
for item in items_for(rows):
temporary.setdefault(item.param_code, {})[item.dimension_value] = item.value
candidate = param_service.ParamSnapshot(base, temporary, {})
for number, raw in enumerate(values[1:], 2):
if len(raw) < 7:
continue
matching = next(
(
r
for r in rows
if (r.product_category, r.customer_type, r.stage) == tuple(raw[:3])
),
None,
)
if matching is None:
continue
pv = personal_values(candidate, matching.product_category)
day = {"first": 1, "linear": 2, "after": int(Fraction(pv["boundary"])) + 1, "fixed": 1}[
matching.stage
]
rate = decide(
{
**pv,
"enterprise": candidate.get(
"enterprise.rate", product_category=matching.product_category
),
},
category=matching.product_category,
customer_type=matching.customer_type,
role_code="salesperson",
days=day,
)[0]
if raw[6] not in (None, "") and (rate == 0 or Fraction(str(raw[6])) != 1 / rate):
errors.append(
{"row": number, "message": "系统系数与公式参数不一致,请清空派生列或重新导出"}
)
if len(raw) > 7 and raw[7] not in (None, ""):
if matching.customer_type == "enterprise":
average = 1 / rate if rate else None
else:
daily = [
decide(
{**pv, "enterprise": "0"},
category=matching.product_category,
customer_type="personal",
role_code="salesperson",
days=d,
)[0]
for d in range(1, 361)
]
average = sum((1 / r for r in daily), Fraction(0)) / 360 if all(daily) else None
if average is None or Decimal(str(raw[7])) != Decimal(_average_text(average)):
errors.append({"row": number, "message": "360天平均系数与公式参数不一致"})
return {
"rows": [r.model_dump() for r in rows],
"errors": errors,
"can_apply": not errors,
"fingerprint": catalog(session, base)["fingerprint"],
"base_date": str(base),
}
except BadRequestError:
raise
except Exception as exc:
raise BadRequestError("无法读取系数表,请检查工作表名称和文件格式") from exc
finally:
if "book" in locals():
book.close()