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
| Archivo | Rol |
|---|---|
app/Domains/Sifen/Actions/DispatchEnvioMasivoAction.php | Orquesta: valida Horizon, crea SifenEnvioMasivo, chunkea de a 50, despacha N jobs. |
app/Jobs/Sifen/ProcesarLoteCompletoJob.php | Por sub-lote: firma 50 DTEs draft + envía SOAP siRecepLoteDE + actualiza counters. |
app/Jobs/Sifen/EnviarLoteSifenJob.php | Variante para 50 DTEs ya signed. |
app/Jobs/Sifen/SincronizarEstadosSifenJob.php | Cada 5 min: consulta siResultLoteDE con cada prot_lote y resuelve estado final. |
app/Jobs/Sifen/MarcarEnviosMasivosStalledJob.php | Watchdog cada 5 min: marca como stalled envíos sin avance > 5 min. |
app/Domains/Sifen/Services/HorizonHealthService.php | Health-check de Horizon. Cache 3s. |
app/Domains/Sifen/Models/SifenEnvioMasivo.php | Tracking 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:
| cod | Significado | Estado DTE |
|---|---|---|
| 0260 | DE autorizado | approved |
| 0270 | DE autorizado con observaciones | approved |
| 0160 | XML mal formado | rejected |
| 1001 | CDC duplicado (ya existía en SIFEN) | rejected |
| 1002 | Documento electrónico duplicado | rejected |
| 0361 | Lote 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_respor 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 DTEsrejected/errorasignedy reencolaSendDteJob.
- 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:
| DTEs | Sub-lotes | Duración | Throughput |
|---|---|---|---|
| 4500 | 90 | 158 s | 28.5 DTE/s |
| 5000 | 100 | 134 s | 37.3 DTE/s |
| 10000 | 200 | 272 s | 36.9 DTE/s |
| 19500 | 390 | 12 m 52 s | 37.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
- Usuario va a
/monitor-sifen→ tab Lotes Asíncronos → "Nuevo Lote". - Modal con DTEs
draft/signedpaginados (50/pág). Selecciona N (miles posibles). - Click "Procesar Lote SIFEN". Pre-check Horizon. Si OK, crea
SifenEnvioMasivo, chunkea, despacha N jobs. - UI cambia a tab Envíos Masivos.
wire:poll.5sactualiza progreso (1 solo poll global condicionado a actividad). - Cada job termina: incrementa counter atómico, escribe
prot_loteen sus 50 DTEs. - El último worker cierra el envío con
finalizar('completed'). - 5 min después:
SincronizarEstadosSifenJobconsulta SIFEN y actualizaestadode cada DTE individual aapproved/rejected. - Usuario expande el detalle → ve desglose real por sub-lote → reenvía errores si hace falta.