仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数 传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的 约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery), 服务层与测试调用同步调整,行为不变。 - repositories 层查询方法签名改为 (session, query),分页与筛选条件 由 Query 承载,_apply_filters 同步收口 - schemas 层补齐各模块 Query 模型(approval/business_role/report 等) - services 层调用点同步改为传 Query - tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、 参数预览顺序稳定 验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
184 lines
7.2 KiB
Python
184 lines
7.2 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
|
||
from app.schemas.personnel import PersonnelQuery
|
||
|
||
# 自引用别名: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, query: PersonnelQuery) -> list[Row]:
|
||
"""分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。
|
||
|
||
排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。
|
||
"""
|
||
stmt = _apply_filters(_base_select(), query)
|
||
stmt = stmt.order_by(Personnel.id.desc()).offset(query.offset).limit(query.page_size)
|
||
return list(session.execute(stmt).all())
|
||
|
||
|
||
def count_personnel(session: Session, query: PersonnelQuery) -> int:
|
||
"""统计符合条件的人员总数,供分页使用。
|
||
|
||
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
|
||
``_apply_filters`` 单点保证)。
|
||
"""
|
||
stmt = _apply_filters(select(func.count(Personnel.id)), query)
|
||
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 count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]:
|
||
"""按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。
|
||
|
||
businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让
|
||
「删除被业务引用的人员」以 500 暴露而非可读的 400。
|
||
"""
|
||
|
||
from app.models import Business
|
||
|
||
owner = (
|
||
select(Business.owner_personnel_id, func.count(Business.id))
|
||
.where(Business.owner_personnel_id.in_(personnel_ids))
|
||
.group_by(Business.owner_personnel_id)
|
||
)
|
||
assistant = (
|
||
select(Business.assistant_personnel_id, func.count(Business.id))
|
||
.where(Business.assistant_personnel_id.in_(personnel_ids))
|
||
.group_by(Business.assistant_personnel_id)
|
||
)
|
||
refs: dict[int, int] = {}
|
||
for stmt in (owner, assistant):
|
||
for owner_id, count in session.execute(stmt).all():
|
||
if owner_id is not None:
|
||
refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count)
|
||
return refs
|
||
|
||
|
||
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, query: PersonnelQuery) -> Select:
|
||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||
if query.keyword:
|
||
stmt = fuzzy(stmt, Personnel.personnel_name, query.keyword)
|
||
if query.status is not None:
|
||
stmt = stmt.where(Personnel.status == query.status)
|
||
if query.business_role_id is not None:
|
||
stmt = stmt.where(Personnel.business_role_id == query.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()
|