PayrollManagementSystem/backend/app/services/customer_service.py
liuwenxin b8ba9e4626 feat(base): 客户管理接口与归属变更留痕(不回溯历史)
- 归属变更同事务更新主表+插留痕;归属调整唯一入口防旁路

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:04 +08:00

426 lines
16 KiB
Python
Raw 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.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: 客户分类不合法 / 归属人员不存在或已离职 /
「公司名称+负责人」已存在(含并发兜底)
"""
_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
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,
):
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: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底)
"""
_ensure_customer_type(payload.customer_type)
# 只读预取在审计上下文之前:目标不存在(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,
):
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:
"""删除客户(物理删除)。
**被业务数据引用的保护检查待业务数据表(V5)就位后补充**:customers
当前仅被 ownership_change_logs 以无外键的固化 ID 引用,而留痕本就要求
「被引用方删除后记录仍完整可读」(见 models/customer.py 的固化论证),
故现在删除不会产生悬挂外键。
:raises NotFoundError: 客户不存在
"""
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("客户不存在")
company_name = customer.company_name # 固化:对象随删除失效
customer_repository.delete(session, customer)
session.commit()
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,
)