fix(param): 预览千分比换算/版本号复用 next_no/维度规范值落库/冗余提交清理

- 企业户与信息员预览分支把 ‰ 存储值当纯比例直接相乘,绩效金额放大
  1000 倍;补两个岗位路径的精确数值用例(既有用例仅覆盖业务员路径)
- 批次版本号改用 no_generator.next_no(MAX FOR UPDATE 锁当日区间),
  移除无锁全拉重造的 _next_version_no(依赖前一笔的长度优先排序)
- 岗位维度值经 ci 校对命中后以库里规范 role_code 落库,防快照字典
  精确匹配静默不命中;变形大小写提交补回读断言
- 删正常路径冗余双 commit;支付权重 R 封底封顶口径注释声明

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-14 14:20:05 +08:00
parent 64c57e9141
commit cb977979da
3 changed files with 139 additions and 32 deletions

View File

@ -76,6 +76,7 @@ MODULE_RATING = "客户评级"
NO_PREFIX_BUSINESS = "YW" NO_PREFIX_BUSINESS = "YW"
NO_PREFIX_SHIPMENT = "SH" NO_PREFIX_SHIPMENT = "SH"
NO_PREFIX_RECEIPT = "HK" NO_PREFIX_RECEIPT = "HK"
NO_PREFIX_PARAM = "P"
ACTION_CREATE = "新增" ACTION_CREATE = "新增"
ACTION_UPDATE = "修改" ACTION_UPDATE = "修改"

View File

@ -13,7 +13,6 @@ from decimal import Decimal
from fractions import Fraction from fractions import Fraction
from io import BytesIO from io import BytesIO
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -21,6 +20,7 @@ from app.core.constants import (
ACTION_DELETE, ACTION_DELETE,
ACTION_UPDATE, ACTION_UPDATE,
MODULE_PARAM, MODULE_PARAM,
NO_PREFIX_PARAM,
PRODUCT_CATEGORIES, PRODUCT_CATEGORIES,
) )
from app.core.exceptions import BadRequestError, NotFoundError 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 import audit_service, formula
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import to_decimal 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 from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize
logger = logging.getLogger(__name__) 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( def submit_change(
session: Session, session: Session,
payload: ParamChangeIn, payload: ParamChangeIn,
@ -240,18 +226,20 @@ def submit_change(
if definition is None: if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移 # 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") 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 dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "": if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if ( if spec.dimension_type == "BUSINESS_ROLE" and dimension:
spec.dimension_type == "BUSINESS_ROLE" role = business_role_repository.get_by_code(session, dimension)
and dimension if role is None:
and business_role_repository.get_by_code(session, dimension) is None
):
raise BadRequestError( raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
) )
dimension = role.role_code
if ( if (
spec.dimension_type == "PRODUCT_CATEGORY" spec.dimension_type == "PRODUCT_CATEGORY"
and dimension and dimension
@ -273,7 +261,15 @@ def submit_change(
batch = param_repository.create_batch( batch = param_repository.create_batch(
session, session,
ParamChangeBatch( 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, effective_date=effective,
change_type=payload.change_type, change_type=payload.change_type,
remark=payload.remark, remark=payload.remark,
@ -299,10 +295,8 @@ def submit_change(
batch_id=batch.id, batch_id=batch.id,
), ),
) )
session.commit()
except IntegrityError as exc: except IntegrityError as exc:
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员 # version_no 唯一键的并发冲突:回滚并翻译为可重试的 400
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
session.rollback() session.rollback()
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
session.commit() session.commit()
@ -503,7 +497,9 @@ def _preview_rate(
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
""" """
if payload.customer_type == "enterprise": if payload.customer_type == "enterprise":
rate = take("enterprise.rate") # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
rate = take("enterprise.rate") / 1000
return rate, "企业户固定计提比例", None return rate, "企业户固定计提比例", None
role_code = payload.business_role role_code = payload.business_role
@ -519,7 +515,8 @@ def _preview_rate(
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
) )
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
rate = take("info.rate") # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
rate = take("info.rate") / 1000
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
@ -591,7 +588,11 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
start_ratio = take("payment.start.ratio") start_ratio = take("payment.start.ratio")
start_ok = formula.start_payment_ok(received, revenue, 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) weight = formula.payment_weight(weight_r)
base_amount = after_barter if barter_applied else performance base_amount = after_barter if barter_applied else performance
payable = to_decimal(Fraction(base_amount) * weight) payable = to_decimal(Fraction(base_amount) * weight)

View File

@ -11,6 +11,7 @@
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` 运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
""" """
import uuid
from datetime import date, timedelta from datetime import date, timedelta
from fractions import Fraction from fractions import Fraction
@ -49,6 +50,31 @@ def track_batches():
session.commit() 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: 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"] items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"]
@ -328,6 +354,85 @@ class TestPreview:
== 400 == 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: class TestPermission:
"""权限:普通用户全部 403。""" """权限:普通用户全部 403。"""