feat(customer): 客户列表展示等级徽标并统一等级不落库口径

评级计算器并入客户管理后,列表只有分数看不出档位,本批补上等级徽标:
- 抽出 utils/rating.ts 的 resolveGrade(按 sort_order 升序取首个
  min_score ≤ 总分的等级,单边判定避免区间边界歧义),列表页与弹窗共用
- 等级映射按需拉取并缓存在内存,等级配置改动后回调父页重拉,避免
  「同一分数在弹窗是 B、在列表是 C」的两处口径分歧
- 徽标色调按等级名首字母映射,自定义等级名落回中性灰(等级名可配置,
  不能靠写死枚举判语义色)
- 悬浮提示给出等级区间与评级时间

同时订正三处与实现不符的注释:rate_customer 实际只写 rating_score 与
rating_time,等级由分数按当前映射实时推导,三处却写成「后端判定并固化等级
名快照」,v13 迁移 docstring 还引用了并不存在的 v14(d5b8f2a46c71)。
纯注释改动,无逻辑变更。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-23 16:18:47 +08:00
parent 61fe5cca98
commit 111ac5425a
7 changed files with 220 additions and 42 deletions

View File

@ -9,9 +9,9 @@ Create Date: 2026-10-23 10:30:00.000000
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
五项评分明细与命中等级不落库:等级映射是可变判定表,回溯「当时命中的
等级」需要快照设计,超出档案属性的范围;评级时间已足够回答「这个分是
什么时候打的」。
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
的等级随之更新),因此本迁移只落总分与评级时间两列。
"""
from collections.abc import Sequence

View File

@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati
def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None:
"""按主键查询。
等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update``
锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。
等级表无外键引用(客户档案只存总分,等级由分数按当前映射实时推导,
不存等级 id),无需 ``for_update`` 锁行 —— product_repository 锁行防的
是并发引用悬空,此处不存在该风险:删掉一个等级,只是以后不再判到它。
"""
return session.get(CustomerRatingGrade, grade_id)
@ -60,6 +61,10 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade:
def delete(session: Session, grade: CustomerRatingGrade) -> None:
"""物理删除等级。等级表无下游引用,调用方无需引用保护校验。"""
"""物理删除等级。
等级表无外键引用(客户档案只存总分,不存等级 id),调用方无需引用保护
校验:删掉等级只是「以后不再判到它」,已存的总分不受影响。
"""
session.delete(grade)
session.flush()

View File

@ -280,7 +280,7 @@ def rate_customer(
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
回答「这个分是什么时候打的」。
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
:raises NotFoundError: 客户不存在
"""

View File

@ -65,7 +65,13 @@ export interface CustomerOwnershipParams {
reason?: string | null
}
/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */
/**
* 保存评级入参:只传总分。
*
* 等级不落库,也不由客户端传入 —— 后端只存总分与评级时间,等级由分数按
* 当前等级映射实时推导(前端 utils/rating.ts)。不收客户端传来的等级,
* 否则改请求体就能把 30 分存成 A 级。
*/
export interface CustomerRatingParams {
score: number
}
@ -127,7 +133,7 @@ export function changeOwnership(
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
}
/** 保存评级(评级计算器「保存评分」;只存总分与评级时间,评分只走本端点) */
/** 保存评级(评级计算器「保存评分」;后端存总分+评级时间,评分只走本端点) */
export function rateCustomer(
customerId: number,
data: CustomerRatingParams,

View File

@ -0,0 +1,48 @@
import type { RatingGradeItem } from '@/api/ratings'
/**
* 评级等级判定(纯函数,客户列表与评级弹窗共用同一口径)。
*
* 规则:按 sort_order 升序取首个「min_score ≤ 总分」的等级,max_score 仅作
* 展示 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」
* 两套写法间的重叠/遗漏歧义。
*
* 后端 min_score 是字符串直出(分值红线:分值全程不进 float),比较前必须
* parseFloat 转数值,禁止字符串直接比大小("90.0" 的字典序比较会得出错误
* 结果)。无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/
export function resolveGrade(
grades: RatingGradeItem[],
totalScore: number,
): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
}
}
return null
}
/**
* 等级名称 → 色调后缀(客户列表的等级徽标用)。
*
* 按名称首字母映射到体系语义色:A 优秀(绿)、B 良好(靛蓝)、C 合格
* (琥珀)、D 待改进(砖红);自定义等级名不在此列,落回中性灰 —— 等级
* 名称是业务可配置的,不能靠写死的枚举判定语义,未知即中性最安全。
*/
export function gradeTone(gradeName: string): '' | 'ok' | 'lock' | 'wait' | 'reject' {
const initial = gradeName.trim().charAt(0).toUpperCase()
switch (initial) {
case 'A':
return 'ok'
case 'B':
return 'lock'
case 'C':
return 'wait'
case 'D':
return 'reject'
default:
return ''
}
}

View File

@ -13,6 +13,7 @@ import {
import { rateCustomer, type CustomerItem } from '@/api/customers'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { formatDateTime } from '@/utils/format'
import { resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error'
/**
@ -20,9 +21,11 @@ import { handledError } from '@/utils/error'
*
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时
* 命中的等级」需要快照设计,超出档案属性的范围)。
* rating_time)。等级**不落库**,由分数 + 当前等级映射实时算(判定函数在
* utils/rating.ts,与列表页共用同一口径);等级映射日后调整,全部客户的
* 等级随之更新,档案只负责记住「这个分是什么时候打的」。
*
* 五项打分明细同样不落库 —— 那是打分过程的中间量,没有下游消费方。
*
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
@ -38,27 +41,16 @@ const props = defineProps<{
const emit = defineEmits<{
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
saved: [item: CustomerItem]
/**
* 等级映射被改动(新增/编辑/删除)。
*
* 父页的等级徽标靠自己缓存的一份映射把分数翻成等级,抽屉里改完配置
* 那份缓存就旧了 —— 必须通知父页重拉,否则「同一分数在弹窗里是 B、
* 在列表里还是 C」,两处判定口径出现可见的分歧。
*/
gradesChanged: []
}>()
// ==================== 等级判定(纯函数) ====================
/**
* 等级判定:按 sort_order 升序取首个 min_score ≤ 总分的等级。
*
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
}
}
return null
}
// ==================== 评分计算(纯前端实时计算) ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
@ -111,6 +103,12 @@ async function loadGrades(): Promise<void> {
}
}
/** 配置被改动后的统一收尾:重载本弹窗的判定列表 + 通知父页刷新徽标缓存 */
async function reloadAfterGradeChange(): Promise<void> {
await loadGrades()
emit('gradesChanged')
}
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 弹窗打开 / 关闭 ====================
@ -130,8 +128,11 @@ const saving = ref(false)
const archivedScore = computed(() => {
const target = props.customer
if (!target || target.rating_score === null) return null
// 等级不落库,按当前映射由已存分数实时算(与列表页同一口径)
const grade = resolveGrade(grades.value, target.rating_score)
return {
score: target.rating_score,
grade: grade?.grade_name ?? null,
time: target.rating_time ? formatDateTime(target.rating_time) : '',
}
})
@ -237,7 +238,7 @@ async function saveGrade(): Promise<void> {
}
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
await reloadAfterGradeChange()
} catch (requestError) {
handledError(requestError)
} finally {
@ -253,7 +254,7 @@ function removeGrade(row: RatingGradeItem): void {
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => loadGrades(),
after: () => reloadAfterGradeChange(),
})
}
</script>
@ -276,6 +277,9 @@ function removeGrade(row: RatingGradeItem): void {
<div v-if="archivedScore" class="archived-line">
档案评分:<b>{{ archivedScore.score }}</b> 分
<template v-if="archivedScore.grade">
· 等级 <b>{{ archivedScore.grade }}</b>
</template>
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
<span class="archived-tip">重新保存将覆盖</span>
</div>

View File

@ -5,11 +5,13 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as customerApi from '@/api/customers'
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
import * as personnelApi from '@/api/personnel'
import { listRatingGrades, type RatingGradeItem } from '@/api/ratings'
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { gradeTone, resolveGrade } from '@/utils/rating'
import { handledError } from '@/utils/error'
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
@ -23,7 +25,8 @@ import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分写入档案字段 rating_score/rating_time
* 保存评分只写档案字段 rating_score / rating_time;等级不落库,由分数按
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
*/
const { confirmAndRun } = useConfirmAction()
@ -102,6 +105,48 @@ function onRatingSaved(item: CustomerItem): void {
if (index !== -1) records.value[index] = item
}
// ==================== 评级列(等级 + 分数) ====================
/**
* 等级映射只读列表:列表页的等级徽标要靠它把分数翻成等级。
*
* 等级不落库(用户口径:等级在前端算),所以每行渲染都要用当前映射;
* 拉一次缓存在内存里,避免逐行请求。配置在评级弹窗的抽屉里改完会回调
* reloadGrades,列表徽标随之更新。
*/
const grades = ref<RatingGradeItem[]>([])
async function loadGrades(): Promise<void> {
try {
grades.value = await listRatingGrades()
} catch (error) {
// 等级配置拉取失败不该挡住客户列表:徽标退化为只显示分数
handledError(error)
}
}
/** 分数 → 等级名(未命中等级时返回「未定级」,不显示空白) */
function gradeLabel(score: number): string {
return resolveGrade(grades.value, score)?.grade_name ?? '未定级'
}
/** 等级色调后缀(A 绿 / B 靛蓝 / C 琥珀 / D 砖红 / 自定义中性) */
function gradeNameClass(score: number): string {
const grade = resolveGrade(grades.value, score)
if (!grade) return ''
const tone = gradeTone(grade.grade_name)
return tone ? `grade__name--${tone}` : ''
}
/** 悬浮提示:等级区间 + 评级时间,回答「这个等级是怎么来的、什么时候打的」 */
function ratingTitle(row: CustomerItem): string {
if (row.rating_score === null) return '尚未评级'
const grade = resolveGrade(grades.value, row.rating_score)
const range = grade ? `${grade.grade_name} 档(${grade.min_score} ~ ${grade.max_score} 分)` : '未命中任何等级档'
const time = row.rating_time ? `,${formatDateTime(row.rating_time)} 评定` : ''
return `${range}${time};重新评级请点行内「评级计算」`
}
// ==================== 归属人员下拉数据源 ====================
interface PersonnelOption {
@ -364,9 +409,11 @@ async function handleDelete(row: CustomerItem): Promise<void> {
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
// 三个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadOwnerOptions()
// 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表
void loadGrades()
})
</script>
@ -375,7 +422,7 @@ onMounted(() => {
<div class="page-head">
<h1 class="page-title">客户管理</h1>
<p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案
</p>
<span class="waterline"></span>
</div>
@ -441,13 +488,32 @@ onMounted(() => {
@sort-change="onSortChange"
>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="rating_score" label="评分" width="88" align="center" sortable="custom">
<!--
评级列 = 等级 + 分数,纯文字两级层次。
等级不落库,按当前等级映射由已存分数实时算(与评级弹窗同一判定函数
utils/rating.ts)。
形态刻意既不用 .ys-pill 也不用色块:全站胶囊是「状态」的语汇(草稿/
待审/已锁定/已驳回,都带圆点和胶囊边),评级是档案属性,套状态形态
会让两种信息混读;色块则会在每行右侧堆一片彩色矩形,表格看起来像
贴了标签墙。这里只用字重与语义色分层 —— 等级立起来(13px/600/语义色),
分数退到弱墨等宽(13px/tabular-nums),一行读完「B 级 82 分」,
整列数字竖向对齐可扫。
排序仍挂在 rating_score 上。
-->
<el-table-column
prop="rating_score"
label="评级"
width="96"
align="center"
sortable="custom"
>
<template #default="{ row }">
<span
v-if="row.rating_score !== null"
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined"
>
{{ row.rating_score }}
<span v-if="row.rating_score !== null" class="grade" :title="ratingTitle(row)">
<span class="grade__name" :class="gradeNameClass(row.rating_score)">
{{ gradeLabel(row.rating_score) }}
</span>
<span class="grade__score">{{ row.rating_score }}</span>
</span>
<span v-else class="cell-muted" title="尚未评级">—</span>
</template>
@ -657,6 +723,7 @@ onMounted(() => {
v-model:visible="ratingVisible"
:customer="ratingTarget"
@saved="onRatingSaved"
@grades-changed="loadGrades"
/>
</div>
</template>
@ -670,4 +737,52 @@ onMounted(() => {
font-size: 12px;
line-height: 1.7;
}
/*
* 评级列 = 等级 + 分数,纯文字两级层次(无底色、无边框、无胶囊)。
*
* 为什么不用胶囊也不用色块:全站胶囊(.ys-pill)是「状态」的语汇 —— 草稿/
* 待审/已锁定/已驳回,都带圆点和胶囊边;评级是档案属性不是流程状态,套状态
* 形态会让两种信息混读。色块方案在实测里会在每行右侧堆一片彩色矩形,表格
* 像贴了标签墙,且「A 档」这类两字自定义等级名一撑就变形。
*
* 改成纯文字分层:等级 13px/600 用语义色立起来(这是几档),分数 13px 弱墨
* 等宽跟在后面(依据多少)。零装饰元素,行高不受影响,等级名多长都不会挤。
*/
.grade {
display: inline-flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.grade__name {
font-size: 13px;
font-weight: 600;
color: var(--ys-ink-2);
}
/* 四档语义色:只改字色,不加底色 —— 表格里色块过多会压过数据本身 */
.grade__name--ok {
color: var(--ys-ok);
}
.grade__name--lock {
color: var(--ys-indigo);
}
.grade__name--wait {
color: var(--ys-warn);
}
.grade__name--reject {
color: var(--ys-danger);
}
/* 分数:等宽数字保证整列竖向对齐,弱墨让视线先落在等级上 */
.grade__score {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--ys-ink-3);
}
</style>