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,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'])
|
||||
|
||||
Reference in New Issue
Block a user