"""客户管理业务编排(需求 2.1.3)。 **事务边界在本层**:repository 只 flush,commit/rollback 只写在这里。 - 「公司名称 + 负责人」组合唯一(uk_customers_company_owner):先查后写不是 原子操作,并发提交撞唯一键时捕 IntegrityError 判 1062 翻译为 400 (同 M1 的并发兜底模式) - **归属变更的裁决语义是「只影响变更后核算,不回溯历史」(对照表 #19)**: 实现为同一事务内「更新 customers.owner_personnel_id + 插入一条 ownership_change_logs」,一次 commit —— 两步拆开提交时,中途失败会出现 「主表已换人、留痕缺失」的静默不一致,事后追溯无据可查 - 归属调整只有 ``change_ownership`` 一条路径;编辑端点不触归属, 防止出现「改归属不留痕」的旁路 """ import logging from sqlalchemy import Row from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.constants import ( ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE, CUSTOMER_TYPES, MODULE_CUSTOMER, ) from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageQuery, PageResult from app.models import Customer, OwnershipChangeLog, Personnel, User from app.models.customer import OWNER_TYPE_CUSTOMER from app.repositories import customer_repository, personnel_repository from app.schemas.customer import ( CustomerCreateIn, CustomerOut, CustomerOwnershipIn, CustomerQuery, CustomerUpdateIn, OwnershipChangeLogOut, ) from app.services import audit_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。""" return CustomerOut( id=customer.id, company_name=customer.company_name, owner_name=customer.owner_name, contact_person=customer.contact_person, contact_phone=customer.contact_phone, address=customer.address, industry=customer.industry, customer_type=customer.customer_type, owner_personnel_id=customer.owner_personnel_id, owner_personnel_name=owner_name, remark=customer.remark, ) def _to_log_out(row: Row) -> OwnershipChangeLogOut: """留痕行 (留痕, 旧归属人名, 新归属人名) 转响应模型。""" log, old_name, new_name = row return OwnershipChangeLogOut( id=log.id, customer_id=log.customer_id, old_owner_personnel_id=log.old_owner_personnel_id, new_owner_personnel_id=log.new_owner_personnel_id, old_owner_name=old_name, new_owner_name=new_name, change_reason=log.change_reason, operator_id=log.operator_id, operator_name=log.operator_name, create_time=log.create_time, ) def _ensure_customer_type(customer_type: str) -> None: """客户分类必须是注册表内的三个值之一(前端为受控下拉,后端仍须校验)。 customer_type 是 M3 计提规则匹配的锚点,脏值会让该客户在核算时匹配不到 任何计提规则而静默漏算。 """ if customer_type not in CUSTOMER_TYPES: raise BadRequestError("客户分类不合法") 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 def _is_duplicate_entry(exc: IntegrityError) -> bool: """判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。 customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景 会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏; 非 1062 原样上抛,由全局处理器按 500 收敛。 """ args = getattr(exc.orig, "args", ()) return bool(args) and args[0] == 1062 def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: """分页查询客户(附归属人员姓名)。""" rows = customer_repository.list_customers( session, offset=query.offset, limit=query.page_size, keyword=query.keyword, customer_type=query.customer_type, owner_personnel_id=query.owner_personnel_id, ) total = customer_repository.count_customers( session, keyword=query.keyword, customer_type=query.customer_type, owner_personnel_id=query.owner_personnel_id, ) return PageResult[CustomerOut]( records=[_to_out(customer, owner_name) for customer, owner_name in rows], total=total, page_num=query.page_num, page_size=query.page_size, ) def get_customer(session: Session, customer_id: int) -> CustomerOut: """客户详情。 :raises NotFoundError: 客户不存在 """ row = customer_repository.get_with_owner_name(session, customer_id) if row is None: raise NotFoundError("客户不存在") customer, owner_name = row return _to_out(customer, owner_name) def create_customer( session: Session, payload: CustomerCreateIn, *, operator: User, meta: RequestMeta | None = None, ) -> CustomerOut: """新增客户。 :raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 / 「公司名称+负责人」已存在(含并发兜底) """ with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_CUSTOMER, action=ACTION_CREATE, summary=f"新增客户 {payload.company_name}({payload.owner_name})", request_data=payload.model_dump(exclude_none=True), ), meta=meta, ): # 值域与归属校验在审计上下文内:失败(400)也要留痕 —— # 与 personnel_service._validate_refs 移入上下文的口径一致 _ensure_customer_type(payload.customer_type) owner_name: str | None = None if payload.owner_personnel_id is not None: owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name if ( customer_repository.get_by_company_owner( session, payload.company_name, payload.owner_name ) is not None ): raise BadRequestError("该公司名称与负责人的客户已存在") customer = Customer( company_name=payload.company_name, owner_name=payload.owner_name, contact_person=payload.contact_person, contact_phone=payload.contact_phone, address=payload.address, industry=payload.industry, customer_type=payload.customer_type, owner_personnel_id=payload.owner_personnel_id, remark=payload.remark, ) try: customer_repository.create(session, customer) # 响应在 commit 之前构造(同 user_service 顶部说明) out = _to_out(customer, owner_name) session.commit() except IntegrityError as exc: # 先查后写不是原子操作:并发提交同一「公司名称+负责人」会撞 # uk_customers_company_owner,回滚并翻译为可读的 400,而非 500 session.rollback() if _is_duplicate_entry(exc): raise BadRequestError("该公司名称与负责人的客户已存在") from exc raise logger.info("新增客户 | customer_id=%s | company=%s", customer.id, payload.company_name) return out def update_customer( session: Session, customer_id: int, payload: CustomerUpdateIn, *, operator: User, meta: RequestMeta | None = None, ) -> CustomerOut: """编辑客户档案(全量替换)。 **不触归属**:owner_personnel_id 保持原值,调整统一走 ``PUT /{id}/ownership``(唯一写留痕的路径),见 ``CustomerUpdateIn`` 说明。 :raises NotFoundError: 客户不存在 :raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底) """ # 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录, # 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出 row = customer_repository.get_with_owner_name(session, customer_id) customer = row[0] if row else None with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_CUSTOMER, action=ACTION_UPDATE, summary=f"修改客户 {customer.company_name if customer else f'#{customer_id}'}", request_data=payload.model_dump(exclude_none=True), ), meta=meta, ): # 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径) _ensure_customer_type(payload.customer_type) if row is None: raise NotFoundError("客户不存在") customer, owner_name = row duplicate = customer_repository.get_by_company_owner( session, payload.company_name, payload.owner_name ) if duplicate is not None and duplicate.id != customer_id: raise BadRequestError("该公司名称与负责人的客户已存在") customer.company_name = payload.company_name customer.owner_name = payload.owner_name customer.contact_person = payload.contact_person customer.contact_phone = payload.contact_phone customer.address = payload.address customer.industry = payload.industry customer.customer_type = payload.customer_type # owner_personnel_id 刻意不赋值 —— 归属调整只走 /ownership try: # 响应在 commit 之前构造(同 user_service 顶部说明) out = _to_out(customer, owner_name) session.commit() except IntegrityError as exc: # 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写 # 的并发窗口会撞唯一键,兜底同 create_customer session.rollback() if _is_duplicate_entry(exc): raise BadRequestError("该公司名称与负责人的客户已存在") from exc raise logger.info("修改客户 | customer_id=%s | company=%s", customer_id, payload.company_name) return out def delete_customer( session: Session, customer_id: int, *, operator: User, meta: RequestMeta | None = None, ) -> None: """删除客户(物理删除):被业务引用 → 400 引导处理业务单据。 businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除—— 否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位 后由本处补齐,留痕表是无外键固化不参与拦截)。 :raises NotFoundError: 客户不存在 :raises BadRequestError: 客户已被业务单据引用 """ customer = customer_repository.get_by_id(session, customer_id) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_CUSTOMER, action=ACTION_DELETE, summary=f"删除客户 {customer.company_name if customer else f'#{customer_id}'}", ), meta=meta, ): if customer is None: raise NotFoundError("客户不存在") if customer_repository.count_business_refs(session, customer_id): raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") company_name = customer.company_name # 固化:对象随删除失效 try: customer_repository.delete(session, customer) session.commit() except IntegrityError as exc: # 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400 session.rollback() raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) def change_ownership( session: Session, customer_id: int, payload: CustomerOwnershipIn, *, operator: User, meta: RequestMeta | None = None, ) -> CustomerOut: """归属变更:同一事务内「改主表归属 + 插留痕」,一次 commit。 裁决口径为「只影响变更后核算,不回溯历史」(对照表 #19):历史核算结果 已由 M3 台账固化,留痕仅承担追溯职责,故无 effective_date 时点化设计。 :raises NotFoundError: 客户不存在 :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 """ # 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在 # 上下文内进行,失败同样要留下可追溯的记录 row = customer_repository.get_with_owner_name(session, customer_id) old_person = ( personnel_repository.get_by_id(session, row[0].owner_personnel_id) if row is not None and row[0].owner_personnel_id is not None else None ) new_person = ( personnel_repository.get_by_id(session, payload.new_owner_personnel_id) if payload.new_owner_personnel_id is not None else None ) old_label = old_person.personnel_name if old_person else "无" new_label = new_person.personnel_name if new_person else "无" customer_name = row[0].company_name if row else f"#{customer_id}" with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_CUSTOMER, action=ACTION_UPDATE, summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}", request_data=payload.model_dump(exclude_none=True), ), meta=meta, ): if row is None: raise NotFoundError("客户不存在") customer, _ = row if payload.new_owner_personnel_id is not None: _ensure_owner_personnel(session, payload.new_owner_personnel_id) old_owner_id = customer.owner_personnel_id if old_owner_id == payload.new_owner_personnel_id: raise BadRequestError("归属未变化") # 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring) customer.owner_personnel_id = payload.new_owner_personnel_id customer_repository.add_ownership_log( session, OwnershipChangeLog( owner_type=OWNER_TYPE_CUSTOMER, customer_id=customer.id, old_owner_personnel_id=old_owner_id, new_owner_personnel_id=payload.new_owner_personnel_id, change_reason=payload.reason, operator_id=operator.id, operator_name=operator.username, ), ) # 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准 out = _to_out(customer, new_person.personnel_name if new_person else None) session.commit() logger.info( "调整客户归属 | customer_id=%s | old=%s | new=%s", customer_id, old_owner_id, payload.new_owner_personnel_id, ) return out def list_ownership_logs( session: Session, customer_id: int, query: PageQuery, ) -> PageResult[OwnershipChangeLogOut]: """某客户的归属变更留痕(分页,按变更时间倒序)。 :raises NotFoundError: 客户不存在 """ if customer_repository.get_by_id(session, customer_id) is None: raise NotFoundError("客户不存在") rows = customer_repository.list_ownership_logs( session, customer_id=customer_id, offset=query.offset, limit=query.page_size, ) total = customer_repository.count_ownership_logs(session, customer_id=customer_id) return PageResult[OwnershipChangeLogOut]( records=[_to_log_out(row) for row in rows], total=total, page_num=query.page_num, page_size=query.page_size, )