""" 业绩报表导出服务模块。 职责: 将业绩统计数据导出为 CSV 或 Excel(xlsx)格式的文件,供用户下载。 负责文件命名、格式校验、内容写入和格式美化。 依赖: - openpyxl:Excel 文件生成 - csv:标准库 CSV 写入 被引用方: - ReportService.export_performance():调用本服务生成导出文件 """ import csv from datetime import datetime from pathlib import Path from openpyxl import Workbook class ExportService: """业绩报表导出服务。 根据传入的业绩报表数据,生成 CSV 或 Excel 格式的导出文件, 并返回文件元信息(路径、大小、MIME 类型等)供上层服务上传到 OSS。 导出文件存储在本地 /tmp/order-flow-exports 目录下。 """ def __init__(self) -> None: from backend.app.core.config import get_settings settings = get_settings() # 复用 LOCAL_UPLOAD_DIR 的父目录,保持路径风格一致 self.export_root = Path(settings.local_upload_dir).parent / "exports" self.export_root.mkdir(parents=True, exist_ok=True) def build_performance_export(self, report: dict, export_format: str = "csv", raw_rows: list | None = None) -> dict: """根据业绩报表数据生成导出文件(含订单明细)。 根据指定的导出格式(csv 或 xlsx),将汇总数据和订单明细写入本地文件, 并返回文件元信息供上层服务上传到 OSS。 参数: report (dict): 业绩报表数据,包含 stat_type(统计类型)和 list(数据列表) export_format (str): 导出格式,支持 "csv" 和 "xlsx",默认 "csv" raw_rows (list): 订单明细行列表,用于在导出中追加明细数据 返回: dict: 包含 file_name、file_path、file_size、content_type、 object_key、export_format 等字段 被调用方: - ReportService.export_performance() → 业绩报表导出 API """ timestamp = datetime.now().strftime("%Y%m%d%H%M%S") suffix = report["stat_type"] normalized_format = self._normalize_export_format(export_format) file_name = f"performance-{suffix}-{timestamp}.{normalized_format}" file_path = self.export_root / file_name if normalized_format == "xlsx": self._write_xlsx(file_path, report, raw_rows) content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" else: self._write_csv(file_path, report, raw_rows) content_type = "text/csv" return { "file_name": file_name, "file_path": str(file_path), "file_size": file_path.stat().st_size, "content_type": content_type, "object_key": f"exports/performance/{file_name}", "export_format": normalized_format, } def _normalize_export_format(self, export_format: str) -> str: """校验并标准化导出格式。 将传入的格式字符串转为小写并去除空白,若不在支持的格式列表中 则默认回退为 csv。 参数: export_format (str): 用户指定的导出格式 返回: str: 标准化后的格式字符串("csv" 或 "xlsx") """ normalized = (export_format or "csv").strip().lower() if normalized not in {"csv", "xlsx"}: return "csv" return normalized def _format_stat_period(self, dt, stat_type: str) -> str: """将日期按统计类型格式化为统计周期标识字符串。""" if dt is None: return "" 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") def _build_detail_map(self, raw_rows: list | None, stat_type: str = "month") -> dict[str, list]: """将订单明细行按统计周期分组。""" if not raw_rows: return {} detail_map: dict[str, list] = {} for row in raw_rows: if row.created_at is None: continue period = self._format_stat_period(row.created_at, stat_type) detail_map.setdefault(period, []).append(row) return detail_map def _write_csv(self, file_path: Path, report: dict, raw_rows: list | None = None) -> None: """将报表汇总和订单明细写入 CSV 文件。 使用 UTF-8-BOM 编码写入,确保 Excel 打开时中文不乱码。 第一行为汇总表头,每个周期后追加该周期的订单明细行。 参数: file_path (Path): 目标 CSV 文件路径 report (dict): 报表数据,包含 list 列表 raw_rows (list): 订单明细行列表,用于追加明细数据 """ detail_map = self._build_detail_map(raw_rows, report["stat_type"]) import logging _log = logging.getLogger(__name__) _log.info("[CSV导出] stat_type=%s, 汇总周期数=%d, raw_rows=%d, detail_map周期=%s", report["stat_type"], len(report["list"]), len(raw_rows) if raw_rows else 0, list(detail_map.keys()) if detail_map else "空") with file_path.open("w", encoding="utf-8-sig", newline="") as csvfile: writer = csv.writer(csvfile) writer.writerow(["统计周期", "订单数", "订单金额", "固定提成", "分类明细"]) for item in report["list"]: writer.writerow(self._build_row(item)) for detail in detail_map.get(item["stat_period"], []): writer.writerow([ "", detail.order_no or "", detail.customer_name or "", detail.product_name or "", detail.quantity or "", f"{float(detail.sale_price or 0):.2f}", f"{float(detail.contract_amount or 0):.2f}", f"{float(detail.profit_total or 0):.2f}", f"{float(detail.commission_amount or 0):.2f}", (detail.created_at.strftime("%Y-%m-%d") if detail.created_at else ""), ]) writer.writerow(self._build_summary_row(report["list"])) _log.info("[CSV导出] 写入完成,文件=%s, 行数=%d", file_path.name, sum(1 for _ in file_path.open(encoding="utf-8-sig"))) def _write_xlsx(self, file_path: Path, report: dict, raw_rows: list | None = None) -> None: """将报表汇总和订单明细写入 Excel 文件。 使用 openpyxl 创建工作簿,第一个 sheet 为汇总数据, 第二个 sheet 为订单明细。 参数: file_path (Path): 目标 xlsx 文件路径 report (dict): 报表数据,包含 list 列表 raw_rows (list): 订单明细行列表 """ workbook = Workbook() # Sheet 1: 汇总 sheet = workbook.active sheet.title = "业绩统计" sheet.append(["统计周期", "订单数", "订单金额", "固定提成", "分类明细"]) for item in report["list"]: sheet.append(self._build_row(item)) summary = self._build_summary_row(report["list"]) sheet.append(summary) for cell in sheet[1]: cell.font = cell.font.copy(bold=True) last_row = sheet.max_row for cell in sheet[last_row]: cell.font = cell.font.copy(bold=True) sheet.column_dimensions["A"].width = 18 sheet.column_dimensions["B"].width = 12 sheet.column_dimensions["C"].width = 16 sheet.column_dimensions["D"].width = 16 sheet.column_dimensions["E"].width = 48 # Sheet 2: 订单明细 import logging _log_xlsx = logging.getLogger(__name__) if raw_rows: _log_xlsx.info("[XLSX导出] 订单明细行数=%d", len(raw_rows)) detail_sheet = workbook.create_sheet("订单明细") detail_sheet.append(["统计周期", "订单号", "客户", "产品", "数量", "单价", "金额", "利润", "提成", "日期"]) for row in raw_rows: stat_period = self._format_stat_period(row.created_at, report["stat_type"]) detail_sheet.append([ stat_period, row.order_no or "", row.customer_name or "", row.product_name or "", row.quantity or "", f"{float(row.sale_price or 0):.2f}", f"{float(row.contract_amount or 0):.2f}", f"{float(row.profit_total or 0):.2f}", f"{float(row.commission_amount or 0):.2f}", row.created_at.strftime("%Y-%m-%d") if row.created_at else "", ]) for cell in detail_sheet[1]: cell.font = cell.font.copy(bold=True) detail_sheet.column_dimensions["A"].width = 18 detail_sheet.column_dimensions["B"].width = 22 detail_sheet.column_dimensions["C"].width = 18 detail_sheet.column_dimensions["D"].width = 24 detail_sheet.column_dimensions["E"].width = 8 detail_sheet.column_dimensions["F"].width = 10 detail_sheet.column_dimensions["G"].width = 12 detail_sheet.column_dimensions["H"].width = 12 detail_sheet.column_dimensions["I"].width = 10 detail_sheet.column_dimensions["J"].width = 14 workbook.save(file_path) def _build_summary_row(self, items: list) -> list: """构建合计行。""" total_count = sum(item["order_count"] for item in items) total_amount = sum(item["order_amount"] for item in items) total_commission = sum(item["commission_amount"] for item in items) return [ "合计", total_count, f"{total_amount:.2f}", f"{total_commission:.2f}", "", ] def _build_row(self, item: dict) -> list: """将单条报表数据项转换为表格行。 将分类明细的金额列表用中文分号连接为可读字符串, 数值字段格式化为保留两位小数的字符串。 参数: item (dict): 单条报表数据,包含 stat_period、order_count、 order_amount、commission_amount、category_amounts 返回: list: 包含 [统计周期, 订单数, 订单金额, 固定提成, 分类明细] 的列表 """ category_text = ";".join( f"{category['category_name']}:{category['amount']:.2f}" for category in item.get("category_amounts", []) ) return [ item["stat_period"], item["order_count"], f"{item['order_amount']:.2f}", f"{item['commission_amount']:.2f}", category_text, ] export_service = ExportService()