"""报表统计服务(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, 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", } # 标题是导出审计摘要的一部分:进入 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