fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- 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
This commit is contained in:
@@ -7,8 +7,10 @@ NUMERIC(14,2) in the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.accounting_engine import create_manual_entry
|
||||
@@ -696,7 +698,9 @@ def aging_summary():
|
||||
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
||||
s.id as sale_id, s.total, s.created_at,
|
||||
s.id as sale_id,
|
||||
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
|
||||
s.created_at,
|
||||
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||
FROM customers c
|
||||
JOIN sales s ON s.customer_id = c.id
|
||||
@@ -709,6 +713,11 @@ def aging_summary():
|
||||
customers = {}
|
||||
for r in cur.fetchall():
|
||||
cust_id = r[0]
|
||||
balance = round(float(r[6]) if r[6] else 0, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
days = r[8] or 0
|
||||
|
||||
if cust_id not in customers:
|
||||
customers[cust_id] = {
|
||||
'id': r[0], 'name': r[1], 'rfc': r[2],
|
||||
@@ -718,24 +727,22 @@ def aging_summary():
|
||||
'total': 0,
|
||||
}
|
||||
|
||||
amount = float(r[6]) if r[6] else 0
|
||||
days = r[8] or 0
|
||||
|
||||
if days <= 0:
|
||||
customers[cust_id]['corriente'] += amount
|
||||
customers[cust_id]['corriente'] += balance
|
||||
elif days <= 30:
|
||||
customers[cust_id]['d1_30'] += amount
|
||||
customers[cust_id]['d1_30'] += balance
|
||||
elif days <= 60:
|
||||
customers[cust_id]['d31_60'] += amount
|
||||
customers[cust_id]['d31_60'] += balance
|
||||
elif days <= 90:
|
||||
customers[cust_id]['d61_90'] += amount
|
||||
customers[cust_id]['d61_90'] += balance
|
||||
else:
|
||||
customers[cust_id]['d90_plus'] += amount
|
||||
customers[cust_id]['d90_plus'] += balance
|
||||
|
||||
customers[cust_id]['total'] += amount
|
||||
customers[cust_id]['total'] += balance
|
||||
|
||||
result = list(customers.values())
|
||||
for c in result:
|
||||
c['credit_balance'] = round(c['total'], 2)
|
||||
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
||||
c[key] = round(c[key], 2)
|
||||
|
||||
@@ -754,6 +761,161 @@ def aging_summary():
|
||||
return jsonify({'data': result, 'totals': totals})
|
||||
|
||||
|
||||
@accounting_bp.route('/aging/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_aging():
|
||||
"""Export receivables or payables to CSV/PDF."""
|
||||
report_type = request.args.get('type', 'receivable')
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
if report_type == 'payable':
|
||||
# Payables always CSV for now
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
|
||||
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
|
||||
cur.execute("""
|
||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
||||
po.status, s.name
|
||||
FROM purchase_orders po
|
||||
JOIN suppliers s ON s.id = po.supplier_id
|
||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
||||
ORDER BY po.created_at DESC
|
||||
""")
|
||||
for r in cur.fetchall():
|
||||
po_id = r[0]
|
||||
invoice = r[1] or f'OC-{po_id}'
|
||||
total = float(r[2]) if r[2] else 0
|
||||
created_at = r[3]
|
||||
expected = r[4]
|
||||
status = r[5]
|
||||
vendor = r[6]
|
||||
paid = 0
|
||||
balance = round(total - paid, 2)
|
||||
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
|
||||
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
|
||||
writer.writerow([
|
||||
invoice, vendor,
|
||||
created_at.strftime('%Y-%m-%d') if created_at else '',
|
||||
expected.strftime('%Y-%m-%d') if expected else '',
|
||||
total, paid, balance, days_overdue, status_label
|
||||
])
|
||||
cur.close(); conn.close()
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
|
||||
)
|
||||
|
||||
# Receivables: CSV or PDF
|
||||
cur.execute("""
|
||||
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
|
||||
c.name,
|
||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
||||
FROM sales s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
ORDER BY s.created_at DESC
|
||||
""")
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
sale_id = r[0]
|
||||
subtotal = float(r[1]) if r[1] else 0
|
||||
tax = float(r[2]) if r[2] else 0
|
||||
total = float(r[3]) if r[3] else 0
|
||||
created_at = r[4]
|
||||
customer_name = r[5]
|
||||
payments_total = float(r[6]) if r[6] else 0
|
||||
balance = round(total - payments_total, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
rows.append({
|
||||
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
|
||||
'customer': customer_name,
|
||||
'subtotal': subtotal,
|
||||
'tax': tax,
|
||||
'total': total,
|
||||
'balance': balance,
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
if fmt == 'pdf':
|
||||
from fpdf import FPDF
|
||||
|
||||
class ReceivablesPDF(FPDF):
|
||||
def header(self):
|
||||
self.set_font('Arial', 'B', 14)
|
||||
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
|
||||
self.set_font('Arial', '', 10)
|
||||
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
|
||||
self.ln(4)
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
self.set_font('Arial', 'I', 8)
|
||||
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
|
||||
|
||||
pdf = ReceivablesPDF('L', 'mm', 'A4')
|
||||
pdf.add_page()
|
||||
pdf.set_font('Arial', 'B', 10)
|
||||
pdf.set_fill_color(230, 230, 230)
|
||||
|
||||
col_widths = [30, 95, 35, 30, 35, 35]
|
||||
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
|
||||
for i, h in enumerate(headers):
|
||||
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
|
||||
pdf.ln()
|
||||
|
||||
pdf.set_font('Arial', '', 9)
|
||||
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
|
||||
for row in rows:
|
||||
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
|
||||
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
|
||||
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
|
||||
totals['subtotal'] += row['subtotal']
|
||||
totals['tax'] += row['tax']
|
||||
totals['total'] += row['total']
|
||||
totals['balance'] += row['balance']
|
||||
|
||||
pdf.set_font('Arial', 'B', 9)
|
||||
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
|
||||
|
||||
pdf_bytes = bytes(pdf.output(dest='S'))
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
|
||||
)
|
||||
|
||||
# CSV fallback
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
|
||||
for row in rows:
|
||||
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
|
||||
)
|
||||
|
||||
|
||||
# ─── Fiscal Periods ────────────────────────────────
|
||||
|
||||
@accounting_bp.route('/periods', methods=['GET'])
|
||||
@@ -860,7 +1022,7 @@ def close_period():
|
||||
|
||||
|
||||
@accounting_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('accounting.read')
|
||||
@require_auth('accounting.view')
|
||||
def api_accounting_stats():
|
||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
Reference in New Issue
Block a user