Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -13,6 +13,7 @@ Account codes (from SAT seed):
140 = IVA Acreditable
210 = Proveedores
220 = IVA Trasladado
240 = Anticipos de Clientes (created on demand for layaways)
410 = Ventas
420 = Devoluciones sobre Ventas
510 = Costo de Mercancia Vendida
@@ -20,10 +21,13 @@ Account codes (from SAT seed):
All functions receive a psycopg2 connection (caller controls commit).
"""
import logging
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from flask import g
logger = logging.getLogger('accounting')
def _to_dec(val):
"""Convert a value to Decimal for precise arithmetic."""
@@ -57,6 +61,48 @@ def _get_account_ids(cur, codes):
return rows
def _ensure_account(cur, code, name, parent_code, acc_type):
"""Return the account ID for a code, creating it if it is missing.
Tenants seeded before an auxiliary account (e.g. customer advances) was
introduced do not have it in their chart of accounts, so it is created
on first use. Creation is idempotent (accounts.code is unique).
Args:
cur: psycopg2 cursor
code: account code to look up / create
name: account name used when creating
parent_code: code of the parent account (None for roots)
acc_type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto'
Returns:
int: account ID
"""
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
row = cur.fetchone()
if row:
return row[0]
parent_id = None
if parent_code:
cur.execute("SELECT id FROM accounts WHERE code = %s", (parent_code,))
parent = cur.fetchone()
parent_id = parent[0] if parent else None
cur.execute("""
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
VALUES (%s, %s, %s, %s, %s, false, true)
ON CONFLICT (code) DO NOTHING
RETURNING id
""", (code, name, parent_id, acc_type, code))
row = cur.fetchone()
if row:
return row[0]
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
return cur.fetchone()[0]
def get_next_entry_number(conn):
"""Get the next sequential journal entry number.
@@ -576,6 +622,434 @@ def record_cancellation_entry(conn, sale):
return entry_id
def record_return_entry(conn, ret):
"""Generate the journal entry for a customer return (devolucion).
Reverses the revenue side of the original sale and puts the returned
goods back into inventory:
Debit 420 Devoluciones sobre Ventas = net (after discount, before tax)
Debit 220 IVA Trasladado = tax
Credit 110/111/120 (refund account) = net + tax
Debit 130 Inventarios = cost
Credit 510 Costo de Mercancia Vendida = cost
The net/tax/cost split is read from return_items joined with sale_items,
so callers only need the return id and the original sale.
Args:
conn: psycopg2 connection to tenant DB
ret: dict with keys:
id: return id
sale_id: original sale id (optional)
sale_type: 'cash' | 'credit' (optional, looked up from sales)
payment_method: original payment method (optional, looked up)
total: refund total (used only when the item detail is missing)
Returns:
int: journal entry ID
"""
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
return_id = ret['id']
sale_id = ret.get('sale_id')
sale_type = ret.get('sale_type')
payment_method = ret.get('payment_method')
cur.execute("""
SELECT si.unit_price, si.discount_pct, si.tax_rate, si.unit_cost, ri.quantity
FROM return_items ri
JOIN sale_items si ON si.id = ri.sale_item_id
WHERE ri.return_id = %s
""", (return_id,))
item_rows = cur.fetchall()
if sale_id and (sale_type is None or payment_method is None):
cur.execute("SELECT sale_type, payment_method FROM sales WHERE id = %s", (sale_id,))
sale_row = cur.fetchone()
if sale_row:
sale_type = sale_type or sale_row[0]
payment_method = payment_method or sale_row[1]
net = Decimal('0')
tax = Decimal('0')
cost_total = Decimal('0')
for unit_price, discount_pct, tax_rate, unit_cost, quantity in item_rows:
qty = int(quantity or 0)
line_gross = (_to_dec(unit_price) * qty).quantize(TWO, ROUND_HALF_UP)
line_net = (line_gross * (Decimal('1') - _to_dec(discount_pct) / Decimal('100'))
).quantize(TWO, ROUND_HALF_UP)
net += line_net
tax += (line_net * _to_dec(tax_rate)).quantize(TWO, ROUND_HALF_UP)
cost_total += (_to_dec(unit_cost) * qty).quantize(TWO, ROUND_HALF_UP)
if net == 0 and tax == 0:
# No item detail available: fall back to the refund total (no IVA split)
net = _to_dec(ret.get('total', 0))
total = (net + tax).quantize(TWO, ROUND_HALF_UP)
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '420', '510'])
if sale_type == 'credit':
refund_account_id = accounts['120'] # Clientes
refund_desc = f'Clientes - Devolucion #{return_id}'
elif payment_method in ('transferencia', 'tarjeta'):
refund_account_id = accounts['111'] # Bancos
refund_desc = f'Bancos - Devolucion #{return_id}'
else:
refund_account_id = accounts['110'] # Caja
refund_desc = f'Caja - Devolucion #{return_id}'
lines = [
{
'account_id': accounts['420'],
'debit': float(net.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Devoluciones sobre ventas - Devolucion #{return_id}',
},
{
'account_id': refund_account_id,
'debit': 0,
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
'description': refund_desc,
},
]
if tax > 0:
lines.append({
'account_id': accounts['220'],
'debit': float(tax.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'IVA Trasladado - Devolucion #{return_id}',
})
if cost_total > 0:
lines.append({
'account_id': accounts['130'],
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Inventarios - Devolucion #{return_id}',
})
lines.append({
'account_id': accounts['510'],
'debit': 0,
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'Costo mercancia - Devolucion #{return_id}',
})
entry_id = _create_entry(
cur, entry_number, entry_date, 'egreso',
f'Devolucion #{return_id}' + (f' - Venta #{sale_id}' if sale_id else ''),
'return', return_id, lines
)
cur.close()
return entry_id
def record_layaway_entry(conn, layaway):
"""Generate the journal entry for a layaway (apartado) payment.
A layaway deposit is money held on behalf of the customer, not revenue,
so it is recorded as a customer advance:
Debit 110 Caja (or 111 Bancos) = amount
Credit 240 Anticipos de Clientes = amount
Args:
conn: psycopg2 connection
layaway: dict with keys:
id: layaway id
amount: float
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
Returns:
int or None: journal entry ID (None when amount <= 0)
"""
amount = _to_dec(layaway.get('amount', 0))
if amount <= 0:
return None
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
layaway_id = layaway['id']
method = layaway.get('payment_method', 'efectivo')
accounts = _get_account_ids(cur, ['110', '111'])
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
if method in ('transferencia', 'tarjeta'):
debit_account = accounts['111']
debit_desc = f'Bancos - Apartado #{layaway_id}'
else:
debit_account = accounts['110']
debit_desc = f'Caja - Apartado #{layaway_id}'
lines = [
{
'account_id': debit_account,
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': debit_desc,
},
{
'account_id': advance_account,
'debit': 0,
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
},
]
entry_id = _create_entry(
cur, entry_number, entry_date, 'ingreso',
f'Apartado #{layaway_id} - anticipo ({method})',
'layaway', layaway_id, lines
)
cur.close()
return entry_id
def record_layaway_conversion_entry(conn, sale):
"""Generate the journal entry for a fully paid layaway converted to a sale.
Applies the advances collected during the layaway and recognizes the
revenue, trasladado IVA and cost of goods:
Debit 240 Anticipos de Clientes = total
Credit 410 Ventas = subtotal
Credit 220 IVA Trasladado = tax_total
Debit 510 Costo de Mercancia Vendida = cost
Credit 130 Inventarios = cost
Args:
conn: psycopg2 connection
sale: dict with keys:
id: sale id created for the layaway
layaway_id: layaway id (optional)
subtotal: float (net, after discounts, before tax)
tax_total: float
total: float (subtotal + tax_total)
items: [{unit_cost, quantity}] (optional; read from DB when absent)
Returns:
int: journal entry ID
"""
cur = conn.cursor()
entry_date = date.today()
_check_period_open(cur, entry_date)
entry_number = get_next_entry_number(conn)
sale_id = sale['id']
layaway_id = sale.get('layaway_id')
subtotal = _to_dec(sale['subtotal'])
tax_total = _to_dec(sale['tax_total'])
total = _to_dec(sale['total'])
items = sale.get('items')
if not items:
cur.execute("SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s", (sale_id,))
items = [{'unit_cost': r[0], 'quantity': r[1]} for r in cur.fetchall()]
cost_total = Decimal('0')
for item in items:
cost_total += (_to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
).quantize(TWO, ROUND_HALF_UP)
accounts = _get_account_ids(cur, ['130', '220', '410', '510'])
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
lines = [
{
'account_id': advance_account,
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
},
{
'account_id': accounts['410'],
'debit': 0,
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
'description': f'Ventas - Apartado #{layaway_id} (venta #{sale_id})',
},
{
'account_id': accounts['220'],
'debit': 0,
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'IVA Trasladado - Apartado #{layaway_id} (venta #{sale_id})',
},
]
if cost_total > 0:
lines.append({
'account_id': accounts['510'],
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Costo mercancia - Apartado #{layaway_id}',
})
lines.append({
'account_id': accounts['130'],
'debit': 0,
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
'description': f'Inventarios - Apartado #{layaway_id}',
})
entry_id = _create_entry(
cur, entry_number, entry_date, 'ingreso',
f'Apartado #{layaway_id} - venta #{sale_id}',
'layaway', layaway_id, lines
)
cur.close()
return entry_id
def ensure_supplier_payments_table(conn):
"""Create the supplier_payments table if it does not exist (idempotent).
The table links each payment to the purchase order (cuentas por pagar)
it settles. It is created on first use so tenants that predate it work
without a manual migration.
"""
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS supplier_payments (
id SERIAL PRIMARY KEY,
po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE,
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
amount NUMERIC(12,2) NOT NULL,
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
reference VARCHAR(100),
employee_id INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_supplier_payments_po ON supplier_payments(po_id)"
)
cur.close()
def record_supplier_payment_entry(conn, payment):
"""Register a payment to a supplier against a purchase order (CxP).
Debit 210 Proveedores = amount
Credit 110 Caja / 111 Bancos = amount
The payment is stored in supplier_payments (created on first use) and the
purchase order is marked 'paid' once the accumulated payments cover its
total. This is what feeds the 'paid' column of the accounts-payable aging.
Args:
conn: psycopg2 connection
payment: dict with keys:
po_id: purchase order id (required)
amount: float (required, in the PO currency)
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
reference: str (optional)
Returns:
dict: {entry_id, payment_id, po_id, status, paid, balance}
"""
cur = conn.cursor()
po_id = payment.get('po_id')
amount = _to_dec(payment.get('amount', 0))
if not po_id:
raise ValueError("po_id is required")
if amount <= 0:
raise ValueError("amount must be greater than 0")
cur.execute("""
SELECT supplier_id, total, status FROM purchase_orders WHERE id = %s FOR UPDATE
""", (po_id,))
row = cur.fetchone()
if not row:
raise ValueError(f"Purchase order {po_id} not found")
supplier_id, po_total, po_status = row
if po_status == 'cancelled':
raise ValueError("Cannot pay a cancelled purchase order")
ensure_supplier_payments_table(conn)
cur.execute("SELECT COALESCE(SUM(amount), 0) FROM supplier_payments WHERE po_id = %s", (po_id,))
already_paid = _to_dec(cur.fetchone()[0])
po_total = _to_dec(po_total)
paid_total = already_paid + amount
if paid_total > po_total:
raise ValueError(
f"Payment ${amount} exceeds balance ${(po_total - already_paid).quantize(TWO)} "
f"of purchase order {po_id}"
)
entry_date = date.today()
_check_period_open(cur, entry_date)
try:
created_by = getattr(g, 'employee_id', None)
except RuntimeError:
created_by = None
cur.execute("""
INSERT INTO supplier_payments
(po_id, supplier_id, amount, payment_method, reference, employee_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
po_id, supplier_id, float(amount),
payment.get('payment_method', 'efectivo'),
payment.get('reference'), created_by
))
payment_id = cur.fetchone()[0]
accounts = _get_account_ids(cur, ['110', '111', '210'])
method = payment.get('payment_method', 'efectivo')
if method in ('transferencia', 'tarjeta'):
credit_account = accounts['111']
credit_desc = f'Bancos - Pago a proveedor OC #{po_id}'
else:
credit_account = accounts['110']
credit_desc = f'Caja - Pago a proveedor OC #{po_id}'
entry_number = get_next_entry_number(conn)
entry_id = _create_entry(
cur, entry_number, entry_date, 'egreso',
f'Pago a proveedor - OC #{po_id}',
'purchase', po_id,
[
{
'account_id': accounts['210'],
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'credit': 0,
'description': f'Proveedores - Pago OC #{po_id}',
},
{
'account_id': credit_account,
'debit': 0,
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
'description': credit_desc,
},
]
)
new_status = 'paid' if paid_total >= po_total else po_status
if new_status != po_status:
cur.execute("UPDATE purchase_orders SET status = %s WHERE id = %s", (new_status, po_id))
cur.close()
return {
'entry_id': entry_id,
'payment_id': payment_id,
'po_id': po_id,
'status': new_status,
'paid': float(paid_total.quantize(TWO, ROUND_HALF_UP)),
'balance': float((po_total - paid_total).quantize(TWO, ROUND_HALF_UP)),
}
def create_manual_entry(conn, entry_data):
"""Create a manual journal entry (type='diario').