PayrollManagementSystem/backend/app/services/customer_service.py
liuwenxin 60f6af6494 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>
2026-10-15 09:49:53 +08:00

423 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""客户管理业务编排(需求 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.db_errors import is_duplicate_entry
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, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
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:
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label="归属")
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
"""
return is_duplicate_entry(exc)
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,
)