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'])
@require_auth('accounting.view')
def aging_report():
"""Antiguedad de saldos (accounts receivable aging).
"""Antiguedad de saldos.
Returns individual credit sales with outstanding balance, ready to be
collected, viewed or cancelled.
Returns individual credit sales with outstanding balance when type=receivable
(default), or purchase orders payable to suppliers when type=payable.
"""
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id)
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 = []
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)
if report_type == 'payable':
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.id, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
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
if balance <= 0:
continue
paid = 0 # TODO: sum supplier payments when that table is added
balance = round(total - paid, 2)
if balance <= 0:
continue
# 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
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
if days_overdue > 0:
po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
if days_overdue > 0:
sale_status = 'overdue'
label = 'Vencida'
elif paid > 0:
sale_status = 'partial'
label = 'Parcial'
else:
sale_status = 'pending'
label = 'Vigente'
rows.append({
'po_id': po_id,
'invoice': invoice,
'vendor_name': vendor_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': expected_date.isoformat() if expected_date else None,
'total': total,
'paid': paid,
'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({
'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,
})
if balance <= 0:
continue
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:
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 = {
'count': len(rows),

View File

@@ -231,12 +231,12 @@ 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' : 'Pendiente');
return `<tr>
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
<td class="td--primary">${r.name || r.vendor_name || '-'}</td>
<td>${r.receipt_date ? new Date(r.receipt_date).toLocaleDateString('es-MX') : '-'}</td>
<td class="td--primary">${r.vendor_name || r.name || '-'}</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 class="td--amount">$${fmt(r.total)}</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)
// so templates can use cache-busting query params freely.
const CACHE_NAME = 'nexus-pos-v24';
const CACHE_NAME = 'nexus-pos-v25';
const APP_SHELL = [
'/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/pos-utils.js?v=2" 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>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>