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

102 lines
5.4 KiB
Python

"""业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。"""
from sqlalchemy import select
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
Business,
BusinessRole,
CalcTemplateDefinition,
Customer,
CustomerRatingGrade,
Personnel,
Product,
Shipment,
)
from app.services.authorization_service import calculate
OPTIONS = {
"managers": (Personnel, ("id", "personnel_name", "business_role_id", "status"), (
"base:personnel:view", "base:personnel:create", "base:personnel:update",
)),
"rating_grades": (CustomerRatingGrade, ("id", "grade_name", "min_score", "max_score", "sort_order", "remark"), (
"base:customers:view", "base:customers:rating", "business:ratings:view",
)),
"personnel": (Personnel, ("id", "personnel_name", "business_role_id", "status"), (
"base:personnel:view", "base:customers:view", "base:customers:create", "base:customers:update",
"base:customers:ownership", "business:businesses:view", "business:businesses:create",
"business:businesses:update", "business:businesses:ownership", "payroll:prepare", "adjustments:create",
"payroll:view", "reports:personal:view", "reports:department:view", "reports:business:view", "reports:payments:view",
)),
"customers": (Customer, ("id", "company_name", "customer_type"), (
"base:customers:view", "business:businesses:view", "business:businesses:create", "business:businesses:update",
"reports:business:view",
)),
"products": (Product, ("id", "product_name", "spec_model", "unit"), (
"base:products:view", "business:businesses:create", "business:businesses:update",
"business:shipments:create", "business:shipments:update",
)),
"businesses": (Business, ("id", "business_no", "status"), (
"business:businesses:view", "business:shipments:view", "business:shipments:create",
"business:shipments:update", "business:receipts:view", "business:receipts:create", "business:receipts:update",
)),
"shipments": (Shipment, ("id", "shipment_no", "business_id", "ship_date"), (
"business:shipments:view", "business:receipts:view", "business:receipts:create", "business:receipts:update",
)),
"business_roles": (BusinessRole, ("id", "role_code", "role_name", "status"), (
"base:personnel:view", "base:personnel:create", "base:personnel:update", "system:business_roles:view",
"system:params:view", "reports:personal:view", "reports:department:view", "reports:business:view", "reports:payments:view",
)),
}
def payroll_options(session, actor, business_id):
"""核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。"""
from app.core.exceptions import NotFoundError
from app.models import ShipmentItem
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:view")
if session.get(Business, business_id) is None:
raise NotFoundError("业务不存在")
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201)))
if len(heads) > 200:
raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料")
people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()]
if len(people) > 200:
raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理")
lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings()
by_head = {head.id: head for head in heads}
return {"personnel": people, "shipments": [
{"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no,
"ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])}
for row in lines
]}
def options(session, actor, resource, *, business_id=None, search=None):
definition = OPTIONS.get(resource)
if definition is None:
raise BadRequestError("不支持的参照类型")
model, fields, callers = definition
if not set(calculate(session, actor)["permissions"]) & set(callers):
raise ForbiddenError("没有使用此参照数据的业务权限")
statement = select(*(getattr(model, f) for f in fields))
if resource == "managers":
statement = (
statement.join(BusinessRole, BusinessRole.id == Personnel.business_role_id)
.join(CalcTemplateDefinition, CalcTemplateDefinition.template_code == BusinessRole.calc_template)
.where(
Personnel.status == 1, BusinessRole.status == 1,
CalcTemplateDefinition.formula_type == "manager_share",
)
)
if business_id is not None and model == Shipment:
statement = statement.where(Shipment.business_id == business_id)
if search:
name = getattr(model, fields[1])
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
statement = statement.where(name.like(f"%{escaped}%", escape="\\"))
order = (model.sort_order, model.id) if model == CustomerRatingGrade else (model.id,)
rows = session.execute(statement.order_by(*order).limit(200)).mappings()
return [dict(row) for row in rows]