文件
PayrollManagementSystem/backend/app/services/personnel_service.py
T
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

472 行
18 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
业务规则(前端会做提示,**后端校验是唯一防线**):
- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位
会让 M3 核算找不到模板
- 直属经理是可空的档案参考;实际绩效按业务分管及冻结核算资料归属。
新选经理不能是自己,须在职且使用启用的经理计提岗位;旧参考关系保留供核对
- 删除保护:被团队成员(personnel.manager_id)或客户归属
(customers.owner_personnel_id)引用的人员**禁删**,应改为离职 ——
历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用
- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float
辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入
(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。
"""
import logging
from datetime import date
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.models.personnel import PERSONNEL_STATUS_ACTIVE
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, calc_templates
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示,
# 引导管理员走「离职」而非删除
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
:raises BadRequestError: {label}人员不存在或已离职
"""
person = personnel_repository.get_by_id(session, personnel_id)
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
raise BadRequestError(f"{label}人员不存在或已离职")
return person
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel(session, query)
total = personnel_repository.count_personnel(session, query)
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(),
salary_history_json=personnel.salary_history_json,
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,
probation_start_date=payload.probation_start_date,
regularization_date=payload.regularization_date,
resignation_date=payload.resignation_date,
salary_standard=_parse_salary(payload.salary_standard),
status=1,
remark=payload.remark,
)
try:
personnel_repository.create(session, personnel)
from app.services.personnel_salary import save_version
save_version(personnel, payload.salary_effective_date or payload.hire_date or date.today())
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 = session.get(Personnel, personnel_id, with_for_update=True, populate_existing=True)
role, manager_name = _validate_refs(
session, payload.business_role_id, payload.manager_id, personnel.id
)
from app.services.personnel_salary import FIELDS, begin_update, save_version
salary_changed = any(str(getattr(personnel, key)) != str(getattr(payload, key)) for key in FIELDS)
effective = payload.salary_effective_date or date.today()
history = begin_update(session, personnel, effective) if salary_changed else None
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.probation_start_date = payload.probation_start_date
personnel.regularization_date = payload.regularization_date
personnel.resignation_date = payload.resignation_date
personnel.salary_standard = _parse_salary(payload.salary_standard)
personnel.remark = payload.remark
if salary_changed:
save_version(personnel, effective, history)
# 响应与后置动作全部在 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,
*,
effective_date: date | None = None,
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("人员不存在")
_, role_name, manager_name = row
personnel = session.get(Personnel, personnel_id, with_for_update=True, populate_existing=True)
from app.services.personnel_salary import begin_update, save_version
effective = effective_date or date.today()
history = begin_update(session, personnel, effective)
previous_status = personnel.status
personnel.status = status
if status == 0:
personnel.resignation_date = effective
else:
personnel.resignation_date = None
if previous_status == 0:
personnel.hire_date = effective
save_version(personnel, effective, history)
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,
probation_start_date=personnel.probation_start_date,
regularization_date=personnel.regularization_date,
resignation_date=personnel.resignation_date,
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:
raise BadRequestError("直属经理不存在或已离职")
original = session.get(Personnel, self_id) if self_id is not None else None
unchanged = original is not None and original.manager_id == manager_id
if manager.status != 1 and not unchanged:
raise BadRequestError("直属经理不存在或已离职")
manager_role = business_role_repository.get_by_id(session, manager.business_role_id)
if not unchanged and (
manager_role is None or manager_role.status != 1
or calc_templates.formula_type_of(session, manager_role.calc_template) != "manager_share"
):
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