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)

View File

@@ -151,7 +151,7 @@ def update_branch(branch_id):
@config_bp.route('/employees', methods=['GET'])
@require_auth('config.view')
@require_auth()
def list_employees():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -200,7 +200,7 @@ def create_employee():
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant', 'workshop']
if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
@@ -223,15 +223,22 @@ def create_employee():
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices'],
'config.view', 'config.edit', 'config.edit_prices',
'workshop.view', 'workshop.edit',
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
'catalog.view', 'customers.view', 'customers.create'],
'catalog.view',
'inventory.view', 'inventory.create',
'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view'],
'customers.view',
'fleet.view'],
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items', 'customers.view',
'fleet.view'],
}
for perm in role_permissions.get(data['role'], []):

View File

@@ -30,10 +30,24 @@ def list_customers():
per_page = min(int(request.args.get('per_page', 50)), 200)
search = request.args.get('q', '').strip()
branch_id = request.args.get('branch_id')
price_tier = request.args.get('price_tier', '').strip()
status = request.args.get('status', '').strip().lower()
where_clauses = ["c.is_active = true"]
where_clauses = []
params = []
if status == 'inactive':
where_clauses.append("c.is_active = false")
elif status == 'overdue':
where_clauses.append(
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
)
elif status == 'all':
pass # no is_active filter
else:
# Default to active customers for backwards compatibility
where_clauses.append("c.is_active = true")
if branch_id:
where_clauses.append("c.branch_id = %s")
params.append(int(branch_id))
@@ -42,8 +56,20 @@ def list_customers():
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
)
params.extend([f'%{search}%'] * 4)
if price_tier:
# Support numeric tier or Spanish labels
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
tier_val = tier_map.get(price_tier.lower())
if tier_val is None:
try:
tier_val = int(price_tier)
except ValueError:
tier_val = None
if tier_val in (1, 2, 3):
where_clauses.append("c.price_tier = %s")
params.append(tier_val)
where = " AND ".join(where_clauses)
where = " AND ".join(where_clauses) if where_clauses else "true"
# Count
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
@@ -54,7 +80,7 @@ def list_customers():
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
c.address, c.cp,
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
c.branch_id
c.branch_id, c.is_active, c.created_at, c.last_purchase
FROM customers c
WHERE {where}
ORDER BY c.name
@@ -71,6 +97,9 @@ def list_customers():
'credit_balance': float(r[10]) if r[10] else 0,
'vehicle_info': r[11],
'branch_id': r[12],
'is_active': r[13],
'created_at': str(r[14]) if r[14] else None,
'last_purchase': str(r[15]) if r[15] else None,
})
cur.close()
@@ -472,6 +501,32 @@ def record_customer_payment(customer_id):
UPDATE customers SET credit_balance = %s WHERE id = %s
""", (new_balance, customer_id))
# Allocate the customer payment to oldest unpaid credit sales so the
# aging report and per-sale balances reflect the remaining debt.
remaining = amount
cur.execute("""
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
FROM sales s
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
WHERE s.customer_id = %s
AND s.sale_type = 'credit'
AND s.status = 'completed'
GROUP BY s.id, s.total, s.created_at
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
ORDER BY s.created_at
""", (customer_id,))
for sale_id, balance in cur.fetchall():
if remaining <= 0:
break
pay = round(min(remaining, float(balance)), 2)
cur.execute("""
INSERT INTO sale_payments
(sale_id, register_id, method, amount, reference)
VALUES (%s, %s, %s, %s, %s)
""", (sale_id, register_id, payment_method, pay,
f'Abono cliente #{customer_id}'))
remaining = round(remaining - pay, 2)
# Record cash movement on register if cash payment
if register_id and payment_method == 'efectivo':
cur.execute("""

View File

@@ -118,3 +118,70 @@ def get_employee_stats():
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()

View File

@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
# ─── Vehicles CRUD ─────────────────────────────
@fleet_bp.route('/vehicles', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def list_vehicles():
"""List fleet vehicles with pagination and search.
@@ -83,7 +83,7 @@ def list_vehicles():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def get_vehicle(vehicle_id):
"""Vehicle detail with maintenance schedules and recent logs."""
conn = get_tenant_conn(g.tenant_id)
@@ -155,8 +155,117 @@ def get_vehicle(vehicle_id):
return jsonify(vehicle)
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
@require_auth('fleet.view')
def list_all_schedules():
"""Return all active maintenance schedules joined with vehicle data.
Replaces the N+1 pattern of fetching schedules per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
where_clauses = ["v.is_active = true", "s.is_active = true"]
params = []
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
s.interval_months, s.last_done_at, s.last_done_km,
s.next_due_at, s.next_due_km, s.notes,
v.plate, v.make, v.model, v.current_mileage, v.color
FROM fleet_maintenance_schedules s
JOIN fleet_vehicles v ON v.id = s.vehicle_id
WHERE {where}
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
""", params)
schedules = []
for r in cur.fetchall():
schedules.append({
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
'interval_km': r[3], 'interval_months': r[4],
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
'notes': r[9],
'vehicle': {
'plate': r[10], 'make': r[11], 'model': r[12],
'current_mileage': r[13], 'color': r[14]
}
})
cur.close(); conn.close()
return jsonify({'data': schedules})
@fleet_bp.route('/vehicles/history', methods=['GET'])
@require_auth('fleet.view')
def list_all_history():
"""Return recent maintenance logs for all vehicles joined with vehicle data.
Replaces the N+1 pattern of fetching logs per vehicle.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
limit = min(int(request.args.get('limit', 200)), 500)
where_clauses = ["v.is_active = true"]
params = [limit]
if g.branch_id:
where_clauses.append("v.branch_id = %s")
params.append(g.branch_id)
vehicle_ids = request.args.get('vehicle_ids', '').strip()
if vehicle_ids:
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
if ids:
where_clauses.append("v.id = ANY(%s)")
params.append(ids)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
e.name as employee_name,
v.plate, v.make, v.model, v.color
FROM fleet_maintenance_logs l
JOIN fleet_vehicles v ON v.id = l.vehicle_id
LEFT JOIN employees e ON l.employee_id = e.id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT %s
""", params)
logs = []
for r in cur.fetchall():
logs.append({
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
'maintenance_type': r[3], 'mileage_at': r[4],
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
'employee_name': r[9],
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
})
cur.close(); conn.close()
return jsonify({'data': logs})
@fleet_bp.route('/vehicles', methods=['POST'])
@require_auth()
@require_auth('fleet.create')
def create_vehicle():
"""Create a fleet vehicle.
@@ -201,7 +310,7 @@ def create_vehicle():
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
@require_auth()
@require_auth('fleet.edit')
def update_vehicle(vehicle_id):
"""Update vehicle fields including mileage.
@@ -245,7 +354,7 @@ def update_vehicle(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
@require_auth()
@require_auth('fleet.delete')
def deactivate_vehicle(vehicle_id):
"""Soft-delete: set is_active = false."""
conn = get_tenant_conn(g.tenant_id)
@@ -266,7 +375,7 @@ def deactivate_vehicle(vehicle_id):
# ─── Maintenance Schedules ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def list_schedules(vehicle_id):
"""Maintenance schedules for a vehicle."""
conn = get_tenant_conn(g.tenant_id)
@@ -299,7 +408,7 @@ def list_schedules(vehicle_id):
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
@require_auth()
@require_auth('fleet.create')
def create_schedule(vehicle_id):
"""Create maintenance schedule for a vehicle.
@@ -344,7 +453,7 @@ def create_schedule(vehicle_id):
# ─── Maintenance Logs ─────────────────────────────
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
@require_auth()
@require_auth('fleet.create')
def record_maintenance(vehicle_id):
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
@@ -427,7 +536,7 @@ def record_maintenance(vehicle_id):
# ─── Alerts ─────────────────────────────
@fleet_bp.route('/alerts', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def fleet_alerts():
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
conn = get_tenant_conn(g.tenant_id)
@@ -472,7 +581,7 @@ def fleet_alerts():
# ─── Stats ─────────────────────────────
@fleet_bp.route('/stats', methods=['GET'])
@require_auth()
@require_auth('fleet.view')
def fleet_stats():
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
conn = get_tenant_conn(g.tenant_id)

View File

@@ -485,7 +485,7 @@ def get_sale_pdf(sale_id):
@invoicing_bp.route("/stats", methods=["GET"])
@require_auth("invoicing.read")
@require_auth("invoicing.view")
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)

View File

@@ -24,6 +24,8 @@ Routes:
POST /pos/api/marketplace-ext/webhook/meli
"""
import urllib.parse
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn
@@ -81,6 +83,49 @@ def get_config():
conn.close()
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
@require_auth()
def init_meli_connect():
"""Store client credentials server-side and return the MercadoLibre auth URL.
The frontend no longer keeps the client_secret in localStorage.
"""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
client_secret = data.get("client_secret", "").strip()
category = data.get("category", "").strip()
shipping = data.get("shipping", "").strip()
if not client_id or not client_secret:
return jsonify({"error": "client_id and client_secret required"}), 400
base = _get_public_base_url().rstrip("/")
redirect_uri = f"{base}/pos/marketplace-external/callback"
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_client_id": client_id,
"meli_client_secret": client_secret,
"meli_default_category_id": category,
"meli_shipping_mode": shipping,
})
finally:
conn.close()
auth_url = (
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
f"&client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
"&scope=read+write+offline_access"
)
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
@marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth()
def connect_meli():
@@ -90,12 +135,21 @@ def connect_meli():
data = request.get_json() or {}
code = data.get("code")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
redirect_uri = data.get("redirect_uri", "")
if not code or not client_id or not client_secret:
return jsonify({"error": "code, client_id and client_secret required"}), 400
if not code:
return jsonify({"error": "code required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
client_id = data.get("client_id") or cfg.get("meli_client_id")
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
finally:
conn.close()
if not client_id or not client_secret:
return jsonify({"error": "ML credentials not configured"}), 400
try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)

View File

@@ -232,6 +232,75 @@ def list_sales():
})
@pos_bp.route('/sales/recent', methods=['GET'])
@require_auth('pos.view')
def recent_sales():
"""Return recent sales with their items in a single response.
Query params:
date_from: YYYY-MM-DD (defaults to today)
date_to: YYYY-MM-DD (defaults to today)
limit: int (default 10, max 50)
"""
from datetime import date
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
date_from = request.args.get('date_from') or str(date.today())
date_to = request.args.get('date_to') or date_from
limit = min(int(request.args.get('limit', 10)), 50)
where_clauses = [
"s.created_at >= %s",
"s.created_at < %s::date + interval '1 day'",
"s.status != 'cancelled'"
]
params = [date_from, date_to]
if g.branch_id:
where_clauses.append("s.branch_id = %s")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT s.id, s.customer_id, s.payment_method, s.total, s.status, s.created_at,
c.name as customer_name
FROM sales s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY s.created_at DESC
LIMIT %s
""", params + [limit])
sales = []
sale_ids = []
for r in cur.fetchall():
sale_ids.append(r[0])
sales.append({
'id': r[0], 'customer_id': r[1], 'payment_method': r[2],
'total': float(r[3]) if r[3] else 0, 'status': r[4],
'created_at': str(r[5]), 'customer_name': r[6],
'items': []
})
if sale_ids:
cur.execute("""
SELECT sale_id, name, quantity
FROM sale_items
WHERE sale_id = ANY(%s)
ORDER BY sale_id, id
""", (sale_ids,))
for r in cur.fetchall():
for sale in sales:
if sale['id'] == r[0]:
sale['items'].append({'name': r[1], 'quantity': r[2]})
break
cur.close(); conn.close()
return jsonify({'data': sales})
@pos_bp.route('/historical-sales', methods=['GET'])
@require_auth('pos.view')
def list_historical_sales():
@@ -310,9 +379,13 @@ def list_historical_sales():
@pos_bp.route('/sales/<int:sale_id>', methods=['GET'])
@require_auth('pos.view')
@require_auth()
def get_sale(sale_id):
"""Get sale detail with items."""
# Allow POS users or accounting users to view receivable/sale detail.
if g.employee_role != 'owner' and not ({'pos.view', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
@@ -378,12 +451,17 @@ def get_sale(sale_id):
@pos_bp.route('/sales/<int:sale_id>/cancel', methods=['PUT'])
@require_auth('pos.sell')
@require_auth()
def api_cancel_sale(sale_id):
"""Cancel a sale. Requires mandatory reason.
Body: {reason: str}
"""
# Allow POS sellers or accounting staff to cancel tickets from the
# receivables / accounting view.
if g.employee_role != 'owner' and not ({'pos.sell', 'accounting.view'} & g.permissions):
return jsonify({'error': 'Missing permissions'}), 403
data = request.get_json() or {}
reason = data.get('reason', '').strip()
@@ -682,9 +760,23 @@ def list_quotations():
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
@require_auth('pos.sell')
def delete_quotation(quot_id):
"""Delete a quotation and its items."""
"""Delete a quotation, release its stock reservations and remove its items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Release reserved stock before deleting items
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
# Continue with deletion even if release fails (e.g. no reservations)
pass
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
deleted = cur.rowcount
@@ -986,6 +1078,8 @@ def patch_quotation(quot_id):
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
old_status = row[1]
fields = []
params = []
if 'customer_id' in data:
@@ -997,9 +1091,10 @@ def patch_quotation(quot_id):
if 'valid_until' in data:
fields.append('valid_until = %s')
params.append(data['valid_until'])
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
new_status = data.get('status')
if new_status and new_status in ('active', 'cancelled', 'expired'):
fields.append('status = %s')
params.append(data['status'])
params.append(new_status)
if not fields:
cur.close(); conn.close()
@@ -1007,6 +1102,20 @@ def patch_quotation(quot_id):
params.append(quot_id)
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
# Release reservations when cancelling or expiring
if new_status in ('cancelled', 'expired') and old_status not in ('cancelled', 'expired', 'converted'):
from services.quote_reservation import (
release_quotation_reservation,
get_quotation_items_for_reservation
)
try:
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
if reservation_items:
release_quotation_reservation(conn, quot_id, reservation_items, employee_id=g.employee_id)
except Exception:
pass
conn.commit()
cur.close(); conn.close()
return jsonify({'message': 'Quotation updated'})
@@ -1911,10 +2020,14 @@ def complete_layaway(layaway_id):
# Create sale_items (no inventory deduction — already reserved)
sale_items = []
inv_ids = [item['inventory_id'] for item in totals_calc['items']]
cur.execute("""
SELECT id, part_number, name, cost FROM inventory WHERE id = ANY(%s)
""", (inv_ids,))
inv_map = {r[0]: (r[1], r[2], r[3]) for r in cur.fetchall()}
for item in totals_calc['items']:
cur.execute("SELECT part_number, name, cost FROM inventory WHERE id = %s",
(item['inventory_id'],))
inv = cur.fetchone()
inv = inv_map.get(item['inventory_id'], ('', '', 0))
cur.execute("""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity,
@@ -1923,9 +2036,9 @@ def complete_layaway(layaway_id):
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
sale_id, item['inventory_id'],
inv[0] if inv else '', inv[1] if inv else '',
inv[0] or '', inv[1] or '',
item['quantity'], item['unit_price'],
float(inv[2]) if inv and inv[2] else 0,
float(inv[2]) if inv[2] else 0,
item['discount_pct'], item['discount_amount'],
item['tax_rate'], item['tax_amount'], item['subtotal']
))
@@ -2067,7 +2180,7 @@ def create_return():
try:
# Validate sale exists and is completed
cur.execute("""
SELECT id, customer_id, total, status, branch_id
SELECT id, customer_id, total, status, branch_id, sale_type
FROM sales WHERE id = %s
""", (sale_id,))
sale = cur.fetchone()
@@ -2078,6 +2191,7 @@ def create_return():
sale_customer_id = sale[1]
sale_branch_id = sale[4] or g.branch_id
sale_type = sale[5]
# Validate each return item against original sale items
total_refund = 0
@@ -2179,10 +2293,10 @@ def create_return():
new_status = 'returned' if returned_total >= sold_total else 'partially_returned'
cur.execute("UPDATE sales SET status = %s WHERE id = %s", (new_status, sale_id))
# Update customer credit if applicable
if sale_customer_id:
# Update customer credit if the original sale was on credit
if sale_customer_id and sale_type == 'credit':
cur.execute("""
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) + %s
UPDATE customers SET credit_balance = COALESCE(credit_balance, 0) - %s
WHERE id = %s
""", (total_refund, sale_customer_id))

View File

@@ -33,12 +33,15 @@ service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/se
@service_order_bp.route('', methods=['GET'])
@require_auth()
@require_auth('workshop.view')
def list_orders():
status = request.args.get('status')
priority = request.args.get('priority')
customer_id = request.args.get('customer_id', type=int)
employee_id = request.args.get('employee_id', type=int)
delivery_method = request.args.get('delivery_method')
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
q = request.args.get('q')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
@@ -47,15 +50,53 @@ def list_orders():
result = list_service_orders(
conn, status=status, branch_id=g.branch_id,
customer_id=customer_id, priority=priority,
employee_id=employee_id, page=page, per_page=per_page
employee_id=employee_id, delivery_method=delivery_method,
is_direct=is_direct, q=q, page=page, per_page=per_page
)
return jsonify(result)
finally:
conn.close()
@service_order_bp.route('/from-pos', methods=['POST'])
@require_auth('pos.sell')
def create_order_from_pos():
"""Create a service order directly from the POS cart (quotation)."""
data = request.get_json() or {}
items = data.get('items', [])
estimated_cost = sum(
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
for it in items
)
conn = get_tenant_conn(g.tenant_id)
try:
result = create_service_order(conn, {
'tenant_id': g.tenant_id,
'branch_id': data.get('branch_id', g.branch_id),
'customer_id': data.get('customer_id'),
'vehicle_id': data.get('vehicle_id'),
'priority': data.get('priority', 'normal'),
'reception_notes': data.get('reception_notes'),
'estimated_cost': estimated_cost or None,
'estimated_completion': data.get('estimated_completion'),
'employee_id': data.get('employee_id'),
'mileage_in': data.get('mileage_in'),
'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
})
so_id = result['service_order_id']
for item in items:
add_item(conn, so_id, item)
order = get_service_order(conn, so_id)
return jsonify(order), 201
finally:
conn.close()
@service_order_bp.route('', methods=['POST'])
@require_auth()
@require_auth('workshop.edit')
def create_order():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -73,6 +114,9 @@ def create_order():
'mileage_in': data.get('mileage_in'),
'fuel_level': data.get('fuel_level'),
'created_by': getattr(g, 'employee_id', None),
'delivery_method': data.get('delivery_method'),
'courier_id': data.get('courier_id'),
'is_direct': data.get('is_direct', False),
})
return jsonify(result), 201
finally:
@@ -80,7 +124,7 @@ def create_order():
@service_order_bp.route('/<int:so_id>', methods=['GET'])
@require_auth()
@require_auth('workshop.view')
def get_order(so_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -93,7 +137,7 @@ def get_order(so_id):
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_auth()
@require_auth('workshop.edit')
def update_order(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -107,7 +151,7 @@ def update_order(so_id):
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_auth()
@require_auth('workshop.edit')
def change_status(so_id):
data = request.get_json() or {}
new_status = data.get('status')
@@ -130,7 +174,7 @@ def change_status(so_id):
# ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_auth()
@require_auth('workshop.edit')
def add_order_item(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -142,7 +186,7 @@ def add_order_item(so_id):
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_auth()
@require_auth('workshop.edit')
def update_order_item(item_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -156,7 +200,7 @@ def update_order_item(item_id):
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_auth()
@require_auth('workshop.edit')
def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -169,7 +213,7 @@ def delete_order_item(item_id):
# ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_auth()
@require_auth('workshop.edit')
def add_order_labor(so_id):
data = request.get_json() or {}
if not data.get('description'):
@@ -183,7 +227,7 @@ def add_order_labor(so_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_auth()
@require_auth('workshop.edit')
def update_order_labor(labor_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -197,7 +241,7 @@ def update_order_labor(labor_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_auth()
@require_auth('workshop.edit')
def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -210,7 +254,7 @@ def delete_order_labor(labor_id):
# ─── Kanban Summary ─────────────────────────────
@service_order_bp.route('/kanban/summary', methods=['GET'])
@require_auth()
@require_auth('workshop.view')
def kanban_summary():
conn = get_tenant_conn(g.tenant_id)
try:
@@ -224,7 +268,7 @@ def kanban_summary():
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_auth()
@require_auth('workshop.edit')
def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id)
@@ -238,7 +282,7 @@ def reserve_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_auth()
@require_auth('workshop.edit')
def release_order_item(so_id, item_id):
"""Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id)
@@ -294,7 +338,7 @@ def convert_order_to_sale(so_id):
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
@require_auth()
@require_auth('workshop.edit')
def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order."""
data = request.get_json() or {}
@@ -316,7 +360,7 @@ def assign_mechanic_endpoint(so_id):
@service_order_bp.route('/service-catalog', methods=['GET'])
@require_auth()
@require_auth('workshop.view')
def list_catalog():
"""List reusable labor/service concepts."""
active_only = request.args.get('active_only', 'true').lower() != 'false'
@@ -329,7 +373,7 @@ def list_catalog():
@service_order_bp.route('/service-catalog', methods=['POST'])
@require_auth()
@require_auth('workshop.edit')
def create_catalog_item():
"""Create a reusable labor concept."""
data = request.get_json() or {}
@@ -345,7 +389,7 @@ def create_catalog_item():
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_auth()
@require_auth('workshop.edit')
def update_catalog_item(item_id):
"""Update a reusable labor concept."""
data = request.get_json() or {}
@@ -360,7 +404,7 @@ def update_catalog_item(item_id):
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_auth()
@require_auth('workshop.edit')
def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id)
@@ -375,7 +419,7 @@ def delete_catalog_item(item_id):
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_auth()
@require_auth('workshop.view')
def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order.

View File

@@ -28,7 +28,7 @@ def enqueue_report():
@tasks_bp.route('/<task_id>/status', methods=['GET'])
@require_auth
@require_auth()
def task_status(task_id):
"""Get status of a background task."""
from celery_app import celery