From 60f6af64941bfacb7ec3d907c1e04366479f0a00 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Thu, 15 Oct 2026 09:49:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(repos):=20=E5=BD=92=E5=B1=9E=E7=95=99?= =?UTF-8?q?=E7=97=95=E5=85=B1=E4=BA=AB=E6=95=B0=E6=8D=AE=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=EF=BC=8C=E4=BA=BA=E5=91=98=E5=9C=A8=E8=81=8C=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=8D=95=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/models/personnel.py | 4 ++ .../app/repositories/business_repository.py | 44 +++++--------- .../app/repositories/customer_repository.py | 48 ++++++--------- .../repositories/ownership_log_repository.py | 58 +++++++++++++++++++ backend/app/services/business_service.py | 14 ++--- backend/app/services/customer_service.py | 19 +----- backend/app/services/personnel_service.py | 17 ++++++ 7 files changed, 119 insertions(+), 85 deletions(-) create mode 100644 backend/app/repositories/ownership_log_repository.py diff --git a/backend/app/models/personnel.py b/backend/app/models/personnel.py index f9256a7..6b7e89d 100644 --- a/backend/app/models/personnel.py +++ b/backend/app/models/personnel.py @@ -22,6 +22,10 @@ from app.models.base import ( mysql_table_args, ) +# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py +# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移) +PERSONNEL_STATUS_ACTIVE = 1 + class Personnel(Base, TimestampMixin, TPlusSyncMixin): """营销人员表(需求 2.1.1)。 diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index 1b579ac..87f138f 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -21,6 +21,7 @@ from app.models import ( Shipment, ) 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: @@ -229,41 +230,28 @@ def count_status_logs(session: Session, business_id: int) -> int: def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None: - """追加归属变更留痕(不 commit)。""" - session.add(log) - session.flush() + """追加归属变更留痕(共享实现,不 commit)。""" + ownership_log_repository.add(session, log) def list_ownership_logs( session: Session, business_id: int, *, offset: int, limit: int ) -> list[Row]: - """某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。""" - old_p = aliased(Personnel) - new_p = aliased(Personnel) - return list( - session.execute( - select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name) - .outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id) - .outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id) - .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() + """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。""" + return ownership_log_repository.list_by_ref( + session, + owner_type=OWNER_TYPE_BUSINESS, + ref_column=OwnershipChangeLog.business_id, + ref_id=business_id, + offset=offset, + limit=limit, ) def count_ownership_logs(session: Session, business_id: int) -> int: - return int( - session.execute( - select(func.count()) - .select_from(OwnershipChangeLog) - .where( - OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, - OwnershipChangeLog.business_id == business_id, - ) - ).scalar_one() + return ownership_log_repository.count_by_ref( + session, + owner_type=OWNER_TYPE_BUSINESS, + ref_column=OwnershipChangeLog.business_id, + ref_id=business_id, ) diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index 99d28a7..9e10511 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -4,17 +4,13 @@ """ 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.customer import OWNER_TYPE_CUSTOMER +from app.repositories import ownership_log_repository 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, limit: int, ) -> list[Row]: - """查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。 - - 按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条 - 记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。 - """ - stmt = ( - select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name) - .outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id) - .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) + """查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。""" + return ownership_log_repository.list_by_ref( + session, + owner_type=OWNER_TYPE_CUSTOMER, + ref_column=OwnershipChangeLog.customer_id, + ref_id=customer_id, + offset=offset, + limit=limit, ) - return list(session.execute(stmt).all()) def count_ownership_logs(session: Session, *, customer_id: int) -> int: """统计某客户的归属留痕总数,供分页使用。""" - stmt = select(func.count(OwnershipChangeLog.id)).where( - OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER, - OwnershipChangeLog.customer_id == customer_id, + return ownership_log_repository.count_by_ref( + session, + 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: @@ -186,7 +174,5 @@ def delete(session: Session, customer: Customer) -> None: def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog: - """写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。""" - session.add(log) - session.flush() - return log + """写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。""" + return ownership_log_repository.add(session, log) diff --git a/backend/app/repositories/ownership_log_repository.py b/backend/app/repositories/ownership_log_repository.py new file mode 100644 index 0000000..d47447e --- /dev/null +++ b/backend/app/repositories/ownership_log_repository.py @@ -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 diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 00b0f0d..1cb9591 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -49,7 +49,7 @@ from app.models.business import ( BUSINESS_STATUS_DONE, ) 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 ( BusinessCreateIn, BusinessDetailOut, @@ -66,7 +66,7 @@ from app.schemas.business import ( GrossProfitRowOut, 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.formula import converted_profit, profit_rate from app.services.no_generator import next_no @@ -77,9 +77,6 @@ logger = logging.getLogger(__name__) # 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷 _NO_RETRY = 1 -# personnel.status 的在职值(与 customer_service 同口径,注释见彼处) -_PERSONNEL_STATUS_ACTIVE = 1 - def _to_out(row: tuple) -> BusinessOut: """(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: - """归属人员须存在且在职(人员侧复用校验文案口径)。""" - 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 + """归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。""" + return personnel_service.ensure_active(session, personnel_id, label=label) def _ensure_customer(session: Session, customer_id: int) -> Customer: diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index d8243eb..0d4879d 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -40,15 +40,11 @@ from app.schemas.customer import ( CustomerUpdateIn, OwnershipChangeLogOut, ) -from app.services import audit_service +from app.services import audit_service, personnel_service from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) -# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被 -# 客户归属引用;人员侧抽出共享常量后此处替换 -_PERSONNEL_STATUS_ACTIVE = 1 - def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: """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: - """归属人员必须存在且在职,返回人员行。 - - 「不存在」与「已离职」合并为同一文案:人员 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 + """归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。""" + return personnel_service.ensure_active(session, personnel_id, label="归属") def _is_duplicate_entry(exc: IntegrityError) -> bool: diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py index 606fa3c..c88df6f 100644 --- a/backend/app/services/personnel_service.py +++ b/backend/app/services/personnel_service.py @@ -30,6 +30,7 @@ from app.core.constants import ( 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, @@ -51,6 +52,22 @@ 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(