- 四表接口 /api/v1/reports/*:个人明细(全筛选 summary)、部门汇总(团队基数与月结聚合同源、12.5%/5% 分列、零行经理、冻结对照)、业务统计(排除聚合行)、回款统计(8 档区间恒在、回款金额按 receipt_id 去重防分摊拆行翻倍) - Excel 导出:公司页眉/财务复核栏/领导签字栏/日期/版本、Decimal 原值 + number_format、文本防公式注入、2 万行超限 400、导出行为入操作日志 - manager_share 共享模块:月结聚合与报表部门汇总同源取参与分桶(薄委托保留测试入口) - 区间元数据落 core/report_meta(repository 需要类型,避免反向依赖 service 层) - 准入 require_roles(admin/finance/leader),支持自定义 403 文案 Co-Authored-By: Claude Code <noreply@anthropic.com>
699 lines
28 KiB
Python
699 lines
28 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 (
|
||
REPORT_EXPORT_MAX_ROWS,
|
||
REPORT_TEMPLATE_VERSION,
|
||
DayBucket,
|
||
)
|
||
from app.models import BusinessRole, 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:
|
||
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。"""
|
||
month = query.settlement_month
|
||
rows = report_repository.list_month_receipt_rows(session, month)
|
||
share = manager_share.manager_share_params(session, month)
|
||
buckets = manager_share.collect_team_buckets(session, rows, share.include_manager_personal)
|
||
company_base = manager_share.company_base_of(rows, share.include_manager_personal)
|
||
|
||
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)
|
||
manager_ids = _manager_ids(session, buckets, query.manager_id)
|
||
# 岗位判定范围必须覆盖「无成员的经理」——只按 member_map 取会让零行团队被跳过
|
||
manager_of, manager_role = _team_relations(session, member_map, manager_ids)
|
||
|
||
teams: list[dict] = []
|
||
for manager_id in manager_ids:
|
||
manager = session.get(Personnel, manager_id)
|
||
if manager is None or manager_role.get(manager_id) != "sales_manager":
|
||
continue # 非营销经理的上级不展示团队行(与月结聚合同口径)
|
||
aggregate = aggregates.get(manager_id)
|
||
member_rows = _member_rows(
|
||
manager_id, member_map, manager_of, adjustments, share.include_manager_personal
|
||
)
|
||
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(buckets.get(manager_id, {}).get("performance", Decimal("0")))
|
||
# 12.5% 与 5% 分列(FR-030「全公司 5% 部分单列」);两项之和才是月结
|
||
# 聚合行的金额(frozen_amount 对照),重复计入会双倍展示
|
||
team_share = _team_share(team_base, share.team_pct)
|
||
company_share = _company_share(company_base, share.company_pct)
|
||
teams.append(
|
||
{
|
||
"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),
|
||
"company_share_amount": _q2(company_share),
|
||
"company_base_ratio": _ratio(team_base, company_base),
|
||
"adjust_extra": _q2(team_extra),
|
||
"adjust_deduct": _q2(team_deduct),
|
||
"adjust_net": _q2(team_extra - team_deduct),
|
||
"net_total": _q2(team_share + company_share + team_extra - team_deduct),
|
||
"frozen_amount": aggregate.performance_amount if aggregate else None,
|
||
"aggregate_ledger_id": aggregate.id if aggregate else None,
|
||
"members": member_rows,
|
||
}
|
||
)
|
||
|
||
unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments)
|
||
return {
|
||
"settlement_month": month,
|
||
"params": {
|
||
"team_text": share.team_text,
|
||
"company_text": share.company_text,
|
||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||
"params_as_of": str(share.param_date),
|
||
},
|
||
"teams": teams,
|
||
"unassigned": unassigned,
|
||
"company": {
|
||
"team_base_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
|
||
"company_base": _q2(company_base),
|
||
"company_share_pct": share.company_text,
|
||
"company_share_amount": _q2(_company_share(company_base, share.company_pct)),
|
||
},
|
||
"totals": {
|
||
"performance_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
|
||
"payable_total": _q2(sum((t["net_total"] for t in teams), Decimal("0"))),
|
||
},
|
||
}
|
||
|
||
|
||
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]:
|
||
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。"""
|
||
by_person: dict[int, dict] = {}
|
||
for order in orders:
|
||
acc = by_person.setdefault(
|
||
order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")}
|
||
)
|
||
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"),
|
||
"manager_personal_performance": Decimal("0"),
|
||
"manager_personal_payable": Decimal("0"),
|
||
"ledger_ids": [],
|
||
"has_manager_personal": False,
|
||
},
|
||
)
|
||
performance = row.performance_amount or Decimal("0")
|
||
payable = row.payable_amount or Decimal("0")
|
||
acc["performance"] += performance
|
||
acc["payable"] += payable
|
||
acc["ledger_ids"].append(row.id)
|
||
if row.customer_type == "manager_personal":
|
||
acc["has_manager_personal"] = True
|
||
acc["manager_personal_performance"] += performance
|
||
acc["manager_personal_payable"] += payable
|
||
return members
|
||
|
||
|
||
def _team_relations(
|
||
session, member_map: dict[int, dict], manager_ids: list[int]
|
||
) -> tuple[dict[int, int | None], dict[int, str]]:
|
||
"""人员 → 直属经理与人员 → 岗位编码(各一次查询,避免逐人 get 的 N+1)。
|
||
|
||
查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」
|
||
会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。
|
||
经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径:
|
||
离职人员的 manager_id 保留,历史业绩因此留在原团队。
|
||
"""
|
||
pids = list(set(member_map) | set(manager_ids))
|
||
if not pids:
|
||
return {}, {}
|
||
people = list(session.execute(select(Personnel).where(Personnel.id.in_(pids))).scalars())
|
||
manager_of = {p.id: p.manager_id for p in people}
|
||
role_by_id = {
|
||
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
|
||
}
|
||
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
|
||
return manager_of, manager_role
|
||
|
||
|
||
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 _member_rows(
|
||
manager_id: int,
|
||
member_map: dict[int, dict],
|
||
manager_of: dict[int, int | None],
|
||
adjustments: dict[int, dict],
|
||
include_manager_personal: bool,
|
||
) -> list[dict]:
|
||
"""团队内成员行:业务员按 manager_id 归属;经理个人户按开关归属本人。"""
|
||
pids = [pid for pid, mid in manager_of.items() if mid == manager_id and pid != manager_id]
|
||
# 经理本人(经理个人户行):#12 开关开启时计入本团队基数
|
||
if include_manager_personal and manager_id in member_map:
|
||
pids.append(manager_id)
|
||
return [_member_row(pid, member_map[pid], adjustments) for pid in pids]
|
||
|
||
|
||
def _member_row(pid: int, member: dict, adjustments: dict[int, dict]) -> dict:
|
||
"""单条成员行(绩效/应付求和 + 调整净额 + 台账 id 列表)。"""
|
||
adj = adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
|
||
net = member["payable"] + adj["extra"] - adj["deduct"]
|
||
return {
|
||
"personnel_id": pid,
|
||
"personnel_name": member["personnel_name"],
|
||
"role_code": member["role_code"],
|
||
"is_probation": member["is_probation"],
|
||
"performance_sum": _q2(member["performance"]),
|
||
"payable_sum": _q2(member["payable"]),
|
||
"adjust_extra": _q2(adj["extra"]),
|
||
"adjust_deduct": _q2(adj["deduct"]),
|
||
"adjust_net": _q2(adj["extra"] - adj["deduct"]),
|
||
"net_total": _q2(net),
|
||
"ledger_ids": member["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],
|
||
) -> list[dict]:
|
||
"""未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。
|
||
|
||
经理个人户业绩默认不计入任何基数(#12 开关关闭时),也不在此展示——
|
||
其金额仍可在个人明细报表中查询,避免与「团队基数 + 未分配」口径混淆。
|
||
"""
|
||
rows = []
|
||
for pid, member in member_map.items():
|
||
mid = manager_of.get(pid)
|
||
if mid is not None and manager_role.get(mid) == "sales_manager":
|
||
continue
|
||
regular = member["performance"] - member["manager_personal_performance"]
|
||
if regular == 0 and member["manager_personal_performance"] != 0:
|
||
continue
|
||
row = _member_row(pid, member, adjustments)
|
||
row["performance_sum"] = _q2(regular)
|
||
row["payable_sum"] = _q2(member["payable"] - member["manager_personal_payable"])
|
||
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, offset=query.offset, limit=query.page_size
|
||
)
|
||
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",
|
||
}
|
||
|
||
|
||
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
|
||
"""导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。
|
||
|
||
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
|
||
"""
|
||
from app.services import report_export
|
||
|
||
rows, context = _export_payload(session, code, query)
|
||
with audit_service.audit(
|
||
session,
|
||
operator=operator,
|
||
record=AuditRecord(
|
||
module=MODULE_REPORT,
|
||
action=ACTION_EXPORT,
|
||
summary=f"导出{context['title']}({context['row_count']} 行)",
|
||
request_data=query.model_dump(exclude_none=True),
|
||
),
|
||
meta=meta,
|
||
):
|
||
content = report_export.build_workbook(
|
||
code=code,
|
||
title=context["title"],
|
||
filters=context["filters"],
|
||
rows=rows,
|
||
summary=context.get("summary"),
|
||
buckets=context.get("buckets"),
|
||
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": "个人绩效工资明细表",
|
||
"row_count": total,
|
||
"filters": _filter_text(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)
|
||
member_total = sum(len(t["members"]) for t in data["teams"])
|
||
_check_export_limit(member_total)
|
||
rows = _department_export_rows(data)
|
||
return rows, {
|
||
"title": "部门绩效工资汇总表",
|
||
"row_count": member_total,
|
||
"filters": f"统计月份 {query.settlement_month}",
|
||
"summary": data["company"],
|
||
"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": "业务统计表",
|
||
"row_count": total,
|
||
"filters": _filter_text(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": "回款情况统计表",
|
||
"row_count": len(rows),
|
||
"filters": _filter_text(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 _department_export_rows(data: dict) -> list[dict]:
|
||
"""部门汇总导出展平:每个成员一行,团队列冗余(便于签字稿核对)。"""
|
||
rows = []
|
||
for team in data["teams"]:
|
||
for member in team["members"]:
|
||
rows.append(
|
||
{
|
||
"manager_name": team["manager_name"],
|
||
"member_name": member["personnel_name"],
|
||
"role_code": member["role_code"],
|
||
"performance_sum": member["performance_sum"],
|
||
"payable_sum": member["payable_sum"],
|
||
"adjust_net": member["adjust_net"],
|
||
"net_total": member["net_total"],
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def _filter_text(query) -> str:
|
||
"""筛选条件摘要(导出页眉展示;未设置项不展示)。"""
|
||
parts = []
|
||
for field, label in (
|
||
("settlement_month", "统计月份"),
|
||
("customer_type", "客户类型"),
|
||
("product_category", "产品类别"),
|
||
("role_code", "岗位"),
|
||
("keyword", "关键字"),
|
||
("date_from", "日期起"),
|
||
("date_to", "日期止"),
|
||
):
|
||
value = getattr(query, field, None)
|
||
if value:
|
||
parts.append(f"{label} {value}")
|
||
return " | ".join(parts) or "全部"
|
||
|
||
|
||
def _company_name(session) -> str:
|
||
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
|
||
from app.services import system_setting_service
|
||
|
||
return system_setting_service.get_setting(session).company_name
|