feat/m3-increments #1
@ -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 = "导出"
|
||||
|
||||
392
backend/app/repositories/report_repository.py
Normal file
392
backend/app/repositories/report_repository.py
Normal file
@ -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()
|
||||
)
|
||||
260
backend/app/schemas/report.py
Normal file
260
backend/app/schemas/report.py
Normal file
@ -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
|
||||
76
backend/app/services/report_meta.py
Normal file
76
backend/app/services/report_meta.py
Normal file
@ -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")
|
||||
Loading…
Reference in New Issue
Block a user