Saltar al contenido principal

Envío masivo SIFEN — pipeline async

Procesar miles de DTEs en un solo disparo, dividiéndolos en sub-lotes de 50 (límite SIFEN) y despachándolos en paralelo a siRecepLoteDE con tracking agregado en SifenEnvioMasivo.


1. Componentes

DispatchEnvioMasivoAction


SifenEnvioMasivo ─── tracking agregado (counters atómicos)

├──── chunk(50) ──── ProcesarLoteCompletoJob × N (cola sifen-batch)
│ │
│ ▼
│ SifenBatchService::enviarLote ── siRecepLoteDE
│ │ cod=0300 + prot_lote
│ ▼
│ incrementEach() atómico

└──── (5 min después) SincronizarEstadosSifenJob (cola default, scheduled)
│ siResultLoteDE
▼ cod_res FINAL por DTE
actualizarDtes()
estado: sent → approved/rejected
ArchivoRol
app/Domains/Sifen/Actions/DispatchEnvioMasivoAction.phpOrquesta: valida Horizon, crea SifenEnvioMasivo, chunkea de a 50, despacha N jobs.
app/Jobs/Sifen/ProcesarLoteCompletoJob.phpPor sub-lote: firma 50 DTEs draft + envía SOAP siRecepLoteDE + actualiza counters.
app/Jobs/Sifen/EnviarLoteSifenJob.phpVariante para 50 DTEs ya signed.
app/Jobs/Sifen/SincronizarEstadosSifenJob.phpCada 5 min: consulta siResultLoteDE con cada prot_lote y resuelve estado final.
app/Jobs/Sifen/MarcarEnviosMasivosStalledJob.phpWatchdog cada 5 min: marca como stalled envíos sin avance > 5 min.
app/Domains/Sifen/Services/HorizonHealthService.phpHealth-check de Horizon. Cache 3s.
app/Domains/Sifen/Models/SifenEnvioMasivo.phpTracking del envío (counters, estado, timestamps, duración).

2. Counters atómicos — fix de race condition

SifenEnvioMasivo mantiene 5 counters: lotes_completados, lotes_aceptados, lotes_rechazados, lotes_fallidos, total_lotes. Es crítico que sean atómicos — hasta 8 workers concurrentes los actualizan a la vez.

Patrón correcto (en uso):

SifenEnvioMasivo::where('id', $envio->id)->incrementEach([
'lotes_completados' => 1,
'lotes_aceptados' => 1,
]);

$fresh = SifenEnvioMasivo::find($envio->id);
if ($fresh && $fresh->estado === 'processing'
&& $fresh->lotes_completados >= $fresh->total_lotes) {
$fresh->finalizar('completed');
}

incrementEach() ejecuta UPDATE ... SET col = col + 1 directo en SQL — atómico por definición.

Patrón incorrecto (eliminado):

// NO hacer — pierde increments con concurrencia
DB::transaction(function () use ($envio) {
$envio->refresh();
$envio->lotes_completados++;
$envio->save();
});

La transacción NO previene el race porque no toma row-lock. Dos workers pueden leer el mismo valor antes de que el primero guarde.

Síntoma del bug: envíos con lotes_completados < total_lotes aunque todos los prot_lote ya estén en BD. Si pasa: scripts en scripts/reconciliar_envio_*.php reconstruyen counters desde laravel.log y cierran el envío.


3. Pipeline async de SIFEN — 2 fases

Fase 1 — Recepción (siRecepLoteDE)

POST recibe-lote.wsdl → cod=0300 "Lote recibido con éxito" + prot_lote

Esto NO es aprobación. Es solo el ack de que SIFEN recibió el lote en su cola interna. Los 50 DTEs quedan en estado sent con prot_lote asignado.

Fase 2 — Resultado individual (siResultLoteDE)

SincronizarEstadosSifenJob corre cada 5 min, consulta el endpoint con cada prot_lote distinto y obtiene el resultado final por DTE:

codSignificadoEstado DTE
0260DE autorizadoapproved
0270DE autorizado con observacionesapproved
0160XML mal formadorejected
1001CDC duplicado (ya existía en SIFEN)rejected
1002Documento electrónico duplicadorejected
0361Lote aún en procesamiento(mantiene sent, retry)

Confusión común: ver cod=0300 y pensar que el DTE ya fue rechazado/aprobado. NO. Hay que esperar al sync.


4. Endpoints API

POST /api/sifen/dtes/lote-masivo
Authorization: Bearer <sanctum-token>
X-Empresa-Id: 89
Content-Type: application/json

{ "dte_ids": [101, 102, 103, ..., 4600] }

Response 202 Accepted:

{
"envio_id": 12,
"total_dtes": 4500,
"total_lotes": 90,
"estado": "processing",
"started_at": "2026-05-04T21:15:56-04:00"
}

Polling con GET /api/sifen/envios-masivos/12 devuelve los counters actualizados.


5. UI — /monitor-sifen tab "Envíos Masivos"

  • Fila principal: ID, total DTEs, progreso (X/Y + %), aceptados/rechazados/fallidos, duración, estado badge, botones Cancelar/Detalle.
  • Animación dinámica: stripes diagonales + glow azul + shimmer + card pulse cuando estado='processing'.
  • Drill-down: paginador de 15 sub-lotes con desglose por estado individual de DTEs (aprobados / observados / rechazados / pendientes) + breakdown completo de cod_res por sub-lote.
  • Acciones por sub-lote:
    • Ver DTEs: abre el modal del tab "Lotes Asíncronos" con la lista paginada de 50 DTEs del sub-lote.
    • Reenviar N err. (solo si hay dtes_rejected > 0): pone DTEs rejected/error a signed y reencola SendDteJob.
  • Cancelar envío: flush Redis + estado='cancelled'.

6. Watchdog — envíos stalled

Si Horizon estaba apagado al disparar el envío, los jobs quedan en Redis sin procesador. MarcarEnviosMasivosStalledJob (cada 5 min) los marca:

SifenEnvioMasivo::where('estado', 'processing')
->where('started_at', '<', now()->subMinutes(5))
->where('lotes_completados', 0)
->update(['estado' => 'stalled', 'finished_at' => now()]);

El pre-check HorizonHealthService::isProcessingQueue('sifen-batch') en DispatchEnvioMasivoAction evita el problema arriba: aborta antes de crear el SifenEnvioMasivo si Horizon no está cubriendo la cola.


7. Scripts de operación

scripts/prepare_4500_drafts.php <N> # Genera N borradores FE (acepta argv)
scripts/dispatch_envio.php <startNum> <endNum> # Despacha envío y poll hasta cerrar
scripts/benchmark_3_envios.sh # 3 rounds correlativos con métricas
scripts/dispatch_sync.php # SincronizarEstadosSifenJob manual
scripts/peek_todos_envios.php # Lista todos los SifenEnvioMasivo
scripts/diag_envios_pendientes.php # Diagnóstico processing/stalled
scripts/reconciliar_envio_<id>.php # Reconstruye counters desde log
scripts/backfill_duraciones.php # Llena duracion_seconds en cerrados

8. Performance medida

Test contra SIFEN-test con 8 workers paralelos en sifen-batch:

DTEsSub-lotesDuraciónThroughput
450090158 s28.5 DTE/s
5000100134 s37.3 DTE/s
10000200272 s36.9 DTE/s
1950039012 m 52 s37.0 DTE/s (3 envíos correlativos)

La latencia SOAP de SIFEN-test fija el techo (~1.4 s por sub-lote end-to-end). En producción contra SIFEN-prod debería ser similar.


9. Errores conocidos

cURL error 56: Recv failure: Connection reset by peer

Ocurre cuando SIFEN-test rechaza la conexión TCP bajo carga (~1% de los sub-lotes). El sub-lote falla; los DTEs del chunk quedan en estado signed con prot_lote=null (no llegaron a SIFEN). NO cuentan como rejected — se pueden reencolar reseteando prot_lote=null y disparando otro envío masivo solo con esos IDs.

Counter desfasado vs realidad

Si después de modificar el código de un Job ves un envío con lotes_completados < prot_lotes_reales, es porque Horizon tenía el código viejo cacheado en memoria. Ejecutá:

php artisan horizon:terminate
php artisan horizon # reinicia con código nuevo

Y reconciliá con scripts/reconciliar_envio_<id>.php.


10. Flujo completo desde la UI

  1. Usuario va a /monitor-sifen → tab Lotes Asíncronos → "Nuevo Lote".
  2. Modal con DTEs draft/signed paginados (50/pág). Selecciona N (miles posibles).
  3. Click "Procesar Lote SIFEN". Pre-check Horizon. Si OK, crea SifenEnvioMasivo, chunkea, despacha N jobs.
  4. UI cambia a tab Envíos Masivos. wire:poll.5s actualiza progreso (1 solo poll global condicionado a actividad).
  5. Cada job termina: incrementa counter atómico, escribe prot_lote en sus 50 DTEs.
  6. El último worker cierra el envío con finalizar('completed').
  7. 5 min después: SincronizarEstadosSifenJob consulta SIFEN y actualiza estado de cada DTE individual a approved/rejected.
  8. Usuario expande el detalle → ve desglose real por sub-lote → reenvía errores si hace falta.