refactor(repos): 归属留痕共享数据访问,人员在职校验收口单点

- ownership_log_repository:客户/业务两侧逐行同构的 list/count/add
  收口(owner_type 与引用列参数化),双键倒序与被删人员兜底口径
  单点维护;两侧 repository 变薄包装,service 层零改动
- PERSONNEL_STATUS_ACTIVE 单点导出于 models/personnel(对照
  BUSINESS_STATUS_* 先例);业务/客户两侧的「存在且在职」校验收口
  于 personnel_service.ensure_active——此前两份常量+校验仅靠注释
  维持同步,在职判定调整时会两入口口径分叉;校验放 service 层遵守
  「repositories 不抛业务异常」红线

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-15 09:49:53 +08:00
1 parent 409e26614f
commit 60f6af6494
7 files changed
+119 -85

No files matched your search

+4
View File
@@ -22,6 +22,10 @@ from app.models.base import (
mysql_table_args, mysql_table_args,
) )
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
PERSONNEL_STATUS_ACTIVE = 1
class Personnel(Base, TimestampMixin, TPlusSyncMixin): class Personnel(Base, TimestampMixin, TPlusSyncMixin):
"""营销人员表(需求 2.1.1)。 """营销人员表(需求 2.1.1)。
+16 -28
View File
@@ -21,6 +21,7 @@ from app.models import (
Shipment, Shipment,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@@ -229,41 +230,28 @@ def count_status_logs(session: Session, business_id: int) -> int:
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None: def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
"""追加归属变更留痕(不 commit)。""" """追加归属变更留痕(共享实现,不 commit)。"""
session.add(log) ownership_log_repository.add(session, log)
session.flush()
def list_ownership_logs( def list_ownership_logs(
session: Session, business_id: int, *, offset: int, limit: int session: Session, business_id: int, *, offset: int, limit: int
) -> list[Row]: ) -> list[Row]:
"""某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。""" """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
old_p = aliased(Personnel) return ownership_log_repository.list_by_ref(
new_p = aliased(Personnel) session,
return list( owner_type=OWNER_TYPE_BUSINESS,
session.execute( ref_column=OwnershipChangeLog.business_id,
select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name) ref_id=business_id,
.outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id) offset=offset,
.outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id) limit=limit,
.where(
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
).all()
) )
def count_ownership_logs(session: Session, business_id: int) -> int: def count_ownership_logs(session: Session, business_id: int) -> int:
return int( return ownership_log_repository.count_by_ref(
session.execute( session,
select(func.count()) owner_type=OWNER_TYPE_BUSINESS,
.select_from(OwnershipChangeLog) ref_column=OwnershipChangeLog.business_id,
.where( ref_id=business_id,
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
).scalar_one()
) )
+17 -31
View File
@@ -4,17 +4,13 @@
""" """
from sqlalchemy import ColumnElement, Row, Select, func, or_, select from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
# 归属留痕表对人员 ID 无外键固化:新旧人员姓名靠对 personnel 的两次外联带出,
# 人员被删除后姓名为 null,行仍完整可读
_old_owner = aliased(Personnel)
_new_owner = aliased(Personnel)
# ==================== 读取 ==================== # ==================== 读取 ====================
@@ -102,33 +98,25 @@ def list_ownership_logs(
offset: int, offset: int,
limit: int, limit: int,
) -> list[Row]: ) -> list[Row]:
"""查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。 """查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条 session,
记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。 owner_type=OWNER_TYPE_CUSTOMER,
""" ref_column=OwnershipChangeLog.customer_id,
stmt = ( ref_id=customer_id,
select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name) offset=offset,
.outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id) limit=limit,
.outerjoin(_new_owner, OwnershipChangeLog.new_owner_personnel_id == _new_owner.id)
.where(
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
OwnershipChangeLog.customer_id == customer_id,
)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
) )
return list(session.execute(stmt).all())
def count_ownership_logs(session: Session, *, customer_id: int) -> int: def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。""" """统计某客户的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where( return ownership_log_repository.count_by_ref(
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER, session,
OwnershipChangeLog.customer_id == customer_id, owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
) )
return int(session.execute(stmt).scalar_one())
def _base_select() -> Select: def _base_select() -> Select:
@@ -186,7 +174,5 @@ def delete(session: Session, customer: Customer) -> None:
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog: def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。""" """写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
session.add(log) return ownership_log_repository.add(session, log)
session.flush()
return log
@@ -0,0 +1,58 @@
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
def list_by_ref(
session: Session,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
保证翻页稳定(与操作日志同一理由)。
"""
old_person = aliased(Personnel)
new_person = aliased(Personnel)
stmt = (
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_by_ref(
session: Session, *, owner_type: int, ref_column, ref_id: int
) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
)
return int(session.execute(stmt).scalar_one())
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
session.add(log)
session.flush()
return log
+4 -10
View File
@@ -49,7 +49,7 @@ from app.models.business import (
BUSINESS_STATUS_DONE, BUSINESS_STATUS_DONE,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository from app.repositories import business_repository, customer_repository
from app.schemas.business import ( from app.schemas.business import (
BusinessCreateIn, BusinessCreateIn,
BusinessDetailOut, BusinessDetailOut,
@@ -66,7 +66,7 @@ from app.schemas.business import (
GrossProfitRowOut, GrossProfitRowOut,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
from app.services import audit_service, param_service from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no from app.services.no_generator import next_no
@@ -77,9 +77,6 @@ logger = logging.getLogger(__name__)
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷 # 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
_NO_RETRY = 1 _NO_RETRY = 1
# personnel.status 的在职值(与 customer_service 同口径,注释见彼处)
_PERSONNEL_STATUS_ACTIVE = 1
def _to_out(row: tuple) -> BusinessOut: def _to_out(row: tuple) -> BusinessOut:
"""(business, company_name, owner_name, assistant_name) → 响应模型。 """(business, company_name, owner_name, assistant_name) → 响应模型。
@@ -214,11 +211,8 @@ def _generate_no(session: Session) -> str:
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel: def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
"""归属人员须存在且在职(人员侧复用校验文案口径)。""" """归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
person = personnel_repository.get_by_id(session, personnel_id) return personnel_service.ensure_active(session, personnel_id, label=label)
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
raise BadRequestError(f"{label}人员不存在或已离职")
return person
def _ensure_customer(session: Session, customer_id: int) -> Customer: def _ensure_customer(session: Session, customer_id: int) -> Customer:
+3 -16
View File
@@ -40,15 +40,11 @@ from app.schemas.customer import (
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
from app.services import audit_service from app.services import audit_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被
# 客户归属引用;人员侧抽出共享常量后此处替换
_PERSONNEL_STATUS_ACTIVE = 1
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
@@ -95,17 +91,8 @@ def _ensure_customer_type(customer_type: str) -> None:
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel: def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
"""归属人员必须存在且在职,返回人员行。 """归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label="归属")
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要
向调用方区分的信息(同登录防枚举的思路)。
:raises BadRequestError: 归属人员不存在或已离职
"""
person = personnel_repository.get_by_id(session, personnel_id)
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
raise BadRequestError("归属人员不存在或已离职")
return person
def _is_duplicate_entry(exc: IntegrityError) -> bool: def _is_duplicate_entry(exc: IntegrityError) -> bool:
+17
View File
@@ -30,6 +30,7 @@ from app.core.constants import (
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User 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.repositories import business_role_repository, personnel_repository
from app.schemas.personnel import ( from app.schemas.personnel import (
PersonnelCreateIn, PersonnelCreateIn,
@@ -51,6 +52,22 @@ logger = logging.getLogger(__name__)
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" _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]: def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。""" """分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel( rows = personnel_repository.list_personnel(