PayrollManagementSystem/frontend/src/views/base/customers/RatingCalculatorDialog.vue
liuwenxin 32b7940067 feat(customer): 客户评级并入客户管理并落库
- customers 表新增 rating_score/rating_time(V13 迁移 + V10 SQL + init-dev 同步)
- 新端点 PUT /customers/{id}/rating:评分是档案属性,只存总分与评级时间(五项明细不落库)
- 列表排序 order_by=rating_score,未评分客户升序/降序都垫底,id desc 兜底稳定分页
- 前端 RatingCalculatorDialog 并入客户管理(评分列 + 评级计算按钮),删除独立页与路由
- 测试:评级保存/越界/排序垫底 3 例,test_customers.py 18 passed;schema 同步 216 passed
2026-10-23 10:59:33 +08:00

554 lines
16 KiB
Vue
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.

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
listRatingGrades,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
} from '@/api/ratings'
import { rateCustomer, type CustomerItem } from '@/api/customers'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
/**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
*
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时
* 命中的等级」需要快照设计,超出档案属性的范围)。
*
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
*/
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
/** 目标客户;弹窗打开期间必非空(由父页在 open 时赋值) */
customer: CustomerItem | null
}>()
const emit = defineEmits<{
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
saved: [item: CustomerItem]
}>()
// ==================== 等级判定(纯函数) ====================
/**
* 等级判定:按 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) */
const SCORE_ITEMS = [
{ key: 'ship_amount', label: '发货额' },
{ key: 'receipt_rate', label: '回款率' },
{ key: 'profit_rate', label: '毛利率' },
{ key: 'contract_cooperation', label: '合同履约配合能力' },
{ key: 'site_construction', label: '现场规范施工能力' },
] as const
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
const scores = reactive<Record<ScoreKey, number | undefined>>({
ship_amount: 0,
receipt_rate: 0,
profit_rate: 0,
contract_cooperation: 0,
site_construction: 0,
})
const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
)
/** 重置即把五项清零(不动档案里已保存的评分) */
function resetScores(): void {
for (const item of SCORE_ITEMS) {
scores[item.key] = 0
}
}
// ==================== 等级映射(判定用列表) ====================
const { confirmAndRun } = useConfirmAction()
const grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> {
gradesLoading.value = true
try {
grades.value = await listRatingGrades()
} catch (error) {
handledError(error)
} finally {
gradesLoading.value = false
}
}
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 弹窗打开 / 关闭 ====================
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
watch(visible, (isOpen) => {
if (!isOpen) return
resetScores()
void loadGrades()
})
// ==================== 保存评分 ====================
const saving = ref(false)
/** 档案里已保存的评分(打开时的快照,仅展示用;保存成功后由父页更新行数据) */
const archivedScore = computed(() => {
const target = props.customer
if (!target || target.rating_score === null) return null
return {
score: target.rating_score,
time: target.rating_time ? formatDateTime(target.rating_time) : '',
}
})
async function handleSave(): Promise<void> {
const target = props.customer
if (!target) return
saving.value = true
try {
const item = await rateCustomer(target.id, { score: totalScore.value })
ElMessage.success('评级已保存')
emit('saved', item)
visible.value = false
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
saving.value = false
}
}
// ==================== 等级映射设置抽屉 ====================
const drawerVisible = ref(false)
/** 打开时重新拉取,避免展示陈旧数据 */
function openDrawer(): void {
drawerVisible.value = true
void loadGrades()
}
// ==================== 新增 / 编辑等级弹窗 ====================
const editVisible = ref(false)
/** null = 新增,否则为编辑目标 id */
const editingId = ref<number | null>(null)
const editSaving = ref(false)
const isCreate = computed(() => editingId.value === null)
const editForm = reactive({
grade_name: '',
min_score: undefined as number | undefined,
max_score: undefined as number | undefined,
sort_order: 0,
})
function openCreate(): void {
editingId.value = null
editForm.grade_name = ''
editForm.min_score = undefined
editForm.max_score = undefined
editForm.sort_order = 0
editVisible.value = true
}
function openEdit(row: RatingGradeItem): void {
editingId.value = row.id
editForm.grade_name = row.grade_name
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
editForm.min_score = parseFloat(row.min_score)
editForm.max_score = parseFloat(row.max_score)
editForm.sort_order = row.sort_order
editVisible.value = true
}
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
if (!editForm.grade_name.trim()) return '请输入等级名称'
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
}
return null
}
async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score
const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore)
if (error) {
ElMessage.warning(error)
return
}
const payload: RatingGradeParams = {
grade_name: editForm.grade_name.trim(),
min_score: String(minScore),
max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0,
}
// 后端为全量替换语义;remark 本组件不维护,编辑时原样带回避免误清
if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
}
editSaving.value = true
try {
if (editingId.value === null) {
await createRatingGrade(payload)
} else {
await updateRatingGrade(editingId.value, payload)
}
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
} catch (requestError) {
handledError(requestError)
} finally {
editSaving.value = false
}
}
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => loadGrades(),
})
}
</script>
<template>
<el-dialog
v-model="visible"
:title="`客户评级 · ${customer?.company_name ?? ''}`"
width="680px"
destroy-on-close
>
<!-- 对照表 #25:口径提示改写 —— 评分现在会落库,但仍不参与绩效金额 -->
<el-alert
class="dialog-alert"
title="评分保存为客户档案,只用于客户评级展示,不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<div v-if="archivedScore" class="archived-line">
档案评分:<b>{{ archivedScore.score }}</b> 分
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
<span class="archived-tip">重新保存将覆盖</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="calc-result">
<div class="section-title">判定结果</div>
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射,仍可保存总分</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
<template #footer>
<el-button @click="resetScores">重置</el-button>
<el-button @click="openDrawer">等级映射设置</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存评分</el-button>
</template>
</el-dialog>
<!-- 等级映射设置抽屉(评级弹窗的二级层,append-to-body 保证遮罩层级正确) -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px" append-to-body>
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
</div>
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
</div>
<el-table v-loading="gradesLoading" :data="grades" size="small">
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
<el-table-column prop="max_score" label="上限" width="90" align="center" />
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 -->
<el-dialog
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
destroy-on-close
append-to-body
@closed="editingId = null"
>
<el-form label-width="90px" @submit.prevent>
<el-form-item label="等级名称" required>
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
</el-form-item>
<el-form-item label="下限(含)" required>
<el-input-number
v-model="editForm.min_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="上限" required>
<el-input-number
v-model="editForm.max_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="editForm.sort_order"
:min="0"
:step="10"
controls-position="right"
/>
<span class="form-tip">升序判定,越小越先判定</span>
</el-form-item>
</el-form>
<div class="form-tip-block">
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
</div>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
/* 版式皮肤(.card/.section-title/按钮/表格)由全局 index.css 提供,这里只留弹窗特有排版 */
.dialog-alert {
margin-bottom: 12px;
}
/* 档案评分快照行:一眼看出这是覆盖式保存 */
.archived-line {
font-size: 13px;
color: var(--ys-ink-2);
margin-bottom: 12px;
}
.archived-time {
color: var(--ys-ink-3);
}
.archived-tip {
margin-left: 10px;
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-grid {
display: flex;
gap: 28px;
align-items: stretch;
flex-wrap: wrap;
}
.calc-form {
flex: 1 1 320px;
min-width: 280px;
}
.score-row {
display: flex;
align-items: center;
gap: 12px;
line-height: 2.7;
}
.score-label {
width: 140px;
font-size: 13px;
color: var(--ys-ink);
text-align: right;
}
.score-unit {
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-result {
flex: 0 0 220px;
min-width: 200px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 18px 20px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex;
flex-direction: column;
justify-content: center;
gap: 18px;
}
.result-label {
display: block;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ys-ink-3);
}
.total-num {
font-size: 36px;
color: var(--ys-indigo);
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.total-max {
font-size: 13px;
color: var(--ys-ink-3);
}
.grade-name {
font-family: var(--ys-font-brand);
font-size: 26px;
font-weight: 600;
color: var(--ys-ink);
line-height: 1.3;
margin-top: 2px;
}
.grade-hint {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 4px;
}
.drawer-hint {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 12px;
}
.drawer-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.form-tip {
font-size: 12px;
color: var(--ys-ink-3);
margin-left: 8px;
}
.form-tip-block {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) {
.score-label {
width: auto;
text-align: left;
}
.calc-result {
flex: 1 1 100%;
}
}
</style>