PayrollManagementSystem/backend/app/services/calc_service.py
liuwenxin df1a3839a1 feat(calc): 单笔回款核算引擎与绩效台账
- calc_service.calc_receipt:锁回款行→settlement_status=0 校验→发货批次/
  归属前置→resolve_snapshot(回款日)→按 calc_template 分派(与 _preview_rate
  同构,‰÷1000 防线)→items 行级折算(行类别阈值,混合按行汇总)→
  barter/起付/R→台账 v1→CAS 置已核算,单 audit 事务
- 策略点集中:_personal_rate(#12 经理个人户单笔落 0 待答复)、_row_profit
  (#20 不含税原样);核算粒度 #25 一期口径=业务级一次性计提(重复回款
  repeated=True 仅登记进度)
- 台账仓储:生效行查询/业务级判定/版本号;schemas LedgerOut/Query
- api /api/v1/calc/*:台账分页/详情/核算触发(写限 admin+marketing_ops)
- 测试 8 例:金标准双类别复算到分(DB 锚定 4000.00/39.11/39.11)、
  一次性计提、已核算 400、未指定批次、成本缺失行保守 -3000、负绩效照算、
  准入、列表筛选;行级明细折算两位固化
- conftest 清理顺序修正:V7 台账/月结清理移到回款/人员删除之前
  (RESTRICT 外键不挡序)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 20:52:02 +08:00

392 lines
16 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.

"""绩效核算服务(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,
) -> CalcReceiptOut:
"""单笔回款核算:回款 → 发货批次 → 业务 → 明细行逐行折算 → 台账固化。
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status=0 校验),
第三层是 CAS UPDATE(rowcount=0 即并发撞车 400)。全程一个 audit
事务(MODULE_CALC),出参在 commit 前固化。
:raises BadRequestError: 已核算 / 未指定发货批次 / 归属未指定 /
无明细行 / 并发撞车
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC,
action="核算",
summary=f"核算回款 #{receipt_id}",
request_data={"receipt_id": receipt_id},
),
meta=meta,
):
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
raise BadRequestError("回款不存在")
if 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("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
# 核算粒度 #25(一期口径):业务级一次性计提——已有生效台账行时,
# 本笔回款仅置已核算状态(支付进度),不重复计提
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_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,
),
)
_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 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)