From 6b00e45f5d183af195f31aefd56f16ba6de12b22 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Fri, 26 Jun 2026 00:17:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20detalle=20de=20cuentas=20por=20cobrar?= =?UTF-8?q?=20con=20opci=C3=B3n=20de=20cancelar=20ticket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pos/blueprints/accounting_bp.py | 101 ++++++++++++++++---------------- pos/static/js/accounting.js | 99 +++++++++++++++++++++++++++++-- pos/static/pwa/sw.js | 2 +- pos/templates/accounting.html | 2 +- 4 files changed, 146 insertions(+), 58 deletions(-) diff --git a/pos/blueprints/accounting_bp.py b/pos/blueprints/accounting_bp.py index 373fa00..dc98425 100644 --- a/pos/blueprints/accounting_bp.py +++ b/pos/blueprints/accounting_bp.py @@ -7,7 +7,7 @@ NUMERIC(14,2) in the database. """ import json -from datetime import date, datetime +from datetime import date, datetime, timedelta from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn @@ -567,75 +567,76 @@ def balance_sheet(): def aging_report(): """Antiguedad de saldos (accounts receivable aging). - Groups outstanding credit sales by age: - - Corriente (not yet due) - - 1-30 dias - - 31-60 dias - - 61-90 dias - - 90+ dias + Returns individual credit sales with outstanding balance, ready to be + collected, viewed or cancelled. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() 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, - EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding - FROM customers c - JOIN sales s ON s.customer_id = c.id + SELECT s.id, s.total, s.created_at, s.status, + c.id, c.name, c.rfc, + 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' - AND c.credit_balance > 0 - ORDER BY c.name, s.created_at + ORDER BY s.created_at DESC """) - customers = {} + rows = [] for r in cur.fetchall(): - cust_id = r[0] - if cust_id not in customers: - customers[cust_id] = { - 'id': r[0], 'name': r[1], 'rfc': r[2], - 'credit_limit': float(r[3]) if r[3] else 0, - 'credit_balance': float(r[4]) if r[4] else 0, - 'corriente': 0, 'd1_30': 0, 'd31_60': 0, 'd61_90': 0, 'd90_plus': 0, - 'total': 0, - } + sale_id = r[0] + total = float(r[1]) if r[1] else 0 + created_at = r[2] + status = r[3] + customer_name = r[5] + payments_total = float(r[7]) if r[7] else 0 + paid = payments_total + balance = round(total - paid, 2) - amount = float(r[6]) if r[6] else 0 - days = r[8] or 0 + # Only receivables with pending balance + if balance <= 0: + continue - if days <= 0: - customers[cust_id]['corriente'] += amount - elif days <= 30: - customers[cust_id]['d1_30'] += amount - elif days <= 60: - customers[cust_id]['d31_60'] += amount - elif days <= 90: - customers[cust_id]['d61_90'] += amount + # Default due date = 30 days after issuance + due_date = created_at + timedelta(days=30) if created_at else None + days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0 + + if days_overdue > 0: + sale_status = 'overdue' + label = 'Vencida' + elif paid > 0: + sale_status = 'partial' + label = 'Parcial' else: - customers[cust_id]['d90_plus'] += amount + sale_status = 'pending' + label = 'Vigente' - customers[cust_id]['total'] += amount + rows.append({ + 'sale_id': sale_id, + 'invoice': 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, + 'total': total, + 'paid': paid, + 'balance': balance, + 'days_overdue': days_overdue, + 'status': sale_status, + 'status_label': label, + }) - result = list(customers.values()) - # Round all amounts - for c in result: - for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'): - c[key] = round(c[key], 2) - - # Totals row totals = { - 'corriente': round(sum(c['corriente'] for c in result), 2), - 'd1_30': round(sum(c['d1_30'] for c in result), 2), - 'd31_60': round(sum(c['d31_60'] for c in result), 2), - 'd61_90': round(sum(c['d61_90'] for c in result), 2), - 'd90_plus': round(sum(c['d90_plus'] for c in result), 2), - 'total': round(sum(c['total'] for c in result), 2), + 'count': len(rows), + 'total': round(sum(r['total'] for r in rows), 2), + 'paid': round(sum(r['paid'] for r in rows), 2), + 'balance': round(sum(r['balance'] for r in rows), 2), } cur.close() conn.close() - return jsonify({'data': result, 'totals': totals}) + return jsonify({'data': rows, 'totals': totals}) # ─── Fiscal Periods ──────────────────────────────── diff --git a/pos/static/js/accounting.js b/pos/static/js/accounting.js index fae4927..ffa0e92 100644 --- a/pos/static/js/accounting.js +++ b/pos/static/js/accounting.js @@ -97,18 +97,19 @@ const Accounting = (() => { } tbody.innerHTML = rows.map(r => { - const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending'; - const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente'; + const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending'); + const label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente'); + const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver'; return ` ${r.invoice || r.folio || '-'} - ${r.name || r.customer_name || '-'} + ${r.customer_name || r.name || '-'} ${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'} ${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'} $${fmt(r.total)} $${fmt(r.paid || 0)} $${fmt(r.balance || r.total)} ${statusBadge(status, label)} - + `; }).join(''); @@ -123,6 +124,90 @@ const Accounting = (() => { } } + // ---- Receivable detail / cancel ticket ---- + async function showReceivableDetail(saleId) { + try { + const sale = await api('/pos/api/sales/' + saleId); + if (!sale || sale.error) { + alert('No se pudo cargar el detalle de la venta'); + return; + } + const itemsHtml = (sale.items || []).map(function (item) { + return '' + + '' + esc(item.part_number || '-') + '' + + '' + esc(item.name) + '' + + '' + item.quantity + '' + + '$' + fmt(item.unit_price) + '' + + '$' + fmt(item.subtotal) + '' + + ''; + }).join(''); + + const paid = (sale.payments || []).reduce(function (sum, p) { return sum + (p.amount || 0); }, 0) + (sale.amount_paid || 0); + const balance = (sale.total || 0) - paid; + const canCancel = sale.status !== 'cancelled' && balance > 0; + + const html = ''; + + const existing = document.getElementById('receivableDetailOverlay'); + if (existing) existing.remove(); + document.body.insertAdjacentHTML('beforeend', html); + } catch (e) { + alert('Error al cargar detalle: ' + e.message); + } + } + + function closeReceivableDetail() { + const el = document.getElementById('receivableDetailOverlay'); + if (el) el.remove(); + } + + async function cancelReceivable(saleId) { + const reason = prompt('Motivo de cancelación del ticket (mínimo 3 caracteres):'); + if (!reason || reason.trim().length < 3) { + alert('Se requiere un motivo para cancelar.'); + return; + } + if (!confirm('¿Estás seguro de cancelar el ticket VTA-' + saleId + '? Esta acción reversa el inventario y el crédito del cliente.')) { + return; + } + try { + const res = await api('/pos/api/sales/' + saleId + '/cancel', { + method: 'PUT', + body: JSON.stringify({ reason: reason.trim() }) + }); + if (res.error) { + alert('Error: ' + res.error); + return; + } + alert('Ticket cancelado correctamente.'); + closeReceivableDetail(); + loadAging(); + } catch (e) { + alert('Error al cancelar: ' + e.message); + } + } + // ---- Tab 2: Cuentas por Pagar ---- async function loadAccountsPayable() { const panel = document.getElementById('panel-cxp'); @@ -500,12 +585,14 @@ const Accounting = (() => { window.closeNewEntryModal = closeNewEntryModal; window.addEntryLine = addEntryLine; window.submitNewEntry = submitNewEntry; - - return { + window.Accounting = { switchTab, loadAging, loadAccountsPayable, loadBalanceSheet, loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose, exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry, + showReceivableDetail, closeReceivableDetail, cancelReceivable, }; + + return window.Accounting; // Register Cmd+K items if (typeof registerCmdKItem === "function") { registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" }); diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js index 1632e25..9307447 100644 --- a/pos/static/pwa/sw.js +++ b/pos/static/pwa/sw.js @@ -6,7 +6,7 @@ // The fetch handler normalizes static asset URLs (strips ?v= query strings) // so templates can use cache-busting query params freely. -const CACHE_NAME = 'nexus-pos-v21'; +const CACHE_NAME = 'nexus-pos-v22'; const APP_SHELL = [ '/pos/static/css/tokens.css', diff --git a/pos/templates/accounting.html b/pos/templates/accounting.html index 89aaeb2..dd74beb 100644 --- a/pos/templates/accounting.html +++ b/pos/templates/accounting.html @@ -499,7 +499,7 @@ - +