feat(base): 客户管理接口与归属变更留痕(不回溯历史)

- 归属变更同事务更新主表+插留痕;归属调整唯一入口防旁路

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-13 10:35:04 +08:00
1 parent 7eeed85c96
commit b8ba9e4626
4 files changed
+895

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
"""客户管理的请求/响应模型(需求 2.1.3)。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.core.response import PageQuery
class CustomerOut(BaseModel):
"""客户响应(列表、详情与写操作回包共用)。
owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的
脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕
「ID 固化、姓名随联表」同一口径。
"""
model_config = ConfigDict(from_attributes=True)
id: int
company_name: str
owner_name: str
contact_person: str | None = None
contact_phone: str | None = None
address: str | None = None
industry: str | None = None
customer_type: str
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
class CustomerCreateIn(BaseModel):
"""新增客户。
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
``CustomerUpdateIn`` 的说明)。camelCase 别名与蛇形字段名均可绑定,
兼容前端两种拼写。
"""
model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
owner_name: str = Field(
min_length=1,
max_length=64,
description="客户负责人姓名(客户公司的负责人人名,非系统内营销人员)",
)
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
address: str | None = Field(default=None, max_length=255, description="地址")
industry: str | None = Field(default=None, max_length=64, description="所属行业")
customer_type: str = Field(
min_length=1,
max_length=32,
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
)
owner_personnel_id: int | None = Field(
default=None,
ge=1,
alias="ownerPersonnelId",
description="归属负责营销人员ID(初始归属,可空)",
)
remark: str | None = Field(default=None, max_length=255, description="备注")
class CustomerUpdateIn(BaseModel):
"""编辑客户(全量替换,前端表单全量提交)。
**不含 owner_personnel_id** —— 归属调整统一走 ``PUT /{id}/ownership``,
只有那条路径才写归属变更留痕。若在本端点放开该字段,会出现「改了归属
却无留痕」的旁路,破坏留痕的 append-only 完整性;编辑表单即使提交
ownerPersonnelId,pydantic 默认忽略未声明字段,归属以归属端点为准。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
owner_name: str = Field(min_length=1, max_length=64, description="客户负责人姓名")
contact_person: str | None = Field(default=None, max_length=64, description="联系人")
contact_phone: str | None = Field(default=None, max_length=20, description="联系电话")
address: str | None = Field(default=None, max_length=255, description="地址")
industry: str | None = Field(default=None, max_length=64, description="所属行业")
customer_type: str = Field(
min_length=1,
max_length=32,
description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户",
)
remark: str | None = Field(default=None, max_length=255, description="备注")
class CustomerQuery(PageQuery):
"""客户列表查询条件。
customer_type / owner_personnel_id 同时接受蛇形(项目既有契约,见前端
`types.ts` 的说明)与驼峰别名,两种拼写不并存即可。
"""
keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(
default=None, max_length=32, alias="customerType", description="客户分类筛选"
)
owner_personnel_id: int | None = Field(
default=None, ge=1, alias="ownerPersonnelId", description="归属人员ID筛选"
)
class CustomerOwnershipIn(BaseModel):
"""归属变更入参。
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
"""
model_config = ConfigDict(populate_by_name=True)
new_owner_personnel_id: int | None = Field(
default=None,
ge=1,
alias="newOwnerPersonnelId",
description="变更后归属人员ID,null=清空归属",
)
reason: str | None = Field(default=None, max_length=255, description="变更原因")
class OwnershipChangeLogOut(BaseModel):
"""归属变更留痕响应。
新旧归属人员姓名由 LEFT JOIN 带出,人员被删除后为 null;固化的
``*_personnel_id`` 始终原样返回 —— 留痕表对人员 ID 无外键固化
(见 `models/customer.py` 的论证),姓名可随人员消失,ID 不可。
"""
model_config = ConfigDict(from_attributes=True)
id: int
customer_id: int
old_owner_personnel_id: int | None = None
new_owner_personnel_id: int | None = None
old_owner_name: str | None = None
new_owner_name: str | None = None
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime