- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾) - 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 30% 会算错轨交工资) - submit 校验顺序前置查库(未注册 param_code 由 500 修正为 400) - 金额拒绝 NaN/Infinity(比较抛 InvalidOperation、计算抛溢出均穿透为 500) - version_no 并发冲突翻译为 400(对齐 user/role service 的 IntegrityError 处理) - 旧值留痕改取条目生效日时点的值(多未来批次下留痕链不再错位) - 脱敏红线:phone/mobile/id_card 键名整值抹除 + 手机号正则兼容分隔符写法 - 前端:撤回按钮与导出文件名改本地日期(UTC/本地混算)、维度行内精确「改」入口、 detailOf 补 catch、抽屉分页 size 生效、blob 错误读出真实 message - e2e 密码移除硬编码 fallback(缺失即报错退出);按钮文案同步「改」 - 全量回归:后端 406 + 集成 104 + 前端 type-check/build + e2e 7 步 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
665 lines
26 KiB
Python
665 lines
26 KiB
Python
"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。
|
||
|
||
ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日)
|
||
冻结当时口径,禁读「当前值」计算历史数据(算法红线)。
|
||
变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。
|
||
"""
|
||
|
||
import logging
|
||
from collections.abc import Iterable
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
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
|
||
|
||
from app.core.constants import (
|
||
ACTION_DELETE,
|
||
ACTION_UPDATE,
|
||
MODULE_PARAM,
|
||
PRODUCT_CATEGORIES,
|
||
)
|
||
from app.core.exceptions import BadRequestError, NotFoundError
|
||
from app.core.response import PageResult
|
||
from app.models import ParamChangeBatch, ParamDefinition, ParamValue, User
|
||
from app.repositories import business_role_repository, param_repository
|
||
from app.schemas.param import (
|
||
ParamBatchDetailOut,
|
||
ParamBatchItemOut,
|
||
ParamBatchOut,
|
||
ParamBatchQuery,
|
||
ParamCatalogItemOut,
|
||
ParamChangeBatchOut,
|
||
ParamChangeIn,
|
||
ParamDiffOut,
|
||
ParamDimensionValueOut,
|
||
ParamHistoryOut,
|
||
ParamHistoryQuery,
|
||
ParamPreviewIn,
|
||
ParamPreviewOut,
|
||
ParamUsedOut,
|
||
)
|
||
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.param_registry import get_spec, parse_fraction, validate_and_normalize
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ==================== 快照 ====================
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ParamSnapshot:
|
||
"""某业务日期下的参数快照(冻结不可变,核算期间保持一致)。
|
||
|
||
覆盖链:角色维度 > 产品类别维度 > 默认行('');均无匹配时回退注册表默认值。
|
||
"""
|
||
|
||
biz_date: date
|
||
_values: dict[str, dict[str, str]]
|
||
_effective: dict[tuple[str, str], date]
|
||
|
||
@classmethod
|
||
def from_rows(
|
||
cls, biz_date: date, rows: Iterable[tuple[str, str, str, date]]
|
||
) -> "ParamSnapshot":
|
||
"""由 (param_code, dimension_value, value_text, effective_date) 行构造。
|
||
|
||
rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。
|
||
"""
|
||
values: dict[str, dict[str, str]] = {}
|
||
effective: dict[tuple[str, str], date] = {}
|
||
for code, dimension, value_text, effective_date in rows:
|
||
if (code, dimension) in effective:
|
||
continue
|
||
effective[(code, dimension)] = effective_date
|
||
values.setdefault(code, {}).setdefault(dimension, value_text)
|
||
return cls(biz_date=biz_date, _values=values, _effective=effective)
|
||
|
||
def get(
|
||
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||
) -> str:
|
||
"""取参数原始值(覆盖链解析)。无值且无注册表默认值时抛 400。"""
|
||
candidates = self._values.get(code)
|
||
if candidates:
|
||
for dimension in (role_code, product_category, ""):
|
||
if dimension is not None and dimension in candidates:
|
||
return candidates[dimension]
|
||
spec = get_spec(code) # 未注册编码 → ValueError(代码缺陷)
|
||
if spec.default_value is None:
|
||
raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护")
|
||
return spec.default_value
|
||
|
||
def effective_date_of(
|
||
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||
) -> date | None:
|
||
"""取参数当前生效值的生效日期(回退注册表默认值时为 None)。"""
|
||
candidates = self._values.get(code)
|
||
if candidates:
|
||
for dimension in (role_code, product_category, ""):
|
||
if dimension is not None and (code, dimension) in self._effective:
|
||
return self._effective[(code, dimension)]
|
||
return None
|
||
|
||
def iter_items(self) -> Iterable[tuple[str, str, str]]:
|
||
"""遍历快照全部 (param_code, dimension_value, value_text),供对比。"""
|
||
for code, dims in self._values.items():
|
||
for dimension, value_text in dims.items():
|
||
yield code, dimension, value_text
|
||
|
||
def fraction(
|
||
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||
) -> Fraction:
|
||
"""取参数值并精确解析为 Fraction("4/360" → 1/90,不经 float)。"""
|
||
return parse_fraction(
|
||
self.get(code, role_code=role_code, product_category=product_category)
|
||
)
|
||
|
||
def integer(
|
||
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||
) -> int:
|
||
"""取参数值为整数。配置成非整数属数据缺陷(写入经校验,不应发生)。"""
|
||
value = self.fraction(code, role_code=role_code, product_category=product_category)
|
||
if value.denominator != 1:
|
||
raise ValueError(f"参数 {code} 配置为非整数:{value}")
|
||
return int(value)
|
||
|
||
def boolean(
|
||
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||
) -> bool:
|
||
"""取布尔参数(0/1)。"""
|
||
return self.get(code, role_code=role_code, product_category=product_category) == "1"
|
||
|
||
def text(
|
||
self, code: str, *, role_code: str | None = None, product_category: str | None = None
|
||
) -> str:
|
||
"""取参数原文(枚举码/文本)。"""
|
||
return self.get(code, role_code=role_code, product_category=product_category)
|
||
|
||
|
||
def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot:
|
||
"""按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。"""
|
||
return ParamSnapshot.from_rows(biz_date, param_repository.resolve_values(session, biz_date))
|
||
|
||
|
||
# ==================== 目录 ====================
|
||
|
||
|
||
def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCatalogItemOut]:
|
||
"""参数目录:全部启用定义 + 各维度截至 asOf(默认今日)的生效值。"""
|
||
biz_date = as_of or date.today()
|
||
resolved = param_repository.resolve_values(session, biz_date)
|
||
by_code: dict[str, list[ParamDimensionValueOut]] = {}
|
||
for code, dimension, value_text, effective_date in resolved:
|
||
by_code.setdefault(code, []).append(
|
||
ParamDimensionValueOut(
|
||
dimension_value=dimension, value_text=value_text, effective_date=effective_date
|
||
)
|
||
)
|
||
items: list[ParamCatalogItemOut] = []
|
||
for definition in param_repository.list_definitions(session, status=1):
|
||
spec = get_spec(definition.param_code)
|
||
items.append(
|
||
ParamCatalogItemOut(
|
||
param_code=definition.param_code,
|
||
param_name=definition.param_name,
|
||
param_group=definition.param_group,
|
||
value_type=definition.value_type,
|
||
unit=definition.unit,
|
||
dimension_type=definition.dimension_type,
|
||
options=list(spec.options or ()),
|
||
default_value=definition.default_value,
|
||
value_min=definition.value_min,
|
||
value_max=definition.value_max,
|
||
description=definition.description,
|
||
values=by_code.get(definition.param_code, []),
|
||
)
|
||
)
|
||
return items
|
||
|
||
|
||
# ==================== 变更与版本管理 ====================
|
||
|
||
|
||
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,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None = None,
|
||
) -> ParamChangeBatchOut:
|
||
"""提交变更批次:全量校验 → 建批次 → 逐项写条目(旧值取提交时生效值)。
|
||
|
||
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 生效日期早于今日 /
|
||
同批次重复条目
|
||
"""
|
||
effective = payload.effective_date if payload.change_type == 2 else date.today()
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_PARAM,
|
||
action=ACTION_UPDATE,
|
||
summary=(
|
||
f"提交计提参数变更 {len(payload.items)} 项,"
|
||
f"{'立即生效' if payload.change_type == 1 else f'{effective} 起生效'}"
|
||
),
|
||
request_data=payload.model_dump(exclude_none=True),
|
||
),
|
||
meta=meta,
|
||
):
|
||
if payload.change_type == 2 and effective < date.today():
|
||
raise BadRequestError("指定生效日期不得早于今日")
|
||
|
||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for item in payload.items:
|
||
definition = param_repository.get_definition_by_code(session, item.param_code)
|
||
if definition is None:
|
||
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||
spec = get_spec(item.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 == "PRODUCT_CATEGORY"
|
||
and dimension
|
||
and dimension not in PRODUCT_CATEGORIES
|
||
):
|
||
raise BadRequestError(
|
||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||
)
|
||
value = validate_and_normalize(spec, item.value)
|
||
key = (item.param_code, dimension)
|
||
if key in seen:
|
||
raise BadRequestError(
|
||
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||
)
|
||
seen.add(key)
|
||
normalized.append((definition, dimension, value))
|
||
|
||
try:
|
||
batch = param_repository.create_batch(
|
||
session,
|
||
ParamChangeBatch(
|
||
version_no=_next_version_no(session),
|
||
effective_date=effective,
|
||
change_type=payload.change_type,
|
||
remark=payload.remark,
|
||
operator_id=operator.id,
|
||
operator_name=operator.username,
|
||
item_count=len(normalized),
|
||
),
|
||
)
|
||
# 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时,
|
||
# 每条记录的是它实际顶替的那一版(而非提交时刻的当前值)
|
||
for definition, dimension, value in normalized:
|
||
old = param_repository.latest_value_before(
|
||
session, definition.id, dimension, effective
|
||
)
|
||
param_repository.create_value(
|
||
session,
|
||
ParamValue(
|
||
param_id=definition.id,
|
||
dimension_value=dimension,
|
||
effective_date=effective,
|
||
value_text=value,
|
||
old_value_text=old.value_text if old else None,
|
||
batch_id=batch.id,
|
||
),
|
||
)
|
||
session.commit()
|
||
except IntegrityError as exc:
|
||
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
|
||
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
|
||
session.rollback()
|
||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||
session.commit()
|
||
logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
|
||
return ParamChangeBatchOut(
|
||
id=batch.id,
|
||
version_no=batch.version_no,
|
||
effective_date=batch.effective_date,
|
||
change_type=batch.change_type,
|
||
remark=batch.remark,
|
||
operator_name=batch.operator_name,
|
||
item_count=batch.item_count,
|
||
create_time=batch.create_time,
|
||
)
|
||
|
||
|
||
def withdraw_batch(
|
||
session: Session, batch_id: int, *, operator: User, meta: RequestMeta | None = None
|
||
) -> None:
|
||
"""撤回未生效批次。已生效禁撤;条目删除后解析自然回退到更早版本。
|
||
|
||
:raises NotFoundError: 批次不存在
|
||
:raises BadRequestError: 批次已生效
|
||
"""
|
||
batch = param_repository.get_batch_by_id(session, batch_id, for_update=True)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_PARAM,
|
||
action=ACTION_DELETE,
|
||
summary=f"撤回参数变更批次 {batch.version_no if batch else f'#{batch_id}'}",
|
||
),
|
||
meta=meta,
|
||
):
|
||
if batch is None:
|
||
raise NotFoundError("变更批次不存在")
|
||
if batch.effective_date <= date.today():
|
||
raise BadRequestError("批次已生效,不可撤回")
|
||
|
||
param_repository.delete_values_by_batch(session, batch.id)
|
||
param_repository.delete_batch(session, batch)
|
||
session.commit()
|
||
logger.info("撤回参数变更批次 | version_no=%s", batch.version_no)
|
||
|
||
|
||
def get_history(
|
||
session: Session, param_code: str, query: ParamHistoryQuery
|
||
) -> PageResult[ParamHistoryOut]:
|
||
"""单参数版本历史(分页,最新在前)。
|
||
|
||
:raises NotFoundError: 参数不存在
|
||
"""
|
||
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
|
||
)
|
||
return PageResult[ParamHistoryOut](
|
||
records=[
|
||
ParamHistoryOut(
|
||
version_no=batch.version_no,
|
||
dimension_value=value.dimension_value,
|
||
old_value_text=value.old_value_text,
|
||
value_text=value.value_text,
|
||
effective_date=value.effective_date,
|
||
operator_name=batch.operator_name,
|
||
create_time=value.create_time,
|
||
)
|
||
for value, batch in rows
|
||
],
|
||
total=total,
|
||
page_num=query.page_num,
|
||
page_size=query.page_size,
|
||
)
|
||
|
||
|
||
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
|
||
)
|
||
return PageResult[ParamBatchOut](
|
||
records=[
|
||
ParamBatchOut(
|
||
id=batch.id,
|
||
version_no=batch.version_no,
|
||
effective_date=batch.effective_date,
|
||
change_type=batch.change_type,
|
||
remark=batch.remark,
|
||
operator_name=batch.operator_name,
|
||
item_count=batch.item_count,
|
||
create_time=batch.create_time,
|
||
)
|
||
for batch in batches
|
||
],
|
||
total=total,
|
||
page_num=query.page_num,
|
||
page_size=query.page_size,
|
||
)
|
||
|
||
|
||
def get_batch_detail(session: Session, batch_id: int) -> ParamBatchDetailOut:
|
||
"""批次明细:批次头 + 逐项旧值 → 新值。
|
||
|
||
:raises NotFoundError: 批次不存在
|
||
"""
|
||
batch = param_repository.get_batch_by_id(session, batch_id)
|
||
if batch is None:
|
||
raise NotFoundError("变更批次不存在")
|
||
items = [
|
||
ParamBatchItemOut(
|
||
param_code=definition.param_code,
|
||
param_name=definition.param_name,
|
||
dimension_value=value.dimension_value,
|
||
old_value_text=value.old_value_text,
|
||
value_text=value.value_text,
|
||
effective_date=value.effective_date,
|
||
)
|
||
for value, definition in param_repository.list_values_by_batch(session, batch_id)
|
||
]
|
||
return ParamBatchDetailOut(
|
||
id=batch.id,
|
||
version_no=batch.version_no,
|
||
effective_date=batch.effective_date,
|
||
change_type=batch.change_type,
|
||
remark=batch.remark,
|
||
operator_name=batch.operator_name,
|
||
item_count=batch.item_count,
|
||
create_time=batch.create_time,
|
||
items=items,
|
||
)
|
||
|
||
|
||
# ==================== 对比与预览 ====================
|
||
|
||
|
||
def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDiffOut]:
|
||
"""两日期快照逐项对比(全量参数,changed 标记变化)。
|
||
|
||
:raises BadRequestError: 起始日期晚于截止日期
|
||
"""
|
||
if from_date > to_date:
|
||
raise BadRequestError("起始日期不得晚于截止日期")
|
||
old_items = {
|
||
(code, dimension): value
|
||
for code, dimension, value in resolve_snapshot(session, from_date).iter_items()
|
||
}
|
||
new_items = {
|
||
(code, dimension): value
|
||
for code, dimension, value in resolve_snapshot(session, to_date).iter_items()
|
||
}
|
||
out: list[ParamDiffOut] = []
|
||
for code, dimension in sorted(set(old_items) | set(new_items)):
|
||
spec = get_spec(code)
|
||
old_value = old_items.get((code, dimension))
|
||
new_value = new_items.get((code, dimension))
|
||
out.append(
|
||
ParamDiffOut(
|
||
param_code=code,
|
||
param_name=spec.name,
|
||
param_group=spec.group,
|
||
dimension_value=dimension,
|
||
old_value=old_value,
|
||
new_value=new_value,
|
||
changed=old_value != new_value,
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def _preview_rate(
|
||
session: Session,
|
||
payload: ParamPreviewIn,
|
||
snapshot: ParamSnapshot,
|
||
days: int,
|
||
take,
|
||
) -> tuple[Fraction, str, Decimal | None]:
|
||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||
|
||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||
"""
|
||
if payload.customer_type == "enterprise":
|
||
rate = take("enterprise.rate")
|
||
return rate, "企业户固定计提比例", None
|
||
|
||
role_code = payload.business_role
|
||
template = "TPL_SALESPERSON_DECAY"
|
||
if role_code:
|
||
role = business_role_repository.get_by_code(session, role_code)
|
||
if role is None:
|
||
raise BadRequestError(f"岗位 {role_code!r} 不存在")
|
||
template = role.calc_template
|
||
|
||
if template == "TPL_MANAGER_SHARE":
|
||
raise BadRequestError(
|
||
"营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览"
|
||
)
|
||
if template == "TPL_INFO_SALES_FIXED":
|
||
rate = take("info.rate")
|
||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
||
if template == "TPL_SALESPERSON_DECAY":
|
||
start = take("salesperson.rate.start")
|
||
slope = take("salesperson.rate.slope")
|
||
floor = take("salesperson.rate.floor")
|
||
boundary = int(take("salesperson.days.boundary"))
|
||
rate = formula.salesperson_rate(
|
||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||
)
|
||
return (
|
||
rate,
|
||
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
||
None,
|
||
)
|
||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
||
|
||
|
||
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] = []
|
||
|
||
def take(code: str) -> Fraction:
|
||
raw = snapshot.get(
|
||
code,
|
||
role_code=payload.business_role,
|
||
product_category=payload.product_category,
|
||
)
|
||
used.append(
|
||
ParamUsedOut(
|
||
param_code=code,
|
||
value_text=raw,
|
||
effective_date=snapshot.effective_date_of(
|
||
code,
|
||
role_code=payload.business_role,
|
||
product_category=payload.product_category,
|
||
),
|
||
)
|
||
)
|
||
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)
|
||
weight_r = 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)
|
||
|
||
probation_note: str | None = None
|
||
if payload.probation:
|
||
annual = take("probation.annual.threshold")
|
||
probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算"
|
||
|
||
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),
|
||
probation_note=probation_note,
|
||
params_used=used,
|
||
)
|
||
|
||
|
||
# ==================== 导出 ====================
|
||
|
||
|
||
def export_changes(
|
||
session: Session, *, start_date: date | None = None, end_date: date | None = None
|
||
) -> bytes:
|
||
"""变更记录导出 Excel(openpyxl)。列:版本号/变更人/变更时间/生效日期/参数/维度/旧值/新值。"""
|
||
from openpyxl import Workbook
|
||
|
||
rows = param_repository.list_export_rows(session, start_date=start_date, end_date=end_date)
|
||
workbook = Workbook()
|
||
sheet = workbook.active
|
||
sheet.title = "参数变更记录"
|
||
sheet.append(
|
||
[
|
||
"版本号",
|
||
"变更人",
|
||
"变更时间",
|
||
"生效日期",
|
||
"参数编码",
|
||
"参数名称",
|
||
"维度值",
|
||
"旧值",
|
||
"新值",
|
||
]
|
||
)
|
||
for batch, value, definition in rows:
|
||
sheet.append(
|
||
[
|
||
batch.version_no,
|
||
batch.operator_name,
|
||
batch.create_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
value.effective_date.strftime("%Y-%m-%d"),
|
||
definition.param_code,
|
||
definition.param_name,
|
||
value.dimension_value,
|
||
value.old_value_text,
|
||
value.value_text,
|
||
]
|
||
)
|
||
buffer = BytesIO()
|
||
workbook.save(buffer)
|
||
return buffer.getvalue()
|