fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-06-29 23:54:58 +00:00
parent 59a4893e84
commit 2bdeb2973a
61 changed files with 2879 additions and 706 deletions

View File

@@ -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)