Saltar al contenido principal

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):

EtapaTiempo
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:

  1. Filtrar firmados (filtrarFirmados): solo DTEs en estado signed con xml_signed_path.
  2. Construir XML del lote (construirXmlLote): concatena los XMLs firmados como string envueltos en <rLoteDE xmlns="http://ekuatia.set.gov.py/sifen/xsd">...</rLoteDE>. NO usa DOMDocument::importNode() (ver "Por qué no funcionaba").
  3. Empaquetar en ZIP (construirZipBase64): crea archivo temporal .zip con 1.xml adentro, lee bytes, base64-encode, elimina temporal.
  4. Construir SOAP envelope (SifenSoapEnvelopeBuilder::buildAsincronoLote): inyecta el ZIP base64 en <xDE>...</xDE> dentro de <rEnvioLote>.
  5. POST mTLS (SifenHttpClient::post) al endpoint async/recibe-lote.wsdl con cert P12.
  6. Persistir respuesta: extrae dProtConsLote del body, actualiza todos los DTEs del lote a estado=sent, prot_lote=<protocolo>, last_sent_at=now().
  7. 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):

  1. Buscar DTEs en estado sent con prot_lote != null y last_sent_at <= now()-5min.
  2. Agrupar por prot_lote (un lote → una sola consulta WS).
  3. Para cada prot_lote, llamar procesarLote():
    • Construir envelope rEnviConsLoteDe con dProtConsLote=<protocolo>.
    • POST mTLS al endpoint consultas/consulta-lote.wsdl.
    • Extraer dCodResLot del body (NO confiar en $response->codRes que extrae el inner DE-level).
    • Si 0361 (lote en proceso) → return, reintentar próximo ciclo.
    • Si 0362 (lote procesado) → parsear gResProcLote y actualizar DTEs.
    • Cualquier otro → rechazar todos los DTEs del lote con el código recibido.
  4. parsearResultadosIndividuales(): extrae cada <gResProcLote> con id (CDC), dEstRes, gResProc/dCodRes, gResProc/dMsgRes.
  5. actualizarDtes(): mapea CDC → estado final usando SifenCodigoRespuesta::isAprobado(). Códigos 0260 (DE_AUTORIZADO) y los aprobado-con-observaciones (02xx AO) → 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ódigoNivelSignificado
0260DE individual (sync y lote) — gResProc/dCodResDE aprobado
0300lote (dCodResLot de rEnvioLote)Lote recibido (NO es aprobación del DE, solo de la cola)
0361dCodResLot de siResultLoteDELote en procesamiento — reintentar
0362dCodResLot de siResultLoteDELote procesado — leer gResProcLote
0160DE individualXML mal formado (causa típica: xmlns ausente en rDE del lote)
1002DE individualCDC 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

ArchivoCambio
app/Domains/Sifen/Services/Soap/SifenBatchService.phpconstruirDomLote()construirXmlLote() (string concat). Backup: .bak_20260409.
app/Jobs/Sifen/SincronizarEstadosSifenJob.phpParser 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 detallados
  • docs/SIFEN_SINCRONIZACION_LOTES.md — documentación inicial del job