Files
Autoparts-DB/pos/services/cfdi_queue.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
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
2026-10-12 00:57:31 +00:00

846 lines
28 KiB
Python

# /home/Autopartes/pos/services/cfdi_queue.py
"""CFDI queue service: manages the Facturapi timbrado pipeline.
Flow:
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
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
GET /v2/invoices/:id — fetch invoice metadata
DELETE /v2/invoices/:id — cancel with SAT motive
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
"""
import json
import logging
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__)
# Backoff intervals in seconds: 5s, 30s, 2m, 10m, 1h
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.
Uses a transaction-level advisory lock to avoid duplicate provisional
folios when multiple CFDIs are enqueued concurrently.
"""
cur = conn.cursor()
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0]
cur.close()
return f"PRE-{seq:05d}"
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
"""Add a CFDI to the timbrado queue.
Args:
conn: psycopg2 connection
sale_id: int (FK to sales), may be None for global invoices
cfdi_type: 'ingreso' | 'egreso' | 'pago'
payload: dict (Facturapi JSON payload) or str (JSON string)
Returns:
dict: {id, sale_id, type, status, provisional_folio}
"""
provisional_folio = _generate_provisional_folio(conn)
cur = conn.cursor()
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
cur.execute(
"""
INSERT INTO cfdi_queue
(sale_id, type, payload_unsigned, status, provisional_folio)
VALUES (%s, %s, %s, 'pending', %s)
RETURNING id, created_at
""",
(sale_id, cfdi_type, payload_json, provisional_folio),
)
cfdi_id, created_at = cur.fetchone()
cur.close()
return {
"id": cfdi_id,
"sale_id": sale_id,
"type": cfdi_type,
"status": "pending",
"provisional_folio": provisional_folio,
"created_at": str(created_at),
}
# ─── 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.
Sends each pending payload to Facturapi for timbrado. On success, updates
the record with the signed XML and UUID fiscal. On failure, increments
retry_count and records the error.
Args:
conn: psycopg2 connection
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
dry_run: if True, validates payload without stamping
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(
"""
SELECT id, sale_id, type, payload_unsigned, retry_count
FROM cfdi_queue
WHERE status IN ('pending', 'failed')
AND retry_count < %s
ORDER BY created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED
""",
(MAX_RETRIES,),
)
items = cur.fetchall()
results = {"processed": 0, "stamped": 0, "failed": 0, "details": []}
api_key = tenant_config.get("facturapi_key")
if not api_key:
cur.close()
raise ValueError("Facturapi key not configured for tenant")
for cfdi_id, _sale_id, _cfdi_type, payload_unsigned, _retry_count in items:
results["processed"] += 1
# Update status to 'sending'
cur.execute(
"""
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
""",
(cfdi_id,),
)
conn.commit()
try:
payload = json.loads(payload_unsigned or "{}")
if not payload:
raise ValueError("Empty payload in queue item")
if dry_run:
# TODO: Facturapi dry-run validation (not officially supported)
# For now we just skip the API call and mark as stamped with a fake UUID
raise ValueError("dry_run is not supported with Facturapi")
invoice = facturapi_service.create_invoice(tenant_config, payload)
invoice_id = invoice.get("id")
uuid_fiscal = invoice.get("uuid")
# Download signed XML for storage
try:
xml_signed = facturapi_service.download_xml(tenant_config, invoice_id)
xml_signed_str = xml_signed.decode("utf-8") if isinstance(xml_signed, bytes) else str(xml_signed)
except Exception as xml_err:
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
xml_signed_str = ""
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'stamped',
xml_signed = %s,
uuid_fiscal = %s,
external_id = %s,
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""",
(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(
{
"id": cfdi_id,
"status": "stamped",
"uuid": uuid_fiscal,
"external_id": invoice_id,
}
)
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)[:500]}"
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = %s
WHERE id = %s
""",
(error_msg, cfdi_id),
)
conn.commit()
results["failed"] += 1
results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg})
cur.close()
return results
def retry_failed(conn):
"""Find failed items eligible for retry and reset to pending.
Uses exponential backoff: item is eligible for retry only if enough
time has passed since the last attempt based on retry_count.
"""
cur = conn.cursor()
cur.execute(
"""
SELECT id, retry_count, created_at
FROM cfdi_queue
WHERE status = 'failed' AND retry_count < %s
ORDER BY created_at ASC
""",
(MAX_RETRIES,),
)
items = cur.fetchall()
reset_count = 0
now = datetime.utcnow()
for cfdi_id, retry_count, created_at in items:
wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
# Use created_at as approximation for last attempt.
# In production, track last_attempt_at separately.
elapsed = (now - created_at).total_seconds()
if elapsed >= wait_seconds:
cur.execute(
"""
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
""",
(cfdi_id,),
)
reset_count += 1
conn.commit()
cur.close()
return reset_count
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
"""Cancel a stamped CFDI via Facturapi.
SAT cancellation motives:
01: Comprobante emitido con errores con relacion (requires replacement UUID)
02: Comprobante emitido con errores sin relacion
03: No se llevo a cabo la operacion
04: Operacion nominativa relacionada en factura global
Args:
conn: psycopg2 connection
cfdi_id: int (cfdi_queue.id)
motive: str ('01', '02', '03', '04')
replacement_uuid: str (required if motive == '01')
tenant_config: dict with facturapi_key
Returns:
dict: {id, status, message}
Raises:
ValueError: on validation errors
"""
if motive not in ("01", "02", "03", "04"):
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
if motive == "01" and not replacement_uuid:
raise ValueError("Motive 01 requires a replacement UUID")
cur = conn.cursor()
cur.execute(
"""
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
""",
(cfdi_id,),
)
row = cur.fetchone()
if not row:
raise ValueError(f"CFDI queue item {cfdi_id} not found")
_, uuid_fiscal, external_id, current_status = row
if current_status == "cancelled":
raise ValueError("CFDI is already cancelled")
if current_status != "stamped":
# If not stamped, we can just mark as cancelled locally
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'cancelled', cancel_motive = %s
WHERE id = %s
""",
(motive, cfdi_id),
)
conn.commit()
cur.close()
return {"id": cfdi_id, "status": "cancelled", "message": "Cancelled locally (was not stamped)"}
if not tenant_config or not tenant_config.get("facturapi_key"):
cur.close()
raise ValueError("Facturapi key not configured for tenant")
if not external_id:
cur.close()
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
try:
facturapi_service.cancel_invoice(
tenant_config,
external_id,
motive,
replacement_uuid=replacement_uuid,
)
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'cancelled',
cancel_motive = %s,
cancel_replacement_uuid = %s,
error_message = NULL
WHERE id = %s
""",
(motive, replacement_uuid, cfdi_id),
)
conn.commit()
cur.close()
return {
"id": cfdi_id,
"status": "cancelled",
"message": f"Cancelled with SAT (motive {motive})",
}
except Exception as e:
error_msg = f"Cancel failed: {str(e)[:500]}"
cur.execute(
"""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""",
(error_msg, cfdi_id),
)
conn.commit()
cur.close()
raise ValueError(error_msg) from e
def get_queue_status(conn, filters=None):
"""Get CFDI queue items with optional filters."""
filters = filters or {}
cur = conn.cursor()
page = int(filters.get("page", 1))
per_page = min(int(filters.get("per_page", 50)), 200)
where_clauses = ["1=1"]
params = []
if filters.get("status"):
where_clauses.append("q.status = %s")
params.append(filters["status"])
if filters.get("sale_id"):
where_clauses.append("q.sale_id = %s")
params.append(int(filters["sale_id"]))
if filters.get("type"):
where_clauses.append("LOWER(q.type) = LOWER(%s)")
params.append(filters["type"])
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(
f"""
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
q.retry_count, q.provisional_folio, q.error_message,
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
c.name as customer_name, c.rfc,
s.subtotal, s.tax_total, s.total, s.payment_method
FROM cfdi_queue q
LEFT JOIN sales s ON q.sale_id = s.id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE {where}
ORDER BY q.created_at DESC
LIMIT %s OFFSET %s
""",
params + [per_page, (page - 1) * per_page],
)
items = []
for r in cur.fetchall():
items.append(
{
"id": r[0],
"sale_id": r[1],
"type": r[2],
"uuid_fiscal": r[3],
"status": r[4],
"retry_count": r[5],
"provisional_folio": r[6],
"error_message": r[7],
"cancel_motive": r[8],
"created_at": str(r[9]) if r[9] else None,
"stamped_at": str(r[10]) if r[10] else None,
"external_id": r[11],
"customer_name": r[12],
"rfc": r[13],
"subtotal": float(r[14]) if r[14] else 0,
"tax_total": float(r[15]) if r[15] else 0,
"total": float(r[16]) if r[16] else 0,
"payment_method": r[17],
}
)
cur.close()
total_pages = (total + per_page - 1) // per_page
return {
"data": items,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
},
}