From 61fe5cca98b8ef6a905f7acffced7053987cf989 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 23 Oct 2026 16:15:09 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(dashboard):=20=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E6=94=B9=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作台「最近动态」原为聚合端点内嵌、固定截断前 20 条,操作日志变长后 既撑大响应又只能看到头部。改为独立分页端点: - GET /api/v1/dashboard/activities,支持 pageNum/pageSize/module 筛选 - 模块筛选项与列表同源返回(全量模块名,不随翻页变化),前端不必再取一次全量 - 聚合端点去掉内嵌 activities,避免同一份数据两处查询 前端改用 usePagedList + 统一 AppPagination(每页 10/20/50),模块筛选由 本地过滤改为服务端筛选。集成测试 13 例(含翻页不重不漏 / 模块筛选 / 越权 403)。 Co-Authored-By: Claude Code --- backend/app/api/v1/dashboard.py | 22 +- .../repositories/operation_log_repository.py | 9 + backend/app/schemas/dashboard.py | 47 +- backend/app/services/dashboard_service.py | 66 +- backend/tests/integration/test_dashboard.py | 47 +- frontend/src/api/dashboard.ts | 23 +- frontend/src/views/dashboard/index.vue | 950 ++++++++++++------ 7 files changed, 835 insertions(+), 329 deletions(-) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 70afe93..e49a769 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,4 +1,4 @@ -"""工作台接口(M4 ⑧):四区块一次性聚合。 +"""工作台接口(M4 ⑧):三区块聚合 + 最近动态分页。 准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」 待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。 @@ -12,7 +12,7 @@ from app.api.deps import DbSession, require_roles from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER from app.core.response import ApiResponse, ok from app.models import User -from app.schemas.dashboard import DashboardOut +from app.schemas.dashboard import ActivityPageOut, ActivityQuery, DashboardOut from app.services import dashboard_service router = APIRouter(prefix="/dashboard", tags=["工作台"]) @@ -23,7 +23,7 @@ _DashboardUser = Annotated[ ] -@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块") +@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台三区块") def get_dashboard( session: DbSession, current_user: _DashboardUser, @@ -31,8 +31,22 @@ def get_dashboard( str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") ] = None, ) -> ApiResponse[DashboardOut]: - """一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。 + """一次返回经营总览/待办/风险预警(待办与预警实时派生)。 待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。 + 最近动态是分页列表,走 `GET /dashboard/activities`。 """ return ok(dashboard_service.get_dashboard(session, month, current_user=current_user)) + + +@router.get("/activities", response_model=ApiResponse[ActivityPageOut], summary="最近动态(分页)") +def list_dashboard_activities( + session: DbSession, + current_user: _DashboardUser, + query: Annotated[ActivityQuery, Query()], +) -> ApiResponse[ActivityPageOut]: + """分页查询最近动态,可按模块筛选。 + + 与聚合端点拆开:翻页只重取这张列表,不必重算总览/待办/预警。 + """ + return ok(dashboard_service.list_activities(session, query)) diff --git a/backend/app/repositories/operation_log_repository.py b/backend/app/repositories/operation_log_repository.py index 0aba518..20b55b0 100644 --- a/backend/app/repositories/operation_log_repository.py +++ b/backend/app/repositories/operation_log_repository.py @@ -142,6 +142,15 @@ def count_logs(session: Session, query: OperationLogListQuery) -> int: return int(session.execute(stmt).scalar_one()) +def distinct_modules(session: Session) -> list[str]: + """全部模块名(去重、按名称升序):工作台「最近动态」的模块筛选项。 + + 交给 SQL 的 DISTINCT,不在应用层聚合 —— 应用层去重要把整列拉进内存。 + """ + stmt = select(OperationLog.module).distinct().order_by(OperationLog.module) + return [module for module in session.execute(stmt).scalars() if module] + + def _apply_filters(stmt: Select, query: OperationLogListQuery) -> Select: """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" stmt = apply_log_filters(stmt, OperationLog, query.filters) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index d52bc7b..118d88b 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -1,9 +1,13 @@ """工作台的请求/响应模型(M4 ⑧)。 -四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返): -经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal +聚合端点一次返回三区块(前端一次请求渲染整页,避免多次往返): +经营绩效总览 / 待办事项 / 风险预警。金额一律 Decimal (序列化为字符串),前端不做数值运算。 +**最近动态独立成分页端点**:它可能有很长(操作日志全量),塞进聚合 +响应既撑爆页面又只能截断前 20 条;拆开后翻页只重取这张列表,不必 +重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。 + 待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); ⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, 不返回伪造的 0 条。 @@ -12,7 +16,9 @@ from datetime import date, datetime from decimal import Decimal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + +from app.core.response import PageQuery class TrendPoint(BaseModel): @@ -116,11 +122,42 @@ class ActivityItem(BaseModel): create_time: datetime +class ActivityQuery(PageQuery): + """最近动态查询条件:分页 + 模块筛选。 + + 只支持模块筛选(工作台只需要这一个维度);其余筛选项见日志查询页。 + """ + + module: str | None = Field(default=None, max_length=64, description="模块,精确匹配") + + +class ActivityPageOut(BaseModel): + """最近动态分页结果:当前页 + 总数 + 模块筛选项。 + + 分页字段名沿用全站规范(list/total/pageNum/pageSize),与 + `PageResult` 同形;额外带 `modules`(全量模块名), + 供前端模块下拉使用 —— 不随当前页变化,避免翻页后选项增减。 + """ + + model_config = ConfigDict(populate_by_name=True) + + records: list[ActivityItem] = Field( + default_factory=list, alias="list", serialization_alias="list", description="当前页数据" + ) + total: int = Field(default=0, description="总记录数") + page_num: int = Field( + default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码" + ) + page_size: int = Field( + default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数" + ) + modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)") + + class DashboardOut(BaseModel): - """工作台四区块一次性响应。""" + """工作台三区块一次性响应(最近动态走独立分页端点)。""" month: str overview: OverviewBlock todos: TodoBlock warnings: WarningBlock - activities: list[ActivityItem] = Field(default_factory=list) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 546416f..eff98b2 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -1,4 +1,4 @@ -"""工作台服务(M4 ⑧):四区块实时聚合编排。 +"""工作台服务(M4 ⑧):区块实时聚合编排 + 最近动态分页。 设计决策(2026-10-19): - **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合, @@ -9,6 +9,8 @@ - **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以 ``sync_available=false`` 显式降级,不返回伪造的 0 条 - 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照 +- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里 + 只能塞前 20 条;拆成独立端点后翻页只重取这张列表 """ from datetime import date, datetime, timedelta @@ -20,22 +22,23 @@ from app.core.exceptions import BadRequestError from app.core.month import validate_month from app.repositories import dashboard_repository, operation_log_repository, user_repository from app.repositories.query_utils import LogFilters +from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery from app.services import approval_service # 工作台只展示摘要,完整列表跳转对应管理页 _TODO_LIMIT = 5 _WARNING_LIMIT = 20 -_ACTIVITY_LIMIT = 20 _TREND_MONTHS = 6 _ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict: - """四区块一次性出数(前端一次请求渲染整页)。 + """三区块一次性出数(前端一次请求渲染整页)。 待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单); 风险预警仍是全局经营视角,不按人裁剪。 + 最近动态不在此返回 —— 它是分页列表,走 ``list_activities``。 """ resolved = _normalize_month(month) warning_params = _warning_params(session) @@ -47,7 +50,6 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) - "overview": _overview(session, resolved), "todos": _todos(session, pending_statuses), "warnings": _warnings(session, warning_params), - "activities": _activities(session), } @@ -112,8 +114,8 @@ def _overview(session: Session, month: str) -> dict: for item in months ], "note": ( - "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月" - "(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单" + "绩效按台账归属月统计(二次分配按参与人拆分后求和,不重复不计漏);" + "回款按到账月统计(含尚未核算的回款);不含经理团队分成与调整单" ), } @@ -247,28 +249,38 @@ def _warnings(session: Session, params: dict) -> dict: } -def _activities(session: Session) -> list[dict]: - """最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。""" - logs = operation_log_repository.list_logs( - session, - operation_log_repository.OperationLogListQuery( - filters=LogFilters(), - offset=0, - limit=_ACTIVITY_LIMIT, - ), +def list_activities(session: Session, query: ActivityQuery) -> ActivityPageOut: + """最近动态分页(FR-036):以操作日志为唯一来源(写操作即留痕)。 + + 模块筛选项与列表同源返回(全量模块名,不随当前页变化), + 前端模块下拉不必另取一次全量。 + """ + repo_query = operation_log_repository.OperationLogListQuery( + filters=LogFilters(), + offset=query.offset, + limit=query.page_size, + module=query.module, + ) + logs = operation_log_repository.list_logs(session, repo_query) + total = operation_log_repository.count_logs(session, repo_query) + return ActivityPageOut( + records=[ + ActivityItem( + id=log.id, + module=log.module, + action=log.action, + summary=log.summary, + username=log.username, + success=log.success, + create_time=log.create_time, + ) + for log in logs + ], + total=total, + page_num=query.page_num, + page_size=query.page_size, + modules=operation_log_repository.distinct_modules(session), ) - return [ - { - "id": log.id, - "module": log.module, - "action": log.action, - "summary": log.summary, - "username": log.username, - "success": log.success, - "create_time": log.create_time, - } - for log in logs - ] def _dec(value) -> Decimal | None: diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py index e8c27cf..a4d9978 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -118,10 +118,20 @@ def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> d return resp.json()["data"] +def _activities(client: TestClient, headers: dict, **params) -> dict: + """最近动态分页端点。""" + resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + def test_权限_普通用户403文案(client: TestClient, normal: dict): resp = client.get(_DASHBOARD, headers=normal["headers"]) assert resp.status_code == 403 assert resp.json()["message"] == "无权访问工作台" + # 最近动态分页端点同属工作台,权限一致 + resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"]) + assert resp.status_code == 403 def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict): @@ -221,9 +231,40 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient def test_动态_来源操作日志(client: TestClient, admin: dict): _chain(client, admin["headers"]) - data = _dashboard(client, admin["headers"]) - assert data["activities"], "工作台动态为空:写操作应产生操作日志" - assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0]) + data = _activities(client, admin["headers"]) + assert data["list"], "工作台动态为空:写操作应产生操作日志" + assert {"module", "action", "summary", "create_time"} <= set(data["list"][0]) + + +def test_动态_分页翻页不重不漏(client: TestClient, admin: dict): + _chain(client, admin["headers"]) + first = _activities(client, admin["headers"], pageNum=1, pageSize=2) + assert first["pageNum"] == 1 + assert first["pageSize"] == 2 + assert len(first["list"]) == 2 + assert first["total"] >= 2 + + second = _activities(client, admin["headers"], pageNum=2, pageSize=2) + assert len(second["list"]) == 2 + # 两页不重不漏(倒序取,按 id 倒排) + first_ids = {item["id"] for item in first["list"]} + second_ids = {item["id"] for item in second["list"]} + assert first_ids.isdisjoint(second_ids) + assert first["list"][0]["id"] > second["list"][0]["id"] + + +def test_动态_模块筛选与筛选项(client: TestClient, admin: dict): + _chain(client, admin["headers"]) + page = _activities(client, admin["headers"], pageSize=50) + assert page["modules"], "模块筛选项不应为空" + module = page["modules"][0] + + filtered = _activities(client, admin["headers"], module=module, pageSize=50) + assert filtered["total"] >= 1 + assert filtered["list"], "按模块筛选后应有数据" + assert all(item["module"] == module for item in filtered["list"]) + # 筛选项不随筛选条件变化(下拉里始终是全量模块) + assert filtered["modules"] == page["modules"] # ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)==== diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 1cf1153..5ff42cb 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -1,4 +1,5 @@ import { request } from './request' +import type { PageData } from './types' /** 近 6 个月趋势点(绩效按台账归属月,回款按到账月) */ export interface TrendPoint { @@ -91,9 +92,29 @@ export interface DashboardData { overview: OverviewBlock todos: TodoBlock warnings: WarningBlock - activities: ActivityItem[] +} + +/** + * 最近动态分页结果:分页字段与全站规范同形(list/total/pageNum/pageSize), + * 额外带 `modules` —— 全量模块名(不随当前页变化),供模块下拉使用。 + */ +export interface ActivityPage extends PageData { + modules: string[] } export function getDashboard(params: { month?: string }): Promise { return request({ url: '/v1/dashboard', method: 'get', params }) } + +/** + * 分页查询最近动态(工作台动态列表)。 + * + * 独立于聚合接口:翻页只重取这张列表,不必重算总览/待办/预警。 + */ +export function listDashboardActivities(params: { + pageNum?: number + pageSize?: number + module?: string +}): Promise { + return request({ url: '/v1/dashboard/activities', method: 'get', params }) +} diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index c621421..8ff1dce 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,5 +1,5 @@ From 61965e0b607b518d4e4b1257d6c1ef73c1171686 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 23 Oct 2026 16:20:05 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(ui):=20=E9=9D=A2=E5=90=91=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=9A=84=E6=96=87=E6=A1=88=E4=B8=AD=E6=96=87=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统由财务与营销人员日常使用,不是给开发者看的。本批把会渲染到页面上的 文本统一改成业务语言: - param_registry 的 22 条参数说明重写为「管什么、钱怎么变、何时该调它」, 去掉英文枚举名、需求编号、内部阶段号与精度约束 - 前端补 formatByCode:变更记录、版本历史、快照对比只给裸 value_text, 需回目录按编码补 value_type 与 unit 才能翻译枚举,否则这些位置仍显示 INCLUDE/FULL/1;编辑弹窗 BOOL 改为开启/关闭下拉 - 后端错误文案:模板报错用中文模板名并给出下一步,产品类别不再甩 municipal_water / rail_transit - 台账与预览落库的计提说明口语化(rate_note / strategy_note / 聚合行 note), 去掉「基数开关=1」「#12 参数化」「9.8 方案公式」 分数原文 11/1125 与「聚合:20%+5%」不动——那是金额口径的可复核原文, 不是说明文案。init-dev.sql 种子 description 与 registry 同源。 Co-Authored-By: Claude Code --- backend/app/services/calc_service.py | 21 +++-- backend/app/services/param_registry.py | 64 ++++++------- backend/app/services/param_service.py | 28 ++++-- backend/app/services/settlement_service.py | 5 +- .../integration/test_param_management.py | 6 +- db/init/init-dev.sql | 46 +++++----- .../src/views/business/businesses/index.vue | 4 +- .../src/views/business/receipts/index.vue | 2 +- frontend/src/views/system/params/index.vue | 92 ++++++++++++++----- 9 files changed, 164 insertions(+), 104 deletions(-) diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index 3cfd511..8fb740c 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -60,7 +60,7 @@ from app.services.param_service import parse_fraction, resolve_snapshot logger = logging.getLogger(__name__) # snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记) -_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算" +_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(未计入分成基数):绩效在月结时按团队与全公司分成计算" @dataclass(frozen=True, slots=True) @@ -544,7 +544,7 @@ def _personal_rate( if customer_type == "enterprise": # 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线) rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000 - return _RateDecision(rate, "企业户固定计提比例", None, False) + return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False) template = role.calc_template if role else "TPL_SALESPERSON_DECAY" if template == "TPL_MANAGER_SHARE": @@ -554,7 +554,7 @@ def _personal_rate( if snapshot.get("manager.personal.included") != "1": return _RateDecision( Decimal("0"), - "经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0", + "经理个人户:绩效按团队与全公司分成在月结时计算,单笔不计提", None, True, ) @@ -563,12 +563,12 @@ def _personal_rate( floor = parse_fraction(snapshot.get("salesperson.rate.floor")) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) - note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天" + note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰" return _RateDecision(rate, note, None, False) if template == "TPL_INFO_SALES_FIXED": # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 rate = parse_fraction(snapshot.get("info.rate")) / 1000 - return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False) + return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False) if template == "TPL_SALESPERSON_DECAY": start = parse_fraction(snapshot.get("salesperson.rate.start")) slope = parse_fraction(snapshot.get("salesperson.rate.slope")) @@ -577,9 +577,16 @@ def _personal_rate( # 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损) boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) - note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)" + note = ( + f"回款 {days} 天,从起始 {start}‰ 按日递减" + f"(满 {boundary} 天后固定按 {floor}‰ 计算)" + ) return _RateDecision(rate, note, None, False) - raise BadRequestError(f"公式模板 {template} 暂不支持核算") + # 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」 + from app.services.calc_templates import TEMPLATES + + tpl_name = TEMPLATES[template].name if template in TEMPLATES else template + raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置") def _role_of(session: Session, person): diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py index 103d720..14a747b 100644 --- a/backend/app/services/param_registry.py +++ b/backend/app/services/param_registry.py @@ -3,6 +3,10 @@ 注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。 + +`description` 会直接显示在「计提参数」页的参数名下方,**面向业务人员撰写**: +说清这个参数管什么、钱怎么变、什么时候该调它。不写英文枚举名、需求编号、 +内部阶段号,也不写实现层面的精度约束(那些留在代码注释与测试里)。 """ from dataclasses import dataclass @@ -43,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="10", value_min="0", value_max=None, - description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", + description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。", sort_order=1, ), ParamSpec( @@ -57,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="4/360", value_min="0", value_max=None, - description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", + description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。", sort_order=2, ), ParamSpec( @@ -71,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="4", value_min="0", value_max=None, - description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", + description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。", sort_order=3, ), ParamSpec( @@ -85,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="360", value_min="0", value_max=None, - description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限", + description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。", sort_order=4, ), ParamSpec( @@ -99,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="12.5", value_min="0", value_max="100", - description="∑本人带领业务员绩效 × 此比例(限本团队)", + description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。", sort_order=5, ), ParamSpec( @@ -113,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="5", value_min="0", value_max="100", - description="∑全部业务员绩效 × 此比例(全公司)", + description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。", sort_order=6, ), ParamSpec( @@ -127,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="3", value_min="0", value_max=None, - description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", + description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。", sort_order=7, ), ParamSpec( @@ -141,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="1", value_min="0", value_max=None, - description="信息员绩效 = 本人销售额 × 此比例", + description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。", sort_order=8, ), ParamSpec( @@ -155,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="10", value_min="0", value_max=None, - description="预付款统一按此比例计提", + description="预付款到账即按这个比例计提绩效,不必等到全额回款。", sort_order=9, ), ParamSpec( @@ -169,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="80", value_min="0", value_max="100", - description="以物抵顶回款绩效按应发绩效的此比例折算", + description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。", sort_order=10, ), ParamSpec( @@ -183,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="60", value_min="0", value_max="100", - description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", + description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。", sort_order=11, ), ParamSpec( @@ -197,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="FULL", value_min=None, value_max=None, - description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", + description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。", sort_order=12, ), ParamSpec( @@ -211,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="30", value_min="0", value_max="100", - description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", + description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。", sort_order=13, ), ParamSpec( @@ -225,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="INCLUDE", value_min=None, value_max=None, - description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", + description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。", sort_order=14, ), ParamSpec( @@ -239,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="18000", value_min="0", value_max=None, - description="试用期年累计绩效超过此值部分年底一次性结算", + description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。", sort_order=15, ), ParamSpec( @@ -253,11 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="1", value_min=None, value_max=None, - description=( - "0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);" - "1=提交后进入财务复核→领导审核审批流。" - "默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭" - ), + description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。", sort_order=17, ), ParamSpec( @@ -271,11 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="1", value_min=None, value_max=None, - description=( - "月度结算审批链开关(④ 月结提交时消费):" - "0=结算生成即生效,1=走财务复核→领导审核审批流。" - "默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭" - ), + description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。", sort_order=18, ), ParamSpec( @@ -289,11 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="0", value_min=None, value_max=None, - description=( - "0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。" - "调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链," - "默认值业务未明确,暂按直接生效,业务可开启)" - ), + description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。", sort_order=19, ), ParamSpec( @@ -307,11 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="0", value_min=None, value_max=None, - description=( - "经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 " - "业务答复 2026-10-19:作为参数配置、手动调整是否纳入):" - "0=不计入(默认,暂口径) 1=计入" - ), + description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。", sort_order=20, ), # ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)===== @@ -327,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="1", value_min=None, value_max=None, - description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)", + description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。", sort_order=21, ), ParamSpec( @@ -341,7 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="90", value_min="1", value_max=None, - description=("发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)"), + description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。", sort_order=22, ), ParamSpec( @@ -355,7 +343,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { default_value="3", value_min="1", value_max=None, - description=("待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)"), + description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。", sort_order=23, ), ] diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py index d82c3df..66fd2ae 100644 --- a/backend/app/services/param_service.py +++ b/backend/app/services/param_service.py @@ -44,7 +44,7 @@ from app.schemas.param import ( ParamPreviewOut, ParamUsedOut, ) -from app.services import audit_service, formula +from app.services import audit_service, calc_templates, formula from app.services.audit_service import AuditRecord, RequestMeta from app.services.formula import to_decimal from app.services.no_generator import next_no @@ -557,25 +557,25 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) rate = take("enterprise.rate") / 1000 - return rate, "企业户固定计提比例", None + return rate, "企业户:不按回款天数,统一按固定比例计提", None role_code = payload.business_role template = "TPL_SALESPERSON_DECAY" if role_code: role = business_role_repository.get_by_code(ctx.session, role_code) if role is None: - raise BadRequestError(f"岗位 {role_code!r} 不存在") + raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认") template = role.calc_template if template == "TPL_MANAGER_SHARE": raise BadRequestError( - "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" + "营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看" ) if template == "TPL_INFO_SALES_FIXED": # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) rate = take("info.rate") / 1000 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 - return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) + return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue) if template == "TPL_SALESPERSON_DECAY": start = take("salesperson.rate.start") slope = take("salesperson.rate.slope") @@ -584,20 +584,27 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim rate = formula.salesperson_rate( days, start=start, slope=slope, floor=floor, boundary_days=boundary ) + # 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降 return ( rate, - f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", + f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减" + f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)", None, ) - raise BadRequestError(f"公式模板 {template} 暂不支持预览") + # 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它 + tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template + raise BadRequestError( + f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提" + ) def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision: """产品类别校验 + 回款天数 + 计提比例判定。""" payload = ctx.payload if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES: + # 提示里用中文类别名:前端下拉显示的是「市政水利」,报错却回英文编码会让人对不上 raise BadRequestError( - f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit" + f"产品类别「{payload.product_category}」不存在,可选:市政水利、轨道交通" ) days = formula.payment_days(payload.ship_date, payload.receipt_date) rate, rate_text, base_override = _preview_rate(ctx, days) @@ -651,7 +658,10 @@ def _preview_probation_note(ctx: _PreviewContext) -> str | None: if not ctx.payload.probation: return None annual = ctx.take("probation.annual.threshold") - return f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算" + return ( + f"试用期人员:绩效照常计算但不发放;年内累计超过 {to_decimal(annual)} 元的部分," + "年底一次性结清。" + ) def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py index 8a531ed..e356360 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -423,9 +423,10 @@ def _aggregate_row( "members": member_names, "params_as_of": str(share.param_date), "manager_personal_included": 1 if share.include_manager_personal else 0, + # 说明写给业务人员看:说清这一笔的基数是怎么凑出来的 "note": ( - f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}" - "经理个人户业绩;9.8 方案公式" + f"团队分成:基数{'含' if share.include_manager_personal else '不含'}" + "经理本人名下业绩,按本团队与全公司两级比例合计计提" ), }, ), diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index 022d4b0..f7d5070 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -371,7 +371,11 @@ class TestPreview: } resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) assert resp.status_code == 400 - assert "聚合" in resp.json()["message"] + # 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数); + # ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。 + message = resp.json()["message"] + assert "营销经理" in message + assert "月度结算" in message def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None: payload = { diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index ee7ca31..404f5aa 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -439,29 +439,29 @@ INSERT INTO param_change_batches (id, version_no, effective_date, change_type, r (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19); INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES -(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1), -(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2), -(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3), -(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4), -(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5), -(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6), -(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7), -(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8), -(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9), -(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10), -(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11), -(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12), -(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13), -(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14), -(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15), -(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17), -(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18), -(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19), -(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20), --- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」) -(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21), -(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22), -(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23); +(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。', 1), +(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。', 2), +(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。', 3), +(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。', 4), +(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。', 5), +(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。', 6), +(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。', 7), +(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。', 8), +(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款到账即按这个比例计提绩效,不必等到全额回款。', 9), +(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。', 10), +(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。', 11), +(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, '选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。', 12), +(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。', 13), +(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, '算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。', 14), +(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。', 15), +(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。', 17), +(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。', 18), +(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。', 19), +(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。', 20), +-- 预警规则参数:工作台预警阈值,可按业务需要调整 +(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。', 21), +(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。', 22), +(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。', 23); INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1), diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index ffa270c..26186b9 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -171,7 +171,7 @@ const allocationHint = computed(() => { const total = Number(allocationTotal.value.toFixed(2)) if (total === 100) return `二次分配合计 ${total.toFixed(2)}%` const rest = Number((100 - total).toFixed(2)) - return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)` + return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归负责人)` }) function addAllocationRow(): void { @@ -880,7 +880,7 @@ onMounted(() => {
二次分配方案 - (可选;比例不要求合计 100%,分摊计算待业务确认后另批) + (可选;各参与人比例不要求合计 100%,余额归负责人) 添加参与人
diff --git a/frontend/src/views/business/receipts/index.vue b/frontend/src/views/business/receipts/index.vue index fb6caf9..4679455 100644 --- a/frontend/src/views/business/receipts/index.vue +++ b/frontend/src/views/business/receipts/index.vue @@ -479,7 +479,7 @@ onMounted(() => { v-model="form.receipt_date" type="date" value-format="YYYY-MM-DD" - placeholder="M3 核算按此日匹配参数版本" + placeholder="选到账当天;计提比例按这一天的参数计算" style="width: 100%" /> diff --git a/frontend/src/views/system/params/index.vue b/frontend/src/views/system/params/index.vue index 4a9997c..d5a8530 100644 --- a/frontend/src/views/system/params/index.vue +++ b/frontend/src/views/system/params/index.vue @@ -99,9 +99,24 @@ function valueLabel(value: string | null | undefined): string { return ENUM_VALUE_LABELS[value] ?? value } -/** 带单位的值展示 */ +/** 带单位的值展示:枚举与开关先翻成中文,其余原样加单位 */ function formatValue(item: ParamCatalogItem, value: string): string { - return item.unit ? `${value} ${item.unit}` : value + // 枚举/开关的原始值是 INCLUDE、FULL、1、0 —— 直接摆给业务人员看不懂, + // 数字型(比例、天数、金额)保持原样,只补单位 + const shown = + item.value_type === 'ENUM' || item.value_type === 'BOOL' ? valueLabel(value) : value + return item.unit ? `${shown} ${item.unit}` : shown +} + +/** + * 按参数编码格式化取值:变更记录 / 版本历史 / 快照对比拿到的都是裸 value_text, + * 没有 value_type 与 unit,只能回目录里按编码查定义再格式化。 + * 目录还没加载完或编码已下线时原样返回,宁可显示原文也不显示空白。 + */ +function formatByCode(code: string, value: string | null | undefined): string { + if (value === null || value === undefined || value === '') return '—' + const item = catalog.value.find((entry) => entry.param_code === code) + return item ? formatValue(item, value) : value } // ==================== 修改(待提交) ==================== @@ -436,7 +451,7 @@ onMounted(() => {

计提参数

- 计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。 + 计提比例、系数、阈值、分成与起付比例都在这里统一维护;每次改动留下旧值,可以指定从哪天起生效。

@@ -484,7 +499,7 @@ onMounted(() => { type="warning" disable-transitions > - 待改为 {{ pendingOf(item, v.dimension_value)!.new_value }} + 待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }} { - 未设置(默认 {{ item.default_value ?? '—' }}) + 未设置(默认 {{ formatByCode(item.param_code, item.default_value) }}) @@ -537,10 +552,14 @@ onMounted(() => { - + - + - +
@@ -637,18 +682,18 @@ onMounted(() => { 导出记录
@@ -719,7 +764,11 @@ onMounted(() => { @@ -809,9 +858,10 @@ onMounted(() => {
{{ previewResult.probation_note }}
-
所用参数(按回款到账日取值)
+
本次用到的参数(按回款到账日取当天生效值)
+
- {{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }} + {{ paramNameByCode(u.param_code) }} = {{ formatByCode(u.param_code, u.value_text) }} ({{ u.effective_date }} 起)
From a00fca942d091514ac04c9c8cde91f614dc9d344 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 23 Oct 2026 16:21:19 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor(frontend):=20=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E5=B1=82=E7=BA=A7=E4=B8=8E=E8=A1=A8=E5=8D=95=E9=A1=B5=E7=89=88?= =?UTF-8?q?=E5=BC=8F=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 侧栏层级口径改版(视觉稿 v4.2):组标题与菜单项同字号 13px,层级改由 「折叠箭头 + 组间留白 + 子项缩进」表达,不靠字号/字重压过父级。 - 去掉组标题的靛蓝竖标与激活项左侧 2px 竖条(竖条只能落在侧栏内边缘、 与白底悬空,是重复的第二个信号),激活只留白底浮起 + 靛蓝字 - 子项缩进 30px 收到 22px,图标父子统一 16px - AppMenu 的 .app-menu 提供横向 10px 留白,BasicLayout 的 .app-aside__menu 只写上下内距——两处都用 padding 简写会同权重靠注入顺序决胜,横向留白 会被整条抹掉、条目贴到侧栏边缘 公司信息页字段自适应分列:卡片够宽时两字段并排,窄时折回单列;说明文字 紧贴控件下方,不再顶到卡片最右缘(宽屏下与输入框隔上千像素,视线关联断掉)。 设计稿同步升 v4.2。 Co-Authored-By: Claude Code --- docs/design/视觉方向预览.html | 37 +++-- frontend/src/components/layout/AppMenu.vue | 145 +++++++++++------- .../src/components/layout/BasicLayout.vue | 8 +- frontend/src/views/system/settings/index.vue | 20 +-- 4 files changed, 130 insertions(+), 80 deletions(-) diff --git a/docs/design/视觉方向预览.html b/docs/design/视觉方向预览.html index 2727210..9dfa016 100644 --- a/docs/design/视觉方向预览.html +++ b/docs/design/视觉方向预览.html @@ -5,7 +5,12 @@ 衍水工资系统 · 视觉方向预览