feat: detalle de cuentas por cobrar con opción de cancelar ticket
This commit is contained in:
@@ -7,7 +7,7 @@ NUMERIC(14,2) in the database.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
@@ -567,75 +567,76 @@ def balance_sheet():
|
|||||||
def aging_report():
|
def aging_report():
|
||||||
"""Antiguedad de saldos (accounts receivable aging).
|
"""Antiguedad de saldos (accounts receivable aging).
|
||||||
|
|
||||||
Groups outstanding credit sales by age:
|
Returns individual credit sales with outstanding balance, ready to be
|
||||||
- Corriente (not yet due)
|
collected, viewed or cancelled.
|
||||||
- 1-30 dias
|
|
||||||
- 31-60 dias
|
|
||||||
- 61-90 dias
|
|
||||||
- 90+ dias
|
|
||||||
"""
|
"""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
SELECT s.id, s.total, s.created_at, s.status,
|
||||||
s.id as sale_id, s.total, s.created_at,
|
c.id, c.name, c.rfc,
|
||||||
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
||||||
FROM customers c
|
FROM sales s
|
||||||
JOIN sales s ON s.customer_id = c.id
|
JOIN customers c ON c.id = s.customer_id
|
||||||
WHERE s.sale_type = 'credit'
|
WHERE s.sale_type = 'credit'
|
||||||
AND s.status = 'completed'
|
AND s.status = 'completed'
|
||||||
AND c.credit_balance > 0
|
ORDER BY s.created_at DESC
|
||||||
ORDER BY c.name, s.created_at
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
customers = {}
|
rows = []
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
cust_id = r[0]
|
sale_id = r[0]
|
||||||
if cust_id not in customers:
|
total = float(r[1]) if r[1] else 0
|
||||||
customers[cust_id] = {
|
created_at = r[2]
|
||||||
'id': r[0], 'name': r[1], 'rfc': r[2],
|
status = r[3]
|
||||||
'credit_limit': float(r[3]) if r[3] else 0,
|
customer_name = r[5]
|
||||||
'credit_balance': float(r[4]) if r[4] else 0,
|
payments_total = float(r[7]) if r[7] else 0
|
||||||
'corriente': 0, 'd1_30': 0, 'd31_60': 0, 'd61_90': 0, 'd90_plus': 0,
|
paid = payments_total
|
||||||
'total': 0,
|
balance = round(total - paid, 2)
|
||||||
}
|
|
||||||
|
|
||||||
amount = float(r[6]) if r[6] else 0
|
# Only receivables with pending balance
|
||||||
days = r[8] or 0
|
if balance <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
if days <= 0:
|
# Default due date = 30 days after issuance
|
||||||
customers[cust_id]['corriente'] += amount
|
due_date = created_at + timedelta(days=30) if created_at else None
|
||||||
elif days <= 30:
|
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
|
||||||
customers[cust_id]['d1_30'] += amount
|
|
||||||
elif days <= 60:
|
if days_overdue > 0:
|
||||||
customers[cust_id]['d31_60'] += amount
|
sale_status = 'overdue'
|
||||||
elif days <= 90:
|
label = 'Vencida'
|
||||||
customers[cust_id]['d61_90'] += amount
|
elif paid > 0:
|
||||||
|
sale_status = 'partial'
|
||||||
|
label = 'Parcial'
|
||||||
else:
|
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 = {
|
totals = {
|
||||||
'corriente': round(sum(c['corriente'] for c in result), 2),
|
'count': len(rows),
|
||||||
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
'total': round(sum(r['total'] for r in rows), 2),
|
||||||
'd31_60': round(sum(c['d31_60'] for c in result), 2),
|
'paid': round(sum(r['paid'] for r in rows), 2),
|
||||||
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
'balance': round(sum(r['balance'] for r in rows), 2),
|
||||||
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
|
|
||||||
'total': round(sum(c['total'] for c in result), 2),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'data': result, 'totals': totals})
|
return jsonify({'data': rows, 'totals': totals})
|
||||||
|
|
||||||
|
|
||||||
# ─── Fiscal Periods ────────────────────────────────
|
# ─── Fiscal Periods ────────────────────────────────
|
||||||
|
|||||||
@@ -97,18 +97,19 @@ const Accounting = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = rows.map(r => {
|
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 status = r.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 label = r.status_label || (status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente');
|
||||||
|
const actionLabel = r.balance > 0 ? 'Cobrar' : 'Ver';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||||
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
|
<td class="td--primary">${r.customer_name || r.name || '-'}</td>
|
||||||
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
|
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||||
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
|
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||||
<td class="td--amount">$${fmt(r.total)}</td>
|
<td class="td--amount">$${fmt(r.total)}</td>
|
||||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||||
<td>${statusBadge(status, label)}</td>
|
<td>${statusBadge(status, label)}</td>
|
||||||
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
|
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.showReceivableDetail(${r.sale_id})">${esc(actionLabel)}</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).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 '<tr>' +
|
||||||
|
'<td>' + esc(item.part_number || '-') + '</td>' +
|
||||||
|
'<td>' + esc(item.name) + '</td>' +
|
||||||
|
'<td style="text-align:right">' + item.quantity + '</td>' +
|
||||||
|
'<td style="text-align:right">$' + fmt(item.unit_price) + '</td>' +
|
||||||
|
'<td style="text-align:right">$' + fmt(item.subtotal) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).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 = '<div class="modal-overlay" id="receivableDetailOverlay" style="display:flex;z-index:2000;">' +
|
||||||
|
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
|
||||||
|
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
|
||||||
|
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">✕</button></div>' +
|
||||||
|
'<div style="padding:var(--space-4);">' +
|
||||||
|
'<p><strong>Ticket:</strong> VTA-' + sale.id + '</p>' +
|
||||||
|
'<p><strong>Cliente:</strong> ' + esc(sale.customer_name || '-') + '</p>' +
|
||||||
|
'<p><strong>Fecha:</strong> ' + (sale.created_at ? new Date(sale.created_at).toLocaleString('es-MX') : '-') + '</p>' +
|
||||||
|
'<p><strong>Estado:</strong> ' + esc(sale.status) + '</p>' +
|
||||||
|
'<p><strong>Total:</strong> $' + fmt(sale.total) + '</p>' +
|
||||||
|
'<p><strong>Pagado:</strong> $' + fmt(paid) + '</p>' +
|
||||||
|
'<p><strong>Saldo:</strong> $' + fmt(balance) + '</p>' +
|
||||||
|
'<h4 style="margin-top:var(--space-4);margin-bottom:var(--space-2);">Artículos</h4>' +
|
||||||
|
'<table class="data-table"><thead><tr><th>Clave</th><th>Producto</th><th>Cant</th><th>P.Unit</th><th>Subtotal</th></tr></thead><tbody>' +
|
||||||
|
(itemsHtml || '<tr><td colspan="5" style="text-align:center;">Sin artículos</td></tr>') +
|
||||||
|
'</tbody></table>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-footer">' +
|
||||||
|
'<button class="btn btn-ghost" onclick="Accounting.closeReceivableDetail()">Cerrar</button>' +
|
||||||
|
(canCancel ? '<button class="btn btn-danger" onclick="Accounting.cancelReceivable(' + sale.id + ')">Cancelar Ticket</button>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
|
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 ----
|
// ---- Tab 2: Cuentas por Pagar ----
|
||||||
async function loadAccountsPayable() {
|
async function loadAccountsPayable() {
|
||||||
const panel = document.getElementById('panel-cxp');
|
const panel = document.getElementById('panel-cxp');
|
||||||
@@ -500,12 +585,14 @@ const Accounting = (() => {
|
|||||||
window.closeNewEntryModal = closeNewEntryModal;
|
window.closeNewEntryModal = closeNewEntryModal;
|
||||||
window.addEntryLine = addEntryLine;
|
window.addEntryLine = addEntryLine;
|
||||||
window.submitNewEntry = submitNewEntry;
|
window.submitNewEntry = submitNewEntry;
|
||||||
|
window.Accounting = {
|
||||||
return {
|
|
||||||
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
||||||
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
||||||
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
||||||
|
showReceivableDetail, closeReceivableDetail, cancelReceivable,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return window.Accounting;
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
if (typeof registerCmdKItem === "function") {
|
if (typeof registerCmdKItem === "function") {
|
||||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||||
// so templates can use cache-busting query params freely.
|
// so templates can use cache-busting query params freely.
|
||||||
|
|
||||||
const CACHE_NAME = 'nexus-pos-v21';
|
const CACHE_NAME = 'nexus-pos-v22';
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/pos/static/css/tokens.css',
|
'/pos/static/css/tokens.css',
|
||||||
|
|||||||
@@ -499,7 +499,7 @@
|
|||||||
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/accounting.js" defer></script>
|
<script src="/pos/static/js/accounting.js?v=1" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user