diff --git a/app/api/endpoints/statistics.py b/app/api/endpoints/statistics.py new file mode 100644 index 0000000..289ea33 --- /dev/null +++ b/app/api/endpoints/statistics.py @@ -0,0 +1,176 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime, date, timedelta +from app.models.database import get_db +from app.models.statistics import DailyOrderStats, DailyCommunityOrderStats +from pydantic import BaseModel +from app.api.deps import get_current_user +from enum import Enum + +router = APIRouter() + +class DailyOrderStatsResponse(BaseModel): + stats_date: date + total_order_count: int + total_original_amount: float + total_final_amount: float + total_communities: int + + class Config: + from_attributes = True + +class DailyCommunityOrderStatsResponse(BaseModel): + stats_date: date + community_id: int + community_name: str + order_count: int + total_original_amount: float + total_final_amount: float + + class Config: + from_attributes = True + +class OrderFilter(str, Enum): + ALL = "all" # 所有小区 + WITH_ORDERS = "with_orders" # 有订单的小区 + WITHOUT_ORDERS = "without_orders" # 没有订单的小区 + +@router.get("/daily-stats", response_model=List[DailyOrderStatsResponse]) +async def get_daily_order_stats( + start_date: Optional[date] = None, + end_date: Optional[date] = None, + limit: int = Query(30, ge=1, le=100), + db: Session = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + 获取每日订单统计数据 + + - **start_date**: 开始日期(可选) + - **end_date**: 结束日期(可选) + - **limit**: 返回记录数量限制,默认30条 + """ + query = db.query(DailyOrderStats) + + if start_date: + query = query.filter(DailyOrderStats.stats_date >= start_date) + if end_date: + query = query.filter(DailyOrderStats.stats_date <= end_date) + + # 按日期降序排序,最新的在前面 + query = query.order_by(DailyOrderStats.stats_date.desc()).limit(limit) + + return query.all() + +@router.get("/daily-stats/{stats_date}", response_model=DailyOrderStatsResponse) +async def get_daily_order_stats_by_date( + stats_date: date, + db: Session = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + 获取指定日期的订单统计数据 + + - **stats_date**: 统计日期 + """ + stats = db.query(DailyOrderStats).filter( + DailyOrderStats.stats_date == stats_date + ).first() + + if not stats: + raise HTTPException(status_code=404, detail=f"未找到{stats_date}的统计数据") + + return stats + +@router.get("/community-stats", response_model=List[DailyCommunityOrderStatsResponse]) +async def get_community_order_stats( + stats_date: Optional[date] = None, + community_id: Optional[int] = None, + filter_type: OrderFilter = OrderFilter.ALL, + limit: int = Query(50, ge=1, le=500), + db: Session = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + 获取小区订单统计数据 + + - **stats_date**: 统计日期(可选) + - **community_id**: 小区ID(可选) + - **filter_type**: 过滤类型(all: 所有小区, with_orders: 有订单的小区, without_orders: 没有订单的小区) + - **limit**: 返回记录数量限制,默认50条 + """ + query = db.query(DailyCommunityOrderStats) + + if stats_date: + query = query.filter(DailyCommunityOrderStats.stats_date == stats_date) + if community_id: + query = query.filter(DailyCommunityOrderStats.community_id == community_id) + + # 根据过滤类型筛选 + if filter_type == OrderFilter.WITH_ORDERS: + query = query.filter(DailyCommunityOrderStats.order_count > 0) + elif filter_type == OrderFilter.WITHOUT_ORDERS: + query = query.filter(DailyCommunityOrderStats.order_count == 0) + + # 如果没有指定日期,则按日期降序排序 + if not stats_date: + query = query.order_by(DailyCommunityOrderStats.stats_date.desc()) + + # 按订单数量降序排序 + if filter_type != OrderFilter.WITHOUT_ORDERS: + query = query.order_by(DailyCommunityOrderStats.order_count.desc()) + else: + # 对于无订单的小区,按小区名称排序 + query = query.order_by(DailyCommunityOrderStats.community_name) + + query = query.limit(limit) + + return query.all() + +@router.get("/community-stats/{community_id}/trend", response_model=List[DailyCommunityOrderStatsResponse]) +async def get_community_order_stats_trend( + community_id: int, + days: int = Query(7, ge=1, le=30), + db: Session = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + 获取指定小区的订单统计趋势数据 + + - **community_id**: 小区ID + - **days**: 天数,默认7天 + """ + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + stats = db.query(DailyCommunityOrderStats).filter( + DailyCommunityOrderStats.community_id == community_id, + DailyCommunityOrderStats.stats_date >= start_date, + DailyCommunityOrderStats.stats_date <= end_date + ).order_by(DailyCommunityOrderStats.stats_date.asc()).all() + + return stats + +@router.get("/community-stats/date/{stats_date}/summary", response_model=List[DailyCommunityOrderStatsResponse]) +async def get_community_stats_summary_by_date( + stats_date: date, + top_n: int = Query(10, ge=1, le=100), + db: Session = Depends(get_db), + current_user = Depends(get_current_user) +): + """ + 获取指定日期的小区订单统计摘要(前N名) + + - **stats_date**: 统计日期 + - **top_n**: 返回前N名小区,默认10 + """ + stats = db.query(DailyCommunityOrderStats).filter( + DailyCommunityOrderStats.stats_date == stats_date, + DailyCommunityOrderStats.order_count > 0 + ).order_by(DailyCommunityOrderStats.order_count.desc()).limit(top_n).all() + + if not stats: + raise HTTPException(status_code=404, detail=f"未找到{stats_date}的小区统计数据") + + return stats \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 664f259..946a9da 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -12,6 +12,8 @@ class Settings(BaseSettings): # 企业微信机器人系统异常通知 URL_WECOMBOT_SYS_EXCEPTION : str = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=edcc54c5-c352-42dd-b9be-0cc5b39cc0dc" + # 企业微信机器人每日报告通知 + URL_WECOMBOT_DAILY_REPORT : str = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=6869b6e2-57fc-471a-bb62-028014e2b1c8" # 积分别名 POINT_ALIAS: str = "蜂蜜" diff --git a/app/main.py b/app/main.py index e386ac4..1366472 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.endpoints import wechat,user, address, community, station, order, coupon, community_building, upload, merchant, merchant_product, merchant_order, point, config, merchant_category, log, account,merchant_pay_order, message, bank_card, withdraw, mp, point_product, point_product_order, coupon_activity, dashboard, wecom, feedback, timeperiod, community_timeperiod, order_additional_fee, ai, community_set, community_set_mapping, community_profit_sharing, partner, health, scheduler +from app.api.endpoints import wechat,user, address, community, station, order, coupon, community_building, upload, merchant, merchant_product, merchant_order, point, config, merchant_category, log, account,merchant_pay_order, message, bank_card, withdraw, mp, point_product, point_product_order, coupon_activity, dashboard, wecom, feedback, timeperiod, community_timeperiod, order_additional_fee, ai, community_set, community_set_mapping, community_profit_sharing, partner, health, scheduler, statistics from app.models.database import Base, engine from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -104,6 +104,7 @@ app.include_router(config.router, prefix="/api/config", tags=["系统配置"]) app.include_router(log.router, prefix="/api/logs", tags=["系统日志"]) app.include_router(feedback.router, prefix="/api/feedback", tags=["反馈"]) app.include_router(health.router, prefix="/api/health", tags=["系统健康检查"]) +app.include_router(statistics.router, prefix="/api/statistics", tags=["统计数据"]) @app.get("/") async def root(): diff --git a/app/models/statistics.py b/app/models/statistics.py new file mode 100644 index 0000000..69c1394 --- /dev/null +++ b/app/models/statistics.py @@ -0,0 +1,52 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, Date, ForeignKey +from sqlalchemy.sql import func +from app.models.database import Base +from datetime import datetime + +class DailyCommunityOrderStats(Base): + """每日小区订单统计数据表""" + __tablename__ = "daily_community_order_stats" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # 统计日期 + stats_date = Column(Date, nullable=False, index=True) + + # 小区信息 + community_id = Column(Integer, ForeignKey("communities.id"), nullable=False, index=True) + community_name = Column(String(100), nullable=False) + + # 订单统计 + order_count = Column(Integer, nullable=False, default=0) # 订单数量 + total_original_amount = Column(Float, nullable=False, default=0) # 总订单金额 + total_final_amount = Column(Float, nullable=False, default=0) # 总支付金额 + + # 元数据 + create_time = Column(DateTime(timezone=True), server_default=func.now()) + update_time = Column(DateTime(timezone=True), onupdate=func.now()) + + class Config: + from_attributes = True + + +class DailyOrderStats(Base): + """每日订单总体统计数据表""" + __tablename__ = "daily_order_stats" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # 统计日期 + stats_date = Column(Date, nullable=False, unique=True, index=True) + + # 订单统计 + total_order_count = Column(Integer, nullable=False, default=0) # 总订单数量 + total_original_amount = Column(Float, nullable=False, default=0) # 总订单金额 + total_final_amount = Column(Float, nullable=False, default=0) # 总支付金额 + total_communities = Column(Integer, nullable=False, default=0) # 有订单的小区数量 + + # 元数据 + create_time = Column(DateTime(timezone=True), server_default=func.now()) + update_time = Column(DateTime(timezone=True), onupdate=func.now()) + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/tasks/daily_tasks.py b/app/tasks/daily_tasks.py index 17ec90a..a203ba7 100644 --- a/app/tasks/daily_tasks.py +++ b/app/tasks/daily_tasks.py @@ -6,38 +6,218 @@ from app.core.scheduler import scheduler from sqlalchemy import text import json import os +from app.core.wecombot import WecomBot +from app.core.config import settings +from app.models.statistics import DailyCommunityOrderStats, DailyOrderStats +from sqlalchemy.exc import IntegrityError +from app.models.community import CommunityDB logger = logging.getLogger(__name__) - - -async def daily_statistics_report(): - """每日统计报告任务""" - logger.info(f"开始生成每日统计报告: {datetime.now()}") +async def daily_community_order_statistics(): + """每日小区订单统计任务 + + 每天早上9点执行,统计每个小区昨日的订单量(已完成)、总订单金额和总支付金额 + """ + logger.info(f"开始执行每日小区订单统计任务: {datetime.now()}") try: + # 计算昨天的日期范围 + yesterday = datetime.now() - timedelta(days=1) + yesterday_start = datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0) + yesterday_end = datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59) + yesterday_date = yesterday.date() + yesterday_str = yesterday.strftime("%Y-%m-%d") + with get_db_context() as db: + # 获取所有小区列表 + all_communities = db.query(CommunityDB).all() + community_dict = {community.id: community.name for community in all_communities} - #获取昨日时间 - yesterday = datetime.now() - timedelta(days=1) - yesterday_start = datetime.combine(yesterday, datetime.min.time()) - yesterday_end = datetime.combine(yesterday, datetime.max.time()) + # 查询每个小区昨日的订单统计 + result = db.execute( + text(""" + SELECT + c.id AS community_id, + c.name AS community_name, + COUNT(o.orderid) AS order_count, + COALESCE(SUM(o.original_amount + o.additional_fee_amount), 0) AS total_original_amount, + COALESCE(SUM(o.final_amount), 0) AS total_final_amount + FROM + shipping_orders o + JOIN + communities c ON o.address_community_id = c.id + WHERE + o.create_time BETWEEN :start_time AND :end_time + AND o.status = 'COMPLETED' + GROUP BY + c.id, c.name + ORDER BY + order_count DESC + """), + { + "start_time": yesterday_start, + "end_time": yesterday_end + } + ) + # 转换结果为字典,以小区ID为键 + community_stats_dict = {} + for row in result: + community_stats_dict[row.community_id] = { + "community_id": row.community_id, + "community_name": row.community_name, + "order_count": row.order_count, + "total_original_amount": float(row.total_original_amount), + "total_final_amount": float(row.total_final_amount) + } + # 确保每个小区都有一条记录 + community_stats = [] + for community_id, community_name in community_dict.items(): + if community_id in community_stats_dict: + # 使用查询结果 + community_stats.append(community_stats_dict[community_id]) + else: + # 创建零值记录 + community_stats.append({ + "community_id": community_id, + "community_name": community_name, + "order_count": 0, + "total_original_amount": 0.0, + "total_final_amount": 0.0 + }) - except Exception as e: - logger.error(f"生成每日统计报告失败: {str(e)}") + # 按订单数量降序排序 + community_stats.sort(key=lambda x: x["order_count"], reverse=True) + + # 计算总计(只计算有订单的小区) + communities_with_orders = [stat for stat in community_stats if stat["order_count"] > 0] + total_order_count = sum(item["order_count"] for item in communities_with_orders) + total_original_amount = sum(item["total_original_amount"] for item in communities_with_orders) + total_final_amount = sum(item["total_final_amount"] for item in communities_with_orders) + total_communities = len(communities_with_orders) + + # 保存到数据库 - 总体统计 + try: + # 检查是否已存在该日期的记录 + existing_stats = db.query(DailyOrderStats).filter( + DailyOrderStats.stats_date == yesterday_date + ).first() + + if existing_stats: + # 更新现有记录 + existing_stats.total_order_count = total_order_count + existing_stats.total_original_amount = total_original_amount + existing_stats.total_final_amount = total_final_amount + existing_stats.total_communities = total_communities + existing_stats.update_time = datetime.now() + else: + # 创建新记录 + daily_stats = DailyOrderStats( + stats_date=yesterday_date, + total_order_count=total_order_count, + total_original_amount=total_original_amount, + total_final_amount=total_final_amount, + total_communities=total_communities + ) + db.add(daily_stats) + + # 保存到数据库 - 小区统计 + # 先删除该日期的所有小区统计记录,然后重新插入 + db.query(DailyCommunityOrderStats).filter( + DailyCommunityOrderStats.stats_date == yesterday_date + ).delete() + + # 批量插入小区统计记录 + for stat in community_stats: + community_stat = DailyCommunityOrderStats( + stats_date=yesterday_date, + community_id=stat["community_id"], + community_name=stat["community_name"], + order_count=stat["order_count"], + total_original_amount=stat["total_original_amount"], + total_final_amount=stat["total_final_amount"] + ) + db.add(community_stat) + + db.commit() + logger.info(f"已将{yesterday_str}的订单统计数据保存到数据库,共{len(community_stats)}个小区") + except IntegrityError as e: + db.rollback() + logger.error(f"保存订单统计数据到数据库失败: {str(e)}") + except Exception as e: + db.rollback() + logger.error(f"保存订单统计数据到数据库失败: {str(e)}") + + # 生成报告消息 + message = f"""## {yesterday_str} 小区订单统计报告 + +### 总计 +- 订单总数: {total_order_count} +- 订单总金额: ¥{total_original_amount:.2f} +- 支付总金额: ¥{total_final_amount:.2f} +- 有订单小区数: {total_communities} +- 总小区数: {len(community_stats)} +### 小区排名 (前5名) +""" + + # 添加前5个小区的数据 + for i, item in enumerate(communities_with_orders[:5], 1): + message += f""" +{i}. **{item['community_name']}** + - 订单数: {item['order_count']} + - 订单金额: ¥{item['total_original_amount']:.2f} + - 支付金额: ¥{item['total_final_amount']:.2f} +""" + + # 发送企业微信消息 + if communities_with_orders and settings.URL_WECOMBOT_DAILY_REPORT: + try: + wecom_bot = WecomBot(settings.URL_WECOMBOT_DAILY_REPORT) + await wecom_bot.send_markdown(message) + logger.info("每日小区订单统计报告已发送到企业微信") + except Exception as e: + logger.error(f"发送企业微信消息失败: {str(e)}") + + logger.info("每日小区订单统计任务完成") + except Exception as e: + logger.error(f"每日小区订单统计任务失败: {str(e)}") + +def run_daily_community_order_statistics(): + """ + 包装异步函数的同步函数,用于APScheduler调度 + """ + try: + # 获取或创建事件循环 + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 运行异步任务 + if loop.is_running(): + # 如果循环已经在运行,使用create_task + future = asyncio.ensure_future(daily_community_order_statistics()) + # 可以添加回调函数处理结果 + future.add_done_callback(lambda f: logger.info("每日小区订单统计任务完成")) + else: + # 如果循环没有运行,直接运行到完成 + loop.run_until_complete(daily_community_order_statistics()) + except Exception as e: + logger.error(f"运行每日小区订单统计任务失败: {str(e)}") def register_daily_tasks(): """注册所有每日定时任务""" - - # 每天早上8点生成统计报告 + + # 每天早上9点执行小区订单统计 scheduler.add_cron_job( - daily_statistics_report, - hour=8, + run_daily_community_order_statistics, # 使用同步包装函数 + hour=3, minute=0, - job_id="daily_stats_report" + job_id="daily_community_order_stats" ) logger.info("已注册所有每日定时任务") \ No newline at end of file diff --git a/jobs.sqlite b/jobs.sqlite index 90423dd..bf61051 100644 Binary files a/jobs.sqlite and b/jobs.sqlite differ diff --git a/reports/community_orders/community_orders_2025-03-10.json b/reports/community_orders/community_orders_2025-03-10.json new file mode 100644 index 0000000..04e940b --- /dev/null +++ b/reports/community_orders/community_orders_2025-03-10.json @@ -0,0 +1,27 @@ +{ + "date": "2025-03-10", + "total_stats": { + "order_count": 2, + "total_original_amount": 11.0, + "total_final_amount": 11.0, + "total_communities": 1, + "all_communities_count": 2 + }, + "community_stats": [ + { + "community_id": 1, + "community_name": "龙光·天悦龙庭", + "order_count": 2, + "total_original_amount": 11.0, + "total_final_amount": 11.0 + }, + { + "community_id": 2, + "community_name": "龙湖·云麓小区", + "order_count": 0, + "total_original_amount": 0.0, + "total_final_amount": 0.0 + } + ], + "generated_at": "2025-03-11T08:30:00.766323" +} \ No newline at end of file