PayrollManagementSystem/backend/app/schemas/customer.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

158 lines
6.3 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)。"""
from datetime import datetime
from typing import Literal
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
rating_score: int | None = None
rating_time: datetime | None = None
class CustomerCreateIn(BaseModel):
"""新增客户。
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装
与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。
"""
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企业户",
)
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):
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进
``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个
有意义的排序键,白名单足够。
"""
keyword: str | None = Field(
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
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 整数)")
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