diff --git a/backend/README.md b/backend/README.md index e647574..b459a06 100644 --- a/backend/README.md +++ b/backend/README.md @@ -83,8 +83,9 @@ app/ | GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) | | GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) | | PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 | -| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) | +| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选;`order_by=rating_score` 按评分排序,未评分垫底)/ 新增(分类值域;公司名+负责人唯一) | | GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 | +| PUT | `/api/v1/base/customers/{id}/rating` | 保存评级(评级计算器「保存评分」;只写 `rating_score`/`rating_time`,五项明细不落库 #25) | | PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) | | GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) | diff --git a/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py b/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py new file mode 100644 index 0000000..fe53295 --- /dev/null +++ b/backend/alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py @@ -0,0 +1,51 @@ +"""v13 客户评级落库 + +Revision ID: c3a9e1d7b2f4 +Revises: 7f4f7b918bc4 +Create Date: 2026-10-23 10:30:00.000000 + +评级计算器从独立工具页并入客户管理(行内弹窗),评分由「不落库」改为 +保存为客户档案属性:``rating_score`` 总分 + ``rating_time`` 评级时间。 +两列均可空(null = 未评分,历史客户与新客户无需回填),人工维护字段, +T+ 同步守则不覆盖(见 TPlusSyncMixin 的同步守则说明)。 + +五项评分明细与命中等级不落库:等级映射是可变判定表,回溯「当时命中的 +等级」需要快照设计,超出档案属性的范围;评级时间已足够回答「这个分是 +什么时候打的」。 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c3a9e1d7b2f4" +down_revision: str | Sequence[str] | None = "7f4f7b918bc4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """customers 加评级两列,紧跟 remark 之后保持与 ORM 声明同序。 + + 用 execute 手写而非 op.add_column:后者不带 AFTER 子句,两列会落到 + 表尾(T+ 预留列之后),与 ORM/init-dev 的声明顺序不一致。 + """ + op.execute( + "ALTER TABLE customers " + "ADD COLUMN rating_score TINYINT COMMENT " + "'评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' " + "AFTER remark" + ) + op.execute( + "ALTER TABLE customers " + "ADD COLUMN rating_time DATETIME COMMENT " + "'最近评级时间(评级计算器保存评分时刷新;人工维护字段)' " + "AFTER rating_score" + ) + + +def downgrade() -> None: + """删除评级两列。评分数据随列删除,评级无下游引用、无悬空风险。""" + op.drop_column("customers", "rating_time") + op.drop_column("customers", "rating_score") diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py index 26b6d67..96aa995 100644 --- a/backend/app/api/v1/base/customers.py +++ b/backend/app/api/v1/base/customers.py @@ -21,6 +21,7 @@ from app.schemas.customer import ( CustomerOut, CustomerOwnershipIn, CustomerQuery, + CustomerRatingIn, CustomerUpdateIn, OwnershipChangeLogOut, ) @@ -102,6 +103,31 @@ def delete_customer( return ok(message="客户已删除") +@router.put( + "/{customer_id}/rating", + response_model=ApiResponse[CustomerOut], + summary="保存客户评级", +) +def rate_customer( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + payload: CustomerRatingIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """保存评级总分(评级计算器「保存评分」)。 + + 评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25); + 编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。 + """ + return ok( + customer_service.rate_customer( + session, customer_id, payload, operator=current_user, meta=meta + ), + message="评级已保存", + ) + + @router.put( "/{customer_id}/ownership", response_model=ApiResponse[CustomerOut], diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index 693cb7b..0a4d74d 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -63,6 +63,14 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin): comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", ) remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + rating_score: Mapped[int | None] = mapped_column( + TINYINT_TYPE, + comment="评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)", + ) + rating_time: Mapped[datetime | None] = mapped_column( + DateTime, + comment="最近评级时间(评级计算器保存评分时刷新;人工维护字段)", + ) class OwnershipChangeLog(Base): diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index 498a94e..e159f93 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -52,10 +52,12 @@ def list_customers(session: Session, query: CustomerQuery) -> list[Row]: 归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) 不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 - 排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 + 排序键由查询白名单决定(见 ``CustomerQuery.order_by``): + id 倒序 = 新建档靠前,rating_score 按分数排、未评分垫底。 """ stmt = _apply_filters(_base_select(), query) - stmt = stmt.order_by(Customer.id.desc()).offset(query.offset).limit(query.page_size) + stmt = _apply_order(stmt, query) + stmt = stmt.offset(query.offset).limit(query.page_size) return list(session.execute(stmt).all()) @@ -131,6 +133,23 @@ def _keyword_clause(keyword: str) -> ColumnElement[bool]: ) +def _apply_order(stmt: Select, query: CustomerQuery) -> Select: + """应用排序。排序键已由 schema 白名单收口,此处只做键 → 列的映射。 + + MySQL 把 NULL 视为最小值:ASC 时未评分(null)会排最前,但「没打分」 + 不该压在「打了低分」之上 —— ASC 分支用 ``is_(None)`` 布尔键把 null + 挪到末尾;DESC 分支 null 天然在最后,无需处理。尾部再按 id 倒序兜底, + 保证同分行的次序稳定(分页不跳行)。 + """ + if query.order_by == "rating_score": + score = Customer.rating_score + if query.order == "asc": + stmt = stmt.order_by(score.is_(None), score.asc()) + else: + stmt = stmt.order_by(score.desc()) + return stmt.order_by(Customer.id.desc()) + + # ==================== 写入 ==================== diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index fde79e3..b7b13b7 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -1,6 +1,7 @@ """客户管理的请求/响应模型(需求 2.1.3)。""" from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -28,6 +29,8 @@ class CustomerOut(BaseModel): owner_personnel_id: int | None = None owner_personnel_name: str | None = None remark: str | None = None + rating_score: int | None = None + rating_time: datetime | None = None class CustomerCreateIn(BaseModel): @@ -90,13 +93,32 @@ class CustomerUpdateIn(BaseModel): class CustomerQuery(PageQuery): - """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。""" + """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 + + 排序用 ``Literal`` 白名单而不是自由字符串:``order_by`` 直接拼进 + ``ORDER BY``,自由值等于把注入面开到排序键上;客户列表只有两个 + 有意义的排序键,白名单足够。 + """ keyword: str | None = Field( default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" ) customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") + order_by: Literal["id", "rating_score"] = Field( + default="id", description="排序键:id 建档序 / rating_score 评级总分" + ) + order: Literal["asc", "desc"] = Field(default="desc", description="排序方向") + + +class CustomerRatingIn(BaseModel): + """保存客户评级入参(评级计算器「保存评分」)。 + + score 是五项之和的整数(0-100);五项明细不落库(评级记录只存总分, + 见对照表 #25 的定位:评分是档案属性而非审计事件)。 + """ + + score: int = Field(ge=0, le=100, description="评级总分(0-100 整数)") class CustomerOwnershipIn(BaseModel): diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index e102a19..4a402da 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -15,6 +15,7 @@ import logging from dataclasses import dataclass +from datetime import datetime from sqlalchemy import Row from sqlalchemy.exc import IntegrityError @@ -38,6 +39,7 @@ from app.schemas.customer import ( CustomerOut, CustomerOwnershipIn, CustomerQuery, + CustomerRatingIn, CustomerUpdateIn, OwnershipChangeLogOut, ) @@ -61,6 +63,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: owner_personnel_id=customer.owner_personnel_id, owner_personnel_name=owner_name, remark=customer.remark, + rating_score=customer.rating_score, + rating_time=customer.rating_time, ) @@ -263,6 +267,53 @@ def update_customer( return out +def rate_customer( + session: Session, + customer_id: int, + payload: CustomerRatingIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """保存客户评级(评级计算器「保存评分」)。 + + 评分是客户档案属性而非审计事件:只存总分与评级时间,五项明细与所用 + 等级配置不落库(对照表 #25 的定位口径)——等级映射是可变判定表,回溯 + 「当时命中的等级」需要快照设计,超出档案属性的范围;评级时间已足够 + 回答「这个分是什么时候打的」。 + + :raises NotFoundError: 客户不存在 + """ + # 客户名进审计摘要,预取放在审计上下文之前(同 change_ownership 口径) + row = customer_repository.get_with_owner_name(session, customer_id) + customer_name = row[0].company_name if row else f"#{customer_id}" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_UPDATE, + summary=f"保存客户评级 {customer_name}:{payload.score} 分", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("客户不存在") + customer, owner_name = row + + customer.rating_score = payload.score + # 评级时间取应用层本地时间(库列 TZ=Asia/Shanghai,与 TimestampMixin + # 的服务器默认值同一时区口径),不用数据库 NOW():行已在本会话 + customer.rating_time = datetime.now() + + # 响应在 commit 之前构造(同 user_service 顶部说明) + out = _to_out(customer, owner_name) + session.commit() + logger.info("保存客户评级 | customer_id=%s | score=%s", customer_id, payload.score) + return out + + def delete_customer( session: Session, customer_id: int, diff --git a/backend/tests/README.md b/backend/tests/README.md index 35609df..e545de6 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -98,9 +98,9 @@ uv run pytest tests/integration -q # 仅集成 | --- | --- | --- | | `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 | | `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 | -| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 | +| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕、评级保存(回显总分/时间、覆盖式保存)、评分 101→400、按评分排序未评分垫底(asc/desc) | 18 | -运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。 +运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等);2026-10-23 评级功能后客户单文件 **18 passed**(18.5s)。 ### 业务数据管理(2026-10-13 完成) diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py index 2945a8a..5333aec 100644 --- a/backend/tests/integration/test_customers.py +++ b/backend/tests/integration/test_customers.py @@ -376,3 +376,73 @@ class TestAudit: latest = logs[0] assert customer["company_name"] in latest.summary, "摘要应含客户名" assert "→" in latest.summary, "摘要应体现新旧归属走向" + + +class TestRating: + """评级保存与列表排序 —— 评分是档案属性,只存总分与评级时间。 + + 守的是:评分只走专属端点(编辑端点不含评分,旁路写评分会绕开口径); + 分值红线 0-100 在入口拦截;按评分排序时未评分客户必须垫底,不能因 + MySQL 把 NULL 视为最小值而在升序里压过打了低分的客户。 + """ + + def test_保存评级_回显总分与时间_且编辑端点不含评分( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + customer = _create_customer(client, headers) + assert customer["rating_score"] is None, "新客户应无评分" + + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 88}, headers=headers + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["rating_score"] == 88 + assert body["rating_time"] is not None, "保存评分必须刷新评级时间" + + # 覆盖式保存:新分数替换旧分数 + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 60}, headers=headers + ) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["rating_score"] == 60 + + def test_评分越界_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _create_customer(client, headers) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers + ) + assert resp.status_code == 400, resp.text + + def test_按评分排序_未评分垫底_升序降序都不压过低分( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + high = _create_customer(client, headers) + low = _create_customer(client, headers) + unrated = _create_customer(client, headers) + for target, score in ((high, 80), (low, 60)): + resp = client.put( + f"{_CUSTOMERS}/{target['id']}/rating", json={"score": score}, headers=headers + ) + assert resp.status_code == 200, resp.text + + def _order_names(order: str) -> list[str]: + resp = client.get( + _CUSTOMERS, + params={"pageNum": 1, "pageSize": 50, "order_by": "rating_score", "order": order}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + names = [row["company_name"] for row in resp.json()["data"]["list"]] + return names + + # 降序:高分在前,未评分最后;升序:低分在前,未评分仍垫底 + desc_names = _order_names("desc") + assert desc_names.index(high["company_name"]) < desc_names.index(low["company_name"]) + assert desc_names[-1] == unrated["company_name"] + asc_names = _order_names("asc") + assert asc_names.index(low["company_name"]) < asc_names.index(high["company_name"]) + assert asc_names[-1] == unrated["company_name"], "未评分必须垫底" diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 40dde34..ee7ca31 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -366,6 +366,8 @@ CREATE TABLE customers ( customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal', owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)', remark VARCHAR(255) COMMENT '备注', + rating_score TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)', + rating_time DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)', create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', @@ -891,4 +893,4 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('7f4f7b918bc4'); +INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4'); diff --git a/db/migration/V10__add_customer_rating.sql b/db/migration/V10__add_customer_rating.sql new file mode 100644 index 0000000..0b70042 --- /dev/null +++ b/db/migration/V10__add_customer_rating.sql @@ -0,0 +1,10 @@ +-- V10: 客户评级落库(2026-10-23) +-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性; +-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。 +-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py +-- 每列一条 ALTER:test_schema_sync 按「ALTER TABLE … ADD COLUMN」逐条解析, +-- 一条语句挂多个 ADD 只会解析出第一列,两份 DDL 会假性漂移。 +ALTER TABLE `customers` + ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`; +ALTER TABLE `customers` + ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`; diff --git a/frontend/src/api/customers.ts b/frontend/src/api/customers.ts index 812721c..f82877a 100644 --- a/frontend/src/api/customers.ts +++ b/frontend/src/api/customers.ts @@ -21,6 +21,10 @@ export interface CustomerItem { owner_personnel_id: number | null owner_personnel_name: string | null remark: string | null + /** 评级总分(0-100,评级计算器保存;null=未评分) */ + rating_score: number | null + /** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */ + rating_time: string | null } export interface CustomerQueryParams { @@ -30,6 +34,10 @@ export interface CustomerQueryParams { keyword?: string customer_type?: string owner_personnel_id?: number + /** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */ + order_by?: 'id' | 'rating_score' + /** 排序方向,默认 desc */ + order?: 'asc' | 'desc' } /** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */ @@ -57,6 +65,11 @@ export interface CustomerOwnershipParams { reason?: string | null } +/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */ +export interface CustomerRatingParams { + score: number +} + /** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */ export interface OwnershipLogItem { id: number @@ -114,6 +127,14 @@ export function changeOwnership( return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data }) } +/** 保存评级(评级计算器「保存评分」;只存总分与评级时间,评分只走本端点) */ +export function rateCustomer( + customerId: number, + data: CustomerRatingParams, +): Promise { + return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data }) +} + /** 查某客户的归属变更留痕(分页,按变更时间倒序) */ export function listOwnershipLogs( customerId: number, diff --git a/frontend/src/components/layout/menuIcons.ts b/frontend/src/components/layout/menuIcons.ts index fbf7b62..adc0ab3 100644 --- a/frontend/src/components/layout/menuIcons.ts +++ b/frontend/src/components/layout/menuIcons.ts @@ -17,7 +17,6 @@ import { SetUp, Shop, Stamp, - Star, Tickets, TrendCharts, User, @@ -54,7 +53,6 @@ const ICONS = { businesses: Briefcase, shipments: Van, receipts: Money, - rating: Star, ledger: Notebook, settlements: Calendar, 'report-personal': DataLine, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6b272f1..0d0bd47 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -170,14 +170,7 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/business/receipts/index.vue'), meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 }, }, - { - // 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额, - // 登录用户即可用,不设 requiresAdmin(区别于系统管理组) - path: '/business/rating', - name: 'business-rating', - component: () => import('@/views/business/rating/index.vue'), - meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 }, - }, + // 客户评级计算器已并入客户管理(行内弹窗),独立页移除 { // 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤 // (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin) diff --git a/frontend/src/views/base/customers/RatingCalculatorDialog.vue b/frontend/src/views/base/customers/RatingCalculatorDialog.vue new file mode 100644 index 0000000..e5dba91 --- /dev/null +++ b/frontend/src/views/base/customers/RatingCalculatorDialog.vue @@ -0,0 +1,553 @@ + + + + + diff --git a/frontend/src/views/base/customers/index.vue b/frontend/src/views/base/customers/index.vue index 3fd1e66..c970474 100644 --- a/frontend/src/views/base/customers/index.vue +++ b/frontend/src/views/base/customers/index.vue @@ -11,6 +11,7 @@ import { useDropdownOptions } from '@/composables/useDropdownOptions' import { usePagedList } from '@/composables/usePagedList' import { formatDateTime } from '@/utils/format' import { handledError } from '@/utils/error' +import RatingCalculatorDialog from './RatingCalculatorDialog.vue' /** * 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。 @@ -21,6 +22,8 @@ import { handledError } from '@/utils/error' * - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」 * (对照表 #19 的业务口径) * - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序) + * - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分, + * 保存评分写入档案字段 rating_score/rating_time */ const { confirmAndRun } = useConfirmAction() @@ -39,6 +42,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange, keyword: filters.keyword, customer_type: filters.customer_type, owner_personnel_id: filters.owner_personnel_id, + order_by: sortState.order_by, + order: sortState.order, }), ) @@ -49,6 +54,54 @@ function resetFilters(): void { search() } +// ==================== 排序(评分列) ==================== + +/** + * 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。 + * 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。 + */ +const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({ + order_by: 'id', + order: 'desc', +}) + +function onSortChange({ + prop, + order, +}: { + prop: string + order: 'ascending' | 'descending' | null +}): void { + if (prop === 'rating_score' && order) { + sortState.order_by = 'rating_score' + sortState.order = order === 'ascending' ? 'asc' : 'desc' + } else { + sortState.order_by = 'id' + sortState.order = 'desc' + } + search() +} + +// ==================== 评级计算 ==================== + +const ratingVisible = ref(false) +const ratingTarget = ref(null) + +function openRating(row: CustomerItem): void { + ratingTarget.value = row + ratingVisible.value = true +} + +/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */ +function onRatingSaved(item: CustomerItem): void { + if (sortState.order_by === 'rating_score') { + void load() + return + } + const index = records.value.findIndex((record) => record.id === item.id) + if (index !== -1) records.value[index] = item +} + // ==================== 归属人员下拉数据源 ==================== interface PersonnelOption { @@ -322,7 +375,7 @@ onMounted(() => {

客户管理

- 客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕 + 客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案

@@ -385,8 +438,20 @@ onMounted(() => { :data="records" stripe empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查" + @sort-change="onSortChange" > + + + - + diff --git a/frontend/src/views/business/rating/index.vue b/frontend/src/views/business/rating/index.vue deleted file mode 100644 index ca55b15..0000000 --- a/frontend/src/views/business/rating/index.vue +++ /dev/null @@ -1,493 +0,0 @@ - - - - -