"""营销人员管理业务编排(需求 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