feat/m3-increments #1
@ -8,6 +8,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
from app.api.v1.system import business_roles as system_business_roles
|
||||
from app.api.v1.system import logs as system_logs
|
||||
from app.api.v1.system import roles as system_roles
|
||||
from app.api.v1.system import users as system_users
|
||||
@ -25,6 +26,7 @@ v1_router.include_router(auth.router)
|
||||
system_router = APIRouter(prefix="/system")
|
||||
system_router.include_router(system_users.router)
|
||||
system_router.include_router(system_roles.router)
|
||||
system_router.include_router(system_business_roles.router)
|
||||
system_router.include_router(system_logs.operation_router)
|
||||
system_router.include_router(system_logs.login_router)
|
||||
v1_router.include_router(system_router)
|
||||
|
||||
122
backend/app/api/v1/system/business_roles.py
Normal file
122
backend/app/api/v1/system/business_roles.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。
|
||||
|
||||
保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见
|
||||
`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
|
||||
|
||||
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
|
||||
层写入操作日志。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
BusinessRoleOut,
|
||||
BusinessRoleQuery,
|
||||
BusinessRoleStatusIn,
|
||||
BusinessRoleUpdateIn,
|
||||
TemplateOptionOut,
|
||||
)
|
||||
from app.services import business_role_service
|
||||
|
||||
router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表")
|
||||
def list_roles(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
query: Annotated[BusinessRoleQuery, Query()],
|
||||
) -> ApiResponse[PageResult[BusinessRoleOut]]:
|
||||
"""分页查询业务岗位角色(含停用)。"""
|
||||
return ok(business_role_service.list_roles(session, query))
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位")
|
||||
def create_role(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
payload: BusinessRoleCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[BusinessRoleOut]:
|
||||
"""新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。"""
|
||||
return ok(
|
||||
business_role_service.create_role(session, payload, operator=current_user, meta=meta),
|
||||
message="岗位已创建",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/meta/templates",
|
||||
response_model=ApiResponse[list[TemplateOptionOut]],
|
||||
summary="公式模板下拉",
|
||||
)
|
||||
def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]:
|
||||
"""取公式模板下拉(含声明参数,供岗位表单展示)。
|
||||
|
||||
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
|
||||
"""
|
||||
return ok(business_role_service.list_template_options())
|
||||
|
||||
|
||||
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
|
||||
def get_role(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[BusinessRoleOut]:
|
||||
"""按主键查询岗位详情。"""
|
||||
return ok(business_role_service.get_role(session, role_id))
|
||||
|
||||
|
||||
@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位")
|
||||
def update_role(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessRoleUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[BusinessRoleOut]:
|
||||
"""编辑岗位名称/模板/说明/排序。
|
||||
|
||||
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点。
|
||||
"""
|
||||
return ok(
|
||||
business_role_service.update_role(
|
||||
session, role_id, payload, operator=current_user, meta=meta
|
||||
),
|
||||
message="已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位"
|
||||
)
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessRoleStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[BusinessRoleOut]:
|
||||
"""启用或禁用岗位。内置岗位不可停用。"""
|
||||
result = business_role_service.set_status(
|
||||
session, role_id, payload.status, operator=current_user, meta=meta
|
||||
)
|
||||
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
|
||||
|
||||
|
||||
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位")
|
||||
def delete_role(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
role_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
"""删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。"""
|
||||
business_role_service.delete_role(session, role_id, operator=current_user, meta=meta)
|
||||
return ok(message="岗位已删除")
|
||||
@ -50,6 +50,7 @@ PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
|
||||
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||
MODULE_USER = "用户管理"
|
||||
MODULE_ROLE = "角色管理"
|
||||
MODULE_BUSINESS_ROLE = "岗位角色"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
|
||||
88
backend/app/repositories/business_role_repository.py
Normal file
88
backend/app/repositories/business_role_repository.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""业务岗位角色数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
|
||||
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None:
|
||||
"""按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。"""
|
||||
if for_update:
|
||||
stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update()
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
return session.get(BusinessRole, role_id)
|
||||
|
||||
|
||||
def get_by_code(session: Session, role_code: str) -> BusinessRole | None:
|
||||
"""按编码查询岗位。编码是参数维度值(dimension_value)的锚点。"""
|
||||
stmt = select(BusinessRole).where(BusinessRole.role_code == role_code)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[BusinessRole]:
|
||||
"""分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。"""
|
||||
stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
"""统计符合条件的岗位总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线,
|
||||
不转义时 ``_`` 会当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
BusinessRole.role_code.contains(keyword, autoescape=True),
|
||||
BusinessRole.role_name.contains(keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(BusinessRole.status == status)
|
||||
return stmt
|
||||
|
||||
|
||||
def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
|
||||
"""批量统计各岗位下的人员数(删除保护的依据)。
|
||||
|
||||
② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY,
|
||||
禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
def create(session: Session, role: BusinessRole) -> BusinessRole:
|
||||
"""新增岗位。只 flush 以取得自增 id。"""
|
||||
session.add(role)
|
||||
session.flush()
|
||||
return role
|
||||
|
||||
|
||||
def delete(session: Session, role: BusinessRole) -> None:
|
||||
"""删除岗位。调用方须先完成保护规则校验。"""
|
||||
session.delete(role)
|
||||
session.flush()
|
||||
90
backend/app/schemas/business_role.py
Normal file
90
backend/app/schemas/business_role.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""业务岗位角色的请求/响应模型。"""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。
|
||||
# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点,
|
||||
# 保持单一风格避免大小写/全角混用造成对照困难
|
||||
BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
|
||||
|
||||
|
||||
class BusinessRoleOut(BaseModel):
|
||||
"""岗位响应(列表与详情共用)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
role_code: str
|
||||
role_name: str
|
||||
calc_template: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
is_builtin: int = 0
|
||||
|
||||
|
||||
class TemplateParamOut(BaseModel):
|
||||
"""模板声明的参数(供岗位表单展示,含当前口径说明)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
|
||||
|
||||
class TemplateOptionOut(BaseModel):
|
||||
"""公式模板下拉项。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
params: list[TemplateParamOut]
|
||||
|
||||
|
||||
class BusinessRoleCreateIn(BaseModel):
|
||||
"""新增岗位。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
role_code: str = Field(min_length=2, max_length=32, description="岗位编码,创建后不可修改")
|
||||
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
|
||||
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
|
||||
@field_validator("role_code")
|
||||
@classmethod
|
||||
def _check_code(cls, value: str) -> str:
|
||||
if not BUSINESS_ROLE_CODE_PATTERN.match(value):
|
||||
raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线")
|
||||
return value
|
||||
|
||||
|
||||
class BusinessRoleUpdateIn(BaseModel):
|
||||
"""编辑岗位。
|
||||
|
||||
**不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。
|
||||
前端表单全量提交,故为全量替换语义。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
role_name: str = Field(min_length=1, max_length=64, description="岗位名称")
|
||||
calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
|
||||
|
||||
class BusinessRoleStatusIn(BaseModel):
|
||||
"""启用/禁用岗位。"""
|
||||
|
||||
status: int = Field(ge=0, le=1, description="1启用 0禁用")
|
||||
|
||||
|
||||
class BusinessRoleQuery(PageQuery):
|
||||
"""岗位列表查询条件。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
287
backend/app/services/business_role_service.py
Normal file
287
backend/app/services/business_role_service.py
Normal file
@ -0,0 +1,287 @@
|
||||
"""业务岗位角色管理业务编排。
|
||||
|
||||
需求 #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
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
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(calc_template: str) -> None:
|
||||
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
|
||||
if calc_template not in TEMPLATES:
|
||||
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
|
||||
|
||||
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||
"""分页查询岗位(含停用)。"""
|
||||
roles = business_role_repository.list_roles(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
keyword=query.keyword,
|
||||
status=query.status,
|
||||
)
|
||||
total = business_role_repository.count_roles(
|
||||
session, keyword=query.keyword, status=query.status
|
||||
)
|
||||
return PageResult[BusinessRoleOut](
|
||||
records=[_to_out(role) for role in roles],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
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() -> list[TemplateOptionOut]:
|
||||
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
|
||||
options: list[TemplateOptionOut] = []
|
||||
for tpl in TEMPLATES.values():
|
||||
options.append(
|
||||
TemplateOptionOut(
|
||||
code=tpl.code,
|
||||
name=tpl.name,
|
||||
description=tpl.description,
|
||||
params=[
|
||||
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
|
||||
for code in tpl.params
|
||||
],
|
||||
)
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def create_role(
|
||||
session: Session,
|
||||
payload: BusinessRoleCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> BusinessRoleOut:
|
||||
"""新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。
|
||||
|
||||
:raises BadRequestError: 编码已存在 / 模板不存在
|
||||
"""
|
||||
_ensure_template(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(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)
|
||||
Loading…
Reference in New Issue
Block a user