From cb977979daa2f68b7aee9e3834c42e6d564a2f81 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 14 Oct 2026 14:20:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(param):=20=E9=A2=84=E8=A7=88=E5=8D=83?= =?UTF-8?q?=E5=88=86=E6=AF=94=E6=8D=A2=E7=AE=97/=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=A4=8D=E7=94=A8=20next=5Fno/=E7=BB=B4=E5=BA=A6?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=80=BC=E8=90=BD=E5=BA=93/=E5=86=97?= =?UTF-8?q?=E4=BD=99=E6=8F=90=E4=BA=A4=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 企业户与信息员预览分支把 ‰ 存储值当纯比例直接相乘,绩效金额放大 1000 倍;补两个岗位路径的精确数值用例(既有用例仅覆盖业务员路径) - 批次版本号改用 no_generator.next_no(MAX FOR UPDATE 锁当日区间), 移除无锁全拉重造的 _next_version_no(依赖前一笔的长度优先排序) - 岗位维度值经 ci 校对命中后以库里规范 role_code 落库,防快照字典 精确匹配静默不命中;变形大小写提交补回读断言 - 删正常路径冗余双 commit;支付权重 R 封底封顶口径注释声明 Co-Authored-By: Claude Code --- backend/app/core/constants.py | 1 + backend/app/services/param_service.py | 65 +++++------ .../integration/test_param_management.py | 105 ++++++++++++++++++ 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 8566e60..ef6fc85 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -76,6 +76,7 @@ MODULE_RATING = "客户评级" NO_PREFIX_BUSINESS = "YW" NO_PREFIX_SHIPMENT = "SH" NO_PREFIX_RECEIPT = "HK" +NO_PREFIX_PARAM = "P" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py index 9b68f28..a67d6a6 100644 --- a/backend/app/services/param_service.py +++ b/backend/app/services/param_service.py @@ -13,7 +13,6 @@ from decimal import Decimal from fractions import Fraction from io import BytesIO -from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -21,6 +20,7 @@ from app.core.constants import ( ACTION_DELETE, ACTION_UPDATE, MODULE_PARAM, + NO_PREFIX_PARAM, PRODUCT_CATEGORIES, ) from app.core.exceptions import BadRequestError, NotFoundError @@ -46,6 +46,7 @@ from app.schemas.param import ( from app.services import audit_service, formula from app.services.audit_service import AuditRecord, RequestMeta from app.services.formula import to_decimal +from app.services.no_generator import next_no from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize logger = logging.getLogger(__name__) @@ -187,21 +188,6 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat # ==================== 变更与版本管理 ==================== -def _next_version_no(session: Session) -> str: - """生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。""" - today = date.today() - prefix = f"P{today:%Y%m%d}" - seq = 0 - for (version_no,) in session.execute( - select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%")) - ): - try: - seq = max(seq, int(version_no.rsplit("-", 1)[1])) - except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号 - continue - return f"{prefix}-{seq + 1:02d}" - - def submit_change( session: Session, payload: ParamChangeIn, @@ -240,18 +226,20 @@ def submit_change( if definition is None: # 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移 raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") - spec = get_spec(item.param_code) + # ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值: + # 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致 + # 取值链静默不命中(按错误参数算钱且无报错) + spec = get_spec(definition.param_code) dimension = item.dimension_value if spec.dimension_type == "NONE" and dimension != "": raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") - if ( - spec.dimension_type == "BUSINESS_ROLE" - and dimension - and business_role_repository.get_by_code(session, dimension) is None - ): - raise BadRequestError( - f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" - ) + if spec.dimension_type == "BUSINESS_ROLE" and dimension: + role = business_role_repository.get_by_code(session, dimension) + if role is None: + raise BadRequestError( + f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" + ) + dimension = role.role_code if ( spec.dimension_type == "PRODUCT_CATEGORY" and dimension @@ -273,7 +261,15 @@ def submit_change( batch = param_repository.create_batch( session, ParamChangeBatch( - version_no=_next_version_no(session), + # 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间; + # 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400 + version_no=next_no( + session, + model=ParamChangeBatch, + column_name="version_no", + prefix=NO_PREFIX_PARAM, + seq_width=2, + ), effective_date=effective, change_type=payload.change_type, remark=payload.remark, @@ -299,10 +295,8 @@ def submit_change( batch_id=batch.id, ), ) - session.commit() except IntegrityError as exc: - # version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员 - # 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400 + # version_no 唯一键的并发冲突:回滚并翻译为可重试的 400 session.rollback() raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc session.commit() @@ -503,7 +497,9 @@ def _preview_rate( 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 """ if payload.customer_type == "enterprise": - rate = take("enterprise.rate") + # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount + # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) + rate = take("enterprise.rate") / 1000 return rate, "企业户固定计提比例", None role_code = payload.business_role @@ -519,7 +515,8 @@ def _preview_rate( "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" ) if template == "TPL_INFO_SALES_FIXED": - rate = take("info.rate") + # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) + rate = take("info.rate") / 1000 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) if template == "TPL_SALESPERSON_DECAY": @@ -591,7 +588,11 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: start_ratio = take("payment.start.ratio") start_ok = formula.start_payment_ok(received, revenue, start_ratio) - weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0 + # R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int() + # 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线 + weight_r = ( + max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0 + ) weight = formula.payment_weight(weight_r) base_amount = after_barter if barter_applied else performance payable = to_decimal(Fraction(base_amount) * weight) diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index 43d68f4..cd17dbc 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -11,6 +11,7 @@ 运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` """ +import uuid from datetime import date, timedelta from fractions import Fraction @@ -49,6 +50,31 @@ def track_batches(): session.commit() +@pytest.fixture +def make_role_info(client: TestClient, admin: dict): + """造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。 + + 信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位; + 内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。 + """ + code = f"t_info_{uuid.uuid4().hex[:6]}" + resp = client.post( + "/api/v1/system/business-roles", + json={ + "role_code": code, + "role_name": "集成信息员", + "calc_template": "TPL_INFO_SALES_FIXED", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + role_id = resp.json()["data"]["id"] + try: + yield code + finally: + client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"]) + + def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None: """从目录接口取某参数默认维度的当前生效值。""" items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"] @@ -328,6 +354,85 @@ class TestPreview: == 400 ) + def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None: + """企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。 + + 修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍 + (15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。 + """ + payload = { + "customer_type": "enterprise", + "product_category": "municipal_water", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + assert Fraction(data["rate"]) == Fraction(3, 1000) + # 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000) + assert Fraction(data["performance_amount"]) == Fraction(45) + # 全额回款 R=100 → 权重 1,可付 == 绩效 + assert data["payment_weight_r"] == 100 + assert Fraction(data["payable_amount"]) == Fraction(45) + + def test_信息员_销售额基数乘千分比例( + self, client: TestClient, admin: dict, make_role_info + ) -> None: + """信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。 + + 修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。 + """ + payload = { + "business_role": make_role_info, + "customer_type": "personal", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + # 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值) + assert Fraction(data["converted_profit"]) == Fraction(100000) + assert Fraction(data["rate"]) == Fraction(1, 1000) + # 100000 × 1‰ = 100 + assert Fraction(data["performance_amount"]) == Fraction(100) + + def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None: + """岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。 + + 快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中 + (按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci + 等价冲突由 IntegrityError 分支翻译 400 兜底。 + """ + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [ + {"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"} + ], + }, + headers=admin["headers"], + ) + assert resp.status_code in (200, 400), resp.text + if resp.status_code == 400: + pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖") + batch = resp.json()["data"] + track_batches(batch["id"]) + detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[ + "data" + ] + dims = [item["dimension_value"] for item in detail["items"]] + assert "salesperson" in dims + assert "SALESPERSON" not in dims + class TestPermission: """权限:普通用户全部 403。"""