feat/m3-increments #1
@ -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)
|
||||
|
||||
@ -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。"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user