PayrollManagementSystem/backend/app/services/rating_service.py
liuwenxin 91fc6f6293 fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:48:32 +08:00

278 lines
11 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.

"""客户评级等级配置业务编排。
评级计算器定位(对照表 #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,
)
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.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(唯一键冲突)。
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
500 只留给程序缺陷。
"""
return is_duplicate_entry(exc)
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)
_ensure_no_overlap(rating_repository.list_all(session), new_min, new_max)
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(
rating_repository.list_all(session), new_min, new_max, exclude_id=grade_id
)
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)