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,12 +13,56 @@ from datetime import date, datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.accounting_engine import create_manual_entry
from services.accounting_engine import create_manual_entry, record_supplier_payment_entry
from services.audit import log_action
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
def _csv_response(rows, header, filename):
"""Build a UTF-8 CSV Response with BOM (Excel-friendly)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(header)
writer.writerows(rows)
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
def _period_bounds(year, month):
"""Return (period_start, period_end) ISO dates for a fiscal month."""
period_start = f'{year}-{month:02d}-01'
if month == 12:
period_end = f'{year + 1}-01-01'
else:
period_end = f'{year}-{month + 1:02d}-01'
return period_start, period_end
def _supplier_paid_totals(cur):
"""Return {po_id: paid_amount} from supplier_payments.
The table is created on first supplier payment, so tenants that never paid
a supplier simply have no data (and no table) yet.
"""
cur.execute("SELECT to_regclass('public.supplier_payments')")
if not cur.fetchone()[0]:
return {}
cur.execute("""
SELECT po_id, COALESCE(SUM(amount), 0)
FROM supplier_payments
WHERE po_id IS NOT NULL
GROUP BY po_id
""")
return {r[0]: float(r[1]) for r in cur.fetchall()}
# ─── Chart of Accounts ─────────────────────────────
@accounting_bp.route('/accounts', methods=['GET'])
@@ -309,29 +353,11 @@ def create_entry():
# ─── Financial Reports ──────────────────────────────
@accounting_bp.route('/trial-balance', methods=['GET'])
@require_auth('accounting.view')
def trial_balance():
"""Balanza de comprobacion for a period.
Query params:
year: int (default current year)
month: int (default current month)
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
"""
conn = get_tenant_conn(g.tenant_id)
def _trial_balance_rows(conn, year, month):
"""Balanza de comprobacion rows for a fiscal month."""
cur = conn.cursor()
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
# Period start/end dates
period_start = f'{year}-{month:02d}-01'
if month == 12:
period_end = f'{year + 1}-01-01'
else:
period_end = f'{year}-{month + 1:02d}-01'
period_start, period_end = _period_bounds(year, month)
cur.execute("""
WITH initial_balances AS (
@@ -384,31 +410,62 @@ def trial_balance():
})
cur.close()
conn.close()
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
return rows
@accounting_bp.route('/income-statement', methods=['GET'])
@accounting_bp.route('/trial-balance', methods=['GET'])
@require_auth('accounting.view')
def income_statement():
"""Estado de resultados for a period.
def trial_balance():
"""Balanza de comprobacion for a period.
Query params: year, month
Query params:
year: int (default current year)
month: int (default current month)
Groups: Ingresos (400), Costos (500), Gastos (600)
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
period_start = f'{year}-{month:02d}-01'
if month == 12:
period_end = f'{year + 1}-01-01'
else:
period_end = f'{year}-{month + 1:02d}-01'
rows = _trial_balance_rows(conn, year, month)
conn.close()
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
@accounting_bp.route('/trial-balance/export', methods=['GET'])
@require_auth('accounting.view')
def export_trial_balance():
"""Export the balanza de comprobacion as CSV (default) or JSON.
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
rows = _trial_balance_rows(conn, year, month)
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
return _csv_response(
[[r['code'], r['name'], r['type'], r['saldo_inicial'], r['cargos'],
r['abonos'], r['saldo_final']] for r in rows],
['Cuenta', 'Nombre', 'Tipo', 'Saldo inicial', 'Cargos', 'Abonos', 'Saldo final'],
f'balanza_{year}-{month:02d}.csv'
)
def _income_statement_data(conn, year, month):
"""Estado de resultados grouped by section for a fiscal month."""
cur = conn.cursor()
period_start, period_end = _period_bounds(year, month)
cur.execute("""
SELECT a.id, a.code, a.name, a.type,
@@ -456,16 +513,215 @@ def income_statement():
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
cur.close()
conn.close()
return jsonify({
return {
'period': {'year': year, 'month': month},
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
'costos': {'items': costos, 'total': round(total_costos, 2)},
'utilidad_bruta': utilidad_bruta,
'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
'utilidad_neta': utilidad_neta,
})
}
@accounting_bp.route('/income-statement', methods=['GET'])
@require_auth('accounting.view')
def income_statement():
"""Estado de resultados for a period.
Query params: year, month
Groups: Ingresos (400), Costos (500), Gastos (600)
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
data = _income_statement_data(conn, year, month)
conn.close()
return jsonify(data)
@accounting_bp.route('/income-statement/export', methods=['GET'])
@require_auth('accounting.view')
def export_income_statement():
"""Export the estado de resultados as CSV (default) or JSON.
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
data = _income_statement_data(conn, year, month)
conn.close()
if fmt == 'json':
return jsonify(data)
rows = []
for item in data['ingresos']['items']:
rows.append(['Ingresos', item['code'], item['name'], item['amount']])
rows.append(['Ingresos', '', 'Total ingresos', data['ingresos']['total']])
for item in data['costos']['items']:
rows.append(['Costos', item['code'], item['name'], item['amount']])
rows.append(['Costos', '', 'Total costos', data['costos']['total']])
rows.append(['', '', 'Utilidad bruta', data['utilidad_bruta']])
for item in data['gastos']['items']:
rows.append(['Gastos', item['code'], item['name'], item['amount']])
rows.append(['Gastos', '', 'Total gastos', data['gastos']['total']])
rows.append(['', '', 'Utilidad neta', data['utilidad_neta']])
return _csv_response(
rows,
['Seccion', 'Cuenta', 'Nombre', 'Importe'],
f'estado_resultados_{year}-{month:02d}.csv'
)
@accounting_bp.route('/entries/export', methods=['GET'])
@require_auth('accounting.view')
def export_entries():
"""Export the polizas (journal entries) of a period with their lines.
Query params: date_from, date_to, type, reference_type, format=csv|json
CSV output has one row per journal entry line (accountant-friendly).
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
fmt = request.args.get('format', 'csv').lower()
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
entry_type = request.args.get('type')
ref_type = request.args.get('reference_type')
where_clauses = ["e.status = 'posted'"]
params = []
if date_from:
where_clauses.append("e.date >= %s")
params.append(date_from)
if date_to:
where_clauses.append("e.date <= %s")
params.append(date_to)
if entry_type:
where_clauses.append("e.type = %s")
params.append(entry_type)
if ref_type:
where_clauses.append("e.reference_type = %s")
params.append(ref_type)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT e.entry_number, e.date, e.type, e.description,
e.reference_type, e.reference_id,
a.code, a.name, l.description, l.debit, l.credit
FROM journal_entry_lines l
JOIN journal_entries e ON l.journal_entry_id = e.id
JOIN accounts a ON l.account_id = a.id
WHERE {where}
ORDER BY e.date, e.entry_number, l.id
""", params)
rows = []
for r in cur.fetchall():
rows.append({
'entry_number': r[0],
'date': r[1].isoformat() if r[1] else None,
'type': r[2],
'entry_description': r[3],
'reference_type': r[4],
'reference_id': r[5],
'account_code': r[6],
'account_name': r[7],
'line_description': r[8],
'debit': float(r[9] or 0),
'credit': float(r[10] or 0),
})
cur.close()
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'count': len(rows)})
return _csv_response(
[[r['entry_number'], r['date'], r['type'], r['entry_description'],
r['reference_type'], r['reference_id'], r['account_code'],
r['account_name'], r['line_description'], r['debit'], r['credit']]
for r in rows],
['Poliza', 'Fecha', 'Tipo', 'Descripcion poliza', 'Referencia', 'Ref ID',
'Cuenta', 'Nombre cuenta', 'Descripcion', 'Debe', 'Haber'],
f'polizas_{date.today().isoformat()}.csv'
)
@accounting_bp.route('/diot/export', methods=['GET'])
@require_auth('accounting.view')
def export_diot():
"""Export the DIOT (operations with third parties) for a fiscal month.
Data source: purchase orders received during the month, grouped by
supplier. All amounts are expressed in the purchase-order currency (MXN
for locally created orders).
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
period_start, period_end = _period_bounds(year, month)
cur.execute("""
SELECT s.rfc, s.name,
COALESCE(SUM(po.subtotal), 0) as base,
COALESCE(SUM(po.tax_total), 0) as iva,
COALESCE(SUM(po.total), 0) as total
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('draft', 'cancelled')
AND po.received_at >= %s AND po.received_at < %s
GROUP BY s.rfc, s.name
ORDER BY s.name
""", (period_start, period_end))
rows = []
for r in cur.fetchall():
rows.append({
'rfc': r[0] or '',
'supplier_name': r[1],
'tipo_tercero': '04', # Proveedor
'tipo_operacion': '02', # Compra de bienes
'base': round(float(r[2]), 2),
'iva_16': round(float(r[3]), 2),
'iva_retenido': 0.0,
'total': round(float(r[4]), 2),
})
cur.close()
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'period': {'year': year, 'month': month},
'count': len(rows)})
return _csv_response(
[[r['tipo_tercero'], r['tipo_operacion'], r['rfc'], r['supplier_name'],
r['base'], r['iva_16'], r['iva_retenido'], r['total']] for r in rows],
['Tipo de tercero', 'Tipo de operacion', 'RFC', 'Nombre', 'Base',
'IVA 16%', 'IVA retenido', 'Monto total'],
f'diot_{year}-{month:02d}.csv'
)
@accounting_bp.route('/balance-sheet', methods=['GET'])
@@ -578,6 +834,7 @@ def aging_report():
rows = []
if report_type == 'payable':
paid_by_po = _supplier_paid_totals(cur)
# 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,
@@ -596,7 +853,7 @@ def aging_report():
status = r[5]
vendor_name = r[7]
paid = 0 # TODO: sum supplier payments when that table is added
paid = paid_by_po.get(po_id, 0.0)
balance = round(total - paid, 2)
if balance <= 0:
continue
@@ -772,6 +1029,7 @@ def export_aging():
if report_type == 'payable':
# Payables always CSV for now
paid_by_po = _supplier_paid_totals(cur)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
@@ -792,10 +1050,10 @@ def export_aging():
expected = r[4]
status = r[5]
vendor = r[6]
paid = 0
paid = paid_by_po.get(po_id, 0.0)
balance = round(total - paid, 2)
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
status_label = 'Vencida' if days_overdue > 0 else ('Parcial' if paid > 0 else 'Pendiente')
writer.writerow([
invoice, vendor,
created_at.strftime('%Y-%m-%d') if created_at else '',
@@ -916,6 +1174,100 @@ def export_aging():
)
# ─── Accounts Payable payments ─────────────────────
@accounting_bp.route('/supplier-payments', methods=['POST'])
@require_auth('accounting.create')
def create_supplier_payment():
"""Register a payment to a supplier against a purchase order (CxP).
Body: {po_id: int, amount: float, payment_method: str, reference: str}
"""
data = request.get_json() or {}
if not data.get('po_id'):
return jsonify({'error': 'po_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = record_supplier_payment_entry(conn, data)
log_action(conn, 'SUPPLIER_PAYMENT', 'purchase_order', result['po_id'],
new_value={'amount': float(data.get('amount', 0)),
'payment_id': result['payment_id'],
'status': result['status']})
conn.commit()
conn.close()
return jsonify(result), 201
except ValueError as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 400
except Exception as e:
conn.rollback()
conn.close()
return jsonify({'error': str(e)}), 500
@accounting_bp.route('/supplier-payments', methods=['GET'])
@require_auth('accounting.view')
def list_supplier_payments():
"""List supplier payments. Query params: po_id, supplier_id, page, per_page."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT to_regclass('public.supplier_payments')")
if not cur.fetchone()[0]:
cur.close(); conn.close()
return jsonify({'data': [], 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 0}})
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
if request.args.get('po_id'):
where_clauses.append("sp.po_id = %s")
params.append(int(request.args.get('po_id')))
if request.args.get('supplier_id'):
where_clauses.append("sp.supplier_id = %s")
params.append(int(request.args.get('supplier_id')))
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM supplier_payments sp WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT sp.id, sp.po_id, sp.supplier_id, sp.amount, sp.payment_method,
sp.reference, sp.created_at, s.name as supplier_name
FROM supplier_payments sp
LEFT JOIN suppliers s ON sp.supplier_id = s.id
WHERE {where}
ORDER BY sp.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = []
for r in cur.fetchall():
rows.append({
'id': r[0], 'po_id': r[1], 'supplier_id': r[2],
'amount': float(r[3]) if r[3] else 0,
'payment_method': r[4], 'reference': r[5],
'created_at': str(r[6]) if r[6] else None,
'supplier_name': r[7],
})
cur.close()
conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': rows,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
# ─── Fiscal Periods ────────────────────────────────
@accounting_bp.route('/periods', methods=['GET'])