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

@@ -3,9 +3,11 @@
Flow:
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
2. process_queue() — sends pending items to Facturapi, updates status
3. retry_failed() — retries failed items with exponential backoff
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
2. enqueue_pending_complements() — enqueues the missing payment complements
(CFDI type "P") of PPD invoices and the credit notes (type "E") of returns
3. process_queue() — sends pending items to Facturapi, updates status
4. retry_failed() — retries failed items with exponential backoff
5. cancel_cfdi() — cancels a stamped CFDI via Facturapi
Facturapi endpoints used:
POST /v2/invoices — create and stamp an invoice
@@ -17,9 +19,11 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
import json
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from services import facturapi_service
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
logger = logging.getLogger(__name__)
@@ -27,6 +31,64 @@ logger = logging.getLogger(__name__)
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
MAX_RETRIES = len(BACKOFF_INTERVALS)
# Movements older than this window are only reconciled on explicit backfill.
COMPLEMENT_LOOKBACK_DAYS = 7
TWO = Decimal("0.01")
def _send_cfdi_pdf_wa(conn, invoice_id_external, sale_id, tenant_config):
"""Descarga el PDF timbrado y lo envía por WhatsApp al cliente (si tiene teléfono)."""
try:
from services import facturapi_service
cur = conn.cursor()
cur.execute("""
SELECT c.phone FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
""", (sale_id,))
row = cur.fetchone()
cur.close()
if not row or not row[0]:
return
phone = row[0]
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
raw = {k: v for k, v in cur.fetchall()}
cur.close()
cfg = {
'provider': raw.get('whatsapp_provider', 'baileys'),
'phone_number_id': raw.get('wa_phone_number_id', ''),
'access_token': raw.get('wa_access_token', ''),
'bridge_url': raw.get('whatsapp_bridge_url', ''),
}
# Solo enviar si hay proveedor configurado (Meta o Baileys con bridge)
if cfg['provider'] != 'meta' and not cfg['bridge_url']:
return
pdf_bytes = facturapi_service.download_pdf(tenant_config, invoice_id_external)
import base64 as _b64
b64 = _b64.b64encode(pdf_bytes).decode()
filename = f"CFDI_{sale_id}.pdf"
from services.whatsapp_service import wa_send_document
result = wa_send_document(cfg, phone, b64, filename,
caption="Adjuntamos tu factura CFDI. Gracias por tu compra.")
try:
from services.audit import log_action
log_action(conn, 'CFDI_PDF_SENT', 'sale', sale_id,
new_value={'result': result, 'phone': phone})
except Exception:
pass
except Exception as e:
try:
logger.warning("PDF WA send failed for sale %s: %s", sale_id, e)
except Exception:
pass
def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001.
@@ -81,6 +143,340 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
}
# ─── Payment complements (REP) and credit notes reconciliation ──────────────
def _to_dec(val):
if val is None:
return Decimal("0")
return Decimal(str(val))
def _payload_customer(payload):
"""Adapt the receptor block of a stored CFDI payload to builder input."""
customer = payload.get("customer") or {}
tax_id = customer.get("tax_id")
if not tax_id:
return None
address = customer.get("address") or {}
return {
"rfc": tax_id,
"razon_social": customer.get("legal_name"),
"regimen_fiscal": customer.get("tax_system"),
"cp": address.get("zip"),
}
def _pago_related_amounts(payload):
"""Amounts of the related documents of a stored pago payload."""
amounts = []
for complement in payload.get("complements") or []:
data = complement.get("data")
blocks = data if isinstance(data, list) else [data]
for block in blocks:
if not isinstance(block, dict):
continue
for doc in block.get("related_documents") or []:
try:
amounts.append(round(float(doc.get("amount") or 0), 2))
except (TypeError, ValueError):
continue
return amounts
def _items_signature(payload):
"""Comparable signature of a payload's concepts (product key, qty, price)."""
signature = []
for item in payload.get("items") or []:
product = item.get("product") or {}
try:
signature.append(
(
str(product.get("product_key") or ""),
int(item.get("quantity") or 0),
round(float(product.get("price") or 0), 2),
)
)
except (TypeError, ValueError):
continue
return tuple(sorted(signature))
def _stored_signatures(cur, sale_id, cfdi_type):
"""Signatures/amounts of the non-cancelled CFDIs of a sale, for dedupe."""
cur.execute(
"""
SELECT payload_unsigned FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status <> 'cancelled'
""",
(sale_id, cfdi_type),
)
values = []
for (payload_unsigned,) in cur.fetchall():
try:
payload = json.loads(payload_unsigned or "{}")
except ValueError:
continue
if cfdi_type == "pago":
values.extend(_pago_related_amounts(payload))
else:
signature = _items_signature(payload)
if signature:
values.append(signature)
return values
def _enqueue_pending_pagos(conn, cur, tenant_config, since_dt):
"""Enqueue payment complements for every payment of a stamped PPD invoice."""
cur.execute(
"""
SELECT q.sale_id, q.uuid_fiscal, q.payload_unsigned,
s.total, s.subtotal, s.discount_total, s.tax_total
FROM cfdi_queue q
JOIN sales s ON s.id = q.sale_id
WHERE q.type = 'ingreso' AND q.status = 'stamped'
AND q.uuid_fiscal IS NOT NULL AND q.sale_id IS NOT NULL
AND (%s::timestamptz IS NULL OR EXISTS (
SELECT 1 FROM sale_payments sp
WHERE sp.sale_id = q.sale_id
AND sp.amount > 0
AND sp.created_at >= %s))
ORDER BY q.sale_id
""",
(since_dt, since_dt),
)
created = []
for sale_id, original_uuid, payload_unsigned, total, subtotal, discount_total, tax_total in cur.fetchall():
try:
invoice_payload = json.loads(payload_unsigned or "{}")
except ValueError:
continue
if str(invoice_payload.get("payment_method") or "").upper() != "PPD":
continue
customer = _payload_customer(invoice_payload)
if not customer:
continue
cur.execute(
"""
SELECT id, amount, method, created_at
FROM sale_payments
WHERE sale_id = %s AND amount > 0
ORDER BY id
""",
(sale_id,),
)
payments = cur.fetchall()
if not payments:
continue
already = _stored_signatures(cur, sale_id, "pago")
base_total = _to_dec(subtotal) - _to_dec(discount_total)
tax_rate = (_to_dec(tax_total) / base_total) if base_total > 0 else Decimal("0.16")
balance = _to_dec(total)
for installment, (payment_id, amount, method, created_at) in enumerate(payments, start=1):
paid = _to_dec(amount)
previous_balance = balance if balance > Decimal("0") else Decimal("0")
balance -= paid
if paid <= 0:
continue
if round(float(paid), 2) in already:
already.remove(round(float(paid), 2))
continue
if since_dt and created_at and created_at < since_dt:
continue
try:
payload = build_pago_payload(
{
"id": payment_id,
"amount": paid,
"payment_method": method,
"date": created_at,
"installment": installment,
"last_balance": previous_balance,
"tax_rate": tax_rate,
},
tenant_config,
customer,
original_uuid,
idempotency_key=f"nexus-rep-{payment_id}",
)
queued = enqueue_cfdi(conn, sale_id, "pago", payload)
except Exception as e:
logger.warning("Could not enqueue payment complement for sale_payment %s: %s", payment_id, e)
continue
created.append(
{
"cfdi_id": queued["id"],
"sale_id": sale_id,
"sale_payment_id": payment_id,
"amount": float(paid),
"installment": installment,
"original_uuid": original_uuid,
}
)
return created
def _enqueue_pending_egresos(conn, cur, tenant_config, since_dt):
"""Enqueue credit notes (egreso) for returns of already stamped sales."""
cur.execute(
"""
SELECT r.id, r.sale_id, r.created_at, s.currency, s.exchange_rate,
ing.uuid_fiscal, ing.payload_unsigned
FROM returns r
JOIN sales s ON s.id = r.sale_id
JOIN LATERAL (
SELECT q.uuid_fiscal, q.payload_unsigned
FROM cfdi_queue q
WHERE q.sale_id = r.sale_id AND q.type = 'ingreso'
AND q.status = 'stamped' AND q.uuid_fiscal IS NOT NULL
ORDER BY q.stamped_at DESC NULLS LAST, q.id DESC
LIMIT 1
) ing ON TRUE
WHERE r.status = 'completed'
AND (%s::timestamptz IS NULL OR r.created_at >= %s)
ORDER BY r.id
""",
(since_dt, since_dt),
)
created = []
for return_id, sale_id, created_at, currency, exchange_rate, original_uuid, payload_unsigned in cur.fetchall():
if since_dt and created_at and created_at < since_dt:
continue
try:
invoice_payload = json.loads(payload_unsigned or "{}")
except ValueError:
invoice_payload = {}
customer = _payload_customer(invoice_payload)
if not customer:
continue
cur.execute(
"""
SELECT si.part_number, si.name, ri.quantity, si.unit_price, si.discount_pct,
si.tax_rate, si.clave_prod_serv, si.clave_unidad
FROM return_items ri
JOIN sale_items si ON si.id = ri.sale_item_id
WHERE ri.return_id = %s
ORDER BY ri.id
""",
(return_id,),
)
items = []
for part_number, name, quantity, unit_price, discount_pct, tax_rate, clave_prod, clave_unidad in cur.fetchall():
price = float(unit_price or 0)
items.append(
{
"part_number": part_number,
"name": name or part_number,
"quantity": int(quantity or 0),
"unit_price": price,
"discount_amount": round(price * float(discount_pct or 0) / 100 * int(quantity or 0), 2),
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
"clave_prod_serv": clave_prod,
"clave_unidad": clave_unidad,
}
)
if not items:
continue
try:
payload = build_egreso_payload(
{
"id": sale_id,
"items": items,
"currency": currency,
"exchange_rate": exchange_rate,
"forma_pago_sat": invoice_payload.get("payment_form"),
},
tenant_config,
customer,
original_uuid,
folio_number=return_id,
idempotency_key=f"nexus-egreso-{return_id}",
)
except Exception as e:
logger.warning("Could not build credit note for return %s: %s", return_id, e)
continue
signature = _items_signature(payload)
already = _stored_signatures(cur, sale_id, "egreso")
if signature and signature in already:
already.remove(signature)
continue
try:
queued = enqueue_cfdi(conn, sale_id, "egreso", payload)
except Exception as e:
logger.warning("Could not enqueue credit note for return %s: %s", return_id, e)
continue
created.append(
{
"cfdi_id": queued["id"],
"sale_id": sale_id,
"return_id": return_id,
"original_uuid": original_uuid,
}
)
return created
def enqueue_pending_complements(conn, tenant_config=None, since=None, backfill=False):
"""Enqueue the missing payment complements and credit notes.
Every payment of a stamped PPD invoice gets a CFDI type "P" linked to the
original UUID, and every completed return of a stamped sale gets a CFDI
type "E". Idempotent: a movement that already has a non-cancelled CFDI of
the matching type/amount is skipped.
Args:
conn: psycopg2 connection
tenant_config: issuer config (only the default zip is used)
since: datetime or ISO string; reconcile movements created after it.
Defaults to COMPLEMENT_LOOKBACK_DAYS ago.
backfill: reconcile the whole history, ignoring `since`
Returns:
dict: {pagos: [...], egresos: [...], since: iso string}
"""
if backfill:
since_dt = None
elif since:
if isinstance(since, datetime):
since_dt = since
else:
since_dt = datetime.fromisoformat(str(since))
if since_dt.tzinfo is None:
since_dt = since_dt.replace(tzinfo=timezone.utc)
else:
since_dt = datetime.now(timezone.utc) - timedelta(days=COMPLEMENT_LOOKBACK_DAYS)
tenant_config = tenant_config or {}
cur = conn.cursor()
result = {"pagos": [], "egresos": [], "since": str(since_dt) if since_dt else None}
for key, sweep in (("pagos", _enqueue_pending_pagos), ("egresos", _enqueue_pending_egresos)):
try:
result[key] = sweep(conn, cur, tenant_config, since_dt)
except Exception as e:
logger.warning("Could not reconcile %s complements: %s", key, e)
conn.rollback()
conn.commit()
cur.close()
return result
def process_queue(conn, tenant_config, dry_run=False):
"""Process all pending CFDI items in the queue.
@@ -96,6 +492,15 @@ def process_queue(conn, tenant_config, dry_run=False):
Returns:
dict: {processed: int, stamped: int, failed: int, details: [...]}
"""
# First enqueue the complements (REP / credit notes) that are still missing,
# so they are stamped in this same run.
if not dry_run:
try:
enqueue_pending_complements(conn, tenant_config)
except Exception as e:
logger.warning("Complement reconciliation failed: %s", e)
conn.rollback()
cur = conn.cursor()
cur.execute(
@@ -164,10 +569,17 @@ def process_queue(conn, tenant_config, dry_run=False):
error_message = NULL
WHERE id = %s
""",
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
)
conn.commit()
# Enviar PDF por WhatsApp al cliente (best effort)
try:
if _sale_id:
_send_cfdi_pdf_wa(conn, invoice_id, _sale_id, tenant_config)
except Exception:
pass
results["stamped"] += 1
results["details"].append(
{