diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5a89cf7..9999577 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/system/business_roles.py b/backend/app/api/v1/system/business_roles.py new file mode 100644 index 0000000..62e9b96 --- /dev/null +++ b/backend/app/api/v1/system/business_roles.py @@ -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="岗位已删除") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index ed67f0a..4d9f579 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -50,6 +50,7 @@ PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 MODULE_USER = "用户管理" MODULE_ROLE = "角色管理" +MODULE_BUSINESS_ROLE = "岗位角色" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/repositories/business_role_repository.py b/backend/app/repositories/business_role_repository.py new file mode 100644 index 0000000..f245c4f --- /dev/null +++ b/backend/app/repositories/business_role_repository.py @@ -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() diff --git a/backend/app/schemas/business_role.py b/backend/app/schemas/business_role.py new file mode 100644 index 0000000..fe6a0ab --- /dev/null +++ b/backend/app/schemas/business_role.py @@ -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="状态筛选") diff --git a/backend/app/services/business_role_service.py b/backend/app/services/business_role_service.py new file mode 100644 index 0000000..d89037c --- /dev/null +++ b/backend/app/services/business_role_service.py @@ -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)