- Arregla @require_auth, permisos, race conditions, locks de caja/stock - Elimina N+1 en layaway, flotilla, dashboard y global_invoice - Asegura folios atomicos para CFDI, ordenes de servicio y polizas - Protege client_secret de MercadoLibre en backend - Conecta botones/filtros de config, customers, accounting e invoicing - Mejora accesibilidad (labels/aria-label) y estados de carga/vacio - Limpia accounting.js obsoleto y consolida accounting.v9.js - Actualiza cache busting a v32 y Service Worker a v32 - Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md Tests: 35 passed
188 lines
6.4 KiB
Python
188 lines
6.4 KiB
Python
"""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()
|