Resumen
FEPDF.ProcesarPlantilla() lanza KeyError cuando un ítem del detalle tiene iva_id 1 (No Gravado) o 2 (Exento).
ivas_ds (pyfepdf.py:205) mapea sólo las alícuotas con porcentaje:
ivas_ds = {3: 0, 4: 10.5, 5: 21, 6: 27, 8: 5, 9: 2.5}
Los códigos 1 y 2 son valores válidos de la tabla FEParamGetTiposIva de AFIP, pero no tienen porcentaje asociado, así que no están en ese diccionario. El acceso de pyfepdf.py:1336 no los contempla:
imp_it_iva = Decimal(it['imp_iva'] or "0.00")
if not imp_it_iva and it['importe']:
p = self.ivas_ds[int(it['iva_id'])] # KeyError con iva_id 1 o 2
La rama existe para despejar el IVA contenido en el importe bruto de los ítems que no informan imp_iva. Un ítem exento entra igual, porque su imp_iva es 0 por definición, no por omisión — y ahí revienta.
Reproducción
Script autocontenido, sólo con la API pública. Se ejecuta desde el directorio de instalación de la librería:
import os, sys
os.chdir("/code/pyafipws") # ajustar al directorio de instalación
sys.path.insert(0, "/code")
from pyafipws.pyfepdf import FEPDF
fepdf = FEPDF()
fepdf.CargarFormato("plantillas/factura.csv")
fepdf.CUIT = "20267565393"
fepdf.CrearFactura(
concepto=1, tipo_doc=80, nro_doc="20267565393",
tipo_cbte=1, punto_vta=1, cbte_nro=1,
imp_total=221.00, imp_neto=100.00, imp_iva=21.00,
imp_op_ex=100.00,
fecha_cbte="20260811", cae="12345678901234", fch_venc_cae="20260821",
nombre_cliente="Cliente Ejemplo",
)
# Ítem gravado al 21% con imp_iva informado: no entra a la rama del despeje.
fepdf.AgregarDetalleItem(None, None, "1", "Item gravado", 1, 7, 100.00, 0.00,
iva_id=5, imp_iva=21.00, importe=100.00, despacho="")
# Ítem EXENTO (iva_id=2), imp_iva=0 -> KeyError: 2
fepdf.AgregarDetalleItem(None, None, "2", "Item exento", 1, 7, 100.00, 0.00,
iva_id=2, imp_iva=0, importe=100.00, despacho="")
fepdf.CrearPlantilla(papel="A4", orientacion="portrait")
fepdf.ProcesarPlantilla(num_copias=1, lineas_max=24, qty_pos="izq")
print("EXCEPCION:", fepdf.Excepcion)
print("TRACEBACK:", fepdf.Traceback)
Salida:
EXCEPCION: KeyError: 2
TRACEBACK: Traceback (most recent call last):
File ".../pyafipws/pyfepdf.py", line 1336, in ProcesarPlantilla
p = self.ivas_ds[int(it['iva_id'])]
KeyError: 2
Con iva_id=1 (No Gravado) pasa lo mismo.
Condiciones de disparo, para acotar el alcance: el ítem informa iva_id 1 o 2, con imp_iva en 0 o ausente e importe distinto de cero, y la factura es A/M — o B con receptor consumidor final, que es cuando se cumple letra_fact != "B" or consumidor_final (pyfepdf.py:1359).
El síntoma que llega al usuario es otro
Esto es lo que hace el problema difícil de diagnosticar: el KeyError no se ve. Lo que se ve es un error de imagen, a varios pasos de distancia.
ProcesarPlantilla atrapa la excepción en su except Exception (pyfepdf.py:1653) para imprimirla dentro del PDF, y deja la traza en <tempdir>/traceback.txt.
- Al abortar antes de tiempo, nunca llega a ejecutar
f.set("AFIP_QR", self.archivo_qr) (pyfepdf.py:1648).
- El elemento
AFIP_QR de las plantillas que vienen con la librería tiene el texto vacío en el CSV:
'AFIP_QR';'I';11.10;261.10;36.10;286.10;None;0;0;0;0;0;0;'I';'';2
- El sanity check de
CrearPlantilla (pyfepdf.py:969-975) intenta resolver las rutas relativas de las imágenes. Con texto vacío, os.path.join(self.InstallDir, "") da el directorio de instalación, que os.path.exists() reporta como existente por ser un directorio, así que lo acepta como ruta de imagen válida:
if field["type"] == "I" and not os.path.exists(text):
if os.path.exists(os.path.join(self.InstallDir, text)):
field["text"] = os.path.join(self.InstallDir, text)
fpdf.image() recibe un directorio. Como no tiene extensión, name.rfind('.') devuelve -1, y el guard if(not pos) no se dispara porque -1 es truthy. El nombre entero termina usándose como si fuera el tipo de imagen.
Resultado final:
RuntimeError: FPDF error: Unsupported image type: /ruta/de/instalacion/pyafipws/
Un error que no menciona ni el IVA, ni el ítem, ni la línea real. Dejo la cadena documentada porque probablemente explique reportes previos con ese mensaje.
Fix propuesto
Guardar el acceso al diccionario. Si el código de IVA no tiene alícuota asociada, no hay IVA contenido que despejar y el importe queda como está:
imp_it_iva = Decimal(it['imp_iva'] or "0.00")
- if not imp_it_iva and it['importe']:
+ if (not imp_it_iva and it['importe']
+ and int(it['iva_id']) in self.ivas_ds):
p = self.ivas_ds[int(it['iva_id'])]
importe_it = Decimal(it['importe'])
Es el mismo criterio que la librería ya aplica sobre este campo en fmt_iva (pyfepdf.py:799), que tolera los códigos sin alícuota devolviendo "":
def fmt_iva(self, i):
if int(i) in self.ivas_ds:
...
else:
return ""
Con ese cambio, el script de arriba genera el PDF correctamente.
Nota aparte: en las plantillas factura.csv, fca.csv y fcb.csv que vienen con la librería no existen los campos Item.IvaId ni Item.AlicuotaIva, y para una factura B el iva_liq acumulado en esa rama se sobrescribe después con el imp_iva de la cabecera (pyfepdf.py:1524). O sea que en esos casos el bloque no llega a aportar nada al PDF.
Versión
Reproducido en d595b07, que al momento de este reporte es la punta de main.
Con gusto mando un PR con el cambio si les parece bien el enfoque.
Resumen
FEPDF.ProcesarPlantilla()lanzaKeyErrorcuando un ítem del detalle tieneiva_id1 (No Gravado) o 2 (Exento).ivas_ds(pyfepdf.py:205) mapea sólo las alícuotas con porcentaje:Los códigos 1 y 2 son valores válidos de la tabla
FEParamGetTiposIvade AFIP, pero no tienen porcentaje asociado, así que no están en ese diccionario. El acceso de pyfepdf.py:1336 no los contempla:La rama existe para despejar el IVA contenido en el importe bruto de los ítems que no informan
imp_iva. Un ítem exento entra igual, porque suimp_ivaes0por definición, no por omisión — y ahí revienta.Reproducción
Script autocontenido, sólo con la API pública. Se ejecuta desde el directorio de instalación de la librería:
Salida:
Con
iva_id=1(No Gravado) pasa lo mismo.Condiciones de disparo, para acotar el alcance: el ítem informa
iva_id1 o 2, conimp_ivaen 0 o ausente eimportedistinto de cero, y la factura es A/M — o B con receptor consumidor final, que es cuando se cumpleletra_fact != "B" or consumidor_final(pyfepdf.py:1359).El síntoma que llega al usuario es otro
Esto es lo que hace el problema difícil de diagnosticar: el
KeyErrorno se ve. Lo que se ve es un error de imagen, a varios pasos de distancia.ProcesarPlantillaatrapa la excepción en suexcept Exception(pyfepdf.py:1653) para imprimirla dentro del PDF, y deja la traza en<tempdir>/traceback.txt.f.set("AFIP_QR", self.archivo_qr)(pyfepdf.py:1648).AFIP_QRde las plantillas que vienen con la librería tiene el texto vacío en el CSV:CrearPlantilla(pyfepdf.py:969-975) intenta resolver las rutas relativas de las imágenes. Con texto vacío,os.path.join(self.InstallDir, "")da el directorio de instalación, queos.path.exists()reporta como existente por ser un directorio, así que lo acepta como ruta de imagen válida:fpdf.image()recibe un directorio. Como no tiene extensión,name.rfind('.')devuelve-1, y el guardif(not pos)no se dispara porque-1es truthy. El nombre entero termina usándose como si fuera el tipo de imagen.Resultado final:
Un error que no menciona ni el IVA, ni el ítem, ni la línea real. Dejo la cadena documentada porque probablemente explique reportes previos con ese mensaje.
Fix propuesto
Guardar el acceso al diccionario. Si el código de IVA no tiene alícuota asociada, no hay IVA contenido que despejar y el importe queda como está:
Es el mismo criterio que la librería ya aplica sobre este campo en
fmt_iva(pyfepdf.py:799), que tolera los códigos sin alícuota devolviendo"":Con ese cambio, el script de arriba genera el PDF correctamente.
Nota aparte: en las plantillas
factura.csv,fca.csvyfcb.csvque vienen con la librería no existen los camposItem.IvaIdniItem.AlicuotaIva, y para una factura B eliva_liqacumulado en esa rama se sobrescribe después con elimp_ivade la cabecera (pyfepdf.py:1524). O sea que en esos casos el bloque no llega a aportar nada al PDF.Versión
Reproducido en
d595b07, que al momento de este reporte es la punta demain.Con gusto mando un PR con el cambio si les parece bien el enfoque.