"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。 从 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"), )