PayrollManagementSystem/backend/app/services/param_service.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

757 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。
ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日)
冻结当时口径,禁读「当前值」计算历史数据(算法红线)。
变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。
"""
import logging
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_DELETE,
ACTION_UPDATE,
MODULE_PARAM,
NO_PREFIX_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,
ParamChangeItemIn,
ParamDiffOut,
ParamDimensionValueOut,
ParamHistoryOut,
ParamHistoryQuery,
ParamPreviewIn,
ParamPreviewOut,
ParamUsedOut,
)
from app.services import audit_service, calc_templates, 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__)
# ==================== 快照 ====================
@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=spec.description,
values=by_code.get(definition.param_code, []),
)
)
return items
# ==================== 变更与版本管理 ====================
def _normalize_items(
session: Session, items: list[ParamChangeItemIn]
) -> list[tuple[ParamDefinition, str, str]]:
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
"""
codes = list({item.param_code for item in items})
definitions = param_repository.list_definitions_by_codes(session, codes)
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
roles = {
code.casefold(): role
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
}
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in items:
definition = definitions.get(item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {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:
role = roles.get(dimension.casefold())
if role is None:
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
dimension = role.role_code
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))
return normalized
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("指定生效日期不得早于今日")
# 系数表与原参数页共用串行化入口,未来版本也不能绕过预览指纹。
from sqlalchemy import select
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
normalized = _normalize_items(session, payload.items)
try:
batch = param_repository.create_batch(
session,
ParamChangeBatch(
# 版本号与三单据同口径: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,
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,
),
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突:回滚并翻译为可重试的 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, query)
total = param_repository.count_history(session, definition.id, query)
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, query)
total = param_repository.count_batches(session, query)
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
@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]:
"""试算使用正式规则的优先级和基数,并记录真正使用的覆盖参数。"""
from app.services.rate_rules import decide, personal_values
payload, take = ctx.payload, ctx.take
role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY"
if role_code:
role = business_role_repository.get_by_code(ctx.session, role_code)
if role is None:
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
template = role.calc_template
definition = calc_templates.resolve_definition(ctx.session, template)
prepayment = payload.receipt_date < payload.ship_date
if definition.formula_type == "manager_share" or payload.customer_type == "manager_personal":
return Fraction(0), "经理本人业务不单笔计提,团队分成请到月度工资查看", None
if definition.formula_type == "fixed_sales":
rate = take(definition.params.get("rate", "info.rate")) / 1000
return rate, "按岗位固定比例计提", Decimal(payload.revenue)
if payload.customer_type == "enterprise" and not prepayment:
rate = take("enterprise.rate") / 1000
return rate, "企业户按产品分类及到账日的固定比例计提", None
if definition.formula_type != "linear_decay" and not prepayment:
raise BadRequestError(f"「{definition.template_name}」不支持单笔试算,请检查岗位模板")
decay = definition.params
for code in (decay.get("start", "salesperson.rate.start"), decay.get("slope", "salesperson.rate.slope"), decay.get("floor", "salesperson.rate.floor"), decay.get("boundary", "salesperson.days.boundary")):
take(code)
snapshot = resolve_snapshot(ctx.session, payload.receipt_date)
category = payload.product_category or "municipal_water"
values = personal_values(snapshot, category)
for field in ("first", "start", "slope", "floor", "boundary"):
code = f"coefficient.personal.{field}"
dimensions = snapshot._values.get(code, {})
if category in dimensions or "" in dimensions:
take(code)
rate, _, rule = decide({**values, "enterprise": "0"}, category=category,
customer_type=payload.customer_type, role_code="salesperson", days=days, prepayment=prepayment)
return rate, rule, None
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}」不存在,可选:市政水利、轨道交通"
)
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)
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)
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=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,
)
# ==================== 导出 ====================
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()