From a694570dd1f36790157af57c4a00db1497b669e9 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 10:35:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(base):=20=E4=BA=BA=E5=91=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=B2=97=E4=BD=8D/?= =?UTF-8?q?=E5=B7=A5=E8=B5=84=E6=A0=87=E5=87=86/=E5=9B=A2=E9=98=9F/?= =?UTF-8?q?=E8=BE=90=E5=B0=84=E5=8C=BA=E5=9F=9F/=E6=8B=93=E5=B1=95?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除保护:被团队成员/客户归属引用禁删,引导改离职 - 子表全量替换采用 clear 后立即 flush 防御写法 - 工资标准字符串进出,超两位小数拒绝而非静默舍入 Co-Authored-By: Claude Code --- backend/app/api/v1/base/__init__.py | 8 + backend/app/api/v1/base/personnel.py | 141 ++++++ .../app/repositories/personnel_repository.py | 182 ++++++++ backend/app/schemas/personnel.py | 147 +++++++ backend/app/services/personnel_service.py | 407 ++++++++++++++++++ 5 files changed, 885 insertions(+) create mode 100644 backend/app/api/v1/base/__init__.py create mode 100644 backend/app/api/v1/base/personnel.py create mode 100644 backend/app/repositories/personnel_repository.py create mode 100644 backend/app/schemas/personnel.py create mode 100644 backend/app/services/personnel_service.py diff --git a/backend/app/api/v1/base/__init__.py b/backend/app/api/v1/base/__init__.py new file mode 100644 index 0000000..f64505d --- /dev/null +++ b/backend/app/api/v1/base/__init__.py @@ -0,0 +1,8 @@ +"""基础数据管理接口(需求 2.1)。 + +挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。 + +与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" diff --git a/backend/app/api/v1/base/personnel.py b/backend/app/api/v1/base/personnel.py new file mode 100644 index 0000000..9b305c2 --- /dev/null +++ b/backend/app/api/v1/base/personnel.py @@ -0,0 +1,141 @@ +"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。 + +基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。 +校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见 +`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。 + +写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.personnel import ( + PersonnelCreateIn, + PersonnelDetailOut, + PersonnelOut, + PersonnelQuery, + PersonnelStatusIn, + PlanReplaceIn, + RegionReplaceIn, +) +from app.services import personnel_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/personnel", tags=["人员管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表") +def list_personnel( + session: DbSession, + _: CurrentUser, + query: Annotated[PersonnelQuery, Query()], +) -> ApiResponse[PageResult[PersonnelOut]]: + """分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。""" + return ok(personnel_service.list_personnel(session, query)) + + +@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员") +def create_personnel( + session: DbSession, + current_user: CurrentUser, + payload: PersonnelCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。""" + return ok( + personnel_service.create_personnel(session, payload, operator=current_user, meta=meta), + message="人员已创建", + ) + + +@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情") +def get_personnel( + session: DbSession, + _: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[PersonnelDetailOut]: + """人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。""" + return ok(personnel_service.get_detail(session, personnel_id)) + + +@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员") +def update_personnel( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PersonnelCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """编辑人员(全量提交,不含状态)。校验规则同新增。""" + return ok( + personnel_service.update_personnel( + session, personnel_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员") +def delete_personnel( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除人员。被团队成员或客户归属引用时禁删,应改为离职。""" + personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta) + return ok(message="人员已删除") + + +@router.put( + "/{personnel_id}/status", + response_model=ApiResponse[PersonnelOut], + summary="设为在职/离职", +) +def set_status( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PersonnelStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """在职/离职切换。离职人员不可再被业务与客户归属引用。""" + result = personnel_service.set_status( + session, personnel_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职") + + +@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域") +def replace_regions( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: RegionReplaceIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """全量替换辐射区域,空列表即清空。""" + personnel_service.replace_regions( + session, personnel_id, payload, operator=current_user, meta=meta + ) + return ok(message="已保存") + + +@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划") +def replace_plans( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PlanReplaceIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """全量替换拓展计划,空列表即清空。""" + personnel_service.replace_plans(session, personnel_id, payload, operator=current_user, meta=meta) + return ok(message="已保存") diff --git a/backend/app/repositories/personnel_repository.py b/backend/app/repositories/personnel_repository.py new file mode 100644 index 0000000..602e127 --- /dev/null +++ b/backend/app/repositories/personnel_repository.py @@ -0,0 +1,182 @@ +"""营销人员数据访问(含辐射区域与拓展计划子表)。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Row, Select, func, select +from sqlalchemy import delete as sa_delete +from sqlalchemy.orm import Session, aliased + +from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion +from app.repositories.query_utils import fuzzy + +# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」 +_manager = aliased(Personnel) + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。""" + if for_update: + stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Personnel, personnel_id) + + +def get_with_names(session: Session, personnel_id: int) -> Row | None: + """查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。""" + stmt = _base_select().where(Personnel.id == personnel_id) + return session.execute(stmt).first() + + +def list_personnel( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, + business_role_id: int | None = None, +) -> list[Row]: + """分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。 + + 排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。 + """ + stmt = _apply_filters( + _base_select(), keyword=keyword, status=status, business_role_id=business_role_id + ) + stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_personnel( + session: Session, + *, + keyword: str | None = None, + status: int | None = None, + business_role_id: int | None = None, +) -> int: + """统计符合条件的人员总数,供分页使用。 + + 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 + ``_apply_filters`` 单点保证)。 + """ + stmt = _apply_filters( + select(func.count(Personnel.id)), + keyword=keyword, + status=status, + business_role_id=business_role_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]: + """按直属经理批量统计团队成员数(删除保护的依据)。 + + 一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。 + """ + stmt = ( + select(Personnel.manager_id, func.count(Personnel.id)) + .where(Personnel.manager_id.in_(manager_ids)) + .group_by(Personnel.manager_id) + ) + return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()} + + +def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]: + """按归属人员批量统计客户数(删除保护的另一半依据)。""" + stmt = ( + select(Customer.owner_personnel_id, func.count(Customer.id)) + .where(Customer.owner_personnel_id.in_(personnel_ids)) + .group_by(Customer.owner_personnel_id) + ) + return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()} + + +def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]: + """查人员的全部辐射区域,按插入顺序返回。""" + stmt = ( + select(PersonnelRegion) + .where(PersonnelRegion.personnel_id == personnel_id) + .order_by(PersonnelRegion.id) + ) + return list(session.execute(stmt).scalars()) + + +def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]: + """查人员的全部拓展计划,按插入顺序返回。""" + stmt = ( + select(PersonnelPlan) + .where(PersonnelPlan.personnel_id == personnel_id) + .order_by(PersonnelPlan.id) + ) + return list(session.execute(stmt).scalars()) + + +def _base_select() -> Select: + """人员主表联岗位名称与直属经理姓名。 + + 岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让 + 整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。 + """ + return ( + select(Personnel, BusinessRole.role_name, _manager.personnel_name) + .outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id) + .outerjoin(_manager, Personnel.manager_id == _manager.id) + ) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + status: int | None, + business_role_id: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if keyword: + stmt = fuzzy(stmt, Personnel.personnel_name, keyword) + if status is not None: + stmt = stmt.where(Personnel.status == status) + if business_role_id is not None: + stmt = stmt.where(Personnel.business_role_id == business_role_id) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, personnel: Personnel) -> Personnel: + """新增人员。只 flush 以取得自增 id。""" + session.add(personnel) + session.flush() + return personnel + + +def delete(session: Session, personnel: Personnel) -> None: + """删除人员。调用方须先完成引用保护校验。""" + session.delete(personnel) + session.flush() + + +def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None: + """全量替换人员的辐射区域。 + + **先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT + 后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的 + replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法, + 避免后续加约束时翻车。 + """ + session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id)) + session.flush() + session.add_all(rows) + session.flush() + + +def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None: + """全量替换人员的拓展计划(防御写法同 ``replace_regions``)。""" + session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id)) + session.flush() + session.add_all(rows) + session.flush() diff --git a/backend/app/schemas/personnel.py b/backend/app/schemas/personnel.py new file mode 100644 index 0000000..12032cc --- /dev/null +++ b/backend/app/schemas/personnel.py @@ -0,0 +1,147 @@ +"""营销人员的请求/响应模型(需求 2.1.1)。""" + +from datetime import date +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。 +# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float +SALARY_MAX = Decimal("9999999999.99") + + +class PersonnelRegionOut(BaseModel): + """辐射区域条目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + region_name: str + remark: str | None = None + + +class PersonnelPlanOut(BaseModel): + """拓展计划条目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + plan_period: str + plan_content: str + target_desc: str | None = None + + +class PersonnelOut(BaseModel): + """人员响应(列表与写操作回包共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + personnel_name: str + business_role_id: int + role_name: str | None = None + manager_id: int | None = None + manager_name: str | None = None + phone: str | None = None + hire_date: date | None = None + is_probation: int = 0 + salary_standard: str | None = None + status: int = 1 + remark: str | None = None + + +class PersonnelDetailOut(PersonnelOut): + """人员详情:附区域与拓展计划,供编辑抽屉一次回显。""" + + regions: list[PersonnelRegionOut] = Field(default_factory=list) + plans: list[PersonnelPlanOut] = Field(default_factory=list) + + +class PersonnelCreateIn(BaseModel): + """新增人员。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + personnel_name: str = Field(min_length=1, max_length=64, description="姓名") + business_role_id: int = Field(ge=1, description="岗位角色ID") + manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无") + phone: str | None = Field(default=None, max_length=20, description="联系电话") + hire_date: date | None = Field(default=None, description="入职日期,ISO 格式") + is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否") + salary_standard: str | None = Field(default=None, description="月工资标准(元),字符串形式的数字") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("salary_standard") + @classmethod + def _check_salary_standard(cls, value: str | None) -> str | None: + """按 Numeric(12,2) 的容量校验字符串金额。 + + 金额以字符串进出避免 float 精度损失(项目金额红线); + 小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。 + """ + if value is None: + return None + try: + amount = Decimal(value) + except InvalidOperation: + raise ValueError("工资标准须为有效数字") from None + if not amount.is_finite(): + raise ValueError("工资标准须为有效数字") + if amount < 0: + raise ValueError("工资标准不能为负数") + if amount > SALARY_MAX: + raise ValueError("工资标准超出可录入上限") + if -amount.as_tuple().exponent > 2: + raise ValueError("工资标准最多两位小数") + return value + + +class PersonnelUpdateIn(PersonnelCreateIn): + """编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。""" + + +class PersonnelStatusIn(BaseModel): + """在职/离职切换。""" + + status: int = Field(ge=0, le=1, description="1在职 0离职") + + +class RegionItemIn(BaseModel): + """辐射区域条目。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + region_name: str = Field(min_length=1, max_length=128, description="区域名称") + remark: str | None = Field(default=None, max_length=255) + + +class RegionReplaceIn(BaseModel): + """辐射区域全量替换。空列表即清空该人员的全部区域。""" + + items: list[RegionItemIn] = Field(default_factory=list, description="全量条目") + + +class PlanItemIn(BaseModel): + """拓展计划条目。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1") + plan_content: str = Field(min_length=1, max_length=500, description="计划内容") + target_desc: str | None = Field(default=None, max_length=255, description="指标说明") + + +class PlanReplaceIn(BaseModel): + """拓展计划全量替换。空列表即清空该人员的全部计划。""" + + items: list[PlanItemIn] = Field(default_factory=list, description="全量条目") + + +class PersonnelQuery(PageQuery): + """人员列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职") + business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选") diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py new file mode 100644 index 0000000..f58e91d --- /dev/null +++ b/backend/app/services/personnel_service.py @@ -0,0 +1,407 @@ +"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。 + +业务规则(前端会做提示,**后端校验是唯一防线**): + +- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位 + 会让 M3 核算找不到模板 +- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5% + 聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算 +- 删除保护:被团队成员(personnel.manager_id)或客户归属 + (customers.owner_personnel_id)引用的人员**禁删**,应改为离职 —— + 历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用 +- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float + +辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入 +(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_PERSONNEL, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User +from app.repositories import business_role_repository, personnel_repository +from app.schemas.personnel import ( + PersonnelCreateIn, + PersonnelDetailOut, + PersonnelOut, + PersonnelPlanOut, + PersonnelQuery, + PersonnelRegionOut, + PlanReplaceIn, + RegionReplaceIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# 删除保护的统一文案:两种引用共享同一提示,引导管理员走「离职」而非删除 +_DELETE_BLOCKED = "存在团队成员或客户归属引用,请改为离职而非删除" + + +def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: + """分页查询人员(含离职)。""" + rows = personnel_repository.list_personnel( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + business_role_id=query.business_role_id, + ) + total = personnel_repository.count_personnel( + session, + keyword=query.keyword, + status=query.status, + business_role_id=query.business_role_id, + ) + return PageResult[PersonnelOut]( + records=[_to_out(personnel, role_name, manager_name) for personnel, role_name, manager_name in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut: + """人员详情(含区域与拓展计划,供编辑抽屉回显)。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + if row is None: + raise NotFoundError("人员不存在") + personnel, role_name, manager_name = row + return PersonnelDetailOut( + **_to_out(personnel, role_name, manager_name).model_dump(), + regions=[ + PersonnelRegionOut.model_validate(item) + for item in personnel_repository.list_regions(session, personnel_id) + ], + plans=[ + PersonnelPlanOut.model_validate(item) + for item in personnel_repository.list_plans(session, personnel_id) + ], + ) + + +def create_personnel( + session: Session, + payload: PersonnelCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """新增人员,默认在职。 + + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + role, manager_name = _validate_refs(session, payload.business_role_id, payload.manager_id, None) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_CREATE, + summary=f"新增人员 {payload.personnel_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + personnel = Personnel( + personnel_name=payload.personnel_name, + business_role_id=payload.business_role_id, + manager_id=payload.manager_id, + phone=payload.phone, + hire_date=payload.hire_date, + is_probation=payload.is_probation, + salary_standard=_parse_salary(payload.salary_standard), + status=1, + remark=payload.remark, + ) + try: + personnel_repository.create(session, personnel) + out = _to_out(personnel, role.role_name, manager_name) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:岗位/经理行被并发删除时 FK RESTRICT 令写入失败 + session.rollback() + raise BadRequestError("岗位或直属经理引用已失效,请刷新后重试") from exc + + logger.info("新增人员 | personnel_id=%s | name=%s", personnel.id, payload.personnel_name) + return out + + +def update_personnel( + session: Session, + personnel_id: int, + payload: PersonnelCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """编辑人员(全量替换,不含状态)。 + + 经理自引用校验以**被编辑人员的 id** 为准:把经理改成自己是环引用。 + + :raises NotFoundError: 人员不存在 + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"修改人员 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + personnel = row[0] + role, manager_name = _validate_refs( + session, payload.business_role_id, payload.manager_id, personnel.id + ) + personnel.personnel_name = payload.personnel_name + personnel.business_role_id = payload.business_role_id + personnel.manager_id = payload.manager_id + personnel.phone = payload.phone + personnel.hire_date = payload.hire_date + personnel.is_probation = payload.is_probation + personnel.salary_standard = _parse_salary(payload.salary_standard) + personnel.remark = payload.remark + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(personnel, role.role_name, manager_name) + session.commit() + logger.info("修改人员 | personnel_id=%s", personnel_id) + return out + + +def delete_personnel( + session: Session, + personnel_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除人员。有团队成员或客户归属引用时禁删,引导走「离职」。 + + 查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用,锁行使 + 并发外键写入在删除时失败(可见的 400)而非引用被静默悬空。 + + :raises NotFoundError: 人员不存在 + :raises BadRequestError: 存在团队成员或客户归属引用 + """ + personnel = personnel_repository.get_by_id(session, personnel_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_DELETE, + summary=f"删除人员 {personnel.personnel_name if personnel else f'#{personnel_id}'}", + ), + meta=meta, + ): + if personnel is None: + raise NotFoundError("人员不存在") + + ids = [personnel_id] + if ( + personnel_repository.count_team_members(session, ids) + or personnel_repository.count_customer_ownerships(session, ids) + ): + raise BadRequestError(_DELETE_BLOCKED) + + name = personnel.personnel_name # 固化:对象随删除失效 + try: + personnel_repository.delete(session, personnel) + session.commit() + except IntegrityError as exc: + # 校验与删除之间并发新增了团队成员/客户归属,FK RESTRICT 令删除失败 + session.rollback() + raise BadRequestError(_DELETE_BLOCKED) from exc + logger.info("删除人员 | personnel_id=%s | name=%s", personnel_id, name) + + +def set_status( + session: Session, + personnel_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """在职/离职切换。 + + 人员语义不用「启用/禁用」词表,action 记 ACTION_UPDATE、摘要写明去向。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + verb = "设为在职" if status == 1 else "设为离职" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"{verb} {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + personnel, role_name, manager_name = row + personnel.status = status + out = _to_out(personnel, role_name, manager_name) + session.commit() + logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status) + return out + + +def replace_regions( + session: Session, + personnel_id: int, + payload: RegionReplaceIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """全量替换辐射区域,空列表即清空。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"维护辐射区域 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + rows = [ + PersonnelRegion(personnel_id=personnel_id, region_name=item.region_name, remark=item.remark) + for item in payload.items + ] + personnel_repository.replace_regions(session, personnel_id, rows) + session.commit() + logger.info("维护辐射区域 | personnel_id=%s | 条目数=%s", personnel_id, len(rows)) + + +def replace_plans( + session: Session, + personnel_id: int, + payload: PlanReplaceIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """全量替换拓展计划,空列表即清空。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"维护拓展计划 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + rows = [ + PersonnelPlan( + personnel_id=personnel_id, + plan_period=item.plan_period, + plan_content=item.plan_content, + target_desc=item.target_desc, + ) + for item in payload.items + ] + personnel_repository.replace_plans(session, personnel_id, rows) + session.commit() + logger.info("维护拓展计划 | personnel_id=%s | 条目数=%s", personnel_id, len(rows)) + + +def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | None) -> PersonnelOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return PersonnelOut( + id=personnel.id, + personnel_name=personnel.personnel_name, + business_role_id=personnel.business_role_id, + role_name=role_name, + manager_id=personnel.manager_id, + manager_name=manager_name, + phone=personnel.phone, + hire_date=personnel.hire_date, + is_probation=personnel.is_probation, + salary_standard=( + str(personnel.salary_standard) if personnel.salary_standard is not None else None + ), + status=personnel.status, + remark=personnel.remark, + ) + + +def _validate_refs( + session: Session, + business_role_id: int, + manager_id: int | None, + self_id: int | None, +) -> tuple[BusinessRole, str | None]: + """新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。 + + ``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。 + 返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。 + + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + role = business_role_repository.get_by_id(session, business_role_id) + if role is None or role.status != 1: + raise BadRequestError("岗位不存在或已停用") + + manager_name: str | None = None + if manager_id is not None: + if manager_id == self_id: + raise BadRequestError("直属经理不能是自己") + manager = personnel_repository.get_by_id(session, manager_id) + if manager is None or manager.status != 1: + raise BadRequestError("直属经理不存在或已离职") + manager_name = manager.personnel_name + return role, manager_name + + +def _parse_salary(value: str | None) -> Decimal | None: + """入参字符串转 Decimal 存库。 + + 格式、非负与容量已在 schema 层校验,此处只做类型转换 —— + 金额红线:全程 Decimal,不经 float。 + """ + return Decimal(value) if value is not None else None