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

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