- 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露) - 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500) - schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000' 原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败) - 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」 Co-Authored-By: Claude Code <noreply@anthropic.com>
422 lines
15 KiB
Python
422 lines
15 KiB
Python
"""营销人员管理业务编排(需求 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: 岗位不存在或已停用 / 直属经理不合法
|
||
"""
|
||
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,
|
||
):
|
||
# 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径
|
||
# 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象,
|
||
# 校验留在上下文外会让这类失败操作无据可查
|
||
role, manager_name = _validate_refs(
|
||
session, payload.business_role_id, payload.manager_id, None
|
||
)
|
||
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)
|
||
or personnel_repository.count_business_roles_refs(session, ids)
|
||
):
|
||
# 业务归属引用(负责/协助)随业务表就位一并纳入删除保护——
|
||
# 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400
|
||
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
|