Files
PayrollManagementSystem/backend/app/repositories/personnel_repository.py
T
liuwenxinandClaude Code a694570dd1 feat(base): 人员管理接口(岗位/工资标准/团队/辐射区域/拓展计划)
- 删除保护:被团队成员/客户归属引用禁删,引导改离职
- 子表全量替换采用 clear 后立即 flush 防御写法
- 工资标准字符串进出,超两位小数拒绝而非静默舍入

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:02 +08:00

183 lines
6.5 KiB
Python

"""营销人员数据访问(含辐射区域与拓展计划子表)。
本层只 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()