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,14 +565,66 @@ 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()
rows = []
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]
paid = 0 # TODO: sum supplier payments when that table is added
balance = round(total - paid, 2)
if balance <= 0:
continue
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'
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(""" cur.execute("""
SELECT s.id, s.total, s.created_at, s.status, SELECT s.id, s.total, s.created_at, s.status,
c.id, c.name, c.rfc, c.id, c.name, c.rfc,
@@ -583,8 +635,6 @@ def aging_report():
AND s.status = 'completed' AND s.status = 'completed'
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
""") """)
rows = []
for r in cur.fetchall(): for r in cur.fetchall():
sale_id = r[0] sale_id = r[0]
total = float(r[1]) if r[1] else 0 total = float(r[1]) if r[1] else 0
@@ -595,11 +645,9 @@ def aging_report():
paid = payments_total paid = payments_total
balance = round(total - paid, 2) balance = round(total - paid, 2)
# Only receivables with pending balance
if balance <= 0: if balance <= 0:
continue continue
# Default due date = 30 days after issuance
due_date = created_at + timedelta(days=30) if created_at else None 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) - due_date).days if due_date else 0

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>