feat(后端): 添加授权体系、系统监控与试用期计提

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

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

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
这个提交包含在:
liuwenxin 于 2026-11-03 11:20:55 +08:00 提交
1 父节点 02fb06e0af
当前提交 1461a8d113
149 个文件已更改
+8736 -368

没有与搜索条件相匹配的文件

+36 -44
查看文件
@@ -179,7 +179,8 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
default_value=definition.default_value,
value_min=definition.value_min,
value_max=definition.value_max,
description=definition.description,
# 说明文案随已确认规则更新,数值与生效日期仍从历史记录读取。
description=spec.description,
values=by_code.get(definition.param_code, []),
)
)
@@ -280,6 +281,11 @@ def submit_change(
if payload.change_type == 2 and effective < date.today():
raise BadRequestError("指定生效日期不得早于今日")
# 系数表与原参数页共用串行化入口,未来版本也不能绕过预览指纹。
from sqlalchemy import select
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
normalized = _normalize_items(session, payload.items)
@@ -545,20 +551,10 @@ class _PreviewPayment:
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
"""
payload = ctx.payload
take = ctx.take
if payload.customer_type == "enterprise":
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000
return rate, "企业户:不按回款天数,统一按固定比例计提", None
"""试算使用正式规则的优先级和基数,并记录真正使用的覆盖参数。"""
from app.services.rate_rules import decide, personal_values
payload, take = ctx.payload, ctx.take
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
@@ -566,36 +562,32 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
if role is None:
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template
if template == "TPL_MANAGER_SHARE":
raise BadRequestError(
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
)
if template == "TPL_INFO_SALES_FIXED":
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope")
floor = take("salesperson.rate.floor")
boundary = int(take("salesperson.days.boundary"))
rate = formula.salesperson_rate(
days, start=start, slope=slope, floor=floor, boundary_days=boundary
)
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
return (
rate,
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
None,
)
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
raise BadRequestError(
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
)
definition = calc_templates.resolve_definition(ctx.session, template)
prepayment = payload.receipt_date < payload.ship_date
if definition.formula_type == "manager_share" or payload.customer_type == "manager_personal":
return Fraction(0), "经理本人业务不单笔计提,团队分成请到月度工资查看", None
if definition.formula_type == "fixed_sales":
rate = take(definition.params.get("rate", "info.rate")) / 1000
return rate, "按岗位固定比例计提", Decimal(payload.revenue)
if payload.customer_type == "enterprise" and not prepayment:
rate = take("enterprise.rate") / 1000
return rate, "企业户按产品分类及到账日的固定比例计提", None
if definition.formula_type != "linear_decay" and not prepayment:
raise BadRequestError(f"「{definition.template_name}」不支持单笔试算,请检查岗位模板")
decay = definition.params
for code in (decay.get("start", "salesperson.rate.start"), decay.get("slope", "salesperson.rate.slope"), decay.get("floor", "salesperson.rate.floor"), decay.get("boundary", "salesperson.days.boundary")):
take(code)
snapshot = resolve_snapshot(ctx.session, payload.receipt_date)
category = payload.product_category or "municipal_water"
values = personal_values(snapshot, category)
for field in ("first", "start", "slope", "floor", "boundary"):
code = f"coefficient.personal.{field}"
dimensions = snapshot._values.get(code, {})
if category in dimensions or "" in dimensions:
take(code)
rate, _, rule = decide({**values, "enterprise": "0"}, category=category,
customer_type=payload.customer_type, role_code="salesperson", days=days, prepayment=prepayment)
return rate, rule, None
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision: