From a2bd3960e184aa38b8ad21b1cb65e443aa9f4e27 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 19 Oct 2026 13:53:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20M4=20=E6=8A=A5=E8=A1=A8?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E2=80=94=E2=80=94=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E3=80=81=E6=9F=A5=E8=AF=A2=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=E5=8F=AA=E8=AF=BB=E8=81=9A=E5=90=88=E4=BB=93=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - report_meta:回款天数区间桶(含可空/负值兜底桶)、客户类型标签、导出上限 - schemas/report:明细/汇总查询入参与分页响应,金额比例全程 Decimal - report_repository:报表专用只读聚合,显式排除台账聚合行、口径与月结区分 - constants:新增报表统计模块与导出动作常量 Co-Authored-By: Claude Code --- backend/app/core/constants.py | 2 + backend/app/repositories/report_repository.py | 392 ++++++++++++++++++ backend/app/schemas/report.py | 260 ++++++++++++ backend/app/services/report_meta.py | 76 ++++ 4 files changed, 730 insertions(+) create mode 100644 backend/app/repositories/report_repository.py create mode 100644 backend/app/schemas/report.py create mode 100644 backend/app/services/report_meta.py diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 272683a..b085763 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -87,6 +87,7 @@ MODULE_SYSTEM_SETTINGS = "系统设置" MODULE_APPROVAL = "审批管理" MODULE_CALC = "绩效核算" MODULE_SETTLEMENT = "月度结算" +MODULE_REPORT = "报表统计" # ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== # 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 @@ -105,3 +106,4 @@ ACTION_ENABLE = "启用" ACTION_DISABLE = "禁用" ACTION_APPROVE = "审批通过" ACTION_REJECT = "审批驳回" +ACTION_EXPORT = "导出" diff --git a/backend/app/repositories/report_repository.py b/backend/app/repositories/report_repository.py new file mode 100644 index 0000000..b9956fe --- /dev/null +++ b/backend/app/repositories/report_repository.py @@ -0,0 +1,392 @@ +"""报表统计仓储:只读聚合查询;不 commit、不抛业务异常。 + +报表与台账分页查询刻意分开而非复用 ``performance_ledger_repository``: +- 台账查询允许聚合行(calc_type=2),报表明细必须显式排除——经理聚合 + 行 receipt_id 为 NULL,混入明细会让合同编号/天数列失真、业务统计的 + 绩效被整团队聚合额污染; +- 月结的 ``_confirm_scope`` 带 ``settlement_id IS NULL``(可入结口径), + 报表要看的是同月**全量**生效单笔行,口径不同; +- 聚合需求(SUM/GROUP BY)台账仓储完全没有,报表在此统一实现。 + +发货额/回款额直接读 businesses 的汇总冗余列(服务层事务内单点维护), +不逐业务子查询发货/回款表;绩效额 businesses 无冗余列,用子查询联台账。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import ( + AdjustmentOrder, + Business, + Customer, + MonthlySettlement, + PerformanceLedger, + Personnel, +) +from app.schemas.report import ( + BusinessStatsQuery, + PaymentDetailQuery, + PaymentStatsQuery, + PersonalDetailQuery, +) + +# 单笔生效行基础条件:报表四表共用(聚合行与已替代版本一律排除) +_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1) + + +# ==== 个人绩效明细(FR-029)==== + +def _personal_filters(query: PersonalDetailQuery) -> tuple: + filters = [*_ACTIVE_SINGLE] + if query.settlement_month: + filters.append(PerformanceLedger.settlement_month == query.settlement_month) + if query.personnel_id is not None: + filters.append(PerformanceLedger.personnel_id == query.personnel_id) + if query.customer_type: + filters.append(PerformanceLedger.customer_type == query.customer_type) + if query.product_category: + filters.append(PerformanceLedger.product_category == query.product_category) + if query.role_code: + filters.append(PerformanceLedger.role_code == query.role_code) + return tuple(filters) + + +def list_personal_detail( + session: Session, query: PersonalDetailQuery, *, offset: int, limit: int +) -> list[PerformanceLedger]: + """个人绩效明细分页(回款日倒序,同人按版本可追溯)。""" + stmt = select(PerformanceLedger).where(*_personal_filters(query)) + stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc()) + stmt = stmt.offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int: + """个人绩效明细计数(与 list_personal_detail 同筛选)。""" + return int( + session.execute( + select(func.count()) + .select_from(PerformanceLedger) + .where(*_personal_filters(query)) + ).scalar_one() + ) + + +def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict: + """全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。""" + return session.execute( + select( + func.count().label("row_count"), + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("performance_total"), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"), + func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label( + "converted_profit_total" + ), + ).where(*_personal_filters(query)) + ).mappings().one() + + +# ==== 部门汇总取数(FR-030)==== + +def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]: + """同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。""" + return list( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.settlement_month == month, *_ACTIVE_SINGLE + ) + ).scalars() + ) + + +def list_month_manager_aggregates(session: Session, month: str) -> list[PerformanceLedger]: + """同月经理聚合行(calc_type=2):已生成月结时的冻结对照值。""" + return list( + session.execute( + select(PerformanceLedger).where( + PerformanceLedger.settlement_month == month, + PerformanceLedger.calc_type == 2, + PerformanceLedger.is_active == 1, + ) + ).scalars() + ) + + +def list_month_adjustments(session: Session, month: str) -> list[AdjustmentOrder]: + """同月已生效调整单(status=2)——报表汇总并入的口径。 + + 与 list_adjustments(调整单管理页)不同:必须按月筛选且只取生效行, + 待审批/已驳回/已撤回的金额绝不能混进报表。 + """ + return list( + session.execute( + select(AdjustmentOrder).where( + AdjustmentOrder.settlement_month == month, + AdjustmentOrder.status == 2, + ) + ).scalars() + ) + + +def list_month_settlement(session: Session, month: str) -> MonthlySettlement | None: + """同月月结头(部门汇总展示锁定状态用;无则 None)。""" + return session.execute( + select(MonthlySettlement).where(MonthlySettlement.settlement_month == month) + ).scalar_one_or_none() + + +# ==== 业务统计(FR-031 之一)==== + +def _business_perf_subquery(): + """业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。""" + return ( + select( + PerformanceLedger.business_id.label("business_id"), + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("perf"), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable"), + ) + .where(*_ACTIVE_SINGLE) + .group_by(PerformanceLedger.business_id) + .subquery() + ) + + +def _business_filters(query: BusinessStatsQuery) -> tuple: + filters = [] + if query.keyword: + # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 转义通配符, + # 与 business_repository._apply_filters 同口径(双列 OR 用法为该处既定) + filters.append( + Business.business_no.contains(query.keyword, autoescape=True) + | Customer.company_name.contains(query.keyword, autoescape=True) + ) + if query.customer_id is not None: + filters.append(Business.customer_id == query.customer_id) + if query.owner_personnel_id is not None: + filters.append(Business.owner_personnel_id == query.owner_personnel_id) + if query.status is not None: + filters.append(Business.status == query.status) + if query.date_from is not None: + filters.append(Business.business_date >= query.date_from) + if query.date_to is not None: + filters.append(Business.business_date <= query.date_to) + return tuple(filters) + + +def _business_select(query: BusinessStatsQuery): + perf_sq = _business_perf_subquery() + return ( + select( + Business, + Customer.company_name.label("customer_name"), + Personnel.personnel_name.label("owner_personnel_name"), + perf_sq.c.perf.label("performance_amount"), + perf_sq.c.payable.label("payable_amount"), + ) + .outerjoin(Customer, Customer.id == Business.customer_id) + .outerjoin(Personnel, Personnel.id == Business.owner_personnel_id) + .outerjoin(perf_sq, perf_sq.c.business_id == Business.id) + .where(*_business_filters(query)) + ) + + +def list_business_stats( + session: Session, query: BusinessStatsQuery, *, offset: int, limit: int +) -> list: + """业务统计分页(业务日期倒序)。""" + stmt = _business_select(query).order_by(Business.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_business_stats(session: Session, query: BusinessStatsQuery) -> int: + """业务统计计数(联客户表因关键字筛选涉及客户名)。""" + stmt = ( + select(func.count()) + .select_from(Business) + .outerjoin(Customer, Customer.id == Business.customer_id) + .where(*_business_filters(query)) + ) + return int(session.execute(stmt).scalar_one()) + + +def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict: + """业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。""" + perf_sq = _business_perf_subquery() + return session.execute( + select( + func.count().label("row_count"), + func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"), + func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"), + func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"), + func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"), + func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"), + ) + .outerjoin(Customer, Customer.id == Business.customer_id) + .outerjoin(perf_sq, perf_sq.c.business_id == Business.id) + .where(*_business_filters(query)) + ).mappings().one() + + +# ==== 回款统计(FR-031 之二)==== + +def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple: + filters = [*_ACTIVE_SINGLE] + if query.settlement_month: + filters.append(PerformanceLedger.settlement_month == query.settlement_month) + if query.date_from is not None: + filters.append(PerformanceLedger.receipt_date >= query.date_from) + if query.date_to is not None: + filters.append(PerformanceLedger.receipt_date <= query.date_to) + if query.customer_type: + filters.append(PerformanceLedger.customer_type == query.customer_type) + if query.product_category: + filters.append(PerformanceLedger.product_category == query.product_category) + return tuple(filters) + + +def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list: + """按(回款天数精确值, 客户类型, 产品类别)聚合绩效与应付。""" + return list( + session.execute( + select( + PerformanceLedger.payment_days, + PerformanceLedger.customer_type, + PerformanceLedger.product_category, + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( + "performance_total" + ), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label( + "payable_total" + ), + func.count().label("ledger_count"), + ) + .where(*_payment_filters(query)) + .group_by( + PerformanceLedger.payment_days, + PerformanceLedger.customer_type, + PerformanceLedger.product_category, + ) + ).all() + ) + + +def group_payment_receipt_by_days(session: Session, query: PaymentStatsQuery) -> list: + """按维度聚合回款金额与笔数——金额侧必须按 receipt_id 去重。 + + 二次分摊拆行使同一回款在台账出现多行,直接 SUM(receipts.amount) 会 + 按拆行数翻倍;台账拆行只改归属人,不改维度值(payment_days/客户类型/ + 产品类别),因此先对台账行 DISTINCT 出回款维度,再联回款表求和。 + """ + distinct_receipts = ( + select( + PerformanceLedger.receipt_id.label("receipt_id"), + PerformanceLedger.payment_days.label("payment_days"), + PerformanceLedger.customer_type.label("customer_type"), + PerformanceLedger.product_category.label("product_category"), + ) + .where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None)) + .distinct() + .subquery() + ) + from app.models import Receipt + + return list( + session.execute( + select( + distinct_receipts.c.payment_days, + distinct_receipts.c.customer_type, + distinct_receipts.c.product_category, + func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"), + func.count(distinct_receipts.c.receipt_id).label("receipt_count"), + ) + .outerjoin(Receipt, Receipt.id == distinct_receipts.c.receipt_id) + .group_by( + distinct_receipts.c.payment_days, + distinct_receipts.c.customer_type, + distinct_receipts.c.product_category, + ) + ).all() + ) + + +def group_payment_by_customer_type(session: Session, query: PaymentStatsQuery) -> list: + """按客户类型聚合(绩效 + 回款金额)。""" + return _group_by_dimension(session, query, PerformanceLedger.customer_type) + + +def group_payment_by_product_category(session: Session, query: PaymentStatsQuery) -> list: + """按产品类别聚合(绩效 + 回款金额)。""" + return _group_by_dimension(session, query, PerformanceLedger.product_category) + + +def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column) -> list: + """维度聚合共用实现(绩效侧 GROUP BY + 金额侧 DISTINCT 去重联回款)。""" + from app.models import Receipt + + dim_perf = ( + select( + dim_column.label("dim"), + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("performance_total"), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"), + func.count().label("ledger_count"), + ) + .where(*_payment_filters(query)) + .group_by(dim_column) + ) + dim_receipts = ( + select( + dim_column.label("dim"), + PerformanceLedger.receipt_id.label("receipt_id"), + ) + .where(*_payment_filters(query), PerformanceLedger.receipt_id.is_not(None)) + .distinct() + .subquery() + ) + perf_rows = { + row.dim: row + for row in session.execute(dim_perf).all() + } + receipt_rows = session.execute( + select( + dim_receipts.c.dim, + func.coalesce(func.sum(Receipt.amount), 0).label("receipt_amount"), + ) + .outerjoin(Receipt, Receipt.id == dim_receipts.c.receipt_id) + .group_by(dim_receipts.c.dim) + ).all() + receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows} + return [ + (row.dim, receipt_amounts.get(row.dim), row.performance_total, row.payable_total, row.ledger_count) + for row in perf_rows.values() + ] + + +def list_payment_detail( + session: Session, query: PaymentDetailQuery, *, offset: int, limit: int +) -> list: + """区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。""" + from app.models import Receipt + + stmt = ( + select( + PerformanceLedger, + Receipt.amount.label("receipt_amount"), + Receipt.receipt_batch_no.label("receipt_batch_no"), + ) + .outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id) + .where(*_payment_filters(query)) + ) + stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc()) + stmt = stmt.offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_payment_detail(session: Session, query: PaymentDetailQuery) -> int: + """区间下钻计数(与 list_payment_detail 同筛选)。""" + return int( + session.execute( + select(func.count()) + .select_from(PerformanceLedger) + .where(*_payment_filters(query)) + ).scalar_one() + ) diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py new file mode 100644 index 0000000..ffa2048 --- /dev/null +++ b/backend/app/schemas/report.py @@ -0,0 +1,260 @@ +"""报表统计的请求/响应模型(M4 ⑤)。 + +金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失); +比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。 +""" + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.response import PageQuery + +# ==== 查询入参 ==== + +class PersonalDetailQuery(PageQuery): + """个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。""" + + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + personnel_id: int | None = Field(default=None, ge=1, description="人员筛选") + customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选") + product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选") + role_code: str | None = Field(default=None, max_length=32, description="岗位筛选") + + +class DepartmentSummaryQuery(BaseModel): + """部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。""" + + settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM") + manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队") + + +class BusinessStatsQuery(PageQuery): + """业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。""" + + keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称") + customer_id: int | None = Field(default=None, ge=1, description="客户筛选") + owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选") + status: int | None = Field(default=None, description="业务状态筛选") + date_from: date | None = Field(default=None, description="业务日期起") + date_to: date | None = Field(default=None, description="业务日期止") + + +class PaymentStatsQuery(BaseModel): + """回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。""" + + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + date_from: date | None = Field(default=None, description="回款日期起") + date_to: date | None = Field(default=None, description="回款日期止") + customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选") + product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选") + + +class PaymentDetailQuery(PageQuery): + """回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。""" + + bucket: str = Field(min_length=1, max_length=16, description="区间桶编码") + settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM") + date_from: date | None = Field(default=None, description="回款日期起") + date_to: date | None = Field(default=None, description="回款日期止") + customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选") + product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选") + + +# ==== 响应出参 ==== + +class ReportSummary(BaseModel): + """全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。""" + + row_count: int = Field(default=0, description="筛选结果行数") + performance_total: Decimal | None = Field(default=None, description="绩效金额合计") + payable_total: Decimal | None = Field(default=None, description="应付绩效合计") + converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计") + + +class ReportPage[T](BaseModel): + """报表分页:标准分页四字段 + 全筛选合计。 + + 序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端 + usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。 + """ + + model_config = ConfigDict(populate_by_name=True) + + records: list[T] = Field( + default_factory=list, alias="list", serialization_alias="list", description="当前页数据" + ) + total: int = Field(default=0, description="总记录数") + page_num: int = Field( + default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码" + ) + page_size: int = Field( + default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数" + ) + summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计") + + +class PersonalDetailRow(BaseModel): + """个人绩效明细行(需规 2.4.1 列全覆盖)。 + + 二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内 + 不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额 + 下沉到快照下钻。 + """ + + ledger_id: int + business_id: int + business_no: str + personnel_id: int + personnel_name: str + role_code: str + role_name: str + customer_type: str + product_category: str + ship_date: date | None = None + receipt_date: date + payment_days: int | None = None + rate_text: str + weight_r: int | None = None + payment_weight: Decimal | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + converted_profit: Decimal | None = None + is_probation: int + missing_cost: int + calc_version: int + settlement_id: int | None = None + + +class DepartmentMemberRow(BaseModel): + """部门汇总 · 团队成员明细(内联于团队行)。""" + + personnel_id: int + personnel_name: str + role_code: str + is_probation: int + performance_sum: Decimal | None = None + payable_sum: Decimal | None = None + adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计") + adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计") + adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)") + net_total: Decimal | None = None + ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)") + + +class DepartmentTeamRow(BaseModel): + """部门汇总 · 经理团队行。""" + + manager_id: int + manager_name: str + manager_status: int = Field(description="经理在职状态(1 在职 0 离职)") + member_count: int + team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)") + team_share_amount: Decimal = Field(description="12.5% 团队分成") + company_share_amount: Decimal = Field(description="5% 全公司分成(同一基数,各团队不互斥)") + company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)") + adjust_extra: Decimal = Decimal("0") + adjust_deduct: Decimal = Decimal("0") + adjust_net: Decimal = Decimal("0") + net_total: Decimal = Field(description="团队净额 = 分成 + 调整净额") + frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)") + aggregate_ledger_id: int | None = None + members: list[DepartmentMemberRow] = Field(default_factory=list) + + +class DepartmentCompanyBlock(BaseModel): + """部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。""" + + team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)") + company_base: Decimal = Field(description="全公司基数(5% 的底)") + company_share_pct: str = Field(description="分成比例参数原文") + company_share_amount: Decimal = Field(description="5% 全公司分成") + + +class DepartmentSummaryOut(BaseModel): + """部门绩效工资汇总响应(FR-030)。""" + + settlement_month: str + params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)") + teams: list[DepartmentTeamRow] = Field(default_factory=list) + unassigned: list[DepartmentMemberRow] = Field( + default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)" + ) + company: DepartmentCompanyBlock | None = None + totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)") + + +class BusinessStatsRow(BaseModel): + """业务统计行(发货/回款/绩效为该业务累计值)。""" + + business_id: int + business_no: str + business_date: date | None = None + status: int + customer_name: str | None = None + owner_personnel_name: str | None = None + total_sale_amount: Decimal | None = None + total_ship_amount: Decimal | None = None + total_received_amount: Decimal | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + + +class BucketRow(BaseModel): + """回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。""" + + code: str + label: str + min_days: int | None = None + max_days: int | None = None + receipt_amount: Decimal | None = None + receipt_count: int = 0 + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + ledger_count: int = 0 + + +class GroupRow(BaseModel): + """回款统计 · 维度对比行(客户类型 / 产品类别)。""" + + code: str + label: str + receipt_amount: Decimal | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None + ledger_count: int = 0 + + +class PaymentStatsOut(BaseModel): + """回款情况统计响应(FR-031)。 + + 口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行, + 不在本表(页面需注明)。 + """ + + settlement_month: str | None = None + buckets: list[BucketRow] = Field(default_factory=list) + by_customer_type: list[GroupRow] = Field(default_factory=list) + by_product_category: list[GroupRow] = Field(default_factory=list) + totals: dict = Field(default_factory=dict, description="全表合计") + + +class PaymentDetailRow(BaseModel): + """回款区间下钻行(行级展示回款金额与批次号)。""" + + ledger_id: int + receipt_id: int | None = None + receipt_batch_no: str | None = None + amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)") + business_id: int + business_no: str + personnel_id: int + personnel_name: str + customer_type: str + product_category: str + ship_date: date | None = None + receipt_date: date + payment_days: int | None = None + performance_amount: Decimal | None = None + payable_amount: Decimal | None = None diff --git a/backend/app/services/report_meta.py b/backend/app/services/report_meta.py new file mode 100644 index 0000000..b7bc8d6 --- /dev/null +++ b/backend/app/services/report_meta.py @@ -0,0 +1,76 @@ +"""报表统计元数据(区间桶、中文标签、导出上限)——叶子模块。 + +刻意不导入 app 内任何模块:report_service 与 report_export 都要消费这里的 +定义,若它反向依赖 service 层会形成循环导入;也不走参数体系——FR-031 +「区间可配置」一期以代码常量落地(参数化需注册表 + 迁移 + init-dev + +registry 测试四处同改,业务未提诉求前不做),配置缝收在 +get_payment_day_buckets 单函数,未来在线可配时只改该函数。 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class DayBucket: + """回款天数区间档位:min/max 均为 None 表示「无回款天数」兜底桶。""" + + code: str + label: str + min_days: int | None + max_days: int | None + + +# 8 档区间(催办清单 R2 默认口径)。两个兜底桶必须保留:payment_days 可空 +# (回款未指定发货批次)且可为负(先款后货照实负值),缺了它们这些行会 +# 静默消失、金额对不上。 +PAYMENT_DAY_BUCKETS: tuple[DayBucket, ...] = ( + DayBucket("le0", "≤0 天(先款后货)", None, 0), + DayBucket("1-30", "1-30 天", 1, 30), + DayBucket("31-60", "31-60 天", 31, 60), + DayBucket("61-90", "61-90 天", 61, 90), + DayBucket("91-180", "91-180 天", 91, 180), + DayBucket("181-360", "181-360 天", 181, 360), + DayBucket("361+", "361 天以上", 361, None), + DayBucket("none", "无回款天数", None, None), +) + +CUSTOMER_TYPE_LABELS: dict[str, str] = { + "personal": "个人户", + "manager_personal": "经理个人户", + "enterprise": "企业户", +} + +# mixed 不在 core.constants.PRODUCT_CATEGORIES(那只是可维护的档案类别), +# 但台账行可以是 mixed(混合业务行级明细入 snapshot_json),报表必须能标它 +PRODUCT_CATEGORY_LABELS: dict[str, str] = { + "municipal_water": "市政水利", + "rail_transit": "轨道交通", + "mixed": "混合业务", +} + +REPORT_TEMPLATE_VERSION = "V1.0" +# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通 +# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出 +REPORT_EXPORT_MAX_ROWS = 20000 + + +def get_payment_day_buckets() -> tuple[DayBucket, ...]: + """区间档位唯一出口:前端下拉与分桶都以本函数返回为准,禁止硬编码。 + + 未来「区间在线可配」时改此处读参数即可,调用方与前端零改动。 + """ + return PAYMENT_DAY_BUCKETS + + +def bucket_of(payment_days: int | None) -> DayBucket: + """把单笔回款天数落入对应区间桶;未匹配任何区间按「无回款天数」兜底。""" + if payment_days is None: + return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none") + for bucket in PAYMENT_DAY_BUCKETS: + if bucket.code == "none": + continue + lower_ok = bucket.min_days is None or payment_days >= bucket.min_days + upper_ok = bucket.max_days is None or payment_days <= bucket.max_days + if lower_ok and upper_ok: + return bucket + return next(b for b in PAYMENT_DAY_BUCKETS if b.code == "none")