"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。 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.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, 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.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=definition.description, values=by_code.get(definition.param_code, []), ) ) return items # ==================== 变更与版本管理 ==================== 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} 未在系统中定义") # 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 = 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 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( # 版本号与三单据同口径: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, 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": # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) rate = take("enterprise.rate") / 1000 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": # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) rate = take("info.rate") / 1000 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 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) # 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) 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()