diff --git a/backend/README.md b/backend/README.md index b459a06..0c67150 100644 --- a/backend/README.md +++ b/backend/README.md @@ -264,6 +264,17 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是 - **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。 - **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 +### 面向业务人员的文案(2026-10-23 定稿,改文案前先读) + +系统由财务与营销人员日常使用,**不是给开发者看的**。凡是会渲染到页面的文本(参数说明、错误提示、口径注释)一律按业务语言写: + +- **`param_registry.py` 的 `description` 是唯一显示源**,会直出到「计提参数」页参数名下方。写清「这个参数管什么、钱怎么变、什么时候该调它」,不写英文枚举名(`FULL`/`BALANCE`)、需求编号(`澄清 #10`)、内部阶段号(`M3`/`M4 ⑧`)、实现层精度约束(「禁止按 0.011111 截断存储」留在代码注释与测试里)。 +- **枚举值在页面上必须翻译**:前端 `params/index.vue` 的 `ENUM_VALUE_LABELS`(`FULL` 当月全额 / `BALANCE` 余额 / `INCLUDE` 含基本工资 / `DEDUCT` 扣除基本工资 / `EXCLUDE` 不计基本工资 / `1` 开 / `0` 关)与 `DIMENSION_LABELS`(`municipal_water` 市政水利 / `rail_transit` 轨道交通)。后端报错也不许把原始枚举值甩给用户 —— `param_service._preview_rate` 的产品类别校验用 `「市政水利、轨道交通」` 而不是 `municipal_water / rail_transit`。 +- **公式模板报错用中文模板名**:`calc_templates.TEMPLATES[code].name`,取不到才回退 code;文案要给出下一步(「请在岗位角色里改用业务员递减或信息员固定计提」),而不是只说「不支持」。 +- **`db/init/init-dev.sql` 的种子 description 必须与 registry 同源**:它只在数据卷为空时首次启动执行,已有库要跑 `ai-aux/preview/sync_param_desc.py`(读 `PARAM_REGISTRY` 逐条 UPDATE)才看得到新文案。`db/migration/V*.sql` 里的旧描述是历史记录,**不要回头改** —— 改了会让已部署库与脚本不一致。 +- **前端模板层禁止出现开发者语汇**:`澄清 / 待业务 / M3 / M4 / 一期 / 二阶段 / 暂口径 / 待确认 / 待接 / 暂不 / 待补` 只允许出现在代码注释里。改完用 grep 扫一遍 `frontend/src/views`。 +- **台账与预览里落库的说明文字(`rate_text` / `rate_note` / 聚合行 `snapshot_json.note`)同样受管**:这些会出现在绩效台账、个人明细、打印稿与计算预览上。写成「这笔为什么是这个比例、什么时候不再降」(如「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」),不写 `基数开关=1`、`#12 参数化`、`9.8 方案公式` 这类需求编号。**分数原文(`11/1125`)与百分比拼装(`聚合:20%+5%`)不算说明文字**,那是金额口径的可复核原文,不要为了「口语化」把它改成四舍五入的小数。 + ## 常见问题 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 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/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/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/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/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/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/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/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/README.md b/backend/tests/README.md index e545de6..838ec70 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -209,6 +209,9 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 | | 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 | +| 2026-10-23 | 参数文案中文化 | 单元 526 + 参数集成 24 | ✅ 526 passed(45.15s)/ 24 passed(10.60s) | **计提参数页全面中文化**:`param_registry` 22 条 `description` 改写为业务语言(去掉「澄清 #10」「M3」「禁止按 0.011111 截断」等开发者语汇),`db/init/init-dev.sql` 种子同源同步;`param_service` 模板报错改中文模板名、产品类别报错去掉英文枚举;`dashboard_service` 概览口径说明重写。**这些文案无测试断言**(`rate_text` 断言 `11/1125`、`聚合:20%+5%` 未动),故用例数不变、全绿即口径未被破坏。ruff 绿;前端 `pnpm type-check` 绿 | +| 2026-10-23 | 台账/预览的计提说明口语化 | 单元 526 + 集成 87(calc/settlements/param/reports) | ✅ 526 passed(47.29s)/ 87 passed(7:41) | **`rate_text`/`rate_note`/聚合行 `note` 改业务话**:`企业户固定计提比例`→「企业户:不按回款天数,统一按固定比例计提」、`业务员递减:10‰ 起,20 天(分界 360 天…)`→「回款 20 天,从起始 10‰ 按日递减(满 360 天后固定按 4‰ 计算)」、`信息员:本人销售额 × 计提比例`→「信息员:按本人销售额乘计提比例,不随回款天数递减」、聚合行 `#12 参数化:基数不含经理个人户业绩;9.8 方案公式`→「团队分成:基数不含经理本人名下业绩,按本团队与全公司两级比例合计计提」。**用例同步**:`test_经理聚合口径_预览拒绝` 原断言消息含「聚合」,改为断言含「营销经理」+「月度结算」(守「拒绝且给下一步」,不再绑定措辞);`rate_text` 的 `11/1125`、`聚合:20%+5%` 断言未动(那是分数原文与百分比拼装,非说明文案) | + ## 四、测试发现并修复的缺陷 | 日期 | 缺陷 | 影响 | 处理 | 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/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/docs/design/视觉方向预览.html b/docs/design/视觉方向预览.html index 2727210..9dfa016 100644 --- a/docs/design/视觉方向预览.html +++ b/docs/design/视觉方向预览.html @@ -5,7 +5,12 @@ 衍水工资系统 · 视觉方向预览 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/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 @@