dingdanquanliucheng/backend/app/services/export_service.py
wsb1224 9fe93d1aa1 fix: 报表导出增加诊断日志
在 CSV/XLSX 导出时记录 raw_rows 数量和 detail_map 周期键,
用于排查导出缺少订单明细的问题。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 18:03:49 +08:00

265 lines
11 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 或 Excelxlsx格式的文件供用户下载。
负责文件命名、格式校验、内容写入和格式美化。
依赖:
- openpyxlExcel 文件生成
- 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()