2026-10-12 22:35:04 -04:00
|
|
|
|
"""客户管理业务编排(需求 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
|
2026-10-22 07:43:20 -04:00
|
|
|
|
from dataclasses import dataclass
|
2026-10-22 22:59:33 -04:00
|
|
|
|
from datetime import datetime
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-10-14 03:17:00 -04:00
|
|
|
|
from app.core.db_errors import is_duplicate_entry
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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,
|
2026-10-22 22:59:33 -04:00
|
|
|
|
CustomerRatingIn,
|
2026-10-12 22:35:04 -04:00
|
|
|
|
CustomerUpdateIn,
|
|
|
|
|
|
OwnershipChangeLogOut,
|
|
|
|
|
|
)
|
2026-10-14 21:49:53 -04:00
|
|
|
|
from app.services import audit_service, personnel_service
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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,
|
2026-10-22 22:59:33 -04:00
|
|
|
|
rating_score=customer.rating_score,
|
|
|
|
|
|
rating_time=customer.rating_time,
|
2026-10-12 22:35:04 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-10-14 21:49:53 -04:00
|
|
|
|
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
|
|
|
|
|
return personnel_service.ensure_active(session, personnel_id, label="归属")
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
2026-10-14 03:17:00 -04:00
|
|
|
|
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
|
|
|
|
|
|
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
|
2026-10-14 06:48:32 -04:00
|
|
|
|
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
|
2026-10-12 22:35:04 -04:00
|
|
|
|
"""
|
2026-10-14 03:17:00 -04:00
|
|
|
|
return is_duplicate_entry(exc)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
|
|
|
|
|
|
"""分页查询客户(附归属人员姓名)。"""
|
2026-10-22 07:43:20 -04:00
|
|
|
|
rows = customer_repository.list_customers(session, query)
|
|
|
|
|
|
total = customer_repository.count_customers(session, query)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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,
|
|
|
|
|
|
):
|
2026-10-12 23:22:34 -04:00
|
|
|
|
# 值域与归属校验在审计上下文内:失败(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
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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,
|
|
|
|
|
|
):
|
2026-10-12 23:22:34 -04:00
|
|
|
|
# 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径)
|
|
|
|
|
|
_ensure_customer_type(payload.customer_type)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 22:59:33 -04:00
|
|
|
|
def rate_customer(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
customer_id: int,
|
|
|
|
|
|
payload: CustomerRatingIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> CustomerOut:
|
|
|
|
|
|
"""保存客户评级(评级计算器「保存评分」)。
|
|
|
|
|
|
|
|
|
|
|
|
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
|
|
|
|
|
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
|
|
|
|
|
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
2026-10-23 04:18:47 -04:00
|
|
|
|
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
|
2026-10-22 22:59:33 -04:00
|
|
|
|
|
|
|
|
|
|
: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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 22:35:04 -04:00
|
|
|
|
def delete_customer(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
customer_id: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> None:
|
2026-10-13 05:06:46 -04:00
|
|
|
|
"""删除客户(物理删除):被业务引用 → 400 引导处理业务单据。
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
2026-10-13 05:06:46 -04:00
|
|
|
|
businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除——
|
|
|
|
|
|
否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位
|
|
|
|
|
|
后由本处补齐,留痕表是无外键固化不参与拦截)。
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
:raises NotFoundError: 客户不存在
|
2026-10-13 05:06:46 -04:00
|
|
|
|
:raises BadRequestError: 客户已被业务单据引用
|
2026-10-12 22:35:04 -04:00
|
|
|
|
"""
|
|
|
|
|
|
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("客户不存在")
|
2026-10-13 05:06:46 -04:00
|
|
|
|
if customer_repository.count_business_refs(session, customer_id):
|
|
|
|
|
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据")
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
company_name = customer.company_name # 固化:对象随删除失效
|
2026-10-13 05:06:46 -04:00
|
|
|
|
try:
|
|
|
|
|
|
customer_repository.delete(session, customer)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
|
# 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400
|
|
|
|
|
|
session.rollback()
|
|
|
|
|
|
raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc
|
2026-10-12 22:35:04 -04:00
|
|
|
|
logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
@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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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: 归属人员不存在或已离职 / 归属未变化
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在
|
|
|
|
|
|
# 上下文内进行,失败同样要留下可追溯的记录
|
2026-10-22 07:43:20 -04:00
|
|
|
|
prefetch = _prefetch_customer_ownership(session, customer_id, payload)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_CUSTOMER,
|
|
|
|
|
|
action=ACTION_UPDATE,
|
2026-10-22 07:43:20 -04:00
|
|
|
|
summary=(
|
|
|
|
|
|
f"调整客户归属 {prefetch.customer_name}:"
|
|
|
|
|
|
f"{prefetch.old_label} → {prefetch.new_label}"
|
|
|
|
|
|
),
|
2026-10-12 22:35:04 -04:00
|
|
|
|
request_data=payload.model_dump(exclude_none=True),
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
2026-10-22 07:43:20 -04:00
|
|
|
|
if prefetch.row is None:
|
2026-10-12 22:35:04 -04:00
|
|
|
|
raise NotFoundError("客户不存在")
|
2026-10-22 07:43:20 -04:00
|
|
|
|
customer, _ = prefetch.row
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
# 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring)
|
|
|
|
|
|
customer.owner_personnel_id = payload.new_owner_personnel_id
|
|
|
|
|
|
customer_repository.add_ownership_log(
|
2026-10-22 07:43:20 -04:00
|
|
|
|
session, _customer_ownership_log(customer, payload, old_owner_id, operator)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
)
|
|
|
|
|
|
# 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准
|
2026-10-22 07:43:20 -04:00
|
|
|
|
out = _to_out(customer, prefetch.new_person.personnel_name if prefetch.new_person else None)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|