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:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user