"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。 单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一 公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按 岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务 按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、 核算才是算钱的法定固化点。 策略点(待业务答复,全部集中为单函数,答复后只改函数 + 按回款批量重算): - ``_personal_rate``:#12 经理个人户单笔落台账 rate=0/绩效 0(12.5%+5% 聚合口径在月结时由 manager_team_share 计算);#20 在 ``_row_profit`` - 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发, 后续回款仅置 settlement_status=1(repeated=True),不重复计提 """ import logging from dataclasses import dataclass from decimal import Decimal from fractions import Fraction from typing import Any from sqlalchemy import update from sqlalchemy.orm import Session from app.core.constants import MODULE_CALC from app.core.exceptions import BadRequestError from app.models import ( Business, BusinessItem, Customer, PerformanceLedger, Receipt, Shipment, User, ) from app.models.performance_ledger import CALC_TYPE_RECEIPT from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING from app.repositories import business_repository, performance_ledger_repository from app.schemas.calc import CalcReceiptOut from app.services import audit_service, param_service, personnel_service from app.services.audit_service import AuditRecord, RequestMeta from app.services.formula import ( apply_barter, clamp_weight_r, converted_profit, payment_days, payment_weight, performance_amount, profit_rate, salesperson_rate, start_payment_ok, ) from app.services.param_service import parse_fraction, resolve_snapshot logger = logging.getLogger(__name__) # snapshot_json 里的策略点注记(答复回填时的检索锚点) _NOTE_MANAGER_PENDING = "经理个人户单笔不计提(#12 待答复):聚合口径在月结时由团队聚合计算" @dataclass(frozen=True, slots=True) class _RateDecision: """单笔计提比例的分派结果。""" rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text) rate_note: str # 人读说明(入 snapshot) base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额) manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点) def calc_receipt( session: Session, receipt_id: int, *, operator: User, meta: RequestMeta | None = None, recalc_reason: str | None = None, ) -> CalcReceiptOut: """单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。 防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层 是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在 commit 前固化。 首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时 仅登记进度(repeated=True)。 重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数 落 calc_version+1 新行,calc_reason 留痕。 """ is_recalc = recalc_reason is not None action = "重算" if is_recalc else "核算" with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_CALC, action=action, summary=f"{action}回款 #{receipt_id}", request_data={ "receipt_id": receipt_id, **({"reason": recalc_reason} if is_recalc else {}), }, ), meta=meta, ): receipt = session.get(Receipt, receipt_id, with_for_update=True) if receipt is None: raise BadRequestError("回款不存在") if is_recalc: if receipt.settlement_status == SETTLEMENT_PENDING: raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)") elif receipt.settlement_status == SETTLEMENT_DONE: raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)") business = session.get(Business, receipt.business_id, with_for_update=True) if business is None: raise BadRequestError("回款关联的业务不存在") if business.owner_personnel_id is None: raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)") # 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算 if is_recalc: old = performance_ledger_repository.get_active_by_receipt(session, receipt_id) if old is not None: performance_ledger_repository.mark_inactive(session, old) # 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效 # 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提 repeated = False if not is_recalc: repeated = performance_ledger_repository.has_active_for_business( session, business.id, calc_type=CALC_TYPE_RECEIPT ) if repeated: _mark_settled(session, receipt) row = performance_ledger_repository.get_active_by_receipt_of_business( session, business.id ) session.flush() out = CalcReceiptOut(ledger=_to_out(row), repeated=True) session.commit() logger.info( "回款登记进度(业务已核算)| receipt=%s | business=%s", receipt_id, business.id ) return out if receipt.shipment_id is None: raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)") shipment = session.get(Shipment, receipt.shipment_id) if shipment is None or shipment.ship_date is None: raise BadRequestError("发货批次缺少发货日期,无法核算") person = personnel_service.ensure_active( session, business.owner_personnel_id, label="核算归属" ) if person.business_role_id is None: raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式") receipt_date = receipt.receipt_date snapshot = resolve_snapshot(session, receipt_date) items = business_repository.list_items(session, business.id) if not items: raise BadRequestError("业务无产品明细行,无法核算") # 逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记) total_revenue = Decimal("0") total_profit = Decimal("0") converted_total = Decimal("0") missing_cost = False line_details: list[dict[str, Any]] = [] for item in items: row_revenue = item.sale_amount if item.cost_amount is None: missing_cost = True row_profit = Decimal("0") else: row_profit = _row_profit(row_revenue, item.cost_amount) total_revenue += row_revenue total_profit += row_profit threshold = parse_fraction( snapshot.get( "product.threshold", role_code=role_code_of(person), product_category=item.product_category, ) ) row_converted = converted_profit( row_revenue, profit_rate(row_profit, row_revenue), threshold ) converted_total += row_converted line_details.append( { "item_id": item.id, "product_category": item.product_category, "revenue": str(row_revenue), "profit": str(row_profit), # 行级折算同样固化为两位(to_decimal 对整数值无小数位, # 快照/台账口径需与列一致) "converted": str(row_converted.quantize(Decimal("0.01"))), } ) # 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入) days = payment_days(shipment.ship_date, receipt_date) decision = _personal_rate(session, snapshot, person, business, days) if decision.base_override is not None: converted = decision.base_override # 信息员:基数=本人销售额 else: converted = converted_total performance = ( Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate) ) barter_applied = receipt.is_barter == 1 base_amount = performance if barter_applied: discount = parse_fraction( snapshot.get("barter.discount", role_code=role_code_of(person)) ) base_amount = apply_barter(performance, discount) received_total = business.total_received_amount or Decimal("0") start_ratio = parse_fraction( snapshot.get("payment.start.ratio", role_code=role_code_of(person)) ) start_ok = start_payment_ok(received_total, total_revenue, start_ratio) weight_r = clamp_weight_r(received_total, total_revenue) weight = payment_weight(weight_r) payable = param_service.to_decimal(Fraction(base_amount) * weight) settlement_month = receipt_date.strftime("%Y-%m") snapshot_json: dict[str, Any] = { "params_as_of": str(receipt_date), "line_details": line_details, "start_payment_ok": start_ok, "rate_note": decision.rate_note, "revenue_total": str(total_revenue), "profit_total": str(total_profit), } if decision.manager_pending: snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING def q2(value: Decimal | None) -> Decimal | None: """金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。""" return value.quantize(Decimal("0.01")) if value is not None else None row = performance_ledger_repository.add( session, PerformanceLedger( receipt_id=receipt.id, calc_version=performance_ledger_repository.next_version(session, receipt.id), calc_type=CALC_TYPE_RECEIPT, business_id=business.id, business_no=business.business_no, personnel_id=person.id, personnel_name=person.personnel_name, role_code=role_code_of(person), customer_type=_customer_type(session, business), product_category=_category_label(items), ship_date=shipment.ship_date, receipt_date=receipt_date, payment_days=days, rate_text=str(decision.rate), converted_profit=q2(converted), performance_amount=q2(performance), barter_applied=1 if barter_applied else 0, weight_r=weight_r, payment_weight=param_service.to_decimal(weight), payable_amount=q2(payable), settlement_month=settlement_month, is_probation=1 if person.is_probation == 1 else 0, missing_cost=1 if missing_cost else 0, snapshot_json=snapshot_json, calc_reason=recalc_reason, ), ) # CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0 # 会 rowcount=0 误报并发撞车) if not is_recalc: _mark_settled(session, receipt) session.flush() out = CalcReceiptOut(ledger=_to_out(row), repeated=False) session.commit() logger.info("核算完成 | receipt=%s | ledger=%s | payable=%s", receipt_id, row.id, payable) return out def _personal_rate( session: Session, snapshot, person, business: Business, days: int, ) -> _RateDecision: """按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。 #12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提 —— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。 """ role = _role_of(session, person) customer_type = _customer_type(session, business) if customer_type == "enterprise": # 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线) rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000 return _RateDecision(rate, "企业户固定计提比例", None, False) template = role.calc_template if role else "TPL_SALESPERSON_DECAY" if template == "TPL_MANAGER_SHARE": return _RateDecision( Decimal("0"), "经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0", None, True, ) if template == "TPL_INFO_SALES_FIXED": # 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线 rate = parse_fraction(snapshot.get("info.rate")) / 1000 return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False) if template == "TPL_SALESPERSON_DECAY": start = parse_fraction(snapshot.get("salesperson.rate.start")) slope = parse_fraction(snapshot.get("salesperson.rate.slope")) floor = parse_fraction(snapshot.get("salesperson.rate.floor")) boundary = int(snapshot.get("salesperson.days.boundary")) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)" return _RateDecision(rate, note, None, False) raise BadRequestError(f"公式模板 {template} 暂不支持核算") def _role_of(session: Session, person): from app.repositories import business_role_repository if person.business_role_id is None: return None return business_role_repository.get_by_id(session, person.business_role_id) def role_code_of(person) -> str: """人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。""" from app.repositories import business_role_repository if person.business_role_id is None: return "" session = Session.object_session(person) role = business_role_repository.get_by_id(session, person.business_role_id) return role.role_code if role else "" def _customer_type(session: Session, business: Business) -> str: customer = session.get(Customer, business.customer_id) return customer.customer_type if customer else "personal" def _category_label(items: list[BusinessItem]) -> str: """单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。""" categories = {item.product_category for item in items} return categories.pop() if len(categories) == 1 else "mixed" def _mark_settled(session: Session, receipt: Receipt) -> None: """CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。""" result = session.execute( update(Receipt) .where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING) .values(settlement_status=SETTLEMENT_DONE) ) if result.rowcount == 0: raise BadRequestError("该回款已被并发核算,请刷新后查看台账") def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal: """行毛利 = 行销售额 − 行成本。 #20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样 相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款 批量重算回填。 """ return revenue - cost def recalc_receipt( session: Session, receipt_id: int, reason: str, *, operator: User, meta: RequestMeta | None = None, ) -> CalcReceiptOut: """重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。 薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定 的拦截在月结(T5)接入后补入本函数。 """ if not reason or not reason.strip(): raise BadRequestError("重算必须填写原因") return calc_receipt( session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip() ) def batch_calc( session: Session, receipt_ids: list[int], *, operator: User, meta: RequestMeta | None = None, ) -> list[dict]: """批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。 **不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应 回滚其它笔的核算结果。 """ results: list[dict] = [] for rid in receipt_ids: try: out = calc_receipt(session, rid, operator=operator, meta=meta) results.append( { "receipt_id": rid, "ok": True, "repeated": out.repeated, "ledger_id": out.ledger.id, } ) except Exception as exc: results.append( {"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"} ) ok_count = sum(1 for r in results if r["ok"]) logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count) return results def list_ledger_page(session: Session, query): """分页查询台账(api 层转发)。""" rows = performance_ledger_repository.list_ledger(session, query) total = performance_ledger_repository.count_ledger(session, query) from app.core.response import PageResult from app.schemas.calc import LedgerOut return PageResult[LedgerOut]( records=[LedgerOut.model_validate(r) for r in rows], total=total, page_num=query.page_num, page_size=query.page_size, ) def get_ledger(session: Session, ledger_id: int): """台账详情(api 层转发)。""" row = performance_ledger_repository.get_by_id(session, ledger_id) if row is None: from app.core.exceptions import NotFoundError raise NotFoundError("台账记录不存在") from app.schemas.calc import LedgerOut return LedgerOut.model_validate(row) def _to_out(row: PerformanceLedger): from app.schemas.calc import LedgerOut return LedgerOut.model_validate(row)