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:
parent
61fe5cca98
commit
111ac5425a
@ -9,9 +9,9 @@ Create Date: 2026-10-23 10:30:00.000000
|
||||
两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段,
|
||||
T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。
|
||||
|
||||
五项评分明细与命中等级不落库:等级映射是可变判定表,回溯「当时命中的
|
||||
等级」需要快照设计,超出档案属性的范围;评级时间已足够回答「这个分是
|
||||
什么时候打的」。
|
||||
五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方;
|
||||
后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户
|
||||
的等级随之更新),因此本迁移只落总分与评级时间两列。
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -280,7 +280,7 @@ def rate_customer(
|
||||
评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用
|
||||
等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯
|
||||
「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够
|
||||
回答「这个分是什么时候打的」。
|
||||
回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。
|
||||
|
||||
:raises NotFoundError: 客户不存在
|
||||
"""
|
||||
|
||||
@ -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,
|
||||
|
||||
48
frontend/src/utils/rating.ts
Normal file
48
frontend/src/utils/rating.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user