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