"""Dashboard Stats Blueprint — In-app real-time analytics. Endpoints for sales, productivity, and top products charts. """ from flask import Blueprint, request, jsonify, g from functools import wraps from datetime import datetime, timedelta from decimal import Decimal import json dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api/dashboard') from middleware import require_auth from tenant_db import get_tenant_conn class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return float(o) return super().default(o) @dashboard_stats_bp.route('/stats', methods=['GET']) @require_auth() def get_stats(): """Summary stats for today and this month.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() today = datetime.utcnow().date() month_start = today.replace(day=1) try: # Sales today cur.execute( """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total FROM sales WHERE DATE(created_at) = %s""", (today,) ) today_sales = cur.fetchone() # Sales this month cur.execute( """SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total FROM sales WHERE DATE(created_at) >= %s""", (month_start,) ) month_sales = cur.fetchone() # Top 5 products today cur.execute( """SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue FROM sale_items si JOIN sales s ON si.sale_id = s.id WHERE DATE(s.created_at) = %s GROUP BY si.name ORDER BY revenue DESC LIMIT 5""", (today,) ) top_products = cur.fetchall() # Hourly sales today (0-23) cur.execute( """SELECT EXTRACT(HOUR FROM created_at)::int as hour, COUNT(*) as count, COALESCE(SUM(total), 0) as total FROM sales WHERE DATE(created_at) = %s GROUP BY hour ORDER BY hour""", (today,) ) hourly = cur.fetchall() hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly} return jsonify({ 'today': { 'sales_count': today_sales[0], 'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0, }, 'month': { 'sales_count': month_sales[0], 'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0, }, 'top_products': [ {'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0} for row in top_products ], 'hourly_sales': [ {'hour': h, 'count': hourly_map.get(h, {}).get('count', 0), 'total': float(hourly_map.get(h, {}).get('total', 0))} for h in range(24) ], }) finally: cur.close() conn.close() @dashboard_stats_bp.route('/stats/employees', methods=['GET']) @require_auth() def get_employee_stats(): """Sales per employee today.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() today = datetime.utcnow().date() try: cur.execute( """SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total FROM sales s JOIN employees e ON s.employee_id = e.id WHERE DATE(s.created_at) = %s GROUP BY e.name ORDER BY total DESC""", (today,) ) rows = cur.fetchall() return jsonify({ 'employees': [ {'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0} for row in rows ] }) finally: cur.close() conn.close() @dashboard_stats_bp.route('/credit-alerts', methods=['GET']) @require_auth() def credit_alerts(): """Credit sales that are overdue or due within the next 7 days.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: cur.execute(""" SELECT s.id, c.name as customer_name, s.total, s.created_at, s.created_at + INTERVAL '30 days' as due_date, COALESCE(SUM(sp.amount), 0) as paid FROM sales s JOIN customers c ON c.id = s.customer_id LEFT JOIN sale_payments sp ON sp.sale_id = s.id WHERE s.sale_type = 'credit' AND s.status = 'completed' GROUP BY s.id, c.name, s.total, s.created_at HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0 AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days' ORDER BY due_date LIMIT 50 """) rows = [] now = datetime.utcnow() for r in cur.fetchall(): sale_id = r[0] customer_name = r[1] total = float(r[2]) if r[2] else 0 paid = float(r[5]) if r[5] else 0 balance = round(total - paid, 2) created_at = r[3] due_date = r[4] days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None if days_until_due is None: continue status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current') label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente') rows.append({ 'sale_id': sale_id, 'folio': f'VTA-{sale_id}', 'customer_name': customer_name, 'issue_date': created_at.isoformat() if created_at else None, 'due_date': due_date.isoformat() if due_date else None, 'days_until_due': days_until_due, 'total': total, 'paid': paid, 'balance': balance, 'status': status, 'status_label': label, }) return jsonify({ 'data': rows, 'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'), 'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'), }) finally: cur.close() conn.close()