- 四表接口 /api/v1/reports/*:个人明细(全筛选 summary)、部门汇总(团队基数与月结聚合同源、12.5%/5% 分列、零行经理、冻结对照)、业务统计(排除聚合行)、回款统计(8 档区间恒在、回款金额按 receipt_id 去重防分摊拆行翻倍) - Excel 导出:公司页眉/财务复核栏/领导签字栏/日期/版本、Decimal 原值 + number_format、文本防公式注入、2 万行超限 400、导出行为入操作日志 - manager_share 共享模块:月结聚合与报表部门汇总同源取参与分桶(薄委托保留测试入口) - 区间元数据落 core/report_meta(repository 需要类型,避免反向依赖 service 层) - 准入 require_roles(admin/finance/leader),支持自定义 403 文案 Co-Authored-By: Claude Code <noreply@anthropic.com>
119 lines
4.5 KiB
Python
119 lines
4.5 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.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);聚合与月工资口径没有
|
||
更细的业务日期,取该月最后可确定的生效时点。
|
||
"""
|
||
year, mon = int(month[:4]), int(month[5:7])
|
||
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:
|
||
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 include_manager_personal or r.customer_type != "manager_personal"
|
||
),
|
||
Decimal("0"),
|
||
)
|