PayrollManagementSystem/backend/app/services/customer_service.py
liuwenxin 32b7940067 feat(customer): 客户评级并入客户管理并落库
- customers 表新增 rating_score/rating_time(V13 迁移 + V10 SQL + init-dev 同步)
- 新端点 PUT /customers/{id}/rating:评分是档案属性,只存总分与评级时间(五项明细不落库)
- 列表排序 order_by=rating_score,未评分客户升序/降序都垫底,id desc 兜底稳定分页
- 前端 RatingCalculatorDialog 并入客户管理(评分列 + 评级计算按钮),删除独立页与路由
- 测试:评级保存/越界/排序垫底 3 例,test_customers.py 18 passed;schema 同步 216 passed
2026-10-23 10:59:33 +08:00

505 lines
19 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 dataclasses import dataclass
from datetime import datetime
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,
CustomerRatingIn,
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,
rating_score=customer.rating_score,
rating_time=customer.rating_time,
)
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, query)
total = customer_repository.count_customers(session, query)
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 rate_customer(
session: Session,
customer_id: int,
payload: CustomerRatingIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CustomerOut:
"""保存客户评级(评级计算器「保存评分」)。
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
回答「这个分是什么时候打的」。
:raises NotFoundError: 客户不存在
"""
# 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径)
row = customer_repository.get_with_owner_name(session, customer_id)
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}:{payload.score} 分",
request_data=payload.model_dump(),
),
meta=meta,
):
if row is None:
raise NotFoundError("客户不存在")
customer, owner_name = row
customer.rating_score = payload.score
# 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin
# 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话
customer.rating_time = datetime.now()
# 响应在 commit 之前构造(同 user_service 顶部说明)
out = _to_out(customer, owner_name)
session.commit()
logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score)
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)
@dataclass(frozen=True, slots=True)
class _CustomerOwnershipPrefetch:
"""客户归属变更的审计前只读预取(摘要要客户名与新旧归属人名)。"""
row: Row | None
new_person: Personnel | None
old_label: str
new_label: str
customer_name: str
def _prefetch_customer_ownership(
session: Session, customer_id: int, payload: CustomerOwnershipIn
) -> _CustomerOwnershipPrefetch:
"""审计上下文之前的只读预取(预取不写库,失败路径也要有可读摘要)。"""
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
)
return _CustomerOwnershipPrefetch(
row=row,
new_person=new_person,
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}",
)
def _validate_customer_ownership(
session: Session, customer: Customer, payload: CustomerOwnershipIn
) -> int | None:
"""校验新归属人(须在职)并判定归属是否变化,返回变更前归属人 ID。"""
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("归属未变化")
return old_owner_id
def _customer_ownership_log(
customer: Customer,
payload: CustomerOwnershipIn,
old_owner_id: int | None,
operator: User,
) -> OwnershipChangeLog:
"""客户归属变更留痕行。"""
return 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,
)
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: 归属人员不存在或已离职 / 归属未变化
"""
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
# 上下文内进行,失败同样要留下可追溯的记录
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CUSTOMER,
action=ACTION_UPDATE,
summary=(
f"调整客户归属 {prefetch.customer_name}:"
f"{prefetch.old_label} → {prefetch.new_label}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if prefetch.row is None:
raise NotFoundError("客户不存在")
customer, _ = prefetch.row
old_owner_id = _validate_customer_ownership(session, customer, payload)
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log(
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
)
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.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,
)