按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""业务岗位角色管理业务编排。
|
||
|
||
需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表),
|
||
新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**):
|
||
|
||
- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** ——
|
||
参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行)
|
||
- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value)
|
||
与模板绑定语义的锚点
|
||
- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码
|
||
- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效,
|
||
当前钩子恒为空(见 business_role_repository.count_personnel_by_role)
|
||
"""
|
||
|
||
import logging
|
||
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.constants import (
|
||
ACTION_CREATE,
|
||
ACTION_DELETE,
|
||
ACTION_DISABLE,
|
||
ACTION_ENABLE,
|
||
ACTION_UPDATE,
|
||
MODULE_BUSINESS_ROLE,
|
||
)
|
||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||
from app.core.response import PageResult
|
||
from app.models import BusinessRole, User
|
||
from app.repositories import business_role_repository, calc_template_repository
|
||
from app.schemas.business_role import (
|
||
BusinessRoleCreateIn,
|
||
BusinessRoleOptionOut,
|
||
BusinessRoleOut,
|
||
BusinessRoleQuery,
|
||
BusinessRoleUpdateIn,
|
||
TemplateOptionOut,
|
||
TemplateParamOut,
|
||
)
|
||
from app.services import audit_service
|
||
from app.services.audit_service import AuditRecord, RequestMeta
|
||
from app.services.calc_templates import TEMPLATES
|
||
from app.services.param_registry import PARAM_REGISTRY
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _to_out(role: BusinessRole) -> BusinessRoleOut:
|
||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||
return BusinessRoleOut(
|
||
id=role.id,
|
||
role_code=role.role_code,
|
||
role_name=role.role_name,
|
||
calc_template=role.calc_template,
|
||
description=role.description,
|
||
sort_order=role.sort_order,
|
||
status=role.status,
|
||
is_builtin=role.is_builtin,
|
||
)
|
||
|
||
|
||
def _ensure_template(session: Session, calc_template: str) -> None:
|
||
"""模板必须存在于模板定义;迁移前旧库回退内置注册表兜底。"""
|
||
if calc_template_repository.get_by_code(session, calc_template) is not None:
|
||
return
|
||
if calc_template in TEMPLATES:
|
||
return
|
||
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
|
||
|
||
|
||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||
"""分页查询岗位(含停用)。"""
|
||
roles = business_role_repository.list_roles(session, query)
|
||
total = business_role_repository.count_roles(session, query)
|
||
return PageResult[BusinessRoleOut](
|
||
records=[_to_out(role) for role in roles],
|
||
total=total,
|
||
page_num=query.page_num,
|
||
page_size=query.page_size,
|
||
)
|
||
|
||
|
||
def list_options(session: Session) -> list[BusinessRoleOptionOut]:
|
||
"""岗位下拉选项(含停用,不分页)。
|
||
|
||
含停用是因为人员档案可能仍引用停用岗位(停用只是禁止新增引用,
|
||
不改历史归属);岗位规模有限(内置 + 自定义),一次取全量。
|
||
"""
|
||
return [
|
||
BusinessRoleOptionOut(
|
||
id=role.id,
|
||
role_code=role.role_code,
|
||
role_name=role.role_name,
|
||
status=role.status,
|
||
)
|
||
for role in business_role_repository.list_all(session)
|
||
]
|
||
|
||
|
||
def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||
"""岗位详情。
|
||
|
||
:raises NotFoundError: 岗位不存在
|
||
"""
|
||
role = business_role_repository.get_by_id(session, role_id)
|
||
if role is None:
|
||
raise NotFoundError("岗位不存在")
|
||
return _to_out(role)
|
||
|
||
|
||
def list_template_options(session: Session) -> list[TemplateOptionOut]:
|
||
"""公式模板下拉:数据库定义优先;迁移前旧库回退内置注册表。
|
||
|
||
含停用模板(历史岗位仍绑定);新增岗位表单按 status 过滤启用项。
|
||
"""
|
||
options: list[TemplateOptionOut] = []
|
||
rows = calc_template_repository.list_all(session)
|
||
seen: set[str] = set()
|
||
for row in rows:
|
||
seen.add(row.template_code)
|
||
options.append(
|
||
TemplateOptionOut(
|
||
code=row.template_code,
|
||
name=row.template_name,
|
||
description=row.description or "",
|
||
params=[
|
||
TemplateParamOut(code=param_code, name=PARAM_REGISTRY[param_code].name)
|
||
for param_code in (row.params_json or {}).values()
|
||
if param_code in PARAM_REGISTRY
|
||
],
|
||
)
|
||
)
|
||
for spec in TEMPLATES.values():
|
||
if spec.code in seen:
|
||
continue
|
||
options.append(
|
||
TemplateOptionOut(
|
||
code=spec.code,
|
||
name=spec.name,
|
||
description=spec.description,
|
||
params=[
|
||
TemplateParamOut(code=param_code, name=PARAM_REGISTRY[param_code].name)
|
||
for param_code in spec.params.values()
|
||
if param_code in PARAM_REGISTRY
|
||
],
|
||
)
|
||
)
|
||
return options
|
||
|
||
|
||
def create_role(
|
||
session: Session,
|
||
payload: BusinessRoleCreateIn,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> BusinessRoleOut:
|
||
"""新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。
|
||
|
||
:raises BadRequestError: 编码已存在 / 模板不存在
|
||
"""
|
||
_ensure_template(session, payload.calc_template)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_BUSINESS_ROLE,
|
||
action=ACTION_CREATE,
|
||
summary=f"新增岗位 {payload.role_name}({payload.role_code})",
|
||
request_data=payload.model_dump(exclude_none=True),
|
||
),
|
||
meta=meta,
|
||
):
|
||
if business_role_repository.get_by_code(session, payload.role_code) is not None:
|
||
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在")
|
||
|
||
role = BusinessRole(
|
||
role_code=payload.role_code,
|
||
role_name=payload.role_name,
|
||
calc_template=payload.calc_template,
|
||
description=payload.description,
|
||
sort_order=payload.sort_order,
|
||
status=1,
|
||
is_builtin=0,
|
||
)
|
||
try:
|
||
business_role_repository.create(session, role)
|
||
out = _to_out(role)
|
||
session.commit()
|
||
except IntegrityError as exc:
|
||
# 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code
|
||
session.rollback()
|
||
raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc
|
||
|
||
logger.info(
|
||
"新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template
|
||
)
|
||
return out
|
||
|
||
|
||
def update_role(
|
||
session: Session,
|
||
role_id: int,
|
||
payload: BusinessRoleUpdateIn,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> BusinessRoleOut:
|
||
"""编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。
|
||
|
||
:raises NotFoundError: 岗位不存在
|
||
:raises BadRequestError: 模板不存在
|
||
"""
|
||
role = business_role_repository.get_by_id(session, role_id)
|
||
_ensure_template(session, payload.calc_template)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_BUSINESS_ROLE,
|
||
action=ACTION_UPDATE,
|
||
summary=f"修改岗位 {role.role_name if role else f'#{role_id}'}",
|
||
request_data=payload.model_dump(exclude_none=True),
|
||
),
|
||
meta=meta,
|
||
):
|
||
if role is None:
|
||
raise NotFoundError("岗位不存在")
|
||
|
||
role.role_name = payload.role_name
|
||
role.calc_template = payload.calc_template
|
||
role.description = payload.description
|
||
role.sort_order = payload.sort_order
|
||
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||
out = _to_out(role)
|
||
session.commit()
|
||
logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template)
|
||
return out
|
||
|
||
|
||
def set_status(
|
||
session: Session,
|
||
role_id: int,
|
||
status: int,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> BusinessRoleOut:
|
||
"""启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。
|
||
|
||
:raises NotFoundError: 岗位不存在
|
||
:raises ForbiddenError: 试图停用内置岗位
|
||
"""
|
||
role = business_role_repository.get_by_id(session, role_id)
|
||
verb = "启用" if status == 1 else "禁用"
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_BUSINESS_ROLE,
|
||
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
|
||
summary=f"{verb}岗位 {role.role_name if role else f'#{role_id}'}",
|
||
request_data={"status": status},
|
||
),
|
||
meta=meta,
|
||
):
|
||
if role is None:
|
||
raise NotFoundError("岗位不存在")
|
||
|
||
if role.is_builtin == 1 and status == 0:
|
||
raise ForbiddenError("内置岗位不可停用")
|
||
|
||
role.status = status
|
||
out = _to_out(role)
|
||
session.commit()
|
||
logger.info("变更岗位状态 | role_id=%s | status=%s", role_id, status)
|
||
return out
|
||
|
||
|
||
def delete_role(
|
||
session: Session,
|
||
role_id: int,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> None:
|
||
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。
|
||
|
||
查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用(M2 接入后),
|
||
锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。
|
||
|
||
:raises NotFoundError: 岗位不存在
|
||
:raises ForbiddenError: 试图删除内置岗位
|
||
:raises BadRequestError: 岗位下仍有人引用
|
||
"""
|
||
role = business_role_repository.get_by_id(session, role_id, for_update=True)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_BUSINESS_ROLE,
|
||
action=ACTION_DELETE,
|
||
summary=f"删除岗位 {role.role_name if role else f'#{role_id}'}",
|
||
),
|
||
meta=meta,
|
||
):
|
||
if role is None:
|
||
raise NotFoundError("岗位不存在")
|
||
|
||
if role.is_builtin == 1:
|
||
raise ForbiddenError("内置岗位不可删除")
|
||
|
||
counts = business_role_repository.count_personnel_by_role(session, [role_id])
|
||
person_count = counts.get(role_id, 0)
|
||
if person_count > 0:
|
||
raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属")
|
||
|
||
role_code = role.role_code # 固化:对象随删除失效
|
||
business_role_repository.delete(session, role)
|
||
session.commit()
|
||
logger.info("删除岗位 | role_code=%s", role_code)
|