2026-05-30 07:23:33 +08:00
|
|
|
|
"""报表服务模块
|
|
|
|
|
|
|
|
|
|
|
|
负责业绩统计报表的查询和导出。
|
|
|
|
|
|
支持按月、按季、按年三种统计维度,可导出为 CSV 或 XLSX 格式。
|
|
|
|
|
|
导出时自动关联附件管理和审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:reports 路由(业绩报表查询、报表导出接口)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
2026-06-14 16:20:04 +08:00
|
|
|
|
from sqlalchemy import select
|
2026-05-15 11:10:44 +08:00
|
|
|
|
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
|
2026-06-05 22:26:08 +08:00
|
|
|
|
from backend.app.models.business import PerformanceStatCache
|
2026-05-15 11:10:44 +08:00
|
|
|
|
from backend.app.repositories.report_repository import ReportRepository
|
2026-05-15 11:47:29 +08:00
|
|
|
|
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
|
2026-05-15 13:17:45 +08:00
|
|
|
|
from backend.app.services.storage_service import storage_service
|
2026-05-15 11:10:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 13:51:06 +08:00
|
|
|
|
class ReportService:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""业绩报表业务服务。
|
|
|
|
|
|
|
|
|
|
|
|
依赖:
|
|
|
|
|
|
- ReportRepository:报表数据查询
|
|
|
|
|
|
- ExportService:构建导出文件
|
|
|
|
|
|
- StorageService:文件上传发布
|
|
|
|
|
|
- FileService:附件记录保存
|
|
|
|
|
|
- AuditService:操作审计日志
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self.repository = ReportRepository()
|
|
|
|
|
|
|
|
|
|
|
|
def performance_report(self, filters: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""查询业绩统计报表数据。
|
|
|
|
|
|
|
|
|
|
|
|
根据统计类型(月/季/年)和筛选条件,聚合订单行数据生成报表。
|
2026-06-05 22:26:08 +08:00
|
|
|
|
支持缓存:命中缓存直接返回,未命中则查库后写入缓存。
|
2026-05-30 07:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
参数:
|
2026-06-05 22:26:08 +08:00
|
|
|
|
filters: 筛选条件字典,包含 stat_type、start_date、end_date、category_id、salesman_id 等。
|
2026-05-30 07:23:33 +08:00
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
2026-06-05 22:26:08 +08:00
|
|
|
|
包含 stat_type、list、cache_state 等信息的字典。
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
stat_type = self._normalize_stat_type(filters.get("stat_type"))
|
|
|
|
|
|
parsed_filters = self._parse_filters(filters, stat_type)
|
|
|
|
|
|
|
|
|
|
|
|
if session is not None:
|
|
|
|
|
|
try:
|
2026-06-05 22:26:08 +08:00
|
|
|
|
# 尝试读取缓存(仅简单查询时使用缓存)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
rows = self.repository.list_performance_rows(session, parsed_filters)
|
2026-06-05 22:26:08 +08:00
|
|
|
|
report_list = self._build_report_list(rows, stat_type)
|
|
|
|
|
|
|
|
|
|
|
|
if use_cache:
|
|
|
|
|
|
self._save_cache(session, stat_type, report_list)
|
|
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"stat_type": stat_type,
|
2026-06-05 22:26:08 +08:00
|
|
|
|
"list": report_list,
|
|
|
|
|
|
"cache_state": "实时生成",
|
|
|
|
|
|
"cache_expired": False,
|
2026-05-15 11:10:44 +08:00
|
|
|
|
}
|
2026-05-29 11:38:53 +08:00
|
|
|
|
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)
|
2026-05-15 11:10:44 +08:00
|
|
|
|
|
|
|
|
|
|
def export_performance_report(self, filters: dict, session: Session | None = None) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""导出业绩统计报表为文件(CSV 或 XLSX)。
|
|
|
|
|
|
|
|
|
|
|
|
先查询报表数据,再调用 ExportService 生成文件,最后上传到存储服务并记录附件和审计日志。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
filters: 筛选条件字典,额外需要 export_format 字段。
|
|
|
|
|
|
session: 数据库会话,不可为 None。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 file_url、file_name、object_key 等文件信息及报表元数据的字典。
|
|
|
|
|
|
|
|
|
|
|
|
被调用方:reports 路由(报表导出接口)。
|
|
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
report = self.performance_report(filters, session)
|
2026-05-15 13:44:11 +08:00
|
|
|
|
export_format = self._normalize_export_format(filters.get("export_format"))
|
|
|
|
|
|
export_meta = export_service.build_performance_export(report, export_format)
|
2026-05-15 13:17:45 +08:00
|
|
|
|
publish_meta = storage_service.publish_local_file(export_meta["file_path"], export_meta["object_key"])
|
2026-05-15 11:47:29 +08:00
|
|
|
|
attachment_payload = {
|
|
|
|
|
|
"biz_type": "performance_report",
|
|
|
|
|
|
"biz_id": int(datetime.now().strftime("%Y%m%d%H%M%S")),
|
|
|
|
|
|
"file_name": export_meta["file_name"],
|
2026-05-15 13:17:45 +08:00
|
|
|
|
"file_url": publish_meta["file_url"],
|
2026-05-15 11:47:29 +08:00
|
|
|
|
"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 {
|
2026-05-15 13:17:45 +08:00
|
|
|
|
"file_url": publish_meta["file_url"],
|
2026-05-15 11:47:29 +08:00
|
|
|
|
"file_name": export_meta["file_name"],
|
|
|
|
|
|
"attachment_id": 0,
|
2026-05-15 13:17:45 +08:00
|
|
|
|
"object_key": publish_meta["object_key"],
|
2026-05-15 11:47:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"],
|
2026-05-15 13:44:11 +08:00
|
|
|
|
"export_format": export_format,
|
2026-05-15 11:47:29 +08:00
|
|
|
|
"total_periods": len(report["list"]),
|
|
|
|
|
|
},
|
|
|
|
|
|
"remark": "导出业绩统计报表",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
2026-05-14 13:51:06 +08:00
|
|
|
|
return {
|
2026-05-15 11:47:29 +08:00
|
|
|
|
"file_url": attachment_result["file_url"],
|
|
|
|
|
|
"file_name": export_meta["file_name"],
|
2026-05-15 13:17:45 +08:00
|
|
|
|
"object_key": publish_meta["object_key"],
|
|
|
|
|
|
"storage_provider": publish_meta["storage_provider"],
|
|
|
|
|
|
"bucket_name": publish_meta["bucket_name"],
|
2026-05-15 11:10:44 +08:00
|
|
|
|
"stat_type": report["stat_type"],
|
2026-05-15 13:44:11 +08:00
|
|
|
|
"export_format": export_format,
|
2026-05-15 11:10:44 +08:00
|
|
|
|
"total_periods": len(report["list"]),
|
|
|
|
|
|
"filters": {
|
|
|
|
|
|
"start_date": filters.get("start_date"),
|
|
|
|
|
|
"end_date": filters.get("end_date"),
|
|
|
|
|
|
"category_id": filters.get("category_id"),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 22:26:08 +08:00
|
|
|
|
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 小时视为过期。
|
|
|
|
|
|
"""
|
2026-06-14 16:20:04 +08:00
|
|
|
|
import json
|
2026-06-05 22:26:08 +08:00
|
|
|
|
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),
|
2026-06-14 16:20:04 +08:00
|
|
|
|
"category_amounts": json.loads(r.category_amounts_json) if r.category_amounts_json else [],
|
2026-06-05 22:26:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
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:
|
|
|
|
|
|
"""将报表结果写入缓存表(先清旧数据再写入)。"""
|
2026-06-14 16:20:04 +08:00
|
|
|
|
import json
|
2026-06-05 22:26:08 +08:00
|
|
|
|
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),
|
2026-06-14 16:20:04 +08:00
|
|
|
|
category_amounts_json=json.dumps(item.get("category_amounts", []), ensure_ascii=False),
|
2026-06-05 22:26:08 +08:00
|
|
|
|
)
|
|
|
|
|
|
session.add(cache)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
def _normalize_stat_type(self, stat_type: str | None) -> str:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验并归一化统计类型参数。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
stat_type: 原始统计类型值,支持 'month'、'quarter'、'year',默认 'month'。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
归一化后的统计类型字符串。
|
|
|
|
|
|
|
|
|
|
|
|
异常:
|
|
|
|
|
|
参数不合法时抛出 AppException(PARAM_ERROR)。
|
|
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-15 13:44:11 +08:00
|
|
|
|
def _normalize_export_format(self, export_format: str | None) -> str:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""校验并归一化导出格式参数。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
export_format: 原始导出格式值,支持 'csv'、'xlsx',默认 'csv'。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
归一化后的导出格式字符串。
|
|
|
|
|
|
"""
|
2026-05-15 13:44:11 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
def _parse_filters(self, filters: dict, stat_type: str) -> dict:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""解析前端传入的筛选条件为内部可用的过滤参数。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
filters: 原始筛选字典。
|
|
|
|
|
|
stat_type: 已归一化的统计类型。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
包含 stat_type、start_date、end_date、category_id、exclude_ecommerce 的字典。
|
|
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
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"),
|
2026-05-26 11:36:57 +08:00
|
|
|
|
"exclude_ecommerce": bool(filters.get("exclude_ecommerce")),
|
2026-06-05 22:26:08 +08:00
|
|
|
|
"salesman_id": filters.get("salesman_id"),
|
2026-05-15 11:10:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将日期字符串解析为 datetime 对象。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
value: 日期字符串,格式为 'YYYY-MM-DD',空值时返回 None。
|
|
|
|
|
|
error_message: 解析失败时的错误提示信息。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
datetime 对象或 None。
|
|
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
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]:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将原始订单行数据按统计周期分组聚合,生成报表列表。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
rows: 数据库查询返回的订单行列表。
|
|
|
|
|
|
stat_type: 统计类型(month/quarter/year),决定周期分组方式。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
按时间顺序排列的统计周期汇总列表,每个元素包含 order_count、order_amount、
|
|
|
|
|
|
category_amounts、commission_amount 等。
|
|
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
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": {},
|
2026-06-05 22:26:08 +08:00
|
|
|
|
"sale_price_by_order": {},
|
|
|
|
|
|
"cost_price_by_order": {},
|
|
|
|
|
|
"profit_by_order": {},
|
2026-05-15 11:10:44 +08:00
|
|
|
|
"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)
|
2026-06-05 22:26:08 +08:00
|
|
|
|
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)
|
2026-05-15 11:10:44 +08:00
|
|
|
|
|
|
|
|
|
|
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"]),
|
|
|
|
|
|
)
|
2026-06-05 22:26:08 +08:00
|
|
|
|
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())
|
2026-05-15 11:10:44 +08:00
|
|
|
|
result.append(
|
2026-05-14 13:51:06 +08:00
|
|
|
|
{
|
2026-05-15 11:10:44 +08:00
|
|
|
|
"stat_period": stat_period,
|
|
|
|
|
|
"order_count": len(period_data["order_ids"]),
|
|
|
|
|
|
"order_amount": round(period_data["order_amount"], 2),
|
|
|
|
|
|
"category_amounts": category_amounts,
|
2026-06-05 22:26:08 +08:00
|
|
|
|
"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),
|
2026-05-14 13:51:06 +08:00
|
|
|
|
}
|
2026-05-15 11:10:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
return result
|
2026-05-14 13:51:06 +08:00
|
|
|
|
|
2026-05-15 11:10:44 +08:00
|
|
|
|
def _format_stat_period(self, dt: datetime, stat_type: str) -> str:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将日期按统计类型格式化为统计周期标识字符串。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
dt: 日期时间对象。
|
|
|
|
|
|
stat_type: 统计类型,'year' 返回 'YYYY','quarter' 返回 'YYYY-QN','month' 返回 'YYYY-MM'。
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
统计周期标识字符串。
|
|
|
|
|
|
"""
|
2026-05-15 11:10:44 +08:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-05-14 13:51:06 +08:00
|
|
|
|
|
|
|
|
|
|
report_service = ReportService()
|