- 四表接口 /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>
424 lines
16 KiB
Python
424 lines
16 KiB
Python
"""报表统计仓储:只读聚合查询;不 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.core.report_meta import DayBucket
|
||
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 _bucket_condition(bucket: DayBucket) -> tuple:
|
||
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
|
||
if bucket.code == "none":
|
||
return (PerformanceLedger.payment_days.is_(None),)
|
||
conds = []
|
||
if bucket.min_days is not None:
|
||
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
|
||
if bucket.max_days is not None:
|
||
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
|
||
return tuple(conds)
|
||
|
||
|
||
def list_payment_detail(
|
||
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
|
||
) -> list:
|
||
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
|
||
|
||
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
|
||
"""
|
||
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), *_bucket_condition(bucket))
|
||
)
|
||
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, bucket: DayBucket) -> int:
|
||
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
|
||
return int(
|
||
session.execute(
|
||
select(func.count())
|
||
.select_from(PerformanceLedger)
|
||
.where(*_payment_filters(query), *_bucket_condition(bucket))
|
||
).scalar_one()
|
||
)
|