Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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:
@@ -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').
|
||||
|
||||
|
||||
Reference in New Issue
Block a user