SIFEN — Envío async por lotes (cómo funciona)
Fecha de cierre del flujo: 2026-04-09 Estado: Production-ready (probado con 50/50 DTEs aprobados en ~12s) Tickets Jira: FAC-72 (épica), FAC-82 (job de sincronización)
Qué logramos
Pipeline completo de envío asíncrono por lotes a SIFEN, sin intervención manual:
DTEs en BD (signed)
↓
SifenBatchService::enviarLote() [POST único, hasta 50 DTEs]
↓
SIFEN responde 0300 "Lote recibido" → DTEs pasan a 'sent' + prot_lote
↓
SincronizarEstadosSifenJob (cada 5 min) [scheduler Laravel]
↓
SIFEN responde 0362 "Lote procesado"
↓
DTEs pasan a 'approved' o 'rejected' según gResProcLote/gResProc/dCodRes
Benchmark real (lote 85789778101447742):
| Etapa | Tiempo |
|---|---|
| Crear y firmar 50 DTEs (BuildAndSignDteJob sync) | 2.6s |
| Enviar lote único (1 ZIP, 1 POST mTLS) | 1.1s |
| Procesamiento SIFEN | ~8s |
| Sincronizar estados (1 query siResultLoteDE) | <1s |
| Total | ~12s para 50 facturas aprobadas |
Componentes del pipeline
1. SifenBatchService::enviarLote(Collection $dtes, SifenEmitter $emitter, int $batchId)
Orquesta el envío. Flujo interno:
- Filtrar firmados (
filtrarFirmados): solo DTEs en estadosignedconxml_signed_path. - Construir XML del lote (
construirXmlLote): concatena los XMLs firmados como string envueltos en<rLoteDE xmlns="http://ekuatia.set.gov.py/sifen/xsd">...</rLoteDE>. NO usaDOMDocument::importNode()(ver "Por qué no funcionaba"). - Empaquetar en ZIP (
construirZipBase64): crea archivo temporal.zipcon1.xmladentro, lee bytes, base64-encode, elimina temporal. - Construir SOAP envelope (
SifenSoapEnvelopeBuilder::buildAsincronoLote): inyecta el ZIP base64 en<xDE>...</xDE>dentro de<rEnvioLote>. - POST mTLS (
SifenHttpClient::post) al endpointasync/recibe-lote.wsdlcon cert P12. - Persistir respuesta: extrae
dProtConsLotedel body, actualiza todos los DTEs del lote aestado=sent,prot_lote=<protocolo>,last_sent_at=now(). - Audit trail: persiste request y response SOAP en
storage/app/private/sifen/logs/{Y-m}/.
2. SincronizarEstadosSifenJob (cola default, scheduler cada 5 min)
Reemplaza el proceso manual de descargar .txt del portal SIFEN.
// routes/console.php
Schedule::job(new SincronizarEstadosSifenJob())->everyFiveMinutes();
Flujo interno (handle):
- Buscar DTEs en estado
sentconprot_lote != nullylast_sent_at <= now()-5min. - Agrupar por
prot_lote(un lote → una sola consulta WS). - Para cada
prot_lote, llamarprocesarLote():- Construir envelope
rEnviConsLoteDecondProtConsLote=<protocolo>. - POST mTLS al endpoint
consultas/consulta-lote.wsdl. - Extraer
dCodResLotdel body (NO confiar en$response->codResque extrae el inner DE-level). - Si
0361(lote en proceso) → return, reintentar próximo ciclo. - Si
0362(lote procesado) → parseargResProcLotey actualizar DTEs. - Cualquier otro → rechazar todos los DTEs del lote con el código recibido.
- Construir envelope
parsearResultadosIndividuales(): extrae cada<gResProcLote>conid(CDC),dEstRes,gResProc/dCodRes,gResProc/dMsgRes.actualizarDtes(): mapea CDC → estado final usandoSifenCodigoRespuesta::isAprobado(). Códigos0260(DE_AUTORIZADO) y los aprobado-con-observaciones (02xxAO) →EstadoDte::APPROVED. Cualquier otro →EstadoDte::REJECTED.
Garantías operativas:
WithoutOverlapping('sincronizar-estados-sifen')— solo una instancia corre a la vez (TTL 10 min).tries=3, backoff=[30,60,120]— reintentos ante fallos de red/TLS.
Por qué NO funcionaba (los 3 bugs encadenados)
Bug #1 — xmlns desaparecía del rDE dentro del lote
Síntoma: SIFEN respondía 0362 (lote procesado) pero cada DE individual venía rechazado con 0160 "XML malformado: Cannot find the declaration of element 'rDE'".
Causa raíz: El código usaba DOMDocument::importNode():
$root = $domLote->createElementNS($sifenNs, 'rLoteDE'); // namespace por defecto
$imported = $domLote->importNode($tmpDom->documentElement, true);
$root->appendChild($imported); // ← PHP elimina el xmlns del rDE porque ya está heredado
Resultado serializado:
<rLoteDE xmlns="http://ekuatia.set.gov.py/sifen/xsd"><rDE xmlns:xsi="..." xsi:schemaLocation="...">
↑ ¡SIN xmlns propio!
Pero el Manual Técnico v150 §7.2.2.2 dice textualmente:
"En el caso de envío de lote, cada DE debe contener la declaración de su namespace individual"
Y muestra el ejemplo:
<rDE
xmlns="http://ekuatia.set.gov.py/sifen/xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="...">
En marzo 2026 SIFEN TEST aceptaba el lote sin xmlns explícito. Desde abril valida estricto y rechaza con el confuso mensaje del Xerces "Cannot find the declaration of element 'rDE'".
Fix aplicado (SifenBatchService::construirXmlLote):
private function construirXmlLote(Collection $signed): string {
$sifenNs = (string) config('sifen.namespaces.sifen');
$rdes = '';
foreach ($signed as $dte) {
$rdes .= $this->sanitizarXml(Storage::disk('local')->get($dte->xml_signed_path));
}
return '<?xml version="1.0" encoding="UTF-8"?>'
. '<rLoteDE xmlns="' . $sifenNs . '">' . $rdes . '</rLoteDE>';
}
Concatena los XMLs firmados como string puro. Cada rDE conserva intactos su xmlns, xmlns:xsi y xsi:schemaLocation. La firma XMLDSig NO se afecta (ningún byte del DE firmado cambia).
Bug #2 — Parser buscaba un nodo que no existía
Síntoma: Después del fix #1, siResultLoteDE devolvía 0362 correctamente, pero el job loguea "0362 sin nodos xResultDE en el body" y los DTEs quedaban en sent para siempre.
Causa raíz: El parser buscaba //ns2:xResultDE (estructura del manual) pero la respuesta REAL en TEST 2026 usa otra:
<ns2:rResEnviConsLoteDe>
<ns2:dCodResLot>0362</ns2:dCodResLot>
<ns2:dMsgResLot>Procesamiento de lote {...} concluido</ns2:dMsgResLot>
<ns2:gResProcLote> ← NO es xResultDE
<ns2:id>CDC44...</ns2:id> ← NO es dCDC
<ns2:dEstRes>Aprobado</ns2:dEstRes>
<ns2:dProtAut>48912209</ns2:dProtAut>
<ns2:gResProc>
<ns2:dCodRes>0260</ns2:dCodRes>
<ns2:dMsgRes>Aprobado</ns2:dMsgRes>
</ns2:gResProc>
</ns2:gResProcLote>
</ns2:rResEnviConsLoteDe>
Fix aplicado: parser primero intenta gResProcLote (variante TEST 2026), con fallback al xResultDE histórico documentado en el manual por compatibilidad.
Bug #3 — procesarLote() confundía código de lote con código de DE
Síntoma: Después del fix #2, el parser extraía cod_res=0260 y msg=Aprobado correctamente, pero el DTE quedaba en estado rejected.
Causa raíz: SifenHttpClient::post() parsea el primer <ns2:dCodRes> que encuentra en el body y lo asigna a $response->codRes. Para siResultLoteDE ese primero NO es el código del lote, es el del DE individual:
<ns2:dCodResLot>0362</ns2:dCodResLot> ← código del LOTE (lo que queremos)
...
<ns2:gResProc>
<ns2:dCodRes>0260</ns2:dCodRes> ← código del DE (lo que extraía SifenHttpClient)
</ns2:gResProc>
procesarLote() hacía tryFromCodigo($response->codRes) que devolvía DE_AUTORIZADO, no matcheaba ni LOTE_EN_PROCESAMIENTO (0361) ni LOTE_PROCESADO (0362), y caía al else hardcoded:
$dtes->each(fn ($dte) => $dte->update([
'estado' => EstadoDte::REJECTED, // ← marca todos como rechazados
'sifen_result_code' => $response->codRes, // pero con cod=0260 y msg="Aprobado"
'sifen_result_msg' => $response->msgRes, // ¡contradicción!
]));
Fix aplicado: nuevo helper extraerCodResLot() que lee //ns2:dCodResLot directamente del body con XPath, antes de decidir el flujo:
$codResLot = $this->extraerCodResLot($response->body);
$codigo = SifenCodigoRespuesta::tryFromCodigo($codResLot !== '' ? $codResLot : $response->codRes);
Códigos SIFEN relevantes (confirmados en TEST 2026)
| Código | Nivel | Significado |
|---|---|---|
0260 | DE individual (sync y lote) — gResProc/dCodRes | DE aprobado |
0300 | lote (dCodResLot de rEnvioLote) | Lote recibido (NO es aprobación del DE, solo de la cola) |
0361 | dCodResLot de siResultLoteDE | Lote en procesamiento — reintentar |
0362 | dCodResLot de siResultLoteDE | Lote procesado — leer gResProcLote |
0160 | DE individual | XML mal formado (causa típica: xmlns ausente en rDE del lote) |
1002 | DE individual | CDC duplicado |
dEstRes viene como string "Aprobado" o "Rechazado" y sirve como criterio adicional human-readable.
Cómo probar
Envío individual (1 DTE como lote)
php artisan tinker --execute="require base_path('scripts/tinker_test_sincronizar_lote.php');"
Crea 1 DTE → firma → envía como lote → fuerza last_sent_at atrás → corre el sync job → reporta estado final. Resultado esperado:
[6/6] Resultado final del DTE:
estado = approved
sifen_cod_res = 0260
sifen_msg_res = Aprobado
Lote de N DTEs (hasta 50)
Adaptar el script o usar el endpoint REST:
POST /api/sifen/dtes/lote
Authorization: Bearer <token>
Content-Type: application/json
{ "dtes": [ {...}, {...}, ... ] }
El endpoint encola BuildAndSignDteJob para cada DTE y al terminar dispara SifenBatchService::enviarLote() con la colección completa. El sync job (cada 5 min) cierra el ciclo.
Verificar estado de DTEs
php artisan tinker --execute="
\App\Domains\Sifen\Models\SifenDte::orderByDesc('id')->take(10)->get(['id','estado','sifen_result_code','prot_lote','cdc'])->each(fn(\$d) => print
str_pad((string)\$d->id,4).' | '.str_pad(\$d->estado->value,12).' | '.(\$d->sifen_result_code ?? '----').' | '.(\$d->prot_lote ?? '-').PHP_EOL
);"
Archivos modificados
| Archivo | Cambio |
|---|---|
app/Domains/Sifen/Services/Soap/SifenBatchService.php | construirDomLote() → construirXmlLote() (string concat). Backup: .bak_20260409. |
app/Jobs/Sifen/SincronizarEstadosSifenJob.php | Parser nuevo gResProcLote + helper extraerCodResLot(). |
Referencias
- Manual Técnico v150 —
docs/Manual Técnico Versión 150.pdf- §7.2.2.2 Particularidad del envío de lote (xmlns en cada rDE)
- §9.2 WS recepción lote DE – siRecepLoteDE (max 50 DEs por lote)
- §12.3.3 WS consulta resultado de lote DE – siResultLoteDE
docs/manual_v150_cap12_validaciones.md— códigos de respuesta detalladosdocs/SIFEN_SINCRONIZACION_LOTES.md— documentación inicial del job