"""业务岗位角色管理业务编排。 需求 #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)