"""报表服务模块 负责业绩统计报表的查询和导出。 支持按月、按季、按年三种统计维度,可导出为 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'。 返回: 归一化后的统计类型字符串。 异常: 参数不合法时抛出 AppException(PARAM_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.sale_price_total 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()