2026-10-13 00:01:47 -04:00
|
|
|
|
"""客户评级等级配置业务编排。
|
|
|
|
|
|
|
|
|
|
|
|
评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库
|
|
|
|
|
|
——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个
|
|
|
|
|
|
min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级):
|
|
|
|
|
|
|
|
|
|
|
|
- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串
|
|
|
|
|
|
到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内**
|
|
|
|
|
|
完成,失败的 400 同样留痕(2026-10-13 定稿口径)
|
|
|
|
|
|
- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表
|
|
|
|
|
|
失效。判定为**闭区间两两相交**:任一其他等级满足
|
|
|
|
|
|
``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。
|
|
|
|
|
|
边界相邻不算重叠(前段 max=89、后段 min=90)—— 种子 A/B/C/D 即按
|
|
|
|
|
|
相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order
|
|
|
|
|
|
靠前的等级,相邻无歧义
|
|
|
|
|
|
|
|
|
|
|
|
删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不
|
|
|
|
|
|
回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出
|
|
|
|
|
|
等级(前端已对应提示),故不强制保留任何一条。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.constants import (
|
|
|
|
|
|
ACTION_CREATE,
|
|
|
|
|
|
ACTION_DELETE,
|
|
|
|
|
|
ACTION_UPDATE,
|
|
|
|
|
|
MODULE_RATING,
|
|
|
|
|
|
)
|
2026-10-14 03:17:00 -04:00
|
|
|
|
from app.core.db_errors import is_duplicate_entry
|
2026-10-13 00:01:47 -04:00
|
|
|
|
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.services import audit_service
|
|
|
|
|
|
from app.services.audit_service import AuditRecord, RequestMeta
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# 分值域上下界(Decimal 常量,禁用 float 参与比较)
|
|
|
|
|
|
_SCORE_FLOOR = Decimal("0")
|
|
|
|
|
|
_SCORE_CEIL = Decimal("100")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
|
|
|
|
|
|
"""ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。"""
|
|
|
|
|
|
return RatingGradeOut(
|
|
|
|
|
|
id=grade.id,
|
|
|
|
|
|
grade_name=grade.grade_name,
|
|
|
|
|
|
min_score=str(grade.min_score),
|
|
|
|
|
|
max_score=str(grade.max_score),
|
|
|
|
|
|
sort_order=grade.sort_order,
|
|
|
|
|
|
remark=grade.remark,
|
|
|
|
|
|
create_time=grade.create_time,
|
|
|
|
|
|
update_time=grade.update_time,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_decimal(value: str) -> Decimal:
|
|
|
|
|
|
"""数字字符串转 Decimal。
|
|
|
|
|
|
|
|
|
|
|
|
入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float
|
|
|
|
|
|
二进制近似,分值红线在最后一道转换处仍然成立。schema 已保证可解析,
|
|
|
|
|
|
此处不再重复校验。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return Decimal(str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_score_range(min_score: Decimal, max_score: Decimal) -> None:
|
|
|
|
|
|
"""区间合法性:0 ≤ min_score ≤ max_score ≤ 100。
|
|
|
|
|
|
|
|
|
|
|
|
超界/倒挂任一不满足即「分值区间不合法」——统一一条消息不区分具体
|
|
|
|
|
|
越界方式,前端据此整体提示。须在审计上下文内调用,失败也要留痕。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not (_SCORE_FLOOR <= min_score <= max_score <= _SCORE_CEIL):
|
|
|
|
|
|
raise BadRequestError("分值区间不合法")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_no_overlap(
|
|
|
|
|
|
grades: list[CustomerRatingGrade],
|
|
|
|
|
|
new_min: Decimal,
|
|
|
|
|
|
new_max: Decimal,
|
|
|
|
|
|
*,
|
|
|
|
|
|
exclude_id: int | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""新区间与既有等级**不得重叠**(闭区间两两相交判定)。
|
|
|
|
|
|
|
|
|
|
|
|
任一其他等级满足 ``other.min_score <= new_max 且 new_min <= other.max_score``
|
|
|
|
|
|
即重叠,400 并点名冲突等级,便于前端定位到具体行。边界相邻不算重叠
|
|
|
|
|
|
(见模块 docstring)。exclude_id 供编辑时排除自身。
|
|
|
|
|
|
|
|
|
|
|
|
:raises BadRequestError: 分值区间与等级 X 重叠
|
|
|
|
|
|
"""
|
|
|
|
|
|
for other in grades:
|
|
|
|
|
|
if exclude_id is not None and other.id == exclude_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if other.min_score <= new_max and new_min <= other.max_score:
|
|
|
|
|
|
raise BadRequestError(f"分值区间与等级 {other.grade_name} 重叠")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
|
|
|
|
|
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
|
|
|
|
|
|
|
2026-10-14 06:48:32 -04:00
|
|
|
|
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
|
|
|
|
|
|
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
|
|
|
|
|
|
500 只留给程序缺陷。
|
2026-10-13 00:01:47 -04:00
|
|
|
|
"""
|
2026-10-14 03:17:00 -04:00
|
|
|
|
return is_duplicate_entry(exc)
|
2026-10-13 00:01:47 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raise_if_duplicate(exc: IntegrityError) -> None:
|
|
|
|
|
|
"""唯一键冲突统一转业务提示;其余完整性错误原样重抛。"""
|
|
|
|
|
|
if _is_duplicate_entry(exc):
|
|
|
|
|
|
raise BadRequestError("该等级名称已存在") from exc
|
|
|
|
|
|
raise exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_grades(session: Session) -> list[RatingGradeOut]:
|
|
|
|
|
|
"""全量等级列表,按判定顺序(sort_order 升序)直出。
|
|
|
|
|
|
|
|
|
|
|
|
不分页:等级映射是判定表,数量个位数,分页无意义。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return [_to_out(grade) for grade in rating_repository.list_all(session)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_grade(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
payload: RatingGradeCreateIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> RatingGradeOut:
|
|
|
|
|
|
"""新增评级等级。
|
|
|
|
|
|
|
|
|
|
|
|
区间须 0≤min≤max≤100 且与既有等级不重叠(新增同样判重叠:判定表
|
|
|
|
|
|
语义下重叠即脏配置,没有「先停用旧区间」之类的两阶段配置手段)。
|
|
|
|
|
|
|
|
|
|
|
|
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
|
|
|
|
|
|
"""
|
|
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_RATING,
|
|
|
|
|
|
action=ACTION_CREATE,
|
|
|
|
|
|
summary=f"新增评级等级 {payload.grade_name}",
|
|
|
|
|
|
request_data=payload.model_dump(exclude_none=True),
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
# 值域校验在审计上下文内:分值非法的 400 也要留痕(成功与失败都记)
|
|
|
|
|
|
new_min = _to_decimal(payload.min_score)
|
|
|
|
|
|
new_max = _to_decimal(payload.max_score)
|
|
|
|
|
|
_ensure_score_range(new_min, new_max)
|
2026-10-14 07:01:11 -04:00
|
|
|
|
_ensure_no_overlap(rating_repository.list_all(session, for_update=True), new_min, new_max)
|
2026-10-13 00:01:47 -04:00
|
|
|
|
if rating_repository.get_by_name(session, payload.grade_name) is not None:
|
|
|
|
|
|
raise BadRequestError("该等级名称已存在")
|
|
|
|
|
|
|
|
|
|
|
|
grade = CustomerRatingGrade(
|
|
|
|
|
|
grade_name=payload.grade_name,
|
|
|
|
|
|
min_score=new_min,
|
|
|
|
|
|
max_score=new_max,
|
|
|
|
|
|
sort_order=payload.sort_order,
|
|
|
|
|
|
remark=payload.remark,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
rating_repository.create(session, grade)
|
|
|
|
|
|
# 响应与后置动作全部在 commit 之前完成(见 product_service 同处说明)
|
|
|
|
|
|
out = _to_out(grade)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
|
# 先查后写不是原子操作:并发提交同名等级会撞 uk_customer_rating_grades_grade_name
|
|
|
|
|
|
session.rollback()
|
|
|
|
|
|
_raise_if_duplicate(exc)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("新增评级等级 | grade_id=%s | name=%s", grade.id, payload.grade_name)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_grade(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
grade_id: int,
|
|
|
|
|
|
payload: RatingGradeUpdateIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> RatingGradeOut:
|
|
|
|
|
|
"""编辑评级等级(全量替换)。
|
|
|
|
|
|
|
|
|
|
|
|
区间与其他等级不得重叠 —— 重叠判定以**修改后的区间**对全部既有等级
|
|
|
|
|
|
(排除自身)两两相交,命中即 400 并点名冲突等级。
|
|
|
|
|
|
|
|
|
|
|
|
:raises NotFoundError: 评级等级不存在
|
|
|
|
|
|
:raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在
|
|
|
|
|
|
"""
|
|
|
|
|
|
grade = rating_repository.get_by_id(session, grade_id)
|
|
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_RATING,
|
|
|
|
|
|
action=ACTION_UPDATE,
|
|
|
|
|
|
summary=f"修改评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
|
|
|
|
|
|
request_data=payload.model_dump(exclude_none=True),
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
# 值域校验在审计上下文内(同 create 口径)
|
|
|
|
|
|
new_min = _to_decimal(payload.min_score)
|
|
|
|
|
|
new_max = _to_decimal(payload.max_score)
|
|
|
|
|
|
_ensure_score_range(new_min, new_max)
|
|
|
|
|
|
if grade is None:
|
|
|
|
|
|
raise NotFoundError("评级等级不存在")
|
|
|
|
|
|
|
|
|
|
|
|
_ensure_no_overlap(
|
2026-10-15 05:25:24 -04:00
|
|
|
|
rating_repository.list_all(session, for_update=True),
|
|
|
|
|
|
new_min,
|
|
|
|
|
|
new_max,
|
|
|
|
|
|
exclude_id=grade_id,
|
2026-10-13 00:01:47 -04:00
|
|
|
|
)
|
|
|
|
|
|
existed = rating_repository.get_by_name(session, payload.grade_name)
|
|
|
|
|
|
if existed is not None and existed.id != grade_id:
|
|
|
|
|
|
raise BadRequestError("该等级名称已存在")
|
|
|
|
|
|
|
|
|
|
|
|
grade.grade_name = payload.grade_name
|
|
|
|
|
|
grade.min_score = new_min
|
|
|
|
|
|
grade.max_score = new_max
|
|
|
|
|
|
grade.sort_order = payload.sort_order
|
|
|
|
|
|
grade.remark = payload.remark
|
|
|
|
|
|
out = _to_out(grade)
|
|
|
|
|
|
try:
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
|
# 改名可能撞 uk_customer_rating_grades_grade_name(本行改值与他人既有数据重合)
|
|
|
|
|
|
session.rollback()
|
|
|
|
|
|
_raise_if_duplicate(exc)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("修改评级等级 | grade_id=%s | name=%s", grade_id, payload.grade_name)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_grade(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
grade_id: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""物理删除评级等级。
|
|
|
|
|
|
|
|
|
|
|
|
等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定
|
|
|
|
|
|
只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。
|
|
|
|
|
|
|
|
|
|
|
|
**允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示),
|
|
|
|
|
|
不强制保留任何一条。
|
|
|
|
|
|
|
|
|
|
|
|
:raises NotFoundError: 评级等级不存在
|
|
|
|
|
|
"""
|
|
|
|
|
|
grade = rating_repository.get_by_id(session, grade_id)
|
|
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_RATING,
|
|
|
|
|
|
action=ACTION_DELETE,
|
|
|
|
|
|
summary=f"删除评级等级 {grade.grade_name if grade else f'#{grade_id}'}",
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
if grade is None:
|
|
|
|
|
|
raise NotFoundError("评级等级不存在")
|
|
|
|
|
|
|
|
|
|
|
|
grade_name = grade.grade_name # 固化:对象随删除失效
|
|
|
|
|
|
rating_repository.delete(session, grade)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
logger.info("删除评级等级 | grade_name=%s", grade_name)
|