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

196 lines
6.8 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.

"""计提公式模板管理服务。
页面可维护:名称、说明、参数绑定(受骨架角色约束);公式骨架是受控枚举,
创建后锁定,避免自由表达式注入与执行错位。系统模板随迁移种子初始化,
禁删且骨架/编码/参数锁定;自定义模板无岗位引用才可删除。
"""
from sqlalchemy.exc import IntegrityError
from app.core.constants import ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import CalcTemplateDefinition, User
from app.repositories import calc_template_repository
from app.schemas.calc_template import (
CalcTemplateCreateIn,
CalcTemplateOut,
CalcTemplateUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.calc_templates import FORMULAS, get_formula
from app.services.param_registry import PARAM_REGISTRY
MODULE_CALC_TEMPLATE = "公式模板"
def _to_out(row: CalcTemplateDefinition, role_count: int = 0) -> CalcTemplateOut:
return CalcTemplateOut(
id=row.id,
template_code=row.template_code,
template_name=row.template_name,
formula_type=row.formula_type,
params=dict(row.params_json or {}),
description=row.description,
sort_order=row.sort_order,
status=row.status,
is_system=row.is_system,
role_count=role_count,
)
def _validate_params(formula_type: str, params: dict[str, str]) -> dict[str, str]:
"""骨架角色必须全覆盖且不引入多余角色;目标参数必须已注册。"""
spec = get_formula(formula_type)
expected = set(spec.roles)
provided = set(params)
if provided != expected:
missing = sorted(expected - provided)
extra = sorted(provided - expected)
details = []
if missing:
details.append("缺少参数角色:" + "、".join(missing))
if extra:
details.append("多余参数角色:" + "、".join(extra))
raise BadRequestError(";".join(details))
unknown = sorted(code for code in params.values() if code not in PARAM_REGISTRY)
if unknown:
raise BadRequestError("参数编码未注册:" + "、".join(unknown))
return {role: params[role] for role in spec.roles}
def list_templates(session) -> PageResult[CalcTemplateOut]:
"""模板列表(含停用与引用计数,供管理页)。"""
rows = calc_template_repository.list_all(session)
counts = calc_template_repository.count_roles_by_codes(session, [r.template_code for r in rows])
return PageResult[CalcTemplateOut](
records=[_to_out(row, counts.get(row.template_code, 0)) for row in rows],
total=len(rows),
page_num=1,
page_size=max(len(rows), 1),
)
def list_formula_options() -> list[dict]:
"""骨架下拉(受控集合)。"""
return [
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)}
for spec in FORMULAS.values()
]
def create_template(
session,
payload: CalcTemplateCreateIn,
*,
operator: User,
meta: RequestMeta,
) -> CalcTemplateOut:
"""新增模板。编码唯一;骨架受控;参数绑定按骨架角色校验。"""
if calc_template_repository.get_by_code(session, payload.template_code) is not None:
raise BadRequestError(f"模板编码 {payload.template_code} 已存在")
try:
get_formula(payload.formula_type)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
params = _validate_params(payload.formula_type, payload.params)
row = CalcTemplateDefinition(
template_code=payload.template_code,
template_name=payload.template_name,
formula_type=payload.formula_type,
params_json=params,
description=payload.description,
sort_order=payload.sort_order,
status=1,
is_system=0,
)
try:
calc_template_repository.create(session, row)
session.commit()
except IntegrityError as exc:
session.rollback()
raise BadRequestError(f"模板编码 {payload.template_code} 已存在") from exc
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC_TEMPLATE,
action=ACTION_CREATE,
target=payload.template_code,
summary=f"新增计提公式模板 {payload.template_name}",
detail={"formula_type": payload.formula_type, "params": params},
),
meta=meta,
):
pass
return _to_out(row)
def update_template(
session,
template_id: int,
payload: CalcTemplateUpdateIn,
*,
operator: User,
meta: RequestMeta,
) -> CalcTemplateOut:
"""编辑模板。系统模板参数锁定;自定义模板参数仍按骨架角色校验。"""
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
if row is None:
raise NotFoundError("模板不存在")
params = dict(row.params_json or {})
if row.is_system != 1:
params = _validate_params(row.formula_type, payload.params)
row.template_name = payload.template_name
row.description = payload.description
row.sort_order = payload.sort_order
row.params_json = params
session.commit()
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC_TEMPLATE,
action=ACTION_UPDATE,
target=row.template_code,
summary=f"修改计提公式模板 {row.template_name}",
detail={"params": params},
),
meta=meta,
):
pass
return _to_out(row)
def delete_template(
session,
template_id: int,
*,
operator: User,
meta: RequestMeta,
) -> None:
"""删除模板。系统模板禁删;被岗位引用禁删。"""
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
if row is None:
raise NotFoundError("模板不存在")
if row.is_system == 1:
raise ForbiddenError("系统内置模板不可删除")
counts = calc_template_repository.count_roles_by_codes(session, [row.template_code])
if counts.get(row.template_code):
raise BadRequestError("模板已被岗位引用,不能删除;可先调整岗位模板后再删除")
calc_template_repository.delete(session, row)
session.commit()
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC_TEMPLATE,
action=ACTION_DELETE,
target=row.template_code,
summary=f"删除计提公式模板 {row.template_name}",
),
meta=meta,
):
pass