fix: separa Cuentas por Cobrar (ventas a crédito) y Cuentas por Pagar (órdenes de compra)
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-26 00:32:46 +00:00
parent 9a55e15142
commit 66d1268bfb
4 changed files with 105 additions and 57 deletions

View File

@@ -565,67 +565,115 @@ def balance_sheet():
@accounting_bp.route('/aging', methods=['GET']) @accounting_bp.route('/aging', methods=['GET'])
@require_auth('accounting.view') @require_auth('accounting.view')
def aging_report(): def aging_report():
"""Antiguedad de saldos (accounts receivable aging). """Antiguedad de saldos.
Returns individual credit sales with outstanding balance, ready to be Returns individual credit sales with outstanding balance when type=receivable
collected, viewed or cancelled. (default), or purchase orders payable to suppliers when type=payable.
""" """
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute("""
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'
ORDER BY s.created_at DESC
""")
rows = [] rows = []
for r in cur.fetchall(): if report_type == 'payable':
sale_id = r[0] # Accounts payable: purchase orders to suppliers that are not paid/cancelled
total = float(r[1]) if r[1] else 0 cur.execute("""
created_at = r[2] SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
status = r[3] po.status, s.id, s.name
customer_name = r[5] FROM purchase_orders po
payments_total = float(r[7]) if r[7] else 0 JOIN suppliers s ON s.id = po.supplier_id
paid = payments_total WHERE po.status NOT IN ('paid', 'cancelled')
balance = round(total - paid, 2) 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_date = r[4]
status = r[5]
vendor_name = r[7]
# Only receivables with pending balance paid = 0 # TODO: sum supplier payments when that table is added
if balance <= 0: balance = round(total - paid, 2)
continue if balance <= 0:
continue
# Default due date = 30 days after issuance days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
due_date = created_at + timedelta(days=30) if created_at else None if days_overdue > 0:
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0 po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
if days_overdue > 0: rows.append({
sale_status = 'overdue' 'po_id': po_id,
label = 'Vencida' 'invoice': invoice,
elif paid > 0: 'vendor_name': vendor_name,
sale_status = 'partial' 'issue_date': created_at.isoformat() if created_at else None,
label = 'Parcial' 'due_date': expected_date.isoformat() if expected_date else None,
else: 'total': total,
sale_status = 'pending' 'paid': paid,
label = 'Vigente' 'balance': balance,
'days_overdue': days_overdue,
'status': po_status,
'status_label': label,
})
else:
# Accounts receivable: credit sales to customers
cur.execute("""
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'
ORDER BY s.created_at DESC
""")
for r in cur.fetchall():
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)
rows.append({ if balance <= 0:
'sale_id': sale_id, continue
'invoice': f'VTA-{sale_id}',
'customer_name': customer_name, due_date = created_at + timedelta(days=30) if created_at else None
'issue_date': created_at.isoformat() if created_at else None, days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
'due_date': due_date.isoformat() if due_date else None,
'total': total, if days_overdue > 0:
'paid': paid, sale_status = 'overdue'
'balance': balance, label = 'Vencida'
'days_overdue': days_overdue, elif paid > 0:
'status': sale_status, sale_status = 'partial'
'status_label': label, label = 'Parcial'
}) else:
sale_status = 'pending'
label = 'Vigente'
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,
})
totals = { totals = {
'count': len(rows), 'count': len(rows),

View File

@@ -231,12 +231,12 @@ 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' : 'Pendiente');
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.vendor_name || '-'}</td> <td class="td--primary">${r.vendor_name || r.name || '-'}</td>
<td>${r.receipt_date ? new Date(r.receipt_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>

View File

@@ -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-v24'; const CACHE_NAME = 'nexus-pos-v25';
const APP_SHELL = [ const APP_SHELL = [
'/pos/static/css/tokens.css', '/pos/static/css/tokens.css',

View File

@@ -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?v=2" defer></script> <script src="/pos/static/js/accounting.js?v=3" 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>