feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
这个提交包含在:
1 父节点
02fb06e0af
当前提交
1461a8d113
149 个文件已更改
+8736
-368
没有与搜索条件相匹配的文件
@@ -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:
|
||||
|
||||
在新工单中引用
屏蔽一个用户