PayrollManagementSystem/backend/app/services/report_service.py
liuwenxin 5ece9f6dab refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数
传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的
约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery),
服务层与测试调用同步调整,行为不变。

- repositories 层查询方法签名改为 (session, query),分页与筛选条件
  由 Query 承载,_apply_filters 同步收口
- schemas 层补齐各模块 Query 模型(approval/business_role/report 等)
- services 层调用点同步改为传 Query
- tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、
  参数预览顺序稳定

验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00

1130 lines
46 KiB
Python
Raw Permalink 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.

"""报表统计服务(M4 ⑤):四张报表取数编排 + Excel 导出编排。
全局口径(四表共用,改动须同步 backend/README「报表口径」小节):
1. 数据源 ``performance_ledgers`` 固定 **is_active=1 AND calc_type=1**(单笔
生效行);经理聚合行(calc_type=2)只在部门汇总作冻结对照单读——把
聚合额混进明细会让「汇总 = 明细合计」失效;
2. 金额全程 Decimal(两位收口 ROUND_HALF_UP);比例原样输出 ``rate_text``
(如 88/9000),禁止转 float 或截断展示;
3. 调整单并入:仅 status=2(生效);direction=1 补发记 +、2 扣减记 −
(金额恒正,方向表达语义);
4. 离职人员不按 ``personnel.status`` 过滤——历史业绩留在原团队(FR-030
「变更不回溯」原则);无成员团队仍出 0 值行;
5. 团队基数直接取 ``manager_share.collect_team_buckets`` 的分桶结果,与
月结聚合行同源(不是报表侧另算一遍)。
"""
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import select
from app.core import report_meta
from app.core.constants import ACTION_EXPORT, MODULE_REPORT
from app.core.exceptions import BadRequestError
from app.core.report_meta import (
BUSINESS_STATUS_LABELS,
CUSTOMER_TYPE_LABELS,
PRODUCT_CATEGORY_LABELS,
REPORT_EXPORT_MAX_ROWS,
REPORT_TEMPLATE_VERSION,
DayBucket,
)
from app.models import BusinessRole, Customer, PerformanceLedger, Personnel, User
from app.models.settlement import ADJUSTMENT_DIRECTION_EXTRA
from app.repositories import report_repository
from app.services import audit_service, manager_share
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share
# ==== 元数据 ====
def get_payment_day_buckets() -> list[dict]:
"""回款天数区间元数据(前端下拉唯一数据源,禁止前端硬编码档位)。"""
return [
{"code": b.code, "label": b.label, "min_days": b.min_days, "max_days": b.max_days}
for b in report_meta.get_payment_day_buckets()
]
# ==== 个人绩效明细(FR-029)====
def list_personal_detail(session, query) -> dict:
"""明细分页 + 全筛选合计(summary 与分页同条件,避免页脚合计≠全部结果)。"""
rows = report_repository.list_personal_detail(
session, query, offset=query.offset, limit=query.page_size
)
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return {
"list": [_person_row(r, role_names) for r in rows],
"total": report_repository.count_personal_detail(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {
"row_count": sums["row_count"],
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
"converted_profit_total": _q2(_dec(sums["converted_profit_total"])),
},
}
def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
"""台账行 → 明细行出参(金额保持 Decimal,序列化交由信封层)。"""
return {
"ledger_id": row.id,
"business_id": row.business_id,
"business_no": row.business_no,
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"role_name": role_names.get(row.role_code, row.role_code),
"customer_type": row.customer_type,
"product_category": row.product_category,
"ship_date": row.ship_date,
"receipt_date": row.receipt_date,
"payment_days": row.payment_days,
"rate_text": row.rate_text,
"weight_r": row.weight_r,
"payment_weight": row.payment_weight,
"performance_amount": row.performance_amount,
"payable_amount": row.payable_amount,
"converted_profit": row.converted_profit,
"is_probation": row.is_probation,
"missing_cost": row.missing_cost,
"calc_version": row.calc_version,
"settlement_id": row.settlement_id,
}
def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return {r.role_code: r.role_name for r in session.execute(select(BusinessRole)).scalars()}
def _dec(value) -> Decimal | None:
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
if value is None:
return None
return value if isinstance(value, Decimal) else Decimal(str(value))
def _q2(value: Decimal | None) -> Decimal | None:
"""两位小数收口(HALF_UP,与核算/月结舍入口径一致)。"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# ==== 部门绩效汇总(FR-030)====
def department_summary(session, query) -> dict:
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。
人员归属按「行类型」而不是「人」划分:普通台账行跟随 ``manager_id``,
经理个人户行按开关归经理本人——与 ``collect_team_buckets`` 完全同口径。
调整单是人的属性,优先跟随其普通行归属;只有经理个人户的经理跟随
本人团队。每个人/每类金额在团队与未分配之间只出现一次。
"""
month = query.settlement_month
rows = report_repository.list_month_receipt_rows(session, month)
share = manager_share.manager_share_params(session, month)
include_mp = share.include_manager_personal
buckets = manager_share.collect_team_buckets(session, rows, include_mp)
company_base = manager_share.company_base_of(rows, include_mp)
aggregates = {
row.personnel_id: row
for row in report_repository.list_month_manager_aggregates(session, month)
}
adjustments = _adjustments_by_person(report_repository.list_month_adjustments(session, month))
member_map = _member_map(rows)
# 先构建全部团队再按筛选展示:other_*(隐藏团队金额)需要完整集合,
# 只按 query.manager_id 枚举会让筛选态的 other_* 恒为 0
manager_ids = _manager_ids(session, buckets, None)
if query.manager_id is not None and query.manager_id not in manager_ids:
manager_ids = sorted(set(manager_ids) | {query.manager_id})
# 岗位判定范围必须覆盖「无成员的经理」与调整单人员——只按 member_map 取
# 会让零行团队被跳过,也会让其他经理的下属被误判为无经理
manager_of, manager_role, person_info = _team_relations(
session, member_map, manager_ids, adjustments
)
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
_merge_adjustment_only_members(member_map, adjustments, person_info)
context = {
"aggregates": aggregates,
"buckets": buckets,
"member_map": member_map,
"manager_of": manager_of,
"manager_role": manager_role,
"adjustments": adjustments,
"include_mp": include_mp,
"share": share,
"company_base": company_base,
}
teams_all = _build_teams(session, manager_ids, context)
if query.manager_id is None:
# 离职经理本月无台账、但其下属只有调整单时,团队仍必须展示,
# 否则调整金额会从团队与未分配同时消失
extra_ids = {
target
for pid in adjustments
if (target := _adjustment_target(pid, member_map, manager_of, manager_role, include_mp))
is not None
} - set(manager_ids)
if extra_ids:
teams_all.extend(_build_teams(session, sorted(extra_ids), context))
teams = [
team
for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id
]
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct)
company_share_count = sum(1 for team in teams if team["has_aggregate"])
reconciliation = _reconciliation(teams, teams_all, unassigned, company_base)
return {
"settlement_month": month,
"params": {
"team_text": share.team_text,
"company_text": share.company_text,
"manager_personal_included": 1 if include_mp else 0,
"params_as_of": str(share.param_date),
},
"teams": teams,
"unassigned": unassigned,
"company": {
"team_base_total": reconciliation["team_base_total"],
"company_base": _q2(company_base),
"company_share_pct": share.company_text,
# 单经理名义值;结算总额见 company_share_total(每位有聚合行的经理各计一次)
"company_share_amount": _q2(company_share_amount),
"company_share_count": company_share_count,
"company_share_total": _q2(company_share_amount * company_share_count),
},
"reconciliation": reconciliation,
"totals": {
"performance_total": reconciliation["team_base_total"],
"payable_total": reconciliation["payable_total"],
},
}
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
"""按经理枚举生成团队行(含零成员团队)。"""
teams = []
for manager_id in manager_ids:
team = _build_team(session, manager_id, context)
if team is not None:
teams.append(team)
return teams
def _build_team(session, manager_id: int, context: dict) -> dict | None:
"""单个经理团队行;非营销经理的上级不展示(与月结聚合同口径)。"""
manager = session.get(Personnel, manager_id)
if manager is None or context["manager_role"].get(manager_id) != "sales_manager":
return None
aggregate = context["aggregates"].get(manager_id)
member_rows = _member_rows(
manager_id,
context["member_map"],
context["manager_of"],
context["manager_role"],
context["adjustments"],
context["include_mp"],
)
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
# 月结只为有台账 bucket 的营销经理生成聚合行;零成员团队不发 5%
has_aggregate = manager_id in context["buckets"] or aggregate is not None
team_share = _team_share(team_base, context["share"].team_pct)
# 聚合额必须与月结「一次舍入」口径一致:优先取冻结行;未生成月结时
# 才用 12.5%+5% 合并公式预览。分列各自 q2 后相加会差 0.01~0.02。
if aggregate is not None:
aggregate_amount = _dec(aggregate.performance_amount or Decimal("0"))
elif manager_id in context["buckets"]:
aggregate_amount = _q2(
manager_team_share(
team_base,
context["company_base"],
team_pct=context["share"].team_pct,
company_pct=context["share"].company_pct,
)
)
else:
aggregate_amount = Decimal("0")
company_share = (
_company_share(context["company_base"], context["share"].company_pct)
if has_aggregate
else Decimal("0")
)
team_subtotal = _q2(aggregate_amount + team_extra - team_deduct)
return {
"manager_id": manager_id,
"manager_name": manager.personnel_name,
"manager_status": manager.status,
"member_count": len(member_rows),
"team_base": _q2(team_base),
"team_share_amount": _q2(team_share),
# 单经理 5% 名义值;无聚合行时为 0(不会发放)
"company_share_amount": _q2(company_share),
"has_aggregate": has_aggregate,
# 与月结聚合行同口径(12.5% + 5% 一次舍入);无聚合行时为 0
"aggregate_amount": _q2(aggregate_amount),
# 团队应发小计 = 聚合行金额 + 调整净额;不再另加一次 5%
"team_subtotal": team_subtotal,
"company_base_ratio": _ratio(team_base, context["company_base"]),
"adjust_extra": _q2(team_extra),
"adjust_deduct": _q2(team_deduct),
"adjust_net": _q2(team_extra - team_deduct),
"net_total": team_subtotal,
"frozen_amount": aggregate.performance_amount if aggregate else None,
"aggregate_ledger_id": aggregate.id if aggregate else None,
"members": member_rows,
}
def _reconciliation(
teams: list[dict],
teams_all: list[dict],
unassigned: list[dict],
company_base: Decimal,
) -> dict:
"""展示口径的对账块。
应发 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
聚合行金额(``aggregate_amount``)含 12.5%+5%,且与月结「一次舍入」
完全一致;``team_share_total``/``company_share_total`` 只作分列展示,
两者之和与聚合额的差记入 ``aggregate_rounding_diff``(分列各自 q2
会产生 0.01~0.02 的尾差,直接相加会与月结总额不符)。
筛选态下 ``other_*`` 记录被隐藏团队的金额;它们不进入应发。
"""
displayed_ids = {team["manager_id"] for team in teams}
other_teams = [team for team in teams_all if team["manager_id"] not in displayed_ids]
team_member_payable = sum(
(m["payable_sum"] for team in teams for m in team["members"]), Decimal("0")
)
unassigned_payable = sum((row["payable_sum"] for row in unassigned), Decimal("0"))
team_share_total = sum((team["team_share_amount"] for team in teams), Decimal("0"))
company_share_total = sum((team["company_share_amount"] for team in teams), Decimal("0"))
aggregate_payable_total = sum((team["aggregate_amount"] for team in teams), Decimal("0"))
aggregate_rounding_diff = aggregate_payable_total - team_share_total - company_share_total
adjust_net_total = sum(
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
unassigned_performance = sum((row["performance_sum"] for row in unassigned), Decimal("0"))
payable_total = (
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
)
return {
"team_member_payable_total": _q2(team_member_payable),
"unassigned_payable_total": _q2(unassigned_payable),
"team_share_total": _q2(team_share_total),
# 各位有聚合行的经理 5% 之和(单份 × 计入次数)
"company_share_total": _q2(company_share_total),
# 与月结聚合行同口径的应发合计(12.5% + 5% 一次舍入)
"aggregate_payable_total": _q2(aggregate_payable_total),
# 分列展示与聚合额之间的舍入差;导出签字稿单列一行
"aggregate_rounding_diff": _q2(aggregate_rounding_diff),
"adjust_net_total": _q2(adjust_net_total),
"payable_total": _q2(payable_total),
"team_base_total": _q2(team_base_total),
"unassigned_performance_total": _q2(unassigned_performance),
# 筛选时包含其他团队基数与全公司未分配;未筛选时应为 0
"other_team_base": _q2(company_base - team_base_total - unassigned_performance),
"other_team_member_payable_total": _q2(
sum((m["payable_sum"] for team in other_teams for m in team["members"]), Decimal("0"))
),
"other_team_share_total": _q2(
sum((team["team_share_amount"] for team in other_teams), Decimal("0"))
),
"other_team_company_share_total": _q2(
sum((team["company_share_amount"] for team in other_teams), Decimal("0"))
),
"other_team_aggregate_total": _q2(
sum((team["aggregate_amount"] for team in other_teams), Decimal("0"))
),
"other_adjust_net_total": _q2(
sum((team["adjust_net"] for team in other_teams), Decimal("0"))
),
"company_base": _q2(company_base),
}
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0))
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
"""5% 全公司分成:团队部分传 0,仅取 company_pct 项。"""
return manager_team_share(
Decimal("0"), company_base, team_pct=Fraction(0), company_pct=company_pct
)
def _ratio(part: Decimal, whole: Decimal) -> Decimal:
"""占比(除零返回 0,不抛 ZeroDivisionError)。"""
if not whole:
return Decimal("0")
return (part / whole).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def _adjustments_by_person(orders) -> dict[int, dict]:
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。
不要求该人本月有台账行——只有调整单的人也必须进入报表(计划 §一.3)。
"""
by_person: dict[int, dict] = {}
for order in orders:
acc = by_person.setdefault(
order.personnel_id,
{
"extra": Decimal("0"),
"deduct": Decimal("0"),
"personnel_name": order.personnel_name,
},
)
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
acc["extra"] += order.amount
else:
acc["deduct"] += order.amount
return by_person
def _member_map(rows) -> dict[int, dict]:
"""台账行按人求和(成员明细的唯一数据源)。
普通行与经理个人户行分开记账:团队基数只含后者,成员行也只能取
后者,否则经理本人的普通业绩会同时出现在成员行与未分配中。
"""
members: dict[int, dict] = {}
for row in rows:
acc = members.setdefault(
row.personnel_id,
{
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"is_probation": row.is_probation,
"performance": Decimal("0"),
"payable": Decimal("0"),
"regular_performance": Decimal("0"),
"regular_payable": Decimal("0"),
"manager_personal_performance": Decimal("0"),
"manager_personal_payable": Decimal("0"),
"regular_ledger_ids": [],
"manager_personal_ledger_ids": [],
"has_regular_rows": False,
"has_manager_personal": False,
},
)
performance = row.performance_amount or Decimal("0")
payable = row.payable_amount or Decimal("0")
acc["performance"] += performance
acc["payable"] += payable
if row.customer_type == "manager_personal":
acc["has_manager_personal"] = True
acc["manager_personal_performance"] += performance
acc["manager_personal_payable"] += payable
acc["manager_personal_ledger_ids"].append(row.id)
else:
acc["has_regular_rows"] = True
acc["regular_performance"] += performance
acc["regular_payable"] += payable
acc["regular_ledger_ids"].append(row.id)
return members
def _team_relations(
session,
member_map: dict[int, dict],
manager_ids: list[int],
adjustments: dict[int, dict],
) -> tuple[dict[int, int | None], dict[int, str], dict[int, dict]]:
"""人员 → 直属经理、岗位编码与展示信息(批量查询,避免 N+1)。
查询范围 = 成员 ∪ 经理枚举 ∪ 调整单人员;随后再补查这些人引用的
上级。缺少上级的岗位信息时,其他经理的下属会被误判为「无经理」,
零成员团队也会被岗位过滤静默跳过(FR-030 要求出 0 行)。
经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径:
离职人员的 manager_id 保留,历史业绩因此留在原团队。
"""
seed = set(member_map) | set(manager_ids) | set(adjustments)
if not seed:
return {}, {}, {}
people = list(session.execute(select(Personnel).where(Personnel.id.in_(seed))).scalars())
referenced = {p.manager_id for p in people if p.manager_id is not None}
missing = referenced - seed
if missing:
people.extend(session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars())
role_by_id = {r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()}
manager_of = {p.id: p.manager_id for p in people}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
person_info = {
p.id: {
"personnel_name": p.personnel_name,
"role_code": role_by_id.get(p.business_role_id, ""),
"is_probation": p.is_probation,
}
for p in people
}
return manager_of, manager_role, person_info
def _merge_adjustment_only_members(
member_map: dict[int, dict], adjustments: dict[int, dict], person_info: dict[int, dict]
) -> None:
"""把「只有调整单、本月无台账行」的人补成零值成员。
补进 member_map 后,``_member_rows`` / ``_unassigned_rows`` 的归属
逻辑无需再为调整单单独开一条遍历分支;绩效为 0 也保证团队基数不变。
"""
for personnel_id, adjustment in adjustments.items():
if personnel_id in member_map:
continue
info = person_info.get(personnel_id) or {
"personnel_name": adjustment["personnel_name"],
"role_code": "",
"is_probation": 0,
}
member_map[personnel_id] = {
"personnel_name": info["personnel_name"],
"role_code": info["role_code"],
"is_probation": info["is_probation"],
"performance": Decimal("0"),
"payable": Decimal("0"),
"regular_performance": Decimal("0"),
"regular_payable": Decimal("0"),
"manager_personal_performance": Decimal("0"),
"manager_personal_payable": Decimal("0"),
"regular_ledger_ids": [],
"manager_personal_ledger_ids": [],
"has_regular_rows": False,
"has_manager_personal": False,
}
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
"""经理枚举:数据中出现的经理 ∪ 在职营销经理(无成员也要出 0 值行)。"""
if only is not None:
return [only]
found = set(buckets)
role = session.execute(
select(BusinessRole).where(BusinessRole.role_code == "sales_manager")
).scalar_one_or_none()
if role is not None:
active = session.execute(
select(Personnel.id).where(Personnel.business_role_id == role.id, Personnel.status == 1)
).scalars()
found.update(active)
return sorted(found)
def _regular_team_of(
pid: int, manager_of: dict[int, int | None], manager_role: dict[int, str]
) -> int | None:
"""普通台账行的团队归属:直属上级是营销经理时归其团队。"""
manager_id = manager_of.get(pid)
if manager_id is not None and manager_role.get(manager_id) == "sales_manager":
return manager_id
return None
def _adjustment_target(
pid: int,
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
include_manager_personal: bool,
) -> int | None:
"""调整单归属:优先跟随普通行;只有经理个人户的经理跟随本人团队。
调整单没有行类型,只能按人的实时归属落位。每人只允许一行携带调整,
因此这里返回唯一的团队键(None = 未分配)。
"""
member = member_map.get(pid)
if member is not None and member["has_regular_rows"]:
return _regular_team_of(pid, manager_of, manager_role)
if (
member is not None
and include_manager_personal
and member["has_manager_personal"]
and manager_role.get(pid) == "sales_manager"
):
return pid
return _regular_team_of(pid, manager_of, manager_role)
def _member_rows(
manager_id: int,
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
adjustments: dict[int, dict],
include_manager_personal: bool,
) -> list[dict]:
"""团队内成员行:普通行按 manager_id 归属;经理个人户按开关归属本人。
经理本人只以「经理个人户金额」出现在自己团队,普通行业绩留在上级
团队或未分配——同一笔业绩不会在团队与未分配中重复展示。
"""
rows = []
for pid, member in member_map.items():
target = _adjustment_target(
pid, member_map, manager_of, manager_role, include_manager_personal
)
regular_team = _regular_team_of(pid, manager_of, manager_role)
if pid != manager_id and member["has_regular_rows"] and regular_team == manager_id:
rows.append(
_member_row(
pid,
member,
adjustments,
"regular",
performance=member["regular_performance"],
payable=member["regular_payable"],
ledger_ids=member["regular_ledger_ids"],
include_adjust=target == manager_id,
)
)
personal_in_team = (
pid == manager_id and include_manager_personal and member["has_manager_personal"]
)
if personal_in_team:
rows.append(
_member_row(
pid,
member,
adjustments,
"manager_personal",
performance=member["manager_personal_performance"],
payable=member["manager_personal_payable"],
ledger_ids=member["manager_personal_ledger_ids"],
include_adjust=target == manager_id,
)
)
carried = (
member["has_regular_rows"] and regular_team == manager_id and target == manager_id
) or (personal_in_team and target == manager_id)
# 调整单没有被普通行/个人户行承载时单独出一行(例如只有个人户、
# 但上级是营销经理的人员:调整随实时 manager_id 归上级团队)
if pid in adjustments and target == manager_id and not carried:
rows.append(
_member_row(
pid,
member,
adjustments,
"adjustment",
performance=Decimal("0"),
payable=Decimal("0"),
ledger_ids=[],
include_adjust=True,
)
)
return rows
def _member_row(
pid: int,
member: dict,
adjustments: dict[int, dict],
member_kind: str,
*,
performance: Decimal,
payable: Decimal,
ledger_ids: list[int],
include_adjust: bool,
) -> dict:
"""单条成员行(按行类型取金额,调整净额最多计入一次)。"""
adj = (
adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
if include_adjust
else {"extra": Decimal("0"), "deduct": Decimal("0")}
)
net = payable + adj["extra"] - adj["deduct"]
return {
"personnel_id": pid,
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"is_probation": member["is_probation"],
"member_kind": member_kind,
"performance_sum": _q2(performance),
"payable_sum": _q2(payable),
"adjust_extra": _q2(adj["extra"]),
"adjust_deduct": _q2(adj["deduct"]),
"adjust_net": _q2(adj["extra"] - adj["deduct"]),
"net_total": _q2(net),
"ledger_ids": list(ledger_ids),
}
def _unassigned_rows(
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
adjustments: dict[int, dict],
include_manager_personal: bool,
) -> list[dict]:
"""未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。
经理个人户开关关闭时不展示个人户金额(与全公司基数排除口径一致);
开关开启但本人不是营销经理时放入未分配,避免金额静默消失。
调整单在这里只出现一次:普通行/个人户行未承载时才单列。
"""
rows = []
for pid, member in member_map.items():
target = _adjustment_target(
pid, member_map, manager_of, manager_role, include_manager_personal
)
regular_team = _regular_team_of(pid, manager_of, manager_role)
regular_unassigned = member["has_regular_rows"] and regular_team is None
personal_unassigned = (
include_manager_personal
and member["has_manager_personal"]
and manager_role.get(pid) != "sales_manager"
)
if regular_unassigned:
row = _member_row(
pid,
member,
adjustments,
"regular",
performance=member["regular_performance"],
payable=member["regular_payable"],
ledger_ids=member["regular_ledger_ids"],
include_adjust=target is None,
)
row["unassigned_reason"] = "无经理或上级非营销经理"
rows.append(row)
if personal_unassigned:
row = _member_row(
pid,
member,
adjustments,
"manager_personal",
performance=member["manager_personal_performance"],
payable=member["manager_personal_payable"],
ledger_ids=member["manager_personal_ledger_ids"],
include_adjust=target is None and not member["has_regular_rows"],
)
row["unassigned_reason"] = "经理个人户(本人非营销经理)"
rows.append(row)
carried = (regular_unassigned and target is None) or (
personal_unassigned and target is None and not member["has_regular_rows"]
)
if pid in adjustments and target is None and not carried:
row = _member_row(
pid,
member,
adjustments,
"adjustment",
performance=Decimal("0"),
payable=Decimal("0"),
ledger_ids=[],
include_adjust=True,
)
row["unassigned_reason"] = "仅有调整单且无团队归属"
rows.append(row)
return rows
# ==== 业务统计(FR-031 之一)====
def business_stats(session, query) -> dict:
"""业务统计分页 + 全筛选合计;回款/绩效为业务累计值。"""
rows = report_repository.list_business_stats(
session, query, offset=query.offset, limit=query.page_size
)
sums = report_repository.sum_business_stats(session, query)
return {
"list": [_business_row(r) for r in rows],
"total": report_repository.count_business_stats(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {
"row_count": sums["row_count"],
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"sale_total": _q2(_dec(sums["sale_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
}
def _business_row(row) -> dict:
"""业务统计行(ORM 行与三个联查列)。"""
business = row[0]
return {
"business_id": business.id,
"business_no": business.business_no,
"business_date": business.business_date,
"status": business.status,
"customer_name": row.customer_name,
"owner_personnel_name": row.owner_personnel_name,
"total_sale_amount": business.total_sale_amount,
"total_ship_amount": business.total_ship_amount,
"total_received_amount": business.total_received_amount,
"performance_amount": row.performance_amount,
"payable_amount": row.payable_amount,
}
# ==== 回款统计(FR-031 之二)====
def payment_stats(session, query) -> dict:
"""区间/客户类型/产品类别三维度统计;空桶恒在且为 0。"""
perf_rows = report_repository.group_payment_by_days(session, query)
receipt_rows = report_repository.group_payment_receipt_by_days(session, query)
buckets = _bucket_rows(perf_rows, receipt_rows)
by_customer = _group_rows(report_repository.group_payment_by_customer_type(session, query))
by_category = _group_rows(report_repository.group_payment_by_product_category(session, query))
return {
"settlement_month": query.settlement_month,
"buckets": buckets,
"by_customer_type": by_customer,
"by_product_category": by_category,
"totals": {
"receipt_amount": _q2(
sum((b["receipt_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
"receipt_count": sum(b["receipt_count"] for b in buckets),
"performance_amount": _q2(
sum((b["performance_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
"payable_amount": _q2(
sum((b["payable_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
},
}
def _bucket_rows(perf_rows, receipt_rows) -> list[dict]:
"""把(天数, 类型, 类别)精确值聚合结果按 8 档区间分桶(Python 侧分桶单点)。"""
perf_acc: dict[str, dict] = {}
for row in perf_rows:
bucket = report_meta.bucket_of(row.payment_days)
acc = perf_acc.setdefault(
bucket.code, {"performance": Decimal("0"), "payable": Decimal("0"), "count": 0}
)
acc["performance"] += _dec(row.performance_total)
acc["payable"] += _dec(row.payable_total)
acc["count"] += int(row.ledger_count)
receipt_acc: dict[str, dict] = {}
for row in receipt_rows:
bucket = report_meta.bucket_of(row.payment_days)
acc = receipt_acc.setdefault(bucket.code, {"amount": Decimal("0"), "count": 0})
acc["amount"] += _dec(row.receipt_amount)
acc["count"] += int(row.receipt_count)
zero = Decimal("0")
return [
{
"code": bucket.code,
"label": bucket.label,
"min_days": bucket.min_days,
"max_days": bucket.max_days,
# 空桶显示 0 而非 null(FR-031「空区间正常显示」)
"receipt_amount": _q2(receipt_acc.get(bucket.code, {}).get("amount", zero)),
"receipt_count": receipt_acc.get(bucket.code, {}).get("count", 0),
"performance_amount": _q2(perf_acc.get(bucket.code, {}).get("performance", zero)),
"payable_amount": _q2(perf_acc.get(bucket.code, {}).get("payable", zero)),
"ledger_count": perf_acc.get(bucket.code, {}).get("count", 0),
}
for bucket in report_meta.get_payment_day_buckets()
]
def _group_rows(rows) -> list[dict]:
"""维度对比行(客户类型/产品类别),标签来自 report_meta。
仓储返回元组 (dim, 回款额, 绩效, 应付, 台账行数),按位置解包。
"""
labels = report_meta.CUSTOMER_TYPE_LABELS | report_meta.PRODUCT_CATEGORY_LABELS
result = []
for dim, receipt_amount, performance_amount, payable_amount, ledger_count in rows:
result.append(
{
"code": dim,
"label": labels.get(dim, dim or "未分类"),
"receipt_amount": _q2(_dec(receipt_amount)),
"performance_amount": _q2(_dec(performance_amount)),
"payable_amount": _q2(_dec(payable_amount)),
"ledger_count": int(ledger_count),
}
)
return result
def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail(session, query, bucket)
return {
"list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {"row_count": len(rows)},
}
def _payment_row(row) -> dict:
"""下钻行(台账行 + 回款金额/批次号)。"""
ledger = row[0]
return {
"ledger_id": ledger.id,
"receipt_id": ledger.receipt_id,
"receipt_batch_no": row.receipt_batch_no,
"amount": row.receipt_amount,
"business_id": ledger.business_id,
"business_no": ledger.business_no,
"personnel_id": ledger.personnel_id,
"personnel_name": ledger.personnel_name,
"customer_type": ledger.customer_type,
"product_category": ledger.product_category,
"ship_date": ledger.ship_date,
"receipt_date": ledger.receipt_date,
"payment_days": ledger.payment_days,
"performance_amount": ledger.performance_amount,
"payable_amount": ledger.payable_amount,
}
def _bucket_by_code(code: str) -> DayBucket:
"""按编码取区间桶;未注册编码返回 400(防前端传参与后端档位漂移)。"""
for bucket in report_meta.get_payment_day_buckets():
if bucket.code == code:
return bucket
raise BadRequestError(f"未知的回款天数区间:{code}")
# ==== 导出编排(FR-032)====
EXPORT_FILENAMES = {
"personal-detail": "personal_detail",
"department-summary": "department_summary",
"business-stats": "business_stats",
"payment-stats": "payment_stats",
}
# 标题是导出审计摘要的一部分:进入 audit 前就要确定,不能在取数后才拼
EXPORT_TITLES = {
"personal-detail": "个人绩效工资明细表",
"department-summary": "部门绩效工资汇总表",
"business-stats": "业务统计表",
"payment-stats": "回款情况统计表",
}
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
"""导出 Excel:取数 → 构建 → 审计留痕(独立 commit)。
取数与上限校验必须在审计上下文内:超限 400 和取数异常同样要留下
失败记录(M4 计划 T12「导出 400/失败审计」)。`AuditRecord` 是 frozen
的,摘要只写静态标题,超限行数由异常文案带入 error_message。
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
"""
from app.services import report_export
title = EXPORT_TITLES.get(code)
if title is None:
raise BadRequestError(f"未知的报表编码:{code}")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_REPORT,
action=ACTION_EXPORT,
summary=f"导出{title}",
request_data=query.model_dump(exclude_none=True),
),
meta=meta,
):
rows, context = _export_payload(session, code, query)
content = report_export.build_workbook(
code=code,
title=context["title"],
filters=context["filters"],
rows=rows,
summary=context.get("summary"),
buckets=context.get("buckets"),
department=context.get("department"),
template_version=REPORT_TEMPLATE_VERSION,
operator_name=getattr(operator, "username", ""),
company_name=context["company_name"],
)
return content, EXPORT_FILENAMES[code]
def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
"""按报表编码取全量数据并做上限校验(复用查询函数,不走分页)。"""
if code == "personal-detail":
total = report_repository.count_personal_detail(session, query)
_check_export_limit(total)
rows = report_repository.list_personal_detail(
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return [_person_row(r, role_names) for r in rows], {
"title": EXPORT_TITLES["personal-detail"],
"row_count": total,
"filters": _filter_text(session, query),
"summary": {
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
"company_name": _company_name(session),
}
if code == "department-summary":
data = department_summary(session, query)
row_count = (
len(data["teams"])
+ sum(len(team["members"]) for team in data["teams"])
+ len(data["unassigned"])
)
_check_export_limit(row_count)
return [], {
"title": EXPORT_TITLES["department-summary"],
"row_count": row_count,
"filters": _filter_text(session, query),
"department": data,
"company_name": _company_name(session),
}
if code == "business-stats":
total = report_repository.count_business_stats(session, query)
_check_export_limit(total)
rows = report_repository.list_business_stats(
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
sums = report_repository.sum_business_stats(session, query)
return [_business_row(r) for r in rows], {
"title": EXPORT_TITLES["business-stats"],
"row_count": total,
"filters": _filter_text(session, query),
"summary": {
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
},
"company_name": _company_name(session),
}
if code == "payment-stats":
data = payment_stats(session, query)
rows = data["buckets"]
return rows, {
"title": EXPORT_TITLES["payment-stats"],
"row_count": len(rows),
"filters": _filter_text(session, query),
"summary": data["totals"],
"buckets": data,
"company_name": _company_name(session),
}
raise BadRequestError(f"未知的报表编码:{code}")
def _check_export_limit(row_count: int) -> None:
"""导出上限(需求「超限提示」口径):超过即 400 引导缩小筛选。"""
if row_count > REPORT_EXPORT_MAX_ROWS:
raise BadRequestError(
f"导出数据量 {row_count} 行超过上限 {REPORT_EXPORT_MAX_ROWS} 行,"
"请缩小筛选范围(如按月导出)"
)
def _filter_text(session, query) -> str:
"""筛选条件摘要(导出页眉展示;未设置项不展示;ID 转名称)。
页眉是签字稿追溯筛选范围的唯一凭据:漏掉人员/经理/客户/归属人/
状态会让复核者把筛选结果当成全量数据。
"""
personnel_ids = {
value
for field in ("personnel_id", "manager_id", "owner_personnel_id")
if (value := getattr(query, field, None)) not in (None, "")
}
personnel_names = _personnel_names(session, personnel_ids)
parts = []
for field, label in (
("settlement_month", "统计月份"),
("personnel_id", "人员"),
("manager_id", "经理"),
("customer_id", "客户"),
("owner_personnel_id", "归属人"),
("customer_type", "客户类型"),
("product_category", "产品类别"),
("role_code", "岗位"),
("status", "状态"),
("keyword", "关键字"),
("date_from", "日期起"),
("date_to", "日期止"),
):
value = getattr(query, field, None)
if value in (None, ""):
continue
if field in ("personnel_id", "manager_id", "owner_personnel_id"):
name = personnel_names.get(value)
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
elif field == "customer_id":
name = _customer_name(session, value)
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
elif field == "customer_type":
parts.append(f"{label} {CUSTOMER_TYPE_LABELS.get(value, value)}")
elif field == "product_category":
parts.append(f"{label} {PRODUCT_CATEGORY_LABELS.get(value, value)}")
elif field == "role_code":
parts.append(f"{label} {_role_name_by_code(session, value) or value}")
elif field == "status":
parts.append(f"{label} {BUSINESS_STATUS_LABELS.get(value, value)}")
else:
parts.append(f"{label} {value}")
return " | ".join(parts) or "全部"
def _personnel_names(session, ids: set[int]) -> dict[int, str]:
"""批量取人员姓名;查不到时由调用方回退展示 #id。"""
if not ids:
return {}
rows = session.execute(
select(Personnel.id, Personnel.personnel_name).where(Personnel.id.in_(ids))
).all()
return dict(rows)
def _customer_name(session, customer_id: int) -> str | None:
"""客户名称(导出页眉 ID→名称)。"""
return session.execute(
select(Customer.company_name).where(Customer.id == customer_id)
).scalar_one_or_none()
def _role_name_by_code(session, role_code: str) -> str | None:
"""岗位名称(导出页眉展示中文,而不是内部编码)。"""
return session.execute(
select(BusinessRole.role_name).where(BusinessRole.role_code == role_code)
).scalar_one_or_none()
def _company_name(session) -> str:
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
from app.services import system_setting_service
return system_setting_service.get_setting(session).company_name