2026-10-12 22:35:04 -04:00
|
|
|
|
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
2026-10-22 22:59:33 -04:00
|
|
|
|
from typing import Literal
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-22 22:59:33 -04:00
|
|
|
|
rating_score: int | None = None
|
|
|
|
|
|
rating_time: datetime | None = None
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CustomerCreateIn(BaseModel):
|
|
|
|
|
|
"""新增客户。
|
|
|
|
|
|
|
|
|
|
|
|
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
|
|
|
|
|
|
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
|
2026-10-13 00:01:48 -04:00
|
|
|
|
``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装
|
|
|
|
|
|
与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。
|
2026-10-12 22:35:04 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-10-13 00:01:48 -04:00
|
|
|
|
model_config = ConfigDict(str_strip_whitespace=True)
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
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):
|
2026-10-22 22:59:33 -04:00
|
|
|
|
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
|
|
|
|
|
|
|
|
|
|
|
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
|
|
|
|
|
|
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
|
|
|
|
|
|
有意义的排序键,白名单足够。
|
|
|
|
|
|
"""
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
keyword: str | None = Field(
|
|
|
|
|
|
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
|
|
|
|
|
)
|
2026-10-13 00:01:48 -04:00
|
|
|
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
|
|
|
|
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
2026-10-22 22:59:33 -04:00
|
|
|
|
order_by: Literal["id", "rating_score"] = Field(
|
|
|
|
|
|
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
|
|
|
|
|
)
|
|
|
|
|
|
order: Literal["asc", "desc"] = Field(default="desc", description="排序方向")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CustomerRatingIn(BaseModel):
|
|
|
|
|
|
"""保存客户评级入参(评级计算器「保存评分」)。
|
|
|
|
|
|
|
|
|
|
|
|
score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分,
|
|
|
|
|
|
见对照表 #25 的定位:评分是档案属性而非审计事件)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)")
|
2026-10-12 22:35:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CustomerOwnershipIn(BaseModel):
|
|
|
|
|
|
"""归属变更入参。
|
|
|
|
|
|
|
|
|
|
|
|
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
new_owner_personnel_id: int | None = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
ge=1,
|
|
|
|
|
|
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
|