PayrollManagementSystem/backend/app/services/report_service.py

1130 lines
46 KiB
Python
Raw Normal View History

"""报表统计服务(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