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