补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
131 lines
5.2 KiB
Python
131 lines
5.2 KiB
Python
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
|
||
|
||
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
|
||
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
|
||
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
|
||
结果。分成金额仍走 formula.manager_team_share 纯函数。
|
||
"""
|
||
|
||
from calendar import monthrange
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from fractions import Fraction
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.exceptions import BadRequestError
|
||
from app.core.month import parse_month
|
||
from app.models import Personnel
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ManagerShareParams:
|
||
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
|
||
|
||
month: str
|
||
param_date: date
|
||
team_text: str
|
||
company_text: str
|
||
team_pct: Fraction
|
||
company_pct: Fraction
|
||
include_manager_personal: bool
|
||
|
||
|
||
def month_end(month: str) -> date:
|
||
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
|
||
|
||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||
更细的业务日期,取该月最后可确定的生效时点。
|
||
"""
|
||
try:
|
||
year, mon = parse_month(month)
|
||
except ValueError as exc:
|
||
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
|
||
raise BadRequestError(str(exc)) from exc
|
||
return date(year, mon, monthrange(year, mon)[1])
|
||
|
||
|
||
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
|
||
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
|
||
|
||
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
|
||
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
|
||
"""
|
||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||
|
||
param_date = month_end(month)
|
||
snapshot = resolve_snapshot(session, param_date)
|
||
team_text = snapshot.get("manager.share.team")
|
||
company_text = snapshot.get("manager.share.company")
|
||
return ManagerShareParams(
|
||
month=month,
|
||
param_date=param_date,
|
||
team_text=team_text,
|
||
company_text=company_text,
|
||
team_pct=parse_fraction(team_text),
|
||
company_pct=parse_fraction(company_text),
|
||
include_manager_personal=snapshot.get("manager.personal.included") == "1",
|
||
)
|
||
|
||
|
||
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
|
||
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
|
||
bucket = managers.setdefault(
|
||
manager_id,
|
||
{
|
||
"performance": Decimal("0"),
|
||
"business_id": row.business_id,
|
||
"business_no": row.business_no,
|
||
"names": set(),
|
||
},
|
||
)
|
||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||
bucket["names"].add(row.personnel_name)
|
||
|
||
|
||
def collect_team_buckets(
|
||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||
) -> dict[int, dict]:
|
||
"""按经理采集团队业绩。
|
||
|
||
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
|
||
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
|
||
按上溯会被跳过漏计)。
|
||
"""
|
||
managers: dict[int, dict] = {}
|
||
for row in ledger_rows:
|
||
snapshot = getattr(row, "snapshot_json", None) or {}
|
||
if snapshot.get("engine") == "FIFO":
|
||
if not snapshot.get("manager_own_excluded") and snapshot.get("manager_id") is not None:
|
||
add_team_performance(managers, int(snapshot["manager_id"]), row)
|
||
continue
|
||
if row.customer_type == "manager_personal":
|
||
if include_manager_personal:
|
||
add_team_performance(managers, row.personnel_id, row)
|
||
continue # 默认不计入聚合基数(#12 参数化)
|
||
person = session.get(Personnel, row.personnel_id)
|
||
manager_id = person.manager_id if person else None
|
||
if manager_id is not None:
|
||
add_team_performance(managers, manager_id, row)
|
||
return managers
|
||
|
||
|
||
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
|
||
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
|
||
|
||
口径逐行固定,调用方在循环外算一次。
|
||
"""
|
||
return sum(
|
||
(
|
||
r.performance_amount or Decimal("0")
|
||
for r in ledger_rows
|
||
if not (getattr(r, "snapshot_json", None) or {}).get("manager_own_excluded")
|
||
and (include_manager_personal or r.customer_type != "manager_personal")
|
||
),
|
||
Decimal("0"),
|
||
)
|