feat(base): 人员管理接口(岗位/工资标准/团队/辐射区域/拓展计划)
- 删除保护:被团队成员/客户归属引用禁删,引导改离职 - 子表全量替换采用 clear 后立即 flush 防御写法 - 工资标准字符串进出,超两位小数拒绝而非静默舍入 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
f22d14e439
commit
a694570dd1
8
backend/app/api/v1/base/__init__.py
Normal file
8
backend/app/api/v1/base/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""基础数据管理接口(需求 2.1)。
|
||||
|
||||
挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。
|
||||
|
||||
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
|
||||
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
|
||||
由 service 层写入操作日志。
|
||||
"""
|
||||
141
backend/app/api/v1/base/personnel.py
Normal file
141
backend/app/api/v1/base/personnel.py
Normal file
@ -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="已保存")
|
||||
182
backend/app/repositories/personnel_repository.py
Normal file
182
backend/app/repositories/personnel_repository.py
Normal file
@ -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()
|
||||
147
backend/app/schemas/personnel.py
Normal file
147
backend/app/schemas/personnel.py
Normal file
@ -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="岗位筛选")
|
||||
407
backend/app/services/personnel_service.py
Normal file
407
backend/app/services/personnel_service.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user