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
This commit is contained in:
1 parent
b9781f7d3f
commit
32b7940067
17 files changed
+917
-512
No files matched your search
@@ -21,6 +21,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -102,6 +103,31 @@ def delete_customer(
|
||||
return ok(message="客户已删除")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{customer_id}/rating",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
summary="保存客户评级",
|
||||
)
|
||||
def rate_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerRatingIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CustomerOut]:
|
||||
"""保存评级总分(评级计算器「保存评分」)。
|
||||
|
||||
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
|
||||
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
|
||||
"""
|
||||
return ok(
|
||||
customer_service.rate_customer(
|
||||
session, customer_id, payload, operator=current_user, meta=meta
|
||||
),
|
||||
message="评级已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{customer_id}/ownership",
|
||||
response_model=ApiResponse[CustomerOut],
|
||||
|
||||
@@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
rating_score: Mapped[int | None] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)",
|
||||
)
|
||||
rating_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)",
|
||||
)
|
||||
|
||||
|
||||
class OwnershipChangeLog(Base):
|
||||
|
||||
@@ -52,10 +52,12 @@ def list_customers(session: Session, query: CustomerQuery) -> list[Row]:
|
||||
|
||||
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
|
||||
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
|
||||
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
|
||||
排序键由查询白名单决定(见 ``CustomerQuery.order_by``):
|
||||
id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。
|
||||
"""
|
||||
stmt = _apply_filters(_base_select(), query)
|
||||
stmt = stmt.order_by(Customer.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
stmt = _apply_order(stmt, query)
|
||||
stmt = stmt.offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
@@ -131,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]:
|
||||
)
|
||||
|
||||
|
||||
def _apply_order(stmt: Select, query: CustomerQuery) -> Select:
|
||||
"""应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。
|
||||
|
||||
MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」
|
||||
不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null
|
||||
挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底,
|
||||
保证同分行的次序稳定(分页不跳行)。
|
||||
"""
|
||||
if query.order_by == "rating_score":
|
||||
score = Customer.rating_score
|
||||
if query.order == "asc":
|
||||
stmt = stmt.order_by(score.is_(None), score.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(score.desc())
|
||||
return stmt.order_by(Customer.id.desc())
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""客户管理的请求/响应模型(需求 2.1.3)。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -28,6 +29,8 @@ class CustomerOut(BaseModel):
|
||||
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):
|
||||
@@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel):
|
||||
|
||||
|
||||
class CustomerQuery(PageQuery):
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
||||
"""客户列表查询条件(字段名蛇形直出,与前端 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):
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -38,6 +39,7 @@ from app.schemas.customer import (
|
||||
CustomerOut,
|
||||
CustomerOwnershipIn,
|
||||
CustomerQuery,
|
||||
CustomerRatingIn,
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
@@ -61,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -263,6 +267,53 @@ def update_customer(
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user