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:
parent
64c57e9141
commit
cb977979da
@ -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 = "修改"
|
||||||
|
|||||||
@ -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(
|
||||||
):
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||||
raise BadRequestError(
|
)
|
||||||
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)
|
||||||
|
|||||||
@ -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。"""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user