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:
1 parent
64c57e9141
commit
cb977979da
3 files changed
+139
-32
No files matched your search
@@ -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 = "修改"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user