From 111ac5425ab1899aaf0f3f3efd942463933fe15c Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 23 Oct 2026 16:18:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(customer):=20=E5=AE=A2=E6=88=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=B1=95=E7=A4=BA=E7=AD=89=E7=BA=A7=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E7=AD=89=E7=BA=A7=E4=B8=8D=E8=90=BD?= =?UTF-8?q?=E5=BA=93=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评级计算器并入客户管理后,列表只有分数看不出档位,本批补上等级徽标: - 抽出 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 --- .../c3a9e1d7b2f4_v13_客户评级落库.py | 6 +- backend/app/repositories/rating_repository.py | 11 +- backend/app/services/customer_service.py | 2 +- frontend/src/api/customers.ts | 10 +- frontend/src/utils/rating.ts | 48 +++++++ .../base/customers/RatingCalculatorDialog.vue | 52 +++---- frontend/src/views/base/customers/index.vue | 133 ++++++++++++++++-- 7 files changed, 220 insertions(+), 42 deletions(-) create mode 100644 frontend/src/utils/rating.ts diff --git a/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py b/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py index fe53295..d50ea69 100644 --- a/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py +++ b/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py @@ -9,9 +9,9 @@ Create Date: 2026-10-23 10:30:00.000000 两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段, T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。 -五项评分明细与命中等级不落库:等级映射是可变判定表,回溯「当时命中的 -等级」需要快照设计,超出档案属性的范围;评级时间已足够回答「这个分是 -什么时候打的」。 +五项评分明细与命中等级都不落库:前者是打分过程的中间量,没有下游消费方; +后者由分数按当前等级映射实时推导(等级映射是可变判定表,改了配置全部客户 +的等级随之更新),因此本迁移只落总分与评级时间两列。 """ from collections.abc import Sequence diff --git a/backend/app/repositories/rating_repository.py b/backend/app/repositories/rating_repository.py index 9a39849..88c1257 100644 --- a/backend/app/repositories/rating_repository.py +++ b/backend/app/repositories/rating_repository.py @@ -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() diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index 4a402da..6c1ee0c 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -280,7 +280,7 @@ def rate_customer( 评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用 等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯 「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够 - 回答「这个分是什么时候打的」。 + 回答「这个分是什么时候打的」。等级由前端按当前映射实时算并展示。 :raises NotFoundError: 客户不存在 """ diff --git a/frontend/src/api/customers.ts b/frontend/src/api/customers.ts index f82877a..8c3aa95 100644 --- a/frontend/src/api/customers.ts +++ b/frontend/src/api/customers.ts @@ -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, diff --git a/frontend/src/utils/rating.ts b/frontend/src/utils/rating.ts new file mode 100644 index 0000000..329003e --- /dev/null +++ b/frontend/src/utils/rating.ts @@ -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 '' + } +} diff --git a/frontend/src/views/base/customers/RatingCalculatorDialog.vue b/frontend/src/views/base/customers/RatingCalculatorDialog.vue index e5dba91..ffee2ff 100644 --- a/frontend/src/views/base/customers/RatingCalculatorDialog.vue +++ b/frontend/src/views/base/customers/RatingCalculatorDialog.vue @@ -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 { } } +/** 配置被改动后的统一收尾:重载本弹窗的判定列表 + 通知父页刷新徽标缓存 */ +async function reloadAfterGradeChange(): Promise { + 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 { } 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(), }) } @@ -276,6 +277,9 @@ function removeGrade(row: RatingGradeItem): void {
档案评分:{{ archivedScore.score }} 分 + ({{ archivedScore.time }} 保存) 重新保存将覆盖
diff --git a/frontend/src/views/base/customers/index.vue b/frontend/src/views/base/customers/index.vue index c970474..e0ceb16 100644 --- a/frontend/src/views/base/customers/index.vue +++ b/frontend/src/views/base/customers/index.vue @@ -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([]) + +async function loadGrades(): Promise { + 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 { // ==================== 初始化 ==================== onMounted(() => { - // 两个请求各自容错(load 内部已兜底),互不拖累 + // 三个请求各自容错(load 内部已兜底),互不拖累 void load() void loadOwnerOptions() + // 等级徽标要用等级映射把分数翻成等级,先拉一次;失败只影响徽标,不影响列表 + void loadGrades() }) @@ -375,7 +422,7 @@ onMounted(() => {

客户管理

- 客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案 + 客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分与等级由「评级计算」维护并保存进档案

@@ -441,13 +488,32 @@ onMounted(() => { @sort-change="onSortChange" > - + + @@ -657,6 +723,7 @@ onMounted(() => { v-model:visible="ratingVisible" :customer="ratingTarget" @saved="onRatingSaved" + @grades-changed="loadGrades" /> @@ -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); +}