打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
1218 lines
51 KiB
Python
1218 lines
51 KiB
Python
"""报表统计服务(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
|
||
)
|
||
attributed_managers = {mid for a in adjustments.values() for mid in a.get("by_manager", {}) if mid is not None}
|
||
manager_ids = sorted(set(manager_ids) | attributed_managers)
|
||
frozen_rows = rows if any((getattr(r, "snapshot_json", None) or {}).get("engine") == "FIFO" for r in rows) or attributed_managers else None
|
||
for mid in attributed_managers:
|
||
manager_role[mid] = "sales_manager"
|
||
if frozen_rows is not None:
|
||
for row in frozen_rows:
|
||
snapshot = row.snapshot_json or {}
|
||
if snapshot.get("engine") == "FIFO" and snapshot.get("manager_id") is not None:
|
||
manager_role[int(snapshot["manager_id"])] = "sales_manager"
|
||
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
|
||
_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,
|
||
"frozen_rows": frozen_rows,
|
||
}
|
||
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 = (
|
||
_frozen_member_rows(None, context) if frozen_rows is not None
|
||
else _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)
|
||
from app.services.salary_completion import frozen_rows
|
||
|
||
visible_people = {member["personnel_id"] for team in teams for member in team["members"]} | {team["manager_id"] for team in teams}
|
||
salary_rows = frozen_rows(session, month, visible_people if query.manager_id is not None else None)
|
||
return {
|
||
"settlement_month": month,
|
||
"salary_rows": salary_rows,
|
||
"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 _frozen_member_rows(manager_id, context):
|
||
"""按台账固化的业务分管拆成员,同一业务员可分别出现在多个团队。"""
|
||
targets = {}
|
||
source_rows = context["frozen_rows"]
|
||
for row in source_rows:
|
||
snap = row.snapshot_json or {}
|
||
if snap.get("engine") == "FIFO":
|
||
target = snap.get("manager_id") if not snap.get("manager_own_excluded") else None
|
||
else:
|
||
target = _regular_team_of(row.personnel_id, context["manager_of"], context["manager_role"])
|
||
if row.customer_type == "manager_personal":
|
||
target = row.personnel_id if context["include_mp"] else None
|
||
targets[row.id] = target
|
||
selected = [r for r in source_rows if targets[r.id] == manager_id]
|
||
members = _member_map(selected)
|
||
local_adjustments = {}
|
||
for pid, adjustment in context["adjustments"].items():
|
||
candidates = {targets[r.id] for r in source_rows if r.personnel_id == pid}
|
||
present = sorted(t for t in candidates if t is not None)
|
||
default_target = present[0] if present else (
|
||
None if candidates else _regular_team_of(pid, context["manager_of"], context["manager_role"])
|
||
)
|
||
totals = {"extra": Decimal(0), "deduct": Decimal(0)}
|
||
selected_adjustment = adjustment.get("by_manager", {}).get(manager_id, {})
|
||
for field in totals:
|
||
totals[field] += selected_adjustment.get(field, Decimal(0))
|
||
if default_target == manager_id:
|
||
totals[field] += adjustment.get("unattributed", adjustment)[field]
|
||
local_adjustments[pid] = totals
|
||
if any(totals.values()) and pid not in members:
|
||
members[pid] = context["member_map"][pid]
|
||
output = []
|
||
for pid, member in members.items():
|
||
actual = [r for r in selected if r.personnel_id == pid]
|
||
output.append(_member_row(pid, member, local_adjustments, "regular",
|
||
performance=sum((r.performance_amount or Decimal(0) for r in actual), Decimal(0)),
|
||
payable=sum((r.payable_amount or Decimal(0) for r in actual), Decimal(0)),
|
||
ledger_ids=[r.id for r in actual], include_adjust=True))
|
||
return output
|
||
|
||
|
||
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"],
|
||
)
|
||
if context.get("frozen_rows") is not None:
|
||
member_rows = _frozen_member_rows(manager_id, context)
|
||
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,
|
||
"by_manager": {},
|
||
"unattributed": {"extra": Decimal(0), "deduct": Decimal(0)},
|
||
},
|
||
)
|
||
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
|
||
acc["extra"] += order.amount
|
||
else:
|
||
acc["deduct"] += order.amount
|
||
attribution = getattr(order, "attribution_json", None)
|
||
bucket = acc["by_manager"].setdefault(attribution.get("manager_id"), {"extra": Decimal(0), "deduct": Decimal(0)}) if attribution else acc["unattributed"]
|
||
bucket["extra" if order.direction == ADJUSTMENT_DIRECTION_EXTRA else "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 _pending_sales_count(session, query):
|
||
from app.services.business_source import sales_relations_pending
|
||
|
||
count, cursor = 0, 0
|
||
while True:
|
||
rows = report_repository.list_source_business_sales(session, query, after_id=cursor, limit=200)
|
||
if not rows:
|
||
return count
|
||
pending = sales_relations_pending(session, [row.id for row in rows])
|
||
count += len(pending | {row.id for row in rows if row.total_sale_amount is None})
|
||
cursor = rows[-1].id
|
||
|
||
|
||
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)
|
||
pending_count = _pending_sales_count(session, query)
|
||
from app.services.business_source import sales_relations_pending
|
||
|
||
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
|
||
return {
|
||
"list": [_business_row(r, sales_pending=r[0].id in pending) 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": None if pending_count else _q2(_dec(sums["sale_total"])),
|
||
"sale_pending_count": pending_count,
|
||
"performance_total": _q2(_dec(sums["performance_total"])),
|
||
"payable_total": _q2(_dec(sums["payable_total"])),
|
||
},
|
||
}
|
||
|
||
|
||
def _business_row(row, *, sales_pending=False) -> 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": None if sales_pending else 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)
|
||
from app.services.business_source import sales_relations_pending
|
||
|
||
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
|
||
return [_business_row(r, sales_pending=r[0].id in pending) 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
|