feat(system): 业务岗位角色管理接口与保护规则

- 四层实现:仓储(FOR UPDATE 锁行 + 引用计数钩子预留)/ schemas / service / api 共 7 个端点
- 保护规则:内置岗位禁删禁停、编码创建后不可改、模板须在注册表内(未注册 400)
- 写操作全走审计上下文;冒烟验证 7 项(含成功/失败双路径落日志)
- 端点:列表/新增/详情/编辑/删除/启停 + meta/templates 模板下拉

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-12 16:39:08 +08:00
1 parent 1b3509b20f
commit 7a0f3f4b98
6 files changed
+590

No files matched your search

@@ -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)