dingdanquanliucheng/backend/app/services/report_service.py

424 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""报表服务模块
负责业绩统计报表的查询和导出。
支持按月、按季、按年三种统计维度,可导出为 CSV 或 XLSX 格式。
导出时自动关联附件管理和审计日志。
被调用方reports 路由(业绩报表查询、报表导出接口)。
"""
from collections import defaultdict
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.models.business import PerformanceStatCache
from backend.app.repositories.report_repository import ReportRepository
from backend.app.services.audit_service import audit_service
from backend.app.services.export_service import export_service
from backend.app.services.file_service import file_service
from backend.app.services.storage_service import storage_service
class ReportService:
"""业绩报表业务服务。
依赖:
- ReportRepository报表数据查询
- ExportService构建导出文件
- StorageService文件上传发布
- FileService附件记录保存
- AuditService操作审计日志
"""
def __init__(self) -> None:
self.repository = ReportRepository()
def performance_report(self, filters: dict, session: Session | None = None) -> dict:
"""查询业绩统计报表数据。
根据统计类型(月/季/年)和筛选条件,聚合订单行数据生成报表。
支持缓存:命中缓存直接返回,未命中则查库后写入缓存。
参数:
filters: 筛选条件字典,包含 stat_type、start_date、end_date、category_id、salesman_id 等。
session: 数据库会话,不可为 None。
返回:
包含 stat_type、list、cache_state 等信息的字典。
"""
stat_type = self._normalize_stat_type(filters.get("stat_type"))
parsed_filters = self._parse_filters(filters, stat_type)
if session is not None:
try:
# 尝试读取缓存(仅简单查询时使用缓存)
use_cache = (
not parsed_filters.get("start_date")
and not parsed_filters.get("end_date")
and not parsed_filters.get("category_id")
and not parsed_filters.get("salesman_id")
)
if use_cache:
cached = self._load_cache(session, stat_type)
if cached is not None:
return cached
rows = self.repository.list_performance_rows(session, parsed_filters)
report_list = self._build_report_list(rows, stat_type)
if use_cache:
self._save_cache(session, stat_type, report_list)
return {
"stat_type": stat_type,
"list": report_list,
"cache_state": "实时生成",
"cache_expired": False,
}
except SQLAlchemyError as exc:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
def export_performance_report(self, filters: dict, session: Session | None = None) -> dict:
"""导出业绩统计报表为文件CSV 或 XLSX
先查询报表数据,再调用 ExportService 生成文件,最后上传到存储服务并记录附件和审计日志。
参数:
filters: 筛选条件字典,额外需要 export_format 字段。
session: 数据库会话,不可为 None。
返回:
包含 file_url、file_name、object_key 等文件信息及报表元数据的字典。
被调用方reports 路由(报表导出接口)。
"""
report = self.performance_report(filters, session)
export_format = self._normalize_export_format(filters.get("export_format"))
export_meta = export_service.build_performance_export(report, export_format)
publish_meta = storage_service.publish_local_file(export_meta["file_path"], export_meta["object_key"])
attachment_payload = {
"biz_type": "performance_report",
"biz_id": int(datetime.now().strftime("%Y%m%d%H%M%S")),
"file_name": export_meta["file_name"],
"file_url": publish_meta["file_url"],
"file_type": export_meta["content_type"],
"file_size": export_meta["file_size"],
}
attachment_result = file_service.save_attachment(attachment_payload, session) if session is not None else {
"file_url": publish_meta["file_url"],
"file_name": export_meta["file_name"],
"attachment_id": 0,
"object_key": publish_meta["object_key"],
}
if session is not None:
audit_service.write_log(
session,
{
"operate_type": "report_export",
"biz_type": "performance_report",
"biz_id": attachment_result.get("attachment_id", 0),
"before_value": None,
"after_value": {
"file_name": export_meta["file_name"],
"stat_type": report["stat_type"],
"export_format": export_format,
"total_periods": len(report["list"]),
},
"remark": "导出业绩统计报表",
},
)
session.commit()
return {
"file_url": attachment_result["file_url"],
"file_name": export_meta["file_name"],
"object_key": publish_meta["object_key"],
"storage_provider": publish_meta["storage_provider"],
"bucket_name": publish_meta["bucket_name"],
"stat_type": report["stat_type"],
"export_format": export_format,
"total_periods": len(report["list"]),
"filters": {
"start_date": filters.get("start_date"),
"end_date": filters.get("end_date"),
"category_id": filters.get("category_id"),
},
}
def audit_report(self, filters: dict, session: Session | None = None) -> dict:
"""查询报表核对数据。
在业绩报表基础上,动态计算异常项和真实剔除数。
"""
stat_type = self._normalize_stat_type(filters.get("stat_type"))
parsed_filters = self._parse_filters(filters, stat_type)
if session is None:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
rows = self.repository.list_performance_rows(session, parsed_filters)
report_list = self._build_report_list(rows, stat_type)
issues = []
for item in report_list:
period = item["stat_period"]
order_count = item["order_count"]
order_amount = item["order_amount"]
commission = item["commission_amount"]
if order_count > 0 and order_amount == 0:
issues.append({"title": f"{period} 金额异常", "stat_period": period, "description": f"该周期有 {order_count} 笔订单但金额为零。", "type": "amount_zero"})
if order_amount > 0:
rate = commission / order_amount
if rate > 0.15:
issues.append({"title": f"{period} 提成比例偏高", "stat_period": period, "description": f"提成比例 {rate:.1%} 超过 15% 阈值。", "type": "commission_high"})
elif rate < 0.01:
issues.append({"title": f"{period} 提成比例偏低", "stat_period": period, "description": f"提成比例 {rate:.2%} 低于 1%。", "type": "commission_low"})
excluded_count = 0
if filters.get("exclude_ecommerce"):
excluded_count = self.repository.count_excluded_orders(session, parsed_filters)
return {
"stat_type": stat_type,
"list": report_list,
"issues": issues,
"excluded_count": excluded_count,
}
def _load_cache(self, session: Session, stat_type: str) -> dict | None:
"""从缓存表加载业绩统计数据。
按 stat_type 匹配最近一条缓存记录,若 created_at 超过 24 小时视为过期。
"""
import json
from datetime import timedelta as td
cutoff = datetime.now() - td(hours=24)
stmt = (
select(PerformanceStatCache)
.where(PerformanceStatCache.stat_type == stat_type, PerformanceStatCache.created_at >= cutoff)
.order_by(PerformanceStatCache.created_at.desc())
)
rows = list(session.execute(stmt).scalars().all())
if not rows:
return None
latest = rows[0]
return {
"stat_type": stat_type,
"list": [
{
"stat_period": r.stat_period,
"order_count": r.order_count,
"order_amount": float(r.order_amount),
"commission_amount": float(r.commission_amount),
"total_profit": float(r.total_profit),
"category_amounts": json.loads(r.category_amounts_json) if r.category_amounts_json else [],
}
for r in rows
],
"cache_state": "缓存命中",
"cache_updated_at": str(latest.created_at),
"cache_expired": False,
}
def _save_cache(self, session: Session, stat_type: str, report_list: list) -> None:
"""将报表结果写入缓存表(先清旧数据再写入)。"""
import json
from sqlalchemy import delete
session.execute(delete(PerformanceStatCache).where(PerformanceStatCache.stat_type == stat_type))
for item in report_list:
cache = PerformanceStatCache(
stat_type=stat_type,
stat_period=item["stat_period"],
order_count=item["order_count"],
order_amount=item["order_amount"],
commission_amount=item["commission_amount"],
total_profit=item.get("total_profit", 0),
category_amounts_json=json.dumps(item.get("category_amounts", []), ensure_ascii=False),
)
session.add(cache)
session.flush()
def _normalize_stat_type(self, stat_type: str | None) -> str:
"""校验并归一化统计类型参数。
参数:
stat_type: 原始统计类型值,支持 'month''quarter''year',默认 'month'
返回:
归一化后的统计类型字符串。
异常:
参数不合法时抛出 AppExceptionPARAM_ERROR
"""
value = (stat_type or "month").strip().lower()
if value not in {"month", "quarter", "year"}:
raise AppException(code=ErrorCode.PARAM_ERROR, message="统计类型不支持", status_code=400)
return value
def _normalize_export_format(self, export_format: str | None) -> str:
"""校验并归一化导出格式参数。
参数:
export_format: 原始导出格式值,支持 'csv''xlsx',默认 'csv'
返回:
归一化后的导出格式字符串。
"""
value = (export_format or "csv").strip().lower()
if value not in {"csv", "xlsx"}:
raise AppException(code=ErrorCode.PARAM_ERROR, message="导出格式仅支持 csv 或 xlsx", status_code=400)
return value
def _parse_filters(self, filters: dict, stat_type: str) -> dict:
"""解析前端传入的筛选条件为内部可用的过滤参数。
参数:
filters: 原始筛选字典。
stat_type: 已归一化的统计类型。
返回:
包含 stat_type、start_date、end_date、category_id、exclude_ecommerce 的字典。
"""
parsed = {
"stat_type": stat_type,
"start_date": self._parse_date(filters.get("start_date"), "开始日期格式错误"),
"end_date": self._parse_date(filters.get("end_date"), "结束日期格式错误"),
"category_id": filters.get("category_id"),
"exclude_ecommerce": bool(filters.get("exclude_ecommerce")),
"salesman_id": filters.get("salesman_id"),
}
if parsed["start_date"] and parsed["end_date"] and parsed["start_date"] > parsed["end_date"]:
raise AppException(code=ErrorCode.PARAM_ERROR, message="开始日期不能晚于结束日期", status_code=400)
return parsed
def _parse_date(self, value: str | None, error_message: str) -> datetime | None:
"""将日期字符串解析为 datetime 对象。
参数:
value: 日期字符串,格式为 'YYYY-MM-DD',空值时返回 None。
error_message: 解析失败时的错误提示信息。
返回:
datetime 对象或 None。
"""
if value is None or not str(value).strip():
return None
try:
return datetime.strptime(str(value).strip(), "%Y-%m-%d")
except ValueError as exc:
raise AppException(code=ErrorCode.PARAM_ERROR, message=error_message, status_code=400) from exc
def _build_report_list(self, rows: list, stat_type: str) -> list[dict]:
"""将原始订单行数据按统计周期分组聚合,生成报表列表。
参数:
rows: 数据库查询返回的订单行列表。
stat_type: 统计类型month/quarter/year决定周期分组方式。
返回:
按时间顺序排列的统计周期汇总列表,每个元素包含 order_count、order_amount、
category_amounts、commission_amount 等。
"""
grouped: dict[str, dict] = {}
period_order: list[str] = []
for row in rows:
created_at = row.created_at
if created_at is None:
continue
stat_period = self._format_stat_period(created_at, stat_type)
if stat_period not in grouped:
grouped[stat_period] = {
"stat_period": stat_period,
"order_ids": set(),
"order_amount": 0.0,
"commission_by_order": {},
"sale_price_by_order": {},
"cost_price_by_order": {},
"profit_by_order": {},
"category_amounts": defaultdict(lambda: {"category_id": 0, "category_name": "未分类", "amount": 0.0}),
}
period_order.append(stat_period)
period_data = grouped[stat_period]
line_amount = float(row.quantity or 0) * float(row.sale_price or 0)
period_data["order_amount"] += line_amount
period_data["order_ids"].add(row.order_id)
if row.order_id not in period_data["commission_by_order"]:
period_data["commission_by_order"][row.order_id] = float(row.commission_amount or 0)
if row.order_id not in period_data["sale_price_by_order"]:
period_data["sale_price_by_order"][row.order_id] = float(row.contract_amount or 0)
if row.order_id not in period_data["cost_price_by_order"]:
period_data["cost_price_by_order"][row.order_id] = float(row.cost_price_total or 0)
if row.order_id not in period_data["profit_by_order"]:
period_data["profit_by_order"][row.order_id] = float(row.profit_total or 0)
category_key = row.category_id or 0
category_item = period_data["category_amounts"][category_key]
category_item["category_id"] = row.category_id or 0
category_item["category_name"] = row.category_name or "未分类"
category_item["amount"] += line_amount
result: list[dict] = []
for stat_period in period_order:
period_data = grouped[stat_period]
category_amounts = sorted(
(
{
"category_id": item["category_id"],
"category_name": item["category_name"],
"amount": round(item["amount"], 2),
}
for item in period_data["category_amounts"].values()
),
key=lambda item: (-item["amount"], item["category_id"]),
)
total_sale_price = sum(period_data["sale_price_by_order"].values())
total_cost_price = sum(period_data["cost_price_by_order"].values())
total_profit = sum(period_data["profit_by_order"].values())
total_commission = sum(period_data["commission_by_order"].values())
result.append(
{
"stat_period": stat_period,
"order_count": len(period_data["order_ids"]),
"order_amount": round(period_data["order_amount"], 2),
"category_amounts": category_amounts,
"commission_amount": round(total_commission, 2),
"total_sale_price": round(total_sale_price, 2),
"total_cost_price": round(total_cost_price, 2),
"total_profit": round(total_profit, 2),
"total_profit_rate": round(total_sale_price and (total_profit / total_sale_price * 100) or 0, 2),
}
)
return result
def _format_stat_period(self, dt: datetime, stat_type: str) -> str:
"""将日期按统计类型格式化为统计周期标识字符串。
参数:
dt: 日期时间对象。
stat_type: 统计类型,'year' 返回 'YYYY''quarter' 返回 'YYYY-QN''month' 返回 'YYYY-MM'
返回:
统计周期标识字符串。
"""
if stat_type == "year":
return dt.strftime("%Y")
if stat_type == "quarter":
quarter = ((dt.month - 1) // 3) + 1
return f"{dt.year}-Q{quarter}"
return dt.strftime("%Y-%m")
report_service = ReportService()