wip: 客户评级弹窗化/公司信息精简/参数中文化/品牌站点同步(基于 927592b)
This commit is contained in:
1 parent
927592b472
commit
27533eb465
43 files changed
+2959
-434
No files matched your search
@@ -1,12 +1,14 @@
|
||||
"""客户评级等级配置业务编排。
|
||||
"""客户评级业务编排(等级映射配置 + 每客户评级落库)。
|
||||
|
||||
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
|
||||
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
|
||||
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
|
||||
评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户
|
||||
落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的
|
||||
**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级,
|
||||
max_score 仅展示。两条硬约束(违反会算错等级):
|
||||
|
||||
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
|
||||
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
|
||||
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
|
||||
到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分
|
||||
0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿
|
||||
口径)
|
||||
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
|
||||
失效。判定为**闭区间两两相交**:任一其他等级满足
|
||||
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
|
||||
@@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反
|
||||
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
|
||||
靠前的等级,相邻无歧义
|
||||
|
||||
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
|
||||
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
|
||||
等级(前端已对应提示),故不强制保留任何一条。
|
||||
删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不
|
||||
回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空
|
||||
后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -33,9 +35,15 @@ from app.core.constants import (
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import CustomerRatingGrade, User
|
||||
from app.repositories import rating_repository
|
||||
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||
from app.models import CustomerRating, CustomerRatingGrade, User
|
||||
from app.repositories import customer_rating_repository, customer_repository, rating_repository
|
||||
from app.schemas.rating import (
|
||||
CustomerRatingOut,
|
||||
CustomerRatingSaveIn,
|
||||
RatingGradeCreateIn,
|
||||
RatingGradeOut,
|
||||
RatingGradeUpdateIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
@@ -44,6 +52,8 @@ logger = logging.getLogger(__name__)
|
||||
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
|
||||
_SCORE_FLOOR = Decimal("0")
|
||||
_SCORE_CEIL = Decimal("100")
|
||||
# 单项评分上界(五项各 0-20,总分 0-100)
|
||||
_ITEM_SCORE_CEIL = Decimal("20")
|
||||
|
||||
|
||||
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
||||
@@ -252,10 +262,11 @@ def delete_grade(
|
||||
) -> None:
|
||||
"""物理删除评级等级。
|
||||
|
||||
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
|
||||
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
|
||||
等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见
|
||||
``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除
|
||||
且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。
|
||||
|
||||
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
|
||||
**允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示),
|
||||
不强制保留任何一条。
|
||||
|
||||
:raises NotFoundError: 评级等级不存在
|
||||
@@ -278,3 +289,164 @@ def delete_grade(
|
||||
rating_repository.delete(session, grade)
|
||||
session.commit()
|
||||
logger.info("删除评级等级 | grade_name=%s", grade_name)
|
||||
|
||||
|
||||
# ==================== 客户评级(每客户一行,落库) ====================
|
||||
|
||||
|
||||
def resolve_grade(
|
||||
grades: list[CustomerRatingGrade], total_score: Decimal
|
||||
) -> CustomerRatingGrade | None:
|
||||
"""等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。
|
||||
|
||||
与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较
|
||||
——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部
|
||||
下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。
|
||||
"""
|
||||
# id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None)
|
||||
for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)):
|
||||
if grade.min_score <= total_score:
|
||||
return grade
|
||||
return None
|
||||
|
||||
|
||||
def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut:
|
||||
"""客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
||||
return CustomerRatingOut(
|
||||
customer_id=rating.customer_id,
|
||||
score_ship_amount=str(rating.score_ship_amount),
|
||||
score_receipt_rate=str(rating.score_receipt_rate),
|
||||
score_profit_rate=str(rating.score_profit_rate),
|
||||
score_contract_cooperation=str(rating.score_contract_cooperation),
|
||||
score_site_construction=str(rating.score_site_construction),
|
||||
total_score=str(rating.total_score),
|
||||
grade_name=rating.grade_name,
|
||||
remark=rating.remark,
|
||||
scorer_name=rating.scorer_name,
|
||||
create_time=rating.create_time,
|
||||
update_time=rating.update_time,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_item_scores(scores: list[Decimal]) -> None:
|
||||
"""单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。
|
||||
|
||||
须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。
|
||||
"""
|
||||
for score in scores:
|
||||
if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL):
|
||||
raise BadRequestError("单项评分须在 0~20 之间")
|
||||
|
||||
|
||||
def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None:
|
||||
"""查某客户当前评级;未评级返回 None(接口信封 data 为 null)。
|
||||
|
||||
「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。
|
||||
|
||||
:raises NotFoundError: 客户不存在
|
||||
"""
|
||||
customer = customer_repository.get_by_id(session, customer_id)
|
||||
if customer is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
rating = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||
return _to_rating_out(rating) if rating is not None else None
|
||||
|
||||
|
||||
def save_customer_rating(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
payload: CustomerRatingSaveIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> CustomerRatingOut:
|
||||
"""保存客户评级(upsert:首次创建,再次保存整体覆盖)。
|
||||
|
||||
总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按
|
||||
保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改
|
||||
不影响该行(见 ``models/customer_rating.py``)。
|
||||
|
||||
:raises NotFoundError: 客户不存在
|
||||
:raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕)
|
||||
"""
|
||||
# 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录,
|
||||
# 摘要需要客户名
|
||||
customer = customer_repository.get_by_id(session, customer_id)
|
||||
existing = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_RATING,
|
||||
action=ACTION_CREATE if existing is None else ACTION_UPDATE,
|
||||
summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级",
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
# 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径)
|
||||
item_scores = [
|
||||
_to_decimal(payload.score_ship_amount),
|
||||
_to_decimal(payload.score_receipt_rate),
|
||||
_to_decimal(payload.score_profit_rate),
|
||||
_to_decimal(payload.score_contract_cooperation),
|
||||
_to_decimal(payload.score_site_construction),
|
||||
]
|
||||
_ensure_item_scores(item_scores)
|
||||
if customer is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
|
||||
total_score = sum(item_scores, _SCORE_FLOOR)
|
||||
grade = resolve_grade(rating_repository.list_all(session), total_score)
|
||||
|
||||
def _apply(row: CustomerRating) -> None:
|
||||
"""把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。"""
|
||||
(
|
||||
row.score_ship_amount,
|
||||
row.score_receipt_rate,
|
||||
row.score_profit_rate,
|
||||
row.score_contract_cooperation,
|
||||
row.score_site_construction,
|
||||
) = item_scores
|
||||
row.total_score = total_score
|
||||
row.grade_name = grade.grade_name if grade is not None else None
|
||||
row.remark = payload.remark
|
||||
row.scorer_id = operator.id
|
||||
row.scorer_name = operator.username
|
||||
|
||||
try:
|
||||
if existing is None:
|
||||
rating = CustomerRating(
|
||||
customer_id=customer_id,
|
||||
scorer_id=operator.id,
|
||||
scorer_name=operator.username,
|
||||
)
|
||||
_apply(rating)
|
||||
customer_rating_repository.create(session, rating)
|
||||
else:
|
||||
_apply(existing)
|
||||
rating = existing
|
||||
out = _to_rating_out(rating)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后写不是原子操作:并发为同一客户首次评级会撞
|
||||
# uk_customer_ratings_customer_id,回滚后转为更新兜底
|
||||
session.rollback()
|
||||
if not is_duplicate_entry(exc):
|
||||
raise
|
||||
raced = customer_rating_repository.get_by_customer_id(session, customer_id)
|
||||
if raced is None:
|
||||
# 1062 但行不在:数据态异常,交全局 IntegrityError 兜底
|
||||
raise
|
||||
_apply(raced)
|
||||
rating = raced
|
||||
out = _to_rating_out(rating)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
"保存客户评级 | customer_id=%s | total=%s | grade=%s",
|
||||
customer_id,
|
||||
out.total_score,
|
||||
out.grade_name,
|
||||
)
|
||||
return out
|
||||
Reference in new issue
Block a user