- 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月 搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算 (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定 - 台账 settlement_id 回填落地:generate 在 replace_items 后回填纳入行、 重生成前先解绑(clear_ledger_settlement_id)——不回填/不解绑会让 「未入结」判据失效:重生成漏算整月或把已固化行反复计入 - 经理聚合行落库:原只进内存参与 items 汇总,台账表永远无 calc_type=2 行、报表下钻落空;重生成先作废旧聚合行(deactivate_month_aggregates), _confirm_scope 限定计算类型避免聚合行混入单笔池 - 月结取参锚定所属月月末(_month_end):原用 date.today(),补生成历史 月份时分成/月工资口径随生成日漂移;聚合行 receipt_date 同步为月末 - generate/submit 出参固化前置到 commit 之前(commit 后 SELECT 失败会把 已生成的月结记成失败审计,与库中事实相反) - V9 迁移:business_secondary_allocations 唯一键条件式补齐(幂等裸 SQL, 临时库验证「无键→补」「有键→跳过」两分支),修复旧 V6 升级库拿不到 V6 就地加入的约束;V8 迁移 revision 声明补类型注解(原格式漂移使 schema 同步测试的 head 推断静默漏检 V8);init-dev 版本号推进至 V9 - V7 留存 SQL 约束/索引名单数改复数(与 alembic op.f 与 init-dev 对齐, 消除 SHOW CREATE TABLE 双轨漂移) - 测试:显式控制审批开关(不依赖 V8 翻转后的种子默认值);新增回归 8 例 (回填落库/聚合不重复/月末取参/月末纯函数 5 例/锁定拦截回款与发货与 核算);全量 773 passed Co-Authored-By: Claude Code <noreply@anthropic.com>
490 lines
20 KiB
Python
490 lines
20 KiB
Python
"""绩效核算服务(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 ROUND_HALF_UP, 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("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||
|
||
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
|
||
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
|
||
from app.services import settlement_service
|
||
|
||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||
|
||
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),折算体随后按当前参数重算。
|
||
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
|
||
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||
# 两行生效行导致双重计提 → 一律 400
|
||
if is_recalc:
|
||
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
|
||
if old is None:
|
||
raise BadRequestError(
|
||
"该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算"
|
||
)
|
||
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 对整数值无小数位,
|
||
# 快照/台账口径需与列一致;显式 HALF_UP——Python 默认
|
||
# 银行家舍入会让 x.xx5 少 1 分)
|
||
"converted": str(
|
||
row_converted.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
),
|
||
}
|
||
)
|
||
|
||
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||
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/序列化三方一致。
|
||
|
||
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家
|
||
舍入),x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
|
||
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=...)——折算体只有一份;所属月已审定
|
||
的拦截在 calc_receipt 内(核算与重算同口径)。
|
||
"""
|
||
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)
|