refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数 传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的 约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery), 服务层与测试调用同步调整,行为不变。 - repositories 层查询方法签名改为 (session, query),分页与筛选条件 由 Query 承载,_apply_filters 同步收口 - schemas 层补齐各模块 Query 模型(approval/business_role/report 等) - services 层调用点同步改为传 Query - tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、 参数预览顺序稳定 验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
这个提交包含在:
1 父节点
c48d24ab6d
当前提交
5ece9f6dab
45 个文件已更改
+1045
-1028
没有与搜索条件相匹配的文件
@@ -6,7 +6,7 @@ ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
@@ -379,16 +379,8 @@ def get_history(
|
||||
definition = param_repository.get_definition_by_code(session, param_code)
|
||||
if definition is None:
|
||||
raise NotFoundError("参数不存在")
|
||||
rows = param_repository.list_history(
|
||||
session,
|
||||
definition.id,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
dimension_value=query.dimension_value,
|
||||
)
|
||||
total = param_repository.count_history(
|
||||
session, definition.id, dimension_value=query.dimension_value
|
||||
)
|
||||
rows = param_repository.list_history(session, definition.id, query)
|
||||
total = param_repository.count_history(session, definition.id, query)
|
||||
return PageResult[ParamHistoryOut](
|
||||
records=[
|
||||
ParamHistoryOut(
|
||||
@@ -410,16 +402,8 @@ def get_history(
|
||||
|
||||
def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]:
|
||||
"""变更批次分页(按创建时间倒序)。"""
|
||||
batches = param_repository.list_batches(
|
||||
session,
|
||||
offset=query.offset,
|
||||
limit=query.page_size,
|
||||
start_date=query.start_date,
|
||||
end_date=query.end_date,
|
||||
)
|
||||
total = param_repository.count_batches(
|
||||
session, start_date=query.start_date, end_date=query.end_date
|
||||
)
|
||||
batches = param_repository.list_batches(session, query)
|
||||
total = param_repository.count_batches(session, query)
|
||||
return PageResult[ParamBatchOut](
|
||||
records=[
|
||||
ParamBatchOut(
|
||||
@@ -509,19 +493,66 @@ def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDi
|
||||
return out
|
||||
|
||||
|
||||
def _preview_rate(
|
||||
session: Session,
|
||||
payload: ParamPreviewIn,
|
||||
snapshot: ParamSnapshot,
|
||||
days: int,
|
||||
take,
|
||||
) -> tuple[Fraction, str, Decimal | None]:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewContext:
|
||||
"""预览上下文(会话、入参、参数取值闭包)。
|
||||
|
||||
``take`` 闭包同时负责取数与记录「用了哪些参数」——**调用顺序即
|
||||
``params_used`` 的展示顺序**,拆分时不得调换各段的先后。
|
||||
"""
|
||||
|
||||
session: Session
|
||||
payload: ParamPreviewIn
|
||||
take: Callable[[str], Fraction]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewRateDecision:
|
||||
"""计提比例判定(回款天数、纯比例、人读说明、绩效基数覆盖值)。"""
|
||||
|
||||
days: int
|
||||
rate: Fraction
|
||||
rate_text: str
|
||||
base_override: Decimal | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewEarning:
|
||||
"""折算与绩效(毛利润率、折算额、绩效额)。"""
|
||||
|
||||
profit_rate: Fraction
|
||||
converted: Decimal
|
||||
performance: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewBarter:
|
||||
"""以物抵顶(是否适用、折算后绩效、计发基数)。"""
|
||||
|
||||
applied: bool
|
||||
after_barter: Decimal | None
|
||||
base_amount: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreviewPayment:
|
||||
"""支付环节(起付判定、R 权重、权重、应付金额)。"""
|
||||
|
||||
start_ok: bool
|
||||
weight_r: int
|
||||
weight: Fraction
|
||||
payable: Decimal
|
||||
|
||||
|
||||
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
|
||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||
|
||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
payload = ctx.payload
|
||||
take = ctx.take
|
||||
if payload.customer_type == "enterprise":
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
@@ -531,7 +562,7 @@ def _preview_rate(
|
||||
role_code = payload.business_role
|
||||
template = "TPL_SALESPERSON_DECAY"
|
||||
if role_code:
|
||||
role = business_role_repository.get_by_code(session, role_code)
|
||||
role = business_role_repository.get_by_code(ctx.session, role_code)
|
||||
if role is None:
|
||||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
||||
template = role.calc_template
|
||||
@@ -561,12 +592,73 @@ def _preview_rate(
|
||||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
||||
|
||||
|
||||
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
|
||||
"""产品类别校验 + 回款天数 + 计提比例判定。"""
|
||||
payload = ctx.payload
|
||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||
raise BadRequestError(
|
||||
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
||||
)
|
||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||
rate, rate_text, base_override = _preview_rate(ctx, days)
|
||||
return _PreviewRateDecision(
|
||||
days=days, rate=rate, rate_text=rate_text, base_override=base_override
|
||||
)
|
||||
|
||||
|
||||
def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _PreviewEarning:
|
||||
"""毛利润率 → 折算额(阈值)→ 绩效额(比例)。"""
|
||||
payload = ctx.payload
|
||||
revenue = Decimal(payload.revenue)
|
||||
profit = Decimal(payload.profit)
|
||||
profit_rate = formula.profit_rate(profit, revenue)
|
||||
if decision.base_override is not None:
|
||||
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||
converted = decision.base_override
|
||||
else:
|
||||
threshold = ctx.take("product.threshold")
|
||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||
performance = formula.performance_amount(converted, decision.rate)
|
||||
return _PreviewEarning(profit_rate=profit_rate, converted=converted, performance=performance)
|
||||
|
||||
|
||||
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
|
||||
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
|
||||
applied = ctx.payload.barter
|
||||
after_barter: Decimal | None = None
|
||||
if applied:
|
||||
discount = ctx.take("barter.discount")
|
||||
after_barter = formula.apply_barter(performance, discount)
|
||||
base_amount = after_barter if applied else performance
|
||||
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
|
||||
|
||||
|
||||
def _preview_payment(
|
||||
ctx: _PreviewContext, received: Decimal, revenue: Decimal, base_amount: Decimal
|
||||
) -> _PreviewPayment:
|
||||
"""起付比例判定 + R 权重 + 应付金额。"""
|
||||
start_ratio = ctx.take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||
weight_r = formula.clamp_weight_r(received, revenue)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
return _PreviewPayment(start_ok=start_ok, weight_r=weight_r, weight=weight, payable=payable)
|
||||
|
||||
|
||||
def _preview_probation_note(ctx: _PreviewContext) -> str | None:
|
||||
"""试用期说明(绩效照算不发放,年累计超阈值的部分年底一次性结算)。"""
|
||||
if not ctx.payload.probation:
|
||||
return None
|
||||
annual = ctx.take("probation.annual.threshold")
|
||||
return f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
||||
|
||||
|
||||
def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
"""计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。"""
|
||||
# 参数按回款到账日取值(对照表 #11)
|
||||
snapshot = resolve_snapshot(session, payload.receipt_date)
|
||||
revenue = Decimal(payload.revenue)
|
||||
profit = Decimal(payload.profit)
|
||||
received = Decimal(payload.received_amount)
|
||||
used: list[ParamUsedOut] = []
|
||||
|
||||
@@ -589,55 +681,29 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
)
|
||||
return parse_fraction(raw)
|
||||
|
||||
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||
raise BadRequestError(
|
||||
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
||||
)
|
||||
|
||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
|
||||
|
||||
profit_rate = formula.profit_rate(profit, revenue)
|
||||
if base_override is not None:
|
||||
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||
converted = base_override
|
||||
else:
|
||||
threshold = take("product.threshold")
|
||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||
performance = formula.performance_amount(converted, rate)
|
||||
|
||||
barter_applied = payload.barter
|
||||
after_barter: Decimal | None = None
|
||||
if barter_applied:
|
||||
discount = take("barter.discount")
|
||||
after_barter = formula.apply_barter(performance, discount)
|
||||
|
||||
start_ratio = take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||
weight_r = formula.clamp_weight_r(received, revenue)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
base_amount = after_barter if barter_applied else performance
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
|
||||
probation_note: str | None = None
|
||||
if payload.probation:
|
||||
annual = take("probation.annual.threshold")
|
||||
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
||||
ctx = _PreviewContext(session=session, payload=payload, take=take)
|
||||
# 各段顺序固定 —— take 的调用顺序即 params_used 的展示顺序
|
||||
decision = _preview_rate_decision(ctx)
|
||||
earning = _preview_earning(ctx, decision)
|
||||
barter = _preview_barter(ctx, earning.performance)
|
||||
payment = _preview_payment(ctx, received, revenue, barter.base_amount)
|
||||
probation_note = _preview_probation_note(ctx)
|
||||
|
||||
return ParamPreviewOut(
|
||||
payment_days=days,
|
||||
rate=str(rate),
|
||||
rate_text=rate_text,
|
||||
profit_rate=str(profit_rate),
|
||||
converted_profit=str(converted),
|
||||
performance_amount=str(performance),
|
||||
barter_applied=barter_applied,
|
||||
performance_after_barter=str(after_barter) if after_barter is not None else None,
|
||||
start_payment_ok=start_ok,
|
||||
payment_weight_r=weight_r,
|
||||
payment_weight=str(weight),
|
||||
payable_amount=str(payable),
|
||||
payment_days=decision.days,
|
||||
rate=str(decision.rate),
|
||||
rate_text=decision.rate_text,
|
||||
profit_rate=str(earning.profit_rate),
|
||||
converted_profit=str(earning.converted),
|
||||
performance_amount=str(earning.performance),
|
||||
barter_applied=barter.applied,
|
||||
performance_after_barter=(
|
||||
str(barter.after_barter) if barter.after_barter is not None else None
|
||||
),
|
||||
start_payment_ok=payment.start_ok,
|
||||
payment_weight_r=payment.weight_r,
|
||||
payment_weight=str(payment.weight),
|
||||
payable_amount=str(payment.payable),
|
||||
probation_note=probation_note,
|
||||
params_used=used,
|
||||
)
|
||||
|
||||
在新工单中引用
屏蔽一个用户