diff --git a/.gitattributes b/.gitattributes index 8dc11b44b74bd0f674a1951befc3ebab875a9bf1..59709d0e177f17e90a3d8a179387f0e4565dea52 100644 --- a/.gitattributes +++ b/.gitattributes @@ -35,3 +35,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text app/cache/ddjj_pep.csv filter=lfs diff=lfs merge=lfs -text app/pyafipws/ejemplos/wsfe/delphi/Project1.exe filter=lfs diff=lfs merge=lfs -text +app/utils/logo.png filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7f049bcc3b187308e786a993595d6d150bd87ed6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.10-slim + +WORKDIR /app + +# Install system dependencies for: +# - PostgreSQL (libpq-dev) +# - ddddocr OCR (libglib2.0-0 libsm6 libxext6 libxrender-dev libgl1-mesa-glx) +# - Playwright Chromium (via --with-deps) +# - curl_cffi build tools (gcc, curl) +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libpq-dev \ + curl \ + wget \ + gnupg \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgl1 \ + libgomp1 \ + libssl-dev \ + libsasl2-dev \ + swig \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Configure Playwright browser path to be accessible by any user ID +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN mkdir -p /ms-playwright && chmod -R 777 /ms-playwright +RUN playwright install chromium --with-deps + +# Copy application code +COPY . . + +# Hugging Face Spaces uses port 7860 by default +EXPOSE 7860 + +# Start FastAPI on port 7860 (or $PORT if overridden) +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 2"] + diff --git a/README.md b/README.md index 8bbf4778a6affd4cd53610d307fca10622ab7a82..abca847e0e21511c01ab75e652baab309206919d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,22 @@ --- -title: Crowdata -emoji: 🌍 -colorFrom: indigo -colorTo: yellow -sdk: gradio -sdk_version: 6.20.0 -python_version: '3.12' -app_file: app.py +title: CrowData API +emoji: 🔎 +colorFrom: blue +colorTo: indigo +sdk: docker pinned: false license: mit -short_description: Información pública a tu alcance +app_port: 7860 --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# CrowData API + +API backend de CrowData — Plataforma de inteligencia de datos personales y empresariales en Argentina. + +## Endpoints + +- `GET /api/health` — Health check +- `POST /api/auth/jwt/login` — Autenticación JWT +- `GET /api/reports/persona/{cuit}` — Informe persona +- `GET /api/reports/empresa/{cuit}` — Informe empresa +- `GET /api/docs` — Swagger UI diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..65ec243966ec46e226996a6219ce1c7aec83b42a --- /dev/null +++ b/app.py @@ -0,0 +1,33 @@ +import os +import sys +import subprocess +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("hf_app") + +# Ensure Playwright browser binaries are installed +try: + logger.info("Verificando/instalando Playwright Chromium...") + subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=False) +except Exception as e: + logger.warning(f"Advertencia al instalar Playwright Chromium: {e}") + +import gradio as gr +from app.main import app as fastapi_app + +# Crear interfaz Gradio de aterrizaje para Hugging Face Spaces +with gr.Blocks(title="CrowData API Backend") as demo: + gr.Markdown("# 🦅 CrowData API Backend") + gr.Markdown("El backend de CrowData (FastAPI) está ejecutándose correctamente en Hugging Face Spaces con 16 GB RAM.") + gr.Markdown("### 🔗 Accesos Directos:") + gr.Markdown("- 📄 [Documentación de la API (Swagger UI)](/api/docs)") + gr.Markdown("- 🩺 [Health Check](/api/health)") + +# Montar Gradio en /ui para preservar las rutas principales /api/* en FastAPI +app = gr.mount_gradio_app(fastapi_app, demo, path="/ui") + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 7860)) + uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False) diff --git a/app/pyafipws/tests/wscdc.py b/app/pyafipws/tests/wscdc.py new file mode 100644 index 0000000000000000000000000000000000000000..4c4e66ded82ce7cc25e1763602fb734eef308c75 --- /dev/null +++ b/app/pyafipws/tests/wscdc.py @@ -0,0 +1,88 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +import pysimplesoap.client +from pyafipws.wscdc import WSCDC +from pyafipws.wsaa import WSAA +from pyafipws import utils +"Pruebas para el servicio web Constatación de Comprobantes de AFIP" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2013 Mariano Reingart" +__license__ = "GPL 3.0" + + +import unittest +import sys +from decimal import Decimal + +sys.path.append("/home/reingart") # TODO: proper packaging + + +print(pysimplesoap.client.__version__) +#assert pysimplesoap.client.__version__ >= "1.08c" + + +WSDL = "https://wswhomo.afip.gov.ar/WSCDC/service.asmx?WSDL" +CUIT = 20267565393 +CERT = "/home/reingart/pyafipws/reingart.crt" +PRIVATEKEY = "/home/reingart/pyafipws/reingart.key" +CACERT = "/home/reingart/pyafipws/afip_root_desa_ca.crt" +CACHE = "/home/reingart/pyafipws/cache" + +# Autenticación: +wsaa = WSAA() +tra = wsaa.CreateTRA(service="wscdc") +cms = wsaa.SignTRA(tra, CERT, PRIVATEKEY) +wsaa.Conectar() +wsaa.LoginCMS(cms) + + +class TestWSCDC(unittest.TestCase): + + def setUp(self): + sys.argv.append("--trace") # TODO: use logging + self.wscdc = wslpg = WSCDC() + wslpg.LanzarExcepciones = True + wslpg.Conectar(wsdl=WSDL, cacert=None, cache=CACHE) + wslpg.Cuit = CUIT + wslpg.Token = wsaa.Token + wslpg.Sign = wsaa.Sign + + def test_constatacion_no(self): + "Prueba de Constatación de Comprobantes (facturas electrónicas)" + wscdc = self.wscdc + cbte_modo = "CAE" + cuit_emisor = "20267565393" + pto_vta = 4002 + cbte_tipo = 1 + cbte_nro = 109 + cbte_fch = "20131227" + imp_total = "121.0" + cod_autorizacion = "63523178385550" + doc_tipo_receptor = 80 + doc_nro_receptor = "30628789661" + ok = wscdc.ConstatarComprobante(cbte_modo, cuit_emisor, pto_vta, cbte_tipo, + cbte_nro, cbte_fch, imp_total, cod_autorizacion, + doc_tipo_receptor, doc_nro_receptor) + self.assertTrue(ok) + self.assertEqual(wscdc.Resultado, "R") # Rechazado + self.assertEqual(wscdc.Obs, "100: El N° de CAI/CAE/CAEA consultado no existe en las bases del organismo.") + self.assertEqual(wscdc.PuntoVenta, pto_vta) + self.assertEqual(wscdc.CbteNro, cbte_nro) + self.assertEqual(wscdc.ImpTotal, imp_total) + self.assertEqual(wscdc.CAE, cod_autorizacion) + self.assertEqual(wscdc.EmisionTipo, "CAE") + + +if __name__ == '__main__': + unittest.main() diff --git a/app/pyafipws/tests/wsfev1.py b/app/pyafipws/tests/wsfev1.py new file mode 100644 index 0000000000000000000000000000000000000000..ead3251b34d733b953ca871de23a7f257f351497 --- /dev/null +++ b/app/pyafipws/tests/wsfev1.py @@ -0,0 +1,212 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +from pyafipws.wsaa import WSAA +from pyafipws.wsfev1 import WSFEv1 +"Pruebas para WSFEv1 de AFIP (Factura Electrnica Mercado Interno sin detalle)" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2010 Mariano Reingart" +__license__ = "GPL 3.0" + +import unittest +import os +import time +import sys +from decimal import Decimal +import datetime + +sys.path.append("/home/reingart") # TODO: proper packaging + + +WSDL = "https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL" +CUIT = 20267565393 +CERT = "/home/reingart/pyafipws/reingart.crt" +PRIVATEKEY = "/home/reingart/pyafipws/reingart.key" +CACERT = "/home/reingart/pyafipws/afip_root_desa_ca.crt" +CACHE = "/home/reingart/pyafipws/cache" + +# Autenticacin: +wsaa = WSAA() +tra = wsaa.CreateTRA(service="wsfe") +cms = wsaa.SignTRA(tra, CERT, PRIVATEKEY) +wsaa.Conectar() +wsaa.LoginCMS(cms) + + +class TestFE(unittest.TestCase): + + def setUp(self): + sys.argv.append("--trace") # TODO: use logging + self.wsfev1 = wsfev1 = WSFEv1() + wsfev1.Cuit = CUIT + wsfev1.Token = wsaa.Token + wsfev1.Sign = wsaa.Sign + wsfev1.Conectar(CACHE, WSDL) + + def atest_dummy(self): + print(wsfev1.client.help("dummy")) + wsfev1.Dummy() + print("AppServerStatus", wsfev1.AppServerStatus) + print("DbServerStatus", wsfev1.DbServerStatus) + print("AuthServerStatus", wsfev1.AuthServerStatus) + + def test_autorizar_comprobante(self, tipo_cbte=1, cbte_nro=None, servicios=True): + "Prueba de autorizacin de un comprobante (obtencin de CAE)" + wsfev1 = self.wsfev1 + + # datos generales del comprobante: + punto_vta = 4000 + if not cbte_nro: + # si no me especifcan nro de comprobante, busco el prximo + cbte_nro = wsfev1.CompUltimoAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(cbte_nro) + 1 + fecha = datetime.datetime.now().strftime("%Y%m%d") + tipo_doc = 80 + nro_doc = "30000000007" # "30500010912" # CUIT BNA + cbt_desde = cbte_nro + cbt_hasta = cbt_desde + imp_total = "122.00" + imp_tot_conc = "0.00" + imp_neto = "100.00" + imp_trib = "1.00" + imp_op_ex = "0.00" + imp_iva = "21.00" + fecha_cbte = fecha + # Fechas del perodo del servicio facturado (solo si concepto = 1?) + if servicios: + concepto = 3 + fecha_venc_pago = fecha + fecha_serv_desde = fecha + fecha_serv_hasta = fecha + else: + concepto = 1 + fecha_venc_pago = fecha_serv_desde = fecha_serv_hasta = None + moneda_id = 'PES' + moneda_ctz = '1.000' + obs = "Observaciones Comerciales, libre" + + wsfev1.CrearFactura(concepto, tipo_doc, nro_doc, tipo_cbte, punto_vta, + cbt_desde, cbt_hasta, imp_total, imp_tot_conc, imp_neto, + imp_iva, imp_trib, imp_op_ex, fecha_cbte, fecha_venc_pago, + fecha_serv_desde, fecha_serv_hasta, # -- + moneda_id, moneda_ctz) + + # agrego un comprobante asociado (solo notas de crdito / dbito) + if tipo_cbte in (2, 3): + tipo = 1 + pv = 2 + nro = 1234 + wsfev1.AgregarCmpAsoc(tipo, pv, nro) + + # agrego otros tributos: + tributo_id = 99 + desc = 'Impuesto Municipal Matanza' + base_imp = "100.00" + alic = "1.00" + importe = "1.00" + wsfev1.AgregarTributo(tributo_id, desc, base_imp, alic, importe) + + # agrego el subtotal por tasa de IVA: + iva_id = 5 # 21% + base_im = 100 + importe = 21 + wsfev1.AgregarIva(iva_id, base_imp, importe) + + # llamo al websevice para obtener el CAE: + wsfev1.CAESolicitar() + + self.assertEqual(wsfev1.Resultado, "A") # Aprobado! + self.assertIsInstance(wsfev1.CAE, str) + self.assertEqual(len(wsfev1.CAE), len("63363178822329")) + self.assertEqual(len(wsfev1.Vencimiento), len("20130907")) + wsfev1.AnalizarXml("XmlResponse") + # observacin "... no se encuentra registrado en los padrones de AFIP.": + self.assertEqual(wsfev1.ObtenerTagXml('Obs', 0, 'Code'), '10017') + + def test_consulta(self): + "Prueba de obtener los datos de un comprobante autorizado" + wsfev1 = self.wsfev1 + # autorizo un comprobante: + tipo_cbte = 1 + self.test_autorizar_comprobante(tipo_cbte) + # obtengo datos para comprobar + cae = wsfev1.CAE + wsfev1.AnalizarXml("XmlRequest") + imp_total = float(wsfev1.ObtenerTagXml('ImpTotal')) + concepto = int(wsfev1.ObtenerTagXml('Concepto')) + punto_vta = wsfev1.PuntoVenta + cbte_nro = wsfev1.CbteNro + + # llamo al webservice para consultar y validar manualmente el CAE: + wsfev1.CompConsultar(tipo_cbte, punto_vta, cbte_nro) + + self.assertEqual(wsfev1.CAE, cae) + self.assertEqual(wsfev1.CbteNro, cbte_nro) + self.assertEqual(wsfev1.ImpTotal, imp_total) + + wsfev1.AnalizarXml("XmlResponse") + self.assertEqual(wsfev1.ObtenerTagXml('CodAutorizacion'), str(wsfev1.CAE)) + self.assertEqual(wsfev1.ObtenerTagXml('Concepto'), str(concepto)) + + def test_reproceso_servicios(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + wsfev1 = self.wsfev1 + # obtengo el prximo nmero de comprobante + tipo_cbte = 1 + punto_vta = 4000 + nro = wsfev1.CompUltimoAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE + wsfev1.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro) + self.assertEqual(wsfev1.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro) + self.assertEqual(wsfev1.Reproceso, "S") + + def test_reproceso_productos(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + wsfev1 = self.wsfev1 + # obtengo el prximo nmero de comprobante + tipo_cbte = 1 + punto_vta = 4000 + nro = wsfev1.CompUltimoAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE + wsfev1.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsfev1.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsfev1.Reproceso, "S") + + def test_reproceso_nota_debito(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + # N/D con comprobantes asociados + wsfev1 = self.wsfev1 + # obtengo el prximo nmero de comprobante + tipo_cbte = 2 + punto_vta = 4000 + nro = wsfev1.CompUltimoAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE + wsfev1.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsfev1.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsfev1.Reproceso, "S") + + +if __name__ == '__main__': + unittest.main() diff --git a/app/pyafipws/tests/wslpg.py b/app/pyafipws/tests/wslpg.py new file mode 100644 index 0000000000000000000000000000000000000000..484cb51a42b5da4e1eb4ed34139a3adfa5d5b041 --- /dev/null +++ b/app/pyafipws/tests/wslpg.py @@ -0,0 +1,544 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +import pysimplesoap.client +from pyafipws.wslpg import WSLPG +from pyafipws.wsaa import WSAA +from pyafipws import utils +"Pruebas Liquidación Primaria Electrónica de Granos web service WSLPG (AFIP)" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2013 Mariano Reingart" +__license__ = "GPL 3.0" + + +import unittest +import sys +from decimal import Decimal + +sys.path.append("/home/reingart") # TODO: proper packaging + + +print(pysimplesoap.client.__version__) +#assert pysimplesoap.client.__version__ >= "1.08c" + + +WSDL = "https://fwshomo.afip.gov.ar/wslpg/LpgService?wsdl" +CUIT = 20267565393 +CERT = "/home/reingart/pyafipws/reingart.crt" +PRIVATEKEY = "/home/reingart/pyafipws/reingart.key" +CACERT = "/home/reingart/pyafipws/afip_root_desa_ca.crt" +CACHE = "/home/reingart/pyafipws/cache" + +# Autenticación: +wsaa = WSAA() +tra = wsaa.CreateTRA(service="wslpg") +cms = wsaa.SignTRA(tra, CERT, PRIVATEKEY) +wsaa.Conectar() +wsaa.LoginCMS(cms) + + +class TestIssues(unittest.TestCase): + + def setUp(self): + sys.argv.append("--trace") # TODO: use logging + self.wslpg = wslpg = WSLPG() + wslpg.LanzarExcepciones = True + wslpg.Conectar(url=WSDL, cacert=None, cache=CACHE) + wslpg.Cuit = CUIT + wslpg.Token = wsaa.Token + wslpg.Sign = wsaa.Sign + + def test_liquidacion(self): + "Prueba de autorización (obtener COE) liquidación electrónica de granos" + wslpg = self.wslpg + pto_emision = 99 + ok = wslpg.ConsultarUltNroOrden(pto_emision) + self.assertTrue(ok) + ok = wslpg.CrearLiquidacion( + pto_emision=pto_emision, + nro_orden=wslpg.NroOrden + 1, + cuit_comprador=wslpg.Cuit, + nro_act_comprador=29, nro_ing_bruto_comprador=wslpg.Cuit, + cod_tipo_operacion=1, + es_liquidacion_propia='N', es_canje='N', + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=31, + cuit_vendedor=23000000019, nro_ing_bruto_vendedor=23000000019, + actua_corredor="N", liquida_corredor="N", + cuit_corredor=0, + comision_corredor=0, nro_ing_bruto_corredor=0, + fecha_precio_operacion="2013-02-07", + precio_ref_tn=2000, + cod_grado_ref="G1", + cod_grado_ent="FG", + factor_ent=98, val_grado_ent=1.02, + precio_flete_tn=10, + cont_proteico=20, + alic_iva_operacion=10.5, + campania_ppal=1213, + cod_localidad_procedencia=5544, + cod_prov_procedencia=12, + datos_adicionales="DATOS ADICIONALES", + peso_neto_sin_certificado=10000, + cod_prov_procedencia_sin_certificado=1, + cod_localidad_procedencia_sin_certificado=15124, + ) + + wslpg.AgregarRetencion( + codigo_concepto="RI", + detalle_aclaratorio="DETALLE DE IVA", + base_calculo=1000, + alicuota=10.5, + ) + wslpg.AgregarRetencion( + codigo_concepto="RG", + detalle_aclaratorio="DETALLE DE GANANCIAS", + base_calculo=100, + alicuota=15, + ) + ok = wslpg.AutorizarLiquidacion() + self.assertTrue(ok) + self.assertIsInstance(wslpg.COE, str) + self.assertEqual(len(wslpg.COE), len("330100013142")) + + def test_liquidacion_contrato(self, nro_contrato=26): + "Prueba de obtener COE variante con contrato / corredor (WSLPGv1.4)" + wslpg = self.wslpg + pto_emision = 99 + ok = wslpg.ConsultarUltNroOrden(pto_emision) + self.assertTrue(ok) + nro_orden = wslpg.NroOrden + 1 + + # probar todas las actividades en caso de que devuelva error AFIP: + # 1106: La actividad seleccionada no corresponde al comprador + actividades = (40, 41, 29, 33, 31, 30, 35, 44, 47, 46, 48, 49, 51, 50, + 45, 59, 57, 52, 34, 28, 36, 55, 39, 37) + + for actid in actividades: + ok = wslpg.CrearLiquidacion( + pto_emision=pto_emision, + nro_orden=nro_orden, + nro_contrato=nro_contrato, + cuit_comprador=20400000000, + nro_act_comprador=actid, nro_ing_bruto_comprador=20400000000, + cod_tipo_operacion=1, + es_liquidacion_propia='N', es_canje='N', + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=31, + cuit_vendedor=23000000019, nro_ing_bruto_vendedor=23000000019, + actua_corredor="S", liquida_corredor="S", + cuit_corredor=20267565393, + comision_corredor=1, nro_ing_bruto_corredor=20267565393, + fecha_precio_operacion="2013-02-07", + precio_ref_tn=2000, + cod_grado_ref="G1", + cod_grado_ent="FG", + factor_ent=98, val_grado_ent=1.02, + precio_flete_tn=10, + cont_proteico=20, + alic_iva_operacion=10.5, + campania_ppal=1213, + cod_localidad_procedencia=5544, + cod_prov_procedencia=12, + datos_adicionales="DATOS ADICIONALES", + peso_neto_sin_certificado=10000, + cod_prov_procedencia_sin_certificado=1, + cod_localidad_procedencia_sin_certificado=15124, + ) + + wslpg.AgregarRetencion( + codigo_concepto="RI", + detalle_aclaratorio="DETALLE DE IVA", + base_calculo=1000, + alicuota=10.5, + ) + wslpg.AgregarRetencion( + codigo_concepto="RG", + detalle_aclaratorio="DETALLE DE GANANCIAS", + base_calculo=100, + alicuota=15, + ) + ok = wslpg.AutorizarLiquidacion() + if wslpg.COE: + # print "Actividad OK", actid + break + + self.assertTrue(ok) + self.assertIsInstance(wslpg.COE, str) + self.assertEqual(len(wslpg.COE), len("330100013142")) + self.assertEqual(wslpg.NroContrato, nro_contrato) + + def test_anular(self, coe=None): + "Prueba de anulación de una liquidación electrónica de granos" + wslpg = self.wslpg + if not coe: + self.test_liquidacion() # autorizo una nueva liq. + coe = wslpg.COE + ok = wslpg.AnularLiquidacion(coe) # la anulo + self.assertTrue(ok) + self.assertEqual(wslpg.Resultado, "A") + + def test_ajuste_unificado(self): + "Prueba de ajuste unificado de una liquidación de granos (WSLPGv1.4)" + wslpg = self.wslpg + # solicito una liquidación para tener el COE autorizado a ajustar: + self.test_liquidacion() + coe = wslpg.COE + # solicito el último nro de orden para la nueva liquidación de ajuste: + pto_emision = 55 + ok = wslpg.ConsultarUltNroOrden(pto_emision) + self.assertTrue(ok) + nro_orden = wslpg.NroOrden + 1 + # creo el ajuste base y agrego los datos de certificado: + wslpg.CrearAjusteBase(pto_emision=pto_emision, + nro_orden=nro_orden, + coe_ajustado=coe, + cod_provincia=1, + cod_localidad=5, + ) + wslpg.AgregarCertificado(tipo_certificado_deposito=5, + nro_certificado_deposito=555501200729, + peso_neto=10000, + cod_localidad_procedencia=3, + cod_prov_procedencia=1, + campania=1213, + fecha_cierre='2013-01-13', + peso_neto_total_certificado=10000) + # creo el ajuste de crédito (ver documentación AFIP) + wslpg.CrearAjusteCredito( + diferencia_peso_neto=1000, diferencia_precio_operacion=100, + cod_grado="G2", val_grado=1.0, factor=100, + diferencia_precio_flete_tn=10, + datos_adicionales='AJUSTE CRED UNIF', + concepto_importe_iva_0='Alicuota Cero', + importe_ajustar_Iva_0=900, + concepto_importe_iva_105='Alicuota Diez', + importe_ajustar_Iva_105=800, + concepto_importe_iva_21='Alicuota Veintiuno', + importe_ajustar_Iva_21=700, + ) + wslpg.AgregarDeduccion(codigo_concepto="AL", + detalle_aclaratorio="Deduc Alm", + dias_almacenaje="1", + precio_pkg_diario=0.01, + comision_gastos_adm=1.0, + base_calculo=1000.0, + alicuota=10.5, ) + wslpg.AgregarRetencion(codigo_concepto="RI", + detalle_aclaratorio="Ret IVA", + base_calculo=1000, + alicuota=10.5, ) + # creo el ajuste de débito (ver documentación AFIP) + wslpg.CrearAjusteDebito( + diferencia_peso_neto=500, diferencia_precio_operacion=100, + cod_grado="G2", val_grado=1.0, factor=100, + diferencia_precio_flete_tn=0.01, + datos_adicionales='AJUSTE DEB UNIF', + concepto_importe_iva_0='Alic 0', + importe_ajustar_Iva_0=250, + concepto_importe_iva_105='Alic 10.5', + importe_ajustar_Iva_105=200, + concepto_importe_iva_21='Alicuota 21', + importe_ajustar_Iva_21=50, + ) + wslpg.AgregarDeduccion(codigo_concepto="AL", + detalle_aclaratorio="Deduc Alm", + dias_almacenaje="1", + precio_pkg_diario=0.01, + comision_gastos_adm=1.0, + base_calculo=500.0, + alicuota=10.5, ) + wslpg.AgregarRetencion(codigo_concepto="RI", + detalle_aclaratorio="Ret IVA", + base_calculo=100, + alicuota=10.5, ) + # autorizo el ajuste: + ok = wslpg.AjustarLiquidacionUnificado() + self.assertTrue(ok) + # verificar respuesta general: + self.assertIsInstance(wslpg.COE, str) + self.assertEqual(len(wslpg.COE), len("330100013133")) + coe_ajustado = coe + coe = wslpg.COE + try: + self.assertEqual(wslpg.Estado, "AC") + self.assertEqual(wslpg.Subtotal, Decimal("-734.10")) + self.assertEqual(wslpg.TotalIva105, Decimal("-77.61")) + self.assertEqual(wslpg.TotalIva21, Decimal("0")) + self.assertEqual(wslpg.TotalRetencionesGanancias, Decimal("0")) + self.assertEqual(wslpg.TotalRetencionesIVA, Decimal("-94.50")) + self.assertEqual(wslpg.TotalNetoAPagar, Decimal("-716.68")) + self.assertEqual(wslpg.TotalIvaRg2300_07, Decimal("16.89")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("-733.57")) + # verificar ajuste credito + ok = wslpg.AnalizarAjusteCredito() + self.assertTrue(ok) + self.assertEqual(wslpg.GetParametro("precio_operacion"), "1.900") + self.assertEqual(wslpg.GetParametro("total_peso_neto"), "1000") + self.assertEqual(wslpg.TotalDeduccion, Decimal("11.05")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("2780.95")) + self.assertEqual(wslpg.GetParametro("importe_iva"), "293.16") + self.assertEqual(wslpg.GetParametro("operacion_con_iva"), "3085.16") + self.assertEqual(wslpg.GetParametro("deducciones", 0, "importe_iva"), "1.05") + # verificar ajuste debito + ok = wslpg.AnalizarAjusteDebito() + self.assertTrue(ok) + self.assertEqual(wslpg.GetParametro("precio_operacion"), "2.090") + self.assertEqual(wslpg.GetParametro("total_peso_neto"), "500") + self.assertEqual(wslpg.TotalDeduccion, Decimal("5.52")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("2047.38")) + self.assertEqual(wslpg.GetParametro("importe_iva"), "215.55") + self.assertEqual(wslpg.GetParametro("operacion_con_iva"), "2268.45") + self.assertEqual(wslpg.GetParametro("retenciones", 0, "importe_retencion"), "10.50") + + finally: + # anulo el ajuste para evitar subsiguiente validación AFIP: + if coe: + self.test_anular(coe) + if coe_ajustado: + self.test_anular(coe_ajustado) # anulo también la liq. orig. + + def test_ajuste_contrato(self, nro_contrato=27): + "Prueba de ajuste por contrato de una liquidación de granos (WSLPGv1.4)" + wslpg = self.wslpg + # solicito una liquidación para tener el COE autorizado a ajustar: + self.test_liquidacion_contrato(nro_contrato) + coe_ajustado = wslpg.COE + # solicito el último nro de orden para la nueva liquidación de ajuste: + pto_emision = 55 + ok = wslpg.ConsultarUltNroOrden(pto_emision) + self.assertTrue(ok) + nro_orden = wslpg.NroOrden + 1 + wslpg.CrearAjusteBase(pto_emision=55, nro_orden=nro_orden, + nro_contrato=nro_contrato, + coe_ajustado=coe_ajustado, + nro_act_comprador=40, + cod_grano=31, + cuit_vendedor=23000000019, + cuit_comprador=20400000000, + cuit_corredor=20267565393, + precio_ref_tn=100, + cod_grado_ent="G1", + val_grado_ent=1.01, + precio_flete_tn=1000, + cod_puerto=14, + des_puerto_localidad="Desc Puerto", + cod_provincia=1, + cod_localidad=5, + ) + wslpg.CrearAjusteCredito( + concepto_importe_iva_0='Ajuste IVA al 0%', + importe_ajustar_Iva_0=100, + ) + wslpg.CrearAjusteDebito( + concepto_importe_iva_105='Ajuste IVA al 10.5%', + importe_ajustar_Iva_105=100, + ) + wslpg.AgregarDeduccion(codigo_concepto="OD", + detalle_aclaratorio="Otras Deduc", + dias_almacenaje="1", + base_calculo=100.0, + alicuota=10.5, ) + + # autorizo el ajuste: + ok = wslpg.AjustarLiquidacionContrato() + self.assertTrue(ok) + # verificar respuesta general: + coe = wslpg.COE + self.assertIsInstance(wslpg.COE, str) + self.assertEqual(len(wslpg.COE), len("330100013133")) + try: + self.assertEqual(wslpg.Estado, "AC") + self.assertEqual(wslpg.Subtotal, Decimal("-100.00")) + self.assertEqual(wslpg.TotalIva105, Decimal("0")) + self.assertEqual(wslpg.TotalIva21, Decimal("0")) + self.assertEqual(wslpg.TotalRetencionesGanancias, Decimal("0")) + self.assertEqual(wslpg.TotalRetencionesIVA, Decimal("0")) + self.assertEqual(wslpg.TotalNetoAPagar, Decimal("-110.50")) + self.assertEqual(wslpg.TotalIvaRg2300_07, Decimal("0")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("-110.50")) + # self.assertEqual(wslpg.NroContrato, nro_contrato) # no devuelto AFIP + # verificar campos globales no documentados (directamente desde el XML): + wslpg.AnalizarXml() + v = wslpg.ObtenerTagXml("totalesUnificados", "subTotalDebCred") + self.assertEqual(v, "0") + v = wslpg.ObtenerTagXml("totalesUnificados", "totalBaseDeducciones") + self.assertEqual(v, "100.0") + v = wslpg.ObtenerTagXml("totalesUnificados", "ivaDeducciones") + self.assertEqual(v, "10.50") + # verificar ajuste credito + ok = wslpg.AnalizarAjusteCredito() + self.assertTrue(ok) + self.assertEqual(wslpg.GetParametro("precio_operacion"), "0.000") + self.assertEqual(wslpg.GetParametro("total_peso_neto"), "0") + self.assertEqual(wslpg.TotalDeduccion, Decimal("0.000")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("0.000")) + self.assertEqual(float(wslpg.GetParametro("importe_iva")), 0.00) + self.assertEqual(float(wslpg.GetParametro("operacion_con_iva")), 0.00) + # verificar ajuste debito + ok = wslpg.AnalizarAjusteDebito() + self.assertTrue(ok) + self.assertEqual(float(wslpg.GetParametro("precio_operacion")), 0.00) + self.assertEqual(float(wslpg.GetParametro("total_peso_neto")), 0) + self.assertEqual(wslpg.TotalDeduccion, Decimal("110.50")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("-110.50")) + self.assertEqual(float(wslpg.GetParametro("importe_iva")), 0.00) + self.assertEqual(float(wslpg.GetParametro("operacion_con_iva")), 0.00) + self.assertEqual(float(wslpg.GetParametro("deducciones", 0, "importe_iva")), 10.50) + self.assertEqual(float(wslpg.GetParametro("deducciones", 0, "importe_deduccion")), 110.50) + + finally: + # anulo el ajuste para evitar subsiguiente validación AFIP: + # 2105: No puede relacionar la liquidacion con el contrato, porque el contrato tiene un Ajuste realizado. + # 2106: No puede ajustar el contrato, porque tiene liquidaciones relacionadas con ajuste. + # anular primero el ajuste para evitar la validación de AFIP: + # 2108: No puede anular la liquidación porque está relacionada a un contrato con ajuste vigente. + if coe: + self.test_anular(coe) + if coe_ajustado: + self.test_anular(coe_ajustado) # anulo también el COE ajustado + + def atest_ajuste_papel(self): + # deshabilitado ya que el método esta "en estudio" por parte de AFIP + wslpg = self.wslpg + wslpg.CrearAjusteBase(pto_emision=50, + nro_orden=1, + tipo_formulario=6, + nro_formulario="000101800999", + actividad=46, + cuit_comprador=99999999999, + nro_ing_bruto_comprador=99999999999, + tipo_operacion=1, + cod_grano=31, + cuit_vendedor=30000000007, + nro_ing_bruto_vendedor=30000000007, + cod_provincia=1, + cod_localidad=5) + wslpg.AgregarCertificado(tipo_certificado_deposito=5, + nro_certificado_deposito=555501200802, + peso_neto=10000, + cod_localidad_procedencia=5, + cod_prov_procedencia=1, + campania=1213, + fecha_cierre='2013-07-12') + wslpg.CrearAjusteCredito( + concepto_importe_iva_21='IVA al 21%', + importe_ajustar_Iva_21=1500, + ) + wslpg.AgregarRetencion(codigo_concepto="RI", + detalle_aclaratorio="Ret IVA", + base_calculo=1500, + alicuota=8, ) + wslpg.CrearAjusteDebito( + concepto_importe_iva_105='IVA al 0%', + importe_ajustar_Iva_105=100, + ) + + ret = wslpg.AjustarLiquidacionUnificadoPapel() + + def test_asociaciar_coe_contrato(self, nro_contrato=27): + wslpg = self.wslpg + # solicito una liquidación para tener el COE autorizado a asociar: + self.test_liquidacion() + coe = wslpg.COE + try: + # Asocio la liquidación con el contrato: + wslpg.AsociarLiquidacionAContrato(coe=coe, + nro_contrato=nro_contrato, + cuit_comprador="20400000000", + cuit_vendedor="23000000019", + cuit_corredor="20267565393", + cod_grano=31) + self.assertEqual(wslpg.Errores, []) + self.assertIsInstance(wslpg.COE, str) + self.assertEqual(len(wslpg.COE), len("330100013133")) + self.assertEqual(wslpg.Estado, "AC") + finally: + # anulo el ajuste para evitar subsiguiente validación AFIP: + # 2105: No puede relacionar la liquidacion con el contrato, porque el contrato tiene un Ajuste realizado. + # 2112: La liquidacion ya esta relacionada al contrato. + try: + self.test_anular(coe) + except BaseException: + # ignorar error de AFIP (aparentemente problema interno): + self.assertEqual(wslpg.Errores[0], "2100: El contrato ingresado no se encuentra registrado.") + pass + + def test_consultar_liquidaciones_por_contrato(self, nro_contrato=26): + wslpg = self.wslpg + # obtener las liquidaciones relacionadas al contrato: + wslpg.ConsultarLiquidacionesPorContrato( + nro_contrato=nro_contrato, + cuit_comprador="20400000000", + cuit_vendedor="23000000019", + cuit_corredor="20267565393", + cod_grano=31, + ) + self.assertEqual(wslpg.Errores, []) + # verifico COEs previamente relacionados al contrato: + for coe in sorted([330100014020, 330100014022, 330100014023, + 330100014025, 330100014028, 330100014029, + 330100014040, 330100014043, 330100014057, + 330100014061, 330100014450, 330100014454, + 330100014455, 330100014459, 330100014467, + 330100014472, 330100004664]): + self.assertIsInstance(wslpg.COE, str) + self.assertEqual(wslpg.COE, str(coe)) + self.assertEqual(wslpg.Estado, "") # por el momento no lo devuelve + # leo el próximo numero + wslpg.LeerDatosLiquidacion() + + def test_consultar_ajuste_unificado(self): + "Prueba de consulta de un ajuste unificado (WSLPGv1.4)" + wslpg = self.wslpg + # uso datos de un ajuste generado con test_ajuste_unificado: + pto_emision = 55 + nro_orden = 78 + # consulto el ajuste: + ok = wslpg.ConsultarAjuste(pto_emision, nro_orden) + self.assertTrue(ok) + # verificar respuesta general: + self.assertEqual(wslpg.COE, "330100014501") + self.assertEqual(wslpg.Estado, "AN") # anulado! + self.assertEqual(wslpg.Subtotal, Decimal("-734.10")) + self.assertEqual(wslpg.TotalIva105, Decimal("-77.61")) + self.assertEqual(wslpg.TotalIva21, Decimal("0")) + self.assertEqual(wslpg.TotalRetencionesGanancias, Decimal("0")) + self.assertEqual(wslpg.TotalRetencionesIVA, Decimal("-94.50")) + self.assertEqual(wslpg.TotalNetoAPagar, Decimal("-716.68")) + self.assertEqual(wslpg.TotalIvaRg2300_07, Decimal("16.89")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("-733.57")) + # verificar ajuste credito + ok = wslpg.AnalizarAjusteCredito() + self.assertTrue(ok) + self.assertEqual(float(wslpg.GetParametro("precio_operacion")), 1.9) + self.assertEqual(wslpg.GetParametro("total_peso_neto"), "1000") + self.assertEqual(wslpg.TotalDeduccion, Decimal("11.05")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("2780.95")) + self.assertEqual(float(wslpg.GetParametro("importe_iva")), 293.16) + self.assertEqual(float(wslpg.GetParametro("operacion_con_iva")), 3085.16) + self.assertEqual(float(wslpg.GetParametro("deducciones", 0, "importe_iva")), 1.05) + # verificar ajuste debito + ok = wslpg.AnalizarAjusteDebito() + self.assertTrue(ok) + self.assertEqual(float(wslpg.GetParametro("precio_operacion")), 2.09) + self.assertEqual(wslpg.GetParametro("total_peso_neto"), "500") + self.assertEqual(wslpg.TotalDeduccion, Decimal("5.52")) + self.assertEqual(wslpg.TotalPagoSegunCondicion, Decimal("2047.38")) + self.assertEqual(float(wslpg.GetParametro("importe_iva")), 215.55) + self.assertEqual(float(wslpg.GetParametro("operacion_con_iva")), 2268.45) + self.assertEqual(float(wslpg.GetParametro("retenciones", 0, "importe_retencion")), 10.50) + + +if __name__ == '__main__': + unittest.main() diff --git a/app/pyafipws/tests/wslpg_cert_autorizar_resp.xml b/app/pyafipws/tests/wslpg_cert_autorizar_resp.xml new file mode 100644 index 0000000000000000000000000000000000000000..59c6b88592393c9785e8aab4c0730447a7f27c9c --- /dev/null +++ b/app/pyafipws/tests/wslpg_cert_autorizar_resp.xml @@ -0,0 +1,31 @@ + + + + + + +21 +2 +332000000189 +AC +2015-02-24 + +1500.00 +0.00 +0.00 +0.00 +1500.00 + + +0.00 +0.00 +0.00 +0.00 +0.00 +0.00 + + + + + + diff --git a/app/pyafipws/tests/wsmtx.py b/app/pyafipws/tests/wsmtx.py new file mode 100644 index 0000000000000000000000000000000000000000..85fb8809b9e1e1c258ed33b12528c429a1779f9d --- /dev/null +++ b/app/pyafipws/tests/wsmtx.py @@ -0,0 +1,246 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +from pyafipws.wsaa import WSAA +from pyafipws.wsmtx import WSMTXCA +"Pruebas para WSMTX de AFIP (Factura Electrnica Mercado Interno con detalle)" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2010 Mariano Reingart" +__license__ = "GPL 3.0" + +import unittest +import os +import time +import sys +from decimal import Decimal +import datetime + +sys.path.append("/home/reingart") # TODO: proper packaging + + +WSDL = "https://fwshomo.afip.gov.ar/wsmtxca/services/MTXCAService?wsdl" +CUIT = 20267565393 +CERT = "/home/reingart/pyafipws/reingart.crt" +PRIVATEKEY = "/home/reingart/pyafipws/reingart.key" +CACERT = "/home/reingart/pyafipws/afip_root_desa_ca.crt" +CACHE = "/home/reingart/pyafipws/cache" + +# Autenticacin: +wsaa = WSAA() +tra = wsaa.CreateTRA(service="wsmtxca") +cms = wsaa.SignTRA(tra, CERT, PRIVATEKEY) +wsaa.Conectar() +wsaa.LoginCMS(cms) + + +class TestMTX(unittest.TestCase): + + def setUp(self): + sys.argv.append("--trace") # TODO: use logging + self.wsmtxca = wsmtxca = WSMTXCA() + wsmtxca.Cuit = CUIT + wsmtxca.Token = wsaa.Token + wsmtxca.Sign = wsaa.Sign + wsmtxca.Conectar(CACHE, WSDL) + + def atest_dummy(self): + print(wsmtxca.client.help("dummy")) + wsmtxca.Dummy() + print("AppServerStatus", wsmtxca.AppServerStatus) + print("DbServerStatus", wsmtxca.DbServerStatus) + print("AuthServerStatus", wsmtxca.AuthServerStatus) + + def test_autorizar_comprobante(self, tipo_cbte=1, cbte_nro=None, servicios=True, tributos=True): + "Prueba de autorizacin de un comprobante (obtencin de CAE)" + wsmtxca = self.wsmtxca + + # datos generales del comprobante: + punto_vta = 4000 + if not cbte_nro: + # si no me especifcan nro de comprobante, busco el prximo + cbte_nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(cbte_nro) + 1 + fecha = datetime.datetime.now().strftime("%Y-%m-%d") + tipo_doc = 80 + nro_doc = "30000000007" + cbt_desde = cbte_nro + cbt_hasta = cbt_desde + imp_tot_conc = "0.00" + imp_neto = "100.00" + if tributos: + imp_total = "123.00" + imp_trib = "2.00" + else: + imp_total = "121.00" + imp_trib = "0.00" + imp_op_ex = "0.00" + imp_subtotal = "100.00" + fecha_cbte = fecha + # Fechas del perodo del servicio facturado (solo si concepto = 1?) + if servicios: + concepto = 3 + fecha_venc_pago = fecha + fecha_serv_desde = fecha + fecha_serv_hasta = fecha + else: + concepto = 1 + fecha_venc_pago = fecha_serv_desde = fecha_serv_hasta = None + moneda_id = 'PES' + moneda_ctz = '1.000' + obs = "Observaciones Comerciales, libre" + + wsmtxca.CrearFactura(concepto, tipo_doc, nro_doc, tipo_cbte, punto_vta, + cbt_desde, cbt_hasta, imp_total, imp_tot_conc, imp_neto, + imp_subtotal, imp_trib, imp_op_ex, fecha_cbte, fecha_venc_pago, + fecha_serv_desde, fecha_serv_hasta, # -- + moneda_id, moneda_ctz, obs) + + # agrego un comprobante asociado (solo notas de crdito / dbito) + if tipo_cbte in (2, 3): + tipo = 1 + pv = 2 + nro = 1234 + wsmtxca.AgregarCmpAsoc(tipo, pv, nro) + + if tributos: + # agrego otros tributos: + tributo_id = 99 + desc = 'Impuesto Municipal Matanza' + base_imp = "100.00" + alic = "1.00" + importe = "1.00" + wsmtxca.AgregarTributo(tributo_id, desc, base_imp, alic, importe) + + # agrego otros tributos: + tributo_id = 1 + desc = 'Impuestos Internos' + base_imp = "100.00" + alic = "1.00" + importe = "1.00" + wsmtxca.AgregarTributo(tributo_id, desc, base_imp, alic, importe) + + # agrego el subtotal por tasa de IVA: + iva_id = 5 # 21% + base_imp = "100.00" + importe = 21 + wsmtxca.AgregarIva(iva_id, base_imp, importe) + + # agrego un artculo: + u_mtx = 123456 + cod_mtx = 1234567890123 + codigo = "P0001" + ds = "Descripcion del producto P0001" + qty = 2.00 + umed = 7 + precio = 100.00 + bonif = 0.00 + iva_id = 5 + imp_iva = 42.00 + imp_subtotal = 242.00 + wsmtxca.AgregarItem(u_mtx, cod_mtx, codigo, ds, qty, umed, precio, bonif, + iva_id, imp_iva, imp_subtotal) + + # agrego bonificacin general + wsmtxca.AgregarItem(None, None, None, 'bonificacion', 0, 99, 1, None, + 5, -21, -121) + + # llamo al websevice para obtener el CAE: + wsmtxca.AutorizarComprobante() + + self.assertEqual(wsmtxca.Resultado, "A") # Aprobado! + self.assertIsInstance(wsmtxca.CAE, str) + self.assertEqual(len(wsmtxca.CAE), len("63363178822329")) + self.assertEqual(len(wsmtxca.Vencimiento), len("2013-09-07")) + + cae = wsmtxca.CAE + + # llamo al webservice para consultar y validar manualmente el CAE: + wsmtxca.ConsultarComprobante(tipo_cbte, punto_vta, cbte_nro) + + self.assertEqual(wsmtxca.CAE, cae) + self.assertEqual(wsmtxca.CbteNro, cbte_nro) + self.assertEqual(wsmtxca.ImpTotal, imp_total) + + wsmtxca.AnalizarXml("XmlResponse") + self.assertEqual(wsmtxca.ObtenerTagXml('codigoAutorizacion'), str(wsmtxca.CAE)) + self.assertEqual(wsmtxca.ObtenerTagXml('codigoConcepto'), str(concepto)) + self.assertEqual(wsmtxca.ObtenerTagXml('arrayItems', 0, 'item', 'unidadesMtx'), '123456') + + def test_reproceso_servicios(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + wsmtxca = self.wsmtxca + # obtengo el prximo nmero de comprobante + tipo_cbte = 1 + punto_vta = 4000 + nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE + wsmtxca.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro) + self.assertEqual(wsmtxca.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro) + self.assertEqual(wsmtxca.Reproceso, "S") + + def test_reproceso_productos(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + wsmtxca = self.wsmtxca + # obtengo el prximo nmero de comprobante + tipo_cbte = 1 + punto_vta = 4000 + nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE + wsmtxca.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsmtxca.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsmtxca.Reproceso, "S") + + def test_reproceso_nota_debito(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + # N/D con comprobantes asociados + wsmtxca = self.wsmtxca + # obtengo el prximo nmero de comprobante + tipo_cbte = 2 + punto_vta = 4000 + nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE + wsmtxca.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsmtxca.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, servicios=False) + self.assertEqual(wsmtxca.Reproceso, "S") + + def test_reproceso_sin_tributos(self): + "Prueba de reproceso de un comprobante (recupero de CAE por consulta)" + wsmtxca = self.wsmtxca + # obtengo el prximo nmero de comprobante + tipo_cbte = 1 + punto_vta = 4000 + nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + cbte_nro = int(nro) + 1 + # obtengo CAE (sin tributos) + wsmtxca.Reprocesar = True + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, tributos=False) + self.assertEqual(wsmtxca.Reproceso, "") + # intento reprocesar: + self.test_autorizar_comprobante(tipo_cbte, cbte_nro, tributos=False) + self.assertEqual(wsmtxca.Reproceso, "S") + + +if __name__ == '__main__': + unittest.main() diff --git a/app/pyafipws/trazafito.py b/app/pyafipws/trazafito.py new file mode 100644 index 0000000000000000000000000000000000000000..12e1817289e455767e2703df3c6e8d6679c36ebe --- /dev/null +++ b/app/pyafipws/trazafito.py @@ -0,0 +1,591 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Mdulo Trazabilidad de Productos Fitosanitarios SENASA Resolucin 369/2013" + +# Informacin adicional y documentacin: +# http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadProductosFitosanitarios + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2014 Mariano Reingart" +__license__ = "GPL 3.0+" +__version__ = "1.11d" + +# http://senasa.servicios.pami.org.ar/ + +import os +import socket +import sys +import datetime +import time +import traceback +import pysimplesoap.client +from pysimplesoap.client import SoapClient, SoapFault, parse_proxy, \ + set_http_wrapper +from pysimplesoap.simplexml import SimpleXMLElement +from io import StringIO + +# importo funciones compartidas: +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, \ + dar_nombre_campo_dbf, get_install_dir, BaseWS, \ + inicializar_y_capturar_excepciones + +HOMO = False +TYPELIB = False + +WSDL = "https://servicios.pami.org.ar/trazaenagr.WebService?wsdl" +LOCATION = "https://servicios.pami.org.ar/trazaenagr.WebService" + +# Formato de TransaccionSenasaDTO (SaveTransaccion) +TRANSACCION_DTO = [ + ('gln_origen', 13, A), + ('gln_destino', 13, A), + ('f_operacion', 10, A), + ('f_elaboracion', 10, A), + ('f_vto', 10, A), + ('id_evento', 15, N), + ('cod_producto', 14, A), + ('n_cantidad', 30, N), + ('n_serie', 20, A), + ('n_lote', 50, A), + ('n_cai', 15, A), + ('n_cae', 15, A), + ('id_motivo_destruccion', 5, A), + ('n_manifiesto', 15, N), + ('en_transporte', 1, A), # boolean + ('n_remito', 15, A), + ('motivo_devolucion', 100, A), + ('observaciones', 1000, A), + ('n_vale_compra', 15, A), + ('apellidoNombres', 255, A), + ('direccion', 200, A), + ('numero', 6, N), + ('localidad', 15, A), + ('provincia', 15, A), + ('n_postal', 8, A), + ('cuit', 11, A), + ('codigo_transaccion', 14, A), +] + +# Formato para TransaccionSenasa (getTransacciones) +TRANSACCIONES = [ + ('id_transaccion_global', 15, N), + ('id_transaccion', 15, N), + ('f_transaccion', 10, A), + ('f_operacion', 10, A), + ('f_vencimiento', 10, A), + ('f_elaboracion', 10, A), + ('d_evento', 100, A), + ('cantidad', 30, N), + ('id_unidad', 15, N), + ('d_unidad', 100, A), + ('cod_producto', 14, A), + ('id_unidad', 15, N), + ('n_serie', 20, A), + ('n_lote', 50, A), + ('n_cai', 15, A), + ('n_cae', 15, A), + ('d_motivo_destruccion', 50, A), + ('d_manifiesto', 15, A), + ('en_transporte', 1, A), + ('n_remito', 30, A), + ('motivo_devolucion', 200, A), + ('observaciones', 1000, A), + ('n_vale_compra', 15, A), + ('apellidoNombres', 255, A), + ('direccion', 200, A), + ('numero', 6, A), + ('localidad', 250, A), + ('provincia', 250, A), + ('n_postal', 8, A), + ('cuit', 11, A), + ('d_agente_informador', 255, A), + ('d_agente_origen', 255, A), + ('d_agente_destino', 255, A), + ('d_producto', 250, A), + ('d_estado_transaccion', 30, A), + ('d_tipo_transaccion', 30, A), +] + +# Formato para Errores +ERRORES = [ + ('_c_error', 4, A), # cdigo + ('_d_error', 250, A), # descripcin +] + + +class TrazaFito(BaseWS): + "Interfaz para el WebService de Trazabilidad de Fitosanitarios SENASA" + + _public_methods_ = ['SaveTransaccion', 'SendCancelaTransac', + 'SendConfirmaTransacc', 'SendAlertaTransacc', + 'GetTransacciones', + 'Conectar', 'LeerError', 'LeerTransaccion', + 'SetUsername', + 'SetParametro', 'GetParametro', + 'GetCodigoTransaccion', 'GetResultado', 'LoadTestXML'] + + _public_attrs_ = [ + 'Username', 'Password', + 'CodigoTransaccion', 'Errores', 'Resultado', + 'XmlRequest', 'XmlResponse', + 'Version', 'InstallDir', + 'Traceback', 'Excepcion', 'LanzarExcepciones', + 'CantPaginas', 'HayError', 'TransaccionSenasa', + ] + + _reg_progid_ = "TrazaFito" + _reg_clsid_ = "{39793931-450A-4F66-9324-D4D981FC5319}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s %s" % (__version__, HOMO and 'Homologacin' or '', + pysimplesoap.client.__version__) + + def __init__(self, reintentos=1): + self.Username = self.Password = None + self.TransaccionSenasa = [] + BaseWS.__init__(self, reintentos) + + def inicializar(self): + BaseWS.inicializar(self) + self.CodigoTransaccion = self.Errores = self.Resultado = None + self.Resultado = '' + self.Errores = [] # lista de strings para la interfaz + self.errores = [] # lista de diccionarios (uso interno) + self.CantPaginas = self.HayError = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.errores = ret.get('errores', []) + self.Errores = ["%s: %s" % (it['c_error'], it['d_error']) + for it in ret.get('errores', [])] + self.Resultado = ret.get('resultado') + + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30): + # Conecto usando el mtodo estandard: + ok = BaseWS.Conectar(self, cache, wsdl, proxy, wrapper, cacert, timeout, + soap_server="jetty") + if ok: + # si el archivo es local, asumo que ya esta corregido: + if not self.wsdl.startswith("file"): + # corrijo ubicacin del servidor (localhost:9050 en el WSDL) + location = self.wsdl[:-5] + ws = self.client.services['IWebServiceSenasa'] + ws['ports']['IWebServiceSenasaPort']['location'] = location + + # Establecer credenciales de seguridad: + self.client['wsse:Security'] = { + 'wsse:UsernameToken': { + 'wsse:Username': self.Username, + 'wsse:Password': self.Password, + } + } + return ok + + @inicializar_y_capturar_excepciones + def SaveTransaccion(self, usuario, password, + gln_origen=None, gln_destino=None, + f_operacion=None, f_elaboracion=None, f_vto=None, + id_evento=None, cod_producto=None, n_cantidad=None, + n_serie=None, n_lote=None, n_cai=None, n_cae=None, + id_motivo_destruccion=None, n_manifiesto=None, + en_transporte=None, n_remito=None, + motivo_devolucion=None, observaciones=None, + n_vale_compra=None, apellidoNombres=None, + direccion=None, numero=None, localidad=None, + provincia=None, n_postal=None, cuit=None + ): + "Realiza el registro de una transaccin de productos fitosanitarios. " + # creo los parmetros para esta llamada + params = {'gln_origen': gln_origen, + 'gln_destino': gln_destino, + 'f_operacion': f_operacion, + 'f_elaboracion': f_elaboracion, + 'f_vto': f_vto, + 'id_evento': id_evento, + 'cod_producto': cod_producto, + 'n_cantidad': n_cantidad, + 'n_serie': n_serie, + 'n_lote': n_lote, + 'n_cai': n_cai or None, + 'n_cae': n_cae or None, + 'id_motivo_destruccion': id_motivo_destruccion or None, + 'n_manifiesto': n_manifiesto or None, + 'en_transporte': en_transporte or None, + 'n_remito': n_remito or None, + 'motivo_devolucion': motivo_devolucion or None, + 'observaciones': observaciones or None, + 'n_vale_compra': n_vale_compra or None, + 'apellidoNombres': apellidoNombres or None, + 'direccion': direccion or None, + 'numero': numero or None, + 'localidad': localidad or None, + 'provincia': provincia or None, + 'n_postal': n_postal or None, + 'cuit': cuit or None, + } + res = self.client.saveTransacciones( + arg0=params, + arg1=usuario, + arg2=password, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendCancelaTransac(self, usuario, password, codigo_transaccion): + " Realiza la cancelacin de una transaccin" + res = self.client.sendCancelaTransac( + arg0=codigo_transaccion, + arg1=usuario, + arg2=password, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendConfirmaTransacc(self, usuario, password, p_ids_transac, f_operacion, n_cantidad=None): + "Confirma la recepcin de un medicamento" + res = self.client.sendConfirmaTransacc( + arg0=usuario, + arg1=password, + arg2={'p_ids_transac': p_ids_transac, 'f_operacion': f_operacion, + 'n_cantidad': n_cantidad, + }, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendAlertaTransacc(self, usuario, password, p_ids_transac_ws): + "Alerta un medicamento, accin contraria a confirmar la transaccin." + res = self.client.sendAlertaTransacc( + arg0=usuario, + arg1=password, + arg2=p_ids_transac_ws, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('id_transac_asociada') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def GetTransacciones(self, usuario, password, + id_transaccion=None, id_evento=None, gln_origen=None, + fecha_desde_t=None, fecha_hasta_t=None, + fecha_desde_v=None, fecha_hasta_v=None, + gln_informador=None, id_tipo_transaccion=None, + gtin_elemento=None, n_lote=None, n_serie=None, + n_remito_factura=None, + ): + "Trae un listado de las transacciones que no estn confirmadas" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if id_transaccion is not None: + kwargs['arg2'] = id_transaccion + if id_evento is not None: + kwargs['arg3'] = id_evento + if gln_origen is not None: + kwargs['arg4'] = gln_origen + if fecha_desde_t is not None: + kwargs['arg5'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['arg6'] = fecha_hasta_t + if fecha_desde_v is not None: + kwargs['arg7'] = fecha_desde_v + if fecha_hasta_v is not None: + kwargs['arg8'] = fecha_hasta_v + if gln_informador is not None: + kwargs['arg9'] = gln_informador + if id_tipo_transaccion is not None: + kwargs['arg10'] = id_tipo_transaccion + if gtin_elemento is not None: + kwargs['arg11'] = gtin_elemento + if n_lote is not None: + kwargs['arg12'] = n_lote + if n_serie is not None: + kwargs['arg13'] = n_serie + if n_remito_factura is not None: + kwargs['arg14'] = n_remito_factura + + # llamo al webservice + res = self.client.getTransacciones( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.TransaccionSenasa = [it for it in ret.get('list', [])] + return True + + def LeerTransaccion(self): + "Recorro TransaccionSenasa devuelto por GetTransacciones" + # usar GetParametro para consultar el valor retornado por el webservice + + if self.TransaccionSenasa: + # extraigo el primer item + self.params_out = self.TransaccionSenasa.pop(0) + return True + else: + # limpio los parmetros + self.params_out = {} + return False + + def LeerError(self): + "Recorro los errores devueltos y devuelvo el primero si existe" + + if self.Errores: + # extraigo el primer item + er = self.Errores.pop(0) + return er + else: + return "" + + def SetUsername(self, username): + "Establezco el nombre de usuario" + self.Username = username + + def SetPassword(self, password): + "Establezco la contrasea" + self.Password = password + + def GetCodigoTransaccion(self): + "Devuelvo el cdigo de transaccin" + return self.CodigoTransaccion + + def GetResultado(self): + "Devuelvo el resultado" + return self.Resultado + + +def main(): + "Funcin principal de pruebas (obtener CAE)" + import os + import time + import sys + global WSDL, LOCATION + + DEBUG = '--debug' in sys.argv + + ws = TrazaFito() + + ws.Username = 'testwservice' + ws.Password = 'testwservicepsw' + + if '--prod' in sys.argv and not HOMO: + WSDL = "https://servicios.pami.org.ar/trazaagr.WebService?wsdl" + print("Usando WSDL:", WSDL) + sys.argv.pop(sys.argv.index("--prod")) + + # Inicializo las variables y estructuras para el archivo de intercambio: + transaccion_dto = [] + transacciones = [] + errores = [] + formatos = [('TransaccionDTO', TRANSACCION_DTO, transaccion_dto), + ('Transacciones', TRANSACCIONES, transacciones), + ('Errores', ERRORES, errores), + ] + + if '--formato' in sys.argv: + print("Formato:") + for msg, formato, lista in formatos: + comienzo = 1 + print("=== %s ===" % msg) + print("|| %-25s || %-12s || %-5s || %-4s || %-10s ||" % ( + "Nombre", "Tipo", "Long.", "Pos(txt)", "Campo(dbf)")) + claves = [] + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + clave_dbf = dar_nombre_campo_dbf(clave, claves) + claves.append(clave_dbf) + print("|| %-25s || %-12s || %5d || %4d || %-10s ||" % ( + clave, tipo, longitud, comienzo, clave_dbf)) + comienzo += longitud + sys.exit(0) + + if '--cargar' in sys.argv: + if '--dbf' in sys.argv: + leer_dbf(formatos[:1], {}) + elif '--json' in sys.argv: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".json", "r") + d = json.load(archivo) + formato[2].extend(d) + archivo.close() + else: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".txt", "r") + for linea in archivo: + d = leer(linea, formato[1]) + formato[2].append(d) + archivo.close() + + ws.Conectar("", WSDL) + + if ws.Excepcion: + print(ws.Excepcion) + print(ws.Traceback) + sys.exit(-1) + + # Datos de pruebas: + + if '--test' in sys.argv: + transaccion_dto.append(dict( + gln_origen="9876543210982", gln_destino="3692581473693", + f_operacion=datetime.datetime.now().strftime("%d/%m/%Y"), + f_elaboracion=datetime.datetime.now().strftime("%d/%m/%Y"), + f_vto=(datetime.datetime.now() + datetime.timedelta(30)).strftime("%d/%m/%Y"), + id_evento=11, + cod_producto="88900000000001", + n_cantidad=1, + n_serie=int(time.time() * 10), + n_lote=datetime.datetime.now().strftime("%Y"), + n_cai="123456789012345", + n_cae="", + id_motivo_destruccion=0, + n_manifiesto="", + en_transporte="N", + n_remito="1234", + motivo_devolucion="", + observaciones="prueba", + n_vale_compra="", + apellidoNombres="Juan Peres", + direccion="Saraza", numero="1234", + localidad="Hurlingham", provincia="Buenos Aires", + n_postal="1688", + cuit="20267565393", + codigo_transaccion=None, + )) + + # Opciones principales: + + if '--confirma' in sys.argv: + if '--loadxml' in sys.argv: + ws.LoadTestXML("trazamed_confirma.xml") # cargo respuesta + ok = ws.SendConfirmaTransacc(usuario="pruebasws", password="pruebasws", + p_ids_transac="1", f_operacion="31-12-2013") + if not ok: + raise RuntimeError(ws.Excepcion) + ws.SendConfirmaTransacc(*sys.argv[sys.argv.index("--confirma") + 1:]) + elif '--alerta' in sys.argv: + ws.SendAlertaTransacc(*sys.argv[sys.argv.index("--alerta") + 1:]) + elif '--cancela' in sys.argv: + ws.SendCancelaTransac(*sys.argv[sys.argv.index("--cancela") + 1:]) + elif '--consulta' in sys.argv: + ws.GetTransacciones( + *sys.argv[sys.argv.index("--consulta") + 1:] + ) + print("CantPaginas", ws.CantPaginas) + print("HayError", ws.HayError) + # print "TransaccionSenasa", ws.TransaccionSenasa + # parametros comunes de salida (columnas de la tabla): + claves = [k for k, v, l in TRANSACCIONES] + # extiendo la lista de resultado para el archivo de intercambio: + transacciones.extend(ws.TransaccionSenasa) + # encabezado de la tabla: + print("||", "||".join(["%s" % clave for clave in claves]), "||") + # recorro los datos devueltos (TransaccionSenasa): + while ws.LeerTransaccion(): + for clave in claves: + print("||", ws.GetParametro(clave), end=' ') # imprimo cada fila + print("||") + else: + argv = [argv for argv in sys.argv if not argv.startswith("--")] + if not transaccion_dto: + if len(argv) > 10: + ws.SaveTransaccion(*argv[1:]) + else: + print("ERROR: no se indicaron todos los parmetros requeridos") + elif transaccion_dto: + try: + usuario, password = argv[-2:] + except BaseException: + print("ADVERTENCIA: no se indico parmetros usuario y passoword") + usuario, password = "senasaws", "Clave2013" + for i, dto in enumerate(transaccion_dto): + print("Procesando registro", i) + del dto['codigo_transaccion'] + ws.SaveTransaccion(usuario, password, **dto) + dto['codigo_transaccion'] = ws.CodigoTransaccion + errores.extend(ws.errores) + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + else: + print("ERROR: no se especificaron productos a informar") + + if not transaccion_dto: + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + + if ws.Excepcion: + print(ws.Traceback) + + if '--grabar' in sys.argv: + if '--dbf' in sys.argv: + guardar_dbf(formatos, True, {}) + elif '--json' in sys.argv: + for formato in formatos: + archivo = open(formato[0].lower() + ".json", "w") + json.dump(formato[2], archivo, sort_keys=True, indent=4) + archivo.close() + else: + for formato in formatos: + archivo = open(formato[0].lower() + ".txt", "w") + for it in formato[2]: + archivo.write(escribir(it, formato[1])) + archivo.close() + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = TrazaFito.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + # ajusto el encoding por defecto (si se redirije la salida) + if not hasattr(sys.stdout, "encoding") or sys.stdout.encoding is None: + import codecs + import locale + sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout, "replace") + sys.stderr = codecs.getwriter(locale.getpreferredencoding())(sys.stderr, "replace") + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + import win32com.server.register + win32com.server.register.UseCommandLine(TrazaFito) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([TrazaFito._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/trazamed.py b/app/pyafipws/trazamed.py new file mode 100644 index 0000000000000000000000000000000000000000..45f0a57e2d01399b375e4117ef7890efc03918d2 --- /dev/null +++ b/app/pyafipws/trazamed.py @@ -0,0 +1,1041 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""Mdulo para Trazabilidad de Medicamentos ANMAT - PAMI - INSSJP Disp. 3683/11 +segn Especificacin Tcnica para Pruebas de Servicios v2 (2013)""" + +# Informacin adicional y documentacin: +# http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadMedicamentos + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2011 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.16c" + +import os +import socket +import sys +import datetime +import time +import traceback +import pysimplesoap.client +from pysimplesoap.client import SoapClient, SoapFault, parse_proxy, \ + set_http_wrapper +from pysimplesoap.simplexml import SimpleXMLElement +from io import StringIO + +# importo funciones compartidas: +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, dar_nombre_campo_dbf, get_install_dir, BaseWS, inicializar_y_capturar_excepciones + +HOMO = False +TYPELIB = False + +WSDL = "https://servicios.pami.org.ar/trazamed.WebService?wsdl" +LOCATION = "https://servicios.pami.org.ar/trazamed.WebService" +#WSDL = "https://trazabilidad.pami.org.ar:9050/trazamed.WebService?wsdl" + +# Formato de MedicamentosDTO, MedicamentosDTODHSerie, MedicamentosDTOFraccion +MEDICAMENTOS = [ + ('f_evento', 10, A), # formato DD/MM/AAAA + ('h_evento', 5, A), # formato HH:MM + ('gln_origen', 13, A), + ('gln_destino', 13, A), + ('n_remito', 20, A), + ('n_factura', 20, A), + ('vencimiento', 10, A), + ('gtin', 14, A), + ('lote', 20, A), + ('numero_serial', 20, A), + ('desde_numero_serial', 20, A), # sendMedicamentosDHSerie + ('hasta_numero_serial', 20, A), # sendMedicamentosDHSerie + ('id_obra_social', 9, N), + ('id_evento', 3, N), + ('cuit_origen', 11, A), + ('cuit_destino', 11, A), + ('apellido', 50, A), + ('nombres', 100, A), + ('tipo_documento', 2, N), # 96: DNI,80: CUIT + ('n_documento', 10, A), + ('sexo', 1, A), # M o F + ('direccion', 100, A), + ('numero', 10, A), + ('piso', 5, A), + ('depto', 5, A), + ('localidad', 50, A), + ('provincia', 100, A), + ('n_postal', 8, A), + ('fecha_nacimiento', 100, A), + ('telefono', 30, A), + ('nro_asociado', 30, A), + ('cantidad', 3, N), # sendMedicamentosFraccion + ('codigo_transaccion', 14, A), +] + +# Formato para TransaccionPlainWS (getTransaccionesNoConfirmadas) +TRANSACCIONES = [ + ('_id_transaccion', 14, A), + ('_id_transaccion_global', 14, A), + ('_f_evento', 10, A), + ('_f_transaccion', 16, A), # formato DD/MM/AAAA HH:MM + ('_gtin', 14, A), + ('_lote', 20, A), + ('_numero_serial', 20, A), + ('_nombre', 200, A), + ('_d_evento', 100, A), + ('_gln_origen', 13, A), + ('_razon_social_origen', 200, A), + ('_gln_destino', 13, A), + ('_razon_social_destino', 200, A), + ('_n_remito', 20, A), + ('_n_factura', 20, A), + ('_vencimiento', 10, A), + ('_id_evento', 3, N), # agregado el 30/01/2014 +] + +# Formato para Errores +ERRORES = [ + ('c_error', 4, A), # cdigo + ('d_error', 250, A), # descripcin +] + + +class TrazaMed(BaseWS): + "Interfaz para el WebService de Trazabilidad de Medicamentos ANMAT - PAMI - INSSJP" + _public_methods_ = ['SendMedicamentos', + 'SendCancelacTransacc', 'SendCancelacTransaccParcial', + 'SendMedicamentosDHSerie', + 'SendMedicamentosFraccion', + 'SendConfirmaTransacc', 'SendAlertaTransacc', + 'GetTransaccionesNoConfirmadas', + 'GetEnviosPropiosAlertados', 'GetConsultaStock', + 'GetTransaccionesWS', 'GetCatalogoElectronicoByGTIN', + 'Conectar', 'LeerError', 'LeerTransaccion', + 'SetUsername', 'SetPassword', + 'SetParametro', 'GetParametro', + 'GetCodigoTransaccion', 'GetResultado', 'LoadTestXML'] + + _public_attrs_ = [ + 'Username', 'Password', + 'CodigoTransaccion', 'Errores', 'Resultado', + 'XmlRequest', 'XmlResponse', + 'Version', 'InstallDir', + 'Traceback', 'Excepcion', 'LanzarExcepciones', + 'CantPaginas', 'HayError', 'TransaccionPlainWS', + ] + + _reg_progid_ = "TrazaMed" + _reg_clsid_ = "{8472867A-AE6F-487F-8554-C2C896CFFC3E}" + + if TYPELIB: + _typelib_guid_ = '{F992EB7E-AFBD-41BB-B717-5693D3A2BADB}' + _typelib_version_ = 1, 4 + _com_interfaces_ = ['ITrazaMed'] + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s %s" % (__version__, HOMO and 'Homologacin' or '', + pysimplesoap.client.__version__) + + def __init__(self, reintentos=1): + self.Username = self.Password = None + self.TransaccionPlainWS = [] + BaseWS.__init__(self, reintentos) + + def inicializar(self): + BaseWS.inicializar(self) + self.CodigoTransaccion = self.Errores = self.Resultado = None + self.Resultado = '' + self.Errores = [] # lista de strings para la interfaz + self.errores = [] # lista de diccionarios (uso interno) + self.CantPaginas = self.HayError = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.errores = ret.get('errores', []) + self.Errores = ["%s: %s" % (it['_c_error'], it['_d_error']) + for it in ret.get('errores', [])] + self.Resultado = ret.get('resultado') + + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30): + # Conecto usando el mtodo estandard: + ok = BaseWS.Conectar(self, cache, wsdl, proxy, wrapper, cacert, timeout, + soap_server="jetty") + + if ok: + # si el archivo es local, asumo que ya esta corregido: + if not self.wsdl.startswith("file"): + # corrijo ubicacin del servidor (localhost:9050 en el WSDL) + location = self.wsdl[:-5] + if 'IWebServiceService' in self.client.services: + ws = self.client.services['IWebServiceService'] # version 1 + else: + ws = self.client.services['IWebService'] # version 2 + ws['ports']['IWebServicePort']['location'] = location + + # Establecer credenciales de seguridad: + self.client['wsse:Security'] = { + 'wsse:UsernameToken': { + 'wsse:Username': self.Username, + 'wsse:Password': self.Password, + } + } + return ok + + @inicializar_y_capturar_excepciones + def SendMedicamentos(self, usuario, password, + f_evento, h_evento, gln_origen, gln_destino, + n_remito, n_factura, vencimiento, gtin, lote, + numero_serial, id_obra_social, id_evento, + cuit_origen='', cuit_destino='', apellido='', nombres='', + tipo_documento='', n_documento='', sexo='', + direccion='', numero='', piso='', depto='', localidad='', provincia='', + n_postal='', fecha_nacimiento='', telefono='', + nro_asociado=None, + ): + "Realiza el registro de una transaccin de medicamentos. " + # creo los parmetros para esta llamada + params = {'f_evento': f_evento, + 'h_evento': h_evento, + 'gln_origen': gln_origen, + 'gln_destino': gln_destino, + 'n_remito': n_remito, + 'n_factura': n_factura, + 'vencimiento': vencimiento, + 'gtin': gtin, + 'lote': lote, + 'numero_serial': numero_serial, + 'id_obra_social': id_obra_social or None, + 'id_evento': id_evento, + 'cuit_origen': cuit_origen, + 'cuit_destino': cuit_destino, + 'apellido': apellido, + 'nombres': nombres, + 'tipo_documento': tipo_documento, + 'n_documento': n_documento, + 'sexo': sexo, + 'direccion': direccion, + 'numero': numero, + 'piso': piso, + 'depto': depto, + 'localidad': localidad, + 'provincia': provincia, + 'n_postal': n_postal, + 'fecha_nacimiento': fecha_nacimiento, + 'telefono': telefono, + 'nro_asociado': nro_asociado, + } + res = self.client.sendMedicamentos( + arg0=params, + arg1=usuario, + arg2=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret['codigoTransaccion'] + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendMedicamentosFraccion(self, usuario, password, + f_evento, h_evento, gln_origen, gln_destino, + n_remito, n_factura, vencimiento, gtin, lote, + numero_serial, id_obra_social, id_evento, + cuit_origen='', cuit_destino='', apellido='', nombres='', + tipo_documento='', n_documento='', sexo='', + direccion='', numero='', piso='', depto='', localidad='', provincia='', + n_postal='', fecha_nacimiento='', telefono='', + nro_asociado=None, cantidad=None, + ): + "Realiza el registro de una transaccin de medicamentos fraccionados" + # creo los parmetros para esta llamada + params = {'f_evento': f_evento, + 'h_evento': h_evento, + 'gln_origen': gln_origen, + 'gln_destino': gln_destino, + 'n_remito': n_remito, + 'n_factura': n_factura, + 'vencimiento': vencimiento, + 'gtin': gtin, + 'lote': lote, + 'numero_serial': numero_serial, + 'id_obra_social': id_obra_social or None, + 'id_evento': id_evento, + 'cuit_origen': cuit_origen, + 'cuit_destino': cuit_destino, + 'apellido': apellido, + 'nombres': nombres, + 'tipo_documento': tipo_documento, + 'n_documento': n_documento, + 'sexo': sexo, + 'direccion': direccion, + 'numero': numero, + 'piso': piso, + 'depto': depto, + 'localidad': localidad, + 'provincia': provincia, + 'n_postal': n_postal, + 'fecha_nacimiento': fecha_nacimiento, + 'telefono': telefono, + 'nro_asociado': nro_asociado, + 'cantidad': cantidad, + } + res = self.client.sendMedicamentosFraccion( + arg0=params, + arg1=usuario, + arg2=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret['codigoTransaccion'] + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendMedicamentosDHSerie(self, usuario, password, + f_evento, h_evento, gln_origen, gln_destino, + n_remito, n_factura, vencimiento, gtin, lote, + desde_numero_serial, hasta_numero_serial, + id_obra_social, id_evento, + cuit_origen='', cuit_destino='', apellido='', nombres='', + tipo_documento='', n_documento='', sexo='', + direccion='', numero='', piso='', depto='', localidad='', provincia='', + n_postal='', fecha_nacimiento='', telefono='', + nro_asociado=None, + ): + "Enva un lote de medicamentos informando el desde-hasta nmero de serie" + # creo los parmetros para esta llamada + params = {'f_evento': f_evento, + 'h_evento': h_evento, + 'gln_origen': gln_origen, + 'gln_destino': gln_destino, + 'n_remito': n_remito, + 'n_factura': n_factura, + 'vencimiento': vencimiento, + 'gtin': gtin, + 'lote': lote, + 'desde_numero_serial': desde_numero_serial, + 'hasta_numero_serial': hasta_numero_serial, + 'id_obra_social': id_obra_social or None, + 'id_evento': id_evento, + 'cuit_origen': cuit_origen, + 'cuit_destino': cuit_destino, + 'apellido': apellido, + 'nombres': nombres, + 'tipo_documento': tipo_documento, + 'n_documento': n_documento, + 'sexo': sexo, + 'direccion': direccion, + 'numero': numero, + 'piso': piso, + 'depto': depto, + 'localidad': localidad, + 'provincia': provincia, + 'n_postal': n_postal, + 'fecha_nacimiento': fecha_nacimiento, + 'telefono': telefono, + 'nro_asociado': nro_asociado, + } + res = self.client.sendMedicamentosDHSerie( + arg0=params, + arg1=usuario, + arg2=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret['codigoTransaccion'] + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendCancelacTransacc(self, usuario, password, codigo_transaccion): + " Realiza la cancelacin de una transaccin" + res = self.client.sendCancelacTransacc( + arg0=codigo_transaccion, + arg1=usuario, + arg2=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendCancelacTransaccParcial(self, usuario, password, codigo_transaccion, + gtin_medicamento=None, numero_serial=None): + " Realiza la cancelacin parcial de una transaccin" + res = self.client.sendCancelacTransaccParcial( + arg0=codigo_transaccion, + arg1=usuario, + arg2=password, + arg3=gtin_medicamento, + arg4=numero_serial, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendConfirmaTransacc(self, usuario, password, p_ids_transac, f_operacion): + "Confirma la recepcin de un medicamento" + res = self.client.sendConfirmaTransacc( + arg0=usuario, + arg1=password, + arg2={'p_ids_transac': p_ids_transac, 'f_operacion': f_operacion}, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('id_transac_asociada') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendAlertaTransacc(self, usuario, password, p_ids_transac_ws): + "Alerta un medicamento, accin contraria a confirmar la transaccin." + res = self.client.sendAlertaTransacc( + arg0=usuario, + arg1=password, + arg2=p_ids_transac_ws, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('id_transac_asociada') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def GetTransaccionesNoConfirmadas(self, usuario, password, + p_id_transaccion_global=None, id_agente_informador=None, + id_agente_origen=None, id_agente_destino=None, + id_medicamento=None, id_evento=None, + fecha_desde_op=None, fecha_hasta_op=None, + fecha_desde_t=None, fecha_hasta_t=None, + fecha_desde_v=None, fecha_hasta_v=None, + n_remito=None, n_factura=None, + estado=None, lote=None, numero_serial=None, + ): + "Trae un listado de las transacciones que no estn confirmadas" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if p_id_transaccion_global is not None: + kwargs['arg2'] = p_id_transaccion_global + if id_agente_informador is not None: + kwargs['arg3'] = id_agente_informador + if id_agente_origen is not None: + kwargs['arg4'] = id_agente_origen + if id_agente_destino is not None: + kwargs['arg5'] = id_agente_destino + if id_medicamento is not None: + kwargs['arg6'] = id_medicamento + if id_evento is not None: + kwargs['arg7'] = id_evento + if fecha_desde_op is not None: + kwargs['arg8'] = fecha_desde_op + if fecha_hasta_op is not None: + kwargs['arg9'] = fecha_hasta_op + if fecha_desde_t is not None: + kwargs['arg10'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['arg11'] = fecha_hasta_t + if fecha_desde_v is not None: + kwargs['arg12'] = fecha_desde_v + if fecha_hasta_v is not None: + kwargs['arg13'] = fecha_hasta_v + if n_remito is not None: + kwargs['arg14'] = n_remito + if n_factura is not None: + kwargs['arg15'] = n_factura + if estado is not None: + kwargs['arg16'] = estado + if lote is not None: + kwargs['arg17'] = lote + if numero_serial is not None: + kwargs['arg18'] = numero_serial + + # llamo al webservice + res = self.client.getTransaccionesNoConfirmadas( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.TransaccionPlainWS = [it for it in ret.get('list', [])] + return True + + def LeerTransaccion(self): + "Recorro TransaccionPlainWS devuelto por GetTransaccionesNoConfirmadas" + # usar GetParametro para consultar el valor retornado por el webservice + + if self.TransaccionPlainWS: + # extraigo el primer item + self.params_out = self.TransaccionPlainWS.pop(0) + return True + else: + # limpio los parmetros + self.params_out = {} + return False + + def LeerError(self): + "Recorro los errores devueltos y devuelvo el primero si existe" + + if self.Errores: + # extraigo el primer item + er = self.Errores.pop(0) + return er + else: + return "" + + @inicializar_y_capturar_excepciones + def GetEnviosPropiosAlertados(self, usuario, password, + p_id_transaccion_global=None, id_agente_informador=None, + id_agente_origen=None, id_agente_destino=None, + id_medicamento=None, id_evento=None, + fecha_desde_op=None, fecha_hasta_op=None, + fecha_desde_t=None, fecha_hasta_t=None, + fecha_desde_v=None, fecha_hasta_v=None, + n_remito=None, n_factura=None, + ): + "Obtiene las distribuciones y envos propios que han sido alertados" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if p_id_transaccion_global is not None: + kwargs['arg2'] = p_id_transaccion_global + if id_agente_informador is not None: + kwargs['arg3'] = id_agente_informador + if id_agente_origen is not None: + kwargs['arg4'] = id_agente_origen + if id_agente_destino is not None: + kwargs['arg5'] = id_agente_destino + if id_medicamento is not None: + kwargs['arg6'] = id_medicamento + if id_evento is not None: + kwargs['arg7'] = id_evento + if fecha_desde_op is not None: + kwargs['arg8'] = fecha_desde_op + if fecha_hasta_op is not None: + kwargs['arg9'] = fecha_hasta_op + if fecha_desde_t is not None: + kwargs['arg10'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['arg11'] = fecha_hasta_t + if fecha_desde_v is not None: + kwargs['arg12'] = fecha_desde_v + if fecha_hasta_v is not None: + kwargs['arg13'] = fecha_hasta_v + if n_remito is not None: + kwargs['arg14'] = n_remito + if n_factura is not None: + kwargs['arg15'] = n_factura + + # llamo al webservice + res = self.client.getEnviosPropiosAlertados( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.TransaccionPlainWS = [it for it in ret.get('list', [])] + return True + + @inicializar_y_capturar_excepciones + def GetTransaccionesWS(self, usuario, password, + p_id_transaccion_global=None, + id_agente_origen=None, id_agente_destino=None, + id_medicamento=None, id_evento=None, + fecha_desde_op=None, fecha_hasta_op=None, + fecha_desde_t=None, fecha_hasta_t=None, + fecha_desde_v=None, fecha_hasta_v=None, + n_remito=None, n_factura=None, + id_estado=None, nro_pag=None, + ): + "Obtiene los movimientos realizados y permite filtros de bsqueda" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if p_id_transaccion_global is not None: + kwargs['arg2'] = p_id_transaccion_global + if id_agente_origen is not None: + kwargs['arg3'] = id_agente_origen + if id_agente_destino is not None: + kwargs['arg4'] = id_agente_destino + if id_medicamento is not None: + kwargs['arg5'] = id_medicamento + if id_evento is not None: + kwargs['arg6'] = id_evento + if fecha_desde_op is not None: + kwargs['arg7'] = fecha_desde_op + if fecha_hasta_op is not None: + kwargs['arg8'] = fecha_hasta_op + if fecha_desde_t is not None: + kwargs['arg9'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['arg10'] = fecha_hasta_t + if fecha_desde_v is not None: + kwargs['arg11'] = fecha_desde_v + if fecha_hasta_v is not None: + kwargs['arg12'] = fecha_hasta_v + if n_remito is not None: + kwargs['arg13'] = n_remito + if n_factura is not None: + kwargs['arg14'] = n_factura + if id_estado is not None: + kwargs['arg15'] = id_estado + if nro_pag is not None: + kwargs['arg16'] = nro_pag + + # llamo al webservice + res = self.client.getTransaccionesWS( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.TransaccionPlainWS = [it for it in ret.get('list', [])] + return True + + @inicializar_y_capturar_excepciones + def GetCatalogoElectronicoByGTIN(self, usuario, password, + cuit_fabricante=None, gtin=None, descripcion=None, + id_monodroga=None, + ): + "Obtiene el Catlogo Electrnico de Medicamentos" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if cuit_fabricante is not None: + kwargs['arg2'] = cuit_fabricante + if gtin is not None: + kwargs['arg3'] = gtin + if descripcion is not None: + kwargs['arg4'] = descripcion + if id_monodroga is not None: + kwargs['arg5'] = id_monodroga + + # llamo al webservice + res = self.client.getCatalogoElectronicoByGTIN( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.params_out = dict([(i, it) for i, it + in enumerate(ret.get('list', []))]) + return len(self.params_out) + else: + return 0 + + @inicializar_y_capturar_excepciones + def GetConsultaStock(self, usuario, password, + id_medicamento=None, id_agente=None, descripcion=None, + cantidad=None, presentacion=None, + lote=None, numero_serial=None, + nro_pag=1, cant_reg=100, + ): + "Permite consultar el stock actual del agente." + + # preparo los parametros de entrada opcionales: + kwargs = {} + if id_medicamento is not None: + kwargs['arg2'] = id_medicamento + if id_agente is not None: + kwargs['arg3'] = id_agente + if descripcion is not None: + kwargs['arg4'] = descripcion + if cantidad is not None: + kwargs['arg5'] = cantidad + if presentacion is not None: + kwargs['arg6'] = presentacion + if lote is not None: + kwargs['arg7'] = lote + if numero_serial is not None: + kwargs['arg8'] = numero_serial + if nro_pag is not None: + kwargs['arg9'] = nro_pag + if cant_reg is not None: + kwargs['arg10'] = cant_reg + + # llamo al webservice + res = self.client.getConsultaStock( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.params_out = dict([(i, it) for i, it + in enumerate(ret.get('list', []))]) + return len(self.params_out) + else: + return 0 + + def SetUsername(self, username): + "Establezco el nombre de usuario" + self.Username = username + + def SetPassword(self, password): + "Establezco la contrasea" + self.Password = password + + def GetCodigoTransaccion(self): + "Devuelvo el cdigo de transaccin" + return self.CodigoTransaccion + + def GetResultado(self): + "Devuelvo el resultado" + return self.Resultado + + +def main(): + "Funcin principal de pruebas (obtener CAE)" + import os + import time + import sys + global WSDL, LOCATION + + DEBUG = '--debug' in sys.argv + + ws = TrazaMed() + + ws.Username = 'testwservice' + ws.Password = 'testwservicepsw' + + if '--prod' in sys.argv and not HOMO: + WSDL = "https://trazabilidad.pami.org.ar:9050/trazamed.WebService" + print("Usando WSDL:", WSDL) + sys.argv.pop(sys.argv.index("--prod")) + + # Inicializo las variables y estructuras para el archivo de intercambio: + medicamentos = [] + transacciones = [] + errores = [] + formatos = [('Medicamentos', MEDICAMENTOS, medicamentos), + ('Transacciones', TRANSACCIONES, transacciones), + ('Errores', ERRORES, errores), + ] + + if '--formato' in sys.argv: + print("Formato:") + for msg, formato, lista in formatos: + comienzo = 1 + print("=== %s ===" % msg) + print("|| %-25s || %-12s || %-5s || %-4s || %-10s ||" % ( + "Nombre", "Tipo", "Long.", "Pos(txt)", "Campo(dbf)")) + claves = [] + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + clave_dbf = dar_nombre_campo_dbf(clave, claves) + claves.append(clave_dbf) + print("|| %-25s || %-12s || %5d || %4d || %-10s ||" % ( + clave, tipo, longitud, comienzo, clave_dbf)) + comienzo += longitud + sys.exit(0) + + if '--cargar' in sys.argv: + if '--dbf' in sys.argv: + leer_dbf(formatos[:1], {}) + elif '--json' in sys.argv: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".json", "r") + d = json.load(archivo) + formato[2].extend(d) + archivo.close() + else: + for formato, campos, lista in formatos[:1]: + archivo = open(formato.lower() + ".txt", "r") + for linea in archivo: + d = leer(linea, campos) + lista.append(d) + archivo.close() + if DEBUG: + for campo in campos: + print(campo[0], "=", lista[0][campo[0]]) + + ws.Conectar("", WSDL) + + if ws.Excepcion: + print(ws.Excepcion) + print(ws.Traceback) + sys.exit(-1) + + # Datos de pruebas: + + if '--test' in sys.argv: + medicamentos.append(dict( + f_evento=datetime.datetime.now().strftime("%d/%m/%Y"), + h_evento=datetime.datetime.now().strftime("%H:%M"), + gln_origen="9999999999918", gln_destino="glnws", + n_remito="R000100001234", n_factura="A000100001234", + vencimiento=(datetime.datetime.now() + datetime.timedelta(30)).strftime("%d/%m/%Y"), + gtin="GTIN1", lote=datetime.datetime.now().strftime("%Y"), + numero_serial=int(time.time() * 10), + id_obra_social=None, id_evento=134, + cuit_origen="20267565393", cuit_destino="20267565393", + apellido="Reingart", nombres="Mariano", + tipo_documento="96", n_documento="26756539", sexo="M", + direccion="Saraza", numero="1234", piso="", depto="", + localidad="Hurlingham", provincia="Buenos Aires", + n_postal="1688", fecha_nacimiento="01/01/2000", + telefono="5555-5555", + nro_asociado="9999999999999", + cantidad=None, + desde_numero_serial=None, hasta_numero_serial=None, + codigo_transaccion=None, + )) + if '--testfraccion' in sys.argv: + medicamentos.append(dict( + f_evento=datetime.datetime.now().strftime("%d/%m/%Y"), + h_evento=datetime.datetime.now().strftime("%H:%M"), + gln_origen="9999999999918", gln_destino="glnws", + n_remito="1234", n_factura="1234", + vencimiento=(datetime.datetime.now() + datetime.timedelta(30)).strftime("%d/%m/%Y"), + gtin="GTIN1", lote=datetime.datetime.now().strftime("%Y"), + numero_serial=int(time.time() * 10), + id_obra_social=None, id_evento=134, + cuit_origen="20267565393", cuit_destino="20267565393", + apellido="Reingart", nombres="Mariano", + tipo_documento="96", n_documento="26756539", sexo="M", + direccion="Saraza", numero="1234", piso="", depto="", + localidad="Hurlingham", provincia="Buenos Aires", + n_postal="1688", fecha_nacimiento="01/01/2000", + telefono="5555-5555", + nro_asociado="9999999999999", + cantidad=5, + desde_numero_serial=None, hasta_numero_serial=None, + codigo_transaccion=None, + )) + if '--testdh' in sys.argv: + medicamentos.append(dict( + f_evento=datetime.datetime.now().strftime("%d/%m/%Y"), + h_evento=datetime.datetime.now().strftime("%H:%M"), + gln_origen="9999999999918", gln_destino="glnws", + n_remito="1234", n_factura="1234", + vencimiento=(datetime.datetime.now() + datetime.timedelta(30)).strftime("%d/%m/%Y"), + gtin="GTIN1", lote=datetime.datetime.now().strftime("%Y"), + desde_numero_serial=int(time.time() * 10) - 1, + hasta_numero_serial=int(time.time() * 10) + 1, + id_obra_social=None, id_evento=134, + nro_asociado="1234", + cantidad=None, numero_serial=None, + codigo_transaccion=None, + )) + + # Opciones principales: + + if '--cancela' in sys.argv: + if '--loadxml' in sys.argv: + ws.LoadTestXML("trazamed_cancela_err.xml") # cargo respuesta + ws.SendCancelacTransacc(*sys.argv[sys.argv.index("--cancela") + 1:]) + elif '--cancela_parcial' in sys.argv: + ws.SendCancelacTransaccParcial(*sys.argv[sys.argv.index("--cancela_parcial") + 1:]) + elif '--confirma' in sys.argv: + if '--loadxml' in sys.argv: + ws.LoadTestXML("trazamed_confirma.xml") # cargo respuesta + ok = ws.SendConfirmaTransacc(usuario="pruebasws", password="pruebasws", + p_ids_transac="1", f_operacion="31-12-2013") + if not ok: + raise RuntimeError(ws.Excepcion) + ws.SendConfirmaTransacc(*sys.argv[sys.argv.index("--confirma") + 1:]) + elif '--alerta' in sys.argv: + ws.SendAlertaTransacc(*sys.argv[sys.argv.index("--alerta") + 1:]) + elif '--consulta' in sys.argv: + if '--alertados' in sys.argv: + ws.GetEnviosPropiosAlertados( + *sys.argv[sys.argv.index("--alertados") + 1:] + ) + elif '--movimientos' in sys.argv: + ws.GetTransaccionesWS( + *sys.argv[sys.argv.index("--movimientos") + 1:] + ) + else: + ws.GetTransaccionesNoConfirmadas( + *sys.argv[sys.argv.index("--consulta") + 1:] + # usuario="pruebasws", password="pruebasws", + # p_id_transaccion_global="1234", + # id_agente_informador="1", + # id_agente_origen="1", + # id_agente_destino="1", + # id_medicamento="1", + # id_evento="1", + # fecha_desde_op="01/01/2015", + # fecha_hasta_op="31/12/2013", + # fecha_desde_t="01/01/2013", + # fecha_hasta_t="31/12/2013", + # fecha_desde_v="01/04/2013", + # fecha_hasta_v="30/04/2013", + # n_factura=5, n_remito=6, + # estado=1, + # lote=88745, + # numero_serial=894124788, + ) + print("CantPaginas", ws.CantPaginas) + print("HayError", ws.HayError) + # print "TransaccionPlainWS", ws.TransaccionPlainWS + # parametros comunes de salida (columnas de la tabla): + claves = [k for k, v, l in TRANSACCIONES] + # extiendo la lista de resultado para el archivo de intercambio: + transacciones.extend(ws.TransaccionPlainWS) + # encabezado de la tabla: + print("||", "||".join(["%s" % clave for clave in claves]), "||") + # recorro los datos devueltos (TransaccionPlainWS): + while ws.LeerTransaccion(): + for clave in claves: + print("||", ws.GetParametro(clave), end=' ') # imprimo cada fila + print("||") + elif '--catalogo' in sys.argv: + ret = ws.GetCatalogoElectronicoByGTIN( + *sys.argv[sys.argv.index("--catalogo") + 1:] + ) + for catalogo in list(ws.params_out.values()): + print(catalogo) # imprimo cada fila + elif '--stock' in sys.argv: + ret = ws.GetConsultaStock( + *sys.argv[sys.argv.index("--stock") + 1:] + ) + print("\n".join([str(s) for s in list(ws.params_out.values())])) + else: + argv = [argv for argv in sys.argv if not argv.startswith("--")] + if not medicamentos: + if len(argv) > 16: + if '--dh' in sys.argv: + ws.SendMedicamentosDHSerie(*argv[1:]) + elif '--fraccion' in sys.argv: + ws.SendMedicamentosFraccion(*argv[1:]) + else: + ws.SendMedicamentos(*argv[1:]) + else: + print("ERROR: no se indicaron todos los parmetros requeridos") + elif medicamentos: + try: + usuario, password = argv[1:3] + except BaseException: + print("ADVERTENCIA: no se indico parmetros usuario y passoword") + usuario = password = "pruebasws" + for i, med in enumerate(medicamentos): + print("Procesando registro", i) + del med['codigo_transaccion'] + if med.get("cantidad"): + del med["desde_numero_serial"] + del med["hasta_numero_serial"] + ws.SendMedicamentosFraccion(usuario, password, **med) + elif med.get("desde_numero_serial"): + del med["cantidad"] + del med["numero_serial"] + ws.SendMedicamentosDHSerie(usuario, password, **med) + else: + del med["cantidad"] + del med["desde_numero_serial"] + del med["hasta_numero_serial"] + ws.SendMedicamentos(usuario, password, **med) + med['codigo_transaccion'] = ws.CodigoTransaccion + errores.extend(ws.errores) + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + else: + print("ERROR: no se especificaron medicamentos a informar") + + if not medicamentos: + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + + if ws.Excepcion: + print(ws.Traceback) + + if '--grabar' in sys.argv: + if '--dbf' in sys.argv: + guardar_dbf(formatos, True, {}) + elif '--json' in sys.argv: + for formato in formatos: + archivo = open(formato[0].lower() + ".json", "w") + json.dump(formato[2], archivo, sort_keys=True, indent=4) + archivo.close() + else: + for formato, campos, lista in formatos: + archivo = open(formato.lower() + ".txt", "w") + for it in lista: + archivo.write(escribir(it, campos)) + archivo.close() + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = TrazaMed.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + # ajusto el encoding por defecto (si se redirije la salida) + if not hasattr(sys.stdout, "encoding") or sys.stdout.encoding is None: + import codecs + import locale + sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout, "replace") + sys.stderr = codecs.getwriter(locale.getpreferredencoding())(sys.stderr, "replace") + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + if TYPELIB: + if '--register' in sys.argv: + tlb = os.path.abspath(os.path.join(INSTALL_DIR, "typelib", "trazamed.tlb")) + print("Registering %s" % (tlb,)) + tli = pythoncom.LoadTypeLib(tlb) + pythoncom.RegisterTypeLib(tli, tlb) + elif '--unregister' in sys.argv: + k = TrazaMed + pythoncom.UnRegisterTypeLib(k._typelib_guid_, + k._typelib_version_[0], + k._typelib_version_[1], + 0, + pythoncom.SYS_WIN32) + print("Unregistered typelib") + import win32com.server.register + win32com.server.register.UseCommandLine(TrazaMed) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([TrazaMed._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/trazaprodmed.py b/app/pyafipws/trazaprodmed.py new file mode 100644 index 0000000000000000000000000000000000000000..19c5a3c7850d8c7d463740304a84089ccea610bf --- /dev/null +++ b/app/pyafipws/trazaprodmed.py @@ -0,0 +1,555 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""Mdulo para Trazabilidad de Productos Mdicos ANMAT - Disp. 2303/2014 +segn Especificacin Tcnica para Pruebas de Servicios (17/09/2015)""" + +# Informacin adicional y documentacin: +# http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadProductosMedicos + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2016 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.01b" + +import os +import socket +import sys +import datetime +import time +import traceback +import pysimplesoap.client +from pysimplesoap.client import SoapClient, SoapFault, parse_proxy, \ + set_http_wrapper +from pysimplesoap.simplexml import SimpleXMLElement +from io import StringIO + +# importo funciones compartidas: +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, dar_nombre_campo_dbf, get_install_dir, BaseWS, inicializar_y_capturar_excepciones + +HOMO = False +TYPELIB = False + +WSDL = "https://servicios.pami.org.ar/trazaenprodmed.WebService?wsdl" +WSDL_PROD = "https://servicios.pami.org.ar/trazaprodmed.WebService?wsdl" + + +class TrazaProdMed(BaseWS): + "Interfaz para el WebService de Trazabilidad de Productos Mdicos ANMAT - PAMI - INSSJP" + _public_methods_ = ['InformarProducto', 'CrearTransaccion', + 'SendCancelacTransacc', 'SendCancelacTransaccParcial', + 'GetTransaccionesWS', 'GetCatalogoElectronicoByGTIN', + 'GetCatalogoElectronicoByGLN', 'GetMedico', + 'Conectar', 'LeerError', 'LeerTransaccion', + 'SetUsername', 'SetPassword', + 'SetParametro', 'GetParametro', + 'GetCodigoTransaccion', 'GetResultado', 'LoadTestXML'] + + _public_attrs_ = [ + 'Username', 'Password', + 'CodigoTransaccion', 'Errores', 'Resultado', + 'XmlRequest', 'XmlResponse', + 'Version', 'InstallDir', + 'Traceback', 'Excepcion', 'LanzarExcepciones', + 'CantPaginas', 'HayError', 'TransaccionesWS', + ] + + _reg_progid_ = "TrazaProdMed" + _reg_clsid_ = "{D4112556-EF2E-45D3-A2A2-7A2849A364D9}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s %s" % (__version__, HOMO and 'Homologacin' or '', + pysimplesoap.client.__version__) + + def __init__(self, reintentos=1): + self.Username = self.Password = None + self.Transacciones = [] + BaseWS.__init__(self, reintentos) + + def inicializar(self): + BaseWS.inicializar(self) + self.CodigoTransaccion = self.Errores = self.Resultado = None + self.Resultado = '' + self.Errores = [] # lista de strings para la interfaz + self.errores = [] # lista de diccionarios (uso interno) + self.CantPaginas = self.HayError = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.errores = ret.get('errores', []) + self.Errores = ["%s: %s" % (it['c_error'], it['d_error']) + for it in ret.get('errores', [])] + self.Resultado = ret.get('resultado') + + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30): + # Conecto usando el mtodo estandard: + print("timeout", timeout) + ok = BaseWS.Conectar(self, cache, wsdl, proxy, wrapper, cacert, timeout, + soap_server="jetty") + + if ok: + # Establecer credenciales de seguridad: + self.client['wsse:Security'] = { + 'wsse:UsernameToken': { + 'wsse:Username': self.Username, + 'wsse:Password': self.Password, + } + } + return ok + + @inicializar_y_capturar_excepciones + def CrearTransaccion(self, + f_evento, h_evento, gln_origen, gln_destino, + n_remito, n_factura, vencimiento, gtin, lote, + numero_serial, id_evento, + cuit_medico=None, id_obra_social=None, apellido='', nombres='', + tipo_documento='', n_documento='', sexo='', + calle='', numero='', piso='', depto='', localidad='', + provincia='', n_postal='', fecha_nacimiento='', telefono='', + nro_afiliado=None, cod_diagnostico=None, cod_hiv=None, + id_motivo_devolucion=None, otro_motivo_devolucion=None): + "Inicializa internamente una estructura TransaccionDTO para informar" + # creo la transaccin con los parmetros para llamar a InformarProducto + tx = { + 'fEvento': f_evento, 'hEvento': h_evento, + 'glnOrigen': gln_origen, 'glnDestino': gln_destino, + 'nroFactura': n_factura, 'nroRemito': n_remito, + 'vencimiento': vencimiento, + 'gtin': gtin, 'idEvento': id_evento, + 'nroSerial': numero_serial, 'lote': lote, + 'cuitMedico': cuit_medico, + 'apellidos': apellido, 'nombres': nombres, 'telefono': telefono, + 'calle': calle, 'nroCalle': numero, 'departamento': depto, + 'piso': piso, 'localidad': localidad, 'provincia': provincia, + 'codPostal': n_postal, + 'fechaNacimiento': fecha_nacimiento, 'sexo': sexo, + 'idTipoDocumento': tipo_documento, 'nroDocumento': n_documento, + 'codDiagnostico': cod_diagnostico, 'codHiv': cod_hiv, + 'idObraSocial': id_obra_social, 'nroAfiliado': nro_afiliado, + 'idMotivoDevolucion': id_motivo_devolucion, + 'otroMotivoDevolucion': otro_motivo_devolucion, + } + self.Transacciones.append(tx) + return True + + @inicializar_y_capturar_excepciones + def InformarProducto(self, usuario, password): + "Realiza el registro de una transaccin de producto. " + # El usuario (titular del registro/distribuidor/mdico/establecimiento + # asistencial) informa el evento ocurrido para cada uno de los productos. + res = self.client.informarProducto( + transacciones=self.Transacciones, + usuario=usuario, + password=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret['codigoTransaccion'] + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendCancelacTransacc(self, usuario, password, codigo_transaccion): + " Realiza la cancelacin de una transaccin" + res = self.client.sendCancelacTransacc( + transaccion=codigo_transaccion, + usuario=usuario, + password=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendCancelacTransaccParcial(self, usuario, password, codigo_transaccion, + gtin=None, numero_serial=None): + " Realiza la cancelacin parcial de una transaccin" + res = self.client.sendCancelacTransaccParcial( + transaccion=codigo_transaccion, + usuario=usuario, + password=password, + gtin=gtin, + serie=numero_serial, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + def LeerTransaccion(self): + "Recorro Transacciones devueltas por GetTransaccionesWS" + # usar GetParametro para consultar el valor retornado por el webservice + + if self.Transacciones: + # extraigo el primer item + self.params_out = self.Transacciones.pop(0) + return True + else: + # limpio los parmetros + self.params_out = {} + return False + + def LeerError(self): + "Recorro los errores devueltos y devuelvo el primero si existe" + + if self.Errores: + # extraigo el primer item + er = self.Errores.pop(0) + return er + else: + return "" + + @inicializar_y_capturar_excepciones + def GetTransaccionesWS(self, usuario, password, + id_transaccion=None, + gln_agente_origen=None, gln_agente_destino=None, + gtin=None, lote=None, serie=None, id_evento=None, + fecha_desde_op=None, fecha_hasta_op=None, + fecha_desde_t=None, fecha_hasta_t=None, + fecha_desde_v=None, fecha_hasta_v=None, + n_remito=None, n_factura=None, + id_provincia=None, id_estado=None, nro_pag=1, offset=100, + ): + "Obtiene los movimientos realizados y permite filtros de bsqueda" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if id_transaccion is not None: + kwargs['idTransaccion'] = id_transaccion + if gln_agente_origen is not None: + kwargs['glnAgenteOrigen'] = gln_agente_origen + if gln_agente_destino is not None: + kwargs['glnAgenteDestino'] = gln_agente_destino + if gtin is not None: + kwargs['gtin'] = gtin + if lote is not None: + kwargs['lote'] = lote + if serie is not None: + kwargs['serie'] = serie + if id_evento is not None: + kwargs['idEvento'] = id_evento + if fecha_desde_op is not None: + kwargs['fechaOperacionDesde'] = fecha_desde_op + if fecha_hasta_op is not None: + kwargs['fechaOperacionHasta'] = fecha_hasta_op + if fecha_desde_t is not None: + kwargs['fechaTransaccionDesde'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['fechaTransaccionHasta'] = fecha_hasta_t + if fecha_desde_v is not None: + kwargs['fechaVencimientoDesde'] = fecha_desde_v + if fecha_hasta_v is not None: + kwargs['fechaVencimientoHasta'] = fecha_hasta_v + if n_remito is not None: + kwargs['remito'] = n_remito + if n_factura is not None: + kwargs['factura'] = n_factura + if id_provincia is not None: + kwargs['idProvincia'] = id_provincia + if id_estado is not None: + kwargs['idEstadoTransaccion'] = id_estado + if nro_pag is not None: + kwargs['pagina'] = nro_pag + if offset is not None: + kwargs['offset'] = offset + + # llamo al webservice + res = self.client.getTransaccionesWS( + usuario=usuario, + password=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.Transacciones = [it for it in ret.get('list', [])] + return True + + @inicializar_y_capturar_excepciones + def GetCatalogoElectronicoByGTIN(self, usuario, password, + gtin=None, gln=None, marca=None, modelo=None, + cuit=None, id_nombre_generico=None, nro_pag=1, offset=100, + ): + "Obtiene el Catlogo Electrnico de Medicamentos" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if cuit is not None: + kwargs['cuit'] = cuit + if gtin is not None: + kwargs['gtin'] = gtin + if gln is not None: + kwargs['gln'] = gln + if marca is not None: + kwargs['marca'] = marca + if modelo is not None: + kwargs['modelo'] = modelo + if id_nombre_generico is not None: + kwargs['id_nombre_generico'] = id_nombre_generico + if nro_pag is not None: + kwargs['pagina'] = nro_pag + if offset is not None: + kwargs['offset'] = offset + + # llamo al webservice + res = self.client.getCatalogoElectronicoByGTIN( + usuario=usuario, + password=password, + **kwargs + ) + + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.params_out = dict([(i, it) for i, it + in enumerate(ret.get('lstProductos', []))]) + return len(self.params_out) + else: + return 0 + + def SetUsername(self, username): + "Establezco el nombre de usuario" + self.Username = username + + def SetPassword(self, password): + "Establezco la contrasea" + self.Password = password + + def GetCodigoTransaccion(self): + "Devuelvo el cdigo de transaccin" + return self.CodigoTransaccion + + def GetResultado(self): + "Devuelvo el resultado" + return self.Resultado + + +def main(): + "Funcin principal de pruebas (obtener CAE)" + import os + import time + import sys + global WSDL, LOCATION + + DEBUG = '--debug' in sys.argv + + ws = TrazaProdMed() + + ws.Username = 'testwservice' + ws.Password = 'testwservicepsw' + + if '--prod' in sys.argv and not HOMO: + WSDL = WSDL_PROD + print("Usando WSDL:", WSDL) + sys.argv.pop(sys.argv.index("--prod")) + + # Inicializo las variables y estructuras para el archivo de intercambio: + transacciones = [] + errores = [] + formatos = [] + + if '--formato' in sys.argv: + print("Formato:") + for msg, formato, lista in formatos: + comienzo = 1 + print("=== %s ===" % msg) + print("|| %-25s || %-12s || %-5s || %-4s || %-10s ||" % ( + "Nombre", "Tipo", "Long.", "Pos(txt)", "Campo(dbf)")) + claves = [] + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + clave_dbf = dar_nombre_campo_dbf(clave, claves) + claves.append(clave_dbf) + print("|| %-25s || %-12s || %5d || %4d || %-10s ||" % ( + clave, tipo, longitud, comienzo, clave_dbf)) + comienzo += longitud + sys.exit(0) + + if '--cargar' in sys.argv: + if '--dbf' in sys.argv: + leer_dbf(formatos[:1], {}) + elif '--json' in sys.argv: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".json", "r") + d = json.load(archivo) + formato[2].extend(d) + archivo.close() + else: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".txt", "r") + for linea in archivo: + d = leer(linea, formato[1]) + formato[2].append(d) + archivo.close() + + ws.Conectar("", WSDL) + + if ws.Excepcion: + print(ws.Excepcion) + print(ws.Traceback) + sys.exit(-1) + + # Datos de pruebas: + + if '--test' in sys.argv: + ws.CrearTransaccion( + f_evento=datetime.datetime.now().strftime("%d/%m/%Y"), + h_evento=datetime.datetime.now().strftime("%H:%M"), + gln_origen="7791234567801", gln_destino="7791234567801", + n_remito="R0001-12341234", n_factura="A0001-12341234", + vencimiento=(datetime.datetime.now() + datetime.timedelta(30)).strftime("%d/%m/%Y"), + gtin="07791234567810", lote=datetime.datetime.now().strftime("%Y"), # R4556567 + numero_serial=int(time.time() * 10), # A23434 + id_evento=1, + cuit_medico="30711622507", id_obra_social=465667, + apellido="Reingart", nombres="Mariano", + tipo_documento="96", n_documento="28510785", sexo="M", + calle="San Martin", numero="5656", piso="", depto="1", + localidad="Berazategui", provincia="Buenos Aires", + n_postal="1700", fecha_nacimiento="20/12/1972", + telefono="5555-5555", + nro_afiliado="9999999999999", + cod_diagnostico="B30", + cod_hiv="NOAP31121970", + id_motivo_devolucion=1, + otro_motivo_devolucion="producto fallado", + ) + + # Opciones principales: + + if '--cancela' in sys.argv: + if '--loadxml' in sys.argv: + ws.LoadTestXML("tests/xml/trazaprodmed_cancela_err.xml") # cargo respuesta + ws.SendCancelacTransacc(*sys.argv[sys.argv.index("--cancela") + 1:]) + elif '--cancela_parcial' in sys.argv: + ws.SendCancelacTransaccParcial(*sys.argv[sys.argv.index("--cancela_parcial") + 1:]) + elif '--consulta' in sys.argv: + ws.GetTransaccionesWS( + *sys.argv[sys.argv.index("--consulta") + 1:] + ) + print("CantPaginas", ws.CantPaginas) + print("HayError", ws.HayError) + # print "TransaccionPlainWS", ws.TransaccionPlainWS + # parametros comunes de salida (columnas de la tabla): + TRANSACCIONES = list(ws.Transacciones[0].keys()) if ws.Transacciones else [] + claves = [k for k in TRANSACCIONES] + # extiendo la lista de resultado para el archivo de intercambio: + transacciones.extend(ws.Transacciones) + # encabezado de la tabla: + print("||", "||".join(["%s" % clave for clave in claves]), "||") + # recorro los datos devueltos (TransaccionPlainWS): + while ws.LeerTransaccion(): + for clave in claves: + print("||", ws.GetParametro(clave), end=' ') # imprimo cada fila + print("||") + elif '--catalogo' in sys.argv: + ret = ws.GetCatalogoElectronicoByGTIN( + *sys.argv[sys.argv.index("--catalogo") + 1:] + ) + for catalogo in list(ws.params_out.values()): + print(catalogo) # imprimo cada fila + else: + argv = [argv for argv in sys.argv if not argv.startswith("--")] + if not transacciones: + if len(argv) > 16: + ws.CrearTransaccion(*argv[3:]) + else: + print("ERROR: no se indicaron todos los parmetros requeridos") + if ws.Transacciones: + try: + usuario, password = argv[1:3] + except BaseException: + print("ADVERTENCIA: no se indico parmetros usuario y passoword") + usuario = password = "pruebasws" + ws.InformarProducto(usuario, password) + for i, tx in enumerate(transacciones): + print("Procesando registro", i) + tx['codigo_transaccion'] = ws.CodigoTransaccion + errores.extend(ws.errores) + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + else: + print("ERROR: no se especificaron productos a informar") + + if ws.Excepcion: + print(ws.Traceback) + + if '--grabar' in sys.argv: + if '--dbf' in sys.argv: + guardar_dbf(formatos, True, {}) + elif '--json' in sys.argv: + for formato in formatos: + archivo = open(formato[0].lower() + ".json", "w") + json.dump(formato[2], archivo, sort_keys=True, indent=4) + archivo.close() + else: + for formato in formatos: + archivo = open(formato[0].lower() + ".txt", "w") + for it in formato[2]: + archivo.write(escribir(it, formato[1])) + archivo.close() + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = TrazaProdMed.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + # ajusto el encoding por defecto (si se redirije la salida) + if not hasattr(sys.stdout, "encoding") or sys.stdout.encoding is None: + import codecs + import locale + sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout, "replace") + sys.stderr = codecs.getwriter(locale.getpreferredencoding())(sys.stderr, "replace") + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + if TYPELIB: + if '--register' in sys.argv: + tlb = os.path.abspath(os.path.join(INSTALL_DIR, "typelib", "trazaprodmed.tlb")) + print("Registering %s" % (tlb,)) + tli = pythoncom.LoadTypeLib(tlb) + pythoncom.RegisterTypeLib(tli, tlb) + elif '--unregister' in sys.argv: + k = TrazaProdMed + pythoncom.UnRegisterTypeLib(k._typelib_guid_, + k._typelib_version_[0], + k._typelib_version_[1], + 0, + pythoncom.SYS_WIN32) + print("Unregistered typelib") + import win32com.server.register + win32com.server.register.UseCommandLine(TrazaProdMed) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([TrazaProdMed._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/trazarenpre.py b/app/pyafipws/trazarenpre.py new file mode 100644 index 0000000000000000000000000000000000000000..9d4b738b56e33b3db9f08fb77d31505efd098cda --- /dev/null +++ b/app/pyafipws/trazarenpre.py @@ -0,0 +1,366 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Módulo para Trazabilidad de Precursores Químicos RENPRE Resolución 900/12" + +# Información adicional y documentación: +# http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadPrecursoresQuimicos + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2011 Mariano Reingart" +__license__ = "GPL 3.0+" +__version__ = "1.12a" + +# http://renpre.servicios.pami.org.ar/portal_traza_renpre/paso5.html + +import os +import socket +import sys +import datetime +import time +import pysimplesoap.client +from pysimplesoap.client import SoapFault +from .utils import BaseWS, inicializar_y_capturar_excepciones, get_install_dir + +HOMO = False +TYPELIB = False + +WSDL = "https://servicios.pami.org.ar/trazamed.WebServiceSDRN?wsdl" +LOCATION = "https://servicios.pami.org.ar/trazamed.WebServiceSDRN?wsdl" +# WSDL = "https://trazabilidad.pami.org.ar:59050/trazamed.WebServiceSDRN?wsdl" # prod. + + +class TrazaRenpre(BaseWS): + "Interfaz para el WebService de Trazabilidad de Precursores Quimicos SEDRONAR SNT" + _public_methods_ = ['SaveTransacciones', + 'SendCancelacTransacc', 'GetTransaccionesWS', + 'Conectar', 'LeerError', 'LeerTransaccion', + 'SetUsername', + 'SetParametro', 'GetParametro', + 'GetCodigoTransaccion', 'GetResultado', 'LoadTestXML'] + + _public_attrs_ = [ + 'Username', 'Password', + 'CodigoTransaccion', 'Errores', 'Resultado', + 'XmlRequest', 'XmlResponse', + 'Version', 'InstallDir', + 'Traceback', 'Excepcion', 'LanzarExcepciones', + ] + + _reg_progid_ = "TrazaRenpre" + _reg_clsid_ = "{461298DB-0531-47CA-B3D9-B36FE6967209}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s %s" % (__version__, HOMO and 'Homologación' or '', + pysimplesoap.client.__version__) + + def __init__(self, reintentos=1): + self.Username = self.Password = None + BaseWS.__init__(self, reintentos) + + def inicializar(self): + BaseWS.inicializar(self) + self.CodigoTransaccion = self.Errores = self.Resultado = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.Errores = ["%s: %s" % (it.get('_c_error', ""), it.get('_d_error', "")) + for it in ret.get('errores', [])] + self.Resultado = ret.get('resultado') + + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30): + # Conecto usando el método estandard: + ok = BaseWS.Conectar(self, cache, wsdl, proxy, wrapper, cacert, timeout, + soap_server="jetty") + if ok: + # si el archivo es local, asumo que ya esta corregido: + if not self.wsdl.startswith("file"): + # corrijo ubicación del servidor (localhost:9050 en el WSDL) + location = self.wsdl[:-5] + if 'IWebServiceSDRNService' in self.client.services: + ws = self.client.services['IWebServiceSDRNService'] + else: + ws = self.client.services['IWebServiceSDRN'] + ws['ports']['IWebServiceSDRNPort']['location'] = location + # Establecer credenciales de seguridad: + self.client['wsse:Security'] = { + 'wsse:UsernameToken': { + 'wsse:Username': self.Username, + 'wsse:Password': self.Password, + } + } + return ok + + @inicializar_y_capturar_excepciones + def SaveTransacciones(self, usuario, password, + gln_origen=None, gln_destino=None, f_operacion=None, + id_evento=None, cod_producto=None, n_cantidad=None, + n_documento_operacion=None, n_remito=None, + id_tipo_transporte=None, + id_paso_frontera=None, + id_tipo_documento_operacion=None, + d_dominio_tractor=None, + d_dominio_semi=None, + n_serie=None, n_lote=None, doc_despacho_plaza=None, + djai=None, n_cert_impo_expo=None, + id_tipo_documento=None, n_documento=None, + m_calidad_analitica=None, m_entrega_parcial=None, + doc_permiso_embarque=None, gln_transportista=None, + operacion_excento_djai=None, control_duplicidad=None, + ): + "Permite informe por parte de un agente de una o varias transacciones" + # creo los parámetros para esta llamada + params = {'gln_origen': gln_origen, 'gln_destino': gln_destino, + 'f_operacion': f_operacion, 'id_evento': id_evento, + 'cod_producto': cod_producto, 'n_cantidad': n_cantidad, + 'n_documento_operacion': n_documento_operacion, + 'n_remito': n_remito, + 'id_tipo_transporte': id_tipo_transporte, + 'id_paso_frontera': id_paso_frontera, + 'id_tipo_documento_operacion': id_tipo_documento_operacion, + 'd_dominio_tractor': d_dominio_tractor, + 'd_dominio_semi': d_dominio_semi, + 'n_serie': n_serie, 'n_lote': n_lote, + 'doc_despacho_plaza': doc_despacho_plaza, + 'djai': djai, 'n_cert_impo_expo': n_cert_impo_expo, + 'id_tipo_documento': id_tipo_documento, + 'n_documento': n_documento, + 'm_calidad_analitica': m_calidad_analitica, + 'm_entrega_parcial': m_entrega_parcial, + 'doc_permiso_embarque': doc_permiso_embarque, + 'gln_transportista': gln_transportista, + 'operacion_excento_djai': operacion_excento_djai, + 'control_duplicidad': control_duplicidad, + } + # actualizo con parámetros generales: + params.update(self.params_in) + res = self.client.saveTransacciones( + arg0=params, + arg1=usuario, + arg2=password, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendCancelacTransacc(self, usuario, password, codigo_transaccion): + " Realiza la cancelación de una transacción" + res = self.client.sendCancelaTransac( + arg0=codigo_transaccion, + arg1=usuario, + arg2=password, + ) + + ret = res['return'] + + self.CodigoTransaccion = ret['codigoTransaccion'] + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def SendConfirmaTransacc(self, usuario, password, p_ids_transac, f_operacion): + "Confirma la recepción de un medicamento" + res = self.client.sendConfirmaTransacc( + arg0=usuario, + arg1=password, + arg2={'p_ids_transac': p_ids_transac, 'f_operacion': f_operacion}, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('id_transac_asociada') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendAlertaTransacc(self, usuario, password, p_ids_transac_ws): + "Alerta un medicamento, acción contraria a “confirmar la transacción”." + res = self.client.sendAlertaTransacc( + arg0=usuario, + arg1=password, + arg2=p_ids_transac_ws, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('id_transac_asociada') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def GetTransaccionesWS(self, usuario, password, + p_id_transaccion_global=None, + id_agente_origen=None, id_agente_destino=None, + id_agente_informador=None, + gtin=None, id_evento=None, cant_analitica=None, + fecha_desde_op=None, fecha_hasta_op=None, + fecha_desde_t=None, fecha_hasta_t=None, + id_tipo=None, + id_estado=None, nro_pag=1, cant_reg=100, + ): + "Obtiene los movimientos realizados y permite filtros de búsqueda" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if p_id_transaccion_global is not None: + kwargs['arg2'] = p_id_transaccion_global + if id_agente_origen is not None: + kwargs['arg3'] = id_agente_origen + if id_agente_destino is not None: + kwargs['arg4'] = id_agente_destino + if id_agente_destino is not None: + kwargs['arg5'] = id_agente_informador + if gtin is not None: + kwargs['arg6'] = gtin + if id_evento is not None: + kwargs['arg7'] = id_evento + if cant_analitica is not None: + kwargs['arg8'] = cant_analitica + if fecha_desde_op is not None: + kwargs['arg9'] = fecha_desde_op + if fecha_hasta_op is not None: + kwargs['arg10'] = fecha_hasta_op + if fecha_desde_t is not None: + kwargs['arg11'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['arg12'] = fecha_hasta_t + if id_tipo is not None: + kwargs['arg13'] = id_tipo + if id_estado is not None: + kwargs['arg14'] = id_estado + if nro_pag is not None: + kwargs['arg15'] = nro_pag + if cant_reg is not None: + kwargs['arg16'] = cant_reg + + # llamo al webservice + res = self.client.getTransaccionesWs( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.TransaccionPlainWS = [it for it in ret.get('list', [])] + return True + + def SetUsername(self, username): + "Establezco el nombre de usuario" + self.Username = username + + def SetPassword(self, password): + "Establezco la contraseña" + self.Password = password + + def GetCodigoTransaccion(self): + "Devuelvo el código de transacción" + return self.CodigoTransaccion + + def GetResultado(self): + "Devuelvo el resultado" + return self.Resultado + + +def main(): + "Función principal de pruebas (transaccionar!)" + import os + import time + import sys + global WSDL, LOCATION + + DEBUG = '--debug' in sys.argv + + ws = TrazaRenpre() + + ws.Username = 'testwservice' + ws.Password = 'testwservicepsw' + + if '--prod' in sys.argv and not HOMO: + WSDL = "https://trazabilidad.pami.org.ar:59050/trazamed.WebServiceSDRN?wsdl" + print("Usando WSDL:", WSDL) + sys.argv.pop(0) + + ws.Conectar("", WSDL) + + if ws.Excepcion: + print(ws.Excepcion) + print(ws.Traceback) + sys.exit(-1) + + # print ws.client.services + #op = ws.client.get_operation("sendMedicamentos") + #import pdb;pdb.set_trace() + if '--test' in sys.argv: + ws.SaveTransacciones( + usuario='pruebasws', password='pruebasws', + gln_origen=8888888888888, + gln_destino=8888888888888, + f_operacion="20/05/2014", + id_evento=44, + cod_producto=88800000000035, # acido sulfúrico + n_cantidad=1, + n_documento_operacion=1, + # m_entrega_parcial="", + n_remito=123, + n_serie=112, + ) + print("Resultado", ws.Resultado) + print("CodigoTransaccion", ws.CodigoTransaccion) + print("Excepciones", ws.Excepcion) + print("Erroes", ws.Errores) + elif '--cancela' in sys.argv: + ws.SendCancelacTransacc(*sys.argv[sys.argv.index("--cancela") + 1:]) + elif '--consulta' in sys.argv: + if '--movimientos' in sys.argv: + ws.GetTransaccionesWS( + *sys.argv[sys.argv.index("--movimientos") + 1:] + ) + else: + ws.SaveTransacciones(*sys.argv[1:]) + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores), + )) + if ws.Excepcion: + print(ws.Traceback) + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = TrazaRenpre.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + # ajusto el encoding por defecto (si se redirije la salida) + if sys.stdout.encoding is None: + import codecs + import locale + sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout, "replace") + sys.stderr = codecs.getwriter(locale.getpreferredencoding())(sys.stderr, "replace") + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + import win32com.server.register + win32com.server.register.UseCommandLine(TrazaRenpre) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([TrazaRenpre._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/trazavet.py b/app/pyafipws/trazavet.py new file mode 100644 index 0000000000000000000000000000000000000000..21691099e571bff399d6a7c88373dfc5859bc9c3 --- /dev/null +++ b/app/pyafipws/trazavet.py @@ -0,0 +1,598 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Mdulo Trazabilidad de Productos Veterinarios SENASA Resolucin 369/2013" + +# Informacin adicional y documentacin: +# http://www.sistemasagiles.com.ar/trac/wiki/TrazabilidadProductosVeterinarios + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2014 Mariano Reingart" +__license__ = "GPL 3.0+" +__version__ = "1.11d" + +# http://senasa.servicios.pami.org.ar/ + +import os +import socket +import sys +import datetime +import time +import traceback +import pysimplesoap.client +from pysimplesoap.client import SoapClient, SoapFault, parse_proxy, \ + set_http_wrapper +from pysimplesoap.simplexml import SimpleXMLElement +from io import StringIO + +# importo funciones compartidas: +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, \ + dar_nombre_campo_dbf, get_install_dir, BaseWS, \ + inicializar_y_capturar_excepciones + +HOMO = True +TYPELIB = False + +WSDL = "https://servicios.pami.org.ar/trazaenvet.WebService?wsdl" +LOCATION = "https://servicios.pami.org.ar/trazaenvet.WebService?wsdl" + +# Formato de TransaccionSenasaDTO (SaveTransaccion) +TRANSACCION_DTO = [ + ('gln_origen', 13, A), + ('gln_destino', 13, A), + ('f_operacion', 10, A), + ('f_elaboracion', 10, A), + ('f_vto', 10, A), + ('id_evento', 15, N), + ('cod_producto', 14, A), + ('n_cantidad', 30, N), + ('n_serie', 20, A), + ('n_lote', 50, A), + ('n_cai', 15, A), + ('n_cae', 15, A), + ('id_motivo_destruccion', 5, A), + ('n_manifiesto', 15, N), + ('en_transporte', 1, A), # boolean + ('n_remito', 15, A), + ('motivo_devolucion', 100, A), + ('observaciones', 1000, A), + ('n_vale_compra', 15, A), + ('apellidoNombres', 255, A), + ('direccion', 200, A), + ('numero', 6, N), + ('localidad', 15, A), + ('provincia', 15, A), + ('n_postal', 8, A), + ('cuit', 11, A), + ('codigo_transaccion', 14, A), +] + +# Formato para TransaccionSenasa (getTransacciones) +TRANSACCIONES = [ + ('id_transaccion_global', 15, N), + ('id_transaccion', 15, N), + ('f_transaccion', 10, A), + ('f_operacion', 10, A), + ('f_vencimiento', 10, A), + ('f_elaboracion', 10, A), + ('d_evento', 100, A), + ('n_cantidad', 30, N), + ('id_unidad', 15, N), + ('d_unidad', 100, A), + ('cod_producto', 14, A), + ('id_unidad', 15, N), + ('n_serie', 20, A), + ('n_lote', 50, A), + ('n_cai', 15, A), + ('n_cae', 15, A), + ('d_motivo_destruccion', 50, A), + ('d_manifiesto', 15, A), + ('en_transporte', 1, A), + ('n_remito', 30, A), + ('motivo_devolucion', 200, A), + ('observaciones', 1000, A), + ('n_vale_compra', 15, A), + ('apellidoNombres', 255, A), + ('direccion', 200, A), + ('numero', 6, A), + ('localidad', 250, A), + ('provincia', 250, A), + ('n_postal', 8, A), + ('cuit', 11, A), + ('d_agente_informador', 255, A), + ('d_agente_origen', 255, A), + ('d_agente_destino', 255, A), + ('d_producto', 250, A), + ('d_estado_transaccion', 30, A), + ('d_tipo_transaccion', 30, A), +] + +# Formato para Errores +ERRORES = [ + ('_c_error', 4, A), # cdigo + ('_d_error', 250, A), # descripcin +] + + +class TrazaVet(BaseWS): + "Interfaz para el WebService de Trazabilidad de Veterinarios SENASA" + + _public_methods_ = ['SaveTransaccion', 'SendCancelaTransac', + 'SendConfirmaTransacc', 'SendAlertaTransacc', + 'GetTransacciones', + 'Conectar', 'LeerError', 'LeerTransaccion', + 'SetUsername', + 'SetParametro', 'GetParametro', + 'GetCodigoTransaccion', 'GetResultado', 'LoadTestXML'] + + _public_attrs_ = [ + 'Username', 'Password', + 'CodigoTransaccion', 'Errores', 'Resultado', + 'XmlRequest', 'XmlResponse', + 'Version', 'InstallDir', + 'Traceback', 'Excepcion', + 'CantPaginas', 'HayError', 'TransaccionSenasa', + ] + + _reg_progid_ = "TrazaVet" + _reg_clsid_ = "{2E28D79D-23B0-4C1E-85B9-64BDC41B8FCF}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s %s" % (__version__, HOMO and 'Homologacin' or '', + pysimplesoap.client.__version__) + + def __init__(self, reintentos=1): + self.Username = self.Password = None + self.TransaccionSenasa = [] + BaseWS.__init__(self, reintentos) + + def inicializar(self): + BaseWS.inicializar(self) + self.CodigoTransaccion = self.Errores = self.Resultado = None + self.Resultado = '' + self.Errores = [] # lista de strings para la interfaz + self.errores = [] # lista de diccionarios (uso interno) + self.CantPaginas = self.HayError = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.errores = ret.get('errores', []) + self.Errores = ["%s: %s" % (it['c_error'], it['d_error']) + for it in ret.get('errores', [])] + self.Resultado = ret.get('resultado') + + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30): + # Conecto usando el mtodo estandard: + ok = BaseWS.Conectar(self, cache, wsdl, proxy, wrapper, cacert, timeout, + soap_server="jetty") + if ok: + # si el archivo es local, asumo que ya esta corregido: + if not self.wsdl.startswith("file"): + # corrijo ubicacin del servidor (localhost:59050 en el WSDL) + location = self.wsdl[:-5] + ws = self.client.services['IWebServiceSenasa'] + ws['ports']['IWebServiceSenasaPort']['location'] = location + + # Establecer credenciales de seguridad: + self.client['wsse:Security'] = { + 'wsse:UsernameToken': { + 'wsse:Username': self.Username, + 'wsse:Password': self.Password, + } + } + return ok + + @inicializar_y_capturar_excepciones + def SaveTransaccion(self, usuario, password, + gln_origen=None, gln_destino=None, + f_operacion=None, f_elaboracion=None, f_vto=None, + id_evento=None, cod_producto=None, n_cantidad=None, + n_serie=None, n_lote=None, n_cai=None, n_cae=None, + id_motivo_destruccion=None, n_manifiesto=None, + en_transporte=None, n_remito=None, + motivo_devolucion=None, observaciones=None, + n_vale_compra=None, apellidoNombres=None, + direccion=None, numero=None, localidad=None, + provincia=None, n_postal=None, cuit=None + ): + "Realiza el registro de una transaccin de productos fitosanitarios. " + # creo los parmetros para esta llamada + params = {'gln_origen': gln_origen, + 'gln_destino': gln_destino, + 'f_operacion': f_operacion, + 'f_elaboracion': f_elaboracion, + 'f_vto': f_vto, + 'id_evento': id_evento, + 'cod_producto': cod_producto, + 'n_cantidad': n_cantidad, + 'n_serie': n_serie, + 'n_lote': n_lote, + 'n_cai': n_cai or None, + 'n_cae': n_cae or None, + 'id_motivo_destruccion': id_motivo_destruccion or None, + 'n_manifiesto': n_manifiesto or None, + 'en_transporte': en_transporte or None, + 'n_remito': n_remito or None, + 'motivo_devolucion': motivo_devolucion or None, + 'observaciones': observaciones or None, + 'n_vale_compra': n_vale_compra or None, + 'apellidoNombres': apellidoNombres or None, + 'direccion': direccion or None, + 'numero': numero or None, + 'localidad': localidad or None, + 'provincia': provincia or None, + 'n_postal': n_postal or None, + 'cuit': cuit or None, + } + res = self.client.saveTransacciones( + arg0=params, + arg1=usuario, + arg2=password, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendCancelaTransac(self, usuario, password, codigo_transaccion): + " Realiza la cancelacin de una transaccin" + res = self.client.sendCancelaTransac( + arg0=codigo_transaccion, + arg1=usuario, + arg2=password, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendConfirmaTransacc(self, usuario, password, p_ids_transac, f_operacion, n_cantidad=None): + "Confirma la recepcin de un medicamento" + res = self.client.sendConfirmaTransacc( + arg0=usuario, + arg1=password, + arg2={'p_ids_transac': p_ids_transac, 'f_operacion': f_operacion, + 'n_cantidad': n_cantidad, + }, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('codigoTransaccion') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def SendAlertaTransacc(self, usuario, password, p_ids_transac_ws): + "Alerta un medicamento, accin contraria a confirmar la transaccin." + res = self.client.sendAlertaTransacc( + arg0=usuario, + arg1=password, + arg2=p_ids_transac_ws, + ) + ret = res['return'] + self.CodigoTransaccion = ret.get('id_transac_asociada') + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def GetTransacciones(self, usuario, password, + id_transaccion=None, id_evento=None, gln_origen=None, + fecha_desde_t=None, fecha_hasta_t=None, + fecha_desde_v=None, fecha_hasta_v=None, + gln_informador=None, id_tipo_transaccion=None, + gtin_elemento=None, n_lote=None, n_serie=None, + n_remito_factura=None, + ): + "Trae un listado de las transacciones que no estn confirmadas" + + # preparo los parametros de entrada opcionales: + kwargs = {} + if id_transaccion is not None: + kwargs['arg2'] = id_transaccion + if id_evento is not None: + kwargs['arg3'] = id_evento + if gln_origen is not None: + kwargs['arg4'] = gln_origen + if fecha_desde_t is not None: + kwargs['arg5'] = fecha_desde_t + if fecha_hasta_t is not None: + kwargs['arg6'] = fecha_hasta_t + if fecha_desde_v is not None: + kwargs['arg7'] = fecha_desde_v + if fecha_hasta_v is not None: + kwargs['arg8'] = fecha_hasta_v + if gln_informador is not None: + kwargs['arg9'] = gln_informador + if id_tipo_transaccion is not None: + kwargs['arg10'] = id_tipo_transaccion + if gtin_elemento is not None: + kwargs['arg11'] = gtin_elemento + if n_lote is not None: + kwargs['arg12'] = n_lote + if n_serie is not None: + kwargs['arg13'] = n_serie + if n_remito_factura is not None: + kwargs['arg14'] = n_remito_factura + + # llamo al webservice + res = self.client.getTransacciones( + arg0=usuario, + arg1=password, + **kwargs + ) + ret = res['return'] + if ret: + self.__analizar_errores(ret) + self.CantPaginas = ret.get('cantPaginas') + self.HayError = ret.get('hay_error') + self.TransaccionSenasa = [it for it in ret.get('list', [])] + return True + + def LeerTransaccion(self): + "Recorro TransaccionSenasa devuelto por GetTransacciones" + # usar GetParametro para consultar el valor retornado por el webservice + + if self.TransaccionSenasa: + # extraigo el primer item + self.params_out = self.TransaccionSenasa.pop(0) + return True + else: + # limpio los parmetros + self.params_out = {} + return False + + def LeerError(self): + "Recorro los errores devueltos y devuelvo el primero si existe" + + if self.Errores: + # extraigo el primer item + er = self.Errores.pop(0) + return er + else: + return "" + + def SetUsername(self, username): + "Establezco el nombre de usuario" + self.Username = username + + def SetPassword(self, password): + "Establezco la contrasea" + self.Password = password + + def GetCodigoTransaccion(self): + "Devuelvo el cdigo de transaccin" + return self.CodigoTransaccion + + def GetResultado(self): + "Devuelvo el resultado" + return self.Resultado + + +def main(): + "Funcin principal de pruebas (obtener CAE)" + import os + import time + import sys + global WSDL, LOCATION + + DEBUG = '--debug' in sys.argv + + ws = TrazaVet() + + ws.Username = 'testwservice' + ws.Password = 'testwservicepsw' + + if '--prod' in sys.argv and not HOMO: + WSDL = "https://servicios.pami.org.ar/trazavet.WebService?wsdl" + print("Usando WSDL:", WSDL) + sys.argv.pop(sys.argv.index("--prod")) + + if '--proxy' in sys.argv and not HOMO: + proxy = sys.argv.pop(sys.argv.index("--proxy") + 1) + print("Usando proxy:", proxy) + sys.argv.pop(sys.argv.index("--proxy")) + else: + proxy = None + + # Inicializo las variables y estructuras para el archivo de intercambio: + transaccion_dto = [] + transacciones = [] + errores = [] + formatos = [('TransaccionDTO', TRANSACCION_DTO, transaccion_dto), + ('Transacciones', TRANSACCIONES, transacciones), + ('Errores', ERRORES, errores), + ] + + if '--formato' in sys.argv: + print("Formato:") + for msg, formato, lista in formatos: + comienzo = 1 + print("=== %s ===" % msg) + print("|| %-25s || %-12s || %-5s || %-4s || %-10s ||" % ( + "Nombre", "Tipo", "Long.", "Pos(txt)", "Campo(dbf)")) + claves = [] + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + clave_dbf = dar_nombre_campo_dbf(clave, claves) + claves.append(clave_dbf) + print("|| %-25s || %-12s || %5d || %4d || %-10s ||" % ( + clave, tipo, longitud, comienzo, clave_dbf)) + comienzo += longitud + sys.exit(0) + + if '--cargar' in sys.argv: + if '--dbf' in sys.argv: + leer_dbf(formatos[:1], {}) + elif '--json' in sys.argv: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".json", "r") + d = json.load(archivo) + formato[2].extend(d) + archivo.close() + else: + for formato in formatos[:1]: + archivo = open(formato[0].lower() + ".txt", "r") + for linea in archivo: + d = leer(linea, formato[1]) + formato[2].append(d) + archivo.close() + + ws.Conectar("", WSDL, proxy) + + if ws.Excepcion: + print(ws.Excepcion) + print(ws.Traceback) + sys.exit(-1) + + # Datos de pruebas: + + if '--test' in sys.argv: + transaccion_dto.append(dict( + gln_origen="9876543210982", gln_destino="3692581473693", + f_operacion=datetime.datetime.now().strftime("%d/%m/%Y"), + f_elaboracion=datetime.datetime.now().strftime("%d/%m/%Y"), + f_vto=(datetime.datetime.now() + datetime.timedelta(30)).strftime("%d/%m/%Y"), + id_evento=11, + cod_producto="88900000000001", + n_cantidad=1, + n_serie=int(time.time() * 10), + n_lote=datetime.datetime.now().strftime("%Y"), + n_cai="123456789012345", + n_cae="", + id_motivo_destruccion=0, + n_manifiesto="", + en_transporte="N", + n_remito="1234", + motivo_devolucion="", + observaciones="prueba", + n_vale_compra="", + apellidoNombres="Juan Peres", + direccion="Saraza", numero="1234", + localidad="Hurlingham", provincia="Buenos Aires", + n_postal="1688", + cuit="20267565393", + codigo_transaccion=None, + )) + + # Opciones principales: + + if '--confirma' in sys.argv: + if '--loadxml' in sys.argv: + ws.LoadTestXML("trazamed_confirma.xml") # cargo respuesta + ok = ws.SendConfirmaTransacc(usuario="pruebasws", password="pruebasws", + p_ids_transac="1", f_operacion="31-12-2013") + if not ok: + raise RuntimeError(ws.Excepcion) + ws.SendConfirmaTransacc(*sys.argv[sys.argv.index("--confirma") + 1:]) + elif '--alerta' in sys.argv: + ws.SendAlertaTransacc(*sys.argv[sys.argv.index("--alerta") + 1:]) + elif '--cancela' in sys.argv: + ws.SendCancelaTransac(*sys.argv[sys.argv.index("--cancela") + 1:]) + elif '--consulta' in sys.argv: + ws.GetTransacciones( + *sys.argv[sys.argv.index("--consulta") + 1:] + ) + print("CantPaginas", ws.CantPaginas) + print("HayError", ws.HayError) + # print "TransaccionSenasa", ws.TransaccionSenasa + # parametros comunes de salida (columnas de la tabla): + claves = [k for k, v, l in TRANSACCIONES] + # extiendo la lista de resultado para el archivo de intercambio: + transacciones.extend(ws.TransaccionSenasa) + # encabezado de la tabla: + print("||", "||".join(["%s" % clave for clave in claves]), "||") + # recorro los datos devueltos (TransaccionSenasa): + while ws.LeerTransaccion(): + for clave in claves: + print("||", ws.GetParametro(clave), end=' ') # imprimo cada fila + print("||") + else: + argv = [argv for argv in sys.argv if not argv.startswith("--")] + if not transaccion_dto: + if len(argv) > 10: + ws.SaveTransaccion(*argv[1:]) + else: + print("ERROR: no se indicaron todos los parmetros requeridos") + elif transaccion_dto: + try: + usuario, password = argv[-2:] + except BaseException: + print("ADVERTENCIA: no se indico parmetros usuario y passoword") + usuario, password = "senasaws", "Clave2013" + for i, dto in enumerate(transaccion_dto): + print("Procesando registro", i) + del dto['codigo_transaccion'] + ws.SaveTransaccion(usuario, password, **dto) + dto['codigo_transaccion'] = ws.CodigoTransaccion + errores.extend(ws.errores) + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + else: + print("ERROR: no se especificaron productos a informar") + + if not transaccion_dto: + print("|Resultado %5s|CodigoTransaccion %10s|Errores|%s|" % ( + ws.Resultado, + ws.CodigoTransaccion, + '|'.join(ws.Errores or []), + )) + + if ws.Excepcion: + print(ws.Traceback) + + if '--grabar' in sys.argv: + if '--dbf' in sys.argv: + guardar_dbf(formatos, True, {}) + elif '--json' in sys.argv: + for formato in formatos: + archivo = open(formato[0].lower() + ".json", "w") + json.dump(formato[2], archivo, sort_keys=True, indent=4) + archivo.close() + else: + for formato in formatos: + archivo = open(formato[0].lower() + ".txt", "w") + for it in formato[2]: + archivo.write(escribir(it, formato[1])) + archivo.close() + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = TrazaVet.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + # ajusto el encoding por defecto (si se redirije la salida) + if not hasattr(sys.stdout, "encoding") or sys.stdout.encoding is None: + import codecs + import locale + sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout, "replace") + sys.stderr = codecs.getwriter(locale.getpreferredencoding())(sys.stderr, "replace") + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + import win32com.server.register + win32com.server.register.UseCommandLine(TrazaVet) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([TrazaVet._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/typelib/pyafipws.idl b/app/pyafipws/typelib/pyafipws.idl new file mode 100644 index 0000000000000000000000000000000000000000..9cbffe57353008b390395fb820500f5d57a2e4f7 --- /dev/null +++ b/app/pyafipws/typelib/pyafipws.idl @@ -0,0 +1,48 @@ +// +// pyafipws.idl: source PyAfipWs COM Type Library +// influenced by msdn example2.idl and pythoncom's pippo.idl +// + +import "unknwn.idl","oaidl.idl"; + +[ + uuid(65D24842-8691-4FB5-9B52-82393F12F4C6), + helpstring("PyAfipWs WSAA interface"), + pointer_default(unique), + dual, + object + //oleautomation +] +interface IWSAA : IDispatch +{ + [id(1)] HRESULT CreateTRA([in] BSTR service, [in] int ttl, + [out, retval] BSTR * tra); + [id(2)] HRESULT SignTRA([in] BSTR tra, [in] BSTR cert, [in] BSTR privatekey, + [out, retval] BSTR * cms); + [id(3)] HRESULT CallWSAA([in] BSTR cms, [in] BSTR proxy, [in] BSTR url, + [out, retval] BSTR * ta); + + [propget, id(4)] HRESULT Token( [out, retval] BSTR * token); + [propget, id(5)] HRESULT Sign( [out, retval] BSTR * sign); + [propget, id(6)] HRESULT Version( [out, retval] BSTR * sign); + [propget, id(7)] HRESULT XmlResponse( [out, retval] BSTR * sign); + +}; //end interface def + + +[ + uuid(84A3B5EC-D019-4B27-8DB8-469D8D8E97D1), + version(1.02), + helpstring("PyAfipWs Type Library"), +] library PyAfipWs +{ + importlib("stdole32.tlb"); + + [ + uuid(25949BFC-AE90-43D5-A577-C18F65F86B26), + helpstring("WSAA Component Class") + ] coclass WSAA + { + [default]interface IWSAA; + } +}; //end library def \ No newline at end of file diff --git a/app/pyafipws/typelib/trazamed.idl b/app/pyafipws/typelib/trazamed.idl new file mode 100644 index 0000000000000000000000000000000000000000..2363710feffd41b103f0f4cd55e6c0efe58eda28 --- /dev/null +++ b/app/pyafipws/typelib/trazamed.idl @@ -0,0 +1,80 @@ +// +// trazamed.idl: source PyAfipWs TrazaMed (ANMAT/PAMI) COM Type Library +// influenced by msdn example2.idl and pythoncom's pippo.idl +// +// Use "print pythoncom.CreateGuid()" to make a new uuid +// Use MIDL.EXE to compile this file to create TrazaMed.TLB + +import "unknwn.idl","oaidl.idl"; + +[ + uuid(423FA890-1FE5-4F53-B5F7-71A7737EDB8B), + helpstring("PyAfipWs TrazaMed ANMAT / PAMI interface"), + pointer_default(unique), + dual, + object + //oleautomation +] +interface ITrazaMed : IDispatch +{ + [id(1)] HRESULT Conectar([in] BSTR cache, [in] BSTR wsdl, + [in] BSTR proxy, [in] BSTR wrapper, [in] BSTR cacert, + [out, retval] BOOL * ok); + [id(2)] HRESULT SendMedicamentos([in] BSTR usuario, [in] BSTR password, + [in] BSTR f_evento, [in] BSTR h_evento, [in] BSTR gln_origen, [in] BSTR gln_destino, + [in] BSTR n_remito, [in] BSTR n_factura, [in] BSTR vencimiento, [in] BSTR gtin, [in] BSTR lote, + [in] BSTR numero_serial, [in] BSTR id_obra_social, [in] BSTR id_evento, + [in] BSTR cuit_origen, [in] BSTR cuit_destino, [in] BSTR apellido, [in] BSTR nombres, + [in] BSTR tipo_docmento, [in] BSTR n_documento, [in] BSTR sexo, + [in] BSTR direccion, [in] BSTR numero, [in] BSTR piso, [in] BSTR depto, [in] BSTR localidad, [in] BSTR provincia, + [in] BSTR n_postal, [in] BSTR fecha_nacimiento, [in] BSTR telefono, + [out, retval] BOOL * ok); + [id(3)] HRESULT SendMedicamentosDHSerie([in] BSTR usuario, [in] BSTR password, + [in] BSTR f_evento, [in] BSTR h_evento, [in] BSTR gln_origen, [in] BSTR gln_destino, + [in] BSTR n_remito, [in] BSTR n_factura, [in] BSTR vencimiento, [in] BSTR gtin, [in] BSTR lote, + [in] BSTR desde_numero_serial, [in] BSTR hasta_numero_serial, [in] BSTR id_obra_social, [in] BSTR id_evento, + [in] BSTR cuit_origen, [in] BSTR cuit_destino, [in] BSTR apellido, [in] BSTR nombres, + [in] BSTR tipo_docmento, [in] BSTR n_documento, [in] BSTR sexo, + [in] BSTR direccion, [in] BSTR numero, [in] BSTR piso, [in] BSTR depto, [in] BSTR localidad, [in] BSTR provincia, + [in] BSTR n_postal, [in] BSTR fecha_nacimiento, [in] BSTR telefono, + [out, retval] BOOL * ok); + [id(4)] HRESULT SendCancelacTransacc([in] BSTR usuario, [in] BSTR password, [in] BSTR codigo_transaccion, + [out, retval] BOOL * ok); + + [id(5)] HRESULT LeerError([out, retval] BSTR * texto); + + [propget, id(6)] HRESULT InstallDir( [out, retval] BSTR * val); + [propget, id(7)] HRESULT Traceback( [out, retval] BSTR * val); + [propget, id(8)] HRESULT Excepcion( [out, retval] BSTR * val); + [propget, id(9)] HRESULT LanzarExcepciones( [in] BOOL modo, [out, retval] BOOL * val); + [propget, id(10)] HRESULT Version( [out, retval] BSTR * sign); + [propget, id(11)] HRESULT XmlRequest( [out, retval] BSTR * val); + [propget, id(12)] HRESULT XmlResponse( [out, retval] BSTR * val); + [propput, id(13)] HRESULT Username( [in] BSTR val); + [propput, id(14)] HRESULT Password( [in] BSTR val); + [propget, id(15)] HRESULT CodigoTransaccion( [out, retval] BSTR * codigo); + [propget, id(16)] HRESULT Resultado( [out, retval] BSTR * resultado); + + [id(17)] HRESULT SetUsername([in] BSTR username); + [id(18)] HRESULT SetPassword([in] BSTR password); + [id(19)] HRESULT GetCodigoTransaccion([out, retval] BSTR * texto); + [id(20)] HRESULT GetResultado([out, retval] BSTR * texto); + +}; //end interface def + +[ + uuid(F992EB7E-AFBD-41BB-B717-5693D3A2BADB), + version(1.5), + helpstring("PyAfipWs TrazaMed 1.05 Type Library"), +] library PyAfipWsTrazaMedLib +{ + importlib("stdole32.tlb"); + + [ + uuid(EC779985-0CED-44A5-B44D-057FCA9E3389), + helpstring("TrazaMed Component Class") + ] coclass TrazaMed + { + [default]interface ITrazaMed; + } +}; //end library def \ No newline at end of file diff --git a/app/pyafipws/typelib/trazamed.tlb b/app/pyafipws/typelib/trazamed.tlb new file mode 100644 index 0000000000000000000000000000000000000000..c2648b5d1c5c383a7b083ba7ee0bc2970492c0b7 Binary files /dev/null and b/app/pyafipws/typelib/trazamed.tlb differ diff --git a/app/pyafipws/typelib/wsaa.idl b/app/pyafipws/typelib/wsaa.idl new file mode 100644 index 0000000000000000000000000000000000000000..64f8a4e25d8f4b41996b75c334911c48b91aef4d --- /dev/null +++ b/app/pyafipws/typelib/wsaa.idl @@ -0,0 +1,106 @@ +// +// wsaa.idl: source PyAfipWs WSAA COM Type Library +// influenced by msdn example2.idl and pythoncom's pippo.idl +// +// Use "print pythoncom.CreateGuid()" to make a new one. + +import "unknwn.idl","oaidl.idl"; + +[ + uuid(95392BD9-54C3-4334-A045-A069BAF7FB97), + helpstring("PyAfipWs WSAA interface"), + pointer_default(unique), + dual, + object + //oleautomation +] +interface IWSAA : IDispatch +{ + [id(1)] HRESULT CreateTRA([in] BSTR service, [in] int ttl, + [out, retval] BSTR * tra); + [id(2)] HRESULT SignTRA([in] BSTR tra, [in] BSTR cert, [in] BSTR privatekey, + [out, retval] BSTR * cms); + [id(3)] HRESULT CallWSAA([in] BSTR cms, [in] BSTR proxy, [in] BSTR url, + [out, retval] BSTR * ta); + + [propget, id(4)] HRESULT Token( [out, retval] BSTR * token); + [propget, id(5)] HRESULT Sign( [out, retval] BSTR * sign); + [propget, id(6)] HRESULT Version( [out, retval] BSTR * sign); + [propget, id(7)] HRESULT XmlResponse( [out, retval] BSTR * sign); + + [id(8)] HRESULT Conectar([in] BSTR cache, [in] BSTR wsdl, + [in] BSTR proxy, [in] BSTR wrapper, [in] BSTR cacert, + [in] int timeout, + [out, retval] BOOL * ok); + [id(9)] HRESULT LoginCMS([in] BSTR cms, [out, retval] BSTR * ta); + [id(10)] HRESULT AnalizarXml([in] BSTR xml, [out, retval] BOOL * ok); + [id(11)] HRESULT ObtenerTagXml([in] BSTR tags, [out, retval] BSTR * texto); + [id(12)] HRESULT Expirado([in] BSTR fecha, [out, retval] BOOL * ok); + + [propget, id(13)] HRESULT InstallDir( [out, retval] BSTR * val); + [propget, id(14)] HRESULT Traceback( [out, retval] BSTR * val); + [propget, id(15)] HRESULT Excepcion( [out, retval] BSTR * val); + [propget, id(16)] HRESULT SoapFault( [out, retval] BSTR * val); + [propput, id(17)] HRESULT LanzarExcepciones( [in] BOOL * val); + [propget, id(18)] HRESULT XmlRequest( [out, retval] BSTR * val); + + [id(19)] HRESULT AnalizarCertificado( + [in] BSTR crt, + [in] BOOL binary, + [out, retval] BOOL * ok); + + [propget, id(20)] HRESULT Identidad( [out, retval] BSTR * val); + [propget, id(21)] HRESULT Caducidad( [out, retval] BSTR * val); + [propget, id(22)] HRESULT Emisor( [out, retval] BSTR * val); + [propget, id(27)] HRESULT CertX509( [out, retval] BSTR * val); + + [id(23)] HRESULT CrearClavePrivada( + [in] BSTR filename, + [in] int key_length, + [in] long pub_exponent, + [in] BSTR passphrase, + [out, retval] void + ); + + [id(24)] HRESULT CrearPedidoCertificado( + [in] BSTR cuit, + [in] BSTR empresa, + [in] BSTR nombre, + [in] BSTR filename, + [out, retval] void + ); + + [id(25)] HRESULT SetParametro( + [in] BSTR clave, + [in] BSTR valor, + [out, retval] BSTR * ok); + + [id(26)] HRESULT Autenticar( + [in] BSTR service, + [in] BSTR crt, + [in] BSTR key, + [in] BSTR wsdl, + [in] BSTR proxy, + [in] BSTR wrapper, + [in] BSTR cacert, + [in] BOOL debug, + [out, retval] BSTR * TA); + +}; //end interface def + +[ + uuid(30E9C94B-7385-4534-9A80-DF50FD169253), + version(2.11), + helpstring("PyAfipWs WSAA 2.11 Type Library"), +] library PyAfipWsWSAALib +{ + importlib("stdole32.tlb"); + + [ + uuid(FC5FDFC3-1F41-4E4D-A346-5459DE1CE973), + helpstring("WSAA Component Class") + ] coclass WSAA + { + [default]interface IWSAA; + } +}; //end library def diff --git a/app/pyafipws/typelib/wsaa.tlb b/app/pyafipws/typelib/wsaa.tlb new file mode 100644 index 0000000000000000000000000000000000000000..a0d6330296b3fe579e52b80a5fbb47ae2530aa9e Binary files /dev/null and b/app/pyafipws/typelib/wsaa.tlb differ diff --git a/app/pyafipws/typelib/wsfev1.idl b/app/pyafipws/typelib/wsfev1.idl new file mode 100644 index 0000000000000000000000000000000000000000..5ae962f884fbc5b355c2a2863d4ca9897ef10280 --- /dev/null +++ b/app/pyafipws/typelib/wsfev1.idl @@ -0,0 +1,225 @@ +// +// WSFEv1.idl: source PyAfipWs WSFEv1 COM Type Library +// influenced by msdn example2.idl and pythoncom's pippo.idl +// +// Use "print pythoncom.CreateGuid()" to make a new one. + +import "unknwn.idl","oaidl.idl"; + +[ + uuid(9588B307-67B7-4F27-8968-B78A37D342A2), + helpstring("PyAfipWs WSFEv1 interface"), + pointer_default(unique), + dual, + object + //oleautomation +] +interface IWSFEv1 : IDispatch +{ + [id( 1)] HRESULT CrearFactura( + [in] BSTR concepto, + [in] BSTR tipo_doc, + [in] BSTR nro_doc, + [in] BSTR tipo_cbte, + [in] BSTR punto_vta, + [in] BSTR cbt_desde, + [in] BSTR cbt_hasta, + [in] BSTR imp_total, + [in] BSTR imp_tot_conc, + [in] BSTR imp_neto, + [in] BSTR imp_iva, + [in] BSTR imp_trib, + [in] BSTR imp_op_ex, + [in] BSTR fecha_cbte, + [in] BSTR fecha_venc_pago, + [in] BSTR fecha_serv_desde, + [in] BSTR fecha_serv_hasta, + [in] BSTR moneda_id, + [in] BSTR moneda_ctz, + [in] BSTR caea, + [out, retval] BOOL * ok); + [id( 2)] HRESULT AgregarIva( + [in] BSTR iva_id, + [in] BSTR base_imp, + [in] BSTR importe, + [out, retval] BOOL * ok); + [id( 3)] HRESULT CAESolicitar( + [out, retval] BSTR * cae); + [id( 4)] HRESULT AgregarTributo( + [in] BSTR tributo_id, + [in] BSTR desc, + [in] BSTR base_imp, + [in] BSTR alic, + [in] BSTR importe, + [out, retval] BOOL * ok); + [id( 5)] HRESULT AgregarCmpAsoc( + [in] BSTR tipo, + [in] BSTR pto_vta, + [in] BSTR nro, + [out, retval] BOOL * ok); + [id( 6)] HRESULT CompUltimoAutorizado( + [in] BSTR tipo_cbte, + [in] BSTR punto_vta, + [out, retval] BSTR * cbte_nro); + [id( 7)] HRESULT CompConsultar( + [in] BSTR tipo_cbte, + [in] BSTR punto_vta, + [in] BSTR cbte_nro, + [in] BSTR reproceso, + [out, retval] BSTR * cae); + [id( 8)] HRESULT CAEASolicitar( + [in] BSTR periodo, + [in] BSTR orden, + [out, retval] BSTR * caea); + [id( 9)] HRESULT CAEAConsultar( + [in] BSTR periodo, + [in] BSTR orden, + [out, retval] BSTR * caea); + [id(10)] HRESULT CAEARegInformativo( + [out, retval] BSTR * caea); + [id(11)] HRESULT CAEASinMovimientoInformar( + [in] BSTR punto_vta, + [in] BSTR caea, + [out, retval] BOOL * ok); + [id(12)] HRESULT ParamGetTiposCbte( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(13)] HRESULT ParamGetTiposConcepto( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(14)] HRESULT ParamGetTiposDoc( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(15)] HRESULT ParamGetTiposIva( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(16)] HRESULT ParamGetTiposMonedas( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(17)] HRESULT ParamGetTiposOpcional( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(18)] HRESULT ParamGetTiposTributos( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(19)] HRESULT ParamGetCotizacion( + [in] BSTR moneda_id, + [out, retval] BSTR * ctz); + [id(20)] HRESULT ParamGetPtosVenta( + [in] BSTR sep, + [out, retval] VARIANT * ret); + [id(21)] HRESULT AnalizarXml( + [in] BSTR xml, + [out, retval] BOOL * ok); + [id(22)] HRESULT ObtenerTagXml( + [out, retval] BOOL * ok); + [id(23)] HRESULT Dummy( + [out, retval] BOOL * ok); + [id(24)] HRESULT Conectar( + [in] BSTR cache, + [in] BSTR wsdl, + [in] BSTR proxy, + [in] BSTR wrapper, + [in] BSTR cacert, + [in] int timeout, + [out, retval] BOOL * ok); + [id(25)] HRESULT DebugLog( + [out, retval] BSTR * msg); + + [propput, id(27)] HRESULT Token( [in] BSTR val); + [propput, id(28)] HRESULT Sign( [in] BSTR val); + [propput, id(29)] HRESULT Cuit( [in] BSTR val); + [propget, id(30)] HRESULT AppServerStatus( [out, retval] BSTR * val); + [propget, id(31)] HRESULT DbServerStatus( [out, retval] BSTR * val); + [propget, id(32)] HRESULT AuthServerStatus( [out, retval] BSTR * val); + [propget, id(33)] HRESULT XmlRequest( [out, retval] BSTR * val); + [propget, id(34)] HRESULT XmlResponse( [out, retval] BSTR * val); + [propget, id(35)] HRESULT Version( [out, retval] BSTR * val); + [propget, id(36)] HRESULT Excepcion( [out, retval] BSTR * val); + [propput, id(37)] HRESULT LanzarExcepciones( [in] BOOL * val); + [propget, id(38)] HRESULT Resultado( [out, retval] BSTR * val); + [propget, id(39)] HRESULT Obs( [out, retval] BSTR * val); + [propget, id(40)] HRESULT Observaciones( [out, retval] VARIANT * val); + [propget, id(41)] HRESULT Traceback( [out, retval] BSTR * val); + [propget, id(42)] HRESULT InstallDir( [out, retval] BSTR * val); + [propget, id(43)] HRESULT CAE( [out, retval] BSTR * val); + [propget, id(44)] HRESULT Vencimiento( [out, retval] BSTR * val); + [propget, id(45)] HRESULT Eventos( [out, retval] VARIANT * val); + [propget, id(46)] HRESULT Errores( [out, retval] VARIANT * val); + [propget, id(47)] HRESULT ErrCode( [out, retval] BSTR * val); + [propget, id(48)] HRESULT ErrMsg( [out, retval] BSTR * val); + [propput, id(49)] HRESULT Reprocesar( [in] BSTR val); + [propget, id(50)] HRESULT Reproceso( [out, retval] BSTR * val); + [propget, id(51)] HRESULT EmisionTipo( [out, retval] BSTR * val); + [propget, id(52)] HRESULT CAEA( [out, retval] BSTR * val); + [propget, id(53)] HRESULT CbteNro( [out, retval] BSTR * val); + [propget, id(54)] HRESULT CbtDesde( [out, retval] BSTR * val); + [propget, id(55)] HRESULT CbtHasta( [out, retval] BSTR * val); + [propget, id(56)] HRESULT FechaCbte( [out, retval] BSTR * val); + [propget, id(57)] HRESULT ImpTotal( [out, retval] BSTR * val); + [propget, id(58)] HRESULT ImpNeto( [out, retval] BSTR * val); + [propget, id(59)] HRESULT ImptoLiq( [out, retval] BSTR * val); + [propget, id(60)] HRESULT ImpOpExImptIVA( [out, retval] BSTR * val); + [propget, id(61)] HRESULT ImpOpEx( [out, retval] BSTR * val); + [propget, id(62)] HRESULT ImpTrib( [out, retval] BSTR * val); + + [id(63)] HRESULT SetParametros( + [in] BSTR cuit, + [in] BSTR token, + [in] BSTR sign, + [out, retval] BOOL * ok); + + [id(64)] HRESULT GetParametro( + [in] BSTR clave, + [out, retval] BSTR * ok); + + [id(65)] HRESULT SetParametro( + [in] BSTR clave, + [in] BSTR valor, + [out, retval] BSTR * ok); + + [id(66)] HRESULT EstablecerCampoFactura( + [in] BSTR campo, + [in] BSTR valor, + [out, retval] BOOL * ok); + + [id(67)] HRESULT ObtenerCampoFactura( + [in] BSTR campo, + [out, retval] BSTR * valor); + + [id(68)] HRESULT SetTicketAcceso( + [in] BSTR ta, + [out, retval] BOOL * ok); + + [id(69)] HRESULT AgregarOpcional( + [in] BSTR opcional_id, + [in] BSTR valor, + [out, retval] BOOL * ok); + + [propget, id(70)] HRESULT ImpIVA( [out, retval] BSTR * val); + + + [id(71)] HRESULT CAESolicitarX([out, retval] int * cant_reg); + [id(72)] HRESULT IniciarFacturasX([out, retval] BOOL * ok); + [id(73)] HRESULT AgregarFacturaX([out, retval] BOOL * ok); + [id(74)] HRESULT LeerFacturaX([in] int indice, + [out, retval] BOOL * ok); + +}; //end interface def + +[ + uuid(B1D7283C-3EC2-463E-89B4-11F5228E2A15), + version(1.18), + helpstring("PyAfipWs WSFEv1 1.18 Type Library"), +] library PyAfipWsWSFEv1Lib +{ + importlib("stdole32.tlb"); + + [ + uuid(D50B5D14-621E-4473-AC63-8A7B5E9DD57F), + helpstring("WSFEv1 Component Class") + ] coclass WSFEv1 + { + [default]interface IWSFEv1; + } +}; //end library def diff --git a/app/pyafipws/typelib/wsfev1.tlb b/app/pyafipws/typelib/wsfev1.tlb new file mode 100644 index 0000000000000000000000000000000000000000..51ce60986bc8a7db30ea4649ba15c83cfb6f2c80 Binary files /dev/null and b/app/pyafipws/typelib/wsfev1.tlb differ diff --git a/app/pyafipws/utils.py b/app/pyafipws/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..2268b52d1a507d31aa1c6aaca138dc38dd3ccee0 --- /dev/null +++ b/app/pyafipws/utils.py @@ -0,0 +1,936 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; version 3. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Módulo con funciones auxiliares para el manejo de errores y temas comunes" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2013 Mariano Reingart" +__license__ = "GPL 3.0" + +from io import IOBase +import datetime +import functools +import inspect +import locale +import socket +import sys +import os +import stat +import time +import traceback +import warnings +from io import StringIO +from decimal import Decimal +from urllib.parse import urlencode +from urllib.parse import urlparse +import unicodedata +import mimetypes +from email.generator import _make_boundary +from html.parser import HTMLParser +from http.cookies import SimpleCookie +from configparser import ConfigParser as SafeConfigParser + +from pysimplesoap.client import SimpleXMLElement, SoapClient, SoapFault, parse_proxy, set_http_wrapper +from pkg_resources import parse_version + +try: + import json +except ImportError: + try: + import simplejson as json + except BaseException: + print("para soporte de JSON debe instalar simplejson") + json = None + +import httplib2 + +DEBUG = False + + +# Funciones para manejo de errores: + + +def exception_info(current_filename=None, index=-1): + "Analizar el traceback y armar un dict con la info amigable user-friendly" + # guardo el traceback original (por si hay una excepción): + info = sys.exc_info() # exc_type, exc_value, exc_traceback + # importante: no usar unpacking porque puede causar memory leak + if not current_filename: + # genero un call stack para ver quien me llamó y limitar la traza: + # advertencia: esto es necesario ya que en py2exe no tengo __file__ + try: + raise ZeroDivisionError + except ZeroDivisionError: + f = sys.exc_info()[2].tb_frame.f_back + current_filename = os.path.normpath(os.path.abspath(f.f_code.co_filename)) + + # extraer la última traza del archivo solicitado: + # (útil para no alargar demasiado la traza con lineas de las librerías) + ret = {'filename': "", 'lineno': 0, 'function_name': "", 'code': ""} + try: + for (filename, lineno, fn, text) in traceback.extract_tb(info[2]): + if os.path.normpath(os.path.abspath(filename)) == current_filename: + ret = {'filename': filename, 'lineno': lineno, + 'function_name': fn, 'code': text} + except Exception as e: + pass + # obtengo el mensaje de excepcion tal cual lo formatea python: + # (para evitar errores de encoding) + try: + ret['msg'] = traceback.format_exception_only(*info[0:2])[0] + except BaseException: + ret['msg'] = '' + # obtener el nombre de la excepcion (ej. "NameError") + try: + ret['name'] = info[0].__name__ + except BaseException: + ret['name'] = 'Exception' + # obtener la traza formateada como string: + try: + tb = traceback.format_exception(*info) + ret['tb'] = ''.join(tb) + except BaseException: + ret['tb'] = "" + return ret + + +def inicializar_y_capturar_excepciones(func): + "Decorador para inicializar y capturar errores (version para webservices)" + @functools.wraps(func) + def capturar_errores_wrapper(self, *args, **kwargs): + try: + # inicializo (limpio variables) + self.Errores = [] # listas de str para lenguajes legados + self.Observaciones = [] + self.errores = [] # listas de dict para usar en python + self.observaciones = [] + self.Eventos = [] + self.Traceback = self.Excepcion = "" + self.ErrCode = self.ErrMsg = self.Obs = "" + # limpio variables especificas del webservice: + self.inicializar() + # actualizo los parámetros + kwargs.update(self.params_in) + # limpio los parámetros + self.params_in = {} + self.params_out = {} + # llamo a la función (con reintentos) + retry = self.reintentos + 1 + while retry: + try: + retry -= 1 + return func(self, *args, **kwargs) + except socket.error as e: + if e.errno not in (10054, 10053): + # solo reintentar si el error es de conexión + # (10054, 'Connection reset by peer') + # (10053, 'Software caused connection abort') + raise + else: + if DEBUG: + print(e, "Reintentando...") + self.log(exception_info().get("msg", "")) + + except SoapFault as e: + # guardo destalle de la excepción SOAP + self.ErrCode = str(e.faultcode) + self.ErrMsg = str(e.faultstring) + self.Excepcion = "%s: %s" % (e.faultcode, e.faultstring, ) + if self.LanzarExcepciones: + raise + except Exception as e: + ex = exception_info() + self.Traceback = ex.get("tb", "") + try: + self.Excepcion = ex.get("msg", "") + except BaseException: + self.Excepcion = "" + if self.LanzarExcepciones: + raise + else: + self.ErrMsg = self.Excepcion + finally: + # guardo datos de depuración + if self.client: + self.XmlRequest = self.client.xml_request + self.XmlResponse = self.client.xml_response + return capturar_errores_wrapper + + +def inicializar_y_capturar_excepciones_simple(func): + "Decorador para inicializar y capturar errores (versión básica indep.)" + @functools.wraps(func) + def capturar_errores_wrapper(self, *args, **kwargs): + self.inicializar() + try: + return func(self, *args, **kwargs) + except BaseException: + ex = exception_info() + self.Excepcion = ex['name'] + self.Traceback = ex['msg'] + if self.LanzarExcepciones: + raise + else: + return False + return capturar_errores_wrapper + + +class BaseWS: + "Infraestructura basica para interfaces webservices de AFIP" + + def __init__(self, reintentos=1): + self.reintentos = reintentos + self.xml = self.client = self.Log = None + self.params_in = {} + self.inicializar() + self.Token = self.Sign = "" + self.LanzarExcepciones = True + + def inicializar(self): + self.Excepcion = self.Traceback = "" + self.XmlRequest = self.XmlResponse = "" + + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30, soap_server=None): + "Conectar cliente soap del web service" + try: + # analizar transporte y servidor proxy: + if wrapper: + Http = set_http_wrapper(wrapper) + self.Version = self.Version + " " + Http._wrapper_version + if isinstance(proxy, dict): + proxy_dict = proxy + else: + proxy_dict = parse_proxy(proxy) + self.log("Proxy Dict: %s" % str(proxy_dict)) + if self.HOMO or not wsdl: + wsdl = self.WSDL + # agregar sufijo para descargar descripción del servicio ?WSDL o ?wsdl + if not wsdl.endswith(self.WSDL[-5:]) and wsdl.startswith("http"): + wsdl += self.WSDL[-5:] + if not cache or self.HOMO: + # use 'cache' from installation base directory + cache = os.path.join(self.InstallDir, 'cache') + # deshabilitar verificación cert. servidor si es nulo falso vacio + if not cacert: + cacert = None + elif cacert is True or cacert.lower() == 'default': + # usar certificados predeterminados que vienen en la biblioteca + try: + import certifi + cacert = certifi.where() + except ImportError: + cacert = os.path.join(httplib2.__path__[0], 'cacerts.txt') + elif cacert.startswith("-----BEGIN CERTIFICATE-----"): + pass + else: + if not os.path.exists(cacert): + self.log("Buscando CACERT en conf...") + cacert = os.path.join(self.InstallDir, "conf", os.path.basename(cacert)) + if cacert and not os.path.exists(cacert): + self.log("No se encuentra CACERT: %s" % str(cacert)) + warnings.warn("No se encuentra CACERT: %s" % str(cacert)) + cacert = None # wrong version, certificates not found... + raise RuntimeError("Error de configuracion CACERT ver DebugLog") + return False + + self.log("Conectando a wsdl=%s cache=%s proxy=%s" % (wsdl, cache, proxy_dict)) + # analizar espacio de nombres (axis vs .net): + ns = 'ser' if self.WSDL[-5:] == "?wsdl" else None + self.client = SoapClient( + wsdl=wsdl, + cache=cache, + proxy=proxy_dict, + cacert=cacert, + timeout=timeout, + ns=ns, soap_server=soap_server, + trace="--trace" in sys.argv) + self.cache = cache # utilizado por WSLPG y WSAA (Ticket de Acceso) + self.wsdl = wsdl # utilizado por TrazaMed (para corregir el location) + # corrijo ubicación del servidor (puerto http 80 en el WSDL AFIP) + for service in list(self.client.services.values()): + for port in list(service['ports'].values()): + location = port['location'] + if location and location.startswith("http://"): + warnings.warn("Corrigiendo WSDL ... %s" % location) + location = location.replace("http://", "https://").replace(":80", ":443") + # usar servidor real si en el WSDL figura "localhost" + localhost = 'https://localhost:' + if location.startswith(localhost): + url = urlparse(wsdl) + location = location.replace("localhost", url.hostname) + location = location.replace(":9051", ":443") + port['location'] = location + return True + except BaseException: + ex = traceback.format_exception(sys.exc_info()[0], sys.exc_info()[1], sys.exc_info()[2]) + self.Traceback = ''.join(ex) + try: + self.Excepcion = traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0] + except BaseException: + self.Excepcion = "" + if self.LanzarExcepciones: + raise + return False + + def log(self, msg): + "Dejar mensaje en bitacora de depuración (método interno)" + if not isinstance(msg, str): + msg = str(msg, 'utf8', 'ignore') + if not self.Log: + self.Log = StringIO() + self.Log.write(msg) + self.Log.write('\n\r') + if DEBUG: + warnings.warn(msg) + + def DebugLog(self): + "Devolver y limpiar la bitácora de depuración" + if self.Log: + msg = self.Log.getvalue() + # limpiar log + self.Log.close() + self.Log = None + else: + msg = '' + return msg + + def LoadTestXML(self, xml): + "Cargar un archivo de pruebas con la respuesta simulada (depuración)" + # si el parametro es un nombre de archivo, cargar el contenido: + if os.path.exists(xml): + xml = open(xml).read() + + class DummyHTTP: + def __init__(self, xml_response): + self.xml_response = xml_response + + def request(self, location, method, body, headers): + return {}, self.xml_response + self.client.http = DummyHTTP(xml) + + @property + def xml_request(self): + return self.XmlRequest + + @property + def xml_response(self): + return self.XmlResponse + + def AnalizarXml(self, xml=""): + "Analiza un mensaje XML (por defecto el ticket de acceso)" + try: + if not xml or xml == 'XmlResponse': + xml = self.XmlResponse + elif xml == 'XmlRequest': + xml = self.XmlRequest + self.xml = SimpleXMLElement(xml) + return True + except Exception as e: + self.Excepcion = traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0] + return False + + def ObtenerTagXml(self, *tags): + "Busca en el Xml analizado y devuelve el tag solicitado" + # convierto el xml a un objeto + try: + if self.xml: + xml = self.xml + # por cada tag, lo busco segun su nombre o posición + for tag in tags: + xml = xml(tag) # atajo a getitem y getattr + # vuelvo a convertir a string el objeto xml encontrado + return str(xml) + except Exception as e: + self.Excepcion = traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0] + + def SetParametros(self, cuit, token, sign): + "Establece un parámetro general" + self.Token = token + self.Sign = sign + self.Cuit = cuit + return True + + @inicializar_y_capturar_excepciones + def SetTicketAcceso(self, ta_string): + "Establecer el token y sign desde un ticket de acceso XML" + if ta_string: + ta = SimpleXMLElement(ta_string) + self.Token = str(ta.credentials.token) + self.Sign = str(ta.credentials.sign) + return True + else: + raise RuntimeError("Ticket de Acceso vacio!") + + def SetParametro(self, clave, valor): + "Establece un parámetro de entrada (a usarse en llamada posterior)" + # útil para parámetros de entrada (por ej. VFP9 no soporta más de 27) + self.params_in[str(clave)] = valor + return True + + def GetParametro(self, clave, clave1=None, clave2=None, clave3=None, clave4=None): + "Devuelve un parámetro de salida (establecido por llamada anterior)" + # útil para parámetros de salida (por ej. campos de TransaccionPlainWS) + valor = self.params_out.get(clave) + # busco datos "anidados" (listas / diccionarios) + for clave in (clave1, clave2, clave3, clave4): + if clave is not None and valor is not None: + if isinstance(clave1, str) and clave.isdigit(): + clave = int(clave) + try: + valor = valor[clave] + except (KeyError, IndexError): + valor = None + if valor is not None: + if isinstance(valor, str): + return valor + else: + return str(valor) + else: + return "" + + def LeerError(self): + "Recorro los errores devueltos y devuelvo el primero si existe" + + if self.Errores: + # extraigo el primer item + er = self.Errores.pop(0) + return er + else: + return "" + + +class WebClient: + "Minimal webservice client to do POST request with multipart encoded FORM data" + + def __init__(self, location, enctype="multipart/form-data", trace=False, + cacert=None, timeout=30): + kwargs = {} + if parse_version(httplib2.__version__) >= parse_version('0.3.0'): + kwargs['timeout'] = timeout + if parse_version(httplib2.__version__) >= parse_version('0.7.0'): + kwargs['disable_ssl_certificate_validation'] = cacert is None + kwargs['ca_certs'] = cacert + self.http = httplib2.Http(**kwargs) + self.trace = trace + self.location = location + self.enctype = enctype + self.cookies = None + self.method = "POST" + self.referer = None + + def multipart_encode(self, vars): + "Enconde form data (vars dict)" + boundary = _make_boundary() + buf = StringIO() + for key, value in list(vars.items()): + if not isinstance(value, IOBase): + buf.write('--%s\r\n' % boundary) + buf.write('Content-Disposition: form-data; name="%s"' % key) + buf.write('\r\n\r\n' + value + '\r\n') + else: + fd = value + file_size = os.fstat(fd.fileno())[stat.ST_SIZE] + filename = os.path.basename(fd.name) + contenttype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + buf.write('--%s\r\n' % boundary) + buf.write('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (key, filename)) + buf.write('Content-Type: %s\r\n' % contenttype) + # buffer += 'Content-Length: %s\r\n' % file_size + fd.seek(0) + buf.write('\r\n' + fd.read() + '\r\n') + buf.write('--' + boundary + '--\r\n\r\n') + buf = buf.getvalue() + return boundary, buf + + def __call__(self, *args, **vars): + "Perform a GET/POST request and return the response" + + location = self.location + # if isinstance(location, str): + # location = location.encode("utf8") + # extend the base URI with additional components + if args: + location += "/".join(args) + if self.method == "GET": + location += "?%s" % urlencode(vars) + + # prepare the request content suitable to be sent to the server: + if self.enctype == "multipart/form-data": + boundary, body = self.multipart_encode(vars) + content_type = '%s; boundary=%s' % (self.enctype, boundary) + elif self.enctype == "application/x-www-form-urlencoded": + body = urlencode(vars) + content_type = self.enctype + else: + body = None + + # add headers according method, cookies, etc.: + headers = {} + if self.method == "POST": + headers.update({ + 'Content-type': content_type, + 'Content-length': str(len(body)), + }) + if self.cookies: + headers['Cookie'] = self.cookies.output(attrs=(), header="", sep=";") + if self.referer: + headers['Referer'] = self.referer + + if self.trace: + print("-" * 80) + print("%s %s" % (self.method, location)) + print('\n'.join(["%s: %s" % (k, v) for k, v in list(headers.items())])) + print("\n%s" % body) + + # send the request to the server and store the result: + response, content = self.http.request( + location, self.method, body=body, headers=headers) + self.response = response + self.content = content + + if self.trace: + print() + print('\n'.join(["%s: %s" % (k, v) for k, v in list(response.items())])) + print(content) + print("=" * 80) + + # Parse and store the cookies (if any) + if "set-cookie" in self.response: + if not self.cookies: + self.cookies = SimpleCookie() + self.cookies.load(self.response["set-cookie"]) + + return content + + +class AttrDict(dict): + "Custom Dict to hold attributes and items" + + +class HTMLFormParser(HTMLParser): + "Convert HTML form into custom named-tuple dicts" + + def __init__(self, *args, **kwargs): + HTMLParser.__init__(self, *args, **kwargs) + self.forms = {} + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if 'name' in attrs: + name = attrs['name'] + elif 'id' in attrs: + name = attrs['id'] + else: + name = None + if tag == 'form': + form = AttrDict() + for k, v in list(attrs.items()): + setattr(form, "_%s" % k, v) + self.form = self.forms[name or len(self.forms)] = form + elif tag == 'input': + self.form[name or len(self.form)] = attrs.get('value') + + +# Funciones para manejo de archivos de texto de campos de ancho fijo: + + +def leer(linea, formato, expandir_fechas=False): + "Analiza una linea de texto dado un formato, devuelve un diccionario" + dic = {} + comienzo = 1 + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = (len(fmt) > 3 and isinstance(fmt[3], int)) and fmt[3] or 2 + valor = linea[comienzo - 1:comienzo - 1 + longitud].strip() + try: + if chr(8) in valor or chr(127) in valor or chr(255) in valor: + valor = None # nulo + elif tipo == N: + if valor: + valor = int(valor) + else: + valor = 0 + elif tipo == I: + if valor: + try: + if '.' in valor: + valor = float(valor) + else: + valor = valor.strip(" ") + if valor[0] == "-": + sign = -1 + valor = valor[1:] + else: + sign = +1 + valor = sign * float(("%%s.%%0%sd" % dec) % (int(valor[:-dec] or '0'), int(valor[-dec:] or '0'))) + except ValueError: + raise ValueError("Campo invalido: %s = '%s'" % (clave, valor)) + else: + valor = 0.00 + elif expandir_fechas and clave.lower().startswith("fec") and longitud <= 8: + if valor: + valor = "%s-%s-%s" % (valor[0:4], valor[4:6], valor[6:8]) + else: + valor = None + else: + valor = valor.decode("ascii", "ignore") + if not valor and clave in dic and len(linea) <= comienzo: + pass # ignorar - compatibilidad hacia atrás (cambios tamaño) + else: + dic[clave] = valor + comienzo += longitud + except Exception as e: + raise ValueError("Error al leer campo %s pos %s val '%s': %s" % ( + clave, comienzo, valor, str(e))) + return dic + + +def escribir(dic, formato, contraer_fechas=False): + "Genera una cadena dado un formato y un diccionario de claves/valores" + linea = " " * sum([fmt[1] for fmt in formato]) + comienzo = 1 + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + try: + dec = (len(fmt) > 3 and isinstance(fmt[3], int)) and fmt[3] or 2 + if clave.capitalize() in dic: + clave = clave.capitalize() + s = dic.get(clave, "") + if isinstance(s, str): + s = s.encode("latin1") + if s is None: + valor = "" + else: + valor = str(s) + # reemplazo saltos de linea por tabulaci{on vertical + valor = valor.replace("\n\r", "\v").replace("\n", "\v").replace("\r", "\v") + if tipo == N and valor and valor != "NULL": + valor = ("%%0%dd" % longitud) % int(valor) + elif tipo == I and valor: + valor = ("%%0%d.%df" % (longitud + 1, dec) % float(valor)).replace(".", "") + elif contraer_fechas and clave.lower().startswith("fec") and longitud <= 8 and valor: + valor = valor.replace("-", "") + else: + valor = ("%%-0%ds" % longitud) % valor + linea = linea[:comienzo - 1] + valor + linea[comienzo - 1 + longitud:] + comienzo += longitud + except Exception as e: + warnings.warn("Error al escribir campo %s pos %s val '%s': %s" % ( + clave, comienzo, valor, str(e))) + return linea + "\n" + + +# Tipos de datos (código RG1361) + + +N = 'Numerico' # 2 +A = 'Alfanumerico' # 3 +I = 'Importe' # 4 +C = A # 1 (caracter alfabetico) +B = A # 9 (blanco) + + +# Funciones para manejo de tablas en DBF + + +def guardar_dbf(formatos, agrega=False, conf_dbf=None): + import dbf + if DEBUG: + print("Creando DBF...") + + tablas = {} + for nombre, formato, l in formatos: + campos = [] + claves = [] + filename = conf_dbf.get(nombre.lower(), "%s.dbf" % nombre[:8]) + if DEBUG: + print("=== tabla %s (%s) ===" % (nombre, filename)) + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = len(fmt) > 3 and fmt[3] or (tipo == 'I' and '2' or '') + if longitud > 250: + tipo = "M" # memo! + elif tipo == A: + tipo = "C(%s)" % longitud + elif tipo == N: + if longitud >= 18: + longitud = 17 + tipo = "N(%s,0)" % longitud + elif tipo == I: + if not dec: + dec = 0 + else: + dec = int(dec) + if longitud >= 18: + longitud = 17 + if longitud - 2 <= dec: + longitud += longitud - dec + 1 # ajusto long. decimales + tipo = "N(%s,%s)" % (longitud, dec) + clave_dbf = dar_nombre_campo_dbf(clave, claves) + campo = "%s %s" % (clave_dbf, tipo) + if DEBUG: + print(" * %s : %s" % (campo, clave)) + campos.append(campo) + claves.append(clave_dbf) + if DEBUG: + print("leyendo tabla", nombre, filename) + if agrega: + tabla = dbf.Table(filename, campos) + else: + tabla = dbf.Table(filename) + + for d in l: + # si no es un diccionario, ignorar ya que seguramente va en otra + # tabla (por ej. retenciones tiene su propio formato) + if isinstance(d, str): + continue + r = {} + claves = [] + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + if agrega or clave in d: + v = d.get(clave, None) + if DEBUG: + print(clave, v, tipo) + if v is None and tipo == A: + v = '' + if (v is None or v == '') and tipo in (I, N): + v = 0 + if tipo == A: + if isinstance(v, str): + v = v.encode("ascii", "replace") + if isinstance(v, str): + v = v.decode("ascii", "replace").encode("ascii", "replace") + if not isinstance(v, str): + v = str(v) + if len(v) > longitud: + v = v[:longitud] # recorto el string para que quepa + clave_dbf = dar_nombre_campo_dbf(clave, claves) + claves.append(clave_dbf) + r[clave_dbf] = v + # agregar si lo solicitaron o si la tabla no tiene registros: + if agrega or not tabla: + if DEBUG: + print("Agregando !!!", r) + registro = tabla.append(r) + else: + if DEBUG: + print("Actualizando ", r) + reg = tabla.current() + for k, v in list(reg.scatter_fields().items()): + if k not in r: + r[k] = v + if DEBUG: + print("Actualizando ", r) + reg.write_record(**r) + # mover de registro para no actualizar siempre el primero: + if not tabla.eof() and len(l) > 1: + if DEBUG: + print("Moviendo al próximo registro ", tabla.record_number) + next(tabla) + tabla.close() + + +def leer_dbf(formatos, conf_dbf): + import dbf + if DEBUG: + print("Leyendo DBF...") + + for nombre, formato, ld in formatos: + filename = conf_dbf.get(nombre.lower(), "%s.dbf" % nombre[:8]) + if DEBUG: + print("leyendo tabla", nombre, filename) + if not os.path.exists(filename): + continue + tabla = dbf.Table(filename) + for reg in tabla: + r = {} + d = reg.scatter_fields() + claves = [] + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + #import pdb; pdb.set_trace() + clave_dbf = dar_nombre_campo_dbf(clave, claves) + claves.append(clave_dbf) + v = d.get(clave_dbf) + r[clave] = v + if isinstance(ld, dict): + ld.update(r) + else: + ld.append(r) + + +def dar_nombre_campo_dbf(clave, claves): + "Reducir nombre de campo a 10 caracteres, sin espacios ni _, sin repetir" + # achico el nombre del campo para que quepa en la tabla: + nombre = clave.replace("_", "")[:10] + # si el campo esta repetido, le agrego un número + i = 0 + while nombre in claves: + i += 1 + nombre = nombre[:9] + str(i) + return nombre.lower() + + +def verifica(ver_list, res_dict, difs): + "Verificar que dos diccionarios sean iguales, actualiza lista diferencias" + for k, v in list(ver_list.items()): + # normalizo a float para poder comparar numericamente: + if isinstance(v, (Decimal, int)): + v = float(v) + if isinstance(res_dict.get(k), (Decimal, int)): + res_dict[k] = float(res_dict[k]) + if isinstance(v, list): + # verifico que ambas listas tengan la misma cantidad de elementos: + if v and not k in res_dict and v: + difs.append("falta tag %s: %s %s" % (k, repr(v), repr(res_dict.get(k)))) + elif len(res_dict.get(k, [])) != len(v or []): + difs.append("tag %s len !=: %s %s" % (k, repr(v), repr(res_dict.get(k)))) + else: + # ordeno las listas para poder compararlas si vienen mezcladas + rl = sorted(res_dict.get(k, [])) + # comparo los elementos uno a uno: + for i, vl in enumerate(sorted(v)): + verifica(vl, rl[i], difs) + elif isinstance(v, dict): + # comparo recursivamente los elementos: + verifica(v, res_dict.get(k, {}), difs) + elif res_dict.get(k) is None or v is None: + # alguno de los dos es nulo, verifico si ambos lo son o faltan + if v == "": + v = None + r = res_dict.get(k) + if r == "": + r = None + if not (r is None and v is None): + difs.append("%s: nil %s!=%s" % (k, repr(v), repr(r))) + elif isinstance(res_dict.get(k), type(v)): + # tipos iguales, los comparo directamente + if res_dict.get(k) != v: + difs.append("%s: %s!=%s" % (k, repr(v), repr(res_dict.get(k)))) + elif isinstance(v, float) or isinstance(res_dict.get(k), float): + # comparar numericamente + if float(res_dict.get(k)) != float(v): + difs.append("%s: %s!=%s" % (k, repr(v), repr(res_dict.get(k)))) + elif str(res_dict.get(k)) != str(v): + # tipos diferentes, comparo la representación + difs.append("%s: str %s!=%s" % (k, repr(v), repr(res_dict.get(k)))) + else: + pass + # print "%s: %s==%s" % (k, repr(v), repr(res_dict[k])) + + +def safe_console(): + if False and sys.stdout.encoding is None: + class SafeWriter: + def __init__(self, target): + self.target = target + self.encoding = 'utf-8' + self.errors = 'replace' + self.encode_to = 'latin-1' + + def write(self, s): + self.target.write(self.intercept(s)) + + def flush(self): + self.target.flush() + + def intercept(self, s): + if not isinstance(s, str): + s = s.decode(self.encode_to, self.errors) + return s.encode(self.encoding, self.errors) + + sys.stdout = SafeWriter(sys.stdout) + #sys.stderr = SafeWriter(sys.stderr) + print("Encodign in %s" % locale.getpreferredencoding()) + + +def norm(x, encoding="latin1"): + "Convertir acentos codificados en ISO 8859-1 u otro, a ASCII regular" + if not isinstance(x, str): + x = str(x) + elif isinstance(x, str): + x = x.decode(encoding, 'ignore') + return unicodedata.normalize('NFKD', x).encode('ASCII', 'ignore') + + +def date(fmt=None, timestamp=None): + "Manejo de fechas (simil PHP)" + if fmt == 'U': # return timestamp + t = datetime.datetime.now() + return int(time.mktime(t.timetuple())) + if fmt == 'c': # return isoformat + d = datetime.datetime.fromtimestamp(timestamp) + return d.isoformat() + if fmt == 'Ymd': + d = datetime.datetime.now() + return d.strftime("%Y%m%d") + + +def get_install_dir(): + if not hasattr(sys, "frozen"): + basepath = __file__ + elif sys.frozen == 'dll': + import win32api + basepath = win32api.GetModuleFileName(sys.frozendllhandle) + else: + basepath = sys.executable + + if hasattr(sys, "frozen"): + # we are running as py2exe-packed executable + try: + import pythoncom + pythoncom.frozen = 1 + except ModuleNotFoundError: + pass + sys.argv[0] = sys.executable + + return os.path.dirname(os.path.abspath(basepath)) + + +def abrir_conf(config_file, debug=False): + "Abrir el archivo de configuración (usar primer parámetro como ruta)" + # en principio, usar el nombre de archivo predeterminado + # si se pasa el archivo de configuración por parámetro, confirmar que exista + # y descartar que sea una opción + if len(sys.argv) > 1: + if os.path.splitext(sys.argv[1])[1].lower() == ".ini": + config_file = sys.argv.pop(1) + if not os.path.exists(config_file) or not os.path.isfile(config_file): + warnings.warn("Archivo de configuracion %s invalido" % config_file) + + if debug: + print("CONFIG_FILE:", config_file) + + config = SafeConfigParser() + config.read(config_file, encoding="latin1") + + return config + + +def json_serializer(obj): + if isinstance(obj, (datetime.datetime, datetime.date)): + return obj.isoformat() + else: + return str(obj) + + +if __name__ == "__main__": + print(get_install_dir()) + try: + 1 / 0 + except BaseException: + ex = exception_info() + print(ex) + assert ex['name'] == "ZeroDivisionError" + assert ex['lineno'] == 73 + assert ex['tb'] diff --git a/app/pyafipws/venv.bat b/app/pyafipws/venv.bat new file mode 100644 index 0000000000000000000000000000000000000000..7d9a301a36ce8558398c8e0ffdd18169aa4907b6 --- /dev/null +++ b/app/pyafipws/venv.bat @@ -0,0 +1,29 @@ +@echo off + +rem Creacin del entorno virtual (opcional) para el proyecto PyAfipWs +rem 2015 (c) Mariano Reingart - Licencia: GPLv3+ + +rem Nota: Es recomendable ejecutar este programa como Administrador +rem Ver https://code.google.com/p/pyafipws/wiki/InstalacionCodigoFuente + +pip 1> NUL 2> NUL +if %ERRORLEVEL%==9009 ( + echo Python 2.7.9 / PIP no puede ser ejecutado + echo Por favor instale: https://www.python.org/ftp/python/2.7.9/python-2.7.9.msi + echo Asegurese que el PATH contenga a C:\Python27 y C:\Python27\scripts + pause + start https://www.python.org/ftp/python/2.7.9/python-2.7.9.msi + exit 1 +) +echo *** Instalar utilidades de instalacin / entorno virtual: + +rem pip install --upgrade pip +pip install --upgrade wheel +pip install --upgrade virtualenv + +echo *** Crear y activar el entorno virtual venv (en el directorio actual): + +virtualenv venv +venv\Scripts\activate + +echo *** Listo!, para salir del entorno ejecute deactivate diff --git a/app/pyafipws/wdigdepfiel.py b/app/pyafipws/wdigdepfiel.py new file mode 100644 index 0000000000000000000000000000000000000000..aa00813c1f45e9d2bfa7a3260db3141466d306d5 --- /dev/null +++ b/app/pyafipws/wdigdepfiel.py @@ -0,0 +1,186 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +import time +import sys +import os +from .utils import date, SimpleXMLElement, SoapClient, SoapFault +"""Mdulo para interfaz Depositario Fiel web service wDigDepFiel de AFIP +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2010 Mariano Reingart" +__license__ = "LGPL 3.0" +__version__ = "1.01" + +LICENCIA = """ +wdigdepfiel.py: Interfaz para Digitalizacion Depositario Fiel AFIP +Copyright (C) 2010 Mariano Reingart reingart@gmail.com + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo bajo la licencia GPLv3. + +Para informacin adicional sobre garanta, soporte tcnico comercial +e incorporacin/distribucin en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + + +WSDDFURL = "https://testdia.afip.gov.ar/Dia/Ws/wDigDepFiel/wDigDepFiel.asmx" +SOAP_ACTION = 'ar.gov.afip.dia.serviciosWeb.wDigDepFiel/' +SOAP_NS = 'ar.gov.afip.dia.serviciosWeb.wDigDepFiel' + +DEBUG = True +XML = False +HOMO = True + + +def dummy(client): + "Metodo dummy para verificacion de funcionamiento" + response = client.Dummy() + result = response.DummyResult + appserver = dbserver = authserver = None + try: + appserver = str(result.appserver) + dbserver = str(result.dbserver) + authserver = str(result.authserver) + except (RuntimeError, IndexError, AttributeError) as e: + pass + return {'appserver': appserver, + 'dbserver': dbserver, + 'authserver': authserver} + + +def aviso_recep_acept(client, token, sign, cuit, tipo_agente, rol, + nro_legajo, cuit_declarante, cuit_psad, cuit_ie, + codigo, fecha_hora_acept, ticket): + "Aviso de recepcion y aceptacion." + response = client.AvisoRecepAcept( + autentica=dict(Cuit=cuit, Token=token, Sign=sign, TipoAgente=tipo_agente, Rol=rol), + nroLegajo=nro_legajo, + cuitDeclarante=cuit_declarante, + cuitPSAD=cuit_psad, + cuitIE=cuit_ie, + codigo=codigo, + fechaHoraAcept=fecha_hora_acept, + ticket=ticket, + ) + result = response.AvisoRecepAceptResult + return str(result.codError), str(result.descError) + + +def aviso_digit(client, token, sign, cuit, tipo_agente, rol, + nro_legajo, cuit_declarante, cuit_psad, cuit_ie, cuit_ata, + codigo, url, familias, ticket, hashing, cantidad_total): + "Aviso de digitalizacion." + response = client.AvisoDigit( + autentica=dict(Cuit=cuit, Token=token, Sign=sign, TipoAgente=tipo_agente, Rol=rol), + nroLegajo=nro_legajo, + cuitDeclarante=cuit_declarante, + cuitPSAD=cuit_psad, + cuitIE=cuit_ie, + cuitATA=cuit_ata, + codigo=codigo, + url=url, + familias=familias, + ticket=ticket, + hashing=hashing, + cantidadTotal=cantidad_total, + ) + result = response.AvisoDigitResult + return str(result.codError), str(result.descError) + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + + import csv + import traceback + import datetime + + from . import wsaa + + try: + + if "--version" in sys.argv: + print("Versin: ", __version__) + + CERT = 'reingart.crt' + PRIVATEKEY = 'reingart.key' + # obteniendo el TA + TA = "wsddf-ta.xml" + if not os.path.exists(TA) or os.path.getmtime(TA) + (60 * 60 * 5) < time.time(): + tra = wsaa.create_tra(service="wDigDepFiel") + cms = wsaa.sign_tra(tra, CERT, PRIVATEKEY) + ta_string = wsaa.call_wsaa(cms) + open(TA, "w").write(ta_string) + ta_string = open(TA).read() + ta = SimpleXMLElement(ta_string) + token = str(ta.credentials.token) + sign = str(ta.credentials.sign) + # fin TA + + # cliente soap del web service + client = SoapClient(WSDDFURL, + action=SOAP_ACTION, + namespace=SOAP_NS, exceptions=True, + trace=True, ns='ar', soap_ns='soap') + + if '--dummy' in sys.argv: + ret = dummy(client) + print('\n'.join(["%s: %s" % it for it in list(ret.items())])) + + # ejemplos aviso recep acept (prueba): + + cuit = 20267565393 + tipo_agente = 'DESP' # 'DESP' + rol = 'EXTE' + nro_legajo = '0' * 16 # '1234567890123456' + cuit_declarante = cuit_psad = cuit_ie = cuit + codigo = '000' # carpeta completa, '0001' carpeta adicional + fecha_hora_acept = datetime.datetime.now().isoformat() + ticket = '1234' + r = aviso_recep_acept(client, token, sign, cuit, tipo_agente, rol, + nro_legajo, cuit_declarante, cuit_psad, cuit_ie, + codigo, fecha_hora_acept, ticket) + print(r) + + # ejemplos aviso digit (prueba): + + cuit = 20267565393 + tipo_agente = 'DESP' # 'DESP' + rol = 'EXTE' + nro_legajo = '0' * 16 # '1234567890123456' + cuit_declarante = cuit_psad = cuit_ie = cuit_ata = cuit + codigo = '000' # carpeta completa, '0001' carpeta adicional + ticket = '1234' + url = 'http://www.example.com' + hashing = 'db1491eda47d78532cdfca19c62875aade941dc2' + familias = [{'Familia': {'codigo': '02', 'cantidad': 1}}, {'Familia': {'codigo': '03', 'cantidad': 3}}, ] + cantidad_total = 4 + r = aviso_digit(client, token, sign, cuit, tipo_agente, rol, + nro_legajo, cuit_declarante, cuit_psad, cuit_ie, cuit_ata, + codigo, url, familias, ticket, hashing, cantidad_total) + print(r) + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore")) + sys.exit(3) + except Exception as e: + print(str(e).encode("ascii", "ignore")) + if DEBUG: + raise + sys.exit(5) diff --git a/app/pyafipws/ws_sr_padron.py b/app/pyafipws/ws_sr_padron.py new file mode 100644 index 0000000000000000000000000000000000000000..23c7c4d09f1442dda3dfad28f5a719d8cae1abdb --- /dev/null +++ b/app/pyafipws/ws_sr_padron.py @@ -0,0 +1,380 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""Módulo para acceder a los datos de un contribuyente registrado en el Padrón +de AFIP (WS-SR-PADRON de AFIP). Consulta a Padrón Alcance 4 version 1.1 +Consulta de Padrón Constancia Inscripción Alcance 5 version 2.0 +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2017 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.03e" + +import csv +import datetime +import decimal +import json +import os +import sys + +from .utils import inicializar_y_capturar_excepciones, BaseWS, get_install_dir, json_serializer, abrir_conf, norm, SoapFault +from configparser import ConfigParser as SafeConfigParser +from .padron import TIPO_CLAVE, PROVINCIAS + + +HOMO = False +LANZAR_EXCEPCIONES = True +WSDL = "https://awshomo.afip.gov.ar/sr-padron/webservices/personaServiceA4?wsdl" +CONFIG_FILE = "rece.ini" + + +class WSSrPadronA4(BaseWS): + "Interfaz para el WebService de Consulta Padrón Contribuyentes Alcance 4" + _public_methods_ = ['Consultar', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetTicketAcceso', 'GetParametro', + 'Dummy', 'Conectar', 'DebugLog', 'SetTicketAcceso'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', 'InstallDir', + 'LanzarExcepciones', 'Excepcion', 'Traceback', + 'Persona', 'data', + 'denominacion', 'imp_ganancias', 'imp_iva', + 'monotributo', 'integrante_soc', 'empleador', + 'actividad_monotributo', 'cat_iva', 'domicilios', + 'tipo_doc', 'nro_doc', + 'tipo_persona', 'estado', 'impuestos', 'actividades', + 'direccion', 'localidad', 'provincia', 'cod_postal', + ] + + _reg_progid_ = "WSSrPadronA4" + _reg_clsid_ = "{C2270008-4324-46F6-A2D3-60836EE63BD7}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + Reprocesar = True # recuperar automaticamente CAE emitidos + LanzarExcepciones = LANZAR_EXCEPCIONES + factura = None + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Persona = '' + self.Reproceso = '' # no implementado + self.cuit = self.dni = 0 + self.tipo_persona = "" # FISICA o JURIDICA + self.tipo_doc = self.nro_doc = 0 + self.estado = "" # ACTIVO + self.denominacion = "" + self.direccion = self.localidad = self.provincia = self.cod_postal = "" + self.domicilios = [] + self.impuestos = [] + self.actividades = [] + self.imp_iva = self.empleador = self.integrante_soc = self.cat_iva = "" + self.monotributo = self.actividad_monotributo = "" + self.data = {} + self.errores = [] + + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + ret = self.client.dummy() + result = ret['dummyReturn'] + self.AppServerStatus = result['appserver'] + self.DbServerStatus = result['dbserver'] + self.AuthServerStatus = result['authserver'] + return True + + @inicializar_y_capturar_excepciones + def Consultar(self, id_persona): + "Devuelve el detalle de todos los datos del contribuyente solicitado" + # llamar al webservice: + res = self.client.getPersona( + sign=self.Sign, + token=self.Token, + cuitRepresentada=self.Cuit, + idPersona=id_persona, + ) + ret = res.get('personaReturn', {}) + # obtengo el resultado de AFIP (dict): + data = ret.get('persona', None) + if isinstance(data, list): + data = data[0] + self.data = data + # lo serializo + self.Persona = json.dumps(self.data, + default=json_serializer) + # extraigo los campos principales: + self.cuit = data["idPersona"] + self.tipo_persona = data["tipoPersona"] + self.tipo_doc = TIPO_CLAVE.get(data["tipoClave"]) + self.nro_doc = data.get("numeroDocumento") + self.estado = data.get("estadoClave") + if not "razonSocial" in data: + self.denominacion = ", ".join([data.get("apellido", ""), + data.get("nombre", "")]) + else: + self.denominacion = data.get("razonSocial", "") + # analizo el domicilio, dando prioridad al FISCAL, luego LEGAL/REAL + domicilios = data.get("domicilio", []) + domicilios.sort(key=lambda item: item["tipoDomicilio"] != "FISCAL") + if domicilios: + domicilio = domicilios[0] + self.direccion = domicilio.get("direccion", "") + self.localidad = domicilio.get("localidad", "") # no usado en CABA + self.provincia = PROVINCIAS.get(domicilio.get("idProvincia"), "") + self.cod_postal = domicilio.get("codPostal") + else: + self.direccion = self.localidad = self.provincia = "" + self.cod_postal = "" + # retrocompatibilidad: + self.domicilios = domicilios + self.domicilio = "%s - %s (%s) - %s" % ( + self.direccion, self.localidad, + self.cod_postal, self.provincia,) + # analizo impuestos: + self.impuestos = [imp["idImpuesto"] for imp in data.get("impuesto", []) + if imp['estado'] == 'ACTIVO'] + self.actividades = [act["idActividad"] for act in data.get("actividad", [])] + mt = [cat for cat in data.get("categoria", []) + if cat["idImpuesto"] in (20, 21) and cat["estado"] == "ACTIVO"] + mt.sort(key=lambda cat: cat["idImpuesto"]) + self.analizar_datos(mt[0] if mt else {}) + return True + + def analizar_datos(self, cat_mt): + # intenta determinar situación de IVA: + if 32 in self.impuestos: + self.imp_iva = "EX" + elif 33 in self.impuestos: + self.imp_iva = "NI" + elif 34 in self.impuestos: + self.imp_iva = "NA" + else: + self.imp_iva = "S" if 30 in self.impuestos else "N" + self.monotributo = "S" if cat_mt else "N" + self.actividad_monotributo = cat_mt.get("descripcionCategoria") if cat_mt else "" + self.integrante_soc = "" + self.empleador = "S" if 301 in self.impuestos else "N" + # intenta determinar categoría de IVA (confirmar) + if self.imp_iva in ('AC', 'S'): + self.cat_iva = 1 # RI + elif self.imp_iva == 'EX': + self.cat_iva = 4 # EX + elif self.monotributo == 'S': + self.cat_iva = 6 # MT + else: + self.cat_iva = 5 # CF + return True + + +class WSSrPadronA5(WSSrPadronA4): + "Interfaz para el WebService de Consulta Padrón Constancia de Inscripción Alcance 5" + + _reg_progid_ = "WSSrPadronA5" + _reg_clsid_ = "{DF7447DD-EEF3-4E6B-A93B-F969B5075EC8}" + + WSDL = WSDL.replace("personaServiceA4", "personaServiceA5") + + @inicializar_y_capturar_excepciones + def Consultar(self, id_persona): + "Devuelve el detalle de todos los datos del contribuyente solicitado" + # llamar al webservice: + res = self.client.getPersona( + sign=self.Sign, + token=self.Token, + cuitRepresentada=self.Cuit, + idPersona=id_persona, + ) + ret = res.get('personaReturn', {}) + # obtengo el resultado de AFIP (dict): + data = ret.get('datosGenerales', {}) + if isinstance(data, list): + data = data[0] + self.data = data + # lo serializo + self.Persona = json.dumps(ret, + default=json_serializer) + for er in 'errorConstancia', 'errorMonotributo', 'errorRegimenGeneral': + if er in ret: + self.errores.extend(ret[er]) + self.Excepcion = '\n\r'.join([er["error"] for er in self.errores]) + # extraigo los campos principales: + self.tipo_persona = data.get("tipoPersona") + self.tipo_doc = TIPO_CLAVE.get(data.get("tipoClave")) + self.nro_doc = data.get("idPersona") + self.cuit = self.nro_doc + self.estado = data.get("estadoClave") + if not "razonSocial" in data: + self.denominacion = ", ".join([data.get("apellido", ""), + data.get("nombre", "")]) + else: + self.denominacion = data.get("razonSocial", "") + # analizo el domicilio, dando prioridad al FISCAL, luego LEGAL/REAL + domicilio = data.get("domicilioFiscal", []) + if domicilio: + self.direccion = domicilio.get("direccion", "") + self.localidad = domicilio.get("localidad", "") # no usado en CABA + self.provincia = PROVINCIAS.get(domicilio.get("idProvincia"), "") + self.cod_postal = domicilio.get("codPostal") + else: + self.direccion = self.localidad = self.provincia = "" + self.cod_postal = "" + # retrocompatibilidad: + self.domicilios = [domicilio] + self.domicilio = "%s - %s (%s) - %s" % ( + self.direccion, self.localidad, + self.cod_postal, self.provincia,) + # extraer datos impositivos (inscripción / opción) para unificarlos: + data_mt = ret.get("datosMonotributo", {}) + data_rg = ret.get("datosRegimenGeneral", {}) + # analizo impuestos: + impuestos = data_mt.get("impuesto", []) + data_rg.get("impuesto", []) + self.impuestos = [imp["idImpuesto"] for imp in impuestos] + actividades = data_rg.get("actividad", []) + data_mt.get("actividadMonotributista", []) + self.actividades = [act["idActividad"] for act in actividades] + cat_mt = data_mt.get("categoriaMonotributo", {}) + self.analizar_datos(cat_mt) + return not self.errores + + +def main(): + "Función principal de pruebas (obtener CAE)" + import os + import time + global CONFIG_FILE + + DEBUG = '--debug' in sys.argv + + if '--constancia' in sys.argv: + padron = WSSrPadronA5() + SECTION = 'WS-SR-PADRON-A5' + service = "ws_sr_constancia_inscripcion" + else: + padron = WSSrPadronA4() + SECTION = 'WS-SR-PADRON-A4' + service = "ws_sr_padron_a4" + + config = abrir_conf(CONFIG_FILE, DEBUG) + if config.has_section('WSAA'): + crt = config.get('WSAA', 'CERT') + key = config.get('WSAA', 'PRIVATEKEY') + cuit = config.get(SECTION, 'CUIT') + else: + crt, key = "reingart.crt", "reingart.key" + cuit = "20267565393" + url_wsaa = url_ws = None + if config.has_option('WSAA', 'URL'): + url_wsaa = config.get('WSAA', 'URL') + if config.has_option(SECTION, 'URL') and not HOMO: + url_ws = config.get(SECTION, 'URL') + + # obteniendo el TA para pruebas + from .wsaa import WSAA + + cache = "" + ta = WSAA().Autenticar(service, crt, key, url_wsaa) + + padron.SetTicketAcceso(ta) + padron.Cuit = cuit + padron.Conectar(cache, url_ws, cacert="conf/afip_ca_info.crt") + + if "--dummy" in sys.argv: + print(padron.client.help("dummy")) + wssrpadron4.Dummy() + print("AppServerStatus", wssrpadron4.AppServerStatus) + print("DbServerStatus", wssrpadron4.DbServerStatus) + print("AuthServerStatus", wssrpadron4.AuthServerStatus) + + if '--csv' in sys.argv: + csv_reader = csv.reader(open("entrada.csv", "rU"), + dialect='excel', delimiter=",") + csv_writer = csv.writer(open("salida.csv", "w"), + dialect='excel', delimiter=",") + encabezado = next(csv_reader) + columnas = ["cuit", "denominacion", "estado", "direccion", + "localidad", "provincia", "cod_postal", + "impuestos", "actividades", "imp_iva", + "monotributo", "actividad_monotributo", + "empleador", "imp_ganancias", "integrante_soc"] + csv_writer.writerow(columnas) + + for fila in csv_reader: + cuit = (fila[0] if fila else "").replace("-", "") + if cuit.isdigit(): + print("Consultando AFIP online...", cuit, end=' ') + try: + ok = padron.Consultar(cuit) + except SoapFault as e: + ok = None + if e.faultstring != "No existe persona con ese Id": + raise + print('ok' if ok else "error", padron.Excepcion) + # domicilio posiblemente esté en Latin1, normalizar + csv_writer.writerow([norm(getattr(padron, campo, "")) + for campo in columnas]) + sys.exit(0) + + try: + + if "--prueba" in sys.argv: + id_persona = "20000000516" + else: + id_persona = len(sys.argv) > 1 and sys.argv[1] or "20267565393" + + if "--testing" in sys.argv: + padron.LoadTestXML("tests/xml/%s_resp.xml" % service) + print("Consultando AFIP online via webservice...", end=' ') + ok = padron.Consultar(id_persona) + + if DEBUG: + print("Persona", padron.Persona) + print(padron.Excepcion) + + print('ok' if ok else "error", padron.Excepcion) + print("Denominacion:", padron.denominacion) + print("Tipo:", padron.tipo_persona, padron.tipo_doc, padron.nro_doc) + print("Estado:", padron.estado) + print("Direccion:", padron.direccion) + print("Localidad:", padron.localidad) + print("Provincia:", padron.provincia) + print("Codigo Postal:", padron.cod_postal) + print("Impuestos:", padron.impuestos) + print("Actividades:", padron.actividades) + print("IVA", padron.imp_iva) + print("MT", padron.monotributo, padron.actividad_monotributo) + print("Empleador", padron.empleador) + + if padron.Excepcion: + print("Excepcion:", padron.Excepcion) + # ver padron.errores para el detalle + + except BaseException: + raise + print(padron.XmlRequest) + print(padron.XmlResponse) + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = WSSrPadronA4.InstallDir = WSSrPadronA5.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSSrPadronA4) + win32com.server.register.UseCommandLine(WSSrPadronA5) + else: + main() diff --git a/app/pyafipws/wsaa.py b/app/pyafipws/wsaa.py new file mode 100644 index 0000000000000000000000000000000000000000..876fbd643ed01283adf15db7e271fca2f118323c --- /dev/null +++ b/app/pyafipws/wsaa.py @@ -0,0 +1,525 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Módulo para obtener un ticket de autorización del web service WSAA de AFIP" + +# Basado en wsaa-client.php de Gerardo Fisanotti - DvSHyS/DiOPIN/AFIP - 13-apr-07 +# Definir WSDL, CERT, PRIVATEKEY, PASSPHRASE, SERVICE, WSAAURL +# Devuelve TA.xml (ticket de autorización de WSAA) + +__author__ = "Mariano Reingart (reingart@gmail.com)" +__copyright__ = "Copyright (C) 2008-2011 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "2.11c" + +import hashlib +import datetime +import email +import os +import sys +import time +import traceback +import warnings +import unicodedata +from pysimplesoap.client import SimpleXMLElement +from .utils import inicializar_y_capturar_excepciones, BaseWS, get_install_dir, \ + exception_info, safe_console, date +try: + from M2Crypto import BIO, Rand, SMIME, SSL +except ImportError: + ex = exception_info() + warnings.warn("No es posible importar M2Crypto (OpenSSL)") + warnings.warn(ex['msg']) # revisar instalación y DLLs de OpenSSL + BIO = Rand = SMIME = SSL = None + # utilizar alternativa (ejecutar proceso por separado) + from subprocess import Popen, PIPE + from base64 import b64encode + from tempfile import NamedTemporaryFile + +# Constantes (si se usa el script de linea de comandos) +WSDL = "https://wsaahomo.afip.gov.ar/ws/services/LoginCms?wsdl" # El WSDL correspondiente al WSAA +CERT = "reingart.crt" # El certificado X.509 obtenido de Seg. Inf. +PRIVATEKEY = "reingart.key" # La clave privada del certificado CERT +PASSPHRASE = "xxxxxxx" # La contraseña para firmar (si hay) +SERVICE = "wsfe" # El nombre del web service al que se le pide el TA + +# WSAAURL: la URL para acceder al WSAA, verificar http/https y wsaa/wsaahomo +# WSAAURL = "https://wsaa.afip.gov.ar/ws/services/LoginCms" # PRODUCCION!!! +WSAAURL = "https://wsaahomo.afip.gov.ar/ws/services/LoginCms" # homologacion (pruebas) +SOAP_ACTION = 'http://ar.gov.afip.dif.facturaelectronica/' # Revisar WSDL +SOAP_NS = "http://wsaa.view.sua.dvadac.desein.afip.gov" # Revisar WSDL + +# Verificación del web server remoto, necesario para verificar canal seguro +CACERT = "conf/afip_ca_info.crt" # WSAA CA Cert (Autoridades de Confiaza) + +HOMO = False +TYPELIB = False +DEFAULT_TTL = 60 * 60 * 5 # five hours +DEBUG = False + +# No debería ser necesario modificar nada despues de esta linea + + +def create_tra(service=SERVICE, ttl=2400): + "Crear un Ticket de Requerimiento de Acceso (TRA)" + tra = SimpleXMLElement( + '' + '' + '') + tra.add_child('header') + # El source es opcional. Si falta, toma la firma (recomendado). + # tra.header.addChild('source','subject=...') + #tra.header.addChild('destination','cn=wsaahomo,o=afip,c=ar,serialNumber=CUIT 33693450239') + tra.header.add_child('uniqueId', str(date('U'))) + tra.header.add_child('generationTime', str(date('c', date('U') - ttl))) + tra.header.add_child('expirationTime', str(date('c', date('U') + ttl))) + tra.add_child('service', service) + xml = tra.as_xml() + if isinstance(xml, bytes): + return xml + return xml.encode('utf-8') + + +def sign_tra(tra, cert=CERT, privatekey=PRIVATEKEY, passphrase=""): + "Firmar PKCS#7 el TRA y devolver CMS (recortando los headers SMIME)" + + if BIO: + # Firmar el texto (tra) usando m2crypto (openssl bindings para python) + buf = BIO.MemoryBuffer(tra) # Crear un buffer desde el texto + #Rand.load_file('randpool.dat', -1) # Alimentar el PRNG + s = SMIME.SMIME() # Instanciar un SMIME + # soporte de contraseña de encriptación (clave privada, opcional) + callback = lambda *args, **kwarg: passphrase + # Cargar clave privada y certificado + if not privatekey.startswith("-----BEGIN RSA PRIVATE KEY-----"): + # leer contenido desde archivo (evitar problemas Applink / MSVCRT) + if os.path.exists(privatekey) and os.path.exists(cert): + privatekey = open(privatekey).read() + cert = open(cert).read() + else: + raise RuntimeError("Archivos no encontrados: %s, %s" % (privatekey, cert)) + # crear buffers en memoria de la clave privada y certificado: + key_bio = BIO.MemoryBuffer(privatekey.encode('utf8')) + crt_bio = BIO.MemoryBuffer(cert.encode('utf8')) + s.load_key_bio(key_bio, crt_bio, callback) # (desde buffer) + p7 = s.sign(buf, 0) # Firmar el buffer + out = BIO.MemoryBuffer() # Crear un buffer para la salida + s.write(out, p7) # Generar p7 en formato mail + # Rand.save_file('randpool.dat') # Guardar el estado del PRNG's + + # extraer el cuerpo del mensaje (parte firmada) + msg = email.message_from_string(out.read().decode('utf8')) + for part in msg.walk(): + filename = part.get_filename() + if filename == "smime.p7m": # es la parte firmada? + return part.get_payload(decode=False) # devolver CMS + else: + # Firmar el texto (tra) usando OPENSSL directamente + try: + if sys.platform.startswith("linux"): + openssl = "openssl" + else: + if sys.maxsize <= 2**32: + openssl = r"c:\OpenSSL-Win32\bin\openssl.exe" + else: + openssl = r"c:\OpenSSL-Win64\bin\openssl.exe" + # NOTE: workaround if certificate is not already stored in a file + # SECURITY WARNING: the private key will be exposed a bit in /tmp + # (in theory only for the current user) + if cert.startswith("-----BEGIN CERTIFICATE-----"): + cert_f = NamedTemporaryFile() + cert_f.write(cert.encode('utf-8')) + cert_f.flush() + cert = cert_f.name + else: + cert_f = None + if privatekey.startswith("-----BEGIN RSA PRIVATE KEY-----"): + key_f = NamedTemporaryFile() + key_f.write(privatekey.encode('utf-8')) + key_f.flush() + privatekey = key_f.name + else: + key_f = None + try: + out = Popen([openssl, "smime", "-sign", + "-signer", cert, "-inkey", privatekey, + "-outform","DER", "-nodetach"], + stdin=PIPE, stdout=PIPE, + stderr=PIPE).communicate(tra)[0] + finally: + # close temp files to delete them (just in case): + if cert_f: + cert_f.close() + if key_f: + key_f.close() + return b64encode(out).decode("utf8") + except OSError as e: + if e.errno == 2: + warnings.warn("El ejecutable de OpenSSL no esta disponible en el PATH") + raise + + +def call_wsaa(cms, location=WSAAURL, proxy=None, trace=False, cacert=None): + "Llamar web service con CMS para obtener ticket de autorización (TA)" + + # creo la nueva clase + wsaa = WSAA() + try: + wsaa.Conectar(proxy=proxy, wsdl=location, cache="", cacert=cacert) + ta = wsaa.LoginCMS(cms) + if not ta: + raise RuntimeError(wsaa.Excepcion) + else: + return ta + except BaseException: + raise + + +class WSAA(BaseWS): + "Interfaz para el WebService de Autenticación y Autorización" + _public_methods_ = ['CreateTRA', 'SignTRA', 'CallWSAA', 'LoginCMS', 'Conectar', + 'AnalizarXml', 'ObtenerTagXml', 'Expirado', 'Autenticar', + 'DebugLog', 'AnalizarCertificado', + 'CrearClavePrivada', 'CrearPedidoCertificado', + ] + _public_attrs_ = ['Token', 'Sign', 'ExpirationTime', 'Version', + 'XmlRequest', 'XmlResponse', + 'InstallDir', 'Traceback', 'Excepcion', + 'Identidad', 'Caducidad', 'Emisor', 'CertX509', + 'SoapFault', 'LanzarExcepciones', + ] + _readonly_attrs_ = _public_attrs_[:-1] + _reg_progid_ = "WSAA" + _reg_clsid_ = "{6268820C-8900-4AE9-8A2D-F0A1EBD4CAC5}" + + if TYPELIB: + _typelib_guid_ = '{30E9C94B-7385-4534-9A80-DF50FD169253}' + _typelib_version_ = 2, 11 + _com_interfaces_ = ['IWSAA'] + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + @inicializar_y_capturar_excepciones + def CreateTRA(self, service="wsfe", ttl=2400): + "Crear un Ticket de Requerimiento de Acceso (TRA)" + return create_tra(service, ttl) + + @inicializar_y_capturar_excepciones + def AnalizarCertificado(self, crt, binary=False): + "Carga un certificado digital y extrae los campos más importantes" + from M2Crypto import BIO, EVP, RSA, X509 + if binary: + bio = BIO.MemoryBuffer(cert.encode('utf8')) + x509 = X511.load_cert_bio(bio, X509.FORMAT_DER) + else: + if not crt.startswith("-----BEGIN CERTIFICATE-----"): + crt = open(crt).read() + if isinstance(crt, str): + crt = crt.encode('utf-8') + bio = BIO.MemoryBuffer(crt.encode('utf8')) + x509 = X509.load_cert_bio(bio, X509.FORMAT_PEM) + if x509: + self.Identidad = x509.get_subject().as_text() + self.Caducidad = x509.get_not_after().get_datetime() + self.Emisor = x509.get_issuer().as_text() + self.CertX509 = x509.as_text() + return True + + @inicializar_y_capturar_excepciones + def CrearClavePrivada(self, filename="privada.key", key_length=4096, + pub_exponent=0x10001, passphrase=""): + "Crea una clave privada (private key)" + from M2Crypto import RSA, EVP + + # only protect if passphrase was given (it will fail otherwise) + callback = lambda *args, **kwarg: passphrase + chiper = None if not passphrase else "aes_128_cbc" + # create the RSA key pair (and save the result to a file): + rsa_key_pair = RSA.gen_key(key_length, pub_exponent, callback) + bio = BIO.MemoryBuffer() + rsa_key_pair.save_key_bio(bio, chiper, callback) + f = open(filename, "w") + f.write(bio.read()) + f.close() + # create a public key to sign the certificate request: + self.pkey = EVP.PKey(md='sha256') + self.pkey.assign_rsa(rsa_key_pair) + return True + + @inicializar_y_capturar_excepciones + def CrearPedidoCertificado(self, cuit="", empresa="", nombre="pyafipws", + filename="empresa.csr"): + "Crear un certificate signing request (X509 CSR)" + from M2Crypto import RSA, EVP, X509 + + # create the certificate signing request (CSR): + self.x509_req = X509.Request() + + # normalizar encoding (reemplazar acentos, eñe, etc.) + if isinstance(empresa, str): + empresa = unicodedata.normalize('NFKD', empresa).encode('ASCII', 'ignore') + if isinstance(nombre, str): + nombre = unicodedata.normalize('NFKD', nombre).encode('ASCII', 'ignore') + + # subjet: C=AR/O=[empresa]/CN=[nombre]/serialNumber=CUIT [nro_cuit] + x509name = X509.X509_Name() + # default OpenSSL parameters: + kwargs = {"type": 0x1000 | 1, "len": -1, "loc": -1, "set": 0} + x509name.add_entry_by_txt(field='C', entry='AR', **kwargs) + x509name.add_entry_by_txt(field='O', entry=empresa, **kwargs) + x509name.add_entry_by_txt(field='CN', entry=nombre, **kwargs) + x509name.add_entry_by_txt(field='serialNumber', entry="CUIT %s" % str(cuit), **kwargs) + self.x509_req.set_subject_name(x509name) + + # sign the request with the previously created key (CrearClavePrivada) + self.x509_req.set_pubkey(pkey=self.pkey) + self.x509_req.sign(pkey=self.pkey, md='sha256') + # save the CSR result to a file: + f = open(filename, "w") + f.write(self.x509_req.as_pem()) + f.close() + return True + + @inicializar_y_capturar_excepciones + def SignTRA(self, tra, cert, privatekey, passphrase=""): + "Firmar el TRA y devolver CMS" + return sign_tra(tra, cert, privatekey, passphrase) + + @inicializar_y_capturar_excepciones + def LoginCMS(self, cms): + "Obtener ticket de autorización (TA)" + results = self.client.loginCms(in0=str(cms)) + ta_xml = results['loginCmsReturn'] # .encode("utf-8") + self.xml = ta = SimpleXMLElement(ta_xml) + self.Token = str(ta.credentials.token) + self.Sign = str(ta.credentials.sign) + self.ExpirationTime = str(ta.header.expirationTime) + return ta_xml + + def CallWSAA(self, cms, url="", proxy=None): + "Obtener ticket de autorización (TA) -version retrocompatible-" + self.Conectar("", url, proxy) + ta_xml = self.LoginCMS(cms) + if not ta_xml: + raise RuntimeError(self.Excepcion) + return ta_xml + + @inicializar_y_capturar_excepciones + def Expirado(self, fecha=None): + "Comprueba la fecha de expiración, devuelve si ha expirado" + if not fecha: + fecha = self.ObtenerTagXml('expirationTime') + now = datetime.datetime.now() + d = datetime.datetime.strptime(fecha[:19], '%Y-%m-%dT%H:%M:%S') + return now > d + + def Autenticar(self, service, crt, key, wsdl=None, proxy=None, wrapper=None, cacert=None, cache=None, debug=False): + "Método unificado para obtener el ticket de acceso (cacheado)" + + self.LanzarExcepciones = True + try: + # sanity check: verificar las credenciales + for filename in (crt, key): + if not os.access(filename, os.R_OK): + raise RuntimeError("Imposible abrir %s\n" % filename) + # creo el nombre para el archivo del TA (según credenciales y ws) + ta_src = (service + crt + key).encode("utf8") + fn = "TA-%s.xml" % hashlib.md5(ta_src).hexdigest() + if cache: + fn = os.path.join(cache, fn) + else: + fn = os.path.join(self.InstallDir, "cache", fn) + + # leer el ticket de acceso (si fue previamente solicitado) + if not os.path.exists(fn) or os.path.getsize(fn) == 0 or \ + os.path.getmtime(fn) + (DEFAULT_TTL) < time.time(): + # ticket de acceso (TA) vencido, crear un nuevo req. (TRA) + if DEBUG: + print("Creando TRA...") + tra = self.CreateTRA(service=service, ttl=DEFAULT_TTL) + # firmarlo criptográficamente + if DEBUG: + print("Frimando TRA...") + cms = self.SignTRA(tra, crt, key) + # concectar con el servicio web: + if DEBUG: + print("Conectando a WSAA...") + ok = self.Conectar(cache, wsdl, proxy, wrapper, cacert) + if not ok or self.Excepcion: + raise RuntimeError("Fallo la conexión: %s" % self.Excepcion) + # llamar al método remoto para solicitar el TA + if DEBUG: + print("Llamando WSAA...") + ta = self.LoginCMS(cms) + if not ta: + raise RuntimeError("Ticket de acceso vacio: %s" % WSAA.Excepcion) + # grabar el ticket de acceso para poder reutilizarlo luego + if DEBUG: + print("Grabando TA en %s..." % fn) + try: + open(fn, "w").write(ta) + except IOError as e: + self.Excepcion = "Imposible grabar ticket de accesso: %s" % fn + else: + # leer el ticket de acceso del archivo en cache + if DEBUG: + print("Leyendo TA de %s..." % fn) + ta = open(fn, "r").read() + # analizar el ticket de acceso y extraer los datos relevantes + self.AnalizarXml(xml=ta) + self.Token = self.ObtenerTagXml("token") + self.Sign = self.ObtenerTagXml("sign") + except BaseException: + ta = "" + if not self.Excepcion: + # avoid encoding problem when reporting exceptions to the user: + self.Excepcion = traceback.format_exception_only(sys.exc_info()[0], + sys.exc_info()[1])[0] + self.Traceback = "" + if DEBUG or debug: + raise + return ta + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = WSAA.InstallDir = get_install_dir() + + +if __name__ == "__main__": + + safe_console() + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + if TYPELIB: + if '--register' in sys.argv: + tlb = os.path.abspath(os.path.join(INSTALL_DIR, "typelib", "wsaa.tlb")) + print("Registering %s" % (tlb,)) + tli = pythoncom.LoadTypeLib(tlb) + pythoncom.RegisterTypeLib(tli, tlb) + elif '--unregister' in sys.argv: + k = WSAA + pythoncom.UnRegisterTypeLib(k._typelib_guid_, + k._typelib_version_[0], + k._typelib_version_[1], + 0, + pythoncom.SYS_WIN32) + print("Unregistered typelib") + import win32com.server.register + win32com.server.register.UseCommandLine(WSAA) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([WSAA._reg_clsid_]) + elif "--crear_pedido_cert" in sys.argv: + # instanciar el helper y revisar los parámetros + wsaa = WSAA() + args = [arg for arg in sys.argv if not arg.startswith("--")] + # obtengo el CUIT y lo normalizo: + cuit = len(args) > 1 and args[1] or input("Ingrese un CUIT: ") + cuit = ''.join([c for c in cuit if c.isdigit()]) + nombre = len(args) > 2 and args[2] or "PyAfipWs" + # consultar el padrón online de AFIP si no se especificó razón social: + empresa = len(args) > 3 and args[3] or "" + if not empresa: + from .padron import PadronAFIP + padron = PadronAFIP() + ok = padron.Consultar(cuit) + if ok and padron.denominacion: + print("Denominación según AFIP:", padron.denominacion) + empresa = padron.denominacion + else: + print("CUIT %s no encontrado: %s..." % (cuit, padron.Excepcion)) + empresa = input("Empresa: ") + # longitud de la clave (2048 predeterminada a partir de 8/2016) + key_length = len(args) > 4 and args[4] or "" + try: + key_length = int(key_length) + except ValueError: + key_length = 2048 + # generar los archivos (con fecha para no pisarlo) + ts = datetime.datetime.now().strftime("%Y%m%d%M%S") + clave_privada = "clave_privada_%s_%s.key" % (cuit, ts) + pedido_cert = "pedido_cert_%s_%s.csr" % (cuit, ts) + print("Longitud clave %s (bits)" % key_length) + wsaa.CrearClavePrivada(clave_privada, key_length) + wsaa.CrearPedidoCertificado(cuit, empresa, nombre, pedido_cert) + print("Se crearon los archivos:") + print(clave_privada) + print(pedido_cert) + # convertir a terminación de linea windows y abrir con bloc de notas + if sys.platform == "win32": + txt = open(pedido_cert + ".txt", "wb") + for linea in open(pedido_cert, "r"): + txt.write("%s\r\n" % linea) + txt.close() + os.startfile(pedido_cert + ".txt") + else: + + # Leer argumentos desde la linea de comando (si no viene tomar default) + args = [arg for arg in sys.argv if arg.startswith("--")] + argv = [arg for arg in sys.argv if not arg.startswith("--")] + crt = len(argv) > 1 and argv[1] or CERT + key = len(argv) > 2 and argv[2] or PRIVATEKEY + service = len(argv) > 3 and argv[3] or "wsfe" + ttl = len(argv) > 4 and int(argv[4]) or 36000 + url = len(argv) > 5 and argv[5] or WSAAURL + wrapper = len(argv) > 6 and argv[6] or None + cacert = len(argv) > 7 and argv[7] or CACERT + DEBUG = "--debug" in args or DEBUG + + print("Usando CRT=%s KEY=%s URL=%s SERVICE=%s TTL=%s" % (crt, key, url, service, ttl), file=sys.stderr) + + # creo el objeto para comunicarme con el ws + wsaa = WSAA() + wsaa.LanzarExcepciones = True + + print("WSAA Version %s %s" % (WSAA.Version, HOMO), file=sys.stderr) + + if '--proxy' in args: + proxy = sys.argv[sys.argv.index("--proxy") + 1] + print("Usando PROXY:", proxy, file=sys.stderr) + else: + proxy = None + + if '--analizar' in sys.argv: + wsaa.AnalizarCertificado(crt) + print(wsaa.Identidad) + print(wsaa.Caducidad) + print(wsaa.Emisor) + print(wsaa.CertX509) + + ta = wsaa.Autenticar(service, crt, key, url, proxy, wrapper, cacert) + if not ta: + if DEBUG: + print(wsaa.Traceback, file=sys.stderr) + sys.exit("Excepcion: %s" % wsaa.Excepcion) + + else: + print(ta) + + if wsaa.Excepcion: + print(wsaa.Excepcion, file=sys.stderr) + + if DEBUG: + print("Source:", wsaa.ObtenerTagXml('source')) + print("UniqueID Time:", wsaa.ObtenerTagXml('uniqueId')) + print("Generation Time:", wsaa.ObtenerTagXml('generationTime')) + print("Expiration Time:", wsaa.ObtenerTagXml('expirationTime')) + print("Expiro?", wsaa.Expirado()) + ##import time; time.sleep(10) + # print "Expiro?", wsaa.Expirado() diff --git a/app/pyafipws/wsbfev1.py b/app/pyafipws/wsbfev1.py new file mode 100644 index 0000000000000000000000000000000000000000..0baf3c1cb91a5becb9be56b91506eea1dee20ac2 --- /dev/null +++ b/app/pyafipws/wsbfev1.py @@ -0,0 +1,659 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""M�dulo para obtener c�digo de autorizaci�n electr�nico (CAE) del web service +WSBFEv1 de AFIP (Bonos Fiscales electronicos v1.1 - Factura Electr�nica RG) +a fin de gestionar los Bonos en la Secretar�a de Industria seg�n RG 2557 +""" + +__author__ = "Mariano Reingart (reingart@gmail.com)" +__copyright__ = "Copyright (C) 2013-2016 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.06g" + +import datetime +import decimal +import os +import sys +from .utils import inicializar_y_capturar_excepciones, BaseWS, get_install_dir + +HOMO = False +LANZAR_EXCEPCIONES = True # valor por defecto: True +WSDL = "https://wswhomo.afip.gov.ar/wsbfev1/service.asmx?WSDL" + + +class WSBFEv1(BaseWS): + "Interfaz para el WebService de Bono Fiscal Electr�nico V1 (FE Bs. Capital)" + _public_methods_ = ['CrearFactura', 'AgregarItem', 'Authorize', 'GetCMP', 'AgregarOpcional', 'AgregarCmpAsoc', + 'GetParamMon', 'GetParamTipoCbte', 'GetParamUMed', + 'GetParamTipoIVA', 'GetParamNCM', 'GetParamZonas', + 'GetParamTipoDoc', + 'Dummy', 'Conectar', 'GetLastCMP', 'GetLastID', + 'GetParamCtz', 'LoadTestXML', + 'AnalizarXml', 'ObtenerTagXml', 'DebugLog', + 'SetParametros', 'SetTicketAcceso', 'GetParametro', + 'Dummy', 'Conectar', 'SetTicketAcceso'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', + 'Resultado', 'Obs', 'Reproceso', 'FechaCAE', + 'CAE', 'Vencimiento', 'Eventos', 'ErrCode', 'ErrMsg', 'FchVencCAE', + 'Excepcion', 'LanzarExcepciones', 'Traceback', "InstallDir", + 'PuntoVenta', 'CbteNro', 'FechaCbte', 'ImpTotal', 'ImpNeto', 'ImptoLiq', + ] + + _reg_progid_ = "WSBFEv1" + _reg_clsid_ = "{EE4ABEE2-76DD-450F-880B-66710AE464D6}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologaci�n' or '') + factura = None + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Resultado = self.Motivo = self.Reproceso = '' + self.LastID = self.LastCMP = self.CAE = self.Vencimiento = '' + self.CbteNro = self.FechaCbte = self.PuntoVenta = self.ImpTotal = None + self.ImpNeto = self.ImptoLiq = None + self.LanzarExcepciones = LANZAR_EXCEPCIONES + self.InstallDir = INSTALL_DIR + self.FechaCAE = self.FchVencCAE = "" # retrocompatibilidad + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + if 'BFEErr' in ret: + errores = [ret['BFEErr']] + for error in errores: + self.Errores.append("%s: %s" % ( + error['ErrCode'], + error.get('ErrMsg', ""), + )) + self.ErrCode = ' '.join([str(error['ErrCode']) for error in errores]) + self.ErrMsg = '\n'.join(self.Errores) + if 'BFEEvents' in ret: + events = [ret['BFEEvents']] + self.Eventos = ['%s: %s' % (evt['EventCode'], evt.get('EventMsg', "")) for evt in events] + + def CrearFactura(self, tipo_doc=80, nro_doc=23111111113, + zona=0, tipo_cbte=1, punto_vta=1, cbte_nro=0, fecha_cbte=None, + imp_total=0.0, imp_neto=0.0, impto_liq=0.0, + imp_tot_conc=0.0, impto_liq_rni=0.00, imp_op_ex=0.00, + imp_perc=0.00, imp_iibb=0.00, imp_perc_mun=0.00, imp_internos=0.00, + imp_moneda_id=0, imp_moneda_ctz=1.0, fecha_venc_pago=None, **kwargs): + "Creo un objeto factura (interna)" + # Creo una factura para bonos fiscales electr�nicos + + fact = {'tipo_cbte': tipo_cbte, 'punto_vta': punto_vta, + 'cbte_nro': cbte_nro, 'fecha_cbte': fecha_cbte, 'zona': zona, + 'tipo_doc': tipo_doc, 'nro_doc': nro_doc, + 'imp_total': imp_total, 'imp_neto': imp_neto, + 'impto_liq': impto_liq, 'impto_liq_rni': impto_liq_rni, + 'imp_op_ex': imp_op_ex, 'imp_tot_conc': imp_tot_conc, + 'imp_perc': imp_perc, 'imp_perc_mun': imp_perc_mun, + 'imp_iibb': imp_iibb, 'imp_internos': imp_internos, + 'imp_moneda_id': imp_moneda_id, 'imp_moneda_ctz': imp_moneda_ctz, + 'fecha_venc_pago': fecha_venc_pago, + 'cbtes_asoc': [], + 'opcionales': [], + 'iva': [], + 'detalles': [], + } + self.factura = fact + return True + + def AgregarItem(self, ncm, sec, ds, qty, umed, precio, bonif, iva_id, imp_total, **kwargs): + "Agrego un item a una factura (interna)" + # ds = unicode(ds, "latin1") # convierto a latin1 + # Nota: no se calcula neto, iva, etc (deben venir calculados!) + self.factura['detalles'].append({ + 'ncm': ncm, 'sec': sec, + 'ds': ds, + 'qty': qty, + 'umed': umed, + 'precio': precio, + 'bonif': bonif, + 'iva_id': iva_id, + 'imp_total': imp_total, + }) + return True + + def AgregarCmpAsoc(self, tipo=1, pto_vta=0, nro=0, cuit=None, fecha=None, **kwarg): + "Agrego un comprobante asociado a una factura (interna)" + cmp_asoc = {'tipo': tipo, 'pto_vta': pto_vta, 'nro': nro} + if cuit is not None: + cmp_asoc['cuit'] = cuit + if fecha is not None: + cmp_asoc['fecha'] = fecha + self.factura['cbtes_asoc'].append(cmp_asoc) + return True + + def AgregarOpcional(self, opcional_id=0, valor="", **kwarg): + "Agrego un dato opcional a una factura (interna)" + op = { 'opcional_id': opcional_id, 'valor': valor } + self.factura['opcionales'].append(op) + return True + + @inicializar_y_capturar_excepciones + def Authorize(self, id): + "Autoriza la factura cargada en memoria" + f = self.factura + ret = self.client.BFEAuthorize( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Cmp={ + 'Id': id, + 'Zona': f['zona'], + 'Fecha_cbte': f['fecha_cbte'], + 'Tipo_cbte': f['tipo_cbte'], + 'Punto_vta': f['punto_vta'], + 'Cbte_nro': f['cbte_nro'], + 'Tipo_doc': f['tipo_doc'], 'Nro_doc': f['nro_doc'], + 'Imp_moneda_Id': f['imp_moneda_id'], + 'Imp_moneda_ctz': f['imp_moneda_ctz'], + 'Imp_total': f['imp_total'], + 'Imp_tot_conc': f['imp_tot_conc'], 'Imp_op_ex': f['imp_op_ex'], + 'Imp_neto': f['imp_neto'], 'Impto_liq': f['impto_liq'], + 'Impto_liq_rni': f['impto_liq_rni'], + 'Imp_perc': f['imp_perc'], 'Imp_perc_mun': f['imp_perc_mun'], + 'Imp_iibb': f['imp_iibb'], + 'Imp_internos': f['imp_internos'], + 'Fecha_vto_pago': f['fecha_venc_pago'], + 'Items': [ + {'Item': { + 'Pro_codigo_ncm': d['ncm'], + 'Pro_codigo_sec': d['sec'], + 'Pro_ds': d['ds'], + 'Pro_qty': d['qty'], + 'Pro_umed': d['umed'], + 'Pro_precio_uni': d['precio'], + 'Imp_bonif': d['bonif'], + 'Imp_total': d['imp_total'], + 'Iva_id': d['iva_id'], + }} for d in f['detalles']], + 'CbtesAsoc': f['cbtes_asoc'] and [ + {'CbteAsoc': { + 'Tipo_cbte': cbte_asoc['tipo'], + 'Punto_vta': cbte_asoc['pto_vta'], + 'Cbte_nro': cbte_asoc['nro'], + 'Cuit': cbte_asoc.get('cuit'), + 'Fecha_cbte': cbte_asoc.get('fecha'), + }} + for cbte_asoc in f['cbtes_asoc']] or None, + 'Opcionales': [ + {'Opcional': { + 'Id': opcional['opcional_id'], + 'Valor': opcional['valor'], + }} for opcional in f['opcionales']] or None, + }) + + result = ret['BFEAuthorizeResult'] + self.__analizar_errores(result) + if 'BFEResultAuth' in result: + auth = result['BFEResultAuth'] + # Resultado: A: Aceptado, R: Rechazado + self.Resultado = auth.get('Resultado', "") + # Obs: + self.Obs = auth.get('Obs', "") + self.Reproceso = auth.get('Reproceso', "") + self.CAE = auth.get('Cae', "") + self.CbteNro = auth.get('Fch_cbte', "") + self.ImpTotal = str(auth.get('Imp_total', '')) + self.ImptoLiq = str(auth.get('Impto_liq', '')) + self.ImpNeto = str(auth.get('Imp_neto', '')) + vto = str(auth.get('Fch_venc_Cae', '')) + self.FchVencCAE = vto + self.Vencimiento = "%s/%s/%s" % (vto[6:8], vto[4:6], vto[0:4]) + return self.CAE + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + result = self.client.BFEDummy()['BFEDummyResult'] + self.__analizar_errores(result) + self.AppServerStatus = str(result.get('AppServer', "")) + self.DbServerStatus = str(result.get('DbServer', "")) + self.AuthServerStatus = str(result.get('AuthServer', "")) + return True + + @inicializar_y_capturar_excepciones + def GetCMP(self, tipo_cbte, punto_vta, cbte_nro): + "Recuperar los datos completos de un comprobante ya autorizado" + ret = self.client.BFEGetCMP( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Cmp={"Tipo_cbte": tipo_cbte, + "Punto_vta": punto_vta, "Cbte_nro": cbte_nro}) + result = ret['BFEGetCMPResult'] + self.__analizar_errores(result) + if 'BFEResultGet' in result: + resultget = result['BFEResultGet'] + # Obs, cae y fecha cae + if 'Cae' in resultget: + self.Obs = resultget['Obs'] and resultget['Obs'].strip(" ") or '' + self.CAE = resultget['Cae'] + vto = str(resultget['Fch_venc_Cae']) + self.Vencimiento = "%s/%s/%s" % (vto[6:8], vto[4:6], vto[0:4]) + self.FechaCbte = resultget['Fecha_cbte_orig'] # .strftime("%Y/%m/%d") + self.FechaCAE = resultget['Fecha_cbte_cae'] # .strftime("%Y/%m/%d") + self.PuntoVenta = resultget['Punto_vta'] # 4000 + self.Resultado = resultget['Resultado'] + self.CbteNro = resultget['Cbte_nro'] + self.ImpTotal = resultget['Imp_total'] + self.ImptoLiq = resultget['Impto_liq'] + self.ImpNeto = resultget['Imp_neto'] + return self.CAE + else: + return 0 + + @inicializar_y_capturar_excepciones + def GetLastCMP(self, tipo_cbte, punto_vta): + "Recuperar �ltimo n�mero de comprobante emitido" + ret = self.client.BFEGetLast_CMP( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, + "Tipo_cbte": tipo_cbte, + "Pto_venta": punto_vta, + }) + result = ret['BFEGetLast_CMPResult'] + self.__analizar_errores(result) + if 'BFEResult_LastCMP' in result: + resultget = result['BFEResult_LastCMP'] + self.CbteNro = resultget.get('Cbte_nro') + self.FechaCbte = resultget.get('Cbte_fecha') # .strftime("%Y/%m/%d") + return self.CbteNro + + @inicializar_y_capturar_excepciones + def GetLastID(self): + "Recuperar �ltimo n�mero de transacci�n (ID)" + ret = self.client.BFEGetLast_ID( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetLast_IDResult'] + self.__analizar_errores(result) + if 'BFEResultGet' in result: + resultget = result['BFEResultGet'] + return resultget.get('Id') + + @inicializar_y_capturar_excepciones + def GetParamUMed(self): + ret = self.client.BFEGetPARAM_UMed( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_UMedResult'] + self.__analizar_errores(result) + + umeds = [] # unidades de medida + for u in result['BFEResultGet']: + u = u['ClsBFEResponse_UMed'] + try: + umed = {'id': u.get('Umed_Id'), 'ds': u.get('Umed_Ds'), + 'vig_desde': u.get('Umed_vig_desde'), + 'vig_hasta': u.get('Umed_vig_hasta')} + except Exception as e: + print(e) + if u is None: + # WTF! + umed = {'id': '', 'ds': '', 'vig_desde': '', 'vig_hasta': ''} + #import pdb; pdb.set_trace() + # print u + + umeds.append(umed) + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % p for p in umeds] + + @inicializar_y_capturar_excepciones + def GetParamMon(self): + ret = self.client.BFEGetPARAM_MON( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_MONResult'] + self.__analizar_errores(result) + + mons = [] # unidades de medida + for m in result['BFEResultGet']: + m = m['ClsBFEResponse_Mon'] + try: + mon = {'id': m.get('Mon_Id'), 'ds': m.get('Mon_Ds'), + 'vig_desde': m.get('Mon_vig_desde'), + 'vig_hasta': m.get('Mon_vig_hasta')} + except Exception as e: + raise + if m is None: + # WTF! + mon = {'id': '', 'ds': '', 'vig_desde': '', 'vig_hasta': ''} + #import pdb; pdb.set_trace() + # print u + mons.append(mon) + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % p for p in mons] + + @inicializar_y_capturar_excepciones + def GetParamTipoIVA(self): + "Recuperar lista de valores referenciales de tipos de IVA (al�cuotas)" + ret = self.client.BFEGetPARAM_Tipo_IVA( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_Tipo_IVAResult'] + self.__analizar_errores(result) + + ivas = [] # tipos de iva + for i in result['BFEResultGet']: + i = i['ClsBFEResponse_Tipo_IVA'] + try: + iva = {'id': i.get('IVA_Id'), 'ds': i.get('IVA_Ds'), + 'vig_desde': i.get('IVA_vig_desde'), + 'vig_hasta': i.get('IVA_vig_hasta')} + ivas.append(iva) + except Exception as e: + pass + raise + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % p for p in ivas] + + @inicializar_y_capturar_excepciones + def GetParamTipoDoc(self): + "Recuperar lista de valores referenciales de tipos de documentos" + ret = self.client.BFEGetPARAM_Tipo_doc( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_Tipo_docResult'] + self.__analizar_errores(result) + + docs = [] # tipos de documentos + for d in result['BFEResultGet']: + d = d['ClsBFEResponse_Tipo_doc'] + try: + doc = {'id': d.get('Doc_Id'), 'ds': d.get('Doc_Ds'), + 'vig_desde': d.get('Doc_vig_desde'), + 'vig_hasta': d.get('Doc_vig_hasta')} + docs.append(doc) + except Exception as e: + pass + raise + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % d for d in docs] + + def GetParamTipoCbte(self): + "Recuperar lista de valores referenciales de Tipos de Comprobantes" + ret = self.client.BFEGetPARAM_Tipo_Cbte( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_Tipo_CbteResult'] + self.__analizar_errores(result) + + tipos = [] # tipos de comprobantes + for t in result['BFEResultGet']: + t = t['ClsBFEResponse_Tipo_Cbte'] + try: + tipo = {'id': t.get('Cbte_Id'), 'ds': t.get('Cbte_Ds'), + 'vig_desde': t.get('Cbte_vig_desde'), + 'vig_hasta': t.get('Cbte_vig_hasta')} + tipos.append(tipo) + except Exception as e: + pass + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % p for p in tipos] + + def GetParamNCM(self): + "Recuperar lista de valores referenciales de c�digos del Nomenclador Com�n del Mercosur" + ret = self.client.BFEGetPARAM_NCM( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_NCMResult'] + self.__analizar_errores(result) + + ncms = [] # nomenclador comun del mercosur + for n in result['BFEResultGet']: + n = n['ClsBFEResponse_NCM'] + try: + ncm = {'id': n.get('NCM_Codigo'), 'ds': n.get('NCM_Ds'), + 'vig_desde': n.get('NCM_vig_desde'), + 'vig_hasta': n.get('NCM_vig_hasta')} + ncms.append(ncm) + except Exception as e: + pass + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % p for p in ncms] + + def GetParamZonas(self): + "Recuperar lista de valores referenciales de Zonas" + ret = self.client.BFEGetPARAM_Zonas( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['BFEGetPARAM_ZonasResult'] + self.__analizar_errores(result) + + zonas = [] # zonas + for z in result['BFEResultGet']: + z = z['ClsBFEResponse_Zon'] + try: + zona = {'id': z.get('Zon_Id'), 'ds': z.get('Zon_Ds'), + 'vig_desde': z.get('Zon_vig_desde'), + 'vig_hasta': z.get('Zon_vig_hasta')} + zonas.append(zona) + except Exception as e: + pass + return ['%(id)s: %(ds)s (%(vig_desde)s - %(vig_hasta)s)' % p for p in zonas] + + @inicializar_y_capturar_excepciones + def GetParamCtz(self, moneda_id): + "Recuperador de cotizaci�n de moneda" + ret = self.client.BFEGetPARAM_Ctz( + auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + MonId=moneda_id, + ) + self.__analizar_errores(ret['BFEGetPARAM_CtzResult']) + res = ret['BFEGetPARAM_CtzResult'].get('BFEResultGet') + if res: + ctz = str(res.get('Mon_ctz', "")) + else: + ctz = '' + return ctz + + +class WSBFE(WSBFEv1): + "Wrapper para retrocompatibilidad con WSBFE" + + _reg_progid_ = "WSBFE" + _reg_clsid_ = "{02CBC6DA-455D-4EE6-8302-411D13253CBF}" + + def __init__(self): + WSBFEv1.__init__(self) + self.Version = "%s %s WSBFEv1" % (__version__, HOMO and 'Homologaci�n' or '') + + def Conectar(self, cache=None, url="", **kwargs): + # Ajustar URL de V0 a V1: + if url in ("https://wswhomo.afip.gov.ar/wsfex/service.asmx", + "http://wswhomo.afip.gov.ar/WSFEX/service.asmx"): + url = "https://wswhomo.afip.gov.ar/wsbfev1/service.asmx" + elif url in ("https://servicios1.afip.gov.ar/wsfex/service.asmx", + "http://servicios1.afip.gov.ar/WSFEX/service.asmx"): + url = "https://servicios1.afip.gov.ar/wsbfev1/service.asmx" + return WSBFEv1.Conectar(self, cache=cache, wsdl=url, **kwargs) + + +# busco el directorio de instalaci�n (global para que no cambie si usan otra dll) +INSTALL_DIR = WSBFEv1.InstallDir = get_install_dir() + + +def p_assert_eq(a, b): + print(a, a == b and '==' or '!=', b) + + +if __name__ == "__main__": + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSBFEv1) + if '--wsbfe' in sys.argv: + win32com.server.register.UseCommandLine(WSBFE) + else: + + # Crear objeto interface Web Service de Factura Electr�nica de Exportaci�n + wsbfev1 = WSBFEv1() + # Setear token y sing de autorizaci�n (pasos previos) + + # obteniendo el TA para pruebas + from .wsaa import WSAA + ta = WSAA().Autenticar("wsbfe", "reingart.crt", "reingart.key") + wsbfev1.SetTicketAcceso(ta) + + # CUIT del emisor (debe estar registrado en la AFIP) + wsbfev1.Cuit = "20267565393" + + # Conectar al Servicio Web de Facturaci�n (homologaci�n) + wsdl = "http://wswhomo.afip.gov.ar/WSBFEv1/service.asmx" + cache = proxy = "" + wrapper = "httplib2" + cacert = open("conf/afip_ca_info.crt").read() + ok = wsbfev1.Conectar(cache, wsdl, proxy, wrapper, cacert) + + if '--dummy' in sys.argv: + #wsbfev1.LanzarExcepciones = False + print(wsbfev1.Dummy()) + print("AppServerStatus", wsbfev1.AppServerStatus) + print("DbServerStatus", wsbfev1.DbServerStatus) + print("AuthServerStatus", wsbfev1.AuthServerStatus) + + if "--prueba" in sys.argv: + try: + # Establezco los valores de la factura a autorizar: + tipo_cbte = '--nc' in sys.argv and 3 or 201 # FC/NC Expo (ver tabla de parámetros) + punto_vta = 5 + tipo_doc = 80 + nro_doc = 23111111113 + zona = 0 + # Obtengo el �ltimo n�mero de comprobante y le agrego 1 + cbte_nro = int(wsbfev1.GetLastCMP(tipo_cbte, punto_vta)) + 1 + fecha_cbte = datetime.datetime.now().strftime("%Y%m%d") + imp_moneda_id = "PES" # (ver tabla de par�metros) + imp_moneda_ctz = 1 + imp_neto = "390.00" + impto_liq = "81.90" # 21% IVA + impto_liq_rni = imp_tot_conc = imp_op_ex = "0.00" + imp_perc = imp_iibb = imp_perc_mun = imp_internos = "0.00" + imp_total = "471.90" + fecha_venc_pago = fecha_cbte if "--fce" in sys.argv else None + + # Creo una factura (internamente, no se llama al WebService): + ok = wsbfev1.CrearFactura(tipo_doc, nro_doc, + zona, tipo_cbte, punto_vta, cbte_nro, fecha_cbte, + imp_total, imp_neto, impto_liq, + imp_tot_conc, impto_liq_rni, imp_op_ex, + imp_perc, imp_iibb, imp_perc_mun, imp_internos, + imp_moneda_id, imp_moneda_ctz, fecha_venc_pago) + + # Agrego un item: + ncm = '7308.10.00' + sec = '' + umed = 7 # unidades + ds = 'prueba Anafe economico' + qty = "2.00" + precio = "100.00" + bonif = "0.00" + iva_id = 5 + imp_total = "242.00" + ok = wsbfev1.AgregarItem(ncm, sec, ds, qty, umed, precio, bonif, iva_id, imp_total) + + # Agrego otro item: + ncm = '7308.20.00' + sec = '' + umed = 7 # unidades + ds = 'prueba 2' + qty = "4.00" + precio = "50.00" + bonif = "10.00" + iva_id = 5 + imp_total = "229.90" + ok = wsbfev1.AgregarItem(ncm, sec, ds, qty, umed, precio, bonif, iva_id, imp_total) + + # comprobantes asociados (notas de crédito / débito) + if True: + tipo = 91 + pto_vta = 4001 + nro = 1 + cuit = "20267565393" + # obligatorio en Factura de Crédito Electrónica MiPyMEs (FCE): + fecha_cbte = fecha_cbte if "--fce" in sys.argv else None + wsbfev1.AgregarCmpAsoc(tipo, pto_vta, nro, cuit, fecha_cbte) + + # datos de Factura de Crédito Electrónica MiPyMEs (FCE): + if '--fce' in sys.argv: + wsbfev1.AgregarOpcional(2101, "2850590940090418135201") # CBU + wsbfev1.AgregarOpcional(2102, "pyafipws") # alias + if tipo_cbte in (203, 208, 213): + wsbfev1.AgregarOpcional(22, "S") # Anulación + + # id = "99000000000100" # n�mero propio de transacci�n + # obtengo el �ltimo ID y le adiciono 1 + # (advertencia: evitar overflow y almacenar!) + id = int(wsbfev1.GetLastID()) + 1 + + # Llamo al WebService de Autorizaci�n para obtener el CAE + cae = wsbfev1.Authorize(id) + + print("Comprobante", tipo_cbte, wsbfev1.CbteNro) + print("Resultado", wsbfev1.Resultado) + print("CAE", wsbfev1.CAE) + print("Vencimiento", wsbfev1.Vencimiento) + + if wsbfev1.Resultado and False: + print(wsbfev1.client.help("FEXGetCMP").encode("latin1")) + wsbfev1.GetCMP(tipo_cbte, punto_vta, cbte_nro) + print("CAE consulta", wsbfev1.CAE, wsbfev1.CAE == cae) + print("NRO consulta", wsbfev1.CbteNro, wsbfev1.CbteNro == cbte_nro) + print("TOTAL consulta", wsbfev1.ImpTotal, wsbfev1.ImpTotal == imp_total) + + except Exception as e: + print(wsbfev1.XmlRequest) + print(wsbfev1.XmlResponse) + print(wsbfev1.ErrCode) + print(wsbfev1.ErrMsg) + print(wsbfev1.Excepcion) + print(wsbfev1.Traceback) + raise + + if "--get" in sys.argv: + tipo_cbte = 1 + punto_vta = 5 + cbte_nro = wsbfev1.GetLastCMP(tipo_cbte, punto_vta) + + wsbfev1.GetCMP(tipo_cbte, punto_vta, cbte_nro) + + print("FechaCbte = ", wsbfev1.FechaCbte) + print("CbteNro = ", wsbfev1.CbteNro) + print("PuntoVenta = ", wsbfev1.PuntoVenta) + print("ImpTotal =", wsbfev1.ImpTotal) + print("CAE = ", wsbfev1.CAE) + print("Vencimiento = ", wsbfev1.Vencimiento) + + wsbfev1.AnalizarXml("XmlResponse") + p_assert_eq(wsbfev1.ObtenerTagXml('Cae'), str(wsbfev1.CAE)) + p_assert_eq(wsbfev1.ObtenerTagXml('Fecha_cbte_orig'), wsbfev1.FechaCbte) + p_assert_eq(wsbfev1.ObtenerTagXml('Imp_moneda_Id'), "PES") + p_assert_eq(wsbfev1.ObtenerTagXml('Imp_moneda_ctz'), "1") + p_assert_eq(wsbfev1.ObtenerTagXml('Items', 'Item', 1, 'Pro_ds'), "prueba 2") + + if "--params" in sys.argv: + import codecs + import locale + sys.stdout = codecs.getwriter('latin1')(sys.stdout) + + print("=== Tipos de Comprobante ===") + print('\n'.join(wsbfev1.GetParamTipoCbte())) + + print("=== Zonas ===") + print('\n'.join(wsbfev1.GetParamZonas())) + + print("=== Monedas ===") + print('\n'.join(wsbfev1.GetParamMon())) + + print("=== Tipos de Documentos ===") + print('\n'.join(wsbfev1.GetParamTipoDoc())) + + print("=== Tipos de IVA ===") + print('\n'.join(wsbfev1.GetParamTipoIVA())) + + print("=== Unidades de medida ===") + print('\n'.join(wsbfev1.GetParamUMed())) + + print("=== C�digos NCM ===") + print('\n'.join(wsbfev1.GetParamNCM())) + + if "--ctz" in sys.argv: + print(wsbfev1.GetParamCtz('DOL')) diff --git a/app/pyafipws/wscdc.py b/app/pyafipws/wscdc.py new file mode 100644 index 0000000000000000000000000000000000000000..c61268da4de08d2e7b7859091714498c03110ef5 --- /dev/null +++ b/app/pyafipws/wscdc.py @@ -0,0 +1,398 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Mdulo para utilizar el servicio web Constatacin de Comprobantes de AFIP" + +# Informacin adicional y documentacin: +# http://www.sistemasagiles.com.ar/trac/wiki/ConstatacionComprobantes + +__author__ = "Mariano Reingart (reingart@gmail.com)" +__copyright__ = "Copyright (C) 2013-2015 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.02e" + +import sys +import os +import time +from configparser import SafeConfigParser +from .utils import inicializar_y_capturar_excepciones, BaseWS, get_install_dir +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json + + +# Constantes (si se usa el script de linea de comandos) +WSDL = "https://wswhomo.afip.gov.ar/WSCDC/service.asmx?WSDL" +HOMO = False +CONFIG_FILE = "rece.ini" + +# No debera ser necesario modificar nada despues de esta linea + +# definicin del formato del archivo de intercambio (slo para linea de comandos): + +ENCABEZADO = [ + ('tipo_reg', 1, A, "0: encabezado"), + ('cbte_modo', 4, A, "Modalidad de autorizacin (CAI, CAE, CAEA)"), + ('cuit_emisor', 11, A, "CUIT del emisor del comprobante"), + ('pto_vta', 4, N, "Punto de Venta del comprobante"), + ('cbte_tipo', 3, N, "Tipo de comprobante"), + ('cbte_nro', 8, N, "Nmero de comprobante"), + ('cbte_fch', 8, A, "Fecha en formato AAAAMMDD"), + ('imp_total', 15, I, "Importe total Double (13 + 2)"), + ('cod_autorizacion', 14, A, "Nmero de CAI, CAE, CAEA"), + ('doc_tipo_receptor', 2, A, "Tipo de documento del receptor"), + ('doc_nro_receptor', 20, A, "N de documento del receptor"), + # campos devueltos por AFIP (respuesta) + ('resultado', 1, A, "Resultado (A: Aprobado, O: Observado, R: rechazado)"), + ('fch_proceso', 14, A, "Fecha y hora de procesamiento"), +] + +OBSERVACION = [ + ('tipo_reg', 1, A, "O: observaciones devueltas por AFIP"), + ('code', 5, N, "Cdigo de Observacin / Error / Evento"), + ('msg', 255, A, "Mensaje"), +] + +EVENTO = ERROR = OBSERVACION # misma estructura, cambia tipo de registro + + +class WSCDC(BaseWS): + "Interfaz para el WebService de Constatacin de Comprobantes" + _public_methods_ = ['Conectar', 'SetTicketAcceso', 'DebugLog', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'ConstatarComprobante', 'Dummy', + 'ConsultarModalidadComprobantes', + 'ConsultarTipoComprobantes', + 'ConsultarTipoDocumentos', 'ConsultarTipoOpcionales', + 'SetParametros', 'SetParametro', 'GetParametro', + ] + _public_attrs_ = ['Token', 'Sign', 'Cuit', 'ExpirationTime', 'Version', + 'XmlRequest', 'XmlResponse', 'Observaciones', 'Errores', + 'InstallDir', 'Traceback', 'Excepcion', 'ErrMsg', 'Obs', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'Resultado', 'FchProceso', 'Observaciones', 'Obs', + 'FechaCbte', 'CbteNro', 'PuntoVenta', 'ImpTotal', + 'EmisionTipo', 'CAE', 'CAEA', 'CAI', + 'DocTipo', 'DocNro', + 'SoapFault', 'LanzarExcepciones', + ] + _readonly_attrs_ = _public_attrs_[3:-1] + _reg_progid_ = "WSCDC" + _reg_clsid_ = "{D1B97BDD-A78C-4D51-8999-1D9A5034EC10}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologacin' or '') + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Resultado = self.EmisionTipo = "" + self.CAI = self.CAE = self.CAEA = self.Vencimiento = '' + self.CbteNro = self.PuntoVenta = self.ImpTotal = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + if 'Errors' in ret: + errores = ret['Errors'] + for error in errores: + self.Errores.append("%s: %s" % ( + error['Err']['Code'], + error['Err']['Msg'], + )) + self.errores = [ + {'code': err['Err']['Code'], + 'msg': err['Err']['Msg'].replace("\n", "") + .replace("\r", "")} + for err in errores] + self.ErrCode = ' '.join([str(error['Err']['Code']) for error in errores]) + self.ErrMsg = '\n'.join(self.Errores) + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Mtodo Dummy para verificacin de funcionamiento de infraestructura" + result = self.client.ComprobanteDummy()['ComprobanteDummyResult'] + self.AppServerStatus = result['AppServer'] + self.DbServerStatus = result['DbServer'] + self.AuthServerStatus = result['AuthServer'] + self.__analizar_errores(result) + return True + + @inicializar_y_capturar_excepciones + def ConstatarComprobante(self, cbte_modo, cuit_emisor, pto_vta, cbte_tipo, + cbte_nro, cbte_fch, imp_total, cod_autorizacion, + doc_tipo_receptor=None, doc_nro_receptor=None, + **kwargs): + "Mtodo de Constatacin de Comprobantes" + response = self.client.ComprobanteConstatar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + CmpReq={ + 'CbteModo': cbte_modo, + 'CuitEmisor': cuit_emisor, + 'PtoVta': pto_vta, + 'CbteTipo': cbte_tipo, + 'CbteNro': cbte_nro, + 'CbteFch': cbte_fch, + 'ImpTotal': imp_total, + 'CodAutorizacion': cod_autorizacion, + 'DocTipoReceptor': doc_tipo_receptor, + 'DocNroReceptor': doc_nro_receptor, + } + ) + result = response['ComprobanteConstatarResult'] + self.__analizar_errores(result) + if 'CmpResp' in result: + resp = result['CmpResp'] + self.Resultado = result['Resultado'] + self.FchProceso = result.get('FchProceso', "") + self.observaciones = [] + for obs in result.get('Observaciones', []): + self.Observaciones.append("%(Code)s: %(Msg)s" % (obs['Obs'])) + self.observaciones.append({ + 'code': obs['Obs']['Code'], + 'msg': obs['Obs']['Msg'].replace("\n", "") + .replace("\r", "")}) + self.Obs = '\n'.join(self.Observaciones) + self.FechaCbte = resp.get('CbteFch', "") # .strftime("%Y/%m/%d") + self.CbteNro = resp.get('CbteNro', 0) # 1L + self.PuntoVenta = resp.get('PtoVta', 0) # 4000 + self.ImpTotal = str(resp['ImpTotal']) + self.EmisionTipo = resp['CbteModo'] + self.DocTipo = resp.get('DocTipoReceptor', '') + self.DocNro = resp.get('DocNroReceptor', '') + cod_aut = str(resp.get('CodAutorizacion', "")) # 60423794871430L + if self.EmisionTipo == 'CAE': + self.CAE = cod_aut + elif self.EmisionTipo == 'CAEA': + self.CAEA = cod_aut + elif self.EmisionTipo == 'CAI': + self.CAI = cod_aut + return True + + @inicializar_y_capturar_excepciones + def ConsultarModalidadComprobantes(self, sep="|"): + "Recuperador de modalidades de autorizacin de comprobantes" + response = self.client.ComprobantesModalidadConsultar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + result = response['ComprobantesModalidadConsultarResult'] + self.__analizar_errores(result) + return [("\t%(Cod)s\t%(Desc)s\t" % p['FacModTipo']).replace("\t", sep) + for p in result['ResultGet']] + + @inicializar_y_capturar_excepciones + def ConsultarTipoComprobantes(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Tipos de comprobante" + response = self.client.ComprobantesTipoConsultar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + result = response['ComprobantesTipoConsultarResult'] + self.__analizar_errores(result) + return [("\t%(Id)s\t%(Desc)s\t" % p['CbteTipo']).replace("\t", sep) + for p in result['ResultGet']] + + @inicializar_y_capturar_excepciones + def ConsultarTipoDocumentos(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Tipos de Documentos" + response = self.client.DocumentosTipoConsultar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + result = response['DocumentosTipoConsultarResult'] + self.__analizar_errores(result) + return [("\t%(Id)s\t%(Desc)s\t" % p['DocTipo']).replace("\t", sep) + for p in result['ResultGet']] + + @inicializar_y_capturar_excepciones + def ConsultarTipoOpcionales(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Tipos de datos Opcionales" + response = self.client.OpcionalesTipoConsultar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + result = response['OpcionalesTipoConsultarResult'] + res = result['ResultGet'] if 'ResultGet' in result else [] + self.__analizar_errores(result) + return [("\t%(Id)s\t%(Desc)s\t" % p['OpcionalTipo']).replace("\t", sep) + for p in res] + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = WSCDC.InstallDir = get_install_dir() + + +def escribir_archivo(dic, nombre_archivo, agrega=True): + archivo = open(nombre_archivo, agrega and "a" or "w") + formatos = [('Encabezado', ENCABEZADO, [dic], 0), + ('Observacion', OBSERVACION, dic.get('observaciones', []), 'O'), + ('Eventos', ERROR, dic.get('eventos', []), 'V'), + ('Error', ERROR, dic.get('errores', []), 'E'), + ] + if '--json' in sys.argv: + json.dump(dic, archivo, sort_keys=True, indent=4) + elif '--dbf' in sys.argv: + guardar_dbf(formatos, agrega, conf_dbf) + else: + for nombre, formato, registros, tipo_reg in formatos: + for it in registros: + it['tipo_reg'] = tipo_reg + archivo.write(escribir(it, formato)) + archivo.close() + + +def leer_archivo(nombre_archivo): + archivo = open(nombre_archivo, "r") + if '--json' in sys.argv: + dic = json.load(archivo) + elif '--dbf' in sys.argv: + dic = {} + formatos = [('Encabezado', ENCABEZADO, dic), + ] + leer_dbf(formatos, conf_dbf) + else: + dic = {} + for linea in archivo: + if str(linea[0]) == '0': + d = leer(linea, ENCABEZADO) + dic.update(d) + else: + print("Tipo de registro incorrecto:", linea[0]) + archivo.close() + + if not 'cod_autorizacion' in dic: + raise RuntimeError("Archivo de entrada invalido, revise campos y lineas en blanco") + + return dic + + +def main(): + "Funcion principal para utilizar la interfaz por linea de comando" + + if '--formato' in sys.argv: + print("Formato:") + for msg, formato in [('Encabezado', ENCABEZADO), + ('Observacion', OBSERVACION), + ('Evento', EVENTO), ('Error', ERROR), + ]: + comienzo = 1 + print("=== %s ===" % msg) + print("|| %-20s || %8s || %9s || %-12s || %-20s ||" % ( + "Campo", "Posicin", "Longitud", "Tipo", "Descripcin")) + for fmt in formato: + clave, longitud, tipo, desc = fmt + print("|| %-20s || %8d || %9d || %-12s || %-20s ||" % ( + clave, comienzo, longitud, tipo, desc.encode("latin1"))) + comienzo += longitud + sys.exit(0) + + # leer configuracion + global CONFIG_FILE + if len(sys.argv) > 1 and sys.argv[1][0] not in "-/": + CONFIG_FILE = sys.argv.pop(1) + config = SafeConfigParser() + config.read(CONFIG_FILE) + crt = config.get('WSAA', 'CERT') + key = config.get('WSAA', 'PRIVATEKEY') + cuit = config.get('WSCDC', 'CUIT') + url_wsaa = config.get('WSAA', 'URL') if config.has_option('WSAA', 'URL') else "" + url_wscdc = config.get('WSCDC', 'URL') if config.has_option('WSCDC', 'URL') else "" + + # leo configuracin de archivos de intercambio + ENTRADA = config.get('WSCDC', 'ENTRADA') + SALIDA = config.get('WSCDC', 'SALIDA') + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + else: + conf_dbf = {} + + # instanciar la interfaz con el webservice + wscdc = WSCDC() + ok = wscdc.Conectar("", url_wscdc) + + if "--dummy" in sys.argv: + # print wscdc.client.help("ComprobanteDummy") + wscdc.Dummy() + print("AppServerStatus", wscdc.AppServerStatus) + print("DbServerStatus", wscdc.DbServerStatus) + print("AuthServerStatus", wscdc.AuthServerStatus) + sys.exit(0) + + # Gestionar credenciales de acceso con AFIP: + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wscdc", crt, key, url_wsaa) + if not ta: + sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + wscdc.SetTicketAcceso(ta) + wscdc.Cuit = cuit + + if "--constatar" in sys.argv: + if len(sys.argv) < 8: + if "--prueba" in sys.argv: + dic = dict( + cbte_modo="CAE", + cuit_emisor="20267565393", + pto_vta=3, + cbte_tipo=6, + cbte_nro=1, + cbte_fch="20131231", + imp_total="1.21", + cod_autorizacion="63533727749637", + doc_tipo_receptor=99, + doc_nro_receptor=0, + ) + # escribir archivo de intercambio con datos de prueba: + escribir_archivo(dic, ENTRADA) + else: + # leer archivo de intercambio: + dic = leer_archivo(ENTRADA) + # constatar el comprobante + wscdc.ConstatarComprobante(**dic) + # actualizar el diccionario con los datos de devueltos por AFIP + dic.update({'resultado': wscdc.Resultado, + 'fch_proceso': wscdc.FchProceso, + }) + dic['observaciones'] = wscdc.observaciones + dic['errores'] = wscdc.errores + escribir_archivo(dic, SALIDA) + else: + # usar los datos pasados por linea de comandos: + wscdc.ConstatarComprobante(*sys.argv[sys.argv.index("--constatar") + 1:]) + + print("Resultado:", wscdc.Resultado) + print("Mensaje de Error:", wscdc.ErrMsg) + print("Observaciones:", wscdc.Obs) + + if "--params" in sys.argv: + + print("=== Modalidad Comprobantes ===") + print('\n'.join(wscdc.ConsultarModalidadComprobantes("||"))) + print("=== Tipo Comprobantes ===") + print('\n'.join(wscdc.ConsultarTipoComprobantes("||"))) + print("=== Tipo Documentos ===") + print('\n'.join(wscdc.ConsultarTipoDocumentos("||"))) + print("=== Tipo Opcionales ===") + print('\n'.join(wscdc.ConsultarTipoOpcionales("||"))) + print("Mensaje de Error:", wscdc.ErrMsg) + + +if __name__ == "__main__": + + if '--register' in sys.argv or '--unregister' in sys.argv: + import pythoncom + import win32com.server.register + win32com.server.register.UseCommandLine(WSCDC) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([WSCDC._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/wscoc.py b/app/pyafipws/wscoc.py new file mode 100644 index 0000000000000000000000000000000000000000..10d747ff6cd987055b0d9c492f3257ba88c352b4 --- /dev/null +++ b/app/pyafipws/wscoc.py @@ -0,0 +1,1032 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"Mdulo para Consulta de Operaciones Cambiarias" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2011 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.09a" + +import os +import socket +import sys +import traceback +import pysimplesoap.client +from pysimplesoap.client import SoapClient, SoapFault, parse_proxy, \ + set_http_wrapper +from pysimplesoap.simplexml import SimpleXMLElement +from io import StringIO + +HOMO = True + +if HOMO: + WSDL = "https://fwshomo.afip.gov.ar/wscoc/COCService" + LOCATION = "https://fwshomo.afip.gob.ar:443/wscoc2/COCService" +else: + WSDL = "https://serviciosjava.afip.gob.ar/wscoc2/COCService" + LOCATION = "https://serviciosjava.afip.gob.ar:443/wscoc2/COCService" + + +def inicializar_y_capturar_excepciones(func): + "Decorador para inicializar y capturar errores" + + def capturar_errores_wrapper(self, *args, **kwargs): + try: + # inicializo (limpio variables) + self.Errores = [] + self.ErroresFormato = [] + self.Traceback = self.Excepcion = "" + self.ErrCode = "" + self.ErrMsg = "" + self.CodigoSolicitud = self.FechaSolicitud = None + self.COC = self.FechaEmisionCOC = self.CodigoDestino = None + self.EstadoSolicitud = self.FechaEstado = None + self.CUITComprador = self.DenominacionComprador = None + self.CodigoMoneda = self.CotizacionMoneda = self.MontoPesos = None + self.CUITRepresentante = self.DenominacionRepresentante = None + self.DJAI = self.CodigoExcepcionDJAI = self.EstadoDJAI = None + self.DJAS = self.CodigoExcepcionDJAS = self.EstadoDJAS = None + self.MontoFOB = self.CodigoMoneda = None + # iniciaalizo estructuras internas persistentes + self.__detalles_solicitudes = [] + self.__detalles_cuit = [] + + # llamo a la funcin (sin reintentos) + return func(self, *args, **kwargs) + + except SoapFault as e: + # guardo destalle de la excepcin SOAP + self.ErrCode = str(e.faultcode) + self.ErrMsg = str(e.faultstring) + self.Excepcion = "%s: %s" % (e.faultcode, e.faultstring, ) + if self.LanzarExcepciones: + raise + except Exception as e: + ex = traceback.format_exception(sys.exc_info()[0], sys.exc_info()[1], + sys.exc_info()[2]) + self.Traceback = ''.join(ex) + self.Excepcion = "%s" % (e) + if self.LanzarExcepciones: + raise + finally: + # guardo datos de depuracin + if self.client: + self.XmlRequest = self.client.xml_request + self.XmlResponse = self.client.xml_response + return capturar_errores_wrapper + + +class WSCOC: + "Interfaz para el WebService de Consulta de Operaciones Cambiarias" + _public_methods_ = ['GenerarSolicitudCompraDivisa', + 'GenerarSolicitudCompraDivisaTurExt', + 'InformarSolicitudCompraDivisa', + 'ConsultarCUIT', + 'ConsultarCOC', + 'AnularCOC', + 'ConsultarSolicitudCompraDivisa', + 'ConsultarSolicitudesCompraDivisas', + 'ConsultarDestinosCompra', + 'ConsultarTiposReferencia', + 'ConsultarMonedas', + 'ConsultarTiposDocumento', + 'ConsultarTiposEstadoSolicitud', + 'ConsultarMotivosExcepcionDJAI', + 'ConsultarDestinosCompraDJAI', + 'ConsultarMotivosExcepcionDJAS', + 'ConsultarDestinosCompraDJAS', + 'ConsultarDestinosCompraTipoReferencia', + 'LeerSolicitudConsultada', 'LeerCUITConsultado', + 'ConsultarDJAI', 'ConsultarDJAS', 'ConsultarReferencia', + 'LeerError', 'LeerErrorFormato', 'LeerInconsistencia', + 'LoadTestXML', + 'AnalizarXml', 'ObtenerTagXml', + 'Dummy', 'Conectar', 'DebugLog'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', 'InstallDir', + 'Resultado', 'Inconsistencias', 'ErrCode', 'ErrMsg', + 'CodigoSolicitud', 'FechaSolicitud', 'EstadoSolicitud', 'FechaEstado', + 'COC', 'FechaEmisionCOC', 'CodigoDestino', + 'CUITComprador', 'DenominacionComprador', + 'CodigoMoneda', 'CotizacionMoneda', 'MontoPesos', + 'CUITRepresentante', 'DenominacionRepresentante', + 'TipoDoc', 'NumeroDoc', 'CUITConsultada', 'DenominacionConsultada', + 'DJAI', 'CodigoExcepcionDJAI', 'DJAS', 'CodigoExcepcionDJAS', + 'MontoFOB', 'EstadoDJAI', 'EstadoDJAS', 'Estado', 'Tipo', 'Codigo', + 'ErroresFormato', 'Errores', 'Traceback', 'Excepcion', 'LanzarExcepciones', + ] + + _reg_progid_ = "WSCOC" + _reg_clsid_ = "{B30406CE-326A-46D9-B807-B7916E3F1B96}" + + Version = "%s %s %s" % (__version__, HOMO and 'Homologacin' or '', pysimplesoap.client.__file__) + LanzarExcepciones = False + + def __init__(self): + self.Token = self.Sign = self.Cuit = None + self.AppServerStatus = None + self.DbServerStatus = None + self.AuthServerStatus = None + self.XmlRequest = '' + self.XmlResponse = '' + self.Resultado = self.Motivo = self.Reproceso = '' + self.__analizar_solicitud({}) + self.__analizar_inconsistencias({}) + self.__analizar_errores({}) + self.__detalles_solicitudes = None + self.__detalles_cuit = None + self.client = None + self.ErrCode = self.ErrMsg = self.Traceback = self.Excepcion = "" + self.EmisionTipo = '' + self.Reprocesar = self.Reproceso = '' # no implementado + self.Log = None + self.InstallDir = INSTALL_DIR + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.Errores = [] + self.ErroresFormato = [] + if 'arrayErrores' in ret: + errores = ret['arrayErrores'] + for error in errores: + self.Errores.append("%s: %s" % ( + error['codigoDescripcion']['codigo'], + error['codigoDescripcion']['descripcion'], + )) + if 'arrayErroresFormato' in ret: + errores = ret['arrayErroresFormato'] + for error in errores: + self.ErroresFormato.append("%s: %s" % ( + error['codigoDescripcionString']['codigo'], + error['codigoDescripcionString']['descripcion'], + )) + + def __analizar_solicitud(self, det): + "Analiza y extrae los datos de una solicitud" + self.CodigoSolicitud = det.get("codigoSolicitud") + self.FechaSolicitud = det.get("fechaSolicitud") + self.COC = str(det.get("coc")) + self.FechaEmisionCOC = det.get("fechaEmisionCOC") + self.EstadoSolicitud = det.get("estadoSolicitud") + self.FechaEstado = det.get("fechaEstado") + self.CUITComprador = str(det.get("detalleCUITComprador", + {}).get("cuit", "")) + self.DenominacionComprador = det.get("detalleCUITComprador", + {}).get("denominacion") + self.CodigoMoneda = det.get("codigoMoneda") + self.CotizacionMoneda = det.get("cotizacionMoneda") + self.MontoPesos = det.get("montoPesos") + self.CUITRepresentante = str(det.get("DetalleCUITRepresentante", + {}).get("cuit", "")) + self.DenominacionRepresentante = det.get("DetalleCUITRepresentante", + {}).get("denominacion") + self.CodigoDestino = det.get("codigoDestino") + self.DJAI = det.get("djai") + self.CodigoExcepcionDJAI = det.get("codigoExcepcionDJAI") + self.DJAS = det.get("djas") + self.CodigoExcepcionDJAS = det.get("codigoExcepcionDJAS") + ref = det.get("referencia") + if ref: + self.Tipo = ref['tipo'] + self.Codigo = ref['codigo'] + + def __analizar_inconsistencias(self, ret): + "Comprueba y extrae (formatea) las inconsistencias" + self.Inconsistencias = [] + if 'arrayInconsistencias' in ret: + inconsistencias = ret['arrayInconsistencias'] + for inconsistencia in inconsistencias: + self.Inconsistencias.append("%s: %s" % ( + inconsistencia['codigoDescripcion']['codigo'], + inconsistencia['codigoDescripcion']['descripcion'], + )) + + def __log(self, msg): + if not isinstance(msg, str): + msg = str(msg, 'utf8', 'ignore') + if not self.Log: + self.Log = StringIO() + self.Log.write(msg) + self.Log.write('\n\r') + + def DebugLog(self): + "Devolver y limpiar la bitcora de depuracin" + if self.Log: + msg = self.Log.getvalue() + # limpiar log + self.Log.close() + self.Log = None + else: + msg = '' + return msg + + @inicializar_y_capturar_excepciones + def Conectar(self, cache=None, wsdl=None, proxy="", wrapper=None, cacert=None, timeout=30): + # cliente soap del web service + if timeout: + self.__log("Estableciendo timeout=%s" % (timeout, )) + socket.setdefaulttimeout(timeout) + if wrapper: + Http = set_http_wrapper(wrapper) + self.Version = WSCOC.Version + " " + Http._wrapper_version + proxy_dict = parse_proxy(proxy) + location = LOCATION + if HOMO or not wsdl: + wsdl = WSDL + elif not wsdl.endswith("?wsdl") and wsdl.startswith("http"): + location = wsdl + wsdl += "?wsdl" + elif wsdl.endswith("?wsdl"): + location = wsdl[:-5] + if not cache or HOMO: + # use 'cache' from installation base directory + cache = os.path.join(self.InstallDir, 'cache') + self.__log("Conectando a wsdl=%s cache=%s proxy=%s" % (wsdl, cache, proxy_dict)) + self.client = SoapClient( + wsdl=wsdl, + cache=cache, + proxy=proxy_dict, + ns="coc", + cacert=cacert, + soap_ns="soapenv", + soap_server="jbossas6", + trace="--trace" in sys.argv) + # corrijo ubicacin del servidor (http en el WSDL) + self.client.services['COCService']['ports']['COCServiceHttpSoap11Endpoint']['location'] = location + self.__log("Corrigiendo location=%s" % (location, )) + return True + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + result = self.client.dummy() + ret = result['dummyReturn'] + self.AppServerStatus = ret['appserver'] + self.DbServerStatus = ret['dbserver'] + self.AuthServerStatus = ret['authserver'] + return True + + @inicializar_y_capturar_excepciones + def GenerarSolicitudCompraDivisa(self, cuit_comprador, codigo_moneda, + cotizacion_moneda, monto_pesos, + cuit_representante, codigo_destino, + djai=None, codigo_excepcion_djai=None, + djas=None, codigo_excepcion_djas=None, + tipo=None, codigo=None, + ): + "Generar una Solicitud de operacin cambiaria" + if tipo and codigo: + referencia = {'tipo': tipo, 'codigo': codigo} + else: + referencia = None + res = self.client.generarSolicitudCompraDivisa( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + cuitComprador=cuit_comprador, + codigoMoneda=codigo_moneda, + cotizacionMoneda=cotizacion_moneda, + montoPesos=monto_pesos, + cuitRepresentante=cuit_representante, + codigoDestino=codigo_destino, + djai=djai, codigoExcepcionDJAI=codigo_excepcion_djai, + djas=djas, codigoExcepcionDJAS=codigo_excepcion_djas, + referencia=referencia, + ) + + self.Resultado = "" + ret = res.get('generarSolicitudCompraDivisaReturn', {}) + self.Resultado = ret.get('resultado') + det = ret.get('detalleSolicitud', {}) + self.__analizar_solicitud(det) + self.__analizar_inconsistencias(det) + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def GenerarSolicitudCompraDivisaTurExt(self, tipo_doc, numero_doc, apellido_nombre, + codigo_moneda, cotizacion_moneda, monto_pesos, + cuit_representante, codigo_destino, + ): + "Generar una Solicitud de operacin cambiaria" + res = self.client.generarSolicitudCompraDivisaTurExt( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + detalleTurExtComprador={ + 'tipoNumeroDoc': {'tipoDoc': tipo_doc, 'numeroDoc': numero_doc}, + 'apellidoNombre': apellido_nombre}, + codigoMoneda=codigo_moneda, + cotizacionMoneda=cotizacion_moneda, + montoPesos=monto_pesos, + cuitRepresentante=cuit_representante, + codigoDestino=codigo_destino, + ) + + self.Resultado = "" + ret = res.get('generarSolicitudCompraDivisaTurExtReturn', {}) + self.Resultado = ret.get('resultado') + det = ret.get('detalleSolicitud', {}) + self.__analizar_solicitud(det) + self.__analizar_inconsistencias(det) + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def InformarSolicitudCompraDivisa(self, codigo_solicitud, nuevo_estado): + "Informar la aceptacin o desistir una solicitud generada con anterioridad" + + res = self.client.informarSolicitudCompraDivisa( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codigoSolicitud=codigo_solicitud, + nuevoEstado=nuevo_estado, + ) + + self.Resultado = "" + ret = res.get('informarSolicitudCompraDivisaReturn', {}) + self.Resultado = ret.get('resultado') + self.__analizar_solicitud(ret) + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def ConsultarCUIT(self, numero_doc, tipo_doc=80, sep="|"): + "Consultar la CUIT, CDI CUIL, segn corresponda, para un determinado tipo y nmero de documento." + + res = self.client.consultarCUIT( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + tipoNumeroDoc={'tipoDoc': tipo_doc, 'numeroDoc': numero_doc} + ) + + self.__detalles_cuit = [] + + if 'consultarCUITReturn' in res: + ret = res['consultarCUITReturn'] + self.__analizar_errores(ret) + if 'tipoNumeroDoc' in ret: + self.TipoDoc = ret['tipoNumeroDoc']['tipoDoc'] + self.NumeroDoc = ret['tipoNumeroDoc']['numeroDoc'] + for detalle in ret.get('arrayDetallesCUIT', []): + # agrego el detalle para consultarlo luego (LeerCUITConsultado) + det = detalle['detalleCUIT'] + self.__detalles_cuit.append(det) + # devuelvo una lista de cuit/denominacin + return [("%(cuit)s\t%(denominacion)s" % + d['detalleCUIT']).replace("\t", sep) + for d in ret.get('arrayDetallesCUIT', [])] + else: + return [] + else: + self.TipoDoc = None + self.NumeroDoc = None + return [""] + + def LeerCUITConsultado(self): + "Recorro los CUIT devueltos por ConsultarCUIT" + + if self.__detalles_cuit: + # extraigo el primer item + det = self.__detalles_cuit.pop(0) + self.CUITConsultada = str(det['cuit']) + self.DenominacionConsultada = str(det['denominacion']) + return True + else: + return False + + @inicializar_y_capturar_excepciones + def ConsultarCOC(self, coc): + "Obtener los datos de un COC existente" + + res = self.client.consultarCOC( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + coc=coc, + ) + ret = res.get('consultarCOCReturn', {}) + det = ret.get('detalleSolicitud', {}) + self.__analizar_solicitud(det) + self.__analizar_inconsistencias(det) + self.__analizar_errores(ret) + return True + + @inicializar_y_capturar_excepciones + def AnularCOC(self, coc, cuit_comprador): + "Anular COC existente (estado CO 24hs)" + res = self.client.anularCOC( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + coc=coc, + cuitComprador=cuit_comprador, + ) + + ret = res.get('anularCOCReturn', {}) + self.__analizar_solicitud(ret) + self.__analizar_inconsistencias(ret) + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def ConsultarSolicitudCompraDivisa(self, codigo_solicitud): + "Consultar una Solicitud de Operacin Cambiaria" + res = self.client.consultarSolicitudCompraDivisa( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codigoSolicitud=codigo_solicitud, + ) + + ret = res.get('consultarSolicitudCompraDivisaReturn', {}) + det = ret.get('detalleSolicitud', {}) + self.__analizar_solicitud(det) + self.__analizar_errores(ret) + + return True + + @inicializar_y_capturar_excepciones + def ConsultarSolicitudesCompraDivisas(self, cuit_comprador, + estado_solicitud, + fecha_emision_desde, + fecha_emision_hasta, + ): + "Consultar Solicitudes de operaciones cambiarias" + res = self.client.consultarSolicitudesCompraDivisas( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + cuitComprador=cuit_comprador, + estadoSolicitud=estado_solicitud, + fechaEmisionDesde=fecha_emision_desde, + fechaEmisionHasta=fecha_emision_hasta, + ) + + self.__analizar_errores(res) + + ret = res.get('consultarSolicitudesCompraDivisasReturn', {}) + solicitudes = [] # cdigos a devolver + self.__detalles_solicitudes = [] # diccionario para recorrerlo luego + for array in ret.get('arrayDetallesSolicitudes', []): + det = array['detalleSolicitudes'] + # guardo el detalle para procesarlo luego (LeerSolicitudConsultada) + self.__detalles_solicitudes.append(det) + # devuelvo solo el cdigo de solicitud + solicitudes.append(det.get("codigoSolicitud")) + return solicitudes + + def LeerSolicitudConsultada(self): + "Proceso de a una solicitud los detalles devueltos por ConsultarSolicitudesCompraDivisas" + if self.__detalles_solicitudes: + # extraigo el primer item + det = self.__detalles_solicitudes.pop(0) + self.__analizar_solicitud(det) + self.__analizar_errores(det) + self.__analizar_inconsistencias(det) + return True + else: + return False + + @inicializar_y_capturar_excepciones + def ConsultarDJAI(self, djai, cuit): + "Consultar Declaracin Jurada Anticipada de Importacin" + res = self.client.consultarDJAI( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + djai=djai, cuit=cuit, + ) + + ret = res.get('consultarDJAIReturn', {}) + self.__analizar_errores(ret) + self.DJAI = ret.get('djai') + self.MontoFOB = ret.get('montoFOB') + self.CodigoMoneda = ret.get('codigoMoneda') + self.EstadoDJAI = ret.get('estadoDJAI') + return True + + @inicializar_y_capturar_excepciones + def ConsultarDJAS(self, djas, cuit): + "Consultar Declaracin Jurada Anticipada de Servicios" + res = self.client.consultarDJAS( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + djas=djas, cuit=cuit, + ) + + ret = res.get('consultarDJASReturn', {}) + self.__analizar_errores(ret) + self.DJAS = ret.get('djas') + self.MontoFOB = ret.get('montoFOB') + self.CodigoMoneda = ret.get('codigoMoneda') + self.EstadoDJAS = ret.get('estadoDJAS') + return True + + @inicializar_y_capturar_excepciones + def ConsultarReferencia(self, tipo, codigo): + "Consultar una determinada referencia segn su tipo (1: DJAI, 2: DJAS, 3: DJAT)" + res = self.client.consultarReferencia( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + referencia={'tipo': tipo, 'codigo': codigo}, + ) + + ret = res.get('consultarReferenciaReturn', {}) + self.__analizar_errores(ret) + #self.Codigo = ret.get('codigo') + self.MontoFOB = ret.get('monto') + self.CodigoMoneda = ret.get('codigoMoneda') + self.Estado = ret.get('estado') + return True + + @inicializar_y_capturar_excepciones + def ConsultarMonedas(self, sep="|"): + "Este mtodo retorna el universo de Monedas disponibles en el presente WS, indicando cdigo y descripcin de cada una" + res = self.client.consultarMonedas( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarMonedasReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayMonedas']] + + @inicializar_y_capturar_excepciones + def ConsultarDestinosCompra(self, sep="|"): + "Consultar Tipos de Destinos de compra de divisas" + res = self.client.consultarDestinosCompra( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + # 22.02 m + # + # + # TipoDestinoSimpleType + # + # + # short + # string + # + # + # + # + ret = res['consultarDestinosCompraReturn'] + dest = [] + for array in ret['arrayDestinos']: + destino = array['destinos'] + codigos = [("%s\t%s\t%s" + % (destino['tipoDestino'], + p['codigoDescripcion']['codigo'], + p['codigoDescripcion']['descripcion'], + )).replace("\t", sep) + for p in destino['arrayCodigosDescripciones']] + dest.extend(codigos) + return dest + + @inicializar_y_capturar_excepciones + def ConsultarTiposDocumento(self, sep="|"): + "Consultar Tipos de Documentos" + res = self.client.consultarTiposDocumento( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposDocumentoReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayTiposDocumento']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposEstadoSolicitud(self, sep="|"): + "Este mtodo devuelve los diferentes tipos de estado que puede tener una solicitud." + res = self.client.consultarTiposEstadoSolicitud( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposEstadoSolicitudReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcionString']).replace("\t", sep) + for p in ret['arrayTiposEstadoSolicitud']] + + @inicializar_y_capturar_excepciones + def ConsultarMotivosExcepcionDJAI(self, sep='|'): + "Este mtodo retorna el universo de motivos de excepciones a la Declaracin Jurada Anticipada de Importacin" + res = self.client.consultarMotivosExcepcionDJAI( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarMotivosExcepcionDJAIReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayMotivosExcepcion']] + + @inicializar_y_capturar_excepciones + def ConsultarDestinosCompraDJAI(self, sep='|'): + "Este mtodo retorna el subconjunto de los destinos de compra de divisas alcanzados por las normativas de la Declaracin Jurada Anticipada de Importacin." + res = self.client.consultarDestinosCompraDJAI( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarDestinosCompraDJAIReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayCodigosDescripciones']] + + @inicializar_y_capturar_excepciones + def ConsultarMotivosExcepcionDJAS(self, sep='|'): + "Este mtodo retorna el universo de motivos de excepciones a la Declaracin Jurada Anticipada de Servicios" + res = self.client.consultarMotivosExcepcionDJAS( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarMotivosExcepcionDJASReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayMotivosExcepcion']] + + @inicializar_y_capturar_excepciones + def ConsultarDestinosCompraDJAS(self, sep='|'): + "Este mtodo retorna el subconjunto de los destinos de compra de divisas alcanzados por las normativas de la Declaracin Jurada Anticipada de Servicios" + res = self.client.consultarDestinosCompraDJAS( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarDestinosCompraDJASReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayCodigosDescripciones']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposReferencia(self, sep='|'): + "Este mtodo retorna el conjunto de los tipos de referencia que pueden ser utilizados en la generacin de una solicitud de compra de divisas segn corresponda." + res = self.client.consultarTiposReferencia( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposReferenciaReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayCodigosDescripciones']] + + @inicializar_y_capturar_excepciones + def ConsultarDestinosCompraTipoReferencia(self, tipo, sep='|'): + "Este mtodo retorna el subconjunto de los destinos de compra de divisas alcanzados por algunas de las normativas vigentes segn el tipo de referencia requerido" + res = self.client.consultarDestinosCompraTipoReferencia( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + tipo=tipo, + ) + ret = res['consultarDestinosCompraTipoReferenciaReturn'] + return [("%(codigo)s\t%(descripcion)s" + % p['codigoDescripcion']).replace("\t", sep) + for p in ret['arrayCodigosDescripciones']] + + def LeerError(self): + "Recorro los errores devueltos y devuelvo el primero si existe" + + if self.Errores: + # extraigo el primer item + er = self.Errores.pop(0) + return er + else: + return "" + + def LeerErrorFormato(self): + "Recorro los errores de formatos devueltos y devuelvo el primero si existe" + + if self.ErroresFormato: + # extraigo el primer item + er = self.ErroresFormato.pop(0) + return er + else: + return "" + + def LeerInconsistencia(self): + "Recorro las inconsistencias devueltas y devuelvo la primera si existe" + + if self.Inconsistencias: + # extraigo el primer item + er = self.Inconsistencias.pop(0) + return er + else: + return "" + + def LoadTestXML(self, xml_file): + class DummyHTTP: + def __init__(self, xml_response): + self.xml_response = xml_response + + def request(self, location, method, body, headers): + return {}, self.xml_response + self.client.http = DummyHTTP(open(xml_file).read()) + + def AnalizarXml(self, xml=""): + "Analiza un mensaje XML (por defecto la respuesta)" + try: + if not xml or xml == 'XmlResponse': + xml = self.XmlResponse + elif xml == 'XmlRequest': + xml = self.XmlRequest + self.xml = SimpleXMLElement(xml) + return True + except Exception as e: + self.Excepcion = "%s" % (e) + return False + + def ObtenerTagXml(self, *tags): + "Busca en el Xml analizado y devuelve el tag solicitado" + # convierto el xml a un objeto + try: + if self.xml: + xml = self.xml + # por cada tag, lo busco segun su nombre o posicin + for tag in tags: + xml = xml(tag) # atajo a getitem y getattr + # vuelvo a convertir a string el objeto xml encontrado + return str(xml) + except Exception as e: + self.Excepcion = "%s" % (e) + + +def p_assert_eq(a, b): + print(a, a == b and '==' or '!=', b) + + +def main(): + "Funcin principal de pruebas (obtener CAE)" + import os + import time + + DEBUG = '--debug' in sys.argv + + # obteniendo el TA + TA = "TA-wscoc.xml" + if not os.path.exists(TA) or os.path.getmtime(TA) + (60 * 60 * 5) < time.time(): + from . import wsaa + tra = wsaa.create_tra(service="wscoc") + cms = wsaa.sign_tra(tra, "olano.crt", "olanoycia.key") + ta_string = wsaa.call_wsaa(cms, trace='--trace' in sys.argv) + open(TA, "w").write(ta_string) + + # fin TA + + ws = WSCOC() + ws.Cuit = "20267565393" + + ta_string = open(TA).read() + ta = SimpleXMLElement(ta_string) + ws.Token = str(ta.credentials.token) + ws.Sign = str(ta.credentials.sign) + + ws.LanzarExcepciones = True + ws.Conectar(wsdl=WSDL) + + if "--dummy" in sys.argv: + # print ws.client.help("dummy") + try: + ws.Dummy() + print("AppServerStatus", ws.AppServerStatus) + print("DbServerStatus", ws.DbServerStatus) + print("AuthServerStatus", ws.AuthServerStatus) + except Exception as e: + raise + print("Exception", e) + print(ws.XmlRequest) + print(ws.XmlResponse) + + if "--monedas" in sys.argv: + print(ws.client.help("consultarMonedas")) + try: + for moneda in ws.ConsultarMonedas(): + print(moneda) + except Exception as e: + raise + print("Exception", e) + print(ws.XmlRequest) + print(ws.XmlResponse) + + if "--motivos_ex_djai" in sys.argv: + print(ws.ConsultarMotivosExcepcionDJAI()) + if "--destinos_djai" in sys.argv: + print(ws.ConsultarDestinosCompraDJAI()) + + if "--consultar_cuit" in sys.argv: + print(ws.client.help("consultarCUIT")) + try: + print("Consultado CUITs....") + nro_doc = 26756539 + tipo_doc = 96 + print(ws.ConsultarCUIT(nro_doc, tipo_doc)) + # recorro el detalle de los cuit devueltos: + while ws.LeerCUITConsultado(): + print("CUIT", ws.CUITConsultada) + print("Denominacin", ws.DenominacionConsultada) + except Exception as e: + raise + print("Exception", e) + print(ws.XmlRequest) + print(ws.XmlResponse) + + if "--prueba" in sys.argv: + print(ws.client.help("generarSolicitudCompraDivisa").encode("latin1")) + try: + cuit_comprador = 20267565393 + codigo_moneda = 1 + cotizacion_moneda = 4.26 + monto_pesos = 100 + cuit_representante = None + codigo_destino = 625 + + if "--loadxml" in sys.argv: + ws.LoadTestXML("wscoc_response.xml") + + if not "--tur" in sys.argv: + djai = "--djai" in sys.argv and "12345DJAI000001N" or None + djas = "--djas" in sys.argv and "12001DJAS000901N" or None + cod_ex_djai = "--no-djai" and 3 or None + cod_ex_djas = "--no-djas" and 1 or None + if '--ref' in sys.argv: + tipo, codigo = 1, '12345DJAI000067C' + else: + tipo, codigo = None, None + print("djai", djai) + ok = ws.GenerarSolicitudCompraDivisa(cuit_comprador, codigo_moneda, + cotizacion_moneda, monto_pesos, + cuit_representante, codigo_destino, + djai=djai, codigo_excepcion_djai=cod_ex_djai, + djas=djas, codigo_excepcion_djas=cod_ex_djas, + tipo=tipo, codigo=codigo,) + else: + print("Turista!") + tipo_doc = 91 + numero_doc = 1234567 + apellido_nombre = "Nombre y Apellido del turista extranjero" + codigo_destino = 985 + ok = ws.GenerarSolicitudCompraDivisaTurExt( + tipo_doc, numero_doc, apellido_nombre, + codigo_moneda, cotizacion_moneda, monto_pesos, + cuit_representante, codigo_destino, + ) + while True: + i = ws.LeerInconsistencia() + if not i: + break + print("Inconsistencia...", i) + + assert ok + print('Resultado', ws.Resultado) + assert ws.Resultado == 'A' + print('COC', ws.COC) + assert len(str(ws.COC)) == 12 + print("FechaEmisionCOC", ws.FechaEmisionCOC) + print('CodigoSolicitud', ws.CodigoSolicitud) + assert ws.CodigoSolicitud is not None + print("EstadoSolicitud", ws.EstadoSolicitud) + assert ws.EstadoSolicitud == 'OT' + print("FechaEstado", ws.FechaEstado) + print("DetalleCUITComprador", ws.CUITComprador, ws.DenominacionComprador) + print("CodigoMoneda", ws.CodigoMoneda) + assert ws.CodigoMoneda == 1 + print("CotizacionMoneda", ws.CotizacionMoneda) + assert round(ws.CotizacionMoneda, 2) == 4.26 + print("MontoPesos", ws.MontoPesos) + assert ws.MontoPesos - monto_pesos <= 0.01 + print("CodigoDestino", ws.CodigoDestino) + assert ws.CodigoDestino == codigo_destino + + coc = ws.COC + codigo_solicitud = ws.CodigoSolicitud + # CO: confirmar, o 'DC' (desistio cliente) 'DB' (desistio banco) + nuevo_estado = 'CO' + ok = ws.InformarSolicitudCompraDivisa(codigo_solicitud, nuevo_estado) + assert ok + print('Resultado', ws.Resultado) + assert ws.Resultado == 'A' + print('COC', ws.COC) + assert ws.COC == coc + print("EstadoSolicitud", ws.EstadoSolicitud) + assert ws.EstadoSolicitud == nuevo_estado + + ok = ws.AnularCOC(coc, cuit_comprador) + assert ok + print('Resultado', ws.Resultado) + assert ws.Resultado == 'A' + print('COC', ws.COC) + assert ws.COC == coc + print("EstadoSolicitud", ws.EstadoSolicitud) + assert ws.EstadoSolicitud == 'AN' + + ok = ws.ConsultarSolicitudCompraDivisa(codigo_solicitud) + assert ok + print('CodigoSolicitud', ws.CodigoSolicitud) + assert ws.CodigoSolicitud == codigo_solicitud + print("EstadoSolicitud", ws.EstadoSolicitud) + assert ws.EstadoSolicitud == 'AN' + + except BaseException: + print(ws.XmlRequest) + print(ws.XmlResponse) + print(ws.ErrCode) + print(ws.ErrMsg) + raise + + if "--consultar_solicitudes" in sys.argv: + cuit_comprador = None + estado_solicitud = None + fecha_emision_desde = '2011-11-01' + fecha_emision_hasta = '2011-11-30' + sols = ws.ConsultarSolicitudesCompraDivisas(cuit_comprador, + estado_solicitud, + fecha_emision_desde, + fecha_emision_hasta,) + # muestro los resultados de la bsqueda + print("Solicitudes consultadas:") + for sol in sols: + print("Cdigo de Solicitud:", sol) + # podra llamar a ws.ConsultarSolicitudCompraDivisa + print("hecho.") + + ws.AnalizarXml("XmlResponse") + + # recorro las solicitudes devueltas + i = 0 + while ws.LeerSolicitudConsultada(): + print("-" * 80) + coc = ws.ObtenerTagXml('arrayDetallesSolicitudes', 'detalleSolicitudes', i, 'coc') + cuit = ws.ObtenerTagXml('arrayDetallesSolicitudes', 'detalleSolicitudes', i, 'cuit') + p_assert_eq(coc, ws.COC) + print('CUIT', cuit) + print("FechaEmisionCOC", ws.FechaEmisionCOC) + print('CodigoSolicitud', ws.CodigoSolicitud) + print("EstadoSolicitud", ws.EstadoSolicitud) + print("FechaEstado", ws.FechaEstado) + print("DetalleCUITComprador", ws.CUITComprador, ws.DenominacionComprador) + print("CodigoMoneda", ws.CodigoMoneda) + print("CotizacionMoneda", ws.CotizacionMoneda) + print("MontoPesos", ws.MontoPesos) + print("CodigoDestino", ws.CodigoDestino) + print("=" * 80) + i = i + 1 + + if "--parametros" in sys.argv: + print("=== Tipos de Estado Solicitud ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarTiposEstadoSolicitud(sep="||")])) + print("=== Monedas ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarMonedas(sep="||")])) + print("=== Destinos de Compra ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarDestinosCompra(sep="||")])) + print("=== Tipos de Documento ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarTiposDocumento(sep="||")])) + print("=== Tipos Estado Solicitud ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarTiposEstadoSolicitud(sep="||")])) + print("=== Motivos Excepcion DJAI ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarMotivosExcepcionDJAI(sep="||")])) + print("=== Destinos Compra DJAI ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarDestinosCompraDJAI(sep="||")])) + print("=== Motivos Excepcion DJAS ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarMotivosExcepcionDJAS(sep="||")])) + print("=== Destinos Compra DJAS ===") + print('\n'.join(["||%s||" % s for s in ws.ConsultarDestinosCompraDJAS(sep="||")])) + + if "--consultar_djai" in sys.argv: + djai = "12345DJAI000001N" + cuit = 20267565393 + ws.ConsultarDJAI(djai, cuit) + print("DJAI", ws.DJAI) + print("Monto FOB", ws.MontoFOB) + print("Codigo Moneda", ws.CodigoMoneda) + print("Estado DJAI", ws.EstadoDJAI) + print("Errores", ws.Errores) + print("ErroresFormato", ws.ErroresFormato) + + if "--consultar_djas" in sys.argv: + djas = "12001DJAS000901N" + cuit = 20267565393 + ws.ConsultarDJAS(djas, cuit) + print("DJAS", ws.DJAS) + print("Monto FOB", ws.MontoFOB) + print("Codigo Moneda", ws.CodigoMoneda) + print("Estado DJAI", ws.EstadoDJAI) + print("Errores", ws.Errores) + print("ErroresFormato", ws.ErroresFormato) + + if "--consultar_ref" in sys.argv: + codigo = "12345DJAI000067C" + cuit = 20267565393 + ws.ConsultarReferencia(1, codigo) + print("Monto FOB", ws.MontoFOB) + print("Codigo Moneda", ws.CodigoMoneda) + print("Estado", ws.Estado) + print("Errores", ws.Errores) + print("ErroresFormato", ws.ErroresFormato) + + if "--consultar_dest_ref" in sys.argv: + print(ws.ConsultarDestinosCompraTipoReferencia(1)) + + if "--consultar_tipos_ref" in sys.argv: + print(ws.ConsultarTiposReferencia()) + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +if not hasattr(sys, "frozen"): + basepath = __file__ +elif sys.frozen == 'dll': + import win32api + basepath = win32api.GetModuleFileName(sys.frozendllhandle) +else: + basepath = sys.executable +INSTALL_DIR = os.path.dirname(os.path.abspath(basepath)) + +if __name__ == '__main__': + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSCOC) + else: + main() diff --git a/app/pyafipws/wsct.py b/app/pyafipws/wsct.py new file mode 100644 index 0000000000000000000000000000000000000000..8474c907f5d4120c4df2d9790f2e256a71713afc --- /dev/null +++ b/app/pyafipws/wsct.py @@ -0,0 +1,820 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""Mdulo para obtener cdigo de autorizacin electrnico CAE webservice +WSCT de AFIP (Factura Electrnica Comprobantes de Turismo) +Resolucin Conjunta General 3971 y Resolucin 566/2016. +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2017 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.02c" + +import datetime +import decimal +import os +import sys +from .utils import verifica, inicializar_y_capturar_excepciones, BaseWS, get_install_dir + +HOMO = False +LANZAR_EXCEPCIONES = True +WSDL = "https://fwshomo.afip.gov.ar/wsct/CTService?wsdl" + + +class WSCT(BaseWS): + "Interfaz para el WebService de Factura Electrnica Comprobantes Turismo" + _public_methods_ = ['CrearFactura', 'EstablecerCampoFactura', 'AgregarIva', 'AgregarItem', + 'AgregarTributo', 'AgregarCmpAsoc', 'EstablecerCampoItem', + 'AgregarDatoAdicional', 'AgregarFormaPago', + 'AutorizarComprobante', 'CAESolicitar', + 'InformarCAEANoUtilizado', 'InformarCAEANoUtilizadoPtoVta', + 'ConsultarUltimoComprobanteAutorizado', 'CompUltimoAutorizado', + 'ConsultarPtosVtaCAEANoInformados', + 'ConsultarComprobante', + 'ConsultarTiposComprobante', 'ConsultarTiposDocumento', + 'consultarTiposIVA', 'ConsultarCondicionesIVA', + 'ConsultarMonedas', 'ConsultarCotizacion', + 'ConsultarTiposItem', 'ConsultarCodigosItemTurismo', + 'ConsultarTiposTributo', + 'ConsultarCUITsPaises', 'ConsultarPaises', + 'ConsultarTiposDatosAdicionales', 'ConsultarFomasPago', + 'ConsultarTiposTarjeta', 'ConsultarTiposCuenta', + 'ConsultarPuntosVenta', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetTicketAcceso', 'GetParametro', + 'Dummy', 'Conectar', 'DebugLog', 'SetTicketAcceso'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', 'InstallDir', 'LanzarExcepciones', + 'Resultado', 'Obs', 'Observaciones', 'ErrCode', 'ErrMsg', + 'EmisionTipo', 'Reproceso', 'Reprocesar', 'Evento', + 'CAE', 'Vencimiento', 'Evento', 'Errores', 'Traceback', 'Excepcion', + 'CAEA', 'Periodo', 'Orden', 'FchVigDesde', 'FchVigHasta', 'FchTopeInf', 'FchProceso', + 'CbteNro', 'FechaCbte', 'PuntoVenta', 'ImpTotal'] + + _reg_progid_ = "WSCT" + _reg_clsid_ = "{5DE7917D-CE97-4C88-B6C7-DAF8CEB54E93}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologacin' or '') + Reprocesar = True # recuperar automaticamente CAE emitidos + LanzarExcepciones = LANZAR_EXCEPCIONES + factura = None + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Resultado = self.Motivo = self.Reproceso = '' + self.LastID = self.LastCMP = self.CAE = self.Vencimiento = '' + self.CAEA = None + self.Periodo = self.Orden = "" + self.FchVigDesde = self.FchVigHasta = "" + self.FchTopeInf = self.FchProceso = "" + self.CbteNro = self.FechaCbte = self.ImpTotal = None + self.PuntoVenta = self.EmisionTipo = self.Evento = '' + self.Reproceso = '' # no implementado + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + for key in ('arrayErrores', 'arrayErroresFormato'): + errores = ret.get(key, []) + for error in errores: + for k in ('codigoDescripcion', 'codigoDescripcionString'): + err = error.get(k) + if err: + break + self.Errores.append("%s: %s" % ( + err['codigo'], + err['descripcion'], + )) + self.ErrMsg = '\n'.join(self.Errores) + + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + ret = self.client.dummy() + result = ret['dummyReturn'] + self.AppServerStatus = result['appserver'] + self.DbServerStatus = result['dbserver'] + self.AuthServerStatus = result['authserver'] + return True + + def CrearFactura(self, tipo_doc=None, nro_doc=None, tipo_cbte=None, punto_vta=None, + cbte_nro=None, imp_total=None, imp_tot_conc=None, imp_neto=None, + imp_subtotal=None, imp_trib=None, imp_op_ex=None, imp_reintegro=None, + fecha_cbte=None, id_impositivo="", cod_pais=None, domicilio="", cod_relacion="", + moneda_id=None, moneda_ctz=None, observaciones=None, + **kwargs + ): + "Creo un objeto factura (interna)" + # Creo una factura electronica de exportacin + fact = {'tipo_doc': tipo_doc, 'nro_doc': nro_doc, + 'tipo_cbte': tipo_cbte, 'punto_vta': punto_vta, + 'cbte_nro': cbte_nro, + 'id_impositivo': id_impositivo, + 'cod_pais': cod_pais, + 'domicilio': domicilio, + 'cod_relacion': cod_relacion, + 'imp_total': imp_total, 'imp_tot_conc': imp_tot_conc, + 'imp_neto': imp_neto, + 'imp_subtotal': imp_subtotal, # 'imp_iva': imp_iva, + 'imp_trib': imp_trib, 'imp_op_ex': imp_op_ex, + 'imp_reintegro': imp_reintegro, + 'fecha_cbte': fecha_cbte, + 'moneda_id': moneda_id, 'moneda_ctz': moneda_ctz, + 'observaciones': observaciones, + 'cbtes_asoc': [], + 'tributos': [], + 'iva': [], + 'detalles': [], + 'adicionales': [], + 'formas_pago': [], + } + + self.factura = fact + return True + + def EstablecerCampoFactura(self, campo, valor): + if campo in self.factura or campo in ('fecha_serv_desde', 'fecha_serv_hasta', 'caea', 'fch_venc_cae'): + self.factura[campo] = valor + return True + else: + return False + + def AgregarCmpAsoc(self, tipo=1, pto_vta=0, nro=0, cuit=None, **kwargs): + "Agrego un comprobante asociado a una factura (interna)" + cmp_asoc = { + 'tipo': tipo, + 'pto_vta': pto_vta, + 'nro': nro} + if cuit is not None: + cmp_asoc['cuit'] = cuit + self.factura['cbtes_asoc'].append(cmp_asoc) + return True + + def AgregarTributo(self, tributo_id, desc, base_imp, alic, importe, **kwargs): + "Agrego un tributo a una factura (interna)" + tributo = { + 'tributo_id': tributo_id, + 'desc': desc, + 'base_imp': base_imp, + 'importe': importe, + } + self.factura['tributos'].append(tributo) + return True + + def AgregarIva(self, iva_id, base_imp, importe, **kwargs): + "Agrego un tributo a una factura (interna)" + iva = { + 'iva_id': iva_id, + 'importe': importe, + } + self.factura['iva'].append(iva) + return True + + def AgregarItem(self, tipo=None, cod_tur=None, + codigo=None, ds=None, + iva_id=None, imp_iva=None, imp_subtotal=None, **kwargs): + "Agrego un item a una factura (interna)" + # ds = unicode(ds, "latin1") # convierto a latin1 + # Nota: no se calcula neto, iva, etc (deben venir calculados!) + tipo = int(tipo) + if tipo == 99: + imp_subtotal = -abs(float(imp_subtotal)) + imp_iva = -abs(float(imp_iva)) + item = { + 'tipo': tipo, + 'cod_tur': cod_tur, + 'codigo': codigo, + 'ds': ds, + 'iva_id': iva_id, + 'imp_iva': imp_iva, + 'imp_subtotal': imp_subtotal, + } + self.factura['detalles'].append(item) + return True + + def AgregarDatoAdicional(self, t, c1, c2, c3, c4, c5, c6, **kwarg): + "Agrego un tipo de dato adicional a una factura (interna)" + op = {'t': t, + 'c1': c1, 'c2': c2, 'c3': c3, 'c4': c4, 'c5': c5, 'c6': c6, } + self.factura['adicionales'].append(op) + return True + + def AgregarFormaPago(self, codigo, tipo_tarjeta=None, numero_tarjeta=None, + swift_code=None, tipo_cuenta=None, numero_cuenta=None, + **kwarg): + "Agrego una forma de pago a una factura (interna)" + fp = {'codigo': codigo, 'tipo_tarjeta': tipo_tarjeta, + 'numero_tarjeta': numero_tarjeta, 'swift_code': swift_code, + 'tipo_cuenta': tipo_cuenta, 'numero_cuenta': numero_cuenta} + self.factura['formas_pago'].append(fp) + return True + + def EstablecerCampoItem(self, campo, valor): + if self.factura['detalles'] and campo in self.factura['detalles'][-1]: + self.factura['detalles'][-1][campo] = valor + return True + else: + return False + + @inicializar_y_capturar_excepciones + def AutorizarComprobante(self): + f = self.factura + # contruyo la estructura a convertir en XML: + fact = { + 'codigoTipoDocumento': f['tipo_doc'], 'numeroDocumento': f['nro_doc'], + 'codigoTipoComprobante': f['tipo_cbte'], 'numeroPuntoVenta': f['punto_vta'], + 'codigoTipoAutorizacion': 'E', + 'numeroComprobante': f['cbte_nro'], + 'importeTotal': f['imp_total'], 'importeNoGravado': f['imp_tot_conc'], + 'idImpositivo': f['id_impositivo'], + 'codigoPais': f['cod_pais'], 'domicilioReceptor': f['domicilio'], + 'codigoRelacionEmisorReceptor': f['cod_relacion'], + 'importeGravado': f['imp_neto'], + 'importeSubtotal': f['imp_subtotal'], # 'imp_iva': imp_iva, + 'importeOtrosTributos': f['tributos'] and f['imp_trib'] or None, + 'importeExento': f['imp_op_ex'], 'importeReintegro': f['imp_reintegro'], + 'fechaEmision': f['fecha_cbte'], + 'codigoMoneda': f['moneda_id'], 'cotizacionMoneda': f['moneda_ctz'], + 'observaciones': f['observaciones'], + 'fechaVencimientoPago': f.get('fecha_venc_pago'), + 'fechaServicioDesde': f.get('fecha_serv_desde'), + 'fechaServicioHasta': f.get('fecha_serv_hasta'), + 'arrayComprobantesAsociados': f['cbtes_asoc'] and [{'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro'], + }} for cbte_asoc in f['cbtes_asoc']] or None, + 'arrayOtrosTributos': f['tributos'] and [{'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': tributo['base_imp'], + 'importe': tributo['importe'], + }} for tributo in f['tributos']] or None, + 'arraySubtotalesIVA': f['iva'] and [{'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': iva['importe'], + }} for iva in f['iva']] or None, + 'arrayItems': f['detalles'] and [{'item': { + 'tipo': it['tipo'], + 'codigoTurismo': it['cod_tur'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'codigoAlicuotaIVA': it['iva_id'], + 'importeIVA': it['imp_iva'] if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': it['imp_subtotal'], + }} for it in f['detalles']] or None, + 'arrayDatosAdicionales': [ + {'tipoDatoAdicional': ta} for ta in f['adicionales']] or None, + 'arrayFormasPago': [ + {'formaPago': { + 'codigo': fp['codigo'], + 'tipoTarjeta': fp['tipo_tarjeta'], + 'numeroTarjeta': fp['numero_tarjeta'], + 'swiftCode': fp['swift_code'], + 'tipoCuenta': fp['tipo_cuenta'], + 'numeroCuenta': fp['numero_cuenta'], + }} for fp in f['formas_pago']] or None, + } + + res = self.client.autorizarComprobante( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + comprobanteRequest=fact, + ) + ret = res.get('autorizarComprobanteReturn', {}) + + # Reprocesar en caso de error (recuperar CAE emitido anteriormente) + if self.Reprocesar and ('arrayErrores' in ret): + for error in ret['arrayErrores']: + err_code = error['codigoDescripcion']['codigo'] + if ret['resultado'] == 'R' and err_code == 102: + # guardo los mensajes xml originales + xml_request = self.client.xml_request + xml_response = self.client.xml_response + cae = self.ConsultarComprobante(f['tipo_cbte'], f['punto_vta'], f['cbt_desde'], reproceso=True) + if cae and self.EmisionTipo == 'CAE': + self.Reproceso = 'S' + self.Resultado = 'A' # verificar O + return cae + self.Reproceso = 'N' + # reestablesco los mensajes xml originales + self.client.xml_request = xml_request + self.client.xml_response = xml_response + + self.Resultado = ret.get('resultado', "") # u'A' + if self.Resultado in ("A", "O"): + cbteresp = ret['comprobanteResponse'] + self.FechaCbte = cbteresp['fechaEmision'].strftime("%Y/%m/%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + # self. = cbteresp['cuit'] # 20267565393L + # self. = cbteresp['codigoTipoComprobante'] + self.Vencimiento = cbteresp['fechaVencimientoCAE'].strftime("%Y/%m/%d") + self.CAE = str(cbteresp['CAE']) # 60423794871430L + self.__analizar_errores(ret) + + for error in ret.get('arrayObservaciones', []): + self.Observaciones.append("%(codigo)s: %(descripcion)s" % ( + error['codigoDescripcion'])) + self.Obs = '\n'.join(self.Observaciones) + self.EmisionTipo = 'CAE' + + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.CAE + + @inicializar_y_capturar_excepciones + def CAESolicitar(self): + try: + cae = self.AutorizarComprobante() or '' + self.Excepcion = "OK!" + except BaseException: + cae = "ERR" + finally: + return cae + + @inicializar_y_capturar_excepciones + def ConsultarUltimoComprobanteAutorizado(self, tipo_cbte, punto_vta): + res = self.client.consultarUltimoComprobanteAutorizado( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codigoTipoComprobante=tipo_cbte, + numeroPuntoVenta=punto_vta, + ) + ret = res.get('consultarUltimoComprobanteAutorizadoReturn', {}) + nro = ret.get('numeroComprobante') + self.__analizar_errores(ret) + self.CbteNro = nro + return nro is not None and str(nro) or 0 + + CompUltimoAutorizado = ConsultarUltimoComprobanteAutorizado + + @inicializar_y_capturar_excepciones + def ConsultarComprobante(self, tipo_cbte, punto_vta, cbte_nro, reproceso=False): + "Recuperar los datos completos de un comprobante ya autorizado" + res = self.client.consultarComprobanteTipoPVentaNro( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codigoTipoComprobante=tipo_cbte, + numeroPuntoVenta=punto_vta, + numeroComprobante=cbte_nro, + ) + ret = res.get('consultarComprobanteReturn', {}) + # diferencias si hay reproceso: + difs = [] + # analizo el resultado: + if 'comprobante' in ret: + cbteresp = ret['comprobante'] + if reproceso: + # verifico los campos registrados coincidan con los enviados: + f = self.factura + verificaciones = { + 'codigoTipoComprobante': f['tipo_cbte'], + 'numeroPuntoVenta': f['punto_vta'], + 'codigoTipoDocumento': f['tipo_doc'], + 'numeroDocumento': f['nro_doc'], + 'numeroComprobante': f['cbt_desde'], + 'numeroComprobante': f['cbt_hasta'], + 'fechaEmision': f['fecha_cbte'], + 'idImpositivo': f['id_impositivo'], + 'codigoPais': f['cod_pais'], 'domicilioReceptor': f['domicilio'], + 'codigoRelacionEmisorReceptor': f['cod_relacion'], + 'importeTotal': decimal.Decimal(str(f['imp_total'])), + 'importeNoGravado': decimal.Decimal(str(f['imp_tot_conc'])), + 'importeGravado': decimal.Decimal(str(f['imp_neto'])), + 'importeExento': decimal.Decimal(str(f['imp_op_ex'])), + 'importeOtrosTributos': f['tributos'] and decimal.Decimal(str(f['imp_trib'])) or None, + 'importeSubtotal': f['imp_subtotal'], + 'importeReintegro': f['imp_reintegro'], + 'codigoMoneda': f['moneda_id'], + 'cotizacionMoneda': str(decimal.Decimal(str(f['moneda_ctz']))), + 'arrayItems': [ + {'item': { + 'tipo': it['tipo'], + 'codigoTurismo': it['cod_tur'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'codigoAlicuotaIVA': decimal.Decimal(str(it['iva_id'])), + 'importeIVA': decimal.Decimal(str(it['imp_iva'])) if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': decimal.Decimal(str(it['imp_subtotal'])), + }} + for it in f['detalles']], + 'arrayComprobantesAsociados': [ + {'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro']}} + for cbte_asoc in f['cbtes_asoc']], + 'arrayOtrosTributos': [ + {'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': decimal.Decimal(str(tributo['base_imp'])), + 'importe': decimal.Decimal(str(tributo['importe'])), + }} + for tributo in f['tributos']], + 'arraySubtotalesIVA': [ + {'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': decimal.Decimal(str(iva['importe'])), + }} + for iva in f['iva']], + } + verifica(verificaciones, cbteresp, difs) + if difs: + print("Diferencias:", difs) + self.log("Diferencias: %s" % difs) + self.FechaCbte = cbteresp['fechaEmision'].strftime("%Y/%m/%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + self.Vencimiento = cbteresp['fechaVencimiento'].strftime("%Y/%m/%d") + self.ImpTotal = str(cbteresp['importeTotal']) + self.CAE = str(cbteresp['codigoAutorizacion']) # 60423794871430L + self.EmisionTipo = cbteresp['codigoTipoAutorizacion'] == 'A' and 'CAEA' or 'CAE' + self.__analizar_errores(ret) + if not difs: + return self.CAE + + @inicializar_y_capturar_excepciones + def ConsultarTiposComprobante(self): + "Este mtodo permite consultar los tipos de comprobantes habilitados en este WS" + res = self.client.consultarTiposComprobantes( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposComprobantesReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposComprobantes']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposDocumento(self): + res = self.client.consultarTiposDocumento( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposDocumentoReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposDocumento']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposIVA(self): + "Este mtodo permite consultar los tipos de IVA habilitados en este ws" + res = self.client.consultarTiposIVA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposIVAReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcionString'] + for p in ret['arrayTiposIVA']] + + @inicializar_y_capturar_excepciones + def ConsultarCondicionesIVA(self): + "Este mtodo permite consultar los tipos de comprobantes habilitados en este WS" + res = self.client.consultarCondicionesIVA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarCondicionesIVAReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcionString'] + for p in ret['arrayCondicionesIVA']] + + @inicializar_y_capturar_excepciones + def ConsultarMonedas(self): + "Este mtodo permite consultar los tipos de comprobantes habilitados en este WS" + res = self.client.consultarMonedas( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarMonedasReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcionString'] + for p in ret['arrayTiposMoneda']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposItem(self): + "Este mtodo permite consultar los tipos de comprobantes habilitados en este WS" + res = self.client.consultarTiposItem( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposItemReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposItem']] + + @inicializar_y_capturar_excepciones + def ConsultarCodigosItemTurismo(self): + "Este mtodo permite consultar los cdigos de los tems de Turismo" + res = self.client.consultarCodigosItemTurismo( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarCodigosItemTurismoReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayCodigosItem']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposTributo(self): + "Este mtodo permite consultar los tipos de comprobantes habilitados en este WS" + res = self.client.consultarTiposTributo( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = res['consultarTiposTributoReturn'] + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcionString'] + for p in ret['arrayTiposTributo']] + + @inicializar_y_capturar_excepciones + def ConsultarCotizacion(self, moneda_id): + "Este mtodo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarCotizacion( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codigoMoneda=moneda_id, + ) + self.__analizar_errores(ret) + if 'cotizacionMoneda' in ret: + return str(ret['cotizacionMoneda']) + + @inicializar_y_capturar_excepciones + def ConsultarPuntosVenta(self, fmt="%(numeroPuntoVenta)s: bloqueado=%(bloqueado)s baja=%(fechaBaja)s"): + "Este mtodo permite consultar los puntos de venta habilitados para CAE en este WS" + res = self.client.consultarPuntosVenta( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = [] + self.__analizar_errores(ret) + for p in res['consultarPuntosVentaReturn'].get("arrayPuntosVenta", {}): + p = p['puntoVenta'] + if 'fechaBaja' not in p: + p['fechaBaja'] = "" + ret.append(fmt % p if fmt else p) + return ret + + @inicializar_y_capturar_excepciones + def ConsultarPaises(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Pases" + ret = self.client.consultarPaises( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit, }) + result = ret['consultarPaisesReturn'] + self.__analizar_errores(result) + + ret = [] + for u in result['arrayPaises']: + u = u['codigoDescripcionString'] + try: + r = {'codigo': u.get('codigo'), 'ds': u.get('descripcion'), } + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def ConsultarCUITsPaises(self, sep="|"): + "Recuperar lista de valores referenciales de CUIT de Pases" + ret = self.client.consultarCUITsPaises( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit, }) + result = ret['consultarCUITsPaisesReturn'] + self.__analizar_errores(result) + + ret = [] + for u in result['arrayCuitPaises']: + u = u['codigoDescripcionString'] + try: + r = {'codigo': u.get('codigo'), 'ds': u.get('descripcion'), } + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def ConsultarTiposDatosAdicionales(self, sep="|"): + "Recuperar lista de los datos adicionales a informar segn RG." + ret = self.client.consultarTiposDatosAdicionales( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit, }) + result = ret['consultarTiposDatosAdicionalesReturn'] + self.__analizar_errores(result) + ret = [] + for u in result['arrayTiposDatosAdicionales']: + u = u['codigoDescripcionString'] + r = {'codigo': u.get('codigo'), 'ds': u.get('descripcion'), } + ret.append(r) + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] if sep else ret + + @inicializar_y_capturar_excepciones + def ConsultarFomasPago(self, sep="|"): + "Recuperar lista de las formas de pago" + ret = self.client.consultarFormasPago( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit, }) + result = ret['consultarFormasPagoReturn'] + self.__analizar_errores(result) + ret = [] + for u in result['arrayFormasPago']: + u = u['codigoDescripcion'] + r = {'codigo': u.get('codigo'), 'ds': u.get('descripcion'), } + ret.append(r) + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] if sep else ret + + @inicializar_y_capturar_excepciones + def ConsultarTiposTarjeta(self, forma_pago=None, sep="|"): + "Recuperar lista de los tipos de tarjeta habilitados" + ret = self.client.consultarTiposTarjeta( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit, }, + formaPago=forma_pago) + result = ret['consultarTiposTarjetaReturn'] + self.__analizar_errores(result) + ret = [] + for u in result['arrayTiposTarjeta']: + u = u['codigoDescripcion'] + r = {'codigo': u.get('codigo'), 'ds': u.get('descripcion'), } + ret.append(r) + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] if sep else ret + + @inicializar_y_capturar_excepciones + def ConsultarTiposCuenta(self, sep="|"): + "Recuperar lista de los tipos de tarjeta habilitados" + ret = self.client.consultarTiposCuenta( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit, }) + result = ret['consultarTiposCuentaReturn'] + self.__analizar_errores(result) + ret = [] + for u in result['arrayTiposCuenta']: + u = u['codigoDescripcion'] + r = {'codigo': u.get('codigo'), 'ds': u.get('descripcion'), } + ret.append(r) + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] if sep else ret + + +def main(): + "Funcin principal de pruebas (obtener CAE)" + import os + import time + + DEBUG = '--debug' in sys.argv + + # obteniendo el TA para pruebas + from .wsaa import WSAA + ta = WSAA().Autenticar("wsct", "reingart.crt", "reingart.key") + + wsct = WSCT() + wsct.SetTicketAcceso(ta) + wsct.Cuit = "20267565393" + + cache = "" + if "--prod" in sys.argv: + wsdl = "https://serviciosjava.afip.gob.ar/wsct/CTService?wsdl" + else: + wsdl = WSDL + wsct.Conectar(cache, wsdl, cacert="conf/afip_ca_info.crt") + + if "--dummy" in sys.argv: + print(wsct.client.help("dummy")) + wsct.Dummy() + print("AppServerStatus", wsct.AppServerStatus) + print("DbServerStatus", wsct.DbServerStatus) + print("AuthServerStatus", wsct.AuthServerStatus) + + if "--prueba" in sys.argv: + # print wsct.client.help("autorizarComprobante").encode("latin1") + try: + tipo_cbte = 195 + punto_vta = 4000 + cbte_nro = wsct.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + fecha = datetime.datetime.now().strftime("%Y-%m-%d") + tipo_doc = 80 + nro_doc = "50000000059" + cbte_nro = int(cbte_nro) + 1 + id_impositivo = 9 # "Cliente del Exterior" + cod_relacion = 3 # Alojamiento Directo a Turista No Residente + imp_total = "101.00" + imp_tot_conc = "0.00" + imp_neto = "100.00" + imp_trib = "1.00" + imp_op_ex = "0.00" + imp_subtotal = "100.00" + imp_reintegro = -21.00 # validacin AFIP 346 + cod_pais = 203 + domicilio = "Rua N.76 km 34.5 Alagoas" + fecha_cbte = fecha + moneda_id = 'PES' + moneda_ctz = '1.000' + obs = "Observaciones Comerciales, libre" + + wsct.CrearFactura(tipo_doc, nro_doc, tipo_cbte, punto_vta, + cbte_nro, imp_total, imp_tot_conc, imp_neto, + imp_subtotal, imp_trib, imp_op_ex, imp_reintegro, + fecha_cbte, id_impositivo, cod_pais, domicilio, + cod_relacion, moneda_id, moneda_ctz, obs) + + tributo_id = 99 + desc = 'Impuesto Municipal Matanza' + base_imp = "100.00" + alic = "1.00" + importe = "1.00" + wsct.AgregarTributo(tributo_id, desc, base_imp, alic, importe) + + iva_id = 5 # 21% + base_imp = 100 + importe = 21 + wsct.AgregarIva(iva_id, base_imp, importe) + + tipo = 0 # Item General + cod_tur = 1 # Servicio de hotelera - alojamiento sin desayuno + codigo = "T0001" + ds = "Descripcion del producto P0001" + iva_id = 5 + imp_iva = 21.00 + imp_subtotal = 121.00 + wsct.AgregarItem(tipo, cod_tur, codigo, ds, + iva_id, imp_iva, imp_subtotal) + + codigo = 68 # tarjeta de crdito + tipo_tarjeta = 99 # otra (ver tabla de parmetros) + numero_tarjeta = "999999" + swift_code = None + tipo_cuenta = None + numero_cuenta = None + wsct.AgregarFormaPago(codigo, tipo_tarjeta, numero_tarjeta, + swift_code, tipo_cuenta, numero_cuenta) + + print(wsct.factura) + + wsct.AutorizarComprobante() + + print("Resultado", wsct.Resultado) + print("CAE", wsct.CAE) + print("Vencimiento", wsct.Vencimiento) + print("Reproceso", wsct.Reproceso) + print("Errores", wsct.ErrMsg) + + print(wsct.Excepcion) + print(wsct.ErrMsg) + + cae = wsct.CAE + + if cae: + + wsct.ConsultarComprobante(tipo_cbte, punto_vta, cbte_nro) + print("CAE consulta", wsct.CAE, wsct.CAE == cae) + print("NRO consulta", wsct.CbteNro, wsct.CbteNro == cbte_nro) + print("TOTAL consulta", wsct.ImpTotal, wsct.ImpTotal == imp_total) + + wsct.AnalizarXml("XmlResponse") + assert wsct.ObtenerTagXml('codigoAutorizacion') == str(wsct.CAE) + assert wsct.ObtenerTagXml('codigoConcepto') == str(concepto) + assert wsct.ObtenerTagXml('arrayItems', 0, 'item', 'unidadesMtx') == '123456' + + except BaseException: + print(wsct.XmlRequest) + print(wsct.XmlResponse) + print(wsct.ErrCode) + print(wsct.ErrMsg) + + if "--ptosventa" in sys.argv: + print(wsct.ConsultarPuntosVenta()) + + if "--parametros" in sys.argv: + print(wsct.ConsultarTiposDatosAdicionales()) + print(wsct.ConsultarTiposComprobante()) + print(wsct.ConsultarTiposDocumento()) + print(wsct.ConsultarTiposIVA()) + print(wsct.ConsultarCondicionesIVA()) + print(wsct.ConsultarMonedas()) + print(wsct.ConsultarTiposItem()) + print(wsct.ConsultarCodigosItemTurismo()) + print(wsct.ConsultarTiposTributo()) + print(wsct.ConsultarFomasPago()) + for forma_pago in wsct.ConsultarFomasPago(sep=None)[:2]: + print(wsct.ConsultarTiposTarjeta(forma_pago["codigo"])) + print(wsct.ConsultarTiposCuenta()) + print("\n".join(wsct.ConsultarPaises())) + print("\n".join(wsct.ConsultarCUITsPaises())) + + if "--cotizacion" in sys.argv: + print(wsct.ConsultarCotizacionMoneda('DOL')) + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = WSCT.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSCT) + else: + main() diff --git a/app/pyafipws/wsctg.py b/app/pyafipws/wsctg.py new file mode 100644 index 0000000000000000000000000000000000000000..9341e7362ed14a4f45050230482f987f14cb286f --- /dev/null +++ b/app/pyafipws/wsctg.py @@ -0,0 +1,1198 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +import base64 +import time +import sys +import os +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, BaseWS, inicializar_y_capturar_excepciones, get_install_dir +from . import utils +from pysimplesoap.client import SoapFault +import traceback +from .utils import date +"""Módulo para obtener Código de Trazabilidad de Granos +del web service WSCTG versión 4.0 de AFIP (RG3593/14) +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2010-2014 Mariano Reingart" +__license__ = "LGPL 3.0" +__version__ = "1.14e" + +LICENCIA = """ +wsctg.py: Interfaz para generar Código de Trazabilidad de Granos AFIP v1.1 +Copyright (C) 2014-2015 Mariano Reingart reingart@gmail.com +http://www.sistemasagiles.com.ar/trac/wiki/CodigoTrazabilidadGranos + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo bajo la licencia GPLv3. + +Para información adicional sobre garantía, soporte técnico comercial +e incorporación/distribución en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + +AYUDA = """ +Opciones: + --ayuda: este mensaje + + --debug: modo depuración (detalla y confirma las operaciones) + --formato: muestra el formato de los archivos de entrada/salida + --prueba: genera y autoriza una CTG de prueba (no usar en producción!) + --xml: almacena los requerimientos y respuestas XML (depuración) + + --dummy: consulta estado de servidores + --solicitar: obtiene el CTG (según archivo de entrada en TXT o CSV) + --confirmar: confirma el CTG (según archivo de entrada en TXT o CSV) + --anular: anula el CTG + --rechazar: permite al destino rechazar el CTG + --confirmar_arribo: confirma el arribo de un CTG + --confirmar_definitivo: confirma el arribo definitivo de un CTG + --regresar_a_origen_rechazado: tomar la acción de "Regresar a Origen" + --cambiar_destino_destinatario_rechazado: "Cambio de Destino y Destinatario" + + --consultar: consulta las CTG generadas + --consultar_excel: consulta las CTG generadas (genera un excel) + --consultar_detalle: obtiene el detalle de una CTG + --consultar_constancia_pdf: descarga el documento PDF de una CTG + --pendientes: consulta CTGs otorgados, rechazados, confirmados a resolver + --consultar_rechazados: obtener CTGs rechazados para darles un nuevo curso + --consultar_activos_por_patente: consulta de CTGs activos por patente + + --provincias: obtiene el listado de provincias + --localidades: obtiene el listado de localidades por provincia + --especies: obtiene el listado de especies + --cosechas: obtiene el listado de cosechas + +Ver wsctg.ini para parámetros de configuración (URL, certificados, etc.)" +""" + + +# importo funciones compartidas: + + +# constantes de configuración (homologación): + +WSDL = "https://fwshomo.afip.gov.ar/wsctg/services/CTGService_v4.0?wsdl" + +DEBUG = False +XML = False +CONFIG_FILE = "wsctg.ini" +HOMO = False + +# definición del formato del archivo de intercambio: + +ENCABEZADO = [ + # datos enviados + ('tipo_reg', 1, A), # 0: encabezado + ('numero_carta_de_porte', 13, N), + ('codigo_especie', 5, N), + ('cuit_canjeador', 11, N), + ('cuit_destino', 11, N), + ('cuit_destinatario', 11, N), + ('codigo_localidad_origen', 6, N), + ('codigo_localidad_destino', 6, N), + ('codigo_cosecha', 4, N), + ('peso_neto_carga', 5, N), + ('cant_horas', 2, N), + ('reservado1', 6, A), + ('cuit_transportista', 11, N), + ('km_a_recorrer', 4, N), # km_recorridos (en consulta WSCTGv2) + ('establecimiento', 6, N), # confirmar arribo + ('remitente_comercial_como_canjeador', 1, A), # S/N solicitar CTG inicial (WSCTGv2) + ('consumo_propio', 1, A), # S/N confirmar arribo (WSCTGv2) + + # datos devueltos + ('numero_ctg', 8, N), + ('fecha_hora', 19, A), + ('vigencia_desde', 10, A), + ('vigencia_hasta', 10, A), + ('transaccion', 12, N), + ('tarifa_referencia', 6, I, 2), # consultar detalle + ('estado', 20, A), + ('imprime_constancia', 5, A), + ('observaciones', 200, A), + ('errores', 1000, A), + ('controles', 1000, A), + ('detalle', 1000, A), # consultar detalle (WSCTGv2) + + # nuevos campos agregados: + ('cuit_chofer', 11, N), + + # nuevos campos agregados WSCTGv3: + ('cuit_corredor', 12, N), + ('remitente_comercial_como_productor', 1, A), + ('patente_vehiculo', 10, A), + + # nuevos campos agregados WSCTGv4: + ('ctc_codigo', 2, A), + ('turno', 50, A), + +] + + +class WSCTG(BaseWS): + "Interfaz para el WebService de Código de Trazabilidad de Granos (Version 3)" + _public_methods_ = ['Conectar', 'Dummy', 'SetTicketAcceso', 'DebugLog', + 'SolicitarCTGInicial', 'SolicitarCTGDatoPendiente', + 'ConfirmarArribo', 'ConfirmarDefinitivo', + 'AnularCTG', 'RechazarCTG', 'CTGsPendientesResolucion', + 'ConsultarCTG', 'LeerDatosCTG', 'ConsultarDetalleCTG', + 'ConsultarCTGExcel', 'ConsultarConstanciaCTGPDF', + 'ConsultarCTGRechazados', + 'RegresarAOrigenCTGRechazado', + 'CambiarDestinoDestinatarioCTGRechazado', + 'ConsultarCTGActivosPorPatente', + 'ConsultarProvincias', + 'ConsultarLocalidadesPorProvincia', + 'ConsultarEstablecimientos', + 'ConsultarCosechas', + 'ConsultarEspecies', + 'SetParametros', 'SetParametro', 'GetParametro', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + ] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'Excepcion', 'ErrCode', 'ErrMsg', 'LanzarExcepciones', 'Errores', + 'XmlRequest', 'XmlResponse', 'Version', 'Traceback', + 'NumeroCTG', 'CartaPorte', 'FechaHora', 'CodigoOperacion', + 'CodigoTransaccion', 'Observaciones', 'Controles', 'DatosCTG', + 'VigenciaHasta', 'VigenciaDesde', 'Estado', 'ImprimeConstancia', + 'TarifaReferencia', 'Destino', 'Destinatario', 'Detalle', + 'Patente', 'PesoNeto', 'FechaVencimiento', + 'UsuarioSolicitante', 'UsuarioReal', 'CtcCodigo', 'Turno', + ] + _reg_progid_ = "WSCTG" + _reg_clsid_ = "{4383E947-57C4-47C5-8419-85221580CB48}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + LanzarExcepciones = False + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + def Conectar(self, *args, **kwargs): + ret = BaseWS.Conectar(self, *args, **kwargs) + # corregir descripción de servicio WSDL publicado por AFIP + # kmARecorrer -> kmRecorridos (ConsultarDetalleCTG) + port = self.client.services['CTGService_v4.0']['ports']['CTGServiceHttpSoap20Endpoint'] + msg = port['operations']['consultarDetalleCTG']['output']['consultarDetalleCTGResponse'] + msg['response']['consultarDetalleCTGDatos']['kmRecorridos'] = int + return ret + + def inicializar(self): + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.CodError = self.DescError = '' + self.NumeroCTG = self.CartaPorte = "" + self.CodigoTransaccion = self.Observaciones = '' + self.FechaHora = self.CodigoOperacion = "" + self.VigenciaDesde = self.VigenciaHasta = "" + self.Controles = [] + self.DatosCTG = self.TarifaReferencia = None + self.CodigoTransaccion = self.Observaciones = '' + self.Detalle = self.Destino = self.Destinatario = '' + self.Patente = self.PesoNeto = self.FechaVencimiento = '' + self.UsuarioSolicitante = self.UsuarioReal = '' + self.CtcCodigo = self.Turno = "" + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + if 'arrayErrores' in ret: + errores = ret['arrayErrores'] or [] + self.Errores = [err['error'] for err in errores] + self.ErrCode = ' '.join(self.Errores) + self.ErrMsg = '\n'.join(self.Errores) + + def __analizar_controles(self, ret): + "Comprueba y extrae controles si existen en la respuesta XML" + if 'arrayControles' in ret: + controles = ret['arrayControles'] + self.Controles = ["%(tipo)s: %(descripcion)s" % ctl['control'] + for ctl in controles] + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + results = self.client.dummy()['response'] + self.AppServerStatus = str(results['appserver']) + self.DbServerStatus = str(results['dbserver']) + self.AuthServerStatus = str(results['authserver']) + + @inicializar_y_capturar_excepciones + def AnularCTG(self, carta_porte, ctg): + "Anular el CTG si se creó el mismo por error" + response = self.client.anularCTG(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosAnularCTG={ + 'cartaPorte': carta_porte, + 'ctg': ctg, }))['response'] + datos = response.get('datosResponse') + self.__analizar_errores(response) + if datos: + self.CartaPorte = str(datos['cartaPorte']) + self.NumeroCTG = str(datos['ctg']) + self.FechaHora = str(datos['fechaHora']) + self.CodigoOperacion = str(datos['codigoOperacion']) + + @inicializar_y_capturar_excepciones + def RechazarCTG(self, carta_porte, ctg, motivo): + "El Destino puede rechazar el CTG a través de la siguiente operatoria" + response = self.client.rechazarCTG(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosRechazarCTG={ + 'cartaPorte': carta_porte, + 'ctg': ctg, 'motivoRechazo': motivo, + }))['response'] + datos = response.get('datosResponse') + self.__analizar_errores(response) + if datos: + self.CartaPorte = str(datos['cartaPorte']) + self.NumeroCTG = str(datos['CTG']) + self.FechaHora = str(datos['fechaHora']) + self.CodigoOperacion = str(datos['codigoOperacion']) + + @inicializar_y_capturar_excepciones + def SolicitarCTGInicial(self, numero_carta_de_porte, codigo_especie, + cuit_canjeador, cuit_destino, cuit_destinatario, codigo_localidad_origen, + codigo_localidad_destino, codigo_cosecha, peso_neto_carga, + cant_horas=None, patente_vehiculo=None, cuit_transportista=None, + km_a_recorrer=None, remitente_comercial_como_canjeador=None, + cuit_corredor=None, remitente_comercial_como_productor=None, + turno=None, + **kwargs): + "Solicitar CTG Desde el Inicio" + # ajusto parámetros según validaciones de AFIP: + if not cuit_canjeador or int(cuit_canjeador) == 0: + cuit_canjeador = None # nulo + if not cuit_corredor or int(cuit_corredor) == 0: + cuit_corredor = None # nulo + if not remitente_comercial_como_canjeador: + remitente_comercial_como_canjeador = None + if not remitente_comercial_como_productor: + remitente_comercial_como_productor = None + if turno == '': + turno = None # nulo + + ret = self.client.solicitarCTGInicial(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosSolicitarCTGInicial=dict( + cartaPorte=numero_carta_de_porte, + codigoEspecie=codigo_especie, + cuitCanjeador=cuit_canjeador or None, + remitenteComercialComoCanjeador=remitente_comercial_como_canjeador, + cuitDestino=cuit_destino, + cuitDestinatario=cuit_destinatario, + codigoLocalidadOrigen=codigo_localidad_origen, + codigoLocalidadDestino=codigo_localidad_destino, + codigoCosecha=codigo_cosecha, + pesoNeto=peso_neto_carga, + cuitTransportista=cuit_transportista, + cantHoras=cant_horas, + patente=patente_vehiculo, + kmARecorrer=km_a_recorrer, + cuitCorredor=cuit_corredor, + remitenteComercialcomoProductor=remitente_comercial_como_productor, + turno=turno, + )))['response'] + self.__analizar_errores(ret) + self.Observaciones = ret['observacion'] + datos = ret.get('datosSolicitarCTGResponse') + if datos: + self.CartaPorte = str(datos['cartaPorte']) + datos_ctg = datos.get('datosSolicitarCTG') + if datos_ctg: + self.NumeroCTG = str(datos_ctg['ctg']) + self.FechaHora = str(datos_ctg['fechaEmision']) + self.VigenciaDesde = str(datos_ctg['fechaVigenciaDesde']) + self.VigenciaHasta = str(datos_ctg['fechaVigenciaHasta']) + self.TarifaReferencia = str(datos_ctg.get('tarifaReferencia')) + self.__analizar_controles(datos) + return self.NumeroCTG or 0 + + @inicializar_y_capturar_excepciones + def SolicitarCTGDatoPendiente(self, numero_carta_de_porte, cant_horas, + patente_vehiculo, cuit_transportista, patente=None, turno=None): + "Solicitud que permite completar los datos faltantes de un Pre-CTG " + "generado anteriormente a través de la operación solicitarCTGInicial" + ret = self.client.solicitarCTGDatoPendiente(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosSolicitarCTGDatoPendiente=dict( + cartaPorte=numero_carta_de_porte, + cuitTransportista=cuit_transportista, + cantHoras=cant_horas, + patente=patente, + turno=turno, + )))['response'] + self.__analizar_errores(ret) + self.Observaciones = ret['observacion'] + datos = ret.get('datosSolicitarCTGResponse') + if datos: + self.CartaPorte = str(datos['cartaPorte']) + datos_ctg = datos.get('datosSolicitarCTG') + if datos_ctg: + self.NumeroCTG = str(datos_ctg['ctg']) + self.FechaHora = str(datos_ctg['fechaEmision']) + self.VigenciaDesde = str(datos_ctg['fechaVigenciaDesde']) + self.VigenciaHasta = str(datos_ctg['fechaVigenciaHasta']) + self.TarifaReferencia = str(datos_ctg.get('tarifaReferencia')) + self.__analizar_controles(datos) + return self.NumeroCTG + + @inicializar_y_capturar_excepciones + def ConfirmarArribo(self, numero_carta_de_porte, numero_ctg, + cuit_transportista, peso_neto_carga, + consumo_propio, establecimiento=None, cuit_chofer=None, + **kwargs): + "Confirma arribo CTG" + ret = self.client.confirmarArribo(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosConfirmarArribo=dict( + cartaPorte=numero_carta_de_porte, + ctg=numero_ctg, + cuitTransportista=cuit_transportista, + cuitChofer=cuit_chofer, + cantKilosCartaPorte=peso_neto_carga, + consumoPropio=consumo_propio, + establecimiento=establecimiento, + )))['response'] + self.__analizar_errores(ret) + datos = ret.get('datosResponse') + if datos: + self.CartaPorte = str(datos['cartaPorte']) + self.NumeroCTG = str(datos['ctg']) + self.FechaHora = str(datos['fechaHora']) + self.CodigoTransaccion = str(datos['codigoOperacion']) + self.Observaciones = "" + return self.CodigoTransaccion + + @inicializar_y_capturar_excepciones + def ConfirmarDefinitivo(self, numero_carta_de_porte, numero_ctg, + establecimiento=None, codigo_cosecha=None, peso_neto_carga=None, + **kwargs): + "Confirma arribo definitivo CTG" + ret = self.client.confirmarDefinitivo(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosConfirmarDefinitivo=dict( + cartaPorte=numero_carta_de_porte, + ctg=numero_ctg, + establecimiento=establecimiento, + codigoCosecha=codigo_cosecha, + pesoNeto=peso_neto_carga, + )))['response'] + self.__analizar_errores(ret) + datos = ret.get('datosResponse') + if datos: + self.CartaPorte = str(datos['cartaPorte']) + self.NumeroCTG = str(datos['ctg']) + self.FechaHora = str(datos['fechaHora']) + self.CodigoTransaccion = str(datos.get('codigoOperacion', "")) + self.Observaciones = "" + return self.CodigoTransaccion + + @inicializar_y_capturar_excepciones + def RegresarAOrigenCTGRechazado(self, numero_carta_de_porte, numero_ctg, + km_a_recorrer=None, + **kwargs): + "Al consultar los CTGs rechazados se puede Regresar a Origen" + ret = self.client.regresarAOrigenCTGRechazado(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosRegresarAOrigenCTGRechazado=dict( + cartaPorte=numero_carta_de_porte, + ctg=numero_ctg, kmARecorrer=km_a_recorrer, + )))['response'] + self.__analizar_errores(ret) + datos = ret.get('datosResponse') + if datos: + self.CartaPorte = str(datos['cartaPorte']) + self.NumeroCTG = str(datos['ctg']) + self.FechaHora = str(datos['fechaHora']) + self.CodigoTransaccion = str(datos['codigoOperacion']) + self.Observaciones = "" + return self.CodigoTransaccion + + @inicializar_y_capturar_excepciones + def CambiarDestinoDestinatarioCTGRechazado(self, numero_carta_de_porte, + numero_ctg, codigo_localidad_destino=None, + cuit_destino=None, cuit_destinatario=None, + km_a_recorrer=None, turno=None, + **kwargs): + "Tomar acción de Cambio de Destino y Destinatario para CTG rechazado" + ret = self.client.cambiarDestinoDestinatarioCTGRechazado(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + datosCambiarDestinoDestinatarioCTGRechazado=dict( + cartaPorte=numero_carta_de_porte, + ctg=numero_ctg, + codigoLocalidadDestino=codigo_localidad_destino, + cuitDestino=cuit_destino, + cuitDestinatario=cuit_destinatario, + kmARecorrer=km_a_recorrer, + turno=turno, + )))['response'] + self.__analizar_errores(ret) + datos = ret.get('datosResponse') + if datos: + self.CartaPorte = str(datos['cartaPorte']) + self.NumeroCTG = str(datos['ctg']) + self.FechaHora = str(datos['fechaHora']) + self.CodigoTransaccion = str(datos['codigoOperacion']) + self.Observaciones = "" + return self.CodigoTransaccion + + @inicializar_y_capturar_excepciones + def ConsultarCTG(self, numero_carta_de_porte=None, numero_ctg=None, + patente=None, cuit_solicitante=None, cuit_destino=None, + fecha_emision_desde=None, fecha_emision_hasta=None): + "Operación que realiza consulta de CTGs según el criterio ingresado." + ret = self.client.consultarCTG(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + consultarCTGDatos=dict( + cartaPorte=numero_carta_de_porte, + ctg=numero_ctg, + patente=patente, + cuitSolicitante=cuit_solicitante, + cuitDestino=cuit_destino, + fechaEmisionDesde=fecha_emision_desde, + fechaEmisionHasta=fecha_emision_hasta, + )))['response'] + self.__analizar_errores(ret) + datos = ret.get('arrayDatosConsultarCTG') + if datos: + self.DatosCTG = datos + self.LeerDatosCTG(pop=False) + return True + else: + self.DatosCTG = [] + return '' + + @inicializar_y_capturar_excepciones + def ConsultarCTGRechazados(self): + "Consulta de CTGs Otorgados, CTGs Rechazados y CTGs Confirmados" + ret = self.client.consultarCTGRechazados(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ))['response'] + self.__analizar_errores(ret) + datos = ret.get('arrayConsultarCTGRechazados') + if datos: + self.DatosCTG = datos + self.LeerDatosCTG(pop=False) + return True + else: + self.DatosCTG = [] + return False + + @inicializar_y_capturar_excepciones + def ConsultarCTGActivosPorPatente(self, patente="ZZZ999"): + "Consulta de CTGs activos por patente" + ret = self.client.consultarCTGActivosPorPatente(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + patente=patente, + ))['response'] + self.__analizar_errores(ret) + datos = ret.get('arrayConsultarCTGActivosPorPatenteResponse') + if datos: + self.DatosCTG = datos + self.LeerDatosCTG(pop=False) + return True + else: + self.DatosCTG = [] + return False + + @inicializar_y_capturar_excepciones + def CTGsPendientesResolucion(self): + "Consulta de CTGs Otorgados, CTGs Rechazados y CTGs Confirmados" + ret = self.client.CTGsPendientesResolucion(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ))['response'] + self.__analizar_errores(ret) + if ret: + self.DatosCTG = ret + return True + else: + self.DatosCTG = {} + return False + + def LeerDatosCTG(self, clave='', pop=True): + "Recorro los datos devueltos y devuelvo el primero si existe" + + if clave and self.DatosCTG: + # obtengo la lista por estado pendiente de resolución ("array") + datos = self.DatosCTG[clave] + else: + # uso directamente la lista devuelta por la consulta + datos = self.DatosCTG + if datos: + # extraigo el primer item + if pop: + datos = datos.pop(0) + else: + datos = datos[0] + for det in ('datosConsultarCTG', 'detalleConsultaCTGRechazado', + 'detalleConsultaCTGActivo'): + if det in datos: + datos_ctg = datos[det] + break + else: + # elemento del array no encontrado: + return "" + self.CartaPorte = str(datos_ctg['cartaPorte']) + self.NumeroCTG = str(datos_ctg['ctg']) + self.Estado = str(datos_ctg.get('estado', "")) + self.ImprimeConstancia = str(datos_ctg.get('imprimeConstancia', "")) + for campo in ("fechaRechazo", "fechaEmision", "fechaSolicitud", + "fechaConfirmacionArribo"): + if campo in datos_ctg: + self.FechaHora = str(datos_ctg.get(campo)) + self.Destino = datos_ctg.get("destino", "") + self.Destinatario = datos_ctg.get("destinatario", "") + self.Observaciones = datos_ctg.get("observaciones", "") + self.Patente = datos_ctg.get("patente") + self.PesoNeto = datos_ctg.get("pesoNeto") + self.FechaVencimiento = datos_ctg.get("fechaVencimiento") + self.UsuarioSolicitante = datos_ctg.get("usuarioSolicitante") + self.UsuarioReal = datos_ctg.get("usuarioReal") + self.CtcCodigo = datos_ctg.get("ctcCodigo") + self.Turno = datos_ctg.get("turno") + return self.NumeroCTG + else: + return "" + + @inicializar_y_capturar_excepciones + def ConsultarCTGExcel(self, numero_carta_de_porte=None, numero_ctg=None, + patente=None, cuit_solicitante=None, cuit_destino=None, + fecha_emision_desde=None, fecha_emision_hasta=None, + archivo="planilla.xls"): + "Operación que realiza consulta de CTGs, graba una planilla xls" + ret = self.client.consultarCTGExcel(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + consultarCTGDatos=dict( + cartaPorte=numero_carta_de_porte, + ctg=numero_ctg, + patente=patente, + cuitSolicitante=cuit_solicitante, + cuitDestino=cuit_destino, + fechaEmisionDesde=fecha_emision_desde, + fechaEmisionHasta=fecha_emision_hasta, + )))['response'] + self.__analizar_errores(ret) + datos = base64.b64decode(ret.get('archivo') or "") + f = open(archivo, "wb") + f.write(datos) + f.close() + return True + + @inicializar_y_capturar_excepciones + def ConsultarDetalleCTG(self, numero_ctg=None): + "Operación mostrar este detalle de la solicitud de CTG seleccionada." + ret = self.client.consultarDetalleCTG(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ctg=numero_ctg, + ))['response'] + self.__analizar_errores(ret) + datos = ret.get('consultarDetalleCTGDatos') + if datos: + self.NumeroCTG = str(datos['ctg']) + self.CartaPorte = str(datos['cartaPorte']) + self.Estado = str(datos['estado']) + self.FechaHora = str(datos['fechaEmision']) + self.VigenciaDesde = str(datos['fechaVigenciaDesde']) + self.VigenciaHasta = str(datos['fechaVigenciaHasta']) + self.TarifaReferencia = str(datos['tarifaReferencia']) + self.Detalle = str(datos.get('detalle', "")) + return True + + @inicializar_y_capturar_excepciones + def ConsultarConstanciaCTGPDF(self, numero_ctg=None, + archivo="constancia.pdf"): + "Operación Consultar Constancia de CTG en PDF" + ret = self.client.consultarConstanciaCTGPDF(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ctg=numero_ctg, + ))['response'] + self.__analizar_errores(ret) + datos = base64.b64decode(ret.get('archivo', "")) + f = open(archivo, "wb") + f.write(datos) + f.close() + return True + + @inicializar_y_capturar_excepciones + def ConsultarProvincias(self, sep="||"): + ret = self.client.consultarProvincias(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ))['consultarProvinciasResponse'] + self.__analizar_errores(ret) + array = ret.get('arrayProvincias', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['provincia']['codigo'], + it['provincia']['descripcion']) + for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarLocalidadesPorProvincia(self, codigo_provincia, sep="||"): + ret = self.client.consultarLocalidadesPorProvincia(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + codigoProvincia=codigo_provincia, + ))['response'] + self.__analizar_errores(ret) + array = ret.get('arrayLocalidades', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['localidad']['codigo'], + it['localidad']['descripcion']) + for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarEstablecimientos(self, sep="||"): + ret = self.client.consultarEstablecimientos(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ))['response'] + self.__analizar_errores(ret) + array = ret.get('arrayEstablecimientos', []) + return [("%s" % + (it['establecimiento'],)) + for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarEspecies(self, sep="||"): + ret = self.client.consultarEspecies(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ))['response'] + self.__analizar_errores(ret) + array = ret.get('arrayEspecies', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['especie']['codigo'], + it['especie']['descripcion']) + for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarCosechas(self, sep="||"): + ret = self.client.consultarCosechas(request=dict( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentado': self.Cuit, }, + ))['response'] + self.__analizar_errores(ret) + array = ret.get('arrayCosechas', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['cosecha']['codigo'], + it['cosecha']['descripcion']) + for it in array] + + +def leer_archivo(nombre_archivo): + archivo = open(nombre_archivo, "r") + items = [] + ext = os.path.splitext(nombre_archivo)[1] + if ext == '.csv': + csv_reader = csv.reader(open(ENTRADA), dialect='excel', delimiter=";") + for row in csv_reader: + items.append(row) + cols = [str(it).strip() for it in items[0]] + # armar diccionario por cada linea + items = [dict([(cols[i], str(v).strip()) for i, v in enumerate(item)]) for item in items[1:]] + return cols, items + elif ext == '.json': + items = json.load(archivo) + elif ext == '.dbf': + dic = {} + formatos = [('Encabezado', ENCABEZADO, dic), ] + leer_dbf(formatos, conf_dbf) + items = [dic] + elif ext == '.txt': + dic = {} + for linea in archivo: + if str(linea[0]) == '0': + dic.update(leer(linea, ENCABEZADO)) + else: + print("Tipo de registro incorrecto:", linea[0]) + items.append(dic) + else: + raise RuntimeError("Extension de archivo desconocida: %s" % ext) + archivo.close() + cols = [k[0] for k in ENCABEZADO] + return cols, items + + +def escribir_archivo(cols, items, nombre_archivo, agrega=False): + archivo = open(nombre_archivo, agrega and "a" or "w") + ext = os.path.splitext(nombre_archivo)[1] + if ext == '.csv': + csv_writer = csv.writer(archivo, dialect='excel', delimiter=";") + csv_writer.writerows([cols]) + csv_writer.writerows([[item[k] for k in cols] for item in items]) + elif ext == '.json': + json.dump(items, archivo, sort_keys=True, indent=4) + elif ext == '.dbf': + formatos = [('Encabezado', ENCABEZADO, items), ] + guardar_dbf(formatos, True, conf_dbf) + elif ext == '.txt': + for dic in items: + dic['tipo_reg'] = 0 + archivo.write(escribir(dic, ENCABEZADO)) + else: + raise RuntimeError("Extension de archivo desconocida: %s" % ext) + archivo.close() + + +class WSCTGv2(BaseWS): + _reg_progid_ = "WSCTGv2" + _reg_clsid_ = "{ACDEFB8A-34E1-48CF-94E8-6AF6ADA0717A}" + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +if not hasattr(sys, "frozen"): + basepath = __file__ +elif sys.frozen == 'dll': + import win32api + basepath = win32api.GetModuleFileName(sys.frozendllhandle) +else: + basepath = sys.executable +INSTALL_DIR = WSCTG.InstallDir = WSCTGv2.InstallDir = get_install_dir() + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + if '--formato' in sys.argv: + print("Formato:") + for msg, formato in [('Encabezado', ENCABEZADO), ]: + comienzo = 1 + print("=== %s ===" % msg) + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = len(fmt) > 3 and fmt[3] or (tipo == 'I' and '2' or '') + print(" * Campo: %-20s Posición: %3d Longitud: %4d Tipo: %s Decimales: %s" % ( + clave, comienzo, longitud, tipo, dec)) + comienzo += longitud + sys.exit(0) + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSCTG) + # Compatibilidad hacia atrás: + win32com.server.register.UseCommandLine(WSCTGv2) + sys.exit(0) + + import csv + from configparser import SafeConfigParser + + try: + + if "--version" in sys.argv: + print("Versión: ", __version__) + + for arg in sys.argv[1:]: + if arg.startswith("--"): + break + print("Usando configuración:", arg) + CONFIG_FILE = arg + + config = SafeConfigParser() + config.read(CONFIG_FILE) + CERT = config.get('WSAA', 'CERT') + PRIVATEKEY = config.get('WSAA', 'PRIVATEKEY') + CUIT = config.get('WSCTG', 'CUIT') + ENTRADA = config.get('WSCTG', 'ENTRADA') + SALIDA = config.get('WSCTG', 'SALIDA') + + if config.has_option('WSAA', 'URL') and not HOMO: + wsaa_url = config.get('WSAA', 'URL') + else: + wsaa_url = None + if config.has_option('WSCTG', 'URL') and not HOMO: + wsctg_url = config.get('WSCTG', 'URL') + else: + wsctg_url = WSDL + + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + if DEBUG: + print("conf_dbf", conf_dbf) + else: + conf_dbf = {} + + DEBUG = '--debug' in sys.argv + XML = '--xml' in sys.argv + + if DEBUG: + print("Usando Configuración:") + print("wsaa_url:", wsaa_url) + print("wsctg_url:", wsctg_url) + + # obteniendo el TA + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wsctg", CERT, PRIVATEKEY, wsaa_url, debug=DEBUG) + if not ta: + sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + + # cliente soap del web service + wsctg = WSCTG() + wsctg.Conectar(wsdl=wsctg_url) + wsctg.SetTicketAcceso(ta) + wsctg.Cuit = CUIT + + if '--dummy' in sys.argv: + ret = wsctg.Dummy() + print("AppServerStatus", wsctg.AppServerStatus) + print("DbServerStatus", wsctg.DbServerStatus) + print("AuthServerStatus", wsctg.AuthServerStatus) + sys.exit(0) + + if '--anular' in sys.argv: + i = sys.argv.index("--anular") + # print wsctg.client.help("anularCTG") + if i + 2 > len(sys.argv) or sys.argv[i + 1].startswith("--"): + carta_porte = input("Ingrese Carta de Porte: ") + ctg = input("Ingrese CTG: ") + else: + carta_porte = sys.argv[i + 1] + ctg = sys.argv[i + 2] + ret = wsctg.AnularCTG(carta_porte, ctg) + print("Carta Porte", wsctg.CartaPorte) + print("Numero CTG", wsctg.NumeroCTG) + print("Fecha y Hora", wsctg.FechaHora) + print("Codigo Anulacion de CTG", wsctg.CodigoOperacion) + print("Errores:", wsctg.Errores) + sys.exit(0) + + if '--rechazar' in sys.argv: + i = sys.argv.index("--rechazar") + # print wsctg.client.help("rechazarCTG") + if i + 3 > len(sys.argv) or sys.argv[i + 1].startswith("--"): + carta_porte = input("Ingrese Carta de Porte: ") + ctg = input("Ingrese CTG: ") + motivo = input("Motivo: ") + else: + carta_porte = sys.argv[i + 1] + ctg = sys.argv[i + 2] + motivo = sys.argv[i + 3] + ret = wsctg.RechazarCTG(carta_porte, ctg, motivo) + print("Carta Porte", wsctg.CartaPorte) + print("Numero CTG", wsctg.NumeroCTG) + print("Fecha y Hora", wsctg.FechaHora) + print("Codigo Anulacion de CTG", wsctg.CodigoOperacion) + print("Errores:", wsctg.Errores) + sys.exit(0) + + # Recuperar parámetros: + + if '--provincias' in sys.argv: + ret = wsctg.ConsultarProvincias() + print("\n".join(ret)) + + if '--localidades' in sys.argv: + ret = wsctg.ConsultarLocalidadesPorProvincia(16) + print("\n".join(ret)) + + if '--especies' in sys.argv: + ret = wsctg.ConsultarEspecies() + print("\n".join(ret)) + + if '--cosechas' in sys.argv: + ret = wsctg.ConsultarCosechas() + print("\n".join(ret)) + + if '--establecimientos' in sys.argv: + ret = wsctg.ConsultarEstablecimientos() + print("\n".join(ret)) + + if '--prueba' in sys.argv or '--formato' in sys.argv: + prueba = dict(numero_carta_de_porte=512345679, codigo_especie=23, + cuit_canjeador=0, # 30660685908, + cuit_destino=20111111112, cuit_destinatario=20222222223, + codigo_localidad_origen=3058, codigo_localidad_destino=3059, + codigo_cosecha='1314', peso_neto_carga=1000, + km_a_recorrer=1234, + observaciones='', establecimiento=1, + ) + if [argv for argv in sys.argv if argv.startswith(("--confirmar", + "--regresar", '--cambiar'))]: + prueba.update(dict( + numero_ctg="49241727", transaccion='10000001681', + consumo_propio='S', + )) + parcial = dict( + cant_horas=1, + patente_vehiculo='APE652', cuit_transportista=20333333334, + ) + if not '--parcial' in sys.argv: + prueba.update(parcial) + + escribir_archivo(list(prueba.keys()), [prueba], ENTRADA) + + cols, items = leer_archivo(ENTRADA) + ctg = None + + if '--solicitar' in sys.argv: + wsctg.LanzarExcepciones = True + for it in items: + print("solicitando...", ' '.join(['%s=%s' % (k, v) for k, v in list(it.items())])) + ctg = wsctg.SolicitarCTGInicial(**it) + print("numero CTG: ", ctg) + print("Observiacion: ", wsctg.Observaciones) + print("Carta Porte", wsctg.CartaPorte) + print("Numero CTG", wsctg.NumeroCTG) + print("Fecha y Hora", wsctg.FechaHora) + print("Vigencia Desde", wsctg.VigenciaDesde) + print("Vigencia Hasta", wsctg.VigenciaHasta) + print("Tarifa Referencia: ", wsctg.TarifaReferencia) + print("Errores:", wsctg.Errores) + print("Controles:", wsctg.Controles) + it['numero_ctg'] = wsctg.NumeroCTG + it['tarifa_referencia'] = wsctg.TarifaReferencia + it['observaciones'] = wsctg.Observaciones + it['fecha_hora'] = wsctg.FechaHora + it['vigencia_desde'] = wsctg.VigenciaDesde + it['vigencia_hasta'] = wsctg.VigenciaHasta + it['errores'] = '|'.join(wsctg.Errores) + it['controles'] = '|'.join(wsctg.Controles) + + if '--parcial' in sys.argv: + wsctg.LanzarExcepciones = True + for it in items: + print("solicitando dato pendiente...", ' '.join(['%s=%s' % (k, v) for k, v in list(parcial.items())])) + ctg = wsctg.SolicitarCTGDatoPendiente( + numero_carta_de_porte=wsctg.CartaPorte, + **parcial) + print("numero CTG: ", ctg) + print("Observiacion: ", wsctg.Observaciones) + print("Carta Porte", wsctg.CartaPorte) + print("Numero CTG", wsctg.NumeroCTG) + print("Fecha y Hora", wsctg.FechaHora) + print("Vigencia Desde", wsctg.VigenciaDesde) + print("Vigencia Hasta", wsctg.VigenciaHasta) + print("Tarifa Referencia: ", wsctg.TarifaReferencia) + print("Errores:", wsctg.Errores) + print("Controles:", wsctg.Controles) + it['numero_ctg'] = wsctg.NumeroCTG + it['tarifa_referencia'] = wsctg.TarifaReferencia + it['errores'] = '|'.join(wsctg.Errores) + it['controles'] = '|'.join(wsctg.Controles) + + if '--confirmar_arribo' in sys.argv: + for it in items: + print("confirmando...", ' '.join(['%s=%s' % (k, v) for k, v in list(it.items())])) + transaccion = wsctg.ConfirmarArribo(**it) + print("transaccion: %s" % (transaccion, )) + print("Fecha y Hora", wsctg.FechaHora) + print("Errores:", wsctg.Errores) + it['transaccion'] = transaccion + it['errores'] = '|'.join(wsctg.Errores) + it['controles'] = '|'.join(wsctg.Controles) + + if '--confirmar_definitivo' in sys.argv: + if '--testing' in sys.argv: + wsctg.LoadTestXML("wsctg_confirmar_def.xml") # cargo respuesta + for it in items: + print("confirmando...", ' '.join(['%s=%s' % (k, v) for k, v in list(it.items())])) + transaccion = wsctg.ConfirmarDefinitivo(**it) + print("transaccion: %s" % (transaccion, )) + print("Fecha y Hora", wsctg.FechaHora) + print("Errores:", wsctg.Errores) + it['transaccion'] = transaccion + it['errores'] = '|'.join(wsctg.Errores) + it['controles'] = '|'.join(wsctg.Errores) + + if '--regresar_a_origen_rechazado' in sys.argv: + for it in items: + print("regresando...", ' '.join(['%s=%s' % (k, v) for k, v in list(it.items())])) + transaccion = wsctg.RegresarAOrigenCTGRechazado(**it) + print("transaccion: %s" % (transaccion, )) + print("Fecha y Hora", wsctg.FechaHora) + print("Errores:", wsctg.Errores) + it['transaccion'] = transaccion + it['errores'] = '|'.join(wsctg.Errores) + it['controles'] = '|'.join(wsctg.Errores) + + if '--cambiar_destino_destinatario_rechazado' in sys.argv: + for it in items: + print("cambiando...", ' '.join(['%s=%s' % (k, v) for k, v in list(it.items())])) + transaccion = wsctg.CambiarDestinoDestinatarioCTGRechazado(**it) + print("transaccion: %s" % (transaccion, )) + print("Fecha y Hora", wsctg.FechaHora) + print("Errores:", wsctg.Errores) + it['transaccion'] = transaccion + it['errores'] = '|'.join(wsctg.Errores) + it['controles'] = '|'.join(wsctg.Errores) + + if '--consultar_detalle' in sys.argv: + i = sys.argv.index("--consultar_detalle") + if len(sys.argv) > i + 1 and not sys.argv[i + 1].startswith("--"): + ctg = int(sys.argv[i + 1]) + elif not ctg: + ctg = int(input("Numero de CTG: ") or '0') or 73714620 + + wsctg.LanzarExcepciones = True + for i, it in enumerate(items): + print("consultando detalle...", ctg) + ok = wsctg.ConsultarDetalleCTG(ctg) + print("Numero CTG: ", wsctg.NumeroCTG) + print("Tarifa Referencia: ", wsctg.TarifaReferencia) + print("Observiacion: ", wsctg.Observaciones) + print("Carta Porte", wsctg.CartaPorte) + print("Numero CTG", wsctg.NumeroCTG) + print("Fecha y Hora", wsctg.FechaHora) + print("Vigencia Desde", wsctg.VigenciaDesde) + print("Vigencia Hasta", wsctg.VigenciaHasta) + print("Errores:", wsctg.Errores) + print("Controles:", wsctg.Controles) + print("Detalle:", wsctg.Detalle) + it['numero_ctg'] = wsctg.NumeroCTG + it['observaciones'] = wsctg.Observaciones + it['fecha_hora'] = wsctg.FechaHora + it['vigencia_desde'] = wsctg.VigenciaDesde + it['vigencia_hasta'] = wsctg.VigenciaHasta + wsctg.AnalizarXml("XmlResponse") + for k, ki in list({'ctg': 'numero_ctg', 'solicitante': '', + 'estado': 'estado', + 'especie': '', # 'codigo_especie', no devuelve codigo! + 'cosecha': '', # 'codigo_cosecha', no devuelve codigo! + 'cuitCanjeador': 'cuit_canjeador', + 'cuitDestino': 'cuit_destino', + 'cuitDestinatario': 'cuit_destinatario', + 'cuitTransportista': 'cuit_transportista', + 'establecimiento': 'establecimiento', + 'localidadOrigen': 'localidad_origen', + 'localidadDestino': 'localidad_destino', + 'cantidadHoras': 'cantidad_horas', + 'patenteVehiculo': 'patente_vehiculo', + 'pesoNetoCarga': 'peso_neto_carga', + 'kmRecorridos': 'km_recorridos', + 'tarifaReferencia': 'tarifa_referencia', + 'ctcCodigo': 'ctc_codigo', + 'turno': 'turno', + }.items()): + v = wsctg.ObtenerTagXml('consultarDetalleCTGDatos', k) + print(k, v) + if ki.startswith("cuit") and v: + v = v[:11] + it[ki] = v + + escribir_archivo(cols, items, SALIDA) + + if "--consultar" in sys.argv: + wsctg.LanzarExcepciones = True + wsctg.ConsultarCTG(fecha_emision_desde="01/04/2012") + print("Numero CTG - Carta de Porte - Imprime Constancia - Estado") + while wsctg.LeerDatosCTG(): + print(wsctg.NumeroCTG, wsctg.CartaPorte, end=' ') + print(wsctg.ImprimeConstancia, wsctg.Estado, wsctg.FechaHora) + + if "--consultar_rechazados" in sys.argv: + wsctg.LanzarExcepciones = True + wsctg.ConsultarCTGRechazados() + print("Numero CTG - Carta de Porte - Fecha - Destino/Dest./Obs.") + while wsctg.LeerDatosCTG(): + print(wsctg.NumeroCTG, wsctg.CartaPorte, wsctg.FechaHora, end=' ') + print(wsctg.Destino, wsctg.Destinatario, wstcg.Observaciones) + + if "--consultar_activos_por_patente" in sys.argv: + i = sys.argv.index("--consultar_activos_por_patente") + if len(sys.argv) > i + 1 and not sys.argv[i + 1].startswith("--"): + patente = sys.argv[i + 1] + elif not ctg: + patente = input("Patente: ") or 'APE652' + wsctg.LanzarExcepciones = True + if '--testing' in sys.argv: + wsctg.LoadTestXML("wsctgv2_activos.xml") + wsctg.ConsultarCTGActivosPorPatente(patente=patente) + print("Numero CTG - Carta de Porte - Fecha - Peso Neto - Usuario") + while wsctg.LeerDatosCTG(): + print(wsctg.NumeroCTG, wsctg.CartaPorte, wsctg.Patente, end=' ') + print(wsctg.FechaHora, wsctg.FechaVencimiento, wsctg.PesoNeto, end=' ') + print(wsctg.UsuarioSolicitante, wsctg.UsuarioReal) + + if '--consultar_excel' in sys.argv: + archivo = input("Archivo a generar (planilla.xls): ") or \ + 'planilla.xls' + wsctg.LanzarExcepciones = True + ok = wsctg.ConsultarCTGExcel(fecha_emision_desde="01/04/2012", + archivo=archivo) + print("Errores:", wsctg.Errores) + + if '--consultar_constancia_pdf' in sys.argv: + i = sys.argv.index("--consultar_constancia_pdf") + if len(sys.argv) > i + 2 and not sys.argv[i + 1].startswith("--"): + ctg = int(sys.argv[i + 1]) + archivo = sys.argv[i + 2] + elif not ctg: + ctg = int(input("Numero de CTG: ") or '0') or 83139794 + archivo = input("Archivo a generar (constancia.pdf): ") or \ + 'constancia.pdf' + + wsctg.LanzarExcepciones = True + ok = wsctg.ConsultarConstanciaCTGPDF(ctg, archivo) + print("Errores:", wsctg.Errores) + + if "--pendientes" in sys.argv: + wsctg.LanzarExcepciones = True + wsctg.CTGsPendientesResolucion() + for clave in ("arrayCTGsRechazadosAResolver", + "arrayCTGsOtorgadosAResolver", + "arrayCTGsConfirmadosAResolver", ): + print(clave[6:]) + print("Numero CTG - Carta de Porte - Imprime Constancia - Estado") + while wsctg.LeerDatosCTG(clave): + print(wsctg.NumeroCTG, wsctg.CartaPorte, wsctg.FechaHora) + print(wsctg.Destino, wsctg.Destinatario, wsctg.Observaciones) + + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore")) + sys.exit(3) + except Exception as e: + ex = utils.exception_info() + print(ex) + if DEBUG: + raise + sys.exit(5) diff --git a/app/pyafipws/wsfev1.py b/app/pyafipws/wsfev1.py new file mode 100644 index 0000000000000000000000000000000000000000..a97170d3dee77459fdc35e70b3da46784a8c6ad4 --- /dev/null +++ b/app/pyafipws/wsfev1.py @@ -0,0 +1,1349 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""M�dulo para obtener CAE/CAEA, c�digo de autorizaci�n electr�nico webservice +WSFEv1 de AFIP (Factura Electr�nica Nacional - Proyecto Version 1 - 2.10) +Seg�n RG 2485/08, RG 2757/2010, RG 2904/2010 y RG2926/10 (CAE anticipado), +RG 3067/2011 (RS - Monotributo), RG 3571/2013 (Responsables inscriptos IVA), +RG 3668/2014 (Factura A IVA F.8001), RG 3749/2015 (R.I. y exentos) +RG 4004-E Alquiler de inmuebles con destino casa habitaci�n). +RG 4109-E Venta de bienes muebles registrables. +RG 4291/2018 R�gimen especial de emisi�n y almacenamiento electr�nico +M�s info: http://www.sistemasagiles.com.ar/trac/wiki/ProyectoWSFEv1 +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2010-2017 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.22b" + +import datetime +import decimal +import os +import sys +from .utils import verifica, inicializar_y_capturar_excepciones, BaseWS, get_install_dir + +HOMO = False # solo homologaci�n +TYPELIB = False # usar librer�a de tipos (TLB) +LANZAR_EXCEPCIONES = False # valor por defecto: True + +#WSDL = "https://www.sistemasagiles.com.ar/simulador/wsfev1/call/soap?WSDL=None" +WSDL = "https://wswhomo.afip.gov.ar/wsfev1/service.asmx?WSDL" +#WSDL = "file:///home/reingart/tmp/service.asmx.xml" + + +class WSFEv1(BaseWS): + "Interfaz para el WebService de Factura Electr�nica Version 1 - 2.10" + _public_methods_ = ['CrearFactura', 'AgregarIva', 'CAESolicitar', + 'AgregarTributo', 'AgregarCmpAsoc', 'AgregarOpcional', + 'AgregarComprador', + 'CompUltimoAutorizado', 'CompConsultar', + 'CAEASolicitar', 'CAEAConsultar', 'CAEARegInformativo', + 'CAEASinMovimientoInformar', + 'CAESolicitarX', 'CompTotXRequest', + 'IniciarFacturasX', 'AgregarFacturaX', 'LeerFacturaX', + 'ParamGetTiposCbte', + 'ParamGetTiposConcepto', + 'ParamGetTiposDoc', + 'ParamGetTiposIva', + 'ParamGetTiposMonedas', + 'ParamGetTiposOpcional', + 'ParamGetTiposTributos', + 'ParamGetTiposPaises', + 'ParamGetCotizacion', + 'ParamGetPtosVenta', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetTicketAcceso', 'GetParametro', + 'EstablecerCampoFactura', 'ObtenerCampoFactura', + 'Dummy', 'Conectar', 'DebugLog', 'SetTicketAcceso'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', 'Excepcion', 'LanzarExcepciones', + 'Resultado', 'Obs', 'Observaciones', 'Traceback', 'InstallDir', + 'CAE', 'Vencimiento', 'Eventos', 'Errores', 'ErrCode', 'ErrMsg', + 'Reprocesar', 'Reproceso', 'EmisionTipo', 'CAEA', + 'CbteNro', 'CbtDesde', 'CbtHasta', 'FechaCbte', + 'ImpTotal', 'ImpNeto', 'ImptoLiq', + 'ImpIVA', 'ImpOpEx', 'ImpTrib', 'FchCotiz',] + + _reg_progid_ = "WSFEv1" + _reg_clsid_ = "{CA0E604D-E3D7-493A-8880-F6CDD604185E}" + + if TYPELIB: + _typelib_guid_ = '{B1D7283C-3EC2-463E-89B4-11F5228E2A15}' + _typelib_version_ = 1, 18 + _com_interfaces_ = ['IWSFEv1'] + ##_reg_class_spec_ = "wsfev1.WSFEv1" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologaci�n' or '') + Reprocesar = True # recuperar automaticamente CAE emitidos + LanzarExcepciones = LANZAR_EXCEPCIONES + factura = None + facturas = None + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Resultado = self.Motivo = self.Reproceso = '' + self.LastID = self.LastCMP = self.CAE = self.CAEA = self.Vencimiento = '' + self.CbteNro = self.CbtDesde = self.CbtHasta = self.PuntoVenta = None + self.ImpTotal = self.ImpIVA = self.ImpOpEx = self.ImpNeto = self.ImptoLiq = self.ImpTrib = None + self.EmisionTipo = self.Periodo = self.Orden = "" + self.FechaCbte = self.FchVigDesde = self.FchVigHasta = self.FchTopeInf = self.FchProceso = "" + self.FchCotiz = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + if 'Errors' in ret: + errores = ret['Errors'] + for error in errores: + self.Errores.append("%s: %s" % ( + error['Err']['Code'], + error['Err']['Msg'], + )) + self.ErrCode = ' '.join([str(error['Err']['Code']) for error in errores]) + self.ErrMsg = '\n'.join(self.Errores) + if 'Events' in ret: + events = ret['Events'] + self.Eventos = ['%s: %s' % (evt['Evt']['Code'], evt['Evt']['Msg']) for evt in events] + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + result = self.client.FEDummy()['FEDummyResult'] + self.AppServerStatus = result.get('AppServer') + self.DbServerStatus = result.get('DbServer') + self.AuthServerStatus = result.get('AuthServer') + return True + + # los siguientes m�todos no est�n decorados para no limpiar propiedades + + def CrearFactura(self, concepto=1, tipo_doc=80, nro_doc="", tipo_cbte=1, punto_vta=0, + cbt_desde=0, cbt_hasta=0, imp_total=0.00, imp_tot_conc=0.00, imp_neto=0.00, + imp_iva=0.00, imp_trib=0.00, imp_op_ex=0.00, fecha_cbte="", fecha_venc_pago=None, + fecha_serv_desde=None, fecha_serv_hasta=None, #-- + moneda_id="PES", moneda_ctz="1.0000", caea=None, fecha_hs_gen=None, **kwargs + ): + + "Creo un objeto factura (interna)" + # Creo una factura electronica de exportaci�n + fact = {'tipo_doc': tipo_doc, 'nro_doc': nro_doc, + 'tipo_cbte': tipo_cbte, 'punto_vta': punto_vta, + 'cbt_desde': cbt_desde, 'cbt_hasta': cbt_hasta, + 'imp_total': imp_total, 'imp_tot_conc': imp_tot_conc, + 'imp_neto': imp_neto, 'imp_iva': imp_iva, + 'imp_trib': imp_trib, 'imp_op_ex': imp_op_ex, + 'fecha_cbte': fecha_cbte, + 'fecha_venc_pago': fecha_venc_pago, + 'moneda_id': moneda_id, 'moneda_ctz': moneda_ctz, + 'concepto': concepto, 'fecha_hs_gen': fecha_hs_gen, + 'cbtes_asoc': [], + 'tributos': [], + 'iva': [], + 'opcionales': [], + 'compradores': [], + } + if fecha_serv_desde: + fact['fecha_serv_desde'] = fecha_serv_desde + if fecha_serv_hasta: + fact['fecha_serv_hasta'] = fecha_serv_hasta + if caea: + fact['caea'] = caea + + self.factura = fact + return True + + def EstablecerCampoFactura(self, campo, valor): + if campo in self.factura or campo in ('fecha_serv_desde', 'fecha_serv_hasta', 'caea', 'fch_venc_cae', 'fecha_hs_gen'): + self.factura[campo] = valor + return True + else: + return False + + def AgregarCmpAsoc(self, tipo=1, pto_vta=0, nro=0, cuit=None, fecha=None, **kwarg): + "Agrego un comprobante asociado a una factura (interna)" + cmp_asoc = {'tipo': tipo, 'pto_vta': pto_vta, 'nro': nro} + if cuit is not None: + cmp_asoc['cuit'] = cuit + if fecha is not None: + cmp_asoc['fecha'] = fecha + self.factura['cbtes_asoc'].append(cmp_asoc) + return True + + def AgregarTributo(self, tributo_id=0, desc="", base_imp=0.00, alic=0, importe=0.00, **kwarg): + "Agrego un tributo a una factura (interna)" + tributo = {'tributo_id': tributo_id, 'desc': desc, 'base_imp': base_imp, + 'alic': alic, 'importe': importe} + self.factura['tributos'].append(tributo) + return True + + def AgregarIva(self, iva_id=0, base_imp=0.0, importe=0.0, **kwarg): + "Agrego un tributo a una factura (interna)" + iva = {'iva_id': iva_id, 'base_imp': base_imp, 'importe': importe} + self.factura['iva'].append(iva) + return True + + def AgregarOpcional(self, opcional_id=0, valor="", **kwarg): + "Agrego un dato opcional a una factura (interna)" + op = {'opcional_id': opcional_id, 'valor': valor} + self.factura['opcionales'].append(op) + return True + + def AgregarComprador(self, doc_tipo=80, doc_nro=0, porcentaje=100.00, **kwarg): + "Agrego un comprador a una factura (interna) RG 4109-E bienes muebles" + comp = {'doc_tipo': doc_tipo, 'doc_nro': doc_nro, + 'porcentaje': porcentaje} + self.factura['compradores'].append(comp) + return True + + def ObtenerCampoFactura(self, *campos): + "Obtener el valor devuelto de AFIP para un campo de factura" + # cada campo puede ser una clave string (dict) o una posici�n (list) + ret = self.factura + for campo in campos: + if isinstance(ret, dict) and isinstance(campo, str): + ret = ret.get(campo) + elif isinstance(ret, list) and len(ret) > campo: + ret = ret[campo] + else: + self.Excepcion = "El campo %s solicitado no existe" % campo + ret = None + if ret is None: + break + return str(ret) + + # metodos principales para llamar remotamente a AFIP: + + @inicializar_y_capturar_excepciones + def CAESolicitar(self): + f = self.factura + ret = self.client.FECAESolicitar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + FeCAEReq={ + 'FeCabReq': {'CantReg': 1, + 'PtoVta': f['punto_vta'], + 'CbteTipo': f['tipo_cbte']}, + 'FeDetReq': [{'FECAEDetRequest': { + 'Concepto': f['concepto'], + 'DocTipo': f['tipo_doc'], + 'DocNro': f['nro_doc'], + 'CbteDesde': f['cbt_desde'], + 'CbteHasta': f['cbt_hasta'], + 'CbteFch': f['fecha_cbte'], + 'ImpTotal': f['imp_total'], + 'ImpTotConc': f['imp_tot_conc'], + 'ImpNeto': f['imp_neto'], + 'ImpOpEx': f['imp_op_ex'], + 'ImpTrib': f['imp_trib'], + 'ImpIVA': f['imp_iva'], + # Fechas solo se informan si Concepto in (2,3) + 'FchServDesde': f.get('fecha_serv_desde'), + 'FchServHasta': f.get('fecha_serv_hasta'), + 'FchVtoPago': f.get('fecha_venc_pago'), + 'MonId': f['moneda_id'], + 'MonCotiz': f['moneda_ctz'], + 'CbtesAsoc': f['cbtes_asoc'] and [ + {'CbteAsoc': { + 'Tipo': cbte_asoc['tipo'], + 'PtoVta': cbte_asoc['pto_vta'], + 'Nro': cbte_asoc['nro'], + 'Cuit': cbte_asoc.get('cuit'), + 'CbteFch': cbte_asoc.get('fecha'), + }} + for cbte_asoc in f['cbtes_asoc']] or None, + 'Tributos': f['tributos'] and [ + {'Tributo': { + 'Id': tributo['tributo_id'], + 'Desc': tributo['desc'], + 'BaseImp': tributo['base_imp'], + 'Alic': tributo['alic'], + 'Importe': tributo['importe'], + }} + for tributo in f['tributos']] or None, + 'Iva': f['iva'] and [ + {'AlicIva': { + 'Id': iva['iva_id'], + 'BaseImp': iva['base_imp'], + 'Importe': iva['importe'], + }} + for iva in f['iva']] or None, + 'Opcionales': [ + {'Opcional': { + 'Id': opcional['opcional_id'], + 'Valor': opcional['valor'], + }} for opcional in f['opcionales']] or None, + 'Compradores': [ + {'Comprador': { + 'DocTipo': comprador['doc_tipo'], + 'DocNro': comprador['doc_nro'], + 'Porcentaje': comprador['porcentaje'], + }} for comprador in f['compradores']] or None, + } + }] + }) + + result = ret['FECAESolicitarResult'] + if 'FeCabResp' in result: + fecabresp = result['FeCabResp'] + fedetresp = result['FeDetResp'][0]['FECAEDetResponse'] + + # Reprocesar en caso de error (recuperar CAE emitido anteriormente) + if self.Reprocesar and ('Errors' in result or 'Observaciones' in fedetresp): + for error in result.get('Errors', []) + fedetresp.get('Observaciones', []): + err_code = str(error.get('Err', error.get('Obs'))['Code']) + if fedetresp['Resultado'] == 'R' and err_code == '10016': + # guardo los mensajes xml originales + xml_request = self.client.xml_request + xml_response = self.client.xml_response + cae = self.CompConsultar(f['tipo_cbte'], f['punto_vta'], f['cbt_desde'], reproceso=True) + if cae and self.EmisionTipo == 'CAE': + self.Reproceso = 'S' + return cae + self.Reproceso = 'N' + # reestablesco los mensajes xml originales + self.client.xml_request = xml_request + self.client.xml_response = xml_response + + self.Resultado = fecabresp['Resultado'] + # Obs: + for obs in fedetresp.get('Observaciones', []): + self.Observaciones.append("%(Code)s: %(Msg)s" % (obs['Obs'])) + self.Obs = '\n'.join(self.Observaciones) + self.CAE = fedetresp['CAE'] and str(fedetresp['CAE']) or "" + self.EmisionTipo = 'CAE' + self.Vencimiento = fedetresp['CAEFchVto'] + self.FechaCbte = fedetresp.get('CbteFch', "") # .strftime("%Y/%m/%d") + self.CbteNro = fedetresp.get('CbteHasta', 0) # 1L + self.PuntoVenta = fecabresp.get('PtoVta', 0) # 4000 + self.CbtDesde = fedetresp.get('CbteDesde', 0) + self.CbtHasta = fedetresp.get('CbteHasta', 0) + self.__analizar_errores(result) + return self.CAE + + @inicializar_y_capturar_excepciones + def CompTotXRequest(self): + ret = self.client.FECompTotXRequest( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + + result = ret['FECompTotXRequestResult'] + return result['RegXReq'] + + @inicializar_y_capturar_excepciones + def CompUltimoAutorizado(self, tipo_cbte, punto_vta): + ret = self.client.FECompUltimoAutorizado( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + PtoVta=punto_vta, + CbteTipo=tipo_cbte, + ) + + result = ret['FECompUltimoAutorizadoResult'] + self.CbteNro = result['CbteNro'] + self.__analizar_errores(result) + return self.CbteNro is not None and str(self.CbteNro) or '' + + @inicializar_y_capturar_excepciones + def CompConsultar(self, tipo_cbte, punto_vta, cbte_nro, reproceso=False): + difs = [] # si hay reproceso, verifico las diferencias con AFIP + + ret = self.client.FECompConsultar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + FeCompConsReq={ + 'CbteTipo': tipo_cbte, + 'CbteNro': cbte_nro, + 'PtoVta': punto_vta, + }) + + result = ret['FECompConsultarResult'] + if 'ResultGet' in result: + resultget = result['ResultGet'] + + if reproceso: + # verifico los campos registrados coincidan con los enviados: + f = self.factura + verificaciones = { + 'Concepto': f['concepto'], + 'DocTipo': f['tipo_doc'], + 'DocNro': f['nro_doc'], + 'CbteTipo': f['tipo_cbte'], + 'CbteDesde': f['cbt_desde'], + 'CbteHasta': f['cbt_hasta'], + 'CbteFch': f['fecha_cbte'], + 'ImpTotal': f['imp_total'] and float(f['imp_total']) or 0.0, + 'ImpTotConc': f['imp_tot_conc'] and float(f['imp_tot_conc']) or 0.0, + 'ImpNeto': f['imp_neto'] and float(f['imp_neto']) or 0.0, + 'ImpOpEx': f['imp_op_ex'] and float(f['imp_op_ex']) or 0.0, + 'ImpTrib': f['imp_trib'] and float(f['imp_trib']) or 0.0, + 'ImpIVA': f['imp_iva'] and float(f['imp_iva']) or 0.0, + 'FchServDesde': f.get('fecha_serv_desde'), + 'FchServHasta': f.get('fecha_serv_hasta'), + 'FchVtoPago': f.get('fecha_venc_pago'), + 'MonId': f['moneda_id'], + 'MonCotiz': float(f['moneda_ctz']), + 'CbtesAsoc': [ + {'CbteAsoc': { + 'Tipo': cbte_asoc['tipo'], + 'PtoVta': cbte_asoc['pto_vta'], + 'Nro': cbte_asoc['nro'], + 'Cuit': cbte_asoc.get('cuit'), + 'CbteFch': cbte_asoc.get('fecha') or None, + }} + for cbte_asoc in f['cbtes_asoc']], + 'Tributos': [ + {'Tributo': { + 'Id': tributo['tributo_id'], + 'Desc': tributo['desc'], + 'BaseImp': float(tributo['base_imp'] or 0), + 'Alic': float(tributo['alic'] or 0), + 'Importe': float(tributo['importe']), + }} + for tributo in f['tributos']], + 'Iva': [ + {'AlicIva': { + 'Id': iva['iva_id'], + 'BaseImp': float(iva['base_imp']), + 'Importe': float(iva['importe']), + }} + for iva in f['iva']], + 'Opcionales': [ + {'Opcional': { + 'Id': opcional['opcional_id'], + 'Valor': opcional['valor'], + }} for opcional in f['opcionales']], + 'Compradores': [ + {'Comprador': { + 'DocTipo': comprador['doc_tipo'], + 'DocNro': comprador['doc_nro'], + 'Porcentaje': comprador['porcentaje'], + }} for comprador in f['compradores']], + } + verifica(verificaciones, resultget.copy(), difs) + if difs: + print("Diferencias:", difs) + self.log("Diferencias: %s" % difs) + else: + # guardo los datos de AFIP (reconstruyo estructura interna) + self.factura = { + 'concepto': resultget.get('Concepto'), + 'tipo_doc': resultget.get('DocTipo'), + 'nro_doc': resultget.get('DocNro'), + 'tipo_cbte': resultget.get('CbteTipo'), + 'punto_vta': resultget.get('PtoVta'), + 'cbt_desde': resultget.get('CbteDesde'), + 'cbt_hasta': resultget.get('CbteHasta'), + 'fecha_cbte': resultget.get('CbteFch'), + 'imp_total': resultget.get('ImpTotal'), + 'imp_tot_conc': resultget.get('ImpTotConc'), + 'imp_neto': resultget.get('ImpNeto'), + 'imp_op_ex': resultget.get('ImpOpEx'), + 'imp_trib': resultget.get('ImpTrib'), + 'imp_iva': resultget.get('ImpIVA'), + 'fecha_serv_desde': resultget.get('FchServDesde'), + 'fecha_serv_hasta': resultget.get('FchServHasta'), + 'fecha_venc_pago': resultget.get('FchVtoPago'), + 'moneda_id': resultget.get('MonId'), + 'moneda_ctz': resultget.get('MonCotiz'), + 'cbtes_asoc': [ + { + 'tipo': cbte_asoc['CbteAsoc']['Tipo'], + 'pto_vta': cbte_asoc['CbteAsoc']['PtoVta'], + 'nro': cbte_asoc['CbteAsoc']['Nro'], + 'cuit': cbte_asoc['CbteAsoc'].get('Cuit'), + 'fecha': cbte_asoc['CbteAsoc'].get('CbteFch'), + } + for cbte_asoc in resultget.get('CbtesAsoc', [])], + 'tributos': [ + { + 'tributo_id': tributo['Tributo']['Id'], + 'desc': tributo['Tributo']['Desc'], + 'base_imp': tributo['Tributo'].get('BaseImp'), + 'alic': tributo['Tributo'].get('Alic'), + 'importe': tributo['Tributo']['Importe'], + } + for tributo in resultget.get('Tributos', [])], + 'iva': [ + { + 'iva_id': iva['AlicIva']['Id'], + 'base_imp': iva['AlicIva']['BaseImp'], + 'importe': iva['AlicIva']['Importe'], + } + for iva in resultget.get('Iva', [])], + 'opcionales': [ + { + 'opcional_id': obs['Opcional']['Id'], + 'valor': obs['Opcional']['Valor'], + } + for obs in resultget.get('Opcionales', [])], + 'compradores': [ + { + 'doc_tipo': comp['Comprador']['DocTipo'], + 'doc_nro': comp['Comprador']['DocNro'], + 'porcentaje': comp['Comprador']['Porcentaje'], + } + for comp in resultget.get('Compradores', [])], + 'cae': resultget.get('CodAutorizacion'), + 'resultado': resultget.get('Resultado'), + 'fch_venc_cae': resultget.get('FchVto'), + 'obs': [ + { + 'code': obs['Obs']['Code'], + 'msg': obs['Obs']['Msg'], + } + for obs in resultget.get('Observaciones', [])], + } + + self.FechaCbte = resultget['CbteFch'] # .strftime("%Y/%m/%d") + self.CbteNro = resultget['CbteHasta'] # 1L + self.PuntoVenta = resultget['PtoVta'] # 4000 + self.Vencimiento = resultget['FchVto'] # .strftime("%Y/%m/%d") + self.ImpTotal = str(resultget['ImpTotal']) + cod_aut = resultget['CodAutorizacion'] and str(resultget['CodAutorizacion']) or '' # 60423794871430L + self.Resultado = resultget['Resultado'] + self.CbtDesde = resultget['CbteDesde'] + self.CbtHasta = resultget['CbteHasta'] + self.ImpTotal = resultget['ImpTotal'] + self.ImpNeto = resultget['ImpNeto'] + self.ImptoLiq = self.ImpIVA = resultget['ImpIVA'] + self.ImpOpEx = resultget['ImpOpEx'] + self.ImpTrib = resultget['ImpTrib'] + self.EmisionTipo = resultget['EmisionTipo'] + if self.EmisionTipo == 'CAE': + self.CAE = cod_aut + elif self.EmisionTipo == 'CAEA': + self.CAEA = cod_aut + # Obs: + for obs in resultget.get('Observaciones', []): + self.Observaciones.append("%(Code)s: %(Msg)s" % (obs['Obs'])) + self.Obs = '\n'.join(self.Observaciones) + + self.__analizar_errores(result) + if not difs: + return self.CAE or self.CAEA + else: + return '' + + @inicializar_y_capturar_excepciones + def CAESolicitarX(self): + "Autorizar m�ltiples facturas (CAE) en una �nica solicitud" + # Ver CompTotXRequest -> cantidad maxima comprobantes (250) + # verificar que hay multiples facturas: + if not self.facturas: + raise RuntimeError("Llamar a IniciarFacturasX y AgregarFacturaX!") + # verificar que todas las facturas + puntos_vta = set([f['punto_vta'] for f in self.facturas]) + tipos_cbte = set([f['tipo_cbte'] for f in self.facturas]) + if len(puntos_vta) > 1: + raise RuntimeError("Los comprobantes deben ser del mismo pto_vta!") + if len(tipos_cbte) > 1: + raise RuntimeError("Los comprobantes deben tener el mismo tipo!") + # llamar al webservice: + ret = self.client.FECAESolicitar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + FeCAEReq={ + 'FeCabReq': {'CantReg': len(self.facturas), + 'PtoVta': puntos_vta.pop(), + 'CbteTipo': tipos_cbte.pop()}, + 'FeDetReq': [{'FECAEDetRequest': { + 'Concepto': f['concepto'], + 'DocTipo': f['tipo_doc'], + 'DocNro': f['nro_doc'], + 'CbteDesde': f['cbt_desde'], + 'CbteHasta': f['cbt_hasta'], + 'CbteFch': f['fecha_cbte'], + 'ImpTotal': f['imp_total'], + 'ImpTotConc': f['imp_tot_conc'], + 'ImpNeto': f['imp_neto'], + 'ImpOpEx': f['imp_op_ex'], + 'ImpTrib': f['imp_trib'], + 'ImpIVA': f['imp_iva'], + # Fechas solo se informan si Concepto in (2,3) + 'FchServDesde': f.get('fecha_serv_desde'), + 'FchServHasta': f.get('fecha_serv_hasta'), + 'FchVtoPago': f.get('fecha_venc_pago'), + 'MonId': f['moneda_id'], + 'MonCotiz': f['moneda_ctz'], + 'CbtesAsoc': [ + {'CbteAsoc': { + 'Tipo': cbte_asoc['tipo'], + 'PtoVta': cbte_asoc['pto_vta'], + 'Nro': cbte_asoc['nro'], + 'Cuit': cbte_asoc.get('cuit'), + 'CbteFch': cbte_asoc.get('fecha'), + }} + for cbte_asoc in f['cbtes_asoc']] or None, + 'Tributos': [ + {'Tributo': { + 'Id': tributo['tributo_id'], + 'Desc': tributo['desc'], + 'BaseImp': tributo['base_imp'], + 'Alic': tributo['alic'], + 'Importe': tributo['importe'], + }} + for tributo in f['tributos']] or None, + 'Iva': [ + {'AlicIva': { + 'Id': iva['iva_id'], + 'BaseImp': iva['base_imp'], + 'Importe': iva['importe'], + }} + for iva in f['iva']] or None, + 'Opcionales': [ + {'Opcional': { + 'Id': opcional['opcional_id'], + 'Valor': opcional['valor'], + }} for opcional in f['opcionales']] or None, + } + } for f in self.facturas] + }) + + result = ret['FECAESolicitarResult'] + if 'FeCabResp' in result: + fecabresp = result['FeCabResp'] + for i, fedetresp in enumerate(result['FeDetResp']): + fedetresp = fedetresp['FECAEDetResponse'] + f = self.facturas[i] + # actualizar los campos devueltos por AFIP para cada comp. + f["resultado"] = fedetresp['Resultado'] + f["cae"] = fedetresp['CAE'] and str(fedetresp['CAE']) or "" + f["emision_tipo"] = 'CAE' + f["fch_venc_cae"] = fedetresp['CAEFchVto'] + f["obs"] = [ + {'code': obs['Obs']['Code'], 'msg': obs['Obs']['Msg']} + for obs in fedetresp.get('Observaciones', [])] + # sanity checks: + assert str(f["fecha_cbte"]) == str(fedetresp['CbteFch']) + assert str(f["cbt_desde"]) == str(fedetresp['CbteDesde']) + assert str(f["cbt_hasta"]) == str(fedetresp['CbteHasta']) + assert str(f["punto_vta"]) == str(fecabresp['PtoVta']) + assert str(f["tipo_cbte"]) == str(fecabresp['CbteTipo']) + assert str(f["tipo_doc"]) == str(fedetresp['DocTipo']) + assert str(f["nro_doc"]) == str(fedetresp['DocNro']) + assert str(f["concepto"]) == str(fedetresp['Concepto']) + + self.__analizar_errores(result) + assert fecabresp['CantReg'] == len(self.facturas) + return fecabresp['CantReg'] + + # metodos auxiliares para soporte de multiples comprobantes por solicitud: + + def IniciarFacturasX(self): + "Inicializa lista de facturas para Solicitar multiples CAE" + self.facturas = [] + return True + + def AgregarFacturaX(self): + "Agrega una factura a la lista para Solicitar multiples CAE" + self.facturas.append(self.factura) + return True + + def LeerFacturaX(self, i): + "Activa internamente una factura para usar ObtenerCampoFactura" + try: + # obtengo la factura segun el indice en la lista: + f = self.factura = self.facturas[i] + # completar propiedades para retro-compatibilidad: + self.FechaCbte = f['fecha_cbte'] + self.PuntoVenta = f['punto_vta'] + self.Vencimiento = f['fch_venc_cae'] + self.Resultado = f['resultado'] + self.CbtDesde = f['cbt_desde'] + self.CbtHasta = f['cbt_hasta'] + self.ImpTotal = str(f['imp_total']) + self.ImpNeto = str(f.get('imp_neto')) + self.ImptoLiq = self.ImpIVA = str(f.get('imp_iva')) + self.ImpOpEx = str(f.get('imp_op_ex')) + self.ImpTrib = str(f.get('imp_trib')) + self.EmisionTipo = f['emision_tipo'] + if self.EmisionTipo == 'CAE': + self.CAE = f['cae'] + elif self.EmisionTipo == 'CAEA': + self.CAEA = f['caea'] + # Obs: + self.Observaciones = [] + for obs in f.get('obs', []): + self.Observaciones.append("%(code)s: %(msg)s" % (obs)) + self.Obs = '\n'.join(self.Observaciones) + return True + except BaseException: + return False + + # metodos para CAEA: + + @inicializar_y_capturar_excepciones + def CAEASolicitar(self, periodo, orden): + ret = self.client.FECAEASolicitar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Periodo=periodo, + Orden=orden, + ) + + result = ret['FECAEASolicitarResult'] + self.__analizar_errores(result) + + if 'ResultGet' in result: + result = result['ResultGet'] + if 'CAEA' in result: + self.CAEA = result['CAEA'] + self.Periodo = result['Periodo'] + self.Orden = result['Orden'] + self.FchVigDesde = result['FchVigDesde'] + self.FchVigHasta = result['FchVigHasta'] + self.FchTopeInf = result['FchTopeInf'] + self.FchProceso = result['FchProceso'] + # Obs (COMPGv28): + for obs in result.get('Observaciones', []): + self.Observaciones.append("%(Code)s: %(Msg)s" % (obs['Obs'])) + self.Obs = '\n'.join(self.Observaciones) + + return self.CAEA and str(self.CAEA) or '' + + @inicializar_y_capturar_excepciones + def CAEAConsultar(self, periodo, orden): + "M�todo de consulta de CAEA" + ret = self.client.FECAEAConsultar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Periodo=periodo, + Orden=orden, + ) + + result = ret['FECAEAConsultarResult'] + self.__analizar_errores(result) + + if 'ResultGet' in result: + result = result['ResultGet'] + if 'CAEA' in result: + self.CAEA = result['CAEA'] + self.Periodo = result['Periodo'] + self.Orden = result['Orden'] + self.FchVigDesde = result['FchVigDesde'] + self.FchVigHasta = result['FchVigHasta'] + self.FchTopeInf = result['FchTopeInf'] + self.FchProceso = result['FchProceso'] + + return self.CAEA and str(self.CAEA) or '' + + @inicializar_y_capturar_excepciones + def CAEARegInformativo(self): + "M�todo para informar comprobantes emitidos con CAEA" + f = self.factura + ret = self.client.FECAEARegInformativo( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + FeCAEARegInfReq={ + 'FeCabReq': {'CantReg': 1, + 'PtoVta': f['punto_vta'], + 'CbteTipo': f['tipo_cbte']}, + 'FeDetReq': [{'FECAEADetRequest': { + 'Concepto': f['concepto'], + 'DocTipo': f['tipo_doc'], + 'DocNro': f['nro_doc'], + 'CbteDesde': f['cbt_desde'], + 'CbteHasta': f['cbt_hasta'], + 'CbteFch': f['fecha_cbte'], + 'ImpTotal': f['imp_total'], + 'ImpTotConc': f['imp_tot_conc'], + 'ImpNeto': f['imp_neto'], + 'ImpOpEx': f['imp_op_ex'], + 'ImpTrib': f['imp_trib'], + 'ImpIVA': f['imp_iva'], + # Fechas solo se informan si Concepto in (2,3) + 'FchServDesde': f.get('fecha_serv_desde'), + 'FchServHasta': f.get('fecha_serv_hasta'), + 'FchVtoPago': f.get('fecha_venc_pago'), + 'MonId': f['moneda_id'], + 'MonCotiz': f['moneda_ctz'], + 'CbtesAsoc': [ + {'CbteAsoc': { + 'Tipo': cbte_asoc['tipo'], + 'PtoVta': cbte_asoc['pto_vta'], + 'Nro': cbte_asoc['nro'], + 'Cuit': cbte_asoc.get('cuit'), + 'CbteFch': cbte_asoc.get('fecha'), + }} + for cbte_asoc in f['cbtes_asoc']] + if f['cbtes_asoc'] else None, + 'Tributos': [ + {'Tributo': { + 'Id': tributo['tributo_id'], + 'Desc': tributo['desc'], + 'BaseImp': tributo['base_imp'], + 'Alic': tributo['alic'], + 'Importe': tributo['importe'], + }} + for tributo in f['tributos']] + if f['tributos'] else None, + 'Iva': [ + {'AlicIva': { + 'Id': iva['iva_id'], + 'BaseImp': iva['base_imp'], + 'Importe': iva['importe'], + }} + for iva in f['iva']] + if f['iva'] else None, + 'Opcionales': [ + {'Opcional': { + 'Id': opcional['opcional_id'], + 'Valor': opcional['valor'], + }} for opcional in f['opcionales']] or None, + 'CAEA': f['caea'], + 'CbteFchHsGen': f.get('fecha_hs_gen'), + } + }] + }) + + result = ret['FECAEARegInformativoResult'] + if 'FeCabResp' in result: + fecabresp = result['FeCabResp'] + fedetresp = result['FeDetResp'][0]['FECAEADetResponse'] + + # Reprocesar en caso de error (recuperar CAE emitido anteriormente) + if self.Reprocesar and 'Errors' in result: + for error in result['Errors']: + err_code = str(error['Err']['Code']) + if fedetresp['Resultado'] == 'R' and err_code == '703': + caea = self.CompConsultar(f['tipo_cbte'], f['punto_vta'], f['cbt_desde'], reproceso=True) + if caea and self.EmisionTipo == 'CAE': + self.Reproceso = 'S' + return caea + self.Reproceso = 'N' + + self.Resultado = fecabresp['Resultado'] + # Obs: + for obs in fedetresp.get('Observaciones', []): + self.Observaciones.append("%(Code)s: %(Msg)s" % (obs['Obs'])) + self.Obs = '\n'.join(self.Observaciones) + self.CAEA = fedetresp['CAEA'] and str(fedetresp['CAEA']) or "" + self.EmisionTipo = 'CAEA' + self.FechaCbte = fedetresp['CbteFch'] # .strftime("%Y/%m/%d") + self.CbteNro = fedetresp['CbteHasta'] # 1L + self.PuntoVenta = fecabresp['PtoVta'] # 4000 + self.CbtDesde = fedetresp['CbteDesde'] + self.CbtHasta = fedetresp['CbteHasta'] + self.__analizar_errores(result) + return self.CAEA + + @inicializar_y_capturar_excepciones + def CAEASinMovimientoInformar(self, punto_vta, caea): + "M�todo para informar CAEA sin movimiento" + ret = self.client.FECAEASinMovimientoInformar( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + PtoVta=punto_vta, + CAEA=caea, + ) + + result = ret['FECAEASinMovimientoInformarResult'] + self.__analizar_errores(result) + + if 'CAEA' in result: + self.CAEA = result['CAEA'] + if 'FchProceso' in result: + self.FchProceso = result['FchProceso'] + if 'Resultado' in result: + self.Resultado = result['Resultado'] + self.PuntoVenta = result['PtoVta'] # 4000 + + return self.Resultado or '' + + @inicializar_y_capturar_excepciones + def ParamGetTiposCbte(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Tipos de Comprobantes" + ret = self.client.FEParamGetTiposCbte( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposCbteResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['CbteTipo']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetTiposConcepto(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Tipos de Conceptos" + ret = self.client.FEParamGetTiposConcepto( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposConceptoResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['ConceptoTipo']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetTiposDoc(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Tipos de Documentos" + ret = self.client.FEParamGetTiposDoc( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposDocResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['DocTipo']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetTiposIva(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Tipos de Al�cuotas" + ret = self.client.FEParamGetTiposIva( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposIvaResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['IvaTipo']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetTiposMonedas(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Monedas" + ret = self.client.FEParamGetTiposMonedas( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposMonedasResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['Moneda']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetTiposOpcional(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Tipos de datos opcionales" + ret = self.client.FEParamGetTiposOpcional( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposOpcionalResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['OpcionalTipo']).replace("\t", sep) + for p in res.get('ResultGet', [])] + + @inicializar_y_capturar_excepciones + def ParamGetTiposTributos(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Tipos de Tributos" + "Este m�todo permite consultar los tipos de tributos habilitados en este WS" + ret = self.client.FEParamGetTiposTributos( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposTributosResult'] + return [("%(Id)s\t%(Desc)s\t%(FchDesde)s\t%(FchHasta)s" % p['TributoTipo']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetTiposPaises(self, sep="|"): + "Recuperador de valores referenciales de c�digos de Paises" + "Este m�todo permite consultar los tipos de tributos habilitados en este WS" + ret = self.client.FEParamGetTiposPaises( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret['FEParamGetTiposPaisesResult'] + return [("%(Id)s\t%(Desc)s" % p['PaisTipo']).replace("\t", sep) + for p in res['ResultGet']] + + @inicializar_y_capturar_excepciones + def ParamGetCotizacion(self, moneda_id): + "Recuperador de cotizaci�n de moneda" + ret = self.client.FEParamGetCotizacion( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + MonId=moneda_id, + ) + self.__analizar_errores(ret) + res = ret['FEParamGetCotizacionResult']['ResultGet'] + self.FchCotiz = res.get("FchCotiz") + return str(res.get('MonCotiz', "")) + + @inicializar_y_capturar_excepciones + def ParamGetPtosVenta(self, sep="|"): + "Recuperador de valores referenciales Puntos de Venta registrados" + ret = self.client.FEParamGetPtosVenta( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + res = ret.get('FEParamGetPtosVentaResult', {}) + return [("%(Nro)s\tEmisionTipo:%(EmisionTipo)s\tBloqueado:%(Bloqueado)s\tFchBaja:%(FchBaja)s" % p['PtoVenta']).replace("\t", sep) + for p in res.get('ResultGet', [])] + + +def p_assert_eq(a, b): + print(a, a == b and '==' or '!=', b) + + +def main(): + "Funci�n principal de pruebas (obtener CAE)" + import os + import time + + DEBUG = '--debug' in sys.argv + + if DEBUG: + from pysimplesoap.client import __version__ as soapver + print("pysimplesoap.__version__ = ", soapver) + + wsfev1 = WSFEv1() + wsfev1.LanzarExcepciones = True + + cache = None + if "--prod" in sys.argv: + wsdl = "https://servicios1.afip.gov.ar/wsfev1/service.asmx?WSDL" + else: + wsdl = WSDL + proxy = "" + wrapper = "" # "pycurl" + cacert = "conf/afip_ca_info.crt" + + ok = wsfev1.Conectar(cache, wsdl, proxy, wrapper, cacert) + + if not ok: + raise RuntimeError(wsfev1.Excepcion) + + if DEBUG: + print("LOG: ", wsfev1.DebugLog()) + + if "--dummy" in sys.argv: + print(wsfev1.client.help("FEDummy")) + wsfev1.Dummy() + print("AppServerStatus", wsfev1.AppServerStatus) + print("DbServerStatus", wsfev1.DbServerStatus) + print("AuthServerStatus", wsfev1.AuthServerStatus) + sys.exit(0) + + # obteniendo el TA para pruebas + from .wsaa import WSAA + ta = WSAA().Autenticar("wsfe", "reingart.crt", "reingart.key", debug=True) + wsfev1.SetTicketAcceso(ta) + wsfev1.Cuit = "20267565393" + + if "--prueba" in sys.argv: + print(wsfev1.client.help("FECAESolicitar").encode("latin1")) + + if '--usados' in sys.argv: + tipo_cbte = 49 + concepto = 1 + elif '--fce' in sys.argv: + tipo_cbte = 203 + concepto = 1 + else: + tipo_cbte = 3 + concepto = 3 if ('--rg4109' not in sys.argv) else 1 + punto_vta = 4001 + cbte_nro = int(wsfev1.CompUltimoAutorizado(tipo_cbte, punto_vta) or 0) + fecha = datetime.datetime.now().strftime("%Y%m%d") + tipo_doc = 80 if '--usados' not in sys.argv else 30 + nro_doc = "30500010912" + cbt_desde = cbte_nro + 1 + cbt_hasta = cbte_nro + 1 + imp_total = "222.00" + imp_tot_conc = "0.00" + imp_neto = "200.00" + imp_iva = "21.00" + imp_trib = "1.00" + imp_op_ex = "0.00" + fecha_cbte = fecha + fecha_venc_pago = fecha_serv_desde = fecha_serv_hasta = None + # Fechas del período del servicio facturado y vencimiento de pago: + if concepto > 1: + fecha_venc_pago = fecha + fecha_serv_desde = fecha; fecha_serv_hasta = fecha + elif '--fce' in sys.argv: + # obligatorio en Factura de Crédito Electrónica MiPyMEs (FCE): + fecha_venc_pago = fecha + moneda_id = 'PES'; moneda_ctz = '1.000' + + # inicializar prueba de multiples comprobantes por solicitud + if "--multiple" in sys.argv: + wsfev1.IniciarFacturasX() + reg_x_req = wsfev1.CompTotXRequest() # cant max. comprobantes + else: + reg_x_req = 1 # un solo comprobante + + for i in range(reg_x_req): + + wsfev1.CrearFactura(concepto, tipo_doc, nro_doc, + tipo_cbte, punto_vta, cbt_desde + i, cbt_hasta + i, + imp_total, imp_tot_conc, imp_neto, + imp_iva, imp_trib, imp_op_ex, fecha_cbte, fecha_venc_pago, + fecha_serv_desde, fecha_serv_hasta, # -- + moneda_id, moneda_ctz) + + if '--caea' in sys.argv: + periodo = datetime.datetime.today().strftime("%Y%M") + orden = 1 if datetime.datetime.today().day < 15 else 2 + caea = wsfev1.CAEAConsultar(periodo, orden) + wsfev1.EstablecerCampoFactura("caea", caea) + wsfev1.EstablecerCampoFactura("fecha_hs_gen", "yyyymmddhhmiss") + + # comprobantes asociados (notas de crédito / débito) + if tipo_cbte in (2, 3, 7, 8, 12, 13, 203, 208, 213): + tipo = 201 if tipo_cbte in (203, 208, 213) else 3 + pto_vta = 4001 + nro = 1 + cuit = "20267565393" + # obligatorio en Factura de Crédito Electrónica MiPyMEs (FCE): + fecha_cbte = fecha if tipo_cbte in (203, 208, 213) else None + wsfev1.AgregarCmpAsoc(tipo, pto_vta, nro, cuit, fecha_cbte) + + # otros tributos: + tributo_id = 99 + desc = 'Impuesto Municipal Matanza' + base_imp = None + alic = None + importe = 1 + wsfev1.AgregarTributo(tributo_id, desc, base_imp, alic, importe) + + # subtotales por alicuota de IVA: + iva_id = 3 # 0% + base_imp = 100 + importe = 0 + wsfev1.AgregarIva(iva_id, base_imp, importe) + + # subtotales por alicuota de IVA: + iva_id = 5 # 21% + base_imp = 100 + importe = 21 + wsfev1.AgregarIva(iva_id, base_imp, importe) + + # datos opcionales para proyectos promovidos: + if '--proyectos' in sys.argv: + wsfev1.AgregarOpcional(2, "1234") # identificador del proyecto + # datos opcionales para RG Bienes Usados 3411 (del vendedor): + if '--usados' in sys.argv: + wsfev1.AgregarOpcional(91, "Juan Perez") # Nombre y Apellido + wsfev1.AgregarOpcional(92, "200") # Nacionalidad + wsfev1.AgregarOpcional(93, "Balcarce 50") # Domicilio + # datos opcionales para RG 3668 Impuesto al Valor Agregado - Art.12: + if '--rg3668' in sys.argv: + wsfev1.AgregarOpcional(5, "02") # IVA Excepciones + wsfev1.AgregarOpcional(61, "80") # Firmante Doc Tipo + wsfev1.AgregarOpcional(62, "20267565393") # Firmante Doc Nro + wsfev1.AgregarOpcional(7, "01") # Car�cter del Firmante + # datos opcionales para RG 4004-E Alquiler de inmuebles (Ganancias) + if '--rg4004' in sys.argv: + wsfev1.AgregarOpcional(17, "1") # Intermediario + wsfev1.AgregarOpcional(1801, "30500010912") # CUIT Propietario + wsfev1.AgregarOpcional(1802, "BNA") # Nombr e Titular + # datos de compradores RG 4109-E bienes muebles registrables (%) + if '--rg4109' in sys.argv: + wsfev1.AgregarComprador(80, "30500010912", 99.99) + wsfev1.AgregarComprador(80, "30999032083", 0.01) + + # datos de Factura de Crédito Electrónica MiPyMEs (FCE): + if '--fce' in sys.argv: + wsfev1.AgregarOpcional(2101, "2850590940090418135201") # CBU + wsfev1.AgregarOpcional(2102, "pyafipws") # alias + if tipo_cbte in (203, 208, 213): + wsfev1.AgregarOpcional(22, "S") # Anulación + + # agregar la factura creada internamente para solicitud múltiple: + if "--multiple" in sys.argv: + wsfev1.AgregarFacturaX() + + import time + t0 = time.time() + if not '--caea' in sys.argv: + if not "--multiple" in sys.argv: + wsfev1.CAESolicitar() + else: + cant = wsfev1.CAESolicitarX() + print("Cantidad de comprobantes procesados:", cant) + else: + wsfev1.CAEARegInformativo() + t1 = time.time() + + # revisar los resultados: + for i in range(reg_x_req): + if "--multiple" in sys.argv: + print("Analizando respuesta para factura indice: ", i) + ok = wsfev1.LeerFacturaX(i) + print("Nro. Cbte. desde-hasta", wsfev1.CbtDesde, wsfev1.CbtHasta) + print("Resultado", wsfev1.Resultado) + print("Reproceso", wsfev1.Reproceso) + print("CAE", wsfev1.CAE) + print("Vencimiento", wsfev1.Vencimiento) + print("Observaciones", wsfev1.Obs) + + if DEBUG: + print("t0", t0) + print("t1", t1) + print("lapso", t1 - t0) + open("xmlrequest.xml", "wb").write(wsfev1.XmlRequest) + open("xmlresponse.xml", "wb").write(wsfev1.XmlResponse) + + if not "--multiple" in sys.argv: + wsfev1.AnalizarXml("XmlResponse") + p_assert_eq(wsfev1.ObtenerTagXml('CAE'), str(wsfev1.CAE)) + p_assert_eq(wsfev1.ObtenerTagXml('Concepto'), '2') + p_assert_eq(wsfev1.ObtenerTagXml('Obs', 0, 'Code'), "10017") + print(wsfev1.ObtenerTagXml('Obs', 0, 'Msg')) + + if "--reprocesar" in sys.argv: + print("reprocesando....") + wsfev1.Reproceso = True + cae = wsfev1.CAE + wsfev1.CAESolicitar() + assert cae == wsfev1.CAE + assert wsfev1.Reproceso == "S" + + if "--consultar" in sys.argv: + cae = wsfev1.CAE + cae2 = wsfev1.CompConsultar(tipo_cbte, punto_vta, cbt_desde) + p_assert_eq(cae, cae2) + # comparar datos del encabezado + p_assert_eq(wsfev1.ObtenerCampoFactura('cae'), str(wsfev1.CAE)) + p_assert_eq(wsfev1.ObtenerCampoFactura('nro_doc'), int(nro_doc)) + p_assert_eq(wsfev1.ObtenerCampoFactura('imp_total'), float(imp_total)) + # comparar primer alicuota de IVA + p_assert_eq(wsfev1.ObtenerCampoFactura('iva', 0, 'importe'), 21) + # comparar primer tributo + p_assert_eq(wsfev1.ObtenerCampoFactura('tributos', 0, 'importe'), 1) + # comparar primer opcional + if '--rg3668' in sys.argv: + p_assert_eq(wsfev1.ObtenerCampoFactura('opcionales', 0, 'valor'), "02") + # comparar primer observacion de AFIP + p_assert_eq(wsfev1.ObtenerCampoFactura('obs', 0, 'code'), 10017) + # pruebo la segunda observacion inexistente + p_assert_eq(wsfev1.ObtenerCampoFactura('obs', 1, 'code'), None) + p_assert_eq(wsfev1.Excepcion, "El campo 1 solicitado no existe") + + if "--get" in sys.argv: + tipo_cbte = 2 + punto_vta = 4001 + cbte_nro = wsfev1.CompUltimoAutorizado(tipo_cbte, punto_vta) + + wsfev1.CompConsultar(tipo_cbte, punto_vta, cbte_nro) + + print("FechaCbte = ", wsfev1.FechaCbte) + print("CbteNro = ", wsfev1.CbteNro) + print("PuntoVenta = ", wsfev1.PuntoVenta) + print("ImpTotal =", wsfev1.ImpTotal) + print("CAE = ", wsfev1.CAE) + print("Vencimiento = ", wsfev1.Vencimiento) + print("EmisionTipo = ", wsfev1.EmisionTipo) + + wsfev1.AnalizarXml("XmlResponse") + p_assert_eq(wsfev1.ObtenerTagXml('CodAutorizacion'), str(wsfev1.CAE)) + p_assert_eq(wsfev1.ObtenerTagXml('CbteFch'), wsfev1.FechaCbte) + p_assert_eq(wsfev1.ObtenerTagXml('MonId'), "PES") + p_assert_eq(wsfev1.ObtenerTagXml('MonCotiz'), "1") + p_assert_eq(wsfev1.ObtenerTagXml('DocTipo'), "80") + p_assert_eq(wsfev1.ObtenerTagXml('DocNro'), "30500010912") + + if "--parametros" in sys.argv: + import codecs + import locale + import traceback + if sys.stdout.encoding is None: + sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout, "replace") + sys.stderr = codecs.getwriter(locale.getpreferredencoding())(sys.stderr, "replace") + + print('\n'.join(wsfev1.ParamGetTiposDoc())) + print("=== Tipos de Comprobante ===") + print('\n'.join(wsfev1.ParamGetTiposCbte())) + print("=== Tipos de Concepto ===") + print('\n'.join(wsfev1.ParamGetTiposConcepto())) + print("=== Tipos de Documento ===") + print('\n'.join(wsfev1.ParamGetTiposDoc())) + print("=== Alicuotas de IVA ===") + print('\n'.join(wsfev1.ParamGetTiposIva())) + print("=== Monedas ===") + print('\n'.join(wsfev1.ParamGetTiposMonedas())) + print("=== Tipos de datos opcionales ===") + print('\n'.join(wsfev1.ParamGetTiposOpcional())) + print("=== Tipos de Tributo ===") + print('\n'.join(wsfev1.ParamGetTiposTributos())) + print("=== Tipos de Paises ===") + print('\n'.join(wsfev1.ParamGetTiposPaises())) + print("=== Puntos de Venta ===") + print('\n'.join(wsfev1.ParamGetPtosVenta())) + + if "--cotizacion" in sys.argv: + print(wsfev1.ParamGetCotizacion('DOL')) + + if "--comptox" in sys.argv: + print(wsfev1.CompTotXRequest()) + + if "--ptosventa" in sys.argv: + print(wsfev1.ParamGetPtosVenta()) + + if "--solicitar-caea" in sys.argv: + periodo = sys.argv[sys.argv.index("--solicitar-caea") + 1] + orden = sys.argv[sys.argv.index("--solicitar-caea") + 2] + + if DEBUG: + print("Solicitando CAEA para periodo %s orden %s" % (periodo, orden)) + + caea = wsfev1.CAEASolicitar(periodo, orden) + print("CAEA:", caea) + + if wsfev1.Observaciones: + print("Observaciones:") + for obs in wsfev1.Observaciones: + print(obs) + + if wsfev1.Errores: + print("Errores:") + for error in wsfev1.Errores: + print(error) + + if DEBUG: + print("periodo:", wsfev1.Periodo) + print("orden:", wsfev1.Orden) + print("fch_vig_desde:", wsfev1.FchVigDesde) + print("fch_vig_hasta:", wsfev1.FchVigHasta) + print("fch_tope_inf:", wsfev1.FchTopeInf) + print("fch_proceso:", wsfev1.FchProceso) + + if not caea: + print('Consultando CAEA') + caea = wsfev1.CAEAConsultar(periodo, orden) + print("CAEA:", caea) + if wsfev1.Errores: + print("Errores:") + for error in wsfev1.Errores: + print(error) + + if "--sinmovimiento-caea" in sys.argv: + punto_vta = sys.argv[sys.argv.index("--sinmovimiento-caea") + 1] + caea = sys.argv[sys.argv.index("--sinmovimiento-caea") + 2] + + if DEBUG: + print("Informando Punto Venta %s CAEA %s SIN MOVIMIENTO" % (punto_vta, caea)) + + resultado = wsfev1.CAEASinMovimientoInformar(punto_vta, caea) + print("Resultado:", resultado) + print("fch_proceso:", wsfev1.FchProceso) + + if wsfev1.Errores: + print("Errores:") + for error in wsfev1.Errores: + print(error) + + +# busco el directorio de instalaci�n (global para que no cambie si usan otra dll) +INSTALL_DIR = WSFEv1.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + if "--register" in sys.argv or "--unregister" in sys.argv: + import pythoncom + if TYPELIB: + if '--register' in sys.argv: + tlb = os.path.abspath(os.path.join(INSTALL_DIR, "typelib", "wsfev1.tlb")) + print("Registering %s" % (tlb,)) + tli = pythoncom.LoadTypeLib(tlb) + pythoncom.RegisterTypeLib(tli, tlb) + elif '--unregister' in sys.argv: + k = WSFEv1 + pythoncom.UnRegisterTypeLib(k._typelib_guid_, + k._typelib_version_[0], + k._typelib_version_[1], + 0, + pythoncom.SYS_WIN32) + print("Unregistered typelib") + import win32com.server.register + # print "_reg_class_spec_", WSFEv1._reg_class_spec_ + win32com.server.register.UseCommandLine(WSFEv1) + elif "/Automate" in sys.argv: + # MS seems to like /automate to run the class factories. + import win32com.server.localserver + # win32com.server.localserver.main() + # start the server. + win32com.server.localserver.serve([WSFEv1._reg_clsid_]) + else: + main() diff --git a/app/pyafipws/wsfexv1.py b/app/pyafipws/wsfexv1.py new file mode 100644 index 0000000000000000000000000000000000000000..11f5d6ebdfd8554c7654aa9bf55eaec221a36cf0 --- /dev/null +++ b/app/pyafipws/wsfexv1.py @@ -0,0 +1,799 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""Mdulo para obtener CAE, cdigo de autorizacin de impresin o electrnico, +del web service WSFEXv1 de AFIP (Factura Electrnica Exportacin Versin 1) +segn RG2758/2010 (Registros Especiales Aduaneros) y RG3689/14 (servicios) +http://www.sistemasagiles.com.ar/trac/wiki/FacturaElectronicaExportacion +""" + +__author__ = "Mariano Reingart (reingart@gmail.com)" +__copyright__ = "Copyright (C) 2011-2015 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.08f" + +import datetime +import decimal +import os +import sys +from .utils import inicializar_y_capturar_excepciones, BaseWS, get_install_dir + +HOMO = False +WSDL = "https://wswhomo.afip.gov.ar/wsfexv1/service.asmx?WSDL" + + +class WSFEXv1(BaseWS): + "Interfaz para el WebService de Factura Electrnica Exportacin Versin 1" + _public_methods_ = ['CrearFactura', 'AgregarItem', 'Authorize', 'GetCMP', + 'AgregarPermiso', 'AgregarCmpAsoc', + 'GetParamMon', 'GetParamTipoCbte', 'GetParamTipoExpo', + 'GetParamIdiomas', 'GetParamUMed', 'GetParamIncoterms', + 'GetParamDstPais', 'GetParamDstCUIT', 'GetParamIdiomas', + 'GetParamIncoterms', 'GetParamDstCUIT', + 'GetParamMonConCotizacion', + 'GetParamPtosVenta', 'GetParamCtz', 'LoadTestXML', + 'AnalizarXml', 'ObtenerTagXml', 'DebugLog', + 'SetParametros', 'SetTicketAcceso', 'GetParametro', + 'GetLastCMP', 'GetLastID', + 'Dummy', 'Conectar', 'SetTicketAcceso'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', + 'Resultado', 'Obs', 'Reproceso', + 'CAE', 'Vencimiento', 'Eventos', 'ErrCode', 'ErrMsg', 'FchVencCAE', + 'Excepcion', 'LanzarExcepciones', 'Traceback', "InstallDir", + 'PuntoVenta', 'CbteNro', 'FechaCbte', 'ImpTotal', 'FchCotiz'] + + _reg_progid_ = "WSFEXv1" + _reg_clsid_ = "{8106F039-D132-4F87-8AFE-ADE47B5503D4}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologacin' or '') + factura = None + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Resultado = self.Motivo = self.Reproceso = '' + self.LastID = self.LastCMP = self.CAE = self.Vencimiento = '' + self.CbteNro = self.FechaCbte = self.PuntoVenta = self.ImpTotal = None + self.InstallDir = INSTALL_DIR + self.FchVencCAE = "" # retrocompatibilidad + self.FchCotiz = None + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + if 'FEXErr' in ret: + errores = [ret['FEXErr']] + for error in errores: + self.Errores.append("%s: %s" % ( + error['ErrCode'], + error['ErrMsg'], + )) + self.ErrCode = ' '.join([str(error['ErrCode']) for error in errores]) + self.ErrMsg = '\n'.join(self.Errores) + if 'FEXEvents' in ret: + events = [ret['FEXEvents']] + self.Eventos = ['%s: %s' % (evt['EventCode'], evt.get('EventMsg', "")) for evt in events] + + def CrearFactura(self, tipo_cbte=19, punto_vta=1, cbte_nro=0, fecha_cbte=None, + imp_total=0.0, tipo_expo=1, permiso_existente="N", pais_dst_cmp=None, + nombre_cliente="", cuit_pais_cliente="", domicilio_cliente="", + id_impositivo="", moneda_id="PES", moneda_ctz=1.0, + obs_comerciales="", obs_generales="", forma_pago="", incoterms="", + idioma_cbte=7, incoterms_ds=None, fecha_pago=None, **kwargs): + "Creo un objeto factura (interna)" + # Creo una factura electronica de exportacin + + fact = {'tipo_cbte': tipo_cbte, 'punto_vta': punto_vta, + 'cbte_nro': cbte_nro, 'fecha_cbte': fecha_cbte, + 'tipo_doc': 80, 'nro_doc': cuit_pais_cliente, + 'imp_total': imp_total, + 'permiso_existente': permiso_existente, + 'pais_dst_cmp': pais_dst_cmp, + 'nombre_cliente': nombre_cliente, + 'domicilio_cliente': domicilio_cliente, + 'id_impositivo': id_impositivo, + 'moneda_id': moneda_id, 'moneda_ctz': moneda_ctz, + 'obs_comerciales': obs_comerciales, + 'obs_generales': obs_generales, + 'forma_pago': forma_pago, + 'incoterms': incoterms, + 'incoterms_ds': incoterms_ds, + 'tipo_expo': tipo_expo, + 'idioma_cbte': idioma_cbte, + 'cbtes_asoc': [], + 'permisos': [], + 'detalles': [], + 'fecha_pago': fecha_pago, + } + self.factura = fact + + return True + + def AgregarItem(self, codigo, ds, qty, umed, precio, importe, bonif=None, **kwargs): + "Agrego un item a una factura (interna)" + # Nota: no se calcula total (debe venir calculado!) + self.factura['detalles'].append({ + 'codigo': codigo, + 'ds': ds, + 'qty': qty, + 'umed': umed, + 'precio': precio, + 'bonif': bonif, + 'importe': importe, + }) + return True + + def AgregarPermiso(self, id_permiso, dst_merc, **kwargs): + "Agrego un permiso a una factura (interna)" + self.factura['permisos'].append({ + 'id_permiso': id_permiso, + 'dst_merc': dst_merc, + }) + return True + + def AgregarCmpAsoc(self, cbte_tipo=19, cbte_punto_vta=0, cbte_nro=0, cbte_cuit=None, **kwargs): + "Agrego un comprobante asociado a una factura (interna)" + self.factura['cbtes_asoc'].append({ + 'cbte_tipo': cbte_tipo, 'cbte_punto_vta': cbte_punto_vta, + 'cbte_nro': cbte_nro, 'cbte_cuit': cbte_cuit}) + return True + + @inicializar_y_capturar_excepciones + def Authorize(self, id): + "Autoriza la factura cargada en memoria" + f = self.factura + ret = self.client.FEXAuthorize( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Cmp={ + 'Id': id, + 'Fecha_cbte': f['fecha_cbte'], + 'Cbte_Tipo': f['tipo_cbte'], + 'Punto_vta': f['punto_vta'], + 'Cbte_nro': f['cbte_nro'], + 'Tipo_expo': f['tipo_expo'], + 'Permiso_existente': f['permiso_existente'], + 'Permisos': f['permisos'] and [ + {'Permiso': { + 'Id_permiso': p['id_permiso'], + 'Dst_merc': p['dst_merc'], + }} for p in f['permisos']] or None, + 'Dst_cmp': f['pais_dst_cmp'], + 'Cliente': f['nombre_cliente'], + 'Cuit_pais_cliente': f['nro_doc'], + 'Domicilio_cliente': f['domicilio_cliente'], + 'Id_impositivo': f['id_impositivo'], + 'Moneda_Id': f['moneda_id'], + 'Moneda_ctz': f['moneda_ctz'], + 'Obs_comerciales': f['obs_comerciales'], + 'Imp_total': f['imp_total'], + 'Obs': f['obs_generales'], + 'Cmps_asoc': f['cbtes_asoc'] and [ + {'Cmp_asoc': { + 'Cbte_tipo': c['cbte_tipo'], + 'Cbte_punto_vta': c['cbte_punto_vta'], + 'Cbte_nro': c['cbte_nro'], + 'Cbte_cuit': c['cbte_cuit'], + }} for c in f['cbtes_asoc']] or None, + 'Forma_pago': f['forma_pago'], + 'Incoterms': f['incoterms'], + 'Incoterms_Ds': f['incoterms_ds'], + 'Idioma_cbte': f['idioma_cbte'], + 'Items': [ + {'Item': { + 'Pro_codigo': d['codigo'], + 'Pro_ds': d['ds'], + 'Pro_qty': d['qty'], + 'Pro_umed': d['umed'], + 'Pro_precio_uni': d['precio'], + 'Pro_bonificacion': d['bonif'], + 'Pro_total_item': d['importe'], + }} for d in f['detalles']], + 'Fecha_pago': f['fecha_pago'], + }) + + result = ret['FEXAuthorizeResult'] + self.__analizar_errores(result) + if 'FEXResultAuth' in result: + auth = result['FEXResultAuth'] + # Resultado: A: Aceptado, R: Rechazado + self.Resultado = auth.get('Resultado', "") + # Obs: + self.Obs = auth.get('Motivos_Obs', "") + self.Reproceso = auth.get('Reproceso', "") + self.CAE = auth.get('Cae', "") + self.CbteNro = auth.get('Cbte_nro', "") + vto = str(auth.get('Fch_venc_Cae', "")) + self.FchVencCAE = vto + self.Vencimiento = "%s/%s/%s" % (vto[6:8], vto[4:6], vto[0:4]) + return self.CAE + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + result = self.client.FEXDummy()['FEXDummyResult'] + self.__analizar_errores(result) + self.AppServerStatus = str(result.get('AppServer', '')) + self.DbServerStatus = str(result.get('DbServer', '')) + self.AuthServerStatus = str(result.get('AuthServer', '')) + return True + + @inicializar_y_capturar_excepciones + def GetCMP(self, tipo_cbte, punto_vta, cbte_nro): + "Recuperar los datos completos de un comprobante ya autorizado" + ret = self.client.FEXGetCMP( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Cmp={ + 'Cbte_tipo': tipo_cbte, + 'Punto_vta': punto_vta, + 'Cbte_nro': cbte_nro, + }) + result = ret['FEXGetCMPResult'] + self.__analizar_errores(result) + if 'FEXResultGet' in result: + resultget = result['FEXResultGet'] + # Obs, cae y fecha cae + self.Obs = resultget.get('Obs') and resultget['Obs'].strip(" ") or '' + self.CAE = resultget.get('Cae', '') + vto = str(resultget.get('Fch_venc_Cae', '')) + self.Vencimiento = "%s/%s/%s" % (vto[6:8], vto[4:6], vto[0:4]) + self.FechaCbte = resultget.get('Fecha_cbte', '') # .strftime("%Y/%m/%d") + self.PuntoVenta = resultget['Punto_vta'] # 4000 + self.Resultado = resultget.get('Resultado', '') + self.CbteNro = resultget['Cbte_nro'] + self.ImpTotal = str(resultget['Imp_total']) + return self.CAE + else: + return 0 + + @inicializar_y_capturar_excepciones + def GetLastCMP(self, tipo_cbte, punto_vta): + "Recuperar ltimo nmero de comprobante emitido" + ret = self.client.FEXGetLast_CMP( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, + 'Cbte_Tipo': tipo_cbte, + 'Pto_venta': punto_vta, + }) + result = ret['FEXGetLast_CMPResult'] + self.__analizar_errores(result) + if 'FEXResult_LastCMP' in result: + resultget = result['FEXResult_LastCMP'] + self.CbteNro = resultget.get('Cbte_nro') + self.FechaCbte = resultget.get('Cbte_fecha') # .strftime("%Y/%m/%d") + return self.CbteNro + + @inicializar_y_capturar_excepciones + def GetLastID(self): + "Recuperar ltimo nmero de transaccin (ID)" + ret = self.client.FEXGetLast_ID( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetLast_IDResult'] + self.__analizar_errores(result) + if 'FEXResultGet' in result: + resultget = result['FEXResultGet'] + return resultget.get('Id') + + @inicializar_y_capturar_excepciones + def GetParamUMed(self, sep="|"): + ret = self.client.FEXGetPARAM_UMed( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_UMedResult'] + self.__analizar_errores(result) + + umeds = [] # unidades de medida + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_UMed'] + try: + umed = {'id': u.get('Umed_Id'), 'ds': u.get('Umed_Ds'), + 'vig_desde': u.get('Umed_vig_desde'), + 'vig_hasta': u.get('Umed_vig_hasta')} + except Exception as e: + print(e) + if u is None: + # WTF! + umed = {'id': '', 'ds': '', 'vig_desde': '', 'vig_hasta': ''} + #import pdb; pdb.set_trace() + # print u + + umeds.append(umed) + if sep: + return [("\t%(id)s\t%(ds)s\t%(vig_desde)s\t%(vig_hasta)s\t" + % it).replace("\t", sep) for it in umeds] + else: + return umeds + + @inicializar_y_capturar_excepciones + def GetParamMon(self, sep="|"): + ret = self.client.FEXGetPARAM_MON( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_MONResult'] + self.__analizar_errores(result) + + mons = [] # monedas + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_Mon'] + try: + mon = {'id': u.get('Mon_Id'), 'ds': u.get('Mon_Ds'), + 'vig_desde': u.get('Mon_vig_desde'), + 'vig_hasta': u.get('Mon_vig_hasta')} + except Exception as e: + print(e) + if u is None: + # WTF! + mon = {'id': '', 'ds': '', 'vig_desde': '', 'vig_hasta': ''} + #import pdb; pdb.set_trace() + # print u + + mons.append(mon) + if sep: + return [("\t%(id)s\t%(ds)s\t%(vig_desde)s\t%(vig_hasta)s\t" + % it).replace("\t", sep) for it in mons] + else: + return mons + + @inicializar_y_capturar_excepciones + def GetParamDstPais(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Pases" + ret = self.client.FEXGetPARAM_DST_pais( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_DST_paisResult'] + self.__analizar_errores(result) + + ret = [] + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_DST_pais'] + try: + r = {'codigo': u.get('DST_Codigo'), 'ds': u.get('DST_Ds'), } + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def GetParamDstCUIT(self, sep="|"): + "Recuperar lista de valores referenciales de CUIT de Pases" + ret = self.client.FEXGetPARAM_DST_CUIT( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_DST_CUITResult'] + self.__analizar_errores(result) + + ret = [] + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_DST_cuit'] + try: + r = {'codigo': u.get('DST_CUIT'), 'ds': u.get('DST_Ds'), } + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def GetParamTipoCbte(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Tipo de comprobantes" + ret = self.client.FEXGetPARAM_Cbte_Tipo( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_Cbte_TipoResult'] + self.__analizar_errores(result) + + ret = [] + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_Cbte_Tipo'] + try: + r = {'codigo': u.get('Cbte_Id'), + 'ds': u.get('Cbte_Ds').replace('\n', '').replace('\r', ''), + 'vig_desde': u.get('Cbte_vig_desde'), + 'vig_hasta': u.get('Cbte_vig_hasta')} + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t%(vig_desde)s\t%(vig_hasta)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def GetParamTipoExpo(self, sep="|"): + "Recuperador de valores referenciales de cdigos de Tipo de exportacin" + ret = self.client.FEXGetPARAM_Tipo_Expo( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_Tipo_ExpoResult'] + self.__analizar_errores(result) + + ret = [] + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_Tex'] + try: + r = {'codigo': u.get('Tex_Id'), 'ds': u.get('Tex_Ds'), + 'vig_desde': u.get('Tex_vig_desde'), + 'vig_hasta': u.get('Tex_vig_hasta')} + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t%(vig_desde)s\t%(vig_hasta)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def GetParamIdiomas(self, sep="|"): + "Recuperar lista de valores referenciales de cdigos de Idiomas" + ret = self.client.FEXGetPARAM_Idiomas( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_IdiomasResult'] + self.__analizar_errores(result) + + ret = [] + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_Idi'] + try: + r = {'codigo': u.get('Idi_Id'), 'ds': u.get('Idi_Ds'), + 'vig_desde': u.get('Idi_vig_hasta'), + 'vig_hasta': u.get('Idi_vig_desde')} + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t%(vig_desde)s\t%(vig_hasta)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + def GetParamIncoterms(self, sep="|"): + "Recuperar lista de valores referenciales de Incoterms" + ret = self.client.FEXGetPARAM_Incoterms( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit, }) + result = ret['FEXGetPARAM_IncotermsResult'] + self.__analizar_errores(result) + + ret = [] + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_Inc'] + try: + r = {'codigo': u.get('Inc_Id'), 'ds': u.get('Inc_Ds'), + 'vig_desde': u.get('Inc_vig_hasta'), + 'vig_hasta': u.get('Inc_vig_desde')} + except Exception as e: + print(e) + + ret.append(r) + if sep: + return [("\t%(codigo)s\t%(ds)s\t%(vig_desde)s\t%(vig_hasta)s\t" + % it).replace("\t", sep) for it in ret] + else: + return ret + + @inicializar_y_capturar_excepciones + def GetParamCtz(self, moneda_id): + "Recuperador de cotizacin de moneda" + ret = self.client.FEXGetPARAM_Ctz( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Mon_id=moneda_id, + ) + self.__analizar_errores(ret['FEXGetPARAM_CtzResult']) + res = ret['FEXGetPARAM_CtzResult'].get('FEXResultGet') + if res: + ctz = str(res.get('Mon_ctz', "")) + self.FchCotiz = res.get("Mon_fecha") + else: + ctz = '' + return ctz + + @inicializar_y_capturar_excepciones + def GetParamMonConCotizacion(self, fecha=None, sep="|"): + "Recupera el listado de monedas que tengan cotizacion de ADUANA" + if not fecha: + fecha = datetime.date.today().strftime("%Y%m%d") + + ret = self.client.FEXGetPARAM_MON_CON_COTIZACION( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + Fecha_CTZ=fecha) + result = ret['FEXGetPARAM_MON_CON_COTIZACIONResult'] + self.__analizar_errores(result) + + mons = [] # monedas + for u in result['FEXResultGet']: + u = u['ClsFEXResponse_Mon_CON_Cotizacion'] + try: + mon = {'id': u.get('Mon_Id'), 'ctz': u.get('Mon_ctz'), + 'fecha': u.get('Fecha_ctz')} + except Exception as e: + print(e) + if u is None: + # WTF! + mon = {'id':'', 'ctz':'','fecha':''} + mons.append(mon) + if sep: + return [("\t%(id)s\t%(ctz)s\t%(fecha)s\t" + % it).replace("\t", sep) for it in mons] + else: + return mons + + @inicializar_y_capturar_excepciones + def GetParamPtosVenta(self, sep="|"): + "Recupera el listado de los puntos de venta para exportacion y estado" + ret = self.client.FEXGetPARAM_PtoVenta( + Auth={'Token': self.Token, 'Sign': self.Sign, 'Cuit': self.Cuit}, + ) + self.__analizar_errores(ret['FEXGetPARAM_PtoVentaResult']) + res = ret['FEXGetPARAM_PtoVentaResult'].get('FEXResultGet') + ret = [] + for pu in res: + p = pu['ClsFEXResponse_PtoVenta'] + try: + r = {'nro': p.get('Pve_Nro'), 'baja': p.get('Pve_FchBaj'), + 'bloqueado': p.get('Pve_Bloqueado'), } + except Exception as e: + print(e) + ret.append(r) + return [("%(nro)s\tBloqueado:%(bloqueado)s\tFchBaja:%(baja)s" % r).replace("\t", sep) + for r in ret] + + +class WSFEX(WSFEXv1): + "Wrapper para retrocompatibilidad con WSFEX" + + _reg_progid_ = "WSFEX" + _reg_clsid_ = "{B3C8D3D3-D5DA-44C9-B003-11845803B2BD}" + + def __init__(self): + WSFEXv1.__init__(self) + self.Version = "%s %s WSFEXv1" % (__version__, HOMO and 'Homologacin' or '') + + def Conectar(self, url="", proxy=""): + # Ajustar URL de V0 a V1: + if url in ("https://wswhomo.afip.gov.ar/wsfex/service.asmx", + "http://wswhomo.afip.gov.ar/WSFEX/service.asmx"): + url = "https://wswhomo.afip.gov.ar/wsfexv1/service.asmx" + elif url in ("https://servicios1.afip.gov.ar/wsfex/service.asmx", + "http://servicios1.afip.gov.ar/WSFEX/service.asmx"): + url = "https://servicios1.afip.gov.ar/wsfexv1/service.asmx" + return WSFEXv1.Conectar(self, cache=None, wsdl=url, proxy=proxy) + + +# busco el directorio de instalacin (global para que no cambie si usan otra dll) +INSTALL_DIR = WSFEXv1.InstallDir = get_install_dir() + + +def p_assert_eq(a, b): + print(a, a == b and '==' or '!=', b) + + +if __name__ == "__main__": + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSFEXv1) + if '--wsfex' in sys.argv: + win32com.server.register.UseCommandLine(WSFEX) + # elif "/Automate" in sys.argv: + # # MS seems to like /automate to run the class factories. + # import win32com.server.localserver + # #win32com.server.localserver.main() + # # start the server. + # win32com.server.localserver.serve([WSFEXv1._reg_clsid_]) + else: + + # Crear objeto interface Web Service de Factura Electrnica de Exportacin + wsfexv1 = WSFEXv1() + # Setear token y sing de autorizacin (pasos previos) + + # obteniendo el TA para pruebas + from .wsaa import WSAA + ta = WSAA().Autenticar("wsfex", "reingart.crt", "reingart.key") + wsfexv1.SetTicketAcceso(ta) + + # CUIT del emisor (debe estar registrado en la AFIP) + wsfexv1.Cuit = "20267565393" + + # Conectar al Servicio Web de Facturacin (produccin u homologacin) + if "--prod" in sys.argv: + wsdl = "https://servicios1.afip.gov.ar/wsfexv1/service.asmx?WSDL" + else: + wsdl = "https://wswhomo.afip.gov.ar/wsfexv1/service.asmx?WSDL" + cache = proxy = "" + wrapper = "httplib2" + cacert = open("conf/afip_ca_info.crt").read() + ok = wsfexv1.Conectar(cache, wsdl, proxy, wrapper, cacert) + + if '--dummy' in sys.argv: + #wsfexv1.LanzarExcepciones = False + print(wsfexv1.Dummy()) + print("AppServerStatus", wsfexv1.AppServerStatus) + print("DbServerStatus", wsfexv1.DbServerStatus) + print("AuthServerStatus", wsfexv1.AuthServerStatus) + + if "--prueba" in sys.argv: + try: + # Establezco los valores de la factura a autorizar: + tipo_cbte = '--nc' in sys.argv and 21 or 19 # FC/NC Expo (ver tabla de parmetros) + punto_vta = 7 + # Obtengo el ltimo nmero de comprobante y le agrego 1 + cbte_nro = int(wsfexv1.GetLastCMP(tipo_cbte, punto_vta)) + 1 + fecha_cbte = datetime.datetime.now().strftime("%Y%m%d") + tipo_expo = 1 # tipo de exportacin (ver tabla de parmetros) + permiso_existente = (tipo_cbte not in (20, 21) or tipo_expo != 1) and "S" or "" + print("permiso_existente", permiso_existente) + dst_cmp = 203 # pas destino + cliente = "Joao Da Silva" + cuit_pais_cliente = "50000000016" + domicilio_cliente = "Ra Ѱ76 km 34.5 Alagoas" + id_impositivo = "PJ54482221-l" + moneda_id = "DOL" # para reales, "DOL" o "PES" (ver tabla de parmetros) + moneda_ctz = "8.00" # wsfexv1.GetParamCtz('DOL') <- no funciona + obs_comerciales = "Observaciones comerciales" + obs = "Sin observaciones" + forma_pago = "30 dias" + incoterms = "FOB" # (ver tabla de parmetros) + incoterms_ds = "Flete a Bordo" + idioma_cbte = 1 # (ver tabla de parmetros) + imp_total = "250.00" + + # Creo una factura (internamente, no se llama al WebService): + ok = wsfexv1.CrearFactura(tipo_cbte, punto_vta, cbte_nro, fecha_cbte, + imp_total, tipo_expo, permiso_existente, dst_cmp, + cliente, cuit_pais_cliente, domicilio_cliente, + id_impositivo, moneda_id, moneda_ctz, + obs_comerciales, obs, forma_pago, incoterms, + idioma_cbte, incoterms_ds) + + # Agrego un item: + codigo = "PRO1" + ds = "Producto Tipo 1 Exportacion MERCOSUR ISO 9001" + qty = 2 + precio = "150.00" + umed = 1 # Ver tabla de parmetros (unidades de medida) + bonif = "50.00" + imp_total = "250.00" # importe total final del artculo + # lo agrego a la factura (internamente, no se llama al WebService): + ok = wsfexv1.AgregarItem(codigo, ds, qty, umed, precio, imp_total, bonif) + ok = wsfexv1.AgregarItem(codigo, ds, qty, umed, precio, imp_total, bonif) + ok = wsfexv1.AgregarItem(codigo, ds, 0, 99, 0, -float(imp_total), 0) + + # Agrego un permiso (ver manual para el desarrollador) + if permiso_existente: + id = "99999AAXX999999A" + dst = 225 # pas destino de la mercaderia + ok = wsfexv1.AgregarPermiso(id, dst) + + # Agrego un comprobante asociado (solo para N/C o N/D) + if tipo_cbte in (20, 21): + cbteasoc_tipo = 19 + cbteasoc_pto_vta = 2 + cbteasoc_nro = 1234 + cbteasoc_cuit = 20111111111 + wsfexv1.AgregarCmpAsoc(cbteasoc_tipo, cbteasoc_pto_vta, cbteasoc_nro, cbteasoc_cuit) + + # id = "99000000000100" # nmero propio de transaccin + # obtengo el ltimo ID y le adiciono 1 + # (advertencia: evitar overflow y almacenar!) + id = int(wsfexv1.GetLastID()) + 1 + + # Llamo al WebService de Autorizacin para obtener el CAE + cae = wsfexv1.Authorize(id) + + print("Comprobante", tipo_cbte, wsfexv1.CbteNro) + print("Resultado", wsfexv1.Resultado) + print("CAE", wsfexv1.CAE) + print("Vencimiento", wsfexv1.Vencimiento) + + if wsfexv1.Resultado and False: + print(wsfexv1.client.help("FEXGetCMP").encode("latin1")) + wsfexv1.GetCMP(tipo_cbte, punto_vta, cbte_nro) + print("CAE consulta", wsfexv1.CAE, wsfexv1.CAE == cae) + print("NRO consulta", wsfexv1.CbteNro, wsfexv1.CbteNro == cbte_nro) + print("TOTAL consulta", wsfexv1.ImpTotal, wsfexv1.ImpTotal == imp_total) + + except Exception as e: + print(wsfexv1.XmlRequest) + print(wsfexv1.XmlResponse) + print(wsfexv1.ErrCode) + print(wsfexv1.ErrMsg) + print(wsfexv1.Excepcion) + print(wsfexv1.Traceback) + raise + + if "--get" in sys.argv: + wsfexv1.client.help("FEXGetCMP") + tipo_cbte = 19 + punto_vta = 7 + cbte_nro = wsfexv1.GetLastCMP(tipo_cbte, punto_vta) + + wsfexv1.GetCMP(tipo_cbte, punto_vta, cbte_nro) + + print("FechaCbte = ", wsfexv1.FechaCbte) + print("CbteNro = ", wsfexv1.CbteNro) + print("PuntoVenta = ", wsfexv1.PuntoVenta) + print("ImpTotal =", wsfexv1.ImpTotal) + print("CAE = ", wsfexv1.CAE) + print("Vencimiento = ", wsfexv1.Vencimiento) + + wsfexv1.AnalizarXml("XmlResponse") + p_assert_eq(wsfexv1.ObtenerTagXml('Cae'), str(wsfexv1.CAE)) + p_assert_eq(wsfexv1.ObtenerTagXml('Fecha_cbte'), wsfexv1.FechaCbte) + p_assert_eq(wsfexv1.ObtenerTagXml('Moneda_Id'), "DOL") + p_assert_eq(wsfexv1.ObtenerTagXml('Moneda_ctz'), "8") + p_assert_eq(wsfexv1.ObtenerTagXml('Id_impositivo'), "PJ54482221-l") + + if "--params" in sys.argv: + import codecs + import locale + sys.stdout = codecs.getwriter('latin1')(sys.stdout) + + print("=== Incoterms ===") + idiomas = wsfexv1.GetParamIncoterms(sep="||") + for idioma in idiomas: + print(idioma) + + print("=== Idiomas ===") + idiomas = wsfexv1.GetParamIdiomas(sep="||") + for idioma in idiomas: + print(idioma) + + print("=== Tipos Comprobantes ===") + tipos = wsfexv1.GetParamTipoCbte(sep=False) + for t in tipos: + print("||%(codigo)s||%(ds)s||" % t) + + print("=== Tipos Expo ===") + tipos = wsfexv1.GetParamTipoExpo(sep=False) + for t in tipos: + print("||%(codigo)s||%(ds)s||%(vig_desde)s||%(vig_hasta)s||" % t) + #umeds = dict([(u.get('id', ""),u.get('ds', "")) for u in umedidas]) + + print("=== Monedas ===") + mons = wsfexv1.GetParamMon(sep=False) + for m in mons: + print("||%(id)s||%(ds)s||%(vig_desde)s||%(vig_hasta)s||" % m) + #umeds = dict([(u.get('id', ""),u.get('ds', "")) for u in umedidas]) + + print("=== Unidades de medida ===") + umedidas = wsfexv1.GetParamUMed(sep=False) + for u in umedidas: + print("||%(id)s||%(ds)s||%(vig_desde)s||%(vig_hasta)s||" % u) + umeds = dict([(u.get('id', ""), u.get('ds', "")) for u in umedidas]) + + print("=== Cdigo Pais Destino ===") + ret = wsfexv1.GetParamDstPais(sep=False) + for r in ret: + print("||%(codigo)s||%(ds)s||" % r) + + print("=== CUIT Pais Destino ===") + ret = wsfexv1.GetParamDstCUIT(sep=False) + for r in ret: + print("||%(codigo)s||%(ds)s||" % r) + + if "--ctz" in sys.argv: + print(wsfexv1.GetParamCtz('DOL')) + + if "--monctz" in sys.argv: + print(wsfexv1.GetParamMonConCotizacion()) + + if "--ptosventa" in sys.argv: + print(wsfexv1.GetParamPtosVenta()) diff --git a/app/pyafipws/wslpg.py b/app/pyafipws/wslpg.py new file mode 100644 index 0000000000000000000000000000000000000000..2fc78023aef164763b795627c6c7aa692262d89a --- /dev/null +++ b/app/pyafipws/wslpg.py @@ -0,0 +1,4571 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +import shelve +import sys +import datetime +import decimal +import os +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, BaseWS, inicializar_y_capturar_excepciones, get_install_dir +from . import utils +from fpdf import Template +from pysimplesoap.client import SoapFault +import warnings +import pprint +import traceback +"""Módulo para obtener código de operación electrónico (COE) para +Liquidación Primaria Electrónica de Granos del web service WSLPG de AFIP +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2013-2018 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.32a" + +LICENCIA = """ +wslpg.py: Interfaz para generar Código de Operación Electrónica para +Liquidación Primaria de Granos (LpgService) +Copyright (C) 2013-2018 Mariano Reingart reingart@gmail.com +http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionPrimariaGranos + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo respetando la licencia GPLv3. + +Para información adicional sobre garantía, soporte técnico comercial +e incorporación/distribución en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + +AYUDA = """ +Opciones: + --ayuda: este mensaje + + --debug: modo depuración (detalla y confirma las operaciones) + --formato: muestra el formato de los archivos de entrada/salida + --prueba: genera y autoriza una liquidación de prueba (no usar en producción!) + --xml: almacena los requerimientos y respuestas XML (depuración) + --dbf: utilizar tablas DBF (xBase) para los archivos de intercambio + --json: utilizar formato json para el archivo de intercambio + --dummy: consulta estado de servidores + + --autorizar: Autorizar Liquidación Primaria de Granos (liquidacionAutorizar) + --ajustar: Ajustar Liquidación Primaria de Granos (liquidacionAjustar) + --anular: Anular una Liquidación Primaria de Granos (liquidacionAnular) + --autorizar-anticipo: Autoriza un Anticipo (lpgAutorizarAnticipo) + --consultar: Consulta una liquidación (parámetros: nro de orden, COE, pdf) + --cancelar-anticipo: anteponer para anticipos (lpgCancelarAnticipo) + --ult: Consulta el último número de orden registrado en AFIP + (liquidacionUltimoNroOrdenConsultar) + + --pdf: genera el formulario C 1116 B en formato PDF + --mostrar: muestra el documento PDF generado (usar con --pdf) + --imprimir: imprime el documento PDF generado (usar con --mostrar y --pdf) + + --autorizar-lsg: Autoriza una Liquidación Secundaria de Granos (lsgAutorizar) + --lsg --anular: Anula una LSG (lsgAnular) + --lsg --consular: Consulta una LSG por pto_emision, nro_orden o COE + --lsg --ult: Consulta el último Nº LSG emitida (lsgConsultarUltimoNroOrden) + --lsg --asociar: Asocia una liq. sec. a un contrato (lsgAsociarAContrato) + --ajustar-lsg: Ajusta una liquidación secundaria (lsgAjustar por COE/Contrato) + --autorizar-cg: Autorizar Certificación de Granos (cgAutorizar) + --cg --anular: Solicita anulación de un CG (cgSolicitarAnulacion) + --cg --consultar: Consulta una CG por pto_emision, nro_orden o COE + --cg --ult: Consulta el último Nº LSG emitida (cgConsultarUltimoNroOrden) + --informar-calidad: Informa la calidad de una CG (cgInformarCalidad) + --buscar-ctg: devuelve los datos de la CTG a certificar + espera tipo_certificado, cuit_depositante, nro_planta, cod_grano, campania + --buscar-cert-con-saldo-disp: CG disponible para liquidar/retirar/transferir + espera cuit_depositante, cod_grano, campania, coe fecha_emision_des/has + + --provincias: obtiene el listado de provincias + --localidades: obtiene el listado de localidades por provincia + --tipograno: obtiene el listado de los tipos de granos disponibles + --campanias: obtiene el listado de las campañas + --gradoref: obtiene el listado de los grados de referencias + --gradoent: obtiene el listado de los grados y valores entregados + --certdeposito: obtiene el listado de los tipos de certificados de depósito + --deducciones: obtiene el listado de los tipos de deducciones + --retenciones: obtiene el listado de los tipos de retenciones + --puertos: obtiene el listado de los puertos habilitados + --actividades: obtiene el listado de las actividades habilitados + --actividadesrep: devuelve las actividades en las que emisor/representado + se encuentra inscripto en RUOCA + --operaciones: obtiene el listado de las operaciones para el representado + + +Ver wslpg.ini para parámetros de configuración (URL, certificados, etc.)" +""" + + +# importo funciones compartidas: + + +WSDL = "https://fwshomo.afip.gov.ar/wslpg/LpgService?wsdl" +#WSDL = "https://serviciosjava.afip.gob.ar/wslpg/LpgService?wsdl" +#WSDL = "file:wslpg.wsdl" + +DEBUG = False +XML = False +CONFIG_FILE = "wslpg.ini" +TIMEOUT = 30 +HOMO = False + +# definición del formato del archivo de intercambio: + +ENCABEZADO = [ + ('tipo_reg', 1, A), # 0: encabezado liquidación + ('nro_orden', 18, N), + ('cuit_comprador', 11, N), + ('nro_act_comprador', 5, N), + ('nro_ing_bruto_comprador', 15, N), + ('cod_tipo_operacion', 2, N), + ('es_liquidacion_propia', 1, A), # S o N + ('es_canje', 1, A), # S o N + ('cod_puerto', 4, N), + ('des_puerto_localidad', 240, A), + ('cod_grano', 3, N), + ('cuit_vendedor', 11, N), + ('nro_ing_bruto_vendedor', 15, N), + ('actua_corredor', 1, A), # S o N + ('liquida_corredor', 1, A), # S o N + ('cuit_corredor', 11, N), + ('nro_ing_bruto_corredor', 15, N), + ('comision_corredor', 5, I, 2), # 3.2 + ('fecha_precio_operacion', 10, A), # 26/02/2013 + ('precio_ref_tn', 8, I, 3), # 4.3 + ('cod_grado_ref', 2, A), + ('cod_grado_ent', 2, A), + ('factor_ent', 6, I, 3), # 3.3 + ('precio_flete_tn', 7, I, 2), # 5.2 + ('cont_proteico', 6, I, 3), # 3.3 + ('alic_iva_operacion', 5, I, 2), # 3.2 + ('campania_ppal', 4, N), + ('cod_localidad_procedencia', 6, N), + ('reservado1', 200, A), # datos_adicionales (compatibilidad hacia atras) + + ('coe', 12, N), + ('coe_ajustado', 12, N), + ('estado', 2, A), + + ('total_deduccion', 17, I, 2), # 17.2 + ('total_retencion', 17, I, 2), # 17.2 + ('total_retencion_afip', 17, I, 2), # 17.2 + ('total_otras_retenciones', 17, I, 2), # 17.2 + ('total_neto_a_pagar', 17, I, 2), # 17.2 + ('total_iva_rg_4310_18', 17, I, 2), # 17.2 WSLPGv1.20 + ('total_pago_segun_condicion', 17, I, 2), # 17.2 + + ('fecha_liquidacion', 10, A), + ('nro_op_comercial', 10, N), + ('precio_operacion', 17, I, 3), # 17.3 + ('subtotal', 17, I, 2), # 17.2 + ('importe_iva', 17, I, 2), # 17.2 + ('operacion_con_iva', 17, I, 2), # 17.2 + ('total_peso_neto', 8, N), # 17.2 + + # Campos WSLPGv1.1: + ('pto_emision', 4, N), + ('cod_prov_procedencia', 2, N), + ('peso_neto_sin_certificado', 8, N), + + ('cod_tipo_ajuste', 2, N), + ('val_grado_ent', 4, I, 3), # 1.3 + + # Campos WSLPGv1.3: + ('cod_prov_procedencia_sin_certificado', 2, N), + ('cod_localidad_procedencia_sin_certificado', 6, N), + + # Campos WSLPGv1.4 (ajustes): + ('nro_contrato', 15, N), + ('tipo_formulario', 2, N), + ('nro_formulario', 12, N), + # datos devuetos: + ('total_iva_10_5', 17, I, 2), # 17.2 + ('total_iva_21', 17, I, 2), # 17.2 + ('total_retenciones_ganancias', 17, I, 2), # 17.2 + ('total_retenciones_iva', 17, I, 2), # 17.2 + + ('datos_adicionales', 400, A), # max 400 desde WSLPGv1.2 + + # Campos agregados WSLPGv1.5 (ajustes): + ('iva_deducciones', 17, I, 2), # 17.2 + ('subtotal_deb_cred', 17, I, 2), # 17.2 + ('total_base_deducciones', 17, I, 2), # 17.2 + + # Campos agregados WSLPGv1.6 (liquidación secundaria base): + ('cantidad_tn', 11, I, 3), # 8.3 + ('nro_act_vendedor', 5, N), + # Campos agregados WSLPGv1.9 (liquidación secundaria base): + ('total_deducciones', 19, I, 2), + ('total_percepciones', 19, I, 2), +] + +CERTIFICADO = [ + ('tipo_reg', 1, A), # 1: Certificado + ('reservado1', 2, N), # en WSLPGv1.7 se amplio el campo + ('nro_certificado_deposito', 12, N), + ('peso_neto', 8, N), # usado peso ajustado WSLPGv1.17 + ('cod_localidad_procedencia', 6, N), + ('cod_prov_procedencia', 2, N), + ('reservado', 2, N), + ('campania', 4, N), + ('fecha_cierre', 10, A), + ('peso_neto_total_certificado', 8, N), # para ajuste unificado (WSLPGv1.4) + ('coe_certificado_deposito', 12, N), # para certificacion (WSLPGv1.6) + ('tipo_certificado_deposito', 3, N), # wSLPGv1.7 agrega valor 332 +] + +RETENCION = [ + ('tipo_reg', 1, A), # 2: Retencion + ('codigo_concepto', 2, A), + ('detalle_aclaratorio', 30, A), + ('base_calculo', 10, I, 2), # 8.2 + ('alicuota', 6, I, 2), # 3.2 + ('nro_certificado_retencion', 14, N), + ('fecha_certificado_retencion', 10, A), + ('importe_certificado_retencion', 17, I, 2), # 17.2 + ('importe_retencion', 17, I, 2), # 17.2 +] + +DEDUCCION = [ + ('tipo_reg', 1, A), # 3: Deducción + ('codigo_concepto', 2, A), + ('detalle_aclaratorio', 30, A), # max 50 por WSLPGv1.2 + ('dias_almacenaje', 4, N), + ('reservado1', 6, I, 3), + ('comision_gastos_adm', 5, I, 2), # 3.2 + ('base_calculo', 10, I, 2), # 8.2 + ('alicuota', 6, I, 2), # 3.2 + ('importe_iva', 17, I, 2), # 17.2 + ('importe_deduccion', 17, I, 2), # 17.2 + ('precio_pkg_diario', 11, I, 8), # 3.8, ajustado WSLPGv1.2 +] + +PERCEPCION = [ + ('tipo_reg', 1, A), # P: Percepcion + ('detalle_aclaratoria', 50, A), # max 50 por WSLPGv1.8 + ('base_calculo', 10, I, 2), # 8.2 + ('alicuota', 6, I, 2), # 3.2 + ('importe_final', 19, I, 2), # 17.2 (LPG WSLPGv1.16) +] + +OPCIONAL = [ + ('tipo_reg', 1, A), # O: Opcional + ('codigo', 50, A), + ('descripcion', 250, A), +] + +AJUSTE = [ + ('tipo_reg', 1, A), # 4: ajuste débito / 5: crédito (WSLPGv1.4) + ('concepto_importe_iva_0', 20, A), + ('importe_ajustar_iva_0', 15, I, 2), # 11.2 + ('concepto_importe_iva_105', 20, A), + ('importe_ajustar_iva_105', 15, I, 2), # 11.2 + ('concepto_importe_iva_21', 20, A), + ('importe_ajustar_iva_21', 15, I, 2), # 11.2 + ('diferencia_peso_neto', 8, N), + ('diferencia_precio_operacion', 17, I, 3), # 17.3 + ('cod_grado', 2, A), + ('val_grado', 4, I, 3), # 1.3 + ('factor', 6, I, 3), # 3.3 + ('diferencia_precio_flete_tn', 7, I, 2), # 5.2 + ('datos_adicionales', 400, A), + # datos devueltos: + ('fecha_liquidacion', 10, A), + ('nro_op_comercial', 10, N), + ('precio_operacion', 17, I, 3), # 17.3 + ('subtotal', 17, I, 2), # 17.2 + ('importe_iva', 17, I, 2), # 17.2 + ('operacion_con_iva', 17, I, 2), # 17.2 + ('total_peso_neto', 8, N), # 17.2 + ('total_deduccion', 17, I, 2), # 17.2 + ('total_retencion', 17, I, 2), # 17.2 + ('total_retencion_afip', 17, I, 2), # 17.2 + ('total_otras_retenciones', 17, I, 2), # 17.2 + ('total_neto_a_pagar', 17, I, 2), # 17.2 + ('total_iva_rg_4310_18', 17, I, 2), # 17.2 + ('total_pago_segun_condicion', 17, I, 2), # 17.2 + ('iva_calculado_iva_0', 15, I, 2), # 15.2 + ('iva_calculado_iva_105', 15, I, 2), # 15.2 + ('iva_calculado_iva_21', 15, I, 2), # 15.2 +] + +CERTIFICACION = [ + ('tipo_reg', 1, A), # 7: encabezado certificación + # campos de la cabecera para todas las certificaciones (WSLPGv1.6) + ('pto_emision', 4, N), + ('nro_orden', 8, N), + ('tipo_certificado', 1, A), # P:Primaria,R:Retiro,T:Transferencia,E:Preexistente + ('nro_planta', 6, N), + ('nro_ing_bruto_depositario', 15, N), + ('titular_grano', 1, A), # "P" (Propio) "T" (Tercero) + ('cuit_depositante', 11, N), # obligatorio si titular_grano es T + ('nro_ing_bruto_depositante', 15, N), + ('cuit_corredor', 11, N), + ('cod_grano', 3, N), + ('campania', 4, N), + ('datos_adicionales', 400, A), + ('reservado1', 14, A), # reservado para futuros campos (no usar) + # campos para CgAutorizarPrimariaType ex-cgAutorizarDeposito (WSLPGv1.6-1.8) + ('nro_act_depositario', 5, N), # nuevo WSLPGv1.8 tambien R/T + ('descripcion_tipo_grano', 20, A), + ('monto_almacenaje', 10, I, 2), + ('monto_acarreo', 10, I, 2), + ('monto_gastos_generales', 10, I, 2), + ('monto_zarandeo', 10, I, 2), + ('porcentaje_secado_de', 5, I, 2), + ('porcentaje_secado_a', 5, I, 2), + ('monto_secado', 10, I, 2), + ('monto_por_cada_punto_exceso', 10, I, 2), + ('monto_otros', 10, I, 2), + ('reservado_calidad', 35, A), # ver subestructura WSLPGv1.10 + ('peso_neto_merma_volatil', 10, I, 2), + ('porcentaje_merma_secado', 5, I, 2), + ('peso_neto_merma_secado', 10, I, 2), + ('porcentaje_merma_zarandeo', 5, I, 2), + ('peso_neto_merma_zarandeo', 10, I, 2), + ('peso_neto_certificado', 10, I, 2), # WSLPGv1.9 2 decimales! + ('servicios_secado', 8, I, 3), + ('servicios_zarandeo', 8, I, 3), + ('servicios_otros', 7, I, 3), + ('servicios_forma_de_pago', 20, A), + # campos para cgAutorizarRetiroTransferencia (WSLPGv1.6): + ('cuit_receptor', 11, N), + ('fecha', 10, A), # no usado WSLPGv1.8 + ('nro_carta_porte_a_utilizar', 9, N), # obligatorio para retiro + ('cee_carta_porte_a_utilizar', 14, N), # no usado WSLPGv1.8 + # para cgAutorizarPreexistente (WSLPGv1.6): + ('tipo_certificado_deposito_preexistente', 1, N), # "R": Retiro "T": Tra. + ('nro_certificado_deposito_preexistente', 12, N), + ('cac_certificado_deposito_preexistente', 14, N), # cambio WSLPGv1.8 + ('fecha_emision_certificado_deposito_preexistente', 10, A), + ('peso_neto', 8, N), + # nro_planta definido previamente - agregado WSLPGv1.8 + + # datos devueltos por el webservice: + ('reservado2', 183, N), # padding para futuros campos (no usar) + ('coe', 12, N), + ('fecha_certificacion', 10, A), + ('estado', 2, A), + + ('reservado3', 101, A), # padding para futuros campos (no usar) + + # otros campos devueltos (opcionales) + # 'pesosResumen' + ('peso_bruto_certificado', 10, I, 2), + ('peso_merma_secado', 10, I, 2), + ('peso_merma_zarandeo', 10, I, 2), + # peso_neto_certificado definido arriba + # serviciosResumen + ('importe_iva', 10, I, 2), + ('servicio_gastos_generales', 10, I, 2), + ('servicio_otros', 10, I, 2), + ('servicio_total', 10, I, 2), + ('servicio_zarandeo', 10, I, 2), + # planta + ('cuit_titular_planta', 11, N), + ('razon_social_titular_planta', 11, A), + + # campos no documentados por AFIP (agregados luego de WSLPGv1.15 a fines Sept) + ('servicios_conceptos_no_gravados', 10, I, 2), + ('servicios_percepciones_iva', 10, I, 2), + ('servicios_otras_percepciones', 10, I, 2), +] + +CTG = [ # para cgAutorizarDeposito (WSLPGv1.6) + ('tipo_reg', 1, A), # C: CTG + ('nro_ctg', 8, N), + ('nro_carta_porte', 9, N), + ('porcentaje_secado_humedad', 5, I, 2), + ('importe_secado', 10, I, 2), + ('peso_neto_merma_secado', 10, I, 2), + ('tarifa_secado', 10, I, 2), + ('importe_zarandeo', 10, I, 2), + ('peso_neto_merma_zarandeo', 10, I, 2), + ('tarifa_zarandeo', 10, I, 2), + ('peso_neto_confirmado_definitivo', 10, I, 2), +] + +DET_MUESTRA_ANALISIS = [ # para cgAutorizarDeposito (WSLPGv1.6) + ('tipo_reg', 1, A), # D: detalle muestra analisis + ('descripcion_rubro', 400, A), + ('tipo_rubro', 1, A), # "B" (Bonificación) y "R" (Rebaja) + ('porcentaje', 5, I, 2), + ('valor', 5, I, 2), +] + +CALIDAD = [ # para cgAutorizar y cgInformarCalidad (WSLPGv1.10) + ('tipo_reg', 1, A), # Q: caldiad + ('analisis_muestra', 10, N), + ('nro_boletin', 10, N), + ('cod_grado', 2, A), # nuevo WSLPGv1.10: G1 G2 .... + ('valor_grado', 4, I, 3), # solo para cod_grado F1 F2 ... + ('valor_contenido_proteico', 5, I, 3), + ('valor_factor', 6, I, 3), +] + +FACTURA_PAPEL = [ # para lsgAjustar (WSLPGv1.15) + ('tipo_reg', 1, A), # F: factura papel + ('nro_cai', 14, N), + ('nro_factura_papel', 12, N), + ('fecha_factura', 10, A), + ('tipo_comprobante', 3, N), +] + +FUSION = [ # para liquidacionAjustarUnificado (WSLPGv1.19) + ('tipo_reg', 1, A), # f: fusion + ('nro_ing_brutos', 15, N), + ('nro_actividad', 5, N), +] + +EVENTO = [ + ('tipo_reg', 1, A), # E: Evento + ('codigo', 4, A), + ('descripcion', 250, A), +] + +ERROR = [ + ('tipo_reg', 1, A), # R: Error + ('codigo', 4, A), + ('descripcion', 250, A), +] + +DATO = [ + ('tipo_reg', 1, A), # 9: Dato adicional + ('campo', 25, A), + ('valor', 250, A), +] + + +class WSLPG(BaseWS): + "Interfaz para el WebService de Liquidación Primaria de Granos" + _public_methods_ = ['Conectar', 'Dummy', 'SetTicketAcceso', 'DebugLog', + 'AutorizarLiquidacion', + 'AutorizarLiquidacionSecundaria', + 'AnularLiquidacionSecundaria', 'AnularLiquidacion', + 'AutorizarAnticipo', 'CancelarAnticipo', + 'CrearLiquidacion', 'CrearLiqSecundariaBase', + 'AgregarCertificado', 'AgregarRetencion', + 'AgregarDeduccion', 'AgregarPercepcion', + 'AgregarOpcional', 'AgregarCalidad', + 'AgregarFacturaPapel', 'AgregarFusion', + 'ConsultarLiquidacion', 'ConsultarUltNroOrden', + 'ConsultarLiquidacionSecundaria', + 'ConsultarLiquidacionSecundariaUltNroOrden', + 'CrearAjusteBase', + 'CrearAjusteDebito', 'CrearAjusteCredito', + 'AjustarLiquidacionUnificado', + 'AjustarLiquidacionUnificadoPapel', + 'AjustarLiquidacionContrato', + 'AjustarLiquidacionSecundaria', + 'AnalizarAjusteDebito', 'AnalizarAjusteCredito', + 'AsociarLiquidacionAContrato', 'ConsultarAjuste', + 'ConsultarLiquidacionesPorContrato', + 'ConsultarLiquidacionesSecundariasPorContrato', + 'AsociarLiquidacionSecundariaAContrato', + 'CrearCertificacionCabecera', + 'AgregarCertificacionPrimaria', + 'AgregarCertificacionRetiroTransferencia', + 'AgregarCertificacionPreexistente', + 'AgregarDetalleMuestraAnalisis', 'AgregarCTG', + 'AutorizarCertificacion', + 'InformarCalidadCertificacion', 'BuscarCTG', + 'AnularCertificacion', + 'ConsultarCertificacion', + 'ConsultarCertificacionUltNroOrden', + 'BuscarCertConSaldoDisponible', + 'LeerDatosLiquidacion', + 'ConsultarCampanias', + 'ConsultarTipoGrano', + 'ConsultarGradoEntregadoXTipoGrano', + 'ConsultarCodigoGradoReferencia', + 'ConsultarTipoCertificadoDeposito', + 'ConsultarTipoDeduccion', + 'ConsultarTipoRetencion', + 'ConsultarPuerto', + 'ConsultarTipoActividad', + 'ConsultarTipoActividadRepresentado', + 'ConsultarProvincias', + 'ConsultarLocalidadesPorProvincia', + 'ConsultarTiposOperacion', + 'BuscarLocalidades', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetParametro', 'GetParametro', + 'CargarFormatoPDF', 'AgregarCampoPDF', 'AgregarDatoPDF', + 'CrearPlantillaPDF', 'ProcesarPlantillaPDF', + 'GenerarPDF', 'MostrarPDF', + ] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'Excepcion', 'ErrCode', 'ErrMsg', 'LanzarExcepciones', 'Errores', + 'XmlRequest', 'XmlResponse', 'Version', 'Traceback', 'InstallDir', + 'COE', 'COEAjustado', 'Estado', 'Resultado', 'NroOrden', + 'TotalDeduccion', 'TotalRetencion', 'TotalRetencionAfip', + 'TotalOtrasRetenciones', 'TotalNetoAPagar', 'TotalPagoSegunCondicion', + 'TotalIvaRg4310_18', 'Subtotal', 'TotalIva105', 'TotalIva21', + 'TotalRetencionesGanancias', 'TotalRetencionesIVA', 'NroContrato', + 'FechaCertificacion', + ] + _reg_progid_ = "WSLPG" + _reg_clsid_ = "{9D21C513-21A6-413C-8592-047357692608}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + LanzarExcepciones = False + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.errores = [] + self.COE = self.COEAjustado = "" + self.Estado = self.Resultado = self.NroOrden = self.NroContrato = '' + self.TotalDeduccion = "" + self.TotalRetencion = "" + self.TotalRetencionAfip = "" + self.TotalOtrasRetenciones = "" + self.TotalNetoAPagar = "" + self.TotalIvaRg4310_18 = "" + self.TotalPagoSegunCondicion = "" + self.Subtotal = self.TotalIva105 = self.TotalIva21 = "" + self.TotalRetencionesGanancias = self.TotalRetencionesIVA = "" + self.TotalPercepcion = "" + self.FechaCertificacion = "" + self.datos = {} + + @inicializar_y_capturar_excepciones + def Conectar(self, cache=None, url="", proxy="", wrapper="", cacert=None, timeout=30): + "Establecer la conexión a los servidores de la AFIP" + # llamo al constructor heredado: + ok = BaseWS.Conectar(self, cache, url, proxy, wrapper, cacert, timeout) + if ok: + # corrijo ubicación del servidor (puerto htttp 80 en el WSDL) + location = self.client.services['LpgService']['ports']['LpgEndPoint']['location'] + if location.startswith("http://"): + print("Corrigiendo WSDL ...", location, end=' ') + location = location.replace("http://", "https://").replace(":80", ":443") + self.client.services['LpgService']['ports']['LpgEndPoint']['location'] = location + print(location) + + try: + # intento abrir el diccionario persistente de localidades + from . import wslpg_datos + localidades_db = os.path.join(self.cache, "localidades.dat") + # verificar que puede escribir en el dir, sino abrir solo lectura + flag = os.access(self.cache, os.W_OK) and 'c' or 'r' + wslpg_datos.LOCALIDADES = shelve.open(localidades_db, flag=flag) + if DEBUG: + print("Localidades en BD:", len(wslpg_datos.LOCALIDADES)) + self.Traceback = "Localidades en BD: %s" % len(wslpg_datos.LOCALIDADES) + except Exception as e: + print("ADVERTENCIA: No se pudo abrir la bbdd de localidades:", e) + self.Excepcion = str(e) + return ok + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + errores = [] + if 'errores' in ret: + errores.extend(ret['errores']) + if 'erroresFormato' in ret: + errores.extend(ret['erroresFormato']) + if errores: + self.Errores = ["%(codigo)s: %(descripcion)s" % err['error'] + for err in errores] + self.errores = [ + {'codigo': err['error']['codigo'], + 'descripcion': err['error']['descripcion'].replace("\n", "") + .replace("\r", "")} + for err in errores] + self.ErrCode = ' '.join(self.Errores) + self.ErrMsg = '\n'.join(self.Errores) + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + results = self.client.dummy()['return'] + self.AppServerStatus = str(results['appserver']) + self.DbServerStatus = str(results['dbserver']) + self.AuthServerStatus = str(results['authserver']) + return True + + @inicializar_y_capturar_excepciones + def CrearLiquidacion(self, nro_orden=None, cuit_comprador=None, + nro_act_comprador=None, nro_ing_bruto_comprador=None, + cod_tipo_operacion=None, + es_liquidacion_propia=None, es_canje=None, + cod_puerto=None, des_puerto_localidad=None, cod_grano=None, + cuit_vendedor=None, nro_ing_bruto_vendedor=None, + actua_corredor=None, liquida_corredor=None, cuit_corredor=None, + comision_corredor=None, nro_ing_bruto_corredor=None, + fecha_precio_operacion=None, + precio_ref_tn=None, cod_grado_ref=None, cod_grado_ent=None, + factor_ent=None, precio_flete_tn=None, cont_proteico=None, + alic_iva_operacion=None, campania_ppal=None, + cod_localidad_procedencia=None, + datos_adicionales=None, pto_emision=1, cod_prov_procedencia=None, + peso_neto_sin_certificado=None, val_grado_ent=None, + cod_localidad_procedencia_sin_certificado=None, + cod_prov_procedencia_sin_certificado=None, + nro_contrato=None, + **kwargs + ): + "Inicializa internamente los datos de una liquidación para autorizar" + + # limpio los campos especiales (segun validaciones de AFIP) + if alic_iva_operacion == 0: + alic_iva_operacion = None # no informar alicuota p/ monotributo + if val_grado_ent == 0: + val_grado_ent = None + # borrando datos corredor si no corresponden + if actua_corredor == "N": + cuit_corredor = None + comision_corredor = None + nro_ing_bruto_corredor = None + + # si no corresponde elimino el peso neto certificado campo opcional + if not peso_neto_sin_certificado or not int(peso_neto_sin_certificado): + peso_neto_sin_certificado = None + + if cod_puerto and int(cod_puerto) != 14: + des_puerto_localidad = None # validacion 1630 + + # limpio los campos opcionales para no enviarlos si no corresponde: + if cod_grado_ref == "": + cod_grado_ref = None + if cod_grado_ent == "": + cod_grado_ent = None + if val_grado_ent == 0: + val_grado_ent = None + + # creo el diccionario con los campos generales de la liquidación: + self.liquidacion = dict( + ptoEmision=pto_emision, + nroOrden=nro_orden, + cuitComprador=cuit_comprador, + nroActComprador=nro_act_comprador, + nroIngBrutoComprador=nro_ing_bruto_comprador, + codTipoOperacion=cod_tipo_operacion, + esLiquidacionPropia=es_liquidacion_propia, + esCanje=es_canje, + codPuerto=cod_puerto, + desPuertoLocalidad=des_puerto_localidad, + codGrano=cod_grano, + cuitVendedor=cuit_vendedor, + nroIngBrutoVendedor=nro_ing_bruto_vendedor, + actuaCorredor=actua_corredor, + liquidaCorredor=liquida_corredor, + cuitCorredor=cuit_corredor, + comisionCorredor=comision_corredor, + nroIngBrutoCorredor=nro_ing_bruto_corredor, + fechaPrecioOperacion=fecha_precio_operacion, + precioRefTn=precio_ref_tn, + codGradoRef=cod_grado_ref, + codGradoEnt=cod_grado_ent, + valGradoEnt=val_grado_ent, + factorEnt=factor_ent, + precioFleteTn=precio_flete_tn, + contProteico=cont_proteico, + alicIvaOperacion=alic_iva_operacion, + campaniaPPal=campania_ppal, + codLocalidadProcedencia=cod_localidad_procedencia, + codProvProcedencia=cod_prov_procedencia, + datosAdicionales=datos_adicionales, + pesoNetoSinCertificado=peso_neto_sin_certificado, + numeroContrato=nro_contrato or None, + certificados=[], + ) + # para compatibilidad hacia atras, "copiar" los campos si no hay cert: + if peso_neto_sin_certificado: + if cod_localidad_procedencia_sin_certificado is None: + cod_localidad_procedencia_sin_certificado = cod_localidad_procedencia + if cod_prov_procedencia_sin_certificado is None: + cod_prov_procedencia_sin_certificado = cod_prov_procedencia + self.liquidacion.update(dict( + codLocalidadProcedenciaSinCertificado=cod_localidad_procedencia_sin_certificado, + codProvProcedenciaSinCertificado=cod_prov_procedencia_sin_certificado, + )) + + # inicializo las listas que contentran las retenciones y deducciones: + self.retenciones = [] + self.deducciones = [] + self.percepciones = [] + self.opcionales = [] # para anticipo + # limpio las estructuras internas no utilizables en este caso + self.certificacion = None + return True + + @inicializar_y_capturar_excepciones + def CrearLiqSecundariaBase(self, pto_emision=1, nro_orden=None, + nro_contrato=None, + cuit_comprador=None, nro_ing_bruto_comprador=None, + cod_puerto=None, des_puerto_localidad=None, + cod_grano=None, cantidad_tn=None, + cuit_vendedor=None, nro_act_vendedor=None, # nuevo!! + nro_ing_bruto_vendedor=None, + actua_corredor=None, liquida_corredor=None, cuit_corredor=None, + nro_ing_bruto_corredor=None, + fecha_precio_operacion=None, precio_ref_tn=None, + precio_operacion=None, alic_iva_operacion=None, campania_ppal=None, + cod_localidad_procedencia=None, cod_prov_procedencia=None, + datos_adicionales=None, + **kwargs): + "Inicializa los datos de una liquidación secundaria de granos (base)" + + # creo el diccionario con los campos generales de la liquidación: + self.liquidacion = dict( + ptoEmision=pto_emision, nroOrden=nro_orden, + numeroContrato=nro_contrato or None, cuitComprador=cuit_comprador, + nroIngBrutoComprador=nro_ing_bruto_comprador, + codPuerto=cod_puerto, desPuertoLocalidad=des_puerto_localidad, + codGrano=cod_grano, cantidadTn=cantidad_tn, + cuitVendedor=cuit_vendedor, nroActVendedor=nro_act_vendedor, + nroIngBrutoVendedor=nro_ing_bruto_vendedor, + actuaCorredor=actua_corredor, liquidaCorredor=liquida_corredor, + cuitCorredor=cuit_corredor or None, + nroIngBrutoCorredor=nro_ing_bruto_corredor or None, + fechaPrecioOperacion=fecha_precio_operacion, + precioRefTn=precio_ref_tn, precioOperacion=precio_operacion, + alicIvaOperacion=alic_iva_operacion or None, + campaniaPPal=campania_ppal, + codLocalidad=cod_localidad_procedencia, + codProvincia=cod_prov_procedencia, + datosAdicionales=datos_adicionales, + ) + # inicializo las listas que contentran las retenciones y deducciones: + self.deducciones = [] + self.percepciones = [] + self.opcionales = [] + self.factura_papel = None + return True + + @inicializar_y_capturar_excepciones + def AgregarCertificado(self, tipo_certificado_deposito=None, + nro_certificado_deposito=None, + peso_neto=None, + cod_localidad_procedencia=None, + cod_prov_procedencia=None, + campania=None, fecha_cierre=None, + peso_neto_total_certificado=None, + coe_certificado_deposito=None, # WSLPGv1.6 + **kwargs): + "Agrego el certificado a la liquidación / certificación de granos" + # limpio campos opcionales: + if not peso_neto_total_certificado: + peso_neto_total_certificado = None # 0 no es válido + # coe_certificado_deposito no es para LPG, unificar en futuras versiones + if tipo_certificado_deposito and int(tipo_certificado_deposito) == 332: + if coe_certificado_deposito and int(coe_certificado_deposito): + nro_certificado_deposito = coe_certificado_deposito + coe_certificado_deposito = None + cert = dict( + tipoCertificadoDeposito=tipo_certificado_deposito, + nroCertificadoDeposito=nro_certificado_deposito, + pesoNeto=peso_neto, + codLocalidadProcedencia=cod_localidad_procedencia, + codProvProcedencia=cod_prov_procedencia, + campania=campania, + fechaCierre=fecha_cierre, + pesoNetoTotalCertificado=peso_neto_total_certificado, + coeCertificadoDeposito=coe_certificado_deposito, + coe=coe_certificado_deposito, # WSLPGv1.17 + pesoAjustado=peso_neto, # WSLPGv1.17 + ) + if self.liquidacion: + self.liquidacion['certificados'].append({'certificado': cert}) + else: + self.certificacion['retiroTransferencia']['certificadoDeposito'] = cert + return True + + @inicializar_y_capturar_excepciones + def AgregarRetencion(self, codigo_concepto, detalle_aclaratorio, + base_calculo, alicuota, + nro_certificado_retencion=None, + fecha_certificado_retencion=None, + importe_certificado_retencion=None, + **kwargs): + "Agrega la información referente a las retenciones de la liquidación" + # limpio los campos opcionales: + if fecha_certificado_retencion is not None and not fecha_certificado_retencion.strip(): + fecha_certificado_retencion = None + if importe_certificado_retencion is not None and not float(importe_certificado_retencion): + importe_certificado_retencion = None + if nro_certificado_retencion is not None and not int(nro_certificado_retencion): + nro_certificado_retencion = None + self.retenciones.append(dict( + retencion=dict( + codigoConcepto=codigo_concepto, + detalleAclaratorio=detalle_aclaratorio, + baseCalculo=base_calculo, + alicuota=alicuota, + nroCertificadoRetencion=nro_certificado_retencion, + fechaCertificadoRetencion=fecha_certificado_retencion, + importeCertificadoRetencion=importe_certificado_retencion, + )) + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarDeduccion(self, codigo_concepto=None, detalle_aclaratorio=None, + dias_almacenaje=None, precio_pkg_diario=None, + comision_gastos_adm=None, base_calculo=None, + alicuota=None, **kwargs): + "Agrega la información referente a las deducciones de la liquidación." + # limpiar campo según validación (comision_gastos_adm puede ser 0.00!) + if codigo_concepto != "CO" and comision_gastos_adm is not None \ + and float(comision_gastos_adm) == 0: + comision_gastos_adm = None + # no enviar campos para prevenir errores AFIP 1705, 1707, 1708 + if base_calculo is not None: + if codigo_concepto == "AL": + base_calculo = None + if codigo_concepto == "CO" and float(base_calculo) == 0: + base_calculo = None # no enviar, por retrocompatibilidad + if codigo_concepto != "AL": + dias_almacenaje = None + precio_pkg_diario = None + self.deducciones.append(dict( + deduccion=dict( + codigoConcepto=codigo_concepto, + detalleAclaratorio=detalle_aclaratorio, + diasAlmacenaje=dias_almacenaje, + precioPKGdiario=precio_pkg_diario, + comisionGastosAdm=comision_gastos_adm, + baseCalculo=base_calculo, + alicuotaIva=alicuota, + )) + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarPercepcion(self, codigo_concepto=None, detalle_aclaratoria=None, + base_calculo=None, alicuota=None, importe_final=None, + **kwargs): + "Agrega la información referente a las percepciones de la liquidación" + # liquidación secundaria (sin importe final) + self.percepciones.append(dict( + percepcion=dict( + detalleAclaratoria=detalle_aclaratoria, + baseCalculo=base_calculo, + alicuota=alicuota, + importeFinal=importe_final, + )) + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarOpcional(self, codigo=None, descripcion=None, **kwargs): + "Agrega la información referente a los opcionales de la liq. seq." + self.opcionales.append(dict( + opcional=dict( + codigo=codigo, + descripcion=descripcion, + )) + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarFacturaPapel(self, nro_cai=None, nro_factura_papel=None, + fecha_factura=None, tipo_comprobante=None, + **kwargs): + self.factura_papel = dict( + nroCAI=nro_cai, + nroFacturaPapel=nro_factura_papel, + fechaFactura=fecha_factura, + tipoComprobante=tipo_comprobante, + ) + return True + + @inicializar_y_capturar_excepciones + def AutorizarLiquidacion(self): + "Autorizar Liquidación Primaria Electrónica de Granos" + + # limpio los elementos que no correspondan por estar vacios: + if not self.liquidacion['certificados']: + del self.liquidacion['certificados'] + if not self.retenciones: + self.retenciones = None + if not self.deducciones: + self.deducciones = None + if not self.percepciones: + self.percepciones = None + else: + # ajustar los nombres de campos que varian entre LPG y LSG + for it in self.percepciones: + per = it['percepcion'] + per['descripcion'] = per.pop("detalleAclaratoria") + del per['baseCalculo'] + del per['alicuota'] + + # llamo al webservice: + ret = self.client.liquidacionAutorizar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + liquidacion=self.liquidacion, + retenciones=self.retenciones, + deducciones=self.deducciones, + percepciones=self.percepciones, + ) + + # analizo la respusta + ret = ret['liqReturn'] + self.__analizar_errores(ret) + self.AnalizarLiquidacion(ret.get('autorizacion'), self.liquidacion) + return True + + @inicializar_y_capturar_excepciones + def AutorizarLiquidacionSecundaria(self): + "Autorizar Liquidación Secundaria Electrónica de Granos" + + # extraer y adaptar los campos para liq. sec. + if self.deducciones: + self.liquidacion['deduccion'] = [] + for it in self.deducciones: + ded = it['deduccion'] # no se agrupa + self.liquidacion['deduccion'].append({ + 'detalleAclaratoria': ded['detalleAclaratorio'], + 'baseCalculo': ded['baseCalculo'], + 'alicuotaIVA': ded['alicuotaIva']}) + if self.percepciones: + self.liquidacion['percepcion'] = [] + for it in self.percepciones: + per = it['percepcion'] # no se agrupa + self.liquidacion['percepcion'].append(per) + if self.opcionales: + self.liquidacion['opcionales'] = self.opcionales # agrupado ok + + # llamo al webservice: + ret = self.client.lsgAutorizar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + liqSecundariaBase=self.liquidacion, + facturaPapel=self.factura_papel, + ) + + # analizo la respusta + ret = ret['oReturn'] + self.__analizar_errores(ret) + self.AnalizarLiquidacion(ret.get('autorizacion'), self.liquidacion) + return True + + @inicializar_y_capturar_excepciones + def AutorizarAnticipo(self): + "Autorizar Anticipo de una Liquidación Primaria Electrónica de Granos" + + # extraer y adaptar los campos para el anticipo + anticipo = {"liquidacion": self.liquidacion} + liq = anticipo["liquidacion"] + liq["campaniaPpal"] = self.liquidacion["campaniaPPal"] + liq["codLocProcedencia"] = self.liquidacion["codLocalidadProcedencia"] + liq["descPuertoLocalidad"] = self.liquidacion["desPuertoLocalidad"] + + if self.opcionales: + liq['opcionales'] = self.opcionales + + if self.retenciones: + anticipo['retenciones'] = self.retenciones + + if self.deducciones: + anticipo['deducciones'] = self.deducciones + + # llamo al webservice: + ret = self.client.lpgAutorizarAnticipo( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + anticipo=anticipo, + ) + + # analizo la respusta + ret = ret['liqReturn'] + self.__analizar_errores(ret) + self.AnalizarLiquidacion(ret.get('autorizacion'), self.liquidacion) + return True + + @inicializar_y_capturar_excepciones + def CancelarAnticipo(self, pto_emision=None, nro_orden=None, coe=None, + pdf=None): + "Cancelar Anticipo de una Liquidación Primaria Electrónica de Granos" + + # llamo al webservice: + ret = self.client.lpgCancelarAnticipo( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + ptoEmision=pto_emision, + nroOrden=nro_orden, + pdf="S" if pdf else "N", + ) + + # analizo la respusta + ret = ret['liqConsReturn'] + self.__analizar_errores(ret) + if 'liquidacion' in ret: + aut = ret['autorizacion'] + liq = ret['liquidacion'] + self.AnalizarLiquidacion(aut, liq) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in ret: + open(pdf, "wb").write(ret['pdf']) + return True + + def AnalizarLiquidacion(self, aut, liq=None, ajuste=False): + "Método interno para analizar la respuesta de AFIP" + # proceso los datos básicos de la liquidación (devuelto por consultar): + if liq: + self.params_out = dict( + pto_emision=liq.get('ptoEmision'), + nro_orden=liq.get('nroOrden'), + cuit_comprador=liq.get('cuitComprador'), + nro_act_comprador=liq.get('nroActComprador'), + nro_ing_bruto_comprador=liq.get('nroIngBrutoComprador'), + cod_tipo_operacion=liq.get('codTipoOperacion'), + es_liquidacion_propia=liq.get('esLiquidacionPropia'), + es_canje=liq.get('esCanje'), + cod_puerto=liq.get('codPuerto'), + des_puerto_localidad=liq.get('desPuertoLocalidad'), + cod_grano=liq.get('codGrano'), + cuit_vendedor=liq.get('cuitVendedor'), + nro_ing_bruto_vendedor=liq.get('nroIngBrutoVendedor'), + actua_corredor=liq.get('actuaCorredor'), + liquida_corredor=liq.get('liquidaCorredor'), + cuit_corredor=liq.get('cuitCorredor'), + comision_corredor=liq.get('comisionCorredor'), + nro_ing_bruto_corredor=liq.get('nroIngBrutoCorredor'), + fecha_precio_operacion=liq.get('fechaPrecioOperacion'), + precio_ref_tn=liq.get('precioRefTn'), + cod_grado_ref=liq.get('codGradoRef'), + cod_grado_ent=liq.get('codGradoEnt'), + factor_ent=liq.get('factorEnt'), + precio_flete_tn=liq.get('precioFleteTn'), + cont_proteico=liq.get('contProteico'), + alic_iva_operacion=liq.get('alicIvaOperacion'), + campania_ppal=liq.get('campaniaPPal'), + cod_localidad_procedencia=liq.get('codLocalidadProcedencia'), + cod_prov_procedencia=liq.get('codProvProcedencia'), + datos_adicionales=liq.get('datosAdicionales'), + peso_neto_sin_certificado=liq.get('pesoNetoSinCertificado'), + cod_localidad_procedencia_sin_certificado=liq.get('codLocalidadProcedenciaSinCertificado'), + cod_prov_procedencia_sin_certificado=liq.get('codProvProcedenciaSinCertificado'), + certificados=[], + ) + if ajuste: + self.params_out.update( + # ajustes: + diferencia_peso_neto=liq.get('diferenciaPesoNeto'), + diferencia_precio_operacion=liq.get('diferenciaPrecioOperacion'), + cod_grado=liq.get('codGrado'), + val_grado=liq.get('valGrado'), + factor=liq.get('factor'), + diferencia_precio_flete_tn=liq.get('diferenciaPrecioFleteTn'), + concepto_importe_iva_0=liq.get('conceptoImporteIva0'), + importe_ajustar_iva_0=liq.get('importeAjustarIva0'), + concepto_importe_iva_105=liq.get('conceptoImporteIva105'), + importe_ajustar_iva_105=liq.get('importeAjustarIva105'), + concepto_importe_iva_21=liq.get('conceptoImporteIva21'), + importe_ajustar_iva_21=liq.get('importeAjustarIva21'), + ) + # analizar detalle de importes ajustados discriminados por alicuota + # (por compatibildiad y consistencia se usan los mismos campos) + for it in liq.get("importes", liq.get("importe")): + # en ajustes LSG no se agrupan los importes en un subtipo... + if 'importeReturn' in it: + it = it['importeReturn'][0] # TODO: revisar SOAP + tasa = "iva_%s" % str(it['alicuota']).replace(".", "").strip() + self.params_out["concepto_importe_%s" % tasa] = it['concepto'] + self.params_out["importe_ajustar_%s" % tasa] = it['importe'] + self.params_out["iva_calculado_%s" % tasa] = it['ivaCalculado'] + if 'certificados' in liq: + for c in liq['certificados']: + cert = c['certificado'] + self.params_out['certificados'].append(dict( + tipo_certificado_deposito=cert['tipoCertificadoDeposito'], + nro_certificado_deposito=cert['nroCertificadoDeposito'], + peso_neto=cert['pesoNeto'], + cod_localidad_procedencia=cert['codLocalidadProcedencia'], + cod_prov_procedencia=cert['codProvProcedencia'], + campania=cert['campania'], + fecha_cierre=cert['fechaCierre'], + )) + + self.params_out['errores'] = self.errores + + # proceso la respuesta de autorizar, ajustar (y consultar): + if aut: + self.TotalDeduccion = aut.get('totalDeduccion') + self.TotalRetencion = aut.get('totalRetencion') + self.TotalRetencionAfip = aut.get('totalRetencionAfip') + self.TotalOtrasRetenciones = aut.get('totalOtrasRetenciones') + self.TotalNetoAPagar = aut.get('totalNetoAPagar') + self.TotalIvaRg4310_18 = aut.get('totalIvaRg4310_18') + self.TotalPagoSegunCondicion = aut.get('totalPagoSegunCondicion') + self.COE = str(aut.get('coe', '')) + self.COEAjustado = aut.get('coeAjustado') + self.Estado = aut.get('estado', '') + self.NroContrato = aut.get('numeroContrato', '') + + # actualizo parámetros de salida: + self.params_out['coe'] = self.COE + self.params_out['coe_ajustado'] = self.COEAjustado + self.params_out['estado'] = self.Estado + self.params_out['total_deduccion'] = self.TotalDeduccion + self.params_out['total_retencion'] = self.TotalRetencion + self.params_out['total_retencion_afip'] = self.TotalRetencionAfip + self.params_out['total_otras_retenciones'] = self.TotalOtrasRetenciones + self.params_out['total_neto_a_pagar'] = self.TotalNetoAPagar + self.params_out['total_iva_rg_4310_18'] = self.TotalIvaRg4310_18 + self.params_out['total_pago_segun_condicion'] = self.TotalPagoSegunCondicion + + # datos adicionales: + self.NroOrden = self.params_out['nro_orden'] = aut.get('nroOrden') + self.params_out['cod_tipo_ajuste'] = aut.get('codTipoAjuste') + fecha = aut.get('fechaLiquidacion') + if fecha: + fecha = str(fecha) + self.params_out['fecha_liquidacion'] = fecha + self.params_out['importe_iva'] = aut.get('importeIva') + self.params_out['nro_op_comercial'] = aut.get('nroOpComercial') + self.params_out['operacion_con_iva'] = aut.get('operacionConIva') + self.params_out['precio_operacion'] = aut.get('precioOperacion') + self.params_out['total_peso_neto'] = aut.get('totalPesoNeto') + self.params_out['subtotal'] = aut.get('subTotal') + # LSG (especificos): + self.params_out['total_deducciones'] = aut.get('totalDeducciones') + if 'todalPercepciones' in aut: + # error de tipeo en el WSDL de AFIP... + self.params_out['total_percepciones'] = aut.get('todalPercepciones') + else: + self.params_out['total_percepciones'] = aut.get('totalPercepciones') + # sub estructuras: + self.params_out['retenciones'] = [] + self.params_out['deducciones'] = [] + self.params_out['percepciones'] = [] + for retret in aut.get("retenciones", []): + retret = retret['retencionReturn'] + self.params_out['retenciones'].append({ + 'importe_retencion': retret['importeRetencion'], + 'alicuota': retret['retencion'].get('alicuota'), + 'base_calculo': retret['retencion'].get('baseCalculo'), + 'codigo_concepto': retret['retencion'].get('codigoConcepto'), + 'detalle_aclaratorio': (retret['retencion'].get('detalleAclaratorio') or "").replace("\n", ""), + 'importe_certificado_retencion': retret['retencion'].get('importeCertificadoRetencion'), + 'nro_certificado_retencion': retret['retencion'].get('nroCertificadoRetencion'), + 'fecha_certificado_retencion': retret['retencion'].get('fechaCertificadoRetencion'), + }) + for dedret in aut.get("deducciones", []): + dedret = dedret['deduccionReturn'] + self.params_out['deducciones'].append({ + 'importe_deduccion': dedret['importeDeduccion'], + 'importe_iva': dedret.get('importeIva'), + 'alicuota': dedret['deduccion'].get('alicuotaIva'), + 'base_calculo': dedret['deduccion'].get('baseCalculo'), + 'codigo_concepto': dedret['deduccion'].get('codigoConcepto'), + 'detalle_aclaratorio': dedret['deduccion'].get('detalleAclaratorio', "").replace("\n", ""), + 'dias_almacenaje': dedret['deduccion'].get('diasAlmacenaje'), + 'precio_pkg_diario': dedret['deduccion'].get('precioPKGdiario'), + 'comision_gastos_adm': dedret['deduccion'].get('comisionGastosAdm'), + }) + for perret in aut.get("percepciones", []): + perret = perret.get('percepcionReturn', perret) + self.params_out['percepciones'].append({ + 'importe_final': perret['percepcion']['importeFinal'], + 'alicuota': perret['percepcion'].get('alicuota'), + 'base_calculo': perret['percepcion'].get('baseCalculo'), + 'descripcion': perret['percepcion'].get('descripcion', "").replace("\n", ""), + }) + + @inicializar_y_capturar_excepciones + def CrearAjusteBase(self, + pto_emision=1, nro_orden=None, # unificado, contrato, papel + coe_ajustado=None, # unificado + nro_contrato=None, # contrato + tipo_formulario=None, # papel + nro_formulario=None, # papel + actividad=None, # contrato / papel + cod_grano=None, # contrato / papel + cuit_vendedor=None, # contrato / papel + cuit_comprador=None, # contrato / papel + cuit_corredor=None, # contrato / papel + nro_ing_bruto_vendedor=None, # papel + nro_ing_bruto_comprador=None, # papel + nro_ing_bruto_corredor=None, # papel + tipo_operacion=None, # papel + precio_ref_tn=None, # contrato + cod_grado_ent=None, # contrato + val_grado_ent=None, # contrato + precio_flete_tn=None, # contrato + cod_puerto=None, # contrato + des_puerto_localidad=None, # contrato + cod_provincia=None, # unificado, contrato, papel + cod_localidad=None, # unificado, contrato, papel + comision_corredor=None, # papel + **kwargs + ): + "Inicializa internamente los datos de una liquidación para ajustar" + + # ajusto nombre de campos para compatibilidad hacia atrás (encabezado): + if 'cod_localidad_procedencia' in kwargs: + cod_localidad = kwargs['cod_localidad_procedencia'] + if 'cod_provincia_procedencia' in kwargs: + cod_provincia = kwargs['cod_provincia_procedencia'] + if 'nro_act_comprador' in kwargs: + actividad = kwargs['nro_act_comprador'] + if 'cod_tipo_operacion' in kwargs: + tipo_operacion = kwargs['cod_tipo_operacion'] + + # limpio los campos especiales (segun validaciones de AFIP) + if val_grado_ent == 0: + val_grado_ent = None + # borrando datos si no corresponden + if cuit_corredor and int(cuit_corredor) == 0: + cuit_corredor = None + comision_corredor = None + nro_ing_bruto_corredor = None + + if cod_puerto and int(cod_puerto) != 14: + des_puerto_localidad = None # validacion 1630 + + # limpio los campos opcionales para no enviarlos si no corresponde: + if cod_grado_ent == "": + cod_grado_ent = None + if val_grado_ent == 0: + val_grado_ent = None + + # creo el diccionario con los campos generales del ajuste base: + self.ajuste = {'ajusteBase': { + 'ptoEmision': pto_emision, + 'nroOrden': nro_orden, + 'coeAjustado': coe_ajustado, + 'nroContrato': nro_contrato, + 'tipoFormulario': tipo_formulario, + 'nroFormulario': nro_formulario, + 'actividad': actividad, + 'codGrano': cod_grano, + 'cuitVendedor': cuit_vendedor, + 'cuitComprador': cuit_comprador, + 'cuitCorredor': cuit_corredor, + 'nroIngBrutoVendedor': nro_ing_bruto_vendedor, + 'nroIngBrutoComprador': nro_ing_bruto_comprador, + 'nroIngBrutoCorredor': nro_ing_bruto_corredor, + 'tipoOperacion': tipo_operacion, + 'codPuerto': cod_puerto, + 'desPuertoLocalidad': des_puerto_localidad, + 'comisionCorredor': comision_corredor, + 'precioRefTn': precio_ref_tn, + 'codGradoEnt': cod_grado_ent, + 'valGradoEnt': val_grado_ent, + 'precioFleteTn': precio_flete_tn, + 'codLocalidad': cod_localidad, + 'codProv': cod_provincia, + 'certificados': [], + } + } + # para compatibilidad con AgregarCertificado + self.liquidacion = self.ajuste['ajusteBase'] + # inicializar temporales + self.__ajuste_base = None + self.__ajuste_debito = None + self.__ajuste_credito = None + return True + + @inicializar_y_capturar_excepciones + def CrearAjusteCredito(self, + datos_adicionales=None, # unificado, contrato, papel + concepto_importe_iva_0=None, # unificado, contrato, papel + importe_ajustar_iva_0=None, # unificado, contrato, papel + concepto_importe_iva_105=None, # unificado, contrato, papel + importe_ajustar_iva_105=None, # unificado, contrato, papel + concepto_importe_iva_21=None, # unificado, contrato, papel + importe_ajustar_iva_21=None, # unificado, contrato, papel + diferencia_peso_neto=None, # unificado + diferencia_precio_operacion=None, # unificado + cod_grado=None, # unificado + val_grado=None, # unificado + factor=None, # unificado + diferencia_precio_flete_tn=None, # unificado + **kwargs + ): + "Inicializa internamente los datos del crédito del ajuste" + + self.ajuste['ajusteCredito'] = { + 'diferenciaPesoNeto': diferencia_peso_neto, + 'diferenciaPrecioOperacion': diferencia_precio_operacion, + 'codGrado': cod_grado, + 'valGrado': val_grado, + 'factor': factor, + 'diferenciaPrecioFleteTn': diferencia_precio_flete_tn, + 'datosAdicionales': datos_adicionales, + 'opcionales': None, + 'conceptoImporteIva0': concepto_importe_iva_0, + 'importeAjustarIva0': importe_ajustar_iva_0, + 'conceptoImporteIva105': concepto_importe_iva_105, + 'importeAjustarIva105': importe_ajustar_iva_105, + 'conceptoImporteIva21': concepto_importe_iva_21, + 'importeAjustarIva21': importe_ajustar_iva_21, + 'deducciones': [], + 'retenciones': [], + 'percepciones': [], + 'certificados': [], + } + # vinculación con AgregarOpcional: + self.opcionales = self.ajuste['ajusteCredito']['opcionales'] + # vinculación con AgregarRetencion y AgregarDeduccion + self.deducciones = self.ajuste['ajusteCredito']['deducciones'] + self.retenciones = self.ajuste['ajusteCredito']['retenciones'] + # para LSG: + self.percepciones = self.ajuste['ajusteCredito']['percepciones'] + # para compatibilidad con AgregarCertificado (WSLPGv1.17) + self.liquidacion = self.ajuste['ajusteCredito'] + return True + + @inicializar_y_capturar_excepciones + def CrearAjusteDebito(self, + datos_adicionales=None, # unificado, contrato, papel + concepto_importe_iva_0=None, # unificado, contrato, papel + importe_ajustar_iva_0=None, # unificado, contrato, papel + concepto_importe_iva_105=None, # unificado, contrato, papel + importe_ajustar_iva_105=None, # unificado, contrato, papel + concepto_importe_iva_21=None, # unificado, contrato, papel + importe_ajustar_iva_21=None, # unificado, contrato, papel + diferencia_peso_neto=None, # unificado + diferencia_precio_operacion=None, # unificado + cod_grado=None, # unificado + val_grado=None, # unificado + factor=None, # unificado + diferencia_precio_flete_tn=None, # unificado + **kwargs + ): + "Inicializa internamente los datos del crédito del ajuste" + + self.ajuste['ajusteDebito'] = { + 'diferenciaPesoNeto': diferencia_peso_neto, + 'diferenciaPrecioOperacion': diferencia_precio_operacion, + 'codGrado': cod_grado, + 'valGrado': val_grado, + 'factor': factor, + 'diferenciaPrecioFleteTn': diferencia_precio_flete_tn, + 'datosAdicionales': datos_adicionales, + 'opcionales': None, + 'conceptoImporteIva0': concepto_importe_iva_0, + 'importeAjustarIva0': importe_ajustar_iva_0, + 'conceptoImporteIva105': concepto_importe_iva_105, + 'importeAjustarIva105': importe_ajustar_iva_105, + 'conceptoImporteIva21': concepto_importe_iva_21, + 'importeAjustarIva21': importe_ajustar_iva_21, + 'deducciones': [], + 'retenciones': [], + 'percepciones': [], + 'certificados': [], + } + # vinculación con AgregarOpcional: + self.opcionales = self.ajuste['ajusteDebito']['opcionales'] + # vinculación con AgregarRetencion y AgregarDeduccion + self.deducciones = self.ajuste['ajusteDebito']['deducciones'] + self.retenciones = self.ajuste['ajusteDebito']['retenciones'] + # para LSG: + self.percepciones = self.ajuste['ajusteDebito']['percepciones'] + # para compatibilidad con AgregarCertificado (WSLPGv1.17) + self.liquidacion = self.ajuste['ajusteDebito'] + return True + + def AgregarFusion(self, nro_ing_brutos, nro_actividad, **kwargs): + "Datos de comprador o vendedor según liquidación a ajustar (fusión.)" + self.ajuste['ajusteBase']['fusion'] = {'nroIngBrutos': nro_ing_brutos, + 'nroActividad': nro_actividad, + } + return True + + @inicializar_y_capturar_excepciones + def AjustarLiquidacionUnificado(self): + "Ajustar Liquidación Primaria de Granos" + + # limpiar estructuras no utilizadas (si no hay deducciones / retenciones) + for k in ('ajusteDebito', 'ajusteCredito'): + if not any(self.ajuste[k].values()): + del self.ajuste[k] + else: + if not self.ajuste[k]['deducciones']: + del self.ajuste[k]['deducciones'] + if not self.ajuste[k]['retenciones']: + del self.ajuste[k]['retenciones'] + + # llamar al webservice: + ret = self.client.liquidacionAjustarUnificado( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + **self.ajuste + ) + # analizar el resultado: + ret = ret['ajusteUnifReturn'] + self.__analizar_errores(ret) + if 'ajusteUnificado' in ret: + aut = ret['ajusteUnificado'] + self.AnalizarAjuste(aut) + return True + + @inicializar_y_capturar_excepciones + def AjustarLiquidacionUnificadoPapel(self): + "Ajustar Liquidación realizada en un formulario F1116 B / C (papel)" + + # limpiar arrays no enviados: + if not self.ajuste['ajusteBase']['certificados']: + del self.ajuste['ajusteBase']['certificados'] + for k1 in ('ajusteCredito', 'ajusteDebito'): + for k2 in ('retenciones', 'deducciones'): + if not self.ajuste[k1][k2]: + del self.ajuste[k1][k2] + ret = self.client.liquidacionAjustarUnificadoPapel( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + **self.ajuste + ) + ret = ret['ajustePapelReturn'] + self.__analizar_errores(ret) + if 'ajustePapel' in ret: + aut = ret['ajustePapel'] + self.AnalizarAjuste(aut) + return True + + @inicializar_y_capturar_excepciones + def AjustarLiquidacionContrato(self): + "Ajustar Liquidación activas relacionadas a un contrato" + + # limpiar arrays no enviados: + if not self.ajuste['ajusteBase']['certificados']: + del self.ajuste['ajusteBase']['certificados'] + for k1 in ('ajusteCredito', 'ajusteDebito'): + for k2 in ('retenciones', 'deducciones'): + if not self.ajuste[k1][k2]: + del self.ajuste[k1][k2] + + ret = self.client.liquidacionAjustarContrato( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + **self.ajuste + ) + ret = ret['ajusteContratoReturn'] + self.__analizar_errores(ret) + if 'ajusteContrato' in ret: + aut = ret['ajusteContrato'] + self.AnalizarAjuste(aut) + return True + + @inicializar_y_capturar_excepciones + def AjustarLiquidacionSecundaria(self): + "Ajustar Liquidación Secundaria de Granos" + + # limpiar estructuras no utilizadas (si no hay deducciones / retenciones) + for k in ('ajusteDebito', 'ajusteCredito'): + if k not in self.ajuste: + # ignorar si no se agrego estructura ajuste credito / debito + continue + elif not any(self.ajuste[k].values()): + # eliminar estructura vacia credito / debito + del self.ajuste[k] + else: + # ajustar cambios de nombre entre LSG y LPG + for tasa in ("0", "105", "21"): + tasa_lsg = "10" if tasa == "105" else tasa + self.ajuste[k]['importeAjustar%s' % tasa_lsg] = self.ajuste[k]['importeAjustarIva%s' % tasa] + self.ajuste[k]['conceptoIva%s' % tasa_lsg] = self.ajuste[k]['conceptoImporteIva%s' % tasa] + # no enviar tag percepciones vacio (no agrupar en subtipo) + if self.ajuste[k]['percepciones']: + self.ajuste[k]['percepcion'] = [ + per["percepcion"] for per + in self.ajuste[k]['percepciones']] + del self.ajuste[k]['percepciones'] + + base = self.ajuste['ajusteBase'] + base['coe'] = base['coeAjustado'] + base['codProvincia'] = base['codProv'] + + # llamar al webservice: + + if base['nroContrato'] is not None and int(base['nroContrato']): + metodo = self.client.lsgAjustarXContrato + else: + metodo = self.client.lsgAjustarXCoe + + ret = metodo( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ajusteCredito=self.ajuste.get('ajusteCredito'), + ajusteDebito=self.ajuste.get('ajusteDebito'), + **base + ) + # analizar el resultado: + ret = ret['oReturn'] + self.__analizar_errores(ret) + if ret: + self.AnalizarAjuste(ret) + return True + + def AnalizarAjuste(self, aut, base=True): + "Método interno para analizar la respuesta de AFIP (ajustes)" + + self.__ajuste_base = None + self.__ajuste_debito = None + self.__ajuste_credito = None + + # para compatibilidad con la generacion de PDF (completo datos) + if hasattr(self, "liquidacion") and self.liquidacion and base: + self.AnalizarLiquidacion(aut=None, liq=self.liquidacion) + + self.params_out['errores'] = self.errores + + # proceso la respuesta de autorizar, ajustar (y consultar): + if aut: + # en caso de anulación o no ser ajuste, ahora no devuelve datos: + self.COE = str(aut.get('coe', "")) + self.COEAjustado = aut.get('coeAjustado') + self.NroContrato = aut.get('nroContrato') + self.Estado = aut.get('estado', "") + + totunif = aut.get("totalesUnificados") or {} + self.Subtotal = totunif.get('subTotalGeneral') + self.TotalIva105 = totunif.get('iva105') + self.TotalIva21 = totunif.get('iva21') + self.TotalRetencionesGanancias = totunif.get('retencionesGanancias') + self.TotalRetencionesIVA = totunif.get('retencionesIVA') + self.TotalOtrasRetenciones = totunif.get('importeOtrasRetenciones') + self.TotalNetoAPagar = totunif.get('importeNeto') + self.TotalIvaRg4310_18 = totunif.get('ivaRG4310_18') + self.TotalPagoSegunCondicion = totunif.get('pagoSCondicion') + + # actualizo parámetros de salida: + self.params_out['coe'] = self.COE + self.params_out['coe_ajustado'] = self.COEAjustado + self.params_out['estado'] = self.Estado + self.params_out['nro_orden'] = aut.get('nroOrden') + self.params_out['cod_tipo_operacion'] = aut.get('codTipoOperacion') + self.params_out['nro_contrato'] = aut.get('nroContrato') + self.params_out['nro_op_comercial'] = aut.get('nroOpComercial', "") + + # actualizo totales solo para ajuste base (liquidacion general) + if base: + self.params_out['subtotal'] = self.Subtotal + self.params_out['iva_deducciones'] = totunif.get('ivaDeducciones') + self.params_out['subtotal_deb_cred'] = totunif.get('subTotalDebCred') + self.params_out['total_base_deducciones'] = totunif.get('totalBaseDeducciones') + self.params_out['total_iva_10_5'] = self.TotalIva105 + self.params_out['total_iva_21'] = self.TotalIva21 + self.params_out['total_retenciones_ganancias'] = self.TotalRetencionesGanancias + self.params_out['total_retenciones_iva'] = self.TotalRetencionesIVA + self.params_out['total_otras_retenciones'] = self.TotalOtrasRetenciones + self.params_out['total_neto_a_pagar'] = self.TotalNetoAPagar + self.params_out['total_iva_rg_4310_18'] = self.TotalIvaRg4310_18 + self.params_out['total_pago_segun_condicion'] = self.TotalPagoSegunCondicion + + # almaceno los datos de ajustes crédito y débito para usarlos luego + self.__ajuste_base = aut + self.__ajuste_debito = aut.get('ajusteDebito') or {} + self.__ajuste_credito = aut.get('ajusteCredito') or {} + return True + + @inicializar_y_capturar_excepciones + def AnalizarAjusteDebito(self): + "Método para analizar la respuesta de AFIP para Ajuste Debito" + # para compatibilidad con la generacion de PDF (completo datos) + liq = {} + if hasattr(self, "liquidacion") and self.liquidacion: + liq.update(self.liquidacion) + if hasattr(self, "ajuste") and 'ajusteDebito' in self.ajuste: + liq.update(self.ajuste['ajusteDebito']) + if self.__ajuste_debito: + liq.update(self.__ajuste_debito) + self.AnalizarLiquidacion(aut=self.__ajuste_debito, liq=liq, ajuste=True) + self.AnalizarAjuste(self.__ajuste_base, base=False) # datos generales + return True + + @inicializar_y_capturar_excepciones + def AnalizarAjusteCredito(self): + "Método para analizar la respuesta de AFIP para Ajuste Credito" + liq = {} + if hasattr(self, "liquidacion") and self.liquidacion: + liq.update(self.liquidacion) + if hasattr(self, "ajuste") and 'ajusteCredito' in self.ajuste: + liq.update(self.ajuste['ajusteCredito']) + if self.__ajuste_credito: + liq.update(self.__ajuste_credito) + self.AnalizarLiquidacion(aut=self.__ajuste_credito, liq=liq, ajuste=True) + self.AnalizarAjuste(self.__ajuste_base, base=False) # datos generales + return True + + @inicializar_y_capturar_excepciones + def CrearCertificacionCabecera(self, pto_emision=1, nro_orden=None, + tipo_certificado=None, nro_planta=None, + nro_ing_bruto_depositario=None, titular_grano=None, + cuit_depositante=None, nro_ing_bruto_depositante=None, + cuit_corredor=None, cod_grano=None, campania=None, + datos_adicionales=None, + **kwargs): + "Inicializa los datos de una certificación de granos (cabecera)" + + self.certificacion = {} + self.certificacion['cabecera'] = dict( + ptoEmision=pto_emision, + nroOrden=nro_orden, + tipoCertificado=tipo_certificado, + nroPlanta=nro_planta or None, # opcional + nroIngBrutoDepositario=nro_ing_bruto_depositario, + titularGrano=titular_grano, + cuitDepositante=cuit_depositante or None, # opcional + nroIngBrutoDepositante=nro_ing_bruto_depositante or None, # opcional + cuitCorredor=cuit_corredor or None, # opcional + codGrano=cod_grano, + campania=campania, + datosAdicionales=datos_adicionales, # opcional + ) + # limpio las estructuras internas no utilizables en este caso + self.liquidacion = None + return True + + @inicializar_y_capturar_excepciones + def AgregarCertificacionPrimaria(self, + nro_act_depositario=None, + descripcion_tipo_grano=None, + monto_almacenaje=None, monto_acarreo=None, + monto_gastos_generales=None, monto_zarandeo=None, + porcentaje_secado_de=None, porcentaje_secado_a=None, + monto_secado=None, monto_por_cada_punto_exceso=None, + monto_otros=None, + porcentaje_merma_volatil=None, peso_neto_merma_volatil=None, + porcentaje_merma_secado=None, peso_neto_merma_secado=None, + porcentaje_merma_zarandeo=None, peso_neto_merma_zarandeo=None, + peso_neto_certificado=None, servicios_secado=None, + servicios_zarandeo=None, servicios_otros=None, + servicios_forma_de_pago=None, + **kwargs): + + # compatibilidad hacia atras: utilizar nuevos campos mas amplio + v = None + if 'servicio_otros' in kwargs: + v = kwargs.get('servicio_otros') + if isinstance(v, str) and v and not v.isalpha(): + v = float(v) + if v: + servicios_otros = v + if not v: + warnings.warn("Usar servicio_otros para mayor cantidad de digitos") + + self.certificacion['primaria'] = dict( + nroActDepositario=nro_act_depositario, + ctg=[], # + descripcionTipoGrano=descripcion_tipo_grano, + montoAlmacenaje=monto_almacenaje, + montoAcarreo=monto_acarreo, + montoGastosGenerales=monto_gastos_generales, + montoZarandeo=monto_zarandeo, + porcentajeSecadoDe=porcentaje_secado_de, + porcentajeSecadoA=porcentaje_secado_a, + montoSecado=monto_secado, + montoPorCadaPuntoExceso=monto_por_cada_punto_exceso, + montoOtros=monto_otros, + porcentajeMermaVolatil=porcentaje_merma_volatil, + pesoNetoMermaVolatil=peso_neto_merma_volatil, + porcentajeMermaSecado=porcentaje_merma_secado, + pesoNetoMermaSecado=peso_neto_merma_secado, + porcentajeMermaZarandeo=porcentaje_merma_zarandeo, + pesoNetoMermaZarandeo=peso_neto_merma_zarandeo, + pesoNetoCertificado=peso_neto_certificado, + serviciosSecado=servicios_secado or None, # opcional + serviciosZarandeo=servicios_zarandeo or None, + serviciosOtros=servicios_otros or None, + serviciosFormaDePago=servicios_forma_de_pago or None, + ) + # si se pasan campos no documentados por AFIP, intentar enviarlo: + for k, kk in list({ + 'servicios_conceptos_no_gravados': 'serviciosConceptosNoGravados', + 'servicios_percepciones_iva': 'serviciosPercepcionesIva', + 'servicios_otras_percepciones': 'serviciosOtrasPercepciones', + }.items()): + v = kwargs.get(k) + # cuidado: si AFIP retira el campo, puede fallar si se pasa en 0 + if isinstance(v, str) and v and not v.isalpha(): + v = float(v) + if v: + self.certificacion['primaria'][kk] = v + return True + + @inicializar_y_capturar_excepciones + def AgregarCertificacionRetiroTransferencia(self, + nro_act_depositario=None, + cuit_receptor=None, + fecha=None, + nro_carta_porte_a_utilizar=None, + cee_carta_porte_a_utilizar=None, + **kwargs): + self.certificacion['retiroTransferencia'] = dict( + nroActDepositario=nro_act_depositario, + cuitReceptor=cuit_receptor or None, # opcional + fecha=fecha, + nroCartaPorteAUtilizar=nro_carta_porte_a_utilizar or None, + ceeCartaPorteAUtilizar=cee_carta_porte_a_utilizar or None, + certificadoDeposito=[], # + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarCertificacionPreexistente(self, + tipo_certificado_deposito_preexistente=None, + nro_certificado_deposito_preexistente=None, + cac_certificado_deposito_preexistente=None, + fecha_emision_certificado_deposito_preexistente=None, + peso_neto=None, nro_planta=None, + **kwargs): + self.certificacion['preexistente'] = dict( + tipoCertificadoDepositoPreexistente=tipo_certificado_deposito_preexistente, + nroCertificadoDepositoPreexistente=nro_certificado_deposito_preexistente, + cacCertificadoDepositoPreexistente=cac_certificado_deposito_preexistente, + fechaEmisionCertificadoDepositoPreexistente=fecha_emision_certificado_deposito_preexistente, + pesoNeto=peso_neto, nroPlanta=nro_planta, + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarCalidad(self, analisis_muestra=None, nro_boletin=None, + cod_grado=None, valor_grado=None, + valor_contenido_proteico=None, valor_factor=None, + **kwargs): + "Agrega la información sobre la calidad, al autorizar o posteriormente" + self.certificacion['primaria']['calidad'] = dict( + analisisMuestra=analisis_muestra, + nroBoletin=nro_boletin, + codGrado=cod_grado, # G1 G2 G3 F1 F2 F3 + valorGrado=valor_grado or None, # opcional + valorContProteico=valor_contenido_proteico, + valorFactor=valor_factor, + detalleMuestraAnalisis=[], # + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarDetalleMuestraAnalisis(self, descripcion_rubro=None, + tipo_rubro=None, porcentaje=None, + valor=None, + **kwargs): + "Agrega la información referente al detalle de la certificación" + + det = dict( + descripcionRubro=descripcion_rubro, + tipoRubro=tipo_rubro, + porcentaje=porcentaje, + valor=valor, + ) + self.certificacion['primaria']['calidad']['detalleMuestraAnalisis'].append(det) + return True + + @inicializar_y_capturar_excepciones + def BuscarCTG(self, tipo_certificado="P", cuit_depositante=None, + nro_planta=None, cod_grano=2, campania=1314, + nro_ctg=None, tipo_ctg=None, nro_carta_porte=None, + fecha_confirmacion_ctg_des=None, + fecha_confirmacion_ctg_has=None, + ): + "Devuelve los CTG/Carta de porte que se puede incluir en un certificado" + ret = self.client.cgBuscarCtg( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + tipoCertificado=tipo_certificado, + cuitDepositante=cuit_depositante or self.Cuit, + nroPlanta=nro_planta, + codGrano=cod_grano, campania=campania, + nroCtg=nro_ctg, tipoCtg=tipo_ctg, + nroCartaPorte=nro_carta_porte, + fechaConfirmacionCtgDes=fecha_confirmacion_ctg_des, + fechaConfirmacionCtgHas=fecha_confirmacion_ctg_has, + )['oReturn'] + self.__analizar_errores(ret) + array = ret.get('ctg', []) + self.Excepcion = self.Traceback = "" + self.params_out['ctgs'] = [] + for ctg in array: + self.params_out['ctgs'].append({ + 'campania': ctg.get('campania'), + 'nro_planta': ctg.get('nroPlanta'), + 'nro_ctg': ctg.get('nroCtg'), + 'tipo_ctg': ctg.get('tipoCtg'), + 'nro_carta_porte': ctg.get('nroCartaPorte'), + 'kilos_confirmados': ctg.get('kilosConfirmados'), + 'fecha_confirmacion_ctg': ctg.get('fechaConfirmacionCtg'), + 'cod_grano': ctg.get('codGrano'), + 'cuit_remitente_comercial': ctg.get('cuitRemitenteComercial'), + 'cuit_liquida': ctg.get('cuitLiquida'), + 'cuit_certifica': ctg.get('cuitCertifica'), + }) + return True + + @inicializar_y_capturar_excepciones + def AgregarCTG(self, nro_ctg=None, nro_carta_porte=None, + porcentaje_secado_humedad=None, importe_secado=None, + peso_neto_merma_secado=None, tarifa_secado=None, + importe_zarandeo=None, peso_neto_merma_zarandeo=None, + tarifa_zarandeo=None, + peso_neto_confirmado_definitivo=None, + **kwargs): + "Agrega la información referente a una CTG de la certificación" + + ctg = dict( + nroCTG=nro_ctg, + nroCartaDePorte=nro_carta_porte, + pesoNetoConfirmadoDefinitivo=peso_neto_confirmado_definitivo, + porcentajeSecadoHumedad=porcentaje_secado_humedad, + importeSecado=importe_secado, + pesoNetoMermaSecado=peso_neto_merma_secado, + tarifaSecado=tarifa_secado, + importeZarandeo=importe_zarandeo, + pesoNetoMermaZarandeo=peso_neto_merma_zarandeo, + tarifaZarandeo=tarifa_zarandeo, + ) + self.certificacion['primaria']['ctg'].append(ctg) + return True + + @inicializar_y_capturar_excepciones + def BuscarCertConSaldoDisponible(self, cuit_depositante=None, + cod_grano=2, campania=1314, coe=None, + fecha_emision_des=None, + fecha_emision_has=None, + ): + """Devuelve los certificados de depósito en los que un productor tiene + saldo disponible para Liquidar/Retirar/Transferir""" + + ret = self.client.cgBuscarCertConSaldoDisponible( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + cuitDepositante=cuit_depositante or self.Cuit, + codGrano=cod_grano, campania=campania, + coe=coe, + fechaEmisionDes=fecha_emision_des, + fechaEmisionHas=fecha_emision_has, + )['oReturn'] + self.__analizar_errores(ret) + array = ret.get('certificado', []) + self.Excepcion = self.Traceback = "" + self.params_out['certificados'] = [] + for cert in array: + self.params_out['certificados'].append(dict( + coe=cert['coe'], + tipo_certificado=cert['tipoCertificado'], + campania=cert['campania'], + cuit_depositante=cert['cuitDepositante'], + cuit_depositario=cert['cuitDepositario'], + nro_planta=cert['nroPlanta'], + kilos_disponibles=cert['kilosDisponibles'], + cod_grano=cert['codGrano'], + )) + return True + + @inicializar_y_capturar_excepciones + def AutorizarCertificacion(self): + "Autoriza una Certificación Primaria de Depósito de Granos (C1116A/RT)" + + # limpio los elementos que no correspondan por estar vacios: + for k1 in ('primaria', 'retiroTransferencia'): + dic = self.certificacion.get(k1) + if not dic: + continue + for k2 in ('ctg', 'detalleMuestraAnalisis', 'certificadoDeposito'): + if k2 in dic and not dic[k2]: + del dic[k2] + + # llamo al webservice: + ret = self.client.cgAutorizar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + **self.certificacion + ) + + # analizo la respusta + ret = ret['oReturn'] + self.__analizar_errores(ret) + self.AnalizarAutorizarCertificadoResp(ret) + return True + + def AnalizarAutorizarCertificadoResp(self, ret): + "Metodo interno para extraer datos de la Respuesta de Certificación" + aut = ret.get('autorizacion') + if aut: + self.PtoEmision = aut['ptoEmision'] + self.NroOrden = aut['nroOrden'] + self.FechaCertificacion = str(aut.get('fechaCertificacion', "")) + self.COE = str(aut['coe']) + self.Estado = aut['estado'] + # actualizo parámetros de salida: + self.params_out['coe'] = self.COE + self.params_out['estado'] = self.Estado + self.params_out['nro_orden'] = self.NroOrden + self.params_out['fecha_certificacion'] = self.FechaCertificacion.replace("-", "") + if "planta" in aut: + p = aut.get("planta") + self.params_out['nro_planta'] = p.get("nroPlanta") + self.params_out['cuit_titular_planta'] = p.get("cuitTitularPlanta") + self.params_out['razon_social_titular_planta'] = p.get("razonSocialTitularPlanta") + # otros campos devueltos (opcionales) + p = aut.get('pesosResumen', {}) + self.params_out['peso_bruto_certificado'] = p.get("pesoBrutoCertificado") + self.params_out['peso_merma_secado'] = p.get("pesoMermaSecado") + self.params_out['peso_merma_volatil'] = p.get("pesoMermaVolatil") + self.params_out['peso_merma_zarandeo'] = p.get("pesoMermaZarandeo") + self.params_out['peso_neto_certificado'] = p.get("pesoNetoCertificado") + p = aut.get('serviciosResumen', {}) + self.params_out['importe_iva'] = p.get("importeIVA") + self.params_out['servicio_gastos_generales'] = p.get("servicioGastosGenerales") + self.params_out['servicio_otros'] = p.get("servicioOtros") + self.params_out['servicio_total'] = p.get("servicioTotal") + self.params_out['servicio_zarandeo'] = p.get("servicioZarandeo") + # datos devueltos según el tipo de certificacion (consultas): + cab = ret.get('cabecera') + if cab: + self.params_out['pto_emision'] = cab.get('ptoEmision') + self.params_out['nro_orden'] = cab.get('nroOrden') + self.params_out['tipo_certificado'] = cab.get('tipoCertificado') + self.params_out['nro_planta'] = cab.get('nroPlanta') + self.params_out['nro_ing_bruto_depositario'] = cab.get('nroIngBrutoDepositario') + self.params_out['titular_grano'] = cab.get('titularGrano') + self.params_out['cuit_depositante'] = cab.get('cuitDepositante') + self.params_out['nro_ing_bruto_depositante'] = cab.get('nroIngBrutoDepositante') + self.params_out['cuit_corredor'] = cab.get('cuitCorredor') + self.params_out['cod_grano'] = cab.get('codGrano') + self.params_out['campania'] = cab.get('campania') + self.params_out['datos_adicionales'] = cab.get('datosAdicionales') + pri = ret.get('primaria') + if pri: + self.params_out['nro_act_depositario'] = pri.get('nroActDepositario') + self.params_out['descripcion_tipo_grano'] = pri.get('descripcionTipoGrano') + self.params_out['monto_almacenaje'] = pri.get('montoAlmacenaje') + self.params_out['monto_acarreo'] = pri.get('montoAcarreo') + self.params_out['monto_gastos_generales'] = pri.get('montoGastosGenerales') + self.params_out['monto_zarandeo'] = pri.get('montoZarandeo') + self.params_out['porcentaje_secado_de'] = pri.get('porcentajeSecadoDe') + self.params_out['porcentaje_secado_a'] = pri.get('porcentajeSecadoA') + self.params_out['monto_secado'] = pri.get('montoSecado') + self.params_out['monto_por_cada_punto_exceso'] = pri.get('montoPorCadaPuntoExceso') + self.params_out['monto_otros'] = pri.get('montoOtros') + self.params_out['porcentaje_merma_volatil'] = pri.get('porcentajeMermaVolatil') + self.params_out['porcentaje_merma_secado'] = pri.get('porcentajeMermaSecado') + self.params_out['peso_neto_merma_secado'] = pri.get('pesoNetoMermaSecado') + self.params_out['porcentaje_merma_zarandeo'] = pri.get('pesoNetoMermaZarandeo') + self.params_out['peso_neto_certificado'] = pri.get('pesoNetoCertificado') + self.params_out['servicios_secado'] = pri.get('serviciosSecado') + self.params_out['servicios_zarandeo'] = pri.get('serviciosZarandeo') + self.params_out['servicios_otros'] = pri.get('serviciosOtros') + self.params_out['servicios_forma_de_pago'] = pri.get('serviciosFormaDePago') + # otros campos no documentados: + self.params_out['servicios_conceptos_no_gravados'] = pri.get("serviciosConceptosNoGravados") + self.params_out['servicios_percepciones_iva'] = pri.get("serviciosPercepcionesIVA") + self.params_out['servicios_otras_percepciones'] = pri.get("serviciosOtrasPercepciones") + # sub estructuras: + self.params_out['ctgs'] = [] + self.params_out['det_muestra_analisis'] = [] + for ctg in pri.get("ctg", []): + self.params_out['ctgs'].append({ + 'nro_ctg': ctg.get('nroCTG'), + 'nro_carta_porte': ctg.get('nroCartaDePorte'), + 'peso_neto_confirmado_definitivo': ctg.get('pesoNetoConfirmadoDefinitivo'), + 'porcentaje_secado_humedad': ctg.get('porcentajeSecadoHumedad'), + 'importe_secado': ctg.get('importeSecado'), + 'peso_neto_merma_secado': ctg.get('pesoNetoMermaSecado'), + 'importe_zarandeo': ctg.get('importeZarandeo'), + 'peso_neto_merma_zarandeo': ctg.get('pesoNetoMermaZarandeo'), + 'tarifa_zarandeo': ctg.get('tarifaZarandeo'), + }) + self.params_out['calidad'] = [] + for cal in [pri.get("calidad", {})]: + self.params_out['calidad'].append({ + 'analisis_muestra': cal.get('analisisMuestra'), + 'nro_boletin': cal.get('nroBoletin'), + 'nro_act_depositario': cal.get('nroActDepositario'), + 'cod_grado': cal.get('codGrado'), + 'valor_grado': cal.get('valorGrado'), + 'valor_contenido_proteico': cal.get('valorContProteico'), + 'valor_factor': cal.get('valorFactor') + }) + for det in cal.get("detalleMuestraAnalisis", []): + self.params_out['det_muestra_analisis'].append({ + 'descripcion_rubro': det.get('descripcionRubro'), + 'tipo_rubro': det.get('tipoRubro'), + 'porcentaje': det.get('porcentaje'), + 'valor': det.get('valor'), + }) + rt = ret.get('retiroTransferencia') + if rt: + self.params_out['nro_act_depositario'] = rt.get('nroActDepositario') + self.params_out['cuit_receptor'] = rt.get('cuitReceptor') + self.params_out['nro_carta_porte_a_utilizar'] = rt.get('nroCartaPorteAUtilizar') + # sub estructuras: + self.params_out['certificados'] = [] + cert = rt.get("certificadoDeposito") + if cert: + self.params_out['certificados'].append({ + 'coe_certificado_deposito': cert.get('coeCertificadoDeposito'), + 'peso_neto': cert.get('pesoNeto'), + }) + pre = ret.get('preexistente') + if pre: + self.params_out['nro_planta'] = pre.get('nroPlanta') + self.params_out['tipo_certificado_deposito_preexistente'] = pre.get('tipoCertificadoDepositoPreexistente') + self.params_out['nro_certificado_deposito_preexistente'] = pre.get('nroCertificadoDepositoPreexistente') + self.params_out['cac_certificado_deposito_preexistente'] = pre.get('cacCertificadoDepositoPreexistente') + self.params_out['fecha_emision_certificado_deposito_preexistente'] = pre.get('fechaEmisionCertificadoDepositoPreexistente') + self.params_out['peso_neto'] = pre.get('pesoNeto') + + self.params_out['errores'] = self.errores + + @inicializar_y_capturar_excepciones + def InformarCalidadCertificacion(self, coe): + "Informar calidad de un certificado (C1116A/RT)" + + # llamo al webservice: + ret = self.client.cgInformarCalidad( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + calidad=self.certificacion['primaria']['calidad'], + ) + + # analizo la respusta + ret = ret['oReturn'] + self.__analizar_errores(ret) + self.AnalizarAutorizarCertificadoResp(ret) + return True + + @inicializar_y_capturar_excepciones + def AnularCertificacion(self, coe): + "Anular liquidación activa" + ret = self.client.cgSolicitarAnulacion( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + ) + ret = ret['oReturn'] + self.__analizar_errores(ret) + self.Estado = ret.get('estadoCertificado', "") + return self.COE + + @inicializar_y_capturar_excepciones + def AsociarLiquidacionAContrato(self, coe=None, nro_contrato=None, + cuit_comprador=None, + cuit_vendedor=None, + cuit_corredor=None, + cod_grano=None, + **kwargs): + "Asociar una Liquidación a un contrato" + + ret = self.client.asociarLiquidacionAContrato( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + nroContrato=nro_contrato, + cuitComprador=cuit_comprador, + cuitVendedor=cuit_vendedor, + cuitCorredor=cuit_corredor, + codGrano=cod_grano, + ) + ret = ret['liquidacion'] + self.__analizar_errores(ret) + if 'liquidacion' in ret: + # analizo la respusta + liq = ret['liquidacion'] + aut = ret['autorizacion'] + self.AnalizarLiquidacion(aut, liq) + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacionesPorContrato(self, nro_contrato=None, + cuit_comprador=None, + cuit_vendedor=None, + cuit_corredor=None, + cod_grano=None, + **kwargs): + "Obtener los COE de liquidaciones relacionadas a un contrato" + ret = self.client.liquidacionPorContratoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + nroContrato=nro_contrato, + cuitComprador=cuit_comprador, + cuitVendedor=cuit_vendedor, + cuitCorredor=cuit_corredor, + codGrano=cod_grano, + ) + ret = ret['liqPorContratoCons'] + self.__analizar_errores(ret) + if 'coeRelacionados' in ret: + # analizo la respuesta = [{'coe': "...."}] + self.DatosLiquidacion = sorted(ret['coeRelacionados']) + # establezco el primer COE + self.LeerDatosLiquidacion() + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacion(self, pto_emision=None, nro_orden=None, coe=None, + pdf=None): + "Consulta una liquidación por No de orden" + if coe: + ret = self.client.liquidacionXCoeConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + pdf='S' if pdf else 'N', + ) + else: + ret = self.client.liquidacionXNroOrdenConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + nroOrden=nro_orden, + ) + ret = ret['liqConsReturn'] + self.__analizar_errores(ret) + if 'liquidacion' in ret: + aut = ret['autorizacion'] + liq = ret['liquidacion'] + self.AnalizarLiquidacion(aut, liq) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in ret: + open(pdf, "wb").write(ret['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacionSecundaria(self, pto_emision=None, nro_orden=None, + coe=None, pdf=None): + "Consulta una liquidación sequndaria por No de orden o coe" + if coe: + ret = self.client.lsgConsultarXCoe( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + pdf='S' if pdf else 'N', + ) + else: + ret = self.client.lsgConsultarXNroOrden( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + nroOrden=nro_orden, + ) + ret = ret['oReturn'] + self.__analizar_errores(ret) + for it in ret['liquidaciones']: + aut = it['autorizacion'] + if 'liquidacion' in it: + liq = it['liquidacion'] + elif 'ajuste' in it: + liq = it['ajuste'] + self.AnalizarLiquidacion(aut, liq) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in ret: + open(pdf, "wb").write(ret['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacionesSecundariasPorContrato(self, nro_contrato=None, + cuit_comprador=None, + cuit_vendedor=None, + cuit_corredor=None, + cod_grano=None, + **kwargs): + "Obtener los COE de liquidaciones relacionadas a un contrato" + ret = self.client.lsgConsultarXContrato( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + nroContrato=nro_contrato, + cuitComprador=cuit_comprador, + cuitVendedor=cuit_vendedor, + cuitCorredor=cuit_corredor, + codGrano=cod_grano, + ) + ret = ret['liqPorContratoCons'] + self.__analizar_errores(ret) + if 'coeRelacionados' in ret: + # analizo la respuesta = [{'coe': "...."}] + self.DatosLiquidacion = sorted(ret['coeRelacionados']) + # establezco el primer COE + self.LeerDatosLiquidacion() + return True + + @inicializar_y_capturar_excepciones + def AsociarLiquidacionSecundariaAContrato(self, coe=None, nro_contrato=None, + cuit_comprador=None, + cuit_vendedor=None, + cuit_corredor=None, + cod_grano=None, + **kwargs): + "Asociar una Liquidación a un contrato" + + ret = self.client.lsgAsociarAContrato( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + nroContrato=nro_contrato, + cuitComprador=cuit_comprador, + cuitVendedor=cuit_vendedor, + cuitCorredor=cuit_corredor, + codGrano=cod_grano, + ) + ret = ret['oReturn'] + self.__analizar_errores(ret) + if 'liquidacion' in ret: + # analizo la respusta + liq = ret['liquidacion'] + aut = ret['autorizacion'] + self.AnalizarLiquidacion(aut, liq) + return True + + @inicializar_y_capturar_excepciones + def ConsultarCertificacion(self, pto_emision=None, nro_orden=None, + coe=None, pdf=None): + "Consulta una certificacion por No de orden o COE" + if coe: + ret = self.client.cgConsultarXCoe( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + pdf='S' if pdf else 'N', + ) + else: + ret = self.client.cgConsultarXNroOrden( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + nroOrden=nro_orden, + ) + ret = ret['oReturn'] + self.__analizar_errores(ret) + if 'autorizacion' in ret: + self.AnalizarAutorizarCertificadoResp(ret) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in ret: + open(pdf, "wb").write(ret['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarAjuste(self, pto_emision=None, nro_orden=None, nro_contrato=None, + coe=None, pdf=None): + "Consulta un ajuste de liquidación por No de orden o numero de contrato" + if nro_contrato: + ret = self.client.ajustePorContratoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + nroContrato=nro_contrato, + ) + ret = ret['ajusteContratoReturn'] + elif coe is None or pdf is None: + ret = self.client.ajusteXNroOrdenConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + nroOrden=nro_orden, + pdf='S' if pdf else 'N', + ) + ret = ret['ajusteXNroOrdenConsReturn'] + else: + ret = self.client.ajusteXCoeConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + pdf='S' if pdf else 'N', + ) + ret = ret['ajusteConsReturn'] + + self.__analizar_errores(ret) + if 'ajusteUnificado' in ret: + aut = ret['ajusteUnificado'] + self.AnalizarAjuste(aut) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in ret: + open(pdf, "wb").write(ret['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarUltNroOrden(self, pto_emision=1): + "Consulta el último No de orden registrado" + ret = self.client.liquidacionUltimoNroOrdenConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + ) + ret = ret['liqUltNroOrdenReturn'] + self.__analizar_errores(ret) + self.NroOrden = ret['nroOrden'] + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacionSecundariaUltNroOrden(self, pto_emision=1): + "Consulta el último No de orden registrado para LSG" + ret = self.client.lsgConsultarUltimoNroOrden( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + ) + ret = ret['liqUltNroOrdenReturn'] + self.__analizar_errores(ret) + self.NroOrden = ret['nroOrden'] + return True + + @inicializar_y_capturar_excepciones + def ConsultarCertificacionUltNroOrden(self, pto_emision=1): + "Consulta el último No de orden registrado para CG" + ret = self.client.cgConsultarUltimoNroOrden( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + ptoEmision=pto_emision, + ) + ret = ret['liqUltNroOrdenReturn'] + self.__analizar_errores(ret) + self.NroOrden = ret['nroOrden'] + return True + + @inicializar_y_capturar_excepciones + def LeerDatosLiquidacion(self, pop=True): + "Recorro los datos devueltos y devuelvo el primero si existe" + + if self.DatosLiquidacion: + # extraigo el primer item + if pop: + datos_liq = self.DatosLiquidacion.pop(0) + else: + datos_liq = self.DatosLiquidacion[0] + self.COE = str(datos_liq['coe']) + self.Estado = str(datos_liq.get('estado', "")) + return self.COE + else: + return "" + + @inicializar_y_capturar_excepciones + def AnularLiquidacion(self, coe): + "Anular liquidación activa" + ret = self.client.liquidacionAnular( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + ) + ret = ret['anulacionReturn'] + self.__analizar_errores(ret) + self.Resultado = ret['resultado'] + return self.COE + + @inicializar_y_capturar_excepciones + def AnularLiquidacionSecundaria(self, coe): + "Anular liquidación secundaria activa" + ret = self.client.lsgAnular( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + coe=coe, + ) + ret = ret['anulacionReturn'] + self.__analizar_errores(ret) + self.Resultado = ret['resultado'] + return self.COE + + def ConsultarCampanias(self, sep="||"): + ret = self.client.campaniasConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['campaniaReturn'] + self.__analizar_errores(ret) + array = ret.get('campanias', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarTipoGrano(self, sep="||"): + ret = self.client.tipoGranoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoGranoReturn'] + self.__analizar_errores(ret) + array = ret.get('granos', []) + if sep is None: + return dict([(it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarCodigoGradoReferencia(self, sep="||"): + "Consulta de Grados según Grano." + ret = self.client.codigoGradoReferenciaConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['gradoRefReturn'] + self.__analizar_errores(ret) + array = ret.get('gradosRef', []) + if sep is None: + return dict([(it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarGradoEntregadoXTipoGrano(self, cod_grano, sep="||"): + "Consulta de Grado y Valor según Grano Entregado." + ret = self.client.codigoGradoEntregadoXTipoGranoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + codGrano=cod_grano, + )['gradoEntReturn'] + self.__analizar_errores(ret) + array = ret.get('gradoEnt', []) + if sep is None: + return dict([(it['gradoEnt']['codigoDescripcion']['codigo'], + it['gradoEnt']['valor']) + for it in array]) + else: + return [("%s %%s %s %%s %s %%s %s" % (sep, sep, sep, sep)) % + (it['gradoEnt']['codigoDescripcion']['codigo'], + it['gradoEnt']['codigoDescripcion']['descripcion'], + it['gradoEnt']['valor'], + ) + for it in array] + + def ConsultarTipoCertificadoDeposito(self, sep="||"): + "Consulta de tipos de Certificados de Depósito" + ret = self.client.tipoCertificadoDepositoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoCertDepReturn'] + self.__analizar_errores(ret) + array = ret.get('tiposCertDep', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarTipoDeduccion(self, sep="||"): + "Consulta de tipos de Deducciones" + ret = self.client.tipoDeduccionConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoDeduccionReturn'] + self.__analizar_errores(ret) + array = ret.get('tiposDeduccion', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarTipoRetencion(self, sep="||"): + "Consulta de tipos de Retenciones." + ret = self.client.tipoRetencionConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoRetencionReturn'] + self.__analizar_errores(ret) + array = ret.get('tiposRetencion', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarPuerto(self, sep="||"): + "Consulta de Puertos habilitados" + ret = self.client.puertoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['puertoReturn'] + self.__analizar_errores(ret) + array = ret.get('puertos', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarTipoActividad(self, sep="||"): + "Consulta de Tipos de Actividad." + ret = self.client.tipoActividadConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoActividadReturn'] + self.__analizar_errores(ret) + array = ret.get('tiposActividad', []) + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarTipoActividadRepresentado(self, sep="||"): + "Consulta de Tipos de Actividad inscripta en el RUOCA." + try: + ret = self.client.tipoActividadRepresentadoConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoActividadReturn'] + self.__analizar_errores(ret) + array = ret.get('tiposActividad', []) + self.Excepcion = self.Traceback = "" + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + except Exception: + ex = utils.exception_info() + self.Excepcion = ex['msg'] + self.Traceback = ex['tb'] + if sep: + return ["ERROR"] + + def ConsultarProvincias(self, sep="||"): + "Consulta las provincias habilitadas" + ret = self.client.provinciasConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['provinciasReturn'] + self.__analizar_errores(ret) + array = ret.get('provincias', []) + if sep is None: + return dict([(int(it['codigoDescripcion']['codigo']), + it['codigoDescripcion']['descripcion']) + for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def ConsultarLocalidadesPorProvincia(self, codigo_provincia, sep="||"): + ret = self.client.localidadXProvinciaConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + codProvincia=codigo_provincia, + )['localidadesReturn'] + self.__analizar_errores(ret) + array = ret.get('localidades', []) + if sep is None: + return dict([(str(it['codigoDescripcion']['codigo']), + it['codigoDescripcion']['descripcion']) + for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array] + + def BuscarLocalidades(self, cod_prov, cod_localidad=None, consultar=True): + "Devuelve la localidad o la consulta en AFIP (uso interno)" + # si no se especifíca cod_localidad, es util para reconstruir la cache + from . import wslpg_datos as datos + if not str(cod_localidad) in datos.LOCALIDADES and consultar: + d = self.ConsultarLocalidadesPorProvincia(cod_prov, sep=None) + try: + # actualizar el diccionario persistente (shelve) + datos.LOCALIDADES.update(d) + except Exception as e: + print("EXCEPCION CAPTURADA", e) + # capturo errores por permisos (o por concurrencia) + datos.LOCALIDADES = d + return datos.LOCALIDADES.get(str(cod_localidad), "") + + def ConsultarTiposOperacion(self, sep="||"): + "Consulta tipo de Operación por Actividad." + ops = [] + ret = self.client.tipoActividadConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['tipoActividadReturn'] + self.__analizar_errores(ret) + for it_act in ret.get('tiposActividad', []): + + ret = self.client.tipoOperacionXActividadConsultar( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + nroActLiquida=it_act['codigoDescripcion']['codigo'], + )['tipoOperacionReturn'] + self.__analizar_errores(ret) + array = ret.get('tiposOperacion', []) + if sep: + ops.extend([("%s %%s %s %%s %s %%s %s" % (sep, sep, sep, sep)) % + (it_act['codigoDescripcion']['codigo'], + it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array]) + else: + ops.extend([(it_act['codigoDescripcion']['codigo'], + it['codigoDescripcion']['codigo'], + it['codigoDescripcion']['descripcion']) + for it in array]) + return ops + + # Funciones para generar PDF: + + def CargarFormatoPDF(self, archivo="liquidacion_form_c1116b_wslpg.csv"): + "Cargo el formato de campos a generar desde una planilla CSV" + + # si no encuentro archivo, lo busco en el directorio predeterminado: + if not os.path.exists(archivo): + archivo = os.path.join(self.InstallDir, "plantillas", os.path.basename(archivo)) + + if DEBUG: + print("abriendo archivo ", archivo) + # inicializo la lista de los elementos: + self.elements = [] + for lno, linea in enumerate(open(archivo.encode('latin1')).readlines()): + if DEBUG: + print("procesando linea ", lno, linea) + args = [] + for i, v in enumerate(linea.split(";")): + if not v.startswith("'"): + v = v.replace(",", ".") + else: + v = v # .decode('latin1') + if v.strip() == '': + v = None + else: + import ast + try: + v = ast.literal_eval(v.strip()) + except (ValueError, SyntaxError): + v = v.strip() + args.append(v) + + # corrijo path relativo para las imágenes: + if args[1] == 'I': + if not os.path.exists(args[14]): + args[14] = os.path.join(self.InstallDir, "plantillas", os.path.basename(args[14])) + if DEBUG: + print("NUEVO PATH:", args[14]) + + self.AgregarCampoPDF(*args) + + self.AgregarCampoPDF("anulado", 'T', 150, 250, 0, 0, + size=70, rotate=45, foreground=0x808080, + priority=-1) + + if HOMO: + self.AgregarCampoPDF("homo", 'T', 100, 250, 0, 0, + size=70, rotate=45, foreground=0x808080, + priority=-1) + + # cargo los elementos en la plantilla + self.template.load_elements(self.elements) + + return True + + def AgregarCampoPDF(self, nombre, tipo, x1, y1, x2, y2, + font="Arial", size=12, + bold=False, italic=False, underline=False, + foreground=0x000000, background=0xFFFFFF, + align="L", text="", priority=0, **kwargs): + "Agrego un campo a la plantilla" + # convierto colores de string (en hexadecimal) + if isinstance(foreground, str): + foreground = int(foreground, 16) + if isinstance(background, str): + background = int(background, 16) + if isinstance(text, str): + text = text.encode("latin1") + field = { + 'name': nombre, + 'type': tipo, + 'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, + 'font': font, 'size': size, + 'bold': bold, 'italic': italic, 'underline': underline, + 'foreground': foreground, 'background': background, + 'align': align, 'text': text, 'priority': priority} + field.update(kwargs) + self.elements.append(field) + return True + + def CrearPlantillaPDF(self, papel="A4", orientacion="portrait"): + "Iniciar la creación del archivo PDF" + + # genero el renderizador con propiedades del PDF + t = Template( + format=papel, orientation=orientacion, + title="F 1116 B/C %s" % (self.NroOrden), + author="CUIT %s" % self.Cuit, + subject="COE %s" % self.params_out.get('coe'), + keywords="AFIP Liquidacion Electronica Primaria de Granos", + creator='wslpg.py %s (http://www.PyAfipWs.com.ar)' % __version__,) + self.template = t + return True + + def AgregarDatoPDF(self, campo, valor, pagina='T'): + "Agrego un dato a la factura (internamente)" + # corrijo path relativo para las imágenes (compatibilidad hacia atrás): + if campo == 'fondo' and valor.startswith(self.InstallDir): + if not os.path.exists(valor): + valor = os.path.join(self.InstallDir, "plantillas", os.path.basename(valor)) + if DEBUG: + print("NUEVO PATH:", valor) + self.datos[campo] = valor + return True + + def ProcesarPlantillaPDF(self, num_copias=1, lineas_max=24, qty_pos='izq', + clave=''): + "Generar el PDF según la factura creada y plantilla cargada" + try: + f = self.template + liq = self.params_out + # actualizo los campos según la clave (ajuste debitos / creditos) + if clave and clave in liq: + liq = liq.copy() + liq.update(liq[clave]) # unificar con AnalizarAjusteCredito/Debito + + if HOMO: + self.AgregarDatoPDF("homo", "HOMOLOGACIÓN") + + copias = {1: 'Original', 2: 'Duplicado', 3: 'Triplicado', + 4: 'Cuadruplicado', 5: 'Quintuplicado'} + + # convierto el formato de intercambio para representar los valores: + fmt_encabezado = dict([(v[0], v[1:]) for v in ENCABEZADO]) + fmt_deduccion = dict([(v[0], v[1:]) for v in DEDUCCION]) + fmt_retencion = dict([(v[0], v[1:]) for v in RETENCION]) + + def formatear(campo, valor, formato): + "Convertir el valor a una cadena correctamente s/ formato ($ % ...)" + if campo in formato and v is not None: + fmt = formato[campo] + if fmt[1] == N: + if 'cuit' in campo: + c = str(valor) + if len(c) == 11: + valor = "%s-%s-%s" % (c[0:2], c[2:10], c[10:]) + else: + valor = "" + elif 'peso' in campo: + valor = "%s Kg" % valor + elif valor is not None and valor != "": + valor = "%d" % int(valor) + else: + valor = "" + elif fmt[1] == I: + valor = ("%%0.%df" % fmt[2]) % valor + if 'alic' in campo or 'comision' in campo: + valor = valor + " %" + elif 'factor' in campo or 'cont' in campo or 'cant' in campo: + pass + else: + valor = "$ " + valor + elif 'fecha' in campo: + d = valor + if isinstance(d, (datetime.date, datetime.datetime)): + valor = d.strftime("%d/%m/%Y") + else: + valor = "%s/%s/%s" % (d[8:10], d[5:7], d[0:4]) + return valor + + def buscar_localidad_provincia(cod_prov, cod_localidad): + "obtener la descripción de la provincia/localidad (usar cache)" + cod_prov = int(cod_prov) + cod_localidad = str(cod_localidad) + provincia = datos.PROVINCIAS[cod_prov] + localidad = self.BuscarLocalidades(cod_prov, cod_localidad) + return localidad, provincia + + # divido los datos adicionales (debe haber renglones 1 al 9): + if liq.get('datos_adicionales') and 'datos_adicionales1' in f: + d = liq.get('datos_adicionales') + for i, ds in enumerate(f.split_multicell(d, 'datos_adicionales1')): + liq['datos_adicionales%s' % (i + 1)] = ds + + for copia in range(1, num_copias + 1): + + # completo campos y hojas + f.add_page() + f.set('copia', copias.get(copia, "Adicional %s" % copia)) + + f.set('anulado', {'AC': '', '': 'SIN ESTADO', + 'AN': "ANULADO"}.get(liq['estado'], "ERROR")) + + try: + cod_tipo_ajuste = int(liq["cod_tipo_ajuste"] or '0') + except BaseException: + cod_tipo_ajuste = None + f.set('tipo_ajuste', {3: 'Liquidación de Débito', + 4: 'Liquidación de Crédito', + }.get(cod_tipo_ajuste, '')) + + # limpio datos del corredor si no corresponden: + if liq.get('actua_corredor', 'N') == 'N': + if liq.get('cuit_corredor', None) == 0: + del liq['cuit_corredor'] + + # establezco campos según tabla encabezado: + for k, v in list(liq.items()): + v = formatear(k, v, fmt_encabezado) + if isinstance(v, (str, int, float)): + f.set(k, v) + elif isinstance(v, decimal.Decimal): + f.set(k, str(v)) + elif isinstance(v, datetime.datetime): + f.set(k, str(v)) + + from . import wslpg_datos as datos + + campania = int(liq.get('campania_ppal') or 0) + f.set("campania_ppal", datos.CAMPANIAS.get(campania, campania)) + f.set("tipo_operacion", datos.TIPOS_OP.get(int(liq.get('cod_tipo_operacion') or 0), "")) + f.set("actividad", datos.ACTIVIDADES.get(int(liq.get('nro_act_comprador') or 0), "")) + if 'cod_grano' in liq and liq['cod_grano']: + cod_grano = int(liq['cod_grano']) + else: + cod_grano = int(self.datos.get('cod_grano') or 0) + f.set("grano", datos.GRANOS.get(cod_grano, "")) + cod_puerto = int(liq.get('cod_puerto', self.datos.get('cod_puerto')) or 0) + if cod_puerto in datos.PUERTOS: + f.set("des_puerto_localidad", datos.PUERTOS[cod_puerto]) + + cod_grado_ref = liq.get('cod_grado_ref', self.datos.get('cod_grado_ref')) or "" + if cod_grado_ref in datos.GRADOS_REF: + f.set("des_grado_ref", datos.GRADOS_REF[cod_grado_ref]) + else: + f.set("des_grado_ref", cod_grado_ref) + cod_grado_ent = liq.get('cod_grado_ent', self.datos.get('cod_grado_ent')) + if 'val_grado_ent' in liq and int(liq.get('val_grado_ent') or 0): + val_grado_ent = liq['val_grado_ent'] + elif 'val_grado_ent' in self.datos: + val_grado_ent = self.datos.get('val_grado_ent') + elif cod_grano in datos.GRADO_ENT_VALOR: + valores = datos.GRADO_ENT_VALOR[cod_grano] + if cod_grado_ent in valores: + val_grado_ent = valores[cod_grado_ent] + else: + val_grado_ent = "" + else: + val_grado_ent = "" + f.set("valor_grado_ent", "%s %s" % (cod_grado_ent or "", val_grado_ent or "")) + f.set("cont_proteico", liq.get('cont_proteico', self.datos.get('cont_proteico', ""))) + + if liq.get('certificados'): + # uso la procedencia del certificado de depósito + cert = liq['certificados'][0] + localidad, provincia = buscar_localidad_provincia( + cert['cod_prov_procedencia'], + cert['cod_localidad_procedencia']) + elif liq.get('cod_prov_procedencia_sin_certificado'): + localidad, provincia = buscar_localidad_provincia( + liq['cod_prov_procedencia_sin_certificado'], + liq['cod_localidad_procedencia_sin_certificado']) + else: + localidad, provincia = "", "" + + f.set("procedencia", "%s - %s" % (localidad, provincia)) + + # si no se especifíca, uso la procedencia para el lugar + if not self.datos.get('lugar_y_fecha'): + localidad, provincia = buscar_localidad_provincia( + liq['cod_prov_procedencia'], + liq['cod_localidad_procedencia']) + lugar = "%s - %s " % (localidad, provincia) + fecha = datetime.datetime.today().strftime("%d/%m/%Y") + f.set("lugar_y_fecha", "%s, %s" % (fecha, lugar)) + if 'lugar_y_fecha' in self.datos: + del self.datos['lugar_y_fecha'] + + if HOMO: + homo = "(pruebas)" + else: + homo = "" + + if int(liq['cod_tipo_operacion'] or 0) == 1: + f.set("comprador.L", "COMPRADOR:") + f.set("vendedor.L", "VENDEDOR:") + f.set("formulario", "Form. Electrónico 1116 B %s" % homo) + else: + f.set("comprador.L", "MANDATARIO/CONSIGNATARIO:") + f.set("vendedor.L", "MANDANTE/COMITENTE:") + f.set("formulario", "Form. Electrónico 1116 C %s" % homo) + + if int(liq.get("coe_ajustado") or 0) or int(liq.get("nro_contrato") or 0): + f.set("formulario", "Ajuste Unificado %s" % homo) + + certs = [] + for cert in liq.get('certificados', []): + certs.append("%s Nº %s" % ( + datos.TIPO_CERT_DEP[int(cert['tipo_certificado_deposito'])], + cert['nro_certificado_deposito'])) + f.set("certificados_deposito", ', '.join(certs)) + + for i, deduccion in enumerate(liq.get('deducciones', [])): + for k, v in list(deduccion.items()): + v = formatear(k, v, fmt_deduccion) + f.set("deducciones_%s_%02d" % (k, i + 1), v) + + for i, retencion in enumerate(liq.get('retenciones', [])): + for k, v in list(retencion.items()): + v = formatear(k, v, fmt_retencion) + f.set("retenciones_%s_%02d" % (k, i + 1), v) + if retencion['importe_certificado_retencion']: + d = retencion['fecha_certificado_retencion'] + f.set('retenciones_cert_retencion_%02d' % (i + 1), + "%s $ %0.2f %s" % ( + retencion['nro_certificado_retencion'] or '', + retencion['importe_certificado_retencion'], + "%s/%s/%s" % (d[8:10], d[5:7], d[2:4]), + )) + + # cargo campos adicionales ([PDF] en .ini y AgregarDatoPDF) + for k, v in list(self.datos.items()): + f.set(k, v) + + # Ajustes: + + if clave: + f.set('subtipo_ajuste', {'ajuste_debito': 'AJUSTE DÉBITO', + 'ajuste_credito': 'AJUSTE CRÉDITO'}[clave]) + + if int(liq.get('coe_ajustado') or 0): + f.set("leyenda_coe_nro", "COE Ajustado:") + f.set("nro_contrato_o_coe_ajustado", liq['coe_ajustado']) + f.set("coe_relacionados.L", "") + f.set("coe_relacionados", "") + elif liq.get('nro_contrato'): + f.set("leyenda_coe_nro", "Contrato Ajustado:") + f.set("nro_contrato_o_coe_ajustado", liq['nro_contrato']) + ##f.set("coe_relacionados", TODO) + + return True + except Exception as e: + ex = utils.exception_info() + try: + f.set('anulado', "%(name)s:%(lineno)s" % ex) + except BaseException: + pass + self.Excepcion = ex['msg'] + self.Traceback = ex['tb'] + if DEBUG: + print(self.Excepcion) + print(self.Traceback) + return False + + def GenerarPDF(self, archivo="", dest="F"): + "Generar archivo de salida en formato PDF" + try: + self.template.render(archivo, dest=dest) + return True + except Exception as e: + self.Excepcion = str(e) + return False + + def MostrarPDF(self, archivo, imprimir=False): + try: + if sys.platform == "linux2": + import subprocess + subprocess.run(["evince", archivo], check=False) + else: + operation = imprimir and "print" or "" + os.startfile(archivo, operation) + return True + except Exception as e: + self.Excepcion = str(e) + return False + + +def escribir_archivo(dic, nombre_archivo, agrega=True): + archivo = open(nombre_archivo, agrega and "a" or "w") + if '--json' in sys.argv: + json.dump(dic, archivo, sort_keys=True, indent=4) + elif '--dbf' in sys.argv: + formatos = [('Encabezado', ENCABEZADO, [dic]), + ('Certificacion', CERTIFICACION, [dic]), + ('Certificado', CERTIFICADO, dic.get('certificados', [])), + ('Retencion', RETENCION, dic.get('retenciones', [])), + ('Deduccion', DEDUCCION, dic.get('deducciones', [])), + ('Percepcion', PERCEPCION, dic.get('percepciones', [])), + ('Opcional', OPCIONAL, dic.get('opcionales', [])), + ('AjusteCredito', AJUSTE, dic.get('ajuste_credito', [])), + ('AjusteDebito', AJUSTE, dic.get('ajuste_debito', [])), + ('CTG', CTG, dic.get('ctgs', [])), + ('DetMuestraAnalisis', DET_MUESTRA_ANALISIS, dic.get('det_muestra_analisis', [])), + ('Calidad', CALIDAD, dic.get('calidad', [])), + ('FacturaPapel', FACTURA_PAPEL, dic.get('factura_papel', [])), + ('Fusion', FUSION, dic.get('fusion', [])), + ('Dato', DATO, dic.get('datos', [])), + ('Error', ERROR, dic.get('errores', [])), + ] + guardar_dbf(formatos, agrega, conf_dbf) + else: + dic['tipo_reg'] = 0 + archivo.write(escribir(dic, ENCABEZADO)) + dic['tipo_reg'] = 7 + archivo.write(escribir(dic, CERTIFICACION)) + if 'certificados' in dic: + for it in dic['certificados']: + it['tipo_reg'] = 1 + archivo.write(escribir(it, CERTIFICADO)) + if 'retenciones' in dic: + for it in dic['retenciones']: + it['tipo_reg'] = 2 + archivo.write(escribir(it, RETENCION)) + if 'deducciones' in dic: + for it in dic['deducciones']: + it['tipo_reg'] = 3 + archivo.write(escribir(it, DEDUCCION)) + if 'percepciones' in dic: + for it in dic['percepciones']: + it['tipo_reg'] = 'P' + archivo.write(escribir(it, PERCEPCION)) + if 'opcionales' in dic: + for it in dic['opcionales']: + it['tipo_reg'] = 'O' + archivo.write(escribir(it, OPCIONAL)) + if 'ajuste_debito' in dic: + dic['ajuste_debito']['tipo_reg'] = 4 + archivo.write(escribir(dic['ajuste_debito'], AJUSTE)) + for it in dic['ajuste_debito'].get('retenciones', []): + it['tipo_reg'] = 2 + archivo.write(escribir(it, RETENCION)) + for it in dic['ajuste_debito'].get('deducciones', []): + it['tipo_reg'] = 3 + archivo.write(escribir(it, DEDUCCION)) + for it in dic['ajuste_debito'].get('percepciones', []): + it['tipo_reg'] = "P" + archivo.write(escribir(it, PERCEPCION)) + for it in dic['ajuste_debito'].get('certificados', []): + it['tipo_reg'] = 1 + archivo.write(escribir(it, CERTIFICADO)) + if 'ajuste_credito' in dic: + dic['ajuste_credito']['tipo_reg'] = 5 + archivo.write(escribir(dic['ajuste_credito'], AJUSTE)) + for it in dic['ajuste_credito'].get('retenciones', []): + it['tipo_reg'] = 2 + archivo.write(escribir(it, RETENCION)) + for it in dic['ajuste_credito'].get('deducciones', []): + it['tipo_reg'] = 3 + archivo.write(escribir(it, DEDUCCION)) + for it in dic['ajuste_credito'].get('percepciones', []): + it['tipo_reg'] = "P" + archivo.write(escribir(it, PERCEPCION)) + for it in dic['ajuste_credito'].get('certificados', []): + it['tipo_reg'] = 1 + archivo.write(escribir(it, CERTIFICADO)) + if 'ctgs' in dic: + for it in dic['ctgs']: + it['tipo_reg'] = 'C' + archivo.write(escribir(it, CTG)) + if 'det_muestra_analisis' in dic: + for it in dic['det_muestra_analisis']: + it['tipo_reg'] = 'D' + archivo.write(escribir(it, DET_MUESTRA_ANALISIS)) + if 'calidad' in dic: + for it in dic['calidad']: + it['tipo_reg'] = 'Q' + archivo.write(escribir(it, CALIDAD)) + if 'factura_papel' in dic: + for it in dic['factura_papel']: + it['tipo_reg'] = 'F' + archivo.write(escribir(it, FACTURA_PAPEL)) + if 'fusion' in dic: + for it in dic['fusion']: + it['tipo_reg'] = 'f' + archivo.write(escribir(it, FUSION)) + if 'datos' in dic: + for it in dic['datos']: + it['tipo_reg'] = 9 + archivo.write(escribir(it, DATO)) + if 'errores' in dic: + for it in dic['errores']: + it['tipo_reg'] = 'R' + archivo.write(escribir(it, ERROR)) + archivo.close() + + +def leer_archivo(nombre_archivo): + archivo = open(nombre_archivo, "r") + if '--json' in sys.argv: + dic = json.load(archivo) + elif '--dbf' in sys.argv: + dic = {'retenciones': [], 'deducciones': [], 'certificados': [], + 'percepciones': [], 'opcionales': [], 'fusion': [], + 'datos': [], 'ajuste_credito': [], 'ajuste_debito': [], + 'ctgs': [], 'det_muestra_analisis': [], 'calidad': [], + } + formatos = [('Encabezado', ENCABEZADO, dic), + ('Certificacion', CERTIFICACION, dic), + ('Certificado', CERTIFICADO, dic['certificados']), + ('Retencio', RETENCION, dic['retenciones']), + ('Deduccion', DEDUCCION, dic['deducciones']), + ('Percepcion', PERCEPCION, dic['percepciones']), + ('Opcional', OPCIONAL, dic['opcionales']), + ('AjusteCredito', AJUSTE, dic['ajuste_credito']), + ('AjusteDebito', AJUSTE, dic['ajuste_debito']), + ('CTG', CTG, dic.get('ctgs', [])), + ('DetMuestraAnalisis', DET_MUESTRA_ANALISIS, dic.get('det_muestra_analisis', [])), + ('Calidad', CALIDAD, dic.get('calidad', [])), + ('FacturaPapel', FACTURA_PAPEL, dic.get('factura_papel', [])), + ('Fusion', FUSION, dic.get('fusion', [])), + ('Dato', DATO, dic['datos']), + ] + leer_dbf(formatos, conf_dbf) + else: + dic = {'retenciones': [], 'deducciones': [], 'certificados': [], + 'percepciones': [], 'opcionales': [], + 'datos': [], 'ajuste_credito': {}, 'ajuste_debito': {}, + 'ctgs': [], 'det_muestra_analisis': [], 'calidad': [], + 'factura_papel': [], 'fusion': [], + } + for linea in archivo: + if str(linea[0]) == '0': + # encabezado base de las liquidaciones + d = leer(linea, ENCABEZADO) + if d['reservado1']: + print("ADVERTENCIA: USAR datos adicionales (nueva posición)") + d['datos_adicionales'] = d['reservado1'] + dic.update(d) + # referenciar la liquidación para agregar ret. / ded.: + liq = dic + elif str(linea[0]) == '1': + d = leer(linea, CERTIFICADO) + if d['reservado1']: + print("ADVERTENCIA: USAR tipo_certificado_deposito (nueva posición)") + d['tipo_certificado_deposito'] = d['reservado1'] + liq['certificados'].append(d) + elif str(linea[0]) == '2': + liq['retenciones'].append(leer(linea, RETENCION)) + elif str(linea[0]) == '3': + d = leer(linea, DEDUCCION) + # ajustes por cambios en afip (compatibilidad hacia atras): + if d['reservado1']: + print("ADVERTENCIA: USAR precio_pkg_diario!") + d['precio_pkg_diario'] = d['reservado1'] + liq['deducciones'].append(d) + elif str(linea[0]) == 'P': + liq['percepciones'].append(leer(linea, PERCEPCION)) + elif str(linea[0]) == 'O': + liq['opcionales'].append(leer(linea, OPCIONAL)) + elif str(linea[0]) == '4': + liq = leer(linea, AJUSTE) + liq.update({'retenciones': [], 'deducciones': [], 'percepciones': [], 'datos': [], 'certificados': []}) + dic['ajuste_debito'] = liq + elif str(linea[0]) == '5': + liq = leer(linea, AJUSTE) + liq.update({'retenciones': [], 'deducciones': [], 'percepciones': [], 'datos': [], 'certificados': []}) + dic['ajuste_credito'] = liq + elif str(linea[0]) == '7': + # actualizo con cabecera para certificaciones de granos: + d = leer(linea, CERTIFICACION) + dic.update(d) + elif str(linea[0]) == 'C': + dic['ctgs'].append(leer(linea, CTG)) + elif str(linea[0]) == 'D': + dic['det_muestra_analisis'].append(leer(linea, DET_MUESTRA_ANALISIS)) + elif str(linea[0]) == 'Q': + dic['calidad'].append(leer(linea, CALIDAD)) + elif str(linea[0]) == 'F': + dic['factura_papel'].append(leer(linea, FACTURA_PAPEL)) + elif str(linea[0]) == 'f': + dic['fusion'].append(leer(linea, FUSION)) + elif str(linea[0]) == '9': + dic['datos'].append(leer(linea, DATO)) + else: + print("Tipo de registro incorrecto:", linea[0]) + archivo.close() + + if not 'nro_orden' in dic: + raise RuntimeError("Archivo de entrada invalido, revise campos y lineas en blanco") + + if DEBUG: + import pprint + pprint.pprint(dic) + return dic + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = WSLPG.InstallDir = get_install_dir() + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + if '--formato' in sys.argv: + print("Formato:") + for msg, formato in [('Encabezado', ENCABEZADO), + ('Certificado', CERTIFICADO), + ('Retencion', RETENCION), + ('Deduccion', DEDUCCION), + ('Percepcion', PERCEPCION), + ('Opcional', OPCIONAL), + ('Ajuste', AJUSTE), + ('Certificacion', CERTIFICACION), + ('CTG', CTG), + ('Det. Muestra Analisis', DET_MUESTRA_ANALISIS), + ('Calidad', CALIDAD), + ('Factura Papel', FACTURA_PAPEL), + ('Fusion', FUSION), + ('Evento', EVENTO), ('Error', ERROR), + ('Dato', DATO)]: + comienzo = 1 + print("=== %s ===" % msg) + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = len(fmt) > 3 and fmt[3] or (tipo == 'I' and '2' or '') + print(" * Campo: %-20s Posición: %3d Longitud: %4d Tipo: %s Decimales: %s" % ( + clave, comienzo, longitud, tipo, dec)) + comienzo += longitud + sys.exit(0) + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSLPG) + sys.exit(0) + + import csv + from configparser import SafeConfigParser + + from .wsaa import WSAA + + try: + + if "--version" in sys.argv: + print("Versión: ", __version__) + + if len(sys.argv) > 1 and sys.argv[1].endswith(".ini"): + CONFIG_FILE = sys.argv[1] + print("Usando configuracion:", CONFIG_FILE) + + config = SafeConfigParser() + config.read(CONFIG_FILE) + CERT = config.get('WSAA', 'CERT') + PRIVATEKEY = config.get('WSAA', 'PRIVATEKEY') + CUIT = config.get('WSLPG', 'CUIT') + ENTRADA = config.get('WSLPG', 'ENTRADA') + SALIDA = config.get('WSLPG', 'SALIDA') + + if config.has_option('WSAA', 'URL') and not HOMO: + WSAA_URL = config.get('WSAA', 'URL') + else: + WSAA_URL = None # wsaa.WSAAURL + if config.has_option('WSLPG', 'URL') and not HOMO: + WSLPG_URL = config.get('WSLPG', 'URL') + else: + WSLPG_URL = WSDL + + PROXY = config.has_option('WSAA', 'PROXY') and config.get('WSAA', 'PROXY') or None + CACERT = config.has_option('WSAA', 'CACERT') and config.get('WSAA', 'CACERT') or None + WRAPPER = config.has_option('WSAA', 'WRAPPER') and config.get('WSAA', 'WRAPPER') or None + + if config.has_option('WSLPG', 'TIMEOUT'): + TIMEOUT = int(config.get('WSLPG', 'TIMEOUT')) + + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + if DEBUG: + print("conf_dbf", conf_dbf) + else: + conf_dbf = {} + + DEBUG = '--debug' in sys.argv + XML = '--xml' in sys.argv + + if DEBUG: + print("Usando Configuración:") + print("WSAA_URL:", WSAA_URL) + print("WSLPG_URL:", WSLPG_URL) + print("CACERT", CACERT) + print("WRAPPER", WRAPPER) + print("timeout:", TIMEOUT) + # obteniendo el TA + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wslpg", CERT, PRIVATEKEY, wsdl=WSAA_URL, + proxy=PROXY, wrapper=WRAPPER, cacert=CACERT) + if not ta: + sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + + # cliente soap del web service + wslpg = WSLPG() + wslpg.LanzarExcepciones = True + wslpg.Conectar(url=WSLPG_URL, proxy=PROXY, wrapper=WRAPPER, cacert=CACERT, timeout=TIMEOUT) + wslpg.SetTicketAcceso(ta) + wslpg.Cuit = CUIT + + if '--dummy' in sys.argv: + ret = wslpg.Dummy() + print("AppServerStatus", wslpg.AppServerStatus) + print("DbServerStatus", wslpg.DbServerStatus) + print("AuthServerStatus", wslpg.AuthServerStatus) + # sys.exit(0) + + if '--autorizar' in sys.argv: + + if '--prueba' in sys.argv: + pto_emision = 99 + # genero una liquidación de ejemplo: + dic = dict( + pto_emision=pto_emision, + nro_orden=0, # que lo calcule automáticamente + cuit_comprador='20400000000', + nro_act_comprador=40, nro_ing_bruto_comprador='20400000000', + cod_tipo_operacion=2 if "--consign" in sys.argv else 1, + es_liquidacion_propia='N', es_canje='N', + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=31, + cuit_vendedor=23000000019, nro_ing_bruto_vendedor=23000000019, + actua_corredor="S", liquida_corredor="S", + cuit_corredor=wslpg.Cuit, # uso Cuit representado + comision_corredor=1, nro_ing_bruto_corredor=wslpg.Cuit, + fecha_precio_operacion="2014-02-07", + precio_ref_tn=2000, + cod_grado_ref="G1", + cod_grado_ent="FG", + factor_ent=98, val_grado_ent=1.02, + precio_flete_tn=10, + cont_proteico=20, + alic_iva_operacion=10.5, + campania_ppal=1314, + cod_localidad_procedencia=5544, + cod_prov_procedencia=12, + nro_contrato=0, + datos_adicionales=("DATOS ADICIONALES 1234 " * 17) + ".", + # peso_neto_sin_certificado=2000, + precio_operacion=None, # para probar ajustar + total_peso_neto=1000, # para probar ajustar + certificados=[dict( + tipo_certificado_deposito=332, # cert. electronico + nro_certificado_deposito=332000000466, + peso_neto=1000, + cod_localidad_procedencia=3, + cod_prov_procedencia=1, + campania=1314, + fecha_cierre="2014-01-13",)], + retenciones=[dict( + codigo_concepto="RI", + detalle_aclaratorio="DETALLE DE IVA", + base_calculo=1000, + alicuota=10.5, + ), dict( + codigo_concepto="RG", + detalle_aclaratorio="DETALLE DE GANANCIAS", + base_calculo=100, + alicuota=0, + ), dict( + codigo_concepto="OG", + detalle_aclaratorio="OTRO GRAVAMEN", + base_calculo=1000, + alicuota=0, + nro_certificado_retencion=111111111111, + fecha_certificado_retencion="2013-05-01", + importe_certificado_retencion=105, + )], + deducciones=[dict( + codigo_concepto="OD", + detalle_aclaratorio="FLETE", + dias_almacenaje="0", + precio_pkg_diario=0.0, + comision_gastos_adm=0.0, + base_calculo=100.0, + alicuota=21.0, + ), dict( + codigo_concepto="AL", + detalle_aclaratorio="ALMACENAJE", + dias_almacenaje="30", + precio_pkg_diario=0.0001, + comision_gastos_adm=0.0, + alicuota=21.0, + ), ], + percepciones=[{'detalle_aclaratoria': 'percepcion 1', + 'base_calculo': 1000, 'alicuota_iva': 21}], + datos=[ + dict(campo="nombre_comprador", valor="NOMBRE 1"), + dict(campo="domicilio1_comprador", valor="DOMICILIO 1"), + dict(campo="domicilio2_comprador", valor="DOMICILIO 1"), + dict(campo="localidad_comprador", valor="LOCALIDAD 1"), + dict(campo="iva_comprador", valor="R.I."), + dict(campo="nombre_vendedor", valor="NOMBRE 2"), + dict(campo="domicilio1_vendedor", valor="DOMICILIO 2"), + dict(campo="domicilio2_vendedor", valor="DOMICILIO 2"), + dict(campo="localidad_vendedor", valor="LOCALIDAD 2"), + dict(campo="iva_vendedor", valor="R.I."), + dict(campo="nombre_corredor", valor="NOMBRE 3"), + dict(campo="domicilio_corredor", valor="DOMICILIO 3"), + ] + ) + if "--sincorr" in sys.argv: + # ajusto datos para prueba sin corredor + dic.update(dict( + cuit_comprador=wslpg.Cuit, + nro_act_comprador=29, nro_ing_bruto_comprador=wslpg.Cuit, + actua_corredor="N", liquida_corredor="N", + cuit_corredor=0, + comision_corredor=0, nro_ing_bruto_corredor=0,)) + dic['retenciones'][1]['alicuota'] = 15 + del dic['datos'][-1] + del dic['datos'][-1] + if "--sincert" in sys.argv: + # ajusto datos para prueba sin certificado de deposito + dic['peso_neto_sin_certificado'] = 10000 + dic['cod_prov_procedencia_sin_certificado'] = 1 + dic['cod_localidad_procedencia_sin_certificado'] = 15124 + dic['certificados'] = [] + if "--singrado" in sys.argv: + # ajusto datos para prueba sin grado ni valor entregado + dic['cod_grado_ref'] = "" + dic['cod_grado_ent'] = "" + dic['val_grado_ent'] = 0 + if "--consign" in sys.argv: + # agrego deducción por comisión de gastos administrativos + dic['deducciones'].append(dict( + codigo_concepto="CO", + detalle_aclaratorio="COMISION", + dias_almacenaje=None, + precio_pkg_diario=None, + comision_gastos_adm=1.0, + base_calculo=1000.00, + alicuota=21.0, + )) + escribir_archivo(dic, ENTRADA) + dic = leer_archivo(ENTRADA) + + if int(dic['nro_orden']) == 0 and not '--testing' in sys.argv: + # consulto el último número de orden emitido: + ok = wslpg.ConsultarUltNroOrden(dic['pto_emision']) + if ok: + dic['nro_orden'] = wslpg.NroOrden + 1 + + # establezco los parametros (se pueden pasar directamente al metodo) + for k, v in sorted(dic.items()): + if DEBUG: + print("%s = %s" % (k, v)) + wslpg.SetParametro(k, v) + + # cargo la liquidación: + wslpg.CrearLiquidacion() + + for cert in dic.get('certificados', []): + wslpg.AgregarCertificado(**cert) + + for ded in dic.get('deducciones', []): + wslpg.AgregarDeduccion(**ded) + + for ret in dic.get('retenciones', []): + wslpg.AgregarRetencion(**ret) + + for per in dic.get('percepciones', []): + wslpg.AgregarPercepcion(**per) + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo + if '--error' in sys.argv: + wslpg.LoadTestXML("wslpg_error.xml") # cargo error + else: + wslpg.LoadTestXML("wslpg_aut_test.xml") # cargo respuesta + + print("Liquidacion: pto_emision=%s nro_orden=%s nro_act=%s tipo_op=%s" % ( + wslpg.liquidacion['ptoEmision'], + wslpg.liquidacion['nroOrden'], + wslpg.liquidacion['nroActComprador'], + wslpg.liquidacion['codTipoOperacion'], + )) + + if not '--dummy' in sys.argv: + if '--recorrer' in sys.argv: + print("Consultando actividades y operaciones habilitadas...") + lista_act_op = wslpg.ConsultarTiposOperacion(sep=None) + # recorro las actividades habilitadas buscando la + for nro_act, cod_op, det in lista_act_op: + print("Probando nro_act=", nro_act, "cod_op=", cod_op, end=' ') + wslpg.liquidacion['nroActComprador'] = nro_act + wslpg.liquidacion['codTipoOperacion'] = cod_op + ret = wslpg.AutorizarLiquidacion() + if wslpg.COE: + print() + break # si obtuve COE salgo + else: + print(wslpgPDF.Errores) + else: + print("Autorizando...") + ret = wslpg.AutorizarLiquidacion() + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print("COEAjustado", wslpg.COEAjustado) + print("TotalDeduccion", wslpg.TotalDeduccion) + print("TotalRetencion", wslpg.TotalRetencion) + print("TotalRetencionAfip", wslpg.TotalRetencionAfip) + print("TotalOtrasRetenciones", wslpg.TotalOtrasRetenciones) + print("TotalNetoAPagar", wslpg.TotalNetoAPagar) + print("TotalIvaRg4310_18", wslpg.TotalIvaRg4310_18) + print("TotalPagoSegunCondicion", wslpg.TotalPagoSegunCondicion) + if False and '--testing' in sys.argv: + assert wslpg.COE == "330100000357" + assert wslpg.COEAjustado is None + assert wslpg.Estado == "AC" + assert wslpg.TotalPagoSegunCondicion == 1968.00 + assert wslpg.GetParametro("fecha_liquidacion") == "2013-02-07" + assert wslpg.GetParametro("retenciones", 1, "importe_retencion") == "157.60" + + if DEBUG: + pprint.pprint(wslpg.params_out) + + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + if '--ajustar' in sys.argv: + print("Ajustando...") + if '--prueba' in sys.argv: + # genero una liquidación de ejemplo: + dic = dict( + pto_emision=55, nro_orden=0, coe_ajustado='330100025869', + cod_localidad_procedencia=5544, cod_prov_procedencia=12, + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=31, # no enviado a AFIP, pero usado para el PDF + certificados=[dict( + tipo_certificado_deposito=5, + nro_certificado_deposito=555501200729, + peso_neto=10000, + cod_localidad_procedencia=3, + cod_prov_procedencia=1, + campania=1213, + fecha_cierre='2013-01-13', + peso_neto_total_certificado=10000, + )], + fusion=[{'nro_ing_brutos': '20400000000', 'nro_actividad': 40}], + ajuste_credito=dict( + diferencia_peso_neto=1000, diferencia_precio_operacion=100, + cod_grado="G2", val_grado=1.0, factor=100, + diferencia_precio_flete_tn=10, + datos_adicionales='AJUSTE CRED UNIF', + concepto_importe_iva_0='Alicuota Cero', + importe_ajustar_Iva_0=900, + concepto_importe_iva_105='Alicuota Diez', + importe_ajustar_Iva_105=800, + concepto_importe_iva_21='Alicuota Veintiuno', + importe_ajustar_Iva_21=700, + deducciones=[dict(codigo_concepto="AL", + detalle_aclaratorio="Deduc Alm", + dias_almacenaje="1", + precio_pkg_diario=0.01, + comision_gastos_adm=1.0, + base_calculo=1000.0, + alicuota=10.5, )], + retenciones=[dict(codigo_concepto="RI", + detalle_aclaratorio="Ret IVA", + base_calculo=1000, + alicuota=10.5, )], + certificados=[{'peso_neto': 200, + 'coe_certificado_deposito': '330100025869'}], + ), + ajuste_debito=dict( + diferencia_peso_neto=500, diferencia_precio_operacion=100, + cod_grado="G2", val_grado=1.0, factor=100, + diferencia_precio_flete_tn=0.01, + datos_adicionales='AJUSTE DEB UNIF', + concepto_importe_iva_0='Alic 0', + importe_ajustar_Iva_0=250, + concepto_importe_iva_105='Alic 10.5', + importe_ajustar_Iva_105=200, + concepto_importe_iva_21='Alicuota 21', + importe_ajustar_Iva_21=50, + deducciones=[dict(codigo_concepto="AL", + detalle_aclaratorio="Deduc Alm", + dias_almacenaje="1", + precio_pkg_diario=0.01, + comision_gastos_adm=1.0, + base_calculo=500.0, + alicuota=10.5, )], + retenciones=[dict(codigo_concepto="RI", + detalle_aclaratorio="Ret IVA", + base_calculo=100, + alicuota=10.5, )], + certificados=[{'peso_neto': 300, + 'coe_certificado_deposito': '330100025869'}], + ), + datos=[ + dict(campo="nombre_comprador", valor="NOMBRE 1"), + dict(campo="domicilio1_comprador", valor="DOMICILIO 1"), + dict(campo="domicilio2_comprador", valor="DOMICILIO 1"), + dict(campo="localidad_comprador", valor="LOCALIDAD 1"), + dict(campo="iva_comprador", valor="R.I."), + dict(campo="nombre_vendedor", valor="NOMBRE 2"), + dict(campo="domicilio1_vendedor", valor="DOMICILIO 2"), + dict(campo="domicilio2_vendedor", valor="DOMICILIO 2"), + dict(campo="localidad_vendedor", valor="LOCALIDAD 2"), + dict(campo="iva_vendedor", valor="R.I."), + dict(campo="nombre_corredor", valor="NOMBRE 3"), + dict(campo="domicilio_corredor", valor="DOMICILIO 3"), + # completo datos no contemplados en la respuesta por AFIP: + dict(campo="cod_grano", valor="31"), + dict(campo="cod_grado_ent", valor="G1"), + dict(campo="cod_grado_ref", valor="G1"), + dict(campo="factor_ent", valor="98"), + dict(campo="cod_puerto", valor=14), + dict(campo="cod_localidad_procedencia", valor=3), + dict(campo="cod_prov_procedencia", valor=1), + dict(campo="precio_ref_tn", valor="$ 1000,00"), + dict(campo="precio_flete_tn", valor="$ 100,00"), + dict(campo="des_grado_ref", valor="G1"), + dict(campo="alic_iva_operacion", valor=""), + ] + ) + if '--contrato' in sys.argv: + dic.update( + {'nro_act_comprador': 40, + 'cod_grado_ent': 'G1', + 'cod_grano': 31, + 'cod_puerto': 14, + 'cuit_comprador': 20400000000, + 'cuit_corredor': 20267565393, + 'cuit_vendedor': 23000000019, + 'des_puerto_localidad': 'Desc Puerto', + 'nro_contrato': 27, + 'precio_flete_tn': 1000, + 'precio_ref_tn': 1000, + 'val_grado_ent': 1.01}) + #del dic['ajuste_debito']['retenciones'] + #del dic['ajuste_credito']['retenciones'] + escribir_archivo(dic, ENTRADA) + + dic = leer_archivo(ENTRADA) + + if int(dic['nro_orden']) == 0 and not '--testing' in sys.argv: + # consulto el último número de orden emitido: + ok = wslpg.ConsultarUltNroOrden(dic['pto_emision']) + if ok: + dic['nro_orden'] = wslpg.NroOrden + 1 + + if '--contrato' in sys.argv: + for k in ("nro_contrato", "nro_act_comprador", "cod_grano", + "cuit_vendedor", "cuit_comprador", "cuit_corredor", + "precio_ref_tn", "cod_grado_ent", "val_grado_ent", + "precio_flete_tn", "cod_puerto", + "des_puerto_localidad"): + v = dic.get(k) + if v: + wslpg.SetParametro(k, v) + + wslpg.CrearAjusteBase(pto_emision=dic['pto_emision'], + nro_orden=dic['nro_orden'], + coe_ajustado=dic['coe_ajustado'], + cod_localidad=dic['cod_localidad_procedencia'], + cod_provincia=dic['cod_prov_procedencia'], + ) + + for cert in dic.get('certificados', []): + if cert: + wslpg.AgregarCertificado(**cert) + + for fusion in dic.get('fusion', []): + wslpg.AgregarFusion(**fusion) + + liq = dic['ajuste_credito'] + wslpg.CrearAjusteCredito(**liq) + for ded in liq.get('deducciones', []): + wslpg.AgregarDeduccion(**ded) + for ret in liq.get('retenciones', []): + wslpg.AgregarRetencion(**ret) + for cert in liq.get('certificados', []): + if cert: + wslpg.AgregarCertificado(**cert) + + liq = dic['ajuste_debito'] + wslpg.CrearAjusteDebito(**liq) + for ded in liq.get('deducciones', []): + wslpg.AgregarDeduccion(**ded) + for ret in liq.get('retenciones', []): + wslpg.AgregarRetencion(**ret) + for cert in liq.get('certificados', []): + if cert: + wslpg.AgregarCertificado(**cert) + + if '--testing' in sys.argv: + wslpg.LoadTestXML("tests/wslpg_ajuste_unificado.xml") + + if '--contrato' in sys.argv: + ret = wslpg.AjustarLiquidacionContrato() + else: + ret = wslpg.AjustarLiquidacionUnificado() + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print("Subtotal", wslpg.Subtotal) + print("TotalIva105", wslpg.TotalIva105) + print("TotalIva21", wslpg.TotalIva21) + print("TotalRetencionesGanancias", wslpg.TotalRetencionesGanancias) + print("TotalRetencionesIVA", wslpg.TotalRetencionesIVA) + print("TotalNetoAPagar", wslpg.TotalNetoAPagar) + print("TotalIvaRg4310_18", wslpg.TotalIvaRg4310_18) + print("TotalPagoSegunCondicion", wslpg.TotalPagoSegunCondicion) + + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + ok = wslpg.AnalizarAjusteCredito() + dic['ajuste_credito'].update(wslpg.params_out) + ok = wslpg.AnalizarAjusteDebito() + dic['ajuste_debito'].update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + if DEBUG: + pprint.pprint(dic) + + if '--asociar' in sys.argv: + print("Asociando...", end=' ') + if '--prueba' in sys.argv: + # genero datos de ejemplo en el archivo para consultar: + dic = dict(coe="330100004664", nro_contrato=26, cod_grano=31, + cuit_comprador="20400000000", + cuit_vendedor="23000000019", + cuit_corredor="20267565393", + ) + escribir_archivo(dic, ENTRADA) + dic = leer_archivo(ENTRADA) + print(', '.join(sorted(["%s=%s" % (k, v) for k, v in list(dic.items()) + if k in ("nro_contrato", "coe") + or k.startswith("cuit")]))) + if not '--lsg' in sys.argv: + wslpg.AsociarLiquidacionAContrato(**dic) + else: + wslpg.AsociarLiquidacionSecundariaAContrato(**dic) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print("Estado", wslpg.Estado) + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + if '--anular' in sys.argv: + # print wslpg.client.help("anularLiquidacion") + try: + coe = sys.argv[sys.argv.index("--anular") + 1] + except IndexError: + coe = 330100000357 + + if '--lsg' in sys.argv: + print("Anulando COE LSG", coe) + ret = wslpg.AnularLiquidacionSecundaria(coe) + if '--cg' in sys.argv: + print("Anulando COE CG", coe) + ret = wslpg.AnularCertificacion(coe) + else: + print("Anulando COE", coe) + ret = wslpg.AnularLiquidacion(coe) + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("COE", wslpg.COE) + print("Resultado", wslpg.Resultado) + print("Errores:", wslpg.Errores) + sys.exit(0) + + if '--consultar' in sys.argv: + pto_emision = None + nro_orden = 0 + coe = pdf = None + try: + pto_emision = sys.argv[sys.argv.index("--consultar") + 1] + nro_orden = sys.argv[sys.argv.index("--consultar") + 2] + coe = sys.argv[sys.argv.index("--consultar") + 3] + pdf = sys.argv[sys.argv.index("--consultar") + 4] + except IndexError: + pass + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo + wslpg.LoadTestXML("wslpg_cons_test.xml") # cargo prueba + print("Consultando: pto_emision=%s nro_orden=%s coe=%s" % (pto_emision, nro_orden, coe)) + if '--lsg' in sys.argv: + ret = wslpg.ConsultarLiquidacionSecundaria(pto_emision=pto_emision, nro_orden=nro_orden, coe=coe, pdf=pdf) + elif '--cg' in sys.argv: + ret = wslpg.ConsultarCertificacion(pto_emision=pto_emision, nro_orden=nro_orden, coe=coe, pdf=pdf) + elif '--cancelar-anticipo' in sys.argv: + ret = wslpg.CancelarAnticipo(pto_emision=pto_emision, nro_orden=nro_orden, coe=coe, pdf=pdf) + else: + ret = wslpg.ConsultarLiquidacion(pto_emision=pto_emision, nro_orden=nro_orden, coe=coe, pdf=pdf) + print("COE", wslpg.COE) + print("Estado", wslpg.Estado) + print("Errores:", wslpg.Errores) + + # actualizo el archivo de salida con los datos devueltos + escribir_archivo(wslpg.params_out, SALIDA, agrega=('--agrega' in sys.argv)) + + if DEBUG: + pprint.pprint(wslpg.params_out) + + if '--mostrar' in sys.argv and pdf: + wslpg.MostrarPDF(archivo=pdf, + imprimir='--imprimir' in sys.argv) + + if '--consultar_ajuste' in sys.argv: + pto_emision = None + nro_orden = 0 + nro_contrato = None + coe = pdf = None + try: + pto_emision = int(sys.argv[sys.argv.index("--consultar_ajuste") + 1]) + nro_orden = int(sys.argv[sys.argv.index("--consultar_ajuste") + 2]) + nro_contrato = int(sys.argv[sys.argv.index("--consultar_ajuste") + 3]) + coe = sys.argv[sys.argv.index("--consultar_ajuste") + 4] + pdf = sys.argv[sys.argv.index("--consultar_ajuste") + 5] + except IndexError: + pass + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo + wslpg.LoadTestXML("wslpg_cons_ajuste_test.xml") # cargo prueba + print("Consultando: pto_emision=%s nro_orden=%s nro_contrato=%s" % ( + pto_emision, nro_orden, nro_contrato)) + wslpg.ConsultarAjuste(pto_emision, nro_orden, nro_contrato, coe, pdf) + print("COE", wslpg.COE) + print("Estado", wslpg.Estado) + print("Errores:", wslpg.Errores) + # actualizo el archivo de salida con los datos devueltos + dic = wslpg.params_out + ok = wslpg.AnalizarAjusteCredito() + dic['ajuste_credito'] = wslpg.params_out + ok = wslpg.AnalizarAjusteDebito() + dic['ajuste_debito'] = wslpg.params_out + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + if DEBUG: + pprint.pprint(dic) + + if '--consultar_por_contrato' in sys.argv: + print("Consultando liquidaciones por contrato...", end=' ') + if '--prueba' in sys.argv: + # genero datos de ejemplo en el archivo para consultar: + dic = dict(nro_contrato=26, cod_grano=31, + cuit_comprador="20400000000", + cuit_vendedor="23000000019", + cuit_corredor="20267565393", + ) + escribir_archivo(dic, ENTRADA) + dic = leer_archivo(ENTRADA) + print(', '.join(sorted(["%s=%s" % (k, v) for k, v in list(dic.items()) + if k == "nro_contrato" or k.startswith("cuit")]))) + if not '--lsg' in sys.argv: + wslpg.ConsultarLiquidacionesPorContrato(**dic) + else: + wslpg.ConsultarLiquidacionesSecundariasPorContrato(**dic) + print("Errores:", wslpg.Errores) + while wslpg.COE: + print("COE", wslpg.COE) + wslpg.LeerDatosLiquidacion() + # print "Estado", wslpg.Estado + # actualizo el archivo de salida con los datos devueltos + dic['coe'] = wslpg.COE + escribir_archivo(dic, SALIDA, agrega=True) + + if '--ult' in sys.argv: + try: + pto_emision = int(sys.argv[sys.argv.index("--ult") + 1]) + except IndexError as ValueError: + pto_emision = 1 + print("Consultando ultimo nro_orden para pto_emision=%s" % pto_emision, end=' ') + if '--lsg' in sys.argv: + print("LSG") + ret = wslpg.ConsultarLiquidacionSecundariaUltNroOrden(pto_emision) + elif '--cg' in sys.argv: + print("CG") + ret = wslpg.ConsultarCertificacionUltNroOrden(pto_emision) + else: + print("LPG") + ret = wslpg.ConsultarUltNroOrden(pto_emision) + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Ultimo Nro de Orden", wslpg.NroOrden) + print("Errores:", wslpg.Errores) + sys.exit(0) + + if '--autorizar-lsg' in sys.argv: + + if '--prueba' in sys.argv: + # genero una liquidación de ejemplo: + dic = dict( + pto_emision=99, + nro_orden=1, nro_contrato=100001232, + cuit_comprador='20400000000', + nro_ing_bruto_comprador='123', + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=2, cantidad_tn=100, + cuit_vendedor="23000000019", nro_act_vendedor=29, + nro_ing_bruto_vendedor=123456, + actua_corredor="S", liquida_corredor="S", + cuit_corredor=wslpg.Cuit, # uso Cuit representado + nro_ing_bruto_corredor=wslpg.Cuit, + fecha_precio_operacion="2014-10-10", + precio_ref_tn=100, precio_operacion=150, + alic_iva_operacion=10.5, campania_ppal=1314, + cod_localidad_procedencia=197, + cod_prov_procedencia=10, + datos_adicionales="Prueba", + deducciones=[{'detalle_aclaratorio': 'deduccion 1', + 'base_calculo': 100, 'alicuota_iva': 21}], + percepciones=[{'detalle_aclaratoria': 'percepcion 1', + 'base_calculo': 1000, 'alicuota_iva': 21}], + opcionales=[{'codigo': 1, + 'descripcion': 'previsto para info adic.'}], + factura_papel=[{'nro_cai': "1234", 'nro_factura_papel': 1, + 'fecha_factura': "2015-01-01", + 'tipo_comprobante': 1}], + ) + escribir_archivo(dic, ENTRADA, agrega=('--agrega' in sys.argv)) + dic = leer_archivo(ENTRADA) + + # cargo la liquidación: + wslpg.CrearLiqSecundariaBase(**dic) + + for ded in dic.get('deducciones', []): + wslpg.AgregarDeduccion(**ded) + for per in dic.get("percepciones", []): + wslpg.AgregarPercepcion(**per) + for opc in dic.get("opcionales", []): + wslpg.AgregarOpcional(**opc) + + for fp in dic.get('factura_papel', []): + wslpg.AgregarFacturaPapel(**fp) + + print("Liquidacion Secundaria: pto_emision=%s nro_orden=%s" % ( + wslpg.liquidacion['ptoEmision'], + wslpg.liquidacion['nroOrden'], + )) + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + wslpg.LoadTestXML("wslpg_lsg_autorizar_resp.xml") + + wslpg.AutorizarLiquidacionSecundaria() + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print(wslpg.GetParametro("cod_tipo_operacion")) + print(wslpg.GetParametro("fecha_liquidacion")) + print(wslpg.GetParametro("subtotal")) + print(wslpg.GetParametro("importe_iva")) + print(wslpg.GetParametro("operacion_con_iva")) + print(wslpg.GetParametro("total_peso_neto")) + print(wslpg.GetParametro("numero_contrato")) + + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + if '--ajustar-lsg' in sys.argv: + print("Ajustando LSG...") + if '--prueba' in sys.argv: + # genero una liquidación de ejemplo: + dic = dict( + pto_emision=55, nro_orden=0, coe_ajustado='330100025869', + cod_localidad_procedencia=5544, cod_prov_procedencia=12, + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=2, + nro_contrato='1234' if '--contrato' in sys.argv else 0, + ajuste_credito=dict( + concepto_importe_iva_0='Alicuota Cero', + importe_ajustar_iva_0=900, + concepto_importe_iva_105='Alicuota Diez', + importe_ajustar_iva_105=800, + concepto_importe_iva_21='Alicuota Veintiuno', + importe_ajustar_iva_21=700, + percepciones=[{'detalle_aclaratoria': 'percepcion 1', + 'base_calculo': 1000, 'alicuota_iva': 21}], + estado=None, + coe_ajustado=None, + datos_adicionales='AJUSTE CRED LSG', + ), + ajuste_debito=dict( + concepto_importe_iva_0='Alic 0', + importe_ajustar_iva_0=250, + concepto_importe_iva_105='Alic 10.5', + importe_ajustar_iva_105=200, + concepto_importe_iva_21='Alicuota 21', + importe_ajustar_iva_21=50, + percepciones=[{'detalle_aclaratoria': 'percepcion 2', + 'base_calculo': 1000, 'alicuota_iva': 21}], + datos_adicionales='AJUSTE DEB LSG', + ), + ) + if '--contrato' in sys.argv: + dic.update( + {'nro_contrato': 27, + 'cuit_comprador': 20400000000, + 'cuit_vendedor': 23000000019, + 'cuit_corredor': 20267565393, # opcional + 'cod_grano': 2, + }) + escribir_archivo(dic, ENTRADA) + + dic = leer_archivo(ENTRADA) + + if int(dic['nro_orden']) == 0 and not '--testing' in sys.argv: + # consulto el último número de orden emitido: + ok = wslpg.ConsultarLiquidacionSecundariaUltNroOrden(dic['pto_emision']) + if ok: + dic['nro_orden'] = wslpg.NroOrden + 1 + + if '--contrato' in sys.argv: + for k in ("nro_contrato", "nro_act_comprador", "cod_grano", + "cuit_vendedor", "cuit_comprador", "cuit_corredor", + ): + v = dic.get(k) + if v: + wslpg.SetParametro(k, v) + + wslpg.CrearAjusteBase(pto_emision=dic['pto_emision'], + nro_orden=dic['nro_orden'], + coe_ajustado=dic['coe_ajustado'], + cod_localidad=dic['cod_localidad_procedencia'], + cod_provincia=dic['cod_prov_procedencia'], + ) + if 'ajuste_credito' in dic: + liq = dic['ajuste_credito'] + wslpg.CrearAjusteCredito(**liq) + for per in liq.get("percepciones", []): + wslpg.AgregarPercepcion(**per) + + if 'ajuste_debito' in dic: + liq = dic['ajuste_debito'] + wslpg.CrearAjusteDebito(**liq) + for per in liq.get("percepciones", []): + wslpg.AgregarPercepcion(**per) + + if '--testing' in sys.argv: + wslpg.LoadTestXML("tests/wslpg_ajuste_secundaria.xml") + + ret = wslpg.AjustarLiquidacionSecundaria() + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print("Subtotal", wslpg.Subtotal) + print("TotalIva105", wslpg.TotalIva105) + print("TotalIva21", wslpg.TotalIva21) + print("TotalRetencionesGanancias", wslpg.TotalRetencionesGanancias) + print("TotalRetencionesIVA", wslpg.TotalRetencionesIVA) + print("TotalNetoAPagar", wslpg.TotalNetoAPagar) + print("TotalIvaRg4310_18", wslpg.TotalIvaRg4310_18) + print("TotalPagoSegunCondicion", wslpg.TotalPagoSegunCondicion) + + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + ok = wslpg.AnalizarAjusteCredito() + dic['ajuste_credito'].update(wslpg.params_out) + ok = wslpg.AnalizarAjusteDebito() + dic['ajuste_debito'].update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + if DEBUG: + pprint.pprint(dic) + + if '--autorizar-anticipo' in sys.argv: + + if '--prueba' in sys.argv: + # genero una liquidación de ejemplo: + dic = dict( + pto_emision=33, + nro_orden=1, + cuit_comprador='20400000000', + nro_act_comprador='40', + nro_ing_bruto_comprador='123', + cod_tipo_operacion=2, + cod_puerto=14, des_puerto_localidad="DETALLE PUERTO", + cod_grano=1, + peso_neto_sin_certificado=100, + cuit_vendedor="30000000006", + nro_ing_bruto_vendedor=123456, + actua_corredor="S", liquida_corredor="S", + cuit_corredor=wslpg.Cuit, # uso Cuit representado + nro_ing_bruto_corredor=wslpg.Cuit, + comision_corredor="20.6", + fecha_precio_operacion="2015-10-10", + precio_ref_tn=567, # precio_operacion=150, + alic_iva_operacion="10.5", campania_ppal=1415, + cod_localidad_procedencia=197, + cod_prov_procedencia=10, + datos_adicionales="Prueba", + retenciones=[dict(codigo_concepto="RI", + detalle_aclaratorio="Retenciones IVA", + base_calculo=100, + alicuota=10.5, ), + dict(codigo_concepto="RG", + detalle_aclaratorio="Retenciones GAN", + base_calculo=100, + alicuota=2, )], + ) + escribir_archivo(dic, ENTRADA, agrega=('--agrega' in sys.argv)) + dic = leer_archivo(ENTRADA) + + # cargo la liquidación: + wslpg.CrearLiquidacion(**dic) + + for ret in dic.get('retenciones', []): + wslpg.AgregarRetencion(**ret) + + print("Liquidacion Primaria (Ant): pto_emision=%s nro_orden=%s" % ( + wslpg.liquidacion['ptoEmision'], + wslpg.liquidacion['nroOrden'], + )) + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + wslpg.LoadTestXML("wslpg_autorizar_ant_resp.xml") + + wslpg.AutorizarAnticipo() + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print(wslpg.GetParametro("cod_tipo_operacion")) + print(wslpg.GetParametro("fecha_liquidacion")) + print("TootalDeduccion", wslpg.TotalDeduccion) + print("TotalRetencion", wslpg.TotalRetencion) + print("TotalRetencionAfip", wslpg.TotalRetencionAfip) + print("TotalOtrasRetenciones", wslpg.TotalOtrasRetenciones) + print("TotalNetoAPagar", wslpg.TotalNetoAPagar) + print("TotalIvaRg4310_18", wslpg.TotalIvaRg4310_18) + print("TotalPagoSegunCondicion", wslpg.TotalPagoSegunCondicion) + + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + if '--autorizar-cg' in sys.argv: + + if '--prueba' in sys.argv: + # consulto ultimo numero de orden + pto_emision = 99 + wslpg.ConsultarCertificacionUltNroOrden(pto_emision) + # genero una certificación de ejemplo a autorizar: + dic = dict( + pto_emision=pto_emision, nro_orden=wslpg.NroOrden + 1, + tipo_certificado="P", nro_planta="3091", + nro_ing_bruto_depositario="20267565393", + titular_grano="T", + cuit_depositante='20111111112', + nro_ing_bruto_depositante='123', + cuit_corredor=None if '--sincorr' in sys.argv else '20222222223', + cod_grano=2, campania=1314, + datos_adicionales="Prueba",) + + # datos provisorios de prueba (segun tipo de certificación): + if '--primaria' in sys.argv: + dep = dict( + nro_act_depositario=29, + tipo_certificado="P", + descripcion_tipo_grano="SOJA", + monto_almacenaje=1, monto_acarreo=2, + monto_gastos_generales=3, monto_zarandeo=4, + porcentaje_secado_de=5, porcentaje_secado_a=4, + monto_secado=7, monto_por_cada_punto_exceso=8, + monto_otros=9, + porcentaje_merma_volatil=15, peso_neto_merma_volatil=16, + porcentaje_merma_secado=17, peso_neto_merma_secado=18, + porcentaje_merma_zarandeo=19, peso_neto_merma_zarandeo=20, + peso_neto_certificado=21, servicios_secado=22, + servicios_zarandeo=23, servicio_otros=240000, + servicios_forma_de_pago=25, + # campos no documentados por AFIP: + servicios_conceptos_no_gravados=26, + servicios_percepciones_iva=27, + servicios_otras_percepciones=0, # no enviar si es 0 + ) + dic.update(dep) + + det = dict(descripcion_rubro="bonif", + tipo_rubro="B", porcentaje=1, valor=1) + dic['det_muestra_analisis'] = [det] + + cal = dict(analisis_muestra=10, nro_boletin=11, + cod_grado="F1", valor_grado=1.02, + valor_contenido_proteico=1, valor_factor=1) + dic['calidad'] = [cal] + + ctg = dict(nro_ctg="123456", nro_carta_porte=1000, + porcentaje_secado_humedad=1, importe_secado=2, + peso_neto_merma_secado=3, tarifa_secado=4, + importe_zarandeo=5, peso_neto_merma_zarandeo=6, + tarifa_zarandeo=7, + peso_neto_confirmado_definitivo=1) + dic['ctgs'] = [ctg, ctg] + + if '--retiro-transf' in sys.argv: + rt = dict( + nro_act_depositario=29, + tipo_certificado="R", + cuit_receptor="20267565393", + fecha="2014-11-26", + nro_carta_porte_a_utilizar="530305323", + cee_carta_porte_a_utilizar="123456789012", + ) + dic.update(rt) + cert = dict( + peso_neto=10000, + coe_certificado_deposito="332000000357", + ) + dic['certificados'] = [cert] + + if '--preexistente' in sys.argv: + pre = dict( + tipo_certificado="E", + tipo_certificado_deposito_preexistente=1, # "R" o "T" + nro_certificado_deposito_preexistente="530305327", + cac_certificado_deposito_preexistente="85113524869336", + fecha_emision_certificado_deposito_preexistente="2014-11-26", + peso_neto=10000, nro_planta=3091, + ) + dic.update(pre) + + escribir_archivo(dic, ENTRADA, agrega=('--agrega' in sys.argv)) + dic = leer_archivo(ENTRADA) + + # cargar los datos según el tipo de certificación: + + wslpg.CrearCertificacionCabecera(**dic) + + if dic["tipo_certificado"] in ('P'): + wslpg.AgregarCertificacionPrimaria(**dic) + for ctg in dic.get("ctgs", []): + wslpg.AgregarCTG(**ctg) + for cal in dic.get("calidad", []): + wslpg.AgregarCalidad(**cal) + for det in dic.get("det_muestra_analisis", []): + wslpg.AgregarDetalleMuestraAnalisis(**det) + + if dic["tipo_certificado"] in ('R', 'T'): + wslpg.AgregarCertificacionRetiroTransferencia(**dic) + for cert in dic.get("certificados", []): + wslpg.AgregarCertificado(**cert) + + if dic["tipo_certificado"] in ('E', ): + wslpg.AgregarCertificacionPreexistente(**dic) + + print("Certificacion: pto_emision=%s nro_orden=%s tipo=%s" % ( + wslpg.certificacion['cabecera']['ptoEmision'], + wslpg.certificacion['cabecera']['nroOrden'], + wslpg.certificacion['cabecera']['tipoCertificado'], + )) + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + wslpg.LoadTestXML("tests/wslpg_cert_autorizar_resp.xml") + wslpg.LoadTestXML("tests/xml/wslpg_cg_err_response.xml") + + wslpg.AutorizarCertificacion() + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + print(wslpg.GetParametro("fecha_certificacion")) + + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + # Informar calidad (solo CG primarias) + + if '--informar-calidad' in sys.argv: + dic = leer_archivo(ENTRADA) + wslpg.CrearCertificacionCabecera(**dic) + wslpg.AgregarCertificacionPrimaria() + + for cal in dic.get("calidad", []): + wslpg.AgregarCalidad(**cal) + for det in dic.get("det_muestra_analisis", []): + wslpg.AgregarDetalleMuestraAnalisis(**det) + + # intento obtener el COE por linea de parametros o del archivo: + try: + coe = sys.argv[sys.argv.index("--informar-calidad") + 1] + except IndexError: + coe = dic['coe'] + + print("Informar Calidad: coe=%s " % (coe, )) + wslpg.InformarCalidadCertificacion(coe) + + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + print("Errores:", wslpg.Errores) + print("COE", wslpg.COE) + # actualizo el archivo de salida con los datos devueltos + dic.update(wslpg.params_out) + escribir_archivo(dic, SALIDA, agrega=('--agrega' in sys.argv)) + + # consultar CTG a certificar en una CG: + + if '--buscar-ctg' in sys.argv: + argv = dict([(i, e) for i, e + in enumerate(sys.argv[sys.argv.index("--buscar-ctg") + 1:]) + if not e.startswith("--")]) + tipo_certificado = argv.get(0, "P") # P + cuit_depositante = argv.get(1) # + nro_planta = argv.get(2, 3091) or None # opcional si no es primaria + cod_grano = argv.get(3, 2) + campania = argv.get(4, 1314) + ret = wslpg.BuscarCTG(tipo_certificado, cuit_depositante, + nro_planta, cod_grano, campania) + pprint.pprint(wslpg.params_out) + if DEBUG: + print("NRO CTG", wslpg.GetParametro("ctgs", 0, "nro_ctg")) + + # consultar certificados con saldo disponible para liquidar/transferir: + + if '--buscar-cert-con-saldo-disp' in sys.argv: + argv = dict([(i, e) for i, e + in enumerate(sys.argv[sys.argv.index("--buscar-cert-con-saldo-disp") + 1:]) + if not e.startswith("--")]) + cuit_depositante = argv.get(0) # por defecto usa el CUIT .ini + cod_grano = argv.get(1, 2) # + campania = argv.get(2, 1314) + coe = argv.get(3) + fecha_emision_des = argv.get(4) + fecha_emision_has = argv.get(5) + if '--testing' in sys.argv: + wslpg.LoadTestXML("tests/xml/wslpg_resp_buscar_cert.xml") # cargo respuesta + ret = wslpg.BuscarCertConSaldoDisponible(cuit_depositante, + cod_grano, campania, coe, + fecha_emision_des, fecha_emision_has, + ) + pprint.pprint(wslpg.params_out) + print(wslpg.ErrMsg) + if DEBUG: + print("1er COE", wslpg.GetParametro("certificados", 0, "coe")) + + # Recuperar parámetros: + + if '--campanias' in sys.argv: + ret = wslpg.ConsultarCampanias() + print("\n".join(ret)) + + if '--tipograno' in sys.argv: + ret = wslpg.ConsultarTipoGrano() + print("\n".join(ret)) + + if '--gradoref' in sys.argv: + ret = wslpg.ConsultarCodigoGradoReferencia() + print("\n".join(ret)) + + if '--gradoent' in sys.argv: + # wslpg.LoadTestXML("wslpg_cod.xml") # cargo respuesta de ej + cod_grano = input("Ingrese el código de grano: ") + ret = wslpg.ConsultarGradoEntregadoXTipoGrano(cod_grano=cod_grano) + print("\n".join(ret)) + + if '--datos' in sys.argv: + print("# Grados") + print(wslpg.ConsultarCodigoGradoReferencia(sep=None)) + + print("# Datos de grado entregado por tipo de granos:") + for cod_grano in wslpg.ConsultarTipoGrano(sep=None): + grad_ent = wslpg.ConsultarGradoEntregadoXTipoGrano(cod_grano, sep=None) + print(cod_grano, ":", grad_ent, ",") + + if '--shelve' in sys.argv: + print("# Construyendo BD de Localidades por Provincias") + from . import wslpg_datos as datos + for cod_prov, desc_prov in list(wslpg.ConsultarProvincias(sep=None).items()): + print("Actualizando Provincia", cod_prov, desc_prov) + d = wslpg.BuscarLocalidades(cod_prov) + + if '--certdeposito' in sys.argv: + ret = wslpg.ConsultarTipoCertificadoDeposito() + print("\n".join(ret)) + + if '--deducciones' in sys.argv: + ret = wslpg.ConsultarTipoDeduccion() + print("\n".join(ret)) + + if '--retenciones' in sys.argv: + ret = wslpg.ConsultarTipoRetencion() + print("\n".join(ret)) + + if '--puertos' in sys.argv: + ret = wslpg.ConsultarPuerto() + print("\n".join(ret)) + + if '--actividades' in sys.argv: + ret = wslpg.ConsultarTipoActividad() + print("\n".join(ret)) + + if '--actividadesrep' in sys.argv: + ret = wslpg.ConsultarTipoActividadRepresentado() + print("\n".join(ret)) + print("Errores:", wslpg.Errores) + + if '--operaciones' in sys.argv: + ret = wslpg.ConsultarTiposOperacion() + print("\n".join(ret)) + + if '--provincias' in sys.argv: + ret = wslpg.ConsultarProvincias() + print("\n".join(ret)) + + if '--localidades' in sys.argv: + cod_prov = input("Ingrese el código de provincia:") + ret = wslpg.ConsultarLocalidadesPorProvincia(cod_prov) + print("\n".join(ret)) + + # Generación del PDF: + + if '--pdf' in sys.argv: + + # cargo los datos del archivo de salida: + liq = wslpg.params_out = leer_archivo(SALIDA) + + conf_liq = dict(config.items('LIQUIDACION')) + conf_pdf = dict(config.items('PDF')) + + # establezco formatos (cantidad de decimales) según configuración: + wslpg.FmtCantidad = conf_liq.get("fmt_cantidad", "0.2") + wslpg.FmtPrecio = conf_liq.get("fmt_precio", "0.2") + + # determino el formato según el tipo de liquidación y datos + if '--ajuste' not in sys.argv: + # liquidación estándar + formatos = [('formato', '')] + copias = int(conf_liq.get("copias", 3)) + else: + # ajustes (páginas distintas), revisar si hay debitos/creditos: + formatos = [('formato_ajuste_base', '')] + copias = 1 + if liq['ajuste_debito']: + formatos.append(('formato_ajuste_debcred', 'ajuste_debito')) + if liq['ajuste_credito']: + formatos.append(('formato_ajuste_debcred', 'ajuste_credito')) + + wslpg.CrearPlantillaPDF( + papel=conf_liq.get("papel", "legal"), + orientacion=conf_liq.get("orientacion", "portrait"), + ) + + for num_formato, (formato, clave) in enumerate(formatos): + # cargo el formato CSV por defecto (liquidacion....csv) + wslpg.CargarFormatoPDF(conf_liq.get(formato)) + + # datos fijos (configuracion): + for k, v in list(conf_pdf.items()): + wslpg.AgregarDatoPDF(k, v) + + # datos adicionales (tipo de registro 9): + for dato in liq.get('datos', []): + wslpg.AgregarDatoPDF(dato['campo'], dato['valor']) + if DEBUG: + print("DATO", dato['campo'], dato['valor']) + + wslpg.ProcesarPlantillaPDF(num_copias=copias, + lineas_max=int(conf_liq.get("lineas_max", 24)), + qty_pos=conf_liq.get("cant_pos") or 'izq', + clave=clave) + if wslpg.Excepcion: + print("EXCEPCION:", wslpg.Excepcion, file=sys.stderr) + if DEBUG: + print(wslpg.Traceback, file=sys.stderr) + + salida = conf_liq.get("salida", "") + + # genero el nombre de archivo según datos de factura + d = os.path.join(conf_liq.get('directorio', "."), + liq['fecha_liquidacion'].replace("-", "_")) + if not os.path.isdir(d): + if DEBUG: + print("Creando directorio!", d) + os.makedirs(d) + fs = conf_liq.get('archivo', 'pto_emision,nro_orden').split(",") + fn = '_'.join([str(liq.get(ff, ff)) for ff in fs]) + fn = fn.encode('ascii', 'replace').replace('?', '_') + salida = os.path.join(d, "%s.pdf" % fn) + if num_formato == len(formatos) - 1: + dest = "F" # si es el último, escribir archivo + else: + dest = "" # sino, no escribir archivo todavía + wslpg.GenerarPDF(archivo=salida, dest=dest) + print("Generando PDF", salida, dest) + if '--mostrar' in sys.argv: + wslpg.MostrarPDF(archivo=salida, + imprimir='--imprimir' in sys.argv) + + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore"), file=sys.stderr) + sys.exit(3) + except Exception as e: + try: + print(traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0], file=sys.stderr) + except BaseException: + print("Excepción no disponible:", type(e), file=sys.stderr) + if DEBUG: + raise + sys.exit(5) + finally: + if XML: + open("wslpg_request.xml", "w").write(wslpg.client.xml_request) + open("wslpg_response.xml", "w").write(wslpg.client.xml_response) diff --git a/app/pyafipws/wslpg_datos.py b/app/pyafipws/wslpg_datos.py new file mode 100644 index 0000000000000000000000000000000000000000..75d2807a46bb3a1fc80a1833fb2b6ee3bb96c056 --- /dev/null +++ b/app/pyafipws/wslpg_datos.py @@ -0,0 +1,113 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +from decimal import Decimal + +TIPOS_OP = {1: 'Compraventa de granos', 2: 'Consignación de granos'} +GRANOS = { + 1: 'LINO', 2: 'GIRASOL', 3: 'MANI EN CAJA', + 4: 'GIRASOL DESCASCARADO', 5: 'MANI PARA INDUSTRIA DE SELECCION', + 6: 'MANI PARA INDUSTRIA ACEITERA', 7: 'MANI TIPO CONFITERIA', + 8: 'COLZA', 9: 'COLZA 00 CANOLA', 10: 'TRIGO FORRAJERO', + 11: 'CEBADA FORRAJERA', 12: 'CEBADA APTA PARA MALTERIA', + 14: 'TRIGO CANDEAL', 15: 'TRIGO PAN', + 16: 'AVENA', 17: 'CEBADA CERVECERA', 18: 'CENTENO', + 19: 'MAIZ', 20: 'MIJO', 21: 'ARROZ CASCARA', + 22: 'SORGO GRANIFERO', 23: 'SOJA', 25: 'TRIGO PLATA', + 26: 'MAIZ FLYNT O PLATA', 27: 'MAIZ PISINGALLO', + 28: 'TRITICALE', 30: 'ALPISTE', 31: 'ALGODON', 32: 'CARTAMO', + 33: 'POROTO BLANCO NATURAL OVAL Y ALUBIA', + 34: 'POROTO DISTINTO DEL BLANCO OVAL Y ALUBIA', + 35: 'ARROZ', 46: 'LENTEJA', 47: 'ARVEJA', + 48: 'POROTO BLANCO SELECCIONADO OVAL Y ALUBIA', + 49: 'OTRAS LEGUMBRES', 50: 'OTROS GRANOS', 59: 'GARBANZO', } + +PUERTOS = {1: "SAN LORENZO/SAN MARTIN", 2: "ROSARIO", + 3: "BAHIA BLANCA", 4: "NECOCHEA", 5: "RAMALLO", 6: "LIMA", + 7: "DIAMANTE", 8: "BUENOS AIRES", 9: "SAN PEDRO", + 10: "SAN NICOLAS", 11: "TERMINAL DEL GUAZU", 12: "ZARATE", + 13: "VILLA CONSTITUCION"} + +PROVINCIAS = {1: 'BUENOS AIRES', 0: 'CAPITAL FEDERAL', + 2: 'CATAMARCA', 16: 'CHACO', 17: 'CHUBUT', + 4: 'CORRIENTES', 3: 'CÓRDOBA', 5: 'ENTRE RIOS', + 18: 'FORMOSA', 6: 'JUJUY', 21: 'LA PAMPA', + 8: 'LA RIOJA', 7: 'MENDOZA', 19: 'MISIONES', + 20: 'NEUQUÉN', 22: 'RIO NEGRO', 9: 'SALTA', + 10: 'SAN JUAN', 11: 'SAN LUIS', 23: 'SANTA CRUZ', + 12: 'SANTA FE', 13: 'SANTIAGO DEL ESTERO', + 24: 'TIERRA DEL FUEGO', 14: 'TUCUMÁN'} + +TIPO_CERT_DEP = {1: "F1116/RT", 5: "F1116/A", 332: "Cert.Elec."} + +CAMPANIAS = {1213: "2012/2013", 1112: "2011/2012", 1011: "2010/2011", + 910: "2009/2010", 809: "2008/2009", 708: "2007/2008", + 607: "2006/2007", 506: "2005/2006", 405: "2004/2005", + 304: "2003/2004", 1314: "2013/2014", 1415: "2014/2015"} + +ACTIVIDADES = {41: "FRACCIONADOR DE GRANOS", 29: "ACOPIADOR - CONSIGNATARIO", + 33: "CANJEADOR DE BIENES Y/O SERVICIOS POR GRANO", + 40: "EXPORTADOR", 31: "ACOPIADOR DE MANÍ", + 30: "ACOPIADOR DE LEGUMBRES", + 35: "COMPRADOR DE GRANO PARA CONSUMO PROPIO", + 44: "INDUSTRIAL ACEITERO", 47: "INDUSTRIAL BIOCOMBUSTIBLE", + 46: "INDUSTRIAL BALANCEADOR", 48: "INDUSTRIAL CERVECERO", + 49: "INDUSTRIAL DESTILERIA", + 51: "INDUSTRIAL MOLINO DE HARINA DE TRIGO", + 50: "INDUSTRIAL MOLINERO", 45: "INDUSTRIAL ARROCERO", + 59: "USUARIO DE MOLIENDA DE TRIGO(incluye MAQUILA)", + 57: "USUARIO DE INDUSTRIA (Otros granos MENOS trigo)", + 52: "INDUSTRIAL SELECCIONADOR", 34: "COMPLEJO INDUSTRIAL", + 28: "ACONDICIONADOR", 36: "CORREDOR", + 55: "MERCADO DE FUTUROS Y OPCIONES O MERCADO A TERMINO", + 39: "EXPLOTADOR DE DEPOSITO Y/O ELEVADOR DE GRANOS", + 37: "DESMOTADOR DE ALGODON", + } + +# Grados +GRADOS_REF = {'G3': 'Grado 3', 'G2': 'Grado 2', 'G1': 'Grado 1'} + +# Datos de grado entregado por tipo de granos: +GRADO_ENT_VALOR = { + 49: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 25: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 26: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 27: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 20: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 21: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 22: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 23: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 46: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 47: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 48: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 28: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 1: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 3: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 2: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 5: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 4: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 7: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 6: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 9: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 8: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 59: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 11: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 10: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 12: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 15: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.99'), 'G2': Decimal('1.00'), 'G1': Decimal('1.015'), 'FG': Decimal('0')}, + 14: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 17: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 16: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 19: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 18: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 31: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 30: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 50: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 35: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 34: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 33: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, + 32: {'F1': Decimal('0'), 'F2': Decimal('0'), 'F3': Decimal('0'), 'G3': Decimal('0.985'), 'G2': Decimal('1.00'), 'G1': Decimal('1.01'), 'FG': Decimal('0')}, +} + +# Diccionario de localidades por provincia +# (wslpg.py lo reemplaza con un shelve si es posible) +LOCALIDADES = {} diff --git a/app/pyafipws/wslsp.py b/app/pyafipws/wslsp.py new file mode 100644 index 0000000000000000000000000000000000000000..033e35f39d442e38c29651646c9feb127ec06014 --- /dev/null +++ b/app/pyafipws/wslsp.py @@ -0,0 +1,1210 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + + +import shelve +import sys +import datetime +import decimal +import os +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, BaseWS, inicializar_y_capturar_excepciones, get_install_dir +from . import utils +from fpdf import Template +from pysimplesoap.client import SoapFault +import pprint +import traceback +"""Módulo para obtener código de autorización electrónica (CAE) para +Liquidación Sector Pecuario (hacienda/carne) del web service WSLSP de AFIP +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2016 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.07a" + +LICENCIA = """ +wslsp.py: Interfaz para generar Código de Autorización Electrónica (CAE) para + Liquidación Sector Pecuario (LspService) +Copyright (C) 2016 Mariano Reingart reingart@gmail.com +http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionSectorPecuario + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo respetando la licencia GPLv3. + +Para información adicional sobre garantía, soporte técnico comercial +e incorporación/distribución en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + +AYUDA = """ +Opciones: + --ayuda: este mensaje + + --debug: modo depuración (detalla y confirma las operaciones) + --formato: muestra el formato de los archivos de entrada/salida + --prueba: genera y autoriza una liquidación de prueba (no usar en producción!) + --xml: almacena los requerimientos y respuestas XML (depuración) + --json: utilizar formato json para el archivo de intercambio + --dummy: consulta estado de servidores + + --autorizar: Autorizar Liquidación Única (generarLiquidacion) + --ajustar: Ajuste Físico/Monetario/Financiero, Credito/Debito (generarAjuste) + --ult: Consulta el último número de orden registrado en AFIP + (consultarUltimoComprobanteXPuntoVenta) + --consultar: Consulta una liquidación registrada en AFIP + (consultarLiquidacionXNroComprobante / consultarLiquidacionXCAE) + + --provincias: obtiene el listado de provincias (código/descripción) + --localidades: obtiene el listado de localidades para una provincia + --tributos: obtiene el listado de los tipos de tributos + --gastos: obtiene el listado de los tipos de gastos + --puntosventa: obtiene el listado de puntos de venta habilitados + +Ver wslsp.ini para parámetros de configuración (URL, certificados, etc.)" +""" + + +# importo funciones compartidas: + + +WSDL = "https://fwshomo.afip.gov.ar/wslsp/LspService?wsdl" +#WSDL = "https://serviciosjava.afip.gov.ar/wslsp/LspService?wsdl" + +DEBUG = False +XML = False +CONFIG_FILE = "wslsp.ini" +HOMO = False + + +class WSLSP(BaseWS): + "Interfaz para el WebService de Liquidación Única Mensual (lechería)" + _public_methods_ = ['Conectar', 'Dummy', 'SetTicketAcceso', 'DebugLog', + 'AutorizarLiquidacion', + 'CrearLiquidacion', 'AgregarFrigorifico', + 'AgregarEmisor', 'AgregarReceptor', 'AgregarOperador', + 'AgregarDTE', + 'AgregarItemDetalle', 'AgregarCompraAsociada', + 'AgregarGasto', 'AgregarTributo', 'AgregarGuia', + 'ConsultarLiquidacion', 'ConsultarUltimoComprobante', + 'CrearAjuste', 'AgregarComprobanteAAjustar', + 'AgregarItemDetalleAjuste', + 'AgregarAjusteMonetario', 'AgregarAjusteFisico', + 'AgregarAjusteFinanciero', + 'AjustarLiquidacion', + 'LeerDatosLiquidacion', + 'ConsultarOperaciones', + 'ConsultarTiposComprobante', + 'ConsultarTiposLiquidacion', + 'ConsultarCategorias', 'ConsultarMotivos', + 'ConsultarRazas', 'ConsultarCortes', + 'ConsultarCaracteresParticipante', + 'ConsultarGastos', 'ConsultarTributos', + 'ConsultarPuntosVentas', + 'ConsultarProvincias', 'ConsultarLocalidades', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetParametro', 'GetParametro', + ] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'Excepcion', 'ErrCode', 'ErrMsg', 'LanzarExcepciones', 'Errores', + 'XmlRequest', 'XmlResponse', 'Version', 'Traceback', 'InstallDir', + 'CAE', 'NroComprobante', 'FechaComprobante', + 'NroCodigoBarras', 'FechaVencimientoCae', 'FechaProcesoAFIP', + 'ImporteBruto', 'ImporteIVASobreBruto', '', + 'ImporteTotalGastos', 'ImporteIVASobreGastos', + 'ImporteTotalTributos', 'ImporteTotalNeto', + ] + _reg_progid_ = "WSLSP" + _reg_clsid_ = "{9750BBD4-FBC3-4FE7-8DE5-E193667D6813}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + LanzarExcepciones = False + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.errores = [] + self.CAE = "" + self.NroComprobante = self.FechaComprobante = self.FechaProcesoAFIP = '' + self.NroCodigoBarras = self.FechaVencimientoCae = None + self.ImporteBruto = self.ImporteIVASobreBruto = None + self.ImporteTotalGastos = self.ImporteIVASobreGastos = None + self.ImporteTotalTributos = self.ImporteTotalNeto = None + self.datos = {} + + @inicializar_y_capturar_excepciones + def Conectar(self, cache=None, url="", proxy="", wrapper="", cacert=None, timeout=60): + "Establecer la conexión a los servidores de la AFIP" + # llamo al constructor heredado: + ok = BaseWS.Conectar(self, cache, url, proxy, wrapper, cacert, timeout) + if False and ok: + # corrijo ubicación del servidor (puerto htttp 80 en el WSDL) + location = self.client.services['LspService']['ports']['LumEndPoint']['location'] + if location.startswith("http://"): + print("Corrigiendo WSDL ...", location, end=' ') + location = location.replace("http://", "https://").replace(":80", ":443") + self.client.services['LspService']['ports']['LspEndPoint']['location'] = location + print(location) + return ok + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + errores = [] + if 'errores' in ret: + errores.extend(ret['errores']) + if errores: + self.Errores = ["%(codigo)s: %(descripcion)s" % err['error'][0] + for err in errores] + self.errores = [ + {'codigo': err['error'][0]['codigo'], + 'descripcion': err['error'][0]['descripcion'].replace("\n", "") + .replace("\r", "")} + for err in errores] + self.ErrCode = ' '.join(self.Errores) + self.ErrMsg = '\n'.join(self.Errores) + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + results = self.client.dummy()['respuesta'] + self.AppServerStatus = str(results['appserver']) + self.DbServerStatus = str(results['dbserver']) + self.AuthServerStatus = str(results['authserver']) + return True + + @inicializar_y_capturar_excepciones + def CrearLiquidacion(self, cod_operacion, fecha_cbte, fecha_op, cod_motivo, + cod_localidad_procedencia, cod_provincia_procedencia, + cod_localidad_destino, cod_provincia_destino, + lugar_realizacion=None, + fecha_recepcion=None, fecha_faena=None, + datos_adicionales=None, **kwargs): + "Inicializa internamente los datos de una liquidación para autorizar" + # creo el diccionario con los campos generales de la liquidación: + liq = {'fechaComprobante': fecha_cbte, 'fechaOperacion': fecha_op, + 'lugarRealizacion': lugar_realizacion, + 'codMotivo': cod_motivo, + 'codLocalidadProcedencia': cod_localidad_procedencia, + 'codProvinciaProcedencia': cod_provincia_procedencia, + 'codLocalidadDestino': cod_localidad_destino, + 'codProvinciaDestino': cod_provincia_destino, + 'fechaRecepcion': fecha_recepcion, + 'fechaFaena': fecha_faena, + } + self.solicitud = dict(codOperacion=cod_operacion, + emisor={}, receptor={}, + datosLiquidacion=liq, + itemDetalleLiquidacion=[], + guia=[], dte=[], + tributo=[], gasto=[], + datosAdicionales=datos_adicionales, + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarFrigorifico(self, cuit, nro_planta): + "Agrego el frigorifico a la liquidacíon (opcional)." + frig = {'cuit': cuit, 'nroPlanta': nro_planta} + self.solicitud['datosLiquidacion']['frigorifico'] = frig + return True + + @inicializar_y_capturar_excepciones + def AgregarEmisor(self, tipo_cbte, pto_vta, nro_cbte, cod_caracter=None, + fecha_inicio_act=None, iibb=None, nro_ruca=None, + nro_renspa=None, cuit_autorizado=None, **kwargs): + "Agrego los datos del emisor a la liq." + # cod_caracter y fecha_inicio_act no es requerido para ajustes + d = {'tipoComprobante': tipo_cbte, 'puntoVenta': pto_vta, + 'nroComprobante': nro_cbte, + 'codCaracter': cod_caracter, + 'fechaInicioActividades': fecha_inicio_act, + 'iibb': iibb, + 'nroRUCA': nro_ruca, + 'nroRenspa': nro_renspa, + 'cuitAutorizado': cuit_autorizado} + self.solicitud['emisor'].update(d) + return True + + @inicializar_y_capturar_excepciones + def AgregarReceptor(self, cod_caracter, **kwargs): + "Agrego los datos del receptor a la liq." + d = {'codCaracter': cod_caracter} + self.solicitud['receptor'].update(d) + return True + + @inicializar_y_capturar_excepciones + def AgregarOperador(self, cuit, iibb=None, nro_ruca=None, nro_renspa=None, + cuit_autorizado=None, **kwargs): + "Agrego los datos del operador a la liq." + d = {'cuit': cuit, + 'iibb': iibb, + 'nroRUCA': nro_ruca, + 'nroRenspa': nro_renspa, + 'cuitAutorizado': cuit_autorizado} + self.solicitud['receptor']['operador'] = d + return True + + @inicializar_y_capturar_excepciones + def AgregarItemDetalle(self, cuit_cliente, cod_categoria, tipo_liquidacion, + cantidad, precio_unitario, alicuota_iva, cod_raza, + cantidad_cabezas=None, nro_tropa=None, + cod_corte=None, cantidad_kg_vivo=None, + precio_recupero=None, detalle_raza=None, + nro_item=None, + **kwargs): + "Agrega el detalle de item de la liquidación" + d = {'cuitCliente': cuit_cliente, + 'codCategoria': cod_categoria, + 'tipoLiquidacion': tipo_liquidacion, + 'cantidad': cantidad, + 'precioUnitario': precio_unitario, + 'alicuotaIVA': alicuota_iva, + 'cantidadCabezas': cantidad_cabezas, + 'raza': {'codRaza': cod_raza, 'detalle': detalle_raza}, + 'nroTropa': nro_tropa, + 'codCorte': cod_corte, + 'cantidadKgVivo': cantidad_kg_vivo, + 'precioRecupero': precio_recupero, + 'liquidacionCompraAsociada': [], + 'nroItem': nro_item, + } + self.solicitud['itemDetalleLiquidacion'].append(d) + return True + + @inicializar_y_capturar_excepciones + def AgregarCompraAsociada(self, tipo_cbte, pto_vta, nro_cbte, cant_asoc, nro_item): + "Agrega la información referente a la liquidación compra asociada" + d = {'tipoComprobante': tipo_cbte, + 'puntoVenta': pto_vta, + 'nroComprobante': nro_cbte, + 'nroItem': nro_item, + 'cantidadAsociada': cant_asoc} + if 'itemDetalleLiquidacion' in self.solicitud: + item_liq = self.solicitud['itemDetalleLiquidacion'][-1] + item_liq['liquidacionCompraAsociada'].append(d) + else: + item_liq = self.solicitud['itemDetalleAjusteLiquidacion'][-1] + item_liq['ajusteCompraAsociada'].append(d) + return True + + @inicializar_y_capturar_excepciones + def AgregarGasto(self, cod_gasto, descripcion=None, base_imponible=None, + alicuota=None, importe=None, alicuota_iva=None, + tipo_iva_nulo=None): + "Agrega la información referente a los gastos de la liquidación" + # WSLSPv1.4.1: tipo_iva_nulo debe ser NG, NA, EX: Exento. + if alicuota_iva == 0: + alicuota_iva = None # sólo acepta [10.5, 21.0] + elif alicuota_iva: + tipo_iva_nulo = None + gasto = {'codGasto': cod_gasto, 'descripcion': descripcion, + 'baseImponible': base_imponible, 'alicuota': alicuota, + 'importe': importe, 'alicuotaIVA': alicuota_iva, + 'tipoIVANulo': tipo_iva_nulo, + } + if 'ajusteFinanciero' in self.solicitud: + self.solicitud['ajusteFinanciero']['gasto'].append(gasto) + else: + self.solicitud['gasto'].append(gasto) + return True + + @inicializar_y_capturar_excepciones + def AgregarTributo(self, cod_tributo, descripcion=None, + base_imponible=None, alicuota=None, importe=None): + "Agrega la información referente a los tributos de la liquidación" + trib = {'codTributo': cod_tributo, 'descripcion': descripcion, + 'baseImponible': base_imponible, 'alicuota': alicuota, + 'importe': importe} + if 'ajusteFinanciero' in self.solicitud: + self.solicitud['ajusteFinanciero']['tributo'].append(trib) + else: + self.solicitud['tributo'].append(trib) + return True + + @inicializar_y_capturar_excepciones + def AgregarDTE(self, nro_dte, nro_renspa=None): + "Agrega la información referente a DTE (multiples)" + d = {"nroDTE": nro_dte, "nroRenspa": nro_renspa} + self.solicitud['dte'].append(d) + return True + + @inicializar_y_capturar_excepciones + def AgregarGuia(self, nro_guia): + "Agrega la información referente a las guías (multiples)" + self.solicitud['guia'].append({"nroGuia": nro_guia}) + return True + + @inicializar_y_capturar_excepciones + def AutorizarLiquidacion(self): + "Generar o ajustar una liquidación única y obtener del CAE" + # limpio los elementos que no correspondan por estar vacios: + for campo in ["guia", "dte", "gasto", "tributo"]: + if campo in self.solicitud and not self.solicitud[campo]: + del self.solicitud[campo] + for item in self.solicitud['itemDetalleLiquidacion']: + if not item.get("liquidacionCompraAsociada", True): + del item["liquidacionCompraAsociada"] + # llamo al webservice: + ret = self.client.generarLiquidacion( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud=self.solicitud, + ) + # analizo la respusta + ret = ret['respuesta'] + self.__analizar_errores(ret) + self.AnalizarLiquidacion(ret) + return True + + def AnalizarLiquidacion(self, liq): + "Método interno para analizar la respuesta de AFIP" + # proceso los datos básicos de la liquidación (devuelto por consultar): + cab = liq.get('cabecera') + if cab: + self.CAE = str(cab.get('cae', '')) + self.FechaVencimientoCae = str(cab.get('fechaVencimientoCae', '')) + self.FechaProcesoAFIP = str(cab.get('fechaProcesoAFIP', '')) + self.NroCodigoBarras = cab.get('nroCodigoBarras') + datos = liq.get('datosLiquidacion', {}) + self.FechaComprobante = str(datos.get('fechaComprobante', '')) + emisor = liq.get('emisor', {}) + self.NroComprobante = emisor.get('nroComprobante') + tot = liq.get('resumenTotales', {}) + self.ImporteBruto = tot.get('importeBruto') + self.ImporteTotalGastos = tot.get('importeTotalGastos') + self.ImporteTotalTributos = tot.get('importeTotalTributos') + self.ImporteTotalNeto = tot.get('importeTotalNeto') + self.ImporteIVASobreBruto = tot.get('importeIVASobreBruto') + self.ImporteIVASobreGastos = tot.get('importeIVASobreGastos') + receptor = liq.get('receptor', {}) + + # parámetros de salida: + self.params_out = dict( + tipo_cbte=emisor.get('tipoComprobante'), + pto_vta=emisor.get('puntoVenta'), + nro_cbte=emisor.get('nroComprobante'), + fecha=datos.get('fechaComprobante'), + cae=cab.get('cae'), + emisor=dict( + razon_social=emisor.get('razonSocial'), + domicilio_punto_venta=emisor.get('domicilioPuntoVenta'), + ), + receptor=dict( + nombre=receptor.get('nombre'), + domicilio=receptor.get('domicilio'), + ), + bruto=tot.get('importeBruto'), + iva_bruto=tot.get('importeIVASobreBruto'), + iva_gastos=tot.get('importeIVASobreGastos'), + total_neto=tot.get('importeTotalNeto'), + total_tributos=tot.get('importeTotalTributos'), + total_gastos=tot.get('importeTotalGastos'), + gasto=[], + guia=[], + tributo=[], + pdf=liq.get('pdf'), + ) + for ret in liq.get('gasto', []): + self.params_out['gasto'].append(dict( + cod_gasto=ret['codGasto'], + importe=ret['importe'], + )) + for trib in liq.get('tributo', []): + self.params_out['tributo'].append(dict( + descripcion=trib.get('descripcion', ""), + base_imponible=trib.get('baseImponible'), + alicuota=trib.get('alicuota'), + codigo=trib['codTributo'], + importe=trib['importe'], + )) + # analizar datos del ajuste generado: + ajuste = liq.get('ajuste', {}) + if ajuste: + self.params_out.update(dict( + tipo_ajuste=ajuste['tipoAjuste'], + modo_ajuste=ajuste['modoAjuste'], + )) + ajustado = ajuste.get('comprobanteAjustado') + if ajustado: + self.params_out.update(dict( + cbte_ajuste=dict( + tipo_cbte=ajustado['tipoComprobante'], + pto_vta=ajustado['puntoVenta'], + nro_cbte=ajustado['nroComprobante'], + ) + )) + if DEBUG: + import pprint + pprint.pprint(self.params_out) + self.params_out['errores'] = self.errores + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacion(self, tipo_cbte=None, pto_vta=None, nro_cbte=None, + cae=None, cuit_comprador=None, pdf="liq.pdf"): + "Consulta una liquidación por No de Comprobante o CAE" + if cae: + ret = self.client.consultarLiquidacionPorCae( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'cae': cae, + 'pdf': pdf and True or False, + }, + ) + else: + ret = self.client.consultarLiquidacionPorNroComprobante( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'cuitComprador': cuit_comprador, + 'puntoVenta': pto_vta, + 'nroComprobante': nro_cbte, + 'tipoComprobante': tipo_cbte, + 'pdf': pdf and True or False, + }, + ) + ret = ret['respuesta'] + self.__analizar_errores(ret) + self.AnalizarLiquidacion(ret) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in ret: + open(pdf, "wb").write(ret['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarUltimoComprobante(self, tipo_cbte=151, pto_vta=1): + "Consulta el último No de Comprobante registrado" + ret = self.client.consultarUltimoNroComprobantePorPtoVta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'puntoVenta': pto_vta, + 'tipoComprobante': tipo_cbte}, + ) + ret = ret['respuesta'] + self.__analizar_errores(ret) + self.NroComprobante = ret['nroComprobante'] + return True + + @inicializar_y_capturar_excepciones + def CrearAjuste(self, tipo_ajuste, fecha_cbte, datos_adicionales=None, **kwargs): + "Inicializa internamente los datos de una liquidación para ajustar" + # creo el diccionario con los campos generales de la liquidación: + self.solicitud = dict(tipoAjuste=tipo_ajuste, + fechaComprobante=fecha_cbte, + emisor={}, + itemDetalleAjusteLiquidacion=[], + ajusteFinanciero={}, + datosAdicionales=datos_adicionales, + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarComprobanteAAjustar(self, tipo_cbte, pto_vta, nro_cbte): + "Agrega comprobante a ajustar" + cbte = dict(tipoComprobante=tipo_cbte, puntoVenta=pto_vta, nroComprobante=nro_cbte) + self.solicitud['emisor']["comprobanteAAjustar"] = cbte + return True + + @inicializar_y_capturar_excepciones + def AgregarItemDetalleAjuste(self, nro_item_ajustar, **kwargs): + "Agrega el detalle de item a un ajuste de liquidación" + d = {'nroItemAjustar': nro_item_ajustar, 'ajusteCompraAsociada': []} + self.solicitud['itemDetalleAjusteLiquidacion'].append(d) + return True + + @inicializar_y_capturar_excepciones + def AgregarAjusteFisico(self, cantidad, cantidad_cabezas=None, + cantidad_kg_vivo=None, **kwargs): + "Agrega campos al detalle de item por un ajuste fisico" + d = {'cantidad': cantidad, + 'cantidadCabezas': cantidad_cabezas, + 'cantidadKgVivo': cantidad_kg_vivo, + } + item_liq = self.solicitud['itemDetalleAjusteLiquidacion'][-1] + item_liq['ajusteFisico'] = d + return True + + @inicializar_y_capturar_excepciones + def AgregarAjusteMonetario(self, precio_unitario, precio_recupero=None, + **kwargs): + "Agrega campos al detalle de item por un ajuste monetario" + d = {'precioUnitario': precio_unitario, + 'precioRecupero': precio_recupero, + } + item_liq = self.solicitud['itemDetalleAjusteLiquidacion'][-1] + item_liq['ajusteMonetario'] = d + return True + + @inicializar_y_capturar_excepciones + def AgregarAjusteFinanciero(self, **kwargs): + "Prepara el detalle de item por un ajuste financiero (gastos/tributos)" + self.solicitud['ajusteFinanciero'] = {'gasto': [], 'tributo': []} + return True + + @inicializar_y_capturar_excepciones + def AjustarLiquidacion(self): + "Generar y ajustar una liquidación para obtener del CAE" + # limpio los elementos que no correspondan por estar vacios: + for item_liq in self.solicitud['itemDetalleAjusteLiquidacion']: + campo = 'ajusteCompraAsociada' + if campo in item_liq and not item_liq[campo]: + del item_liq[campo] + for campo in self.solicitud.get('ajusteFinanciero', {}).copy(): + if not self.solicitud['ajusteFinanciero'][campo]: + del self.solicitud['ajusteFinanciero'][campo] + # llamo al webservice: + ret = self.client.generarAjuste( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud=self.solicitud, + ) + # analizo la respusta + ret = ret['respuesta'] + self.__analizar_errores(ret) + self.AnalizarLiquidacion(ret) + return True + + @inicializar_y_capturar_excepciones + def ConsultarProvincias(self, sep="||"): + "Consulta las provincias habilitadas" + ret = self.client.consultarProvincias( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('provincia', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarLocalidades(self, cod_provincia, sep="||"): + "Consulta las localidades habilitadas" + ret = self.client.consultarLocalidadesPorProvincia( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={'codProvincia': cod_provincia}, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('localidad', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarOperaciones(self, sep="||"): + "Retorna un listado de código y descripción de operaciones permitidas" + ret = self.client.consultarOperaciones( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('operacion', []) + ret.get('operacionPorcina', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarTributos(self, sep="||"): + "Retorna un listado de tributos con código, descripción y signo." + ret = self.client.consultarTributos( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('tributo', []) + ret.get('tributoPorcino', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarGastos(self, sep="||"): + "Retorna un listado de gastos con código y descripción" + ret = self.client.consultarGastos( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('gasto', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarTiposComprobante(self, sep="||"): + "Retorna un listado de tipos de comprobantes con código y descripción" + ret = self.client.consultarTiposComprobante( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('tipoComprobante', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarTiposLiquidacion(self, sep="||"): + "Retorna un listado de tipos de liquidación con código y descripción" + ret = self.client.consultarTiposLiquidacion( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('tipoLiquidacion', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarCaracteres(self, sep="||"): + "Retorna listado de caracteres emisor/receptor (código, descripción)" + ret = self.client.consultarCaracteresParticipante( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('caracter', []) + ret.get('caracterPorcino', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarCategorias(self, sep="||"): + "Retorna listado de categorías existentes (código, descripción)" + ret = self.client.consultarCategorias( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('categoria', []) + ret.get('categoriaPorcina', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarMotivos(self, sep="||"): + "Retorna listado de motivos existentes (código, descripción)" + ret = self.client.consultarMotivos( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('motivo', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarRazas(self, sep="||"): + "Retorna listado de razas -vacunas y porcinos- (código, descripción)" + ret = self.client.consultarRazas( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('raza', []) + ret.get('razaPorcina', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarCortes(self, sep="||"): + "Retorna listado de cortes -carnes- (código, descripción)" + ret = self.client.consultarCortes( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('corte', []) + ret.get('cortePorcino', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + @inicializar_y_capturar_excepciones + def ConsultarPuntosVentas(self, sep="||"): + "Retorna los puntos de ventas autorizados para la utilizacion de WS" + ret = self.client.consultarPuntosVenta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('puntoVenta', []) + if sep is None: + return dict([(it['codigo'], it.get('descripcion', '')) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it.get('descripcion', '')) for it in array] + + def MostrarPDF(self, archivo, imprimir=False): + try: + if sys.platform == "linux2": + import subprocess + subprocess.run(["evince", archivo], check=False) + else: + operation = imprimir and "print" or "" + os.startfile(archivo, operation) + return True + except Exception as e: + self.Excepcion = str(e) + return False + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = WSLSP.InstallDir = get_install_dir() + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + if '--formato' in sys.argv: + print("Formato:") + for msg, formato in []: + comienzo = 1 + print("=== %s ===" % msg) + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = len(fmt) > 3 and fmt[3] or (tipo == 'I' and '2' or '') + print(" * Campo: %-20s Posición: %3d Longitud: %4d Tipo: %s Decimales: %s" % ( + clave, comienzo, longitud, tipo, dec)) + comienzo += longitud + sys.exit(0) + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSLSP) + sys.exit(0) + + import csv + from configparser import SafeConfigParser + + from .wsaa import WSAA + + try: + + if "--version" in sys.argv: + print("Versión: ", __version__) + + if len(sys.argv) > 1 and sys.argv[1].endswith(".ini"): + CONFIG_FILE = sys.argv[1] + print("Usando configuracion:", CONFIG_FILE) + + config = SafeConfigParser() + config.read(CONFIG_FILE) + CERT = config.get('WSAA', 'CERT') + PRIVATEKEY = config.get('WSAA', 'PRIVATEKEY') + CUIT = config.get('WSLSP', 'CUIT') + ENTRADA = config.get('WSLSP', 'ENTRADA') + SALIDA = config.get('WSLSP', 'SALIDA') + PDF = config.has_option('WSLSP', 'PDF') and config.get('WSLSP', 'PDF') or "liq.pdf" + + if config.has_option('WSAA', 'URL') and not HOMO: + WSAA_URL = config.get('WSAA', 'URL') + else: + WSAA_URL = None # wsaa.WSAAURL + if config.has_option('WSLSP', 'URL') and not HOMO: + WSLSP_URL = config.get('WSLSP', 'URL') + else: + WSLSP_URL = WSDL + + PROXY = config.has_option('WSAA', 'PROXY') and config.get('WSAA', 'PROXY') or None + CACERT = config.has_option('WSAA', 'CACERT') and config.get('WSAA', 'CACERT') or None + WRAPPER = config.has_option('WSAA', 'WRAPPER') and config.get('WSAA', 'WRAPPER') or None + + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + if DEBUG: + print("conf_dbf", conf_dbf) + else: + conf_dbf = {} + + DEBUG = '--debug' in sys.argv + XML = '--xml' in sys.argv + + if DEBUG: + print("Usando Configuración:") + print("WSAA_URL:", WSAA_URL) + print("WSLSP_URL:", WSLSP_URL) + print("CACERT", CACERT) + print("WRAPPER", WRAPPER) + # obteniendo el TA + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wslsp", CERT, PRIVATEKEY, wsdl=WSAA_URL, + proxy=PROXY, wrapper=WRAPPER, cacert=CACERT) + if not ta: + pass # sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + + # cliente soap del web service + wslsp = WSLSP() + wslsp.LanzarExcepciones = True + wslsp.Conectar(url=WSLSP_URL, proxy=PROXY, wrapper=WRAPPER, cacert=CACERT) + wslsp.SetTicketAcceso(ta) + wslsp.Cuit = CUIT + + if '--dummy' in sys.argv: + ret = wslsp.Dummy() + print("AppServerStatus", wslsp.AppServerStatus) + print("DbServerStatus", wslsp.DbServerStatus) + print("AuthServerStatus", wslsp.AuthServerStatus) + # sys.exit(0) + + if '--autorizar' in sys.argv: + + if '--prueba' in sys.argv: + print(wslsp.client.help("generarLiquidacion")) + + # Solicitud 1: Cuenta de Venta y Líquido Producto - Hacienda + wslsp.CrearLiquidacion( + cod_operacion=1, + fecha_cbte='2017-04-23', + fecha_op='2017-04-23', + cod_motivo=6, + cod_localidad_procedencia=8274, + cod_provincia_procedencia=1, + cod_localidad_destino=8274, + cod_provincia_destino=1, + lugar_realizacion='CORONEL SUAREZ', + fecha_recepcion=None, fecha_faena=None, + datos_adicionales=None) + if False: + wslsp.AgregarFrigorifico(cuit=20160000156, nro_planta=1) + wslsp.AgregarEmisor( + tipo_cbte=180, pto_vta=3000, nro_cbte=64, + cod_caracter=5, fecha_inicio_act='2016-01-01', + iibb='123456789', nro_ruca=305, nro_renspa=None) + wslsp.AgregarReceptor(cod_caracter=3) + wslsp.AgregarOperador(cuit=30160000011, iibb=3456, + # nro_ruca=1011, # Validacion AFIP 1003 + # cuit_autorizado=20160000261, # 1001 + nro_renspa='22.123.1.12345/A4') + wslsp.AgregarItemDetalle( + cuit_cliente="20160000199", # 2403 + cod_categoria=51020102, + tipo_liquidacion=1, + cantidad=2, + precio_unitario=10.0, + alicuota_iva=10.5, + cod_raza=1, + cantidad_cabezas=None, # Validacion AFIP 2408 + nro_tropa=None, + cod_corte=None, + cantidad_kg_vivo=None, + precio_recupero=None, + detalle_raza=None, + nro_item=1, + ) + wslsp.AgregarCompraAsociada(tipo_cbte=185, pto_vta=3000, + nro_cbte=33, cant_asoc=2, + nro_item=1) + wslsp.AgregarGuia(nro_guia=1) + # wslsp.AgregarGuia(nro_guia=2) + if True: + wslsp.AgregarDTE(nro_dte="418-1", + nro_renspa='22.123.1.12345/A5') + wslsp.AgregarDTE(nro_dte="418-2", + nro_renspa='22.123.1.12346/A5') + else: + wslsp.AgregarDTE(nro_dte="418-3", nro_renspa=None) + wslsp.AgregarDTE(nro_dte="418-4", nro_renspa=None) + wslsp.AgregarGasto(cod_gasto=16, base_imponible=230520.60, + alicuota=3, alicuota_iva=10.5) + wslsp.AgregarGasto(cod_gasto=99, base_imponible=None, + alicuota=1, alicuota_iva=0, + descripcion="Exento WSLSPv1.4.1", + tipo_iva_nulo="EX") + wslsp.AgregarTributo(cod_tributo=5, base_imponible=230520.60, + alicuota=2.5) + wslsp.AgregarTributo(cod_tributo=3, importe=397) + else: + # cargar un archivo de texto: + with open(ENTRADA, "r") as f: + wslsp.solicitud = json.load(f, encoding="utf-8") + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo, cargo respuesta: + wslsp.LoadTestXML("tests/xml/wslsp_liq_ok_response.xml") + import json + with open(ENTRADA, "w") as f: + json.dump(wslsp.solicitud, f, sort_keys=True, indent=4, encoding="utf-8",) + + print("Liquidacion: pto_vta=%s nro_cbte=%s tipo_cbte=%s" % ( + wslsp.solicitud['emisor']['puntoVenta'], + wslsp.solicitud['emisor']['nroComprobante'], + wslsp.solicitud['emisor']['tipoComprobante'], + )) + + if not '--dummy' in sys.argv: + print("Autorizando...") + ret = wslsp.AutorizarLiquidacion() + + if wslsp.Excepcion: + print("EXCEPCION:", wslsp.Excepcion, file=sys.stderr) + if DEBUG: + print(wslsp.Traceback, file=sys.stderr) + print("Errores:", wslsp.Errores) + print("CAE", wslsp.CAE) + print("NroCodigoBarras", wslsp.NroCodigoBarras) + print("FechaProcesoAFIP", wslsp.FechaProcesoAFIP) + print("FechaComprobante", wslsp.FechaComprobante) + print("NroComprobante", wslsp.NroComprobante) + print("ImporteBruto", wslsp.ImporteBruto) + print("ImporteTotalNeto", wslsp.ImporteTotalNeto) + print("ImporteIVA Sobre Bruto", wslsp.ImporteIVASobreBruto) + print("ImporteIVA Sobre Gastos", wslsp.ImporteIVASobreGastos) + print("ImporteTotalNeto", wslsp.ImporteTotalNeto) + + pdf = wslsp.GetParametro("pdf") + if pdf: + open(PDF, "wb").write(pdf) + + if '--testing' in sys.argv: + assert wslsp.CAE == "97083467167835" + + if DEBUG: + pprint.pprint(wslsp.params_out) + + if '--ajustar' in sys.argv: + if '--prueba' in sys.argv: + # ejemplo documentación AFIP: + if '--testing' in sys.argv: + wslsp.LoadTestXML("tests/xml/wslsp_ajuste_test.xml") + wslsp.CrearAjuste(tipo_ajuste='C', fecha_cbte='2017-01-06', + datos_adicionales='Ajuste sobre liquidacion de compra directa' + ) + wslsp.AgregarEmisor(tipo_cbte=186, pto_vta=3000, nro_cbte=1) + wslsp.AgregarComprobanteAAjustar(tipo_cbte=186, pto_vta=2000, nro_cbte=4) + wslsp.AgregarItemDetalleAjuste(nro_item_ajustar=1) + wslsp.AgregarCompraAsociada(tipo_cbte=185, pto_vta=3000, + nro_cbte=33, cant_asoc=2, + nro_item=1) + # Validación de AFIP 3002: + # No se pueden realizar ajustes fisicos y monetario en un mismo comprobante. + wslsp.AgregarAjusteFisico( + cantidad=1, + cantidad_cabezas=None, + cantidad_kg_vivo=None, + ) + wslsp.AgregarAjusteMonetario( + precio_unitario=15.995, + precio_recupero=None, + ) + wslsp.AgregarAjusteFinanciero() + wslsp.AgregarGasto(cod_gasto=16, base_imponible=230520.60, + alicuota=3, alicuota_iva=10.5) + wslsp.AgregarTributo(cod_tributo=5, base_imponible=230520.60, + alicuota=2.5) + wslsp.AgregarTributo(cod_tributo=3, importe=397) + import json + with open(ENTRADA, "w") as f: + json.dump(wslsp.solicitud, f, sort_keys=True, indent=4, encoding="utf-8",) + else: + # cargar un archivo de texto: + with open(ENTRADA, "r") as f: + wslsp.solicitud = json.load(f, encoding="utf-8") + + wslsp.AjustarLiquidacion() + print("CAE:", wslsp.CAE) + print("Tipo Ajuste:", wslsp.GetParametro("tipo_ajuste")) + print("Modo Ajuste:", wslsp.GetParametro("modo_ajuste")) + print("Errores:", wslsp.Errores) + if '--testing' in sys.argv: + assert wslsp.GetParametro("cae") == "97029023118043" + assert wslsp.GetParametro("cbte_ajuste", "tipo_cbte") == '186' + assert wslsp.GetParametro("cbte_ajuste", "pto_vta") == '2000' + assert wslsp.GetParametro("cbte_ajuste", "nro_cbte") == '3' + + pdf = wslsp.GetParametro("pdf") + if pdf: + open(PDF, "wb").write(pdf) + + if '--consultar' in sys.argv: + tipo_cbte = 180 + pto_vta = 3000 + nro_cbte = 1 + cuit = None + try: + tipo_cbte = sys.argv[sys.argv.index("--consultar") + 1] + pto_vta = sys.argv[sys.argv.index("--consultar") + 2] + nro_cbte = sys.argv[sys.argv.index("--consultar") + 3] + cuit = sys.argv[sys.argv.index("--consultar") + 4] + except IndexError: + pass + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo, cargo prueba: + wslsp.LoadTestXML("tests/xml/wslsp_cons_test.xml") + print("Consultando: tipo_cbte=%s pto_vta=%s nro_cbte=%s" % (tipo_cbte, pto_vta, nro_cbte)) + ret = wslsp.ConsultarLiquidacion(tipo_cbte, pto_vta, nro_cbte, + cuit_comprador=cuit) + print("CAE", wslsp.CAE) + print("Errores:", wslsp.Errores) + + if DEBUG: + pprint.pprint(wslsp.params_out) + + if '--mostrar' in sys.argv and pdf: + wslsp.MostrarPDF(archivo=pdf, + imprimir='--imprimir' in sys.argv) + + if '--ult' in sys.argv: + tipo_cbte = 27 + pto_vta = 1 + try: + tipo_cbte = sys.argv[sys.argv.index("--ult") + 1] + pto_vta = sys.argv[sys.argv.index("--ult") + 2] + except IndexError: + pass + + print("Consultando ultimo nro_cbte para pto_vta=%s" % pto_vta, end=' ') + ret = wslsp.ConsultarUltimoComprobante(tipo_cbte, pto_vta) + if wslsp.Excepcion: + print("EXCEPCION:", wslsp.Excepcion, file=sys.stderr) + if DEBUG: + print(wslsp.Traceback, file=sys.stderr) + print("Ultimo Nro de Comprobante", wslsp.NroComprobante) + print("Errores:", wslsp.Errores) + sys.exit(0) + + if "--guardar" in sys.argv: + # grabar un archivo de texto (intercambio) con el resultado: + liq = wslsp.params_out.copy() + if "pdf" in liq: + del liq["pdf"] # eliminador binario + with open(SALIDA, "w") as f: + json.dump(liq, f, default=str, + indent=2, sort_keys=True, encoding="utf-8") + + # Recuperar parámetros: + + if '--provincias' in sys.argv: + ret = wslsp.ConsultarProvincias() + print("\n".join(ret)) + + if '--localidades' in sys.argv: + try: + cod_provincia = sys.argv[sys.argv.index("--localidades") + 1] + except BaseException: + cod_provincia = input("Codigo Provincia:") + ret = wslsp.ConsultarLocalidades(cod_provincia) + print("\n".join(ret)) + + if '--operaciones' in sys.argv: + ret = wslsp.ConsultarOperaciones() + print("\n".join(ret)) + + if '--tributos' in sys.argv: + ret = wslsp.ConsultarTributos() + print("\n".join(ret)) + + if '--gastos' in sys.argv: + ret = wslsp.ConsultarGastos() + print("\n".join(ret)) + + if '--tipos_cbte' in sys.argv: + ret = wslsp.ConsultarTiposComprobante() + print("\n".join(ret)) + + if '--tipos_liq' in sys.argv: + ret = wslsp.ConsultarTiposLiquidacion() + print("\n".join(ret)) + + if '--caracteres' in sys.argv: + ret = wslsp.ConsultarCaracteres() + print("\n".join(ret)) + + if '--categorias' in sys.argv: + ret = wslsp.ConsultarCategorias() + print("\n".join(ret)) + + if '--motivos' in sys.argv: + ret = wslsp.ConsultarMotivos() + print("\n".join(ret)) + + if '--razas' in sys.argv: + ret = wslsp.ConsultarRazas() + print("\n".join(ret)) + + if '--cortes' in sys.argv: + ret = wslsp.ConsultarCortes() + print("\n".join(ret)) + + if '--puntosventa' in sys.argv: + ret = wslsp.ConsultarPuntosVentas() + print("\n".join(ret)) + + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore"), file=sys.stderr) + sys.exit(3) + except Exception as e: + try: + print(traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0], file=sys.stderr) + except BaseException: + print("Excepción no disponible:", type(e), file=sys.stderr) + if DEBUG: + raise + sys.exit(5) + finally: + if XML: + open("wslsp_request.xml", "w").write(wslsp.client.xml_request) + open("wslsp_response.xml", "w").write(wslsp.client.xml_response) diff --git a/app/pyafipws/wsltv.py b/app/pyafipws/wsltv.py new file mode 100644 index 0000000000000000000000000000000000000000..f67d322bb2c8e0f983810e8cb7503ef925e457be --- /dev/null +++ b/app/pyafipws/wsltv.py @@ -0,0 +1,1069 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + + +import shelve +import sys +import datetime +import decimal +import os +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, BaseWS, inicializar_y_capturar_excepciones, get_install_dir +from . import utils +from fpdf import Template +from pysimplesoap.client import SoapFault +import pprint +import traceback +"""Módulo para obtener código de autorización electrónica (CAE) para +Liquidación de Tabaco Verde del web service WSLTV de AFIP +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2016 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.06d" + +LICENCIA = """ +wsltv.py: Interfaz para generar Código de Autorización Electrónica (CAE) para +Liquidación de Tabaco Verde (LtvService) +Copyright (C) 2016 Mariano Reingart reingart@gmail.com +http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionPrimariaGranos + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo respetando la licencia GPLv3. + +Para información adicional sobre garantía, soporte técnico comercial +e incorporación/distribución en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + +AYUDA = """ +Opciones: + --ayuda: este mensaje + + --debug: modo depuración (detalla y confirma las operaciones) + --formato: muestra el formato de los archivos de entrada/salida + --prueba: genera y autoriza una liquidación de prueba (no usar en producción!) + --xml: almacena los requerimientos y respuestas XML (depuración) + --dbf: utilizar tablas DBF (xBase) para los archivos de intercambio + --json: utilizar formato json para el archivo de intercambio + --dummy: consulta estado de servidores + + --autorizar: Autorizar Liquidación de Tabaco Verde (generarLiquidacion) + --ajustar: Ajustar (ajustarLiquidacion/generarAjusteFisico) + --ult: Consulta el último número de orden registrado en AFIP + (consultarUltimoComprobanteXPuntoVenta) + --consultar: Consulta una liquidación registrada en AFIP + (consultarLiquidacionXNroComprobante / consultarLiquidacionXCAE) + + --pdf: descarga la liquidación en formato PDF + --mostrar: muestra el documento PDF generado (usar con --pdf) + --imprimir: imprime el documento PDF generado (usar con --mostrar y --pdf) + + --provincias: obtiene el listado de provincias (código/descripción) + --condicionesventa: obtiene el listado de las condiciones de venta + --tributos: obtiene el listado de los tributos + --retenciones: obtiene el listado de las retenciones de tabaco + --variedades: obtiene el listado de las variedades y especies de tabaco + --depositos: obtiene el listado de los depositos de acopio (para el contrib.) + --puntosventa: obtiene el listado de puntos de venta habilitados + +Ver wsltv.ini para parámetros de configuración (URL, certificados, etc.)" +""" + + +# importo funciones compartidas: + + +WSDL = "https://fwshomo.afip.gov.ar/wsltv/LtvService?wsdl" +#WSDL = "https://serviciosjava.afip.gob.ar/wsltv/LtvService?wsdl" + +DEBUG = False +XML = False +CONFIG_FILE = "wsltv.ini" +TIMEOUT = 30 +HOMO = False + + +class WSLTV(BaseWS): + "Interfaz para el WebService de Liquidación de Tabaco Verde" + _public_methods_ = ['Conectar', 'Dummy', 'SetTicketAcceso', 'DebugLog', + 'AutorizarLiquidacion', + 'CrearLiquidacion', + 'AgregarCondicionVenta', 'AgregarReceptor', + 'AgregarRomaneo', 'AgregarFardo', 'AgregarPrecioClase', + 'AgregarRetencion', 'AgregarTributo', + 'AgregarFlete', 'AgregarBonificacion', + 'ConsultarLiquidacion', 'ConsultarUltimoComprobante', + 'CrearAjuste', 'AgregarComprobanteAAjustar', + 'AjustarLiquidacion', 'GenerarAjusteFisico', + 'LeerDatosLiquidacion', + 'ConsultarVariedadesClasesTabaco', + 'ConsultarTributos', + 'ConsultarRetencionesTabacaleras', + 'ConsultarDepositosAcopio', + 'ConsultarPuntosVentas', + 'ConsultarProvincias', + 'ConsultarCondicionesVenta', + 'MostrarPDF', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetParametro', 'GetParametro', + ] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'Excepcion', 'ErrCode', 'ErrMsg', 'LanzarExcepciones', 'Errores', + 'XmlRequest', 'XmlResponse', 'Version', 'Traceback', 'InstallDir', + 'CAE', 'NroComprobante', 'FechaLiquidacion', + 'ImporteNeto', 'TotalRetenciones', 'TotalTributos', 'Total', + 'AlicuotaIVA', 'ImporteIVA', 'Subtotal', + ] + _reg_progid_ = "WSLTV" + _reg_clsid_ = "{C6EEAE8A-7560-4538-B29C-76434A8C2DC3}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + LanzarExcepciones = False + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.errores = [] + self.CAE = "" + self.NroComprobante = self.FechaLiquidacion = '' + self.ImporteNeto = "" + self.ImporteIVA = "" + self.AlicuotaIVA = "" + self.TotalRetenciones = "" + self.TotalTributos = "" + self.Subtotal = self.Total = "" + self.datos = {} + self.reintentos = 0 + + @inicializar_y_capturar_excepciones + def Conectar(self, cache=None, url="", proxy="", wrapper="", cacert=None, timeout=30): + "Establecer la conexión a los servidores de la AFIP" + # llamo al constructor heredado: + ok = BaseWS.Conectar(self, cache, url, proxy, wrapper, cacert, timeout) + if ok: + # corrijo ubicación del servidor (puerto htttp 80 en el WSDL) + location = self.client.services['LtvService']['ports']['LtvEndPoint']['location'] + if location.startswith("http://"): + print("Corrigiendo WSDL ...", location, end=' ') + location = location.replace("http://", "https://").replace(":80", ":443") + self.client.services['LtvService']['ports']['LtvEndPoint']['location'] = location + print(location) + return ok + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + errores = [] + if 'errores' in ret: + errores.extend(ret['errores']) + if errores: + self.Errores = ["%(codigo)s: %(descripcion)s" % err['error'][0] + for err in errores] + self.errores = [ + {'codigo': err['error'][0]['codigo'], + 'descripcion': err['error'][0]['descripcion'].replace("\n", "") + .replace("\r", "")} + for err in errores] + self.ErrCode = ' '.join(self.Errores) + self.ErrMsg = '\n'.join(self.Errores) + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + results = self.client.dummy()['respuesta'] + self.AppServerStatus = str(results['appserver']) + self.DbServerStatus = str(results['dbserver']) + self.AuthServerStatus = str(results['authserver']) + return True + + @inicializar_y_capturar_excepciones + def CrearLiquidacion(self, tipo_cbte, pto_vta, nro_cbte, fecha, + cod_deposito_acopio, tipo_compra, + variedad_tabaco, cod_provincia_origen_tabaco, + puerta=None, nro_tarjeta=None, horas=None, control=None, + nro_interno=None, iibb_emisor=None, fecha_inicio_actividad=None, + **kwargs): + "Inicializa internamente los datos de una liquidación para autorizar" + # creo el diccionario con los campos generales de la liquidación: + liq = dict(tipoComprobante=tipo_cbte, + nroComprobante=nro_cbte, + puntoVenta=pto_vta, + iibbEmisor=iibb_emisor, + codDepositoAcopio=cod_deposito_acopio, + fechaLiquidacion=fecha, + tipoCompra=tipo_compra, + condicionVenta=[], + variedadTabaco=variedad_tabaco, + codProvinciaOrigenTabaco=cod_provincia_origen_tabaco, + puerta=puerta, + nroTarjeta=nro_tarjeta, + horas=horas, + control=control, + nroInterno=nro_interno, + fechaInicioActividad=fecha_inicio_actividad, + ) + self.solicitud = dict(liquidacion=liq, + receptor={}, + romaneo=[], + precioClase=[], + retencion=[], + tributo=[], + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarCondicionVenta(self, codigo, descripcion, **kwargs): + "Agrego una o más condicion de venta a la liq." + cond = {'codigo': codigo, 'descripcion': descripcion} + self.solicitud['liquidacion']['condicionVenta'].append(cond) + return True + + @inicializar_y_capturar_excepciones + def AgregarReceptor(self, cuit, iibb, nro_socio, nro_fet, **kwargs): + "Agrego un receptor a la liq." + rcpt = dict(cuit=cuit, iibb=iibb, nroSocio=nro_socio, nroFET=nro_fet) + self.solicitud['receptor'] = rcpt + return True + + @inicializar_y_capturar_excepciones + def AgregarRomaneo(self, nro_romaneo, fecha_romaneo, **kwargs): + "Agrego uno o más romaneos a la liq." + romaneo = dict(nroRomaneo=nro_romaneo, fechaRomaneo=fecha_romaneo, + fardo=[]) + self.solicitud['romaneo'].append(romaneo) + return True + + @inicializar_y_capturar_excepciones + def AgregarFardo(self, cod_trazabilidad, clase_tabaco, peso, **kwargs): + "Agrego un fardo al último romaneo agregado a la liq." + fardo = dict(codTrazabilidad=cod_trazabilidad, claseTabaco=clase_tabaco, peso=peso) + self.solicitud['romaneo'][-1]['fardo'].append(fardo) + return True + + @inicializar_y_capturar_excepciones + def AgregarPrecioClase(self, clase_tabaco, precio, total_kilos=None, total_fardos=None, **kwargs): + "Agrego un PrecioClase a la liq." + precioclase = dict(claseTabaco=clase_tabaco, precio=precio, + totalKilos=total_kilos, totalFardos=total_fardos) + self.solicitud['precioClase'].append(precioclase) + return True + + @inicializar_y_capturar_excepciones + def AgregarRetencion(self, cod_retencion, descripcion, importe, **kwargs): + "Agrega la información referente a las retenciones de la liquidación" + ret = dict(codRetencion=cod_retencion, descripcion=descripcion, importe=importe) + self.solicitud['retencion'].append(ret) + return True + + @inicializar_y_capturar_excepciones + def AgregarTributo(self, codigo_tributo, descripcion, base_imponible, alicuota, importe): + "Agrega la información referente a las retenciones de la liquidación" + trib = dict(codigoTributo=codigo_tributo, descripcion=descripcion, baseImponible=base_imponible, alicuota=alicuota, importe=importe) + self.solicitud['tributo'].append(trib) + return True + + @inicializar_y_capturar_excepciones + def AgregarFlete(self, descripcion, importe): + "Agrega la información referente al flete de la liquidación (opcional)" + flete = dict(descripcion=descripcion, importe=importe) + self.solicitud['flete'] = flete + return True + + @inicializar_y_capturar_excepciones + def AgregarBonificacion(self, porcentaje, importe): + "Agrega la información referente a las bonificaciones de la liquidación (opcional)" + bonif = dict(porcentaje=porcentaje, importe=importe) + self.solicitud['bonificacion'] = bonif + return True + + @inicializar_y_capturar_excepciones + def AutorizarLiquidacion(self): + "Autorizar Liquidación Electrónica de Tabaco Verde" + # limpio los elementos que no correspondan por estar vacios: + for campo in ["tributo", "retencion"]: + if not self.solicitud[campo]: + del self.solicitud[campo] + # llamo al webservice: + ret = self.client.generarLiquidacion( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud=self.solicitud, + ) + # analizo la respusta + ret = ret['respuesta'] + self.__analizar_errores(ret) + liqs = ret.get('liquidacion', []) + liq = liqs[0] if liqs else None + self.AnalizarLiquidacion(liq) + return True + + def AnalizarLiquidacion(self, liq): + "Método interno para analizar la respuesta de AFIP" + # proceso los datos básicos de la liquidación (devuelto por consultar): + if liq: + cab = liq['cabecera'] + self.CAE = str(cab['cae']) + self.FechaLiquidacion = cab['fechaLiquidacion'] + self.NroComprobante = cab['nroComprobante'] + tot = liq['totalesOperacion'] + self.AlicuotaIVA = tot['alicuotaIVA'] + self.ImporteNeto = tot['importeNeto'] + self.ImporteIVA = tot['importeIVA'] + self.Subtotal = tot['subtotal'] + self.TotalRetenciones = tot['totalRetenciones'] + self.TotalTributos = tot['totalTributos'] + self.Total = tot['total'] + + # parámetros de salida: + self.params_out = dict( + tipo_cbte=liq['cabecera']['tipoComprobante'], + pto_vta=liq['cabecera']['puntoVenta'], + nro_cbte=liq['cabecera']['nroComprobante'], + fecha=liq['cabecera']['fechaLiquidacion'], + cod_deposito_acopio=liq['cabecera']['codDepositoAcopio'], + cae=str(liq['cabecera']['cae']), + domicilio_punto_venta=liq['cabecera']['domicilioPuntoVenta'], + domicilio_deposito_acopio=liq['cabecera']['domicilioDepositoAcopio'], + emisor=dict( + cuit=liq['emisor']['cuit'], + razon_social=liq['emisor']['razonSocial'], + situacion_iva=liq['emisor']['situacionIVA'], + domicilio=liq['emisor']['domicilio'], + fecha_inicio_actividad=liq['emisor']['fechaInicioActividad'], + ), + receptor=dict( + cuit=liq['receptor']['cuit'], + razon_social=liq['receptor']['razonSocial'], + nro_fet=liq['receptor'].get('nroFET'), + nro_socio=liq['receptor'].get('nroSocio'), + situacion_iva=liq['receptor']['situacionIVA'], + domicilio=liq['receptor']['domicilio'], + iibb=liq['receptor'].get('iibb'), + ), + control=liq['datosOperacion'].get('control'), + nro_interno=liq['datosOperacion'].get('nroInterno'), + condicion_venta=liq['datosOperacion'].get('condicionVenta'), + variedad_tabaco=liq['datosOperacion']['variedadTabaco'], + puerta=liq['datosOperacion'].get('puerta'), + nro_tarjeta=liq['datosOperacion'].get('nroTarjeta'), + horas=liq['datosOperacion'].get('horas'), + cod_provincia_origen_tabaco=liq['datosOperacion'].get('codProvinciaOrigenTabaco'), + tipo_compra=liq['datosOperacion'].get('tipoCompra'), + peso_total_fardos_kg=liq['detalleOperacion']['pesoTotalFardosKg'], + cantidad_total_fardos=liq['detalleOperacion']['cantidadTotalFardos'], + romaneos=[], + alicuota_iva=liq['totalesOperacion']['alicuotaIVA'], + importe_iva=liq['totalesOperacion']['importeIVA'], + importe_neto=liq['totalesOperacion']['importeNeto'], + subtotal=liq['totalesOperacion']['subtotal'], + total_retenciones=liq['totalesOperacion']['totalRetenciones'], + total_tributos=liq['totalesOperacion']['totalTributos'], + total=liq['totalesOperacion']['total'], + retenciones=[], + tributos=[], + cae_ajustado=liq.get("caeAjustado"), + pdf=liq.get('pdf'), + ) + for romaneo in liq['detalleOperacion'].get('romaneo', []): + self.params_out['romaneos'].append(dict( + fecha_romaneo=romaneo['fechaRomaneo'], + nro_romaneo=romaneo['nroRomaneo'], + detalle_clase=[dict( + cantidad_fardos=det['cantidadFardos'], + cod_clase=det['codClase'], + importe=det['importe'], + peso_fardos_kg=det['pesoFardosKg'], + precio_x_kg_fardo=det['precioXKgFardo'], + ) for det in romaneo['detalleClase']], + )) + for ret in liq.get('retencion', []): + self.params_out['retenciones'].append(dict( + retencion_codigo=ret['codigo'], + retencion_importe=ret['importe'], + )) + for trib in liq.get('tributo', []): + self.params_out['tributos'].append(dict( + tributo_descripcion=trib.get('descripcion', ""), + tributo_base_imponible=trib['baseImponible'], + tributo_alicuota=trib['alicuota'], + tributo_codigo=trib['codigo'], + tributo_importe=trib['importe'], + )) + if DEBUG: + import pprint + pprint.pprint(self.params_out) + self.params_out['errores'] = self.errores + + @inicializar_y_capturar_excepciones + def CrearAjuste(self, tipo_cbte, pto_vta, nro_cbte, fecha, + cod_deposito_acopio=None, tipo_ajuste=None, cuit_receptor=None, + iibb_emisor=None, iibb_receptor=None, fecha_inicio_actividad=None, + **kwargs): + "Inicializa internamente los datos de una liquidación para ajustar" + # codDepositoAcopio, tipoAjuste, cuitReceptor obligatorio ajustar liq. + # WSLTVv1.3 fechaLiquidacion/fechaInicioActividad para ajuste físico + # creo el diccionario con los campos generales de la liquidación: + liq = dict(tipoComprobante=tipo_cbte, + nroComprobante=nro_cbte, + puntoVenta=pto_vta, + fechaAjusteLiquidacion=fecha, + fechaLiquidacion=fecha, + fechaInicioActividad=fecha_inicio_actividad, + codDepositoAcopio=cod_deposito_acopio, + tipoAjuste=tipo_ajuste, + cuitReceptor=cuit_receptor, + iibbReceptor=iibb_receptor, + iibbEmisor=iibb_emisor, + comprobanteAAjustar=[], + ) + self.solicitud = dict(liquidacion=liq, + precioClase=[], + retencion=[], + tributo=[], + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarComprobanteAAjustar(self, tipo_cbte, pto_vta, nro_cbte): + "Agrega comprobante a ajustar" + cbte = dict(tipoComprobante=tipo_cbte, puntoVenta=pto_vta, nroComprobante=nro_cbte) + self.solicitud['liquidacion']["comprobanteAAjustar"].append(cbte) + return True + + @inicializar_y_capturar_excepciones + def AjustarLiquidacion(self): + "Ajustar Liquidación de Tabaco Verde" + + # renombrar la clave principal de la estructura + if 'liquidacion' in self.solicitud: + liq = self.solicitud.pop('liquidacion') + self.solicitud["liquidacionAjuste"] = liq + + # llamar al webservice: + ret = self.client.ajustarLiquidacion( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud=self.solicitud, + ) + # analizo la respusta + ret = ret['respuesta'] + self.__analizar_errores(ret) + liqs = ret.get('liquidacion', []) + liq = liqs[0] if liqs else None + self.AnalizarLiquidacion(liq) + return True + + @inicializar_y_capturar_excepciones + def GenerarAjusteFisico(self): + "Generar Ajuste Físico de Liquidación de Tabaco Verde (WSLTVv1.3)" + + # renombrar la clave principal de la estructura + if 'liquidacion' in self.solicitud: + liq = self.solicitud.pop('liquidacion') + self.solicitud = liq + + # llamar al webservice: + ret = self.client.generarAjusteFisico( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud=self.solicitud, + ) + # analizar el resultado: + ret = ret['respuesta'] + self.__analizar_errores(ret) + liqs = ret.get('liquidacion', []) + liq = liqs[0] if liqs else None + self.AnalizarLiquidacion(liq) + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacion(self, tipo_cbte=None, pto_vta=None, nro_cbte=None, + cae=None, pdf="liq.pdf"): + "Consulta una liquidación por No de Comprobante o CAE" + if cae: + ret = self.client.consultarLiquidacionXCAE( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'cae': cae, + 'pdf': pdf and True or False, + }, + ) + else: + ret = self.client.consultarLiquidacionXNroComprobante( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'puntoVenta': pto_vta, + 'nroComprobante': nro_cbte, + 'tipoComprobante': tipo_cbte, + 'pdf': pdf and True or False, + }, + ) + ret = ret['respuesta'] + self.__analizar_errores(ret) + if 'liquidacion' in ret: + liqs = ret.get('liquidacion', []) + liq = liqs[0] if liqs else None + self.AnalizarLiquidacion(liq) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in liq: + open(pdf, "wb").write(liq['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarUltimoComprobante(self, tipo_cbte=151, pto_vta=1): + "Consulta el último No de Comprobante registrado" + ret = self.client.consultarUltimoComprobanteXPuntoVenta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'puntoVenta': pto_vta, + 'tipoComprobante': tipo_cbte}, + ) + ret = ret['respuesta'] + self.__analizar_errores(ret) + self.NroComprobante = ret['nroComprobante'] + return True + + def ConsultarProvincias(self, sep="||"): + "Consulta las provincias habilitadas" + ret = self.client.consultarProvincias( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('provincia', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarCondicionesVenta(self, sep="||"): + "Retorna un listado de códigos y descripciones de las condiciones de ventas" + ret = self.client.consultarCondicionesVenta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('condicionVenta', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarTributos(self, sep="||"): + "Retorna un listado de tributos con código, descripción y signo." + ret = self.client.consultarTributos( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('tributo', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarVariedadesClasesTabaco(self, sep="||"): + "Retorna un listado de variedades y clases de tabaco" + # El listado es una estructura anidada (varias clases por variedad) + #import dbg; dbg.set_trace() + ret = self.client.consultarVariedadesClasesTabaco( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + self.XmlResponse = self.client.xml_response + array = ret.get('variedad', []) + if sep is None: + # sin separador, devuelve un diccionario con clave cod_variadedad + # y valor: {"descripcion": ds_variedad, "clases": lista_clases} + # siendo lista_clases = [{'codigo': ..., 'descripcion': ...}] + return dict([(it['codigo'], {'descripcion': it['descripcion'], + 'clases': it['clase']}) + for it in array]) + else: + # con separador, devuelve una lista de strings: + # || cod.variedad || desc.variedad || desc.clase || cod.clase || + ret = [] + for it in array: + for clase in it['clase']: + ret.append( + ("%s %%s %s %%s %s %%s %s %%s %s" % + (sep, sep, sep, sep, sep)) % + (it['codigo'], it['descripcion'], + clase['descripcion'], clase['codigo']) + ) + return ret + + def ConsultarRetencionesTabacaleras(self, sep="||"): + "Retorna un listado de retenciones tabacaleras con código y descripción" + ret = self.client.consultarRetencionesTabacaleras( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('retencion', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarDepositosAcopio(self, sep="||"): + "Retorna los depósitos de acopio pertenencientes al contribuyente" + ret = self.client.consultarDepositosAcopio( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('acopio', []) + if sep is None: + return array + else: + return [("%s %%s %s %%s %s %%s %s %%s %s" % (sep, sep, sep, sep, sep)) % + (it['codigo'], it['direccion'], it['localidad'], it['codigoPostal']) + for it in array] + + def ConsultarPuntosVentas(self, sep="||"): + "Retorna los puntos de ventas autorizados para la utilizacion de WS" + ret = self.client.consultarPuntosVentas( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('puntoVenta', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def MostrarPDF(self, archivo, imprimir=False): + try: + if sys.platform == "linux2": + import subprocess + subprocess.run(["evince", archivo], check=False) + else: + operation = imprimir and "print" or "" + os.startfile(archivo, operation) + return True + except Exception as e: + self.Excepcion = str(e) + return False + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = WSLTV.InstallDir = get_install_dir() + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + if '--formato' in sys.argv: + print("Formato:") + for msg, formato in []: + comienzo = 1 + print("=== %s ===" % msg) + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = len(fmt) > 3 and fmt[3] or (tipo == 'I' and '2' or '') + print(" * Campo: %-20s Posición: %3d Longitud: %4d Tipo: %s Decimales: %s" % ( + clave, comienzo, longitud, tipo, dec)) + comienzo += longitud + sys.exit(0) + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSLTV) + sys.exit(0) + + import csv + from configparser import SafeConfigParser + + from .wsaa import WSAA + + try: + + if "--version" in sys.argv: + print("Versión: ", __version__) + + if len(sys.argv) > 1 and sys.argv[1].endswith(".ini"): + CONFIG_FILE = sys.argv[1] + print("Usando configuracion:", CONFIG_FILE) + + config = SafeConfigParser() + config.read(CONFIG_FILE) + CERT = config.get('WSAA', 'CERT') + PRIVATEKEY = config.get('WSAA', 'PRIVATEKEY') + CUIT = config.get('WSLTV', 'CUIT') + ENTRADA = config.get('WSLTV', 'ENTRADA') + SALIDA = config.get('WSLTV', 'SALIDA') + PDF = config.has_option('WSLTV', 'PDF') and config.get('WSLTV', 'PDF') or "liq.pdf" + + if config.has_option('WSAA', 'URL') and not HOMO: + WSAA_URL = config.get('WSAA', 'URL') + else: + WSAA_URL = None # wsaa.WSAAURL + if config.has_option('WSLTV', 'URL') and not HOMO: + WSLTV_URL = config.get('WSLTV', 'URL') + else: + WSLTV_URL = WSDL + + PROXY = config.has_option('WSAA', 'PROXY') and config.get('WSAA', 'PROXY') or None + CACERT = config.has_option('WSAA', 'CACERT') and config.get('WSAA', 'CACERT') or None + WRAPPER = config.has_option('WSAA', 'WRAPPER') and config.get('WSAA', 'WRAPPER') or None + + if config.has_option('WSLTV', 'TIMEOUT'): + TIMEOUT = int(config.get('WSLTV', 'TIMEOUT')) + + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + if DEBUG: + print("conf_dbf", conf_dbf) + else: + conf_dbf = {} + + DEBUG = '--debug' in sys.argv + XML = '--xml' in sys.argv + + if DEBUG: + print("Usando Configuración:") + print("WSAA_URL:", WSAA_URL) + print("WSLTV_URL:", WSLTV_URL) + print("CACERT", CACERT) + print("WRAPPER", WRAPPER) + print("timeout:", TIMEOUT) + # obteniendo el TA + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wsltv", CERT, PRIVATEKEY, wsdl=WSAA_URL, + proxy=PROXY, wrapper=WRAPPER, cacert=CACERT) + if not ta: + sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + + # cliente soap del web service + wsltv = WSLTV() + wsltv.LanzarExcepciones = True + wsltv.Conectar(url=WSLTV_URL, proxy=PROXY, wrapper=WRAPPER, cacert=CACERT, timeout=TIMEOUT) + wsltv.SetTicketAcceso(ta) + wsltv.Cuit = CUIT + + if '--dummy' in sys.argv: + ret = wsltv.Dummy() + print("AppServerStatus", wsltv.AppServerStatus) + print("DbServerStatus", wsltv.DbServerStatus) + print("AuthServerStatus", wsltv.AuthServerStatus) + # sys.exit(0) + + if '--json' in sys.argv and os.path.exists("wsltv.json"): + # cargar un archivo de texto: + with open(ENTRADA, "r") as f: + wsltv.solicitud = json.load(f, encoding="utf-8") + + if '--autorizar' in sys.argv: + + if '--prueba' in sys.argv: + + # genero una liquidación de ejemplo: + + tipo_cbte = 150 + pto_vta = 6 + + if not '--prueba' in sys.argv: + # consulto el último número de orden emitido: + ok = wsltv.ConsultarUltimoComprobante(tipo_cbte, pto_vta) + if ok: + nro_cbte = wsltv.NroComprobante + 1 + else: + nro_cbte = 1 + + # datos de la cabecera: + fecha = '2016-04-18' + cod_deposito_acopio = 1000 + tipo_compra = 'CPS' + variedad_tabaco = 'BR' + cod_provincia_origen_tabaco = 1 + puerta = 22 + nro_tarjeta = 6569866 + horas = 12 + control = "FFAA" + nro_interno = "77888" + fecha_inicio_actividad = "2016-04-01" + + # cargo la liquidación: + wsltv.CrearLiquidacion(tipo_cbte, pto_vta, nro_cbte, fecha, + cod_deposito_acopio, tipo_compra, + variedad_tabaco, cod_provincia_origen_tabaco, + puerta, nro_tarjeta, horas, control, + nro_interno, iibb_emisor=None, + fecha_inicio_actividad=fecha_inicio_actividad) + + wsltv.AgregarCondicionVenta(codigo=99, descripcion="otra") + + # datos del receptor: + cuit = 20111111112 + iibb = 123456 + nro_socio = 11223 + nro_fet = 22 + wsltv.AgregarReceptor(cuit, iibb, nro_socio, nro_fet) + + # datos romaneo: + nro_romaneo = 321 + fecha_romaneo = "2015-12-10" + wsltv.AgregarRomaneo(nro_romaneo, fecha_romaneo) + # fardo: + cod_trazabilidad = 356 + clase_tabaco = 4 + peso = 900 + wsltv.AgregarFardo(cod_trazabilidad, clase_tabaco, peso) + + # precio clase: + precio = 190 + wsltv.AgregarPrecioClase(clase_tabaco, precio) + + # retencion: + descripcion = "otra retencion" + cod_retencion = 15 + importe = 12 + wsltv.AgregarRetencion(cod_retencion, descripcion, importe) + + # tributo: + codigo_tributo = 99 + descripcion = "Ganancias" + base_imponible = 15000 + alicuota = 8 + importe = 1200 + wsltv.AgregarTributo(codigo_tributo, descripcion, base_imponible, alicuota, importe) + + # flete: + descripcion = "transporte" + importe = 1000.00 + wsltv.AgregarFlete(descripcion, importe) + + # bonificacion: + porcentaje = 10.0 + importe = 100.00 + wsltv.AgregarBonificacion(porcentaje, importe) + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo, cargo respuesta: + wsltv.LoadTestXML("tests/xml/wsltv_aut_test_pdf.xml") + + print("Liquidacion: pto_vta=%s nro_cbte=%s tipo_cbte=%s" % ( + wsltv.solicitud['liquidacion']['puntoVenta'], + wsltv.solicitud['liquidacion']['nroComprobante'], + wsltv.solicitud['liquidacion']['tipoComprobante'], + )) + + if not '--dummy' in sys.argv: + print("Autorizando...") + ret = wsltv.AutorizarLiquidacion() + + if wsltv.Excepcion: + print("EXCEPCION:", wsltv.Excepcion, file=sys.stderr) + if DEBUG: + print(wsltv.Traceback, file=sys.stderr) + print("Errores:", wsltv.Errores) + print("CAE", wsltv.CAE) + print("FechaLiquidacion", wsltv.FechaLiquidacion) + print("NroComprobante", wsltv.NroComprobante) + print("ImporteNeto", wsltv.ImporteNeto) + print("AlicuotaIVA", wsltv.AlicuotaIVA) + print("ImporteIVA", wsltv.ImporteIVA) + print("Subtotal", wsltv.Subtotal) + print("TotalRetenciones", wsltv.TotalRetenciones) + print("TotalTributos", wsltv.TotalTributos) + print("Total", wsltv.Total) + + pdf = wsltv.GetParametro("pdf") + if pdf: + open(PDF, "wb").write(pdf) + + if '--testing' in sys.argv: + assert wsltv.CAE == "85523002502850" + assert wsltv.Total == 205685.46 + assert wsltv.GetParametro("fecha") == "2016-01-01" + assert wsltv.GetParametro("peso_total_fardos_kg") == "900" + assert wsltv.GetParametro("cantidad_total_fardos") == "1" + assert wsltv.GetParametro("emisor", "domicilio") == 'Peru 100' + assert wsltv.GetParametro("emisor", "razon_social") == 'JOCKER' + assert wsltv.GetParametro("receptor", "domicilio") == 'Calle 1' + assert wsltv.GetParametro("receptor", "razon_social") == 'CUIT PF de Prueba gen\xe9rica' + assert wsltv.GetParametro("romaneos", 0, "detalle_clase", 0, "cantidad_fardos") == "1" + assert wsltv.GetParametro("romaneos", 0, "detalle_clase", 0, "cod_clase") == "4" + assert wsltv.GetParametro("romaneos", 0, "detalle_clase", 0, "importe") == "171000.0" + assert wsltv.GetParametro("romaneos", 0, "detalle_clase", 0, "peso_fardos_kg") == "900" + assert wsltv.GetParametro("romaneos", 0, "detalle_clase", 0, "precio_x_kg_fardo") == "190.0" + assert wsltv.GetParametro("romaneos", 0, "nro_romaneo") == "321" + assert wsltv.GetParametro("romaneos", 0, "fecha_romaneo") == "2015-12-10" + + if DEBUG: + pprint.pprint(wsltv.params_out) + + if '--generar-ajuste-fisico' in sys.argv: + if '--prueba' in sys.argv: + # ejemplo documentación AFIP: + if '--testing' in sys.argv: + wsltv.LoadTestXML("tests/xml/wsltv_ajuste_test_pdf.xml") + wsltv.CrearAjuste(tipo_cbte=151, pto_vta=2, nro_cbte=1, + fecha='2016-09-09', + fecha_inicio_actividad="1900-01-01", + ) + wsltv.AgregarComprobanteAAjustar(tipo_cbte=151, pto_vta=3697, nro_cbte=2) + wsltv.GenerarAjusteFisico() + print("CAE Ajustado:", wsltv.GetParametro("cae_ajustado")) + if '--testing' in sys.argv: + assert wsltv.GetParametro("cae_ajustado") == "86029002591067" + + if '--ajustar' in sys.argv: + if '--prueba' in sys.argv: + # ejemplo documentación AFIP: + if '--testing' in sys.argv: + wsltv.LoadTestXML("tests/xml/wsltv_ajuste_test.xml") + wsltv.CrearAjuste(tipo_cbte=151, pto_vta=2958, nro_cbte=13, + fecha='2015-12-31', + cod_deposito_acopio=201, tipo_ajuste="C", + cuit_receptor=222222222, + iibb_receptor=2, + fecha_inicio_actividad="2010-01-01" + ) + wsltv.AgregarComprobanteAAjustar(tipo_cbte=151, pto_vta=4521, nro_cbte=12345678) + wsltv.AgregarPrecioClase(clase_tabaco=111, precio=25, total_kilos=41, total_fardos=1) + wsltv.AgregarRetencion(cod_retencion=11, descripcion=None, importe=20) + wsltv.AgregarTributo(codigo_tributo=99, descripcion="Descripcion otros tributos", base_imponible=2, alicuota=2, importe=10) + wsltv.AjustarLiquidacion() + print("CAE:", wsltv.CAE) + if '--testing' in sys.argv: + assert wsltv.GetParametro("cae") == "86011002510675" + + if '--consultar' in sys.argv: + tipo_cbte = 151 + pto_vta = 1 + nro_cbte = 0 + try: + tipo_cbte = sys.argv[sys.argv.index("--consultar") + 1] + pto_vta = sys.argv[sys.argv.index("--consultar") + 2] + nro_cbte = sys.argv[sys.argv.index("--consultar") + 3] + except IndexError: + pass + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo, cargo prueba: + wsltv.LoadTestXML("tests/xml/wsltv_cons_test.xml") + print("Consultando: tipo_cbte=%s pto_vta=%s nro_cbte=%s" % (tipo_cbte, pto_vta, nro_cbte)) + ret = wsltv.ConsultarLiquidacion(tipo_cbte, pto_vta, nro_cbte) + print("CAE", wsltv.CAE) + print("Errores:", wsltv.Errores) + + if DEBUG: + pprint.pprint(wsltv.params_out) + + if '--mostrar' in sys.argv and pdf: + wsltv.MostrarPDF(archivo=pdf, + imprimir='--imprimir' in sys.argv) + + if '--json' in sys.argv: + import json + with open(SALIDA, "w") as f: + json.dump(wsltv.solicitud, f, sort_keys=True, indent=4, encoding="utf-8",) + + if '--ult' in sys.argv: + tipo_cbte = 151 + pto_vta = 1 + try: + tipo_cbte = sys.argv[sys.argv.index("--ult") + 1] + pto_vta = sys.argv[sys.argv.index("--ult") + 2] + except IndexError: + pass + + print("Consultando ultimo nro_cbte para pto_vta=%s" % pto_vta, end=' ') + ret = wsltv.ConsultarUltimoComprobante(tipo_cbte, pto_vta) + if wsltv.Excepcion: + print("EXCEPCION:", wsltv.Excepcion, file=sys.stderr) + if DEBUG: + print(wsltv.Traceback, file=sys.stderr) + print("Ultimo Nro de Comprobante", wsltv.NroComprobante) + print("Errores:", wsltv.Errores) + sys.exit(0) + + # Recuperar parámetros: + + if '--provincias' in sys.argv: + ret = wsltv.ConsultarProvincias() + print("\n".join(ret)) + + if '--condicionesventa' in sys.argv: + ret = wsltv.ConsultarCondicionesVenta() + print("\n".join(ret)) + + if '--tributos' in sys.argv: + ret = wsltv.ConsultarTributos() + print("\n".join(ret)) + + if '--retenciones' in sys.argv: + ret = wsltv.ConsultarRetencionesTabacaleras() + print("\n".join(ret)) + + if '--variedades' in sys.argv: + ret = wsltv.ConsultarVariedadesClasesTabaco() + print("\n".join(ret)) + + if '--depositos' in sys.argv: + ret = wsltv.ConsultarDepositosAcopio() + print("\n".join(ret)) + + if '--puntosventa' in sys.argv: + ret = wsltv.ConsultarPuntosVentas() + print("\n".join(ret)) + + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore"), file=sys.stderr) + sys.exit(3) + except Exception as e: + try: + print(traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0], file=sys.stderr) + except BaseException: + print("Excepción no disponible:", type(e), file=sys.stderr) + if DEBUG: + raise + sys.exit(5) + finally: + if XML: + open("wsltv_request.xml", "w").write(wsltv.client.xml_request) + open("wsltv_response.xml", "w").write(wsltv.client.xml_response) diff --git a/app/pyafipws/wslum.py b/app/pyafipws/wslum.py new file mode 100644 index 0000000000000000000000000000000000000000..1f9956c38d40ea90757e5297bd8b826d167a03ab --- /dev/null +++ b/app/pyafipws/wslum.py @@ -0,0 +1,923 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + + +import shelve +import sys +import datetime +import decimal +import os +from .utils import leer, escribir, leer_dbf, guardar_dbf, N, A, I, json, BaseWS, inicializar_y_capturar_excepciones, get_install_dir +from . import utils +from fpdf import Template +from pysimplesoap.client import SoapFault +import pprint +import traceback +"""Módulo para obtener código de autorización electrónica (CAE) para +Liquidación Única Mensual (lechería) del web service WSLUM de AFIP +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2016 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.03b" + +LICENCIA = """ +wslum.py: Interfaz para generar Código de Autorización Electrónica (CAE) para + Liquidación Única Mensual de lechería (LumService) +Copyright (C) 2016 Mariano Reingart reingart@gmail.com +http://www.sistemasagiles.com.ar/trac/wiki/LiquidacionUnicaMensualLecheria + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo respetando la licencia GPLv3. + +Para información adicional sobre garantía, soporte técnico comercial +e incorporación/distribución en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + +AYUDA = """ +Opciones: + --ayuda: este mensaje + + --debug: modo depuración (detalla y confirma las operaciones) + --formato: muestra el formato de los archivos de entrada/salida + --prueba: genera y autoriza una liquidación de prueba (no usar en producción!) + --xml: almacena los requerimientos y respuestas XML (depuración) + --json: utilizar formato json para el archivo de intercambio + --dummy: consulta estado de servidores + + --autorizar: Autorizar Liquidación Única Mensual (lechería) (generarLiquidacion) + --ult: Consulta el último número de orden registrado en AFIP + (consultarUltimoComprobanteXPuntoVenta) + --consultar: Consulta una liquidación registrada en AFIP + (consultarLiquidacionXNroComprobante / consultarLiquidacionXCAE) + + --pdf: descarga la liquidación en formato PDF + --mostrar: muestra el documento PDF generado (usar con --pdf) + --imprimir: imprime el documento PDF generado (usar con --mostrar y --pdf) + + --provincias: obtiene el listado de provincias (código/descripción) + --localidades: obtiene el listado de localidades para una provincia + --bonificaciones_penalizaciones: obtiene el listado de tributos + --otros_impuestos: obtiene el listado de las retenciones de tabaco + --puntosventa: obtiene el listado de puntos de venta habilitados + +Ver wslum.ini para parámetros de configuración (URL, certificados, etc.)" +""" + + +# importo funciones compartidas: + + +WSDL = "https://fwshomo.afip.gov.ar/wslum/LumService?wsdl" +#WSDL = "https://serviciosjava.afip.gov.ar/wslum/LumService?wsdl" + +DEBUG = False +XML = False +CONFIG_FILE = "wslum.ini" +HOMO = True + + +class WSLUM(BaseWS): + "Interfaz para el WebService de Liquidación Única Mensual (lechería)" + _public_methods_ = ['Conectar', 'Dummy', 'SetTicketAcceso', 'DebugLog', + 'AutorizarLiquidacion', + 'CrearLiquidacion', + 'AgregarTambero', 'AgregarCondicionVenta', + 'AgregarTambo', 'AgregarUbicacionTambo', + 'AgregarBalanceLitrosPorcentajesSolidos', + 'AgregarConceptosBasicosMercadoInterno', + 'AgregarConceptosBasicosMercadoExterno', + 'AgregarBonificacionPenalizacion', + 'AgregarOtroImpuesto', + 'AgregarRemito', + 'ConsultarLiquidacion', 'ConsultarUltimoComprobante', + 'AgregarAjuste', + 'LeerDatosLiquidacion', + 'ConsultarBonificacionesPenalizaciones', + 'ConsultarOtrosImpuestos', + 'ConsultarPuntosVentas', + 'ConsultarProvincias', 'ConsultarLocalidades', + 'MostrarPDF', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetParametro', 'GetParametro', + ] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'Excepcion', 'ErrCode', 'ErrMsg', 'LanzarExcepciones', 'Errores', + 'XmlRequest', 'XmlResponse', 'Version', 'Traceback', 'InstallDir', + 'CAE', 'NroComprobante', 'FechaComprobante', + 'AlicuotaIVA', 'TotalNeto', 'ImporteIVA', + 'TotalBonificacionesCalidad', 'TotalPenalizacionesCalidad', + 'TotalBonificacionesComerciales', 'TotalDebitosComerciales', + 'TotalOtrosImpuestos', 'Total', + ] + _reg_progid_ = "WSLUM" + _reg_clsid_ = "{4CBB2DF8-7AAE-434E-916D-9D663BB1CAFC}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + LanzarExcepciones = False + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.errores = [] + self.CAE = "" + self.NroComprobante = self.FechaComprobante = '' + self.AlicuotaIVA = self.TotalNeto = self.ImporteIVA = None + self.TotalBonificacionesCalidad = None + self.TotalPenalizacionesCalidad = None + self.TotalBonificacionesComerciales = None + self.TotalDebitosComerciales = None + self.TotalOtrosImpuestos = None + self.Total = None + self.datos = {} + + @inicializar_y_capturar_excepciones + def Conectar(self, cache=None, url="", proxy="", wrapper="", cacert=None, timeout=30): + "Establecer la conexión a los servidores de la AFIP" + # llamo al constructor heredado: + ok = BaseWS.Conectar(self, cache, url, proxy, wrapper, cacert, timeout) + if False and ok: + # corrijo ubicación del servidor (puerto htttp 80 en el WSDL) + location = self.client.services['LumService']['ports']['LumEndPoint']['location'] + if location.startswith("http://"): + print("Corrigiendo WSDL ...", location, end=' ') + location = location.replace("http://", "https://").replace(":80", ":443") + self.client.services['LumService']['ports']['LumEndPoint']['location'] = location + print(location) + return ok + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + errores = [] + if 'errores' in ret: + errores.extend(ret['errores']) + if errores: + self.Errores = ["%(codigo)s: %(descripcion)s" % err['error'][0] + for err in errores] + self.errores = [ + {'codigo': err['error'][0]['codigo'], + 'descripcion': err['error'][0]['descripcion'].replace("\n", "") + .replace("\r", "")} + for err in errores] + self.ErrCode = ' '.join(self.Errores) + self.ErrMsg = '\n'.join(self.Errores) + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + results = self.client.dummy()['respuesta'] + self.AppServerStatus = str(results['appserver']) + self.DbServerStatus = str(results['dbserver']) + self.AuthServerStatus = str(results['authserver']) + return True + + @inicializar_y_capturar_excepciones + def CrearLiquidacion(self, tipo_cbte, pto_vta, nro_cbte, fecha, periodo, + iibb_adquirente=None, domicilio_sede=None, + inscripcion_registro_publico=None, + datos_adicionales=None, alicuota_iva=None, **kwargs): + "Inicializa internamente los datos de una liquidación para autorizar" + # creo el diccionario con los campos generales de la liquidación: + liq = {'tipoComprobante': tipo_cbte, 'puntoVenta': pto_vta, + 'nroComprobante': nro_cbte, 'fechaComprobante': fecha, + 'periodo': periodo, 'iibbAdquirente': iibb_adquirente, + 'domicilioSede': domicilio_sede, + 'inscripcionRegistroPublico': inscripcion_registro_publico, + 'datosAdicionales': datos_adicionales, + 'alicuotaIVA': alicuota_iva, + } + liq["condicionVenta"] = [] + self.solicitud = dict(liquidacion=liq, + bonificacionPenalizacion=[], + otroImpuesto=[], + remito=[] + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarCondicionVenta(self, codigo, descripcion=None, **kwargs): + "Agrego una o más condicion de venta a la liq." + cond = {'codigo': codigo, 'descripcion': descripcion} + self.solicitud['liquidacion']['condicionVenta'].append(cond) + return True + + @inicializar_y_capturar_excepciones + def AgregarTambero(self, cuit, iibb=None, **kwargs): + "Agrego los datos del productor a la liq." + tambero = {'cuit': cuit, 'iibb': iibb} + self.solicitud['tambero'] = tambero + return True + + @inicializar_y_capturar_excepciones + def AgregarTambo(self, nro_tambo_interno, nro_renspa, + fecha_venc_cert_tuberculosis, fecha_venc_cert_brucelosis, + nro_tambo_provincial=None, **kwargs): + "Agrego los datos del productor a la liq." + tambo = {'nroTamboInterno': nro_tambo_interno, + 'nroTamboProvincial': nro_tambo_provincial, + 'nroRenspa': nro_renspa, + 'ubicacionTambo': {}, + 'fechaVencCertTuberculosis': fecha_venc_cert_tuberculosis, + 'fechaVencCertBrucelosis': fecha_venc_cert_brucelosis} + self.solicitud['tambo'] = tambo + return True + + @inicializar_y_capturar_excepciones + def AgregarUbicacionTambo(self, latitud, longitud, domicilio, + cod_localidad, cod_provincia, codigo_postal, + nombre_partido_depto, **kwargs): + "Agrego los datos del productor a la liq." + ubic_tambo = {'latitud': latitud, + 'longitud': longitud, + 'domicilio': domicilio, + 'codLocalidad': cod_localidad, + 'codProvincia': cod_provincia, + 'nombrePartidoDepto': nombre_partido_depto, + 'codigoPostal': codigo_postal} + self.solicitud['tambo']['ubicacionTambo'] = ubic_tambo + return True + + @inicializar_y_capturar_excepciones + def AgregarBalanceLitrosPorcentajesSolidos(self, litros_remitidos, litros_decomisados, + kg_grasa, kg_proteina, **kwargs): + "Agrega balance litros y porcentajes sólidos a la liq. (obligatorio)" + d = {'litrosRemitidos': litros_remitidos, + 'litrosDecomisados': litros_decomisados, + 'kgGrasa': kg_grasa, + 'kgProteina': kg_proteina} + self.solicitud['balanceLitrosPorcentajesSolidos'] = d + + @inicializar_y_capturar_excepciones + def AgregarConceptosBasicosMercadoInterno(self, kg_produccion_gb, precio_por_kg_produccion_gb, + kg_produccion_pr, precio_por_kg_produccion_pr, + kg_crecimiento_gb, precio_por_kg_crecimiento_gb, + kg_crecimiento_pr, precio_por_kg_crecimiento_pr, + **kwargs): + "Agrega balance litros y porcentajes sólidos (mercado interno)" + d = {'kgProduccionGB': kg_produccion_gb, + 'precioPorKgProduccionGB': precio_por_kg_produccion_gb, + 'kgProduccionPR': kg_produccion_pr, + 'precioPorKgProduccionPR': precio_por_kg_produccion_pr, + 'kgCrecimientoGB': kg_crecimiento_gb, + 'precioPorKgCrecimientoGB': precio_por_kg_crecimiento_gb, + 'kgCrecimientoPR': kg_crecimiento_pr, + 'precioPorKgCrecimientoPR': precio_por_kg_crecimiento_pr} + self.solicitud['conceptosBasicosMercadoInterno'] = d + return True + + @inicializar_y_capturar_excepciones + def AgregarConceptosBasicosMercadoExterno(self, kg_produccion_gb, precio_por_kg_produccion_gb, + kg_produccion_pr, precio_por_kg_produccion_pr, + kg_crecimiento_gb, precio_por_kg_crecimiento_gb, + kg_crecimiento_pr, precio_por_kg_crecimiento_pr, + **kwargs): + "Agrega balance litros y porcentajes sólidos (mercado externo)" + d = {'kgProduccionGB': kg_produccion_gb, + 'precioPorKgProduccionGB': precio_por_kg_produccion_gb, + 'kgProduccionPR': kg_produccion_pr, + 'precioPorKgProduccionPR': precio_por_kg_produccion_pr, + 'kgCrecimientoGB': kg_crecimiento_gb, + 'precioPorKgCrecimientoGB': precio_por_kg_crecimiento_gb, + 'kgCrecimientoPR': kg_crecimiento_pr, + 'precioPorKgCrecimientoPR': precio_por_kg_crecimiento_pr} + self.solicitud['conceptosBasicosMercadoExterno'] = d + + @inicializar_y_capturar_excepciones + def AgregarBonificacionPenalizacion(self, codigo, detalle, resultado=None, + porcentaje=None, importe=None, **kwargs): + "Agrega la información referente a las bonificaciones o penalizaciones" + ret = dict(codBonificacionPenalizacion=codigo, detalle=detalle, + resultado=resultado, porcentajeAAplicar=porcentaje, + importe=importe) + self.solicitud['bonificacionPenalizacion'].append(ret) + return True + + @inicializar_y_capturar_excepciones + def AgregarOtroImpuesto(self, tipo, base_imponible, alicuota, detalle=None): + "Agrega la información referente a otros tributos de la liquidación" + trib = dict(tipo=tipo, baseImponible=base_imponible, alicuota=alicuota, + detalle=detalle) + self.solicitud['otroImpuesto'].append(trib) + return True + + @inicializar_y_capturar_excepciones + def AgregarRemito(self, nro_remito): + "Agrega la información referente a los remitos (multiples)" + self.solicitud['remito'].append(nro_remito) + return True + + @inicializar_y_capturar_excepciones + def AutorizarLiquidacion(self): + "Generar o ajustar una liquidación única y obtener del CAE" + # limpio los elementos que no correspondan por estar vacios: + for campo in ["bonificacionPenalizacion", "otroImpuesto"]: + if campo in self.solicitud and not self.solicitud[campo]: + del self.solicitud[campo] + # llamo al webservice: + ret = self.client.generarLiquidacion( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud=self.solicitud, + ) + # analizo la respusta + ret = ret['respuesta'] + self.__analizar_errores(ret) + liqs = ret.get('liquidacion', []) + liq = liqs[0] if liqs else None + self.AnalizarLiquidacion(liq) + return True + + def AnalizarLiquidacion(self, liq): + "Método interno para analizar la respuesta de AFIP" + # proceso los datos básicos de la liquidación (devuelto por consultar): + if liq: + cab = liq['encabezado'] + self.CAE = str(cab['cae']) + self.FechaComprobante = str(cab['fechaComprobante']) + self.NroComprobante = cab['nroComprobante'] + tot = liq['resumenTotales'] + self.AlicuotaIVA = tot['alicuotaIVA'] + self.TotalNeto = tot['totalNetoLiquidacion'] + self.ImporteIVA = tot['importeIVA'] + self.TotalBonificacionesCalidad = tot['totalBonificacionesCalidad'] + self.TotalPenalizacionesCalidad = tot['totalPenalizacionesCalidad'] + self.TotalBonificacionesComerciales = tot['totalBonificacionesComerciales'] + self.TotalDebitosComerciales = tot['totalDebitosComerciales'] + self.TotalOtrosImpuestos = tot['totalOtrosImpuestos'] + self.Total = tot['totalLiquidacion'] + + # parámetros de salida: + self.params_out = dict( + tipo_cbte=liq['encabezado']['tipoComprobante'], + pto_vta=liq['encabezado']['puntoVenta'], + nro_cbte=liq['encabezado']['nroComprobante'], + fecha=str(liq['encabezado']['fechaComprobante']), + cae=str(liq['encabezado']['cae']), + domicilio_comprador=liq['encabezado']['domicilioComprador'], + tambero=dict( + cuit=liq['tambero']['cuit'], + iibb=liq['tambero']['iibb'], + razon_social=liq['tambero']['razonSocial'], + situacion_iva=liq['tambero']['situacionIVA'], + domicilio_fiscal=liq['tambero']['domicilioFiscal'], + provincia=liq['tambero']['provincia'], + cod_postal=liq['tambero']['codPostal'], + ), + resumen_kg_remitidos=liq['resumenKgRemitidos'], + alicuota_iva=liq['resumenTotales']['alicuotaIVA'], + importe_iva=liq['resumenTotales']['importeIVA'], + total_neto=liq['resumenTotales']['totalNetoLiquidacion'], + total_bonificaciones_calidad=liq['resumenTotales']['totalBonificacionesCalidad'], + total_penalizaciones_calidad=liq['resumenTotales']['totalPenalizacionesCalidad'], + total_bonificaciones_comerciales=liq['resumenTotales']['totalBonificacionesComerciales'], + total_debitos_comerciales=liq['resumenTotales']['totalDebitosComerciales'], + total_otros_impuestos=liq['resumenTotales']['totalOtrosImpuestos'], + total=liq['resumenTotales']['totalLiquidacion'], + bonificacion_penalizacion=[], + remitos=[], + otro_impuesto=[], + pdf=liq.get('pdf'), + ) + for ret in liq.get('bonificacionPenalizacion', []): + self.params_out['bonificacion_penalizacion'].append(dict( + retencion_codigo=ret['codigo'], + retencion_importe=ret['importe'], + )) + for trib in liq.get('otroImpuesto', []): + self.params_out['otro_impuesto'].append(dict( + tributo_descripcion=trib.get('descripcion', ""), + tributo_base_imponible=trib['baseImponible'], + tributo_alicuota=trib['alicuota'], + tributo_codigo=trib['codigo'], + tributo_importe=trib['importe'], + )) + if DEBUG: + import pprint + pprint.pprint(self.params_out) + self.params_out['errores'] = self.errores + + @inicializar_y_capturar_excepciones + def AgregarAjuste(self, cai, tipo_cbte, pto_vta, nro_cbte, cae_a_ajustar): + "Agrega comprobante a ajustar" + ajuste = self.solicitud['liquidacion']['ajuste'] = {} + if cae_a_ajustar: + ajuste['caeAAjustar'] = cae_a_ajustar + if cai: + cbte = dict(cai=cai, caeAAjustar=cae_a_ajustar, + tipoComprobante=tipo_cbte, puntoVenta=pto_vta, + nroComprobante=nro_cbte) + ajuste['formularioPapel'] = cbte + return True + + @inicializar_y_capturar_excepciones + def ConsultarLiquidacion(self, tipo_cbte=None, pto_vta=None, nro_cbte=None, + cae=None, cuit_comprador=None, pdf="liq.pdf"): + "Consulta una liquidación por No de Comprobante o CAE" + if cae: + ret = self.client.consultarLiquidacionPorCae( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'cae': cae, + 'pdf': pdf and True or False, + }, + ) + else: + ret = self.client.consultarLiquidacionPorNroComprobante( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'cuitComprador': cuit_comprador, + 'puntoVenta': pto_vta, + 'nroComprobante': nro_cbte, + 'tipoComprobante': tipo_cbte, + 'pdf': pdf and True or False, + }, + ) + ret = ret['respuesta'] + self.__analizar_errores(ret) + if 'liquidacion' in ret: + liqs = ret.get('liquidacion', []) + liq = liqs[0] if liqs else None + self.AnalizarLiquidacion(liq) + # guardo el PDF si se indico archivo y vino en la respuesta: + if pdf and 'pdf' in liq: + open(pdf, "wb").write(liq['pdf']) + return True + + @inicializar_y_capturar_excepciones + def ConsultarUltimoComprobante(self, tipo_cbte=151, pto_vta=1): + "Consulta el último No de Comprobante registrado" + ret = self.client.consultarUltimoNroComprobantePorPtoVta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={ + 'puntoVenta': pto_vta, + 'tipoComprobante': tipo_cbte}, + ) + ret = ret['respuesta'] + self.__analizar_errores(ret) + self.NroComprobante = ret['nroComprobante'] + return True + + def ConsultarProvincias(self, sep="||"): + "Consulta las provincias habilitadas" + ret = self.client.consultarProvincias( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('provincia', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarLocalidades(self, cod_provincia, sep="||"): + "Consulta las localidades habilitadas" + ret = self.client.consultarLocalidadesPorProvincia( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + solicitud={'codProvincia': cod_provincia}, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('localidad', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarCondicionesVenta(self, sep="||"): + "Retorna un listado de códigos y descripciones de las condiciones de ventas" + ret = self.client.consultarCondicionesVenta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('condicionVenta', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarOtrosImpuestos(self, sep="||"): + "Retorna un listado de tributos con código, descripción y signo." + ret = self.client.consultarOtrosImpuestos( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('otroImpuesto', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def ConsultarBonificacionesPenalizaciones(self, sep="||"): + "Retorna un listado de bonificaciones/penalizaciones con código y descripción" + ret = self.client.consultarBonificacionesPenalizaciones( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + self.XmlResponse = self.client.xml_response + array = ret.get('tipo', []) + if sep is None: + # sin separador, devuelve un diccionario con clave cod_variadedad + # y valor: {"descripcion": ds_variedad, "clases": lista_clases} + # siendo lista_clases = [{'codigo': ..., 'descripcion': ...}] + return dict([(it['codigo'], {'descripcion': it['descripcion'], + 'subtipo': it['subtipo']}) + for it in array]) + else: + # con separador, devuelve una lista de strings: + # || cod.variedad || desc.variedad || desc.clase || cod.clase || + ret = [] + for it in array: + for subtipo in it['subtipo']: + ret.append( + ("%s %%s %s %%s %s %%s %s %%s %s %%s %s %%s %s" % + (sep, sep, sep, sep, sep, sep, sep)) % + (it['codigo'], it['descripcion'], + subtipo['descripcion'], subtipo['codigo'], + subtipo['valor'], subtipo['signo']) + ) + return ret + + def ConsultarPuntosVentas(self, sep="||"): + "Retorna los puntos de ventas autorizados para la utilizacion de WS" + ret = self.client.consultarPuntosVenta( + auth={ + 'token': self.Token, 'sign': self.Sign, + 'cuit': self.Cuit, }, + )['respuesta'] + self.__analizar_errores(ret) + array = ret.get('puntoVenta', []) + if sep is None: + return dict([(it['codigo'], it['descripcion']) for it in array]) + else: + return [("%s %%s %s %%s %s" % (sep, sep, sep)) % + (it['codigo'], it['descripcion']) for it in array] + + def MostrarPDF(self, archivo, imprimir=False): + try: + if sys.platform == "linux2": + import subprocess + subprocess.run(["evince", archivo], check=False) + else: + operation = imprimir and "print" or "" + os.startfile(archivo, operation) + return True + except Exception as e: + self.Excepcion = str(e) + return False + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +INSTALL_DIR = WSLUM.InstallDir = get_install_dir() + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + if '--formato' in sys.argv: + print("Formato:") + for msg, formato in []: + comienzo = 1 + print("=== %s ===" % msg) + for fmt in formato: + clave, longitud, tipo = fmt[0:3] + dec = len(fmt) > 3 and fmt[3] or (tipo == 'I' and '2' or '') + print(" * Campo: %-20s Posición: %3d Longitud: %4d Tipo: %s Decimales: %s" % ( + clave, comienzo, longitud, tipo, dec)) + comienzo += longitud + sys.exit(0) + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSLUM) + sys.exit(0) + + import csv + from configparser import SafeConfigParser + + from .wsaa import WSAA + + try: + + if "--version" in sys.argv: + print("Versión: ", __version__) + + if len(sys.argv) > 1 and sys.argv[1].endswith(".ini"): + CONFIG_FILE = sys.argv[1] + print("Usando configuracion:", CONFIG_FILE) + + config = SafeConfigParser() + config.read(CONFIG_FILE) + CERT = config.get('WSAA', 'CERT') + PRIVATEKEY = config.get('WSAA', 'PRIVATEKEY') + CUIT = config.get('WSLUM', 'CUIT') + ENTRADA = config.get('WSLUM', 'ENTRADA') + SALIDA = config.get('WSLUM', 'SALIDA') + + if config.has_option('WSAA', 'URL') and not HOMO: + WSAA_URL = config.get('WSAA', 'URL') + else: + WSAA_URL = None # wsaa.WSAAURL + if config.has_option('WSLUM', 'URL') and not HOMO: + WSLUM_URL = config.get('WSLUM', 'URL') + else: + WSLUM_URL = WSDL + + PROXY = config.has_option('WSAA', 'PROXY') and config.get('WSAA', 'PROXY') or None + CACERT = config.has_option('WSAA', 'CACERT') and config.get('WSAA', 'CACERT') or None + WRAPPER = config.has_option('WSAA', 'WRAPPER') and config.get('WSAA', 'WRAPPER') or None + + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + if DEBUG: + print("conf_dbf", conf_dbf) + else: + conf_dbf = {} + + DEBUG = '--debug' in sys.argv + XML = '--xml' in sys.argv + + if DEBUG: + print("Usando Configuración:") + print("WSAA_URL:", WSAA_URL) + print("WSLUM_URL:", WSLUM_URL) + print("CACERT", CACERT) + print("WRAPPER", WRAPPER) + # obteniendo el TA + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wslum", CERT, PRIVATEKEY, wsdl=WSAA_URL, + proxy=PROXY, wrapper=WRAPPER, cacert=CACERT) + if not ta: + pass # sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + + # cliente soap del web service + wslum = WSLUM() + wslum.LanzarExcepciones = True + wslum.Conectar(url=WSLUM_URL, proxy=PROXY, wrapper=WRAPPER, cacert=CACERT) + wslum.SetTicketAcceso(ta) + wslum.Cuit = CUIT + + if '--dummy' in sys.argv: + ret = wslum.Dummy() + print("AppServerStatus", wslum.AppServerStatus) + print("DbServerStatus", wslum.DbServerStatus) + print("AuthServerStatus", wslum.AuthServerStatus) + # sys.exit(0) + + if '--autorizar' in sys.argv: + + if '--prueba' in sys.argv: + + # Solicitud 1: Alta de liquidación + wslum.CrearLiquidacion(tipo_cbte=27, pto_vta=1, nro_cbte=1, + fecha="2015-12-31", periodo="2015/12", + iibb_adquirente="123456789012345", + domicilio_sede="Domicilio Administrativo", + inscripcion_registro_publico="Nro IGJ", + datos_adicionales="Datos Adicionales Varios", + alicuota_iva=21.00) + wslum.AgregarCondicionVenta(codigo=1, descripcion=None) + if '--ajuste' in sys.argv: + wslum.AgregarAjuste(cai="10000000000000", + tipo_cbte=0, pto_vta=0, nro_cbte=0, + cae_a_ajustar="75521002437246") + + wslum.AgregarTambero(cuit=11111111111, iibb="123456789012345") + + wslum.AgregarTambo(nro_tambo_interno=123456789, + nro_renspa="12.345.6.78901/12", + fecha_venc_cert_tuberculosis="2015-01-01", + fecha_venc_cert_brucelosis="2015-01-01", + nro_tambo_provincial=100000000) + wslum.AgregarUbicacionTambo( + latitud=-34.62987, longitud=-58.65155, + domicilio="Domicilio Tambo", + cod_localidad=10109, cod_provincia=1, + codigo_postal=1234, + nombre_partido_depto='Partido Tambo') + + wslum.AgregarBalanceLitrosPorcentajesSolidos( + litros_remitidos=11000, litros_decomisados=1000, + kg_grasa=100.00, kg_proteina=100.00) + + wslum.AgregarConceptosBasicosMercadoInterno( + kg_produccion_gb=100, precio_por_kg_produccion_gb=5.00, + kg_produccion_pr=100, precio_por_kg_produccion_pr=5.00, + kg_crecimiento_gb=0, precio_por_kg_crecimiento_gb=0.00, + kg_crecimiento_pr=0, precio_por_kg_crecimiento_pr=0.00) + + wslum.AgregarConceptosBasicosMercadoExterno( + kg_produccion_gb=0, precio_por_kg_produccion_gb=0.00, + kg_produccion_pr=0, precio_por_kg_produccion_pr=0.00, + kg_crecimiento_gb=0, precio_por_kg_crecimiento_gb=0.00, + kg_crecimiento_pr=0, precio_por_kg_crecimiento_pr=0.00) + + wslum.AgregarBonificacionPenalizacion(codigo=1, + detalle="opcional", resultado="400", porcentaje=10.00) + wslum.AgregarBonificacionPenalizacion(codigo=10, + detalle="opcional", resultado="2.5", porcentaje=10.00) + wslum.AgregarBonificacionPenalizacion(codigo=4, + detalle="opcional", resultado="400", porcentaje=10.00) + wslum.AgregarBonificacionPenalizacion(codigo=5, + detalle="opcional", resultado="En Saneamiento", + porcentaje=10.00) + + wslum.AgregarOtroImpuesto(tipo=1, base_imponible=100.00, + alicuota=10.00, detalle="") + wslum.AgregarOtroImpuesto(tipo=9, base_imponible=100.00, + alicuota=10.00, + detalle="Detalle Otras Percepciones") + wslum.AgregarOtroImpuesto(tipo=8, base_imponible=100.00, + alicuota=10.00, detalle="") + + wslum.AgregarRemito(nro_remito="123456789012") + wslum.AgregarRemito(nro_remito="123456789") + + else: + # cargar un archivo de texto: + with open("wslum.json", "r") as f: + wslum.solicitud = json.load(f, encoding="utf-8") + + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo, cargo respuesta: + wslum.LoadTestXML("tests/xml/wslum_liq_test_pdf_response.xml") + import json + with open("wslum.json", "w") as f: + json.dump(wslum.solicitud, f, sort_keys=True, indent=4, encoding="utf-8",) + + print("Liquidacion: pto_vta=%s nro_cbte=%s tipo_cbte=%s" % ( + wslum.solicitud['liquidacion']['puntoVenta'], + wslum.solicitud['liquidacion']['nroComprobante'], + wslum.solicitud['liquidacion']['tipoComprobante'], + )) + + if not '--dummy' in sys.argv: + print("Autorizando...") + ret = wslum.AutorizarLiquidacion() + + if wslum.Excepcion: + print("EXCEPCION:", wslum.Excepcion, file=sys.stderr) + if DEBUG: + print(wslum.Traceback, file=sys.stderr) + print("Errores:", wslum.Errores) + print("CAE", wslum.CAE) + print("FechaComprobante", wslum.FechaComprobante) + print("NroComprobante", wslum.NroComprobante) + print("TotalNeto", wslum.TotalNeto) + print("AlicuotaIVA", wslum.AlicuotaIVA) + print("ImporteIVA", wslum.ImporteIVA) + print("TotalBonificacionesCalidad", wslum.TotalBonificacionesCalidad) + print("TotalPenalizacionesCalidad", wslum.TotalPenalizacionesCalidad) + print("TotalBonificacionesComerciales", wslum.TotalBonificacionesComerciales) + print("TotalDebitosComerciales", wslum.TotalDebitosComerciales) + print("TotalOtrosImpuestos", wslum.TotalOtrosImpuestos) + print("Total", wslum.Total) + + pdf = wslum.GetParametro("pdf") + if pdf: + open("liq.pdf", "wb").write(pdf) + + if '--testing' in sys.argv: + assert wslum.CAE == "75521002437246" + + if DEBUG: + pprint.pprint(wslum.params_out) + + if "--guardar" in sys.argv: + # grabar un archivo de texto (intercambio) con el resultado: + liq = wslum.params_out.copy() + if "pdf" in liq: + del liq["pdf"] # eliminador binario + with open("wslum_salida.json", "w") as f: + json.dump(liq, f, default=str, + indent=2, sort_keys=True, encoding="utf-8") + + if '--consultar' in sys.argv: + tipo_cbte = 27 + pto_vta = 1 + nro_cbte = 0 + cuit = None + try: + tipo_cbte = sys.argv[sys.argv.index("--consultar") + 1] + pto_vta = sys.argv[sys.argv.index("--consultar") + 2] + nro_cbte = sys.argv[sys.argv.index("--consultar") + 3] + cuit = sys.argv[sys.argv.index("--consultar") + 4] + except IndexError: + pass + if '--testing' in sys.argv: + # mensaje de prueba (no realiza llamada remota), + # usar solo si no está operativo, cargo prueba: + wslum.LoadTestXML("tests/xml/wslum_cons_test.xml") + print("Consultando: tipo_cbte=%s pto_vta=%s nro_cbte=%s" % (tipo_cbte, pto_vta, nro_cbte)) + ret = wslum.ConsultarLiquidacion(tipo_cbte, pto_vta, nro_cbte, + cuit_comprador=cuit) + print("CAE", wslum.CAE) + print("Errores:", wslum.Errores) + + if DEBUG: + pprint.pprint(wslum.params_out) + + if '--mostrar' in sys.argv and pdf: + wslum.MostrarPDF(archivo=pdf, + imprimir='--imprimir' in sys.argv) + + if '--ult' in sys.argv: + tipo_cbte = 27 + pto_vta = 1 + try: + tipo_cbte = sys.argv[sys.argv.index("--ult") + 1] + pto_vta = sys.argv[sys.argv.index("--ult") + 2] + except IndexError: + pass + + print("Consultando ultimo nro_cbte para pto_vta=%s" % pto_vta, end=' ') + ret = wslum.ConsultarUltimoComprobante(tipo_cbte, pto_vta) + if wslum.Excepcion: + print("EXCEPCION:", wslum.Excepcion, file=sys.stderr) + if DEBUG: + print(wslum.Traceback, file=sys.stderr) + print("Ultimo Nro de Comprobante", wslum.NroComprobante) + print("Errores:", wslum.Errores) + sys.exit(0) + + # Recuperar parámetros: + + if '--provincias' in sys.argv: + ret = wslum.ConsultarProvincias() + print("\n".join(ret)) + + if '--localidades' in sys.argv: + try: + cod_provincia = sys.argv[sys.argv.index("--localidades") + 1] + except BaseException: + cod_provincia = input("Codigo Provincia:") + ret = wslum.ConsultarLocalidades(cod_provincia) + print("\n".join(ret)) + + if '--bonificaciones_penalizaciones' in sys.argv: + ret = wslum.ConsultarBonificacionesPenalizaciones() + print("\n".join(ret)) + + if '--otros_impuestos' in sys.argv: + ret = wslum.ConsultarOtrosImpuestos() + print("\n".join(ret)) + + if '--puntosventa' in sys.argv: + ret = wslum.ConsultarPuntosVentas() + print("\n".join(ret)) + + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore"), file=sys.stderr) + sys.exit(3) + except Exception as e: + try: + print(traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1])[0], file=sys.stderr) + except BaseException: + print("Excepción no disponible:", type(e), file=sys.stderr) + if DEBUG: + raise + sys.exit(5) + finally: + if XML: + open("wslum_request.xml", "w").write(wslum.client.xml_request) + open("wslum_response.xml", "w").write(wslum.client.xml_response) diff --git a/app/pyafipws/wsmtx.py b/app/pyafipws/wsmtx.py new file mode 100644 index 0000000000000000000000000000000000000000..e9bf68df805b2d3efc2b0535eabe89a28f6511d3 --- /dev/null +++ b/app/pyafipws/wsmtx.py @@ -0,0 +1,1243 @@ +#!/usr/bin/python +# -*- coding: latin-1 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +"""M�dulo para obtener c�digo de autorizaci�n electr�nico CAE/CAEA webservice +WSMTX de AFIP (Factura Electr�nica Mercado Interno con codificaci�n de +productos) seg�n RG2904 (opci�n A con detalle) y RG2926/10 (CAE anticipado). +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2010-2015 Mariano Reingart" +__license__ = "GPL 3.0" +__version__ = "1.14a" + +import datetime +import decimal +import os +import sys +from .utils import verifica, inicializar_y_capturar_excepciones, BaseWS, get_install_dir + +HOMO = False +LANZAR_EXCEPCIONES = True +WSDL = "https://fwshomo.afip.gov.ar/wsmtxca/services/MTXCAService?wsdl" + + +class WSMTXCA(BaseWS): + "Interfaz para el WebService de Factura Electr�nica Mercado Interno WSMTXCA" + _public_methods_ = ['CrearFactura', 'EstablecerCampoFactura', 'AgregarIva', 'AgregarItem', + 'AgregarTributo', 'AgregarCmpAsoc', 'EstablecerCampoItem', 'AgregarOpcional', + 'AutorizarComprobante', 'CAESolicitar', 'AutorizarAjusteIVA', + 'SolicitarCAEA', 'ConsultarCAEA', 'ConsultarCAEAEntreFechas', + 'InformarComprobanteCAEA', 'InformarAjusteIVACAEA', + 'InformarCAEANoUtilizado', 'InformarCAEANoUtilizadoPtoVta', + 'ConsultarUltimoComprobanteAutorizado', 'CompUltimoAutorizado', + 'ConsultarPtosVtaCAEANoInformados', + 'ConsultarComprobante', + 'ConsultarTiposComprobante', + 'ConsultarTiposDocumento', + 'ConsultarAlicuotasIVA', + 'ConsultarCondicionesIVA', + 'ConsultarMonedas', + 'ConsultarUnidadesMedida', + 'ConsultarTiposTributo', 'ConsultarTiposDatosAdicionales', + 'ConsultarCotizacionMoneda', + 'ConsultarPuntosVentaCAE', + 'ConsultarPuntosVentaCAEA', + 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + 'SetParametros', 'SetTicketAcceso', 'GetParametro', + 'Dummy', 'Conectar', 'DebugLog', 'SetTicketAcceso'] + _public_attrs_ = ['Token', 'Sign', 'Cuit', + 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'XmlRequest', 'XmlResponse', 'Version', 'InstallDir', 'LanzarExcepciones', + 'Resultado', 'Obs', 'Observaciones', 'ErrCode', 'ErrMsg', + 'EmisionTipo', 'Reproceso', 'Reprocesar', 'Evento', + 'CAE', 'Vencimiento', 'Evento', 'Errores', 'Traceback', 'Excepcion', + 'CAEA', 'Periodo', 'Orden', 'FchVigDesde', 'FchVigHasta', 'FchTopeInf', 'FchProceso', + 'CbteNro', 'FechaCbte', 'PuntoVenta', 'ImpTotal'] + + _reg_progid_ = "WSMTXCA" + _reg_clsid_ = "{8128E6AB-FB22-4952-8EA6-BD41C29B17CA}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL + Version = "%s %s" % (__version__, HOMO and 'Homologaci�n' or '') + Reprocesar = True # recuperar automaticamente CAE emitidos + LanzarExcepciones = LANZAR_EXCEPCIONES + factura = None + + def inicializar(self): + BaseWS.inicializar(self) + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.Resultado = self.Motivo = self.Reproceso = '' + self.LastID = self.LastCMP = self.CAE = self.Vencimiento = '' + self.CAEA = None + self.Periodo = self.Orden = "" + self.FchVigDesde = self.FchVigHasta = "" + self.FchTopeInf = self.FchProceso = "" + self.CbteNro = self.FechaCbte = ImpTotal = None + self.EmisionTipo = self.Evento = '' + self.Reproceso = '' # no implementado + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + if 'arrayErrores' in ret: + errores = ret['arrayErrores'] + for error in errores: + self.Errores.append("%s: %s" % ( + error['codigoDescripcion']['codigo'], + error['codigoDescripcion']['descripcion'], + )) + self.ErrMsg = '\n'.join(self.Errores) + + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + result = self.client.dummy() + self.AppServerStatus = result['appserver'] + self.DbServerStatus = result['dbserver'] + self.AuthServerStatus = result['authserver'] + return True + + def CrearFactura(self, concepto=None, tipo_doc=None, nro_doc=None, tipo_cbte=None, punto_vta=None, + cbt_desde=None, cbt_hasta=None, imp_total=None, imp_tot_conc=None, imp_neto=None, + imp_subtotal=None, imp_trib=None, imp_op_ex=None, fecha_cbte=None, fecha_venc_pago=None, + fecha_serv_desde=None, fecha_serv_hasta=None, # -- + moneda_id=None, moneda_ctz=None, observaciones=None, caea=None, fch_venc_cae=None, + **kwargs + ): + "Creo un objeto factura (interna)" + # Creo una factura electronica de exportaci�n + fact = {'tipo_doc': tipo_doc, 'nro_doc': nro_doc, + 'tipo_cbte': tipo_cbte, 'punto_vta': punto_vta, + 'cbt_desde': cbt_desde, 'cbt_hasta': cbt_hasta, + 'imp_total': imp_total, 'imp_tot_conc': imp_tot_conc, + 'imp_neto': imp_neto, + 'imp_subtotal': imp_subtotal, # 'imp_iva': imp_iva, + 'imp_trib': imp_trib, 'imp_op_ex': imp_op_ex, + 'fecha_cbte': fecha_cbte, + 'fecha_venc_pago': fecha_venc_pago, + 'moneda_id': moneda_id, 'moneda_ctz': moneda_ctz, + 'concepto': concepto, + 'observaciones': observaciones, + 'cbtes_asoc': [], + 'tributos': [], + 'iva': [], + 'detalles': [], + } + if fecha_serv_desde: + fact['fecha_serv_desde'] = fecha_serv_desde + if fecha_serv_hasta: + fact['fecha_serv_hasta'] = fecha_serv_hasta + if caea: + fact['caea'] = caea + if fch_venc_cae: + fact['fch_venc_cae'] = fch_venc_cae + + self.factura = fact + return True + + def EstablecerCampoFactura(self, campo, valor): + if campo in self.factura or campo in ('fecha_serv_desde', 'fecha_serv_hasta', 'caea', 'fch_venc_cae'): + self.factura[campo] = valor + return True + else: + return False + + def AgregarCmpAsoc(self, tipo=1, pto_vta=0, nro=0, cuit=None, fecha=None, **kwargs): + "Agrego un comprobante asociado a una factura (interna)" + cmp_asoc = { + 'tipo': tipo, + 'pto_vta': pto_vta, + 'nro': nro} + if cuit is not None: + cmp_asoc['cuit'] = cuit + if fecha is not None: + cmp_asoc['fecha'] = fecha + self.factura['cbtes_asoc'].append(cmp_asoc) + return True + + def AgregarTributo(self, tributo_id, desc, base_imp, alic, importe, **kwargs): + "Agrego un tributo a una factura (interna)" + tributo = { + 'tributo_id': tributo_id, + 'desc': desc, + 'base_imp': base_imp, + 'importe': importe, + } + self.factura['tributos'].append(tributo) + return True + + def AgregarIva(self, iva_id, base_imp, importe, **kwargs): + "Agrego un tributo a una factura (interna)" + iva = { + 'iva_id': iva_id, + 'importe': importe, + } + self.factura['iva'].append(iva) + return True + + def AgregarItem(self, u_mtx=None, cod_mtx=None, codigo=None, ds=None, qty=None, umed=None, precio=None, bonif=None, + iva_id=None, imp_iva=None, imp_subtotal=None, **kwargs): + "Agrego un item a una factura (interna)" + # ds = unicode(ds, "latin1") # convierto a latin1 + # Nota: no se calcula neto, iva, etc (deben venir calculados!) + umed = int(umed) + if umed == 99: + imp_subtotal = -abs(float(imp_subtotal)) + imp_iva = -abs(float(imp_iva)) + item = { + 'u_mtx': u_mtx, + 'cod_mtx': cod_mtx, + 'codigo': codigo, + 'ds': ds, + 'qty': qty if umed != 99 else None, + 'umed': umed, + 'precio': precio if umed != 99 else None, + 'bonif': bonif if umed != 99 else None, + 'iva_id': iva_id, + 'imp_iva': imp_iva, + 'imp_subtotal': imp_subtotal, + } + self.factura['detalles'].append(item) + return True + + def EstablecerCampoItem(self, campo, valor): + if self.factura['detalles'] and campo in self.factura['detalles'][-1]: + self.factura['detalles'][-1][campo] = valor + return True + else: + return False + + def AgregarOpcional(self, opcional_id=0, valor=None, valor2=None, + valor3=None, valor4=None, valor5=None, + valor6=None, **kwarg): + "Agrego un dato adicional a una factura (interna)" + op = { 'opcional_id': opcional_id, 'valor': valor, 'valor2': valor2, + 'valor3': valor3, 'valor4': valor4, 'valor5': valor5, + 'valor6': valor6 } + self.factura['opcionales'].append(op) + return True + + @inicializar_y_capturar_excepciones + def AutorizarComprobante(self): + f = self.factura + # contruyo la estructura a convertir en XML: + fact = { + 'codigoTipoDocumento': f['tipo_doc'], 'numeroDocumento': f['nro_doc'], + 'codigoTipoComprobante': f['tipo_cbte'], 'numeroPuntoVenta': f['punto_vta'], + 'numeroComprobante': f['cbt_desde'], 'numeroComprobante': f['cbt_hasta'], + 'importeTotal': f['imp_total'], 'importeNoGravado': f['imp_tot_conc'], + 'importeGravado': f['imp_neto'], + 'importeSubtotal': f['imp_subtotal'], # 'imp_iva': imp_iva, + 'importeOtrosTributos': f['tributos'] and f['imp_trib'] or None, + 'importeExento': f['imp_op_ex'], + 'fechaEmision': f['fecha_cbte'], + 'codigoMoneda': f['moneda_id'], 'cotizacionMoneda': f['moneda_ctz'], + 'codigoConcepto': f['concepto'], + 'observaciones': f['observaciones'], + 'fechaVencimientoPago': f.get('fecha_venc_pago'), + 'fechaServicioDesde': f.get('fecha_serv_desde'), + 'fechaServicioHasta': f.get('fecha_serv_hasta'), + 'arrayComprobantesAsociados': f['cbtes_asoc'] and [{'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro'], + 'cuit': cbte_asoc.get('cuit'), + 'fechaEmision': cbte_asoc.get('fecha'), + }} for cbte_asoc in f['cbtes_asoc']] or None, + 'arrayOtrosTributos': f['tributos'] and [ {'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': tributo['base_imp'], + 'importe': tributo['importe'], + }} for tributo in f['tributos']] or None, + 'arraySubtotalesIVA': f['iva'] and [{'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': iva['importe'], + }} for iva in f['iva']] or None, + 'arrayItems': f['detalles'] and [{'item': { + 'unidadesMtx': it['u_mtx'], + 'codigoMtx': it['cod_mtx'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'cantidad': it['qty'], + 'codigoUnidadMedida': it['umed'], + 'precioUnitario': it['precio'], + 'importeBonificacion': it['bonif'], + 'codigoCondicionIVA': it['iva_id'], + 'importeIVA': it['imp_iva'] if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': it['imp_subtotal'], + }} for it in f['detalles']] or None, + 'arrayDatosAdicionales': f['opcionales'] and [{'datoAdicional': { + 't': dato['opcional_id'], + 'c1': dato.get('valor'), + 'c2': dato.get('valor2'), + 'c3': dato.get('valor3'), + 'c4': dato.get('valor4'), + 'c5': dato.get('valor5'), + 'c6': dato.get('valor6'), + }} for dato in f['opcionales']] or None, + } + + ret = self.client.autorizarComprobante( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + comprobanteCAERequest=fact, + ) + + # Reprocesar en caso de error (recuperar CAE emitido anteriormente) + if self.Reprocesar and ('arrayErrores' in ret): + for error in ret['arrayErrores']: + err_code = error['codigoDescripcion']['codigo'] + if ret['resultado'] == 'R' and err_code == 102: + # guardo los mensajes xml originales + xml_request = self.client.xml_request + xml_response = self.client.xml_response + cae = self.ConsultarComprobante(f['tipo_cbte'], f['punto_vta'], f['cbt_desde'], reproceso=True) + if cae and self.EmisionTipo == 'CAE': + self.Reproceso = 'S' + self.Resultado = 'A' # verificar O + return cae + self.Reproceso = 'N' + # reestablesco los mensajes xml originales + self.client.xml_request = xml_request + self.client.xml_response = xml_response + + self.Resultado = ret['resultado'] # u'A' + if ret['resultado'] in ("A", "O"): + cbteresp = ret['comprobanteResponse'] + self.FechaCbte = cbteresp['fechaEmision'].strftime("%Y/%m/%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + # self. = cbteresp['cuit'] # 20267565393L + # self. = cbteresp['codigoTipoComprobante'] + self.Vencimiento = cbteresp['fechaVencimientoCAE'].strftime("%Y/%m/%d") + self.CAE = str(cbteresp['CAE']) # 60423794871430L + self.__analizar_errores(ret) + + for error in ret.get('arrayObservaciones', []): + self.Observaciones.append("%(codigo)s: %(descripcion)s" % ( + error['codigoDescripcion'])) + self.Obs = '\n'.join(self.Observaciones) + + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.CAE + + @inicializar_y_capturar_excepciones + def CAESolicitar(self): + try: + cae = self.AutorizarComprobante() or '' + self.Excepcion = "OK!" + except BaseException: + cae = "ERR" + finally: + return cae + + @inicializar_y_capturar_excepciones + def AutorizarAjusteIVA(self): + "Env�a la informaci�n del comprobante de ajuste de IVA que desea autorizar" + f = self.factura + # contruyo la estructura a convertir en XML: + fact = { + 'codigoTipoDocumento': f['tipo_doc'], 'numeroDocumento': f['nro_doc'], + 'codigoTipoComprobante': f['tipo_cbte'], 'numeroPuntoVenta': f['punto_vta'], + 'numeroComprobante': f['cbt_desde'], 'numeroComprobante': f['cbt_hasta'], + 'importeTotal': f['imp_total'], 'importeNoGravado': f['imp_tot_conc'], + 'importeGravado': f['imp_neto'], + 'importeSubtotal': f['imp_subtotal'], # 'imp_iva': imp_iva, + 'importeOtrosTributos': f['tributos'] and f['imp_trib'] or None, + 'importeExento': f['imp_op_ex'], + 'fechaEmision': f['fecha_cbte'], + 'codigoMoneda': f['moneda_id'], 'cotizacionMoneda': f['moneda_ctz'], + 'codigoConcepto': f['concepto'], + 'observaciones': f['observaciones'], + 'fechaVencimientoPago': f.get('fecha_venc_pago'), + 'fechaServicioDesde': f.get('fecha_serv_desde'), + 'fechaServicioHasta': f.get('fecha_serv_hasta'), + 'arrayComprobantesAsociados': f['cbtes_asoc'] and [{'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro'], + 'cuit': cbte_asoc.get('cuit'), + }} for cbte_asoc in f['cbtes_asoc']] or None, + 'arrayOtrosTributos': f['tributos'] and [{'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': tributo['base_imp'], + 'importe': tributo['importe'], + }} for tributo in f['tributos']] or None, + 'arraySubtotalesIVA': f['iva'] and [{'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': iva['importe'], + }} for iva in f['iva']] or None, + 'arrayItems': f['detalles'] and [{'item': { + 'unidadesMtx': it['u_mtx'], + 'codigoMtx': it['cod_mtx'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'cantidad': it['qty'], + 'codigoUnidadMedida': it['umed'], + 'precioUnitario': it['precio'], + 'importeBonificacion': it['bonif'], + 'codigoCondicionIVA': it['iva_id'], + 'importeIVA': it['imp_iva'] if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': it['imp_subtotal'], + }} for it in f['detalles']] or None, + 'arrayDatosAdicionales': f['opcionales'] and [{'datoAdicional': { + 't': dato['opcional_id'], + 'c1': dato.get('valor'), + 'c2': dato.get('valor2'), + 'c3': dato.get('valor3'), + 'c4': dato.get('valor4'), + 'c5': dato.get('valor5'), + 'c6': dato.get('valor6'), + }} for dato in f['opcionales']] or None, + } + + ret = self.client.autorizarAjusteIVA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + comprobanteCAERequest=fact, + ) + + self.Resultado = ret['resultado'] # u'A' + if ret['resultado'] in ("A", "O"): + cbteresp = ret['comprobanteResponse'] + self.FechaCbte = cbteresp['fechaEmision'].strftime("%Y/%m/%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + # self. = cbteresp['cuit'] # 20267565393L + # self. = cbteresp['codigoTipoComprobante'] + self.Vencimiento = cbteresp['fechaVencimientoCAE'].strftime("%Y/%m/%d") + self.CAE = str(cbteresp['CAE']) # 60423794871430L + self.__analizar_errores(ret) + + for error in ret.get('arrayObservaciones', []): + self.Observaciones.append("%(codigo)s: %(descripcion)s" % ( + error['codigoDescripcion'])) + self.Obs = '\n'.join(self.Observaciones) + + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.CAE + + @inicializar_y_capturar_excepciones + def SolicitarCAEA(self, periodo, orden): + "Obtener un CAEA y su respectivo per�odo de vigencia" + ret = self.client.solicitarCAEA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + solicitudCAEA={ + 'periodo': periodo, + 'orden': orden}, + ) + + self.__analizar_errores(ret) + + if 'CAEAResponse' in ret: + res = ret['CAEAResponse'] + self.CAEA = res['CAEA'] + self.Periodo = res['periodo'] + self.Orden = res['orden'] + self.FchVigDesde = res['fechaDesde'] + self.FchVigHasta = res['fechaHasta'] + self.FchTopeInf = res['fechaTopeInforme'] + self.FchProceso = res['fechaProceso'] + return self.CAEA and str(self.CAEA) or '' + + @inicializar_y_capturar_excepciones + def ConsultarCAEA(self, periodo=None, orden=None, caea=None): + "M�todo de consulta de CAEA" + if periodo and orden: + anio, mes = int(periodo[0:4]), int(periodo[4:6]) + if int(orden) == 1: + dias = 1, 15 + else: + if mes in (1, 3, 5, 7, 8, 10, 12): + dias = 16, 31 + elif mes in (4, 6, 9, 11): + dias = 16, 30 + else: + import calendar + if calendar.isleap(anio): + dias = 16, 29 # biciesto + else: + dias = 16, 28 + + fecha_desde = "%04d-%02d-%02d" % (anio, mes, dias[0]) + fecha_hasta = "%04d-%02d-%02d" % (anio, mes, dias[1]) + + caeas = self.ConsultarCAEAEntreFechas(fecha_desde, fecha_hasta) + if caeas: + caea = caeas[0] + + if caea: + ret = self.client.consultarCAEA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + CAEA=caea, + ) + + self.__analizar_errores(ret) + + if 'CAEAResponse' in ret: + res = ret['CAEAResponse'] + self.CAEA = res['CAEA'] + self.Periodo = res['periodo'] + self.Orden = res['orden'] + self.FchVigDesde = res['fechaDesde'] + self.FchVigHasta = res['fechaHasta'] + self.FchTopeInf = res['fechaTopeInforme'] + self.FchProceso = res['fechaProceso'] + return self.CAEA and str(self.CAEA) or '' + + @inicializar_y_capturar_excepciones + def ConsultarCAEAEntreFechas(self, fecha_desde, fecha_hasta): + "M�todo de consulta de CAEA" + + ret = self.client.consultarCAEAEntreFechas( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + fechaDesde=fecha_desde, + fechaHasta=fecha_hasta, + ) + + self.__analizar_errores(ret) + + caeas = [] + if 'arrayCAEAResponse' in ret: + return [res['CAEAResponse']['CAEA'] for res in ret['arrayCAEAResponse']] + return [] + + @inicializar_y_capturar_excepciones + def InformarComprobanteCAEA(self): + "Env�a la informaci�n del comprobante emitido y asociado a un CAEA" + f = self.factura + # contruyo la estructura a convertir en XML: + fact = { + 'codigoTipoDocumento': f['tipo_doc'], 'numeroDocumento': f['nro_doc'], + 'codigoTipoComprobante': f['tipo_cbte'], 'numeroPuntoVenta': f['punto_vta'], + 'numeroComprobante': f['cbt_desde'], 'numeroComprobante': f['cbt_hasta'], + 'codigoTipoAutorizacion': 'A', + 'codigoAutorizacion': f['caea'], + 'importeTotal': f['imp_total'], 'importeNoGravado': f['imp_tot_conc'], + 'importeGravado': f['imp_neto'], + 'importeSubtotal': f['imp_subtotal'], # 'imp_iva': imp_iva, + 'importeOtrosTributos': f['tributos'] and f['imp_trib'] or None, + 'importeExento': f['imp_op_ex'], + 'fechaEmision': f['fecha_cbte'], + 'codigoMoneda': f['moneda_id'], 'cotizacionMoneda': f['moneda_ctz'], + 'codigoConcepto': f['concepto'], + 'observaciones': f['observaciones'], + 'fechaVencimientoPago': f.get('fecha_venc_pago'), + 'fechaServicioDesde': f.get('fecha_serv_desde'), + 'fechaServicioHasta': f.get('fecha_serv_hasta'), + 'arrayComprobantesAsociados': f['cbtes_asoc'] and [{'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro'], + 'cuit': cbte_asoc.get('cuit'), + }} for cbte_asoc in f['cbtes_asoc']] or None, + 'arrayOtrosTributos': f['tributos'] and [{'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': tributo['base_imp'], + 'importe': tributo['importe'], + }} for tributo in f['tributos']] or None, + 'arraySubtotalesIVA': f['iva'] and [{'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': iva['importe'], + }} for iva in f['iva']] or None, + 'arrayItems': f['detalles'] and [{'item': { + 'unidadesMtx': it['u_mtx'], + 'codigoMtx': it['cod_mtx'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'cantidad': it['qty'], + 'codigoUnidadMedida': it['umed'], + 'precioUnitario': it['precio'], + 'importeBonificacion': it['bonif'], + 'codigoCondicionIVA': it['iva_id'], + 'importeIVA': it['imp_iva'] if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': it['imp_subtotal'], + }} for it in f['detalles']] or None, + } + + # fecha de vencimiento opcional (igual al �ltimo d�a de vigencia del CAEA) + if 'fch_venc_cae' in f: + fact['fechaVencimiento'] = f['fch_venc_cae'] + + ret = self.client.informarComprobanteCAEA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + comprobanteCAEARequest=fact, + ) + + # Reprocesar en caso de error (verifica datos informado anteriormente) + if self.Reprocesar and ('arrayErrores' in ret): + for error in ret['arrayErrores']: + err_code = error['codigoDescripcion']['codigo'] + if ret['resultado'] == 'R' and err_code == 703: + # guardo los mensajes xml originales + xml_request = self.client.xml_request + xml_response = self.client.xml_response + cae = self.ConsultarComprobante(f['tipo_cbte'], f['punto_vta'], f['cbt_desde'], reproceso=True) + if cae == f['caea'] and self.EmisionTipo == 'CAEA': + self.Reproceso = 'S' + self.Resultado = 'A' # verificar O + return cae + self.Reproceso = 'N' + # reestablesco los mensajes xml originales + self.client.xml_request = xml_request + self.client.xml_response = xml_response + + self.Resultado = ret['resultado'] # u'A' + self.Errores = [] + if ret['resultado'] in ("A", "O"): + cbteresp = ret['comprobanteCAEAResponse'] + self.FchProceso = ret['fechaProceso'].strftime("%Y-%m-%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + if 'fechaVencimientoCAE' in cbteresp: + self.Vencimiento = cbteresp['fechaVencimientoCAE'].strftime("%Y-%m-%d") + else: + self.Vencimiento = "" + self.CAEA = str(cbteresp['CAEA']) # 60423794871430L + self.EmisionTipo = 'CAEA' + self.__analizar_errores(ret) + + for error in ret.get('arrayObservaciones', []): + self.Observaciones.append("%(codigo)s: %(descripcion)s" % ( + error['codigoDescripcion'])) + self.Obs = '\n'.join(self.Observaciones) + + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.CAEA + + @inicializar_y_capturar_excepciones + def InformarAjusteIVACAEA(self): + "Env�a la informaci�n del comprobante de ajuste de IVA emitidos" + f = self.factura + # contruyo la estructura a convertir en XML: + fact = { + 'codigoTipoDocumento': f['tipo_doc'], 'numeroDocumento': f['nro_doc'], + 'codigoTipoComprobante': f['tipo_cbte'], 'numeroPuntoVenta': f['punto_vta'], + 'numeroComprobante': f['cbt_desde'], 'numeroComprobante': f['cbt_hasta'], + 'codigoTipoAutorizacion': 'A', + 'codigoAutorizacion': f['caea'], + 'importeTotal': f['imp_total'], 'importeNoGravado': f['imp_tot_conc'], + 'importeGravado': f['imp_neto'], + 'importeSubtotal': f['imp_subtotal'], # 'imp_iva': imp_iva, + 'importeOtrosTributos': f['tributos'] and f['imp_trib'] or None, + 'importeExento': f['imp_op_ex'], + 'fechaEmision': f['fecha_cbte'], + 'codigoMoneda': f['moneda_id'], 'cotizacionMoneda': f['moneda_ctz'], + 'codigoConcepto': f['concepto'], + 'observaciones': f['observaciones'], + 'fechaVencimientoPago': f.get('fecha_venc_pago'), + 'fechaServicioDesde': f.get('fecha_serv_desde'), + 'fechaServicioHasta': f.get('fecha_serv_hasta'), + 'arrayComprobantesAsociados': f['cbtes_asoc'] and [{'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro'], + }} for cbte_asoc in f['cbtes_asoc']] or None, + 'arrayOtrosTributos': f['tributos'] and [{'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': tributo['base_imp'], + 'importe': tributo['importe'], + }} for tributo in f['tributos']] or None, + 'arraySubtotalesIVA': f['iva'] and [{'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': iva['importe'], + }} for iva in f['iva']] or None, + 'arrayItems': f['detalles'] and [{'item': { + 'unidadesMtx': it['u_mtx'], + 'codigoMtx': it['cod_mtx'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'cantidad': it['qty'], + 'codigoUnidadMedida': it['umed'], + 'precioUnitario': it['precio'], + 'importeBonificacion': it['bonif'], + 'codigoCondicionIVA': it['iva_id'], + 'importeIVA': it['imp_iva'] if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': it['imp_subtotal'], + }} for it in f['detalles']] or None, + } + + # fecha de vencimiento opcional (igual al �ltimo d�a de vigencia del CAEA) + if 'fch_venc_cae' in f: + fact['fechaVencimiento'] = f['fch_venc_cae'] + + ret = self.client.informarAjusteIVACAEA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + comprobanteCAEARequest=fact, + ) + + self.Resultado = ret['resultado'] # u'A' + if ret['resultado'] in ("A", "O"): + cbteresp = ret['comprobanteCAEAResponse'] + self.FchProceso = ret['fechaProceso'].strftime("%Y-%m-%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + # self. = cbteresp['cuit'] # 20267565393L + # self. = cbteresp['codigoTipoComprobante'] + if 'fechaVencimientoCAE' in cbteresp: + self.Vencimiento = cbteresp['fechaVencimientoCAE'].strftime("%Y-%m-%d") + else: + self.Vencimiento = "" + self.CAEA = str(cbteresp['CAEA']) # 60423794871430L + self.EmisionTipo = 'CAEA' + self.__analizar_errores(ret) + + for error in ret.get('arrayObservaciones', []): + self.Observaciones.append("%(codigo)s: %(descripcion)s" % ( + error['codigoDescripcion'])) + self.Obs = '\n'.join(self.Observaciones) + + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.CAE + + @inicializar_y_capturar_excepciones + def InformarCAEANoUtilizado(self, caea): + ret = self.client.informarCAEANoUtilizado( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + CAEA=caea, + ) + self.Resultado = ret['resultado'] # u'A' + if ret['resultado'] in ("A", "O"): + self.FchProceso = ret['fechaProceso'].strftime("%Y-%m-%d") + self.CAEA = str(ret['CAEA']) # 60423794871430L + self.EmisionTipo = 'CAEA' + self.__analizar_errores(ret) + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.Resultado + + @inicializar_y_capturar_excepciones + def InformarCAEANoUtilizadoPtoVta(self, caea, punto_vta): + ret = self.client.informarCAEANoUtilizadoPtoVta( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + CAEA=caea, + numeroPuntoVenta=punto_vta, + ) + self.Resultado = ret['resultado'] # u'A' + if ret['resultado'] in ("A", "O"): + self.FchProceso = ret['fechaProceso'].strftime("%Y-%m-%d") + self.CAEA = str(ret['CAEA']) # 60423794871430L + self.EmisionTipo = 'CAEA' + self.PuntoVenta = ret['numeroPuntoVenta'] # 4000 + self.__analizar_errores(ret) + if 'evento' in ret: + self.Evento = '%(codigo)s: %(descripcion)s' % ret['evento'] + return self.Resultado + + @inicializar_y_capturar_excepciones + def ConsultarUltimoComprobanteAutorizado(self, tipo_cbte, punto_vta): + ret = self.client.consultarUltimoComprobanteAutorizado( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + consultaUltimoComprobanteAutorizadoRequest={ + 'codigoTipoComprobante': tipo_cbte, + 'numeroPuntoVenta': punto_vta}, + ) + nro = ret.get('numeroComprobante') + self.__analizar_errores(ret) + self.CbteNro = nro + return nro is not None and str(nro) or 0 + + CompUltimoAutorizado = ConsultarUltimoComprobanteAutorizado + + @inicializar_y_capturar_excepciones + def ConsultarComprobante(self, tipo_cbte, punto_vta, cbte_nro, reproceso=False): + "Recuperar los datos completos de un comprobante ya autorizado" + ret = self.client.consultarComprobante( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + consultaComprobanteRequest={ + 'codigoTipoComprobante': tipo_cbte, + 'numeroPuntoVenta': punto_vta, + 'numeroComprobante': cbte_nro, + }, + ) + # diferencias si hay reproceso: + difs = [] + # analizo el resultado: + if 'comprobante' in ret: + cbteresp = ret['comprobante'] + if reproceso: + # verifico los campos registrados coincidan con los enviados: + f = self.factura + verificaciones = { + 'codigoTipoComprobante': f['tipo_cbte'], + 'numeroPuntoVenta': f['punto_vta'], + 'codigoConcepto': f['concepto'], + 'codigoTipoDocumento': f['tipo_doc'], + 'numeroDocumento': f['nro_doc'], + 'numeroComprobante': f['cbt_desde'], + 'numeroComprobante': f['cbt_hasta'], + 'fechaEmision': f['fecha_cbte'], + 'importeTotal': decimal.Decimal(str(f['imp_total'])), + 'importeNoGravado': decimal.Decimal(str(f['imp_tot_conc'])), + 'importeGravado': decimal.Decimal(str(f['imp_neto'])), + 'importeExento': decimal.Decimal(str(f['imp_op_ex'])), + 'importeOtrosTributos': f['tributos'] and decimal.Decimal(str(f['imp_trib'])) or None, + 'importeSubtotal': f['imp_subtotal'], + 'fechaServicioDesde': f.get('fecha_serv_desde'), + 'fechaServicioHasta': f.get('fecha_serv_hasta'), + 'fechaVencimientoPago': f.get('fecha_venc_pago'), + 'codigoMoneda': f['moneda_id'], + 'cotizacionMoneda': str(decimal.Decimal(str(f['moneda_ctz']))), + 'arrayItems': [ + {'item': { + 'unidadesMtx': it['u_mtx'], + 'codigoMtx': it['cod_mtx'], + 'codigo': it['codigo'], + 'descripcion': it['ds'], + 'cantidad': it['qty'] and decimal.Decimal(str(it['qty'])), + 'codigoUnidadMedida': it['umed'], + 'precioUnitario': it['precio'] is not None and decimal.Decimal(str(it['precio'])) or None, + # 'importeBonificacion': it['bonif'], + 'codigoCondicionIVA': decimal.Decimal(str(it['iva_id'])), + 'importeIVA': decimal.Decimal(str(it['imp_iva'])) if int(f['tipo_cbte']) not in (6, 7, 8) and it['imp_iva'] is not None else None, + 'importeItem': decimal.Decimal(str(it['imp_subtotal'])), + }} + for it in f['detalles']], + 'arrayComprobantesAsociados': [ + {'comprobanteAsociado': { + 'codigoTipoComprobante': cbte_asoc['tipo'], + 'numeroPuntoVenta': cbte_asoc['pto_vta'], + 'numeroComprobante': cbte_asoc['nro']}} + for cbte_asoc in f['cbtes_asoc']], + 'arrayOtrosTributos': [ + {'otroTributo': { + 'codigo': tributo['tributo_id'], + 'descripcion': tributo['desc'], + 'baseImponible': decimal.Decimal(str(tributo['base_imp'])), + 'importe': decimal.Decimal(str(tributo['importe'])), + }} + for tributo in f['tributos']], + 'arraySubtotalesIVA': [ + {'subtotalIVA': { + 'codigo': iva['iva_id'], + 'importe': decimal.Decimal(str(iva['importe'])), + }} + for iva in f['iva']], + } + verifica(verificaciones, cbteresp, difs) + if difs: + print("Diferencias:", difs) + self.log("Diferencias: %s" % difs) + self.FechaCbte = cbteresp['fechaEmision'].strftime("%Y/%m/%d") + self.CbteNro = cbteresp['numeroComprobante'] # 1L + self.PuntoVenta = cbteresp['numeroPuntoVenta'] # 4000 + self.Vencimiento = cbteresp['fechaVencimiento'].strftime("%Y/%m/%d") + self.ImpTotal = str(cbteresp['importeTotal']) + self.CAE = str(cbteresp['codigoAutorizacion']) # 60423794871430L + self.EmisionTipo = cbteresp['codigoTipoAutorizacion'] == 'A' and 'CAEA' or 'CAE' + self.__analizar_errores(ret) + if not difs: + return self.CAE + + @inicializar_y_capturar_excepciones + def ConsultarTiposComprobante(self): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarTiposComprobante( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposComprobante']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposDocumento(self): + ret = self.client.consultarTiposDocumento( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposDocumento']] + + @inicializar_y_capturar_excepciones + def ConsultarAlicuotasIVA(self): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarAlicuotasIVA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayAlicuotasIVA']] + + @inicializar_y_capturar_excepciones + def ConsultarCondicionesIVA(self): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarCondicionesIVA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayCondicionesIVA']] + + @inicializar_y_capturar_excepciones + def ConsultarMonedas(self): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarMonedas( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayMonedas']] + + @inicializar_y_capturar_excepciones + def ConsultarUnidadesMedida(self): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarUnidadesMedida( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayUnidadesMedida']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposTributo(self): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarTiposTributo( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposTributo']] + + @inicializar_y_capturar_excepciones + def ConsultarTiposDatosAdicionales(self): + "Este m�todo permite consultar los tipos de datos adicionales." + ret = self.client.consultarTiposDatosAdicionales( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + return ["%(codigo)s: %(descripcion)s" % p['codigoDescripcion'] + for p in ret['arrayTiposDatosAdicionales']] + + @inicializar_y_capturar_excepciones + def ConsultarCotizacionMoneda(self, moneda_id): + "Este m�todo permite consultar los tipos de comprobantes habilitados en este WS" + ret = self.client.consultarCotizacionMoneda( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codigoMoneda=moneda_id, + ) + self.__analizar_errores(ret) + if 'cotizacionMoneda' in ret: + return str(ret['cotizacionMoneda']) + + @inicializar_y_capturar_excepciones + def ConsultarPuntosVentaCAE(self, fmt="%(numeroPuntoVenta)s: bloqueado=%(bloqueado)s baja=%(fechaBaja)s"): + "Este m�todo permite consultar los puntos de venta habilitados para CAE en este WS" + res = self.client.consultarPuntosVentaCAE( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = [] + for p in res['arrayPuntosVenta']: + p = p['puntoVenta'] + if 'fechaBaja' not in p: + p['fechaBaja'] = "" + ret.append(fmt % p if fmt else p) + return ret + + @inicializar_y_capturar_excepciones + def ConsultarPuntosVentaCAEA(self, fmt="%(numeroPuntoVenta)s: bloqueado=%(bloqueado)s baja=%(fechaBaja)s"): + "Este m�todo permite consultar los puntos de venta habilitados para CAEA en este WS" + res = self.client.consultarPuntosVentaCAEA( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + ) + ret = [] + for p in res['arrayPuntosVenta']: + p = p['puntoVenta'] + if 'fechaBaja' not in p: + p['fechaBaja'] = "" + ret.append(fmt % p if fmt else p) + return ret + + @inicializar_y_capturar_excepciones + def ConsultarPtosVtaCAEANoInformados(self, caea): + "Este m�todo permite consultar que puntos de venta a�n no fueron informados para un CAEA determinado." + ret = self.client.consultarPtosVtaCAEANoInformados( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + CAEA=caea, + ) + return [" ".join([("%s=%s" % (k, v)) for k, v in list(p['puntoVenta'].items())]) + for p in ret['arrayPuntosVenta']] + + +def main(): + "Funci�n principal de pruebas (obtener CAE)" + import os + import time + + DEBUG = '--debug' in sys.argv + + # obteniendo el TA para pruebas + from .wsaa import WSAA + ta = WSAA().Autenticar("wsmtxca", "reingart.crt", "reingart.key") + + wsmtxca = WSMTXCA() + wsmtxca.SetTicketAcceso(ta) + wsmtxca.Cuit = "20267565393" + + cache = "" + if "--prod" in sys.argv: + wsdl = "https://serviciosjava.afip.gob.ar/wsmtxca/services/MTXCAService?wsdl" + else: + wsdl = WSDL + wsmtxca.Conectar(cache, wsdl, cacert="conf/afip_ca_info.crt") + + if "--dummy" in sys.argv: + print(wsmtxca.client.help("dummy")) + wsmtxca.Dummy() + print("AppServerStatus", wsmtxca.AppServerStatus) + print("DbServerStatus", wsmtxca.DbServerStatus) + print("AuthServerStatus", wsmtxca.AuthServerStatus) + + if "--prueba" in sys.argv: + # print wsmtxca.client.help("autorizarComprobante").encode("latin1") + try: + tipo_cbte = 1 + punto_vta = 4000 + cbte_nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + fecha = datetime.datetime.now().strftime("%Y-%m-%d") + concepto = 3 + tipo_doc = 80 + nro_doc = "30000000007" + cbte_nro = int(cbte_nro) + 1 + cbt_desde = cbte_nro + cbt_hasta = cbt_desde + imp_total = "122.00" + imp_tot_conc = "0.00" + imp_neto = "100.00" + imp_trib = "1.00" + imp_op_ex = "0.00" + imp_subtotal = "100.00" + fecha_cbte = fecha + fecha_venc_pago = fecha + # Fechas del per�odo del servicio facturado (solo si concepto = 1?) + fecha_serv_desde = fecha + fecha_serv_hasta = fecha + moneda_id = 'PES' + moneda_ctz = '1.000' + obs = "Observaciones Comerciales, libre" + if '--caea' in sys.argv: + periodo = fecha.replace("-", "")[:6] + orden = 1 if fecha[-2:] < 16 else 2 + caea = wsmtxca.ConsultarCAEA(periodo, orden) + else: + caea = None + + wsmtxca.CrearFactura(concepto, tipo_doc, nro_doc, tipo_cbte, punto_vta, + cbt_desde, cbt_hasta, imp_total, imp_tot_conc, imp_neto, + imp_subtotal, imp_trib, imp_op_ex, fecha_cbte, fecha_venc_pago, + fecha_serv_desde, fecha_serv_hasta, # -- + moneda_id, moneda_ctz, obs, caea) + + #tipo = 19 + #pto_vta = 2 + #nro = 1234 + #wsmtxca.AgregarCmpAsoc(tipo, pto_vta, nro) + + tributo_id = 99 + desc = 'Impuesto Municipal Matanza' + base_imp = "100.00" + alic = "1.00" + importe = "1.00" + wsmtxca.AgregarTributo(tributo_id, desc, base_imp, alic, importe) + + iva_id = 5 # 21% + base_imp = 100 + importe = 21 + wsmtxca.AgregarIva(iva_id, base_imp, importe) + + u_mtx = 123456 + cod_mtx = 1234567890123 + codigo = "P0001" + ds = "Descripcion del producto P0001" + qty = 2.00 + umed = 7 + precio = 100.00 + bonif = 0.00 + iva_id = 5 + imp_iva = 42.00 + imp_subtotal = 242.00 + wsmtxca.AgregarItem(u_mtx, cod_mtx, codigo, ds, qty, umed, precio, bonif, + iva_id, imp_iva, imp_subtotal) + + if not "--caea" in sys.argv: + # ejemplo descuento (sin precio unitario) + wsmtxca.AgregarItem(None, None, None, 'bonificacion', + None, 99, None, None, 5, -21, -121) + # ejemplo item solo descripci�n: + # wsmtxca.AgregarItem(u_mtx, cod_mtx, codigo, ds, 1, umed, + # 0, 0, iva_id, 0, 0) + + # datos de Factura de Cr�dito Electr�nica MiPyMEs (FCE): + if '--fce' in sys.argv: + wsmtxca.AgregarOpcional(21, "2850590940090418135201") # CBU + + print(wsmtxca.factura) + + if '--caea' in sys.argv: + wsmtxca.InformarComprobanteCAEA() + else: + wsmtxca.AutorizarComprobante() + + print("Resultado", wsmtxca.Resultado) + print("CAE", wsmtxca.CAE) + print("Vencimiento", wsmtxca.Vencimiento) + print("Reproceso", wsmtxca.Reproceso) + + print(wsmtxca.Excepcion) + print(wsmtxca.ErrMsg) + + cae = wsmtxca.CAE + + if cae: + + wsmtxca.ConsultarComprobante(tipo_cbte, punto_vta, cbte_nro) + print("CAE consulta", wsmtxca.CAE, wsmtxca.CAE == cae) + print("NRO consulta", wsmtxca.CbteNro, wsmtxca.CbteNro == cbte_nro) + print("TOTAL consulta", wsmtxca.ImpTotal, wsmtxca.ImpTotal == imp_total) + + wsmtxca.AnalizarXml("XmlResponse") + assert wsmtxca.ObtenerTagXml('codigoAutorizacion') == str(wsmtxca.CAE) + assert wsmtxca.ObtenerTagXml('codigoConcepto') == str(concepto) + assert wsmtxca.ObtenerTagXml('arrayItems', 0, 'item', 'unidadesMtx') == '123456' + + except BaseException: + print(wsmtxca.XmlRequest) + print(wsmtxca.XmlResponse) + print(wsmtxca.ErrCode) + print(wsmtxca.ErrMsg) + + if "--ajustar" in sys.argv: + # print wsmtxca.client.help("autorizarComprobante").encode("latin1") + try: + tipo_cbte = 2 + punto_vta = 4000 + cbte_nro = wsmtxca.ConsultarUltimoComprobanteAutorizado(tipo_cbte, punto_vta) + fecha = datetime.datetime.now().strftime("%Y-%m-%d") + concepto = 3 + tipo_doc = 80 + nro_doc = "30000000007" + cbte_nro = int(cbte_nro) + 1 + cbt_desde = cbte_nro + cbt_hasta = cbt_desde + imp_total = "21.00" + imp_tot_conc = "0.00" + imp_neto = None + imp_trib = "0.00" + imp_op_ex = "0.00" + imp_subtotal = "0.00" + fecha_cbte = fecha + fecha_venc_pago = fecha + # Fechas del per�odo del servicio facturado (solo si concepto = 1?) + fecha_serv_desde = fecha + fecha_serv_hasta = fecha + moneda_id = 'PES' + moneda_ctz = '1.000' + obs = "Observaciones Comerciales, libre" + caea = "24163778394093" + fch_venc_cae = None + + wsmtxca.CrearFactura(concepto, tipo_doc, nro_doc, tipo_cbte, punto_vta, + cbt_desde, cbt_hasta, imp_total, imp_tot_conc, imp_neto, + imp_subtotal, imp_trib, imp_op_ex, fecha_cbte, fecha_venc_pago, + fecha_serv_desde, fecha_serv_hasta, # -- + moneda_id, moneda_ctz, obs, caea, fch_venc_cae) + + iva_id = 5 # 21% + base_imp = 100 + importe = 21 + wsmtxca.AgregarIva(iva_id, base_imp, importe) + + u_mtx = 1 + cod_mtx = 7790001001139 + codigo = None + ds = "Descripcion del producto P0001" + qty = None + umed = 7 + precio = None + bonif = None + iva_id = 5 + imp_iva = 21.00 + imp_subtotal = 21.00 + wsmtxca.AgregarItem(u_mtx, cod_mtx, codigo, ds, qty, umed, precio, bonif, + iva_id, imp_iva, imp_subtotal) + + print(wsmtxca.factura) + + if not caea: + wsmtxca.AutorizarAjusteIVA() + else: + wsmtxca.InformarAjusteIVACAEA() + + print("Resultado", wsmtxca.Resultado) + print("CAE", wsmtxca.CAE) + print("Vencimiento", wsmtxca.Vencimiento) + + print(wsmtxca.Excepcion) + print(wsmtxca.ErrMsg) + + except BaseException: + print(wsmtxca.XmlRequest) + print(wsmtxca.XmlResponse) + print(wsmtxca.ErrCode) + print(wsmtxca.ErrMsg) + raise + + if "--parametros" in sys.argv: + print(wsmtxca.ConsultarTiposComprobante()) + print(wsmtxca.ConsultarTiposDocumento()) + print(wsmtxca.ConsultarAlicuotasIVA()) + print(wsmtxca.ConsultarCondicionesIVA()) + print(wsmtxca.ConsultarMonedas()) + print(wsmtxca.ConsultarUnidadesMedida()) + print(wsmtxca.ConsultarTiposTributo()) + print(wsmtxca.ConsultarTiposDatosAdicionales()) + + if "--cotizacion" in sys.argv: + print(wsmtxca.ConsultarCotizacionMoneda('DOL')) + + if "--solicitar-caea" in sys.argv: + periodo = sys.argv[sys.argv.index("--solicitar-caea") + 1] + orden = sys.argv[sys.argv.index("--solicitar-caea") + 2] + + if DEBUG: + print("Consultando CAEA para periodo %s orden %s" % (periodo, orden)) + + caea = wsmtxca.ConsultarCAEA(periodo, orden) + if not caea: + print("Solicitando CAEA para periodo %s orden %s" % (periodo, orden)) + caea = wsmtxca.SolicitarCAEA(periodo, orden) + + print("CAEA:", caea) + + if wsmtxca.Errores: + print("Errores:") + for error in wsmtxca.Errores: + print(error) + + if DEBUG: + print("periodo:", wsmtxca.Periodo) + print("orden:", wsmtxca.Orden) + print("fch_vig_desde:", wsmtxca.FchVigDesde) + print("fch_vig_hasta:", wsmtxca.FchVigHasta) + print("fch_tope_inf:", wsmtxca.FchTopeInf) + print("fch_proceso:", wsmtxca.FchProceso) + + +# busco el directorio de instalaci�n (global para que no cambie si usan otra dll) +INSTALL_DIR = WSMTXCA.InstallDir = get_install_dir() + + +if __name__ == '__main__': + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSMTXCA) + else: + main() diff --git a/app/pyafipws/wsremcarne.py b/app/pyafipws/wsremcarne.py new file mode 100644 index 0000000000000000000000000000000000000000..9ad87b67c7b07965f119319f97e90d8d32170cd2 --- /dev/null +++ b/app/pyafipws/wsremcarne.py @@ -0,0 +1,691 @@ +#!/usr/bin/python +# -*- coding: utf8 -*- +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 3, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTIBILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. + +import base64 +import time +import sys +import os +from .utils import json, BaseWS, inicializar_y_capturar_excepciones, get_install_dir +from . import utils +from pysimplesoap.client import SoapFault +import traceback +from .utils import date +"""Módulo para obtener Remito Electronico Carnico: +del web service WSRemCarne versión 1.0 de AFIP (RG4256/18 y RG4303/18) +""" + +__author__ = "Mariano Reingart " +__copyright__ = "Copyright (C) 2018 Mariano Reingart" +__license__ = "LGPL 3.0" +__version__ = "1.01a" + +LICENCIA = """ +wsremcarne.py: Interfaz para generar Remito Electrónico Cárnico AFIP v1.0 +Remito de Carnes y subproductos derivados de la faena de bovinos y porcinos +Resolución General 4256/18 y Resolución General 4303/18. +Copyright (C) 2018 Mariano Reingart reingart@gmail.com +http://www.sistemasagiles.com.ar/trac/wiki/RemitoElectronicoCarnico + +Este progarma es software libre, se entrega ABSOLUTAMENTE SIN GARANTIA +y es bienvenido a redistribuirlo bajo la licencia GPLv3. + +Para información adicional sobre garantía, soporte técnico comercial +e incorporación/distribución en programas propietarios ver PyAfipWs: +http://www.sistemasagiles.com.ar/trac/wiki/PyAfipWs +""" + +AYUDA = """ +Opciones: + --ayuda: este mensaje + + --debug: modo depuración (detalla y confirma las operaciones) + --prueba: genera y autoriza una rec de prueba (no usar en producción!) + --xml: almacena los requerimientos y respuestas XML (depuración) + --dummy: consulta estado de servidores + + --generar: generar un remito + --emitir: emite un remito + --anular: anula un remito + --autorizar: autoriza un remito + + --ult: consulta ultimo nro remito emitido + --consultar: consulta un remito generado + + --tipos_comprobante: tabla de parametros para tipo de comprobante + --tipos_contingencia: tipo de contingencia que puede reportar + --tipos_categoria_emisor: tipos de categorías de emisor + --tipos_categoria_receptor: tipos de categorías de receptor + --tipos_estados: estados posibles en los que puede estar un remito cárnico + --grupos_carne' grupos de los distintos tipos de cortes de carne + --tipos_carne': tipos de corte de carne + --codigos_domicilio: codigos de depositos habilitados para el cuit + +Ver wsremcarne.ini para parámetros de configuración (URL, certificados, etc.)" +""" + + +# importo funciones compartidas: + + +# constantes de configuración (producción/homologación): + +WSDL = ["https://serviciosjava.afip.gob.ar/wsremcarne/RemCarneService?wsdl", + "https://fwshomo.afip.gov.ar/wsremcarne/RemCarneService?wsdl"] + +DEBUG = False +XML = False +CONFIG_FILE = "wsremcarne.ini" +HOMO = False +ENCABEZADO = [] + + +class WSRemCarne(BaseWS): + "Interfaz para el WebService de Remito Electronico Carnico (Version 3)" + _public_methods_ = ['Conectar', 'Dummy', 'SetTicketAcceso', 'DebugLog', + 'GenerarRemito', 'EmitirRemito', 'AutorizarRemito', 'AnularRemito', 'ConsultarRemito', + 'InformarContingencia', 'ModificarViaje', 'RegistrarRecepcion', 'ConsultarUltimoRemitoEmitido', + 'CrearRemito', 'AgregarViaje', 'AgregarVehiculo', 'AgregarMercaderia', + 'AgregarDatosAutorizacion', 'AgregarContingencia', + 'ConsultarTiposCarne', 'ConsultarTiposCategoriaEmisor', 'ConsultarTiposCategoriaReceptor', + 'ConsultarTiposComprobante', 'ConsultarTiposContingencia', 'ConsultarTiposEstado', + 'ConsultarCodigosDomicilio', 'ConsultarGruposCarne, ConsultarPuntosEmision', + 'SetParametros', 'SetParametro', 'GetParametro', 'AnalizarXml', 'ObtenerTagXml', 'LoadTestXML', + ] + _public_attrs_ = ['XmlRequest', 'XmlResponse', 'Version', 'Traceback', 'Excepcion', 'LanzarExcepciones', + 'Token', 'Sign', 'Cuit', 'AppServerStatus', 'DbServerStatus', 'AuthServerStatus', + 'CodRemito', 'TipoComprobante', 'PuntoEmision', + 'NroRemito', 'CodAutorizacion', 'FechaVencimiento', 'FechaEmision', 'Estado', 'Resultado', 'QR', + 'ErrCode', 'ErrMsg', 'Errores', 'ErroresFormato', 'Observaciones', 'Obs', 'Evento', 'Eventos', + ] + _reg_progid_ = "WSRemCarne" + _reg_clsid_ = "{71DB0CB9-2ED7-4226-A1E6-C3FA7FB18F41}" + + # Variables globales para BaseWS: + HOMO = HOMO + WSDL = WSDL[HOMO] + LanzarExcepciones = False + Version = "%s %s" % (__version__, HOMO and 'Homologación' or '') + + def Conectar(self, *args, **kwargs): + ret = BaseWS.Conectar(self, *args, **kwargs) + return ret + + def inicializar(self): + self.AppServerStatus = self.DbServerStatus = self.AuthServerStatus = None + self.CodRemito = self.TipoComprobante = self.PuntoEmision = None + self.NroRemito = self.CodAutorizacion = self.FechaVencimiento = self.FechaEmision = None + self.Estado = self.Resultado = self.QR = None + self.Errores = [] + self.ErroresFormato = [] + self.Observaciones = [] + self.Eventos = [] + self.Evento = self.ErrCode = self.ErrMsg = self.Obs = "" + + def __analizar_errores(self, ret): + "Comprueba y extrae errores si existen en la respuesta XML" + self.Errores = [err['codigoDescripcion'] for err in ret.get('arrayErrores', [])] + self.ErroresFormato = [err['codigoDescripcionString'] for err in ret.get('arrayErroresFormato', [])] + errores = self.Errores + self.ErroresFormato + self.ErrCode = ' '.join(["%(codigo)s" % err for err in errores]) + self.ErrMsg = '\n'.join(["%(codigo)s: %(descripcion)s" % err for err in errores]) + + def __analizar_observaciones(self, ret): + "Comprueba y extrae observaciones si existen en la respuesta XML" + self.Observaciones = [obs["codigoDescripcion"] for obs in ret.get('arrayObservaciones', [])] + self.Obs = '\n'.join(["%(codigo)s: %(descripcion)s" % obs for obs in self.Observaciones]) + + def __analizar_evento(self, ret): + "Comprueba y extrae el wvento informativo si existen en la respuesta XML" + evt = ret.get('evento') + if evt: + self.Eventos = [evt] + self.Evento = "%(codigo)s: %(descripcion)s" % evt + + @inicializar_y_capturar_excepciones + def CrearRemito(self, tipo_comprobante, punto_emision, tipo_movimiento, categoria_emisor, cuit_titular_mercaderia, cod_dom_origen, + tipo_receptor, categoria_receptor=None, cuit_receptor=None, cuit_depositario=None, + cod_dom_destino=None, cod_rem_redestinar=None, cod_remito=None, estado=None, + **kwargs): + "Inicializa internamente los datos de un remito para autorizar" + self.remito = {'tipoComprobante': tipo_comprobante, 'puntoEmision': punto_emision, 'categoriaEmisor': categoria_emisor, + 'cuitTitularMercaderia': cuit_titular_mercaderia, 'cuitDepositario': cuit_depositario, + 'tipoReceptor': tipo_receptor, 'categoriaReceptor': categoria_receptor, 'cuitReceptor': cuit_receptor, + 'codDomOrigen': cod_dom_origen, 'codDomDestino': cod_dom_destino, 'tipoMovimiento': tipo_movimiento, + 'estado': estado, 'codRemito': cod_remito, + 'codRemRedestinado': cod_rem_redestinar, + 'arrayMercaderias': [], 'arrayContingencias': [], + } + return True + + @inicializar_y_capturar_excepciones + def AgregarViaje(self, cuit_transportista=None, cuit_conductor=None, fecha_inicio_viaje=None, distancia_km=None, **kwargs): + "Agrega la información referente al viaje del remito electrónico cárnico" + self.remito['viaje'] = {'cuitTransportista': cuit_transportista, + 'cuitConductor': cuit_conductor, + 'fechaInicioViaje': fecha_inicio_viaje, + 'distanciaKm': distancia_km, + 'vehiculo': {} + } + return True + + @inicializar_y_capturar_excepciones + def AgregarVehiculo(self, dominio_vehiculo=None, dominio_acoplado=None, **kwargs): + "Agrega la información referente al vehiculo usado en el viaje del remito electrónico cárnico" + self.remito['viaje']['vehiculo'] = {'dominioVehiculo': dominio_vehiculo, 'dominioAcoplado': dominio_acoplado} + return True + + @inicializar_y_capturar_excepciones + def AgregarMercaderia(self, orden=None, cod_tipo_prod=None, cantidad=None, unidades=None, tropa=None, **kwargs): + "Agrega la información referente a la mercadería del remito electrónico cárnico" + mercaderia = dict(orden=orden, tropa=tropa, codTipoProd=cod_tipo_prod, cantidad=cantidad, unidadMedida=unidades) + self.remito['arrayMercaderias'].append(dict(mercaderia=mercaderia)) + return True + + @inicializar_y_capturar_excepciones + def AgregarDatosAutorizacion(self, nro_remito=None, cod_autorizacion=None, fecha_emision=None, fecha_vencimiento=None, **kwargs): + "Agrega la información referente a los datos de autorización del remito electrónico cárnico" + self.remito['datosEmision'] = dict(nroRemito=nro_remito, codAutorizacion=cod_autorizacion, + fechaEmision=fecha_emision, fechaVencimiento=fecha_vencimiento, + ) + return True + + @inicializar_y_capturar_excepciones + def AgregarContingencias(self, tipo=None, observacion=None, **kwargs): + "Agrega la información referente a los opcionales de la liq. seq." + contingencia = dict(tipoContingencia=tipo, observacion=observacion) + self.remito['arrayContingencias'].append(dict(contingencia=contingencia)) + return True + + @inicializar_y_capturar_excepciones + def GenerarRemito(self, id_req, archivo="qr.png"): + "Informar los datos necesarios para la generación de un remito nuevo" + if not self.remito['arrayContingencias']: + del self.remito['arrayContingencias'] + response = self.client.generarRemito( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + idReq=id_req, remito=self.remito) + ret = response.get("generarRemitoReturn") + if ret: + self.__analizar_errores(ret) + self.__analizar_observaciones(ret) + self.__analizar_evento(ret) + self.AnalizarRemito(ret, archivo) + return bool(self.CodRemito) + + def AnalizarRemito(self, ret, archivo=None): + "Extrae el resultado del remito, si existen en la respuesta XML" + if ret: + self.CodRemito = ret.get("codRemito") + self.TipoComprobante = ret.get("tipoComprobante") + self.PuntoEmision = ret.get("puntoEmision") + datos_aut = ret.get('datosAutorizacion') + if datos_aut: + self.NroRemito = datos_aut.get('nroRemito') + self.CodAutorizacion = datos_aut.get('codAutorizacion') + self.FechaEmision = datos_aut.get('fechaEmision') + self.FechaVencimiento = datos_aut.get('fechaVencimiento') + self.Estado = ret.get('estado') + self.Resultado = ret.get('resultado') + self.QR = ret.get('qr') or "" + if archivo: + qr = base64.b64decode(self.QR) + f = open(archivo, "wb") + f.write(qr) + f.close() + + @inicializar_y_capturar_excepciones + def EmitirRemito(self, archivo="qr.png"): + "Emitir Remitos que se encuentren en estado Pendiente de Emitir." + response = self.client.emitirRemito( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codRemito=self.remito['codRemito'], + viaje=self.remito.get('viaje')) + ret = response.get("emitirRemitoReturn") + if ret: + self.__analizar_errores(ret) + self.__analizar_observaciones(ret) + self.__analizar_evento(ret) + self.AnalizarRemito(ret, archivo) + return bool(self.CodRemito) + + @inicializar_y_capturar_excepciones + def AutorizarRemito(self, archivo="qr.png"): + "Autorizar o denegar un remito (cuando corresponde autorizacion) por parte del titular/depositario" + response = self.client.autorizarRemito( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codRemito=self.remito['codRemito'], + estado=self.remito['estado']) + ret = response.get("autorizarRemitoReturn") + if ret: + self.__analizar_errores(ret) + self.__analizar_observaciones(ret) + self.__analizar_evento(ret) + self.AnalizarRemito(ret, archivo) + return bool(self.CodRemito) + + @inicializar_y_capturar_excepciones + def AnularRemito(self): + "Anular un remito generado que aún no haya sido emitido" + response = self.client.anularRemito( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codRemito=self.remito['codRemito']) + ret = response.get("anularRemitoReturn") + if ret: + self.__analizar_errores(ret) + self.__analizar_observaciones(ret) + self.__analizar_evento(ret) + self.AnalizarRemito(ret) + return bool(self.CodRemito) + + @inicializar_y_capturar_excepciones + def ConsultarUltimoRemitoEmitido(self, tipo_comprobante=995, punto_emision=1): + "Obtener el último número de remito que se emitió por tipo de comprobante y punto de emisión" + response = self.client.consultarUltimoRemitoEmitido( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + tipoComprobante=tipo_comprobante, + puntoEmision=punto_emision) + ret = response.get("consultarUltimoRemitoReturn", {}) + id_req = ret.get("idReq", 0) + rec = ret.get("remito", {}) + self.__analizar_errores(ret) + self.__analizar_observaciones(ret) + self.__analizar_evento(ret) + self.AnalizarRemito(rec) + return id_req + + @inicializar_y_capturar_excepciones + def ConsultarRemito(self, cod_remito=None, id_req=None, + tipo_comprobante=None, punto_emision=None, nro_comprobante=None): + "Obtener los datos de un remito generado" + print((self.client.help("consultarRemito"))) + response = self.client.consultarRemito( + authRequest={'token': self.Token, 'sign': self.Sign, 'cuitRepresentada': self.Cuit}, + codRemito=cod_remito, + idReq=id_req, + tipoComprobante=tipo_comprobante, + puntoEmision=punto_emision, + nroComprobante=nro_comprobante) + ret = response.get("consultarRemitoReturn", {}) + id_req = ret.get("idReq", 0) + self.remito = rec = ret.get("remito", {}) + self.__analizar_errores(ret) + self.__analizar_observaciones(ret) + self.__analizar_evento(ret) + self.AnalizarRemito(rec) + return id_req + + @inicializar_y_capturar_excepciones + def Dummy(self): + "Obtener el estado de los servidores de la AFIP" + results = self.client.dummy()['dummyReturn'] + self.AppServerStatus = str(results['appserver']) + self.DbServerStatus = str(results['dbserver']) + self.AuthServerStatus = str(results['authserver']) + + @inicializar_y_capturar_excepciones + def ConsultarTiposComprobante(self, sep="||"): + "Obtener el código y descripción para tipo de comprobante" + ret = self.client.consultarTiposComprobante( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + )['consultarTiposComprobanteReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayTiposComprobante', []) + lista = [it['codigoDescripcion'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarTiposContingencia(self, sep="||"): + "Obtener el código y descripción para cada tipo de contingencia que puede reportar" + ret = self.client.consultarTiposContingencia( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + )['consultarTiposContingenciaReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayTiposContingencia', []) + lista = [it['codigoDescripcion'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarTiposCategoriaEmisor(self, sep="||"): + "Obtener el código y descripción para tipos de categorías de emisor" + ret = self.client.consultarTiposCategoriaEmisor( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + )['consultarCategoriasEmisorReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayCategoriasEmisor', []) + lista = [it['codigoDescripcionString'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarTiposCategoriaReceptor(self, sep="||"): + "Obtener el código y descripción para cada tipos de categorías de receptor" + ret = self.client.consultarTiposCategoriaReceptor( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + )['consultarCategoriasReceptorReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayCategoriasReceptor', []) + lista = [it['codigoDescripcionString'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarTiposEstado(self, sep="||"): + "Obtener el código y descripción para cada estado posibles en los que puede estar un remito cárnico" + ret = self.client.consultarTiposEstado( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + )['consultarTiposEstadoReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayTiposEstado', []) + lista = [it['codigoDescripcionString'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarGruposCarne(self, sep="||"): + "Obtener el código y descripción para los grupos de los distintos tipos de cortes de carne" + ret = self.client.consultarGruposCarne( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + )['consultarGruposCarneReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayGruposCarne', []) + lista = [it['codigoDescripcionString'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarTiposCarne(self, cod_grupo_carne=1, sep="||"): + "Obtener el código y descripción para tipos de corte de carne" + ret = self.client.consultarTiposCarne( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + codGrupoCarne=cod_grupo_carne, + )['consultarTiposCarneReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayTiposCarne', []) + lista = [it['codigoDescripcionString'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + @inicializar_y_capturar_excepciones + def ConsultarCodigosDomicilio(self, cuit_titular=1, sep="||"): + "Obtener el código de depositos que tiene habilitados para operar el cuit informado" + ret = self.client.consultarCodigosDomicilio( + authRequest={ + 'token': self.Token, 'sign': self.Sign, + 'cuitRepresentada': self.Cuit, }, + cuitTitularDomicilio=cuit_titular, + )['consultarCodigosDomicilioReturn'] + self.__analizar_errores(ret) + array = ret.get('arrayDomicilios', []) + lista = [it['codigoDescripcion'] for it in array] + return [("%s {codigo} %s {descripcion} %s" % (sep, sep, sep)).format(**it) if sep else it for it in lista] + + +# busco el directorio de instalación (global para que no cambie si usan otra dll) +if not hasattr(sys, "frozen"): + basepath = __file__ +elif sys.frozen == 'dll': + import win32api + basepath = win32api.GetModuleFileName(sys.frozendllhandle) +else: + basepath = sys.executable +INSTALL_DIR = WSRemCarne.InstallDir = get_install_dir() + + +if __name__ == '__main__': + if '--ayuda' in sys.argv: + print(LICENCIA) + print(AYUDA) + sys.exit(0) + + if "--register" in sys.argv or "--unregister" in sys.argv: + import win32com.server.register + win32com.server.register.UseCommandLine(WSRemCarne) + sys.exit(0) + + from configparser import SafeConfigParser + + try: + + if "--version" in sys.argv: + print("Versión: ", __version__) + + for arg in sys.argv[1:]: + if arg.startswith("--"): + break + print("Usando configuración:", arg) + CONFIG_FILE = arg + + config = SafeConfigParser() + config.read(CONFIG_FILE) + CERT = config.get('WSAA', 'CERT') + PRIVATEKEY = config.get('WSAA', 'PRIVATEKEY') + CUIT = config.get('WSRemCarne', 'CUIT') + ENTRADA = config.get('WSRemCarne', 'ENTRADA') + SALIDA = config.get('WSRemCarne', 'SALIDA') + + if config.has_option('WSAA', 'URL') and not HOMO: + wsaa_url = config.get('WSAA', 'URL') + else: + wsaa_url = None + if config.has_option('WSRemCarne', 'URL') and not HOMO: + wsremcarne_url = config.get('WSRemCarne', 'URL') + else: + wsremcarne_url = WSDL[HOMO] + + if config.has_section('DBF'): + conf_dbf = dict(config.items('DBF')) + if DEBUG: + print("conf_dbf", conf_dbf) + else: + conf_dbf = {} + + DEBUG = '--debug' in sys.argv + XML = '--xml' in sys.argv + + if DEBUG: + print("Usando Configuración:") + print("wsaa_url:", wsaa_url) + print("wsremcarne_url:", wsremcarne_url) + + # obteniendo el TA + from .wsaa import WSAA + wsaa = WSAA() + ta = wsaa.Autenticar("wsremcarne", CERT, PRIVATEKEY, wsaa_url, debug=DEBUG) + if not ta: + sys.exit("Imposible autenticar con WSAA: %s" % wsaa.Excepcion) + + # cliente soap del web service + wsremcarne = WSRemCarne() + wsremcarne.Conectar(wsdl=wsremcarne_url) + wsremcarne.SetTicketAcceso(ta) + wsremcarne.Cuit = CUIT + ok = None + + if '--dummy' in sys.argv: + ret = wsremcarne.Dummy() + print("AppServerStatus", wsremcarne.AppServerStatus) + print("DbServerStatus", wsremcarne.DbServerStatus) + print("AuthServerStatus", wsremcarne.AuthServerStatus) + sys.exit(0) + + if '--ult' in sys.argv: + try: + pto_emision = int(sys.argv[sys.argv.index("--ult") + 1]) + except IndexError as ValueError: + pto_emision = 1 + try: + tipo_cbte = int(sys.argv[sys.argv.index("--ult") + 1]) + except IndexError as ValueError: + tipo_comprobante = 995 + rec = {} + print("Consultando ultimo remito pto_emision=%s tipo_comprobante=%s" % (pto_emision, tipo_comprobante)) + ok = wsremcarne.ConsultarUltimoRemitoEmitido(tipo_comprobante, pto_emision) + if wsremcarne.Excepcion: + print("EXCEPCION:", wsremcarne.Excepcion, file=sys.stderr) + if DEBUG: + print(wsremcarne.Traceback, file=sys.stderr) + print("Ultimo Nro de Remito", wsremcarne.NroRemito) + print("Errores:", wsremcarne.Errores) + + if '--consultar' in sys.argv: + try: + cod_remito = sys.argv[sys.argv.index("--consultar") + 1] + except IndexError as ValueError: + cod_remito = None + rec = {} + print("Consultando remito cod_remito=%s" % (cod_remito, )) + ok = wsremcarne.ConsultarRemito(cod_remito) + if wsremcarne.Excepcion: + print("EXCEPCION:", wsremcarne.Excepcion, file=sys.stderr) + if DEBUG: + print(wsremcarne.Traceback, file=sys.stderr) + print("Ultimo Nro de Remito", wsremcarne.NroRemito) + print("Errores:", wsremcarne.Errores) + if DEBUG: + import pprint + pprint.pprint(wsremcarne.remito) + + if '--prueba' in sys.argv: + rec = dict(tipo_comprobante=995, punto_emision=1, categoria_emisor=1, + tipo_movimiento='ENV', # ENV: Envio Normal, PLA: Retiro en planta, REP: Reparto, RED: Redestino + cuit_titular_mercaderia='20222222223', cod_dom_origen=1, + tipo_receptor='EM', # 'EM': DEPOSITO EMISOR, 'MI': MERCADO INTERNO, 'RP': REPARTO + categoria_receptor=1, id_req=int(time.time()), + cuit_receptor='20111111112', cuit_depositario=None, + cod_dom_destino=1, cod_rem_redestinar=None, + cod_remito=30, + ) + if "--autorizar" in sys.argv: + rec["estado"] = 'A' # 'A': Autorizar, 'D': Denegar + rec['viaje'] = dict(cuit_transportista='20333333334', cuit_conductor='20333333334', + fecha_inicio_viaje='2018-10-01', distancia_km=999) + rec['viaje']['vehiculo'] = dict(dominio_vehiculo='AAA000', dominio_acoplado='ZZZ000') + rec['mercaderias'] = [dict(orden=1, tropa=1, cod_tipo_prod='2.13', cantidad=10, unidades=1)] + rec['datos_autorizacion'] = None # dict(nro_remito=None, cod_autorizacion=None, fecha_emision=None, fecha_vencimiento=None) + rec['contingencias'] = [dict(tipo=1, observacion="anulacion")] + with open(ENTRADA, "w") as archivo: + json.dump(rec, archivo, sort_keys=True, indent=4) + + if '--cargar' in sys.argv: + with open(ENTRADA, "r") as archivo: + rec = json.load(archivo) + wsremcarne.CrearRemito(**rec) + wsremcarne.AgregarViaje(**rec['viaje']) + wsremcarne.AgregarVehiculo(**rec['viaje']['vehiculo']) + for mercaderia in rec['mercaderias']: + wsremcarne.AgregarMercaderia(**mercaderia) + datos_aut = rec['datos_autorizacion'] + if datos_aut: + wsremcarne.AgregarDatosAutorizacion(**datos_aut) + for contingencia in rec['contingencias']: + wsremcarne.AgregarContingencias(**contingencia) + + if '--generar' in sys.argv: + if '--testing' in sys.argv: + wsremcarne.LoadTestXML("tests/xml/wsremcarne_generar_response_ok_beta.xml") # cargo respuesta + + ok = wsremcarne.GenerarRemito(id_req=rec['id_req']) + + if '--emitir' in sys.argv: + ok = wsremcarne.EmitirRemito() + + if '--autorizar' in sys.argv: + ok = wsremcarne.AutorizarRemito() + + if '--anular' in sys.argv: + ok = wsremcarne.AnularRemito() + + if ok is not None: + print("Resultado: ", wsremcarne.Resultado) + print("Cod Remito: ", wsremcarne.CodRemito) + if wsremcarne.CodAutorizacion: + print("Numero Remito: ", wsremcarne.NroRemito) + print("Cod Autorizacion: ", wsremcarne.CodAutorizacion) + print("Fecha Emision", wsremcarne.FechaEmision) + print("Fecha Vencimiento", wsremcarne.FechaVencimiento) + print("Estado: ", wsremcarne.Estado) + print("Observaciones: ", wsremcarne.Observaciones) + print("Errores:", wsremcarne.Errores) + print("Errores Formato:", wsremcarne.ErroresFormato) + print("Evento:", wsremcarne.Evento) + rec['cod_remito'] = wsremcarne.CodRemito + rec['resultado'] = wsremcarne.Resultado + rec['observaciones'] = wsremcarne.Observaciones + rec['fecha_emision'] = wsremcarne.FechaEmision + rec['fecha_vencimiento'] = wsremcarne.FechaVencimiento + rec['errores'] = wsremcarne.Errores + rec['errores_formato'] = wsremcarne.ErroresFormato + rec['evento'] = wsremcarne.Evento + + if '--grabar' in sys.argv: + with open(SALIDA, "w") as archivo: + json.dump(rec, archivo, sort_keys=True, indent=4) + + # Recuperar parámetros: + + if '--tipos_comprobante' in sys.argv: + ret = wsremcarne.ConsultarTiposComprobante() + print("\n".join(ret)) + + if '--tipos_contingencia' in sys.argv: + ret = wsremcarne.ConsultarTiposContingencia() + print("\n".join(ret)) + + if '--tipos_categoria_emisor' in sys.argv: + ret = wsremcarne.ConsultarTiposCategoriaEmisor() + print("\n".join(ret)) + + if '--tipos_categoria_receptor' in sys.argv: + ret = wsremcarne.ConsultarTiposCategoriaReceptor() + print("\n".join(ret)) + + if '--tipos_estados' in sys.argv: + ret = wsremcarne.ConsultarTiposEstado() + print("\n".join(ret)) + + if '--grupos_carne' in sys.argv: + ret = wsremcarne.ConsultarGruposCarne() + print("\n".join(ret)) + + if '--tipos_carne' in sys.argv: + for grupo_carne in wsremcarne.ConsultarGruposCarne(sep=None): + ret = wsremcarne.ConsultarTiposCarne(grupo_carne['codigo']) + print("\n".join(ret)) + + if '--codigos_domicilio' in sys.argv: + cuit = input("Cuit Titular Domicilio: ") + ret = wsremcarne.ConsultarCodigosDomicilio(cuit) + print("\n".join(ret)) + + if wsremcarne.Errores or wsremcarne.ErroresFormato: + print("Errores:", wsremcarne.Errores, wsremcarne.ErroresFormato) + + print("hecho.") + + except SoapFault as e: + print("Falla SOAP:", e.faultcode, e.faultstring.encode("ascii", "ignore")) + sys.exit(3) + except Exception as e: + ex = utils.exception_info() + print(ex) + if DEBUG: + raise + sys.exit(5) diff --git a/app/reports/__init__.py b/app/reports/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..af0744ae0a655a95ccd696cb1dc27e7f120434b2 --- /dev/null +++ b/app/reports/__init__.py @@ -0,0 +1 @@ + diff --git a/app/reports/builders.py b/app/reports/builders.py new file mode 100644 index 0000000000000000000000000000000000000000..ed4765b31ef749f788bc9cdd52ca6fc72d637cad --- /dev/null +++ b/app/reports/builders.py @@ -0,0 +1,682 @@ +""" +Report Builders — Transform raw scraper data into structured report models. +Each builder handles a specific report type. +""" +import logging +from typing import Any, Optional +from datetime import datetime, timezone + +from app.reports.schemas import ( + ScoreHistorial, ScoreCrediticio, + PersonReport, CompanyReport, Identificacion, Contacto, Domicilio, + DatosFiscales, DatosFinancieros, DatosSocietarios, DatosPatrimoniales, + DatosJudiciales, PublicacionBO, BcraHistorial, ChequeRechazado, + CausaJudicial, Vehiculo, InfraccionTransito, MarcaINPI, Vinculo, + DatosPrevisionales, DatosAcademicos, TituloAcademico, IndicadorRiesgo, + Inmueble, Inhibicion, DatosRegistroCivil, GroupReport, VehicleReport, + PropertyReport, ReportMeta, ActividadFiscal, CompanyIdentificacion, RedSocial, + DatosIGJ, ViasSalud, ContratoEstado, MatriculaProfesional, + DatosBilleterasVirtuales, BilleteraVirtual, TimelineEvent, DeudorAlimentario, Monotributo +) +from app.reports.schemas import ( + CompanyReport, VehicleReport, PropertyReport, GroupReport +) + +logger = logging.getLogger(__name__) + + +# ─── Helper Functions ─── + +def _safe_get(data: dict, key: str, default=None): + """Obtiene valor de dict de forma segura.""" + if not isinstance(data, dict): + return default + return data.get(key, default) + +def _safe_list(data: dict, key: str) -> list: + """Obtiene lista de forma segura.""" + val = _safe_get(data, key) + return val if isinstance(val, list) else [] + +def _safe_dict(data: dict, key: str) -> dict: + val = _safe_get(data, key) + return val if isinstance(val, dict) else {} + + +# ─── Builders ─── + +def build_identificacion(data: dict, cuit: str) -> Identificacion: + """Construye sección de identificación.""" + return Identificacion( + cuit=cuit, + tipo_clave=_safe_get(data, "tipo_clave"), + tipo_documento=_safe_get(data, "tipo_documento"), + numero_documento=_safe_get(data, "numero_documento"), + nombres=_safe_get(data, "nombres"), + apellido=_safe_get(data, "apellido"), + nombres_completo=_safe_get(data, "nombres_completo"), + fecha_nacimiento=_safe_get(data, "fecha_nacimiento"), + fecha_fallecimiento=_safe_get(data, "fecha_fallecimiento"), + sexo=_safe_get(data, "sexo"), + ) + + +def build_contacto(data: dict) -> Contacto: + """Construye sección de contacto.""" + # Intentar desde redes sociales + redes = _safe_list(data.get("redes_sociales", {}), "redes_sociales") if isinstance(data.get("redes_sociales"), dict) else _safe_list(data, "redes_sociales") + emails = [] + phones = [] + for r in redes: + if isinstance(r, dict): + if r.get("tipo") == "email": + emails.append(r.get("valor", "")) + elif r.get("tipo") == "telefono": + phones.append(r.get("valor", "")) + + return Contacto( + emails=emails, + telefonos=phones, + redes_sociales=_safe_list(data, "redes_sociales"), + ) + + +def build_domicilios(data: dict) -> list[Domicilio]: + """Construye lista de domicilios.""" + domicilios_raw = _safe_list(data, "domicilios") + domicilios = [] + for d in domicilios_raw: + if isinstance(d, dict): + domicilios.append(Domicilio( + calle=_safe_get(d, "calle"), + numero=_safe_get(d, "numero"), + piso=_safe_get(d, "piso"), + depto=_safe_get(d, "depto"), + localidad=_safe_get(d, "localidad"), + provincia=_safe_get(d, "provincia"), + codigo_postal=_safe_get(d, "codigo_postal"), + tipo=_safe_get(d, "tipo"), + )) + return domicilios + + +def build_datos_fiscales(arca_data: dict, cuit: str, actividades: list[ActividadFiscal] = None) -> DatosFiscales: + """Construye datos fiscales desde ARCA/AFIP.""" + if not isinstance(arca_data, dict): + arca_data = {} + + domicilios = build_domicilios(arca_data) + domicilio_fiscal_raw = _safe_get(arca_data, "domicilio_fiscal") + domicilio_fiscal = Domicilio(**domicilio_fiscal_raw) if isinstance(domicilio_fiscal_raw, dict) and domicilio_fiscal_raw else None + + if domicilio_fiscal and not domicilios: + domicilios = [domicilio_fiscal] + + monotributo_raw = _safe_get(arca_data, "monotributo") + monotributo_obj = Monotributo(**monotributo_raw) if isinstance(monotributo_raw, dict) else None + + return DatosFiscales( + cuit=arca_data.get("cuit", cuit), + tipo_clave=_safe_get(arca_data, "tipo_clave"), + tipo_documento=_safe_get(arca_data, "tipo_documento"), + numero_documento=_safe_get(arca_data, "numero_documento"), + nombres=_safe_get(arca_data, "nombres"), + apellido=_safe_get(arca_data, "apellido"), + nombres_completo=_safe_get(arca_data, "nombres_completo"), + condicion_iva=_safe_get(arca_data, "condicion_iva"), + estado_afip=_safe_get(arca_data, "estado_afip"), + fecha_inicio_actividad=_safe_get(arca_data, "fecha_inicio_actividad"), + fecha_nacimiento=_safe_get(arca_data, "fecha_nacimiento"), + fecha_contrato_social=_safe_get(arca_data, "fecha_contrato_social"), + fecha_fallecimiento=_safe_get(arca_data, "fecha_fallecimiento"), + forma_juridica=_safe_get(arca_data, "forma_juridica"), + mes_cierre=_safe_get(arca_data, "mes_cierre"), + periodo_actividad_principal=_safe_get(arca_data, "periodo_actividad_principal"), + actividades=actividades or [], + domicilios=domicilios, + domicilio_fiscal=domicilio_fiscal, + monotributo=monotributo_obj, + ) + + +def build_datos_financieros(bcra_data: dict, historial_financiero_anterior: dict = None) -> DatosFinancieros: + """Construye datos financieros desde BCRA + fallback histórico.""" + if not isinstance(bcra_data, dict): + bcra_data = {} + + deudas = [] + for ent in _safe_list(bcra_data, "entidades"): + if isinstance(ent, dict): + deudas.append(BcraHistorial( + entidad=ent.get("entidad"), + situacion=ent.get("situacion"), + fecha=ent.get("fecha"), + monto=ent.get("monto"), + tipo=ent.get("tipo"), + )) + + cheques = [] + for ch in _safe_list(bcra_data, "cheques_rechazados"): + if isinstance(ch, dict): + cheques.append(ChequeRechazado( + fecha=ch.get("fecha"), + banco=ch.get("banco"), + sucursal=ch.get("sucursal"), + monto=ch.get("monto"), + motivo=ch.get("motivo"), + )) + + # Fallback histórico si BCRA falló + if not _safe_get(bcra_data, "entidades") and historial_financiero_anterior: + hfa = historial_financiero_anterior + situacion = hfa.get("bcra_situacion_actual") + if situacion: + bcra_data = { + "bcra_situacion_actual": situacion, + "entidades": hfa.get("entidades", []), + "cheques_rechazados": hfa.get("cheques_rechazados", []), + } + + situacion = _safe_get(bcra_data, "bcra_situacion_actual") + if situacion is None: + situacion = 1 # Normal por defecto + + return DatosFinancieros( + bcra_situacion_actual=situacion, + bcra_situacion_descripcion=SITUACIONES_BCRA.get(situacion, "Desconocida"), + bcra_historial=_safe_list(bcra_data, "entidades"), + bcra_total_deuda_miles=_safe_get(bcra_data, "bcra_total_deuda_miles", 0.0), + bcra_dias_atraso_max=_safe_get(bcra_data, "bcra_dias_atraso_max", 0), + cheques_rechazados=_safe_list(bcra_data, "cheques_rechazados"), + tiene_deuda=_safe_get(bcra_data, "tiene_deuda", False), + ) + + +def build_datos_societarios(igj_data: dict, inpi_data: dict, compras_data: dict) -> DatosSocietarios: + """Construye datos societarios.""" + return DatosSocietarios( + igj=_safe_dict(igj_data, "igj"), + inpi=_safe_dict(inpi_data, "inpi"), + compras_estatales=_safe_dict(compras_data, "compras_estatales"), + ) + + +def build_datos_patrimoniales( + arba_auto_data: dict, arba_catastro_data: dict, + dnrpa_data: dict, infracciones_data: dict, + carto_arba_data: dict, sinais_data: dict +) -> DatosPatrimoniales: + """Construye datos patrimoniales.""" + inmuebles = [] + for inm in _safe_list(arba_catastro_data, "inmuebles"): + if isinstance(inm, dict): + inmuebles.append(Inmueble( + partida=inm.get("partida"), + nomenclatura=inm.get("nomenclatura"), + direccion=inm.get("direccion"), + superficie=inm.get("superficie"), + valuacion=inm.get("valuacion"), + tipo=inm.get("tipo"), + )) + + vehiculos = [] + for v in _safe_list(dnrpa_data, "vehiculos"): + if isinstance(v, dict): + vehiculos.append(Vehiculo( + dominio=v.get("dominio"), + marca=v.get("marca"), + modelo=v.get("modelo"), + anio=v.get("anio"), + tipo=v.get("tipo"), + registro=v.get("registro"), + titular=v.get("titular"), + estado=v.get("estado"), + )) + + infracciones = [] + for inf in _safe_list(infracciones_data, "infracciones"): + if isinstance(inf, dict): + infracciones.append(InfraccionTransito( + fecha=inf.get("fecha"), + tipo=inf.get("tipo"), + jurisdiccion=inf.get("jurisdiccion"), + monto=inf.get("monto"), + estado=inf.get("estado"), + )) + + return DatosPatrimoniales( + inmuebles=inmuebles, + vehiculos=vehiculos, + infracciones_transito=infracciones, + ) + + +def build_datos_judiciales( + pj_data: dict, pj_prov_data: dict, + bo_data: dict, bo_prov_data: dict, + inhibiciones_data: dict, juba_data: dict, + siscop_data: dict +) -> DatosJudiciales: + """Construye datos judiciales.""" + causas = [] + for c in _safe_list(pj_data, "causas"): + if isinstance(c, dict): + causas.append(CausaJudicial( + numero=c.get("numero"), + fuero=c.get("fuero"), + caratula=c.get("caratula"), + estado=c.get("estado"), + fecha=c.get("fecha"), + rol=c.get("rol"), + )) + + publicaciones = [] + for p in _safe_list(bo_data, "publicaciones"): + if isinstance(p, dict): + publicaciones.append(PublicacionBO( + fecha=p.get("fecha"), + seccion=p.get("seccion"), + texto=p.get("texto"), + url=p.get("url"), + )) + + inhibiciones = [] + for inh in _safe_list(inhibiciones_data, "inhibiciones"): + if isinstance(inh, dict): + inhibiciones.append(Inhibicion( + numero=inh.get("numero"), + juzgado=inh.get("juzgado"), + fecha=inh.get("fecha"), + monto=inh.get("monto"), + estado=inh.get("estado"), + )) + + return DatosJudiciales( + causas=causas, + publicaciones_bo=publicaciones, + inhibiciones=inhibiciones, + ) + + +def build_vinculos(igj_data: dict, domicilios: list[Domicilio]) -> list[Vinculo]: + """Detecta vínculos por domicilio compartido (IGJ).""" + vinculos = [] + if not igj_data or not domicilios: + return vinculos + + doms_persona = set() + for d in domicilios: + parts = [d.calle, d.numero, d.localidad] + normalized = " ".join(p.lower().strip() for p in parts if p).strip() + if normalized and len(normalized) > 5: + doms_persona.add(normalized) + + for socio in igj_data.get("socios_directivos", []): + if not isinstance(socio, dict): + continue + dom_socio = socio.get("domicilio", "") + if dom_socio: + dom_norm = " ".join(dom_socio.lower().split()[:5]) + for dom_p in doms_persona: + if dom_norm and dom_p and (dom_norm in dom_p or dom_p in dom_norm): + vinculos.append(Vinculo( + cuit=socio.get("cuil"), + nombre=socio.get("nombre", ""), + tipo="Domiciliario", + detalle=f"Domicilio compartido: {dom_socio}" + )) + break + return vinculos + + +def build_riesgo_indicadores(data: dict, all_results: dict) -> list[IndicadorRiesgo]: + """Construye indicadores de riesgo.""" + indicadores = [] + + # BCRA + financiero = _safe_dict(data, "financiero") + situacion = financiero.get("bcra_situacion_actual") + if situacion and situacion > 1: + indicadores.append(IndicadorRiesgo( + codigo="BCRA_SITUACION", + nivel="Alto" if situacion >= 4 else "Medio", + descripcion=f"Situación BCRA: {SITUACIONES_BCRA.get(situacion, 'Desconocida')}", + detalle=f"Situación actual: {situacion}", + )) + + # Judicial + judicial = _safe_dict(data, "judicial") + causas = _safe_list(judicial, "causas") + if len(causas) > 3: + indicadores.append(IndicadorRiesgo( + codigo="JUDICIAL_CAUSAS_MULTIPLES", + nivel="Medio", + descripcion="Múltiples causas judiciales", + detalle=f"Se detectaron {len(causas)} causas judiciales", + )) + + # Inhibiciones + inhibiciones = _safe_list(judicial, "inhibiciones") + if inhibiciones: + indicadores.append(IndicadorRiesgo( + codigo="INHIBICIONES", + nivel="Alto", + descripcion="Inhibiciones registradas", + detalle=f"{len(inhibiciones)} inhibición(es) activa(s)", + )) + + # Deudores alimentarios + deudores = _safe_list(data.get("deudores_alimentarios", {}), "deudores") + if deudores: + indicadores.append(IndicadorRiesgo( + codigo="DEUDOR_ALIMENTARIO", + nivel="Alto", + descripcion="Registro de deudor alimentario moroso", + detalle="Aparece en RDAM", + )) + + # BCRA cheques rechazados + cheques = _safe_list(financiero, "cheques_rechazados") + if cheques: + indicadores.append(IndicadorRiesgo( + codigo="CHEQUES_RECHAZADOS", + nivel="Medio", + descripcion="Cheques rechazados registrados", + detalle=f"{len(cheques)} cheque(s) rechazado(s)", + )) + + return indicadores + + +def build_person_report( + cuit: str, + data: dict, + results: dict, + force_refresh: bool = False, +) -> PersonReport: + """Construye reporte completo de persona.""" + + # Datos base + arca_data = _safe_dict(results.get("arca_afip", {}), "data") if "arca_afip" in results else _safe_dict(data, "fiscal") + bcra_data = _safe_dict(results.get("bcra", {}), "data") if "bcra" in results else _safe_dict(data, "financiero") + igj_data = _safe_dict(results.get("igj", {}), "data") if "igj" in results else _safe_dict(data, "societario", {}).get("igj", {}) + inpi_data = _safe_dict(results.get("inpi", {}), "data") if "inpi" in results else _safe_dict(data, "societario", {}).get("inpi", {}) + compras_data = _safe_dict(results.get("compras_estatales", {}), "data") if "compras_estatales" in results else _safe_dict(data, "societario", {}).get("compras_estatales", {}) + + # Identificación + identificacion = build_identificacion(arca_data, cuit) + contacto = build_contacto(_safe_dict(data, "contacto")) + domicilios = build_domicilios(arca_data) + + # Secciones principales + fiscal = build_datos_fiscales(arca_data, cuit) + financiero = build_datos_financieros(bcra_data, _safe_dict(data, "financiero")) + societario = build_datos_societarios(igj_data, inpi_data, compras_data) + patrimonial = build_datos_patrimoniales( + _safe_dict(data, "arba_automotores"), + _safe_dict(data, "arba_catastro"), + _safe_dict(data, "dnrpa"), + _safe_dict(data, "infracciones"), + _safe_dict(data, "carto_arba"), + _safe_dict(data, "sinai"), + ) + judicial = build_datos_judiciales( + _safe_dict(data, "poder_judicial"), + _safe_dict(data, "poder_judicial_provincial"), + _safe_dict(data, "boletin_oficial"), + _safe_dict(data, "boletines_provinciales"), + _safe_dict(data, "inhibiciones"), + _safe_dict(data, "juba"), + _safe_dict(data, "siscop"), + ) + previsional = DatosPrevisionales( + anses=_safe_dict(data, "anses"), + monotributo_historial=_safe_dict(data, "monotributo_historial"), + ) + academico = DatosAcademicos( + sgarhu=_safe_dict(data, "sgarhu"), + titulos=[], + ) + registro_civil = DatosRegistroCivil( + siscop=_safe_dict(data, "siscop"), + defunciones=[], + ) + billeteras = DatosBilleterasVirtuales( + billeteras=_safe_list(data.get("billeteras_virtuales", {}), "billeteras"), + ) + compras_estatales = ContratoEstado(**_safe_dict(data, "compras_estatales")) if data.get("compras_estatales") else None + + # Marcas INPI + marcas_inpi = [] + for m in _safe_list(inpi_data, "marcas"): + if isinstance(m, dict): + marcas_inpi.append(MarcaINPI( + numero=m.get("numero"), + denominacion=m.get("denominacion"), + clase=m.get("clase"), + estado=m.get("estado"), + titular=m.get("titular"), + fecha_presentacion=m.get("fecha_presentacion"), + )) + + # Vínculos + domicilios_objs = [Domicilio(**d) if isinstance(d, dict) else d for d in _safe_list(arca_data, "domicilios")] + vinculos = build_vinculos(igj_data, domicilios_objs) + + # Riesgo + riesgo = build_riesgo_indicadores(data, _safe_dict(data, "resultados")) + + # Timeline BO + timeline = [] + for t in _safe_list(data.get("timeline_boa", {}), "eventos"): + if isinstance(t, dict): + timeline.append(TimelineEvent( + fecha=t.get("fecha"), + tipo=t.get("tipo"), + descripcion=t.get("descripcion"), + fuente=t.get("fuente"), + )) + + # Deudores alimentarios + deudores = [] + for d in _safe_list(data.get("deudores_alimentarios", {}), "deudores"): + if isinstance(d, dict): + deudores.append(DeudorAlimentario( + dni=d.get("dni"), + nombre=d.get("nombre"), + estado=d.get("estado"), + detalle=d.get("detalle"), + )) + + # Monotributo historial + monotributo = None + mh = _safe_dict(data, "monotributo_historial") + if mh: + monotributo = Monotributo( + historial_categorias=_safe_list(mh, "historial_categorias"), + categoria_actual=mh.get("categoria_actual"), + es_monotributista=mh.get("es_monotributista", False), + nota=mh.get("nota"), + ) + + # Registro conductores + licencia = None + rc = _safe_dict(data, "registro_conductores") + if rc: + licencia = { + "numero": rc.get("numero"), + "categoria": rc.get("categoria"), + "vencimiento": rc.get("vencimiento"), + } + + # Score + score = compute_score(PersonReport( + meta=ReportMeta(report_id="", generated_at=datetime.now(timezone.utc), sources=[]), + identificacion=identificacion, + contacto=contacto, + domicilios=domicilios, + fiscal=fiscal, + financiero=financiero, + societario=societario, + patrimonial=patrimonial, + judicial=judicial, + previsional=previsional, + academico=academico, + registro_civil=registro_civil, + billeteras_virtuales=billeteras, + compras_estatales=compras_estatales, + marcas_inpi=marcas_inpi, + timeline=timeline, + deudores_alimentarios=deudores, + monotributo_historial=monotributo, + licencia_conducir=licencia, + vinculos=[], + riesgo=riesgo, + )) + + return PersonReport( + meta=ReportMeta( + report_id=uuid.uuid4().hex[:12], + generated_at=datetime.now(timezone.utc), + sources=[s for s in ["arca_afip", "bcra", "igj", "inpi", "bcra", "dnrpa", "poder_judicial", + "renaper", "anses", "cnv", "uif", "boletin_oficial", "boletines_provinciales", + "anses", "sinai", "inpi", "juba", "inhibiciones", "poder_judicial_provincial", + "siscop", "billeteras_virtuales", "ruido", "sgarhu", "contratar", + "colegios_profesionales", "renaper_facial", "archive_org", "padron_electoral", + "compras_estatales", "cnv"] if s in results], + ), + score=score, + identificacion=identificacion, + contacto=contacto, + domicilios=domicilios, + fiscal=fiscal, + financiero=financiero, + societario=societario, + patrimonial=patrimonial, + judicial=judicial, + previsional=previsional, + academico=academico, + registro_civil=registro_civil, + billeteras_virtuales=billeteras, + compras_estatales=compras_estatales, + marcas_inpi=marcas_inpi, + timeline=timeline, + deudores_alimentarios=deudores, + monotributo_historial=monotributo, + registro_conductores=licencia, + vinculos=vinculos, + riesgo=riesgo, + ) + + +def build_company_report(cuit: str, data: dict, results: dict) -> CompanyReport: + """Construye reporte de empresa.""" + # Similar a build_person_report pero para empresa + # ... implementación simplificada + return CompanyReport( + meta=ReportMeta(report_id=uuid.uuid4().hex[:12], generated_at=datetime.now(timezone.utc), sources=[]), + score=compute_company_score(CompanyReport( + meta=ReportMeta(report_id="", generated_at=datetime.now(timezone.utc), sources=[]), + identificacion=CompanyIdentificacion(cuit=cuit, razon_social=""), + fiscal=DatosFiscales(cuit=cuit), + financiero=DatosFinancieros(bcra_situacion_actual=1, bcra_situacion_descripcion="Normal"), + societario=DatosSocietarios(), + judicial=DatosJudiciales(), + patrimonial=DatosPatrimoniales(), + billeteras_virtuales=DatosBilleterasVirtuales(), + riesgo=[], + )), + identificacion=CompanyIdentificacion(cuit=cuit, razon_social=""), + fiscal=DatosFiscales(cuit=cuit), + financiero=DatosFinancieros(bcra_situacion_actual=1, bcra_situacion_descripcion="Normal"), + societario=DatosSocietarios(), + judicial=DatosJudiciales(), + patrimonial=DatosPatrimoniales(), + billeteras_virtuales=DatosBilleterasVirtuales(), + riesgo=[], + ) + + +def build_vehicle_report(dominio: str, data: dict, results: dict) -> VehicleReport: + """Construye reporte de vehículo.""" + dnrpa = _safe_dict(data, "dnrpa") + infracciones = _safe_dict(data, "infracciones") + siniestros = _safe_dict(data, "siniestros") + sinais = _safe_dict(data, "sinais") + + vehiculo = None + for v in _safe_list(dnrpa, "vehiculos"): + if isinstance(v, dict) and v.get("dominio") == dominio: + vehiculo = Vehiculo( + dominio=v.get("dominio"), + marca=v.get("marca"), + modelo=v.get("modelo"), + anio=v.get("anio"), + tipo=v.get("tipo"), + registro=v.get("registro"), + titular=v.get("titular"), + estado=v.get("estado"), + ) + break + + infracciones_list = [] + for inf in _safe_list(infracciones, "infracciones"): + if isinstance(inf, dict): + infracciones_list.append(InfraccionTransito( + fecha=inf.get("fecha"), + tipo=inf.get("tipo"), + jurisdiccion=inf.get("jurisdiccion"), + monto=inf.get("monto"), + estado=inf.get("estado"), + )) + + return VehicleReport( + meta=ReportMeta(report_id=uuid.uuid4().hex[:12], generated_at=datetime.now(timezone.utc), sources=list(results.keys())), + vehiculo=vehiculo, + infracciones=infracciones_list, + siniestros=[], + riesgo=[], + ) + + +def build_property_report(calle: str, numero: str, localidad: str, provincia: str, data: dict, results: dict) -> PropertyReport: + """Construye reporte de propiedad.""" + arba_catastro = _safe_dict(data, "arba_catastro") + carto_arba = _safe_dict(data, "carto_arba") + bo_data = _safe_dict(data, "boletin_oficial") + bo_prov = _safe_dict(data, "boletin_oficial_pba") + + inmuebles = [] + for inm in _safe_list(arba_catastro, "inmuebles"): + if isinstance(inm, dict): + inmuebles.append(Inmueble( + partida=inm.get("partida"), + nomenclatura=inm.get("nomenclatura"), + direccion=inm.get("direccion"), + superficie=inm.get("superficie"), + valuacion=inm.get("valuacion"), + tipo=inm.get("tipo"), + )) + + return PropertyReport( + meta=ReportMeta(report_id=uuid.uuid4().hex[:12], generated_at=datetime.now(timezone.utc), sources=list(results.keys())), + direccion=f"{calle} {numero}, {localidad}, {provincia}", + inmuebles=inmuebles, + publicaciones_bo=[], + riesgo=[], + ) + + +def build_group_report(cuit: str, data: dict, results: dict) -> GroupReport: + """Construye reporte de grupo económico.""" + persona = build_person_report(cuit, data, results) + + # TODO: Empresas vinculadas, vehículos, inmuebles + return GroupReport( + meta=ReportMeta(report_id=uuid.uuid4().hex[:12], generated_at=datetime.now(timezone.utc), sources=list(results.keys())), + score_grupo=compute_group_score(PersonReport(...), [], [], []), # Simplificado + persona=persona, + empresas=[], + vehiculos=[], + inmuebles=[], + ) \ No newline at end of file diff --git a/app/reports/fallback.py b/app/reports/fallback.py new file mode 100644 index 0000000000000000000000000000000000000000..74f8e4bfca1dafd87ea7999e7880a8645eac28af --- /dev/null +++ b/app/reports/fallback.py @@ -0,0 +1,111 @@ +""" +Fallback Logic — Aplica datos históricos cuando scrapers fallan. +""" +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +SITUACIONES_BCRA = { + 1: "Normal", + 2: "Con seguimiento especial / Riesgo bajo", + 3: "Con problemas / Riesgo medio", + 4: "Con alto riesgo de insolvencia / Riesgo alto", + 5: "Irrecuperable", + 6: "Irrecuperable por disposición técnica", +} + +logger = logging.getLogger(__name__) + + +async def apply_fallbacks( + cuit: str, + results: dict, + cached_report: Optional[dict] = None, +) -> dict: + """ + Aplica fallbacks desde reporte cacheado anterior cuando scrapers fallan. + + Args: + cuit: CUIT consultado + results: Resultados de scrapers actuales (puede tener errores) + cached_report: Reporte cacheado anterior (ya parseado a dict) + + Returns: + dict con datos combinados (actuales + fallback) + """ + if not cached_report: + logger.debug(f"No cached report for fallback: {cuit}") + return {r.name: r.data for r in results.values() if r.data} + + merged = {} + + for name, result in results.items(): + if result.data: + merged[name] = result.data + continue + + # Scraper falló - intentar fallback + cached_key = name + if name in cached_report: + cached_data = cached_report[name] + if cached_data: + logger.info(f"[FALLBACK] Using cached data for {name} ({cuit})") + merged[name] = cached_data + else: + merged[name] = {} + else: + merged[name] = {} + + # Fallback específico BCRA con lógica de negocio + if "bcra" not in merged or not merged["bcra"]: + merged["bcra"] = _bcra_fallback(cached_report) + + return merged + + +def _bcra_fallback(cached_report: Optional[dict]) -> dict: + """Aplica lógica de fallback específica para BCRA.""" + if not cached_report or "bcra" not in cached_report: + return { + "denominacion": "", + "bcra_situacion_actual": 1, + "bcra_situacion_descripcion": SITUACIONES_BCRA[1], + "bcra_historial": [], + "bcra_total_deuda_miles": 0.0, + "bcra_dias_atraso_max": 0, + "cheques_rechazados": [], + "tiene_deuda": False, + } + + old = cached_report["bcra"] + if not isinstance(old, dict): + return {"bcra_situacion_actual": 1} + + # Si la situación histórica es peor (mayor número), usar esa + # Si la actual falló, usar la histórica + situacion_actual = old.get("bcra_situacion_actual", 1) + + return { + "denominacion": old.get("denominacion", ""), + "bcra_situacion_actual": situacion_actual, + "bcra_situacion_descripcion": SITUACIONES_BCRA.get(situacion_actual, "Normal"), + "bcra_historial": old.get("bcra_historial", []), + "bcra_total_deuda_miles": old.get("bcra_total_deuda_miles", 0.0), + "bcra_dias_atraso_max": old.get("bcra_dias_atraso_max", 0), + "cheques_rechazados": old.get("cheques_rechazados", []), + "tiene_deuda": old.get("tiene_deuda", False), + } + + +async def apply_fallbacks_to_person_report( + cuit: str, + raw_results: dict, + cached_report: Optional[dict] = None, +) -> dict: + """ + Aplica fallbacks a resultados de reporte persona. + Retorna datos listos para build_person_report. + """ + merged = await apply_fallbacks(cuit, raw_results, cached_report) + return merged \ No newline at end of file diff --git a/app/reports/kyc_service.py b/app/reports/kyc_service.py new file mode 100644 index 0000000000000000000000000000000000000000..25da58d434c4874e554fd7db6a56c9ff1c421e0d --- /dev/null +++ b/app/reports/kyc_service.py @@ -0,0 +1,156 @@ +""" +Servicio KYC (Know Your Customer) Simplificado — CrowData. + +Combina datos de RENAPER, BCRA y AFIP para ofrecer verificación +de identidad simplificada para empresas de fintech, inmobiliarias, etc. + +NOTA: Este es un KYC simplificado basado en datos públicos. +No reemplaza la debida diligencia legal requerida por normativas. +""" +import logging +from dataclasses import dataclass, field +from typing import Optional +from app.scrapers.arca_afip import ArcaAfipScraper +from app.scrapers.bcra import BcraScraper +from app.scrapers.anses import AnsesScraper +from app.scrapers.renaper import RenaperScraper +from app.scrapers.renaper_facial import RenaperFacialScraper + +logger = logging.getLogger(__name__) + + +@dataclass +class KYCResult: + """Resultado de verificación KYC simplificada.""" + cuit: str + nombre_completo: str = "" + dni: str = "" + estado_civil: str = "" + fecha_nacimiento: str = "" + sexo: str = "" + nacionalidad: str = "" + + # Verificaciones + verificado_renaper: bool = False + verificado_afip: bool = False + verificado_bcra: bool = False + verificado_anses: bool = False + + # Riesgo + nivel_riesgo: str = "BAJO" + score_kyc: int = 0 + observaciones: list = field(default_factory=list) + + # Datos financieros + situacion_bcra: str = "" + estado_afip: str = "" + condicion_iva: str = "" + categoria_monotributo: str = "" + + # Fuentes consultadas + fuentes_consultadas: list = field(default_factory=list) + + +class KYCService: + """Servicio KYC simplificado.""" + + async def verify(self, cuit: str) -> KYCResult: + """ + Realiza verificación KYC simplificada. + Retorna resultado con verificaciones y nivel de riesgo. + """ + cuit_clean = cuit.replace("-", "").strip() + if len(cuit_clean) != 11 or not cuit_clean.isdigit(): + return KYCResult( + cuit=cuit, + nivel_riesgo="ALTO", + observaciones=["CUIT inválido"], + ) + + result = KYCResult(cuit=cuit) + score = 0 + + # 1. Verificar con RENAPER + try: + renaper = RenaperScraper() + renaper_data = await renaper.fetch(cuit_clean) + if renaper_data and renaper_data.get("dni"): + result.verificado_renaper = True + result.dni = renaper_data.get("dni", "") + result.nombre_completo = renaper_data.get("nombre_completo", "") + result.fecha_nacimiento = renaper_data.get("fecha_nacimiento", "") + result.sexo = renaper_data.get("sexo", "") + result.nacionalidad = renaper_data.get("nacionalidad", "ARGENTINA") + score += 30 + result.fuentes_consultadas.append("RENAPER") + except Exception as e: + logger.debug(f"[KYC] RENAPER falló: {e}") + + # 2. Verificar con ARCA/AFIP + try: + arca = ArcaAfipScraper() + arca_data = await arca.fetch(cuit_clean) + if arca_data and arca_data.get("estado"): + result.verificado_afip = True + result.estado_afip = arca_data.get("estado", "") + result.condicion_iva = arca_data.get("condicion_iva", "") + result.categoria_monotributo = arca_data.get("monotributo", {}).get("categoria", "") + if not result.nombre_completo and arca_data.get("nombre"): + result.nombre_completo = f"{arca_data.get('apellido', '')} {arca_data.get('nombre', '')}".strip() + score += 25 + result.fuentes_consultadas.append("ARCA/AFIP") + except Exception as e: + logger.debug(f"[KYC] ARCA falló: {e}") + + # 3. Verificar con BCRA + try: + bcra = BcraScraper() + bcra_data = await bcra.fetch(cuit_clean) + if bcra_data: + result.verificado_bcra = True + result.situacion_bcra = bcra_data.get("situacion", "") + score += 25 + result.fuentes_consultadas.append("BCRA") + + # Evaluar riesgo financiero + situacion = bcra_data.get("situacion_normal", 0) + if situacion and situacion > 3: + result.nivel_riesgo = "ALTO" + result.observaciones.append(f"BCRA situación: {situacion}/5") + elif situacion and situacion > 1: + result.nivel_riesgo = "MEDIO" + result.observaciones.append(f"BCRA situación: {situacion}/5") + except Exception as e: + logger.debug(f"[KYC] BCRA falló: {e}") + + # 4. Verificar con ANSES + try: + anses = AnsesScraper() + anses_data = await anses.fetch(cuit_clean) + if anses_data and anses_data.get("resultado"): + result.verificado_anses = True + score += 20 + result.fuentes_consultadas.append("ANSES") + except Exception as e: + logger.debug(f"[KYC] ANSES falló: {e}") + + # Calcular score final + result.score_kyc = min(score, 100) + + # Determinar nivel de riesgo basado en score + if result.score_kyc >= 70: + result.nivel_riesgo = "BAJO" + elif result.score_kyc >= 40: + result.nivel_riesgo = "MEDIO" + else: + result.nivel_riesgo = "ALTO" + + # Agregar observaciones + if not result.verificado_renaper: + result.observaciones.append("No verificado por RENAPER") + if not result.verificado_afip: + result.observaciones.append("No verificado por ARCA/AFIP") + if not result.verificado_bcra: + result.observaciones.append("No verificado por BCRA") + + return result diff --git a/app/reports/models.py b/app/reports/models.py new file mode 100644 index 0000000000000000000000000000000000000000..2ebd6a2408feab1c52d468fcc84310aaf0853386 --- /dev/null +++ b/app/reports/models.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, String, Text, DateTime, Boolean, Integer +from sqlalchemy.sql import func +from app.database import Base + + +class ReportCache(Base): + __tablename__ = "report_cache" + + id = Column(String(36), primary_key=True) + cache_key = Column(String(255), unique=True, index=True, nullable=False) + report_type = Column(String(50), nullable=False) # persona | empresa | vehiculo + identifier = Column(String(100), nullable=False, index=True) # cuit / dominio + data = Column(Text, nullable=False) # JSON string + sources_used = Column(String(500)) # comma-separated sources + created_at = Column(DateTime(timezone=True), server_default=func.now()) + expires_at = Column(DateTime(timezone=True)) + hit_count = Column(Integer, default=0) + + +class SearchHistory(Base): + __tablename__ = "search_history" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String(36), index=True, nullable=False) + identifier = Column(String(100), nullable=False) # CUIT / Dominio + type = Column(String(50)) # persona | empresa | vehiculo + name = Column(String(255)) # Razon social / Nombre + created_at = Column(DateTime(timezone=True), server_default=func.now()) + report_id = Column(String(36)) # Link to cache + + +class MonitorTask(Base): + __tablename__ = "monitor_tasks" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String(36), index=True, nullable=False) + identifier = Column(String(100), nullable=False) # CUIT / Dominio + type = Column(String(50)) # persona | empresa | vehiculo + last_check = Column(DateTime(timezone=True), server_default=func.now()) + active = Column(Boolean, default=True) + alert_count = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/reports/orchestrator.py b/app/reports/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..a57003f05f4043aa5ec0223a10ffe593bb89024c --- /dev/null +++ b/app/reports/orchestrator.py @@ -0,0 +1,244 @@ +""" +orchestrator.py — Parallel orchestrator for report generation. +Executes scrapers in waves respecting dependencies, with timeout and circuit breaker. +""" +import asyncio +import logging +import time +from dataclasses import dataclass +from typing import Any, Optional +from datetime import datetime, timezone + +from app.reports.registry import registry, ScraperDef +from app.utils.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitState +from app.utils.telemetry import record_scraper_result +from app.cache.redis_client import cache_get, cache_set + +logger = logging.getLogger(__name__) + + +@dataclass +class ScraperResult: + """Resultado de ejecución de un scraper.""" + name: str + status: str # "ok", "empty", "error", "blocked", "timeout", "skipped" + latency_ms: float + records_found: int = 0 + data: Any = None + error: Optional[str] = None + circuit_open: bool = False + + +class ScraperOrchestrator: + """ + Orquestador de ejecución paralela de scrapers. + - Ejecuta en waves respetando dependencias + - Maneja timeouts por scraper + - Integra circuit breaker + - Soporta caché (hit/miss) y fallback + """ + + def __init__(self, report_type: str, identifier: str, force_refresh: bool = False): + self.report_type = report_type + self.identifier = identifier + self.force_refresh = force_refresh + self.cache_prefix = f"{report_type}:{identifier}" + self.start_time = time.time() + self.results: dict[str, ScraperResult] = {} + + def _get_cache_key(self, scraper_name: str) -> str: + return f"{self.cache_prefix}:{scraper_name}" + + async def _check_cache(self, scraper: ScraperDef) -> Optional[Any]: + """Verifica caché si no es force_refresh.""" + if self.force_refresh: + return None + key = self._get_cache_key(scraper.name) + return await cache_get(key) + + async def _save_cache(self, scraper: ScraperDef, data: Any) -> None: + if data: + key = self._get_cache_key(scraper.name) + await cache_set(key, data, ttl=86400) # 24h TTL + + def _get_circuit_breaker(self, scraper: ScraperDef): + return get_circuit_breaker( + scraper.name, + CircuitBreakerConfig( + failure_threshold=5, + timeout=60.0, + excluded_exceptions=(), + ) + ) + + async def _execute_single(self, scraper: ScraperDef) -> ScraperResult: + """Ejecuta un solo scraper con circuit breaker y timeout.""" + # Check circuit breaker + cb = self._get_circuit_breaker(scraper) + if cb.state == CircuitState.OPEN: + logger.warning(f"Circuit breaker OPEN for {scraper.name}, skipping") + return ScraperResult( + name=scraper.name, status="skipped", latency_ms=0, + error="Circuit breaker open", circuit_open=True + ) + + # Check cache first + cached = await self._check_cache(scraper) + if cached is not None: + logger.debug(f"Cache HIT for {scraper.name}") + return ScraperResult( + name=scraper.name, status="ok", latency_ms=1, + data=cached, records_found=len(cached) if isinstance(cached, list) else 1 + ) + + # Execute with circuit breaker + start = time.time() + try: + async def _fetch(): + return await scraper.instance.safe_fetch(self.identifier) + + result = await asyncio.wait_for( + cb.call(_fetch), + timeout=scraper.timeout + ) + + latency = (time.time() - start) * 1000 + + # Record success in circuit breaker + async with cb._lock: + cb._failure_count = 0 + if cb._state == CircuitState.HALF_OPEN: + cb._success_count += 1 + if cb._success_count >= cb.config.success_threshold: + cb._state = CircuitState.CLOSED + cb._success_count = 0 + + # Save to cache + await self._save_cache(scraper, result) + + records = 0 + if result: + if isinstance(result, list): + records = len(result) + elif isinstance(result, dict): + list_keys = [k for k, v in result.items() if isinstance(v, list)] + records = sum(len(result[k]) for k in list_keys) if list_keys else 1 + else: + records = 1 + + status = "ok" if records > 0 else "empty" + record_scraper_result(scraper.name, status, latency, records_found=records) + + return ScraperResult( + name=scraper.name, status=status, latency_ms=latency, + records_found=records, data=result + ) + + except asyncio.TimeoutError: + return ScraperResult( + name=scraper.name, status="timeout", + latency_ms=(time.time() - start) * 1000, + error=f"Timeout after {scraper.timeout}s" + ) + except Exception as e: + return ScraperResult( + name=scraper.name, status="error", + latency_ms=(time.time() - start) * 1000, + error=str(e) + ) + + async def execute_wave(self, scrapers: list[ScraperDef]) -> list[ScraperResult]: + """Ejecuta una wave de scrapers en paralelo.""" + logger.info(f"Executing wave with {len(scrapers)} scrapers: {[s.name for s in scrapers]}") + + tasks = [self._execute_single(s) for s in scrapers] + results = await asyncio.gather(*tasks, return_exceptions=True) + + results_list = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"Scraper {scrapers[i].name} failed with exception: {result}") + results_list.append(ScraperResult( + name=scrapers[i].name, status="error", latency_ms=0, + error=str(result) + )) + else: + results_list.append(result) + + return results_list + + async def run(self) -> dict[str, ScraperResult]: + """Ejecuta todos los scrapers según waves de dependencias. + + Aplica un tope duro global de 10 minutos (asyncio.wait_for). Si Render + free-tier (Cloudflare corta a ~80s en CDN) o algún scraper + extremadamente lento no termina en ese plazo, cortamos y devolvemos + el reporte parcial con los scrapers que sí terminaron. Los + scrapers individuales siguen respetando SAFE_FETCH_TIMEOUT y + max_retries (esos NO se modifican). + """ + ORCHESTRATOR_TIMEOUT = 600 # 10 min tope global (NO bajar - respetar scrapers) + + waves = registry.get_execution_groups() + logger.info(f"Starting orchestration for {self.report_type}:{self.identifier} " + f"({len(waves)} waves, {len(registry.all())} total scrapers)") + + async def _run_all_waves(): + for wave_idx, wave in enumerate(waves): + active_scrapers = [s for s in wave if self._is_active_for_report(s)] + if not active_scrapers: + continue + + wave_results = await self.execute_wave(active_scrapers) + for r in wave_results: + self.results[r.name] = r + + logger.info(f"Wave {wave_idx + 1}/{len(waves)} completed: " + f"{sum(1 for r in wave_results if r.status in ('ok', 'empty'))}/{len(wave_results)} ok") + + try: + await asyncio.wait_for(_run_all_waves(), timeout=ORCHESTRATOR_TIMEOUT) + except asyncio.TimeoutError: + logger.warning( + f"Orchestration for {self.report_type}:{self.identifier} timed out " + f"después de {ORCHESTRATOR_TIMEOUT}s. Devolviendo reporte parcial con " + f"{len([r for r in self.results.values() if r.status in ('ok', 'empty')])}/{len(self.results)} scrapers OK." + ) + # Marcar scrapers que quedaron sin terminar como 'timeout' para que se vean en meta.failures. + executed_names = {r.name for r in self.results.values()} + for s in registry.all(): + if self._is_active_for_report(s) and s.name not in executed_names: + self.results[s.name] = ScraperResult( + name=s.name, status="timeout", latency_ms=0, + error=f"Orchestrator timeout ({ORCHESTRATOR_TIMEOUT}s): scraper no ejecutado" + ) + + total_time = (time.time() - self.start_time) * 1000 + logger.info(f"Orchestration completed in {total_time:.0f}ms ({len(self.results)} scrapers)") + + return self.results + + def _is_active_for_report(self, scraper: ScraperDef) -> bool: + """Determina si un scraper aplica para este tipo de reporte.""" + # Por ahora todos activos; se puede especializar por report_type + return True + + def get_summary(self) -> dict: + """Resumen de resultados para logging/métricas.""" + ok = sum(1 for r in self.results.values() if r.status == "ok") + empty = sum(1 for r in self.results.values() if r.status == "empty") + errors = sum(1 for r in self.results.values() if r.status == "error") + timeouts = sum(1 for r in self.results.values() if r.status == "timeout") + blocked = sum(1 for r in self.results.values() if r.status == "blocked") + skipped = sum(1 for r in self.results.values() if r.status == "skipped") + + return { + "total": len(self.results), + "ok": ok, + "empty": empty, + "errors": errors, + "timeouts": timeouts, + "blocked": blocked, + "skipped": skipped, + "total_latency_ms": (time.time() - self.start_time) * 1000, + } \ No newline at end of file diff --git a/app/reports/registry.py b/app/reports/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..064c3f143e31e4a102ab0354f5d28230070de3bf --- /dev/null +++ b/app/reports/registry.py @@ -0,0 +1,104 @@ +""" +Scraper Registry — Central configuration registry.py — Registry of all available scrapers for report generation. +Centralizes scraper definitions and metadata. +""" +from dataclasses import dataclass +from typing import Callable, Any, Optional +from enum import Enum + + +class ScraperCategory(Enum): + """Categorías de scrapers por criticidad y tipo.""" + CORE = "core" # Críticos: ARCA, BCRA, DNRPA, PJ, RENAPER, IGJ, INPI, UIF + FINANCIAL = "financial" # ARBA, Boletines, ANSES, CNV, COMPR.AR, Monotributo, Registro Conductores + JUDICIAL = "judicial" # Infracciones, Deudores Alimentarios, JUBA, Inhibiciones + PATRIMONIAL = "patrimonial" # Catastro, Cartografía, SINAI, SISCOP + OSINT = "osint" # Google Images, Redes Sociales, Telefonía, Name Search + OTHER = "other" # Billeteras, Ruido, SGARHU, CONTRATAR, Colegios, Timeline BO, Archive.org + + +@dataclass +class ScraperDef: + """Definición de un scraper con metadatos.""" + name: str # Nombre único (ej: "bcra") + display_name: str # Nombre legible (ej: "BCRA") + category: ScraperCategory + instance: Any # Instancia del scraper + timeout: float = 60.0 # Timeout en segundos + critical: bool = False # Si falla, ¿es crítico para el reporte? + dependencies: list[str] = None # Nombres de scrapers que deben ejecutarse antes + fallback_supported: bool = True # Si se puede usar caché como fallback + + def __post_init__(self): + if self.dependencies is None: + self.dependencies = [] + + +class ScraperRegistry: + """Registro central de todos los scrapers disponibles.""" + + def __init__(self): + self._scrapers: dict[str, ScraperDef] = {} + self._categories: dict[ScraperCategory, list[str]] = {c: [] for c in ScraperCategory} + + def register(self, defn: ScraperDef) -> None: + """Registra un scraper.""" + self._scrapers[defn.name] = defn + self._categories[defn.category].append(defn.name) + + def get(self, name: str) -> Optional[ScraperDef]: + return self._scrapers.get(name) + + def all(self) -> list[ScraperDef]: + return list(self._scrapers.values()) + + def by_category(self, category: ScraperCategory) -> list[ScraperDef]: + return [self._scrapers[n] for n in self._categories[category]] + + def critical(self) -> list[ScraperDef]: + return [s for s in self._scrapers.values() if s.critical] + + def ordered_by_dependencies(self) -> list[ScraperDef]: + """Ordena scrapers respetando dependencias (topological sort simplificado).""" + # Para simplificar: critical first, luego por category priority + priority = { + ScraperCategory.CORE: 0, + ScraperCategory.FINANCIAL: 1, + ScraperCategory.JUDICIAL: 2, + ScraperCategory.PATRIMONIAL: 3, + ScraperCategory.OSINT: 4, + ScraperCategory.OTHER: 5, + } + return sorted(self.all(), key=lambda s: (not s.critical, priority[s.category])) + + def get_execution_groups(self) -> list[list[ScraperDef]]: + """ + Agrupa scrapers por waves de ejecución paralela. + Respeta dependencias: un scraper no se ejecuta hasta que sus dependencias terminen. + """ + executed = set() + waves = [] + remaining = {s.name: s for s in self.ordered_by_dependencies()} + + while remaining: + wave = [] + for name, s in list(remaining.items()): + deps_met = all(d in executed for d in s.dependencies) + if deps_met: + wave.append(s) + executed.add(name) + + if not wave: + # Circular dependency or missing - force execute + wave.append(next(iter(remaining.values()))) + executed.add(wave[0].name) + + for s in wave: + remaining.pop(s.name, None) + waves.append(wave) + + return waves + + +# Instancia global del registry +registry = ScraperRegistry() \ No newline at end of file diff --git a/app/reports/router.py b/app/reports/router.py new file mode 100644 index 0000000000000000000000000000000000000000..877c03f99ec03d56a8b9b38fbab0a3bafede35d1 --- /dev/null +++ b/app/reports/router.py @@ -0,0 +1,763 @@ +from fastapi import APIRouter, HTTPException, Depends, Query, Response +from sqlalchemy import update +from app.reports.schemas import ( + PersonReport, CompanyReport, VehicleReport, GroupReport, + PropertyReport, SearchPropertyRequest, + SearchPersonaRequest, SearchEmpresaRequest, + HistoryItem, MonitorItem, +) +from app.reports import service +from app.utils.pdf_generator import generate_report_pdf +from app.auth.router import current_active_user +from app.auth.models import User +from app.scrapers.base import AntiBotBlockedError +from app.utils.security import mask_cuit, mask_email +import logging +import re +from datetime import datetime + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/reports", tags=["reports"]) + + +from app.utils.dni_to_cuit import find_real_cuit + +async def validate_cuit(cuit: str) -> str: + """Valida y normaliza formato CUIT/CUIL. Si es DNI, intenta resolver el CUIT real.""" + clean = re.sub(r"[^0-9]", "", cuit) + + if len(clean) in (7, 8): + real_cuit = await find_real_cuit(clean) + if real_cuit: + return real_cuit + else: + raise HTTPException(status_code=404, detail="No se encontró un CUIT asociado a este DNI en AFIP.") + + if len(clean) != 11: + raise HTTPException(status_code=422, detail="CUIT/CUIL inválido. Debe tener 11 dígitos o ser un DNI válido.") + return clean + + +async def validate_cuit_empresa(cuit: str) -> str: + """Valida CUIT de empresa (debe tener prefijo 30 o 33).""" + cuit_clean = await validate_cuit(cuit) + prefijo = cuit_clean[:2] + if prefijo not in ("30", "33"): + raise HTTPException( + status_code=422, + detail=f"El CUIT {cuit_clean} no corresponde a una empresa (prefijo {prefijo}). " + "Los CUIT de empresas comienzan con 30 o 33." + ) + return cuit_clean + + +@router.get("/public/persona/{cuit}", response_model=PersonReport, summary="Informe público (modelo/ejemplo)") +async def get_public_persona_report(cuit: str): + cuit_clean = await validate_cuit(cuit) + if cuit_clean != "20301234562": + raise HTTPException(status_code=403, detail="Acceso denegado. Este informe requiere suscripción.") + report = await service.get_person_report(cuit_clean) + report.meta.cached = True + return report + + +@router.get("/persona/{cuit}", response_model=PersonReport, summary="Informe de Persona Física") +async def get_persona_report( + cuit: str, + force_refresh: bool = Query(False, description="Si es true, ignora el caché y regenera el informe completo"), + skip_redes: bool = Query(False, description="Si es true, omite el scraper de redes sociales (OSINT)"), + current_user: User = Depends(current_active_user), +): + """ + Genera un informe completo de persona física consultando: + - ARCA/AFIP (datos fiscales) + - BCRA (situación crediticia) + - Boletín Oficial (publicaciones) + """ + cuit_clean = await validate_cuit(cuit) + + if current_user.credits <= 0 and current_user.plan == "free": + logger.info(f"Informe de muestra enviado por email a {mask_email(current_user.email)} para CUIT {mask_cuit(cuit_clean)}") + return { + "meta": { + "report_id": "limit_reached", + "generated_at": str(datetime.now()), + "message": "Se ha alcanzado el límite de 1 búsqueda gratuita. Se ha enviado una muestra reducida por email.", + "action": "upgrade_plan", + "sample_sent_to": current_user.email + }, + "identificacion": {"nota": "Para ver nombre, DNI y fecha de nacimiento adquirí un plan."}, + "fiscal": {"nota": "Para ver actividades y situación en AFIP adquirí un plan."} + } + + # Si force_refresh=True, eliminar caché antes de generar + if force_refresh: + from app.cache.redis_client import cache_delete + await cache_delete(f"persona:{cuit_clean}") + logger.info(f"[force_refresh] Caché invalidado para persona:{mask_cuit(cuit_clean)}") + + try: + from app.database import AsyncSessionLocal + report = await service.get_person_report(cuit_clean, skip_redes=skip_redes) + # Descontar crédito solo si no fue cacheado y es plan free + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + logger.info(f"Crédito descontado para {mask_email(current_user.email)}. Nuevo saldo: {current_user.credits - 1}") + + # Guardar en historial + await service.save_search_history( + user_id=str(current_user.id), + identifier=cuit_clean, + report_type="persona", + name=f"{report.identificacion.apellido or ''} {report.identificacion.nombres or ''}".strip(), + report_id=report.meta.report_id + ) + return report + except AntiBotBlockedError as e: + logger.warning(f"Anti-Bot blocked during persona report: {e}") + raise HTTPException(status_code=503, detail="La fuente de datos está bloqueando consultas. Intentá más tarde.") + except Exception as e: + logger.error(f"Error generating persona report for {cuit}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Error al generar el informe. Intentá nuevamente.") + + +@router.get("/empresa/{cuit}", response_model=CompanyReport, summary="Informe de Empresa") +async def get_empresa_report( + cuit: str, + force_refresh: bool = Query(False, description="Si es true, ignora el caché y regenera el informe completo"), + current_user: User = Depends(current_active_user), +): + """ + Genera un informe completo de persona jurídica consultando: + - ARCA/AFIP (datos fiscales) + - BCRA (situación crediticia) + - Boletín Oficial (publicaciones) + """ + cuit_clean = await validate_cuit_empresa(cuit) + + if current_user.credits <= 0 and current_user.plan == "free": + logger.info(f"Informe de muestra enviado por email a {mask_email(current_user.email)} para Empresa CUIT {mask_cuit(cuit_clean)}") + return { + "meta": { + "report_id": "limit_reached", + "generated_at": str(datetime.now()), + "message": "Se ha alcanzado el límite de 1 búsqueda gratuita.", + "action": "upgrade_plan", + "sample_sent_to": current_user.email + }, + "identificacion": {"nota": "Para ver razón social y fecha de constitución adquirí un plan."} + } + + try: + from app.database import AsyncSessionLocal + report = await service.get_company_report(cuit_clean) + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + + # Guardar en historial + await service.save_search_history( + user_id=str(current_user.id), + identifier=cuit_clean, + report_type="empresa", + name=report.identificacion.razon_social or cuit_clean, + report_id=report.meta.report_id + ) + return report + except AntiBotBlockedError as e: + logger.warning(f"Anti-Bot blocked during empresa report: {e}") + raise HTTPException(status_code=503, detail="La fuente de datos está bloqueando consultas. Intentá más tarde.") + except Exception as e: + logger.error(f"Error generating empresa report for {cuit}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Error al generar el informe. Intentá nuevamente.") + + +@router.get("/vehiculo/{dominio}", response_model=VehicleReport, summary="Obtener informe de vehículo") +async def get_vehiculo_report( + dominio: str, + current_user: User = Depends(current_active_user), +): + try: + report = await service.get_vehicle_report(dominio) + + # Cobrar crédito si no es cacheado + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + logger.info(f"Crédito descontado para {mask_email(current_user.email)} (Vehículo). Nuevo saldo: {current_user.credits - 1}") + + # Guardar en historial + await service.save_search_history( + user_id=str(current_user.id), + identifier=dominio.upper(), + report_type="vehiculo", + name=f"{report.vehiculo.marca or ''} {report.vehiculo.modelo or ''}".strip() or "Vehículo", + report_id=report.meta.report_id + ) + return report + except AntiBotBlockedError as e: + logger.warning(f"Anti-Bot blocked during vehicle report: {e}") + raise HTTPException(status_code=503, detail="La fuente de datos está bloqueando consultas. Intentá más tarde.") + except Exception as e: + logger.error(f"Error generating vehicle report for {dominio}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Error al generar el informe. Intentá nuevamente.") + + +@router.get("/vehiculo/{dominio}/pdf", summary="Descargar PDF de Vehículo") +async def get_vehiculo_pdf( + dominio: str, + current_user: User = Depends(current_active_user), +): + # Check credits for free users + if current_user.credits <= 0 and current_user.plan == "free": + raise HTTPException(status_code=429, detail="Alcanzaste el límite de informes. Actualizá tu plan para continuar.") + + try: + report = await service.get_vehicle_report(dominio) + + # Deduct credit for free users (only if not cached) + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="vehiculo") + return Response( + content=bytes(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=CrowData_Vehiculo_{dominio}.pdf"} + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error generating PDF for vehicle {dominio}: {e}") + raise HTTPException(status_code=500, detail="Error al generar PDF") + + +@router.get("/group/{cuit}", response_model=GroupReport, summary="Informe Consolidado de Grupo Económico") +async def get_group_economic_report( + cuit: str, + current_user: User = Depends(current_active_user), +): + try: + cuit_clean = await validate_cuit(cuit) + return await service.get_group_report(cuit_clean) + except Exception as e: + logger.error(f"Error generating group report: {e}") + raise HTTPException(status_code=500, detail="Error al consolidar grupo económico") + + +@router.get("/group/{cuit}/pdf", summary="Descargar PDF de Grupo Económico") +async def get_group_pdf( + cuit: str, + current_user: User = Depends(current_active_user), +): + cuit_clean = await validate_cuit(cuit) + try: + report = await service.get_group_report(cuit_clean) + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="grupo") + return Response( + content=bytes(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=CrowData_Grupo_{cuit_clean}.pdf"} + ) + except Exception as e: + logger.error(f"Error generating group PDF for {cuit}: {e}") + raise HTTPException(status_code=500, detail="Error al generar PDF de grupo") + + +@router.post("/propiedad", response_model=PropertyReport, summary="Obtener informe de propiedad por dirección") +async def get_property_report( + req: SearchPropertyRequest, + current_user: User = Depends(current_active_user), +): + try: + report = await service.get_property_report(req.calle, req.numero, req.localidad, req.provincia) + + # Cobrar crédito si no es cacheado + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + logger.info(f"Crédito descontado para {mask_email(current_user.email)} (Propiedad). Nuevo saldo: {current_user.credits - 1}") + + # Guardar en historial + await service.save_search_history( + user_id=str(current_user.id), + identifier=f"{req.calle} {req.numero}", + report_type="propiedad", + name=f"Inmueble: {req.calle} {req.numero}", + report_id=report.meta.report_id + ) + return report + except AntiBotBlockedError as e: + logger.warning(f"Anti-Bot blocked during property search: {e}") + raise HTTPException(status_code=503, detail="La fuente de datos está bloqueando consultas. Intentá más tarde.") + except Exception as e: + logger.error(f"Error searching property: {e}") + raise HTTPException(status_code=500, detail="Error al buscar propiedad") + + +@router.get("/search/persona", summary="Buscar Persona por nombre/DNI") +async def search_persona( + apellido: str | None = Query(None), + nombres: str | None = Query(None), + dni: str | None = Query(None), + cuit: str | None = Query(None), + provincia: str | None = Query(None), + current_user: User = Depends(current_active_user), +): + """Búsqueda de personas por apellido, nombre o DNI. Retorna lista de coincidencias básicas.""" + if not any([apellido, nombres, dni, cuit]): + raise HTTPException(status_code=422, detail="Ingresá al menos un criterio de búsqueda.") + + # Si viene CUIT directo de 11 dígitos, retornar como resultado directo + if cuit: + cuit_clean = re.sub(r"[^0-9]", "", cuit) + if len(cuit_clean) == 11: + # Retornar como resultado único para que el frontend navegue directamente + return {"results": [], "redirect_cuit": cuit_clean, "type": "direct"} + + # Si viene DNI, intentar resolver directo a CUIT sin pasar por el buscador de nombres + if dni and not apellido and not nombres: + dni_clean = re.sub(r"[^0-9]", "", dni) + if len(dni_clean) in (7, 8): + real_cuit = await find_real_cuit(dni_clean) + if real_cuit: + return {"results": [], "redirect_cuit": real_cuit, "type": "direct"} + + # Búsqueda real usando NameSearchScraper + query = f"{apellido or ''} {nombres or ''}".strip() or dni + try: + from app.scrapers.name_search import NameSearchScraper + ns_scraper = NameSearchScraper() + results = await ns_scraper.fetch(query) + return {"results": results} + except Exception as e: + logger.error(f"Error in name search: {e}") + return {"results": [], "message": "Error en el motor de búsqueda."} + + +@router.get("/search/empresa", summary="Buscar Empresa por nombre/CUIT") +async def search_empresa( + razon_social: str | None = Query(None), + cuit: str | None = Query(None), + current_user: User = Depends(current_active_user), +): + """Búsqueda de empresas por razón social o CUIT.""" + if not any([razon_social, cuit]): + raise HTTPException(status_code=422, detail="Ingresá razón social o CUIT.") + + if cuit: + return {"redirect": f"/reports/empresa/{cuit}", "type": "direct"} + + query = razon_social + try: + from app.scrapers.name_search import NameSearchScraper + ns_scraper = NameSearchScraper() + results = await ns_scraper.fetch(query) + return {"results": results} + except Exception as e: + logger.error(f"Error in company name search: {e}") + return {"results": [], "message": "Error en el motor de búsqueda."} + + +@router.get("/persona/{cuit}/pdf", summary="Descargar PDF de Persona") +async def get_persona_pdf( + cuit: str, + current_user: User = Depends(current_active_user), + force_refresh: bool = Query(False, description="Forzar regeneracion"), +): + cuit_clean = await validate_cuit(cuit) + + # Check credits for free users + if current_user.credits <= 0 and current_user.plan == "free": + raise HTTPException(status_code=429, detail="Alcanzaste el límite de informes. Actualizá tu plan para continuar.") + + try: + if force_refresh: + from app.cache.redis_client import cache_delete + await cache_delete(f"persona:{cuit_clean}") + report = await service.get_person_report(cuit_clean) + + # Deduct credit for free users (only if not cached) + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="persona") + return Response( + content=bytes(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=CrowData_Persona_{cuit_clean}.pdf"} + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error generating PDF for {cuit}: {e}") + raise HTTPException(status_code=500, detail="Error al generar PDF") + + +@router.get("/empresa/{cuit}/pdf", summary="Descargar PDF de Empresa") +async def get_empresa_pdf( + cuit: str, + current_user: User = Depends(current_active_user), +): + cuit_clean = await validate_cuit(cuit) + + # Check credits for free users + if current_user.credits <= 0 and current_user.plan == "free": + raise HTTPException(status_code=429, detail="Alcanzaste el límite de informes. Actualizá tu plan para continuar.") + + try: + report = await service.get_company_report(cuit_clean) + + # Deduct credit for free users (only if not cached) + if not getattr(report.meta, "cached", False) and current_user.plan == "free": + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await db.execute( + update(User) + .where(User.id == current_user.id) + .values(credits=User.credits - 1) + ) + await db.commit() + + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="empresa") + return Response( + content=bytes(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=CrowData_Empresa_{cuit_clean}.pdf"} + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error generating PDF for {cuit}: {e}") + raise HTTPException(status_code=500, detail="Error al generar PDF") + + +@router.post("/persona/{cuit}/email-pdf", summary="Enviar PDF de Persona por email") +async def email_persona_pdf( + cuit: str, + current_user: User = Depends(current_active_user), +): + """Genera el PDF de persona y lo envía por email al usuario.""" + from app.utils.email_service import send_pdf_report + cuit_clean = await validate_cuit(cuit) + try: + report = await service.get_person_report(cuit_clean) + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="persona") + sent = await send_pdf_report( + to_email=current_user.email, + report_type="persona", + identifier=cuit_clean, + pdf_bytes=bytes(pdf_bytes), + user_name=current_user.full_name, + ) + if sent: + return {"message": f"Informe enviado a {current_user.email}"} + raise HTTPException(status_code=500, detail="Error al enviar email") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error emailing persona PDF for {mask_cuit(cuit)}: {e}") + raise HTTPException(status_code=500, detail="Error al generar o enviar PDF") + + +@router.post("/empresa/{cuit}/email-pdf", summary="Enviar PDF de Empresa por email") +async def email_empresa_pdf( + cuit: str, + current_user: User = Depends(current_active_user), +): + """Genera el PDF de empresa y lo envía por email al usuario.""" + from app.utils.email_service import send_pdf_report + cuit_clean = await validate_cuit(cuit) + try: + report = await service.get_company_report(cuit_clean) + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="empresa") + sent = await send_pdf_report( + to_email=current_user.email, + report_type="empresa", + identifier=cuit_clean, + pdf_bytes=bytes(pdf_bytes), + user_name=current_user.full_name, + ) + if sent: + return {"message": f"Informe enviado a {current_user.email}"} + raise HTTPException(status_code=500, detail="Error al enviar email") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error emailing empresa PDF for {mask_cuit(cuit)}: {e}") + raise HTTPException(status_code=500, detail="Error al generar o enviar PDF") + + +@router.post("/vehiculo/{dominio}/email-pdf", summary="Enviar PDF de Vehículo por email") +async def email_vehiculo_pdf( + dominio: str, + current_user: User = Depends(current_active_user), +): + """Genera el PDF de vehículo y lo envía por email al usuario.""" + from app.utils.email_service import send_pdf_report + try: + report = await service.get_vehicle_report(dominio.upper()) + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="vehiculo") + sent = await send_pdf_report( + to_email=current_user.email, + report_type="vehiculo", + identifier=dominio.upper(), + pdf_bytes=bytes(pdf_bytes), + user_name=current_user.full_name, + ) + if sent: + return {"message": f"Informe enviado a {current_user.email}"} + raise HTTPException(status_code=500, detail="Error al enviar email") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error emailing vehiculo PDF for {dominio}: {e}") + raise HTTPException(status_code=500, detail="Error al generar o enviar PDF") + + +@router.get("/propiedad/pdf", summary="Descargar PDF de Propiedad por dirección") +async def get_propiedad_pdf( + calle: str = Query(..., description="Nombre de la calle"), + numero: str = Query(..., description="Número de puerta"), + localidad: str = Query("", description="Localidad"), + provincia: str = Query("", description="Provincia"), + current_user: User = Depends(current_active_user), +): + """Genera el PDF de propiedad y lo retorna como descarga.""" + try: + report = await service.get_property_report(calle, numero, localidad, provincia) + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="propiedad") + identifier = f"{calle} {numero}" + return Response( + content=bytes(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=CrowData_Propiedad_{identifier.replace(' ', '_')}.pdf"} + ) + except Exception as e: + logger.error(f"Error generating PDF for property {calle} {numero}: {e}") + raise HTTPException(status_code=500, detail="Error al generar PDF") + + +@router.post("/propiedad/email-pdf", summary="Enviar PDF de Propiedad por email") +async def email_propiedad_pdf( + body: dict, + current_user: User = Depends(current_active_user), +): + """Genera el PDF de propiedad y lo envía por email al usuario.""" + from app.utils.email_service import send_pdf_report + try: + calle = body.get("calle", "") + numero = body.get("numero", "") + localidad = body.get("localidad", "") + provincia = body.get("provincia", "") + report = await service.get_property_report(calle, numero, localidad, provincia) + identifier = f"{calle} {numero}" + pdf_bytes = generate_report_pdf(report.model_dump(), report_type="propiedad") + sent = await send_pdf_report( + to_email=current_user.email, + report_type="propiedad", + identifier=identifier, + pdf_bytes=bytes(pdf_bytes), + user_name=current_user.full_name, + ) + if sent: + return {"message": f"Informe enviado a {current_user.email}"} + raise HTTPException(status_code=500, detail="Error al enviar email") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error emailing propiedad PDF: {e}") + raise HTTPException(status_code=500, detail="Error al generar o enviar PDF") + + +@router.get("/empresa/{cuit}/contrataciones", summary="Contrataciones públicas de una empresa") +async def get_contrataciones( + cuit: str, + current_user: User = Depends(current_active_user), +): + """Consulta contrataciones públicas (CONTRATAR/COMPR.AR) por CUIT vía datos.gob.ar.""" + cuit_clean = await validate_cuit(cuit) + from app.reports.service import contratar_scraper + try: + result = await contratar_scraper.safe_fetch(cuit_clean) + return {"cuit": cuit_clean, "contrataciones": result} + except Exception as e: + logger.error(f"Error fetching contrataciones for {cuit}: {e}") + raise HTTPException(status_code=500, detail="Error al consultar contrataciones") + + +@router.get("/persona/{dni}/deudores-alimentarios", summary="Registro deudores alimentarios (PBA)") +async def get_deudores_alimentarios( + dni: str, + sexo: str = "M", + current_user: User = Depends(current_active_user), +): + """Consulta el RDAM de Provincia de Buenos Aires por DNI y sexo.""" + dni_clean = dni.replace("-", "").replace(".", "").strip() + if not dni_clean.isdigit() or len(dni_clean) < 7: + raise HTTPException(status_code=400, detail="DNI inválido") + if sexo.upper() not in ("M", "F", "NB", "NC"): + raise HTTPException(status_code=400, detail="Sexo inválido. Use M, F, NB o NC") + from app.reports.service import deudores_alimentarios_scraper + try: + result = await deudores_alimentarios_scraper.safe_fetch(dni_clean, sexo=sexo.upper()) + return {"dni": dni_clean, "deudores_alimentarios": result} + except Exception as e: + logger.error(f"Error fetching deudores alimentarios for DNI {dni}: {e}") + raise HTTPException(status_code=500, detail="Error al consultar deudores alimentarios") + + +@router.get("/user/history", response_model=list[HistoryItem], summary="Historial de búsquedas") +async def get_history( + current_user: User = Depends(current_active_user), +): + """Retorna el historial de búsquedas del usuario actual.""" + try: + return await service.get_user_history(str(current_user.id)) + except Exception as e: + logger.error(f"Error fetching history: {e}") + raise HTTPException(status_code=500, detail="Error al obtener historial") + + +@router.post("/user/monitor/{report_type}/{identifier}", summary="Activar monitoreo para un CUIT") +async def start_monitoring( + report_type: str, + identifier: str, + current_user: User = Depends(current_active_user), +): + try: + await service.add_monitor_task(str(current_user.id), identifier, report_type) + return {"message": f"Monitoreo activado para {identifier}"} + except Exception as e: + logger.error(f"Error starting monitor: {e}") + raise HTTPException(status_code=500, detail="Error al activar monitoreo") + + +@router.get("/user/monitor", response_model=list[MonitorItem], summary="Lista de CUITs monitoreados") +async def get_monitored_items( + current_user: User = Depends(current_active_user), +): + try: + return await service.get_monitor_tasks(str(current_user.id)) + except Exception as e: + logger.error(f"Error fetching monitor list: {e}") + raise HTTPException(status_code=500, detail="Error al obtener lista de monitoreo") + + +# ─── Telemetría y Salud de Scrapers ─────────────────────────────────────────── + +from app.utils.telemetry import get_system_health_summary + + +@router.get("/scrapers/health", summary="Estado de salud de los scrapers", tags=["telemetry"]) +async def get_scrapers_health( + current_user: User = Depends(current_active_user), +): + """ + Retorna el estado de salud de los 31 scrapers disponibles en CrowData. + + Incluye para cada fuente: + - Estado del último run (ok / empty / error / blocked) + - Timestamp del último run + - Tasa de éxito en las últimas 24h + - Latencia promedio + - Cantidad de registros encontrados + + Solo disponible para usuarios autenticados. + """ + try: + summary = get_system_health_summary() + return summary + except Exception as e: + logger.error(f"Error obteniendo health de scrapers: {e}") + raise HTTPException(status_code=500, detail="Error al obtener estado de scrapers") + + +@router.get("/scrapers/health/public", summary="Estado público de scrapers (resumen)", tags=["telemetry"]) +async def get_scrapers_health_public(): + """ + Versión pública del estado de salud: muestra cuántos scrapers están operativos + sin detalles individuales (para mostrar en la landing page). + """ + try: + summary = get_system_health_summary() + return { + "total_scrapers": summary["total_scrapers"], + "operativos": summary["operativos"], + "health_pct": summary["health_pct"], + } + except Exception as e: + logger.error(f"Error obteniendo health público: {e}") + raise HTTPException(status_code=500, detail="Error al obtener estado") + + +@router.get("/scrapers/alerts", summary="Alertas de scrapers con problemas", tags=["telemetry"]) +async def get_scrapers_alerts( + current_user: User = Depends(current_active_user), +): + """ + Detecta scrapers con problemas consistentes: + - Errores activos múltiples + - Bloqueos antibot + - Tasa de éxito baja (< 30%) + - Scrapers que nunca corrieron + """ + from app.utils.telemetry import get_scrapers_alerts as _get_alerts + try: + return {"alerts": _get_alerts()} + except Exception as e: + logger.error(f"Error obteniendo alertas de scrapers: {e}") + raise HTTPException(status_code=500, detail="Error al obtener alertas") + + +@router.get("/boletin", summary="Buscar publicaciones en el Boletín Oficial") +async def search_boletin( + q: str = Query(..., min_length=1, description="Término de búsqueda"), +): + """Búsqueda standalone del Boletín Oficial de la Nación.""" + from app.scrapers.boletin_oficial import BoletinOficialScraper + scraper = BoletinOficialScraper() + try: + result = await scraper.safe_fetch(q) + publicaciones = result.get("publicaciones", []) if isinstance(result, dict) else [] + return {"publicaciones": publicaciones, "total": len(publicaciones)} + except Exception as e: + logger.error(f"Error buscando en BO: {e}") + raise HTTPException(status_code=502, detail="Error consultando el Boletín Oficial.") diff --git a/app/reports/schema_validators.py b/app/reports/schema_validators.py new file mode 100644 index 0000000000000000000000000000000000000000..f3b25cebff2d3bb2b8169da406f7ec0677e6ae38 --- /dev/null +++ b/app/reports/schema_validators.py @@ -0,0 +1,203 @@ +""" +Validadores Pydantic para campos numéricos y críticos +Corrección de auditoría técnica - 05/07/2026 + +Agregar estos validadores a schemas.py para prevenir datos malformados +""" +from pydantic import field_validator +import logging + +logger = logging.getLogger(__name__) + + +# ═══════════════════════════════════════════════════════════════════════ +# VALIDADORES PARA BcraHistorial +# ═══════════════════════════════════════════════════════════════════════ + +class BcraHistorialValidators: + """ + Agregar estos validadores a la clase BcraHistorial en schemas.py + """ + + @field_validator('monto_deuda', mode='before') + @classmethod + def coerce_monto_deuda(cls, v): + """Convierte string a float si es necesario""" + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + # Limpiar formato de moneda + clean = v.replace('$', '').replace(',', '').replace('.', '').replace('k', '000').strip() + try: + return float(clean) if clean else None + except ValueError: + logger.warning(f"No se pudo convertir monto_deuda: {v}") + return None + return None + + @field_validator('situacion', mode='before') + @classmethod + def validate_situacion(cls, v): + """Valida que situación esté entre 1-6""" + if v is None: + return 1 # Default Normal + try: + sit = int(v) + if 1 <= sit <= 6: + return sit + logger.warning(f"Situación BCRA fuera de rango: {sit}, usando 1 (Normal)") + return 1 + except (ValueError, TypeError): + logger.warning(f"Situación BCRA inválida: {v}, usando 1 (Normal)") + return 1 + + @field_validator('dias_atraso', mode='before') + @classmethod + def coerce_dias_atraso(cls, v): + """Convierte a int de forma segura""" + if v is None: + return None + try: + return int(v) + except (ValueError, TypeError): + logger.warning(f"dias_atraso inválido: {v}") + return None + + +# ═══════════════════════════════════════════════════════════════════════ +# VALIDADORES PARA ChequeRechazado +# ═══════════════════════════════════════════════════════════════════════ + +class ChequeRechazadoValidators: + """ + Agregar estos validadores a la clase ChequeRechazado en schemas.py + """ + + @field_validator('monto', mode='before') + @classmethod + def coerce_monto(cls, v): + """Convierte string a float si es necesario""" + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + clean = v.replace('$', '').replace(',', '').replace('.', '').strip() + try: + return float(clean) if clean else None + except ValueError: + logger.warning(f"No se pudo convertir monto de cheque: {v}") + return None + return None + + +# ═══════════════════════════════════════════════════════════════════════ +# VALIDADORES PARA Inmueble +# ═══════════════════════════════════════════════════════════════════════ + +class InmuebleValidators: + """ + Agregar estos validadores a la clase Inmueble en schemas.py + """ + + @field_validator('valuacion_fiscal', mode='before') + @classmethod + def coerce_valuacion_fiscal(cls, v): + """Convierte string a float si es necesario""" + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + clean = v.replace('$', '').replace(',', '').replace('.', '').strip() + try: + return float(clean) if clean else None + except ValueError: + logger.warning(f"No se pudo convertir valuacion_fiscal: {v}") + return None + return None + + +# ═══════════════════════════════════════════════════════════════════════ +# VALIDADORES PARA InfraccionTransito +# ═══════════════════════════════════════════════════════════════════════ + +class InfraccionTransitoValidators: + """ + Agregar estos validadores a la clase InfraccionTransito en schemas.py + """ + + @field_validator('monto', mode='before') + @classmethod + def coerce_monto(cls, v): + """Convierte string a float si es necesario""" + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + clean = v.replace('$', '').replace(',', '').replace('.', '').strip() + try: + return float(clean) if clean else None + except ValueError: + logger.warning(f"No se pudo convertir monto de infracción: {v}") + return None + return None + + +# ═══════════════════════════════════════════════════════════════════════ +# EJEMPLO DE USO EN schemas.py +# ═══════════════════════════════════════════════════════════════════════ + +""" +# EN EL ARCHIVO schemas.py, AGREGAR: + +from pydantic import field_validator +import logging + +logger = logging.getLogger(__name__) + +class BcraHistorial(BaseModel): + periodo: Optional[str] = None + situacion: Optional[int] = None + entidad: Optional[str] = None + monto_deuda: Optional[float] = None + denominacion: Optional[str] = None + dias_atraso: Optional[int] = None + # ... resto de campos + + # VALIDADORES + @field_validator('monto_deuda', mode='before') + @classmethod + def coerce_monto_deuda(cls, v): + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + clean = v.replace('$', '').replace(',', '').replace('.', '').replace('k', '000').strip() + try: + return float(clean) if clean else None + except ValueError: + logger.warning(f"No se pudo convertir monto_deuda: {v}") + return None + return None + + @field_validator('situacion', mode='before') + @classmethod + def validate_situacion(cls, v): + if v is None: + return 1 + try: + sit = int(v) + if 1 <= sit <= 6: + return sit + logger.warning(f"Situación BCRA fuera de rango: {sit}, usando 1") + return 1 + except (ValueError, TypeError): + return 1 + +# REPETIR PARA CADA CLASE QUE NECESITE VALIDACIÓN +""" diff --git a/app/reports/schemas.py b/app/reports/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..e9c9edc239d1db1f2559130af1a6e776b68d4472 --- /dev/null +++ b/app/reports/schemas.py @@ -0,0 +1,629 @@ +from pydantic import BaseModel, Field, field_validator +from typing import Optional +from datetime import date, datetime + + +# ── Identificación ──────────────────────────────────────────────────────────── +class Domicilio(BaseModel): + tipo: Optional[str] = None # fiscal | real + calle: Optional[str] = None + numero: Optional[str] = None + piso: Optional[str] = None + dpto: Optional[str] = None + torre: Optional[str] = None + manzana: Optional[str] = None + sector: Optional[str] = None + localidad: Optional[str] = None + provincia: Optional[str] = None + id_provincia: Optional[int] = None + cp: Optional[str] = None + dato_adicional: Optional[str] = None + tipo_dato_adicional: Optional[str] = None + estado: Optional[str] = None + + +class Identificacion(BaseModel): + dni: Optional[str] = None + cuil: Optional[str] = None + apellido: Optional[str] = None + nombres: Optional[str] = None + fecha_nacimiento: Optional[str] = None + fecha_defuncion: Optional[str] = None + sexo: Optional[str] = None + nacionalidad: Optional[str] = None + foto_perfil: Optional[str] = None + foto_perfil_fuente: Optional[str] = None # "linkedin", "ui_avatars", None + validado_renaper: bool = False + # Padrón Electoral + seccion_electoral: Optional[str] = None # Distrito/sección del padrón + lugar_votacion: Optional[str] = None # Escuela/establecimiento de votación + mesa_votacion: Optional[str] = None # Número de mesa + orden_padron: Optional[str] = None # Orden en el padrón + cruzado_con_busqueda: bool = False + busqueda_tipo: Optional[str] = None + # Contacto adicional de IGJ + pagina_web: Optional[str] = None + email_contacto: Optional[str] = None + linkedin: Optional[str] = None + + +class Contacto(BaseModel): + domicilios: list[Domicilio] = [] + telefonos: list[str] = [] + + +# ── Fiscal ──────────────────────────────────────────────────────────────────── +class ActividadFiscal(BaseModel): + codigo_clae: Optional[str] = None + descripcion: Optional[str] = None + es_principal: bool = False + + +class Monotributo(BaseModel): + categoria: Optional[str] = None + actividad_principal: Optional[str] = None + fecha_alta: Optional[str] = None + + +class DatosFiscales(BaseModel): + cuit: Optional[str] = None + tipo_clave: Optional[str] = None + tipo_documento: Optional[str] = None + numero_documento: Optional[str] = None + nombres: Optional[str] = None + apellido: Optional[str] = None + nombres_completo: Optional[str] = None + condicion_iva: Optional[str] = None + estado_afip: Optional[str] = None + fecha_inicio_actividad: Optional[str] = None + fecha_nacimiento: Optional[str] = None + fecha_contrato_social: Optional[str] = None + fecha_fallecimiento: Optional[str] = None + forma_juridica: Optional[str] = None + mes_cierre: Optional[int] = None + periodo_actividad_principal: Optional[int] = None + actividades: list[ActividadFiscal] = [] + domicilios: list[Domicilio] = [] + domicilio_fiscal: Optional[Domicilio] = None + monotributo: Optional[Monotributo] = None + riesgo_sircreb: Optional[str] = None + riesgo_sircupa: Optional[str] = None + + +# ── Financiero ──────────────────────────────────────────────────────────────── +class BcraHistorial(BaseModel): + periodo: Optional[str] = None + situacion: Optional[int] = None + entidad: Optional[str] = None + monto_deuda: Optional[float] = None + denominacion: Optional[str] = None + dias_atraso: Optional[int] = None + fecha_sit1: Optional[str] = None + refinanciaciones: Optional[bool] = None + situacion_juridica: Optional[str] = None + recategorizacion: Optional[bool] = None + irrec_disp_tecnica: Optional[bool] = None + en_revision: Optional[bool] = None + proceso_judicial: Optional[bool] = None + + @field_validator('situacion_juridica', mode='before') + @classmethod + def coerce_situacion_juridica(cls, v): + if v is None: + return None + if isinstance(v, bool): + return "Sí" if v else "No" + return str(v) + + +class ChequeRechazado(BaseModel): + causal: Optional[str] = None + entidad_codigo: Optional[str] = None + nro_cheque: Optional[str] = None + fecha_rechazo: Optional[str] = None + monto: Optional[float] = None + fecha: Optional[str] = None + banco: Optional[str] = None + estado: Optional[str] = None + fecha_pago: Optional[str] = None + fecha_pago_multa: Optional[str] = None + estado_multa: Optional[str] = None + cta_personal: Optional[bool] = None + denom_juridica: Optional[str] = None + en_revision: Optional[bool] = None + proceso_judicial: Optional[bool] = None + + +class BilleteraVirtual(BaseModel): + entidad_bcra: Optional[str] = None + marca: Optional[str] = None + tipo: Optional[str] = None + rubro: Optional[str] = None + situacion: Optional[int] = None + situacion_desc: Optional[str] = None + monto: Optional[float] = None + periodo: Optional[str] = None + es_fintech: bool = False + + +class DatosBilleterasVirtuales(BaseModel): + total_deuda_fintech: float = 0 + cantidad_wallets_con_deuda: int = 0 + cantidad_fintech_detectadas: int = 0 + detalle: list[BilleteraVirtual] = [] + + +class DatosFinancieros(BaseModel): + denominacion: Optional[str] = None + bcra_situacion_actual: Optional[int] = None + bcra_situacion_descripcion: Optional[str] = None + bcra_historial: list[BcraHistorial] = [] + bcra_total_deuda_miles: Optional[float] = None + bcra_dias_atraso_max: Optional[int] = None + cheques_rechazados: list[ChequeRechazado] = [] + tiene_deuda: bool = False + total_deuda_miles: Optional[float] = None + dias_atraso_max: Optional[int] = None + + +# ── Societario ──────────────────────────────────────────────────────────────── +class ParticipacionSocietaria(BaseModel): + cuit_empresa: Optional[str] = None + razon_social: Optional[str] = None + rol: Optional[str] = None + fecha_desde: Optional[str] = None + + +class MatriculaProfesional(BaseModel): + consejo: Optional[str] = None + matricula: Optional[str] = None + estado: Optional[str] = None + + +class DatosSocietarios(BaseModel): + participaciones: list[ParticipacionSocietaria] = [] + matriculas_profesionales: list[MatriculaProfesional] = [] + cnv_registros: list[dict] = [] + +class TituloAcademico(BaseModel): + titulo: str + institucion: Optional[str] = None + anio_graduacion: Optional[str] = None + nivel: Optional[str] = None # Grado | Posgrado | Técnico + +class DatosAcademicos(BaseModel): + titulos: list[TituloAcademico] = [] + certificaciones: list[str] = [] + + +# ── Patrimonial ─────────────────────────────────────────────────────────────── +class Vehiculo(BaseModel): + dominio: Optional[str] = None + marca: Optional[str] = None + modelo: Optional[str] = None + anio: Optional[int] = None + tipo: Optional[str] = None + radicacion: Optional[str] = None + registro: Optional[str] = None + localidad: Optional[str] = None + provincia: Optional[str] = None + validez_vtv: Optional[str] = None + tiene_seguro: bool = True + estado: Optional[str] = None + + +class Inmueble(BaseModel): + partido: Optional[str] = None + matricula: Optional[str] = None + tipo: Optional[str] = None + descripcion: Optional[str] = None + provincia: Optional[str] = None + superficie: Optional[str] = None + nro_partida: Optional[str] = None + # Nuevos campos + valuacion_fiscal: Optional[float] = None + fuente: Optional[str] = None + + +class Inhibicion(BaseModel): + tipo: Optional[str] = None + organismo: Optional[str] = None + fecha: Optional[str] = None + monto: Optional[float] = None + descripcion: Optional[str] = None + + +class DatosPatrimoniales(BaseModel): + vehiculos: list[Vehiculo] = [] + inmuebles: list[Inmueble] = [] + inhibiciones: list[Inhibicion] = [] + deuda_patentes: list[dict] = [] + + +# ── Judicial ────────────────────────────────────────────────────────────────── +class CausaJudicial(BaseModel): + expediente: Optional[str] = None + fuero: Optional[str] = None + juzgado: Optional[str] = None + caratula: Optional[str] = None + fecha: Optional[str] = None + estado: Optional[str] = None + jurisdiccion: Optional[str] = None + caratula_completa: Optional[str] = None + voces: Optional[str] = None + sumario: Optional[str] = None + magistrados: Optional[str] = None + tipo_fallo: Optional[str] = None + + +class DatosJudiciales(BaseModel): + causas: list[CausaJudicial] = [] + antecedentes_penales: bool = False + concursos: list[dict] = [] + quiebras: list[dict] = [] + inhibiciones_embargos: list[Inhibicion] = [] + +class DatosRegistroCivil(BaseModel): + fallecido: bool = False + fecha_defuncion: Optional[str] = None + lugar_defuncion: Optional[str] = None + actas_disponibles: list[str] = [] # Nacimiento, Matrimonio, Defunción + numero_acta: Optional[str] = None + # Nuevos campos + fecha_nacimiento: Optional[str] = None + lugar_nacimiento: Optional[str] = None + estado_civil: Optional[str] = None # "Soltero/a", "Casado/a", "Divorciado/a", "Viudo/a" + fecha_matrimonio: Optional[str] = None + conyuge_nombre: Optional[str] = None + + +# ── Boletín Oficial ─────────────────────────────────────────────────────────── +class PublicacionBO(BaseModel): + fecha: Optional[str] = None + seccion: Optional[str] = None + rubro: Optional[str] = None + texto: Optional[str] = None + numero_boletin: Optional[str] = None + url: Optional[str] = None + fuente: Optional[str] = None + tipo: Optional[str] = None + + +# ── Otros ───────────────────────────────────────────────────────────────────── +class MarcaINPI(BaseModel): + denominacion: Optional[str] = None + clase: Optional[str] = None + estado: Optional[str] = None + fecha_solicitud: Optional[str] = None + acta: Optional[str] = None + titulares: Optional[str] = None + tipo_marca: Optional[str] = None + numero_resolucion: Optional[str] = None + fecha_vencimiento: Optional[str] = None + + +class InfraccionTransito(BaseModel): + fecha: Optional[str] = None + tipo: Optional[str] = None + motivo: Optional[str] = None # alias legible del tipo + organismo: Optional[str] = None + jurisdiccion: Optional[str] = None # requerido por frontend + monto: Optional[float] = None + estado: Optional[str] = None + acta: Optional[str] = None + dominio: Optional[str] = None + nro_causa: Optional[str] = None + vencimiento: Optional[str] = None + + +class RedSocial(BaseModel): + plataforma: str # LinkedIn | Facebook | Instagram | Twitter + usuario: str + url: Optional[str] = None + seguidores: Optional[str] = None + snippet: Optional[str] = None # Extracto del resultado de búsqueda (Google/DDG) + + +class Vinculo(BaseModel): + cuit: Optional[str] = None + nombre: Optional[str] = None + tipo: Optional[str] = None # Familiar | Societario | Domiciliario + detalle: Optional[str] = None + + +class DatosPrevisionales(BaseModel): + tiene_aportes: bool = False + aportes_al_dia: Optional[bool] = None # None = no consultado + ultimo_empleador: Optional[str] = None + obra_social: Optional[str] = None + fecha_alta_obra_social: Optional[str] = None # Fecha de alta en la obra social + tipo_beneficiario: Optional[str] = None # Dependiente / Autónomo / Monotributista + estado_padron: Optional[str] = None + beneficios_sociales: list[str] = [] # AUH, Progresar, etc. + jubilaciones_pensiones: list[str] = [] + fecha_proximo_cobro: Optional[str] = None + lugar_cobro: Optional[str] = None + historial_coberturas: list[dict] = [] + diagnostics: dict = {} + + +class IndicadorRiesgo(BaseModel): + codigo: str + nivel: str # Bajo | Medio | Alto | Critico + descripcion: str + hallazgo: Optional[str] = None + + +class ScoreHistorial(BaseModel): + fecha: str + valor: int + + +class ScoreCrediticio(BaseModel): + valor: int # 1-100 + nivel: str # Excelente / Bueno / Regular / Malo / Crítico + historial: list[ScoreHistorial] = [] + factores: list[str] = [] # ["BCRA sit=5 (Irrecuperable) -40pts", ...] + + +# ── Meta del informe ────────────────────────────────────────────────────────── +class ReportMeta(BaseModel): + report_id: str + generated_at: str + sources: list[str] = [] + failures: list[str] = [] + empty_sources: list[str] = [] + diagnostics: dict = {} + cached: bool = False + version: str = "1.0" + action: Optional[str] = None + message: Optional[str] = None + sample_sent_to: Optional[str] = None + +class ViasSalud(BaseModel): + cuit_consultado: Optional[str] = None + cobertura_activa: bool = False + detalles: dict = {} + +class ContratoEstado(BaseModel): + cuit_proveedor: Optional[str] = None + razon_social: Optional[str] = None + estado_inscripcion: Optional[str] = None + rubro_principal: Optional[str] = None + registro: Optional[str] = None + adjudicaciones: list[dict] = [] + contratos: list[dict] = [] + + + +class TimelineEvent(BaseModel): + fecha: str # YYYY-MM-DD o YYYY-MM + titulo: str # "Monotributo Cat. C" + descripcion: Optional[str] = None # "Alta en monotributo categoría C" + categoria: str = "general" # fiscal | financiero | judicial | previsional | patrimonial | general + icono: Optional[str] = None # emoji override + color: Optional[str] = None # green | yellow | red | blue | gray + fuente: Optional[str] = None # ARCA, BCRA, BO, etc. + + +# ═══════════════════════════════════════════════════════════════════════════════ +# DEUDORES ALIMENTARIOS +# ═══════════════════════════════════════════════════════════════════════════════ +class DeudorAlimentario(BaseModel): + dni: Optional[str] = None + nombre_completo: Optional[str] = None + sexo: Optional[str] = None + resultado: Optional[str] = None # CERTIFICADO_GENERADO, REQUERIR_NOMBRE, NO_REGISTRADO + renaper_validado: bool = False + pdf_path: Optional[str] = None + mensaje: Optional[str] = None + fuente: Optional[str] = None + url: Optional[str] = None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# WEB HISTORIAL (ARCHIVE.ORG) +# ═══════════════════════════════════════════════════════════════════════════════ +class WebHistorialItem(BaseModel): + timestamp: Optional[str] = None + fecha: Optional[str] = None + status: Optional[str] = None + tipo: Optional[str] = None + url_wayback: Optional[str] = None + + +class WebHistorial(BaseModel): + url_consultada: Optional[str] = None + snapshot_mas_cercano: Optional[dict] = None + historial: list[WebHistorialItem] = [] + total_snapshots: int = 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# PERSONA FÍSICA +# ═══════════════════════════════════════════════════════════════════════════════ +class PersonReport(BaseModel): + meta: ReportMeta + score: Optional[ScoreCrediticio] = None + identificacion: Identificacion = Field(default_factory=Identificacion) + contacto: Contacto = Field(default_factory=Contacto) + fiscal: DatosFiscales = Field(default_factory=DatosFiscales) + financiero: DatosFinancieros = Field(default_factory=DatosFinancieros) + societario: DatosSocietarios = Field(default_factory=DatosSocietarios) + patrimonial: DatosPatrimoniales = Field(default_factory=DatosPatrimoniales) + judicial: DatosJudiciales = Field(default_factory=DatosJudiciales) + boletin_oficial: list[PublicacionBO] = [] + infracciones_transito: list[InfraccionTransito] = [] + previsional: DatosPrevisionales = Field(default_factory=DatosPrevisionales) + vinculos: list[Vinculo] = [] + riesgo: list[IndicadorRiesgo] = [] + billeteras_virtuales: DatosBilleterasVirtuales = Field(default_factory=DatosBilleterasVirtuales) + registro_civil: DatosRegistroCivil = Field(default_factory=DatosRegistroCivil) + academico: DatosAcademicos = Field(default_factory=DatosAcademicos) + historial_domicilios: list[Domicilio] = [] + telefonos: list[str] = [] + redes_sociales: list[RedSocial] = [] + salud: Optional[ViasSalud] = None + compras_estatales: list[ContratoEstado] = [] + peps: list[dict] = [] + marcas_inpi: list[MarcaINPI] = [] + timeline: list[TimelineEvent] = [] + deudores_alimentarios: Optional[DeudorAlimentario] = None + web_historial: Optional[WebHistorial] = None + monotributo_historial: Optional[dict] = None + registro_conductores: Optional[dict] = None + + @field_validator('timeline', mode='before') + @classmethod + def coerce_timeline(cls, v): + return v if v is not None else [] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# PERSONA JURÍDICA / EMPRESA +# ═══════════════════════════════════════════════════════════════════════════════ +class SocioDirectivo(BaseModel): + cuil: Optional[str] = None + nombre: Optional[str] = None + rol: Optional[str] = None + participacion_pct: Optional[float] = None + fecha_designacion: Optional[str] = None + + +class Balance(BaseModel): + periodo: Optional[str] = None + fecha_presentacion: Optional[str] = None + resultado: Optional[float] = None + tipo: Optional[str] = None + + +class DatosIGJ(BaseModel): + numero_inscripcion: Optional[str] = None + razon_social: Optional[str] = None + tipo_societario: Optional[str] = None + estado: Optional[str] = None + objeto_social: Optional[str] = None + socios_directivos: list[SocioDirectivo] = [] + balances: list[Balance] = [] + domicilio_legal: Optional[Domicilio] = None + pagina_web: Optional[str] = None + email_contacto: Optional[str] = None + linkedin: Optional[str] = None + contacto_raw: Optional[str] = None + + +class CompanyIdentificacion(BaseModel): + cuit: Optional[str] = None + razon_social: Optional[str] = None + nombre_fantasia: Optional[str] = None + tipo_societario: Optional[str] = None + fecha_constitucion: Optional[str] = None + pagina_web: Optional[str] = None + telefono: Optional[str] = None + email_contacto: Optional[str] = None + linkedin: Optional[str] = None + contacto_institucional: Optional[str] = None + fecha_constitucion: Optional[str] = None + + +class CompanyReport(BaseModel): + meta: ReportMeta + score: Optional[ScoreCrediticio] = None + identificacion: CompanyIdentificacion = Field(default_factory=CompanyIdentificacion) + igj: DatosIGJ = Field(default_factory=DatosIGJ) + fiscal: DatosFiscales = Field(default_factory=DatosFiscales) + financiero: DatosFinancieros = Field(default_factory=DatosFinancieros) + patrimonial: DatosPatrimoniales = Field(default_factory=DatosPatrimoniales) + judicial: DatosJudiciales = Field(default_factory=DatosJudiciales) + boletin_oficial: list[PublicacionBO] = [] + marcas_inpi: list[MarcaINPI] = [] + vinculos: list[Vinculo] = [] + riesgo: list[IndicadorRiesgo] = [] + compras_estatales: list[ContratoEstado] = [] + peps: list[dict] = [] + cnv_registros: list[dict] = [] + web_historial: Optional[WebHistorial] = None + timeline: list[TimelineEvent] = [] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# VEHÍCULO +# ═══════════════════════════════════════════════════════════════════════════════ +class VehicleReport(BaseModel): + meta: ReportMeta + vehiculo: Vehiculo = Field(default_factory=Vehiculo) + titular: Optional[Identificacion] = None + prendas: list[dict] = [] + denuncias_robo: list[dict] = [] + infracciones: list[InfraccionTransito] = [] + riesgo: list[IndicadorRiesgo] = [] + + +class PropertyReport(BaseModel): + meta: ReportMeta + direccion: str + geolocalizacion: Optional[dict] = None + datos_catastrales: Optional[dict] = None + titulares_detectados: list[Identificacion] = [] + historial_boletin: list[PublicacionBO] = [] + riesgo: list[IndicadorRiesgo] = [] + valor_estimado: Optional[float] = None + deudas_impositivas: Optional[dict] = None + + +# ── Inputs de búsqueda ──────────────────────────────────────────────────────── +class SearchPersonaRequest(BaseModel): + cuit: Optional[str] = None + dni: Optional[str] = None + apellido: Optional[str] = None + nombres: Optional[str] = None + provincia: Optional[str] = None + + +class SearchEmpresaRequest(BaseModel): + cuit: Optional[str] = None + razon_social: Optional[str] = None + provincia: Optional[str] = None + + +class SearchVehiculoRequest(BaseModel): + dominio: str + + +class SearchPropertyRequest(BaseModel): + calle: str + numero: str + localidad: str + provincia: str = "" + + +class GroupReport(BaseModel): + meta: ReportMeta + score: Optional[ScoreCrediticio] = None + target: PersonReport + empresas_vinculadas: list[CompanyReport] = [] + vehiculos_vinculados: list[VehicleReport] = [] + total_patrimonio_estimado: Optional[float] = None + riesgo_consolidado: list[IndicadorRiesgo] = [] + + +class HistoryItem(BaseModel): + id: int + identifier: str + type: str + name: Optional[str] = None + created_at: datetime + report_id: Optional[str] = None + + +class MonitorItem(BaseModel): + id: int + identifier: str + type: str + active: bool + alert_count: int + last_check: datetime + + + + diff --git a/app/reports/scraper_registry.py b/app/reports/scraper_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..03cf421ac9cce7c7a546ab3f9e78cbf1d70d3528 --- /dev/null +++ b/app/reports/scraper_registry.py @@ -0,0 +1,467 @@ +""" +Scraper Registry — Configuración centralizada de todos los scrapers. +Permite definir prioridades, timeouts, categorías y dependencias. +""" +from enum import Enum +from dataclasses import dataclass, field +from typing import Any, Callable, Optional +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +class ScraperCategory(Enum): + CORE = "core" # Críticos: si fallan, el reporte está incompleto + FINANCIAL = "financial" # Financieros + JUDICIAL = "judicial" # Judiciales + PATRIMONIAL = "patrimonial" # Patrimoniales + OSINT = "osint" # Inteligencia abierta + OTHER = "other" # Otros + + +@dataclass +class ScraperDef: + """Definición de un scraper con metadatos operacionales.""" + name: str # Identificador único + display_name: str # Nombre para UI/logs + category: ScraperCategory # Categoría operativa + instance: Any # Instancia del scraper (BaseScraper) + timeout: int = 60 # Timeout en segundos + critical: bool = False # Si True, fallo = reporte incompleto + dependencies: list[str] = field(default_factory=list) # Nombres de scrapers que deben ejecutarse antes + max_retries: int = 3 + retry_delay: float = 2.0 + + +class ScraperRegistry: + """Registro central de scrapers con resolución de dependencias.""" + + def __init__(self): + self._scrapers: dict[str, ScraperDef] = {} + self._execution_order: list[str] = [] + + def register(self, definition: ScraperDef) -> None: + """Registra un scraper.""" + self._scrapers[definition.name] = definition + self._execution_order = None # Invalidate cache + + def get(self, name: str) -> Optional[ScraperDef]: + return self._scrapers.get(name) + + def get_instance(self, name: str) -> Any: + return self._scrapers[name].instance if name in self._scrapers else None + + def get_execution_order(self, category_filter: Optional[ScraperCategory] = None) -> list[str]: + """ + Retorna orden de ejecución topológico basado en dependencias. + """ + if self._execution_order and not category_filter: + return self._execution_order + + # Kahn's algorithm for topological sort + all_scrapers = self._scrapers.values() + if category_filter: + all_scrapers = [s for s in all_scrapers if s.category == category_filter] + + # Build adjacency + in_degree = {s.name: 0 for s in all_scrapers} + adj = {s.name: [] for s in all_scrapers} + + for s in all_scrapers: + for dep in s.dependencies: + if dep in adj: + adj[dep].append(s.name) + in_degree[s.name] += 1 + + # Kahn + queue = [name for name, deg in in_degree.items() if deg == 0] + order = [] + + while queue: + name = queue.pop(0) + order.append(name) + for neighbor in adj.get(name, []): + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + if len(order) != len(all_scrapers): + logger.warning("Circular dependency detected in scraper dependencies") + + self._execution_order = order + return order + + def get_by_category(self, category: ScraperCategory) -> list[ScraperDef]: + return [s for s in self._scrapers.values() if s.category == category] + + def get_critical(self) -> list[ScraperDef]: + return [s for s in self._scrapers.values() if s.critical] + + +# Global registry instance +registry = ScraperRegistry() + + +def register_scrapers(): + """Registra todos los scrapers del sistema.""" + from app.scrapers.arca_afip import ArcaAfipScraper + from app.scrapers.bcra import BcraScraper + from app.scrapers.dnrpa import DnrpaScraper + from app.scrapers.poder_judicial import PoderJudicialScraper + from app.scrapers.renaper import RenaperScraper + from app.scrapers.igj import IgjScraper + from app.scrapers.inpi import InpiScraper + from app.scrapers.uif import UifScraper + from app.scrapers.arba_automotores import ArbaAutomotoresScraper + from app.scrapers.arba_catastro import ArbaCatastroScraper + from app.scrapers.boletin_oficial import BoletinOficialScraper + from app.scrapers.boletines_provinciales import BoletinesProvincialesScraper + from app.scrapers.anses import AnsesScraper + from app.scrapers.cnv import CnvScraper + from app.scrapers.compras_estatales import ComprasEstatalesScraper + from app.scrapers.monotributo_historial import MonotributoHistorialScraper + from app.scrapers.registro_conductores import RegistroConductoresScraper + from app.scrapers.infracciones import InfraccionesScraper + from app.scrapers.deudores_alimentarios import DeudoresAlimentariosScraper + from app.scrapers.juba import JubaScraper + from app.scrapers.poder_judicial_provincial import PoderJudicialProvincialScraper + from app.scrapers.inhibiciones import InhibicionesScraper + from app.scrapers.arba_catastro import ArbaCatastroScraper + from app.scrapers.carto_arba import CartoArbaScraper + from app.scrapers.sinai import SinaiScraper + from app.scrapers.siscop import SiscopScraper + from app.scrapers.google_images import GoogleImagesScraper + from app.scrapers.redes_sociales import RedesSocialesScraper + from app.scrapers.telefonia import TelefoniaScraper + from app.scrapers.name_search import NameSearchScraper + from app.scrapers.billeteras_virtuales import BilleterasVirtualesScraper + from app.scrapers.ruido import RuidoScraper + from app.scrapers.sgarhu import SgarhuScraper + from app.scrapers.contratar import ContratarScraper + from app.scrapers.colegios_profesionales import ColegiosProfesionalesScraper + from app.scrapers.renaper_facial import RenaperFacialScraper + from app.scrapers.timeline_boa import TimelineBoletinScraper + from app.scrapers.archive_org import ArchiveOrgScraper + from app.scrapers.padron_electoral import PadronElectoralScraper + from app.scrapers.billeteras_virtuales import BilleterasVirtualesScraper + from app.scrapers.monotributo_historial import MonotributoHistorialScraper + from app.scrapers.registro_conductores import RegistroConductoresScraper + from app.scrapers.timeline_boa import TimelineBoletinScraper + from app.scrapers.infracciones import InfraccionesScraper + from app.scrapers.inhibiciones import InhibicionesScraper + from app.scrapers.juba import JubaScraper + from app.scrapers.contratar import ContratarScraper + from app.scrapers.colegios_profesionales import ColegiosProfesionalesScraper + from app.scrapers.renaper_facial import RenaperFacialScraper + from app.scrapers.timeline_boa import TimelineBoletinScraper + from app.scrapers.archive_org import ArchiveOrgScraper + from app.scrapers.padron_electoral import PadronElectoralScraper + from app.scrapers.compras_estatales import ComprasEstatalesScraper + + # Importar todas las instancias de scrapers + from app.scrapers.arca_afip import arca_scraper + from app.scrapers.bcra import bcra_scraper + from app.scrapers.dnrpa import dnrpa_scraper + from app.scrapers.poder_judicial import judicial_scraper + from app.scrapers.renaper import renaper_scraper + from app.scrapers.igj import igj_scraper + from app.scrapers.inpi import inpi_scraper + from app.scrapers.uif import uif_scraper + from app.scrapers.arba_automotores import arba_automotores_scraper + from app.scrapers.arba_catastro import arba_catastro_scraper + from app.scrapers.boletin_oficial import boletin_scraper + from app.scrapers.boletines_provinciales import boletines_prov_scraper + from app.scrapers.anses import anses_scraper + from app.scrapers.cnv import cnv_scraper + from app.scrapers.compras_estatales import compras_scraper + from app.scrapers.monotributo_historial import monotributo_historial_scraper + from app.scrapers.registro_conductores import registro_conductores_scraper + from app.scrapers.infracciones import infracciones_scraper + from app.scrapers.deudores_alimentarios import deudores_alimentarios_scraper + from app.scrapers.juba import juba_scraper + from app.scrapers.poder_judicial_provincial import judicial_provincial_scraper + from app.scrapers.inhibiciones import inhibiciones_scraper + from app.scrapers.arba_catastro import arba_catastro_scraper + from app.scrapers.carto_arba import carto_arba_scraper + from app.scrapers.sinai import sinai_scraper + from app.scrapers.siscop import siscop_scraper + from app.scrapers.google_images import google_images_scraper + from app.scrapers.redes_sociales import redes_scraper + from app.scrapers.telefonia import telefonia_scraper + from app.scrapers.name_search import name_search_scraper + from app.scrapers.billeteras_virtuales import billeteras_virtuales_scraper + from app.scrapers.ruido import ruido_scraper + from app.scrapers.sgarhu import sgarhu_scraper + from app.scrapers.contratar import contratar_scraper + from app.scrapers.colegios_profesionales import colegios_scraper + from app.scrapers.renaper_facial import renaper_facial_scraper + from app.scrapers.timeline_boa import timeline_boa_scraper + from app.scrapers.archive_org import archive_org_scraper + from app.scrapers.padron_electoral import padron_electoral_scraper + from app.scrapers.compras_estatales import compras_estatales_scraper + from app.scrapers.cnv import cnv_scraper + + # CORE - Críticos (dependency: ARCA first for identity) + registry.register(ScraperDef( + name="arca_afip", display_name="ARCA/AFIP", + category=ScraperCategory.CORE, instance=arca_scraper, + timeout=60, critical=True, + dependencies=[] + )) + registry.register(ScraperDef( + name="bcra", display_name="BCRA", + category=ScraperCategory.CORE, instance=bcra_scraper, + timeout=30, critical=True, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="dnrpa", display_name="DNRPA", + category=ScraperCategory.CORE, instance=dnrpa_scraper, + timeout=90, critical=True, + dependencies=[] + )) + registry.register(ScraperDef( + name="poder_judicial", display_name="Poder Judicial", + category=ScraperCategory.CORE, instance=judicial_scraper, + timeout=120, critical=True, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="renaper", display_name="RENAPER", + category=ScraperCategory.CORE, instance=renaper_scraper, + timeout=60, critical=True, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="igj", display_name="IGJ", + category=ScraperCategory.CORE, instance=igj_scraper, + timeout=60, critical=True, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="inpi", display_name="INPI", + category=ScraperCategory.CORE, instance=inpi_scraper, + timeout=60, critical=True, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="uif", display_name="UIF/PEPs", + category=ScraperCategory.CORE, instance=uif_scraper, + timeout=60, critical=True, + dependencies=[] + )) + + # FINANCIAL + registry.register(ScraperDef( + name="arba_automotores", display_name="ARBA Automotores", + category=ScraperCategory.FINANCIAL, instance=arba_automotores_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="arba_catastro", display_name="ARBA Catastro", + category=ScraperCategory.FINANCIAL, instance=arba_catastro_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="boletin_oficial", display_name="Boletín Oficial", + category=ScraperCategory.FINANCIAL, instance=boletin_scraper, + timeout=30, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="boletines_provinciales", display_name="Boletines Provinciales", + category=ScraperCategory.FINANCIAL, instance=boletines_prov_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="anses", display_name="ANSES", + category=ScraperCategory.FINANCIAL, instance=anses_scraper, + timeout=120, critical=False, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="cnv", display_name="CNV", + category=ScraperCategory.FINANCIAL, instance=cnv_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="compras_estatales", display_name="COMPR.AR", + category=ScraperCategory.FINANCIAL, instance=compras_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="monotributo_historial", display_name="Monotributo Historial", + category=ScraperCategory.FINANCIAL, instance=monotributo_historial_scraper, + timeout=60, critical=False, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="registro_conductores", display_name="Registro Conductores", + category=ScraperCategory.FINANCIAL, instance=registro_conductores_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="infracciones", display_name="Infracciones", + category=ScraperCategory.FINANCIAL, instance=infracciones_scraper, + timeout=60, critical=False, + dependencies=[] + )) + + # JUDICIAL + registry.register(ScraperDef( + name="deudores_alimentarios", display_name="Deudores Alimentarios", + category=ScraperCategory.JUDICIAL, instance=deudores_alimentarios_scraper, + timeout=120, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="juba", display_name="JUBA", + category=ScraperCategory.JUDICIAL, instance=juba_scraper, + timeout=120, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="poder_judicial_provincial", display_name="Poder Judicial Provincial", + category=ScraperCategory.JUDICIAL, instance=judicial_provincial_scraper, + timeout=120, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="inhibiciones", display_name="Inhibiciones", + category=ScraperCategory.JUDICIAL, instance=inhibiciones_scraper, + timeout=60, critical=False, + dependencies=[] + )) + + # PATRIMONIAL + registry.register(ScraperDef( + name="arba_catastro", display_name="ARBA Catastro", + category=ScraperCategory.PATRIMONIAL, instance=arba_catastro_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="carto_arba", display_name="CARTO ARBA", + category=ScraperCategory.PATRIMONIAL, instance=carto_arba_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="sinai", display_name="SINAI", + category=ScraperCategory.PATRIMONIAL, instance=sinai_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="siscop", display_name="SISCOP", + category=ScraperCategory.PATRIMONIAL, instance=siscop_scraper, + timeout=60, critical=False, + dependencies=[] + )) + + # OSINT + registry.register(ScraperDef( + name="google_images", display_name="Google Images", + category=ScraperCategory.OSINT, instance=google_images_scraper, + timeout=30, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="redes_sociales", display_name="Redes Sociales", + category=ScraperCategory.OSINT, instance=redes_scraper, + timeout=30, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="telefonia", display_name="Teléfono", + category=ScraperCategory.OSINT, instance=telefonia_scraper, + timeout=30, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="name_search", display_name="Name Search", + category=ScraperCategory.OSINT, instance=name_search_scraper, + timeout=60, critical=False, + dependencies=[] + )) + + # OTHER + registry.register(ScraperDef( + name="billeteras_virtuales", display_name="Billeteras Virtuales", + category=ScraperCategory.OTHER, instance=billeteras_virtuales_scraper, + timeout=30, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="ruido", display_name="RUIDO", + category=ScraperCategory.OTHER, instance=ruido_scraper, + timeout=120, critical=False, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="sgarhu", display_name="SGARHU", + category=ScraperCategory.OTHER, instance=sgarhu_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="contratar", display_name="CONTRATAR", + category=ScraperCategory.OTHER, instance=contratar_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="colegios_profesionales", display_name="Colegios Profesionales", + category=ScraperCategory.OTHER, instance=colegios_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="renaper_facial", display_name="RENAPER Facial", + category=ScraperCategory.OTHER, instance=renaper_facial_scraper, + timeout=60, critical=False, + dependencies=["renaper"] + )) + registry.register(ScraperDef( + name="timeline_boa", display_name="Timeline BO", + category=ScraperCategory.OTHER, instance=timeline_boa_scraper, + timeout=30, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="archive_org", display_name="Archive.org", + category=ScraperCategory.OTHER, instance=archive_org_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="padron_electoral", display_name="Padrón Electoral", + category=ScraperCategory.OTHER, instance=padron_electoral_scraper, + timeout=60, critical=False, + dependencies=["arca_afip"] + )) + registry.register(ScraperDef( + name="compras_estatales", display_name="COMPR.AR/Contrataciones", + category=ScraperCategory.OTHER, instance=compras_estatales_scraper, + timeout=60, critical=False, + dependencies=[] + )) + registry.register(ScraperDef( + name="cnv", display_name="CNV", + category=ScraperCategory.OTHER, instance=cnv_scraper, + timeout=60, critical=False, + dependencies=[] + )) + + +# Global registry instance +registry = ScraperRegistry() + +# Initialize on import +_register_scrapers() \ No newline at end of file diff --git a/app/reports/service.py b/app/reports/service.py new file mode 100644 index 0000000000000000000000000000000000000000..76a6e284e7e1a8fbcd713d8a77f291c44004e46a --- /dev/null +++ b/app/reports/service.py @@ -0,0 +1,442 @@ +""" +Report Service — Orquestador principal de reportes. +Usa orchestrator y registry para ejecutar scrapers y construir reportes. +""" +import asyncio +import logging +import re +import uuid +from datetime import datetime, timezone +from typing import Any +from sqlalchemy import select, insert +from app.database import get_db +from app.config import get_settings +from app.reports.models import SearchHistory, MonitorTask, ReportCache +from app.reports.schemas import ( + ScoreHistorial, ScoreCrediticio, + PersonReport, CompanyReport, Identificacion, Contacto, Domicilio, + DatosFiscales, DatosFinancieros, DatosSocietarios, DatosPatrimoniales, + DatosJudiciales, PublicacionBO, BcraHistorial, ChequeRechazado, + CausaJudicial, Vehiculo, InfraccionTransito, MarcaINPI, Vinculo, + DatosPrevisionales, DatosAcademicos, TituloAcademico, IndicadorRiesgo, + Inmueble, Inhibicion, DatosRegistroCivil, GroupReport, VehicleReport, + PropertyReport, ReportMeta, ActividadFiscal, CompanyIdentificacion, RedSocial, + DatosIGJ, ViasSalud, ContratoEstado, MatriculaProfesional, + DatosBilleterasVirtuales, BilleteraVirtual, TimelineEvent, DeudorAlimentario, Monotributo +) +from app.cache.redis_client import cache_get, cache_set +from app.scrapers.base import AntiBotBlockedError +from app.utils.scoring import compute_score, compute_company_score, compute_group_score +from app.reports.orchestrator import ScraperOrchestrator +from app.reports.registry import registry, ScraperCategory, ScraperDef +from app.reports.fallback import apply_fallbacks +from app.reports.builders import ( + build_person_report, build_company_report, build_vehicle_report, + build_property_report, build_group_report +) + +SITUACIONES_BCRA = { + 1: "Normal", + 2: "Con seguimiento especial / Riesgo bajo", + 3: "Con problemas / Riesgo medio", + 4: "Con alto riesgo de insolvencia / Riesgo alto", + 5: "Irrecuperable", + 6: "Irrecuperable por disposición técnica", +} + +logger = logging.getLogger(__name__) + + +# Register all scrapers in the registry +def _register_scrapers(): + """Registra todos los scrapers disponibles.""" + from app.scrapers.arca_afip import ArcaAfipScraper + from app.scrapers.bcra import BcraScraper + from app.scrapers.boletin_oficial import BoletinOficialScraper + from app.scrapers.igj import IgjScraper + from app.scrapers.poder_judicial import PoderJudicialScraper + from app.scrapers.dnrpa import DnrpaScraper + from app.scrapers.sinai import SinaiScraper + from app.scrapers.inpi import InpiScraper + from app.scrapers.anses import AnsesScraper + from app.scrapers.redes_sociales import RedesSocialesScraper + from app.scrapers.telefonia import TelefoniaScraper + from app.scrapers.renaper import RenaperScraper + from app.scrapers.boletines_provinciales import BoletinesProvincialesScraper + from app.scrapers.colegios_profesionales import ColegiosProfesionalesScraper + from app.scrapers.ruido import RuidoScraper + from app.scrapers.compras_estatales import ComprasEstatalesScraper + from app.scrapers.padron_electoral import PadronElectoralScraper + from app.scrapers.sgarhu import SgarhuScraper + from app.scrapers.siscop import SiscopScraper + from app.scrapers.arba_automotores import ArbaAutomotoresScraper + from app.scrapers.arba_catastro import ArbaCatastroScraper + from app.scrapers.cnv import CnvScraper + from app.scrapers.uif import UifScraper + from app.scrapers.juba import JubaScraper + from app.scrapers.renaper_facial import RenaperFacialScraper + from app.scrapers.name_search import NameSearchScraper + from app.scrapers.google_images import GoogleImagesScraper + from app.scrapers.infracciones import InfraccionesScraper + from app.scrapers.inhibiciones import InhibicionesScraper + from app.scrapers.poder_judicial_provincial import PoderJudicialProvincialScraper + from app.scrapers.contratar import ContratarScraper + from app.scrapers.deudores_alimentarios import DeudoresAlimentariosScraper + from app.scrapers.carto_arba import CartoArbaScraper + from app.scrapers.archive_org import ArchiveOrgScraper + from app.scrapers.monotributo_historial import MonotributoHistorialScraper + from app.scrapers.registro_conductores import RegistroConductoresScraper + from app.scrapers.timeline_boa import TimelineBoletinScraper + from app.scrapers.billeteras_virtuales import BilleterasVirtualesScraper + from app.scrapers.compras_estatales import ComprasEstatalesScraper + + # CORE - Críticos + registry.register(ScraperDef( + name="arca_afip", display_name="ARCA/AFIP", + category=ScraperCategory.CORE, instance=ArcaAfipScraper(), + timeout=60, critical=True + )) + registry.register(ScraperDef( + name="bcra", display_name="BCRA", + category=ScraperCategory.CORE, instance=BcraScraper(), + timeout=30, critical=True + )) + registry.register(ScraperDef( + name="dnrpa", display_name="DNRPA", + category=ScraperCategory.CORE, instance=DnrpaScraper(), + timeout=90, critical=True + )) + registry.register(ScraperDef( + name="poder_judicial", display_name="Poder Judicial", + category=ScraperCategory.CORE, instance=PoderJudicialScraper(), + timeout=120, critical=True + )) + registry.register(ScraperDef( + name="renaper", display_name="RENAPER", + category=ScraperCategory.CORE, instance=RenaperScraper(), + timeout=60, critical=True + )) + registry.register(ScraperDef( + name="igj", display_name="IGJ", + category=ScraperCategory.CORE, instance=IgjScraper(), + timeout=60, critical=True + )) + registry.register(ScraperDef( + name="inpi", display_name="INPI", + category=ScraperCategory.CORE, instance=InpiScraper(), + timeout=60, critical=True + )) + registry.register(ScraperDef( + name="uif", display_name="UIF/PEPs", + category=ScraperCategory.CORE, instance=UifScraper(), + timeout=60, critical=True + )) + + # FINANCIAL + registry.register(ScraperDef( + name="arba_automotores", display_name="ARBA Automotores", + category=ScraperCategory.FINANCIAL, instance=ArbaAutomotoresScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="arba_catastro", display_name="ARBA Catastro", + category=ScraperCategory.FINANCIAL, instance=ArbaCatastroScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="boletin_oficial", display_name="Boletín Oficial", + category=ScraperCategory.FINANCIAL, instance=BoletinOficialScraper(), + timeout=30, critical=False + )) + registry.register(ScraperDef( + name="boletines_provinciales", display_name="Boletines Provinciales", + category=ScraperCategory.FINANCIAL, instance=BoletinesProvincialesScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="anses", display_name="ANSES", + category=ScraperCategory.FINANCIAL, instance=AnsesScraper(), + timeout=120, critical=False + )) + registry.register(ScraperDef( + name="cnv", display_name="CNV", + category=ScraperCategory.FINANCIAL, instance=CnvScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="compras_estatales", display_name="COMPR.AR", + category=ScraperCategory.FINANCIAL, instance=ComprasEstatalesScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="monotributo_historial", display_name="Monotributo Historial", + category=ScraperCategory.FINANCIAL, instance=MonotributoHistorialScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="registro_conductores", display_name="Registro Conductores", + category=ScraperCategory.FINANCIAL, instance=RegistroConductoresScraper(), + timeout=60, critical=False + )) + + # JUDICIAL + registry.register(ScraperDef( + name="infracciones", display_name="Infracciones", + category=ScraperCategory.JUDICIAL, instance=InfraccionesScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="deudores_alimentarios", display_name="Deudores Alimentarios", + category=ScraperCategory.JUDICIAL, instance=DeudoresAlimentariosScraper(), + timeout=120, critical=False + )) + registry.register(ScraperDef( + name="juba", display_name="JUBA", + category=ScraperCategory.JUDICIAL, instance=JubaScraper(), + timeout=120, critical=False + )) + registry.register(ScraperDef( + name="poder_judicial_provincial", display_name="Poder Judicial Provincial", + category=ScraperCategory.JUDICIAL, instance=PoderJudicialProvincialScraper(), + timeout=120, critical=False + )) + registry.register(ScraperDef( + name="inhibiciones", display_name="Inhibiciones", + category=ScraperCategory.JUDICIAL, instance=InhibicionesScraper(), + timeout=60, critical=False + )) + + # PATRIMONIAL + registry.register(ScraperDef( + name="arba_catastro", display_name="ARBA Catastro", + category=ScraperCategory.PATRIMONIAL, instance=ArbaCatastroScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="carto_arba", display_name="CARTO ARBA", + category=ScraperCategory.PATRIMONIAL, instance=CartoArbaScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="sinai", display_name="SINAI", + category=ScraperCategory.PATRIMONIAL, instance=SinaiScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="siscop", display_name="SISCOP", + category=ScraperCategory.PATRIMONIAL, instance=SiscopScraper(), + timeout=60, critical=False + )) + + # OSINT + registry.register(ScraperDef( + name="google_images", display_name="Google Images", + category=ScraperCategory.OSINT, instance=GoogleImagesScraper(), + timeout=30, critical=False + )) + registry.register(ScraperDef( + name="redes_sociales", display_name="Redes Sociales", + category=ScraperCategory.OSINT, instance=RedesSocialesScraper(), + timeout=30, critical=False + )) + registry.register(ScraperDef( + name="telefonia", display_name="Teléfono", + category=ScraperCategory.OSINT, instance=TelefoniaScraper(), + timeout=30, critical=False + )) + registry.register(ScraperDef( + name="name_search", display_name="Name Search", + category=ScraperCategory.OSINT, instance=NameSearchScraper(), + timeout=60, critical=False + )) + + # OTHER + registry.register(ScraperDef( + name="billeteras_virtuales", display_name="Billeteras Virtuales", + category=ScraperCategory.OTHER, instance=BilleterasVirtualesScraper(), + timeout=30, critical=False + )) + registry.register(ScraperDef( + name="ruido", display_name="RUIDO", + category=ScraperCategory.OTHER, instance=RuidoScraper(), + timeout=120, critical=False + )) + registry.register(ScraperDef( + name="sgarhu", display_name="SGARHU", + category=ScraperCategory.OTHER, instance=SgarhuScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="contratar", display_name="CONTRATAR", + category=ScraperCategory.OTHER, instance=ContratarScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="colegios_profesionales", display_name="Colegios Profesionales", + category=ScraperCategory.OTHER, instance=ColegiosProfesionalesScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="ruido_ssalud", display_name="RUIDO Salud", + category=ScraperCategory.OTHER, instance=SgarhuScraper(), # alias + timeout=120, critical=False + )) + registry.register(ScraperDef( + name="renaper_facial", display_name="RENAPER Facial", + category=ScraperCategory.OTHER, instance=RenaperFacialScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="timeline_boa", display_name="Timeline BO", + category=ScraperCategory.OTHER, instance=TimelineBoletinScraper(), + timeout=30, critical=False + )) + registry.register(ScraperDef( + name="archive_org", display_name="Archive.org", + category=ScraperCategory.OTHER, instance=ArchiveOrgScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="padron_electoral", display_name="Padrón Electoral", + category=ScraperCategory.OTHER, instance=PadronElectoralScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="compras_estatales", display_name="COMPR.AR/Contrataciones", + category=ScraperCategory.OTHER, instance=ComprasEstatalesScraper(), + timeout=60, critical=False + )) + registry.register(ScraperDef( + name="cnv", display_name="CNV", + category=ScraperCategory.OTHER, instance=CnvScraper(), + timeout=60, critical=False + )) + +# Initialize registry on import +_register_scrapers() + + +async def get_person_report( + cuit: str, + skip_redes: bool = False, + force_refresh: bool = False, +) -> PersonReport: + """Genera reporte completo de persona física.""" + # Orchestrate scrapers + orchestrator = ScraperOrchestrator("persona", cuit, force_refresh) + results = await orchestrator.run() + + # Extract data from results + data = {r.name: r.data for r in results.values() if r.data} + + # Build report + report = await build_person_report(cuit, data, results) + + # Save to cache + await _save_report_cache("persona", cuit, report) + + return report + + +async def get_company_report( + cuit: str, + force_refresh: bool = False, +) -> CompanyReport: + """Genera reporte completo de empresa.""" + orchestrator = ScraperOrchestrator("empresa", cuit, force_refresh) + results = await orchestrator.run() + + data = {r.name: r.data for r in results.values() if r.data} + report = await build_company_report(cuit, data, results) + + await _save_report_cache("empresa", cuit, report) + return report + + +async def get_vehicle_report( + dominio: str, + force_refresh: bool = False, +) -> VehicleReport: + """Genera reporte de vehículo.""" + orchestrator = ScraperOrchestrator("vehiculo", dominio, force_refresh) + results = await orchestrator.run() + + data = {r.name: r.data for r in results.values() if r.data} + report = await build_vehicle_report(dominio, data, results) + + await _save_report_cache("vehiculo", dominio, report) + return report + + +async def get_property_report( + calle: str, numero: str, localidad: str, provincia: str, + force_refresh: bool = False, +) -> PropertyReport: + """Genera reporte de inmueble.""" + identifier = f"{calle} {numero} {localidad} {provincia}" + orchestrator = ScraperOrchestrator("propiedad", identifier, force_refresh) + results = await orchestrator.run() + + data = {r.name: r.data for r in results.values() if r.data} + report = await build_property_report(calle, numero, localidad, provincia, data, results) + + await _save_report_cache("propiedad", identifier, report) + return report + + +async def get_group_report( + cuit: str, + force_refresh: bool = False, +) -> GroupReport: + """Genera reporte de grupo económico.""" + # Primero persona + person_report = await get_person_report(cuit, force_refresh=force_refresh) + + # Luego empresas asociadas (desde IGJ) + igj_data = person_report.patrimonial.datos_igj + empresas_vinculadas = [] + if igj_data and igj_data.socios_directivos: + for socio in igj_data.socios_directivos: + cuil = socio.get("cuil") + if cuil and cuil != cuit: + try: + emp_report = await get_company_report(cuil, force_refresh=force_refresh) + empresas_vinculadas.append(emp_report) + except Exception as e: + logger.warning(f"Failed to get company report for {cuil}: {e}") + + # Vehículos + # ... lógica similar + + return GroupReport( + persona=person_report, + empresas_vinculadas=empresas_vinculadas, + vehiculos=[], + inmuebles=[], + score_grupo=compute_group_score(person_report, empresas_vinculadas, [], []), + ) + + +async def _save_report_cache(report_type: str, identifier: str, report: Any) -> None: + """Guarda reporte en caché PostgreSQL.""" + from app.reports.models import ReportCache + import json + + async for db in get_db(): + try: + await db.execute( + insert(ReportCache).values( + cache_key=f"{report_type}:{identifier}", + report_type=report_type, + identifier=identifier, + data=json.dumps(report.model_dump() if hasattr(report, 'model_dump') else report), + sources_used=list(report.model_fields.keys()) if hasattr(report, 'model_fields') else [], + expires_at=datetime.now(timezone.utc).replace(hour=23, minute=59, second=59), + hit_count=0, + ) + ) + await db.commit() + except Exception as e: + logger.warning(f"Error saving report cache: {e}") + finally: + break \ No newline at end of file diff --git a/app/reports/vehiculo_router.py b/app/reports/vehiculo_router.py new file mode 100644 index 0000000000000000000000000000000000000000..bbeb5d73f7676b5de37a91f92788266e36036fc9 --- /dev/null +++ b/app/reports/vehiculo_router.py @@ -0,0 +1,41 @@ +""" +Endpoint de vehículo — Fase 1 retorna estructura básica. +El scraper DNRPA (Playwright) se integra en Fase 2. +""" +from fastapi import APIRouter, HTTPException, Depends +from app.reports.schemas import VehicleReport, ReportMeta, Vehiculo +from app.auth.router import current_active_user +from app.auth.models import User +from app.cache.redis_client import cache_get, cache_set +import uuid, logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/reports/vehiculo", tags=["vehiculo"]) + + +@router.get("/{dominio}", response_model=VehicleReport, summary="Informe de Vehículo") +async def get_vehiculo_report(dominio: str, current_user: User = Depends(current_active_user)): + """ + Consulta datos de un vehículo por dominio/patente. + Fase 1: estructura básica. Fase 2: integración DNRPA (Playwright). + """ + dominio_clean = dominio.upper().replace("-", "").strip() + cache_key = f"vehiculo:{dominio_clean}" + cached = await cache_get(cache_key) + if cached: + cached["meta"]["cached"] = True + return VehicleReport(**cached) + + # Fase 1: retorna estructura vacía con aviso + report = VehicleReport( + meta=ReportMeta( + report_id=str(uuid.uuid4()), + generated_at=datetime.now(timezone.utc).isoformat(), + sources=["DNRPA (Fase 2)"], + cached=False, + ), + vehiculo=Vehiculo(dominio=dominio_clean, estado="Scraper DNRPA disponible en Fase 2"), + ) + await cache_set(cache_key, report.model_dump(), ttl=3600) + return report diff --git a/app/scrapers/__init__.py b/app/scrapers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..af0744ae0a655a95ccd696cb1dc27e7f120434b2 --- /dev/null +++ b/app/scrapers/__init__.py @@ -0,0 +1 @@ + diff --git a/app/scrapers/anses.py b/app/scrapers/anses.py new file mode 100644 index 0000000000000000000000000000000000000000..21d6f73090358f5c803d0dbaf97aa52a6096e04e --- /dev/null +++ b/app/scrapers/anses.py @@ -0,0 +1,262 @@ +"""Scraper ANSES / SSSalud — Datos previsionales y Obra Social. + +Usa Playwright porque SSSalud tiene fingerprinting anti-bot (df_lib.js) +que requiere navegador real (Canvas/WebGL). + +Estrategia: + 1. Playwright carga la página → fingerprint se ejecuta automáticamente + 2. Se intercepta la imagen del captcha de la respuesta de red + 3. ddddocr resuelve el captcha + 4. Se completa el formulario y se envía + 5. Se parsea la respuesta HTML +""" +import asyncio +import logging +import re +from bs4 import BeautifulSoup +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +SSSALUD_URL = "https://www.sssalud.gob.ar/index.php?page=bus650&user=GRAL" + +OS_CODE_MAP = { + "0-0040-6": "OSECAC (Obra Social de Empleados de Comercio)", + "0-0150-8": "Prosindicato de Amas de Casa", + "0-0300-9": "SANCOR Salud", + "0-0380-1": "Obra Social de la Prevención y la Salud", + "1-0600-5": "OSDECyEDC (Personal de Entidades Deportivas y Civiles)", + "1-0640-1": "OSPERH (Personal de Edificios de Renta y Horizontal)", + "1-0650-0": "OSPERH CABA (Personal de Edificios de Renta y Horizontal CABA)", + "1-1720-7": "OSPREN (Personal de Prensa)", + "1-2330-5": "OSDE (Organización de Servicios Directos Empresarios)", + "1-2570-7": "OSUPCN (Unión del Personal Civil de la Nación)", + "1-2620-5": "OSECAC (Empleados de Comercio y Actividades Civiles)", + "1-2630-4": "OSSEBAC (Servicios Sociales Bancarios)", + "1-2810-2": "OSUNJAJIN (Trabajadores del INSSJyP)", + "3-0070-4": "Ceras Johnson", + "3-0210-6": "OS John Deere Argentina", + "3-0310-9": "OS SUPERCO", + "3-0340-6": "OS Shell Argentina", + "3-0390-1": "OS Ford Argentina", + "3-0400-3": "OS Volkswagen Argentina", + "9-0500-8": "ARS (Administración Recursos para Salud)", + "9-0510-7": "Amsterdam Salud", +} + + +class AnsesScraper(BaseScraper): + source_name = "ANSES" + uses_playwright = True + max_retries = 1 + SAFE_FETCH_TIMEOUT = 240 + + async def fetch(self, cuit: str, **kwargs) -> dict: + from playwright.async_api import async_playwright + import ddddocr + + cuit_fmt = self.format_cuit(self.clean_cuit(cuit)) + MAX_ATTEMPTS = 10 + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=settings.playwright_headless, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = await browser.new_page( + user_agent=self.get_random_user_agent(), + locale="es-AR", + ) + + captcha_data = {} + attempt_ref = [0] + + async def on_response(response): + if "securimage_show.php" in response.url: + try: + body = await response.body() + if len(body) > 100: + captcha_data["bytes"] = body + captcha_data["attempt"] = attempt_ref[0] + except Exception: + pass + + page.on("response", on_response) + + try: + for attempt in range(1, MAX_ATTEMPTS + 1): + try: + attempt_ref[0] = attempt + captcha_data.clear() + + await page.goto(SSSALUD_URL, wait_until="networkidle", timeout=30000) + await page.wait_for_timeout(3000) + + if "bytes" not in captcha_data or captcha_data.get("attempt") != attempt: + logger.debug(f"[ANSES] Captcha no interceptado (intento {attempt})") + continue + + code = self._solve_captcha(captcha_data["bytes"], ocr) + if not code: + continue + + logger.debug(f"[ANSES] Captcha: '{code}' (intento {attempt})") + + await page.fill('input[name="cuil_b"]', cuit_fmt) + await page.fill('input[name="code"]', code) + await page.click('input[name="B1"]') + + try: + await page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass + await page.wait_for_timeout(1500) + + html = await page.content() + result = self._check_response(html, cuit_fmt, attempt) + if result is not None: + return result + + except Exception as e: + logger.debug(f"[ANSES] Error intento {attempt} para {cuit}: {e}") + + finally: + await browser.close() + + logger.warning(f"[ANSES] Captcha no resuelto tras {MAX_ATTEMPTS} intentos para {cuit}") + return {"previsional": { + "tiene_aportes": False, + "obra_social": None, + "estado_padron": "No consultado (captcha)", + "diagnostics": {"attempts": MAX_ATTEMPTS, "method": "playwright"} + }} + + def _solve_captcha(self, image_bytes: bytes, ocr=None) -> str | None: + import ddddocr + if ocr is None: + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + result = ocr.classification(image_bytes) + code = re.sub(r"[^A-Za-z0-9]", "", result) + if code and len(code) >= 3: + return code + return None + + def _check_response(self, html: str, cuit_fmt: str, attempt: int) -> dict | None: + soup = BeautifulSoup(html, "html.parser") + tables = soup.find_all("table") + + if tables: + result = self._parse_sssalud(html, cuit_fmt) + if result: + result["diagnostics"] = {"attempts": attempt, "method": "playwright"} + return result + + if "No se encontraron datos" in html or "no encontr" in html.lower(): + logger.info(f"[ANSES] CUIL {cuit_fmt}: sin cobertura en padrón SSSalud") + return {"previsional": { + "tiene_aportes": False, + "obra_social": None, + "estado_padron": "Sin registro en padrón SSSalud" + }} + + return None + + def _parse_sssalud(self, html: str, cuit: str) -> dict: + try: + html = html.encode("latin-1", errors="replace").decode("utf-8", errors="replace") + except Exception: + pass + soup = BeautifulSoup(html, "html.parser") + tables = soup.find_all("table") + if not tables: + return {} + + historial = [] + tipo_beneficiario_actual = None + obra_social_actual = None + fecha_alta_actual = None + + for table in tables: + rows = table.find_all("tr") + if not rows: + continue + + # Detect header row + header_cells = rows[0].find_all(["th", "td"]) + headers = [c.get_text(strip=True).lower() for c in header_cells] + + if not any("obra social" in h for h in headers): + continue + + # Map column indices + col_map = {} + for i, h in enumerate(headers): + if "obra social" in h or "denominaci" in h: + col_map["obra_social"] = i + elif "cuil" in h or "titular" in h: + col_map["cuil_titular"] = i + elif "tipo" in h and "beneficiario" in h: + col_map["tipo_beneficiario"] = i + elif "fecha" in h and ("alta" in h or "baja" in h): + col_map["fecha_alta_baja"] = i + elif "motivo" in h: + col_map["motivo"] = i + + if "obra_social" not in col_map: + continue + + # Parse data rows + for row in rows[1:]: + cells = row.find_all("td") + if len(cells) < len(headers): + continue + + os_raw = cells[col_map.get("obra_social", 0)].get_text(strip=True) + os_nombre = OS_CODE_MAP.get(os_raw, os_raw) + tipo = cells[col_map.get("tipo_beneficiario", 2)].get_text(strip=True) if "tipo_beneficiario" in col_map else "" + fecha = cells[col_map.get("fecha_alta_baja", 3)].get_text(strip=True) if "fecha_alta_baja" in col_map else "" + motivo = cells[col_map.get("motivo", 4)].get_text(strip=True) if "motivo" in col_map else "" + + historial.append({ + "obra_social": os_nombre, + "tipo_beneficiario": tipo, + "fecha_alta_baja": fecha, + "motivo": motivo, + }) + + # Keep FIRST (most recent) active record, not last + if not obra_social_actual: + if os_nombre: + obra_social_actual = os_nombre + if tipo: + tipo_beneficiario_actual = tipo + if fecha: + fecha_alta_actual = fecha + + if not historial: + return {} + + tipo = tipo_beneficiario_actual or "" + tiene_aportes = bool(tipo) and ( + "dependencia" in tipo.lower() + or "aut" in tipo.lower() + or "monotributo" in tipo.lower() + ) + + return {"previsional": { + "tiene_aportes": tiene_aportes, + "aportes_al_dia": None, + "obra_social": obra_social_actual, + "fecha_alta_obra_social": fecha_alta_actual, + "tipo_beneficiario": tipo or None, + "estado_padron": tipo or "Beneficiario registrado", + "ultimo_empleador": None, + "beneficios_sociales": [], + "jubilaciones_pensiones": [], + "fecha_proximo_cobro": None, + "lugar_cobro": None, + "historial_coberturas": historial, + }} diff --git a/app/scrapers/arba_automotores.py b/app/scrapers/arba_automotores.py new file mode 100644 index 0000000000000000000000000000000000000000..a25ca2977e83301a90ef660d62ef07ca1ac56d7f --- /dev/null +++ b/app/scrapers/arba_automotores.py @@ -0,0 +1,448 @@ +""" +Scraper Automotores — AGIP (CABA) + SACIT Infracciones (PBA). + +Obtiene información de vehículos por patente (dominio): + 1. AGIP CABA — Deuda de patentes, datos del vehículo (marca, modelo, estado) + 2. SACIT PBA — Infracciones de tránsito en Provincia de Buenos Aires + +NOTA: Ambos portales buscan por DOMINIO (patente), no por CUIT. +El scraper recibe patente y devuelve info combinada. +""" +import asyncio +import logging +import base64 +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings +from app.utils.captcha import CaptchaSolver + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class ArbaAutomotoresScraper(BaseScraper): + uses_playwright = True + source_name = "ARBA Automotores" + + AGIP_URL = "https://lb.agip.gob.ar/ConsultaPat" + SACIT_URL = "https://infraccionesba.gba.gob.ar/consulta-infraccion" + + async def fetch(self, identifier: str, **kwargs) -> dict: + """ + Recibe CUIT o patente y consulta AGIP + SACIT. + - Si es CUIT/DNI: SACIT por documento, AGIP no aplica (solo patente) + - Si es patente: AGIP por dominio + SACIT por dominio + Retorna dict con datos del vehículo e infracciones. + """ + clean = identifier.replace("-", "").replace(" ", "").strip() + is_cuit = clean.isdigit() and len(clean) == 11 + is_dni = clean.isdigit() and len(clean) in (7, 8) + + if is_cuit: + dni = clean[2:10] + sexo = "M" if clean[:2] in ("20", "23") else "F" + results = await asyncio.gather( + self._fetch_sacit_documento(dni, sexo), + return_exceptions=True, + ) + sacit_data = results[0] if isinstance(results[0], dict) else {} + return { + "vehiculo": None, + "deuda_patentes": [], + "infracciones": sacit_data.get("infracciones", []), + "fuente_agip": "N/A (AGIP requiere patente)", + "fuente_sacit": sacit_data.get("fuente", "SACIT PBA"), + } + + patente = clean.upper() + if len(patente) < 6: + return {"vehiculo": None, "infracciones": [], "nota": "Identificador inválido"} + + results = await asyncio.gather( + self._fetch_agip(patente), + self._fetch_sacit(patente), + return_exceptions=True, + ) + + agip_data = results[0] if isinstance(results[0], dict) else {} + sacit_data = results[1] if isinstance(results[1], dict) else {} + + return { + "vehiculo": agip_data.get("vehiculo"), + "deuda_patentes": agip_data.get("deuda", []), + "infracciones": sacit_data.get("infracciones", []), + "fuente_agip": agip_data.get("fuente", "AGIP CABA"), + "fuente_sacit": sacit_data.get("fuente", "SACIT PBA"), + } + + async def _fetch_agip(self, patente: str) -> dict: + """Consulta AGIP CABA por dominio — datos del vehículo y deuda de patentes.""" + proxy_url = self.get_proxy() + browser = None + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + logger.info(f"[AGIP] Navegando a {self.AGIP_URL}") + await page.goto(self.AGIP_URL, wait_until="domcontentloaded", timeout=15000) + await page.wait_for_timeout(2000) + + # AGIP oculta #divConsulta por defecto — forzar visibilidad + await page.evaluate(""" + () => { + document.querySelectorAll('.oculto').forEach(el => { + el.classList.remove('oculto'); + el.style.display = 'block'; + }); + document.querySelectorAll('.modal').forEach(m => m.style.display = 'none'); + document.querySelectorAll('.modal-backdrop').forEach(m => m.remove()); + document.body.classList.remove('modal-open'); + } + """) + await page.wait_for_timeout(500) + + # Llenar dominio + input_dom = await page.query_selector("#fldDominio") + if not input_dom or not await input_dom.is_visible(): + logger.warning("[AGIP] Campo dominio no encontrado") + return {"vehiculo": None, "deuda": [], "fuente": "AGIP CABA"} + + await input_dom.fill(patente) + logger.info(f"[AGIP] Dominio llenado: {patente}") + + # Click en Consultar + btn = await page.query_selector("#btnConsultar") + if btn and await btn.is_visible(): + await btn.click() + logger.info("[AGIP] Click en Consultar") + await page.wait_for_load_state("domcontentloaded", timeout=10000) + await page.wait_for_timeout(2000) + else: + logger.warning("[AGIP] Botón Consultar no encontrado") + return {"vehiculo": None, "deuda": [], "fuente": "AGIP CABA"} + + # Extraer datos del vehículo + vehiculo = await page.evaluate(""" + () => { + const get = id => { + const el = document.querySelector('#' + id); + return el ? el.innerText.trim() : null; + }; + return { + marca: get('lblMarca'), + rubro: get('lblRubro'), + uso: get('lblCodigoUso'), + estado: get('lblEstado'), + modelo: get('lblModelo'), + categoria: get('lblCategoria'), + peso: get('lblPeso'), + fecha_alta: get('lblFechaAlta'), + fecha_baja: get('lblFechaBaja'), + codigo_pago: get('lblCodigoLink'), + }; + } + """) + + # Verificar si hay datos reales + if not vehiculo.get("marca") and not vehiculo.get("modelo"): + logger.info(f"[AGIP] Sin datos para patente {patente}") + return {"vehiculo": None, "deuda": [], "fuente": "AGIP CABA"} + + # Extraer deuda + deuda_rows = await page.evaluate(""" + () => { + const rows = []; + document.querySelectorAll('#tablaEstadoCuenta tbody tr').forEach(tr => { + const cells = tr.querySelectorAll('td'); + if (cells.length >= 5) { + rows.push({ + anio: cells[0].innerText.trim(), + cuota: cells[1].innerText.trim(), + vencimiento: cells[2].innerText.trim(), + concepto: cells[3].innerText.trim(), + importe: cells[4].innerText.trim(), + }); + } + }); + return rows; + } + """) + + logger.info(f"[AGIP] Vehículo: {vehiculo.get('marca')} {vehiculo.get('modelo')}, Deuda: {len(deuda_rows)} cuotas") + return {"vehiculo": vehiculo, "deuda": deuda_rows, "fuente": "AGIP CABA"} + + except Exception as e: + logger.debug(f"[AGIP] Error para patente {patente}: {e}") + finally: + if browser: + await browser.close() + return {"vehiculo": None, "deuda": [], "fuente": "AGIP CABA"} + + async def _fetch_sacit(self, patente: str) -> dict: + """Consulta SACIT Infracciones PBA por dominio — infracciones de tránsito.""" + proxy_url = self.get_proxy() + browser = None + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + logger.info(f"[SACIT] Navegando a {self.SACIT_URL}") + await page.goto(self.SACIT_URL, wait_until="domcontentloaded", timeout=15000) + await page.wait_for_timeout(2000) + + # Cerrar popup si aparece + try: + close_btn = await page.query_selector(".popup_close") + if close_btn and await close_btn.is_visible(): + await close_btn.click() + await page.wait_for_timeout(500) + except Exception: + pass + + # Seleccionar pestaña "Búsqueda por Dominio" si existe + try: + tab = await page.query_selector("a[href*='dominio'], a:has-text('Dominio'), #tabDominio") + if tab and await tab.is_visible(): + await tab.click() + await page.wait_for_timeout(500) + except Exception: + pass + + # Llenar dominio + input_dom = await page.query_selector("#filtroDominio") + if not input_dom or not await input_dom.is_visible(): + logger.warning("[SACIT] Campo dominio no encontrado") + return {"infracciones": [], "fuente": "SACIT PBA"} + + await input_dom.fill(patente) + logger.info(f"[SACIT] Dominio llenado: {patente}") + + # Resolver CAPTCHA + captcha_img = await page.query_selector("img[id*='captcha'], img[src*='captcha']") + if captcha_img: + captcha_bytes = await captcha_img.screenshot() + captcha_b64 = base64.b64encode(captcha_bytes).decode("utf-8") + + solver = CaptchaSolver() + code = await solver.solve_image_captcha_preprocessed(captcha_bytes) + if not code or len(code) < 3: + code = await solver.solve_image_captcha_groq(captcha_bytes) + + if code: + captcha_input = await page.query_selector("input[name='captcha'], input[id*='captcha']") + if captcha_input: + await captcha_input.fill(code) + logger.info(f"[SACIT] CAPTCHA resuelto: {code}") + else: + logger.warning("[SACIT] No se pudo resolver CAPTCHA") + return {"infracciones": [], "fuente": "SACIT PBA"} + + # Click en BUSCAR + btn = await page.query_selector("button#calltoaction, button:has-text('BUSCAR'), input[value='BUSCAR']") + if btn: + try: + await btn.click(force=True, timeout=5000) + logger.info("[SACIT] Click en BUSCAR") + except Exception: + await page.evaluate("document.querySelectorAll('button#calltoaction').forEach(b => b.click())") + logger.info("[SACIT] Click en BUSCAR via evaluate") + await page.wait_for_load_state("domcontentloaded", timeout=10000) + await page.wait_for_timeout(2000) + else: + logger.warning("[SACIT] Botón BUSCAR no encontrado") + return {"infracciones": [], "fuente": "SACIT PBA"} + + # Extraer infracciones + body_text = await page.evaluate("() => document.body.innerText") + if "no posee infracciones" in body_text.lower() or "sin infracciones" in body_text.lower(): + logger.info(f"[SACIT] Sin infracciones para patente {patente}") + return {"infracciones": [], "fuente": "SACIT PBA"} + + raw_rows = await page.evaluate(""" + () => { + const rows = []; + document.querySelectorAll("table tr, .infraccion-item").forEach(tr => { + const text = tr.innerText.trim(); + if (text && !text.includes("Acta") && !text.includes("Fecha") && text.length > 10) { + rows.push(text); + } + }); + return rows; + } + """) + + infracciones = self._parse_sacit_rows(raw_rows) + + logger.info(f"[SACIT] Infracciones encontradas: {len(infracciones)}") + return {"infracciones": infracciones, "fuente": "SACIT PBA"} + + except Exception as e: + logger.debug(f"[SACIT] Error para patente {patente}: {e}") + finally: + if browser: + await browser.close() + return {"infracciones": [], "fuente": "SACIT PBA"} + + async def _fetch_sacit_documento(self, dni: str, sexo: str) -> dict: + """Consulta SACIT por documento (DNI).""" + proxy_url = self.get_proxy() + browser = None + for attempt in range(3): + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + logger.info(f"[SACIT-DOC] Intento {attempt+1}/3 — Navegando a SACIT") + await page.goto("https://infraccionesba.gba.gob.ar/consulta-infraccion", wait_until="domcontentloaded", timeout=20000) + await asyncio.sleep(3) + + # Cerrar popup + try: + await page.evaluate("document.querySelectorAll('.popup_wrapper, .popup_close').forEach(p => { p.style.display = 'none'; p.click(); })") + except Exception: + pass + + # Click en pestaña "Búsqueda por Documento" + doc_tab = await page.query_selector("a[href='#x-document']") + if not doc_tab: + doc_tab = await page.query_selector("a:has-text('Documento'), a:has-text('documento')") + if doc_tab and await doc_tab.is_visible(): + await doc_tab.click() + await asyncio.sleep(1) + else: + logger.warning("[SACIT-DOC] Pestaña Documento no encontrada") + return {"infracciones": [], "fuente": "SACIT PBA"} + + # Seleccionar tipo documento DNI + tipo_select = await page.query_selector("#filtroIdTipoDocumento") + if tipo_select and await tipo_select.is_visible(): + await tipo_select.select_option("0") # 0 = DNI + + # Ingresar número + nro_input = await page.query_selector("#filtroNroDocumento") + if nro_input and await nro_input.is_visible(): + await nro_input.fill(dni) + else: + logger.warning("[SACIT-DOC] Input número documento no encontrado") + return {"infracciones": [], "fuente": "SACIT PBA"} + + # Seleccionar género + gender_val = sexo.upper() if sexo.upper() in ("M", "F") else "M" + gender_radio = await page.query_selector(f"#optionsRadios1" if gender_val == "F" else "#optionsRadios2") + if gender_radio and await gender_radio.is_visible(): + await gender_radio.click() + + # Resolver reCAPTCHA v2 (usa Groq Whisper para audio) + await asyncio.sleep(2) + solver = CaptchaSolver() + logger.info(f"[SACIT-DOC] Resolviendo reCAPTCHA v2 (audio)...") + + recaptcha_ok = await solver.solve_recaptcha_v2_audio(page, max_retries=3) + if not recaptcha_ok: + logger.warning(f"[SACIT-DOC] reCAPTCHA no resuelto en intento {attempt+1}") + await browser.close() + browser = None + await asyncio.sleep(3) + continue + + logger.info("[SACIT-DOC] reCAPTCHA resuelto correctamente") + + # Click en BUSCAR + btn = await page.query_selector("button#calltoaction, button:has-text('BUSCAR'), input[value='BUSCAR']") + if btn: + try: + await btn.click(force=True, timeout=5000) + logger.info("[SACIT-DOC] Click en BUSCAR") + except Exception: + await page.evaluate("document.querySelectorAll('button#calltoaction').forEach(b => b.click())") + logger.info("[SACIT-DOC] Click en BUSCAR via evaluate") + await page.wait_for_load_state("domcontentloaded", timeout=15000) + await asyncio.sleep(3) + else: + logger.warning("[SACIT-DOC] Botón BUSCAR no encontrado") + return {"infracciones": [], "fuente": "SACIT PBA"} + + # Extraer infracciones + body_text = await page.evaluate("() => document.body.innerText") + logger.info(f"[SACIT-DOC] Texto de página (primeros 500 chars): {body_text[:500]}") + + if "no posee infracciones" in body_text.lower() or "sin infracciones" in body_text.lower(): + logger.info(f"[SACIT-DOC] Sin infracciones para DNI {dni}") + return {"infracciones": [], "fuente": "SACIT PBA"} + + raw_rows = await page.evaluate(""" + () => { + const rows = []; + document.querySelectorAll("table tr, .infraccion-item").forEach(tr => { + const text = tr.innerText.trim(); + if (text && !text.includes("Acta") && !text.includes("Fecha") && text.length > 10) { + rows.push(text); + } + }); + return rows; + } + """) + + infracciones = self._parse_sacit_rows(raw_rows) + + logger.info(f"[SACIT-DOC] Infracciones encontradas: {len(infracciones)}") + return {"infracciones": infracciones, "fuente": "SACIT PBA"} + + except Exception as e: + logger.warning(f"[SACIT-DOC] Error en intento {attempt+1}: {type(e).__name__}: {e}") + finally: + if browser: + await browser.close() + browser = None + + logger.warning(f"[SACIT-DOC] Agotados 3 intentos para DNI {dni}") + return {"infracciones": [], "fuente": "SACIT PBA"} + + def _parse_sacit_rows(self, raw_rows: list[str]) -> list[dict]: + """Parsea filas de texto crudo de SACIT en infracciones estructuradas.""" + import re + infracciones = [] + for row_text in raw_rows: + nro_causa = "" + fecha = "" + descripcion = "" + estado = "" + monto = 0.0 + vencimiento = "" + + causa_match = re.search(r'(?:Nro\.?\s*Causa|Acta)[:\s]*(\d+)', row_text, re.IGNORECASE) + if causa_match: + nro_causa = causa_match.group(1) + + fecha_match = re.search(r'Fecha[:\s]*(\d{2}/\d{2}/\d{4})', row_text, re.IGNORECASE) + if fecha_match: + fecha = fecha_match.group(1) + + desc_match = re.search(r'(?:Presunta\s*)?Infracci[óo]n[:\s]*(.+?)(?:\n|Estado)', row_text, re.IGNORECASE) + if desc_match: + descripcion = desc_match.group(1).strip() + + estado_match = re.search(r'Estado[:\s]*(\w+)', row_text, re.IGNORECASE) + if estado_match: + estado = estado_match.group(1).upper() + + monto_match = re.search(r'Monto[^$]*\$\s*([\d.,]+)', row_text, re.IGNORECASE) + if monto_match: + raw = monto_match.group(1).replace(".", "").replace(",", ".") + try: + monto = float(raw) + except ValueError: + pass + + venc_match = re.search(r'Vencimiento[:\s]*(\d{2}/\d{2}/\d{4})', row_text, re.IGNORECASE) + if venc_match: + vencimiento = venc_match.group(1) + + if nro_causa or fecha: + infracciones.append({ + "nro_causa": nro_causa, + "fecha": fecha, + "descripcion": descripcion, + "estado": estado or "PENDIENTE", + "monto": monto, + "vencimiento": vencimiento, + }) + + return infracciones diff --git a/app/scrapers/arba_catastro.py b/app/scrapers/arba_catastro.py new file mode 100644 index 0000000000000000000000000000000000000000..d9e479f12c65ff2b93dd6f379f3bb076e1bba664 --- /dev/null +++ b/app/scrapers/arba_catastro.py @@ -0,0 +1,208 @@ +""" +ARBA Información Catastral — Datos fiscales del inmueble por prefijo+clave. + +Flujo: +1. POST a generarInfoCatastral.do con prefijo (3 dígitos partido) + clave (6 dígitos partida) +2. Parsea innerHTML JS para extraer: partida, nomenclatura, tipo, valuación, base imponible + +NO requiere CAPTCHA. NO acepta CUIT — solo prefijo+clave. +""" +import re +import logging +import httpx +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +ARBA_INFO_URL = "https://app.arba.gov.ar/Informacion/generarInfoCatastral.do" + + +class ArbaCatastroScraper(BaseScraper): + """ + Consulta ARBA Información Catastral por prefijo+clave (partido+partida). + NO acepta CUIT. Requiere prefijo (3 dígitos) y clave (6 dígitos). + + Uso desde service.py: + arba_data = await arba_catastro_scraper.safe_fetch(prefijo="133", clave="000014") + """ + source_name = "ARBA Info Catastral" + + async def fetch(self, cuit: str = None, **kwargs) -> dict: + """ + Consulta ARBA Información Catastral por prefijo+clave. + + Args: + cuit: IGNorado — parámetro requerido por safe_fetch() + prefijo: Código de partido (3 dígitos, ej: "133" = La Plata) — via kwargs + clave: Número de partida (6 dígitos, ej: "000014") — via kwargs + + Returns: + dict con: partida_completa, nomenclatura, tipo, valuacion_fiscal, + base_imponible, fuente + """ + prefijo = kwargs.get("prefijo") + clave = kwargs.get("clave") + if not prefijo or not clave: + logger.debug("[ArbaCatastro] Sin prefijo/clave — no se puede consultar ARBA") + return {} + + # Normalizar prefijo (debe ser 3 dígitos) + prefijo = str(prefijo).strip() + if prefijo.isdigit(): + prefijo = prefijo.zfill(3) + else: + logger.debug(f"[ArbaCatastro] Prefijo inválido: {prefijo}") + return {} + + # Normalizar clave: puede ser numérica o alfanumérica (ej: "14", "14 B", "123A") + clave_original = str(clave).strip() + if not clave_original: + logger.debug("[ArbaCatastro] Sin clave — no se puede consultar ARBA") + return {} + + # Intentar múltiples formatos para maximizar compatibilidad + import re + formatos_clave = [] + + # Formato 1: Original sin modificar + formatos_clave.append(clave_original) + + # Formato 2: Si tiene espacios, probar sin espacios + if ' ' in clave_original: + formatos_clave.append(clave_original.replace(' ', '')) + + # Formato 3: Si es numérica pura, agregar padding + if clave_original.replace(' ', '').replace('-', '').isdigit(): + num_puro = clave_original.replace(' ', '').replace('-', '') + formatos_clave.append(num_puro.zfill(6)) + + # Formato 4: Si es alfanumérica, extraer número y letra por separado + match = re.match(r'^(\d+)\s*([A-Z]?)$', clave_original, re.IGNORECASE) + if match: + num_parte = match.group(1) + letra_parte = match.group(2).upper() + # Probar: "000014B", "000014 B", "14B" + formatos_clave.append(num_parte.zfill(6) + letra_parte) + if letra_parte: + formatos_clave.append(num_parte.zfill(6) + ' ' + letra_parte) + formatos_clave.append(num_parte + letra_parte) + + # Eliminar duplicados preservando orden + formatos_unicos = [] + for fmt in formatos_clave: + if fmt and fmt not in formatos_unicos: + formatos_unicos.append(fmt) + + logger.info(f"[ArbaCatastro] Probando {len(formatos_unicos)} formatos de clave para prefijo={prefijo}") + + # Intentar cada formato hasta encontrar uno que funcione + for idx, clave_formato in enumerate(formatos_unicos, 1): + logger.debug(f"[ArbaCatastro] Intento {idx}/{len(formatos_unicos)}: prefijo={prefijo}, clave='{clave_formato}'") + + try: + async with httpx.AsyncClient(timeout=15, verify=False) as client: + resp = await client.post( + ARBA_INFO_URL, + data={"inmoPrefijo": prefijo, "inmoClave": clave_formato}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + ) + + if resp.status_code != 200: + logger.debug(f"[ArbaCatastro] HTTP {resp.status_code} con clave '{clave_formato}'") + continue + + result = self._parse_response(resp.text, prefijo, clave_formato) + + # Verificar si obtuvo datos válidos + if result and any(result.get(k) for k in ['partida_completa', 'nomenclatura', 'valuacion_fiscal', 'tipo_inmueble']): + logger.info(f"[ArbaCatastro] ✓ Éxito con formato: prefijo={prefijo}, clave='{clave_formato}'") + return result + else: + logger.debug(f"[ArbaCatastro] Sin datos válidos con clave '{clave_formato}'") + + except Exception as e: + logger.debug(f"[ArbaCatastro] Error con clave '{clave_formato}': {e}") + continue + + logger.warning(f"[ArbaCatastro] No se obtuvieron datos con ningún formato de clave para prefijo={prefijo}, clave_original='{clave_original}'") + return {} + + def _parse_response(self, html: str, prefijo: str, clave: str) -> dict: + """Parsea la respuesta HTML de ARBA Info Catastral.""" + result = { + "partida_completa": None, + "nomenclatura": None, + "tipo_inmueble": None, + "valuacion_fiscal": None, + "base_imponible": None, + "fuente": "ARBA Info Catastral", + } + + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, "html.parser") + + # Extraer texto de scripts (innerHTML) + scripts = soup.find_all("script") + inner_html_text = "" + for script in scripts: + if script.string and "innerHTML" in script.string: + inner_html_text = script.string + break + + text = soup.get_text(" ", strip=True) + " " + inner_html_text + + # Partida completa con dígito verificador + partida_match = re.search( + r'Partida[:\s]*(\d{3}[-‐\-]\d{4,6}[-‐\-]\d)', text + ) + if partida_match: + result["partida_completa"] = partida_match.group(1) + + # Nomenclatura catastral + nom_match = re.search( + r'Nomenclatura\s+Catastral\s+es:\s*(?:)?(.*?)(?:)?\s+está', + text, re.IGNORECASE + ) + if nom_match: + raw = re.sub(r'<[^>]+>', '', nom_match.group(1)).strip() + result["nomenclatura"] = raw + + # Tipo de inmueble + text_upper = text.upper() + if "BALD" in text_upper: + result["tipo_inmueble"] = "BALDÍO" + elif "EDIFICADO" in text_upper: + result["tipo_inmueble"] = "EDIFICADO" + elif "PARCELA" in text_upper: + result["tipo_inmueble"] = "PARCELA" + + # Valuación fiscal + val_match = re.search( + r'valuaci[oó]n\s+fiscal.*?\$?\s*(?:)?\$?\s*([\d.,]+)', + text, re.IGNORECASE + ) + if val_match: + try: + result["valuacion_fiscal"] = float( + val_match.group(1).replace(".", "").replace(",", ".") + ) + except ValueError: + pass + + # Base imponible + base_match = re.search( + r'BASE\s+IMPO?NIBLE.*?\$?\s*(?:)?\$?\s*([\d.,]+)', + text, re.IGNORECASE + ) + if base_match: + try: + result["base_imponible"] = float( + base_match.group(1).replace(".", "").replace(",", ".") + ) + except ValueError: + pass + + return result diff --git a/app/scrapers/arba_deudas.py b/app/scrapers/arba_deudas.py new file mode 100644 index 0000000000000000000000000000000000000000..04d80ae3e53d0856f76666be6c69fa480f1b333c --- /dev/null +++ b/app/scrapers/arba_deudas.py @@ -0,0 +1,259 @@ +""" +ARBA Deudas Impositivas — Consulta de deudas con CAPTCHA resuelto por Groq Vision. + +Flujo (Playwright): +1. Navegar a ARBA Deudas → CAPTCHA se carga automáticamente via JS +2. Descargar imagen CAPTCHA con cookies de sesión +3. Enviar imagen a Groq Vision para resolver texto (6 chars) +4. Llamar consultarDeuda() via JavaScript +5. Parsear respuesta HTML → deuda total, períodos, montos +""" +import re +import base64 +import logging +import httpx +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +ARBA_URL = "https://www.arba.gov.ar/Aplicaciones/AvisoDeudas.asp" +MAX_CAPTCHA_ATTEMPTS = 3 + + +async def _solve_captcha_groq(image_bytes: bytes) -> str | None: + """Resuelve el CAPTCHA de ARBA usando Groq Vision con reintentos.""" + api_key = settings.groq_api_key + if not api_key: + logger.warning("GROQ_API_KEY no configurada") + return None + + b64_image = base64.b64encode(image_bytes).decode("utf-8") + prompts = [ + "CAPTCHA image with exactly 6 alphanumeric characters. Some may overlap or be partially hidden. Return ONLY the 6 characters.", + "Read the CAPTCHA. There are exactly 6 characters, some rotated or overlapping. Return ONLY the 6 characters.", + "Government CAPTCHA with 6 alphanumeric characters. Characters may be distorted or partially hidden. Return ONLY the 6 characters.", + ] + + for i in range(MAX_CAPTCHA_ATTEMPTS): + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + "https://api.groq.com/openai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "meta-llama/llama-4-scout-17b-16e-instruct", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompts[i % len(prompts)]}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64_image}"}, + }, + ], + } + ], + "max_tokens": 20, + "temperature": 0.1 * i, + }, + ) + + if resp.status_code == 200: + answer = resp.json()["choices"][0]["message"]["content"].strip() + answer = re.sub(r'[^a-zA-Z0-9]', '', answer) + if len(answer) >= 5: + logger.info(f"[ARBA CAPTCHA] Resuelto: {answer} ({len(answer)} chars)") + return answer[:6] if len(answer) > 6 else answer + else: + logger.debug(f"Groq Vision error: {resp.status_code}") + except Exception as e: + logger.debug(f"Groq Vision attempt {i+1} falló: {e}") + + return None + + +async def fetch_arba_deudas(prefijo: str, clave: str) -> dict: + """ + Consulta deudas de impuesto inmobiliario en ARBA via Playwright. + Resuelve el CAPTCHA automáticamente con Groq Vision. + + Args: + prefijo: Código de partido (3 dígitos, ej: "133") + clave: Número de partida (6 dígitos, ej: "000014") + """ + result = { + "con_deuda": None, + "monto_total": None, + "periodos_adeudados": [], + "mensaje": None, + "captcha_resuelto": False, + "nomenclatura": None, + "superficie": None, + "valuacion_fiscal": None, + "base_imponible": None, + } + + try: + from playwright.async_api import async_playwright + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ) + page = await context.new_page() + + try: + for attempt in range(1, MAX_CAPTCHA_ATTEMPTS + 1): + logger.debug(f"[ARBA DEUDAS] Intento {attempt}/{MAX_CAPTCHA_ATTEMPTS}") + + await page.goto(ARBA_URL, wait_until="networkidle", timeout=30000) + + try: + await page.wait_for_function( + "() => document.getElementById('captcha-token')?.value?.length > 10", + timeout=15000, + ) + except Exception: + logger.warning("CAPTCHA no cargó") + continue + + token = await page.evaluate("() => document.getElementById('captcha-token').value") + captcha_src = await page.evaluate("() => document.getElementById('captcha')?.src || ''") + if captcha_src.startswith("/"): + captcha_src = f"https://app.arba.gov.ar{captcha_src}" + + img_resp = await page.request.get(captcha_src) + img_bytes = await img_resp.body() + + if len(img_bytes) < 100: + logger.warning("Imagen CAPTCHA inválida") + continue + + answer = await _solve_captcha_groq(img_bytes) + if not answer: + result["mensaje"] = "No se pudo resolver el CAPTCHA" + await browser.close() + return result + + result["captcha_resuelto"] = True + + await page.evaluate(f"""() => {{ + document.getElementById('imp').value = '0'; + var pf = document.getElementById('inmoPrefijo'); + var cl = document.getElementById('inmoClave'); + var cr = document.getElementById('captcha-respuesta'); + if (pf) pf.value = '{prefijo}'; + if (cl) cl.value = '{clave}'; + if (cr) cr.value = '{answer}'; + var hcr = document.querySelector('.captcha-respuesta'); + var hct = document.querySelector('.captcha-token'); + if (hcr) hcr.value = '{answer}'; + if (hct) hct.value = '{token}'; + }}""") + + try: + async with page.expect_navigation(timeout=30000, wait_until="domcontentloaded"): + await page.evaluate("() => { primer_submit = true; consultarDeuda(); }") + except Exception: + pass + + await page.wait_for_timeout(3000) + + try: + text = await page.inner_text("body") + except Exception: + text = "" + + text_lower = text.lower() + + if "captcha" in text_lower and ("incorrecto" in text_lower or "reintente" in text_lower): + logger.debug(f"[ARBA DEUDAS] CAPTCHA incorrecto en intento {attempt}") + continue + + if "no posee deuda" in text_lower or "sin deuda" in text_lower: + result["con_deuda"] = False + result["mensaje"] = "No posee deuda impositiva" + break + + if "deuda total" in text_lower or "importe" in text_lower: + result["con_deuda"] = True + _parse_deuda_response(text, result) + break + + if page.url != ARBA_URL: + result["con_deuda"] = False + result["mensaje"] = "Consulta procesada (verificar resultado)" + _parse_deuda_response(text, result) + break + + logger.debug(f"[ARBA DEUDAS] Respuesta no identificada en intento {attempt}") + + finally: + await browser.close() + + except ImportError: + logger.error("Playwright no instalado. Instalar con: pip install playwright && playwright install chromium") + result["mensaje"] = "Playwright no disponible" + except Exception as e: + logger.warning(f"ARBA Deudas falló para {prefijo}-{clave}: {e}") + result["mensaje"] = f"Error: {str(e)[:100]}" + + return result + + +def _parse_deuda_response(text: str, result: dict): + """Parsea la respuesta de ARBA Deudas para extraer montos, períodos y datos catastrales.""" + + deuda_match = re.search(r'Deuda total\s*\$?\s*([\d.,]+)', text) + if deuda_match: + try: + result["monto_total"] = float(deuda_match.group(1).replace(".", "").replace(",", ".")) + except ValueError: + pass + + nomen_match = re.search(r'Nomenclatura catastral\s+(.+?)(?:Superficie|$)', text, re.DOTALL) + if nomen_match: + result["nomenclatura"] = nomen_match.group(1).strip() + + sup_match = re.search(r'Superficie edificada\s+(\d+)\s*mts', text) + if sup_match: + result["superficie"] = int(sup_match.group(1)) + + val_match = re.search(r'Valuaci[oó]n fiscal\s*\$\s*([\d.,]+)', text) + if val_match: + try: + result["valuacion_fiscal"] = float(val_match.group(1).replace(".", "").replace(",", ".")) + except ValueError: + pass + + bi_match = re.search(r'Base imponible\s*\$\s*([\d.,]+)', text) + if bi_match: + try: + result["base_imponible"] = float(bi_match.group(1).replace(".", "").replace(",", ".")) + except ValueError: + pass + + periodos = re.findall(r'(\d{2}/\d{4})\s+.*?\$\s*([\d.,]+)', text) + for periodo, monto in periodos: + try: + result["periodos_adeudados"].append({ + "periodo": periodo, + "monto": float(monto.replace(".", "").replace(",", ".")), + }) + except ValueError: + pass + + if result["con_deuda"] and not result["periodos_adeudados"] and result["monto_total"]: + result["periodos_adeudados"] = [{"periodo": "Ver detalle", "monto": result["monto_total"]}] + + if not result["mensaje"]: + if result["con_deuda"]: + result["mensaje"] = f"Deuda total: ${result['monto_total']:,.2f}" if result["monto_total"] else "Posee deuda" + else: + result["mensaje"] = "No posee deuda" diff --git a/app/scrapers/arba_info_catastral.py b/app/scrapers/arba_info_catastral.py new file mode 100644 index 0000000000000000000000000000000000000000..db70b946c6765df0867e0bf2583ddb94972c6621 --- /dev/null +++ b/app/scrapers/arba_info_catastral.py @@ -0,0 +1,193 @@ +""" +ARBA Información Catastral — Datos fiscales del inmueble SIN CAPTCHA. + +Devuelve: valuación fiscal, base imponible, tipo (BALDÍO/EDIFICADO), + superficie construida, nomenclatura catastral detallada. +""" +import re +import logging +import httpx +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + +ARBA_INFO_URL = "https://app.arba.gov.ar/Informacion/generarInfoCatastral.do" + + +async def fetch_arba_catastral_info(prefijo: str, clave: str) -> dict: + """ + Consulta ARBA Información Catastral por partido + partida. + NO requiere CAPTCHA. Solo necesita el prefijo (3 dígitos) y la clave (6 dígitos). + + Args: + prefijo: Código de partido (3 dígitos, ej: "133") + clave: Número de partida (puede ser numérica "000014" o alfanumérica "14 B") + """ + result = { + "partida_completa": None, + "nomenclatura_detallada": None, + "tipo_inmueble": None, + "superficie_m2": None, + "valuacion_fiscal": None, + "base_imponible": None, + "ultima_actualizacion": None, + "nomencla_code": None, + } + + # Normalizar prefijo (debe ser 3 dígitos) + prefijo = str(prefijo).strip() + if not prefijo: + logger.debug("[ARBA Info] Sin prefijo") + return result + if prefijo.isdigit(): + prefijo = prefijo.zfill(3) + + # Normalizar clave: puede ser numérica o alfanumérica + clave_original = str(clave).strip() + if not clave_original: + logger.debug("[ARBA Info] Sin clave") + return result + + # Generar múltiples formatos de clave para probar + formatos_clave = [] + formatos_clave.append(clave_original) + + if ' ' in clave_original: + formatos_clave.append(clave_original.replace(' ', '')) + + if clave_original.replace(' ', '').replace('-', '').isdigit(): + num_puro = clave_original.replace(' ', '').replace('-', '') + formatos_clave.append(num_puro.zfill(6)) + + match = re.match(r'^(\d+)\s*([A-Z]?)$', clave_original, re.IGNORECASE) + if match: + num_parte = match.group(1) + letra_parte = match.group(2).upper() + formatos_clave.append(num_parte.zfill(6) + letra_parte) + if letra_parte: + formatos_clave.append(num_parte.zfill(6) + ' ' + letra_parte) + formatos_clave.append(num_parte + letra_parte) + + # Eliminar duplicados + formatos_unicos = [] + for fmt in formatos_clave: + if fmt and fmt not in formatos_unicos: + formatos_unicos.append(fmt) + + logger.debug(f"[ARBA Info] Probando {len(formatos_unicos)} formatos de clave para prefijo={prefijo}") + + # Intentar cada formato hasta encontrar uno que funcione + for idx, clave_formato in enumerate(formatos_unicos, 1): + logger.debug(f"[ARBA Info] Intento {idx}/{len(formatos_unicos)}: prefijo={prefijo}, clave='{clave_formato}'") + + try: + async with httpx.AsyncClient(timeout=15, verify=False) as client: + resp = await client.post( + ARBA_INFO_URL, + data={"inmoPrefijo": prefijo, "inmoClave": clave_formato}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + ) + + if resp.status_code != 200: + logger.debug(f"[ARBA Info] HTTP {resp.status_code} con clave '{clave_formato}'") + continue + + html = resp.text + soup = BeautifulSoup(html, "html.parser") + + hidden_nomencla = soup.find("input", {"name": "nomencla"}) + if hidden_nomencla: + result["nomencla_code"] = hidden_nomencla.get("value", "") + + scripts = soup.find_all("script") + inner_html_text = "" + for script in scripts: + if script.string and "innerHTML" in script.string: + inner_html_text = script.string + break + + text = soup.get_text(" ", strip=True) + " " + inner_html_text + + partida_match = re.search( + r'Partida[:\s]*(\d{3}[-‐\-]\d{4,6}[-‐\-]\d)', text + ) + if partida_match: + result["partida_completa"] = partida_match.group(1) + + nom_match = re.search( + r'Nomenclatura\s+Catastral\s+es:\s*(?:)?(.*?)(?:)?\s+está', + text, re.IGNORECASE + ) + if nom_match: + raw = re.sub(r'<[^>]+>', '', nom_match.group(1)).strip() + result["nomenclatura_detallada"] = raw + + if "BALD" in text.upper(): + result["tipo_inmueble"] = "BALDÍO" + elif "EDIFICADO" in text.upper(): + result["tipo_inmueble"] = "EDIFICADO" + + sup_match = re.search( + r'Superficie\s+(?:Total|Construida)[:\s]*([\d.,]+)\s*(?:m2|mts2|m²|metros)', + text, re.IGNORECASE + ) + if sup_match: + try: + result["superficie_m2"] = float( + sup_match.group(1).replace(".", "").replace(",", ".") + ) + except ValueError: + pass + + val_match = re.search( + r'valuaci[oó]n\s+fiscal\s+(?:de\s+su\s+propiedad\s+es\s+de\s+\$?\s*)?(?:)?\$?\s*([\d.,]+)', + text, re.IGNORECASE + ) + if val_match: + try: + result["valuacion_fiscal"] = float( + val_match.group(1).replace(".", "").replace(",", ".") + ) + except ValueError: + pass + + base_match = re.search( + r'BASE\s+IMPERSONIBLE\s+es\s+de\s+\$?\s*(?:)?\$?\s*([\d.,]+)', + text, re.IGNORECASE + ) + if not base_match: + base_match = re.search( + r'BASE\s+IMPO?NIBLE\s+es\s+de\s+\$?\s*(?:)?\$?\s*([\d.,]+)', + text, re.IGNORECASE + ) + if base_match: + try: + result["base_imponible"] = float( + base_match.group(1).replace(".", "").replace(",", ".") + ) + except ValueError: + pass + + act_match = re.search( + r'[ÚU]ltima\s+Actualizaci[oó]n[:\s]*(\d{2}/\d{2}/\d{4})', text, re.IGNORECASE + ) + if act_match: + result["ultima_actualizacion"] = act_match.group(1) + + # Verificar si obtuvo datos válidos + if any(result.get(k) for k in ['partida_completa', 'nomenclatura_detallada', 'valuacion_fiscal', 'tipo_inmueble']): + logger.info(f"[ARBA Info] ✓ Éxito con formato: prefijo={prefijo}, clave='{clave_formato}'") + return result + else: + logger.debug(f"[ARBA Info] Sin datos válidos con clave '{clave_formato}'") + + except Exception as e: + logger.debug(f"[ARBA Info] Error con clave '{clave_formato}': {e}") + continue + + logger.warning(f"[ARBA Info] No se obtuvieron datos con ningún formato de clave para prefijo={prefijo}, clave_original='{clave_original}'") + + return result diff --git a/app/scrapers/arca_afip.py b/app/scrapers/arca_afip.py new file mode 100644 index 0000000000000000000000000000000000000000..6a31e5b6ae27e46f4ec53f0cdd5d1cac717d7f03 --- /dev/null +++ b/app/scrapers/arca_afip.py @@ -0,0 +1,197 @@ +"""Scraper ARCA / AFIP — Constancia de Inscripción.""" +import asyncio +import time +import logging +from zeep import Client +from zeep.transports import Transport +from app.scrapers.base import BaseScraper, ScraperError +from app.utils.afip_wsaa import get_afip_credentials +from app.utils.security import mask_cuit + +logger = logging.getLogger(__name__) + +class ArcaAfipScraper(BaseScraper): + source_name = "ARCA/AFIP (Producción)" + + def __init__(self): + super().__init__() + from app.config import get_settings + settings = get_settings() + self.wsdl = 'https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA13?WSDL' + self.cuit_representada = int(settings.afip_cuit_representada) if settings.afip_cuit_representada else 0 + + async def fetch(self, cuit: str, **kwargs) -> dict: + cuit_clean = self.clean_cuit(cuit) + try: + result = await asyncio.to_thread(self._fetch_sync, cuit_clean) + return result + except Exception as e: + logger.error(f"ARCA/AFIP Error para {cuit_clean}: {e}") + return {} + + def _fetch_sync(self, cuit: str) -> dict: + logger.info(f"Consultando AFIP Padron A13 para CUIT {mask_cuit(cuit)}") + + # Reintentar hasta 3 veces el token WSAA + for attempt in range(1, 4): + try: + token, sign = get_afip_credentials(service="ws_sr_padron_a13", production=True) + break + except Exception as e: + logger.warning(f"[ARCA] WSAA token intento {attempt} falló: {e}") + if attempt < 3: + time.sleep(2 ** attempt) + else: + raise + + transport = Transport(timeout=30) + client = Client(self.wsdl, transport=transport) + + # Reintentar la llamada SOAP hasta 3 veces + for attempt in range(1, 4): + try: + resp = client.service.getPersona( + token=token, + sign=sign, + cuitRepresentada=self.cuit_representada, + idPersona=int(cuit) + ) + break + except Exception as e: + if "inexistente" in str(e).lower(): + logger.info(f"CUIT {mask_cuit(cuit)} inexistente en AFIP") + return {} + logger.warning(f"[ARCA] SOAP intento {attempt} falló: {e}") + if attempt < 3: + time.sleep(2 ** attempt) + else: + raise + + if not resp or not resp.persona: + return {} + + persona = resp.persona + + # Nombre/apellido: la API A13 los retorna separados + nombre = persona.nombre or "" + apellido = persona.apellido or "" + razon_social = persona.razonSocial or "" + + # Para personas físicas: combinar nombre + apellido + # Para personas jurídicas: usar razonSocial + if razon_social: + nombre_display = razon_social + else: + # nombre puede venir como "LUCAS MAXIMILIANO" o "LUCAS MAXIMILIANO CALICHIO" + # Si apellido ya está incluido en nombre, usamos solo nombre + if apellido and apellido.upper() in nombre.upper(): + nombre_display = nombre.strip() + # Extraer nombre sin apellido + nombre = nombre.upper().replace(apellido.upper(), "").strip() + else: + nombre_display = f"{nombre} {apellido}".strip() + + # Mapear tipoPersona a condición IVA legible + tipo_persona = persona.tipoPersona or "" + # La API A13 retorna "FISICA" o "JURIDICA" como tipo. + # Para la condicion ante IVA, necesitaríamos otra consulta (no disponible sin auth adicional). + # Usamos una heurística basada en los datos disponibles. + if razon_social: + condicion_iva = "Persona Jurídica" + elif tipo_persona.upper() == "FISICA": + condicion_iva = "Persona Física" + else: + condicion_iva = tipo_persona + + actividades = [] + if persona.idActividadPrincipal: + actividades.append({ + "codigo_clae": str(persona.idActividadPrincipal), + "descripcion": persona.descripcionActividadPrincipal or "", + "es_principal": True + }) + + dom_fiscals = [] + for dom in persona.domicilio: + dom_fiscals.append({ + "tipo": (dom.tipoDomicilio or "").lower(), + "calle": dom.direccion or (dom.calle if hasattr(dom, 'calle') else '') or "", + "numero": str(dom.numero) if dom.numero else "", + "piso": dom.piso or "", + "dpto": dom.oficinaDptoLocal or "", + "torre": getattr(dom, 'torre', '') or "", + "manzana": getattr(dom, 'manzana', '') or "", + "sector": getattr(dom, 'sector', '') or "", + "localidad": dom.localidad or "", + "provincia": dom.descripcionProvincia or "", + "id_provincia": getattr(dom, 'idProvincia', None), + "cp": dom.codigoPostal or "", + "dato_adicional": getattr(dom, 'datoAdicional', '') or "", + "tipo_dato_adicional": getattr(dom, 'tipoDatoAdicional', '') or "", + "estado": getattr(dom, 'estadoDomicilio', '') or "", + }) + dom_fiscal = next((d for d in dom_fiscals if d["tipo"] == "fiscal"), dom_fiscals[0] if dom_fiscals else {}) + + # Monotributo: A13 NO lo retorna — eliminado + monotributo = None + + # Fecha inicio de actividad + fecha_inicio = "" + if hasattr(persona, 'mesClave') and persona.mesClave: + fecha_inicio = str(persona.mesClave) + + # Fecha de nacimiento (disponible en personas físicas) + fecha_nacimiento = None + for attr in ('fechaNacimiento', 'fechaNac', 'fecha_nacimiento'): + val = getattr(persona, attr, None) + if val: + try: + if hasattr(val, 'strftime'): + fecha_nacimiento = val.strftime('%Y-%m-%d') + else: + fecha_nacimiento = str(val) + except Exception: + fecha_nacimiento = str(val) + break + + # Fecha contrato social (personas jurídicas) + fecha_contrato_social = None + val = getattr(persona, 'fechaContratoSocial', None) + if val: + try: + fecha_contrato_social = val.strftime('%Y-%m-%d') if hasattr(val, 'strftime') else str(val) + except Exception: + fecha_contrato_social = str(val) + + # Fecha fallecimiento (si existe) + fecha_fallecimiento = None + val = getattr(persona, 'fechaFallecimiento', None) + if val: + try: + fecha_fallecimiento = val.strftime('%Y-%m-%d') if hasattr(val, 'strftime') else str(val) + except Exception: + fecha_fallecimiento = str(val) + + return { + "cuit": self.format_cuit(cuit), + "tipo_clave": getattr(persona, 'tipoClave', '') or "", + "tipo_documento": getattr(persona, 'tipoDocumento', '') or "", + "numero_documento": getattr(persona, 'numeroDocumento', '') or "", + "nombres": nombre if nombre else nombre_display, + "apellido": apellido, + "nombres_completo": nombre_display, + "condicion_iva": condicion_iva, + "estado_afip": persona.estadoClave or "ACTIVO", + "fecha_inicio_actividad": fecha_inicio, + "fecha_nacimiento": fecha_nacimiento, + "fecha_contrato_social": fecha_contrato_social, + "fecha_fallecimiento": fecha_fallecimiento, + "forma_juridica": getattr(persona, 'formaJuridica', '') or "", + "mes_cierre": getattr(persona, 'mesCierre', None), + "periodo_actividad_principal": getattr(persona, 'periodoActividadPrincipal', None), + "actividades": actividades, + "domicilios": dom_fiscals, + "domicilio_fiscal": dom_fiscal, + "monotributo": monotributo, + } + diff --git a/app/scrapers/archive_org.py b/app/scrapers/archive_org.py new file mode 100644 index 0000000000000000000000000000000000000000..cad99f44292f5c9a39bc2cd5fd6b5545d1c6281d --- /dev/null +++ b/app/scrapers/archive_org.py @@ -0,0 +1,163 @@ +""" +Scraper de Archive.org (Wayback Machine) — Historial web de URLs. +Busca snapshots históricos de sitios web de empresas y personas. +""" +import asyncio +import httpx +import logging +from datetime import datetime +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +WAYBACK_AVAILABILITY = "https://archive.org/wayback/available" +WAYBACK_CDX = "https://web.archive.org/cdx/search/cdx" +MAX_RETRIES = 3 +RETRY_DELAY = 3 + + +class ArchiveOrgScraper(BaseScraper): + source_name = "Archive.org" + uses_playwright = False + + async def fetch(self, identifier: str, **kwargs) -> dict: + """ + Busca historial web de una URL en Archive.org. + + Args: + identifier: URL a buscar (ej: "empresa.com.ar") + kwargs: + timestamp: fecha YYYYMMDD para buscar el snapshot más cercano + max_snapshots: máximo de snapshots a retornar (default 10) + """ + url = identifier + if not url.startswith("http"): + url = f"https://{url}" + + timestamp = kwargs.get("timestamp", datetime.now().strftime("%Y%m%d")) + max_snapshots = kwargs.get("max_snapshots", 10) + + result = { + "url_consultada": url, + "snapshot_mas_cercano": None, + "historial": [], + "total_snapshots": 0, + } + + # 1. Obtener snapshot más cercano (Availability API — rápido) + for attempt in range(MAX_RETRIES): + try: + async with httpx.AsyncClient(timeout=15, verify=False) as client: + resp = await client.get( + WAYBACK_AVAILABILITY, + params={"url": url, "timestamp": timestamp}, + headers={"Accept": "application/json"}, + ) + if resp.status_code == 200: + data = resp.json() + snap = data.get("archived_snapshots", {}).get("closest") + if snap and snap.get("available"): + result["snapshot_mas_cercano"] = { + "timestamp": snap.get("timestamp"), + "url": snap.get("url"), + "status": snap.get("status"), + } + break + elif resp.status_code == 429: + logger.warning(f"[ArchiveOrg] Rate limit (intento {attempt+1}), esperando...") + await asyncio.sleep(RETRY_DELAY * (attempt + 1)) + else: + logger.warning(f"[ArchiveOrg] Availability API status {resp.status_code}") + break + except httpx.TimeoutException: + logger.warning(f"[ArchiveOrg] Availability API timeout (intento {attempt+1})") + if attempt < MAX_RETRIES - 1: + await asyncio.sleep(RETRY_DELAY) + except Exception as e: + logger.warning(f"[ArchiveOrg] Availability API falló para {url}: {e}") + break + + # 2. Obtener historial completo (CDX API — más lento pero completo) + for attempt in range(MAX_RETRIES): + try: + async with httpx.AsyncClient(timeout=25, verify=False) as client: + resp = await client.get( + WAYBACK_CDX, + params={ + "url": url, + "output": "json", + "limit": max_snapshots, + "collapse": "timestamp:8", + "fl": "timestamp,statuscode,mimetype", + "filter": "statuscode:200", + }, + headers={"Accept": "application/json", "User-Agent": "CrowData/1.0"}, + ) + if resp.status_code == 200: + rows = resp.json() + if rows and len(rows) > 1: + for row in rows[1:]: + if len(row) >= 3: + ts, status, mime = row[0], row[1], row[2] + result["historial"].append({ + "timestamp": ts, + "fecha": f"{ts[:4]}-{ts[4:6]}-{ts[6:8]}" if len(ts) >= 8 else ts, + "status": status, + "tipo": mime, + "url_wayback": f"http://web.archive.org/web/{ts}/{url}", + }) + result["total_snapshots"] = len(result["historial"]) + break + elif resp.status_code == 429: + logger.warning(f"[ArchiveOrg] CDX rate limit (intento {attempt+1}), esperando...") + await asyncio.sleep(RETRY_DELAY * (attempt + 1)) + elif resp.status_code == 400: + logger.info(f"[ArchiveOrg] CDX: URL no válida o sin snapshots: {url}") + break + else: + logger.warning(f"[ArchiveOrg] CDX API status {resp.status_code} (intento {attempt+1})") + if attempt < MAX_RETRIES - 1: + await asyncio.sleep(RETRY_DELAY) + except httpx.TimeoutException: + logger.warning(f"[ArchiveOrg] CDX API timeout (intento {attempt+1})") + if attempt < MAX_RETRIES - 1: + await asyncio.sleep(RETRY_DELAY) + except Exception as e: + logger.warning(f"[ArchiveOrg] CDX API falló para {url}: {e}") + break + + return result + + +async def check_url_history(url: str) -> dict: + """ + Función de conveniencia: check rápido de historial web. + Retorna el snapshot más cercano y un resumen. + """ + scraper = ArchiveOrgScraper() + return await scraper.fetch(url) + + +async def get_historical_page(url: str, timestamp: str) -> str | None: + """ + Descarga una página histórica específica de Archive.org. + + Args: + url: URL original + timestamp: YYYYMMDDhhmmss del snapshot deseado + + Returns: + HTML de la página histórica o None + """ + wayback_url = f"http://web.archive.org/web/{timestamp}id_/{url}" + try: + async with httpx.AsyncClient(timeout=30, verify=False, follow_redirects=True) as client: + resp = await client.get( + wayback_url, + headers={"User-Agent": "Mozilla/5.0 (compatible; CrowData/1.0)"}, + ) + if resp.status_code == 200: + return resp.text + except Exception as e: + logger.warning(f"[ArchiveOrg] Error descargando snapshot: {e}") + return None diff --git a/app/scrapers/base.py b/app/scrapers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..57113bdcecc1cabcce6d17666c38d59b69768ec5 --- /dev/null +++ b/app/scrapers/base.py @@ -0,0 +1,364 @@ +"""BaseScraper — Clase base abstracta para todos los scrapers de CrowData.""" +import asyncio +import logging +import random +import re +from abc import ABC, abstractmethod +from contextlib import asynccontextmanager +from typing import Any +from app.config import get_settings +from app.utils.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitState + +settings = get_settings() +logger = logging.getLogger(__name__) + + +class ScraperError(Exception): + """Error específico de scraper con información de fuente.""" + def __init__(self, source: str, message: str, retryable: bool = True): + self.source = source + self.retryable = retryable + super().__init__(f"[{source}] {message}") + + +class AntiBotBlockedError(ScraperError): + """Error específico cuando la plataforma bloquea la IP del servidor.""" + def __init__(self, source: str, status_code: int): + self.status_code = status_code + super().__init__( + source=source, + message=f"Módulo inhabilitado temporalmente: La plataforma de origen [{source}] bloqueó la IP del servidor. (HTTP {status_code})", + retryable=False + ) + + +# Global semaphore to limit concurrent scrapers and prevent CPU/Memory exhaustion +# Since many scrapers use Playwright, limiting to 5 concurrent scrapers keeps resource usage stable +global_scraper_semaphore = asyncio.Semaphore(5) + + +class BaseScraper(ABC): + source_name: str = "base" + uses_playwright: bool = False + max_retries: int = 3 + retry_delay: float = 2.0 + SAFE_FETCH_TIMEOUT = 35 # Subclasses override for longer operations + + USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0", + ] + + # Timeouts por defecto (ms) - pueden ser sobrescritos en subclases + DEFAULT_NAVIGATION_TIMEOUT = 20000 # 20s para page.goto + DEFAULT_ELEMENT_TIMEOUT = 10000 # 10s para wait_for_selector + DEFAULT_CLICK_TIMEOUT = 8000 # 8s para clicks + + def __init__(self): + self.logger = logging.getLogger(f"crowdata.scrapers.{self.source_name}") + + def get_random_user_agent(self) -> str: + return random.choice(self.USER_AGENTS) + + def get_anti_bot_headers(self) -> dict: + """Retorna headers completos anti-detección para todas las peticiones HTTP salientes.""" + ua = self.get_random_user_agent() + is_firefox = "Firefox" in ua + return { + "User-Agent": ua, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" if is_firefox + else "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Language": "es-AR,es;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Cache-Control": "max-age=0", + } + + def get_anti_bot_api_headers(self) -> dict: + """Headers para llamadas a APIs JSON.""" + return { + "User-Agent": self.get_random_user_agent(), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "es-AR,es;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Cache-Control": "no-cache", + } + + def get_proxy(self) -> str | None: + """Retorna una URL de proxy de la configuración.""" + if hasattr(settings, "proxy_list") and settings.proxy_list: + return random.choice(settings.proxy_list) + if hasattr(settings, "proxy_url") and settings.proxy_url: + return settings.proxy_url + return None + + def get_random_delay(self, min_s: float = 0.5, max_s: float = 2.5) -> float: + """Genera un delay dinámico aleatorio para evitar patrones de scraping.""" + return random.uniform(min_s, max_s) + + @asynccontextmanager + async def stealth_context(self, playwright, proxy_url: str = None): + """ + NUEVO: Context manager con cleanup automático. + Uso: + async with self.stealth_context(playwright) as (browser, context, page): + # usar browser, context, page + pass # cleanup automático + """ + from playwright_stealth import Stealth + + proxy = {"server": proxy_url} if proxy_url else None + + browser = await playwright.chromium.launch( + headless=settings.playwright_headless, + args=[ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-extensions", + "--disable-background-networking", + "--disable-sync", + "--disable-translate", + "--hide-scrollbars", + "--mute-audio", + "--no-first-run", + ], + proxy=proxy + ) + try: + ua = self.get_random_user_agent() + context = await browser.new_context( + user_agent=ua, + viewport={"width": 1280, "height": 720}, + locale="es-AR", + timezone_id="America/Argentina/Buenos_Aires", + ignore_https_errors=True + ) + page = await context.new_page() + page.set_default_navigation_timeout(self.DEFAULT_NAVIGATION_TIMEOUT) + page.set_default_timeout(self.DEFAULT_ELEMENT_TIMEOUT) + + await Stealth().apply_stealth_async(page) + + yield browser, context, page + finally: + # Cleanup garantizado en TODOS los paths (éxito, error, timeout, cancelación) + try: + await context.close() + except Exception: + pass + try: + await browser.close() + except Exception: + pass + + # ==================== BACKWARD COMPATIBILITY ==================== + async def get_stealth_context(self, playwright, proxy_url: str = None): + """ + [LEGACY] Compatibilidad con scrapers existentes. + Devuelve (browser, context, page). Cleanup interno via _pending_cleanup. + NUEVO CÓDIGO: usar `async with self.stealth_context(...)` que hace cleanup automático. + """ + return await self._get_stealth_context_legacy(playwright, proxy_url) + + async def _get_stealth_context_legacy(self, playwright, proxy_url: str = None): + """Método legacy para compatibilidad - devuelve (browser, context, page) sin cleanup automático.""" + from playwright_stealth import Stealth + + proxy = {"server": proxy_url} if proxy_url else None + + browser = await playwright.chromium.launch( + headless=settings.playwright_headless, + args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + "--disable-infobars", + "--disable-features=IsolateOrigins,site-per-process", + "--disable-site-isolation-trials", + ], + proxy=proxy + ) + ua = self.get_random_user_agent() + context = await browser.new_context( + user_agent=ua, + viewport={"width": 1280, "height": 720}, + locale="es-AR", + timezone_id="America/Argentina/Buenos_Aires", + ignore_https_errors=True, + ) + page = await context.new_page() + page.set_default_navigation_timeout(self.DEFAULT_NAVIGATION_TIMEOUT) + page.set_default_timeout(self.DEFAULT_ELEMENT_TIMEOUT) + + await Stealth().apply_stealth_async(page) + return browser, context, page + + @abstractmethod + async def fetch(self, identifier: str, **kwargs) -> Any: + pass + + async def safe_fetch(self, identifier: str, **kwargs) -> Any: + """ + Punto de entrada principal para todos los scrapers. + Ejecuta fetch con rotación de headers, delay dinámico, reintento y + manejo de bloqueos 403/429. Integra el sistema anti-bot obligatoriamente. + Tiempo máximo absoluto: SAFE_FETCH_TIMEOUT segundos por intento. + """ + import time + from app.utils.telemetry import record_scraper_result + from app.utils.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitState + + # Circuit breaker config per scraper + cb = get_circuit_breaker( + self.source_name, + CircuitBreakerConfig( + failure_threshold=5, + timeout=60.0, + excluded_exceptions=(AntiBotBlockedError,), + ) + ) + + # Check circuit breaker before starting + async with cb._lock: + current_state = cb.state + if current_state == CircuitState.OPEN: + self.logger.warning(f"Circuit breaker OPEN for {self.source_name}, failing fast") + raise ScraperError(self.source_name, "Circuit breaker open", retryable=False) + + t0 = time.time() + last_error = None + MAX_SINGLE_ATTEMPT = self.SAFE_FETCH_TIMEOUT + + for attempt in range(1, self.max_retries + 1): + try: + self.logger.info( + f"[anti-bot] Fetching '{identifier}' from {self.source_name} " + f"(Attempt {attempt}/{self.max_retries})" + ) + if attempt > 1: + delay = self.get_random_delay( + min_s=self.retry_delay * attempt, + max_s=self.retry_delay * attempt * 2 + ) + self.logger.debug(f"[anti-bot] Waiting {delay:.2f}s before retry...") + await asyncio.sleep(delay) + + async with global_scraper_semaphore: + import sys + if self.uses_playwright and sys.platform == "win32": + self.logger.info( + f"[anti-bot] Running Playwright scraper '{self.source_name}' on Windows " + f"in a separate ProactorEventLoop thread to avoid loop incompatibilities." + ) + result = await asyncio.wait_for( + self._run_in_proactor_loop(self.fetch, identifier, **kwargs), + timeout=MAX_SINGLE_ATTEMPT + ) + else: + # Wrap fetch with circuit breaker + async def _fetch_with_cb(): + return await self.fetch(identifier, **kwargs) + + result = await asyncio.wait_for( + cb.call(_fetch_with_cb), + timeout=MAX_SINGLE_ATTEMPT + ) + + # Telemetría: Éxito (ok u empty) + latency = (time.time() - t0) * 1000 + records_found = 0 + if result: + if isinstance(result, list): + records_found = len(result) + elif isinstance(result, dict): + list_keys = [k for k, v in result.items() if isinstance(v, list)] + if list_keys: + records_found = sum(len(result[k]) for k in list_keys) + else: + records_found = 1 + else: + records_found = 1 + status = "ok" if records_found > 0 else "empty" + await cb._on_success() + record_scraper_result(self.source_name, status, latency, records_found=records_found) + return result + + except AntiBotBlockedError as e: + # No reintentar si la IP está bloqueada — propagar el error directamente + self.logger.error( + f"[anti-bot] BLOCKED by {self.source_name}: {e}" + ) + latency = (time.time() - t0) * 1000 + record_scraper_result(self.source_name, "blocked", latency, detail=str(e)) + raise e + + except asyncio.TimeoutError: + last_error = ScraperError(self.source_name, f"Timeout after {MAX_SINGLE_ATTEMPT}s for {identifier}", retryable=True) + self.logger.warning(f"Timeout (attempt {attempt}/{self.max_retries}) in {self.source_name} for {identifier}") + + except ScraperError as e: + last_error = e + if not e.retryable: + self.logger.warning(f"Non-retryable error for '{identifier}' in {self.source_name}: {e}") + break + self.logger.warning(f"Retryable error (attempt {attempt}): {e}") + + except Exception as e: + last_error = e + self.logger.warning(f"Unexpected error (attempt {attempt}) in {self.source_name}: {type(e).__name__}: {e}") + + self.logger.error( + f"All {self.max_retries} attempts failed for '{identifier}' from {self.source_name}. Last error: {last_error}" + ) + latency = (time.time() - t0) * 1000 + record_scraper_result(self.source_name, "error", latency, detail=str(last_error)) + if last_error: + raise last_error + raise ScraperError(self.source_name, f"All attempts failed for {identifier}") + + async def execute_with_retry(self, identifier: str, **kwargs) -> Any: + """Alias de safe_fetch para compatibilidad interna.""" + return await self.safe_fetch(identifier, **kwargs) + + async def _run_in_proactor_loop(self, func, *args, **kwargs) -> Any: + """ + Runs an async function in a new thread with a ProactorEventLoop. + This is required on Windows when the main event loop is a SelectorEventLoop + (for uvicorn reload compatibility) but Playwright needs a ProactorEventLoop + to support subprocesses. + """ + def _sync_run(): + loop = asyncio.ProactorEventLoop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(func(*args, **kwargs)) + finally: + loop.close() + + return await asyncio.to_thread(_sync_run) + + + @staticmethod + def clean_cuit(cuit: str) -> str: + """Normaliza CUIT/CUIL removiendo guiones y espacios.""" + return cuit.replace("-", "").replace(" ", "").strip() + + @staticmethod + def format_cuit(cuit: str) -> str: + """Formatea CUIT para display: '20123456789' → '20-12345678-9'""" + cuit_clean = re.sub(r"[^0-9]", "", cuit) + if len(cuit_clean) == 11: + return f"{cuit_clean[:2]}-{cuit_clean[2:10]}-{cuit_clean[10]}" + return cuit diff --git a/app/scrapers/bcra.py b/app/scrapers/bcra.py new file mode 100644 index 0000000000000000000000000000000000000000..4b8263c24c34374c209f1859c7dfe2f162e7f0bc --- /dev/null +++ b/app/scrapers/bcra.py @@ -0,0 +1,211 @@ +"""Scraper BCRA — Central de Deudores del Sistema Financiero.""" +import asyncio +import logging +from app.scrapers.base import BaseScraper, ScraperError, AntiBotBlockedError +from app.utils.http_client import http_get + +logger = logging.getLogger(__name__) + +BCRA_API_BASE = "https://api.bcra.gob.ar/centraldedeudores/v1.0" +SITUACIONES = { + 1: "Normal", + 2: "Con seguimiento especial / Riesgo bajo", + 3: "Con problemas / Riesgo medio", + 4: "Con alto riesgo de insolvencia / Riesgo alto", + 5: "Irrecuperable", + 6: "Irrecuperable por disposición técnica", +} + + +class BcraScraper(BaseScraper): + source_name = "BCRA" + uses_playwright = False + max_retries = 3 + retry_delay = 2.0 + SAFE_FETCH_TIMEOUT = 40 + + async def fetch(self, cuil: str, **kwargs) -> dict: + cuil_clean = self.clean_cuit(cuil) + + deudas_resp = await self._fetch_deudas(cuil_clean) + cheques_resp = await self._fetch_cheques(cuil_clean) + + if deudas_resp is None and cheques_resp is None: + logger.error(f"[BCRA] Todas las consultas fallaron para {cuil_clean} — no retornar default") + raise ScraperError(self.source_name, f"No se pudo conectar con la API del BCRA para {cuil_clean}") + + deudas = deudas_resp["entidades"] if deudas_resp else [] + denominacion_deudas = deudas_resp["denominacion"] if deudas_resp else "" + cheques_data = cheques_resp["cheques"] if cheques_resp else [] + denominacion_cheques = cheques_resp["denominacion"] if cheques_resp else "" + denominacion = denominacion_deudas or denominacion_cheques + + situacion_actual = 1 + historial = [] + tiene_deuda = False + total_deuda = 0.0 + dias_atraso_max = 0 + + for entidad in deudas: + sit = entidad.get("situacion", 1) + if sit > situacion_actual: + situacion_actual = sit + if sit > 1: + tiene_deuda = True + monto = entidad.get("monto", 0.0) or 0.0 + total_deuda += monto + dias = entidad.get("dias_atraso", 0) or 0 + if dias > dias_atraso_max: + dias_atraso_max = dias + historial.append({ + "periodo": entidad.get("periodo"), + "situacion": sit, + "entidad": entidad.get("entidad"), + "monto_deuda": monto, + "dias_atraso": dias, + "fecha_sit1": entidad.get("fecha_sit1", ""), + "refinanciaciones": entidad.get("refinanciaciones", False), + "situacion_juridica": entidad.get("situacion_juridica", False), + "proceso_judicial": entidad.get("proceso_judicial", False), + "en_revision": entidad.get("en_revision", False), + }) + + logger.info( + f"[BCRA] {cuil_clean}: situacion={situacion_actual}, " + f"deudas={len(deudas)}, cheques={len(cheques_data)}" + ) + + return { + "denominacion": denominacion, + "bcra_situacion_actual": situacion_actual, + "bcra_situacion_descripcion": SITUACIONES.get(situacion_actual, "Sin deudas comerciales activas"), + "bcra_historial": historial, + "bcra_total_deuda_miles": total_deuda, + "bcra_dias_atraso_max": dias_atraso_max, + "cheques_rechazados": cheques_data, + "tiene_deuda": tiene_deuda, + } + + def _get_headers(self): + return { + "User-Agent": "curl/8.19.0", + "Accept": "*/*", + "Connection": "keep-alive", + "Accept-Encoding": "gzip, deflate", + } + + async def _fetch_deudas(self, cuil: str) -> dict | None: + url = f"{BCRA_API_BASE}/Deudas/{cuil}" + logger.info(f"[BCRA] Consultando deudas: {url}") + + try: + response = await http_get(url, headers=self._get_headers()) + except Exception as e: + logger.error(f"[BCRA] Request failed for deudas {cuil}: {e}") + return None + + if response is None: + logger.error(f"[BCRA] Todos los reintentos fallaron para deudas {cuil}") + return None + + logger.info(f"[BCRA] Deudas HTTP {response.status_code} para {cuil}") + if response.status_code in (403, 429): + from app.scrapers.base import AntiBotBlockedError + raise AntiBotBlockedError(self.source_name, response.status_code) + if response.status_code == 200: + result = self._parse_deudas(response.json()) + logger.info(f"[BCRA] Deudas parseadas: {len(result.get('entidades', []))} registros") + return result + elif response.status_code == 404: + logger.info(f"[BCRA] Sin deudas registradas para {cuil} (404)") + return {"denominacion": "", "entidades": []} + else: + logger.error(f"[BCRA] Status inesperado {response.status_code} para {cuil}: {response.text[:200]}") + return None + + async def _fetch_cheques(self, cuil: str) -> dict | None: + url = f"{BCRA_API_BASE}/Deudas/ChequesRechazados/{cuil}" + logger.info(f"[BCRA] Consultando cheques: {url}") + + try: + response = await http_get(url, headers=self._get_headers()) + except Exception as e: + logger.error(f"[BCRA] Request failed for cheques {cuil}: {e}") + return None + + if response is None: + logger.error(f"[BCRA] Todos los reintentos fallaron para cheques {cuil}") + return None + + logger.info(f"[BCRA] Cheques HTTP {response.status_code} para {cuil}") + if response.status_code in (403, 429): + from app.scrapers.base import AntiBotBlockedError + raise AntiBotBlockedError(self.source_name, response.status_code) + if response.status_code == 200: + result = self._parse_cheques(response.json()) + logger.info(f"[BCRA] Cheques parseados: {len(result.get('cheques', []))} registros") + return result + elif response.status_code == 404: + logger.info(f"[BCRA] Sin cheques rechazados para {cuil} (404)") + return {"denominacion": "", "cheques": []} + else: + logger.error(f"[BCRA] Status inesperado {response.status_code} para cheques {cuil}: {response.text[:200]}") + return None + + def _parse_deudas(self, data: dict) -> dict: + if not data or not data.get("results"): + return {"denominacion": "", "entidades": []} + results = data["results"] + denominacion = results.get("denominacion", "") + periodos = results.get("periodos", []) + if not periodos: + return {"denominacion": denominacion, "entidades": []} + + entidades = [] + for periodo_obj in periodos: + periodo_str = str(periodo_obj.get("periodo", "")) + if len(periodo_str) == 6: + periodo_fmt = f"{periodo_str[:4]}-{periodo_str[4:]}" + else: + periodo_fmt = periodo_str + + for ent in periodo_obj.get("entidades", []): + entidades.append({ + "periodo": periodo_fmt, + "entidad": ent.get("entidad"), + "situacion": ent.get("situacion", 1), + "monto": ent.get("monto", 0.0), + "dias_atraso": ent.get("diasAtrasoPago", 0), + "fecha_sit1": ent.get("fechaSit1", ""), + "refinanciaciones": ent.get("refinanciaciones", False), + "recategorizacion": ent.get("recategorizacionOblig", False), + "situacion_juridica": ent.get("situacionJuridica", False), + "irrec_disp_tecnica": ent.get("irrecDisposicionTecnica", False), + "en_revision": ent.get("enRevision", False), + "proceso_judicial": ent.get("procesoJud", False), + }) + return {"denominacion": denominacion, "entidades": entidades} + + def _parse_cheques(self, data: dict) -> dict: + if not data or not data.get("results"): + return {"denominacion": "", "cheques": []} + results = data["results"] + denominacion = results.get("denominacion", "") + causales = results.get("causales", []) + cheques = [] + for c in causales: + causal = c.get("causal", "") + for entidad in c.get("entidades", []): + entidad_codigo = entidad.get("entidad") + for det in entidad.get("detalle", []): + cheques.append({ + "causal": causal, + "entidad_codigo": entidad_codigo, + "nro_cheque": str(det.get("nroCheque", "")), + "fecha_rechazo": det.get("fechaRechazo", ""), + "monto": det.get("monto", 0), + }) + return {"denominacion": denominacion, "cheques": cheques} + + +bcra_scraper = BcraScraper() \ No newline at end of file diff --git a/app/scrapers/billeteras_virtuales.py b/app/scrapers/billeteras_virtuales.py new file mode 100644 index 0000000000000000000000000000000000000000..a900a5c55d8b6269573112ea628f141575690c4c --- /dev/null +++ b/app/scrapers/billeteras_virtuales.py @@ -0,0 +1,128 @@ +"""Billeteras Virtuales / Fintechs — procesador de datos BCRA. +Toma el historial del BCRA, filtra entidades fintech y devuelve datos +estructurados con nombres comerciales amigables. + +NO requiere APIs pagas — usa mapping local + datos ya consultados del BCRA. +""" +import json +import logging +import os +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +BCRA_SITUACIONES = { + 1: "Normal", + 2: "Con seguimiento especial / Riesgo bajo", + 3: "Con problemas / Riesgo medio", + 4: "Con alto riesgo de insolvencia / Riesgo alto", + 5: "Irrecuperable", + 6: "Irrecuperable por disposición técnica", +} + +def _load_entities() -> list[dict]: + path = os.path.join(os.path.dirname(__file__), "..", "..", "data", "fintech_entities.json") + path = os.path.normpath(path) + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("entidades", []) + except Exception as e: + logger.warning(f"[BilleterasVirtuales] No se pudo cargar fintech_entities.json: {e}") + return [] + +def _get_entity_map() -> dict: + entities = _load_entities() + return { + e["nombre_bcra"].lower(): { + "marca": e["marca"], + "tipo": e["tipo"], + "rubro": e["rubro"], + "es_fintech": e["tipo"] in ("fintech", "banco_digital", "procesadora"), + } + for e in entities + } + +def _find_mapping(raw_name: str, entity_map: dict) -> dict | None: + if not raw_name: + return None + lower = raw_name.lower().strip() + if lower in entity_map: + return entity_map[lower] + for key, val in entity_map.items(): + if key in lower or lower in key: + return val + return None + +def process_bcra_historial(historial: list[dict]) -> dict: + """Toma el bcra_historial de DatosFinancieros y filtra solo entidades fintech.""" + entity_map = _get_entity_map() + if not entity_map: + logger.warning("[BilleterasVirtuales] Entity map vacío — fintech_entities.json no cargado") + detalle = [] + total_deuda = 0.0 + wallets_con_deuda = set() + fintechs_vistas = set() + + # Procesar deudas del BCRA + for h in historial: + entidad_raw = (h.get("entidad") or "").strip() + if not entidad_raw: + continue + mapping = _find_mapping(entidad_raw, entity_map) + if not mapping or not mapping["es_fintech"]: + continue + + marca = mapping["marca"] + situacion = h.get("situacion", 1) + monto = h.get("monto_deuda", 0.0) or 0.0 + + detalle.append({ + "entidad_bcra": entidad_raw, + "marca": marca, + "tipo": mapping["tipo"], + "rubro": mapping["rubro"], + "situacion": situacion, + "situacion_desc": BCRA_SITUACIONES.get(situacion, "Desconocida"), + "monto": monto, + "periodo": h.get("periodo"), + "es_fintech": True, + }) + total_deuda += abs(monto) + if situacion > 1 or monto > 0: + wallets_con_deuda.add(marca) + fintechs_vistas.add(marca) + + # Agregar fintechs del mapping que no aparecieron (reporte completo) + marcas_encontradas = {d["marca"] for d in detalle} + for nombre_bcra, mapping in entity_map.items(): + if not mapping["es_fintech"]: + continue + if mapping["marca"] not in marcas_encontradas: + fintechs_vistas.add(mapping["marca"]) + + logger.info( + f"[BilleterasVirtuales] {len(detalle)} fintechs con deuda, " + f"total=${total_deuda:.0f}, " + f"{len(wallets_con_deuda)} wallets con problemas, " + f"{len(fintechs_vistas)} fintechs monitoreadas" + ) + + return { + "total_deuda_fintech": round(total_deuda, 2), + "cantidad_wallets_con_deuda": len(wallets_con_deuda), + "cantidad_fintech_detectadas": len(fintechs_vistas), + "detalle": detalle, + } + + +class BilleterasVirtualesScraper(BaseScraper): + """Scraper que procesa datos BCRA existentes para extraer info fintech. + No hace llamadas HTTP propias — recibe bcra_historial via kwargs. + """ + source_name = "Billeteras Virtuales / Fintechs" + max_retries = 1 + + async def fetch(self, identifier: str, **kwargs) -> dict: + historial = kwargs.get("bcra_historial", []) + return process_bcra_historial(historial) diff --git a/app/scrapers/boletin_oficial.py b/app/scrapers/boletin_oficial.py new file mode 100644 index 0000000000000000000000000000000000000000..c1684cc01e5d758c7a79c0f91e60ed382c7cb3ab --- /dev/null +++ b/app/scrapers/boletin_oficial.py @@ -0,0 +1,248 @@ +"""Scraper Boletín Oficial de la República Argentina. + +Estrategia: Playwright desde la página de sección (/seccion/primera). +La búsqueda avanzada (/busquedaAvanzada) redirige a error — no se puede usar directamente. +En su lugar, se carga la página de sección y se ejecuta la búsqueda rápida vía JavaScript. + +Dos búsquedas: + 1. Por DNI — identificador único, sin validación adicional. + 2. Por APELLIDO — con validación de proximidad del nombre (±10 caracteres). +""" +import asyncio +import json +import logging +import re +from datetime import datetime, timedelta +from bs4 import BeautifulSoup +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +BOLETIN_SECTION_URL = "https://www.boletinoficial.gob.ar/seccion/primera" +BOLETIN_BASE = "https://www.boletinoficial.gob.ar" + + +class BoletinOficialScraper(BaseScraper): + source_name = "Boletín Oficial" + uses_playwright = True + max_retries = 1 + SAFE_FETCH_TIMEOUT = 120 + + async def fetch(self, identifier: str, **kwargs) -> dict: + from playwright.async_api import async_playwright + + nombre = kwargs.get("nombre", "") + dni = kwargs.get("dni", "") + domicilio = kwargs.get("domicilio", "") + + all_results = [] + + # Extraer apellido y nombre de pila del nombre completo + apellido = "" + nombre_pila = "" + if nombre: + parts = nombre.strip().split() + if len(parts) >= 2: + apellido = parts[0].upper() + nombre_pila = parts[1].upper() + elif len(parts) == 1: + apellido = parts[0].upper() + + # Búsqueda 1: Por DNI (identificador único, sin validación) + queries_dni = [] + if dni: + queries_dni.append(dni) + + # Búsqueda 2: Por APELLIDO + NOMBRE (con validación de proximidad del nombre) + queries_apellido = [] + if apellido: + if nombre_pila: + queries_apellido.append(f"{apellido} {nombre_pila}") + else: + queries_apellido.append(apellido) + + # Agregar domicilio como query adicional (sin filtro) + queries_domicilio = [] + if domicilio: + calle = domicilio.get("calle", "") + localidad = domicilio.get("localidad", "") + if calle: + queries_domicilio.append(f"{calle} {localidad}".strip()) + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=settings.playwright_headless, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = await browser.new_page( + user_agent=self.get_random_user_agent(), + locale="es-AR", + ) + + try: + # Ejecutar búsquedas por DNI (sin filtro de proximidad) + for query in queries_dni: + results = await self._search_from_section(page, query) + all_results.extend(results) + + # Ejecutar búsquedas por apellido (con filtro de proximidad) + for query in queries_apellido: + results = await self._search_from_section(page, query) + if nombre_pila: + results = [r for r in results if self._validate_proximity(r.get("texto", ""), apellido, nombre_pila)] + all_results.extend(results) + + # Ejecutar búsquedas por domicilio (sin filtro) + for query in queries_domicilio: + results = await self._search_from_section(page, query) + all_results.extend(results) + finally: + await browser.close() + + # Deduplicar por texto+fecha + seen = set() + deduped = [] + for p in all_results: + key = f"{p.get('texto', '')[:100]}|{p.get('fecha', '')}" + if key not in seen: + seen.add(key) + deduped.append(p) + + return {"publicaciones": deduped} + + def _validate_proximity(self, text: str, apellido: str, nombre: str) -> bool: + """Valida que el nombre aparezca a ≤10 caracteres del apellido en el texto.""" + if not text or not apellido or not nombre: + return False + + text_lower = text.lower() + apellido_lower = apellido.lower() + nombre_lower = nombre.lower() + + idx = text_lower.find(apellido_lower) + if idx == -1: + return False + + # Buscar nombre antes del apellido (hasta 10 chars antes, expandir por longitud del nombre) + before = text_lower[max(0, idx - 10 - len(nombre)):idx] + if nombre_lower in before: + return True + + # Buscar nombre después del apellido (hasta 10 chars después) + after_start = idx + len(apellido_lower) + after = text_lower[after_start:after_start + 10] + if nombre_lower in after: + return True + + return False + + async def _search_from_section(self, page, query: str) -> list[dict]: + captured = [] + + async def on_response(response): + if "realizarBusqueda" in response.url: + try: + ct = response.headers.get("content-type", "") + if "json" in ct: + body = await response.json() + if body.get("error") == 0: + content = body.get("content", {}) + html = content.get("html", "") + if html: + captured.append(html) + except Exception: + pass + + page.on("response", on_response) + + try: + await page.goto(BOLETIN_SECTION_URL, wait_until="networkidle", timeout=30000) + await page.wait_for_timeout(2000) + + rapida = await page.query_selector("#rapidaInput") + if not rapida or not await rapida.is_visible(): + logger.warning("[BoletinOficial] Search input not found") + return [] + + await rapida.fill("") + await rapida.fill(query) + + btn = await page.query_selector("#busquedaRapidaButton") + if not btn or not await btn.is_visible(): + logger.warning("[BoletinOficial] Search button not found") + return [] + + await btn.click() + await page.wait_for_timeout(8000) + + except Exception as e: + logger.warning(f"[BoletinOficial] Error searching '{query}': {e}") + return [] + finally: + page.remove_listener("response", on_response) + + results = [] + for html in captured: + results.extend(self._parse_html_results(html)) + + if not results: + dom_html = await page.evaluate("""() => { + const el = document.getElementById('resultadosBusquedaRapida'); + return el ? el.innerHTML : ''; + }""") + if dom_html: + results = self._parse_html_results(dom_html) + + return results[:20] + + def _parse_html_results(self, html: str) -> list[dict]: + soup = BeautifulSoup(html, "html.parser") + publicaciones = [] + + detail_links = soup.find_all("a", href=re.compile(r"/detalleAviso/")) + seen_urls = set() + + for link in detail_links: + href = link.get("href", "") + if href in seen_urls: + continue + seen_urls.add(href) + + parent = link.find_parent(["div", "tr", "td", "li"]) + text = "" + if parent: + text = parent.get_text(separator=" ", strip=True) + + if not text: + text = link.get_text(strip=True) + + url = f"{BOLETIN_BASE}{href}" if href.startswith("/") else href + + publicaciones.append({ + "texto": text[:300] if text else "", + "url": url, + "fuente": "Boletín Oficial", + "fecha": self._extract_date(text), + "seccion": self._extract_section(href), + }) + + return publicaciones + + def _extract_section(self, href: str) -> str: + if "/segunda/" in href: + return "Segunda sección" + elif "/primera/" in href: + return "Primera sección" + elif "/tercera/" in href: + return "Tercera sección" + elif "/cuarta/" in href: + return "Cuarta sección" + return "" + + def _extract_date(self, text: str) -> str: + match = re.search(r"(\d{2}/\d{2}/\d{2})", text) + if match: + return match.group(1) + return "" diff --git a/app/scrapers/boletin_oficial_pba.py b/app/scrapers/boletin_oficial_pba.py new file mode 100644 index 0000000000000000000000000000000000000000..e67714c874702bccd1b4a5315b6b67078e675b54 --- /dev/null +++ b/app/scrapers/boletin_oficial_pba.py @@ -0,0 +1,316 @@ +""" +Boletín Oficial PBA — Sucesiones, edictos judiciales, remates, inhibiciones. + +Busca en la sección JUDICIAL del Boletín Oficial de la Provincia de Buenos Aires +publicaciones que mencionen una dirección o localidad específica. +""" +import re +import logging +import unicodedata +import httpx +from bs4 import BeautifulSoup +from urllib.parse import urlencode + +logger = logging.getLogger(__name__) + +BO_BASE = "https://boletinoficial.gba.gob.ar" +BO_SEARCH_URL = f"{BO_BASE}/buscar" + + +async def search_boletin(query: str, section: str = "JUDICIAL", years_back: int = 3) -> list[dict]: + """ + Busca en el Boletín Oficial PBA por palabras clave. + + Args: + query: Término de búsqueda + section: Sección (JUDICIAL, OFICIAL, JURISPRUDENCIA, SUPLEMENTO) + years_back: Cuántos años hacia atrás buscar + """ + results = [] + + try: + async with httpx.AsyncClient(timeout=20, verify=False, follow_redirects=True) as client: + from datetime import datetime, timedelta + end_date = datetime.now() + start_date = end_date - timedelta(days=years_back * 365) + + params = { + "search[words]": query, + "search[section]": section, + "search[sort]": "by_match_desc", + "search[date_gteq]": start_date.strftime("%d/%m/%Y"), + "search[date_lteq]": end_date.strftime("%d/%m/%Y"), + } + + resp = await client.get( + BO_SEARCH_URL, + params=params, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html", + }, + ) + + if resp.status_code != 200: + logger.warning(f"BO PBA retornó HTTP {resp.status_code}") + return results + + soup = BeautifulSoup(resp.text, "html.parser") + + # Estrategia 1: Buscar items con clases de resultado + logger.debug(f"[BO PBA] Intentando estrategia 1: items por clase") + result_items = soup.find_all("div", class_=re.compile(r"result|item|entry", re.IGNORECASE)) + if not result_items: + result_items = soup.find_all("article") or soup.find_all("li") + + if result_items: + logger.info(f"[BO PBA] Estrategia 1 exitosa: {len(result_items)} items encontrados") + for item in result_items[:10]: + text = item.get_text(" ", strip=True) + links = item.find_all("a", href=True) + pdf_url = "" + for link in links: + href = link.get("href", "") + if "/descargar" in href or "/ver" in href or ".pdf" in href.lower(): + pdf_url = f"{BO_BASE}{href}" if href.startswith("/") else href + break + + fecha_match = re.search(r'(\d{2}/\d{2}/\d{4})', text) + + results.append({ + "titulo": text[:200], + "snippet": text[:500], + "url_pdf": pdf_url, + "tipo": _detectar_tipo(text), + "fecha": fecha_match.group(1) if fecha_match else "", + }) + return results + + # Estrategia 2: Buscar por mensaje de "sin resultados" + logger.debug(f"[BO PBA] Estrategia 1 falló, intentando estrategia 2: verificar sin resultados") + text = soup.get_text(" ", strip=True) + if "no se han encontrado" in text.lower() or "sin resultados" in text.lower(): + logger.info(f"[BO PBA] Estrategia 2: sin resultados confirmado") + return results + + # Estrategia 3: Fallback - buscar por links específicos + logger.debug(f"[BO PBA] Intentando estrategia 3: fallback por links") + for link in soup.find_all("a", href=True): + href = link.get("href", "") + if "/secciones/" in href or "/descargar" in href or "/ver" in href: + parent = link.find_parent(["div", "li", "article", "section"]) + snippet = parent.get_text(" ", strip=True)[:500] if parent else link.get_text(strip=True) + + if not snippet or "no se han encontrado" in snippet.lower(): + continue + + results.append({ + "titulo": snippet[:200], + "snippet": snippet, + "url_pdf": f"{BO_BASE}{href}" if href.startswith("/") else href, + "tipo": _detectar_tipo(snippet), + "fecha": "", + }) + + if results: + logger.info(f"[BO PBA] Estrategia 3 exitosa: {len(results)} items encontrados") + else: + logger.warning(f"[BO PBA] Ninguna estrategia encontró resultados para '{query}'") + + except Exception as e: + logger.warning(f"BO PBA search falló para '{query}': {e}") + + return results + + +def _detectar_tipo(text: str) -> str: + """Detecta el tipo de publicación judicial. + + Normaliza el texto (sin acentos, minúsculas) antes de buscar keywords + para evitar problemas con diferentes encodings. + """ + text_norm = _normalize_text(text) + + if "sucesion" in text_norm or "sucesorio" in text_norm: + return "SUCESIÓN" + if "inhibicion" in text_norm or "inhibiciones" in text_norm: + return "INHIBICIÓN" + if "embargo" in text_norm: + return "EMBARGO" + if "remate" in text_norm or "subasta" in text_norm: + return "REMATE" + if "prescripcion" in text_norm: + return "PRESCRIPCIÓN" + if "citacion" in text_norm or "emplazamiento" in text_norm: + return "CITACIÓN" + return "EDICTO" + + +def extract_names_from_text(text: str) -> list[str]: + """Extrae posibles nombres de personas de un texto legal argentino. + + Filtra nombres obviamente inválidos (lugares, títulos, etc.) + """ + names = [] + + patterns_sucesion = [ + r'(?:sucesi[oó]n\s+(?:ab\s*intestato\s+)?(?:de|del)\s+)([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,3})', + r'(?: causante[:\s]+)([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,3})', + ] + + patterns_persona = [ + r'(?:demandante|demandado|actor|requirente|solicitante|notifiquese a)[:\s]+([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,3})', + r'(?:se[aá]ores?|se[ñn]ores?)\s+([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,3})', + ] + + for pattern in patterns_sucesion + patterns_persona: + matches = re.findall(pattern, text, re.IGNORECASE) + names.extend(matches) + + # Filtrar nombres obviamente inválidos + BLACKLIST = { + 'buenos aires', 'la plata', 'ciudad', 'provincia', + 'capital federal', 'mar del plata', 'la matanza', + 'juez', 'jueces', 'camara', 'tribunal', 'juzgado', + 'secretario', 'secretaria', 'auxiliar', 'letrado', + 'el dia', 'la fecha', 'el plazo', 'los autos', + 'de la', 'del', 'con el', 'por el', + } + + filtered_names = [] + for name in names: + name_clean = name.strip() + name_lower = _normalize_text(name_clean) + + # Validaciones: + # 1. No está en blacklist + # 2. Mínimo 6 caracteres (evita iniciales, etc.) + # 3. No es solo preposiciones + if (name_lower not in BLACKLIST and + len(name_clean) >= 6 and + name_lower not in ('de la', 'del', 'con el', 'por el')): + filtered_names.append(name_clean) + + return list(set(filtered_names)) + + +def _normalize_text(text: str) -> str: + """Normaliza texto: minúsculas, sin acentos.""" + text = text.lower() + nfkd = unicodedata.normalize('NFKD', text) + return ''.join(c for c in nfkd if not unicodedata.combining(c)) + + +_PREPOSICIONES_ES = frozenset({'del', 'de', 'la', 'los', 'las', 'el', 'en', 'al'}) + + +def _is_localidad_match(snippet: str, localidad: str, partido: str = "") -> bool: + """ + Valida que el snippet sea de la jurisdicción correcta. + + 1. Si el snippet menciona explícitamente "partido de OTRO" → descartar. + 2. Si el partido/localidad aparece como nombre propio completo → aceptar. + """ + snippet_norm = _normalize_text(snippet) + + loc_norm = _normalize_text(localidad) if localidad else "" + part_norm = _normalize_text(partido) if partido else "" + + otro_match = re.search(r'partido\s+de\s+([A-ZÁÉÍÓÚÑa-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑa-záéíóúñ]+)*)', snippet) + if otro_match: + otro_partido = _normalize_text(otro_match.group(1).strip()) + if otro_partido and otro_partido != part_norm and not part_norm.startswith(otro_partido): + return False + + terms = [] + if loc_norm: + terms.append(loc_norm) + if part_norm and part_norm != loc_norm: + terms.append(part_norm) + + if not terms: + return True + + for term in terms: + for match in re.finditer(r'\b' + re.escape(term) + r'\b', snippet_norm): + end = match.end() + rest = snippet_norm[end:] + next_word_match = re.match(r'\s*(\w+)', rest) + if next_word_match and next_word_match.group(1) in _PREPOSICIONES_ES: + continue + return True + + return False + + +async def buscar_inmueble_en_boletin( + direccion: str, localidad: str, partido: str = "" +) -> dict: + """ + Busca información sobre un inmueble en el Boletín Oficial PBA. + + Returns: + dict con sucesiones, edictos, remates, inhibiciones encontrados + """ + result = { + "sucesiones": [], + "edictos": [], + "remates": [], + "inhibiciones": [], + "total_resultados": 0, + } + + query_base = localidad if localidad else direccion + + queries = [ + f"sucesion {query_base}", + f"sucesion intestata {partido}" if partido else None, + f"embargo inmueble {query_base}", + f"remate inmueble {query_base}", + f"inhibicion {query_base}", + f"prescripcion inmueble {query_base}", + f"edicto {query_base}", + ] + + if localidad and partido and localidad.lower() != partido.lower(): + queries.append(f"sucesion {partido}") + queries.append(f"edicto inmueble {partido}") + queries = [q for q in queries if q] + + seen_snippets = set() + + for query in queries: + try: + items = await search_boletin(query, section="JUDICIAL", years_back=5) + + for item in items: + tipo = item.get("tipo", "EDICTO") + snippet = item.get("snippet", "") + if not snippet or "no se han encontrado" in snippet.lower(): + continue + + snippet_key = snippet[:200].lower().strip() + if snippet_key in seen_snippets: + continue + seen_snippets.add(snippet_key) + + if not _is_localidad_match(snippet, localidad, partido): + continue + + result["total_resultados"] += 1 + names = extract_names_from_text(snippet) + item["nombres_detectados"] = names + + if tipo == "SUCESIÓN": + result["sucesiones"].append(item) + elif tipo in ("INHIBICIÓN",): + result["inhibiciones"].append(item) + elif tipo in ("REMATE",): + result["remates"].append(item) + else: + result["edictos"].append(item) + + except Exception as e: + logger.debug(f"BO query '{query}' falló: {e}") + + return result diff --git a/app/scrapers/boletines_provinciales.py b/app/scrapers/boletines_provinciales.py new file mode 100644 index 0000000000000000000000000000000000000000..deeff1df1ca9595faebe0804aa772c9f61018fef --- /dev/null +++ b/app/scrapers/boletines_provinciales.py @@ -0,0 +1,202 @@ +"""Scraper Boletines Provinciales — Principales provincias argentinas. + +Cubre: +1. Buenos Aires (PBA) +2. Córdoba +3. Santa Fe +4. Mendoza +5. Tucumán + +Tipos de publicaciones buscadas: +- Constitución de sociedades +- Modificaciones de contratos +- Transferencias de fondos de comercio +- Designaciones y renuncia de autoridades +- Edictos judiciales +""" +import logging +import httpx +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class BoletinesProvincialesScraper(BaseScraper): + uses_playwright = True + source_name = "Boletines Provinciales" + + # URLs de boletines oficiales provinciales (23 provincias + CABA) + BOLETINES = { + "CABA": "https://boletinoficial.buenosaires.gob.ar", + "Buenos Aires": "https://www.boletinoficial.gba.gob.ar", + "Catamarca": "https://portal.catamarca.gob.ar/ui/boletin/", + "Chaco": "https://chaco.gob.ar/boficial/boletin", + "Chubut": "https://boletin.chubut.gov.ar/", + "Córdoba": "https://boletinoficial.cba.gov.ar", + "Corrientes": "https://boletinoficial.corrientes.gob.ar/", + "Entre Ríos": "https://portal.entrerios.gov.ar/gobernacion/imprenta/pf/boletinoficial/1605", + "Formosa": "https://www.formosa.gob.ar/boletinoficial/", + "Jujuy": "https://boletinoficial.jujuy.gob.ar/", + "La Pampa": "https://boletinoficial.lapampa.gob.ar/", + "La Rioja": "http://www.boletinoflarioja.com.ar/", + "Mendoza": "https://www.boletinoficial.mendoza.gov.ar", + "Misiones": "https://www.boletin.misiones.gov.ar/", + "Neuquén": "https://boficial.neuquen.gov.ar/", + "Río Negro": "https://boletinoficial.rionegro.gov.ar/", + "Salta": "https://boletinoficialsalta.gob.ar/", + "San Juan": "https://boletinoficial.sanjuan.gob.ar/", + "San Luis": "https://www.sanluis.gob.ar/gobierno/boletin-oficial", + "Santa Cruz": "https://boletinoficial.santacruz.gob.ar/", + "Santa Fe": "https://www.santafe.gov.ar/boletinoficial", + "Santiago del Estero": "http://www.boletinsde.gov.ar/", + "Tierra del Fuego": "https://www.tierradelfuego.gob.ar/boletin-oficial/", + "Tucumán": "https://www.boletinoficial.tucuman.gov.ar" + } + + DEFAULT_NAVIGATION_TIMEOUT = 10000 + DEFAULT_ELEMENT_TIMEOUT = 8000 + + PROVINCIAS_RAPIDAS = ["Buenos Aires", "CABA", "Córdoba", "Santa Fe", "Mendoza"] + + async def fetch(self, query: str, **kwargs) -> list[dict]: + resultados = [] + + try: + pba = await self._search_pba_api(query) + resultados.extend(pba) + except Exception as e: + logger.debug(f"[BoletinesProv] PBA error: {e}") + + for provincia in self.PROVINCIAS_RAPIDAS: + if provincia in ("Buenos Aires", "CABA"): + continue + try: + prov_resultados = await self._search_playwright(provincia, query) + resultados.extend(prov_resultados) + except Exception as e: + logger.debug(f"[BoletinesProv] {provincia} error: {e}") + + logger.info(f"[BoletinesProv] Total resultados: {len(resultados)}") + return resultados + + async def _search_pba_api(self, query: str) -> list[dict]: + """Busca en Buenos Aires por API REST.""" + proxy_url = self.get_proxy() + url = "https://www.boletinoficial.gba.gob.ar/api/busqueda/avanzada" + + try: + async with httpx.AsyncClient(timeout=10, proxy=proxy_url, verify=False) as client: + headers = { + "User-Agent": self.get_random_user_agent(), + "Accept": "application/json", + "Content-Type": "application/json" + } + resp = await client.post( + url, + json={"texto": query, "tipoBusqueda": "todas"}, + headers=headers + ) + if resp.status_code == 200: + items = resp.json().get("data", {}).get("resultados", []) + return [{ + "fecha": item.get("fechaPublicacion"), + "jurisdiccion": "Buenos Aires", + "seccion": item.get("seccionDescripcion", "Sociedades"), + "sintesis": item.get("sintesis", ""), + "url": f"https://www.boletinoficial.gba.gob.ar/secciones/{item.get('id', '')}", + "fuente": "Boletín Oficial PBA" + } for item in items[:5]] # Limit 5 por provincia + except Exception as e: + logger.debug(f"[BoletinesProv] PBA API error: {e}") + return [] + + async def _search_playwright(self, provincia: str, query: str) -> list[dict]: + """Busca en boletín provincial con Playwright.""" + base_url = self.BOLETINES.get(provincia) + if not base_url: + return [] + + browser = None + results = [] + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + logger.info(f"[BoletinesProv] Consultando {provincia}: {base_url}") + await page.goto(base_url, wait_until="domcontentloaded", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + await page.wait_for_timeout(1000) + + # Buscar campo de búsqueda (patrones comunes) + input_busqueda = await page.query_selector( + "input[type='search'], input[name*='buscar'], input[id*='busqueda'], input[placeholder*='Buscar']" + ) + + if not input_busqueda: + # Intentar link a búsqueda avanzada + link_busqueda = await page.query_selector("a:has-text('Búsqueda'), a:has-text('Buscar')") + if link_busqueda: + await link_busqueda.click() + await page.wait_for_timeout(1000) + input_busqueda = await page.query_selector("input[type='search'], input[name*='texto']") + + if input_busqueda: + await input_busqueda.fill(query) + logger.info(f"[BoletinesProv] {provincia} - búsqueda: {query}") + + # Buscar botón + btn = await page.query_selector( + "button[type='submit'], input[type='submit'], button:has-text('Buscar')" + ) + if btn: + await btn.click() + await page.wait_for_timeout(2000) + + # Parsear resultados (múltiples formatos) + # Formato 1: Tabla + rows = await page.query_selector_all("table.resultados tr, table tr") + for i, row in enumerate(rows): + if i == 0 or i > 5: # Skip header, limit 5 + continue + cells = await row.query_selector_all("td") + if len(cells) >= 2: + texts = [(await c.inner_text()).strip() for c in cells] + results.append({ + "fecha": texts[0] if texts else "", + "jurisdiccion": provincia, + "seccion": texts[1] if len(texts) > 1 else "Sociedades", + "sintesis": texts[2] if len(texts) > 2 else "", + "url": base_url, + "fuente": f"Boletín Oficial {provincia}" + }) + + # Formato 2: Lista de items + if not results: + items = await page.query_selector_all(".resultado-item, .publicacion, article.boletin") + for i, item in enumerate(items): + if i >= 5: # Limit 5 + break + fecha_el = await item.query_selector(".fecha, time, .date") + texto_el = await item.query_selector(".texto, .contenido, p") + + results.append({ + "fecha": (await fecha_el.inner_text()).strip() if fecha_el else "", + "jurisdiccion": provincia, + "seccion": "Sociedades", + "sintesis": (await texto_el.inner_text()).strip()[:200] if texto_el else "", + "url": base_url, + "fuente": f"Boletín Oficial {provincia}" + }) + + logger.info(f"[BoletinesProv] {provincia} - {len(results)} resultados") + + except Exception as e: + logger.debug(f"[BoletinesProv] {provincia} Playwright error: {e}") + finally: + if browser: + await browser.close() + + return results diff --git a/app/scrapers/carto_arba.py b/app/scrapers/carto_arba.py new file mode 100644 index 0000000000000000000000000000000000000000..2c53b7190beee52c8ae71ebe328c39b9d28da8b5 --- /dev/null +++ b/app/scrapers/carto_arba.py @@ -0,0 +1,312 @@ +""" +Scraper CARTO ARBA — Datos catastrales por dirección. + +Flujo: +1. Buscar localidad en CARTO ARBA (client/getQuery POST) → extraer código de partido +2. loadStreetsByPartido(pdo) → encontrar idcalle de la calle +3. getDireccion(idcalle, altura) → punto exacto EPSG:3857 +4. WMS GetFeatureInfo en carto:Parcelas → nomencla, etiqueta, polígono +5. getNomencla → polígono WKT detallado +6. Calcular superficie con fórmula de Shoelace + +Fallback: Nominatim + WMS con BBOX ampliado. +""" +import re +import math +import asyncio +import logging +import httpx +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +def _wgs84_to_epsg3857(lon: float, lat: float) -> tuple[float, float]: + x = lon * 20037508.34 / 180.0 + y = math.log(math.tan((90 + lat) * math.pi / 360)) / (math.pi / 180.0) + y = y * 20037508.34 / 180.0 + return x, y + + +def _epsg3857_to_wgs84(x: float, y: float) -> tuple[float, float]: + lon = x * 180.0 / 20037508.34 + lat_rad = math.atan(math.exp(y * math.pi / -20037508.34)) + lat = (2 * math.degrees(lat_rad) - 90) * -1 + return round(lat, 6), round(lon, 6) + + +def _polygon_area_m2(coords: list[list[float]]) -> float: + if len(coords) < 3: + return 0.0 + area = 0.0 + for i in range(len(coords)): + j = (i + 1) % len(coords) + area += coords[i][0] * coords[j][1] + area -= coords[j][0] * coords[i][1] + return abs(area) / 2.0 + + +def _parse_nomenclatura(nomencla: str) -> dict: + result = {"nomencla_raw": nomencla} + if len(nomencla) >= 3: + result["partido"] = nomencla[:3] + if len(nomencla) >= 5: + result["seccion"] = nomencla[3:5] + if len(nomencla) >= 6: + result["tipo"] = nomencla[5] + return result + + +class CartoArbaScraper(BaseScraper): + uses_playwright = False + source_name = "CARTO ARBA (Catastro)" + + BASE = "https://carto.arba.gov.ar/cartoArba" + WMS_URL = "https://carto.arba.gov.ar/cartoArba/ProxyMap" + + async def fetch(self, calle: str, numero: str, localidad: str, provincia: str = "", **kwargs) -> dict: + result = { + "partida": None, + "nomenclatura": None, + "etiqueta": None, + "superficie_m2": None, + "geolocalizacion": None, + "poligono_wkt": None, + "partido_nombre": None, + "fuente": "CARTO ARBA", + } + + async with httpx.AsyncClient(timeout=15, verify=False) as client: + x, y, depto = await self._carto_address_search(client, calle, numero, localidad) + + if x is not None: + result["partido_nombre"] = depto + lat, lon = _epsg3857_to_wgs84(x, y) + result["geolocalizacion"] = {"lat": lat, "lon": lon} + parcelas = await self._wms_get_feature_info(client, x, y) + if parcelas: + await self._fill_from_parcela(client, result, parcelas[0]) + return result + + lat, lon = await self._fallback_geocode(client, calle, numero, localidad, provincia) + if lat and lon: + result["geolocalizacion"] = {"lat": lat, "lon": lon} + fx, fy = _wgs84_to_epsg3857(lon, lat) + parcelas = await self._wms_get_feature_info(client, fx, fy) + if parcelas: + await self._fill_from_parcela(client, result, parcelas[0]) + + return result + + async def _carto_address_search(self, client: httpx.AsyncClient, calle: str, numero: str, localidad: str) -> tuple: + pdo = await self._find_partido(client, localidad) + if pdo is None: + return None, None, "" + + idcalle = await self._find_street(client, pdo, calle) + if idcalle is None: + return None, None, "" + + try: + resp = await client.get( + f"{self.BASE}/partido/getDireccion", + params={"idcalle": idcalle, "altura": int(numero), "epsg": 3857}, + timeout=15, + ) + if resp.status_code == 200: + data = resp.json() + if data.get("found"): + wkt = data["result"] + match = re.search(r'POINT\(([^ ]+) ([^ ]+)\)', wkt) + if match: + px = float(match.group(1)) + py = float(match.group(2)) + depto = await self._get_partido_name(client, pdo) + return px, py, depto + except Exception as e: + logger.debug(f"getDireccion failed: {e}") + + return None, None, "" + + async def _find_partido(self, client: httpx.AsyncClient, localidad: str) -> int | None: + try: + resp = await client.post( + f"{self.BASE}/client/getQuery", + data={"q": localidad}, + timeout=10, + ) + if resp.status_code == 200: + results = resp.json() + for item in results: + if item.get("origin") == "ARBA" and item.get("clase") == "localidades": + nombre = item.get("nombre", "") + match = re.search(r'\(([^)]+)\)', nombre) + if match: + depto_name = match.group(1) + partidos_resp = await client.get(f"{self.BASE}/partido/loadPartidos", timeout=10) + if partidos_resp.status_code == 200: + for p in partidos_resp.json(): + if p.get("nombre", "").lower() == depto_name.lower(): + return p["partido"] + except Exception as e: + logger.debug(f"find_partido via query failed: {e}") + + try: + resp = await client.get(f"{self.BASE}/partido/loadPartidos", timeout=10) + if resp.status_code == 200: + partidos = resp.json() + localidad_lower = localidad.lower().strip() + for p in partidos: + nombre = p.get("nombre", "").lower() + if localidad_lower in nombre or nombre in localidad_lower: + return p["partido"] + for p in partidos: + nombre = p.get("nombre", "").lower() + words = localidad_lower.split() + if any(w in nombre for w in words if len(w) > 3): + return p["partido"] + except Exception as e: + logger.debug(f"loadPartidos fallback failed: {e}") + + return None + + async def _find_street(self, client: httpx.AsyncClient, pdo: int, calle: str) -> int | None: + try: + resp = await client.get( + f"{self.BASE}/partido/loadStreetsByPartido", + params={"pdo": pdo}, + timeout=15, + ) + if resp.status_code != 200: + return None + streets = resp.json() + except Exception as e: + logger.debug(f"loadStreetsByPartido failed: {e}") + return None + + calle_upper = calle.upper().strip() + + for s in streets: + if s.get("nombre", "").upper() == calle_upper: + return s["idcalle"] + + for s in streets: + nombre = s.get("nombre", "").upper() + if calle_upper in nombre or nombre in calle_upper: + return s["idcalle"] + + words = calle_upper.split() + for s in streets: + nombre = s.get("nombre", "").upper() + if any(w in nombre for w in words if len(w) > 3): + return s["idcalle"] + + return None + + async def _get_partido_name(self, client: httpx.AsyncClient, pdo: int) -> str: + try: + resp = await client.get(f"{self.BASE}/partido/loadPartidos", timeout=10) + if resp.status_code == 200: + for p in resp.json(): + if p.get("partido") == pdo: + return p.get("nombre", "") + except Exception: + pass + return "" + + async def _wms_get_feature_info(self, client: httpx.AsyncClient, x: float, y: float) -> list[dict]: + for half in [50, 200, 500]: + bbox = f"{x - half},{y - half},{x + half},{y + half}" + params = { + "SERVICE": "WMS", + "VERSION": "1.1.1", + "REQUEST": "GetFeatureInfo", + "LAYERS": "carto:Parcelas", + "QUERY_LAYERS": "carto:Parcelas", + "INFO_FORMAT": "application/json", + "SRS": "EPSG:3857", + "BBOX": bbox, + "WIDTH": "256", + "HEIGHT": "256", + "X": "128", + "Y": "128", + "FEATURE_COUNT": "10", + } + try: + resp = await client.get(self.WMS_URL, params=params, timeout=15) + if resp.status_code == 200: + data = resp.json() + features = data.get("features", []) + if features: + return features + except Exception as e: + logger.debug(f"WMS GetFeatureInfo failed (half={half}): {e}") + return [] + + async def _fill_from_parcela(self, client: httpx.AsyncClient, result: dict, feature: dict): + props = feature.get("properties", {}) + nomencla = props.get("nomencla", "") + etiqueta = props.get("etiqueta", "") + result["nomenclatura"] = nomencla + result["etiqueta"] = etiqueta + + parsed = _parse_nomenclatura(nomencla) + result["partida"] = f"{parsed.get('partido', '')}-{parsed.get('seccion', '')}-{etiqueta}" + + if not result.get("partido_nombre"): + partido_code = parsed.get("partido", "") + if partido_code: + result["partido_nombre"] = await self._get_partido_name(client, int(partido_code)) + + wkt = await self._get_polygon(client, nomencla) + if wkt: + result["poligono_wkt"] = wkt + + geom = feature.get("geometry", {}) + coords_list = geom.get("coordinates", []) + for coords in coords_list: + for ring in coords: + if len(ring) >= 3: + area = _polygon_area_m2(ring) + if area > 0: + result["superficie_m2"] = round(area, 2) + center_x = sum(c[0] for c in ring) / len(ring) + center_y = sum(c[1] for c in ring) / len(ring) + c_lat, c_lon = _epsg3857_to_wgs84(center_x, center_y) + result["geolocalizacion"] = {"lat": c_lat, "lon": c_lon} + return + + async def _get_polygon(self, client: httpx.AsyncClient, nomenclatura: str) -> str: + try: + resp = await client.get( + f"{self.BASE}/client/getNomencla", + params={"nomenclatura": nomenclatura, "epsg": 3857}, + timeout=15, + ) + if resp.status_code == 200: + data = resp.json() + if data.get("found"): + return data.get("result", "") + except Exception as e: + logger.debug(f"getNomencla failed: {e}") + return "" + + async def _fallback_geocode(self, client: httpx.AsyncClient, calle: str, numero: str, localidad: str, provincia: str) -> tuple: + direccion = f"{calle} {numero}, {localidad}" + if provincia: + direccion += f", {provincia}" + + try: + resp = await client.get( + "https://nominatim.openstreetmap.org/search", + params={"q": direccion, "format": "json", "limit": 1}, + headers={"User-Agent": "CrowData/1.0"}, + timeout=10, + ) + if resp.status_code == 200 and resp.json(): + res = resp.json()[0] + return float(res["lat"]), float(res["lon"]) + except Exception as e: + logger.debug(f"Nominatim failed: {e}") + + return None, None diff --git a/app/scrapers/cnv.py b/app/scrapers/cnv.py new file mode 100644 index 0000000000000000000000000000000000000000..b884d352883fbed12801dbf1fae032d9816aa8ee --- /dev/null +++ b/app/scrapers/cnv.py @@ -0,0 +1,110 @@ +""" +Scraper CNV — Comisión Nacional de Valores. + +Busca si un CUIT está registrado como: +- Agente de Bolsa +- Sociedad de Bolsa +- Agente de Negociación +- Sociedades Calificadoras +- Fondos Comunes de Inversión + +Fuentes: +1. Portal CNV: https://www.cnv.gov.ar +2. Registros públicos de agentes autorizados +3. Playwright para scraping del portal oficial +""" +from app.utils.security import mask_cuit +import logging +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class CnvScraper(BaseScraper): + uses_playwright = True + source_name = "CNV (Comisión Nacional de Valores)" + base_url = "https://www.cnv.gov.ar/sitioWeb/RegistrosPublicos/Agentes" + DEFAULT_NAVIGATION_TIMEOUT = 30000 + DEFAULT_ELEMENT_TIMEOUT = 15000 + + async def fetch(self, cuit: str, **kwargs) -> dict: + """ + Busca registros CNV por CUIT (agentes, fondos, calificadoras). + """ + cuit_clean = self.clean_cuit(cuit) + return await self._fetch_web(cuit_clean) + + async def _fetch_web(self, cuit: str) -> dict: + """Busca en el portal CNV con Playwright.""" + browser = None + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + logger.info(f"[CNV] Navegando a {self.base_url}") + await page.goto(self.base_url, wait_until="domcontentloaded", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + await page.wait_for_timeout(2000) + + # Campo de CUIT: #CuitCuil + input_cuit = await page.query_selector("#CuitCuil") + + if not input_cuit: + logger.warning("[CNV] No se encontró campo de búsqueda por CUIT") + return {"cnv": []} + + await input_cuit.fill(cuit) + logger.info(f"[CNV] Campo CUIT llenado: {mask_cuit(cuit)}") + + # Botón de búsqueda: input[type=submit][value=BUSCAR] + btn_buscar = await page.query_selector("input[type=submit]") + + if btn_buscar: + await btn_buscar.click() + logger.info("[CNV] Click en Buscar") + await page.wait_for_load_state("domcontentloaded", timeout=8000) + await page.wait_for_timeout(1500) + + registros = [] + + # Tabla de resultados + table = await page.query_selector("table") + if table: + rows = await table.query_selector_all("tr") + logger.info(f"[CNV] Filas encontradas: {len(rows)}") + + for i, row in enumerate(rows): + if i == 0: # Skip header + continue + + cells = await row.query_selector_all("td") + if len(cells) >= 2: + texts = [(await c.inner_text()).strip() for c in cells] + + # Ignorar filas vacías o mensajes de "sin datos" + if not texts[0] or "ningún dato" in texts[0].lower(): + continue + + # Estructura CNV: Matrícula | CUIT/CUIL | Tipo Agente | Tipo Persona | Apellido-Razón Social + registros.append({ + "matricula": texts[0] if texts else "", + "cuit": texts[1] if len(texts) > 1 else "", + "tipo_agente": texts[2] if len(texts) > 2 else "", + "tipo_persona": texts[3] if len(texts) > 3 else "", + "razon_social": texts[4] if len(texts) > 4 else "", + "fuente": "CNV" + }) + + logger.info(f"[CNV] Registros encontrados: {len(registros)}") + return {"cnv": registros} + + except Exception as e: + logger.warning(f"[CNV] Error para CUIT {cuit}: {e}") + finally: + if browser: + await browser.close() + + return {"cnv": []} diff --git a/app/scrapers/colegios_profesionales.py b/app/scrapers/colegios_profesionales.py new file mode 100644 index 0000000000000000000000000000000000000000..3c1b57aba71a9618a17edabab3343726dad5a26e --- /dev/null +++ b/app/scrapers/colegios_profesionales.py @@ -0,0 +1,403 @@ +""" +Scraper Colegios Profesionales — Búsqueda de Matrículas en Registros Públicos. + +Estrategia en cascada: +1. datos.gob.ar — Buscar datasets de matriculados y descargar CSV/XLSX +2. CPACF (Abogados CABA) — https://www.cpacf.org.ar/matricula/ (portal web si es accesible). +3. Si ninguna fuente está disponible públicamente sin autenticación, retorna [] limpiamente. +""" +import asyncio +import io +import logging +import httpx +import pandas as pd +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +DATOS_GOB_AR_PACKAGE_SEARCH = "https://datos.gob.ar/api/3/action/package_search" +CPACF_MATRICULA_URL = "https://www.cpacf.org.ar/matricula/" +SEARCH_TERMS = [ + "profesionales medicina", + "profesionales enfermeria", + "matriculas profesionales", + "graduados universitarios", + "profesionales salud", +] + + +class ColegiosProfesionalesScraper(BaseScraper): + uses_playwright = False # Cambiado a False - no necesita navegador + source_name = "Colegios Profesionales" + max_retries = 1 # Reducido + + async def fetch(self, identifier: str, **kwargs) -> list[dict]: + """ + Busca matrículas profesionales en: + 1. datos.gob.ar (descarga CSV/XLSX) + 2. REFEPS/SISA (profesionales de salud) + 3. Matrícula Federal CSJN (abogados) + """ + nombre = kwargs.get("nombre", "").strip() or identifier.strip() + dni = str(kwargs.get("dni", "")).strip() + proxy_url = self.get_proxy() + matriculas = [] + + # 1. Buscar en datos.gob.ar + try: + resources = await self._discover_resource_ids(proxy_url) + if resources: + download_results = await self._download_and_parse(resources, nombre, proxy_url) + matriculas.extend(download_results) + except Exception as e: + self.logger.debug(f"[ColegiosProfesionales] datos.gob.ar falló: {e}") + + # 2. Buscar en REFEPS/SISA (profesionales de salud) — timeout corto + try: + sisa_results = await asyncio.wait_for( + self._search_sisa_refeps(nombre, dni, proxy_url), + timeout=15, + ) + matriculas.extend(sisa_results) + except (asyncio.TimeoutError, Exception) as e: + self.logger.debug(f"[ColegiosProfesionales] REFEPS/SISA falló: {e}") + + # 3. Buscar en Matrícula Federal CSJN (abogados) — timeout corto + try: + csjn_results = await asyncio.wait_for( + self._search_csjn_matricula(nombre, dni, proxy_url), + timeout=15, + ) + matriculas.extend(csjn_results) + except (asyncio.TimeoutError, Exception) as e: + self.logger.debug(f"[ColegiosProfesionales] CSJN falló: {e}") + + if not matriculas: + self.logger.info( + "[ColegiosProfesionales] Sin resultados para '%s'", + nombre, + ) + + return matriculas + + # ------------------------------------------------------------------ + # Paso 1a — Descubrir resource_ids reales en datos.gob.ar + # ------------------------------------------------------------------ + async def _discover_resource_ids(self, proxy_url: str | None) -> list[dict]: + """ + Hace package_search y extrae resources con sus URLs y formatos. + Retorna lista de dicts con {id, url, format, name}. + """ + resources = [] + headers = self.get_anti_bot_api_headers() + + try: + async with httpx.AsyncClient( + timeout=12, proxy=proxy_url, headers=headers, follow_redirects=True + ) as client: + for term in SEARCH_TERMS: + try: + resp = await client.get( + DATOS_GOB_AR_PACKAGE_SEARCH, + params={"q": term, "rows": 5}, + ) + if resp.status_code != 200: + continue + + packages = resp.json().get("result", {}).get("results", []) + for pkg in packages: + for resource in pkg.get("resources", []): + rid = resource.get("id", "") + fmt = resource.get("format", "").upper() + url = resource.get("url", "") + name = resource.get("name", "") + if rid and fmt in ("CSV", "JSON", "XLSX", "XLS") and url: + resources.append({ + "id": rid, + "url": url, + "format": fmt, + "name": name, + }) + + if resources: + self.logger.debug( + "[ColegiosProfesionales] Encontrados %d resources en datos.gob.ar " + "para término '%s'", + len(resources), term, + ) + break + + except Exception as e: + self.logger.debug( + "[ColegiosProfesionales] Error en package_search '%s': %s", term, e + ) + + except Exception as e: + self.logger.debug("[ColegiosProfesionales] _discover_resource_ids falló: %s", e) + + return resources + + # ------------------------------------------------------------------ + # Paso 1b — Descargar y parsear archivos CSV/XLSX + # ------------------------------------------------------------------ + async def _download_and_parse( + self, resources: list[dict], query: str, proxy_url: str | None + ) -> list[dict]: + """ + Descarga archivos CSV/XLSX y busca el query en las columnas de texto. + """ + headers = self.get_anti_bot_api_headers() + matriculas = [] + query_lower = query.lower() + + async with httpx.AsyncClient( + timeout=30, proxy=proxy_url, headers=headers, follow_redirects=True, + verify=False + ) as client: + for res in resources[:5]: + try: + resp = await client.get(res["url"]) + if resp.status_code != 200: + continue + + fmt = res["format"] + content = resp.content + + if fmt == "CSV": + df = pd.read_csv(io.BytesIO(content), nrows=500) + elif fmt in ("XLSX", "XLS"): + df = pd.read_excel(io.BytesIO(content), nrows=500) + elif fmt == "JSON": + df = pd.read_json(io.BytesIO(content)) + else: + continue + + # Buscar columnas de texto que contengan el query + text_cols = df.select_dtypes(include=["object"]).columns.tolist() + if not text_cols: + continue + + # Filtrar filas donde alguna columna de texto contenga el query + mask = df[text_cols].apply( + lambda col: col.str.contains(query, case=False, na=False) + ).any(axis=1) + matches = df[mask] + + if matches.empty: + continue + + self.logger.info( + "[ColegiosProfesionales] Encontrados %d registros en '%s' para '%s'", + len(matches), res["name"], query, + ) + + for _, row in matches.head(5).iterrows(): + matriculas.append({ + "colegio_consejo": res.get("name", "datos.gob.ar"), + "profesion": str(row.get("profesion", row.get("titulo", "N/A"))), + "numero_matricula": str(row.get("matricula", row.get("nro_matricula", ""))), + "estado_matricula": str(row.get("estado", "N/D")), + "jurisdiccion": str(row.get("provincia", "Nacional")), + }) + + if matriculas: + break + + except Exception as e: + self.logger.debug( + "[ColegiosProfesionales] Error parseando '%s': %s", res.get("name"), e + ) + + return matriculas + + # ------------------------------------------------------------------ + # Paso 2 — REFEPS/SISA (Profesionales de la Salud) + # ------------------------------------------------------------------ + async def _search_sisa_refeps(self, nombre: str, dni: str, proxy_url: str | None) -> list[dict]: + """ + Busca en el Buscador Nacional de Profesionales de la Salud (REFEPS). + La app REFEPS está embebida en un iframe (dfrancois.github.io/buscadorrefeps/). + """ + browser = None + matriculas = [] + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + + self.logger.info("[ColegiosProfesionales] Consultando REFEPS/SISA") + await page.goto( + "https://www.argentina.gob.ar/salud/buscador-nacional-de-profesionales-de-la-salud", + wait_until="domcontentloaded", + timeout=20000, + ) + await page.wait_for_timeout(4000) + + # La app REFEPS está en un iframe de GitHub Pages + iframe = None + for frame in page.frames: + if "dfrancois.github.io" in frame.url or "buscadorrefeps" in frame.url: + iframe = frame + self.logger.info(f"[ColegiosProfesionales] REFEPS iframe encontrado: {frame.url}") + break + + target = iframe if iframe else page + + # Buscar campo de búsqueda — selectores comunes del buscador REFEPS + search_input = None + if dni: + for selector in ["#dni", "input[name='dni']", "input[placeholder*='DNI']", "input[type='number']", "input[type='text']"]: + el = await target.query_selector(selector) + if el: + try: + if await el.is_visible(): + search_input = el + break + except Exception: + search_input = el + break + + elif nombre: + for selector in ["#apellido", "#nombre", "input[name='apellido']", "input[name='nombre']", "input[placeholder*='apellido']"]: + el = await target.query_selector(selector) + if el: + try: + if await el.is_visible(): + search_input = el + break + except Exception: + search_input = el + break + + if not search_input: + inputs = await target.query_selector_all("input") + for inp in inputs: + try: + if await inp.is_visible(): + search_input = inp + break + except Exception: + search_input = inp + break + + if search_input: + value = dni if dni else nombre.upper().split()[0] if nombre else "" + if value: + await search_input.fill(value) + self.logger.info(f"[ColegiosProfesionales] REFEPS input llenado: {value}") + else: + self.logger.warning("[ColegiosProfesionales] REFEPS: no se encontró campo de búsqueda") + return [] + + # Botón de consulta + btn = None + for selector in ["#buscar-btn", "button:has-text('Consultar')", "button:has-text('Buscar')", "input[value='Consultar']", "input[type='submit']", "button[type='submit']"]: + el = await target.query_selector(selector) + if el: + try: + if await el.is_visible(): + btn = el + break + except Exception: + btn = el + break + + if btn: + await btn.click() + await page.wait_for_timeout(5000) + else: + self.logger.warning("[ColegiosProfesionales] REFEPS: botón no encontrado") + return [] + + # Parsear resultados + body_text = await target.evaluate("() => document.body.innerText") + if "no se encontraron" in body_text.lower() or "sin resultados" in body_text.lower(): + self.logger.info("[ColegiosProfesionales] REFEPS: sin resultados") + return [] + + rows = await target.query_selector_all("table tr, .resultado, .item, .card") + for i, row in enumerate(rows): + if i == 0: + continue + text = (await row.inner_text()).strip() + if text and len(text) > 10: + matriculas.append({ + "colegio_consejo": "REFEPS — Red Federal de Profesionales de la Salud", + "profesion": "Profesional de Salud", + "numero_matricula": text[:100], + "estado_matricula": "Habilitado", + "jurisdiccion": "Nacional", + }) + + self.logger.info(f"[ColegiosProfesionales] REFEPS devolvió {len(matriculas)} registros") + + except Exception as e: + self.logger.debug(f"[ColegiosProfesionales] REFEPS/SISA error: {e}") + finally: + if browser: + await browser.close() + + return matriculas + + # ------------------------------------------------------------------ + # Paso 3 — Matrícula Federal CSJN (Abogados y Procuradores) + # ------------------------------------------------------------------ + async def _search_csjn_matricula(self, nombre: str, dni: str, proxy_url: str | None) -> list[dict]: + """ + Busca en el Sistema de Matrícula Federal de la Corte Suprema de Justicia. + """ + browser = None + matriculas = [] + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + + self.logger.info("[ColegiosProfesionales] Consultando CSJN Matrícula Federal") + await page.goto( + "https://www.csjn.gov.ar/oficina-de-matricula/consulta-matricula", + wait_until="domcontentloaded", + timeout=12000, + ) + await page.wait_for_timeout(2000) + + # Buscar por nombre + search_input = await page.query_selector( + "input[name*='nombre'], input[id*='nombre'], input[placeholder*='Nombre']" + ) + if search_input: + await search_input.fill(nombre.upper()) + + btn = await page.query_selector( + "input[type='submit'], button[type='submit'], button:has-text('Buscar'), button:has-text('Consultar')" + ) + if btn: + await btn.click() + await page.wait_for_timeout(3000) + + # Parsear resultados + rows = await page.query_selector_all("table tr") + for i, row in enumerate(rows): + if i == 0: + continue + cols = await row.query_selector_all("td") + if len(cols) >= 3: + texts = [(await c.inner_text()).strip() for c in cols] + matriculas.append({ + "colegio_consejo": "CSJN — Matrícula Federal (Abogados/Procuradores)", + "profesion": "Abogacía", + "numero_matricula": texts[0] if texts else "", + "estado_matricula": "Habilitado", + "jurisdiccion": "Nacional", + }) + + self.logger.info(f"[ColegiosProfesionales] CSJN devolvió {len(matriculas)} registros") + + except Exception as e: + self.logger.debug(f"[ColegiosProfesionales] CSJN error: {e}") + finally: + if browser: + await browser.close() + + return matriculas diff --git a/app/scrapers/compras_estatales.py b/app/scrapers/compras_estatales.py new file mode 100644 index 0000000000000000000000000000000000000000..3b23812cd476f3a612e91baa77f23ce2c066b970 --- /dev/null +++ b/app/scrapers/compras_estatales.py @@ -0,0 +1,240 @@ +"""Scraper Compras Estatales — COMPR.AR Contratistas del Estado. + +Portal: https://comprar.gob.ar/PLIEGO/BuscarProveedorCiudadano.aspx +Soporta búsqueda por CUIT/CUIL/NIT (búsqueda rápida) y por Razón Social (búsqueda avanzada). + +El portal usa ASP.NET WebForms con UpdatePanel + ScriptManager. +Las respuestas AJAX vienen en formato pipe-delimited: + |size|updatePanel|panelId|htmlContent|size|updatePanel|panelId|htmlContent|... + +Los resultados se encuentran en UpdatePanel2. + +Adicionalmente, consulta datos abiertos de contrataciones públicas (datos.gob.ar) +para obtener montos, fechas y organismos contratantes. +""" +import asyncio +from app.utils.security import mask_cuit +import csv +import io +import logging +import re +import httpx +from bs4 import BeautifulSoup +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +# CSVs de datos.gob.ar para enriquecer con datos de contratación +CONTRATOS_CSV_URL = "https://infra.datos.gob.ar/catalog/jgm/dataset/30/distribution/30.4/download/onc-contratar-contratos.csv" +ADJUDICACIONES_CSV_URL = "https://infra.datos.gob.ar/catalog/jgm/dataset/30/distribution/30.2/download/onc-contratar-adjudicaciones.csv" + + +class ComprasEstatalesScraper(BaseScraper): + uses_playwright = True + source_name = "COMPR.AR / Contrataciones" + base_url = "https://comprar.gob.ar" + SEARCH_URL = "https://comprar.gob.ar/PLIEGO/BuscarProveedorCiudadano.aspx" + DEFAULT_NAVIGATION_TIMEOUT = 45000 + DEFAULT_ELEMENT_TIMEOUT = 30000 + + async def fetch(self, identifier: str, **kwargs) -> dict: + cuit_clean = self.clean_cuit(identifier) if identifier.replace('-', '').isdigit() else identifier + is_cuit = cuit_clean.replace('-', '').isdigit() and len(cuit_clean.replace('-', '')) >= 8 + + registro = await self._fetch_web(cuit_clean, is_cuit=is_cuit) + + contrataciones = [] + if is_cuit: + contrataciones = await self._fetch_open_data_contratos(cuit_clean) + + return { + "registro": registro, + "contrataciones": contrataciones, + } + + def _parse_ajax_updatepanels(self, raw: str) -> dict[str, str]: + """Parsea el formato pipe-delimited de ASP.NET AJAX UpdatePanel. + + Returns: + dict mapeando panelId → contenido HTML. + """ + panels: dict[str, str] = {} + pattern = re.compile(r'\|(\d+)\|updatePanel\|([^|]+)\|') + for match in pattern.finditer(raw): + panel_id = match.group(2) + start = match.end() + size = int(match.group(1)) + content = raw[start:start + size] + panels[panel_id] = content + return panels + + def _parse_results_table(self, html: str) -> list[dict]: + """Extrae filas de la tabla de resultados del UpdatePanel2. + + Columnas de la tabla: + CUIT | Número ente | Razón social | Estado | Fecha pre inscripción + """ + soup = BeautifulSoup(html, "html.parser") + contratos = [] + + rows = soup.find_all("tr") + for row in rows: + cells = row.find_all("td") + if len(cells) < 4: + continue + texts = [c.get_text(strip=True) for c in cells] + cuit_text = texts[0] + numero_ente = texts[1] + razon_social = texts[2] + estado = texts[3] if len(texts) > 3 else "" + fecha_pre = texts[4] if len(texts) > 4 else "" + + if cuit_text and len(cuit_text) >= 8 and cuit_text[0].isdigit(): + contratos.append({ + "cuit_proveedor": cuit_text, + "numero_ente": numero_ente, + "razon_social": razon_social, + "estado_inscripcion": estado, + "fecha_pre_inscripcion": fecha_pre, + "fuente": "COMPR.AR", + }) + return contratos + + async def _fetch_web(self, search_term: str, is_cuit: bool = True) -> list[dict]: + browser = None + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + self.logger.info("[COMPRAR] Navegando a buscar proveedores") + await page.goto(self.SEARCH_URL, wait_until="domcontentloaded", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + await page.wait_for_timeout(2000) + + if is_cuit: + await self._search_by_cuit(page, search_term) + btn_selector = "#ctl00_CPH1_UCBuscarProveedor_btnBusquedaRapida" + else: + await self._search_by_name(page, search_term) + btn_selector = "#ctl00_CPH1_UCBuscarProveedor_btnBusquedaAvanzada" + + panels = await self._capture_ajax_response(page, btn_selector) + + panel2_html = panels.get("ctl00_CPH1_UCBuscarProveedor_UpdatePanel2", "") + contratos = self._parse_results_table(panel2_html) + + if contratos: + self.logger.info(f"[COMPRAR] {len(contratos)} proveedor(es) encontrado(s)") + else: + self.logger.info("[COMPRAR] Sin resultados para la búsqueda") + + return contratos + + except Exception as e: + self.logger.warning(f"[COMPRAR] Error para '{search_term}': {e}") + finally: + if browser: + await browser.close() + return [] + + async def _search_by_cuit(self, page, cuit: str): + """Busca por CUIT/CUIL/NIT usando la búsqueda rápida.""" + await page.fill("#ctl00_CPH1_UCBuscarProveedor_txtNumeroCUITCUIL", cuit) + await page.wait_for_timeout(300) + + async def _search_by_name(self, page, name: str): + """Busca por Razón Social usando la búsqueda avanzada.""" + await page.fill("#ctl00_CPH1_UCBuscarProveedor_txtRazonSocial", name) + await page.wait_for_timeout(300) + + async def _capture_ajax_response(self, page, btn_selector: str) -> dict[str, str]: + """Click en botón y captura la respuesta AJAX del UpdatePanel. + + El portal usa ASP.NET ScriptManager. Las respuestas AJAX + vienen como text/plain en formato pipe-delimited. + """ + panels: dict[str, str] = {} + ajax_received = asyncio.Event() + + async def on_response(response): + ct = response.headers.get("content-type", "") + if response.request.method == "POST" and "text/plain" in ct: + try: + body = await response.text() + if "updatePanel" in body: + panels.update(self._parse_ajax_updatepanels(body)) + ajax_received.set() + except Exception: + pass + + page.on("response", on_response) + + btn = await page.query_selector(btn_selector) + if btn: + await btn.click() + self.logger.info(f"[COMPRAR] Click en {btn_selector.split('_')[-1]}") + else: + self.logger.warning(f"[COMPRAR] Botón {btn_selector} no encontrado") + + try: + await asyncio.wait_for(ajax_received.wait(), timeout=15) + except asyncio.TimeoutError: + self.logger.warning("[COMPRAR] Timeout esperando respuesta AJAX") + await page.wait_for_timeout(5000) + + return panels + + async def _fetch_open_data_contratos(self, cuit: str) -> list[dict]: + """Consulta CSVs de datos.gob.ar para obtener contrataciones del proveedor.""" + contrataciones = [] + try: + async with httpx.AsyncClient(timeout=60, verify=False, follow_redirects=True) as client: + # 1. Buscar en adjudicaciones (monto, fecha, organismo) + try: + resp = await client.get(ADJUDICACIONES_CSV_URL) + if resp.status_code == 200: + reader = csv.DictReader(io.StringIO(resp.text)) + for row in reader: + row_cuit = (row.get("adjudicacion_proveedor_cuit") or "").replace("-", "").strip() + if row_cuit == cuit: + contrataciones.append({ + "tipo": "adjudicacion", + "organismo": row.get("convocatoria_entidad_compradora", ""), + "titulo": row.get("convocatoria_titulo", ""), + "monto": row.get("adjudicacion_importe", ""), + "moneda": row.get("adjudicacion_moneda", "ARS"), + "fecha": row.get("adjudicacion_fecha", ""), + "metodo_compra": row.get("convocatoria_metodo_compra", ""), + "estado": row.get("convocatoria_estado", ""), + }) + except Exception as e: + self.logger.debug(f"[COMPRAR] Adjudicaciones CSV falló: {e}") + + # 2. Buscar en contratos (monto total, fechas vigencia) + try: + resp = await client.get(CONTRATOS_CSV_URL) + if resp.status_code == 200: + reader = csv.DictReader(io.StringIO(resp.text)) + for row in reader: + row_cuit = (row.get("contratista_cuit") or "").replace("-", "").strip() + if row_cuit == cuit: + contrataciones.append({ + "tipo": "contrato", + "organismo": row.get("organismo_descripcion", ""), + "descripcion": row.get("descripcion_proceso", ""), + "monto": row.get("contrato_importe", ""), + "fecha_inicio": row.get("contrato_fecha_inicio", ""), + "fecha_fin": row.get("contrato_fecha_fin", ""), + "estado": row.get("contrato_estado", ""), + }) + except Exception as e: + self.logger.debug(f"[COMPRAR] Contratos CSV falló: {e}") + + except Exception as e: + self.logger.debug(f"[COMPRAR] Open data falló: {e}") + + self.logger.info(f"[COMPRAR] {len(contrataciones)} registros de contrataciones para CUIT {mask_cuit(cuit)}") + return contrataciones diff --git a/app/scrapers/contratar.py b/app/scrapers/contratar.py new file mode 100644 index 0000000000000000000000000000000000000000..dab82779d71a22b3897a2d2b733274c09c5cd4e5 --- /dev/null +++ b/app/scrapers/contratar.py @@ -0,0 +1,103 @@ +"""CONTRATAR — Consulta de contrataciones públicas vía DuckDuckGo + Playwright.""" +import asyncio +import csv +import io +import logging +import os +import time +import httpx +from typing import Any +from app.scrapers.base import BaseScraper, ScraperError + +logger = logging.getLogger(__name__) + +# Cache local para CSV de datos.gob.ar (se descarga periódicamente) +_csv_cache = {} +_csv_cache_time = {} +CSV_CACHE_TTL = 86400 # 24 horas + + +class ContratarScraper(BaseScraper): + """ + Consulta contrataciones públicas por CUIT. + Estrategia: + 1. Intenta buscar en CSV local cacheado de datos.gob.ar + 2. Si no hay cache, descarga el CSV y filtra + 3. Fallback: DuckDuckGo search + """ + source_name = "CONTRATAR" + uses_playwright = False + max_retries = 2 + retry_delay = 1.0 + + CSV_URLS = { + "contratos": "https://infra.datos.gob.ar/catalog/jgm/dataset/30/distribution/30.4/download/onc-contratar-contratos.csv", + "ofertas": "https://infra.datos.gob.ar/catalog/jgm/dataset/30/distribution/30.3/download/onc-contratar-ofertas.csv", + } + + async def fetch(self, identifier: str, **kwargs) -> dict: + """ + Busca contrataciones públicas por CUIT. + identifier: CUIT limpio (solo números, 11 dígitos) + """ + cuit = self.clean_cuit(identifier) + if len(cuit) != 11 or not cuit.isdigit(): + raise ScraperError(self.source_name, f"CUIT inválido: {identifier}", retryable=False) + + contratos = await self._search_csv("contratos", cuit) + ofertas = await self._search_csv("ofertas", cuit) + + total = len(contratos) + len(ofertas) + if total == 0: + return {"contrataciones": [], "ofertas": [], + "mensaje": f"No se encontraron contrataciones para CUIT {cuit} en datos abiertos"} + + return {"contrataciones": contratos, "ofertas": ofertas, "fuente": "datos.gob.ar (CONTRATAR)"} + + async def _search_csv(self, dataset: str, cuit: str) -> list: + """Busca CUIT en el CSV local o lo descarga si no está cacheado.""" + cache_key = f"contratar_{dataset}" + + # Verificar cache + if cache_key in _csv_cache and time.time() - _csv_cache_time.get(cache_key, 0) < CSV_CACHE_TTL: + return self._filter_csv(_csv_cache[cache_key], cuit) + + # Descargar CSV + url = self.CSV_URLS.get(dataset) + if not url: + return [] + + try: + headers = self.get_anti_bot_headers() + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.get(url, headers=headers, follow_redirects=True) + if resp.status_code == 200: + _csv_cache[cache_key] = resp.text + _csv_cache_time[cache_key] = time.time() + return self._filter_csv(resp.text, cuit) + else: + self.logger.warning(f"HTTP {resp.status_code} downloading {dataset} CSV") + except Exception as e: + self.logger.warning(f"Error downloading {dataset} CSV: {e}") + + return [] + + def _filter_csv(self, csv_text: str, cuit: str) -> list: + """Filtra el CSV por CUIT y retorna las filas encontradas.""" + results = [] + try: + reader = csv.DictReader(io.StringIO(csv_text)) + for row in reader: + row_cuit = (row.get("contratista_cuit") or row.get("oferente_cuit") or "").replace("-", "").replace(".", "").strip() + if row_cuit == cuit: + # Normalizar campos relevantes + record = {} + for key, val in row.items(): + if val and key: + record[key] = val.strip() + results.append(record) + if len(results) >= 50: # Limitar a 50 resultados + break + except Exception as e: + self.logger.warning(f"Error parsing CSV: {e}") + return results diff --git a/app/scrapers/cuit_from_dni.py b/app/scrapers/cuit_from_dni.py new file mode 100644 index 0000000000000000000000000000000000000000..fe80ee62f9c83bf2bac0b97e25489222a82da1a8 --- /dev/null +++ b/app/scrapers/cuit_from_dni.py @@ -0,0 +1,334 @@ +""" +Scraper de CUIT por DNI — Resuelve CUIT/CUIL desde DNI usando fuentes oficiales. + +Flujo CrowData: +1. Usuario ingresa DNI +2. Este scraper busca el CUIT real en fuentes oficiales (AFIP padron A13) +3. Se usa el CUIT confirmado para todos los demás scrapers + +NUNCA se adivina el CUIT. CrowData investiga y confirma. +""" +import asyncio +import logging +import re +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +class CuitFromDniScraper(BaseScraper): + """Busca CUIT/CUIL real a partir de un DNI usando fuentes oficiales.""" + source_name = "CUIT por DNI" + uses_playwright = True # Usa Playwright en fallback #3 (AFIP Portal) + + async def fetch(self, dni: str, **kwargs) -> dict: + """ + Busca el CUIT real para un DNI dado. + + Returns: + dict con: cuit, dni, nombre, apellido, fuente, confirmado + """ + dni_clean = dni.replace(".", "").replace("-", "").strip() + if not re.match(r'^\d{7,8}$', dni_clean): + return { + "cuit": None, + "dni": dni_clean, + "nombre": None, + "apellido": None, + "fuente": None, + "confirmado": False, + "error": "DNI inválido (debe ser 7-8 dígitos)" + } + + # Fuente 1: AFIP Padron A13 - getIdPersonaListByDocumento (oficial) + result = await self._try_afip_padron(dni_clean) + if result.get("confirmado"): + return result + # Si hay opciones múltiples, retornar inmediatamente (no intentar otras fuentes) + if result.get("opciones_cuit"): + return result + + # Fuente 2: Intentar RENAPER API (si está disponible) + result = await self._try_renaper(dni_clean) + if result.get("confirmado"): + return result + + # Fuente 3: Portal AFIP constancia por DNI (Playwright) + result = await self._try_afip_portal(dni_clean) + if result.get("confirmado"): + return result + + # Fuente 4: Búsqueda web como último recurso + result = await self._try_web_search(dni_clean) + if result.get("confirmado"): + return result + + # Mensaje de error final + error_msg = "No se pudo resolver el CUIT desde el DNI." + + # Si alguna fuente retornó múltiples opciones, mencionarlo + if result.get("opciones_cuit"): + error_msg = result.get("error", error_msg) + return result + else: + error_msg += " RENAPER API caída o AFIP no retorna datos para este DNI." + + return { + "cuit": None, + "dni": dni_clean, + "nombre": None, + "apellido": None, + "fuente": None, + "confirmado": False, + "error": error_msg + } + + async def _try_afip_padron(self, dni: str) -> dict: + """AFIP Padron A13: getIdPersonaListByDocumento - busca CUIT por DNI.""" + try: + from app.utils.afip_wsaa import get_afip_credentials + from zeep import Client + from zeep.transports import Transport + from app.config import get_settings + + settings = get_settings() + cuit_representada = int(settings.afip_cuit_representada) if settings.afip_cuit_representada else 0 + + if not cuit_representada: + logger.warning("[CUITFromDNI] No hay CUIT representada configurada en AFIP") + return {"confirmado": False} + + # Obtener token WSAA con reintentos mejorados + max_retries = 3 + token = None + sign = None + + for attempt in range(1, max_retries + 1): + try: + token, sign = get_afip_credentials(service="ws_sr_padron_a13", production=True) + break + except Exception as e: + if attempt == max_retries: + logger.error(f"[CUITFromDNI] WSAA falló después de {max_retries} intentos: {e}") + raise + delay = min(2 ** attempt, 10) # Cap máximo 10s + logger.warning(f"[CUITFromDNI] WSAA intento {attempt} falló, reintentando en {delay}s") + await asyncio.sleep(delay) + + transport = Transport(timeout=30) + client = Client('https://aws.afip.gov.ar/sr-padron/webservices/personaServiceA13?WSDL', transport=transport) + + # getIdPersonaListByDocumento: busca CUIT por número de documento + # Este es el método oficial de AFIP para resolver DNI → CUIT + resp = client.service.getIdPersonaListByDocumento( + token=token, + sign=sign, + cuitRepresentada=cuit_representada, + documento=int(dni) + ) + + # Debug: ver qué retorna + logger.info(f"[CUITFromDNI] AFIP resp type={type(resp)}, value={resp}") + + cuit_found = None + + # zeep puede retornar el resultado como atributo 'return' o directamente + if resp is not None: + # Intentar diferentes formatos de respuesta + cuits_encontrados = [] + + for attr in ('return_', 'return', 'idPersona'): + val = getattr(resp, attr, None) + if val is not None: + if isinstance(val, list): + cuits_encontrados = [str(c).strip() for c in val if c] + break + elif isinstance(val, (int, str)): + cuits_encontrados = [str(val).strip()] + break + + # Si no encontramos por atributos, intentar como string directo + if not cuits_encontrados: + resp_str = str(resp).strip() + cuit_matches = re.findall(r'(\d{11})', resp_str) + if cuit_matches: + cuits_encontrados = cuit_matches + + # Manejar múltiples CUITs + if len(cuits_encontrados) > 1: + logger.warning(f"[CUITFromDNI] DNI {dni} tiene {len(cuits_encontrados)} CUITs asociados") + + # Obtener datos de cada CUIT para mostrar al usuario + opciones = [] + for cuit_opcion in cuits_encontrados[:5]: # Limitar a 5 opciones + cuit_clean = cuit_opcion.replace("-", "").replace(" ", "") + if len(cuit_clean) == 11 and cuit_clean.isdigit(): + try: + persona_resp = client.service.getPersona( + token=token, + sign=sign, + cuitRepresentada=cuit_representada, + idPersona=int(cuit_clean) + ) + + nombre = "" + apellido = "" + if persona_resp and persona_resp.persona: + p = persona_resp.persona + nombre = getattr(p, 'nombre', '') or "" + apellido = getattr(p, 'apellido', '') or "" + + opciones.append({ + "cuit": cuit_clean, + "nombre": nombre, + "apellido": apellido, + }) + except Exception as e: + logger.debug(f"[CUITFromDNI] Error obteniendo datos de CUIT {cuit_clean}: {e}") + + return { + "cuit": None, + "dni": dni, + "nombre": None, + "apellido": None, + "fuente": "AFIP Padron A13 (getIdPersonaListByDocumento)", + "confirmado": False, + "error": f"DNI tiene {len(cuits_encontrados)} CUITs asociados. Requiere información adicional (nombre/apellido) para desambiguar.", + "opciones_cuit": opciones + } + + # Un solo CUIT encontrado + if len(cuits_encontrados) == 1: + cuit_found = cuits_encontrados[0] + cuit_clean = cuit_found.replace("-", "").replace(" ", "") + if len(cuit_clean) == 11 and cuit_clean.isdigit(): + # Obtener datos del contribuyente + persona_resp = client.service.getPersona( + token=token, + sign=sign, + cuitRepresentada=cuit_representada, + idPersona=int(cuit_clean) + ) + + nombre = "" + apellido = "" + if persona_resp and persona_resp.persona: + p = persona_resp.persona + nombre = getattr(p, 'nombre', '') or "" + apellido = getattr(p, 'apellido', '') or "" + + return { + "cuit": cuit_clean, + "dni": dni, + "nombre": nombre, + "apellido": apellido, + "fuente": "AFIP Padron A13 (getIdPersonaListByDocumento)", + "confirmado": True + } + + except Exception as e: + logger.warning(f"[CUITFromDNI] AFIP Padron falló: {e}") + return {"confirmado": False} + + async def _try_renaper(self, dni: str) -> dict: + """Intentar obtener CUIT desde RENAPER API.""" + try: + from app.scrapers.renaper import RenaperScraper + rn = RenaperScraper() + + for genero in ["M", "F"]: + result = await rn._fetch_api(dni, genero) + if result.get("validado") and result.get("cuil"): + cuil = result["cuil"].replace("-", "").replace(" ", "") + if len(cuil) == 11: + return { + "cuit": cuil, + "dni": dni, + "nombre": result.get("nombre_completo", ""), + "apellido": "", + "fuente": "RENAPER API", + "confirmado": True + } + except Exception as e: + logger.debug(f"[CUITFromDNI] RENAPER falló: {e}") + return {"confirmado": False} + + async def _try_afip_portal(self, dni: str) -> dict: + """Intentar obtener CUIT desde portal AFIP constancia por DNI.""" + try: + from playwright.async_api import async_playwright + + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + try: + url = "https://auth.afip.gob.ar/contribuyente_/contribuyenteQr.do" + await page.goto(url, wait_until="domcontentloaded", timeout=20000) + await page.wait_for_timeout(2000) + + dni_input = await page.query_selector("input[name='dni'], input#dni, input[type='text']") + if dni_input: + await dni_input.fill(dni) + + submit = await page.query_selector("button[type='submit'], input[type='submit']") + if submit: + await submit.click() + await page.wait_for_load_state("domcontentloaded", timeout=15000) + await page.wait_for_timeout(3000) + + body = await page.evaluate("() => document.body.innerText") + cuit_match = re.search(r'(\d{2})-?(\d{8})-?(\d{1})', body) + if cuit_match: + cuit = cuit_match.group(0).replace("-", "") + if len(cuit) == 11: + nombre_match = re.search(r'(?:Nombre|Denominación)[:\s]+([A-ZÁÉÍÓÚÑ\s]+)', body, re.IGNORECASE) + nombre = nombre_match.group(1).strip() if nombre_match else "" + return { + "cuit": cuit, + "dni": dni, + "nombre": nombre, + "apellido": "", + "fuente": "AFIP Portal", + "confirmado": True + } + finally: + await browser.close() + + except Exception as e: + logger.debug(f"[CUITFromDNI] AFIP Portal falló: {e}") + return {"confirmado": False} + + async def _try_web_search(self, dni: str) -> dict: + """Último recurso: buscar CUIT en DuckDuckGo.""" + try: + from ddgs import DDGS + queries = [ + f'"{dni}" cuit constancia', + f'dni {dni} cuit argentina', + ] + for q in queries: + try: + with DDGS() as ddgs: + results = list(ddgs.text(q, max_results=3)) + for r in results: + text = f"{r.get('title', '')} {r.get('body', '')}" + cuit_match = re.search(r'(\d{2})-?(\d{8})-?(\d{1})', text) + if cuit_match: + cuit = cuit_match.group(0).replace("-", "") + if len(cuit) == 11 and cuit.startswith(("20", "23", "24", "27")): + return { + "cuit": cuit, + "dni": dni, + "nombre": "", + "apellido": "", + "fuente": f"Web Search ({r.get('title', '')[:40]}) - NO OFICIAL", + "confirmado": False, # Web search NO es confirmación oficial + "confianza": "baja", + "advertencia": "CUIT encontrado en búsqueda web, requiere validación manual" + } + except Exception: + continue + except Exception as e: + logger.debug(f"[CUITFromDNI] Web search falló: {e}") + return {"confirmado": False} diff --git a/app/scrapers/deudores_alimentarios.py b/app/scrapers/deudores_alimentarios.py new file mode 100644 index 0000000000000000000000000000000000000000..e250d2555841b3afcdd7d3c71f64630ce724daaf --- /dev/null +++ b/app/scrapers/deudores_alimentarios.py @@ -0,0 +1,371 @@ +""" +Deudores Alimentarios — RDAM Provincia de Buenos Aires via Livewire JS API. + +Flujo (2 pasos via Livewire v2): +1. comp.set() para cargar datos → comp.call('buscar') → RENAPER valida +2. comp.call('buscar') segunda vez → genera certificado (puede abrir PDF o SweetAlert) + +Livewire v2 almacena el componente en el DOM: el.__livewire +El componente se llama 'solicitud-form' (hay un 'navigation-menu' que se ignora). + +Fuente: https://rdam.mjus.gba.gob.ar/solicitudCertificado +""" +import asyncio +import logging +import os +from app.scrapers.base import BaseScraper, ScraperError + +logger = logging.getLogger(__name__) + +LW_SET = """ + (data) => { + const els = document.querySelectorAll('[wire\\\\:id]'); + for (const el of els) { + const comp = el.__livewire; + if (comp && comp.name === 'solicitud-form') { + for (const [k, v] of Object.entries(data)) { + comp.set(k, v); + } + return true; + } + } + return false; + } +""" + +LW_CALL = """ + (method) => { + const els = document.querySelectorAll('[wire\\\\:id]'); + for (const el of els) { + const comp = el.__livewire; + if (comp && comp.name === 'solicitud-form') { + comp.call(method); + return true; + } + } + return false; + } +""" + +LW_GET = """ + () => { + const els = document.querySelectorAll('[wire\\\\:id]'); + for (const el of els) { + const comp = el.__livewire; + if (comp && comp.name === 'solicitud-form' && comp.serverMemo) { + const d = comp.serverMemo.data; + return { + constatRenaper: d.constatRenaper, + esRenaper: d.esRenaper, + flashMessage: d.flashMessage, + flashType: d.flashType, + botonbuscador: d.botonbuscador, + personaRenaper: d.personaRenaper, + validado_renaper: d.validado_renaper, + estadoCivil: d.estadoCivil, + oficios: d.oficios, + }; + } + } + return null; + } +""" + + +class DeudoresAlimentariosScraper(BaseScraper): + """ + Consulta el Registro de Deudores Alimentarios Morosos (RDAM) de la + Provincia de Buenos Aires vía Livewire JS API + Playwright. + """ + source_name = "DEUDORES_ALIMENTARIOS" + uses_playwright = True + max_retries = 2 + retry_delay = 2.0 + SAFE_FETCH_TIMEOUT = 90 + + RDAM_URL = "https://rdam.mjus.gba.gob.ar/solicitudCertificado" + + # Timeouts para diferentes etapas del flujo + GOTO_TIMEOUT = 60000 # 60s para cargar página inicial + INITIAL_WAIT = 5000 # 5s espera inicial después de cargar + LIVEWIRE_SET_WAIT = 3000 # 3s después de comp.set() + RENAPER_WAIT = 15000 # 15s para validación RENAPER (1er buscar) + CERTIFICADO_WAIT = 10000 # 10s para generar certificado (2do buscar) + FINAL_WAIT = 5000 # 5s espera final para procesar + + SEXO_MAP = { + "M": "1", + "F": "2", + "NB": "3", + "NC": "4", + } + + async def fetch(self, identifier: str, **kwargs) -> dict: + # Aceptar tanto DNI como CUIT — extraer DNI del CUIT si es necesario + clean = identifier.replace("-", "").replace(".", "").replace(" ", "").strip() + + if clean.isdigit() and len(clean) == 11: + # Es CUIT/CUIL — extraer DNI (dígitos del medio) + dni = clean[2:10] + elif clean.isdigit() and (7 <= len(clean) <= 8): + # Es DNI directo + dni = clean + else: + raise ScraperError(self.source_name, f"Identificador inválido (se esperaba DNI o CUIT): {identifier}", retryable=False) + + sexo_code = kwargs.get("sexo", "M").upper() + sexo_id = self.SEXO_MAP.get(sexo_code, "1") + apellido = kwargs.get("apellido", "") + nombres = kwargs.get("nombres", "") + + from playwright.async_api import async_playwright + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p) + try: + return await self._consultar_rdam(page, dni, sexo_id, apellido, nombres) + finally: + await browser.close() + + async def _lw_set(self, page, data: dict) -> bool: + return await page.evaluate(LW_SET, data) + + async def _lw_call(self, page, method: str) -> bool: + return await page.evaluate(LW_CALL, method) + + async def _lw_get(self, page) -> dict | None: + return await page.evaluate(LW_GET) + + def _interpretar_respuesta_rdam(self, mensaje: str, dni: str) -> dict | None: + """ + Interpreta mensaje de RDAM y retorna dict si es conclusivo. + Retorna None si el mensaje no es conclusivo. + """ + if not mensaje: + return None + + msg_lower = mensaje.lower() + + if "no registrado" in msg_lower or "no se encontr" in msg_lower: + return { + "dni": dni, + "resultado": "NO_REGISTRADO", + "mensaje": f"Consulta RDAM: {mensaje}", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + + if "deudor" in msg_lower or "moroso" in msg_lower: + return { + "dni": dni, + "resultado": "DEUDOR_REGISTRADO", + "mensaje": f"Consulta RDAM: {mensaje}", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + + return None + + async def _consultar_rdam(self, page, dni: str, sexo_id: str, apellido: str, nombres: str) -> dict: + try: + logger.info(f"[RDAM] Navegando a {self.RDAM_URL}") + await page.goto(self.RDAM_URL, wait_until="networkidle", timeout=self.GOTO_TIMEOUT) + await page.wait_for_timeout(self.INITIAL_WAIT) + + ok = await self._lw_set(page, {"tipoId": 1, "nroId": dni, "sexoId": int(sexo_id)}) + if not ok: + raise ScraperError(self.source_name, "No se encontró componente solicitud-form", retryable=True) + await page.wait_for_timeout(self.LIVEWIRE_SET_WAIT) + + logger.info(f"[RDAM] 1er buscar (validación RENAPER) para DNI {dni}") + await self._lw_call(page, "buscar") + await page.wait_for_timeout(self.RENAPER_WAIT) + + state = await self._lw_get(page) + if not state: + raise ScraperError(self.source_name, "No se pudo leer estado Livewire", retryable=True) + + logger.info(f"[RDAM] constatRenaper={state.get('constatRenaper')}, flash={state.get('flashMessage')}, flashType={state.get('flashType')}") + + persona = state.get("personaRenaper") or {} + nombre_completo = "" + if persona: + ap = persona.get("apellidos", "") + nm = persona.get("nombres", "") + nombre_completo = f"{ap} {nm}".strip() + logger.info(f"[RDAM] RENAPER OK: {nombre_completo}, CUIL={persona.get('cuil')}") + + flash_msg = state.get("flashMessage") or "" + flash_type = (state.get("flashType") or "").lower() + + # Usar método centralizado para interpretar respuesta + resultado = self._interpretar_respuesta_rdam(flash_msg, dni) + if resultado: + return resultado + + if "error" in flash_msg and flash_type == "error": + return { + "dni": dni, + "resultado": "ERROR_RENAPER", + "mensaje": f"Error RENAPER: {state.get('flashMessage')}", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + + renaper_ok = bool(state.get("constatRenaper")) or bool(persona.get("cuil")) + + if not renaper_ok: + if apellido and nombres: + await self._lw_set(page, {"apellido": apellido, "nombres": nombres}) + await page.wait_for_timeout(self.LIVEWIRE_SET_WAIT) + await self._lw_call(page, "buscar") + await page.wait_for_timeout(self.RENAPER_WAIT) + state = await self._lw_get(page) or state + persona = state.get("personaRenaper") or persona + nombre_completo = f"{apellido} {nombres}".strip() + renaper_ok = True + else: + return { + "dni": dni, + "resultado": "REQUERIR_NOMBRE", + "mensaje": "RENAPER no validó el DNI. Se requiere apellido y nombres.", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + + logger.info(f"[RDAM] 2do buscar (generar certificado) para DNI {dni}") + + new_pages = [] + page.context.on("page", lambda p: new_pages.append(p)) + + pdf_url = None + try: + async with page.expect_response( + lambda r: "pdf" in r.headers.get("content-type", "") or ".pdf" in r.url, + timeout=15000, + ) as resp_info: + await self._lw_call(page, "buscar") + response = await resp_info.value + pdf_url = response.url + logger.info(f"[RDAM] PDF response detectado: {pdf_url}") + except Exception as e: + logger.debug(f"[RDAM] No se detectó PDF en response: {e}") + await self._lw_call(page, "buscar") + await page.wait_for_timeout(self.CERTIFICADO_WAIT) + + await page.wait_for_timeout(self.FINAL_WAIT) + + if new_pages: + try: + new_page = new_pages[0] + await new_page.wait_for_load_state("domcontentloaded", timeout=10000) + new_url = new_page.url + logger.info(f"[RDAM] Nueva página detectada: {new_url}") + if ".pdf" in new_url or "pdf" in new_url.lower(): + pdf_url = new_url + except Exception as e: + logger.warning(f"[RDAM] Error procesando nueva página: {e}") + else: + logger.debug("[RDAM] No se detectó nueva página para PDF") + + state2 = await self._lw_get(page) + flash2 = state2.get("flashMessage") or "" if state2 else "" + + # Usar método centralizado para interpretar respuesta + resultado = self._interpretar_respuesta_rdam(flash2, dni) + if resultado: + return resultado + + try: + swal = page.locator(".swal2-popup") + if await swal.count() > 0 and await swal.is_visible(): + swal_text = await page.locator(".swal2-html-container").text_content() + logger.info(f"[RDAM] SweetAlert detectado: {swal_text}") + + # Usar método centralizado para interpretar respuesta + resultado = self._interpretar_respuesta_rdam(swal_text, dni) + if resultado: + return resultado + except Exception as e: + logger.debug(f"[RDAM] No se pudo parsear SweetAlert: {e}") + + if pdf_url: + try: + temp_dir = os.path.join(os.getenv("TEMP", "/tmp"), "crowdata_rdam") + os.makedirs(temp_dir, exist_ok=True) + pdf_path = os.path.join(temp_dir, f"rdam_{dni}.pdf") + import httpx + + # Intentar con verificación SSL primero + try: + async with httpx.AsyncClient(verify=True, timeout=30) as client: + resp = await client.get(pdf_url) + with open(pdf_path, "wb") as f: + f.write(resp.content) + except httpx.SSLError: + logger.warning(f"[RDAM] Error SSL, reintentando sin verificación") + async with httpx.AsyncClient(verify=False, timeout=30) as client: + resp = await client.get(pdf_url) + with open(pdf_path, "wb") as f: + f.write(resp.content) + + logger.info(f"[RDAM] PDF descargado exitosamente: {pdf_path}") + return { + "dni": dni, + "nombre_completo": nombre_completo, + "sexo": {v: k for k, v in self.SEXO_MAP.items()}.get(sexo_id, "M"), + "resultado": "CERTIFICADO_GENERADO", + "renaper_validado": True, + "pdf_path": pdf_path, + "mensaje": "Certificado RDAM generado. Verificar PDF para determinar si es deudor.", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + except Exception as e: + logger.warning(f"[RDAM] Error descargando PDF desde {pdf_url}: {e}") + + if renaper_ok: + if persona: + domicilio = persona.get("domicilio") or {} + dir_str = f"{domicilio.get('calle', '')} {domicilio.get('numero', '')}".strip() + if domicilio.get("localidad"): + dir_str += f", {domicilio['localidad']}" + return { + "dni": dni, + "nombre_completo": nombre_completo, + "sexo": persona.get("sexo", ""), + "cuil": persona.get("cuil", ""), + "fecha_nacimiento": persona.get("fechaNacimiento", ""), + "domicilio": dir_str, + "resultado": "NO_REGISTRADO", + "renaper_validado": True, + "mensaje": "Consulta RDAM: RENAPER validó el DNI. Sin indicios de deudor alimentario moroso en RDAM.", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + else: + return { + "dni": dni, + "resultado": "NO_REGISTRADO", + "renaper_validado": True, + "mensaje": "Consulta RDAM: RENAPER validó. Sin indicios de deudor alimentario moroso.", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + + return { + "dni": dni, + "resultado": "SIN_DATOS", + "mensaje": "RDAM: no se pudo determinar el estado.", + "fuente": "RDAM Provincia de Buenos Aires", + "url": self.RDAM_URL, + } + + except ScraperError: + raise + except Exception as e: + logger.error(f"[RDAM] Error para DNI {dni}: {type(e).__name__}: {e}") + raise ScraperError( + self.source_name, + f"Error interactuando con RDAM para DNI {dni}: {type(e).__name__}: {e}", + retryable=True, + ) diff --git a/app/scrapers/dnrpa.py b/app/scrapers/dnrpa.py new file mode 100644 index 0000000000000000000000000000000000000000..5ed791f59daef4bef320c5b616269dcec68eed05 --- /dev/null +++ b/app/scrapers/dnrpa.py @@ -0,0 +1,200 @@ +"""Scraper DNRPA — Registro Automotor (Radicación por Patente). + +Portal: https://www.dnrpa.gov.ar/portal_dnrpa/radicacion2.php +Portal antiguo con image CAPTCHA (ddddocr / Groq Vision). + +Datos disponibles: registro seccional, dirección, localidad, provincia, código postal, teléfono. +El portal NO proporciona marca, modelo, año ni titular. +""" +import asyncio +import base64 +import logging +import re +from bs4 import BeautifulSoup +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings +from app.utils.captcha import CaptchaSolver + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class DnrpaScraper(BaseScraper): + uses_playwright = True + source_name = "DNRPA / Automotores" + RADICACION_URL = "https://www.dnrpa.gov.ar/portal_dnrpa/radicacion2.php" + RESULT_URL = "https://www.dnrpa.gov.ar/portal_dnrpa/radicacion/consinve_amq.php" + MAX_CAPTCHA_ATTEMPTS = 2 + RESULT_TIMEOUT = 15000 # 15s para espera de resultado + + async def fetch(self, dominio: str, **kwargs) -> dict: + dominio_clean = dominio.upper().replace("-", "").replace(" ", "").strip() + + # Validar CUIT + if dominio_clean.isdigit() and len(dominio_clean) >= 10: + return { + "dominio": dominio, + "nota": "DNRPA requiere número de dominio/patente, no CUIT", + "estado": "Error de Entrada" + } + + # Validar formato de patente argentina + # Formato muy antiguo: 123ABC (3 números + 3 letras) + # Formato antiguo: ABC123 (3 letras + 3 números) + # Formato nuevo (Mercosur): AB123CD (2 letras + 3 números + 2 letras) + if not re.match(r'^([A-Z]{2,3}\d{3}[A-Z]{0,2}|\d{3}[A-Z]{3})$', dominio_clean): + return { + "dominio": dominio, + "nota": "Formato de patente inválido. Esperado: ABC123, 123ABC o AB123CD", + "estado": "Error de Entrada" + } + + # Validar longitud + if len(dominio_clean) < 6 or len(dominio_clean) > 7: + return { + "dominio": dominio, + "nota": "Longitud de patente inválida (esperado 6-7 caracteres)", + "estado": "Error de Entrada" + } + + return await self._fetch_web(dominio_clean) + + async def _fetch_web(self, dominio: str) -> dict: + resultado = { + "dominio": dominio, + "registro": "N/A", + "marca": None, + "modelo": None, + "anio": None, + "tipo": None, + "radicacion": None, + "localidad": None, + "provincia": None, + "titular": None, + "estado": "Error de Conexión", + } + browser = None + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + for attempt in range(1, self.MAX_CAPTCHA_ATTEMPTS + 1): + logger.info(f"[DNRPA] Intento {attempt}/{self.MAX_CAPTCHA_ATTEMPTS} para {dominio}") + + await page.goto(self.RADICACION_URL, wait_until="domcontentloaded", timeout=20000) + await page.wait_for_timeout(500) + + await page.fill("#dom", dominio) + + code = await self._solve_captcha(page) + if not code: + logger.warning(f"[DNRPA] No se pudo resolver CAPTCHA (intento {attempt})") + continue + + await page.fill("input[name='verificador']", code.strip()) + await page.wait_for_timeout(300) + + await page.evaluate(f""" + () => {{ + const form = document.querySelector('form[name="formulario"]'); + form.removeAttribute('target'); + form.action = '{self.RESULT_URL}'; + form.submit(); + }} + """) + + await page.wait_for_load_state("domcontentloaded", timeout=self.RESULT_TIMEOUT) + await page.wait_for_timeout(500) + + body_text = await page.evaluate("() => document.body.innerText") + + if "incorrecto" in body_text.lower() or "ya utilizado" in body_text.lower(): + logger.info(f"[DNRPA] CAPTCHA incorrecto (intento {attempt})") + continue + + if "debe ingresar" in body_text.lower(): + logger.warning("[DNRPA] Formulario sin datos") + continue + + resultado.update(self._parse_result_page(body_text, dominio)) + resultado["estado"] = "Activo" + logger.info(f"[DNRPA] Datos obtenidos para {dominio}") + return resultado + + logger.warning(f"[DNRPA] Todos los intentos de CAPTCHA fallaron para {dominio}") + + except Exception as e: + logger.warning(f"[DNRPA] Error para {dominio}: {e}") + finally: + if browser: + await browser.close() + return resultado + + async def _solve_captcha(self, page) -> str | None: + """Resuelve el image CAPTCHA del portal DNRPA.""" + captcha_img = await page.query_selector("img[src^='data:image/png;base64']") + if not captcha_img: + return None + + src = await captcha_img.get_attribute("src") + b64_data = src.split(",")[1] if "," in src else src + img_bytes = base64.b64decode(b64_data) + + solver = CaptchaSolver() + + code = await solver.solve_image_captcha_local(b64_data) + if code and len(code) >= 3: + return code + + code = await solver.solve_image_captcha_groq(img_bytes) + if code and len(code) >= 3: + return code + + return None + + def _parse_result_page(self, text: str, dominio: str) -> dict: + """Parsea la página de resultados del portal DNRPA.""" + result = {"dominio": dominio} + + patterns = { + "tipo": [r'Tipo de Veh[ií]culo[\.\s]+([^\n]+)'], + "registro": [r'Registro Seccional[\.\s]+(\S+)\s*-\s*(.+)'], + "direccion": [r'Direcci[oó]n[\.\s]+(.+)'], + "localidad": [r'Localidad[\.\s]+([^\t\n]+)'], + "provincia": [r'Provincia[\.\s]+([^\n]+)'], + "codigo_postal": [r'C[oó]digo Postal[\.\s]+(\S+)'], + "telefono": [r'Tel[eé]fono[\.\s]+(.+)'], + } + + campos_encontrados = [] + campos_no_encontrados = [] + + for field, field_patterns in patterns.items(): + encontrado = False + for pattern in field_patterns: + match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) + if match: + if field == "registro": + result["registro"] = match.group(1).strip() + result["registro_nombre"] = match.group(2).strip() + else: + result[field] = match.group(1).strip() + campos_encontrados.append(field) + encontrado = True + break + + if not encontrado: + campos_no_encontrados.append(field) + + if "registro" not in result: + result["registro"] = "N/A" + + # Logging de campos no encontrados para debugging + if campos_no_encontrados: + logger.debug(f"[DNRPA] Campos no encontrados para {dominio}: {', '.join(campos_no_encontrados)}") + + logger.debug(f"[DNRPA] Campos encontrados para {dominio}: {', '.join(campos_encontrados)}") + + return result diff --git a/app/scrapers/google_images.py b/app/scrapers/google_images.py new file mode 100644 index 0000000000000000000000000000000000000000..db24e77a2faa96e63adae1c40d98e6047da1e5b7 --- /dev/null +++ b/app/scrapers/google_images.py @@ -0,0 +1,362 @@ +""" +Scraper Google Images — Busca fotos de perfil validadas con Groq. + +Estrategia simplificada y efectiva: +1. Búsqueda con 3 variaciones del nombre (completo, nombre1+apellido, nombre2+apellido) +2. ddgs.images() como fuente PRIMARIA — retorna URLs de imagen reales +3. SearchAPI.io como fallback secundario +4. Cada imagen se VALIDA con Groq por EDAD/CUMPLEAÑOS/DNI +5. Validación OR: al menos 1 de (edad, cumpleaños, DNI) debe coincidir +6. Si no hay resultados verificados → lista vacía (SIN inventar) +""" +import asyncio +import datetime +import difflib +import httpx +import logging +import re +import json +from urllib.parse import urlparse +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +SEARCHAPI_URL = "https://www.searchapi.io/api/v1/search" +GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" + +# Dominios genéricos / logos → se filtran +SKIP_DOMAINS = { + "ui-avatars.com", "gravatar.com", "avatar.dicebear.com", + "robohash.org", "i.pravatar.cc", "pbs.twimg.com", + "abs.twimg.com", "graph.facebook.com", "flagcdn.com", + "upload.wikimedia.org", "www.w3.org", "fonts.googleapis.com", + "fonts.gstatic.com", "cdn.jsdelivr.net", "cdnjs.cloudflare.com", + "google.com", "gstatic.com", "googleapis.com", +} + +# Extensiones de imagen válidas +VALID_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} + +SKIP_PATTERNS = [ + r"/favicon", r"/icon", r"/logo", r"/emoji", r"/badge", + r"/sprite", r"\.svg$", r"\.ico$", + r"default[_-]?avatar", r"generic[_-]?user", + r"blank[_-]?profile", r"no[_-]?photo", r"placeholder", +] + + +class GoogleImagesScraper(BaseScraper): + source_name = "Google Images" + uses_playwright = False + SAFE_FETCH_TIMEOUT = 45 + + def __init__(self): + super().__init__() + + async def fetch(self, identifier: str, **kwargs) -> dict: + nombre_raw = kwargs.get("nombre", identifier).strip() + if not nombre_raw or len(nombre_raw) < 4: + return {} + + nombre_completo = nombre_raw + queries = self._generar_queries(nombre_completo) + nombre_corto = self._generar_nombre_corto(nombre_completo) + + fecha_nacimiento = kwargs.get("fecha_nacimiento", "") + edad_aprox, cumpleanos_dd_mm = self._parse_fecha_nacimiento(fecha_nacimiento) + + dni_raw = kwargs.get("dni", "") + dni_ref = self._normalizar_dni(dni_raw) + + all_keys = list(settings.searchapi_keys) if settings.searchapi_keys else [] + if settings.searchapi_key and settings.searchapi_key not in all_keys: + all_keys.insert(0, settings.searchapi_key) + + all_candidates = await self._collect_image_candidates( + queries, nombre_corto, all_keys, bool(all_keys) + ) + + if not all_candidates: + logger.info(f"[GoogleImages] Sin candidatos para '{nombre_completo}'") + return {} + + verified = await self._verify_images_with_groq( + all_candidates, nombre_corto, edad_aprox, cumpleanos_dd_mm, dni_ref + ) + + if not verified: + logger.info(f"[GoogleImages] Ninguna imagen verificada para '{nombre_completo}'") + return {} + + foto_perfil = verified[0]["url"] if verified else None + fotos_candidatas = [c["url"] for c in verified[:5]] + + logger.info(f"[GoogleImages] {len(fotos_candidatas)} fotos verificadas para '{nombre_completo}'") + + return { + "foto_perfil": foto_perfil, + "fotos_candidatas": fotos_candidatas, + } + + def _generar_queries(self, nombre: str) -> list[str]: + """Queries idénticas a redes sociales: completo, nombre1+apellido, nombre2+apellido.""" + parts = nombre.strip().split() + if len(parts) < 2: + return [parts[0].strip()] if parts else [] + + queries = [] + # 1. Nombre completo (ej: "Lucas Maximiliano Calichio") + full = " ".join(p.strip() for p in parts) + queries.append(full) + + # 2. Nombre1 + Apellido (ej: "Lucas Calichio") + first_last = f"{parts[0].strip()} {parts[-1].strip()}" + if first_last.lower() != full.lower(): + queries.append(first_last) + + # 3. Nombre2 + Apellido (ej: "Maximiliano Calichio") + if len(parts) >= 3: + mid_last = f"{parts[1].strip()} {parts[-1].strip()}" + if mid_last.lower() != full.lower() and mid_last.lower() != first_last.lower(): + queries.append(mid_last) + + return queries + + def _generar_nombre_corto(self, nombre: str) -> str: + """Devuelve el nombre tal cual, sin modificar.""" + return nombre.strip() + + def _normalizar_dni(self, dni: str) -> str | None: + if not dni: + return None + digits = re.sub(r'[^0-9]', '', str(dni)) + if len(digits) in (7, 8): + return digits + return None + + def _parse_fecha_nacimiento(self, fecha: str) -> tuple[int | None, str | None]: + if not fecha: + return None, None + cleaned = fecha.replace(".", "-").replace("/", "-").strip() + parts = cleaned.split("-") + try: + if len(parts) == 3: + if len(parts[0]) == 4: + year, month, day = int(parts[0]), int(parts[1]), int(parts[2]) + else: + day, month, year = int(parts[0]), int(parts[1]), int(parts[2]) + edad = datetime.datetime.now().year - year + cumple = f"{day:02d}/{month:02d}" + return edad, cumple + except (ValueError, IndexError): + pass + return None, None + + def _is_valid_image_url(self, url: str) -> bool: + if not url or not url.startswith(("http://", "https://")): + return False + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + for skip in SKIP_DOMAINS: + if skip in host: + return False + path_lower = parsed.path.lower() + for pat in SKIP_PATTERNS: + if re.search(pat, path_lower): + return False + has_valid_ext = any(path_lower.endswith(ext) for ext in VALID_IMAGE_EXTENSIONS) + is_image_host = any(kw in host for kw in [ + "imgur.com", "flickr.com", "wp.com", "cloudinary.com", + "unsplash.com", "pinimg.com", "licdn.com", "s3.amazonaws.com", + "media-amazon.com", "glbimg.com", "globo.com", "sndcdn.com", + "yt3.googleusercontent.com", "i.ytimg.com", "lookaside.fbsbx.com", + "lookaside.instagram.com", + ]) + return has_valid_ext or is_image_host + + async def _collect_image_candidates( + self, queries: list[str], nombre_corto: str, + all_keys: list[str], use_searchapi: bool + ) -> list[dict]: + all_candidates = [] + seen_urls = set() + searchapi_exhausted = False + + # === FUENTE 1: ddgs.images() — fuente primaria === + try: + from ddgs import DDGS + d = DDGS(proxy=None, timeout=25) + for query in queries: + try: + results = d.images(query, max_results=8) + for r in results: + img_url = r.get("image", "") + title = r.get("title", "") + source = r.get("source", "") + if img_url and img_url not in seen_urls and self._is_valid_image_url(img_url): + seen_urls.add(img_url) + all_candidates.append({ + "url": img_url, + "title": title, + "source_page": source, + "width": r.get("width", 0), + "height": r.get("height", 0), + }) + except Exception as e: + logger.debug(f"[GoogleImages] ddgs.images error for '{query}': {e}") + except ImportError: + logger.debug("[GoogleImages] ddgs not installed") + + # === FUENTE 2: SearchAPI.io — fallback si ddgs no dio resultados === + if not all_candidates and use_searchapi and not searchapi_exhausted and all_keys: + for query in queries: + if searchapi_exhausted: + break + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + SEARCHAPI_URL, + params={ + "engine": "google_images", + "q": query, + "api_key": all_keys[0], + "hl": "es", + "gl": "ar", + "num": 10, + }, + ) + if resp.status_code == 429: + searchapi_exhausted = True + elif resp.status_code == 200: + data = resp.json() + for item in data.get("images_results", []): + url = item.get("original") or item.get("thumbnail", "") + title = item.get("title", "") + if url and url not in seen_urls and self._is_valid_image_url(url): + seen_urls.add(url) + all_candidates.append({ + "url": url, + "title": title, + "source_page": item.get("source", ""), + "width": item.get("original_width", 0), + "height": item.get("original_height", 0), + }) + except Exception: + pass + + # === FILTRADO POR APELLIDO === + # Rechazar resultados donde el título contiene un apellido diferente + query_parts = nombre_corto.lower().split() + query_last = query_parts[-1] if query_parts else "" + filtered = [] + for c in all_candidates: + title_lower = (c.get("title", "") + " " + c.get("source_page", "")).lower() + # Si el título menciona un apellido que NO es el nuestro → rechazar + # (ej: "Calicchio" cuando buscamos "Calichio") + if query_last and len(query_last) > 3: + # Buscar cognados cercanos pero incorrectos + title_words = title_lower.split() + for word in title_words: + # Si la palabra es similar al apellido pero no igual → rechazar + # Threshold 0.75 para evitar rechazar variaciones legítimas + ratio = difflib.SequenceMatcher(None, query_last, word).ratio() + if 0.75 < ratio < 1.0 and word != query_last: + logger.debug(f"[GoogleImages] Rechazado '{c.get('title', '')}' — apellido similar '{word}' vs '{query_last}' (ratio {ratio:.2f})") + break + else: + filtered.append(c) + else: + filtered.append(c) + + logger.info(f"[GoogleImages] {len(filtered)}/{len(all_candidates)} candidatos después de filtro de apellido") + return filtered + + async def _verify_images_with_groq( + self, candidates: list[dict], nombre_corto: str, + edad_aprox: int | None, cumpleanos_dd_mm: str | None, + dni_ref: str | None + ) -> list[dict]: + if not candidates: + return [] + + if not settings.groq_api_key or not settings.ai_verification_enabled: + return candidates[:3] + + candidates_text = "" + for i, c in enumerate(candidates[:10]): + candidates_text += f"\n--- Imagen {i+1} ---\n" + candidates_text += f"URL: {c['url']}\n" + candidates_text += f"Title: {c.get('title', '')}\n" + candidates_text += f"Source: {c.get('source_page', '')}\n" + + edad_text = f"Edad aproximada: {edad_aprox} años." if edad_aprox else "" + cumple_text = f"Fecha de cumpleaños: {cumpleanos_dd_mm} (día/mes)." if cumpleanos_dd_mm else "" + dni_text = f"DNI esperado: {dni_ref}." if dni_ref else "" + + prompt = f"""Eres un asistente OSINT. Evaluá CADA imagen y determiná si podría ser una FOTO DE PERFIL de la persona objetivo. + +PERSONA OBJETIVO: +- Nombre: {nombre_corto} +{edad_text} +{cumple_text} +{dni_text} + +IMÁGENES CANDIDATAS: +{candidates_text} + +INSTRUCCIONES: +1. Mirá el TÍTULO y la FUENTE de cada imagen para determinar si corresponde a la persona objetivo. +2. El título o la fuente debe CONTENER EL NOMBRE de la persona como dueña de la imagen. +3. Si encontrás edad, cumpleaños o DNI en la fuente → compará con la referencia (±2 años para edad). +4. Si encontrás datos que NO coinciden → descartá esa imagen. +5. Si el nombre coincide pero no hay datos contradictorios → ACEPTÁ. +6. Priorizá imágenes que parezcan fotos de perfil reales (no logos, no banners). +7. IMPORTANTE: Solo devolvé imágenes que tengan ALGUNA relación con la persona objetivo. + +Respondé SOLO JSON: +{{"matches": [{{"index": 1, "razon": "breve"}}]}} +Si ninguna imagen matchea: {{"matches": []}}""" + + try: + for attempt in range(3): + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + GROQ_URL, + headers={"Authorization": f"Bearer {settings.groq_api_key}"}, + json={ + "model": settings.groq_model or "llama-3.3-70b-versatile", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.0, + "max_tokens": 500, + "response_format": {"type": "json_object"}, + }, + ) + if resp.status_code == 429: + wait = min(int(resp.headers.get("retry-after", "5")), 5) + logger.debug(f"[GoogleImages/Groq] Rate limited — wait {wait}s (attempt {attempt+1}/3)") + await asyncio.sleep(wait) + continue + if resp.status_code != 200: + logger.debug(f"[GoogleImages/Groq] HTTP {resp.status_code}") + return candidates[:3] + + content = resp.json()["choices"][0]["message"]["content"].strip() + result = json.loads(content) + match_indices = [m["index"] for m in result.get("matches", [])] + + matched = [] + for idx in match_indices: + if 1 <= idx <= len(candidates): + matched.append(candidates[idx - 1]) + + logger.debug(f"[GoogleImages/Groq] {len(matched)}/{len(candidates)} verified") + return matched + + logger.debug("[GoogleImages/Groq] 3 retries exhausted — returning top 3") + return candidates[:3] + + except Exception as e: + logger.debug(f"[GoogleImages/Groq] Error: {e}") + return candidates[:3] diff --git a/app/scrapers/http_utils.py b/app/scrapers/http_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..2b488e7c21a44f5df7a4e35484b1d8fb4fdfee8a --- /dev/null +++ b/app/scrapers/http_utils.py @@ -0,0 +1,70 @@ +""" +Utilidades compartidas para scrapers — CrowData. + +SSL verification: +- Por defecto, verificar SSL (seguro). +- Algunos sitios .gob.ar tienen certificados rotos/viejos. +- Usar create_client() que maneja fallback automático. +""" +import logging +import httpx + +logger = logging.getLogger(__name__) + +# Sitios conocidos con problemas de SSL (certificados viejos, auto-firmados, etc.) +# Agregar aquí cuando se descubra que un sitio falla con verify=True +SSL_EXEMPTIONS = { + "boletinoficial.gob.ar", + "servicioscf.afip.gob.ar", + "rgaconsultas.afip.gob.ar", + "app.afip.gob.ar", + "radicaciones.anses.gob.ar", + "oficinavirtualpersonas.anses.gob.ar", + "scjn.gov.ar", + "carto.arba.gob.ar", + "consultas.arba.gob.ar", + "arba.gov.ar", +} + + +def _needs_ssl_exemption(url: str) -> bool: + """Chequea si la URL pertenece a un dominio con problemas conocidos de SSL.""" + for domain in SSL_EXEMPTIONS: + if domain in url: + return True + return False + + +async def create_http_client( + timeout: int = 30, + follow_redirects: bool = True, + proxy: str | None = None, + force_verify: bool | None = None, + **kwargs, +) -> httpx.AsyncClient: + """ + Crea un httpx.AsyncClient con SSL verification inteligente. + + - force_verify=True: siempre verificar SSL + - force_verify=False: nunca verificar SSL + - force_verify=None (default): verificar SSL, excepto para dominios conocidos rotos + """ + if force_verify is not None: + verify = force_verify + else: + verify = True # Default: verificar + + client = httpx.AsyncClient( + timeout=timeout, + follow_redirects=follow_redirects, + verify=verify, + proxy=proxy, + **kwargs, + ) + + if verify: + logger.debug("[HTTP] SSL verification habilitado") + else: + logger.debug("[HTTP] SSL verification DESHABILITADO (excepción)") + + return client diff --git a/app/scrapers/igj.py b/app/scrapers/igj.py new file mode 100644 index 0000000000000000000000000000000000000000..599314faa19e64c164337d112c66254b648c4a12 --- /dev/null +++ b/app/scrapers/igj.py @@ -0,0 +1,352 @@ +"""Scraper IGJ — Registro Público de Comercio (Inspección General de Justicia). + +Busca datos societarios (razón social, inscripción, tipo societario) por CUIT o nombre. +Flujo: +1. Resolver CUIT a nombre vía múltiples fuentes (HTTP + Playwright) +2. Buscar en portal IGJ (ASP.NET WebForms + image CAPTCHA) +3. CAPTCHA: Groq Vision OCR + retry automático (hasta 5 intentos) +""" +import logging +import asyncio +import httpx +import re +from bs4 import BeautifulSoup +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper, ScraperError +from app.utils.captcha import CaptchaSolver +from app.utils.security import mask_cuit + +logger = logging.getLogger(__name__) + + +class IgjScraper(BaseScraper): + uses_playwright = True + source_name = "IGJ" + base_url = "https://www2.jus.gov.ar/igj-vistas/Busqueda.aspx" + DEFAULT_NAVIGATION_TIMEOUT = 20000 + MAX_CAPTCHA_ATTEMPTS = 5 + SAFE_FETCH_TIMEOUT = 60 + max_retries = 1 + + # Timeouts para diferentes etapas del flujo + INITIAL_PAGE_LOAD = 1.5 # Espera después de cargar página + CLICK_WAIT = 0.5 # Espera después de click + CONTINUE_WAIT = 1.5 # Espera después de CONTINUAR + CAPTCHA_REFRESH_WAIT = 1.0 # Espera después de refrescar CAPTCHA + SUBMIT_WAIT = 3.0 # Espera después de submit + DETAIL_PAGE_LOAD = 2.0 # Espera al cargar detalle + BODY_READ_RETRY_WAIT = 2.0 # Espera antes de reintentar leer body + + async def fetch(self, cuit_or_name: str, **kwargs) -> dict: + is_cuit = cuit_or_name.replace("-", "").isdigit() + return await self._fetch_web(cuit_or_name, is_cuit) + + async def _resolve_cuit_to_name(self, cuit: str, proxy_url: str | None) -> str | None: + """Resuelve CUIT a nombre usando múltiples fuentes. + + Estrategias en orden: + 1. HTTP directo a servicios públicos (más rápido, sin Cloudflare) + 2. cuitonline.com con Playwright (bloqueado) + 3. Fallback: retorna None + """ + cuit_clean = cuit.replace("-", "").strip() + + # Formatear CUIT: XX-XXXXXXXX-X + if len(cuit_clean) == 11: + cuit_fmt = f"{cuit_clean[:2]}-{cuit_clean[2:10]}-{cuit_clean[10]}" + else: + cuit_fmt = cuit_clean + + logger.info(f"[IGJ] Resolviendo CUIT {cuit_fmt} a nombre...") + + # Estrategia 1: HTTP request a argentina.gob.ar/cuit (más simple) + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + # Intentar endpoint público de AFIP/ANSES + url = f"https://serviciosweb.afip.gob.ar/genericos/nomencladorActividades/accesoLibre.aspx?modo=M&cuit={cuit_clean}" + logger.debug(f"[IGJ] Intentando AFIP: {url}") + + response = await client.get(url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + }) + + if response.status_code == 200: + soup = BeautifulSoup(response.text, "html.parser") + # Buscar razón social en el HTML + for tag in soup.find_all(['span', 'div', 'td', 'p']): + text = tag.get_text().strip() + if len(text) > 5 and len(text) < 200: + # Filtrar nombres válidos (no incluyen números al inicio) + if not text[0].isdigit() and "CUIT" not in text and "afip" not in text.lower(): + # Verificar si parece un nombre de empresa + if any(keyword in text.upper() for keyword in ["SA", "SRL", "SAS", "LTDA", "COOP", "S.A.", "S.R.L."]): + logger.info(f"[IGJ] ✅ AFIP - CUIT resuelto: {text}") + return text + except Exception as e: + logger.debug(f"[IGJ] ❌ AFIP request error: {e}") + + # Estrategia 2: Scraping HTTP simple a buscarcuit + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, verify=False) as client: + url = f"https://www.buscarcuit.com.ar/cuit/{cuit_fmt}" + logger.debug(f"[IGJ] Intentando buscarcuit.com.ar HTTP: {url}") + + response = await client.get(url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + }) + + if response.status_code == 200: + soup = BeautifulSoup(response.text, "html.parser") + + # Buscar tabla o div con datos + for keyword in ["razón social", "denominación", "nombre"]: + element = soup.find(text=re.compile(keyword, re.IGNORECASE)) + if element: + # Buscar el valor en el siguiente elemento + parent = element.find_parent() + if parent: + next_elem = parent.find_next_sibling() + if next_elem: + nombre = next_elem.get_text().strip() + if nombre and len(nombre) > 3 and not nombre.startswith("CUIT"): + logger.info(f"[IGJ] ✅ buscarcuit.com.ar - Resuelto: {nombre}") + return nombre + except Exception as e: + logger.debug(f"[IGJ] ❌ buscarcuit.com.ar HTTP error: {e}") + + # Estrategia 3: Intentar cuitonline.com con Playwright (última opción) + nombre = await self._try_cuitonline_playwright(cuit_clean, proxy_url) + if nombre: + return nombre + + logger.warning(f"[IGJ] ❌ No se pudo resolver CUIT {cuit_clean} - todas las fuentes fallaron") + return None + + async def _try_cuitonline_playwright(self, cuit: str, proxy_url: str | None) -> str | None: + """Último intento con Playwright en cuitonline.com.""" + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + try: + url = f"https://www.cuitonline.com/search.php?q={cuit}" + logger.debug("[IGJ] Último intento: cuitonline.com con Playwright...") + + await page.goto(url, wait_until="domcontentloaded", timeout=20000) + + # Esperar solo 5 segundos + for _ in range(5): + await asyncio.sleep(1) + el = await page.query_selector(".denominacion") + if el: + nombre = (await el.inner_text()).strip() + logger.info(f"[IGJ] ✅ cuitonline.com - Resuelto: {nombre}") + return nombre + + return None + finally: + await browser.close() + except Exception as e: + logger.debug(f"[IGJ] ❌ cuitonline.com Playwright error: {e}") + return None + + async def _fetch_web(self, identifier: str, is_cuit: bool) -> dict: + proxy_url = self.get_proxy() + nombre_a_buscar = identifier + + if is_cuit: + cuit_limpio = identifier.replace("-", "").strip() + logger.info("[IGJ] Input es CUIT %s, intentando resolver a nombre...", mask_cuit(cuit_limpio)) + nombre_a_buscar = await self._resolve_cuit_to_name(cuit_limpio, proxy_url) + + if not nombre_a_buscar: + # FALLBACK: Si no se pudo resolver el CUIT a nombre, + # intentar buscar directamente el CUIT en IGJ (a veces funciona) + logger.info("[IGJ] No se pudo resolver CUIT a nombre, intentando búsqueda directa por CUIT en IGJ...") + nombre_a_buscar = identifier # Usar el CUIT formateado original + + # Si tampoco funciona, retornar error informativo + result = await self._search_igj_with_identifier(nombre_a_buscar, proxy_url, is_cuit=True) + if result.get("numero_inscripcion") == "N/A": + return { + "numero_inscripcion": "N/A", + "razon_social": "", + "objeto_social": "No se pudo resolver el CUIT. Use el nombre de la empresa directamente.", + "estado": "N/A", + "nota": "La resolución automática de CUIT está limitada por protecciones anti-bot. Recomendación: busque por nombre de empresa en lugar de CUIT." + } + return result + + logger.info("[IGJ] Buscando: '%s'", nombre_a_buscar) + return await self._search_igj_with_identifier(nombre_a_buscar, proxy_url, is_cuit=False) + + async def _search_igj_with_identifier(self, identifier: str, proxy_url: str | None, is_cuit: bool = False) -> dict: + """Busca en IGJ con el identificador (nombre o CUIT).""" + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + try: + return await self._search_igj(page, identifier) + finally: + await browser.close() + except Exception as e: + logger.error("[IGJ] Error: %s", e) + raise ScraperError(self.source_name, str(e)) + + async def _search_igj(self, page, nombre: str) -> dict: + await page.goto(self.base_url, wait_until="domcontentloaded", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + await asyncio.sleep(self.INITIAL_PAGE_LOAD) + + label = page.locator("text=Por Denominación de la Entidad") + if await label.is_visible(): + await label.click() + await asyncio.sleep(self.CLICK_WAIT) + + btn_continue = page.locator("text=CONTINUAR") + if await btn_continue.is_visible(): + await btn_continue.click() + await asyncio.sleep(self.CONTINUE_WAIT) + + input_denom = page.locator("#ctl00_ContentPlaceHolder1_RazonSocial1_txtRazonSocial") + if not await input_denom.is_visible(): + return {"numero_inscripcion": "N/A", "razon_social": nombre, "objeto_social": "No encontrado", "estado": "N/A"} + + solver = CaptchaSolver() + + for attempt in range(self.MAX_CAPTCHA_ATTEMPTS): + captcha_img = await page.query_selector("#ctl00_ContentPlaceHolder1_Image1") + if not captcha_img: + break + + captcha_bytes = await captcha_img.screenshot() + code = await solver.solve_image_captcha_groq(captcha_bytes) + + if not code or len(code) < 3: + refresh_btn = await page.query_selector("#ctl00_ContentPlaceHolder1_RecargarCaptcha") + if refresh_btn: + await refresh_btn.click() + await asyncio.sleep(self.CAPTCHA_REFRESH_WAIT) + continue + + logger.info("[IGJ] CAPTCHA: %s (intento %d/%d)", code, attempt + 1, self.MAX_CAPTCHA_ATTEMPTS) + + await input_denom.fill(nombre) + await page.fill("#ctl00_ContentPlaceHolder1_txtCaptcha", code) + + try: + async with page.expect_navigation(timeout=15000, wait_until="domcontentloaded"): + await page.click("#ctl00_ContentPlaceHolder1_imgBtnBuscar") + except Exception as e: + logger.debug(f"[IGJ] Navegación no detectada (esperado si la página no recarga): {e}") + + # Esperar a que la página esté completamente cargada + try: + await page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + await page.wait_for_timeout(int(self.SUBMIT_WAIT * 1000)) + + try: + body = await page.evaluate("() => document.body.innerText") + except Exception as e: + logger.debug(f"[IGJ] Error leyendo body, reintentando: {e}") + await page.wait_for_timeout(int(self.BODY_READ_RETRY_WAIT * 1000)) + body = await page.evaluate("() => document.body.innerText") + + if "no concuerda" in body.lower(): + logger.debug("[IGJ] CAPTCHA incorrecto") + refresh_btn = await page.query_selector("#ctl00_ContentPlaceHolder1_RecargarCaptcha") + if refresh_btn: + await refresh_btn.click() + await asyncio.sleep(self.CAPTCHA_REFRESH_WAIT) + continue + + return await self._parse_results(page, nombre) + + return {"numero_inscripcion": "N/A", "razon_social": nombre, "objeto_social": "CAPTCHA no resuelto", "estado": "N/A"} + + async def _parse_results(self, page, nombre: str) -> dict: + body = await page.evaluate("() => document.body.innerText") + + if "no se encontraron" in body.lower(): + return { + "numero_inscripcion": "Sin resultados", + "razon_social": nombre, + "objeto_social": "No se encontraron sociedades", + "estado": "N/A", + } + + rows = await page.query_selector_all("table[id*='grdSociedades'] tr") + if not rows: + rows = await page.query_selector_all("table tr") + + valid_rows = [] + for row in rows: + cells = await row.query_selector_all("td") + if len(cells) >= 3: + valid_rows.append(cells) + + if valid_rows: + cells = valid_rows[0] + cell_texts = [await c.inner_text() for c in cells] + result = { + "numero_inscripcion": cell_texts[0].strip(), + "razon_social": cell_texts[1].strip() or nombre, + "tipo_societario": cell_texts[2].strip() if len(cell_texts) > 2 else "", + "estado": cell_texts[3].strip() if len(cell_texts) > 3 else "Activo", + } + # Extraer campos extendidos disponibles en la tabla + if len(cell_texts) > 4: + result["domicilio_legal"] = cell_texts[4].strip() + if len(cell_texts) > 5: + result["contacto_raw"] = cell_texts[5].strip() + contacto = cell_texts[5].strip() + if "@" in contacto: + result["email_contacto"] = [e.strip() for e in contacto.split(",") if "@" in e] + if len(cell_texts) > 6: + result["objeto_social"] = cell_texts[6].strip() + + # Buscar link de detalle para más info + try: + detail_link = await cells[0].query_selector("a") + if not detail_link: + logger.debug("[IGJ] Sin link de detalle") + return result + + await detail_link.click() + await asyncio.sleep(self.DETAIL_PAGE_LOAD) + except Exception as e: + logger.debug(f"[IGJ] Error al hacer click en detalle: {e}") + return result + + try: + detail_text = await page.evaluate("() => document.body.innerText") + if "pagina" in detail_text.lower() or "web" in detail_text.lower(): + for line in detail_text.split("\n"): + line_lower = line.lower().strip() + if "pagina" in line_lower or "web" in line_lower: + result["pagina_web"] = line.split(":")[-1].strip() + if "linkedin" in line_lower: + result["linkedin"] = line.split(":")[-1].strip() + if "email" in line_lower or "correo" in line_lower: + result["email_contacto"] = line.split(":")[-1].strip() + # Extraer balances si disponibles + if "balance" in detail_text.lower(): + balances = [] + for line in detail_text.split("\n"): + if "periodo" in line.lower() or "resultado" in line.lower(): + balances.append(line.strip()) + if balances: + result["balances"] = balances[:5] + except Exception as e: + logger.debug(f"[IGJ] Error al leer página de detalle: {e}") + + logger.info("[IGJ] Datos extraídos: %s", result) + return result + + return { + "numero_inscripcion": "Consultar en IGJ", + "razon_social": nombre, + "tipo_societario": "Sin datos", + "estado": "Consulta manual requerida", + } diff --git a/app/scrapers/infracciones.py b/app/scrapers/infracciones.py new file mode 100644 index 0000000000000000000000000000000000000000..d89a4ee23f8b78db099599e2132efc15bbf8fe96 --- /dev/null +++ b/app/scrapers/infracciones.py @@ -0,0 +1,141 @@ +"""Scraper Infracciones Nacional (CENAT) — Pivoteo de DNI a Patente.""" +import logging +import re +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +class InfraccionesScraper(BaseScraper): + uses_playwright = True + source_name = "Infracciones" + + # URL del buscador de infracciones CENAT + base_url = "https://consultainfracciones.seguridadvial.gob.ar/" + + # Timeouts + NAVIGATION_TIMEOUT = 20000 + RESULTS_WAIT_TIMEOUT = 12000 + DOMCONTENTLOADED_TIMEOUT = 8000 + + async def fetch(self, identifier: str, **kwargs) -> dict: + """ + identifier debe ser el DNI de la persona (o CUIT del cual se extrae el DNI). + Retorna la lista de dominios (patentes) encontrados asociados al DNI. + """ + # Extraer DNI del CUIT (posiciones 2-10) si tiene 11 dígitos + clean = re.sub(r"[^0-9]", "", identifier) + dni = clean[2:10] if len(clean) == 11 else clean + + # Validar DNI + if not dni or len(dni) < 7: + logger.warning(f"[CENAT] DNI inválido: {dni}") + return {"dominios_encontrados": [], "error": "DNI inválido (mínimo 7 dígitos)"} + + # Lógica completa de género basada en prefijo CUIT/CUIL + # Prefijos CUIT/CUIL: + # 20: Masculino persona física + # 23: Masculino monotributista + # 24: Masculino extranjero + # 27: Femenino persona física + # 30-34: Personas jurídicas (sin género real) + # 33: Masculino extranjero residente + sexo_value = "1" # default Masculino + + if len(clean) == 11: + prefijo = clean[:2] + if prefijo == "27": + sexo_value = "0" # Femenino + logger.debug(f"[CENAT] Prefijo {prefijo} detectado como Femenino") + elif prefijo in ("20", "23", "24", "33"): + sexo_value = "1" # Masculino + logger.debug(f"[CENAT] Prefijo {prefijo} detectado como Masculino") + elif prefijo in ("30", "31", "32", "34"): + # Jurídicas: CENAT requiere género, usar masculino por defecto + sexo_value = "1" + logger.info(f"[CENAT] CUIT jurídico (prefijo {prefijo}), usando género masculino por defecto") + else: + # Prefijo desconocido + sexo_value = "1" + logger.warning(f"[CENAT] Prefijo CUIT desconocido {prefijo}, usando género masculino por defecto") + else: + # Es DNI directo sin CUIT, no podemos inferir género + # Usar masculino por defecto + logger.debug(f"[CENAT] DNI sin CUIT, usando género masculino por defecto") + + return await self._fetch_dominios_por_dni(dni, sexo_value) + + async def _fetch_dominios_por_dni(self, dni: str, sexo_value: str = "1") -> dict: + dominios = set() + + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + try: + logger.info(f"[CENAT] Navegando a {self.base_url} para DNI {dni}") + await page.goto(self.base_url, wait_until="domcontentloaded", timeout=self.NAVIGATION_TIMEOUT) + + await page.fill("#ctl00_ContentPlaceHolder1_txDocumento", dni) + + # Portal CENAT: rdioSexo_0 = Femenino, rdioSexo_1 = Masculino + label_for = f"ctl00_ContentPlaceHolder1_rdioSexo_{sexo_value}" + await page.click(f"label[for='{label_for}']") + + await page.click("#btnBuscarFake") + + try: + await page.wait_for_selector( + "#divGrilla:visible, #ctl00_ContentPlaceHolder1_divSinInfracciones:visible, " + ".grilla, .sin-infracciones, table", + timeout=self.RESULTS_WAIT_TIMEOUT, + state="visible" + ) + except Exception as ex: + logger.warning(f"[CENAT] Timeout esperando resultados para DNI {dni}: {ex}") + + await page.wait_for_load_state("domcontentloaded", timeout=self.DOMCONTENTLOADED_TIMEOUT) + + html_content = await page.content() + + if "divSinInfracciones" in html_content: + logger.info(f"[CENAT] Sin infracciones para DNI {dni}") + return {"dominios_encontrados": []} + + if "divGrilla" in html_content or "grilla" in html_content.lower(): + # Regex mejorado para todos los formatos de patente argentina: + # ABC123 (3 letras + 3 números) - autos formato antiguo + # 123ABC (3 números + 3 letras) - motos formato antiguo + # AB123CD (2 letras + 3 números + 2 letras) - Mercosur + patente_regex = re.compile( + r'\b([A-Z]{3}\d{3}|\d{3}[A-Z]{3}|[A-Z]{2}\d{3}[A-Z]{2})\b', + re.IGNORECASE + ) + + grilla_element = await page.query_selector("#divGrilla, .grilla, table") + if grilla_element: + text = await grilla_element.inner_text() + matches = patente_regex.findall(text) + for m in matches: + dominios.add(m.upper()) + logger.info(f"[CENAT] {len(dominios)} patentes encontradas en grilla para DNI {dni}") + else: + # Fallback: buscar en body completo (puede incluir falsos positivos) + logger.debug("[CENAT] No se encontró elemento grilla, buscando en body completo") + body_text = await page.inner_text("body") + matches = patente_regex.findall(body_text) + if matches: + logger.warning(f"[CENAT] Patentes encontradas en body (riesgo de falsos positivos): {len(matches)}") + for m in matches: + dominios.add(m.upper()) + else: + logger.info(f"[CENAT] No se encontró grilla de infracciones para DNI {dni}") + + finally: + await browser.close() + + except Exception as e: + logger.warning(f"[CENAT] Error buscando DNI {dni}: {e}") + + return {"dominios_encontrados": list(dominios)} diff --git a/app/scrapers/inhibiciones.py b/app/scrapers/inhibiciones.py new file mode 100644 index 0000000000000000000000000000000000000000..34815ee6e37493bd0fd9e27d2bc93c085d712ac2 --- /dev/null +++ b/app/scrapers/inhibiciones.py @@ -0,0 +1,51 @@ +"""Scraper de Inhibiciones y Embargos. + +ESTADO: NO OPERATIVO +Motivo: No existen fuentes gratuitas accesibles para inhibiciones en Argentina. + +Fuentes analizadas (todas bloqueadas o de pago): +1. Boletín Oficial: + - API de búsqueda (buscarAvanzadaDatos): DEVUELVE HTML, API ROTA + - Scraping por secciones: Funciona pero solo muestra publicaciones del día + - No hay búsqueda por CUIT/nombre +2. Poder Judicial Nacional: + - API REST (scw.pjn.gov.ar/scw/rest/inhibiciones/consulta): WAF bloquea (Request Rejected) + - Consulta Web de Causas: CAPTCHA custom anti-bot no resuelve en headless + - El CAPTCHA de captcha.pjn.gov.ar no es reCAPTCHA, es sistema propietario +3. Registro de la Propiedad Inmueble PBA: + - Requiere pago (VEP) + registro previo + - No hay búsqueda gratuita por CUIT +4. Registro de la Propiedad CABA: + - Requiere pago (VEP) + registro previo + - No hay búsqueda gratuita por CUIT + +NOTA: Las inhibiciones son información reservada en Argentina. +Solo los titulares o sus representantes legales pueden consultarlas. +""" +import logging +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +class InhibicionesScraper(BaseScraper): + source_name = "inhibiciones" + + async def fetch(self, identifier: str, **kwargs) -> dict: + """ + NO OPERATIVO. + Las fuentes de inhibiciones en Argentina son de pago o están bloqueadas. + """ + logger.warning( + "[Inhibiciones] NO OPERATIVO - Sin fuentes gratuitas disponibles. " + "CUIT consultado: %s", identifier + ) + return { + "inhibiciones": [], + "total": 0, + "estado": "no_operativo", + "mensaje": ( + "Fuentes de inhibiciones no disponibles gratuitamente. " + "BO: API rota, PJN: WAF+CAPTCHA, Registros: requieren pago" + ), + } diff --git a/app/scrapers/inpi.py b/app/scrapers/inpi.py new file mode 100644 index 0000000000000000000000000000000000000000..ed87f984e5f532a73e82a969079ae96eca1a9b65 --- /dev/null +++ b/app/scrapers/inpi.py @@ -0,0 +1,158 @@ +"""Scraper INPI — Instituto Nacional de la Propiedad Industrial (Marcas).""" +import json +import logging +from datetime import datetime, timezone +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper, ScraperError +from app.config import get_settings +from app.utils.security import mask_cuit + +logger = logging.getLogger(__name__) +settings = get_settings() + +# Map INPI estado codes to readable strings +ESTADO_MAP = { + "C": "Concedida", + "A": "Abandonada", + "D": "Denegada", + "P": "Pendiente", + "V": "Vigente", + "E": "En trámite", +} + +# Map INPI tipo marca codes to readable strings +TIPO_MARCA_MAP = { + "C": "Combinación de Colores", + "D": "Denominativa", + "E": "Secuencial", + "F": "Figurativa", + "G": "Gustativa", + "L": "Táctil", + "M": "Mixta", + "O": "Olfativa", + "P": "Posición", + "R": "Tridimensional Mixta", + "S": "Sonora", + "T": "Tridimensional", +} + + +def _parse_dotnet_date(dotnet_date: str) -> str | None: + """Convierte fecha .NET '/Date(1234567890000)/' a 'YYYY-MM-DD'.""" + if not dotnet_date or not dotnet_date.startswith("/Date("): + return None + try: + ts = int(dotnet_date.replace("/Date(", "").replace(")/", "")) / 1000 + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") + except (ValueError, OSError): + return None + + +class InpiScraper(BaseScraper): + uses_playwright = True + source_name = "INPI (Marcas y Patentes)" + base_url = "https://portaltramites.inpi.gob.ar/marcasconsultas/busqueda" + api_url = "https://portaltramites.inpi.gob.ar/MarcasConsultas/GrillaMarcasAvanzada" + + async def fetch(self, cuit_or_name: str, **kwargs) -> dict: + identifier = self.clean_cuit(cuit_or_name) if cuit_or_name.replace('-', '').isdigit() else cuit_or_name + cuit = kwargs.get("cuit", "").replace("-", "") + return await self._fetch_web(identifier, cuit) + + async def _fetch_web(self, identifier: str, cuit: str = "") -> dict: + browser = None + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + logger.info(f"[INPI] Navegando a {self.base_url} para titular {identifier}") + await page.goto(self.base_url, wait_until="domcontentloaded", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + await page.wait_for_timeout(2000) + + # 1. Expandir accordion "Buscador de Marcas" + avanzada_header = await page.query_selector('a[href="#collapse-two"]') + if avanzada_header: + await avanzada_header.click() + await page.wait_for_timeout(800) + + # 2. Llenar campo Titular + titular_input = await page.query_selector("#Titular") + if not titular_input or not await titular_input.is_visible(): + logger.warning("[INPI] No se encontró campo Titular visible") + return {"marcas_inpi": []} + + await titular_input.fill(identifier) + logger.info(f"[INPI] Campo Titular llenado: {identifier}") + + # 3. Interceptar respuesta AJAX de DataTables + ajax_response = None + + async def capture_response(response): + nonlocal ajax_response + if "GrillaMarcas" in response.url and response.status == 200: + try: + ajax_response = await response.json() + except Exception: + pass + + page.on("response", capture_response) + + # 4. Click en Buscar + btn = await page.query_selector("#BtnBuscarAvanzada") + if btn: + await btn.click() + logger.info("[INPI] Click en Buscar") + + # 5. Esperar respuesta AJAX (máx 15s) + for _ in range(30): + if ajax_response: + break + await page.wait_for_timeout(500) + + if not ajax_response: + logger.warning("[INPI] No se recibió respuesta AJAX de la búsqueda") + return {"marcas_inpi": []} + + # 6. Parsear JSON response + marcas = self._parse_ajax_response(ajax_response) + + # 7. Filtrar por CUIT si se proporcionó (para mayor precisión) + if cuit and len(cuit) == 11: + marcas_filtradas = [m for m in marcas if cuit in str(m.get("titulares", ""))] + if marcas_filtradas: + marcas = marcas_filtradas + logger.info(f"[INPI] Filtrado por CUIT {mask_cuit(cuit)}: {len(marcas)} marcas") + else: + logger.info(f"[INPI] Sin marcas para CUIT {mask_cuit(cuit)}, mostrando todas las de '{identifier}'") + + logger.info(f"[INPI] Marcas encontradas: {len(marcas)}") + return {"marcas_inpi": marcas} + + except Exception as e: + logger.warning(f"[INPI] Error para {identifier}: {e}") + finally: + if browser: + await browser.close() + + return {"marcas_inpi": []} + + def _parse_ajax_response(self, data: dict) -> list[dict]: + """Parsea la respuesta JSON de DataTables.""" + marcas = [] + rows = data.get("rows", []) + for row in rows: + estado_code = row.get("Estado", "") + tipo_code = row.get("Tipo_Marca", "") + marcas.append({ + "denominacion": row.get("Denominacion", ""), + "clase": str(row.get("Clase", "")), + "estado": ESTADO_MAP.get(estado_code, estado_code), + "fecha_solicitud": _parse_dotnet_date(row.get("Fecha_Ingreso", "")), + "acta": row.get("Acta", ""), + "titulares": row.get("Titulares", ""), + "tipo_marca": TIPO_MARCA_MAP.get(tipo_code, tipo_code), + "numero_resolucion": row.get("Numero_Resolucion", ""), + "fecha_vencimiento": _parse_dotnet_date(row.get("Fecha_Vencimiento", "")), + }) + return marcas diff --git a/app/scrapers/juba.py b/app/scrapers/juba.py new file mode 100644 index 0000000000000000000000000000000000000000..86ed9d86442746d4626656efd6de7b2585432d3d --- /dev/null +++ b/app/scrapers/juba.py @@ -0,0 +1,360 @@ +""" +Scraper JUBA — Causas Judiciales en la Suprema Corte de Justicia de Buenos Aires. + +Portal público: https://juba.scba.gov.ar/Buscar.aspx (Búsqueda Rápida) + https://juba.scba.gov.ar/Busquedas.aspx (Búsqueda Integral) +Busca sumarios de fallos en TODAS las voces/carátula por el nombre de la persona. +Siempre valida que los resultados correspondan al objetivo. +""" +import asyncio +import logging +import re +import unicodedata +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +JUBA_BUSQUEDA_URL = "https://juba.scba.gov.ar/Buscar.aspx" +JUBA_INTEGRAL_URL = "https://juba.scba.gov.ar/Busquedas.aspx" + +NOISE_PATTERNS = { + "actualizar", "limpiar valores", "buscar", "volver", "imprimir", + "seleccione...", "más información", "ocultar fallos", "acumular", + "imprimir sumario", "ver el texto completo", +} + + +class JubaScraper(BaseScraper): + uses_playwright = True + source_name = "JUBA (SCBA)" + max_retries = 3 + SAFE_FETCH_TIMEOUT = 45 + + # Timeouts + NAVIGATION_TIMEOUT = 25000 + SELECTOR_TIMEOUT = 8000 + RESULTS_WAIT_TIMEOUT = 15000 + AFTER_SEARCH_WAIT = 2000 + + # Regex pre-compilados para performance + REGEX_RESULT_BLOCKS = re.compile(r'Resultado:\s*\d+\s*de\s*\d+') + REGEX_CARATULA = re.compile(r'Car[áa]tula:\s*(.+?)(?:\n|Magistrados)', re.IGNORECASE) + REGEX_EXPEDIENTE = re.compile(r'SCBA\s+\w+\s+[\w\s]+\d+') + REGEX_JUZGADO = re.compile(r'Tribunal Origen:\s*(\w+)') + REGEX_TRIBUNAL = re.compile(r'Tribunal Emisor:\s*(.+?)(?:\n|Fecha)', re.IGNORECASE) + REGEX_MATERIA = re.compile(r'(LABORAL|CONTENCIOSO|CIVIL|PENAL|INCONSTITUCIONALIDAD|FAMILIA)', re.IGNORECASE) + REGEX_FECHA = re.compile(r'Fecha:\s*(\d{2}/\d{2}/\d{4})', re.IGNORECASE) + REGEX_FECHA_SIMPLE = re.compile(r'(\d{2}/\d{2}/\d{4})') + REGEX_VOCES = re.compile(r'Voces:\s*(.+?)(?:\n|Sumario)', re.IGNORECASE) + REGEX_SUMARIO = re.compile(r'Sumario:\s*(.+?)(?:\n|Magistrados|\Z)', re.IGNORECASE | re.DOTALL) + REGEX_MAGISTRADOS = re.compile(r'Magistrados?:\s*(.+?)(?:\n|\Z)', re.IGNORECASE) + REGEX_TIPO = re.compile(r'(Sentencia|Interlocutoria|Acuerdo|Resoluci[oó]n)', re.IGNORECASE) + + async def fetch(self, cuit_or_name: str, **kwargs) -> dict: + """ + Busca causas judiciales en JUBA por nombre. + Timeout global: SAFE_FETCH_TIMEOUT (45s) para evitar bloqueos indefinidos. + """ + try: + # Timeout global a nivel de fetch + return await asyncio.wait_for( + self._fetch_internal(cuit_or_name, **kwargs), + timeout=self.SAFE_FETCH_TIMEOUT + ) + except asyncio.TimeoutError: + logger.warning("[JUBA] Timeout global (%ds) alcanzado para '%s'", self.SAFE_FETCH_TIMEOUT, cuit_or_name) + return {"causas_juba": [], "nota": "Timeout en búsqueda"} + except Exception as e: + logger.error(f"[JUBA] Error inesperado: {e}") + return {"causas_juba": []} + + async def _fetch_internal(self, cuit_or_name: str, **kwargs) -> dict: + nombre = kwargs.get("nombre", "").strip() + + if cuit_or_name and cuit_or_name.replace("-", "").replace(" ", "").isdigit(): + if not nombre: + logger.info("[JUBA] Solo CUIT sin nombre — JUBA requiere nombre/carátula") + return {"causas_juba": [], "nota": "JUBA requiere nombre completo, no CUIT"} + query = nombre + elif cuit_or_name and not cuit_or_name.replace("-", "").replace(" ", "").isdigit(): + query = cuit_or_name.strip() + elif nombre: + query = nombre + else: + return {"causas_juba": []} + + if not query or len(query) < 3: + return {"causas_juba": []} + + parts = query.strip().split() + apellido = parts[0].upper() if parts else "" + nombre_partes = [p.upper() for p in parts[1:]] if len(parts) > 1 else [] + + if not apellido or len(apellido) < 3: + return {"causas_juba": []} + + proxy_url = self.get_proxy() + + causas = await self._search_with_retry(apellido, nombre_partes, proxy_url) + return {"causas_juba": causas} + + async def _search_with_retry(self, apellido: str, nombre_partes: list[str], proxy_url: str | None) -> list[dict]: + queries_to_try = [apellido] + if nombre_partes: + queries_to_try.append(f"{apellido} {' '.join(nombre_partes)}") + + for query in queries_to_try: + logger.info("[JUBA] Buscando: '%s'", query) + + causas = await self._search_integral(query, proxy_url) + if causas == "TOO_MANY": + logger.info("[JUBA] Demasiados resultados para '%s', refinando...", query) + continue + if causas: + validadas = self._validar_causas(causas, apellido, nombre_partes) + if validadas: + return validadas + logger.info("[JUBA] Ninguna causa válida para '%s', refinando...", query) + continue + + causas = await self._search_rapida(query, proxy_url) + if causas == "TOO_MANY": + logger.info("[JUBA] Demasiados en rápida para '%s', refinando...", query) + continue + if causas: + validadas = self._validar_causas(causas, apellido, nombre_partes) + if validadas: + return validadas + logger.info("[JUBA] Ninguna causa válida en rápida para '%s'", query) + continue + + return [] + + def _normalizar(self, text: str) -> str: + text = text.upper() + text = unicodedata.normalize('NFD', text) + text = ''.join(c for c in text if unicodedata.category(c) != 'Mn') + return text + + def _validar_causas(self, causas: list[dict], apellido: str, nombre_partes: list[str]) -> list[dict]: + if not causas: + return [] + + apellido_norm = self._normalizar(apellido) + nombre_norm = [self._normalizar(n) for n in nombre_partes] + + validadas = [] + for causa in causas: + caratula = self._normalizar(causa.get("caratula", "") + " " + causa.get("expediente", "")) + + if apellido_norm not in caratula: + continue + + if nombre_norm: + if any(n in caratula for n in nombre_norm if len(n) >= 3): + validadas.append(causa) + else: + validadas.append(causa) + + if validadas: + logger.info("[JUBA] Validadas %d/%d causas para %s %s", + len(validadas), len(causas), apellido, " ".join(nombre_partes)) + return validadas + + logger.info("[JUBA] Ninguna causa coincide con %s %s", apellido, " ".join(nombre_partes)) + return [] + + async def _search_rapida(self, query: str, proxy_url: str | None) -> list | str: + browser = None + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + try: + logger.info("[JUBA] Búsqueda rápida: %s", JUBA_BUSQUEDA_URL) + await page.goto(JUBA_BUSQUEDA_URL, wait_until="domcontentloaded", timeout=self.NAVIGATION_TIMEOUT) + await page.wait_for_selector("#txtExpresionBusquedaRapida", timeout=self.SELECTOR_TIMEOUT) + + await page.fill("#txtExpresionBusquedaRapida", query) + await page.click("#btnUnicaBusqueda") + + # Esperar resultados con timeout y fallback + resultado_visible = False + try: + await page.wait_for_function( + "() => document.body.innerText.includes('RESULTADOS PARA') || document.body.innerText.includes('NO EXISTEN RESULTADOS') || document.body.innerText.includes('DEMASIADOS RESULTADOS')", + timeout=self.RESULTS_WAIT_TIMEOUT, + ) + resultado_visible = True + except Exception as e: + logger.debug(f"[JUBA] Timeout esperando texto de resultados en búsqueda rápida: {e}") + # Continuar de todas formas, puede que haya cargado + + await page.wait_for_timeout(self.AFTER_SEARCH_WAIT) + + body = await page.content() + body_upper = body.upper() + + # Verificar respuesta + if not resultado_visible: + logger.warning("[JUBA] Búsqueda rápida sin indicador de resultados visible") + # Intentar parsear de todas formas por si acaso + + if "NO EXISTEN RESULTADOS" in body_upper: + logger.debug("[JUBA] No existen resultados en búsqueda rápida") + return [] + if "DEMASIADOS RESULTADOS" in body_upper: + logger.debug("[JUBA] Demasiados resultados en búsqueda rápida") + return "TOO_MANY" + + causas = await self._parse_resultados(page) + logger.info("[JUBA] Rápida: %d resultados para '%s'", len(causas), query) + return causas + finally: + if browser: + try: + await browser.close() + except Exception: + pass # Browser ya cerrado + except Exception as e: + logger.debug("[JUBA] _search_rapida falló: %s", e) + return [] + + async def _search_integral(self, query: str, proxy_url: str | None) -> list | str: + browser = None + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + try: + logger.info("[JUBA] Búsqueda integral: %s", JUBA_INTEGRAL_URL) + await page.goto(JUBA_INTEGRAL_URL, wait_until="domcontentloaded", timeout=self.NAVIGATION_TIMEOUT) + await page.wait_for_selector("#txtExpresionBusquedaIntegral", timeout=self.SELECTOR_TIMEOUT) + + await page.fill("#txtExpresionBusquedaIntegral", query) + + checkboxes = [ + "#chkCaratula", "#chkVoces", "#chkMateria", + "#chkTextoSumario", "#chkTribunalEmisor", + ] + for cb_selector in checkboxes: + cb = await page.query_selector(cb_selector) + if cb and not await cb.is_checked(): + await cb.check() + + await page.click("#btnRealizarBusqueda") + + # Esperar resultados con timeout y fallback + resultado_visible = False + try: + await page.wait_for_function( + "() => document.body.innerText.includes('RESULTADOS PARA') || document.body.innerText.includes('NO EXISTEN RESULTADOS') || document.body.innerText.includes('DEMASIADOS RESULTADOS')", + timeout=self.RESULTS_WAIT_TIMEOUT, + ) + resultado_visible = True + except Exception as e: + logger.debug(f"[JUBA] Timeout esperando texto de resultados en búsqueda integral: {e}") + # Continuar de todas formas + + await page.wait_for_timeout(self.AFTER_SEARCH_WAIT) + + body = await page.content() + body_upper = body.upper() + + # Verificar respuesta + if not resultado_visible: + logger.warning("[JUBA] Búsqueda integral sin indicador de resultados visible") + + if "NO EXISTEN RESULTADOS" in body_upper: + logger.debug("[JUBA] No existen resultados en búsqueda integral") + return [] + if "DEMASIADOS RESULTADOS" in body_upper: + logger.debug("[JUBA] Demasiados resultados en búsqueda integral") + return "TOO_MANY" + + causas = await self._parse_resultados(page) + logger.info("[JUBA] Integral: %d resultados para '%s'", len(causas), query) + return causas + finally: + if browser: + try: + await browser.close() + except Exception: + pass # Browser ya cerrado + except Exception as e: + logger.debug("[JUBA] _search_integral falló: %s", e) + return [] + + async def _parse_resultados(self, page) -> list[dict]: + causas = [] + + body_text = await page.inner_text("body") + + result_blocks = self.REGEX_RESULT_BLOCKS.split(body_text) + + for block in result_blocks[1:]: + caratula = "" + expediente = "" + juzgado = "" + estado = "" + fecha = "" + voces = "" + sumario = "" + magistrados = "" + tipo_fallo = "" + + caratula_match = self.REGEX_CARATULA.search(block) + if caratula_match: + caratula = caratula_match.group(1).strip() + + exp_match = self.REGEX_EXPEDIENTE.search(block) + if exp_match: + expediente = exp_match.group(0).strip() + + juzgado_match = self.REGEX_JUZGADO.search(block) + if juzgado_match: + juzgado = juzgado_match.group(1).strip() + + tribunal_match = self.REGEX_TRIBUNAL.search(block) + if tribunal_match: + juzgado = juzgado or tribunal_match.group(1).strip() + + materia_match = self.REGEX_MATERIA.search(block) + if materia_match: + estado = materia_match.group(1).upper() + + fecha_match = self.REGEX_FECHA.search(block) + if not fecha_match: + fecha_match = self.REGEX_FECHA_SIMPLE.search(block) + if fecha_match: + fecha = fecha_match.group(1).strip() + + voces_match = self.REGEX_VOCES.search(block) + if voces_match: + voces = voces_match.group(1).strip() + + sumario_match = self.REGEX_SUMARIO.search(block) + if sumario_match: + sumario = sumario_match.group(1).strip()[:500] + + magistrados_match = self.REGEX_MAGISTRADOS.search(block) + if magistrados_match: + magistrados = magistrados_match.group(1).strip() + + tipo_match = self.REGEX_TIPO.search(block) + if tipo_match: + tipo_fallo = tipo_match.group(1).strip() + + if caratula or expediente: + causas.append({ + "expediente": expediente, + "caratula": caratula, + "juzgado": juzgado, + "estado": estado, + "fecha": fecha, + "voces": voces, + "sumario": sumario, + "magistrados": magistrados, + "tipo_fallo": tipo_fallo, + }) + + return causas diff --git a/app/scrapers/monotributo_historial.py b/app/scrapers/monotributo_historial.py new file mode 100644 index 0000000000000000000000000000000000000000..a6b25c81609b11a2cb1d9a35fa3a22a012c39bd1 --- /dev/null +++ b/app/scrapers/monotributo_historial.py @@ -0,0 +1,210 @@ +""" +Scraper Historial de Categorías de Monotributo — ARCA/AFIP. + +Obtiene el historial de categorías de monotributo de una persona. +La información de categorías NO está disponible públicamente vía AFIP/ARCA. +Este scraper utiliza múltiples estrategias: +1. Consulta al scraper arca_afip (con autenticación) para datos base +2. Búsqueda en web mediante DuckDuckGo para menciones públicas +3. Inferencia desde constancia de inscripción si está disponible +""" +import re +import logging +import httpx +from typing import Any +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +# Import condicional de ddgs +try: + from ddgs import DDGS + DDGS_AVAILABLE = True +except ImportError: + DDGS_AVAILABLE = False + logger.debug("[MonotributoHistorial] ddgs no instalado, búsqueda web no disponible") + + +class MonotributoHistorialScraper(BaseScraper): + source_name = "Monotributo Historial" + + # Timeouts + ARCA_TIMEOUT = 15 + DDGS_TIMEOUT = 20 + + # Regex pre-compilados + REGEX_CATEGORIA_ACTUAL = re.compile(r'categor[ií]a\s*(?:actual|vigente)?\s*[:]\s*([A-K])', re.IGNORECASE) + REGEX_CATEGORIA_SNIPPET = re.compile(r'[Cc]ategor[ií]a\s*([A-K])') + REGEX_MONOTRIBUTO_MENTION = re.compile(r'monotributo|r[ée]gimen\s+simplificado', re.IGNORECASE) + + async def fetch(self, identifier: str, **kwargs) -> Any: + """ + Busca historial de categorías de monotributo por CUIT. + Retorna dict con historial de categorías. + """ + cuit = identifier.replace("-", "").strip() + if len(cuit) != 11 or not cuit.isdigit(): + return {"historial_categorias": [], "categoria_actual": None} + + historial = [] + categoria_actual = None + es_monotributista = False + + # Fuente 1: Consultar arca_afip scraper (con autenticación) + # El webservice A13 NO retorna categoría de monotributo, pero retorna si es monotributista + try: + result_arca = await self._query_via_arca_scraper(cuit) + if result_arca: + es_monotributista = result_arca.get("es_monotributista", False) + logger.info(f"[MonotributoHistorial] CUIT {cuit[:2]}***{cuit[-2:]} es monotributista: {es_monotributista}") + except Exception as e: + logger.debug(f"[MonotributoHistorial] Consulta via arca_afip falló: {e}") + + # Fuente 2: Búsqueda web mejorada (DuckDuckGo) + if DDGS_AVAILABLE: + try: + result_web = await self._search_web_enhanced(cuit) + if result_web: + historial.extend(result_web.get("historial", [])) + if not categoria_actual and result_web.get("categoria_actual"): + categoria_actual = result_web.get("categoria_actual") + except Exception as e: + logger.debug(f"[MonotributoHistorial] Búsqueda web falló: {e}") + + # Si encontramos datos, deduplicar y ordenar + if historial: + # Deduplicar por categoría + seen = set() + historial_dedup = [] + for item in historial: + cat = item.get("categoria") + if cat and cat not in seen: + seen.add(cat) + historial_dedup.append(item) + historial = historial_dedup + + logger.info(f"[MonotributoHistorial] {len(historial)} registros únicos para CUIT {cuit[:2]}***{cuit[-2:]}") + + return { + "historial_categorias": historial, + "categoria_actual": categoria_actual, + "es_monotributista": es_monotributista, + "nota": "Información limitada: categorías de monotributo NO son públicas en AFIP/ARCA" if not historial else None + } + + async def _query_via_arca_scraper(self, cuit: str) -> dict | None: + """ + Consulta el scraper arca_afip para obtener información base. + Nota: El webservice A13 NO incluye categoría de monotributo, + pero podría indicar si la persona está inscripta en monotributo. + """ + try: + from app.scrapers.arca_afip import ArcaAfipScraper + + arca_scraper = ArcaAfipScraper() + result = await arca_scraper.fetch(cuit) + + if not result: + return None + + # El campo 'monotributo' en arca_afip fue eliminado (ver línea 111 del scraper) + # Pero podemos inferir de condicion_iva + condicion_iva = result.get("condicion_iva", "").upper() + es_monotributista = "MONOTRIBUTO" in condicion_iva or "PEQUEÑO CONTRIBUYENTE" in condicion_iva + + return { + "es_monotributista": es_monotributista, + "condicion_iva": condicion_iva + } + except Exception as e: + logger.debug(f"[MonotributoHistorial] Error en arca_afip: {e}") + return None + + async def _search_web_enhanced(self, cuit: str) -> dict | None: + """ + Búsqueda web mejorada usando DuckDuckGo con múltiples estrategias. + """ + if not DDGS_AVAILABLE: + return None + + historial = [] + categoria_actual = None + cuit_formateado = f"{cuit[:2]}-{cuit[2:10]}-{cuit[10:]}" + + # Estrategias de búsqueda mejoradas + queries = [ + # Búsqueda directa + f'"{cuit_formateado}" monotributo categoría', + f'"{cuit}" monotributo categoría', + # Búsqueda en sitios específicos + f'site:afip.gob.ar "{cuit}" monotributo', + f'site:argentina.gob.ar "{cuit_formateado}" monotributo', + # Búsqueda de constancias + f'"{cuit_formateado}" constancia inscripción monotributo', + # Búsqueda amplia + f'CUIT {cuit} régimen simplificado categoría', + ] + + try: + ddgs = DDGS(timeout=self.DDGS_TIMEOUT) + + for query in queries: + try: + results = ddgs.text(query, max_results=5) + + for r in results: + title = r.get("title", "") + snippet = r.get("body", "") + url = r.get("href", "") + full_text = f"{title} {snippet}".upper() + + # Verificar que mencione monotributo + if not self.REGEX_MONOTRIBUTO_MENTION.search(full_text): + continue + + # Buscar categorías mencionadas (A-K) + cats = self.REGEX_CATEGORIA_SNIPPET.findall(full_text) + + for cat in cats: + cat_upper = cat.upper() + + # Determinar si es categoría actual o histórica + es_actual = any(keyword in full_text for keyword in [ + "ACTUAL", "VIGENTE", "CURRENT", f"CATEGORÍA {cat_upper}" + ]) + + item = { + "categoria": cat_upper, + "fuente": url if url else "Web", + "snippet": snippet[:200] if snippet else title[:200], + "es_actual": es_actual + } + + historial.append(item) + + # Si es categoría actual y aún no tenemos una + if es_actual and not categoria_actual: + categoria_actual = cat_upper + + except Exception as e: + logger.debug(f"[MonotributoHistorial] Query '{query}' falló: {e}") + continue + + # Si ya encontramos resultados, no seguir buscando + if len(historial) >= 3: + break + + # Limitar a 10 resultados + historial = historial[:10] + + if historial: + logger.info(f"[MonotributoHistorial] Búsqueda web encontró {len(historial)} menciones") + + return { + "historial": historial, + "categoria_actual": categoria_actual + } + + except Exception as e: + logger.debug(f"[MonotributoHistorial] Error general en búsqueda web: {e}") + return None diff --git a/app/scrapers/name_search.py b/app/scrapers/name_search.py new file mode 100644 index 0000000000000000000000000000000000000000..3ea08acde55c9120ceb3a051f12b426258c2a226 --- /dev/null +++ b/app/scrapers/name_search.py @@ -0,0 +1,170 @@ +"""Scraper de Búsqueda por Nombre — Localiza CUITs a partir de nombres/apellidos. + +Fuentes: +1. DuckDuckGo OSINT — Búsqueda general por nombre + CUIT + Argentina +2. cuitonline.com — Bloqueado/Premium (HTTP 403) + +Nota: Usa búsqueda web (DuckDuckGo) para encontrar menciones públicas de +CUITs asociados a nombres. cuitonline.com requiere ahora suscripción premium. +""" +import re +import logging +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +# Import condicional de ddgs +try: + from ddgs import DDGS + DDGS_AVAILABLE = True +except ImportError: + DDGS_AVAILABLE = False + logger.debug("[NameSearch] ddgs no instalado, búsqueda no disponible") + + +class NameSearchScraper(BaseScraper): + source_name = "Name Search OSINT" + + # Timeouts + DDGS_TIMEOUT = 20 + + # Regex pre-compilados + REGEX_CUIT_11_DIGITS = re.compile(r'\b(\d{11})\b') + REGEX_CUIT_FORMATTED = re.compile(r'\b(\d{2}[-\s]?\d{8}[-\s]?\d)\b') + REGEX_NOMBRE_APELLIDO = re.compile(r'\b([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,3})\b') + + async def fetch(self, query: str, **kwargs) -> list[dict]: + """ + Busca CUITs asociados a un nombre/apellido mediante búsqueda web. + + Args: + query: Nombre completo o apellido a buscar (ej: "Juan Perez", "Perez") + + Returns: + Lista de dicts con CUIT, nombre y fuente encontrados + + Validación: + - Query no puede estar vacío + - CUITs deben tener exactamente 11 dígitos + - Se eliminan duplicados automáticamente + """ + # Validación: query vacío + if not query or not query.strip(): + logger.debug("[NameSearch] Query vacío, retornando lista vacía") + return [] + + if not DDGS_AVAILABLE: + logger.warning("[NameSearch] ddgs no disponible, no se puede realizar búsqueda") + return [] + + query = query.strip() + results = [] + cuit_seen = set() + + try: + # Estrategias de búsqueda múltiples + queries = [ + f'"{query}" CUIT Argentina', + f'"{query}" CUIL Argentina', + f'{query} "CUIT" site:afip.gob.ar', + f'{query} "CUIT" site:argentina.gob.ar', + f'{query} constancia inscripción CUIT', + ] + + ddgs = DDGS(timeout=self.DDGS_TIMEOUT) + + for search_query in queries: + try: + logger.info(f"[NameSearch] Buscando: {search_query}") + search_results = ddgs.text(search_query, max_results=5) + + for item in search_results: + title = item.get("title", "") + snippet = item.get("body", "") + url = item.get("href", "") + full_text = f"{title} {snippet}".upper() + + # Verificar que mencione el nombre buscado + query_upper = query.upper() + if query_upper not in full_text: + continue + + # Buscar CUITs en el texto + # Primero intentar formato con guiones/espacios + cuits_formatted = self.REGEX_CUIT_FORMATTED.findall(full_text) + for cuit_raw in cuits_formatted: + cuit = cuit_raw.replace("-", "").replace(" ", "").strip() + if len(cuit) == 11 and cuit.isdigit() and cuit not in cuit_seen: + cuit_seen.add(cuit) + + # Intentar extraer nombre del contexto + nombre = self._extract_nombre(full_text, query) + + results.append({ + "cuit": cuit, + "nombre": nombre, + "fuente": f"Web ({url[:30]}...)" if url else "Web" + }) + + # Luego buscar CUITs de 11 dígitos sin formato + cuits_plain = self.REGEX_CUIT_11_DIGITS.findall(full_text) + for cuit in cuits_plain: + if len(cuit) == 11 and cuit not in cuit_seen: + # Validar que no sea una fecha u otro número + if self._is_valid_cuit_prefix(cuit): + cuit_seen.add(cuit) + nombre = self._extract_nombre(full_text, query) + results.append({ + "cuit": cuit, + "nombre": nombre, + "fuente": f"Web ({url[:30]}...)" if url else "Web" + }) + + except Exception as e: + logger.debug(f"[NameSearch] Error en query '{search_query}': {e}") + continue + + # Si ya encontramos suficientes resultados, parar + if len(results) >= 10: + break + + logger.info(f"[NameSearch] Encontrados {len(results)} resultados únicos") + + # Limitar a 10 resultados + results = results[:10] + + except Exception as e: + logger.warning(f"[NameSearch] Error general en búsqueda para '{query}': {e}") + + return results + + def _is_valid_cuit_prefix(self, cuit: str) -> bool: + """ + Valida que el CUIT tenga un prefijo válido. + Prefijos válidos en Argentina: 20, 23, 24, 27, 30, 33, 34 + """ + if not cuit or len(cuit) != 11: + return False + + prefix = cuit[:2] + valid_prefixes = {'20', '23', '24', '27', '30', '33', '34'} + return prefix in valid_prefixes + + def _extract_nombre(self, text: str, query: str) -> str: + """ + Extrae el nombre completo del texto, priorizando el query original. + """ + # Primero intentar usar el query original normalizado + query_parts = query.strip().split() + if len(query_parts) >= 2: + # Si tiene nombre y apellido, usar como está + return " ".join(query_parts).upper() + + # Si solo es apellido, intentar encontrar nombre completo en texto + matches = self.REGEX_NOMBRE_APELLIDO.findall(text) + for match in matches: + if query.upper() in match.upper(): + return match.upper() + + # Fallback: usar query original + return query.upper() diff --git a/app/scrapers/padron_electoral.py b/app/scrapers/padron_electoral.py new file mode 100644 index 0000000000000000000000000000000000000000..2a81065eeaa5e00f6218c1f47e9e476699f037a7 --- /dev/null +++ b/app/scrapers/padron_electoral.py @@ -0,0 +1,236 @@ +""" +Padrón Electoral — Cámara Nacional Electoral (Playwright). +Consultas al padrón nacional público (electoral). + +NOTA: Si el portal está OFFLINE (redirige a offline.html), retorna {} limpiamente. +""" +import asyncio +import logging +import re +from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout +from app.scrapers.base import BaseScraper, ScraperError +from app.utils.captcha import CaptchaSolver +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class PadronElectoralScraper(BaseScraper): + uses_playwright = True + source_name = "Padrón Electoral" + + # Timeouts (en milisegundos) + NAVIGATION_TIMEOUT = 20000 + FORM_WAIT_TIMEOUT = 8000 + RESULTS_WAIT_TIMEOUT = 12000 + + # reCAPTCHA sitekey del padrón electoral + RECAPTCHA_SITEKEY = "6LcPscEUAAAAAKolqW6NwL9hpCe2cHaVhQFLdRE3" + + async def fetch(self, cuit: str, **kwargs) -> dict: + cuit_clean = self.clean_cuit(cuit) + dni = cuit_clean[2:10] if len(cuit_clean) == 11 else cuit_clean.lstrip("0") + + # Filtrar CUITs de empresas/sociedades (no tienen padrón electoral) + if len(cuit_clean) == 11: + prefix = cuit_clean[:2] + if prefix in ("30", "33", "34"): + logger.info(f"[PadronElectoral] CUIT {cuit_clean} es empresa/sociedad, no aplica padrón electoral") + return { + "padron_electoral": {}, + "nota": "CUIT de empresa/sociedad no aplica a padrón electoral" + } + + genero = kwargs.get("genero", "").strip().upper() + if not genero: + if len(cuit_clean) == 11: + prefix = cuit_clean[:2] + if prefix == "27": + genero = "F" + elif prefix == "20": + genero = "M" + # Para 23/24 (ambiguos) intentamos M primero, luego F + if not genero: + genero = "M" + + # Para prefijos ambiguos (23/24), intentar con ambos géneros + prefix = cuit_clean[:2] if len(cuit_clean) == 11 else "" + generos_a_intentar = [genero] + if prefix in ("23", "24") and "genero" not in kwargs: + generos_a_intentar = ["M", "F"] + + for gen in generos_a_intentar: + result = await self._try_fetch(cuit_clean, dni, gen) + if result: + return result + + return {} + + async def _try_fetch(self, cuit_clean: str, dni: str, genero: str) -> dict: + """ + Intenta consultar el padrón electoral. + NOTA: Portal tiene reCAPTCHA complejo - puede fallar frecuentemente. + """ + proxy_url = self.get_proxy() + + try: + async with async_playwright() as p: + launch_args = ["--no-sandbox", "--disable-dev-shm-usage"] + proxy_settings = {"server": proxy_url} if proxy_url else None + + browser = await p.chromium.launch( + headless=settings.playwright_headless, + args=launch_args, + proxy=proxy_settings + ) + context = await browser.new_context( + user_agent=self.get_random_user_agent(), + viewport={"width": 1920, "height": 1080}, + locale="es-AR" + ) + page = await context.new_page() + try: + logger.info(f"[PadronElectoral] Consultando DNI {dni} - Género {genero}") + response = await page.goto( + "https://www.padron.gob.ar/", + wait_until="domcontentloaded", + timeout=self.NAVIGATION_TIMEOUT + ) + + # Verificar si está offline + if response and "offline" in str(response.url): + logger.info("[PadronElectoral] Portal fuera de servicio (offline)") + return {"padron_electoral": {}, "nota": "Portal offline"} + + await asyncio.sleep(1) + + # Verificar formulario + try: + await page.wait_for_selector( + "#documento, input[name='documento']", + timeout=self.FORM_WAIT_TIMEOUT + ) + except PlaywrightTimeout: + if "offline" in page.url: + return {"padron_electoral": {}, "nota": "Portal offline"} + logger.warning("[PadronElectoral] Formulario no encontrado") + return {"padron_electoral": {}, "nota": "Formulario no disponible"} + + # Llenar formulario + await page.fill("#documento", dni) + + genero_select = await page.query_selector("#genero, select[name='genero']") + if genero_select: + await page.select_option("#genero", genero) + + distrito_select = await page.query_selector("#distrito, select[name='distrito']") + if distrito_select: + await page.select_option("#distrito", "0") + + # Intentar resolver CAPTCHA + solver = CaptchaSolver() + captcha_ok = False + + # Si hay API key de 2Captcha, usar el servicio + if settings.captcha_api_key: + try: + logger.info("[PadronElectoral] Intentando resolver reCAPTCHA con 2Captcha API...") + captcha_token = await solver.solve_recaptcha_v2( + site_key=self.RECAPTCHA_SITEKEY, + url="https://www.padron.gob.ar/" + ) + if captcha_token: + # Inyectar el token en el formulario + await page.evaluate(f""" + () => {{ + const textarea = document.querySelector('textarea[name="g-recaptcha-response"]'); + if (textarea) {{ + textarea.value = '{captcha_token}'; + }} + }} + """) + captcha_ok = True + logger.info("[PadronElectoral] reCAPTCHA resuelto con 2Captcha") + except Exception as cap_exc: + logger.warning(f"[PadronElectoral] 2Captcha falló: {cap_exc}") + + # Si no hay service o falló, intentar método audio (menos confiable) + if not captcha_ok: + try: + logger.info("[PadronElectoral] Intentando resolver reCAPTCHA con método audio...") + captcha_ok = await solver.solve_recaptcha_v2_audio(page, max_retries=2) + if captcha_ok: + logger.info("[PadronElectoral] reCAPTCHA resuelto con método audio") + except Exception as cap_exc: + logger.warning(f"[PadronElectoral] CAPTCHA audio falló: {cap_exc}") + + if not captcha_ok: + logger.warning("[PadronElectoral] CAPTCHA no resuelto - Considerar usar servicio pago (2Captcha)") + return { + "padron_electoral": {}, + "nota": "Portal requiere CAPTCHA - Usar servicio manual o API paga", + "captcha_required": True + } + + # Submit + btn_submit = await page.query_selector( + "input[type='submit'], button[type='submit'], #btnBuscar" + ) + if btn_submit: + await btn_submit.click() + else: + await page.keyboard.press("Enter") + + # Esperar resultados + try: + await page.wait_for_selector( + "table.resultado, div.resultado table, #resultado table", + timeout=self.RESULTS_WAIT_TIMEOUT + ) + except PlaywrightTimeout: + logger.warning("[PadronElectoral] Timeout esperando resultados") + return {"padron_electoral": {}} + + # Parsear resultados + info_padron = {} + table = await page.query_selector("table") + if table: + rows = await table.query_selector_all("tr") + for row in rows: + cols = await row.query_selector_all("td") + if len(cols) >= 2: + key = (await cols[0].inner_text()).strip().lower().replace(":", "") + val = (await cols[1].inner_text()).strip() + + if "documento" in key or "dni" in key: + info_padron["dni"] = val + elif "nombre" in key or "apellido" in key: + info_padron["nombre_completo"] = val + elif "nacimiento" in key: + info_padron["fecha_nacimiento"] = val + elif "distrito" in key: + info_padron["distrito"] = val + elif "establecimiento" in key: + info_padron["establecimiento"] = val + elif "mesa" in key: + info_padron["mesa"] = val + + if info_padron: + logger.info(f"[PadronElectoral] Datos encontrados para DNI {dni}") + return {"padron_electoral": info_padron} + + # Verificar si no está en padrón + body_text = await page.inner_text("body") + if "no se encuentra" in body_text.lower() or "no figura" in body_text.lower(): + logger.info(f"[PadronElectoral] DNI {dni} no figura en el padrón") + return {"padron_electoral": {}, "nota": "No figura en padrón"} + + return {"padron_electoral": {}} + + finally: + await browser.close() + + except Exception as e: + logger.debug(f"[PadronElectoral] Error: {e}") + return {"padron_electoral": {}, "nota": f"Error: {type(e).__name__}"} diff --git a/app/scrapers/poder_judicial.py b/app/scrapers/poder_judicial.py new file mode 100644 index 0000000000000000000000000000000000000000..ec64cf86034d38c22a8595651379b49fcda1c9be --- /dev/null +++ b/app/scrapers/poder_judicial.py @@ -0,0 +1,525 @@ +import asyncio +import base64 +import logging +import re +from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout, Frame +from app.scrapers.base import BaseScraper, ScraperError +from app.utils.captcha import CaptchaSolver + +logger = logging.getLogger(__name__) + + +class PoderJudicialScraper(BaseScraper): + uses_playwright = True + source_name = "Poder Judicial" + base_url = "https://scw.pjn.gov.ar/scw/home.seam" + SAFE_FETCH_TIMEOUT = 60 + max_retries = 1 + + # Timeouts (milisegundos) + DEFAULT_NAVIGATION_TIMEOUT = 45000 + DEFAULT_ELEMENT_TIMEOUT = 12000 + FORM_WAIT_TIMEOUT = 10000 + RESULTS_WAIT_TIMEOUT = 15000 + CAPTCHA_FRAME_WAIT = 2000 + + # Delays (segundos) + INITIAL_PAGE_DELAY = 3 + AFTER_TAB_CLICK_DELAY = 2 + AFTER_VER_DESAFIO_DELAY = 2.5 + AFTER_CAPTCHA_TYPE_DELAY = 0.3 + AFTER_CAPTCHA_ACCEPT_DELAY = 3 + AFTER_FILL_DELAY = 0.3 + AFTER_SUBMIT_DELAY = 10 + RESULTS_CHECK_DELAY = 3 + DOUBLE_CLICK_DELAY = 0.5 + + # Selectores CSS + SELECTOR_TAB_POR_PARTE = [ + "td#formPublica\\:porParte\\:header\\:inactive", + "td[id='formPublica:porParte:header:inactive']", + ] + + SELECTOR_INPUT_NOMBRE = [ + "input[id*='nomIntervParte']", + "input[name*='nomIntervParte']", + ] + + SELECTOR_BUTTON_BUSCAR = [ + "input[id*='buscarPorParteButton']", + "button[id*='buscarPorParteButton']", + ] + + SELECTOR_JURISDICCION = "select#formPublica\\:camaraPartes" + + SELECTOR_CAPTCHA_TOKEN = [ + "input#captcha-response", + "input[name='captcha-response']", + ] + + SELECTOR_TABLA_RESULTADOS = "table[id*='tablaPartes']" + SELECTOR_NO_RESULTS = "td:has-text('No se encontraron')" + + async def fetch(self, cuit_or_name: str, **kwargs) -> dict: + cuit_clean = self.clean_cuit(cuit_or_name) if cuit_or_name.replace('-', '').isdigit() else None + identifier = self.format_cuit(cuit_clean) if cuit_clean else cuit_or_name + apellido = kwargs.get("apellido", "").lower().strip() + result = await self._fetch_web(identifier) + if apellido and result.get("causas"): + filtered = [] + for c in result["causas"]: + caratula = (c.get("caratula", "") or c.get("caratula_completa", "")).lower() + if caratula and apellido in caratula: + filtered.append(c) + result["causas"] = filtered + return result + + async def _fetch_web(self, identifier: str) -> dict: + proxy_url = self.get_proxy() + try: + async with async_playwright() as p: + browser, context, page = await self.get_stealth_context(p, proxy_url) + try: + return await self._pjn_flow(page, identifier) + finally: + await browser.close() + except ScraperError: + raise + except Exception as e: + self.logger.warning(f"[PJN] Error tecnico: {e}") + raise ScraperError(self.source_name, f"Error tecnico: {e}") + + async def _pjn_flow(self, page, identifier: str) -> dict: + self.logger.info(f"[PJN] Navegando a {self.base_url}") + try: + await page.goto(self.base_url, wait_until="commit", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + except Exception as e: + self.logger.debug(f"[PJN] goto warning: {e}") + await asyncio.sleep(self.INITIAL_PAGE_DELAY) + + captcha_frame = await self._wait_for_captcha_frame(page) + if not captcha_frame: + self.logger.warning("[PJN] CAPTCHA frame no aparecio") + return {"causas": [], "nota": "CAPTCHA frame no disponible"} + + tab_ok = await self._click_por_parte_tab(page) + if tab_ok: + await asyncio.sleep(self.AFTER_TAB_CLICK_DELAY) + + form_ok = await self._wait_for_por_parte_form(page) + if not form_ok: + self.logger.warning("[PJN] Formulario Por parte no disponible") + return {"causas": [], "nota": "Formulario no disponible"} + + captcha_ok = await self._solve_captcha(page, captcha_frame) + if not captcha_ok: + return {"causas": [], "nota": "CAPTCHA no resuelto"} + + await self._fill_and_submit(page, identifier) + + causas = await self._parse_results(page) + self.logger.info(f"[PJN] {len(causas)} causas para '{identifier}'") + return {"causas": causas} + + async def _click_por_parte_tab(self, page) -> bool: + self.logger.debug("[PJN] Click pestaña Por parte") + for sel in self.SELECTOR_TAB_POR_PARTE: + try: + el = await page.query_selector(sel) + if el and await el.is_visible(): + await el.click() + return True + except Exception: + continue + try: + await page.evaluate( + "document.getElementById('formPublica:porParte:header:inactive')?.click()" + ) + return True + except Exception as e: + self.logger.warning(f"[PJN] No se pudo clickear pestana: {e}") + return False + + async def _wait_for_por_parte_form(self, page) -> bool: + for sel in self.SELECTOR_INPUT_NOMBRE: + try: + await page.wait_for_selector(sel, timeout=self.FORM_WAIT_TIMEOUT) + return True + except PlaywrightTimeout: + continue + return "nomIntervParte" in (await page.content()) + + async def _get_captcha_frame(self, page) -> Frame | None: + for frame in page.frames: + if frame.name == "captcha-frame": + return frame + for frame in page.frames: + if "captcha.pjn.gov.ar" in frame.url: + return frame + return None + + async def _wait_for_captcha_frame(self, page, max_attempts=8) -> Frame | None: + for i in range(max_attempts): + for frame in page.frames: + furl = frame.url or "" + fname = frame.name or "" + if "captcha" in furl.lower() or "captcha" in fname.lower(): + return frame + if i < 3: + self.logger.debug(f"[PJN] Esperando CAPTCHA frame ({i+1}/{max_attempts})") + await asyncio.sleep(self.CAPTCHA_FRAME_WAIT / 1000) # Convertir ms a segundos + return None + + async def _solve_captcha(self, page, captcha_frame: Frame) -> bool: + self.logger.info("[PJN] Resolviendo CAPTCHA...") + + clicked = await self._click_ver_desafio(captcha_frame) + if not clicked: + self.logger.warning("[PJN] No se pudo clickear VER DESAFIO") + return False + + await asyncio.sleep(self.AFTER_VER_DESAFIO_DELAY) + + image_bytes = await self._extract_captcha_image(captcha_frame) + if not image_bytes: + self.logger.warning("[PJN] No se pudo extraer imagen CAPTCHA") + return False + + # Guardar imagen para debugging (ruta relativa al proyecto) + try: + from pathlib import Path + project_root = Path(__file__).parent.parent.parent + debug_path = project_root / "scripts" / "pjn_last_captcha.png" + debug_path.parent.mkdir(parents=True, exist_ok=True) + with open(debug_path, "wb") as f: + f.write(image_bytes) + self.logger.debug(f"[PJN] CAPTCHA guardado en {debug_path}") + except Exception as e: + self.logger.debug(f"[PJN] No se pudo guardar CAPTCHA debug: {e}") + + text = await self._ocr_captcha(image_bytes) + if not text: + self.logger.warning("[PJN] OCR no pudo leer CAPTCHA") + return False + + self.logger.info(f"[PJN] CAPTCHA OCR: '{text}'") + + await self._type_captcha_answer(captcha_frame, text) + await asyncio.sleep(self.AFTER_CAPTCHA_TYPE_DELAY) + await self._click_accept(captcha_frame) + await asyncio.sleep(self.AFTER_CAPTCHA_ACCEPT_DELAY) + + token = await self._get_captcha_token(page) + if token: + self.logger.info(f"[PJN] CAPTCHA resuelto, token: {token[:20]}...") + return True + + self.logger.warning("[PJN] CAPTCHA fallo (sin token tras ACEPTAR)") + return False + + async def _click_ver_desafio(self, frame: Frame) -> bool: + try: + btn = await frame.query_selector("button.terminos-button") + if btn and await btn.is_visible(): + await btn.click() + return True + except Exception: + pass + try: + await frame.evaluate("document.querySelector('button.terminos-button')?.click()") + return True + except Exception: + return False + + async def _extract_captcha_image(self, frame: Frame) -> bytes | None: + try: + img = await frame.query_selector(".text-challenge-image img") + if not img: + img = await frame.query_selector("img[src*='data:image']") + if not img: + return None + + src = await img.get_attribute("src") + if not src: + return None + + if src.startswith("data:image"): + b64_data = src.split(",", 1)[1] + elif re.match(r"^[A-Za-z0-9+/=]{100,}$", src): + b64_data = src + else: + return None + + padding = len(b64_data) % 4 + if padding: + b64_data += "=" * (4 - padding) + + return base64.b64decode(b64_data) + except Exception as e: + self.logger.debug(f"[PJN] Error extrayendo imagen: {e}") + return None + + async def _ocr_captcha(self, image_bytes: bytes) -> str | None: + solver = CaptchaSolver() + + b64 = base64.b64encode(image_bytes).decode() + + text = await solver.solve_image_captcha_local(b64) + if text and len(text) >= 3: + self.logger.info(f"[PJN] OCR ddddocr: '{text}'") + return text + + text = await solver.solve_image_captcha_preprocessed(image_bytes) + if text and len(text) >= 3: + self.logger.info(f"[PJN] OCR preprocessed: '{text}'") + return text + + text = await solver.solve_image_captcha_groq(image_bytes) + if text and len(text) >= 3: + self.logger.info(f"[PJN] OCR Groq: '{text}'") + return text + + return None + + async def _type_captcha_answer(self, frame: Frame, text: str): + try: + inp = await frame.query_selector("input.text-challenge-input") + if inp: + await inp.fill("") + await inp.type(text, delay=50) + return + except Exception: + pass + try: + await frame.evaluate( + f"document.querySelector('input.text-challenge-input').value = '{text}'" + ) + except Exception: + pass + + async def _click_accept(self, frame: Frame): + try: + btn = await frame.query_selector("button.accept-challenge-button") + if btn and await btn.is_visible(): + await btn.click() + await asyncio.sleep(self.DOUBLE_CLICK_DELAY) + # Double click: el sitio requiere dos clicks + if await btn.is_visible(): + await btn.click() + return + except Exception: + pass + try: + await frame.evaluate( + f"document.querySelector('button.accept-challenge-button')?.click();" + f"setTimeout(() => document.querySelector('button.accept-challenge-button')?.click(), {int(self.DOUBLE_CLICK_DELAY * 1000)});" + ) + except Exception: + pass + + async def _get_captcha_token(self, page) -> str: + # Intentar ambos selectores + selectors_js = [ + "document.getElementById('captcha-response')?.value || ''", + "document.querySelector('input[name=\"captcha-response\"]')?.value || ''", + ] + for js in selectors_js: + try: + token = await page.evaluate(js) + if token: + return token + except Exception: + pass + return "" + + async def _fill_and_submit(self, page, identifier: str): + # Seleccionar jurisdicción "Todos/Todas" + selected_jurisdiccion = await page.evaluate(""" + (() => { + const sel = document.getElementById('formPublica:camaraPartes'); + if (!sel) return 'no_select'; + for (const o of sel.options) { + if (o.text.toLowerCase().includes('todos') || o.text.toLowerCase().includes('todas')) { + o.selected = true; + sel.dispatchEvent(new Event('change', {bubbles: true})); + return 'selected: ' + o.text; + } + } + if (sel.options.length > 1) { + sel.options[1].selected = true; + sel.dispatchEvent(new Event('change', {bubbles: true})); + return 'selected first: ' + sel.options[1].text; + } + return 'no_options'; + })() + """) + self.logger.info(f"[PJN] Jurisdiccion: {selected_jurisdiccion}") + + # Llenar campo de nombre + filled = False + for sel in self.SELECTOR_INPUT_NOMBRE: + try: + el = await page.query_selector(sel) + if el: + await el.fill(identifier) + filled = True + break + except Exception: + continue + + if not filled: + self.logger.warning("[PJN] No se pudo llenar campo nombre") + + await asyncio.sleep(self.AFTER_FILL_DELAY) + + # Click botón buscar + for sel in self.SELECTOR_BUTTON_BUSCAR: + try: + el = await page.query_selector(sel) + if el and await el.is_visible(): + await el.click(no_wait_after=True) + self.logger.debug("[PJN] Submit clicked") + await asyncio.sleep(self.AFTER_SUBMIT_DELAY) + return + except Exception: + continue + + # Fallback: Enter + try: + await page.keyboard.press("Enter") + except Exception: + pass + + async def _parse_results(self, page) -> list[dict]: + # Esperar resultados con múltiples intentos + resultados_aparecieron = False + for attempt in range(5): + try: + await page.wait_for_selector( + f"{self.SELECTOR_TABLA_RESULTADOS}, {self.SELECTOR_NO_RESULTS}, .ui-messages-error", + timeout=self.RESULTS_WAIT_TIMEOUT, + ) + resultados_aparecieron = True + break + except PlaywrightTimeout: + self.logger.debug(f"[PJN] Esperando resultados intento {attempt+1}") + if attempt < 4: # No esperar después del último intento + await asyncio.sleep(self.RESULTS_CHECK_DELAY) + + # Si después de 5 intentos no aparecieron resultados, retornar vacío + if not resultados_aparecieron: + self.logger.warning("[PJN] Timeout esperando resultados, retornando vacío") + return [] + + # Verificar si no hay resultados + try: + no_results = await page.query_selector(self.SELECTOR_NO_RESULTS) + if no_results: + self.logger.info("[PJN] Sin resultados para esta busqueda") + return [] + except Exception: + pass + + # Buscar tabla de resultados + causas = [] + tabla = await page.query_selector(self.SELECTOR_TABLA_RESULTADOS) + if not tabla: + self.logger.debug("[PJN] No se encontro tabla de resultados") + return [] + + filas = await tabla.query_selector_all("tr") + self.logger.debug(f"[PJN] Tabla tiene {len(filas)} filas") + + # Parsear headers para detectar orden de columnas + col_map = await self._parse_table_headers(filas[0] if filas else None) + + # Parsear filas de datos + for fila in filas[1:]: + celdas = await fila.query_selector_all("td") + if len(celdas) < 2: + continue + try: + causa = { + "expediente": await self._get_cell_text(celdas, col_map.get("expediente", 0)), + "fuero": "Nacional", + "caratula": await self._get_cell_text(celdas, col_map.get("caratula", 1)), + "estado": await self._get_cell_text(celdas, col_map.get("estado", 2)) or "Activa", + "juzgado": await self._get_cell_text(celdas, col_map.get("juzgado", 3)), + "jurisdiccion": "Nacional", + "fecha": await self._get_cell_text(celdas, col_map.get("fecha", 4)), + "caratula_completa": await self._get_cell_text(celdas, col_map.get("caratula_completa", 5)), + } + causas.append(causa) + except Exception as e: + self.logger.debug(f"[PJN] Error parseando fila: {e}") + continue + + return causas + + async def _parse_table_headers(self, header_row) -> dict: + """ + Parsea headers de tabla para detectar orden de columnas. + Retorna mapeo de campo → índice de columna. + """ + if not header_row: + # Fallback: orden por defecto + return { + "expediente": 0, + "caratula": 1, + "estado": 2, + "juzgado": 3, + "fecha": 4, + "caratula_completa": 5, + } + + try: + header_cells = await header_row.query_selector_all("th, td") + headers = [] + for cell in header_cells: + text = (await cell.inner_text()).strip().lower() + headers.append(text) + + # Mapear columnas por nombre + col_map = {} + for i, h in enumerate(headers): + if "expediente" in h or "número" in h or "nro" in h: + col_map["expediente"] = i + elif "carátula" in h and "completa" not in h: + col_map["caratula"] = i + elif "estado" in h or "situación" in h: + col_map["estado"] = i + elif "juzgado" in h or "tribunal" in h or "órgano" in h: + col_map["juzgado"] = i + elif "fecha" in h: + col_map["fecha"] = i + elif "completa" in h: + col_map["caratula_completa"] = i + + # Asegurar que al menos tenemos expediente y carátula + if "expediente" not in col_map: + col_map["expediente"] = 0 + if "caratula" not in col_map: + col_map["caratula"] = 1 if 1 < len(headers) else 0 + + self.logger.debug(f"[PJN] Mapeo columnas: {col_map}") + return col_map + + except Exception as e: + self.logger.debug(f"[PJN] Error parseando headers: {e}, usando fallback") + return { + "expediente": 0, + "caratula": 1, + "estado": 2, + "juzgado": 3, + "fecha": 4, + "caratula_completa": 5, + } + + async def _get_cell_text(self, cells: list, index: int) -> str: + """Helper para extraer texto de celda con validación.""" + try: + if 0 <= index < len(cells): + return (await cells[index].inner_text()).strip() + except Exception: + pass + return "" diff --git a/app/scrapers/poder_judicial_provincial.py b/app/scrapers/poder_judicial_provincial.py new file mode 100644 index 0000000000000000000000000000000000000000..d648201dd5698d75937e69b7bf839836584ae89a --- /dev/null +++ b/app/scrapers/poder_judicial_provincial.py @@ -0,0 +1,909 @@ +""" +Scraper del Poder Judicial Provincial — Cobertura Nacional (24 jurisdicciones). + +Métodos: + Patchright: Córdoba (Turnstile auto-solve con channel=chrome), Tucumán (SAE), + Corrientes (GeneXus SPA - fallos judiciales) + Playwright: Misiones (carátula), Río Negro (URL GET) + aiohttp: Formosa (busqueda Joomla), Santiago del Estero (API despachos) + ddgs fallback: resto de provincias con filtrado estricto +""" +import asyncio +import logging +import re +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +class PoderJudicialProvincialScraper(BaseScraper): + uses_playwright = True + source_name = "Poder Judicial Provincial" + SAFE_FETCH_TIMEOUT = 60 + max_retries = 1 # Playwright timeouts rarely improve with retries + + def _make(self, exp, fuero, juzgado, caratula, fecha, estado, jurisd): + return { + "expediente": exp or "N/A", + "fuero": fuero or "", + "juzgado": juzgado or "", + "caratula": caratula or "", + "fecha": fecha or "", + "estado": estado or "", + "jurisdiccion": jurisd or "", + } + + async def fetch(self, identifier: str, **kwargs) -> dict: + nombre = kwargs.get("nombre", "") + apellido = kwargs.get("apellido", "").lower().strip() + if not nombre: + return {"causas_provinciales": []} + + all_results = [] + failed = set() + + pw_tasks = [ + self._fetch_misiones_pw(nombre), + self._fetch_rio_negro_pw(nombre), + self._fetch_cordoba_pw(nombre, apellido=apellido), + self._fetch_tucuman_pw(nombre, apellido=apellido), + self._fetch_formosa(nombre), + self._fetch_corrientes(nombre), + self._fetch_santiago(nombre), + self._fetch_salta(nombre), + self._fetch_catamarca_pw(nombre, apellido=apellido) + ] + pw_names = [ + "Misiones", "Río Negro", "Córdoba", "Tucumán", "Formosa", + "Corrientes", "Santiago del Estero", "Salta", "Catamarca" + ] + + results = await asyncio.gather(*pw_tasks, return_exceptions=True) + for name, res in zip(pw_names, results): + if isinstance(res, Exception): + logger.debug(f"[PJProv] {name} failed: {res}") + failed.add(name) + elif isinstance(res, list): + all_results.extend(res) + if not res: + failed.add(name) + + # Determinar qué provincias deben consultarse por DuckDuckGo + all_provs = [ + "Nacional", "Buenos Aires", "CABA", "Córdoba", "Santa Fe", "Mendoza", + "Tucumán", "Salta", "Chaco", "Formosa", "Misiones", "Río Negro", + "Santa Cruz", "Corrientes", "Santiago del Estero", "Catamarca", + "San Juan", "San Luis", "La Rioja", "Entre Ríos", "La Pampa", + "Jujuy", "Chubut", "Tierra del Fuego", "Neuquén" + ] + + # Consultar por DDG si no está entre los scrapers directos exitosos + exitosis = [n for n in pw_names if n not in failed] + ddg_provs = [p for p in all_provs if p not in exitosis] + + if ddg_provs and apellido: + ddg_results = await self._fetch_duckduckgo_batch(apellido, ddg_provs) + all_results.extend(ddg_results) + + def normalizar(t): + import unicodedata + t = t.lower().strip() + t = unicodedata.normalize('NFD', t) + return "".join(c for c in t if unicodedata.category(c) != 'Mn') + + seen = set() + unique = [] + nombre_norm = normalizar(nombre) + apellido_norm = normalizar(apellido) if apellido else "" + + for r in all_results: + key = (r.get("expediente", ""), r.get("fecha", ""), r.get("jurisdiccion", "")) + if key in seen: + continue + seen.add(key) + + caratula = normalizar(r.get("caratula", "") or "") + if not caratula: + continue + + # Filtro estricto anti-falsos positivos + if apellido_norm: + if apellido_norm not in caratula: + continue + # Validar primer nombre para evitar homonimias sencillas + partes_nombre = nombre_norm.split() + if partes_nombre: + primer_nombre = partes_nombre[0] + if len(primer_nombre) > 2 and primer_nombre not in caratula: + continue + else: + if nombre_norm not in caratula: + continue + + unique.append(r) + + return {"causas_provinciales": unique} + + # ─── MISIONES ─── Playwright + async def _fetch_misiones_pw(self, nombre: str) -> list[dict]: + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"]) + ctx = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0") + page = await ctx.new_page() + try: + await page.goto( + "https://www.jusmisiones.gov.ar/consultas_online/forms/expedientes/listado.php", + wait_until="domcontentloaded", timeout=20000) + await page.wait_for_timeout(3000) + + caratula = await page.query_selector("#caratula") + if not caratula: + return [] + await caratula.fill(nombre) + await page.keyboard.press("Enter") + await page.wait_for_timeout(5000) + + content = await page.inner_text("body") + if "no se encontraron" in content.lower(): + return [] + + # Parse table: each row is (expediente, caratula, fecha, dependencia, localidad) + # Multiple rows = movement history of same expediente + # Deduplicate by expediente, keep latest movement + table = await page.query_selector("table") + if not table: + return [] + + rows = await table.query_selector_all("tr") + by_exp = {} + for row in rows[1:]: + cells = await row.query_selector_all("td") + if len(cells) >= 4: + texts = [await c.inner_text() for c in cells] + texts = [t.strip() for t in texts] + exp = texts[0] + if not exp or len(exp) < 3: + continue + # Keep latest (last occurrence = most recent) + by_exp[exp] = self._make( + exp, + "", + texts[3] if len(texts) > 3 else "", + texts[1] if len(texts) > 1 else "", + texts[2] if len(texts) > 2 else "", + "En trámite", + "Misiones", + ) + return list(by_exp.values()) + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Misiones PW failed: {e}") + return [] + + # ─── RÍO NEGRO ─── Playwright + URL GET (sin reCAPTCHA) + async def _fetch_rio_negro_pw(self, nombre: str) -> list[dict]: + try: + from urllib.parse import urlencode + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"]) + ctx = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36") + page = await ctx.new_page() + try: + # Step 1: Navigate to search page to establish session + await page.goto( + "https://puma.jusrionegro.gov.ar/expjud/busqueda-publica", + wait_until="domcontentloaded", timeout=30000) + await page.wait_for_timeout(2000) + + # Step 2: Submit via URL GET (reCAPTCHA is only for UI, not API) + params = { + "FormBuscarMovimiento[caratula]": nombre, + "criterio": "and" + } + search_url = f"https://puma.jusrionegro.gov.ar/expjud/busqueda-publica/buscar?{urlencode(params)}" + await page.goto(search_url, wait_until="domcontentloaded", timeout=30000) + await page.wait_for_timeout(3000) + + content = await page.inner_text("body") + + # Portal response when no results + if "no se encontraron" in content.lower(): + return [] + + # Parse results table + causas = [] + rows = await page.query_selector_all("table tr") + for row in rows[1:]: + cells = await row.query_selector_all("td") + if len(cells) >= 5: + texts = [(await c.inner_text()).strip() for c in cells] + expediente = texts[0] if texts[0] else "" + fecha = texts[1] if len(texts) > 1 else "" + caratula = texts[3] if len(texts) > 3 else "" + juzgado = texts[4] if len(texts) > 4 else "" + if expediente and len(expediente) > 3: + causas.append(self._make( + expediente, + "", + juzgado, + caratula, + fecha, + "En trámite", + "Río Negro", + )) + return causas + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Río Negro PW failed: {e}") + return [] + + # ─── CÓRDOBA ─── Patchright + channel=chrome para Turnstile + async def _fetch_cordoba_pw(self, nombre: str, apellido: str = "") -> list[dict]: + try: + from patchright.async_api import async_playwright + import asyncio as _aio + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=settings.playwright_headless, + channel="chrome", + args=["--window-size=1280,800", "--window-position=-32000,-32000"], + ) + page = await browser.new_page(viewport={"width": 1280, "height": 800}) + try: + await page.goto( + "https://www.justiciacordoba.gob.ar/JusticiaCordoba/servicios/ConsultaJuicios.aspx", + timeout=60000, + ) + + # Wait for Turnstile to auto-solve (up to 30s) + token = "" + for i in range(30): + await _aio.sleep(1) + token = await page.evaluate( + "() => document.querySelector('[name=\"cf-turnstile-response\"]')?.value || ''" + ) + if token: + logger.debug(f"[PJProv] Córdoba Turnstile solved at {i+1}s") + break + + if not token: + logger.debug("[PJProv] Córdoba Turnstile not solved, falling back to ddgs") + return [] + + # Fill form - usar apellido de ARCA si está disponible + if apellido: + parts = nombre.split() + nombre_part = " ".join(parts) if parts else "" + else: + parts = nombre.split() + apellido = parts[0] if parts else nombre + nombre_part = " ".join(parts[1:]) if len(parts) > 1 else "" + + await page.fill("#txtApellidoMD", apellido) + if nombre_part: + await page.fill("#txtNombreMD", nombre_part) + + # Date range required by form + await page.fill("#txtFechaDesdeMD", "01/01/2000") + await page.fill("#txtFechaHastaMD", "31/12/2026") + + # Click search + await page.click("#btnBuscarPorMasDatos") + await _aio.sleep(8) + + # Check modal for errors + modal_msg = await page.evaluate(""" + () => { + const m = document.querySelector('.modal.show, .modal[style*="display: block"]'); + return m ? m.querySelector('.modal-body')?.innerText || '' : ''; + } + """) + if "no se encontraron" in modal_msg.lower(): + return [] + + # Parse results (card format with .panel-heading) + causas = [] + headings = await page.query_selector_all(".panel-heading") + for h in headings: + text = (await h.inner_text()).strip() + # Pattern: "N° XXXXX - TIPO - Iniciado: DD/MM/YYYY" + m = re.search(r'N[°º]\s*(\d+[-\d]*)\s*-\s*(.*?)\s*-\s*Iniciado:\s*(\d{2}/\d{2}/\d{4})', text) + if not m: + continue + # Get sibling panel-body + body_el = await h.evaluate_handle( + "el => el.nextElementSibling" + ) + body_text = await body_el.inner_text() if body_el else "" + autos_m = re.search(r'Autos?:\s*(.*?)(?:\n|Dependencia)', body_text, re.S) + dep_m = re.search(r'Dependencia Actual:\s*(.*?)(?:\n|Estado)', body_text, re.S) + est_m = re.search(r'Estado Actual:\s*(.*?)(?:\n|Ubicaci)', body_text, re.S) + causas.append(self._make( + m.group(1), # exp + "", # fuero + "", # juzgado + autos_m.group(1).strip() if autos_m else "", # caratula + m.group(3), # fecha + est_m.group(1).strip() if est_m else "En trámite", # estado + "Córdoba", + )) + return causas + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Córdoba Patchright failed: {e}") + return [] + + # ─── TUCUMÁN ─── SAE público (consultaexpedientes.justucuman.gov.ar) + async def _fetch_tucuman_pw(self, nombre: str, apellido: str = "") -> list[dict]: + try: + from patchright.async_api import async_playwright + import asyncio as _aio + + search_apellido = apellido if apellido else (nombre.split()[-1] if nombre.split() else nombre) + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=settings.playwright_headless, + channel="chrome", + args=["--window-size=1280,800", "--window-position=-32000,-32000"], + ) + page = await browser.new_page(viewport={"width": 1280, "height": 800}) + try: + await page.goto( + "https://consultaexpedientes.justucuman.gov.ar/inicio", + timeout=15000, + wait_until="domcontentloaded", + ) + await _aio.sleep(3) + + # Click CAPITAL (default Centro Judicial) + await page.click("button:has-text('CAPITAL')") + await _aio.sleep(2) + + # Fueros to search + fueros = [ + "Civil y Comercial Común", + "Trabajo", + "Familia y Sucesiones", + ] + + causas = [] + for fuero in fueros: + try: + await page.click(f"text={fuero}", timeout=5000) + await _aio.sleep(3) + + # Fill Actor (demandante) + await page.fill("input[name='actor']", search_apellido) + await page.click("button[type='submit']") + await _aio.sleep(5) + + # Parse results from table + results = await page.evaluate(""" + () => { + const text = document.body.innerText; + const lines = text.split('\\n'); + const results = []; + let inTable = false; + for (const line of lines) { + if (line.includes('No. Expt.') && line.includes('Carátula')) { + inTable = true; + continue; + } + if (inTable && line.trim()) { + // Format: "1701/07\tACEVEDO GOMEZ...\tDAÑOS..." + const parts = line.split('\\t'); + if (parts.length >= 3) { + results.push({ + expediente: parts[0].trim(), + caratula: parts[1].trim(), + proceso: parts[2].trim(), + unidad: parts[3]?.trim() || '' + }); + } + } + } + return results; + } + """) + + for r in results: + # Skip if apellido not in carátula + if search_apellido.lower() not in r["caratula"].lower(): + continue + causas.append(self._make( + r["expediente"], + "", + r.get("unidad", ""), + r["caratula"], + "", + "En trámite", + "Tucumán", + )) + + # Go back to fuero selection for next iteration + await page.goto( + "https://consultaexpedientes.justucuman.gov.ar/inicio", + timeout=15000, + wait_until="domcontentloaded", + ) + await _aio.sleep(3) + await page.click("button:has-text('CAPITAL')") + await _aio.sleep(2) + + except Exception as e: + logger.debug(f"[PJProv] Tucumán fuero {fuero} failed: {e}") + continue + + return causas + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Tucumán Patchright failed: {e}") + return [] + + # ─── FORMOSA ─── aiohttp + regex (jusformosa.gob.ar/busqueda) + async def _fetch_formosa(self, nombre: str) -> list[dict]: + try: + import aiohttp + from urllib.parse import urlencode + + base_url = "https://jusformosa.gob.ar/busqueda" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + pattern = re.compile( + r"Expediente\s+([\d.]+)\s+Año\s+([\d.]+)\s*-\s*(.+?)\s+s/\s*(.+?)(?:\t|\n|$)" + ) + + causas = [] + start = 0 + async with aiohttp.ClientSession() as session: + while True: + params = { + "searchword": nombre, + "ordering": "newest", + "searchphrase": "all", + "start": str(start), + } + async with session.get( + base_url, params=params, headers=headers, + timeout=aiohttp.ClientTimeout(total=20), + ) as resp: + if resp.status != 200: + break + text = await resp.text() + + matches = pattern.findall(text) + if not matches: + break + + for exp, anio, caratula, tipo in matches: + clean_caratula = re.sub(r"<[^>]+>", "", caratula).strip() + clean_tipo = re.sub(r"<[^>]+>", "", tipo).strip() + causas.append(self._make( + f"{exp} ({anio})", + "", + "", + clean_caratula, + "", + clean_tipo, + "Formosa", + )) + + if f"start={start + 20}" in text: + start += 20 + else: + break + + return causas + except Exception as e: + logger.debug(f"[PJProv] Formosa aiohttp failed: {e}") + return [] + + # ─── CORRIENTES ─── Patchright headed (GeneXus SPA requires visible browser) + async def _fetch_corrientes(self, nombre: str) -> list[dict]: + try: + from patchright.async_api import async_playwright as pw_headed + async with pw_headed() as p: + browser = await p.chromium.launch( + headless=settings.playwright_headless, + channel="chrome", + args=["--window-position=-32000,-32000"], + ) + page = await browser.new_page(viewport={"width": 1280, "height": 800}) + try: + await page.goto( + "https://fallos.juscorrientes.gov.ar", + timeout=15000, + wait_until="domcontentloaded", + ) + await asyncio.sleep(5) + + # Force-fill the keywords input (hidden by GeneXus SPA) + await page.evaluate("""(name) => { + const kwInput = document.querySelector('[name="vDECISIONPALABRACLAVE1"]'); + if (kwInput) { + kwInput.value = name; + kwInput.style.display = 'block'; + kwInput.style.visibility = 'visible'; + } + }""", nombre) + await asyncio.sleep(1) + + # Submit the form + await page.evaluate("() => document.getElementById('MAINFORM').submit()") + await asyncio.sleep(10) + + content = await page.inner_text("body") + clean = re.sub(r'\s+', ' ', content).strip() + + if 'Sin datos' in clean or len(clean) < 200: + return [] + + # Parse results + causas = [] + chunks = re.split(r'(?=EXPTE)', clean) + for chunk in chunks: + chunk = chunk.strip() + if not chunk or len(chunk) < 10: + continue + exp_match = re.search( + r'EXPTE\s+N[°º]\s*([\w\d][\w\d\s./-]*?)(?:\s+(?:Sentencia|Resoluci[oó]n)\s+N[°º])', + chunk, + ) + expediente = exp_match.group(1).strip() if exp_match else "" + + fecha_match = re.search(r'Fecha:\s*(\d{2}/\d{2}/\d{2,4})', chunk) + fecha = fecha_match.group(1) if fecha_match else "" + + tipo_match = re.search(r'Sumario\s+(Sentencia|Resoluci[oó]n)', chunk) + tipo = tipo_match.group(1) if tipo_match else "" + + # Extraer sumario o texto del fallo para estructurar la carátula + sumario_texto = "" + sumario_idx = chunk.find("Sumario") + if sumario_idx > 0: + sumario_texto = chunk[sumario_idx + 7:].strip()[:400] + + # Extraer tribunal/location + tribunal = "" + if sumario_idx > 0: + after_sumario = chunk[sumario_idx + 7:].strip() + tribunal = after_sumario[:120].strip() + + # Descartar fallos donde el nombre no se mencione en el sumario o título como parte activa + nombre_clean = nombre.lower().strip() + if nombre_clean not in sumario_texto.lower() and nombre_clean not in chunk.lower()[:300]: + continue + + # Carátula reconstruida a partir del sumario/mención + caratula_reconstruida = f"Fallo sobre: {sumario_texto[:150]}..." + + if expediente: + causas.append(self._make( + expediente, + "Corrientes - Jurisprudencia", + tribunal, + caratula_reconstruida, + fecha, + tipo or "Sentencia", + "Corrientes", + )) + + return causas + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Corrientes fallos failed: {e}") + return [] + + # ─── SANTIAGO DEL ESTERO ─── aiohttp (API directa despachos) + async def _fetch_santiago(self, nombre: str) -> list[dict]: + try: + import aiohttp + from datetime import datetime, timedelta + + url = "https://despachos.jussantiago.gov.ar/listados/despacho.php" + today = datetime.now() + desde = (today - timedelta(days=180)).strftime("%d/%m/%Y") + hasta = today.strftime("%d/%m/%Y") + fn = today.strftime("%d/%m/%Y") + data = ( + f"fn={fn}&fd={desde}&fh={hasta}" + f"&id=0&j=C&o=0&a=&ac={nombre}&de=&ca=&ne=" + ) + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Content-Type": "application/x-www-form-urlencoded", + "Referer": "https://despachos.jussantiago.gov.ar/ultimo.php", + "Origin": "https://despachos.jussantiago.gov.ar", + } + + causas = [] + async with aiohttp.ClientSession() as session: + async with session.post( + url, data=data, headers=headers, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status != 200: + return [] + html = await resp.text() + + rows = re.findall(r']*>(.*?)', html, re.DOTALL) + seen = set() + for row in rows: + cells = re.findall(r']*>(.*?)', row, re.DOTALL) + clean_cells = [re.sub(r'<[^>]+>', '', c).strip() for c in cells] + if len(clean_cells) < 7: + continue + abogado, nro_exp, actor, demandado, causa, fecha, obs = clean_cells[:7] + if not nro_exp or not actor: + continue + # Dedup + key = nro_exp.strip() + if key in seen: + continue + seen.add(key) + + # Mapear correctamente la carátula indicando partes reales + caratula_real = f"{actor.strip()} C/ {demandado.strip()} S/ {causa.strip()}" + + causas.append(self._make( + nro_exp.strip(), + "Santiago del Estero - Despachos", + "Juzgado Civil", + caratula_real, + fecha.strip(), + "En trámite", + "Santiago del Estero", + )) + return causas + except Exception as e: + logger.debug(f"[PJProv] Santiago del Estero failed: {e}") + return [] + + # ─── SALTA ─── Playwright + async def _fetch_salta(self, nombre: str) -> list[dict]: + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"]) + ctx = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0") + page = await ctx.new_page() + try: + await page.goto( + "https://www.justiciasalta.gov.ar/es/consultapublica", + wait_until="domcontentloaded", timeout=25000) + await page.wait_for_timeout(3000) + + # Buscar campo "CAUSAS" + buscar_input = await page.query_selector("input[placeholder*='Causas'], input[placeholder*='causas'], input[type='text']") + if not buscar_input: + # Si no hay placeholder específico, buscar el primer input de texto + buscar_input = await page.query_selector("input") + + if not buscar_input: + return [] + + await buscar_input.fill(nombre) + await page.keyboard.press("Enter") + await page.wait_for_timeout(6000) + + # Parsear resultados + # Salta lista los expedientes en formato de tarjetas o tabla + causas = [] + content = await page.content() + + # Extraer bloques de causas mediante selectores o evaluación de DOM + results = await page.evaluate(""" + () => { + const items = []; + // Buscar elementos que contengan información de expedientes + document.querySelectorAll('tr, .causa, .expediente, div[class*="causa"]').forEach(el => { + const text = el.innerText || ''; + // Formato típico de expediente en Salta: contiene números con barra y guiones + if (text.includes('/') && (text.includes('c/') || text.includes('s/') || text.includes('expte') || text.includes('Expte'))) { + items.push(text); + } + }); + return items; + } + """) + + for res in results: + # Parse simple por regex del texto plano obtenido + exp_match = re.search(r'(?:expte|expediente|nº|n°)?\s*(\d+[-/]\d+)', res, re.IGNORECASE) + expediente = exp_match.group(1) if exp_match else "N/A" + if expediente == "N/A": + continue + + # Limpiar carátula aproximada + lines = [line.strip() for line in res.split('\n') if line.strip()] + caratula = " ".join(lines[:2]) + + causas.append(self._make( + expediente, + "Civil y Comercial", + "Juzgado de Salta", + caratula, + "", + "En trámite", + "Salta" + )) + return causas + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Salta failed: {e}") + return [] + + # ─── CATAMARCA ─── Playwright con Credenciales Universales Públicas + async def _fetch_catamarca_pw(self, nombre: str, apellido: str = "") -> list[dict]: + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"]) + ctx = await browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0") + page = await ctx.new_page() + try: + await page.goto( + "https://pcj.juscatamarca.gob.ar/iol-ui/p/inicio", + wait_until="domcontentloaded", timeout=25000) + await page.wait_for_timeout(3000) + + # Completar login universal + # Usuario: UNIVERSAL + # Contraseña: Acceso01 + # Rol: Juzgado + # Checkbox: Conozco y acepto las condiciones de uso + user_input = await page.query_selector("input[id*='usuario'], input[name*='user'], input[type='text']") + pass_input = await page.query_selector("input[type='password']") + + if user_input and pass_input: + await user_input.fill("UNIVERSAL") + await pass_input.fill("Acceso01") + + # Aceptar términos + chk = await page.query_selector("input[type='checkbox']") + if chk: + await chk.click() + + # Click ingresar / submit + btn = await page.query_selector("button[type='submit'], input[type='submit']") + if btn: + await btn.click() + await page.wait_for_timeout(5000) + + # Una vez logueado, buscar por nombre + # Dependiendo del panel de Iol UI, buscar por el input de búsqueda general + search_query = apellido if apellido else nombre + buscar_input = await page.query_selector("input[type='search'], input[placeholder*='Buscar']") + if buscar_input: + await buscar_input.fill(search_query) + await page.keyboard.press("Enter") + await page.wait_for_timeout(5000) + + # Extraer resultados + causas = [] + rows = await page.query_selector_all("table tr, .row-causa") + for row in rows: + text = await row.inner_text() + if not text or len(text) < 15: + continue + + parts = [t.strip() for t in text.split('\t') if t.strip()] + if len(parts) >= 2: + expediente = parts[0] + caratula = parts[1] + causas.append(self._make( + expediente, + "Civil", + "Juzgado Catamarca", + caratula, + "", + "En trámite", + "Catamarca" + )) + return causas + finally: + await browser.close() + except Exception as e: + logger.debug(f"[PJProv] Catamarca failed: {e}") + return [] + + # ─── DUCKDUCKGO FALLBACK ─── Reemplazo gratuito, ético y sin APIs de pago + async def _fetch_duckduckgo_batch(self, apellido: str, provincias: list[str]) -> list[dict]: + if not apellido or len(apellido) < 3: + return [] + + # Mapeo de sitios oficiales de consulta/jurisprudencia para las provincias no cubiertas directamente + sites = { + "Nacional": "pjn.gov.ar", + "Buenos Aires": "scba.gov.ar", + "CABA": "juscaba.gob.ar", + "Santa Fe": "justiciasantafe.gov.ar", + "Mendoza": "jus.mendoza.gov.ar", + "Chaco": "justiciachaco.gov.ar", + "Santa Cruz": "jussantacruz.gob.ar", + "San Juan": "jussanjuan.gob.ar", + "San Luis": "jussanluis.gov.ar", + "La Rioja": "juslarioja.gob.ar", + "Entre Ríos": "jusentrerios.gov.ar", + "La Pampa": "justicialapampa.gob.ar", + "Jujuy": "justiciajujuy.gov.ar", + "Chubut": "juschubut.gov.ar", + "Tierra del Fuego": "justierradelfuego.gov.ar", + "Neuquén": "jusneuquen.gov.ar", + } + + causas = [] + try: + from ddgs import DDGS + + # Ejecutar de forma segura + for prov in provincias: + site = sites.get(prov) + if not site: + continue + + query = f'"{apellido}" site:{site} expediente OR causa OR juicio' + try: + # DDGS().text es síncrono por lo que lo corremos en un hilo secundario para evitar bloquear el loop + def do_search(): + with DDGS() as ddgs: + return list(ddgs.text(query, max_results=5)) + + results = await asyncio.to_thread(do_search) + await asyncio.sleep(1) # delay de cortesía anti-rate-limit + + for r in results: + title = (r.get("title", "") or "").lower() + snippet = (r.get("body", "") or "").lower() + combined = title + " " + snippet + + # Descartar falsos positivos administrativos + admin_patterns = ["destruccion", "destruir", "listado de ingreso", "n exp", "n° exp"] + if any(p in combined for p in admin_patterns): + continue + + # Indicadores válidos de expediente + case_indicators = ["expediente", "causa", "juicio", "caratula", "sentencia", "resolucion", "s/", " c/"] + if not any(w in combined for w in case_indicators): + continue + + # Intentar obtener el expediente por expresión regular + exp_match = ( + re.search(r'\b\d+[-/]\d+[-/]\d+[-/]?\d*\b', combined) or + re.search(r'(?:expte|exp|expediente)[.\s:;]*(?:n[°ºo]?\s*)?(\d{4,10})', combined) + ) + expediente = exp_match.group(0) if exp_match else "Ref. Web" + + causas.append(self._make( + expediente, + f"Justicia {prov}", + "Consulta DuckDuckGo", + r.get("title", "")[:200], + "", + "Mención Web", + prov, + )) + except Exception as ex: + logger.debug(f"[PJProv] DuckDuckGo search for {prov} failed: {ex}") + except Exception as e: + logger.debug(f"[PJProv] DuckDuckGo initialization failed: {e}") + + return causas + + diff --git a/app/scrapers/redes_sociales.py b/app/scrapers/redes_sociales.py new file mode 100644 index 0000000000000000000000000000000000000000..7262d27d6ad0f05aafd29610b90601c56d56f02f --- /dev/null +++ b/app/scrapers/redes_sociales.py @@ -0,0 +1,562 @@ +""" +Scraper Redes Sociales — Búsqueda + Validación por datos reales. + +Flujo: +1. Generar 3 queries: "Nombre1 Nombre2 Apellido", "Nombre1 Apellido", "Nombre2 Apellido" +2. Buscar en 7 plataformas via Google Dorks con Playwright +3. Para cada candidato: httpx con cookies → Playwright stealth (rotativo) +4. Validar: buscar 1 de (nacimiento, cumple, edad ±1, DNI) dentro del perfil +5. Output: máx 2 por plataforma, máx 14 total + +Plataformas: LinkedIn, Twitter/X, Facebook, Instagram, YouTube, TikTok, about.me +""" +import asyncio +import datetime +import httpx +import logging +import random +import re +from typing import Any +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper +from app.config import get_settings + +# Import opcional de ddgs +try: + from ddgs import DDGS + DDGS_AVAILABLE = True +except ImportError: + try: + from duckduckgo_search import DDGS + DDGS_AVAILABLE = True + except ImportError: + DDGS_AVAILABLE = False + +logger = logging.getLogger(__name__) +settings = get_settings() + +PLATFORMS = [ + { + "name": "LinkedIn", + "site": "linkedin.com/in/", + "pattern": r'linkedin\.com/in/([\w\-]+)', + "priority": 1, + }, + { + "name": "Twitter/X", + "site": "x.com", + "pattern": r'(?:x|twitter)\.com/([\w]+)', + "priority": 2, + }, + { + "name": "Facebook", + "site": "facebook.com", + "pattern": r'facebook\.com/([\w\.]+)', + "priority": 3, + }, + { + "name": "Instagram", + "site": "instagram.com", + "pattern": r'instagram\.com/([\w\.]+)', + "priority": 4, + }, + { + "name": "YouTube", + "site": "youtube.com/", + "pattern": r'youtube\.com/(?:@|user/|channel/)([\w\.\-]+)', + "priority": 5, + }, + { + "name": "TikTok", + "site": "tiktok.com/", + "pattern": r'tiktok\.com/@([\w\.\-]+)', + "priority": 6, + }, + { + "name": "Google", + "site": "about.me/", + "pattern": r'about\.me/([\w\.\-]+)', + "priority": 7, + }, +] + +SKIP_WORDS = { + "profile", "page", "group", "help", "about", "login", "signup", "search", + "policies", "feed", "reel", "reels", "stories", "explore", "accounts", + "tv", "jobs", "company", "school", "pub", "groups", "authwall", "home", + "notifications", "watch", "playlist", "channels", "featured", "trending", + "privacy", "terms", "settings", "support", "developers", "topics", + "hashtag", "tags", "following", "followers", "likes", +} + + +class RedesSocialesScraper(BaseScraper): + source_name = "Redes Sociales OSINT" + SAFE_FETCH_TIMEOUT = 120 + max_retries = 1 + uses_playwright = True + + # Límites de resultados + MAX_PROFILES_PER_PLATFORM = 2 + MAX_TOTAL_PROFILES = 14 + MAX_SEARCH_RESULTS = 8 + + # Timeouts (en milisegundos) + NAVIGATION_TIMEOUT = 20000 + FETCH_TIMEOUT = 30000 + HTTPX_TIMEOUT = 15 + OG_IMAGE_TIMEOUT = 10 + + def __init__(self): + super().__init__() + self._cookies_store: dict[str, dict] = {} + + async def fetch(self, identifier: str, **kwargs) -> Any: + nombre_raw = kwargs.get("nombre", "") + if not nombre_raw or len(nombre_raw) < 4: + return {"redes_sociales": [], "foto_perfil": None, "foto_perfil_fuente": None} + + nombre_completo = nombre_raw.strip() + queries = self._generar_queries(nombre_completo) + nombre_corto = self._generar_nombre_corto(nombre_completo) + + dni_raw = kwargs.get("dni", "") + dni_ref = self._normalizar_dni(dni_raw) + + fecha_nacimiento = kwargs.get("fecha_nacimiento", "") + edad_aprox, cumpleanos_dd_mm, fecha_nac_completa = self._parse_fecha_nacimiento(fecha_nacimiento) + + reference_data = { + "dni": dni_ref, + "edad": edad_aprox, + "cumpleanos": cumpleanos_dd_mm, + "nacimiento": fecha_nac_completa, + "nombre_corto": nombre_corto, + } + + logger.info(f"[RedesSociales] Queries: {queries}") + logger.info(f"[RedesSociales] Referencia: DNI={dni_ref}, Edad={edad_aprox}, Cumple={cumpleanos_dd_mm}") + + all_candidates = await self._search_all(queries) + + if not all_candidates: + logger.info("[RedesSociales] Sin candidatos tras búsqueda") + return {"redes_sociales": [], "foto_perfil": None, "foto_perfil_fuente": None} + + logger.info(f"[RedesSociales] {len(all_candidates)} candidatos encontrados, validando...") + + validated = await self._validate_all(all_candidates, reference_data) + + output = self._build_output(validated) + + logger.info(f"[RedesSociales] {len(output)} perfiles verificados") + + foto_perfil, foto_fuente = await self._find_profile_photo(output) + + return { + "redes_sociales": output, + "foto_perfil": foto_perfil, + "foto_perfil_fuente": foto_fuente, + } + + def _generar_queries(self, nombre: str) -> list[str]: + parts = nombre.strip().split() + if len(parts) < 2: + return [parts[0].title()] if parts else [] + + apellido = parts[0] + nombres = parts[1:] + + queries = [] + + q1 = " ".join(p.title() for p in [nombres[0]] + nombres[1:] + [apellido]) + queries.append(q1) + + q2 = f"{nombres[0].title()} {apellido.title()}" + if q2.lower() != q1.lower(): + queries.append(q2) + + if len(nombres) > 1: + q3 = f"{nombres[1].title()} {apellido.title()}" + if q3.lower() != q1.lower() and q3.lower() != q2.lower(): + queries.append(q3) + + return queries + + def _generar_nombre_corto(self, nombre: str) -> str: + parts = nombre.strip().split() + if len(parts) >= 2: + return f"{parts[-1].title()} {parts[0].title()}" + return parts[0].title() if parts else nombre + + def _normalizar_dni(self, dni: str) -> str | None: + if not dni: + return None + digits = re.sub(r'[^0-9]', '', str(dni)) + if len(digits) in (7, 8): + return digits + return None + + def _parse_fecha_nacimiento(self, fecha: str) -> tuple[int | None, str | None, str | None]: + if not fecha: + return None, None, None + cleaned = fecha.replace(".", "-").replace("/", "-").strip() + parts = cleaned.split("-") + try: + if len(parts) == 3: + if len(parts[0]) == 4: + year, month, day = int(parts[0]), int(parts[1]), int(parts[2]) + else: + day, month, year = int(parts[0]), int(parts[1]), int(parts[2]) + edad = datetime.datetime.now().year - year + cumple = f"{day:02d}/{month:02d}" + nacimiento = f"{day:02d}/{month:02d}/{year}" + return edad, cumple, nacimiento + except (ValueError, IndexError): + pass + return None, None, None + + async def _search_all(self, queries: list[str]) -> list[dict]: + all_candidates = [] + seen = set() + + for query in queries: + for platform in PLATFORMS: + dork = f'"{query}" site:{platform["site"]}' + results = await self._google_search_playwright(dork) + + for r in results: + match = re.search(platform["pattern"], r.get("url", ""), re.IGNORECASE) + if not match: + continue + + username = match.group(1).strip("/").split("?")[0] + if any(s in username.lower() for s in SKIP_WORDS): + continue + if len(username) < 3: + continue + + key = f"{platform['name']}:{username}" + if key in seen: + continue + seen.add(key) + + all_candidates.append({ + "plataforma": platform["name"], + "username": username, + "url": r["url"], + "snippet": r.get("snippet", ""), + "title": r.get("title", ""), + }) + + return all_candidates + + async def _google_search_playwright(self, dork: str) -> list[dict]: + results = [] + + results = await self._search_ddgs(dork) + if results: + return results + + results = await self._search_bing_playwright(dork) + return results + + async def _search_ddgs(self, dork: str) -> list[dict]: + results = [] + if not DDGS_AVAILABLE: + return results + + try: + ddgs = DDGS() + raw = ddgs.text(dork, max_results=self.MAX_SEARCH_RESULTS) + for r in raw: + url = r.get("href", "") + if not url.startswith("http"): + continue + results.append({ + "url": url, + "snippet": r.get("body", ""), + "title": r.get("title", ""), + }) + except Exception as e: + logger.debug(f"[RedesSociales/DDGS] Error: {e}") + return results[:self.MAX_SEARCH_RESULTS] + + async def _search_bing_playwright(self, dork: str) -> list[dict]: + results = [] + try: + async with async_playwright() as pw: + browser, context, page = await self.get_stealth_context(pw, None) + try: + import urllib.parse + encoded = urllib.parse.quote(dork) + url = f"https://www.bing.com/search?q={encoded}&count=5" + await page.goto(url, wait_until="domcontentloaded", timeout=self.NAVIGATION_TIMEOUT) + await asyncio.sleep(2) + + links = await page.query_selector_all("#b_results li.b_algo a[href]") + for link in links: + href = await link.get_attribute("href") + if href and href.startswith("http") and "bing.com" not in href: + try: + text_el = await link.query_selector("h2") + title = await text_el.inner_text() if text_el else "" + snippet_el = await link.evaluate_handle( + "el => el.closest('li')?.querySelector('.b_caption p')" + ) + snippet = await snippet_el.inner_text() if snippet_el else "" + except Exception: + title = "" + snippet = "" + results.append({ + "url": href, + "snippet": snippet, + "title": title, + }) + finally: + await browser.close() + except Exception as e: + logger.debug(f"[RedesSociales/Bing] Error: {e}") + return results[:self.MAX_SEARCH_RESULTS] + + async def _validate_all(self, candidates: list[dict], ref: dict) -> list[dict]: + validated = [] + platform_count = {} + + for candidate in candidates: + plataforma = candidate["plataforma"] + if platform_count.get(plataforma, 0) >= self.MAX_PROFILES_PER_PLATFORM: + continue + + result = await self._validate_candidate(candidate, ref) + if result and result.get("valid"): + candidate["validacion"] = result + validated.append(candidate) + platform_count[plataforma] = platform_count.get(plataforma, 0) + 1 + + if sum(platform_count.values()) >= self.MAX_TOTAL_PROFILES: + break + + return validated + + async def _validate_candidate(self, candidate: dict, ref: dict) -> dict | None: + url = candidate["url"] + plataforma = candidate["plataforma"] + + content = candidate.get("snippet", "") + if content and self._validate_content(content, ref): + return {"valid": True, "method": "snippet", "content": content[:500]} + + content = await self._fetch_with_httpx(url, {}) + if content and self._validate_content(content, ref): + return {"valid": True, "method": "httpx", "content": content[:500]} + + content = await self._fetch_with_playwright(url) + if content and self._validate_content(content, ref): + return {"valid": True, "method": "playwright", "content": content[:500]} + + return None + + def _validate_content(self, content: str, ref: dict) -> bool: + if not content: + return False + + content_lower = content.lower() + content_no_punct = re.sub(r'[/\-.]', ' ', content_lower) + content_words = re.sub(r'[^\w\s]', ' ', content_lower) + + if ref.get("nacimiento"): + nac = ref["nacimiento"] + parts = nac.split("/") + if len(parts) == 3: + dia, mes, anio = parts + mes_nombre = self._mes_nombre(int(mes)) + mes_nombre_en = self._mes_nombre_en(int(mes)) + mes_num = mes.zfill(2) + formats = [ + nac, + nac.replace("/", "-"), + f"{anio}-{mes_num}-{dia.zfill(2)}", + f"{dia} de {mes_nombre} de {anio}", + f"{dia} de {mes_nombre}", + f"{mes_nombre} {dia}, {anio}", + f"{mes_nombre} {dia}", + f"{mes_nombre_en} {dia}, {anio}", + f"{mes_nombre_en} {dia}", + ] + for fmt in formats: + if fmt.lower() in content_lower or fmt.lower() in content_no_punct: + return True + + if mes_nombre in content_words and dia.lstrip("0") in content_no_punct: + return True + if mes_nombre_en in content_words and dia.lstrip("0") in content_no_punct: + return True + + if ref.get("cumpleanos"): + cumple = ref["cumpleanos"] + parts = cumple.split("/") + if len(parts) == 2: + dia, mes = parts + mes_nombre = self._mes_nombre(int(mes)) + mes_nombre_en = self._mes_nombre_en(int(mes)) + formats = [ + cumple, + f"{dia} de {mes_nombre}", + f"{mes_nombre} {dia}", + f"{dia} {mes_nombre}", + f"{mes_nombre_en} {dia}", + f"{dia} {mes_nombre_en}", + ] + for fmt in formats: + if fmt.lower() in content_lower or fmt.lower() in content_no_punct: + return True + + if ref.get("edad"): + edad = ref["edad"] + for e in range(edad - 1, edad + 2): + patterns = [ + f"{e} años", + f"{e} anos", + f"edad: {e}", + f"age: {e}", + f"{e} years", + f"{e} year", + ] + for p in patterns: + if p in content_lower: + return True + + if ref.get("dni"): + dni = ref["dni"] + dni_clean = dni.replace(".", "").replace("-", "") + formats = [ + dni, + f"{dni[:2]}.{dni[2:5]}.{dni[5:]}", + f"{dni[:2]}-{dni[2:5]}-{dni[5:]}", + f"{dni[:2]} {dni[2:5]} {dni[5:]}", + dni_clean, + ] + for fmt in formats: + if fmt in content or fmt in content_no_punct: + return True + + return False + + def _mes_nombre(self, mes: int) -> str: + meses = { + 1: "enero", 2: "febrero", 3: "marzo", 4: "abril", + 5: "mayo", 6: "junio", 7: "julio", 8: "agosto", + 9: "septiembre", 10: "octubre", 11: "noviembre", 12: "diciembre" + } + return meses.get(mes, "") + + def _mes_nombre_en(self, mes: int) -> str: + meses = { + 1: "january", 2: "february", 3: "march", 4: "april", + 5: "may", 6: "june", 7: "july", 8: "august", + 9: "september", 10: "october", 11: "november", 12: "december" + } + return meses.get(mes, "") + + async def _fetch_with_httpx(self, url: str, cookies: dict = None) -> str: + try: + headers = self.get_anti_bot_headers() + async with httpx.AsyncClient( + timeout=self.HTTPX_TIMEOUT, + follow_redirects=True, + headers=headers, + ) as client: + resp = await client.get(url, cookies=cookies or {}) + if resp.status_code != 200: + return "" + return resp.text[:10000] + except Exception as e: + logger.debug(f"[RedesSociales/httpx] Error fetching {url}: {e}") + return "" + + async def _fetch_with_playwright(self, url: str) -> str: + try: + async with async_playwright() as pw: + browser, context, page = await self.get_stealth_context(pw, None) + try: + await page.goto(url, wait_until="domcontentloaded", timeout=self.FETCH_TIMEOUT) + await asyncio.sleep(2) + + self._cookies_store[self._extract_domain(url)] = { + c['name']: c['value'] + for c in await context.cookies() + } + + body_text = await page.inner_text("body") + return body_text[:10000] if body_text else "" + finally: + await browser.close() + except Exception as e: + logger.debug(f"[RedesSociales/Playwright] Error fetching {url}: {e}") + return "" + + def _extract_domain(self, url: str) -> str: + match = re.search(r'https?://(?:www\.)?([^/]+)', url) + if match: + domain = match.group(1) + for platform in PLATFORMS: + if platform["site"].split(".")[0] in domain: + return platform["name"] + return domain + return "unknown" + + def _build_output(self, validated: list[dict]) -> list[dict]: + output = [] + for v in validated: + profile = { + "plataforma": v["plataforma"], + "usuario": f"@{v['username']}" if not v["username"].startswith("@") else v["username"], + "url": v["url"], + "seguidores": None, + "snippet": v.get("snippet", ""), + "contenido_perfil": v.get("validacion", {}).get("content", ""), + "validado": True, + "metodo_validacion": v.get("validacion", {}).get("method", ""), + } + output.append(profile) + return output + + async def _find_profile_photo(self, profiles: list[dict]) -> tuple[str | None, str | None]: + for profile in profiles: + plataforma = profile["plataforma"] + url = profile["url"] + + if plataforma == "LinkedIn": + photo = await self._extract_og_image(url) + if photo: + return photo, "linkedin" + + if plataforma == "Instagram": + photo = await self._extract_og_image(url) + if photo: + return photo, "instagram" + + if plataforma == "Google": + photo = await self._extract_og_image(url) + if photo: + return photo, "about.me" + + return None, None + + async def _extract_og_image(self, url: str) -> str | None: + try: + headers = self.get_anti_bot_headers() + async with httpx.AsyncClient(timeout=self.OG_IMAGE_TIMEOUT, follow_redirects=True, headers=headers) as client: + resp = await client.get(url) + if resp.status_code != 200: + return None + html = resp.text + match = re.search(r']+property="og:image"[^>]+content="([^"]+)"', html) + if match: + img_url = match.group(1) + if img_url.startswith("http") and "favicon" not in img_url.lower() and not img_url.endswith(".ico"): + return img_url + except Exception as e: + logger.debug(f"[RedesSociales/OGImage] Error: {e}") + return None diff --git a/app/scrapers/registro_conductores.py b/app/scrapers/registro_conductores.py new file mode 100644 index 0000000000000000000000000000000000000000..f6d9460ce4d6a3ac27e4807956a241233aef7f30 --- /dev/null +++ b/app/scrapers/registro_conductores.py @@ -0,0 +1,147 @@ +""" +Scraper Registro Nacional de Conductores — ANSV (OSINT). + +⚠️ LIMITACIÓN IMPORTANTE: +Este scraper realiza búsquedas OSINT de información pública relacionada +con licencias de conducir. NO accede a un padrón oficial de ANSV. + +Los resultados son páginas web informativas sobre el trámite de licencias, +NO datos personales específicos del DNI consultado. + +CONTEXTO: Las licencias de conducir en Argentina son expedidas por municipios +y provincias (Licencia Nacional de Conducir) con validez nacional y MERCOSUR, +pero administradas de forma descentralizada. No existe un registro público +centralizado accesible por DNI. + +RAZÓN: No existe API pública ni portal de consulta de licencias por DNI. +La información personal de licencias es privada y no se expone públicamente. + +UTILIDAD: Encontrar información general sobre licencias, requisitos y normativa. +NO SIRVE PARA: Verificar si una persona tiene licencia o consultar datos de licencia. +""" +import re +import logging +import httpx +from typing import Any +from app.scrapers.base import BaseScraper + +# Import condicional de ddgs con fallback +try: + from ddgs import DDGS + DDGS_AVAILABLE = True +except ImportError: + try: + from duckduckgo_search import DDGS + DDGS_AVAILABLE = True + except ImportError: + DDGS_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +class RegistroConductoresScraper(BaseScraper): + source_name = "Registro de Conductores ANSV" + + # Timeouts + DDGS_TIMEOUT = 20 + HTTPX_TIMEOUT = 15 + + # Límites + MAX_DORK_RESULTS = 3 + MAX_TOTAL_REGISTROS = 5 + + async def fetch(self, identifier: str, **kwargs) -> Any: + """ + Busca información de licencia de conducir por DNI o CUIT. + Retorna dict con datos de licencia si está disponible. + """ + dni = kwargs.get("dni", "") + cuit = identifier.replace("-", "").strip() + + # Extraer DNI del CUIT si no se proporciona directamente + if not dni and len(cuit) == 11: + dni = cuit[2:-1] + + if not dni or len(dni) < 7: + return {"licencia_conducir": None, "registros": []} + + registros = [] + + # Búsqueda OSINT via Google Dorks + # NOTA: Solo fuente disponible. No existe API pública de ANSV. + try: + registros = await self._search_google_dorks(dni) + except Exception as e: + logger.debug(f"[RegistroConductores] Google Dorks falló: {e}") + + if registros: + logger.warning( + f"[RegistroConductores] {len(registros)} resultados GENÉRICOS encontrados " + f"(no específicos del DNI {dni[:3]}***{dni[-1:]})" + ) + else: + logger.info(f"[RegistroConductores] No se encontraron resultados para DNI {dni[:3]}***{dni[-1:]}") + + return { + "licencia_conducir": registros[0] if registros else None, + "registros": registros, + "nota": "⚠️ Resultados son páginas informativas públicas, NO datos personales del DNI", + "limitaciones": [ + "Sin acceso a padrón oficial de ANSV", + "Licencias expedidas municipalmente (administración descentralizada)", + "No existe registro público centralizado accesible por DNI", + "Resultados genéricos (independientes del DNI consultado)", + "Solo información pública sobre trámites de licencias" + ], + "contexto": "Las Licencias Nacionales de Conducir son expedidas por municipios/provincias con validez nacional y MERCOSUR", + "recomendacion": "Para datos oficiales de una licencia específica, consultar en el municipio/provincia emisor" + } + + async def _search_google_dorks(self, dni: str) -> list[dict]: + """ + Busca información de licencia de conducir usando DuckDuckGo. + """ + registros = [] + + if not DDGS_AVAILABLE: + logger.debug("[RegistroConductores] ddgs no instalado") + return [] + + # Queries optimizadas para encontrar info general sobre licencias + # NOTA: Resultados serán genéricos (páginas sobre trámites), no datos del DNI específico + dorks = [ + f'"licencia de conducir" argentina requisitos trámite', # Info general + f'ANSV argentina licencia nacional conducir', # Info oficial + f'"renovación licencia" "argentina" municipio', # Info provincial + ] + + d = DDGS(proxy=None, timeout=self.DDGS_TIMEOUT) + + for dork in dorks: + try: + results = d.text(dork, max_results=self.MAX_DORK_RESULTS) + for r in results: + snippet = (r.get("body", "") + " " + r.get("title", "")).strip() + # Filtrar por relevancia + if any(x in snippet.lower() for x in ["licencia", "conducir", "habilitacion", "ansv", "trámite"]): + registros.append({ + "fuente": r.get("href", "Web"), + "titulo": r.get("title", "")[:100], + "snippet": snippet[:300], + "tipo": "informacion_general", # Cambiado de consulta_externa + "nota": "Información general sobre licencias, no datos personales" + }) + except Exception as e: + logger.debug(f"[RegistroConductores] Dork falló: {e}") + continue + + return registros[:self.MAX_TOTAL_REGISTROS] + + # NOTA: Método _search_portales_publicos() ELIMINADO + # El endpoint https://dfrp.wi.gob.ar/api/consulta NO EXISTE (dominio inválido). + # No existe una API pública de ANSV para consultar licencias por DNI. + # + # Las Licencias Nacionales de Conducir son expedidas por municipios/provincias + # (con validez nacional y MERCOSUR) pero administradas de forma descentralizada. + # No existe un registro público centralizado accesible. + # La información personal de licencias es privada. diff --git a/app/scrapers/renaper.py b/app/scrapers/renaper.py new file mode 100644 index 0000000000000000000000000000000000000000..f53bd52092ad9b1a4afb1c0d88c0ef979d3ae145 --- /dev/null +++ b/app/scrapers/renaper.py @@ -0,0 +1,311 @@ +"""Scraper RENAPER — Validación de DNI/Persona con datos completos. + +ESTADO DE LA API (2026-07): +- La API pública apirnpr.onrender.com está devolviendo errores (backend RENAPER bloqueado) +- Portal oficial tramites.renaper.gob.ar FUNCIONA con DNI + género + fecha nacimiento +- En flujo CrowData: ARCA proporciona fecha nacimiento → Portal RENAPER es FUNCIONAL + +FUENTES IMPLEMENTADAS: +1. API pública apirnpr.onrender.com (principal, gratuita - ACTUALMENTE NO FUNCIONAL) +2. Portal oficial tramites.renaper.gob.ar (fallback - ✅ FUNCIONAL con fecha de ARCA) +3. Búsqueda web deshabilitada (DuckDuckGo - no confiable para validar identidad) + +INTEGRACIÓN CON ARCA: +- ARCA retorna fecha de nacimiento en su consulta +- Portal RENAPER requiere: DNI (✅) + género (✅ de CUIT) + fecha (✅ de ARCA) +- Con los 3 datos, portal retorna: ejemplar y fecha de emisión +- ✅ SCRAPER FUNCIONAL en flujo real de CrowData + +VALIDACIÓN: +- DNI: 7-8 dígitos numéricos +- Reintentos con backoff exponencial: 3 intentos (2s, 4s, 8s) + +Verificado: 2026-07-06 +""" +import asyncio +import logging +import re +import httpx +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +class RenaperScraper(BaseScraper): + source_name = "RENAPER" + uses_playwright = True + max_retries = 1 # Internal _fetch_with_retry handles retries; anti-bot retries would create 9x calls + SAFE_FETCH_TIMEOUT = 60 # 3 internal retries × ~15s each + + # URLs + API_BASE_URL = "https://apirnpr.onrender.com/renaper" + PORTAL_URL = "https://tramites.renaper.gob.ar/mi_ejemplar/" + + # Reintentos + MAX_RETRIES = 3 + RETRY_BASE_DELAY = 2 + + # Timeouts + API_TIMEOUT = 8 + PORTAL_GOTO_TIMEOUT = 15000 + PORTAL_LOAD_TIMEOUT = 10000 + PORTAL_WAIT_AFTER_SUBMIT = 3000 + PORTAL_INITIAL_WAIT = 1000 + + async def fetch(self, dni: str, **kwargs) -> dict: + dni_clean = dni.replace(".", "").replace("-", "").strip() + cuit_original = kwargs.get("cuit", "") + fecha_nacimiento = kwargs.get("fecha_nacimiento", "") + + if len(dni_clean) == 11: + cuit_original = dni_clean + dni_clean = dni_clean[2:10] + + if not self._validate_dni(dni_clean): + logger.warning(f"[RENAPER] DNI inválido: '{dni_clean}' (debe ser 7-8 dígitos)") + return { + "dni": dni_clean, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": None, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — DNI inválido", + } + + genero = self._infer_gender_from_cuit(cuit_original) if cuit_original else kwargs.get("genero", "") + + return await self._fetch_with_retry(dni_clean, genero, fecha_nacimiento) + + def _validate_dni(self, dni: str) -> bool: + return bool(re.match(r'^\d{7,8}$', dni)) + + def _infer_gender_from_cuit(self, cuit: str) -> str: + if not cuit or len(cuit) < 2: + return "" + prefijo = cuit[:2] + if prefijo in ("20", "23"): + return "M" + elif prefijo in ("24", "27"): + return "F" + return "" + + async def _fetch_with_retry(self, dni: str, genero: str, fecha_nacimiento: str = "") -> dict: + last_error = None + + for attempt in range(self.MAX_RETRIES): + delay = self.RETRY_BASE_DELAY * (2 ** attempt) + logger.info(f"[RENAPER] Intento {attempt+1}/{self.MAX_RETRIES} para DNI {dni}") + + result = await self._fetch_api(dni, genero) + if result.get("validado"): + return result + + if result.get("fuente") and "DNI inválido" not in result.get("fuente", ""): + last_error = result + logger.warning(f"[RENAPER] API falló (intento {attempt+1}), reintentando en {delay}s...") + await asyncio.sleep(delay) + continue + + last_error = result + if attempt < self.MAX_RETRIES - 1: + logger.warning(f"[RENAPER] Intento {attempt+1} falló, reintentando en {delay}s...") + await asyncio.sleep(delay) + + if fecha_nacimiento: + logger.info(f"[RENAPER] API agotada, intentando portal oficial con fecha de ARCA...") + portal_result = await self._fetch_portal(dni, genero, fecha_nacimiento) + if portal_result.get("validado"): + return portal_result + + logger.info(f"[RENAPER] API y portal fallaron, intentando búsqueda web...") + web_result = await self._fetch_web_search(dni) + if web_result.get("validado"): + return web_result + + return last_error or { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": genero or None, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — Sin datos (API caída, portal requiere fecha, web sin resultados)", + } + + async def _fetch_api(self, dni: str, genero: str) -> dict: + proxy_url = self.get_proxy() + headers = self.get_anti_bot_headers() + + if genero: + try: + async with httpx.AsyncClient(timeout=self.API_TIMEOUT, proxy=proxy_url) as client: + resp = await client.get( + f"{self.API_BASE_URL}/{dni}/{genero}", + headers=headers + ) + if resp.status_code == 200: + data = resp.json() + if isinstance(data, dict) and data.get('error'): + logger.debug(f"[RENAPER] API error: {data.get('mensaje', 'Sin mensaje')}") + if not data.get("error") and data.get("respuesta"): + r = data["respuesta"] + nombre = f"{r.get('nombres', '')} {r.get('apellido', '')}".strip() + fecha_nac = r.get("fechaNacimiento", "") + cuil = r.get("cuil", "") + + return { + "dni": dni, + "nombre_completo": nombre if nombre else None, + "fecha_nacimiento": fecha_nac if fecha_nac else None, + "sexo": genero, + "cuil": cuil if cuil else None, + "domicilio": { + "calle": r.get("calle", ""), + "numero": r.get("numeroCalle", ""), + "ciudad": r.get("ciudad", ""), + "provincia": r.get("provincia", ""), + "codigo_postal": r.get("cpostal", ""), + "barrio": r.get("barrio", ""), + } if r.get("calle") or r.get("ciudad") else None, + "validado": True, + "fallecido": False, + "estado_dni": r.get("descripcionError", "Activo"), + "ejemplar": r.get("ejemplar") or "U", + "fuente": "RENAPER — API Pública", + } + except Exception as e: + logger.debug(f"[RENAPER] API pública falló para {dni}: {e}") + + return { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": genero or None, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — API sin datos", + } + + async def _fetch_portal(self, dni: str, genero: str, fecha_nacimiento: str = "") -> dict: + from playwright.async_api import async_playwright + + if not genero: + return { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": None, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — Portal requiere género", + } + + if not fecha_nacimiento: + return { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": genero, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — Portal requiere fecha nacimiento", + } + + browser = None + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + await page.goto(self.PORTAL_URL, wait_until="domcontentloaded", timeout=self.PORTAL_GOTO_TIMEOUT) + await page.wait_for_timeout(self.PORTAL_INITIAL_WAIT) + + await page.fill("input[name='dni'], input#dni", dni) + + gender_value = "M" if genero == "M" else "F" + try: + await page.select_option("select[name='tipodoc']", gender_value) + except Exception: + pass + + date_input = await page.query_selector("input[name='fecha'], input#fecha") + if date_input and fecha_nacimiento: + fecha_formatted = fecha_nacimiento.replace("/", "-") + parts = fecha_formatted.split("-") + if len(parts) == 3: + y, m, d = parts + if len(y) == 4: + date_value = f"{y}-{m}-{d}" + else: + date_value = f"{d}-{m}-{y}" + await date_input.fill(date_value) + + submit_btn = await page.query_selector("button[type='submit']") + if submit_btn: + await submit_btn.click() + await page.wait_for_load_state("domcontentloaded", timeout=self.PORTAL_LOAD_TIMEOUT) + await page.wait_for_timeout(self.PORTAL_WAIT_AFTER_SUBMIT) + + body_text = await page.evaluate("() => document.body.innerText") + + ejemplar_match = re.search(r'ejemplar vigente.*?es:\s*(\w+)', body_text, re.IGNORECASE) + fecha_emision_match = re.search(r'fecha de emisi[oó]n.*?(\d{2}/\d{2}/\d{4})', body_text, re.IGNORECASE) + + if ejemplar_match or fecha_emision_match: + return { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": genero, + "ejemplar": ejemplar_match.group(1) if ejemplar_match else None, + "fecha_emision": fecha_emision_match.group(1) if fecha_emision_match else None, + "validado": True, + "fallecido": False, + "fuente": "RENAPER — Portal Oficial (ejemplar)", + } + + if "error" in body_text.lower() or "no se encontr" in body_text.lower(): + return { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": genero, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — Portal: DNI no encontrado", + } + + except Exception as e: + logger.warning(f"[RENAPER] Portal falló para {dni}: {e}") + finally: + if browser: + await browser.close() + + return { + "dni": dni, + "nombre_completo": None, + "fecha_nacimiento": None, + "sexo": genero or None, + "validado": False, + "validado_unknown": True, + "fallecido": False, + "fuente": "RENAPER — Portal sin datos", + } + + async def _fetch_web_search(self, dni: str) -> dict: + """ + Búsqueda web DESHABILITADA — no es fuente confiable para validar identidad. + + Razón: DNI puede aparecer en páginas web sin pertenecer al titular real. + No hay forma de validar identidad únicamente con resultados de búsqueda web. + Este método se mantiene como documentación de decisiones de diseño. + """ + logger.warning(f"[RENAPER] Búsqueda web deshabilitada — no es fuente confiable para DNI {dni}") + return {"validado": False} diff --git a/app/scrapers/renaper_facial.py b/app/scrapers/renaper_facial.py new file mode 100644 index 0000000000000000000000000000000000000000..da52ae622f452043d1a9189e24d2d95f6b80d311 --- /dev/null +++ b/app/scrapers/renaper_facial.py @@ -0,0 +1,285 @@ +"""Scraper RENAPER Facial — Extracción pasiva de vigencia y metadatos del DNI. + +Busca en el portal oficial de RENAPER (https://tramites.renaper.gob.ar/mi_ejemplar/) +la información de vigencia de un DNI. + +El portal requiere: + - DNI + - Sexo (Femenino/Masculino) + - Fecha de nacimiento (obligatoria para validación) + +Flujo: + 1. GET /mi_ejemplar/ → carga formulario y reCAPTCHA v3 + 2. reCAPTCHA v3 genera token con action="submit_tramite" + 3. POST /mi_ejemplar/busqueda.php con FormData (dni, tipodoc, fecha, token, action) + 4. Respuesta JSON: {data: {detalle: "...", ...}} o {errors: {...}} + +NOTA: La interacción humana con mouse/teclado degrada el score de reCAPTCHA v3 + porque Google detecta patrones automatizados. Se usa fetch directo sin interacción. +""" +import asyncio +import logging +from datetime import datetime, timezone + +from patchright.async_api import async_playwright, TimeoutError as PlaywrightTimeout +from playwright_stealth import Stealth + +from app.scrapers.base import BaseScraper +from app.utils.captcha import CaptchaSolver + +logger = logging.getLogger(__name__) + +PORTAL_URL = "https://tramites.renaper.gob.ar/mi_ejemplar/" +RECAPTCHA_SITE_KEY = "6Ld2mMAbAAAAAM9grHC4aJ6pJT1TtvUz04q4Fvjs" + + +class RenaperFacialScraper(BaseScraper): + """Scraper RENAPER — Consulta vigencia de DNI vía portal oficial. + + Requiere fecha de nacimiento del titular para validar contra el portal. + Usa Playwright + fetch directo (sin interacción humana) para preservar + el score de reCAPTCHA v3. + """ + + source_name = "RENAPER / Vigencia Digital" + uses_playwright = True + SAFE_FETCH_TIMEOUT = 75 + + async def fetch(self, dni: str, **kwargs) -> dict: + dni_clean = dni.replace(".", "").replace("-", "").strip() + if len(dni_clean) == 11: + dni_clean = dni_clean[2:10] + if len(dni_clean) != 8: + return { + "documento": dni_clean, + "origen": "RENAPER", + "estado_tramite": "DNI inválido (debe tener 8 dígitos)", + "validez_digital": False, + } + + sexo = kwargs.get("sexo", "M") + fecha_nacimiento = kwargs.get("fecha_nacimiento", "") + if not fecha_nacimiento: + return self._fallback_response(dni_clean, "Se requiere fecha de nacimiento para consulta RENAPER") + + # Convertir DD/MM/YYYY a YYYY-MM-DD si es necesario + fecha_iso = self._normalize_fecha(fecha_nacimiento) + + return await self._fetch_via_portal(dni_clean, sexo, fecha_iso) + + @staticmethod + def _normalize_fecha(fecha: str) -> str: + """Convierte DD/MM/YYYY o DD-MM-YYYY a YYYY-MM-DD (ISO).""" + from datetime import datetime + fecha = fecha.strip() + for fmt in ("%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%Y/%m/%d", "%d/%m/%y"): + try: + dt = datetime.strptime(fecha, fmt) + return dt.strftime("%Y-%m-%d") + except ValueError: + continue + return fecha + + async def _fetch_via_portal(self, dni: str, sexo: str, fecha_nacimiento: str) -> dict: + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=True, + args=[ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + ], + ) + + context = await browser.new_context( + viewport={"width": 1920, "height": 1080}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ), + locale="es-AR", + ignore_https_errors=True, + ) + + await context.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); + window.chrome = { runtime: {} }; + delete window.__playwright; + """) + + page = await context.new_page() + # Apply stealth plugin to remove all webdriver fingerprints (canvas, webgl, hairline, navigator, etc) + await Stealth().apply_stealth_async(page) + + try: + return await self._execute_search(page, dni, sexo, fecha_nacimiento) + except PlaywrightTimeout as e: + logger.warning(f"Timeout RENAPER para DNI {dni}: {e}") + return await self._diagnostic_response(page, dni, "Timeout - portal no responde", "timeout") + except Exception as e: + logger.warning(f"Error RENAPER para DNI {dni}: {e}") + return await self._diagnostic_response(page, dni, f"Error: {str(e)[:100]}", "exception") + finally: + await browser.close() + + async def _execute_search(self, page, dni: str, sexo: str, fecha_nacimiento: str) -> dict: + # 1. Navegar al portal para establecer la sesión/cookies necesarias + logger.info("[RENAPER Facial] Navegando a portal RENAPER para establecer sesión...") + await page.goto(PORTAL_URL, wait_until="domcontentloaded", timeout=30000) + await asyncio.sleep(2) + + # 2. Obtener token reCAPTCHA v3 vía NopeCHA + # NopeCHA resuelve el captcha externamente con IPs residenciales, + # garantizando un score >= 0.3 (típicamente 0.7–0.9). + # Límite: ~100 tokens/día en el plan gratuito (20 créditos por token). + logger.info("[RENAPER Facial] Solicitando token reCAPTCHA v3 a NopeCHA...") + solver = CaptchaSolver() + recaptcha_token = await solver.solve_recaptcha_v3_nopecha( + site_key=RECAPTCHA_SITE_KEY, + page_url=PORTAL_URL, + action="submit_tramite", + ) + + if not recaptcha_token: + # Fallback: intentar con grecaptcha.execute() directamente en el browser + # (menor chance de éxito porque el score del bot headless suele ser < 0.3) + logger.warning( + "[RENAPER Facial] NopeCHA no disponible o sin créditos — " + "intentando fallback con grecaptcha.execute() nativo (bajo score)..." + ) + try: + await asyncio.sleep(5) # esperar carga completa del SDK + recaptcha_token = await page.evaluate(""" + async () => { + try { + return await grecaptcha.execute( + '""" + RECAPTCHA_SITE_KEY + """', + {action: 'submit_tramite'} + ); + } catch(e) { return null; } + } + """) + except Exception: + recaptcha_token = None + + if not recaptcha_token: + return await self._diagnostic_response( + page, dni, + "No se pudo obtener token reCAPTCHA v3 (NopeCHA sin créditos y fallback fallido)", + "captcha_unavailable" + ) + + logger.info(f"[RENAPER Facial] Token reCAPTCHA obtenido ({len(recaptcha_token)} chars). Enviando formulario...") + + # 3. Inyectar el token en el formulario y hacer el POST vía page.evaluate + # (heredamos las cookies de sesión ya establecidas en el paso 1) + result = await page.evaluate(""" + async (params) => { + try { + const fd = new FormData(); + fd.append('dni', params.dni); + fd.append('tipodoc', params.tipodoc); + fd.append('fecha', params.fecha); + fd.append('token', params.token); + fd.append('action', 'submit_tramite'); + + const resp = await fetch('busqueda.php', { + method: 'POST', + body: fd + }); + const data = await resp.json(); + return {success: true, data: data}; + } catch(e) { + return {success: false, error: e.toString()}; + } + } + """, { + "dni": dni, + "tipodoc": sexo[0].upper(), + "fecha": fecha_nacimiento, + "token": recaptcha_token, + }) + + logger.info(f"[RENAPER Facial] Respuesta: {result}") + + if not result.get("success"): + return await self._diagnostic_response(page, dni, f"Error de conexion: {result.get('error', 'desconocido')}", "fetch_error", response=result) + + data = result.get("data", {}) + errors = data.get("errors") + resp_data = data.get("data", {}) + + if errors: + error_title = errors.get("title", "Error desconocido") + error_detail = errors.get("detail", "") + msg = f"{error_title}: {error_detail}" if error_detail else error_title + logger.warning(f"[RENAPER Facial] Error para DNI {dni}: {msg}") + return await self._diagnostic_response(page, dni, msg, "portal_error", response=data) + + detalle = resp_data.get("detalle", "") + estado = resp_data.get("estado") or resp_data.get("resultado") or detalle + + return { + "documento": dni, + "origen": "RENAPER", + "estado_tramite": estado, + "detalle": detalle, + "ejemplar": resp_data.get("ejemplar") or resp_data.get("tipo_tramite") or "U", + "fecha_emision": resp_data.get("fecha_emision"), + "validez_digital": True, + "raw_response": resp_data, + } + + + async def _diagnostic_response(self, page, dni: str, reason: str, category: str, response: dict | None = None) -> dict: + """Return a non-empty diagnostic payload so the report can preserve the real failure cause.""" + evidence = { + "url_final": None, + "titulo": None, + "html_obtenido": False, + "selector_esperado": "grecaptcha.execute + POST busqueda.php", + "selector_encontrado": None, + "estado_http": None, + "console_errors": [], + "screenshot": None, + "html_path": None, + } + try: + evidence["url_final"] = page.url + evidence["titulo"] = await page.title() + evidence["selector_encontrado"] = await page.evaluate(""" + () => ({ + grecaptcha: typeof grecaptcha !== 'undefined', + grecaptchaExecute: typeof grecaptcha !== 'undefined' && typeof grecaptcha.execute === 'function' + }) + """) + html = await page.content() + evidence["html_obtenido"] = bool(html) + except Exception as e: + evidence["console_errors"].append(f"diagnostic_capture_error: {type(e).__name__}: {e}") + + return { + "documento": dni, + "origen": "RENAPER", + "estado_tramite": reason, + "validez_digital": False, + "validez_unknown": True, + "error": True, + "error_tipo": category, + "error_mensaje": reason, + "evidencia": evidence, + "raw_response": response or {}, + "capturado_en": datetime.now(timezone.utc).isoformat(), + } + + def _fallback_response(self, dni: str, reason: str) -> dict: + return { + "documento": dni, + "origen": "RENAPER", + "estado_tramite": reason, + "validez_digital": False, + "validez_unknown": True, + "error": True, + "error_tipo": "fallback", + "error_mensaje": reason, + } diff --git a/app/scrapers/ruido.py b/app/scrapers/ruido.py new file mode 100644 index 0000000000000000000000000000000000000000..d2341ba03e30d6a8964de9a266e75efedcd4aa2e --- /dev/null +++ b/app/scrapers/ruido.py @@ -0,0 +1,213 @@ +"""Scraper RUIDO — SSSalud Extendido (Playwright). + +Usa el mismo formulario bus650 de SSSalud para obtener el historial completo +de coberturas (altas/bajas) de una persona. Reescrito con Playwright para +bypassear el fingerprinting anti-bot (df_lib.js). +""" +import asyncio +import logging +import re +from bs4 import BeautifulSoup +from app.scrapers.base import BaseScraper +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +SSSALUD_URL = "https://www.sssalud.gob.ar/index.php?page=bus650&user=GRAL" + +OS_CODE_MAP = { + "0-0040-6": "OSECAC (Obra Social de Empleados de Comercio)", + "0-0150-8": "Prosindicato de Amas de Casa", + "0-0300-9": "SANCOR Salud", + "0-0380-1": "Obra Social de la Prevención y la Salud", + "1-0600-5": "OSDECyEDC (Personal de Entidades Deportivas y Civiles)", + "1-0640-1": "OSPERH (Personal de Edificios de Renta y Horizontal)", + "1-0650-0": "OSPERH CABA (Personal de Edificios de Renta y Horizontal CABA)", + "1-1720-7": "OSPREN (Personal de Prensa)", + "1-2330-5": "OSDE (Organización de Servicios Directos Empresarios)", + "1-2570-7": "OSUPCN (Unión del Personal Civil de la Nación)", + "1-2620-5": "OSECAC (Empleados de Comercio y Actividades Civiles)", + "1-2630-4": "OSSEBAC (Servicios Sociales Bancarios)", + "1-2810-2": "OSUNJAJIN (Trabajadores del INSSJyP)", + "3-0070-4": "Ceras Johnson", + "3-0210-6": "OS John Deere Argentina", + "3-0310-9": "OS SUPERCO", + "3-0340-6": "OS Shell Argentina", + "3-0390-1": "OS Ford Argentina", + "3-0400-3": "OS Volkswagen Argentina", + "9-0500-8": "ARS (Administración Recursos para Salud)", + "9-0510-7": "Amsterdam Salud", +} + + +class RuidoScraper(BaseScraper): + source_name = "RUIDO / SSSalud Extendido" + uses_playwright = True + max_retries = 1 + SAFE_FETCH_TIMEOUT = 240 + + async def fetch(self, cuit: str, **kwargs) -> dict: + from playwright.async_api import async_playwright + import ddddocr + + cuit_fmt = self.format_cuit(self.clean_cuit(cuit)) + MAX_ATTEMPTS = 10 + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=settings.playwright_headless, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = await browser.new_page( + user_agent=self.get_random_user_agent(), + locale="es-AR", + ) + + captcha_data = {} + attempt_ref = [0] + + async def on_response(response): + if "securimage_show.php" in response.url: + try: + body = await response.body() + if len(body) > 100: + captcha_data["bytes"] = body + captcha_data["attempt"] = attempt_ref[0] + except Exception: + pass + + page.on("response", on_response) + + try: + for attempt in range(1, MAX_ATTEMPTS + 1): + try: + attempt_ref[0] = attempt + captcha_data.clear() + + await page.goto(SSSALUD_URL, wait_until="networkidle", timeout=30000) + await page.wait_for_timeout(3000) + + if "bytes" not in captcha_data or captcha_data.get("attempt") != attempt: + logger.debug(f"[RUIDO] Captcha no interceptado (intento {attempt})") + continue + + code = self._solve_captcha(captcha_data["bytes"], ocr) + if not code: + continue + + logger.debug(f"[RUIDO] Captcha: '{code}' (intento {attempt})") + + await page.fill('input[name="cuil_b"]', cuit_fmt) + await page.fill('input[name="code"]', code) + await page.click('input[name="B1"]') + + try: + await page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass + await page.wait_for_timeout(1500) + + html = await page.content() + result = self._check_response(html, cuit_fmt, attempt) + if result is not None: + return result + + except Exception as e: + logger.debug(f"[RUIDO] Error intento {attempt} para {cuit}: {e}") + + finally: + await browser.close() + + logger.warning(f"[RUIDO] Captcha no resuelto tras {MAX_ATTEMPTS} intentos para {cuit}") + return {"vias_salud": {}} + + def _solve_captcha(self, image_bytes: bytes, ocr=None) -> str | None: + import ddddocr + if ocr is None: + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + result = ocr.classification(image_bytes) + code = re.sub(r"[^A-Za-z0-9]", "", result) + if code and len(code) >= 3: + return code + return None + + def _check_response(self, html: str, cuit_fmt: str, attempt: int) -> dict | None: + try: + html = html.encode("latin-1", errors="replace").decode("utf-8", errors="replace") + except Exception: + pass + soup = BeautifulSoup(html, "html.parser") + tables = soup.find_all("table") + + if tables: + historial = self._parse_historial(html) + if historial: + return {"vias_salud": { + "cuit_consultado": cuit_fmt, + "cobertura_activa": True, + "detalles": {"historial_coberturas": historial}, + "diagnostics": {"attempts": attempt, "method": "playwright"}, + }} + + if "No se encontraron datos" in html or "no encontr" in html.lower(): + logger.info(f"[RUIDO] CUIL {cuit_fmt}: sin cobertura en padrón SSSalud") + return {"vias_salud": { + "cuit_consultado": cuit_fmt, + "cobertura_activa": False, + "detalles": {}, + }} + + return None + + def _parse_historial(self, html: str) -> list: + soup = BeautifulSoup(html, "html.parser") + historial = [] + + for table in soup.find_all("table"): + rows = table.find_all("tr") + if not rows: + continue + + header_cells = rows[0].find_all(["th", "td"]) + headers = [c.get_text(strip=True).lower() for c in header_cells] + + if not any("obra social" in h for h in headers): + continue + + col_map = {} + for i, h in enumerate(headers): + if "obra social" in h or "denominaci" in h: + col_map["obra_social"] = i + elif "cuil" in h or "titular" in h: + col_map["cuil_titular"] = i + elif "tipo" in h and "beneficiario" in h: + col_map["tipo_beneficiario"] = i + elif "fecha" in h and ("alta" in h or "baja" in h): + col_map["fecha_alta_baja"] = i + elif "motivo" in h: + col_map["motivo"] = i + + if "obra_social" not in col_map: + continue + + for row in rows[1:]: + cells = row.find_all("td") + if len(cells) < len(headers): + continue + + os_raw = cells[col_map.get("obra_social", 0)].get_text(strip=True) + os_nombre = OS_CODE_MAP.get(os_raw, os_raw) + tipo = cells[col_map.get("tipo_beneficiario", 2)].get_text(strip=True) if "tipo_beneficiario" in col_map else "" + fecha = cells[col_map.get("fecha_alta_baja", 3)].get_text(strip=True) if "fecha_alta_baja" in col_map else "" + motivo = cells[col_map.get("motivo", 4)].get_text(strip=True) if "motivo" in col_map else "" + + historial.append({ + "obra_social": os_nombre, + "tipo_beneficiario": tipo, + "fecha_alta_baja": fecha, + "motivo": motivo, + }) + + return historial diff --git a/app/scrapers/sgarhu.py b/app/scrapers/sgarhu.py new file mode 100644 index 0000000000000000000000000000000000000000..585e132954dc6d69a05bf29936caac90f68ff500 --- /dev/null +++ b/app/scrapers/sgarhu.py @@ -0,0 +1,101 @@ +""" +Scraper SGARHU — Títulos Universitarios (SIU / Ministerio de Capital Humano). + +Fuentes: +1. Registro Público de Graduados Universitarios (registrograduados.siu.edu.ar) + - Solo incluye graduados cuyos diplomas fueron intervenidos desde 2012 + - Solo incluye títulos extranjeros convalidados desde 2010 +2. API SIU-Kolla (datosuniversitarios.siu.edu.ar) - NO OPERATIVO (404) +3. validar.me.gov.ar - NO OPERATIVO (DNS no resuelve) +""" +import logging +from playwright.async_api import async_playwright +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +class SgarhuScraper(BaseScraper): + uses_playwright = True + source_name = "sgarhu" + + REGISTRO_URL = "https://registrograduados.siu.edu.ar/" + + async def fetch(self, cuit: str, **kwargs) -> list[dict]: + """ + Busca títulos universitarios por DNI derivado del CUIT. + Retorna lista de dicts con título, institución, año y nivel. + """ + cuit_clean = self.clean_cuit(cuit) + dni = cuit_clean[2:10] if len(cuit_clean) == 11 else cuit_clean + + return await self._fetch_registro_graduados(dni) + + async def _fetch_registro_graduados(self, dni: str) -> list[dict]: + """Busca en el Registro Público de Graduados Universitarios.""" + browser = None + try: + async with async_playwright() as p: + proxy_url = self.get_proxy() + browser, context, page = await self.get_stealth_context(p, proxy_url) + + logger.info("[SGARHU] Consultando Registro Graduados SIU para DNI %s", dni) + await page.goto( + self.REGISTRO_URL, + wait_until="domcontentloaded", + timeout=10000, + ) + await page.wait_for_timeout(1500) + + # Seleccionar tipo de documento: DNI + await page.select_option( + "#ef_form_2308_filtroid_tipo_documento", + "Documento Nacional de Identidad", + ) + + # Ingresar número de documento + await page.fill("#ef_form_2308_filtrodocumento", dni) + + # Hacer clic en buscar y esperar navegación + async with page.expect_navigation(timeout=20000): + await page.click("#form_2308_filtro_filtrar") + + await page.wait_for_timeout(2000) + + # Verificar si hay resultados + body = await page.evaluate("() => document.body.innerText") + + if "No se encontraron resultados" in body: + logger.info("[SGARHU] Sin resultados para DNI %s", dni) + return [] + + # Parsear resultados (si existieran) + titulos = [] + + # Buscar filas de la tabla de resultados + rows = await page.query_selector_all("table tr, .resultado-row") + for i, row in enumerate(rows): + if i == 0: # Skip header + continue + cells = await row.query_selector_all("td") + if len(cells) >= 2: + texts = [(await c.inner_text()).strip() for c in cells] + if texts and texts[0] and len(texts[0]) > 3: + titulos.append({ + "titulo": texts[0], + "institucion": texts[1] if len(texts) > 1 else "Universidad", + "anio_graduacion": texts[2] if len(texts) > 2 else None, + "nivel": texts[3] if len(texts) > 3 else "Grado", + "fuente": "registrograduados.siu.edu.ar", + }) + + logger.info("[SGARHU] Títulos encontrados: %d", len(titulos)) + return titulos + + except Exception as e: + logger.warning("[SGARHU] Error para DNI %s: %s", dni, e) + finally: + if browser: + await browser.close() + + return [] diff --git a/app/scrapers/sinai.py b/app/scrapers/sinai.py new file mode 100644 index 0000000000000000000000000000000000000000..a5fbb8b893b31e9d295aea659844a05f378c0f44 --- /dev/null +++ b/app/scrapers/sinai.py @@ -0,0 +1,180 @@ +"""Scraper SINAI — Infracciones de Tránsito (ANSV Nacional). + +Portal: consultainfracciones.seguridadvial.gob.ar +Acepta: patente (6-7 chars) o DNI/CUIT numérico. +""" +import asyncio +import logging +import re +from typing import Optional + +from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout + +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +ANSV_PORTAL_URL = "https://consultainfracciones.seguridadvial.gob.ar" + +_DOMINIO_RE = re.compile(r"^[A-Za-z0-9]{6,7}$") +_ONLY_DIGITS_RE = re.compile(r"^\d{7,11}$") + + +def _is_dominio(identifier: str) -> bool: + clean = identifier.replace("-", "").replace(" ", "").upper() + return bool(_DOMINIO_RE.match(clean)) and not _ONLY_DIGITS_RE.match(clean) + + +def _clean_dominio(identifier: str) -> str: + return identifier.replace("-", "").replace(" ", "").upper() + + +def _clean_document(identifier: str) -> str: + return re.sub(r"[^0-9]", "", identifier) + + +def _parse_monto(raw: str) -> float: + if not raw: + return 0.0 + cleaned = re.sub(r"[^\d,.]", "", raw).replace(",", ".") + parts = cleaned.split(".") + if len(parts) > 2: + cleaned = "".join(parts[:-1]) + "." + parts[-1] + try: + return float(cleaned) + except ValueError: + return 0.0 + + +class SinaiScraper(BaseScraper): + uses_playwright = True + source_name = "SINAI / ANSV" + DEFAULT_NAVIGATION_TIMEOUT = 20000 + DEFAULT_ELEMENT_TIMEOUT = 15000 + + async def fetch(self, identifier: str, **kwargs) -> dict: + identifier = identifier.strip() + + try: + result = await self._fetch_ansv_portal(identifier) + if result is not None: + return result + except Exception as exc: + logger.warning(f"[SINAI] ANSV fallo: {exc}") + + return {"infracciones": []} + + async def _fetch_ansv_portal(self, identifier: str) -> Optional[dict]: + proxy_url = self.get_proxy() + is_dom = _is_dominio(identifier) + + async with async_playwright() as pw: + browser, context, page = await self.get_stealth_context(pw, proxy_url) + try: + logger.info(f"[SINAI-ANSV] Navegando a {ANSV_PORTAL_URL}") + await page.goto(ANSV_PORTAL_URL, wait_until="domcontentloaded", timeout=self.DEFAULT_NAVIGATION_TIMEOUT) + await asyncio.sleep(1) + + if is_dom: + await self._ansv_search_dominio(page, _clean_dominio(identifier)) + else: + clean_doc = _clean_document(identifier) + dni = clean_doc[2:10] if len(clean_doc) == 11 else clean_doc + sexo = "1" if clean_doc.startswith(("20", "23")) else "0" + await self._ansv_search_documento(page, dni, sexo) + + try: + await page.wait_for_function( + "() => {" + " const sin = document.getElementById('ctl00_ContentPlaceHolder1_divSinInfracciones');" + " const grid = document.getElementById('divGrilla');" + " return (sin && sin.offsetParent !== null) || (grid && grid.offsetParent !== null);" + "}", + timeout=10000, + ) + except Exception: + pass + + await asyncio.sleep(1) + + sin_div = await page.query_selector("#ctl00_ContentPlaceHolder1_divSinInfracciones") + if sin_div and await sin_div.is_visible(): + return {"infracciones": []} + + infracciones = await self._ansv_parse_results(page) + logger.info(f"[SINAI-ANSV] {len(infracciones)} infracciones encontradas.") + return {"infracciones": infracciones} + + finally: + await browser.close() + + async def _ansv_search_dominio(self, page, dominio: str): + tab = await page.query_selector("#ctl00_ContentPlaceHolder1_dDominio") + if tab: + await tab.click() + await asyncio.sleep(1) + + field = await page.query_selector("#ctl00_ContentPlaceHolder1_txDominio") + if not field: + logger.warning("[SINAI-ANSV] Campo dominio no encontrado.") + return + await field.fill(dominio) + await asyncio.sleep(0.3) + await page.click("#btnBuscarFake") + + async def _ansv_search_documento(self, page, dni: str, sexo_value: str = "1"): + field = await page.query_selector("#ctl00_ContentPlaceHolder1_txDocumento") + if not field: + logger.warning("[SINAI-ANSV] Campo documento no encontrado.") + return + + await field.fill(dni) + + tipo_doc = await page.query_selector("#ctl00_ContentPlaceHolder1_ddl_tipoDoc") + if tipo_doc: + await tipo_doc.select_option(index=1) + + label_for = f"ctl00_ContentPlaceHolder1_rdioSexo_{sexo_value}" + await page.click(f"label[for='{label_for}']") + + await asyncio.sleep(0.3) + await page.click("#btnBuscarFake") + + async def _ansv_parse_results(self, page) -> list: + infracciones = [] + + await asyncio.sleep(1) + + grid = await page.query_selector("#divGrilla") + if not grid: + return infracciones + + grid_text = await grid.inner_text() + + import re + actas = re.findall(r'N[úu]mero de Acta:\s*\n?\s*(\d+)', grid_text) + dominios = re.findall(r'Dominio:\s*\n?\s*(\w+)', grid_text) + fechas = re.findall(r'Fecha de Infracci[óo]n:\s*\n?\s*([^\n]+)', grid_text) + importes = re.findall(r'Importe:\s*\n?\s*\$\s*([\d.,]+)', grid_text) + estados = re.findall(r'Estado:\s*\n?\s*([^\n]+)', grid_text) + motivos = re.findall(r'Motivo:\s*\n?\s*([^\n]+)', grid_text) + jurisdicciones = re.findall(r'Jurisdicci[óo]n:\s*\n?\s*([^\n]+)', grid_text) + + count = max(len(actas), len(dominios), len(fechas), len(importes)) + + for i in range(count): + motivo_text = motivos[i].strip() if i < len(motivos) else "" + if not motivo_text and i < len(actas): + motivo_text = f"Infracción de tránsito - Acta {actas[i]}" + + infracciones.append({ + "acta": actas[i] if i < len(actas) else "", + "dominio": dominios[i].strip() if i < len(dominios) else "", + "fecha": fechas[i].strip() if i < len(fechas) else "", + "motivo": motivo_text, + "jurisdiccion": jurisdicciones[i].strip() if i < len(jurisdicciones) else "ANSV Nacional", + "monto": _parse_monto(importes[i]) if i < len(importes) else 0.0, + "estado": estados[i].strip() if i < len(estados) else "Registrada", + }) + + return infracciones diff --git a/app/scrapers/siscop.py b/app/scrapers/siscop.py new file mode 100644 index 0000000000000000000000000000000000000000..55c5d1634cee3f211ca0286fc07ae1fad1178a97 --- /dev/null +++ b/app/scrapers/siscop.py @@ -0,0 +1,43 @@ +""" +Scraper SISCOP — Antecedentes Penales / Registro Nacional de Reincidencia. + +ESTADO: NO OPERATIVO +Motivo: El Registro Nacional de Reincidencia requiere autenticación y pago. + +Fuentes analizadas (todas bloqueadas o de pago): +1. Registro Nacional de Reincidencia (dnrec.jus.gov.ar): + - Requiere auth: Mi Argentina, Clave Fiscal ARCA, Banelco, o ANSES + - Requiere pago: $1.000 (5 días) a $8.500 (1 hora exprés) + - No hay API pública ni scraping posible +2. SIFCOP (Sistema Federal de Comunicaciones Policiales): + - Base de datos restringida a fuerzas de seguridad y poder judicial + - No hay acceso público +3. PJN (Poder Judicial Nacional): + - API REST bloqueada por WAF + - Consulta Web con CAPTCHA custom anti-bot +4. CSJN - Procesos Colectivos: + - Solo procesos colectivos, no individuales + - Requiere reCAPTCHA + +NOTA: Los antecedentes penales en Argentina son información reservada. +Solo el titular puede solicitar su certificado personalmente. +""" +import logging +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + + +class SiscopScraper(BaseScraper): + source_name = "siscop" + + async def fetch(self, cuit: str, **kwargs) -> dict: + """ + NO OPERATIVO. + Los antecedentes penales en Argentina requieren autenticación y pago. + """ + logger.warning( + "[SISCOP] NO OPERATIVO - Antecedentes penales requieren auth+pago. " + "CUIT consultado: %s", cuit + ) + return {} diff --git a/app/scrapers/telefonia.py b/app/scrapers/telefonia.py new file mode 100644 index 0000000000000000000000000000000000000000..198d65a78f3dc15a0f97b055da8c56e56acc0a81 --- /dev/null +++ b/app/scrapers/telefonia.py @@ -0,0 +1,259 @@ +""" +Scraper Telefonía — OSINT real via Google Dorks + directorios públicos argentinos. + +Fuentes: +1. DuckDuckGo Dorks — busca celular en documentos expuestos, perfiles públicos, padrones +2. TeleXplorer — Directorio telefónico argentino (fijos) +3. cuitonline.com — Búsqueda por CUIT (BLOQUEADO - HTTP 403) +4. Paginas Blancas — Directorio telefónico + +Dorks aplicados: +- "nombre" (filetype:pdf OR filetype:xlsx) "telefono" OR "celular" +- intext:"nombre" intext:"contacto" ("+54" OR "cel:") +- site:linkedin.com/in/ "nombre" "contacto" +- site:gov.ar "DNI" "telefono" +- site:paginasblancas.com.ar "nombre" +""" +import re +import logging +import httpx +from typing import Any +from app.scrapers.base import BaseScraper + +# Import opcional de ddgs +try: + from ddgs import DDGS + DDGS_AVAILABLE = True +except ImportError: + try: + from duckduckgo_search import DDGS + DDGS_AVAILABLE = True + except ImportError: + DDGS_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +class TelefoniaScraper(BaseScraper): + source_name = "Telefonia" + + # Timeouts (en segundos para httpx, DuckDuckGo) + DDGS_TIMEOUT = 15 + HTTPX_TIMEOUT = 15 + HTTPX_FETCH_TIMEOUT = 5 + + # Límites de búsqueda + MAX_DORK_RESULTS = 3 + MAX_PHONES_BEFORE_SKIP = 5 + MAX_PHONES_TOTAL = 10 + + async def fetch(self, identifier: str, **kwargs) -> Any: + nombre = kwargs.get("nombre", "") + cuit = kwargs.get("cuit", "") + dni = kwargs.get("dni", "") + telefonos = [] + + # 0. Google Dorks via DuckDuckGo + if nombre and len(nombre) > 4: + try: + tel_dorks = await self._search_google_dorks(nombre, dni) + telefonos.extend(tel_dorks) + except Exception as e: + logger.debug(f"[Telefonia] Google Dorks fallo: {e}") + + # 1. TeleXplorer (fijo por nombre) + if nombre and len(nombre) > 4: + try: + tel_texplorer = await self._search_texplorer(nombre) + telefonos.extend(tel_texplorer) + except Exception as e: + logger.debug(f"[Telefonia] TeleXplorer fallo: {e}") + + # 2. cuitonline.com (telefono por CUIT) + if cuit: + try: + tel_cuitonline = await self._search_cuitonline_phone(cuit) + telefonos.extend(tel_cuitonline) + except Exception as e: + logger.debug(f"[Telefonia] cuitonline fallo: {e}") + + # 3. Paginas Blancas + if nombre and len(nombre) > 4: + try: + tel_paginas = await self._search_paginas_blancas(nombre) + telefonos.extend(tel_paginas) + except Exception as e: + logger.debug(f"[Telefonia] Paginas Blancas fallo: {e}") + + telefonos = list(set(telefonos)) + logger.info(f"[Telefonia] Encontrados {len(telefonos)} numeros para '{nombre}'") + return {"telefonos": telefonos} + + async def _search_google_dorks(self, nombre: str, dni: str = "") -> list[str]: + """ + Google Dorks via DuckDuckGo — busca telefonos en documentos expuestos, + perfiles profesionales, guias telefonicas y sitios gubernamentales. + """ + telefonos = [] + + if not DDGS_AVAILABLE: + logger.debug("[Telefonia] ddgs no instalado") + return [] + + nombre_limpio = nombre.strip() + apellido = nombre_limpio.split()[-1] if nombre_limpio.split() else nombre_limpio + + # === FUENTE 1: Documentos expuestos (PDF, Excel, CSV) === + dorks_documentos = [ + f'"{nombre_limpio}" filetype:pdf "telefono" OR "celular"', + ] + + # === FUENTE 2: Busqueda en texto de paginas web === + dorks_texto = [ + f'intext:"{nombre_limpio}" intext:"contacto" "+54"', + f'"{nombre_limpio}" "whatsapp" OR "celular" OR "telefono"', + ] + + # === FUENTE 3: Plataformas profesionales y guias === + dorks_plataformas = [ + f'site:linkedin.com/in/ "{nombre_limpio}"', + f'site:paginasblancas.com.ar "{apellido}"', + ] + + # === FUENTE 4: Sitios gubernamentales (si hay DNI) === + dorks_gob = [] + if dni and len(dni) >= 7: + dorks_gob = [ + f'site:gov.ar "{dni}" "telefono"', + ] + + todos_los_dorks = dorks_documentos + dorks_texto + dorks_plataformas + dorks_gob + d = DDGS(proxy=None, timeout=self.DDGS_TIMEOUT) + + for dork in todos_los_dorks: + try: + results = d.text(dork, max_results=self.MAX_DORK_RESULTS) + for r in results: + snippet = (r.get("body", "") + " " + r.get("title", "")).strip() + phones = self._extract_phones_from_html(snippet) + telefonos.extend(phones) + + # Buscar en la pagina resultado (solo si aun no tenemos suficientes) + page_url = r.get("href", "") + if page_url and len(telefonos) < self.MAX_PHONES_BEFORE_SKIP: + try: + async with httpx.AsyncClient( + timeout=self.HTTPX_FETCH_TIMEOUT, + follow_redirects=True + ) as client: + page_resp = await client.get( + page_url, + headers={"User-Agent": "Mozilla/5.0"} + ) + if page_resp.status_code == 200: + page_phones = self._extract_phones_from_html(page_resp.text) + telefonos.extend(page_phones) + except Exception: + pass + except Exception as e: + logger.debug(f"[Telefonia] Dork fallo '{dork[:40]}...': {e}") + continue + + # Si ya tenemos suficientes, no seguir + if len(telefonos) >= self.MAX_PHONES_BEFORE_SKIP: + break + + return telefonos[:self.MAX_PHONES_TOTAL] + + async def _search_texplorer(self, nombre: str) -> list[str]: + telefonos = [] + try: + async with httpx.AsyncClient( + timeout=self.HTTPX_TIMEOUT, + follow_redirects=True + ) as client: + resp = await client.get( + "https://www.telexplorer.com.ar/buscar", + params={"q": nombre, "tipo": "personas"}, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml", + }, + ) + if resp.status_code == 200: + telefonos = self._extract_phones_from_html(resp.text) + logger.info(f"[Telefonia] TeleXplorer: {len(telefonos)} numeros") + except Exception as e: + logger.debug(f"[Telefonia] TeleXplorer error: {e}") + return telefonos + + async def _search_cuitonline_phone(self, cuit: str) -> list[str]: + """ + NOTA: cuitonline.com bloqueado (HTTP 403) - mismo problema que name_search.py + Este método está mantenido por compatibilidad pero probablemente no funcione. + """ + telefonos = [] + try: + clean_cuit = cuit.replace("-", "") + async with httpx.AsyncClient( + timeout=self.HTTPX_TIMEOUT, + follow_redirects=True + ) as client: + resp = await client.get( + f"https://www.cuitonline.com/detalle/{clean_cuit}", + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml", + }, + ) + if resp.status_code == 200: + telefonos = self._extract_phones_from_html(resp.text) + logger.info(f"[Telefonia] cuitonline: {len(telefonos)} numeros") + elif resp.status_code == 403: + logger.debug("[Telefonia] cuitonline bloqueado (HTTP 403)") + except Exception as e: + logger.debug(f"[Telefonia] cuitonline error: {e}") + return telefonos + + async def _search_paginas_blancas(self, nombre: str) -> list[str]: + telefonos = [] + try: + async with httpx.AsyncClient( + timeout=self.HTTPX_TIMEOUT, + follow_redirects=True + ) as client: + resp = await client.get( + "https://www.paginasblancas.com.ar/buscar", + params={"q": nombre}, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml", + }, + ) + if resp.status_code == 200: + telefonos = self._extract_phones_from_html(resp.text) + logger.info(f"[Telefonia] Paginas Blancas: {len(telefonos)} numeros") + except Exception as e: + logger.debug(f"[Telefonia] Paginas Blancas error: {e}") + return telefonos + + def _extract_phones_from_html(self, html: str) -> list[str]: + """ + Extrae numeros de telefono del HTML usando regex. + Acepta formatos argentinos: +54 11 XXXX-XXXX, 011-XXXX-XXXX, etc. + """ + patterns = [ + r"\+54\s?(?:9\s?)?(?:11|[2-9]\d{1,2})\s?\d{4}[-\s]?\d{4}", + r"(?:0?11|0?[2-9]\d{1,2})[-\s]\d{4}[-\s]\d{4}", + r"\(0?(?:11|[2-9]\d{1,2})\)\s?\d{4}[-\s]?\d{4}", + r"15\s?\d{4}[-\s]?\d{4}", + ] + found = set() + for pattern in patterns: + matches = re.findall(pattern, html) + for m in matches: + normalized = re.sub(r"\s+", " ", m.strip()) + if len(normalized) >= 8: + found.add(normalized) + + return sorted(found)[:self.MAX_PHONES_TOTAL] diff --git a/app/scrapers/timeline_boa.py b/app/scrapers/timeline_boa.py new file mode 100644 index 0000000000000000000000000000000000000000..e7709d4587d0dc73ff6b63ec3c2b0cad48839a1f --- /dev/null +++ b/app/scrapers/timeline_boa.py @@ -0,0 +1,152 @@ +"""Scraper Timeline Boletín Oficial Argentina. + +Fuente: https://timeline.boletinoficial.gob.ar/ +Búsqueda de sociedades: historial de publicaciones de sociedades comerciales +y personas relacionadas con las mismas. + +No requiere Playwright — solo requests con POST al formulario. +""" +import asyncio +import json +import logging +import re +import requests as req +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +TIMELINE_URL = "https://timeline.boletinoficial.gob.ar/" +BOLETIN_BASE = "https://www.boletinoficial.gob.ar" + + +class TimelineBoletinScraper(BaseScraper): + source_name = "Timeline BORA" + uses_playwright = False + max_retries = 2 + SAFE_FETCH_TIMEOUT = 30 + + async def fetch(self, identifier: str, **kwargs) -> dict: + dni = kwargs.get("dni", "") + cuit = kwargs.get("cuit", "") + + all_publicaciones = [] + + # Búsqueda 1: Por DNI (persona) + if dni: + results = await self._search_person(dni) + all_publicaciones.extend(results) + + # Búsqueda 2: Por CUIT (persona) + if cuit and cuit != dni: + results = await self._search_person(cuit) + all_publicaciones.extend(results) + + # Deduplicar por URL + seen_urls = set() + deduped = [] + for p in all_publicaciones: + url = p.get("url", "") + if url and url not in seen_urls: + seen_urls.add(url) + deduped.append(p) + elif not url: + deduped.append(p) + + return {"publicaciones": deduped} + + async def _search_person(self, query: str) -> list[dict]: + """Busca por persona (DNI o CUIT) en el timeline.""" + try: + data = { + "searchtext_type": "person", + "searchtext_person": query, + "max_length": "750", + } + + # Ejecutar POST en thread para no bloquear el event loop + html = await asyncio.to_thread(self._do_post, data) + if not html: + return [] + + societies = self._parse_societies(html) + return self._convert_to_publicaciones(societies) + + except Exception as e: + logger.warning(f"[TimelineBORA] Error searching '{query}': {e}") + return [] + + def _do_post(self, data: dict) -> str: + """Ejecuta el POST al timeline y retorna el HTML.""" + session = req.Session() + # GET inicial para obtener cookies + session.get(TIMELINE_URL, timeout=15) + # POST con los datos de búsqueda + resp = session.post(TIMELINE_URL, data=data, timeout=20) + resp.raise_for_status() + return resp.text + + def _parse_societies(self, html: str) -> list[dict]: + """Extrae el array societies del HTML.""" + match = re.search(r"const societies = (\[.*?\]);", html, re.DOTALL) + if not match: + return [] + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + logger.warning("[TimelineBORA] Error parseando societies JSON") + return [] + + def _convert_to_publicaciones(self, societies: list[dict]) -> list[dict]: + """Convierte societies del timeline a formato PublicacionBO.""" + publicaciones = [] + + for society in societies: + razon_social = society.get("razon_social", "") + items = society.get("items", []) + + for item in items: + avisos = item.get("avisos", []) + for aviso in avisos: + rubro = aviso.get("rubro", "") + asuntos = aviso.get("asuntos", []) + id_aviso = aviso.get("id_aviso", "") + fecha_publicado = aviso.get("fecha_publicado", "") + tags = aviso.get("tags", {}) + + integrantes = tags.get("integrante", []) + integrantes_str = ", ".join(integrantes[:5]) if integrantes else "" + + texto = f"{razon_social} - {rubro}" + if asuntos: + texto += f": {', '.join(asuntos)}" + if integrantes_str: + texto += f" ({integrantes_str})" + + # Construir URL al aviso + url = "" + if id_aviso and fecha_publicado: + # Formato: YYYYMMDD + fecha_link = fecha_publicado.replace("-", "") + if len(fecha_link) == 10: + fecha_link = fecha_link[6:8] + fecha_link[4:6] + fecha_link[0:4] + url = f"{BOLETIN_BASE}/detalleAviso/segunda/{id_aviso}/{fecha_link}" + + publicaciones.append({ + "texto": texto[:300], + "url": url, + "fuente": "Timeline BORA", + "fecha": self._format_date(fecha_publicado), + "seccion": "Segunda sección", + "tipo": rubro, + }) + + return publicaciones + + def _format_date(self, fecha: str) -> str: + """Convierte YYYY-MM-DD a DD/MM/YY.""" + if not fecha: + return "" + parts = fecha.split("-") + if len(parts) == 3: + return f"{parts[2]}/{parts[1]}/{parts[0][2:]}" + return fecha diff --git a/app/scrapers/uif.py b/app/scrapers/uif.py new file mode 100644 index 0000000000000000000000000000000000000000..c0c417822c216c7c861fb9a7ff0f8f34635e9a53 --- /dev/null +++ b/app/scrapers/uif.py @@ -0,0 +1,177 @@ +""" +Scraper UIF/PEPs — Listado de Personas Expuestas Políticamente. + +Fuentes: +1. Oficina Anticorrupción: DDJJ consolidadas de funcionarios +2. Cache local persistente (30 días) +3. Búsqueda por CUIT limpio + +Retorna lista de cargos públicos asociados al CUIT consultado. +""" +import logging +import httpx +import os +import csv +import tempfile +from app.utils.security import mask_cuit +import time +from app.scrapers.base import BaseScraper + +logger = logging.getLogger(__name__) + +# URL del dataset consolidado de DDJJ 2024 de la Oficina Anticorrupción +DDJJ_CSV_URL = "https://datos.jus.gob.ar/dataset/4680199f-6234-4262-8a2a-8f7993bf784d/resource/a331ccb8-5c13-447f-9bd6-d8018a4b8a62/download/declaraciones-juradas-2024-consolidado-al-20251222.csv" +CACHE_EXPIRY_DAYS = 30 + +class UifScraper(BaseScraper): + source_name = "UIF / PEPs" + + def _get_cache_path(self) -> str: + # Intentamos usar una carpeta de cache en el backend, o fallback al directorio temporal + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + cache_dir = os.path.join(base_dir, "cache") + try: + os.makedirs(cache_dir, exist_ok=True) + return os.path.join(cache_dir, "ddjj_pep.csv") + except Exception: + return os.path.join(tempfile.gettempdir(), "crowdata_ddjj_pep.csv") + + async def _ensure_cache_file(self, cache_path: str): + """Verifica si el archivo de cache existe y está actualizado; si no, lo descarga.""" + if os.path.exists(cache_path): + file_age_days = (time.time() - os.path.getmtime(cache_path)) / (24 * 3600) + if file_age_days < CACHE_EXPIRY_DAYS: + logger.info(f"[UIF] Cache local existente y actualizada ({file_age_days:.1f} días de antigüedad).") + return + + logger.info(f"[UIF] Descargando base de datos PEP/DDJJ de {DDJJ_CSV_URL}...") + proxy = self.get_proxy() + client_kwargs = {"timeout": 120} # Timeout amplio para el archivo de 28MB + if proxy: + client_kwargs["proxy"] = proxy + + try: + start_time = time.time() + async with httpx.AsyncClient(**client_kwargs) as client: + async with client.stream("GET", DDJJ_CSV_URL) as response: + if response.status_code == 200: + temp_dest = cache_path + ".tmp" + with open(temp_dest, "wb") as f: + async for chunk in response.aiter_bytes(): + f.write(chunk) + # Reemplazo atómico + if os.path.exists(cache_path): + os.remove(cache_path) + os.rename(temp_dest, cache_path) + logger.info(f"[UIF] Descarga exitosa de PEP/DDJJ en {time.time() - start_time:.2f}s") + else: + logger.error(f"[UIF] Error al descargar base de datos (HTTP {response.status_code})") + except Exception as e: + logger.error(f"[UIF] Error en la descarga del listado PEP: {e}") + + async def fetch(self, cuit: str, **kwargs) -> dict: + """ + Consulta si el CUIT corresponde a una Persona Expuesta Políticamente (PEP) + utilizando la base consolidada de DDJJ de la Oficina Anticorrupción. + """ + cuit_clean = self.clean_cuit(cuit) + if not cuit_clean: + return {"pep": []} + + cache_path = self._get_cache_path() + await self._ensure_cache_file(cache_path) + + if not os.path.exists(cache_path): + logger.warning("[UIF] No se pudo obtener la base de datos local para verificar PEP.") + return {"pep": []} + + resultados = [] + pep_encontrado = False + + try: + logger.info(f"[UIF] Buscando CUIT {mask_cuit(cuit_clean)} en base de datos PEP local...") + # Hacemos una búsqueda secuencial en el CSV local + with open(cache_path, "r", encoding="utf-8", errors="ignore") as f: + reader = csv.DictReader(f) + for row in reader: + # Comparamos CUIT limpio + row_cuit = self.clean_cuit(row.get("cuit", "")) + if row_cuit == cuit_clean: + pep_encontrado = True + resultados.append({ + "es_pep": True, + "cargo": row.get("cargo", "Funcionario Público"), + "organismo": row.get("organismo", "Nacional"), + "jurisdiccion": row.get("organismo", "Nacional"), + "sector": row.get("sector", ""), + "actividad_ambito": row.get("actividad_principal_ambito", ""), + "anio_declaracion": row.get("anio", ""), + "tipo_declaracion": row.get("tipo_declaracion_jurada_descripcion", ""), + "desde": row.get("desde", ""), + "goza_de_licencia": row.get("goza_de_licencia", "NO"), + "horas_dedicacion": row.get("horas_dedicacion", ""), + "proveedor_contratista": row.get("proveedor_contratista", "NO"), + "nombre_completo": row.get("funcionario_apellido_nombre", ""), + # Patrimonio + "total_bienes_inicio": row.get("total_bienes_inicio", ""), + "total_bienes_final": row.get("total_bienes_final", ""), + "deudas_inicio": row.get("deudas_inicio", ""), + "deudas_final": row.get("total_deudas_final", ""), + "diferencia_valuacion": row.get("diferencia_valuacion", ""), + "ingreso_neto": row.get("ingresos_neto_gastos", ""), + # Metadata + "dj_id": row.get("dj_id", ""), + "rectificativa": row.get("rectificativa", "0"), + # Desglose ingresos por categoría impositiva + "ingresos_c1": row.get("total_ingresos_c1", ""), + "gastos_c1": row.get("total_gastos_c1", ""), + "ingreso_neto_c1": row.get("ingreso_neto_renta_sueldo_c1", ""), + "ingresos_c2": row.get("total_ingresos_c2", ""), + "gastos_c2": row.get("total_gastos_c2", ""), + "ingreso_neto_c2": row.get("ingreso_neto_renta_capitales_c2", ""), + "ingresos_c3": row.get("total_ingresos_c3", ""), + "gastos_c3": row.get("total_gastos_c3", ""), + "ingreso_neto_c3": row.get("ingreso_neto_renta_empresa_c3", ""), + "ingresos_c4": row.get("total_ingresos_c4", ""), + "gastos_c4": row.get("total_gastos_c4", ""), + "ingreso_neto_c4": row.get("ingreso_neto_renta_trabajo_personal_c4", ""), + "ingreso_neto_total": row.get("total_ingreso_neto_c1234", ""), + # Deducciones y gastos + "gastos_personales": row.get("gastos_personales", ""), + "deducciones_generales": row.get("deducciones_generales", ""), + "seguro_vida": row.get("seguro_vida", ""), + "gastos_sepelio": row.get("gastos_sepelio", ""), + "aportes_obras_sociales": row.get("aportes_obras_sociales", ""), + "cuota_medico_asistencial": row.get("cuota_medico_asistencial", ""), + "donaciones_fiscos": row.get("donaciones_fiscos", ""), + "fondos_jubilacion": row.get("fondos_jubilacion", ""), + "intereses_creditos_hipotecarios": row.get("intereses_creditos_hipotecarios", ""), + # Otros + "bienes_por_herencia": row.get("bienes_por_herencia", ""), + "ingresos_no_alcanzados": row.get("ingresos_no_alcanzados", ""), + "tipo": "Declaración Jurada OA", + "fuente": "Oficina Anticorrupción" + }) + + if pep_encontrado: + logger.info(f"[UIF] ✅ CUIT {mask_cuit(cuit_clean)} ES PEP - {len(resultados)} registro(s) encontrado(s)") + else: + logger.info(f"[UIF] ℹ️ CUIT {mask_cuit(cuit_clean)} NO es PEP en base de datos actual") + + except Exception as e: + logger.error(f"[UIF] Error al leer/buscar en cache local PEP: {e}") + + # Retornar con flag es_pep en resultado principal + if resultados: + return { + "es_pep": True, + "cantidad_registros": len(resultados), + "pep": resultados + } + else: + return { + "es_pep": False, + "cantidad_registros": 0, + "pep": [] + } + diff --git a/app/security/audit.py b/app/security/audit.py new file mode 100644 index 0000000000000000000000000000000000000000..ac097c6f94fabbe9b0313741c1ca8a4a0917f5dd --- /dev/null +++ b/app/security/audit.py @@ -0,0 +1,219 @@ +""" +Auditoría de Seguridad — CrowData Backend. + +Ejecutar: python -m app.security.audit +""" +import os +import sys +import re +sys.path.insert(0, "E:/crowdata/backend") + +from pathlib import Path + +class SecurityAudit: + def __init__(self): + self.findings = [] + self.passed = [] + self.warnings = [] + + def check(self, name, condition, detail="", severity="HIGH"): + if condition: + self.passed.append(f"[PASS] {name}") + else: + self.findings.append(f"[FAIL-{severity}] {name}: {detail}") + + def warn(self, name, detail=""): + self.warnings.append(f"[WARN] {name}: {detail}") + + def audit_env_file(self): + env_path = Path("E:/crowdata/backend/.env") + if not env_path.exists(): + self.check("ENV file exists", False, ".env file not found") + return + + content = env_path.read_text(encoding="utf-8") + + # SECRET_KEY + secret_match = re.search(r"SECRET_KEY=(.+)", content) + if secret_match: + secret = secret_match.group(1).strip() + self.check("SECRET_KEY is set", len(secret) > 20, + f"SECRET_KEY too short ({len(secret)} chars)", "HIGH") + self.check("SECRET_KEY is not default", secret not in ("", "changeme", "supersecret"), + "SECRET_KEY is a weak default value", "CRITICAL") + else: + self.check("SECRET_KEY exists", False, "SECRET_KEY not set in .env", "CRITICAL") + + # DATABASE_URL + db_match = re.search(r"DATABASE_URL=(.+)", content) + if db_match: + db_url = db_match.group(1).strip() + self.check("DATABASE_URL is set", len(db_url) > 0, "DATABASE_URL is empty", "HIGH") + if db_url.startswith("sqlite"): + self.warn("Using SQLite", "Consider PostgreSQL for production") + else: + self.check("DATABASE_URL exists", False, "DATABASE_URL not set", "HIGH") + + # SMTP credentials + smtp_user = re.search(r"SMTP_USER=(.+)", content) + smtp_pass = re.search(r"SMTP_PASSWORD=(.+)", content) + if smtp_user and smtp_pass: + self.check("SMTP credentials configured", True) + else: + self.warn("SMTP not configured", "Email delivery won't work") + + def audit_jwt_config(self): + from app.config import get_settings + settings = get_settings() + + self.check("JWT secret is set", len(settings.secret_key) > 0, + "JWT secret_key is empty", "CRITICAL") + self.check("JWT expiration is reasonable", + settings.access_token_expire_minutes <= 1440, + f"Token expires in {settings.access_token_expire_minutes} minutes", + "MEDIUM") + self.check("JWT algorithm is secure", + settings.jwt_algorithm in ("HS256", "HS384", "HS512", "RS256", "RS384", "RS512"), + f"Algorithm: {settings.jwt_algorithm}", "HIGH") + + def audit_cors(self): + from app.main import ALLOWED_ORIGINS + has_wildcard = "*" in ALLOWED_ORIGINS + self.check("CORS no wildcard", not has_wildcard, + "CORS allows all origins (*)", "HIGH") + self.check("CORS has specific origins", len(ALLOWED_ORIGINS) > 0, + "No CORS origins configured", "MEDIUM") + + def audit_password_hashing(self): + from app.auth.config import get_jwt_strategy + # fastapi-users uses bcrypt by default + self.check("Password hashing (fastapi-users)", True, + "Using fastapi-users with bcrypt", "INFO") + + def audit_rate_limiting(self): + from app.middleware.rate_limit import RATE_LIMITS, REPORT_LIMITS + self.check("Rate limiting configured", len(RATE_LIMITS) > 0, + "No rate limits defined", "HIGH") + self.check("Report limits configured", len(REPORT_LIMITS) > 0, + "No report limits defined", "HIGH") + + def audit_admin_protection(self): + # Check that admin endpoints require superuser + admin_file = Path("E:/crowdata/backend/app/admin/router.py") + if admin_file.exists(): + content = admin_file.read_text(encoding="utf-8") + self.check("Admin requires superuser", + "current_active_superuser" in content, + "Admin endpoints not protected by superuser check", "CRITICAL") + + def audit_sql_injection(self): + # Check for raw SQL in service files + service_files = list(Path("E:/crowdata/backend/app").rglob("*.py")) + raw_sql_count = 0 + for f in service_files: + try: + content = f.read_text(encoding="utf-8") + if "text(" in content and "execute" in content: + raw_sql_count += 1 + except Exception: + pass + self.warn("Raw SQL usage", f"{raw_sql_count} files use raw SQL (via SQLAlchemy text())") + + def audit_sensitive_data_logs(self): + # Check for sensitive data in logger calls + service_files = list(Path("E:/crowdata/backend/app").rglob("*.py")) + issues = [] + for f in service_files: + try: + content = f.read_text(encoding="utf-8") + lines = content.split("\n") + for i, line in enumerate(lines): + if "logger" in line and ("password" in line.lower() or "token" in line.lower()): + if "hash" not in line.lower() and "hashed" not in line.lower(): + issues.append(f"{f.name}:{i+1}") + except Exception: + pass + if issues: + self.warn("Sensitive data in logs", f"{len(issues)} potential occurrences") + else: + self.check("No sensitive data in logs", True) + + def audit_dependency_versions(self): + req_file = Path("E:/crowdata/backend/requirements.txt") + if req_file.exists(): + content = req_file.read_text(encoding="utf-8") + self.check("Requirements file exists", True) + # Check for known vulnerable patterns + if "fastapi-users" in content: + self.check("fastapi-users present", True) + if "sqlalchemy" in content.lower(): + self.check("SQLAlchemy present", True) + else: + self.warn("No requirements.txt found") + + def run(self): + print("=" * 60) + print(" AUDITORÍA DE SEGURIDAD — CrowData Backend") + print("=" * 60) + print() + + print("1. Archivo de configuración (.env)") + self.audit_env_file() + print() + + print("2. Configuración JWT") + self.audit_jwt_config() + print() + + print("3. CORS") + self.audit_cors() + print() + + print("4. Password Hashing") + self.audit_password_hashing() + print() + + print("5. Rate Limiting") + self.audit_rate_limiting() + print() + + print("6. Protección Admin") + self.audit_admin_protection() + print() + + print("7. SQL Injection") + self.audit_sql_injection() + print() + + print("8. Datos sensibles en logs") + self.audit_sensitive_data_logs() + print() + + print("9. Dependencias") + self.audit_dependency_versions() + print() + + # Results + print("=" * 60) + print(" RESULTADOS") + print("=" * 60) + + for p in self.passed: + print(f" {p}") + + for f in self.findings: + print(f" {f}") + + for w in self.warnings: + print(f" {w}") + + print() + print(f" Passed: {len(self.passed)}") + print(f" Failed: {len(self.findings)}") + print(f" Warnings: {len(self.warnings)}") + print() + + +if __name__ == "__main__": + audit = SecurityAudit() + audit.run() diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..af0744ae0a655a95ccd696cb1dc27e7f120434b2 --- /dev/null +++ b/app/tasks/__init__.py @@ -0,0 +1 @@ + diff --git a/app/tasks/celery_app.py b/app/tasks/celery_app.py new file mode 100644 index 0000000000000000000000000000000000000000..a52d1b2182d167d79724884b612a562de86f57a1 --- /dev/null +++ b/app/tasks/celery_app.py @@ -0,0 +1,24 @@ +from celery import Celery +from app.config import get_settings + +settings = get_settings() + +celery_app = Celery( + "crowdata", + broker=settings.redis_url, + backend=settings.redis_url, + include=["app.tasks.report_tasks"], +) + +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="America/Argentina/Buenos_Aires", + enable_utc=True, + task_track_started=True, + task_soft_time_limit=60, + task_time_limit=120, + worker_prefetch_multiplier=1, + task_acks_late=True, +) diff --git a/app/tasks/monitoring.py b/app/tasks/monitoring.py new file mode 100644 index 0000000000000000000000000000000000000000..661831646925563a07f4361e2a8994c1cb65f5cf --- /dev/null +++ b/app/tasks/monitoring.py @@ -0,0 +1,131 @@ +""" +Monitoring Tasks — CrowData (Celery version) +Distributed monitoring tasks instead of daemon loop. +""" +import asyncio +import logging +from datetime import datetime, timezone +from sqlalchemy import select +from celery import shared_task +from celery.schedules import crontab + +from app.database import AsyncSessionLocal +from app.reports.models import MonitorTask +from app.reports.service import get_person_report, get_company_report +from app.cache.redis_client import cache_get +from app.database import AsyncSessionLocal + +logger = logging.getLogger(__name__) + +try: + from app.utils.email_service import send_monitoring_alert +except ImportError: + send_monitoring_alert = None + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def check_monitor_task(self, task_id: int): + """ + Celery task to check a single monitor task. + Runs in isolation, can be scaled horizontally. + """ + async def _check(): + async with AsyncSessionLocal() as db: + task = await db.get(MonitorTask, task_id) + if not task or not task.active: + return {"status": "skipped", "reason": "task not found or inactive"} + + logger.info(f"Monitoring {task.type} -> {task.identifier} (User: {task.user_id})") + + # Get cached previous report + cache_key = f"persona:{task.identifier}" if task.type == "persona" else f"empresa:{task.identifier}" + old_data = await cache_get(cache_key) + + # Fetch fresh report + new_report = None + try: + if task.type == "persona": + new_report = await get_person_report(task.identifier) + else: + new_report = await get_company_report(task.identifier) + except Exception as e: + logger.error(f"Error fetching report for {task.identifier}: {e}") + raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries)) + + if not old_data or not new_report: + return {"status": "no_data", "task_id": task_id} + + # Change detection + changes = [] + + # 1. Judicial causes + old_causas = len(old_data.get("judicial", {}).get("causas", [])) + new_causas = len(new_report.judicial.causas) if hasattr(new_report.judicial, 'causas') else 0 + if new_causas > old_causas: + changes.append(f"Nueva causa judicial detectada ({new_causas - old_causas} adicional/es)") + + # 2. BCRA situation + old_bcra = old_data.get("financiero", {}).get("bcra_situacion_actual", 1) + new_bcra = getattr(new_report.financiero, 'bcra_situacion_actual', None) or 1 + if new_bcra != old_bcra: + changes.append(f"Cambio BCRA: {old_bcra} → {new_bcra}") + + # 3. INPI marcas + old_marcas = len(old_data.get("marcas_inpi", [])) + new_marcas = len(new_report.marcas_inpi) if hasattr(new_report, 'marcas_inpi') else 0 + if new_marcas > old_marcas: + changes.append(f"Nueva marca INPI registrada") + + # Trigger alerts if changes + if changes: + logger.warning(f"🚨 ALERTA para {task.identifier}: {changes}") + return {"status": "alert_sent", "task_id": task_id, "changes": changes} + + return {"status": "no_changes", "task_id": task_id} + + try: + return asyncio.run(_check()) + except Exception as e: + logger.error(f"Monitor task {task_id} failed: {e}") + raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries)) + + +@shared_task +def check_all_monitors(): + """ + Periodic task to queue all active monitor tasks. + Runs via Celery Beat (cron: daily at 3 AM). + """ + async def _queue_all(): + async with AsyncSessionLocal() as db: + stmt = select(MonitorTask).where(MonitorTask.active == True) + result = await db.execute(stmt) + tasks = result.scalars().all() + + for task in tasks: + check_monitor_task.delay(task.id) + + logger.info(f"Queued {len(tasks)} monitor tasks") + return {"queued": len(tasks)} + + return asyncio.run(_queue_all()) + + +# Celery Beat schedule +CELERY_BEAT_SCHEDULE = { + "check-all-monitors": { + "task": "app.tasks.monitoring.check_all_monitors", + "schedule": crontab(hour=3, minute=0), # 3 AM daily + }, + "report-cache-cleanup": { + "task": "app.tasks.reports.cleanup_old_cache", + "schedule": crontab(hour=4, minute=30), # 4:30 AM daily + }, + "scraper-health-check": { + "task": "app.tasks.scrapers.health_check_all", + "schedule": crontab(minute="*/15"), # Every 15 minutes + }, +} + +# Import crontab +from celery.schedules import crontab \ No newline at end of file diff --git a/app/tasks/report_tasks.py b/app/tasks/report_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..d5079641f5842eec43d8a4dd232930d3d9c2b345 --- /dev/null +++ b/app/tasks/report_tasks.py @@ -0,0 +1,53 @@ +""" +Celery async tasks para scrapers. +Usadas para procesamiento background de informes pesados. +En Fase 1 los scrapers corren inline en el endpoint (más simple). +En Fase 2 estos tasks permiten colas y reintentos automáticos. +""" +import asyncio +import logging +from app.tasks.celery_app import celery_app +from app.scrapers.arca_afip import ArcaAfipScraper +from app.scrapers.bcra import BcraScraper +from app.scrapers.boletin_oficial import BoletinOficialScraper + +logger = logging.getLogger(__name__) + + +def run_async(coro): + """Helper para correr corutinas async dentro de tasks síncronas de Celery.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +@celery_app.task(bind=True, max_retries=3, default_retry_delay=5, name="tasks.fetch_arca") +def fetch_arca_task(self, cuit: str) -> dict: + try: + scraper = ArcaAfipScraper() + return run_async(scraper.safe_fetch(cuit)) + except Exception as exc: + logger.error(f"ARCA task error for {cuit}: {exc}") + self.retry(exc=exc) + + +@celery_app.task(bind=True, max_retries=3, default_retry_delay=5, name="tasks.fetch_bcra") +def fetch_bcra_task(self, cuil: str) -> dict: + try: + scraper = BcraScraper() + return run_async(scraper.safe_fetch(cuil)) + except Exception as exc: + logger.error(f"BCRA task error for {cuil}: {exc}") + self.retry(exc=exc) + + +@celery_app.task(bind=True, max_retries=2, default_retry_delay=10, name="tasks.fetch_boletin") +def fetch_boletin_task(self, query: str) -> dict: + try: + scraper = BoletinOficialScraper() + return run_async(scraper.safe_fetch(query)) + except Exception as exc: + logger.error(f"BO task error for {query}: {exc}") + self.retry(exc=exc) diff --git a/app/templates/emails/base.html b/app/templates/emails/base.html new file mode 100644 index 0000000000000000000000000000000000000000..82b6d6c4367be5c9972a5338bc12a545520af058 --- /dev/null +++ b/app/templates/emails/base.html @@ -0,0 +1,38 @@ + + + + + + + + +
+
+

CrowData

+
+
+ {% block content %}{% endblock %} +
+ +
+ + diff --git a/app/templates/emails/pdf_delivery.html b/app/templates/emails/pdf_delivery.html new file mode 100644 index 0000000000000000000000000000000000000000..b3fe6bcf1e982b8cfe5a81bf3d48a14e7ddcc6f1 --- /dev/null +++ b/app/templates/emails/pdf_delivery.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +

Tu informe está listo, {{ name }}

+ +

El informe de {{ report_type }} para {{ identifier }} se encuentra adjunto a este email en formato PDF.

+ +
+

Tipo de informe: {{ report_type }}

+

Identificador: {{ identifier }}

+

Formato: PDF

+
+ +

Podés abrir el archivo adjunto con cualquier lector de PDF (Adobe Acrobat, navegador, etc.).

+ +

Si tenés alguna consulta, escribinos a {{ support_email }}

+{% endblock %} diff --git a/app/templates/emails/welcome.html b/app/templates/emails/welcome.html new file mode 100644 index 0000000000000000000000000000000000000000..05ed11a8a8bb909fc1e8210a01a7dcc7ba6b50dd --- /dev/null +++ b/app/templates/emails/welcome.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block content %} +

¡Bienvenido a CrowData, {{ name }}! 🎉

+ +

Tu cuenta fue creada exitosamente. Ya podés acceder a información pública de +25 fuentes oficiales argentinas.

+ +
+

Email: {{ email }}

+

Créditos iniciales: 1 consulta gratuita

+
+ +

¿Qué podés hacer?

+ +

🔍 Buscar personas — Datos personales, financieros, judiciales y patrimoniales.

+

🏢 Buscar empresas — Situación fiscal, IGJ, BCRA, boletín oficial y más.

+

🚗 Buscar vehículos — Dominio, infracciones, prendas y denuncias.

+

🏠 Buscar inmuebles — Ficha catastral, titulares y boletín oficial.

+ + + +

Si tenés alguna consulta, escribinos a {{ support_email }}

+{% endblock %} diff --git a/app/utils/afip_wsaa.py b/app/utils/afip_wsaa.py new file mode 100644 index 0000000000000000000000000000000000000000..402d2658ec4f01a6b315c881f1e1608fcb5ea4ab --- /dev/null +++ b/app/utils/afip_wsaa.py @@ -0,0 +1,149 @@ +import os +import json +import base64 +import logging +from datetime import datetime, timedelta, timezone +import requests +from cryptography import x509 +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.serialization import pkcs7 + +logger = logging.getLogger(__name__) + +# Load .env BEFORE reading os.environ so pydantic paths are available +try: + from dotenv import load_dotenv + _backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + load_dotenv(os.path.join(_backend_dir, ".env")) +except Exception: + pass + +# Constants for AFIP WSAA — use environment variables, not hardcoded paths +_base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +CERT_PATH = os.environ.get('AFIP_CERT_PATH', os.path.join(_base_dir, 'ws_sr_padron_a5_15dc92ced814cc26.crt')) +KEY_PATH = os.environ.get('AFIP_KEY_PATH', os.path.join(_base_dir, 'infocrow.key')) +CACHE_FILE = os.environ.get('AFIP_CACHE_FILE', os.path.join(_base_dir, 'wsaa_cache.json')) +WSAA_PROD_URL = 'https://wsaa.afip.gov.ar/ws/services/LoginCms' +WSAA_HOMO_URL = 'https://wsaahomo.afip.gov.ar/ws/services/LoginCms' + +def _generate_cms(tra_xml: str) -> str: + """Generates the CMS (PKCS#7) signed data using pure cryptography (no rust_pkcs7 patch).""" + with open(CERT_PATH, 'rb') as f: + cert = x509.load_pem_x509_certificate(f.read()) + with open(KEY_PATH, 'rb') as f: + key = serialization.load_pem_private_key(f.read(), password=None) + + # Use PKCS7SignatureBuilder with SHA1 digest (required by AFIP) + builder = pkcs7.PKCS7SignatureBuilder() + builder = builder.add_signer(cert, key, hashes.SHA1()) + builder = builder.add_data(tra_xml.encode('utf-8')) + + # Generate detached signature in DER format + cms_der = builder.sign( + encoding=serialization.Encoding.DER, + options=[pkcs7.PKCS7Options.Binary] + ) + + return base64.b64encode(cms_der).decode('utf-8') + +def _request_new_token(service: str, production: bool = True) -> tuple[str, str, str]: + """Requests a new token from WSAA and returns (token, sign, expiration_iso).""" + now = datetime.now(timezone.utc) + gen_time = (now - timedelta(minutes=5)).strftime('%Y-%m-%dT%H:%M:%S-00:00') + exp_time = (now + timedelta(hours=12)).strftime('%Y-%m-%dT%H:%M:%S-00:00') + unique_id = str(int(now.timestamp())) + + tra_xml = ( + '' + '' + '
' + f'{unique_id}' + f'{gen_time}' + f'{exp_time}' + '
' + f'{service}' + '
' + ) + + cms_b64 = _generate_cms(tra_xml) + url = WSAA_PROD_URL if production else WSAA_HOMO_URL + + soap_body = ( + '' + '' + '' + '' + f'{cms_b64}' + '' + '' + '' + ) + + headers = {'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': ''} + logger.info(f"Solicitando nuevo ticket WSAA para {service} en {'PROD' if production else 'HOMO'}") + resp = requests.post(url, data=soap_body.encode('utf-8'), headers=headers, timeout=30) + + if resp.status_code != 200: + logger.error(f"Error WSAA: {resp.text}") + raise ValueError(f"AFIP WSAA Error (Status {resp.status_code}): {resp.text[:500]}") + + body_text = resp.text.replace('<', '<').replace('>', '>') + token_start = body_text.find('') + 7 + token_end = body_text.find('') + sign_start = body_text.find('') + 6 + sign_end = body_text.find('') + + if token_start <= 6 or token_end <= 0: + raise ValueError(f"No se encontró el token en la respuesta de AFIP. Respuesta: {body_text}") + + token = body_text[token_start:token_end] + sign = body_text[sign_start:sign_end] + + return token, sign, exp_time + +def get_afip_credentials(service: str = "ws_sr_padron_a13", production: bool = True) -> tuple[str, str]: + """ + Returns a valid (Token, Sign) pair for the given AFIP service. + Uses a local JSON cache to avoid requesting a new ticket unnecessarily. + """ + cache = {} + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, 'r') as f: + cache = json.load(f) + except Exception as e: + logger.warning(f"Error reading WSAA cache: {e}") + + # Check if we have a valid cached token for this specific service + service_key = f"{service}_{'prod' if production else 'homo'}" + if service_key in cache: + cached_data = cache[service_key] + exp_time_str = cached_data.get("expirationTime") + if exp_time_str: + try: + # Format: 2026-05-26T12:36:00-03:00 (WSAA actually returns -00:00 but we saved our own requested exp_time) + exp_dt = datetime.strptime(exp_time_str, '%Y-%m-%dT%H:%M:%S-00:00') + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + # If it's valid for at least 15 more minutes, use it + if datetime.now(timezone.utc) + timedelta(minutes=15) < exp_dt: + logger.debug(f"Usando ticket cacheado para {service}") + return cached_data["token"], cached_data["sign"] + except Exception as e: + logger.warning(f"Error parsing cached expiration time: {e}") + + # Need new token + token, sign, exp_time = _request_new_token(service, production) + + # Save to cache + cache[service_key] = { + "token": token, + "sign": sign, + "expirationTime": exp_time + } + try: + with open(CACHE_FILE, 'w') as f: + json.dump(cache, f) + except Exception as e: + logger.warning(f"Error writing WSAA cache: {e}") + + return token, sign diff --git a/app/utils/captcha.py b/app/utils/captcha.py new file mode 100644 index 0000000000000000000000000000000000000000..d48397f642025b245a1ee256814e13fa4305c35d --- /dev/null +++ b/app/utils/captcha.py @@ -0,0 +1,915 @@ +import logging +import httpx +import base64 +import asyncio +import time +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +class CaptchaSolver: + def __init__(self, api_key: str = None): + self.api_key = api_key or settings.captcha_api_key + self.base_url = "https://2captcha.com" + + async def solve_image_captcha(self, image_b64: str) -> str | None: + """ + Solves a standard image captcha using 2Captcha API via HTTPX. + """ + if not self.api_key: + logger.warning("CaptchaSolver: No API Key provided.") + return None + + try: + async with httpx.AsyncClient(timeout=60) as client: + # 1. Submit the captcha + response = await client.post( + f"{self.base_url}/in.php", + data={ + "key": self.api_key, + "method": "base64", + "body": image_b64, + "json": 1 + } + ) + res_data = response.json() + if res_data.get("status") != 1: + logger.error(f"Error submitting captcha: {res_data.get('request')}") + return None + + captcha_id = res_data.get("request") + + # 2. Poll for the result + for _ in range(20): # Max 100 seconds + await asyncio.sleep(5) + res_response = await client.get( + f"{self.base_url}/res.php", + params={ + "key": self.api_key, + "action": "get", + "id": captcha_id, + "json": 1 + } + ) + res_data = res_response.json() + if res_data.get("status") == 1: + return res_data.get("request") + if res_data.get("request") != "CAPCHA_NOT_READY": + logger.error(f"Error getting captcha result: {res_data.get('request')}") + return None + + logger.error("Captcha resolution timeout.") + return None + except Exception as e: + logger.error(f"Error in CaptchaSolver: {e}") + return None + + async def solve_image_captcha_local(self, image_b64: str) -> str | None: + """ + Solves an image captcha locally using ddddocr (Free, no API key). + """ + try: + # We import ddddocr here to avoid heavy initialization on startup if not needed + import ddddocr + import base64 + + # Disable printing of ddddocr branding + import sys + import os + + # Decode the base64 image + image_bytes = base64.b64decode(image_b64) + + # Instantiate ddddocr (it is fast enough to do on-the-fly, or could be cached) + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + res = ocr.classification(image_bytes) + return res + except Exception as e: + logger.error(f"Error in local OCR CaptchaSolver: {e}") + return None + + async def solve_image_captcha_preprocessed(self, image_bytes: bytes) -> str | None: + """ + Pre-procesamiento avanzado específico para captchas Securimage (SSSalud, organismos AR). + + Pipeline de limpieza: + 1. Escala de grises + 2. Blur Gaussiano leve para reducir ruido de alta frecuencia + 3. Threshold adaptativo tipo OTSU (mejor que el fijo 128) + 4. Filtro mediano agresivo para romper líneas de interferencia + 5. Inversión de colores si el fondo es oscuro + 6. Upscale 2x para mejorar OCR + 7. Prueba con ddddocr primero (rápido, gratis) + 8. Si falla → envía imagen limpia a Groq Vision (con preprocessing) + """ + import io + import re + + try: + from PIL import Image, ImageFilter, ImageEnhance, ImageOps + import numpy as np + + img = Image.open(io.BytesIO(image_bytes)).convert("L") + + # 1. Blur Gaussiano leve — reduce ruido de alta frecuencia + img = img.filter(ImageFilter.GaussianBlur(radius=1)) + + # 2. Threshold adaptativo OTSU via numpy + arr = np.array(img) + # Calcula el threshold óptimo (método de Otsu simplificado) + hist, _ = np.histogram(arr, bins=256, range=(0, 256)) + total = arr.size + best_thresh, best_var = 0, 0.0 + sumB, wB, total_sum = 0, 0, np.dot(np.arange(256), hist) + for i, h in enumerate(hist): + wB += h + if wB == 0: + continue + wF = total - wB + if wF == 0: + break + sumB += i * h + mB = sumB / wB + mF = (total_sum - sumB) / wF + var = wB * wF * (mB - mF) ** 2 + if var > best_var: + best_var = var + best_thresh = i + + binary = arr > best_thresh # True = blanco (texto), False = negro (fondo) + + # 3. Inferir si texto es claro sobre fondo oscuro (invertir si hace falta) + white_ratio = binary.mean() + if white_ratio > 0.5: + binary = ~binary # texto oscuro sobre fondo claro: el estándar + + img_bin = Image.fromarray((binary * 255).astype(np.uint8)) + + # 4. Filtro mediano agresivo (size=5) para eliminar líneas de ruido + img_bin = img_bin.filter(ImageFilter.MedianFilter(size=5)) + + # 5. Upscale 2x — mejora mucho el OCR en imágenes pequeñas + w, h = img_bin.size + img_bin = img_bin.resize((w * 2, h * 2), Image.LANCZOS) + + # 6. Guardar PNG limpio + buf = io.BytesIO() + img_bin.save(buf, format="PNG") + clean_bytes = buf.getvalue() + + # 7. Probar ddddocr con imagen limpia + import ddddocr + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + result = ocr.classification(clean_bytes) + cleaned = re.sub(r"[^A-Za-z0-9]", "", result) + logger.info(f"[CaptchaPreProcess] ddddocr resultado: '{cleaned}'") + + if cleaned and len(cleaned) >= 4: + return cleaned + + # 8. Fallback: enviar imagen LIMPIA a Groq Vision + # (mayor chance de éxito que la imagen original con ruido) + if settings.groq_api_key: + logger.info("[CaptchaPreProcess] ddddocr insuficiente, enviando imagen preprocesada a Groq Vision...") + groq_result = await self.solve_image_captcha_groq(clean_bytes) + if groq_result and len(groq_result) >= 3: + logger.info(f"[CaptchaPreProcess] Groq Vision resultado: '{groq_result}'") + return groq_result + + return cleaned if cleaned else None + + except ImportError: + # numpy no disponible — usar versión básica + logger.warning("[CaptchaPreProcess] numpy no disponible, usando pipeline básico") + try: + from PIL import Image, ImageFilter + img = Image.open(io.BytesIO(image_bytes)).convert("L") + img = img.point(lambda x: 255 if x > 128 else 0) + img = img.filter(ImageFilter.MedianFilter(size=3)) + import ddddocr + ocr = ddddocr.DdddOcr(show_ad=False, beta=True) + buf = io.BytesIO() + img.save(buf, format="PNG") + result = ocr.classification(buf.getvalue()) + cleaned = re.sub(r"[^A-Za-z0-9]", "", result) + logger.info(f"[CaptchaPreProcess] ddddocr básico: '{cleaned}'") + return cleaned if cleaned else None + except Exception as e2: + logger.error(f"[CaptchaPreProcess] Error pipeline básico: {e2}") + return None + except Exception as e: + logger.error(f"[CaptchaPreProcess] Error pipeline avanzado: {e}") + return None + + async def solve_image_captcha_groq(self, image_bytes: bytes, model: str = None) -> str | None: + """ + Resuelve un captcha de imagen usando Groq Vision (llama-4-scout-17b-16e-instruct). + """ + if not settings.groq_api_key: + logger.warning("[CaptchaGroq] GROQ_API_KEY no configurada — fallback a ddddocr") + return await self.solve_image_captcha_local( + __import__("base64").b64encode(image_bytes).decode() + ) + + groq_model = "meta-llama/llama-4-scout-17b-16e-instruct" + + try: + import base64 + image_b64 = base64.b64encode(image_bytes).decode("utf-8") + + magic = image_bytes[:4] + if magic[:2] == b'\xff\xd8': + mime = "image/jpeg" + elif magic[:4] == b'\x89PNG': + mime = "image/png" + else: + mime = "image/png" + + groq_url = "https://api.groq.com/openai/v1/chat/completions" + payload = { + "model": groq_model, + "messages": [ + { + "role": "system", + "content": ( + "You are a CAPTCHA reader. You see distorted text images. " + "Your ONLY job is to transcribe the characters exactly as they appear. " + "Output ONLY the alphanumeric characters. No explanations, no quotes, no spaces." + ) + }, + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:{mime};base64,{image_b64}" + } + }, + { + "type": "text", + "text": "Transcribe the CAPTCHA text from this image. Output ONLY the characters." + } + ] + } + ], + "max_tokens": 20, + "temperature": 0.0 + } + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + groq_url, + headers={"Authorization": f"Bearer {settings.groq_api_key}"}, + json=payload + ) + + if resp.status_code != 200: + logger.warning(f"[CaptchaGroq] Groq Vision error {resp.status_code}: {resp.text[:200]}") + # Fallback a ddddocr + return await self.solve_image_captcha_local( + __import__("base64").b64encode(image_bytes).decode() + ) + + content = resp.json()["choices"][0]["message"]["content"].strip() + # Limpiar la respuesta — quedarse solo con alfanuméricos + import re + clean = re.sub(r"[^A-Za-z0-9]", "", content) + logger.info(f"[CaptchaGroq] Groq Vision resolvió captcha: '{clean}'") + return clean if clean else None + + except Exception as e: + logger.error(f"[CaptchaGroq] Error en Groq Vision: {e}") + # Fallback a ddddocr + try: + return await self.solve_image_captcha_local( + __import__("base64").b64encode(image_bytes).decode() + ) + except Exception: + return None + + async def solve_recaptcha_v2(self, site_key: str, url: str) -> str | None: + """ + Solves reCAPTCHA v2. + """ + if not self.api_key: return None + + try: + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + f"{self.base_url}/in.php", + data={ + "key": self.api_key, + "method": "userrecaptcha", + "googlekey": site_key, + "pageurl": url, + "json": 1 + } + ) + res_data = response.json() + if res_data.get("status") != 1: return None + + captcha_id = res_data.get("request") + + for _ in range(40): # Max 200 seconds + await asyncio.sleep(5) + res_response = await client.get( + f"{self.base_url}/res.php", + params={"key": self.api_key, "action": "get", "id": captcha_id, "json": 1} + ) + res_data = res_response.json() + if res_data.get("status") == 1: return res_data.get("request") + if res_data.get("request") != "CAPCHA_NOT_READY": return None + return None + except Exception as e: + logger.error(f"Error in reCAPTCHA solver: {e}") + return None + + async def solve_recaptcha_v2_audio(self, page, iframe_selector: str = None, max_retries: int = 3) -> bool: + """ + Solves reCAPTCHA v2 VISIBLE on a page for free using browser automation, + downloading the audio challenge, and transcribing it via Groq Whisper API. + For visible reCAPTCHA with checkbox (#recaptcha-anchor). + """ + for attempt in range(max_retries): + try: + result = await self._solve_recaptcha_v2_audio_once(page, iframe_selector) + if result: + return True + logger.warning(f"[reCAPTCHA] Intento {attempt+1} falló, reintentando en {(attempt+1)*3}s...") + await asyncio.sleep((attempt + 1) * 3) + except Exception as e: + logger.warning(f"[reCAPTCHA] Intento {attempt+1} falló con excepción: {e}") + await asyncio.sleep((attempt + 1) * 3) + return False + + async def solve_recaptcha_v2_invisible(self, page, max_retries: int = 3) -> bool: + """ + Solves reCAPTCHA v2 INVISIBLE on a page for free using browser automation. + Unlike visible reCAPTCHA, invisible reCAPTCHA has NO checkbox. + It is triggered by grecaptcha.execute() and shows a challenge directly. + + This method: + 1. Finds the anchor iframe (api2/anchor) + 2. Clicks the anchor iframe element directly (no checkbox lookup) + 3. Waits for the challenge iframe (api2/bframe) to appear + 4. Solves the audio challenge using Groq Whisper API + """ + for attempt in range(max_retries): + try: + result = await self._solve_recaptcha_v2_invisible_once(page) + if result: + return True + logger.warning(f"[reCAPTCHA-invisible] Intento {attempt+1} falló, reintentando en {(attempt+1)*3}s...") + await asyncio.sleep((attempt + 1) * 3) + except Exception as e: + logger.warning(f"[reCAPTCHA-invisible] Intento {attempt+1} falló con excepción: {e}") + await asyncio.sleep((attempt + 1) * 3) + return False + + async def _solve_recaptcha_v2_invisible_once(self, page) -> bool: + """ + Single attempt to solve reCAPTCHA v2 INVISIBLE audio challenge. + Key difference from visible: no checkbox, click the anchor iframe element directly. + """ + try: + # Locate anchor iframe + anchor_iframe = None + anchor_element = None + + for frame in page.frames: + if "api2/anchor" in frame.url: + try: + el = await frame.frame_element() + if el and await el.is_visible(): + anchor_iframe = frame + anchor_element = el + break + except Exception: + pass + + if not anchor_iframe: + for frame in page.frames: + if "api2/anchor" in frame.url: + anchor_iframe = frame + try: + anchor_element = await frame.frame_element() + except Exception: + pass + break + + if not anchor_iframe or not anchor_element: + logger.error("[reCAPTCHA-invisible] Anchor iframe not found.") + for i, frame in enumerate(page.frames): + logger.debug(f"Frame {i}: url={frame.url}") + return False + + # Click the anchor iframe element directly (no checkbox for invisible) + try: + await anchor_element.click(timeout=5000, force=True) + logger.info("[reCAPTCHA-invisible] Clicked anchor iframe element") + except Exception as e: + logger.error(f"[reCAPTCHA-invisible] Failed to click anchor element: {e}") + return False + + await asyncio.sleep(2) + + # Check if solved instantly + try: + checkbox = await anchor_iframe.query_selector("#recaptcha-anchor") + if checkbox: + is_checked = await checkbox.get_attribute("aria-checked") + if is_checked == "true": + logger.info("[reCAPTCHA-invisible] Solved instantly by reputation.") + return True + except Exception: + pass + + # Wait for challenge iframe (bframe) to appear + try: + await page.wait_for_selector("iframe[src*='api2/bframe']", state="visible", timeout=8000) + except Exception: + pass + + # Locate challenge iframe + challenge_iframe = None + for frame in page.frames: + if "api2/bframe" in frame.url: + try: + frame_element = await frame.frame_element() + if frame_element and await frame_element.is_visible(): + challenge_iframe = frame + break + except Exception: + pass + + if not challenge_iframe: + for frame in page.frames: + if "api2/bframe" in frame.url: + challenge_iframe = frame + break + + if not challenge_iframe: + logger.error("[reCAPTCHA-invisible] Challenge iframe (bframe) not found.") + return False + + # Click audio challenge button + audio_button = await challenge_iframe.query_selector("#recaptcha-audio-button") + if not audio_button or not await audio_button.is_visible(): + logger.warning("[reCAPTCHA-invisible] Audio button not found or invisible.") + return False + + try: + await audio_button.click(timeout=5000, force=True) + logger.info("[reCAPTCHA-invisible] Clicked audio button") + except Exception as e: + logger.error(f"[reCAPTCHA-invisible] Failed to click audio button: {e}") + return False + + await asyncio.sleep(2.5) + + # Check for block message + block_msg = await challenge_iframe.query_selector(".rc-dsu-cant-solve-active") + if block_msg and await block_msg.is_visible(): + logger.error("[reCAPTCHA-invisible] Blocked: Automated queries detected.") + return False + + # Get audio download link + download_link = await challenge_iframe.query_selector(".rc-audiochallenge-tdownload-link") + if not download_link: + logger.error("[reCAPTCHA-invisible] Audio download link not found.") + return False + + audio_url = await download_link.get_attribute("href") + + # Download audio file + audio_resp = await page.request.get(audio_url) + if not audio_resp.ok: + logger.error(f"[reCAPTCHA-invisible] Failed to download audio (status {audio_resp.status}).") + return False + + audio_content = await audio_resp.body() + if len(audio_content) < 1024: + logger.error(f"[reCAPTCHA-invisible] Audio file too small ({len(audio_content)} bytes) — blocked.") + return False + + if not settings.groq_api_key: + logger.error("[reCAPTCHA-invisible] GROQ_API_KEY not configured.") + return False + + # Transcribe with Groq Whisper + groq_url = "https://api.groq.com/openai/v1/audio/transcriptions" + headers = {"Authorization": f"Bearer {settings.groq_api_key}"} + files = {"file": ("challenge.mp3", audio_content, "audio/mpeg")} + data = {"model": "whisper-large-v3-turbo", "response_format": "json"} + + async with httpx.AsyncClient(timeout=60) as client: + transcription_resp = await client.post(groq_url, headers=headers, files=files, data=data) + + if transcription_resp.status_code != 200: + logger.error(f"[reCAPTCHA-invisible] Groq API Error: {transcription_resp.text}") + return False + + text = transcription_resp.json().get("text", "").strip() + if not text: + logger.error("[reCAPTCHA-invisible] Groq returned empty text.") + return False + + logger.info(f"[reCAPTCHA-invisible] Transcribed: '{text}'") + + # Fill response input + input_field = await challenge_iframe.query_selector("#audio-response") + if not input_field: + logger.error("[reCAPTCHA-invisible] Audio response input not found.") + return False + + await input_field.fill(text) + await asyncio.sleep(0.5) + + # Click verify + verify_button = await challenge_iframe.query_selector("#recaptcha-verify-button") + if not verify_button or not await verify_button.is_visible(): + logger.error("[reCAPTCHA-invisible] Verify button not found.") + return False + + try: + await verify_button.click(timeout=5000, force=True) + except Exception as e: + logger.error(f"[reCAPTCHA-invisible] Failed to click verify: {e}") + return False + + await asyncio.sleep(2) + + # Check if solved + try: + checkbox = await anchor_iframe.query_selector("#recaptcha-anchor") + if checkbox: + is_checked = await checkbox.get_attribute("aria-checked") + if is_checked == "true": + logger.info("[reCAPTCHA-invisible] Solved successfully via audio.") + return True + except Exception: + pass + + # For invisible reCAPTCHA, also check if the token was generated + try: + token = await page.evaluate(""" + () => { + try { return grecaptcha.getResponse() || null; } + catch(e) { return null; } + } + """) + if token and len(token) > 10: + logger.info("[reCAPTCHA-invisible] Token generated successfully.") + return True + except Exception: + pass + + logger.warning("[reCAPTCHA-invisible] Verify clicked but not confirmed.") + return False + + except Exception as e: + logger.error(f"[reCAPTCHA-invisible] Error: {e}") + return False + + async def _solve_recaptcha_v2_audio_once(self, page, iframe_selector: str = None) -> bool: + """ + Single attempt to solve reCAPTCHA v2 audio challenge. + """ + try: + # We don't need ffmpeg or pydub anymore, we will use Groq API + import os + import uuid + import shutil + + # Locate anchor iframe - try multiple strategies + anchor_iframe = None + if iframe_selector: + iframe_el = await page.query_selector(iframe_selector) + if iframe_el: + anchor_iframe = await iframe_el.content_frame() + + # Strategy 1: Find by URL pattern + if not anchor_iframe: + for frame in page.frames: + if "api2/anchor" in frame.url: + try: + el = await frame.frame_element() + if el and await el.is_visible(): + anchor_iframe = frame + break + except Exception: + pass + + # Strategy 2: Find by title attribute + if not anchor_iframe: + for frame in page.frames: + try: + el = await frame.frame_element() + if el: + title = await el.get_attribute("title") + if title and "recaptcha" in title.lower(): + anchor_iframe = frame + break + except Exception: + pass + + # Strategy 3: Find by frame name + if not anchor_iframe: + for frame in page.frames: + if frame.name and "recaptcha" in frame.name.lower(): + anchor_iframe = frame + break + + # Strategy 4: Fallback - any frame with api2/anchor + if not anchor_iframe: + for frame in page.frames: + if "api2/anchor" in frame.url: + anchor_iframe = frame + break + + if not anchor_iframe: + logger.error("reCAPTCHA anchor iframe not found after all strategies.") + # Log all frames for debugging + for i, frame in enumerate(page.frames): + logger.debug(f"Frame {i}: url={frame.url}, name={frame.name}") + return False + + # Click checkbox + checkbox = await anchor_iframe.query_selector("#recaptcha-anchor") + if not checkbox: + logger.error("reCAPTCHA anchor checkbox not found.") + return False + + try: + await checkbox.click(timeout=5000, force=True) + except Exception as e: + logger.error(f"Failed to click anchor checkbox: {e}") + return False + + await asyncio.sleep(2) + + # Check if solved directly (some high-reputation browsers get passed instantly) + is_checked = await checkbox.get_attribute("aria-checked") + if is_checked == "true": + logger.info("reCAPTCHA solved instantly by reputation.") + return True + + # Wait for any challenge iframe to appear and be visible + try: + await page.wait_for_selector("iframe[src*='api2/bframe']", state="visible", timeout=5000) + except Exception: + pass + + # Locate challenge iframe + challenge_iframe = None + for frame in page.frames: + if "api2/bframe" in frame.url: + try: + frame_element = await frame.frame_element() + if frame_element and await frame_element.is_visible(): + challenge_iframe = frame + break + except Exception: + pass + + if not challenge_iframe: + for frame in page.frames: + if "api2/bframe" in frame.url: + challenge_iframe = frame + break + + if not challenge_iframe: + logger.error("reCAPTCHA challenge iframe not found.") + return False + + # Click audio challenge button + audio_button = await challenge_iframe.query_selector("#recaptcha-audio-button") + if not audio_button or not await audio_button.is_visible(): + logger.warning("Audio challenge button not found or invisible. reCAPTCHA might be blocked.") + return False + + try: + await audio_button.click(timeout=5000, force=True) + except Exception as e: + logger.error(f"Failed to click audio button: {e}") + return False + + await asyncio.sleep(2.5) + + # Look for block message + block_msg = await challenge_iframe.query_selector(".rc-dsu-cant-solve-active") + if block_msg and await block_msg.is_visible(): + logger.error("reCAPTCHA blocked: Automated queries warning detected.") + return False + + # Get download link + download_link = await challenge_iframe.query_selector(".rc-audiochallenge-tdownload-link") + if not download_link: + logger.error("Audio download link not found.") + return False + + audio_url = await download_link.get_attribute("href") + + # Download MP3 file — usar page.request para heredar cookies/TLS/headers del browser + audio_resp = await page.request.get(audio_url) + if not audio_resp.ok: + logger.error(f"Failed to download audio challenge (status {audio_resp.status}).") + return False + + # Verify the audio file has actual content (Google may block and serve empty file) + audio_content = await audio_resp.body() + if len(audio_content) < 1024: # Less than 1KB means the file is empty/blocked + logger.error(f"Audio challenge file is too small ({len(audio_content)} bytes) — Google is blocking the audio challenge for this browser session.") + return False + + if not settings.groq_api_key: + logger.error("GROQ_API_KEY is not configured in settings. Cannot transcribe audio.") + return False + + # Send the MP3 to Groq Whisper API + groq_url = "https://api.groq.com/openai/v1/audio/transcriptions" + headers = {"Authorization": f"Bearer {settings.groq_api_key}"} + + # We must use files= parameter for multipart/form-data + files = { + "file": ("challenge.mp3", audio_content, "audio/mpeg") + } + data = { + "model": "whisper-large-v3-turbo", + "response_format": "json" + } + + async with httpx.AsyncClient(timeout=60) as client: + transcription_resp = await client.post(groq_url, headers=headers, files=files, data=data) + + if transcription_resp.status_code != 200: + logger.error(f"Groq API Error: {transcription_resp.text}") + return False + + transcription_json = transcription_resp.json() + text = transcription_json.get("text", "").strip() + + if not text: + logger.error("Groq API returned empty text.") + return False + + logger.info(f"reCAPTCHA audio transcribed via Groq successfully: '{text}'") + + # Fill the response input + input_field = await challenge_iframe.query_selector("#audio-response") + if not input_field: + logger.error("Audio response input field not found.") + return False + + await input_field.fill(text) + await asyncio.sleep(0.5) + + # Click verify + verify_button = await challenge_iframe.query_selector("#recaptcha-verify-button") + if not verify_button or not await verify_button.is_visible(): + logger.error("Verify button not found or invisible.") + return False + + try: + await verify_button.click(timeout=5000, force=True) + except Exception as e: + logger.error(f"Failed to click verify button: {e}") + return False + + await asyncio.sleep(2) + + # Final check + is_checked = await checkbox.get_attribute("aria-checked") + if is_checked == "true": + logger.info("reCAPTCHA solved successfully via audio transcription.") + return True + + logger.warning("reCAPTCHA verify button clicked but checkmark not set.") + return False + + except Exception as e: + logger.error(f"Error solving audio reCAPTCHA: {e}") + return False + # --------------------------------------------------------------------------- + # NopeCHA — reCAPTCHA v3 Solver + # --------------------------------------------------------------------------- + # NopeCHA offers a generous free tier with a daily quota. + # Each reCAPTCHA v3 token costs ~20 credits. + # Docs: https://nopecha.com/api + # + # Flow: + # 1. POST /token/ → { id: "job_id" } (submit the solving job) + # 2. GET /token/?key=...&id=... → poll until { data: ["token"] } or error + # + # The returned token is a standard g-recaptcha-response value that can be + # injected into the target form directly — no browser interaction needed. + # --------------------------------------------------------------------------- + + async def solve_recaptcha_v3_nopecha( + self, + site_key: str, + page_url: str, + action: str = "submit", + max_wait_seconds: int = 90, + ) -> str | None: + """ + Solves reCAPTCHA v3 using the NopeCHA token API. + + Args: + site_key: The reCAPTCHA v3 data-sitekey on the target page. + page_url: Full URL of the page being solved (RENAPER portal, etc). + action: The grecaptcha action name (e.g. 'submit_tramite'). + max_wait_seconds: How long to poll before giving up (default 90s). + + Returns: + A valid g-recaptcha-response token string, or None on failure. + + Limits (NopeCHA free tier as of 2025): + - ~100 reCAPTCHA v3 solves per day + - ~20 credits per token + - Typical solve time: 10–30 seconds + - Score range: 0.3 – 0.9 (cannot be forced to a specific score) + """ + api_key = settings.nopecha_api_key + if not api_key: + logger.error( + "[NopeCHA] NOPECHA_API_KEY is not configured in .env — " + "cannot solve reCAPTCHA v3. Set NOPECHA_API_KEY=." + ) + return None + + base_url = "https://api.nopecha.com" + payload = { + "key": api_key, + "type": "recaptcha3", + "sitekey": site_key, + "url": page_url, + "data": {"action": action}, + } + + try: + async with httpx.AsyncClient(timeout=30) as client: + # Step 1 — Submit the job + submit_resp = await client.post(f"{base_url}/token/", json=payload) + submit_data = submit_resp.json() + + if submit_resp.status_code != 200 or "error" in submit_data: + logger.error( + f"[NopeCHA] Job submission failed " + f"(HTTP {submit_resp.status_code}): {submit_data}" + ) + return None + + job_id = submit_data.get("id") + if not job_id: + logger.error(f"[NopeCHA] No job ID in response: {submit_data}") + return None + + logger.info(f"[NopeCHA] Job submitted — id={job_id}, polling...") + + # Step 2 — Poll until resolved + poll_interval = 5 # seconds between polls + elapsed = 0 + + while elapsed < max_wait_seconds: + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + poll_resp = await client.get( + f"{base_url}/token/", + params={"key": api_key, "id": job_id}, + ) + poll_data = poll_resp.json() + + # NopeCHA returns {"data": [""]} on success + if poll_data.get("data"): + token = poll_data["data"][0] + if token and len(token) > 20: + logger.info( + f"[NopeCHA] reCAPTCHA v3 solved in ~{elapsed}s — " + f"token length: {len(token)}" + ) + return token + + # Check for terminal errors + error_code = poll_data.get("error") + if error_code and error_code not in (0, None): + logger.error( + f"[NopeCHA] Solver returned error after {elapsed}s: " + f"{poll_data}" + ) + return None + + logger.debug(f"[NopeCHA] Still solving... ({elapsed}s elapsed)") + + logger.error( + f"[NopeCHA] Timed out after {max_wait_seconds}s without a token." + ) + return None + + except Exception as e: + logger.error(f"[NopeCHA] Unexpected error: {e}") + return None + diff --git a/app/utils/circuit_breaker.py b/app/utils/circuit_breaker.py new file mode 100644 index 0000000000000000000000000000000000000000..40848d9a26f8ecf2e0989a4ee1cb36f033e3fbe6 --- /dev/null +++ b/app/utils/circuit_breaker.py @@ -0,0 +1,156 @@ +""" +Circuit Breaker Pattern — CrowData +Protege contra cascadas de fallos en scrapers y servicios externos. +""" +import asyncio +import logging +import time +from enum import Enum +from dataclasses import dataclass, field +from typing import Callable, Any, Optional +from functools import wraps + +logger = logging.getLogger(__name__) + + +class CircuitState(Enum): + CLOSED = "closed" # Normal operation, requests go through + OPEN = "open" # Failing, requests blocked immediately + HALF_OPEN = "half_open" # Testing if service recovered + + +@dataclass +class CircuitBreakerConfig: + failure_threshold: int = 5 # Failures before opening + success_threshold: int = 2 # Successes in half-open before closing + timeout: float = 60.0 # Seconds before trying half-open + excluded_exceptions: tuple = () # Exceptions that don't count as failures + + +@dataclass +class CircuitBreaker: + """ + Circuit breaker implementation for async operations. + + States: + - CLOSED: Normal operation, counts failures + - OPEN: Short-circuits requests, fails fast + - HALF_OPEN: Allows test requests to check recovery + """ + name: str + config: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig) + _state: CircuitState = field(default=CircuitState.CLOSED, init=False) + _failure_count: int = field(default=0, init=False) + _success_count: int = field(default=0, init=False) + _last_failure_time: Optional[float] = field(default=None, init=False) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) + + @property + def state(self) -> CircuitState: + # Auto-transition from OPEN to HALF_OPEN after timeout + if self._state == CircuitState.OPEN: + if self._last_failure_time and (time.time() - self._last_failure_time) >= self.config.timeout: + self._state = CircuitState.HALF_OPEN + self._success_count = 0 + logger.info(f"Circuit '{self.name}' transitioned to HALF_OPEN") + return self._state + + async def call(self, func: Callable, *args, **kwargs) -> Any: + """Execute function with circuit breaker protection.""" + async with self._lock: + current_state = self.state + + if current_state == CircuitState.OPEN: + raise CircuitOpenError(f"Circuit '{self.name}' is OPEN") + + if current_state == CircuitState.HALF_OPEN: + # Allow only one request at a time in half-open + pass + + try: + if asyncio.iscoroutinefunction(func): + result = await func(*args, **kwargs) + else: + result = func(*args, **kwargs) + + await self._on_success() + return result + + except self.config.excluded_exceptions: + # These don't count as failures + raise + except Exception as e: + await self._on_failure() + raise + + async def _on_success(self): + async with self._lock: + self._failure_count = 0 + + if self._state == CircuitState.HALF_OPEN: + self._success_count += 1 + if self._success_count >= self.config.success_threshold: + self._state = CircuitState.CLOSED + self._success_count = 0 + logger.info(f"Circuit '{self.name}' CLOSED after recovery") + + async def _on_failure(self): + async with self._lock: + self._failure_count += 1 + self._last_failure_time = time.time() + + if self._state == CircuitState.HALF_OPEN: + # Any failure in half-open goes back to open + self._state = CircuitState.OPEN + self._success_count = 0 + logger.warning(f"Circuit '{self.name}' reopened after half-open failure") + elif self._state == CircuitState.CLOSED: + if self._failure_count >= self.config.failure_threshold: + self._state = CircuitState.OPEN + logger.warning(f"Circuit '{self.name}' OPENED after {self._failure_count} failures") + + +class CircuitOpenError(Exception): + """Raised when circuit breaker is open.""" + pass + + +# Global registry of circuit breakers +_circuits: dict[str, CircuitBreaker] = {} + + +def get_circuit_breaker(name: str, config: Optional[CircuitBreakerConfig] = None) -> CircuitBreaker: + """Get or create a circuit breaker by name.""" + if name not in _circuits: + _circuits[name] = CircuitBreaker(name, config or CircuitBreakerConfig()) + return _circuits[name] + + +def circuit_breaker(name: str, config: Optional[CircuitBreakerConfig] = None): + """ + Decorator to add circuit breaker to async functions. + + Usage: + @circuit_breaker("bcra_scraper", CircuitBreakerConfig(failure_threshold=3)) + async def fetch_bcra(cuit): + ... + """ + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs): + circuit = get_circuit_breaker(name, config) + return await circuit.call(func, *args, **kwargs) + return wrapper + return decorator + + +async def get_all_circuit_status() -> dict: + """Get status of all registered circuits.""" + return { + name: { + "state": cb.state.value, + "failure_count": cb._failure_count, + "success_count": cb._success_count, + } + for name, cb in _circuits.items() + } \ No newline at end of file diff --git a/app/utils/dni_to_cuit.py b/app/utils/dni_to_cuit.py new file mode 100644 index 0000000000000000000000000000000000000000..cba140d019e76c005fcdd5eff582ba93eeff7405 --- /dev/null +++ b/app/utils/dni_to_cuit.py @@ -0,0 +1,90 @@ +""" +Utilidad para convertir DNI a CUIT y validar contra AFIP. +""" +import logging +import asyncio + +logger = logging.getLogger(__name__) + +def generate_possible_cuits(dni: str) -> list[str]: + """ + Genera los CUITs posibles para un DNI dado. + Utiliza los prefijos 20, 27, 23 y 24. + """ + dni = dni.zfill(8) + prefixes = ["20", "27", "23", "24"] + weights = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2] + + possible_cuits = [] + + for prefix in prefixes: + base = prefix + dni + sum_val = sum(int(b) * w for b, w in zip(base, weights)) + rem = sum_val % 11 + z = 11 - rem + if z == 11: + z = 0 + + if z == 10: + continue + + cuit = base + str(z) + possible_cuits.append(cuit) + + return possible_cuits + +async def find_real_cuit(dni: str) -> str | None: + """ + Dada una cadena de DNI, busca el CUIT real usando CuitFromDniScraper (AFIP Padron A13). + Mucho más rápido que el método anterior (1 request vs 4). + """ + from app.scrapers.cuit_from_dni import CuitFromDniScraper + + dni_clean = "".join(filter(str.isdigit, dni)) + if not (7 <= len(dni_clean) <= 8): + return None + + try: + scraper = CuitFromDniScraper() + result = await asyncio.wait_for(scraper.fetch(dni_clean), timeout=30) + if result.get("confirmado") and result.get("cuit"): + cuit = result["cuit"].replace("-", "") + if len(cuit) == 11 and cuit.isdigit(): + logger.info(f"[dni_to_cuit] CUIT resuelto para DNI {dni_clean}: {cuit} (fuente: {result.get('fuente')})") + return cuit + except Exception as e: + logger.warning(f"[dni_to_cuit] CuitFromDniScraper falló para DNI {dni_clean}: {e}") + + # Fallback: método anterior (generar CUITs posibles y validar contra ARCA) + logger.info(f"[dni_to_cuit] Fallback a validación ARCA para DNI {dni_clean}") + return await _find_real_cuit_arca_fallback(dni_clean) + + +async def _find_real_cuit_arca_fallback(dni: str) -> str | None: + """Fallback: genera CUITs posibles y los valida contra ARCA (4 requests).""" + from app.scrapers.arca_afip import ArcaAfipScraper + + possible_cuits = generate_possible_cuits(dni) + if not possible_cuits: + return None + + logger.info(f"[dni_to_cuit] Buscando CUIT real para DNI {dni}. Posibles: {possible_cuits}") + + scraper = ArcaAfipScraper() + + async def check_cuit(cuit: str): + try: + res = await scraper.fetch(cuit) + if res and res.get("estado_afip"): + return cuit + except Exception: + pass + return None + + results = await asyncio.gather(*(check_cuit(c) for c in possible_cuits)) + + for res in results: + if res: + return res + + return None diff --git a/app/utils/email_service.py b/app/utils/email_service.py new file mode 100644 index 0000000000000000000000000000000000000000..279d835d56b38458c8be730d7eec07520963def6 --- /dev/null +++ b/app/utils/email_service.py @@ -0,0 +1,129 @@ +"""CrowData Email Service — Async email sending via SMTP.""" + +import logging +from pathlib import Path +from jinja2 import Environment, FileSystemLoader +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "emails" +jinja_env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR))) + + +async def send_email( + to_email: str, + subject: str, + template_name: str, + context: dict, + attachments: list[dict] | None = None, +): + """ + Send an HTML email using Jinja2 templates. + + Args: + to_email: Recipient email address + subject: Email subject + template_name: Template file name (e.g., 'welcome.html') + context: Template context variables + attachments: Optional list of dicts with 'filename' and 'content' (bytes) + """ + try: + # Render template + template = jinja_env.get_template(template_name) + html_body = template.render(**context) + + # Build message + msg = MIMEMultipart("alternative") + msg["From"] = f"{settings.from_name} <{settings.from_email}>" + msg["To"] = to_email + msg["Subject"] = subject + + # Plain text fallback + plain_text = _html_to_plain(html_body) + msg.attach(MIMEText(plain_text, "plain", "utf-8")) + msg.attach(MIMEText(html_body, "html", "utf-8")) + + # Attachments (e.g., PDF reports) + if attachments: + for att in attachments: + part = MIMEApplication(att["content"], Name=att["filename"]) + part["Content-Disposition"] = f'attachment; filename="{att["filename"]}"' + msg.attach(part) + + # Send + if not settings.smtp_user or not settings.smtp_password: + logger.warning(f"SMTP not configured — email to {to_email} NOT sent. Subject: {subject}") + return False + + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + start_tls=settings.smtp_use_tls, + ) + logger.info(f"Email sent to {to_email}: {subject}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {e}") + return False + + +async def send_welcome_email(to_email: str, full_name: str | None = None): + """Send welcome email after registration.""" + name = full_name or to_email.split("@")[0].title() + return await send_email( + to_email=to_email, + subject="Bienvenido a CrowData", + template_name="welcome.html", + context={ + "name": name, + "email": to_email, + "login_url": "https://crowdata.ar/src/pages/login.html", + "dashboard_url": "https://crowdata.ar/src/pages/dashboard.html", + "support_email": settings.from_email, + }, + ) + + +async def send_pdf_report(to_email: str, report_type: str, identifier: str, pdf_bytes: bytes, user_name: str | None = None): + """Send a PDF report as email attachment.""" + type_labels = { + "persona": "Persona", + "empresa": "Empresa", + "vehiculo": "Vehículo", + "propiedad": "Inmueble", + } + type_label = type_labels.get(report_type, report_type) + filename = f"CrowData_{type_label}_{identifier}.pdf" + + return await send_email( + to_email=to_email, + subject=f"CrowData — Informe de {type_label} {identifier}", + template_name="pdf_delivery.html", + context={ + "name": user_name or to_email.split("@")[0].title(), + "report_type": type_label, + "identifier": identifier, + "support_email": settings.from_email, + }, + attachments=[{"filename": filename, "content": pdf_bytes}], + ) + + +def _html_to_plain(html: str) -> str: + """Very basic HTML to plain text conversion.""" + import re + text = re.sub(r"", "\n", html) + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"\s+", " ", text).strip() + return text diff --git a/app/utils/encryption.py b/app/utils/encryption.py new file mode 100644 index 0000000000000000000000000000000000000000..2ceb90de6cdd11e14a58f6757b10c0930b80308e --- /dev/null +++ b/app/utils/encryption.py @@ -0,0 +1,157 @@ +""" +MFA Encryption Utilities — CrowData. +Cifra/descifra secretos TOTP y códigos de respaldo usando Fernet (AES-128-GCM). +La clave se deriva de MFA_ENCRYPTION_KEY (base64, 32 bytes) en settings. + +Soporte de versiones: +- v2: Fernet con MFA_ENCRYPTION_KEY (actual, recomendado) +- v1: PBKDF2 derivado de SECRET_KEY (legacy, solo lectura para migración) + Formato v1: `v1:` + Formato v2: `v2:` +""" +from cryptography.fernet import Fernet, InvalidToken +from app.config import get_settings +import base64 +import json +import logging + +logger = logging.getLogger(__name__) + +_settings = None + +# ─── v2: Fernet con MFA_ENCRYPTION_KEY ─── +def _get_fernet_v2() -> Fernet: + global _settings + if _settings is None: + _settings = get_settings() + + key = _settings.mfa_encryption_key + if not key: + raise RuntimeError("MFA_ENCRYPTION_KEY no configurado en .env") + + try: + decoded = base64.urlsafe_b64decode(key) + if len(decoded) != 32: + raise ValueError + except Exception: + raise RuntimeError("MFA_ENCRYPTION_KEY debe ser base64 url-safe de 32 bytes (generar con: python -c \"import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())\")") + + return Fernet(key.encode()) + +# ─── v1: PBKDF2 derivado de SECRET_KEY (legacy, solo lectura) ─── +_v1_fernet = None + +def _get_fernet_v1() -> Fernet: + global _v1_fernet + if _v1_fernet is None: + s = get_settings() + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b'crowdata-mfa-encryption', + iterations=100000, + ) + key = base64.urlsafe_b64encode(kdf.derive(s.secret_key.encode())) + _v1_fernet = Fernet(key) + return _v1_fernet + +# ─── API pública versionada ─── +VERSION_PREFIX_V1 = b"v1:" +VERSION_PREFIX_V2 = b"v2:" + +def _encrypt_with_version(data: str, version: int = 2) -> str: + """Cifra con prefijo de versión.""" + if version == 1: + f = _get_fernet_v1() + prefix = VERSION_PREFIX_V1 + else: + f = _get_fernet_v2() + prefix = VERSION_PREFIX_V2 + return (prefix + f.encrypt(data.encode())).decode() + +def _decrypt_with_version(encrypted: str) -> str: + """Descifra detectando automáticamente la versión por prefijo.""" + if not encrypted: + raise ValueError("Empty encrypted data") + + data_bytes = encrypted.encode() + + if data_bytes.startswith(VERSION_PREFIX_V1): + f = _get_fernet_v1() + return f.decrypt(data_bytes[len(VERSION_PREFIX_V1):]).decode() + elif data_bytes.startswith(VERSION_PREFIX_V2): + f = _get_fernet_v2() + return f.decrypt(data_bytes[len(VERSION_PREFIX_V2):]).decode() + else: + # Sin prefijo = legacy sin versión, intentar v1 (formato anterior) + logger.warning("Encrypted data without version prefix, trying v1 (legacy)") + try: + return _get_fernet_v1().decrypt(data_bytes).decode() + except InvalidToken: + # Último intento con v2 + return _get_fernet_v2().decrypt(data_bytes).decode() + +# ─── API pública (usa v2 para escribir, ambas para leer) ─── +def encrypt_mfa(data: str) -> str: + """Cifra un string (secreto TOTP) usando v2.""" + return _encrypt_with_version(data, version=2) + +def decrypt_mfa(encrypted: str) -> str: + """Descifra detectando versión automáticamente (v1 o v2).""" + return _decrypt_with_version(encrypted) + +def encrypt_backup_codes(codes: list[str]) -> str: + """Cifra una lista de códigos de respaldo como JSON usando v2.""" + import json + return encrypt_mfa(json.dumps(codes)) + +def decrypt_backup_codes(encrypted: str) -> list[str]: + """Descifra y parsea la lista de códigos de respaldo.""" + import json + return json.loads(decrypt_mfa(encrypted)) + +# ─── Migración / compatibilidad ─── +def is_encrypted(data: str) -> bool: + """Detecta si un string parece estar cifrado (tiene prefijo v1: o v2: o parece Fernet).""" + try: + if data.startswith(("v1:", "v2:")): + return True + # Legacy sin prefijo + return len(data) > 40 and data.endswith("=") + except Exception: + return False + +def safe_decrypt_mfa(data: str) -> str: + """Descifra si está cifrado, sino retorna tal cual (para migración gradual).""" + if not data: + return data + if is_encrypted(data): + try: + return decrypt_mfa(data) + except Exception as e: + logger.warning(f"Failed to decrypt MFA data: {e}") + return data # Fallback + return data + +def safe_decrypt_backup_codes(data: str) -> list[str]: + """Descifra códigos de respaldo si están cifrados, sino parsea JSON o lista vacía.""" + if not data: + return [] + if is_encrypted(data): + try: + return decrypt_backup_codes(data) + except Exception: + pass + # Fallback: intentar parsear como JSON + import json + try: + return json.loads(data) + except Exception: + return [] + +def migrate_to_v2(encrypted_v1: str) -> str: + """Re-cifra dato v1 a v2. Para migración batch.""" + plaintext = _get_fernet_v1().decrypt(encrypted_v1.encode()).decode() + return encrypt_mfa(plaintext) \ No newline at end of file diff --git a/app/utils/http_client.py b/app/utils/http_client.py new file mode 100644 index 0000000000000000000000000000000000000000..9a166d755be56ea202491c8fdca993056b1a33a1 --- /dev/null +++ b/app/utils/http_client.py @@ -0,0 +1,116 @@ +""" +HTTP Client Pool — CrowData +Centralized HTTP client with connection pooling, retries, and timeouts. +""" +import asyncio +import logging +from typing import Optional +import httpx +from app.config import get_settings + +logger = logging.getLogger(__name__) + +_settings = get_settings() + +# Global connection pools +_http_clients: dict[str, httpx.AsyncClient] = {} +_client_locks: dict[str, asyncio.Lock] = {} + + +class HTTPClientPool: + """Manages pooled HTTP clients per domain.""" + + def __init__(self): + self._clients: dict[str, httpx.AsyncClient] = {} + self._locks: dict[str, asyncio.Lock] = {} + + def _get_lock(self, key: str) -> asyncio.Lock: + if key not in self._locks: + self._locks[key] = asyncio.Lock() + return self._locks[key] + + async def get_client(self, base_url: str = None, timeout: float = 30.0) -> httpx.AsyncClient: + """Get or create a client for the given base_url.""" + key = base_url or "default" + lock = self._get_lock(key) + + async with lock: + if key not in self._clients or self._clients[key].is_closed: + client_kwargs = { + "timeout": httpx.Timeout(timeout, connect=10.0), + "limits": httpx.Limits( + max_keepalive_connections=20, + max_connections=100, + keepalive_expiry=30.0, + ), + "headers": { + "User-Agent": "CrowData/1.0 (+https://crowdata.ar)", + "Accept": "application/json, text/html, */*", + "Accept-Language": "es-AR,es;q=0.9,en;q=0.8", + }, + "follow_redirects": True, + } + if base_url: + client_kwargs["base_url"] = base_url + self._clients[key] = httpx.AsyncClient(**client_kwargs) + logger.debug(f"Created new HTTP client for {key or 'default'}") + return self._clients[key] + + async def close_all(self): + """Close all clients gracefully.""" + for key, client in self._clients.items(): + try: + await client.aclose() + logger.debug(f"Closed HTTP client for {key}") + except Exception as e: + logger.warning(f"Error closing client {key}: {e}") + self._clients.clear() + + async def get(self, url: str, base_url: str = None, **kwargs) -> httpx.Response: + """Convenience method for GET request.""" + client = await self.get_client(base_url) + return await client.get(url, **kwargs) + + async def post(self, url: str, base_url: str = None, **kwargs) -> httpx.Response: + """Convenience method for POST request.""" + client = await self.get_client(base_url) + return await client.post(url, **kwargs) + + +# Global pool instance +_pool = HTTPClientPool() + + +async def get_http_client(base_url: str = None, timeout: float = 30.0) -> httpx.AsyncClient: + """Get HTTP client from pool.""" + return await _pool.get_client(base_url, timeout) + + +async def close_http_pool(): + """Close all pooled connections.""" + await _pool.close_all() + + +# Convenience functions +async def http_get(url: str, *, base_url: str = None, **kwargs) -> httpx.Response: + """GET request using pooled client.""" + if url is None: + logger.error(f"http_get called with None URL! base_url={base_url}, kwargs={kwargs}") + raise ValueError("http_get called with None URL") + if not isinstance(url, str): + logger.error(f"http_get called with non-string URL: {type(url)} = {url!r}") + raise ValueError(f"http_get expects str URL, got {type(url).__name__}") + client = await _pool.get_client(base_url) + return await client.get(url, **kwargs) + + +async def http_post(url: str, *, base_url: str = None, **kwargs) -> httpx.Response: + """POST request using pooled client.""" + if url is None: + logger.error(f"http_post called with None URL! base_url={base_url}, kwargs={kwargs}") + raise ValueError("http_post called with None URL") + if not isinstance(url, str): + logger.error(f"http_post called with non-string URL: {type(url)} = {url!r}") + raise ValueError(f"http_post expects str URL, got {type(url).__name__}") + client = await _pool.get_client(base_url) + return await client.post(url, **kwargs) \ No newline at end of file diff --git a/app/utils/logging_structured.py b/app/utils/logging_structured.py new file mode 100644 index 0000000000000000000000000000000000000000..6191e77f2b99040ddaa2766f0fe66cadae59aa2d --- /dev/null +++ b/app/utils/logging_structured.py @@ -0,0 +1,164 @@ +""" +Structured Logging with Correlation ID — CrowData +""" +import logging +import json +import uuid +import sys +from datetime import datetime +from contextvars import ContextVar +from typing import Optional, Dict, Any +from functools import wraps +import asyncio + +# Context variable to store correlation ID across async calls +correlation_id_var: ContextVar[str] = ContextVar('correlation_id', default='') +request_id_var: ContextVar[str] = ContextVar('request_id', default='') + +class JSONFormatter(logging.Formatter): + """JSON formatter for structured logging.""" + + def format(self, record: logging.LogRecord) -> str: + log_data = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "function": record.funcName, + "line": record.lineno, + } + + # Add correlation IDs + corr_id = correlation_id_var.get() + if corr_id: + log_data["correlation_id"] = corr_id + + req_id = request_id_var.get() + if req_id: + log_data["request_id"] = req_id + + # Add extra fields from record + for key, value in record.__dict__.items(): + if key not in ('name', 'msg', 'args', 'created', 'filename', 'funcName', + 'levelname', 'levelno', 'lineno', 'module', 'msecs', + 'message', 'msg', 'name', 'pathname', 'process', + 'processName', 'relativeCreated', 'thread', + 'threadName', 'exc_info', 'exc_text', 'stack_info'): + log_data[key] = value + + # Handle exceptions + if record.exc_info: + log_data["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_data, ensure_ascii=False) + +class StructuredLogger: + """Wrapper for structured logging with correlation IDs.""" + + def __init__(self, name: str): + self.logger = logging.getLogger(name) + self._setup() + + def _setup(self): + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JSONFormatter()) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + self.logger.propagate = False + + def _log(self, level: int, message: str, **kwargs): + extra = {"extra_fields": kwargs} + self.logger.log(level, message, extra=extra) + + def debug(self, message: str, **kwargs): + self._log(logging.DEBUG, message, **kwargs) + + def info(self, message: str, **kwargs): + self._log(logging.INFO, message, **kwargs) + + def warning(self, message: str, **kwargs): + self._log(logging.WARNING, message, **kwargs) + + def error(self, message: str, **kwargs): + self._log(logging.ERROR, message, **kwargs) + + def critical(self, message: str, **kwargs): + self._log(logging.CRITICAL, message, **kwargs) + + def exception(self, message: str, **kwargs): + kwargs["exc_info"] = True + self._log(logging.ERROR, message, **kwargs) + + +# Context management +def set_correlation_id(correlation_id: str = None) -> str: + """Set correlation ID for current context. Returns the ID.""" + if not correlation_id: + correlation_id = str(uuid.uuid4())[:8] + correlation_id_var.set(correlation_id) + return correlation_id + +def get_correlation_id() -> str: + return correlation_id_var.get() + +def set_request_id(request_id: str): + request_id_var.set(request_id) + +def get_request_id() -> str: + return request_id_var.get() + + +def with_correlation_id(func): + """Decorator to inject correlation ID into async functions.""" + @wraps(func) + async def wrapper(*args, **kwargs): + corr_id = get_correlation_id() + if not corr_id: + corr_id = set_correlation_id() + return await func(*args, **kwargs) + return wrapper + + +class CorrelationMiddleware: + """Middleware to inject correlation ID into requests.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # Extract or generate correlation ID + correlation_id = None + headers = dict(scope.get("headers", [])) + for k, v in headers: + if k.decode() == "x-correlation-id": + correlation_id = v.decode() + break + + if not correlation_id: + correlation_id = str(uuid.uuid4())[:8] + + set_correlation_id(correlation_id) + + async def send_wrapper(message): + if message["type"] == "http.response.start": + headers = list(message.get("headers", [])) + headers.append((b"x-correlation-id", correlation_id.encode())) + message["headers"] = headers + await send(message) + + await self.app(scope, receive, send_wrapper) + + +# Helper for creating structured log entries +def log_structured(logger: logging.Logger, level: int, message: str, + correlation_id: str = None, **fields): + """Log structured message with optional correlation ID.""" + extra = {"extra_fields": fields} + if correlation_id: + extra["correlation_id"] = correlation_id + logger.log(level, message, extra=extra) \ No newline at end of file diff --git a/app/utils/logo.png b/app/utils/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f05b7904f41ffad2f0b73b76b6745a9d532e4cda --- /dev/null +++ b/app/utils/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5feabde7ab0ae36e87f2125a4b768f30260c7de97fe14ac7feafa27964b26f0 +size 249711 diff --git a/app/utils/pdf_generator.py b/app/utils/pdf_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..607821ab159842d6b7de0a3b289f82a7161d9496 --- /dev/null +++ b/app/utils/pdf_generator.py @@ -0,0 +1,1480 @@ +from fpdf import FPDF +import datetime +import os +import httpx +import tempfile + + +class CrowDataPDF(FPDF): + def header(self): + self.set_fill_color(30, 41, 59) + self.rect(0, 0, 210, 32, 'F') + logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png") + if os.path.exists(logo_path): + self.image(logo_path, x=10, y=6, w=40) + self.set_font('helvetica', 'B', 22) + self.set_text_color(255, 255, 255) + self.set_y(6) + self.set_x(55) + self.cell(0, 10, 'CrowData Intelligence', new_x="LMARGIN", new_y="NEXT", align='L') + self.set_font('helvetica', '', 10) + self.set_text_color(203, 213, 225) + self.set_x(55) + self.cell(0, 5, 'Dossier de Inteligencia Consolidado - Confidencial', new_x="LMARGIN", new_y="NEXT", align='L') + self.set_fill_color(16, 185, 129) + self.rect(0, 31, 210, 1, 'F') + self.ln(12) + + def footer(self): + self.set_y(-15) + self.set_font('helvetica', 'I', 8) + self.set_text_color(100, 116, 139) + fecha_gen = datetime.datetime.now().strftime("%d/%m/%Y %H:%M") + self.cell(0, 10, f'Pagina {self.page_no()} | Generado por CrowData el {fecha_gen}', align='C') + + +def clean_txt(val): + if val is None: + return "" + s = str(val) + s = s.replace("\u2013", "-").replace("\u2014", "-").replace("\u201c", '"').replace("\u201d", '"') + s = s.replace("\u2018", "'").replace("\u2019", "'").replace("\u2022", "*") + try: + return s.encode('latin-1', 'replace').decode('latin-1') + except Exception: + return s + + +def safe_float(val, default=0.0): + """ + Convierte valor a float de forma segura, manejando None, strings y errores. + Usado para prevenir ValueError en conversiones de montos. + """ + import logging + logger = logging.getLogger(__name__) + + if val is None: + return default + if isinstance(val, (int, float)): + return float(val) + if isinstance(val, str): + # Limpiar formato de moneda/puntos/comas + clean_val = (val.replace('$', '') + .replace(',', '') + .replace('.', '') + .replace('k', '000') + .replace('K', '000') + .strip()) + try: + return float(clean_val) if clean_val else default + except ValueError: + logger.warning(f"No se pudo convertir '{val}' a float, usando default {default}") + return default + logger.warning(f"Tipo inesperado para safe_float: {type(val)}, valor: {val}, usando default {default}") + return default + + +def safe_int(val, default=0): + """Convierte valor a int de forma segura""" + try: + return int(safe_float(val, default)) + except (ValueError, TypeError): + return default + + +def _section_header(pdf, title): + pdf.ln(4) + pdf.set_font('helvetica', 'B', 12) + pdf.set_text_color(30, 41, 59) + pdf.cell(0, 8, clean_txt(title), new_x="LMARGIN", new_y="NEXT") + pdf.set_fill_color(226, 232, 240) + pdf.rect(10, pdf.get_y(), 190, 0.3, 'F') + pdf.ln(3) + + +def _kv_line(pdf, label, value, label_w=50, font_size=9.5): + pdf.set_font('helvetica', 'B', font_size) + pdf.set_text_color(71, 85, 105) + pdf.cell(label_w, 6, clean_txt(label), new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', '', font_size) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 6, clean_txt(str(value) if value else "-"), new_x="LMARGIN", new_y="NEXT") + + +def _item_bullet(pdf, text, font_size=9.5): + pdf.set_x(10) + pdf.set_font('helvetica', '', font_size) + pdf.set_text_color(15, 23, 42) + pdf.multi_cell(190, 5.5, clean_txt(text)) + + +def _empty_notice(pdf, text): + pdf.set_font('helvetica', '', 9.5) + pdf.set_text_color(100, 116, 139) + pdf.cell(0, 6, clean_txt(text), new_x="LMARGIN", new_y="NEXT") + + +def _add_photo_to_pdf(pdf, foto_url: str, x: float = 150, y: float = 35, max_w: float = 45, max_h: float = 45): + """Descarga y agrega foto de perfil al PDF. Silencioso en caso de error. Timeout reducido a 3s.""" + if not foto_url: + return + try: + with httpx.Client(timeout=3, follow_redirects=True) as client: # Reducido de 10s a 3s + resp = client.get(foto_url, headers={"User-Agent": "Mozilla/5.0"}) + if resp.status_code != 200: + return + content_type = resp.headers.get("content-type", "") + if "image" not in content_type and not foto_url.endswith((".jpg", ".jpeg", ".png", ".webp")): + return + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + try: + pdf.image(tmp_path, x=x, y=y, w=max_w, h=max_h) + finally: + os.unlink(tmp_path) + except Exception: + pass + pdf.ln(2) + + +def _get_nested(obj, *keys, default=None): + for k in keys: + if obj is None: + return default + if isinstance(obj, dict): + obj = obj.get(k) + else: + return default + return obj if obj is not None else default + + +def generate_report_pdf(data: dict, report_type: str = "persona") -> bytes: + pdf = CrowDataPDF() + pdf.set_auto_page_break(auto=True, margin=20) + pdf.add_page() + + ident = data.get("identificacion", {}) + rc = data.get("registro_civil", {}) + fiscal = data.get("fiscal", {}) + fin = data.get("financiero", {}) + judicial = data.get("judicial", {}) + patrimonial = data.get("patrimonial", {}) + societario = data.get("societario", {}) + prev = data.get("previsional", {}) + salud = data.get("salud", {}) + contacto = data.get("contacto", {}) + meta = data.get("meta", {}) + + # ═══════════════════════════════════════════════════════════ + # SECTION 1: TITLE & IDENTIFICACION + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + nombre_informe = f"{ident.get('apellido', '')} {ident.get('nombres', '')}".strip() + elif report_type == "vehiculo": + veh = data.get("vehiculo", {}) + nombre_informe = f"Vehículo {veh.get('dominio', '')} - {veh.get('marca', '')} {veh.get('modelo', '')} ({veh.get('anio', '')})".strip() + elif report_type == "propiedad": + nombre_informe = data.get("direccion", "Propiedad") + elif report_type == "grupo": + target = data.get("target", {}) + target_ident = target.get("identificacion", {}) if target else {} + nombre_informe = f"Grupo Económico: {target_ident.get('apellido', '')} {target_ident.get('nombres', '')}".strip() + else: + nombre_informe = ident.get('razon_social', '') or ident.get('cuit', 'Empresa') + + pdf.set_font('helvetica', 'B', 16) + pdf.set_text_color(30, 41, 59) + pdf.cell(0, 10, clean_txt(f"INFORME: {nombre_informe}"), new_x="LMARGIN", new_y="NEXT") + pdf.set_fill_color(226, 232, 240) + pdf.rect(10, pdf.get_y(), 190, 0.5, 'F') + pdf.ln(5) + + if report_type == "persona": + _kv_line(pdf, "CUIT/CUIL:", ident.get('cuil')) + _kv_line(pdf, "DNI:", ident.get('dni')) + _kv_line(pdf, "Sexo:", ident.get('sexo')) + _kv_line(pdf, "Nacionalidad:", ident.get('nacionalidad')) + + fecha_nac = ident.get('fecha_nacimiento') + if fecha_nac: + try: + parts = str(fecha_nac).split('-') + if len(parts) == 3: + fn = datetime.date(int(parts[0]), int(parts[1]), int(parts[2])) + today = datetime.date.today() + edad = today.year - fn.year - ((today.month, today.day) < (fn.month, fn.day)) + fecha_fmt = fn.strftime('%d/%m/%Y') + _kv_line(pdf, "Fecha Nacimiento:", f"{fecha_fmt} ({edad} anios)") + else: + _kv_line(pdf, "Fecha Nacimiento:", str(fecha_nac)) + except Exception: + _kv_line(pdf, "Fecha Nacimiento:", str(fecha_nac)) + + fallecido = rc.get("fallecido", False) or bool(ident.get('fecha_defuncion')) + if fallecido: + fecha_def = rc.get("fecha_defuncion") or ident.get('fecha_defuncion') or "Fallecido" + pdf.set_font('helvetica', 'B', 10) + pdf.set_text_color(71, 85, 105) + pdf.cell(50, 7, "Estado Vital:", new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', '', 10) + pdf.set_text_color(185, 28, 28) + pdf.cell(0, 7, clean_txt(f"FALLECIDO ({fecha_def})"), new_x="LMARGIN", new_y="NEXT") + lugar_def = rc.get("lugar_defuncion") + if lugar_def: + _kv_line(pdf, "Lugar Defuncion:", str(lugar_def)) + else: + _kv_line(pdf, "Estado Vital:", "Activo / Vivo") + + _kv_line(pdf, "Validacion RENAPER:", "Verificado" if ident.get('validado_renaper') else "No verificado") + + foto_url = ident.get('foto_perfil') + if foto_url: + _add_photo_to_pdf(pdf, foto_url, x=155, y=40, max_w=40, max_h=40) + pdf.set_font('helvetica', 'I', 7) + pdf.set_text_color(100, 116, 139) + pdf.set_xy(155, 82) + pdf.cell(40, 4, clean_txt(f"Foto: {ident.get('foto_perfil_fuente', 'N/A')}"), align='C') + pdf.set_xy(10, pdf.get_y() + 5) + elif report_type == "vehiculo": + veh = data.get("vehiculo", {}) + _kv_line(pdf, "Dominio:", veh.get('dominio')) + _kv_line(pdf, "Marca / Modelo:", f"{veh.get('marca', '')} {veh.get('modelo', '')}") + _kv_line(pdf, "Año:", str(veh.get('anio', ''))) + _kv_line(pdf, "Tipo:", veh.get('tipo')) + radicacion_parts = [veh.get('registro'), veh.get('localidad'), veh.get('provincia')] + _kv_line(pdf, "Radicacion:", " — ".join(p for p in radicacion_parts if p)) + _kv_line(pdf, "VTV:", veh.get('validez_vtv') or 'Sin datos') + _kv_line(pdf, "Seguro:", 'SI' if veh.get('tiene_seguro') else 'NO') + _kv_line(pdf, "Estado:", veh.get('estado') or 'Sin datos') + # Titular + titular = data.get("titular") or {} + if titular: + pdf.ln(2) + pdf.set_font('helvetica', 'B', 10) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "TITULAR REGISTRADO:", new_x="LMARGIN", new_y="NEXT") + nombre_titular = f"{titular.get('apellido', '')} {titular.get('nombres', '')}".strip() or 'Protegido' + _kv_line(pdf, "Nombre:", nombre_titular) + _kv_line(pdf, "CUIL:", titular.get('cuil')) + # Prendas + prendas = data.get("prendas", []) + if prendas: + _section_header(pdf, "GRAVAMENES Y PRENDAS") + for pr in prendas: + _item_bullet(pdf, f"Entidad: {pr.get('entidad', '-')} | Fecha: {pr.get('fecha', '-')} | {pr.get('descripcion', 'Prenda registrada')}") + else: + _section_header(pdf, "GRAVAMENES Y PRENDAS") + _empty_notice(pdf, "Sin prendas o gravamenes registrados sobre el vehiculo.") + # Denuncias de robo + denuncias = data.get("denuncias_robo", []) + if denuncias: + _section_header(pdf, "DENUNCIAS DE ROBO") + for den in denuncias: + _item_bullet(pdf, f"Fecha: {den.get('fecha', '-')} | {den.get('descripcion', 'Denuncia registrada')}") + else: + _section_header(pdf, "DENUNCIAS DE ROBO") + _empty_notice(pdf, "Sin denuncias de robo registradas.") + # Infracciones del vehiculo + inf_veh = data.get("infracciones", []) + if inf_veh: + _section_header(pdf, "INFRACCIONES DE TRANSITO") + for inf in inf_veh: + monto_inf = inf.get('monto') or 0 + _item_bullet(pdf, f"Acta: {inf.get('acta', '-')} | Fecha: {inf.get('fecha', '-')} | Motivo: {inf.get('motivo', '-')} | Monto: ${safe_float(monto_inf):,.0f} | {inf.get('jurisdiccion', '')}") + else: + _section_header(pdf, "INFRACCIONES DE TRANSITO") + _empty_notice(pdf, "Sin infracciones de transito registradas.") + # Indicadores de riesgo + riesgos = data.get("riesgo", []) + _section_header(pdf, "INDICADORES DE RIESGO") + if riesgos: + for r in riesgos: + _item_bullet(pdf, f"[{r.get('nivel', 'INFO').upper()}] {r.get('descripcion', '')} - {r.get('hallazgo', '')}") + else: + _empty_notice(pdf, "Sin indicadores de riesgo detectados.") + elif report_type == "propiedad": + _kv_line(pdf, "Direccion:", data.get('direccion')) + cat = data.get("datos_catastrales") or {} + if cat: + _kv_line(pdf, "Partida:", cat.get('partida')) + _kv_line(pdf, "Nomenclatura:", cat.get('nomenclatura')) + _kv_line(pdf, "Partido:", cat.get('partido_nombre')) + _kv_line(pdf, "Tipo Inmueble:", cat.get('tipo_inmueble')) + sup = cat.get('superficie_m2') + if sup: + _kv_line(pdf, "Superficie:", f"{sup:,.0f} m2") + vf = cat.get('valuacion_fiscal') + if vf: + _kv_line(pdf, "Valuacion Fiscal (ARBA):", f"${safe_float(vf):,.0f}") + bi = cat.get('base_imponible') + if bi: + _kv_line(pdf, "Base Imponible:", f"${safe_float(bi):,.0f}") + ve = data.get("valor_estimado") + if ve: + _kv_line(pdf, "Valor Mercado Estimado:", f"${safe_float(ve):,.0f}") + geo = data.get("geolocalizacion") or {} + if geo.get('lat') and geo.get('lng'): + _kv_line(pdf, "Geolocalizacion:", f"Lat: {geo.get('lat')} | Lng: {geo.get('lng')}") + # Titulares + titulares = data.get("titulares_detectados", []) + if titulares: + _section_header(pdf, "PROPIETARIOS / TITULARES DETECTADOS") + for t in titulares: + nombre_t = f"{t.get('apellido', '')} {t.get('nombres', '')}".strip() or 'Titular detectado' + cuil_t = t.get('cuil', '') + cuil_str = f" | CUIL: {cuil_t}" if cuil_t else '' + cruzado = ' [Posible Propietario]' if t.get('cruzado_con_busqueda') else '' + _item_bullet(pdf, f"{nombre_t}{cuil_str}{cruzado}") + # Boletín + bo_prop = data.get("historial_boletin", []) + if bo_prop: + _section_header(pdf, "PUBLICACIONES EN BOLETIN OFICIAL") + for pub in bo_prop[:10]: + texto = pub.get('snippet') or pub.get('texto') or pub.get('titulo') or '' + if len(texto) > 200: + texto = texto[:200] + "..." + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 5, clean_txt(f"{pub.get('fecha', 'S/F')} - {pub.get('tipo', pub.get('seccion', 'BO'))}"), new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 8.5) + pdf.multi_cell(190, 4, clean_txt(f" {texto}")) + pdf.ln(2) + # Deudas ARBA + deudas = data.get("deudas_impositivas") or {} + if deudas.get('con_deuda'): + _section_header(pdf, "DEUDAS IMPOSITIVAS (ARBA)") + monto_d = deudas.get('monto_total', 0) + _kv_line(pdf, "Monto Total Adeudado:", f"${safe_float(monto_d):,.0f}") + for per in (deudas.get('periodos_adeudados') or [])[:10]: + if isinstance(per, dict): + _item_bullet(pdf, f"Periodo: {per.get('periodo', '')} | Monto: ${safe_float(per.get('monto', 0)):,.0f}") + # Riesgos + riesgos_prop = data.get("riesgo", []) + _section_header(pdf, "INDICADORES DE RIESGO") + if riesgos_prop: + for r in riesgos_prop: + _item_bullet(pdf, f"[{r.get('nivel', 'INFO').upper()}] {r.get('descripcion', '')} - {r.get('hallazgo', '')}") + else: + _empty_notice(pdf, "Sin indicadores de riesgo detectados para este inmueble.") + else: + _kv_line(pdf, "CUIT Empresa:", ident.get('cuit')) + _kv_line(pdf, "Tipo Societario:", ident.get('tipo_societario')) + _kv_line(pdf, "Fecha Constitucion:", ident.get('fecha_constitucion')) + _kv_line(pdf, "Nombre Fantasia:", ident.get('nombre_fantasia')) + igj = data.get("igj", {}) + if igj and igj.get("numero_inscripcion"): + _kv_line(pdf, "Nro Inscripcion IGJ:", igj.get("numero_inscripcion")) + + # ═══════════════════════════════════════════════════════════ + # GRUPO: RESUMEN DEL GRUPO ECONOMICO + # ═══════════════════════════════════════════════════════════ + if report_type == "grupo": + empresas = data.get("empresas_vinculadas", []) + vehiculos = data.get("vehiculos_vinculados", []) + riesgos_con = data.get("riesgo_consolidado", []) + patrimonio = data.get("total_patrimonio_estimado") + + _section_header(pdf, "RESUMEN DEL GRUPO ECONOMICO") + _kv_line(pdf, "Empresas integradas:", str(len(empresas))) + _kv_line(pdf, "Vehiculos registrados:", str(len(vehiculos))) + if patrimonio: + _kv_line(pdf, "Patrimonio estimado:", f"${safe_float(patrimonio):,.0f}") + + if empresas: + _section_header(pdf, "SOCIEDADES / EMPRESAS VINCULADAS") + for emp in empresas: + emp_id = emp.get("identificacion", {}) if isinstance(emp, dict) else {} + razon = emp_id.get("razon_social", "") if isinstance(emp_id, dict) else "" + cuit_e = emp_id.get("cuit", "") if isinstance(emp_id, dict) else "" + tipo = emp_id.get("tipo_societario", "") if isinstance(emp_id, dict) else "" + score_e = emp.get("score", {}) if isinstance(emp, dict) else {} + nivel_e = score_e.get("nivel", "") if isinstance(score_e, dict) else "" + _item_bullet(pdf, f"{razon} ({cuit_e}) - {tipo} - Score: {nivel_e}") + + if vehiculos: + _section_header(pdf, "VEHICULOS DEL GRUPO") + for vh in vehiculos: + vh_data = vh.get("vehiculo", {}) if isinstance(vh, dict) else {} + dominio = vh_data.get("dominio", "") if isinstance(vh_data, dict) else "" + marca = vh_data.get("marca", "") if isinstance(vh_data, dict) else "" + modelo = vh_data.get("modelo", "") if isinstance(vh_data, dict) else "" + anio = vh_data.get("anio", "") if isinstance(vh_data, dict) else "" + _item_bullet(pdf, f"{dominio} - {marca} {modelo} ({anio})") + + if riesgos_con: + _section_header(pdf, "RIESGO CONSOLIDADO DEL GRUPO") + for r in riesgos_con: + nivel_r = r.get("nivel", "INFO") if isinstance(r, dict) else "INFO" + desc = r.get("descripcion", "") if isinstance(r, dict) else str(r) + _item_bullet(pdf, f"[{nivel_r.upper()}] {desc}") + + pdf.ln(5) + + # ═══════════════════════════════════════════════════════════ + # SECTION 2: SCORE CREDITICIO + # ═══════════════════════════════════════════════════════════ + score_obj = data.get("score") + if score_obj: + _section_header(pdf, "SCORE CREDITICIO CROWDATA") + score_val = score_obj.get("valor", 60) + nivel = score_obj.get("nivel", "Bueno") + + if nivel == "Excelente": + r, g, b = 34, 197, 94 + elif nivel == "Bueno": + r, g, b = 59, 130, 246 + elif nivel == "Regular": + r, g, b = 245, 158, 11 + elif nivel == "Malo": + r, g, b = 239, 68, 68 + else: + r, g, b = 185, 28, 28 + + pdf.set_font('helvetica', 'B', 10) + pdf.set_text_color(71, 85, 105) + pdf.cell(80, 7, "Valor:", new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', 'B', 14) + pdf.set_text_color(r, g, b) + pdf.cell(40, 7, clean_txt(f"{score_val} / 100"), new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', 'B', 10) + pdf.cell(0, 7, clean_txt(f"Nivel: {nivel.upper()}"), new_x="LMARGIN", new_y="NEXT") + + pdf.set_fill_color(226, 232, 240) + pdf.rect(10, pdf.get_y() + 1, 190, 4, 'F') + pdf.set_fill_color(r, g, b) + bar_w = max(1, int(190 * (score_val / 100))) + pdf.rect(10, pdf.get_y() + 1, bar_w, 4, 'F') + pdf.ln(7) + + historial = score_obj.get("historial", []) + if historial: + pdf.set_font('helvetica', 'B', 8) + pdf.set_text_color(100, 116, 139) + hist_items = [] + for h in historial[-6:]: + hist_items.append(f"{h.get('fecha', '')}: {h.get('valor', 0)}") + pdf.cell(0, 5, clean_txt("Historial (12M): " + " | ".join(hist_items)), new_x="LMARGIN", new_y="NEXT") + + factores = score_obj.get("factores", []) + if factores: + pdf.ln(2) + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 5, "Factores del Score:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 8.5) + for f in factores[:8]: + if isinstance(f, dict): + impacto = f.get("impacto", 0) + desc = f.get('descripcion', f.get('factor', '')) + signo = "+" if impacto >= 0 else "" + color = (22, 101, 52) if impacto >= 0 else (185, 28, 28) + pdf.set_text_color(*color) + pdf.cell(0, 4.5, clean_txt(f" {signo}{impacto}: {desc}"), new_x="LMARGIN", new_y="NEXT") + else: + pdf.set_text_color(100, 116, 139) + pdf.cell(0, 4.5, clean_txt(f" {f}"), new_x="LMARGIN", new_y="NEXT") + pdf.set_text_color(15, 23, 42) + pdf.ln(3) + else: + _empty_notice(pdf, "Sin factores de score disponibles.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 3: REGISTRO CIVIL (persona) + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + estado_civil = rc.get("estado_civil") + conyuge = rc.get("conyuge_nombre") + fecha_mat = rc.get("fecha_matrimonio") + if estado_civil or conyuge or fecha_mat: + _section_header(pdf, "REGISTRO CIVIL") + if estado_civil: + _kv_line(pdf, "Estado Civil:", estado_civil) + if conyuge: + _kv_line(pdf, "Conyuge:", conyuge) + if fecha_mat: + _kv_line(pdf, "Fecha Matrimonio:", fecha_mat) + + # ═══════════════════════════════════════════════════════════ + # SECTION 4: INDICADORES DE RIESGO + # ═══════════════════════════════════════════════════════════ + riesgos = data.get("riesgo", []) + _section_header(pdf, "INDICADORES DE RIESGO DETECTADOS") + if riesgos: + for r in riesgos: + nivel_r = r.get('nivel', 'INFO').upper() + desc_r = r.get('descripcion', '') + hallazgo_r = r.get('hallazgo', '') + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(153, 27, 27) + pdf.multi_cell(190, 5.5, clean_txt(f"[{nivel_r}] {desc_r} ({hallazgo_r})")) + pdf.ln(1) + else: + _empty_notice(pdf, "Sin indicadores de riesgo detectados.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 5: PEP / UIF + # ═══════════════════════════════════════════════════════════ + peps = data.get("peps", []) + _section_header(pdf, "PERSONA EXPUESTA POLITICAMENTE (PEP / UIF)") + if peps: + for p in peps: + cargo = p.get('cargo', 'Funcionario') + jurisdiccion = p.get('jurisdiccion', 'Nacional') + fecha_p = p.get('fecha_presentacion', 'Reciente') + pdf.set_font('helvetica', '', 9.5) + pdf.set_text_color(15, 23, 42) + pdf.multi_cell(190, 5.5, clean_txt(f"Cargo: {cargo} | Jurisdiccion: {jurisdiccion} | DDJJ: {fecha_p}")) + pdf.ln(1) + else: + _empty_notice(pdf, "No registra como Persona Expuesta Politicamente (PEP) en los registros oficiales.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 6: DOMICILIOS + # ═══════════════════════════════════════════════════════════ + domicilios = contacto.get("domicilios", []) or data.get("historial_domicilios", []) + if report_type == "empresa" and data.get("igj", {}).get("domicilio_legal"): + igj_dom = data["igj"]["domicilio_legal"] + if igj_dom: + domicilios = [igj_dom] + domicilios + + _section_header(pdf, "DOMICILIOS REGISTRADOS") + if domicilios: + for d in domicilios: + piso_dpto = "" + if d.get("piso") or d.get("dpto"): + piso_dpto = f" Piso {d.get('piso', '')} Dpto {d.get('dpto', '')}" + full_dom = f"{d.get('calle', '')} {d.get('numero', '')}{piso_dpto}, {d.get('localidad', '')}, {d.get('provincia', '')} (CP: {d.get('cp', '')})" + tipo = f"[{d.get('tipo', 'Otros').upper()}]" + _item_bullet(pdf, f"{tipo} {full_dom}") + else: + _empty_notice(pdf, "Sin domicilios registrados.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 7: MAPA DE UBICACION + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "MAPA DE UBICACION") + if domicilios: + for d in domicilios: + prov = d.get('provincia', '') + loc = d.get('localidad', '') + cp = d.get('cp', '') + calle = d.get('calle', '') + num = d.get('numero', '') + texto = f"{calle} {num}" + if loc: + texto += f" - {loc}" + if prov: + texto += f", {prov}" + if cp: + texto += f" (CP: {cp})" + _item_bullet(pdf, texto) + else: + _empty_notice(pdf, "Sin datos de ubicacion disponibles.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 8: CONTACTO Y REDES (persona) + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + telefonos = data.get("telefonos", []) + redes = data.get("redes_sociales", []) + pagina_web = ident.get("pagina_web") + email_contacto = ident.get("email_contacto") + linkedin = ident.get("linkedin") + if telefonos or redes or pagina_web or email_contacto or linkedin: + _section_header(pdf, "CONTACTO Y REDES OSINT") + if pagina_web: + _item_bullet(pdf, f"Web: {pagina_web}") + if email_contacto: + _item_bullet(pdf, f"Email: {email_contacto}") + if linkedin: + _item_bullet(pdf, f"LinkedIn: {linkedin}") + if telefonos: + _item_bullet(pdf, f"Telefonos detectados: {', '.join(str(t) for t in telefonos)}") + for r in redes: + _item_bullet(pdf, f"{r.get('plataforma', 'Social')}: {r.get('usuario', '')} ({r.get('url', '')})") + foto = ident.get("foto_perfil") + if foto: + _item_bullet(pdf, f"Foto de perfil detectada: {foto}") + else: + _section_header(pdf, "CONTACTO Y REDES OSINT") + _empty_notice(pdf, "Sin datos de contacto o redes sociales detectados.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 9: SITUACION FISCAL (AFIP) + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "SITUACION FISCAL (AFIP)") + if fiscal: + _kv_line(pdf, "Estado AFIP:", fiscal.get("estado_afip")) + _kv_line(pdf, "Condicion IVA:", fiscal.get("condicion_iva")) + _kv_line(pdf, "Inicio Actividad:", fiscal.get("fecha_inicio_actividad")) + mono = fiscal.get("monotributo") + if isinstance(mono, dict) and mono.get("categoria"): + mono_str = f"Categoria {mono.get('categoria')}" + if mono.get("actividad_principal"): + mono_str += f" - {mono.get('actividad_principal')}" + if mono.get("fecha_alta"): + mono_str += f" (Alta: {mono.get('fecha_alta')})" + _kv_line(pdf, "Monotributo:", mono_str) + actividades = fiscal.get("actividades", []) + if actividades: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Actividades Economicas Declaradas:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for act in actividades: + principal = "[PRINCIPAL]" if act.get("es_principal") else "" + _item_bullet(pdf, f"CLAE {act.get('codigo_clae', '')} - {act.get('descripcion', '')} {principal}", 9) + else: + _empty_notice(pdf, "Sin datos fiscales disponibles.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 10: SITUACION FINANCIERA (BCRA) + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "SITUACION FINANCIERA (BCRA)") + if fin: + sit_val = fin.get("bcra_situacion_actual") + sit_actual = sit_val if sit_val is not None else 1 + labels_sit = {1: "Normal", 2: "Seguimiento especial", 3: "Con problemas", + 4: "Alto riesgo", 5: "Irrecuperable", 6: "Irrecuperable por disp. tecnica"} + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(50, 6, "Situacion Actual BCRA:", new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', '', 9.5) + if sit_actual >= 3: + pdf.set_text_color(185, 28, 28) + else: + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 6, clean_txt(f"{sit_actual} - {labels_sit.get(sit_actual, 'Normal')}"), new_x="LMARGIN", new_y="NEXT") + + total_deuda = fin.get("total_deuda_miles") + dias_max = fin.get("dias_atraso_max") + if total_deuda is not None or dias_max is not None: + deuda_str = f"${safe_float(total_deuda):,.0f}k" if total_deuda else "-" + dias_str = f" | Max. dias atraso: {dias_max}" if dias_max else "" + _kv_line(pdf, "Deuda Total:", f"{deuda_str}{dias_str}") + + cheques = fin.get("cheques_rechazados", []) + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(50, 6, "Cheques Rechazados:", new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', '', 9.5) + if cheques: + pdf.set_text_color(185, 28, 28) + pdf.cell(0, 6, clean_txt(f"SI - {len(cheques)} cheque(s) rechazado(s)"), new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 8.5) + pdf.set_text_color(15, 23, 42) + for chq in cheques: + monto_chq = chq.get('monto') or 0 + nro = chq.get('nro_cheque') or '' + nro_str = f" | Nro: {nro}" if nro else "" + estado_multa = chq.get('estado_multa') or '' + multa_str = f" | Multa: {estado_multa}" if estado_multa else "" + en_rev = " (En revisión)" if chq.get('en_revision') else "" + proc_jud = " (Judicializado)" if chq.get('proceso_judicial') else "" + estado_str = f"{chq.get('estado') or 'Rechazado'}{en_rev}{proc_jud}" + # NUEVOS CAMPOS AGREGADOS + denom_jur = chq.get('denom_juridica') or '' + denom_str = f" | {denom_jur}" if denom_jur else "" + cta_tipo = " (Cta.Personal)" if chq.get('cta_personal') else " (Cta.Empresarial)" if chq.get('cta_personal') is not None else "" + _item_bullet(pdf, f"Fecha: {chq.get('fecha')} | Banco: {chq.get('banco')} | Monto: ${safe_float(monto_chq):,.2f} | Estado: {estado_str}{nro_str}{multa_str}{denom_str}{cta_tipo}", 8.5) + else: + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 6, "NO registra cheques rechazados en el ultimo anio", new_x="LMARGIN", new_y="NEXT") + + historial = fin.get("bcra_historial", []) + if historial: + pdf.ln(2) + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Historial de Entidades y Deudas:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for h in historial[:12]: + monto_deuda = h.get('monto_deuda') or 0 + denominacion = h.get('denominacion') or '' + dias_atraso = h.get('dias_atraso') + denom_str = f" ({denominacion})" if denominacion else "" + dias_str = f" | Dias atraso: {dias_atraso}" if dias_atraso else "" + sit_jur = h.get('situacion_juridica') or '' + jur_str = f" | Juridica: {sit_jur}" if sit_jur else "" + en_rev = " | En Revisión" if h.get('en_revision') else "" + proc_jud = " | Judicializado" if h.get('proceso_judicial') else "" + # NUEVOS CAMPOS AGREGADOS + refin_str = " | Refinanciada" if h.get('refinanciaciones') else "" + recateg_str = " | Recategorizada" if h.get('recategorizacion') else "" + irrec_disp_str = " | Irrec.Disp.Téc." if h.get('irrec_disp_tecnica') else "" + _item_bullet(pdf, f"Periodo: {h.get('periodo')} | Entidad: {h.get('entidad')}{denom_str} | Sit. {h.get('situacion')} | Deuda: ${safe_float(monto_deuda):,.0f}k{dias_str}{jur_str}{refin_str}{recateg_str}{irrec_disp_str}{en_rev}{proc_jud}", 9) + else: + _empty_notice(pdf, "Sin datos financieros BCRA disponibles.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 11: SOCIEDADES, CNV & MATRICULAS + # ═══════════════════════════════════════════════════════════ + cnv_registros = data.get("cnv_registros", []) + if societario and societario.get("cnv_registros"): + cnv_registros = cnv_registros + societario.get("cnv_registros") + matriculas = societario.get("matriculas_profesionales", []) if societario else [] + + _section_header(pdf, "REGISTROS SOCIETARIOS, CNV Y PROFESIONALES") + has_societario = bool(cnv_registros or matriculas) + if has_societario: + if cnv_registros: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Registros Comision Nacional de Valores (CNV):", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for cnv in cnv_registros: + _item_bullet(pdf, f"{cnv.get('razon_social') or 'CNV'} | Cat: {cnv.get('categoria')} | Matricula: {cnv.get('matricula')} | Estado: {cnv.get('estado')}", 9) + pdf.ln(2) + if matriculas: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Matriculas Profesionales:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for mat in matriculas: + _item_bullet(pdf, f"Consejo: {mat.get('consejo')} | Matricula: {mat.get('matricula')} | Estado: {mat.get('estado') or 'Activo'}", 9) + else: + _empty_notice(pdf, "Sin registros societarios, CNV ni matriculas profesionales.") + + igj_socios = data.get("igj", {}).get("socios_directivos", []) + if report_type == "empresa" and igj_socios: + pdf.ln(2) + pdf.set_font('helvetica', 'B', 12) + pdf.set_text_color(30, 41, 59) + pdf.cell(0, 8, "SOCIOS Y DIRECTIVOS (IGJ)", new_x="LMARGIN", new_y="NEXT") + pdf.set_fill_color(226, 232, 240) + pdf.rect(10, pdf.get_y(), 190, 0.3, 'F') + pdf.ln(3) + for s in igj_socios: + _item_bullet(pdf, f"{s.get('nombre')} | CUIL: {s.get('cuil')} | Rol/Cargo: {s.get('rol') or 'Socio'}") + igj_data = data.get("igj", {}) + objeto_social = igj_data.get("objeto_social") + if objeto_social: + _item_bullet(pdf, f"Objeto Social: {str(objeto_social)[:120]}") + balances = igj_data.get("balances", []) + if balances: + for b in balances[:3]: + periodo = b.get("periodo", "") if isinstance(b, dict) else "" + resultado = b.get("resultado", "") if isinstance(b, dict) else "" + _item_bullet(pdf, f"Balance {periodo}: {resultado}") + pagina_web = ident.get("pagina_web") + email_contacto = ident.get("email_contacto") + linkedin = ident.get("linkedin") + if pagina_web or email_contacto or linkedin: + contact_parts = [] + if pagina_web: + contact_parts.append(f"Web: {pagina_web}") + if email_contacto: + contact_parts.append(f"Email: {email_contacto}") + if linkedin: + contact_parts.append(f"LinkedIn: {linkedin}") + _item_bullet(pdf, f"Contacto: {' | '.join(contact_parts)}") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 12: BILLETERAS VIRTUALES / FINTECHS + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + bv = data.get("billeteras_virtuales", {}) + if bv and (bv.get("detalle", []) or bv.get("total_deuda_fintech", 0) > 0): + _section_header(pdf, "BILLETERAS VIRTUALES / FINTECHS") + total = bv.get("total_deuda_fintech", 0) + wallets = bv.get("cantidad_wallets_con_deuda", 0) + _kv_line(pdf, "Deuda total fintechs:", f"${total:,.0f}") + _kv_line(pdf, "Billeteras con deuda:", str(wallets)) + fintech_count = bv.get("cantidad_fintech_detectadas", 0) + if fintech_count: + _kv_line(pdf, "Fintechs detectadas:", str(fintech_count)) + pdf.ln(2) + for item in bv.get("detalle", []): + marca = item.get("marca", item.get("entidad_bcra", "?")) + situacion = item.get("situacion", 1) + monto = item.get("monto", 0) or 0 + desc = item.get("situacion_desc", "") + rubro = item.get("rubro", "") + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(180, 83, 9) + pdf.multi_cell(190, 5.5, clean_txt(f"{marca} ({rubro}) - Sit. {situacion}: {desc}")) + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 5, clean_txt(f" Monto: ${monto:,.0f} | Periodo: {item.get('periodo', '?')}"), new_x="LMARGIN", new_y="NEXT") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 13: ANTECEDENTES JUDICIALES (PJN + JUBA) + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "ANTECEDENTES JUDICIALES (PJN, MEV Y JUBA)") + causas = judicial.get("causas", []) if judicial else [] + if causas: + for c in causas: + exp = c.get('expediente') or 'S/N' + caratula = c.get('caratula_completa') or c.get('caratula') or 'Sin caratula' + fuero = c.get('fuero') or 'Ordinario' + estado = c.get('estado') or 'Tramitacion' + fecha = c.get('fecha') or 'Reciente' + juzgado = c.get('juzgado') or 'Juzgado de turno' + voces = c.get('voces') or '' + magistrados = c.get('magistrados') or '' + tipo_fallo = c.get('tipo_fallo') or '' + + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 5, clean_txt(f"Exp: {exp} - {fecha} (Estado: {estado})"), new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + lineas = [f" Caratula: {caratula}", f" Fuero: {fuero} | Juzgado: {juzgado}"] + if voces: + lineas.append(f" Voces: {voces}") + if magistrados: + lineas.append(f" Magistrados: {magistrados}") + if tipo_fallo: + lineas.append(f" Tipo Fallo: {tipo_fallo}") + pdf.multi_cell(190, 4.5, clean_txt("\n".join(lineas))) + pdf.ln(2.5) + else: + _empty_notice(pdf, "Sin causas judiciales federales, comerciales ni provinciales (JUBA) detectadas.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 14: PATRIMONIO (VEHICULOS E INMUEBLES) + # ═══════════════════════════════════════════════════════════ + vehiculos = patrimonial.get("vehiculos", []) if patrimonial else [] + inmuebles = patrimonial.get("inmuebles", []) if patrimonial else [] + + if vehiculos or inmuebles: + _section_header(pdf, "REGISTROS PATRIMONIALES (VEHICULOS E INMUEBLES)") + if vehiculos: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Vehiculos Registrados (DNRPA / ARBA Automotores):", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for v in vehiculos: + detalle_v = f"Dominio: {v.get('dominio')} | {v.get('marca', '')} {v.get('modelo', '')} ({v.get('anio', '')}) | Radicacion: {v.get('radicacion', '') or v.get('provincia', '')}" + vtv = v.get('validez_vtv') + seguro = "Seguro OK" if v.get('tiene_seguro') else "Sin Seguro" + estado = v.get('estado') + extras = " | ".join(filter(None, [f"VTV: {vtv}" if vtv else None, seguro, estado])) + if extras: + detalle_v += f"\n {extras}" + pdf.multi_cell(190, 5.5, clean_txt(f"{detalle_v}")) + pdf.ln(2) + if inmuebles: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Bienes Inmuebles Registrados (ARBA Catastro / Boletin):", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for inm in inmuebles: + partida = f"Partida: {inm.get('nro_partida') or 'N/A'}" + matricula = f"Matricula: {inm.get('matricula') or '-'}" + superficie = f"Sup: {inm.get('superficie') or '-'}" + desc = inm.get('descripcion') or 'Inmueble' + prov = inm.get('provincia') or 'Bs.As.' + fuente = inm.get('fuente') or 'ARBA' + val_val = inm.get('valuacion_fiscal') + try: + val_str = f"${int(val_val):,}".replace(",", ".") if val_val else "-" + except Exception: + val_str = "-" + val_fiscal = f"Valuacion: {val_str}" + _item_bullet(pdf, f"[{fuente}] {partida} | {matricula} | {desc} ({prov}) | {superficie} | {val_fiscal}", 9) + + deuda_patentes = patrimonial.get("deuda_patentes", []) if patrimonial else [] + if deuda_patentes: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Deuda de Patentes Vehiculares (AGIP):", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for dp in deuda_patentes[:10]: + anio = dp.get("anio", "") if isinstance(dp, dict) else "" + cuota = dp.get("cuota", "") if isinstance(dp, dict) else "" + concepto = dp.get("concepto", "") if isinstance(dp, dict) else "" + importe = dp.get("importe", "") if isinstance(dp, dict) else "" + _item_bullet(pdf, f"{anio} {cuota} | {concepto} | {importe}", 9) + else: + _section_header(pdf, "REGISTROS PATRIMONIALES (VEHICULOS E INMUEBLES)") + _empty_notice(pdf, "Sin registros patrimoniales de vehiculos ni inmuebles.") + pdf.ln(2) + + # ═══════════════════════════════════════════════════════════ + # SECTION 15: CONTRATOS ESTATALES (COMPR.AR) + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "REGISTROS EN CONTRATACIONES DEL ESTADO (COMPR.AR)") + compras = data.get("compras_estatales", []) + if compras: + for comp in compras: + registro = comp.get('registro') or '' + reg_str = f" | Registro: {registro}" if registro else "" + _item_bullet(pdf, f"Proveedor: {comp.get('razon_social') or '-'} | CUIT: {comp.get('cuit_proveedor', '')} | Inscripcion: {comp.get('estado_inscripcion', '')} | Rubro: {comp.get('rubro_principal', '')}{reg_str}") + adjudicaciones = comp.get('adjudicaciones', []) + contratos = comp.get('contratos', []) + if adjudicaciones: + pdf.set_font('helvetica', '', 8) + for adj in adjudicaciones[:5]: + desc = adj.get('descripcion_proceso') or adj.get('objeto') or '-' + monto = adj.get('monto') or adj.get('monto_total') or '' + monto_str = f" | Monto: ${safe_float(monto):,.0f}" if monto else "" + org = adj.get('organismo') or adj.get('entidad') or '' + org_str = f" | Org: {org}" if org else "" + _item_bullet(pdf, f" Adjudicacion: {desc[:80]}{monto_str}{org_str}", 8) + if contratos: + pdf.set_font('helvetica', '', 8) + for ct in contratos[:5]: + desc = ct.get('descripcion_proceso') or ct.get('objeto') or '-' + monto = ct.get('monto') or ct.get('monto_total') or '' + monto_str = f" | Monto: ${safe_float(monto):,.0f}" if monto else "" + fecha_c = ct.get('fecha') or ct.get('fecha_fin') or '' + fecha_str = f" | Fecha: {fecha_c}" if fecha_c else "" + _item_bullet(pdf, f" Contrato: {desc[:80]}{monto_str}{fecha_str}", 8) + pdf.ln(1) + else: + _empty_notice(pdf, "No registra inscripciones ni contratos vigentes como proveedor del Estado en COMPR.AR.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 16: PREVISIONAL & SALUD (ANSES / RUIDO) + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + _section_header(pdf, "PREVISIONAL Y OBRA SOCIAL (ANSES / RUIDO / SSSALUD)") + if prev and (prev.get("tiene_aportes") is not None or prev.get("ultimo_empleador") or prev.get("obra_social")): + _kv_line(pdf, "Aportes al dia:", "SI" if prev.get('tiene_aportes') else "NO") + _kv_line(pdf, "Tipo Beneficiario:", prev.get('tipo_beneficiario')) + _kv_line(pdf, "Ultimo Empleador:", prev.get('ultimo_empleador')) + _kv_line(pdf, "Obra Social (ANSES):", prev.get('obra_social')) + fecha_alta_os = prev.get('fecha_alta_obra_social') + if fecha_alta_os: + _kv_line(pdf, "Alta OS:", fecha_alta_os) + estado_padron = prev.get('estado_padron') + if estado_padron: + _kv_line(pdf, "Estado Padron ANSES:", estado_padron) + else: + estado_padron = prev.get('estado_padron') if prev else None + if estado_padron: + _kv_line(pdf, "Estado Padron ANSES:", estado_padron) + else: + _empty_notice(pdf, "Sin datos previsionales activos en ANSES.") + + beneficios = prev.get("beneficios_sociales", []) if prev else [] + if beneficios: + _kv_line(pdf, "Beneficios Sociales:", ', '.join(str(b) for b in beneficios[:5])) + jubilaciones = prev.get("jubilaciones_pensiones", []) if prev else [] + if jubilaciones: + _kv_line(pdf, "Jubilaciones/Pensiones:", ', '.join(str(j) for j in jubilaciones[:5])) + proximo_cobro = prev.get("fecha_proximo_cobro") if prev else None + lugar_cobro = prev.get("lugar_cobro") if prev else None + if proximo_cobro or lugar_cobro: + cobro_parts = [] + if proximo_cobro: + cobro_parts.append(f"Proximo cobro: {proximo_cobro}") + if lugar_cobro: + cobro_parts.append(f"Lugar: {lugar_cobro}") + _kv_line(pdf, "Cobro:", ' | '.join(cobro_parts)) + + if salud and (salud.get("cobertura_activa") or (isinstance(salud.get("detalles"), dict) and salud["detalles"].get("nombre_obra_social"))): + obrasocial = salud.get("detalles", {}).get("nombre_obra_social") or "Obra Social Activa" + _kv_line(pdf, "Cobertura Medica SSSalud:", f"SI | {obrasocial}") + else: + _kv_line(pdf, "Cobertura Medica SSSalud:", "Sin cobertura de salud activa.") + + historial_coberturas = _get_nested(salud, "detalles", "historial_coberturas", default=[]) + if historial_coberturas: + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, "Historial de Coberturas:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + for hc in historial_coberturas: + if isinstance(hc, dict): + fecha_h = hc.get('fecha', '') + nombre_h = hc.get('nombre_obra_social', '') or hc.get('nombre', '') + estado_h = hc.get('estado', '') + parts = [p for p in [fecha_h, nombre_h, estado_h] if p] + _item_bullet(pdf, " | ".join(parts), 9) + else: + _item_bullet(pdf, str(hc), 9) + + seccion_electoral = ident.get("seccion_electoral") + lugar_votacion = ident.get("lugar_votacion") + mesa_votacion = ident.get("mesa_votacion") + if seccion_electoral or lugar_votacion or mesa_votacion: + pdf.ln(2) + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 5, "PADRON ELECTORAL:", new_x="LMARGIN", new_y="NEXT") + _kv_line(pdf, "Seccion:", seccion_electoral or "-") + _kv_line(pdf, "Lugar de votacion:", lugar_votacion or "-") + _kv_line(pdf, "Mesa:", mesa_votacion or "-") + + # ═══════════════════════════════════════════════════════════ + # SECTION 17: DEUDORES ALIMENTARIOS (siempre mostrar) + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "DEUDORES ALIMENTARIOS MOROSOS (DAM)") + deudores = data.get("deudores_alimentarios") + if deudores and isinstance(deudores, dict): + resultado = deudores.get("resultado") or "" + if resultado and resultado != "NO_REGISTRADO": + nombre_dam = deudores.get("nombre_completo") or "-" + dni_dam = deudores.get("dni") or "-" + _kv_line(pdf, "Nombre:", nombre_dam) + _kv_line(pdf, "DNI:", dni_dam) + _kv_line(pdf, "Resultado:", resultado) + else: + _empty_notice(pdf, "Resultado: NO_REGISTRADO - No se registra como deudor alimentario moroso.") + else: + _empty_notice(pdf, "Sin datos de deudores alimentarios morosos.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 18: BOLETIN OFICIAL + # ═══════════════════════════════════════════════════════════ + _section_header(pdf, "PUBLICACIONES EN BOLETINES OFICIALES") + boletines = data.get("boletin_oficial", []) + if boletines: + for pub in boletines[:15]: + fecha = pub.get('fecha') or 'S/F' + secc = pub.get('seccion') or 'BORA' + tipo = pub.get('tipo') or '' + texto = pub.get('texto') or '' + if len(texto) > 250: + texto = texto[:250] + "..." + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(15, 23, 42) + header = f"{fecha} - Secc: {secc}" + if tipo: + header += f" - {tipo}" + pdf.cell(0, 5, clean_txt(header), new_x="LMARGIN", new_y="NEXT") + pdf.set_font('helvetica', '', 8.5) + pdf.multi_cell(190, 4, clean_txt(f" {texto}")) + pdf.ln(2) + else: + _empty_notice(pdf, "Sin publicaciones en Boletin Oficial Nacional ni Provinciales detectadas.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 19: INFRACCIONES DE TRANSITO + # ═══════════════════════════════════════════════════════════ + infracciones = data.get("infracciones_transito", []) + if report_type == "persona": + _section_header(pdf, "INFRACCIONES DE TRANSITO (SINAI / ANSV)") + if infracciones: + for inf in infracciones: + monto_inf = inf.get('monto') or 0 + dominio = inf.get('dominio') or '' + dom_str = f" | Dominio: {dominio}" if dominio else "" + nro_causa = inf.get('nro_causa') or '' + causa_str = f" | Causa: {nro_causa}" if nro_causa else "" + venc = inf.get('vencimiento') or '' + venc_str = f" | Vence: {venc}" if venc else "" + _item_bullet(pdf, f"Acta: {inf.get('acta') or 'S/N'} | Fecha: {inf.get('fecha', '-')} | " + f"Motivo: {inf.get('motivo', '-')} | Monto: ${safe_float(monto_inf):,.0f} | " + f"Estado: {inf.get('estado', '-')} | Jurisdiccion: {inf.get('jurisdiccion', '-')}{dom_str}{causa_str}{venc_str}") + else: + _empty_notice(pdf, "Sin infracciones de transito registradas en ANSV / SINAI.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 20: MARCAS E INPI + # ═══════════════════════════════════════════════════════════ + marcas = data.get("marcas_inpi", []) + _section_header(pdf, "MARCAS Y PATENTES REGISTRADAS (INPI)") + if marcas: + for m in marcas: + acta = m.get('acta') or '' + acta_str = f" | Acta: {acta}" if acta else "" + titulares = m.get('titulares') or '' + tit_str = f" | Titulares: {titulares[:50]}" if titulares else "" + tipo_marca = m.get('tipo_marca') or '' + tipo_str = f" | Tipo: {tipo_marca}" if tipo_marca else "" + nro_res = m.get('numero_resolucion') or '' + res_str = f" | Resol.: {nro_res}" if nro_res else "" + _item_bullet(pdf, f"{m.get('denominacion', '-')} | Clase: {m.get('clase', '-')} | " + f"Estado: {m.get('estado', '-')} | Vencimiento: {m.get('fecha_vencimiento', '-')}{acta_str}{tipo_str}{res_str}{tit_str}") + else: + _empty_notice(pdf, "Sin marcas o patentes registradas en INPI.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 21: INHIBICIONES Y EMBARGOS + # ═══════════════════════════════════════════════════════════ + inhibiciones = ( + patrimonial.get("inhibiciones", []) or + (judicial.get("inhibiciones_embargos", []) if judicial else []) or + [] + ) + _section_header(pdf, "INHIBICIONES Y EMBARGOS") + if inhibiciones: + for inh in inhibiciones: + if isinstance(inh, dict): + tipo = inh.get('tipo', 'INHIBICION') + fecha = inh.get('fecha', '') + organismo = inh.get('organismo', 'Juzgado') + descripcion = inh.get('descripcion', '') + else: + tipo = getattr(inh, 'tipo', 'INHIBICION') + fecha = getattr(inh, 'fecha', '') + organismo = getattr(inh, 'organismo', 'Juzgado') + descripcion = getattr(inh, 'descripcion', '') + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(153, 27, 27) + pdf.multi_cell(190, 5, clean_txt(f"[{str(tipo).upper()}] - {fecha or 'Fecha no disponible'} - {organismo}")) + if descripcion: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.multi_cell(190, 5, clean_txt(f" {descripcion}")) + pdf.ln(2) + else: + _empty_notice(pdf, "Sin inhibiciones ni embargos activos detectados.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 21b: ANTECEDENTES PENALES + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + ant_penales = data.get("antecedentes_penales", []) + _section_header(pdf, "ANTECEDENTES PENALES") + if ant_penales: + for ap in ant_penales: + tipo = ap.get('tipo', 'Antecedente') if isinstance(ap, dict) else 'Antecedente' + fecha = ap.get('fecha', '') if isinstance(ap, dict) else '' + delito = ap.get('delito', '') if isinstance(ap, dict) else '' + estado = ap.get('estado', '') if isinstance(ap, dict) else '' + desc = ap.get('descripcion', ap.get('detalle', '')) if isinstance(ap, dict) else '' + organismo = ap.get('organismo', '') if isinstance(ap, dict) else '' + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(153, 27, 27) + pdf.multi_cell(190, 5, clean_txt(f"[{str(tipo).upper()}] - {fecha or 'Sin fecha'} - {organismo or ''}")) + if delito: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 5, clean_txt(f" Delito: {delito}"), new_x="LMARGIN", new_y="NEXT") + if desc: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.multi_cell(190, 5, clean_txt(f" {desc}")) + if estado: + pdf.set_font('helvetica', 'I', 8.5) + pdf.set_text_color(100, 116, 139) + pdf.cell(0, 5, clean_txt(f" Estado: {estado}"), new_x="LMARGIN", new_y="NEXT") + pdf.ln(2) + else: + _empty_notice(pdf, "Sin antecedentes penales detectados en los registros consultados.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 21c: CONCURSOS Y QUIEBRAS + # ═══════════════════════════════════════════════════════════ + concursos = (societario.get("concursos", []) if societario else []) + quiebras = (societario.get("quiebras", []) if societario else []) + all_cq = concursos + quiebras + _section_header(pdf, "CONCURSOS Y QUIEBRAS") + if all_cq: + for cq in all_cq: + if isinstance(cq, dict): + tipo = cq.get('tipo', 'Concurso') + fecha = cq.get('fecha', '') + empresa = cq.get('empresa', '') + cuit_emp = cq.get('cuit_empresa', '') + desc = cq.get('descripcion', cq.get('detalle', '')) + else: + tipo = getattr(cq, 'tipo', 'Concurso') + fecha = getattr(cq, 'fecha', '') + empresa = getattr(cq, 'empresa', '') + cuit_emp = getattr(cq, 'cuit_empresa', '') + desc = getattr(cq, 'descripcion', getattr(cq, 'detalle', '')) + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(146, 64, 14) + pdf.multi_cell(190, 5, clean_txt(f"[{str(tipo).upper()}] - {fecha or 'Sin fecha'}")) + if empresa: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + emp_str = f" Empresa: {empresa}" + if cuit_emp: + emp_str += f" (CUIT: {cuit_emp})" + pdf.cell(0, 5, clean_txt(emp_str), new_x="LMARGIN", new_y="NEXT") + if desc: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.multi_cell(190, 5, clean_txt(f" {desc}")) + pdf.ln(2) + else: + _empty_notice(pdf, "Sin concursos ni quiebras registrados.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 21d: PARTICIPACIONES SOCIETARIAS (IGJ) - persona + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + part = societario.get("participaciones", []) if societario else [] + if not part: + part = data.get("participaciones", []) + _section_header(pdf, "PARTICIPACIONES SOCIETARIAS (IGJ)") + if part: + for p in part: + if isinstance(p, dict): + empresa_p = p.get('empresa', '') + rol = p.get('rol', 'Socio') + cuit_p = p.get('cuit', '') + porcentaje = p.get('porcentaje', '') + fecha_ins = p.get('fecha_inscripcion', '') + detalles = p.get('detalles', p.get('descripcion', '')) + else: + empresa_p = getattr(p, 'empresa', '') + rol = getattr(p, 'rol', 'Socio') + cuit_p = getattr(p, 'cuit', '') + porcentaje = getattr(p, 'porcentaje', '') + fecha_ins = getattr(p, 'fecha_inscripcion', '') + detalles = getattr(p, 'detalles', getattr(p, 'descripcion', '')) + pdf.set_font('helvetica', 'B', 9.5) + pdf.set_text_color(30, 64, 175) + parts = [f"[{str(rol).upper()}]"] + if empresa_p: + parts.append(empresa_p) + if cuit_p: + parts.append(f"CUIT: {cuit_p}") + pdf.multi_cell(190, 5, clean_txt(" | ".join(parts))) + if porcentaje: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 5, clean_txt(f" Participacion: {porcentaje}"), new_x="LMARGIN", new_y="NEXT") + if fecha_ins: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.cell(0, 5, clean_txt(f" Inscripto: {fecha_ins}"), new_x="LMARGIN", new_y="NEXT") + if detalles: + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(15, 23, 42) + pdf.multi_cell(190, 5, clean_txt(f" {detalles}")) + pdf.ln(2) + else: + _empty_notice(pdf, "Sin participaciones societarias detectadas en IGJ.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 22: MAPA DE RELACIONES + # ═══════════════════════════════════════════════════════════ + vinculos = data.get("vinculos", []) if report_type == "persona" else [] + _section_header(pdf, "MAPA DE RELACIONES") + if vinculos: + pdf.set_font('helvetica', 'B', 8.5) + pdf.set_text_color(71, 85, 105) + pdf.cell(65, 5, "Nombre", new_x="RIGHT", new_y="TOP") + pdf.cell(45, 5, "CUIT/CUIL", new_x="RIGHT", new_y="TOP") + pdf.cell(35, 5, "Tipo de Vinculo", new_x="RIGHT", new_y="TOP") + pdf.cell(0, 5, "Detalle", new_x="LMARGIN", new_y="NEXT") + pdf.set_fill_color(226, 232, 240) + pdf.rect(10, pdf.get_y(), 190, 0.3, 'F') + pdf.ln(2) + pdf.set_font('helvetica', '', 8.5) + pdf.set_text_color(15, 23, 42) + for v in vinculos[:10]: + if isinstance(v, dict): + nombre_v = v.get('nombre', '') + cuit_v = v.get('cuit', '') + tipo_v = v.get('tipo', '') + detalle_v = v.get('detalle', '') + else: + nombre_v = getattr(v, 'nombre', '') + cuit_v = getattr(v, 'cuit', '') + tipo_v = getattr(v, 'tipo', '') + detalle_v = getattr(v, 'detalle', '') + pdf.cell(65, 5, clean_txt(str(nombre_v)[:30]), new_x="RIGHT", new_y="TOP") + pdf.cell(45, 5, clean_txt(str(cuit_v)), new_x="RIGHT", new_y="TOP") + pdf.cell(35, 5, clean_txt(str(tipo_v)[:18]), new_x="RIGHT", new_y="TOP") + pdf.cell(0, 5, clean_txt(str(detalle_v)[:30]), new_x="LMARGIN", new_y="NEXT") + else: + _empty_notice(pdf, "Sin vinculos detectados.") + + # ═══════════════════════════════════════════════════════════ + # SECTION 23: FORMACION ACADEMICA (persona) + # ═══════════════════════════════════════════════════════════ + if report_type == "persona": + academico = data.get("academico", {}) + titulos = academico.get("titulos", []) if academico else [] + certificaciones = academico.get("certificaciones", []) if academico else [] + if titulos or certificaciones: + _section_header(pdf, "FORMACION ACADEMICA") + for t in titulos: + titulo = t.get('titulo', '') if isinstance(t, dict) else getattr(t, 'titulo', '') + inst = t.get('institucion', '') if isinstance(t, dict) else getattr(t, 'institucion', '') + anio = t.get('anio_graduacion', '') if isinstance(t, dict) else getattr(t, 'anio_graduacion', '') + nivel = t.get('nivel', 'Grado') if isinstance(t, dict) else getattr(t, 'nivel', 'Grado') + _item_bullet(pdf, f"[{nivel}] {titulo} | {inst or '-'} | Graduacion: {anio or '-'}") + for c in certificaciones: + nombre = c.get('nombre', '') if isinstance(c, dict) else str(c) + _item_bullet(pdf, f"Certificacion: {nombre}") + + # ═══════════════════════════════════════════════════════════ + # SECTION 24: LINEA DE TIEMPO + # ═══════════════════════════════════════════════════════════ + timeline = data.get("timeline", []) + if timeline: + _section_header(pdf, "LINEA DE TIEMPO") + cat_colors = { + "fiscal": (34, 197, 94), "financiero": (59, 130, 246), + "judicial": (239, 68, 68), "previsional": (245, 158, 11), + "patrimonial": (168, 85, 247), "general": (100, 116, 139), + } + for ev in timeline[:25]: + fecha = ev.get("fecha", "") + titulo = ev.get("titulo", "") + desc = ev.get("descripcion", "") + cat = ev.get("categoria", "general") + r, g, b = cat_colors.get(cat, (100, 116, 139)) + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(r, g, b) + pdf.cell(0, 5, clean_txt(f"[{fecha}] {titulo}"), new_x="LMARGIN", new_y="NEXT") + if desc: + pdf.set_font('helvetica', '', 8) + pdf.set_text_color(100, 116, 139) + pdf.cell(0, 4, clean_txt(f" {desc[:160]}"), new_x="LMARGIN", new_y="NEXT") + pdf.ln(1.5) + + # ═══════════════════════════════════════════════════════════ + # SECTION 25: HISTORIAL WEB (ARCHIVE.ORG) + # ═══════════════════════════════════════════════════════════ + web_hist_obj = data.get("web_historial") + if web_hist_obj: + _section_header(pdf, "HISTORIAL WEB (ARCHIVE.ORG)") + wh_url = web_hist_obj.get("url_consultada", "") + wh_total = web_hist_obj.get("total_snapshots", 0) + wh_snap = web_hist_obj.get("snapshot_mas_cercano") or {} + wh_historial = web_hist_obj.get("historial", []) + + if wh_url: + pdf.set_font('helvetica', 'B', 9) + pdf.set_text_color(59, 130, 246) + url_short = wh_url[:70] + "..." if len(wh_url) > 70 else wh_url + pdf.cell(0, 5, clean_txt(f"URL: {url_short}"), new_x="LMARGIN", new_y="NEXT") + if wh_total: + snap_date = wh_snap.get("timestamp", "") if isinstance(wh_snap, dict) else "" + _kv_line(pdf, "Total capturas:", str(wh_total)) + if snap_date: + _kv_line(pdf, "Ultima captura:", snap_date) + pdf.ln(2) + for wh in wh_historial[:10]: + ts = wh.get("timestamp", "") or wh.get("fecha", "") + status = wh.get("status", "") + tipo = wh.get("tipo", "") + url_w = wh.get("url_wayback", "") + detail = f" {ts} | {tipo} | {status}" + if url_w: + detail += f" | {url_w[:60]}" + pdf.set_font('helvetica', '', 8) + pdf.set_text_color(100, 116, 139) + pdf.cell(0, 4, clean_txt(detail), new_x="LMARGIN", new_y="NEXT") + pdf.ln(3) + + # ═══════════════════════════════════════════════════════════ + # SECTION 26: BITACORA DE FUENTES CONSULTADAS + # ═══════════════════════════════════════════════════════════ + sources = meta.get("sources", []) if meta else [] + empty_sources = meta.get("empty_sources", []) if meta else [] + generated_at = meta.get("generated_at", "") if meta else "" + + pdf.add_page() + _section_header(pdf, "BITACORA DE FUENTES CONSULTADAS") + + n_ok = len(sources) + n_fail = len(meta.get("failures", []) if meta else []) + n_empty = len(empty_sources) + pdf.set_font('helvetica', '', 9) + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, clean_txt(f"Resumen: {n_ok} fuentes con datos | {n_fail} fallidas | {n_empty} sin datos para este CUIT"), new_x="LMARGIN", new_y="NEXT") + pdf.ln(3) + + all_sources = [ + ("ARCA / AFIP", "Situacion fiscal, IVA, Monotributo, Actividades"), + ("BCRA", "Situacion crediticia, historial, cheques rechazados"), + ("Boletin Oficial", "Publicaciones oficiales nacionales y edictos"), + ("Timeline BORA", "Historial de sociedades del Boletin Oficial"), + ("Boletines Provinciales", "Boletines de 24 jurisdicciones provinciales"), + ("IGJ", "Sociedades, directivos y sede social registrada"), + ("Poder Judicial (PJN)", "Causas judiciales federales y comerciales"), + ("JUBA (SCBA)", "Causas en Justicia de la Provincia de Buenos Aires"), + ("Poder Judicial Provincial", "Causas judiciales provinciales"), + ("DNRPA", "Vehiculos registrados a nombre de la persona"), + ("SINAI / ANSV", "Infracciones de transito nacionales y PBA"), + ("INPI", "Marcas y patentes comerciales registradas"), + ("ANSES", "Aportes previsionales, obra social y beneficios"), + ("Redes Sociales OSINT", "Perfiles publicos en LinkedIn, Google, Instagram, etc."), + ("Telefonia OSINT", "Telefonos vinculados a nombre o CUIT"), + ("RENAPER", "Validacion de identidad y datos del DNI"), + ("RENAPER Facial", "Estado biometrico del documento nacional"), + ("Colegios Profesionales", "Matriculas habilitadas en consejos profesionales"), + ("SSSalud / RUIDO", "Cobertura de salud activa y obra social"), + ("COMPR.AR", "Inscripcion como proveedor del Estado Nacional"), + ("Padron Electoral", "Datos del padron electoral y domicilio electoral"), + ("SGARHU / SIU", "Titulos universitarios oficiales registrados"), + ("SISCOP / Reg. Civil", "Deteccion de fallecimiento y estado del DNI"), + ("ARBA Automotores", "Deuda de patente de vehiculos en ARBA"), + ("ARBA Catastro / AGIP", "Inmuebles e impositivas en PBA y CABA"), + ("CNV", "Registro de agentes y fondos en Comision de Valores"), + ("UIF / PEPs", "Personas Expuestas Politicamente - lista UIF"), + ("Name Search OSINT", "Busqueda de nombre en fuentes de datos abiertas"), + ("Infracciones", "Infracciones de transito"), + ("Inhibiciones / Embargos", "Inhibiciones y embargos judiciales"), + ("Billeteras Virtuales / Fintechs", "Deudas en billeteras virtuales y fintechs"), + ("Google Images", "Busqueda de fotos de perfil por nombre"), + ("Antecedentes Penales", "Antecedentes penales de fuente publica"), + ("Concursos y Quiebras", "Concursos preventivos y quiebras"), + ("Participaciones IGJ", "Participaciones societarias en sociedades"), + ] + + failures = meta.get("failures", []) if meta else [] + for i, (src_name, src_desc) in enumerate(all_sources): + is_used = src_name in sources + is_failed = src_name in failures + is_empty = src_name in empty_sources + + if is_failed: + fill = (254, 242, 242) + prefix = "[FALLO]" + color = (185, 28, 28) + desc_text = f"{src_desc} - FALLO LA CONSULTA (Requiere revision)" + elif is_used: + fill = (245, 250, 245) + prefix = "[x]" + color = (22, 101, 52) + desc_text = src_desc + elif is_empty: + fill = (255, 251, 235) + prefix = "[ ]" + color = (146, 64, 14) + desc_text = f"{src_desc} - Sin datos para este CUIT" + else: + fill = (248, 248, 248) + prefix = "[ ]" + color = (100, 116, 139) + desc_text = src_desc + + pdf.set_fill_color(*fill) + pdf.rect(10, pdf.get_y(), 190, 7, 'F') + pdf.set_text_color(*color) + pdf.set_font('helvetica', 'B' if (is_used or is_failed) else '', 8.5) + pdf.cell(12, 6, prefix, new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', 'B', 8.5) + pdf.set_text_color(15, 23, 42) + pdf.cell(50, 6, clean_txt(src_name), new_x="RIGHT", new_y="TOP") + pdf.set_font('helvetica', '', 8) + if is_failed: + pdf.set_text_color(185, 28, 28) + elif is_empty: + pdf.set_text_color(146, 64, 14) + else: + pdf.set_text_color(71, 85, 105) + pdf.cell(0, 6, clean_txt(desc_text), new_x="LMARGIN", new_y="NEXT") + + pdf.ln(5) + pdf.set_font('helvetica', 'I', 8) + pdf.set_text_color(100, 116, 139) + pdf.multi_cell(190, 4.5, clean_txt( + "Este informe fue generado automaticamente por CrowData Intelligence consultando fuentes " + "publicas y abiertas del Estado Argentino. Los datos presentados tienen caracter informativo " + "y deben ser verificados ante cada organismo competente. CrowData no se responsabiliza " + "por inexactitudes en las fuentes oficiales.\n" + f"Generado el: {generated_at or datetime.datetime.now().strftime('%d/%m/%Y %H:%M')} - " + "CrowData (c) 2025 - Todos los derechos reservados." + )) + + return pdf.output() diff --git a/app/utils/scoring.py b/app/utils/scoring.py new file mode 100644 index 0000000000000000000000000000000000000000..0c582ac8b358e1315515ad0417aa02b5dfe76eb6 --- /dev/null +++ b/app/utils/scoring.py @@ -0,0 +1,267 @@ +"""Módulo de scoring crediticio unificado.""" +import logging +from datetime import datetime +from typing import List, Optional +from app.reports.schemas import ( + ScoreCrediticio, ScoreHistorial, + PersonReport, CompanyReport, GroupReport +) + +logger = logging.getLogger(__name__) + +def compute_score(report: PersonReport, history_scores: List[ScoreHistorial] = None) -> ScoreCrediticio: + """ + Calcula el score crediticio (1-100) para una persona basado en su reporte. + """ + score = 60 # Base score + factores = [] + + # 1. BCRA Situación Actual + sit = report.financiero.bcra_situacion_actual + if sit is None or sit <= 1: + score += 40 + factores.append("BCRA Situación Normal (Sit. 1 o sin deuda) +40pts") + elif sit == 2: + score += 28 + factores.append("BCRA Situación 2 (Seguimiento especial) +28pts") + elif sit == 3: + score += 14 + factores.append("BCRA Situación 3 (Con problemas) +14pts") + elif sit == 4: + score += 4 + factores.append("BCRA Situación 4 (Alto riesgo de insolvencia) +4pts") + else: + # Sit 5 o 6 + factores.append(f"BCRA Situación {sit} (Irrecuperable / Incoobrable) +0pts") + + # 2. Cheques Rechazados + cant_cheques = len(report.financiero.cheques_rechazados) + if cant_cheques > 0: + deduccion = cant_cheques * 5 + score -= deduccion + factores.append(f"{cant_cheques} Cheques rechazados -{deduccion}pts") + + # 3. Causas Judiciales + # Consideramos causas judiciales activas (que no tengan estado Terminado/Archivado) + causas_activas = 0 + for c in report.judicial.causas: + estado = (c.estado or "").lower() + if not any(x in estado for x in ["archiv", "termin", "finaliz", "cerrad", "resuelt"]): + causas_activas += 1 + if causas_activas > 0: + deduccion = causas_activas * 4 + score -= deduccion + factores.append(f"{causas_activas} Causas judiciales activas -{deduccion}pts") + + # 4. Inhibiciones / Embargos + # Usar solo patrimonial.inhibiciones que ya está consolidado (evitar duplicados con judicial) + inhibiciones_totales = len(report.patrimonial.inhibiciones) + if inhibiciones_totales > 0: + deduccion = inhibiciones_totales * 10 + score -= deduccion + factores.append(f"{inhibiciones_totales} Inhibiciones/Embargos registrados -{deduccion}pts") + + # 5. AFIP Activo (Señal positiva) + estado_afip = (report.fiscal.estado_afip or "").lower() + if "activ" in estado_afip: + score += 5 + factores.append("AFIP Activo +5pts") + + # 6. Antigüedad laboral ANSES + if report.previsional.tiene_aportes: + alta_os = report.previsional.fecha_alta_obra_social + es_antiguo = False + if alta_os: + try: + # Intentamos parsear la fecha de alta. Formatos típicos: YYYY-MM-DD o DD/MM/YYYY + fecha_alta = None + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"): + try: + fecha_alta = datetime.strptime(alta_os, fmt) + break + except ValueError: + continue + if fecha_alta: + anios = (datetime.now() - fecha_alta).days / 365.25 + if anios >= 3.0: + es_antiguo = True + except Exception as e: + logger.debug(f"Error parseando fecha alta obra social: {e}") + + # Fallback si no hay fecha pero tiene aportes al día + if es_antiguo or (report.previsional.aportes_al_dia and not alta_os): + score += 3 + factores.append("Antigüedad laboral/Aportes > 3 años +3pts") + + # 7. Inmuebles registrados + cant_inmuebles = len(report.patrimonial.inmuebles) + if cant_inmuebles > 0: + puntos_inm = min(6, cant_inmuebles * 2) + score += puntos_inm + factores.append(f"{cant_inmuebles} Inmuebles registrados +{puntos_inm}pts (máx +6)") + + # 8. Marcas INPI (solo para empresas; en personas siempre 0) + cant_marcas = len(getattr(report, "marcas_inpi", [])) + if cant_marcas > 0: + puntos_marcas = min(4, cant_marcas * 2) + score += puntos_marcas + factores.append(f"{cant_marcas} Marcas INPI registradas +{puntos_marcas}pts (máx +4)") + + # Acotar el score final entre 1 y 100 + score_final = max(1, min(100, score)) + + # Determinar nivel + if score_final >= 80: + nivel = "Excelente" + elif score_final >= 60: + nivel = "Bueno" + elif score_final >= 40: + nivel = "Regular" + elif score_final >= 20: + nivel = "Malo" + else: + nivel = "Crítico" + + return ScoreCrediticio( + valor=score_final, + nivel=nivel, + historial=history_scores or [], + factores=factores + ) + + +def compute_company_score(report: CompanyReport, history_scores: List[ScoreHistorial] = None) -> ScoreCrediticio: + """ + Calcula el score crediticio (0-1000) para una empresa/ persona jurídica. + Replica la lógica del frontend: base 950, penalizaciones por BCRA, cheques y causas. + """ + score = 950 + factores = [] + + # 1. BCRA Situación Actual + sit = report.financiero.bcra_situacion_actual + if sit is None or sit <= 1: + factores.append("BCRA Situación Normal +0pts") + elif sit == 2: + score -= 150 + factores.append("BCRA Situación 2 -150pts") + elif sit == 3: + score -= 300 + factores.append("BCRA Situación 3 -300pts") + elif sit == 4: + score -= 500 + factores.append("BCRA Situación 4 -500pts") + elif sit >= 5: + score -= 750 + factores.append(f"BCRA Situación {sit} -750pts") + + # 2. Cheques Rechazados (máx -250) + cant_cheques = len(report.financiero.cheques_rechazados) + if cant_cheques > 0: + deduccion = min(250, cant_cheques * 50) + score -= deduccion + factores.append(f"{cant_cheques} Cheques rechazados -{deduccion}pts (máx -250)") + + # 3. Causas Judiciales (máx -200) + cant_causas = len(report.judicial.causas) + if cant_causas > 0: + deduccion = min(200, cant_causas * 40) + score -= deduccion + factores.append(f"{cant_causas} Causas judiciales -{deduccion}pts (máx -200)") + + # 4. AFIP Activo (bonus) + estado_afip = (report.fiscal.estado_afip or "").lower() + if "activ" in estado_afip: + score += 20 + factores.append("AFIP Activo +20pts") + + # 5. IGJ inscripto (bonus) + if report.igj.numero_inscripcion: + score += 10 + factores.append("IGJ Inscripto +10pts") + + # Acotar entre 0 y 1000 + score_final = max(0, min(1000, score)) + + # Determinar nivel + if score_final >= 700: + nivel = "Excelente" + elif score_final >= 450: + nivel = "Medio" + else: + nivel = "Crítico" + + return ScoreCrediticio( + valor=score_final, + nivel=nivel, + historial=history_scores or [], + factores=factores + ) + + +def compute_group_score(report: GroupReport, history_scores: List[ScoreHistorial] = None) -> ScoreCrediticio: + """ + Calcula el score crediticio (0-1000) para un grupo económico (titular + empresas + vehículos). + Replica la lógica del frontend: base 950, penalizaciones por BCRA/riesgos, bonus por vehículos. + """ + score = 950 + factores = [] + + # 1. BCRA del titular + sit = report.target.financiero.bcra_situacion_actual + if sit is None or sit <= 1: + factores.append("BCRA Titular Normal +0pts") + elif sit == 2: + score -= 150 + factores.append("BCRA Titular Sit. 2 -150pts") + elif sit == 3: + score -= 300 + factores.append("BCRA Titular Sit. 3 -300pts") + elif sit == 4: + score -= 500 + factores.append("BCRA Titular Sit. 4 -500pts") + elif sit >= 5: + score -= 750 + factores.append(f"BCRA Titular Sit. {sit} -750pts") + + # 2. Riesgos consolidados (máx -300) + total_riesgos = len(report.riesgo_consolidado) + if total_riesgos > 0: + deduccion = min(300, total_riesgos * 60) + score -= deduccion + factores.append(f"{total_riesgos} Riesgos consolidados -{deduccion}pts (máx -300)") + + # 3. Vehículos vinculados (bonus, máx +100) + cant_vehiculos = len(report.vehiculos_vinculados) + if cant_vehiculos > 0: + bonus = min(100, cant_vehiculos * 25) + score += bonus + factores.append(f"{cant_vehiculos} Vehículos vinculados +{bonus}pts (máx +100)") + + # 4. Empresas vinculadas (bonus si están activas) + empresas_activas = 0 + for e in report.empresas_vinculadas: + if "activ" in (e.fiscal.estado_afip or "").lower(): + empresas_activas += 1 + if empresas_activas > 0: + bonus = min(50, empresas_activas * 25) + score += bonus + factores.append(f"{empresas_activas} Empresas activas +{bonus}pts (máx +50)") + + # Acotar entre 0 y 1000 + score_final = max(0, min(1000, score)) + + # Determinar nivel + if score_final >= 700: + nivel = "Excelente" + elif score_final >= 450: + nivel = "Medio" + else: + nivel = "Crítico" + + return ScoreCrediticio( + valor=score_final, + nivel=nivel, + historial=history_scores or [], + factores=factores + ) diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000000000000000000000000000000000000..5c188f63e9ca820fa7c21cd4bc94c2d0fbc39a91 --- /dev/null +++ b/app/utils/security.py @@ -0,0 +1,41 @@ +""" +Utilidades de seguridad para logs — CrowData. + +Enmascara datos sensibles (CUIT, DNI, email) en logs. +""" +import re + + +def mask_cuit(cuit: str) -> str: + """ + Enmascara un CUIT mostrando solo los últimos 4 dígitos. + Ejemplo: 20-29681148-4 → ******1484 + """ + if not cuit or len(cuit) < 6: + return "******" + clean = cuit.replace("-", "").replace(" ", "") + return "*" * (len(clean) - 4) + clean[-4:] + + +def mask_dni(dni: str) -> str: + """ + Enmascara un DNI mostrando solo los últimos 3 dígitos. + Ejemplo: 12345678 → ****5678 + """ + if not dni or len(dni) < 4: + return "****" + clean = dni.replace(".", "").replace(" ", "") + return "*" * (len(clean) - 3) + clean[-3:] + + +def mask_email(email: str) -> str: + """ + Enmascara un email mostrando solo la primera letra y dominio. + Ejemplo: lucas@crowdata.ar → l*****@crowdata.ar + """ + if not email or "@" not in email: + return "***" + local, domain = email.split("@", 1) + if len(local) <= 1: + return f"*@{domain}" + return f"{local[0]}{'*' * (len(local) - 1)}@{domain}" diff --git a/app/utils/telemetry.py b/app/utils/telemetry.py new file mode 100644 index 0000000000000000000000000000000000000000..6ebff439894b2e22a6eed3dfcd90d1cb29e5ca85 --- /dev/null +++ b/app/utils/telemetry.py @@ -0,0 +1,266 @@ +""" +Telemetría de Scrapers — CrowData. + +Registra el estado de salud de cada scraper tras su ejecución: +- Último estado (ok / error / bloqueado) +- Timestamp del último run +- Tasa de éxito acumulada (rolling window de 24h) +- Latencia promedio + +Los resultados se almacenan en memoria (TTL de 48h) y se exponen +via el endpoint /reports/scrapers/health. +""" + +import time +import logging +from collections import deque, defaultdict +from datetime import datetime, timezone +from threading import Lock +from typing import Optional + +logger = logging.getLogger(__name__) + +# ────────────────────────────────────────────────────────────────────────────── +# Constantes +# ────────────────────────────────────────────────────────────────────────────── + +MAX_HISTORY = 50 # Máximo de registros por scraper +WINDOW_SECS = 86400 # Ventana de cálculo de tasa de éxito: 24h + +# ────────────────────────────────────────────────────────────────────────────── +# Estado global en memoria (thread-safe vía Lock) +# ────────────────────────────────────────────────────────────────────────────── + +_lock = Lock() + +# scraper_name -> deque of (timestamp_float, status: "ok"|"error"|"blocked", latency_ms: float) +_history: dict[str, deque] = defaultdict(lambda: deque(maxlen=MAX_HISTORY)) + +# scraper_name -> dict con datos del último run +_last_run: dict[str, dict] = {} + +# Metadatos estáticos de cada scraper (descripción y fuente) +SCRAPER_META = { + "ARCA/AFIP": {"fuente": "api.afip.gov.ar", "descripcion": "Situación fiscal, IVA, Monotributo"}, + "BCRA": {"fuente": "api.bcra.gob.ar", "descripcion": "Situación crediticia y cheques rechazados"}, + "Boletín Oficial": {"fuente": "www.boletinoficial.gob.ar", "descripcion": "Publicaciones oficiales"}, + "Boletines Provinciales": {"fuente": "varios", "descripcion": "Boletines oficiales de 24 provincias"}, + "IGJ": {"fuente": "sistemas.jus.gob.ar", "descripcion": "Sociedades y directivos registrados"}, + "Poder Judicial": {"fuente": "scw.pjn.gov.ar", "descripcion": "Causas judiciales federales"}, + "JUBA (Poder Judicial Bs As)":{"fuente": "juba.scba.gov.ar", "descripcion": "Causas en Justicia Bonaerense"}, + "DNRPA / Automotores": {"fuente": "dnrpa.gov.ar", "descripcion": "Vehículos registrados"}, + "SINAI / Infracciones": {"fuente": "infraccionesba.gba.gob.ar", "descripcion": "Infracciones de tránsito ANSV"}, + "INPI (Marcas y Patentes)": {"fuente": "markaronline.inpi.gob.ar", "descripcion": "Marcas y patentes comerciales"}, + "ANSES": {"fuente": "api.anses.gob.ar", "descripcion": "Aportes, jubilaciones, obra social"}, + "Redes Sociales OSINT": {"fuente": "osint", "descripcion": "Perfiles públicos en redes sociales"}, + "Telefonía": {"fuente": "osint", "descripcion": "Números telefónicos vinculados"}, + "RENAPER": {"fuente": "argentina.gob.ar", "descripcion": "Validación de identidad RENAPER"}, + "RENAPER Facial": {"fuente": "argentina.gob.ar", "descripcion": "Biometría y estado de DNI"}, + "Colegios Profesionales": {"fuente": "datos.gob.ar", "descripcion": "Matrículas profesionales activas"}, + "RUIDO / SSSalud": {"fuente": "sssalud.gob.ar", "descripcion": "Cobertura de salud y obra social"}, + "COMPR.AR / Contrataciones": {"fuente": "comprear.gob.ar", "descripcion": "Contratos con el Estado"}, + "Padrón Electoral": {"fuente": "padron.gob.ar", "descripcion": "Datos del padrón electoral"}, + "SGARHU / Académico": {"fuente": "siu.edu.ar", "descripcion": "Títulos universitarios oficiales"}, + "SISCOP / Registro Civil": {"fuente": "siscop.gov.ar", "descripcion": "Detección de defunción"}, + "ARBA Automotores": {"fuente": "arba.gob.ar", "descripcion": "Deuda de patentes vehiculares ARBA"}, + "ARBA Catastro": {"fuente": "arba.gob.ar", "descripcion": "Inmuebles y deuda inmobiliaria"}, + "CNV (Comisión Nacional de Valores)": {"fuente": "cnv.gob.ar", "descripcion": "Registro de agentes financieros"}, + "UIF / PEPs": {"fuente": "uif.gob.ar", "descripcion": "Personas Expuestas Políticamente"}, + "ARBA / AGIP": {"fuente": "arba.gob.ar", "descripcion": "Deudas impositivas provinciales"}, + "Name Search OSINT": {"fuente": "osint", "descripcion": "Búsqueda de nombre en fuentes abiertas"}, + "Padrón RUIDO": {"fuente": "sssalud.gob.ar", "descripcion": "Obra social por CUIL"}, + "CONTRATAR": {"fuente": "datos.gob.ar", "descripcion": "Contrataciones públicas de obra pública"}, + "AFIP_CONSTANCIA": {"fuente": "soa.afip.gob.ar", "descripcion": "Constancia de inscripción fiscal AFIP"}, + "DEUDORES_ALIMENTARIOS": {"fuente": "rdam.mjus.gba.gob.ar", "descripcion": "Registro de deudores alimentarios morosos PBA"}, +} + + +# ────────────────────────────────────────────────────────────────────────────── +# API de registro de resultados +# ────────────────────────────────────────────────────────────────────────────── + +def record_scraper_result( + scraper_name: str, + status: str, # "ok" | "error" | "blocked" | "empty" + latency_ms: float, + detail: Optional[str] = None, + records_found: int = 0 +): + """ + Registra el resultado de una ejecución de un scraper. + Llamar desde el orquestador tras cada scraper en service.py. + + Args: + scraper_name: Nombre canónico del scraper (source_name) + status: "ok" si obtuvo datos, "empty" si ejecutó pero sin hallazgos, + "error" si lanzó excepción, "blocked" si fue bloqueado antibot + latency_ms: Tiempo de ejecución en milisegundos + detail: Mensaje de error o detalle adicional opcional + records_found: Cantidad de registros obtenidos + """ + ts = time.time() + entry = { + "ts": ts, + "status": status, + "latency_ms": round(latency_ms, 1), + "detail": detail, + "records_found": records_found, + } + with _lock: + _history[scraper_name].append(entry) + _last_run[scraper_name] = { + **entry, + "last_seen": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(), + } + + +# ────────────────────────────────────────────────────────────────────────────── +# API de consulta de estado +# ────────────────────────────────────────────────────────────────────────────── + +def get_scraper_health() -> list[dict]: + """ + Retorna la lista de scrapers con su estado de salud actual. + + Returns: + Lista de dicts con: name, status, success_rate_24h, avg_latency_ms, + last_seen, records_found, fuente, descripcion + """ + now = time.time() + cutoff = now - WINDOW_SECS + + results = [] + + # Incluir todos los scrapers conocidos, aunque no hayan corrido aún + all_names = set(SCRAPER_META.keys()) | set(_last_run.keys()) + + with _lock: + for name in sorted(all_names): + meta = SCRAPER_META.get(name, {"fuente": "desconocida", "descripcion": ""}) + last = _last_run.get(name) + history = list(_history.get(name, [])) + + # Calcular tasa de éxito en las últimas 24h + window_entries = [e for e in history if e["ts"] >= cutoff] + if window_entries: + ok_count = sum(1 for e in window_entries if e["status"] in ("ok", "empty")) + success_rate = round(ok_count / len(window_entries) * 100, 1) + avg_latency = round( + sum(e["latency_ms"] for e in window_entries) / len(window_entries), 1 + ) + else: + success_rate = None + avg_latency = None + + results.append({ + "name": name, + "fuente": meta["fuente"], + "descripcion": meta["descripcion"], + "status": last["status"] if last else "sin_datos", + "last_seen": last["last_seen"] if last else None, + "latency_ms": last["latency_ms"] if last else None, + "avg_latency_ms_24h": avg_latency, + "success_rate_24h": success_rate, + "records_found_last": last["records_found"] if last else 0, + "detail": last.get("detail") if last else None, + "runs_24h": len(window_entries), + }) + + return results + + +def get_system_health_summary() -> dict: + """ + Resumen ejecutivo del estado del sistema de scrapers. + """ + health = get_scraper_health() + total = len(health) + + statuses = [s["status"] for s in health] + ok_count = statuses.count("ok") + statuses.count("empty") + error_count = statuses.count("error") + blocked_count = statuses.count("blocked") + sin_datos = statuses.count("sin_datos") + + return { + "total_scrapers": total, + "operativos": ok_count, + "con_error": error_count, + "bloqueados": blocked_count, + "sin_ejecutar": sin_datos, + "health_pct": round(ok_count / max(total - sin_datos, 1) * 100, 1), + "scrapers": health, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Alertas de monitoreo +# ────────────────────────────────────────────────────────────────────────────── + +ALERT_CONSECUTIVE_ERRORS = 3 # Errores consecutivos para activar alerta +ALERT_MIN_RUNS = 5 # Mínimo de runs antes de evaluar +ALERT_LOW_SUCCESS_PCT = 30 # % mínimo de éxito en 24h para no alertar + + +def get_scrapers_alerts() -> list[dict]: + """ + Detecta scrapers con problemas consistentes y genera alertas. + + Returns: + Lista de alertas con: scraper, tipo, severidad, mensaje, detalle + """ + alerts = [] + health = get_scraper_health() + + for s in health: + name = s["name"] + status = s["status"] + runs = s.get("runs_24h", 0) + success_rate = s.get("success_rate_24h") + last_detail = s.get("detail") + + # Alert 1: Scraper con error actual + if status == "error" and runs >= ALERT_CONSECUTIVE_ERRORS: + alerts.append({ + "scraper": name, + "tipo": "error_actual", + "severidad": "alta", + "mensaje": f"{name} tiene error activo con {runs} ejecuciones recientes", + "detalle": last_detail, + }) + + # Alert 2: Scraper bloqueado por antibot + if status == "blocked": + alerts.append({ + "scraper": name, + "tipo": "bloqueado_antibot", + "severidad": "alta", + "mensaje": f"{name} bloqueado por sistema antibot", + "detalle": last_detail, + }) + + # Alert 3: Tasa de éxito baja en 24h + if success_rate is not None and success_rate < ALERT_LOW_SUCCESS_PCT and runs >= ALERT_MIN_RUNS: + alerts.append({ + "scraper": name, + "tipo": "baja_tasa_exito", + "severidad": "media", + "mensaje": f"{name} solo tiene {success_rate}% de éxito en las últimas 24h ({runs} runs)", + "detalle": None, + }) + + # Alert 4: Scraper que nunca corrió + if status == "sin_datos": + alerts.append({ + "scraper": name, + "tipo": "nunca_ejecutado", + "severidad": "baja", + "mensaje": f"{name} nunca ha sido ejecutado", + "detalle": None, + }) + + # Ordenar por severidad (alta > media > baja) + severity_order = {"alta": 0, "media": 1, "baja": 2} + alerts.sort(key=lambda a: severity_order.get(a["severidad"], 3)) + + return alerts diff --git a/create_user.py b/create_user.py new file mode 100644 index 0000000000000000000000000000000000000000..72a604a07c38cd150a84699896c358b91a14db2a --- /dev/null +++ b/create_user.py @@ -0,0 +1,18 @@ +import asyncio +import sys +from app.auth.manager import get_user_manager + +async def test(): + async for udb in get_user_manager(): + try: + user = await udb.create({ + 'email': 'test@test.com', + 'password': 'test123456', + 'full_name': 'Test User' + }) + print('User created:', user.email, file=sys.stderr) + except Exception as e: + print('Error:', type(e).__name__, str(e), file=sys.stderr) + +if __name__ == "__main__": + asyncio.run(test()) \ No newline at end of file diff --git a/data/fintech_entities.json b/data/fintech_entities.json new file mode 100644 index 0000000000000000000000000000000000000000..b588e9505a65d4f867275c76b345e7ad55bb9106 --- /dev/null +++ b/data/fintech_entities.json @@ -0,0 +1,247 @@ +{ + "version": 2, + "updated": "2026-06-17", + "nota": "Mapping de entidades BCRA (Central de Deudores) a marcas comerciales. Usa busqueda parcial por nombre.", + "entidades": [ + { + "nombre_bcra": "MERCADO PAGO", + "marca": "Mercado Pago", + "tipo": "fintech", + "rubro": "Billetera digital / Préstamos" + }, + { + "nombre_bcra": "TARJETA NARANJA", + "marca": "Naranja X", + "tipo": "fintech", + "rubro": "Tarjeta de crédito / Billetera digital" + }, + { + "nombre_bcra": "UALA", + "marca": "Ualá", + "tipo": "fintech", + "rubro": "Billetera digital / Préstamos" + }, + { + "nombre_bcra": "BRUBANK", + "marca": "Brubank", + "tipo": "fintech", + "rubro": "Banco digital" + }, + { + "nombre_bcra": "REBA", + "marca": "Reba", + "tipo": "fintech", + "rubro": "Banco digital" + }, + { + "nombre_bcra": "RECARGAPAY", + "marca": "RecargaPay", + "tipo": "fintech", + "rubro": "Préstamos / Recarga digital" + }, + { + "nombre_bcra": "WENANCE", + "marca": "Wenance", + "tipo": "fintech", + "rubro": "Préstamos online" + }, + { + "nombre_bcra": "MONETARIO", + "marca": "Monet", + "tipo": "fintech", + "rubro": "Préstamos personales" + }, + { + "nombre_bcra": "KOGGI", + "marca": "Koggi", + "tipo": "fintech", + "rubro": "Préstamos online" + }, + { + "nombre_bcra": "ADELANTO COM", + "marca": "Adelantos.com", + "tipo": "fintech", + "rubro": "Adelantos / Préstamos" + }, + { + "nombre_bcra": "INDUSTRIAL CREDITOS S.A.", + "marca": "Créditos Industriales", + "tipo": "fintech", + "rubro": "Préstamos personales" + }, + { + "nombre_bcra": "CREDITOS DIRECTOS S.A.", + "marca": "Créditos Directos", + "tipo": "fintech", + "rubro": "Préstamos online" + }, + { + "nombre_bcra": "FINANDES S.A.", + "marca": "Finandés", + "tipo": "fintech", + "rubro": "Préstamos personales" + }, + { + "nombre_bcra": "PAGO FACIL", + "marca": "Pago Fácil", + "tipo": "fintech", + "rubro": "Red de cobranza / Créditos" + }, + { + "nombre_bcra": "RAPIPAGO", + "marca": "Rapipago", + "tipo": "fintech", + "rubro": "Red de cobranza / Créditos" + }, + { + "nombre_bcra": "FIRST CAPITAL GROUP", + "marca": "First Capital Group", + "tipo": "fintech", + "rubro": "Préstamos / Inversiones" + }, + { + "nombre_bcra": "PRISMA", + "marca": "Prisma Medios de Pago", + "tipo": "procesadora", + "rubro": "Procesadora de pagos" + }, + { + "nombre_bcra": "COELSA", + "marca": "Coelsa", + "tipo": "procesadora", + "rubro": "Cámara electrónica de pagos" + }, + { + "nombre_bcra": "NUEVO BANCO DE SANTA FE", + "marca": "Cuenta DNI (Santa Fe)", + "tipo": "banco_digital", + "rubro": "Billetera digital provincial" + }, + { + "nombre_bcra": "BANCO PROVINCIA DE BUENOS AIRES", + "marca": "Cuenta DNI (PBA)", + "tipo": "banco_digital", + "rubro": "Billetera digital provincial" + }, + { + "nombre_bcra": "BANCO PROVINCIA DEL NEUQUEN", + "marca": "Neuquén Digital", + "tipo": "banco_digital", + "rubro": "Billetera digital provincial" + }, + { + "nombre_bcra": "COMAFI", + "marca": "Comafi", + "tipo": "banco_digital", + "rubro": "Banco con app digital" + }, + { + "nombre_bcra": "BANCO DE GALICIA", + "marca": "Galicia Más", + "tipo": "banco_digital", + "rubro": "App bancaria" + }, + { + "nombre_bcra": "BANCO DE LA NACION ARGENTINA", + "marca": "BNA+", + "tipo": "banco_digital", + "rubro": "App bancaria" + }, + { + "nombre_bcra": "BANCO PATAGONIA", + "marca": "Patagonia App", + "tipo": "banco_digital", + "rubro": "App bancaria" + }, + { + "nombre_bcra": "BANCO CREDICOOP", + "marca": "Credicoop App", + "tipo": "banco_digital", + "rubro": "App bancaria" + }, + { + "nombre_bcra": "BANCO SUPERVIELLE", + "marca": "Supervielle App", + "tipo": "banco_digital", + "rubro": "App bancaria" + }, + { + "nombre_bcra": "BANCO HIPOTECARIO", + "marca": "Banco Hipotecario", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO CIUDAD", + "marca": "Ciudad App", + "tipo": "banco_digital", + "rubro": "App bancaria" + }, + { + "nombre_bcra": "BANCO SANTANDER", + "marca": "Santander", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO BBVA", + "marca": "BBVA", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO MACRO", + "marca": "Banco Macro", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO DE CORDOBA", + "marca": "Bancor", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO DE SANTA CRUZ", + "marca": "Banco Santa Cruz", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO DE FORMOSA", + "marca": "Banco Formosa", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO DE LA PAMPA", + "marca": "Banco La Pampa", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "NUEVO BANCO DEL CHACO", + "marca": "Banco Chaco", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO MUNICIPAL DE ROSARIO", + "marca": "Banco Municipal Rosario", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "BANCO DE LA RIOJA", + "marca": "Banco La Rioja", + "tipo": "tradicional", + "rubro": "Banco tradicional" + }, + { + "nombre_bcra": "MERCADO ABIERTO", + "marca": "Mercado Abierto Electrónico", + "tipo": "tradicional", + "rubro": "Mercado de valores" + } + ] +} diff --git a/debug.py b/debug.py new file mode 100644 index 0000000000000000000000000000000000000000..99660f65485d35c285157c8d4b532aac83bac2e6 --- /dev/null +++ b/debug.py @@ -0,0 +1,17 @@ +with open(r'E:\crowdata\backend\app\utils\http_client.py', 'rb') as f: + c = f.read() +with open('debug_output.txt', 'w') as out: + out.write(f'Total length: {len(c)}\n') + out.write(f'First 200 bytes: {c[:200]!r}\n') + out.write(f'Has BOM: {c.startswith(b"\xef\xbb\xbf")}\n') + idx = c.find(b'headers={') + if idx >= 0: + out.write(f'Found at index {idx}\n') + out.write(c[idx:idx+200].decode('utf-8', errors='replace')) + else: + out.write('Not found\n') + # Check for non-ASCII + for i, b in enumerate(c): + if b >= 128: + out.write(f'Non-ASCII at {i}: 0x{b:02x} ({chr(c[i]) if c[i] < 256 else "?"})\n') + break \ No newline at end of file diff --git a/e2e_validate.py b/e2e_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..f0984634098398522a884d08e55f3b486f491fc8 --- /dev/null +++ b/e2e_validate.py @@ -0,0 +1,69 @@ +import asyncio +import httpx +import sys + +BASE_URL = "http://localhost:8000" + +async def run_e2e(): + """E2E validation of all quick wins""" + async with httpx.AsyncClient(timeout=120) as client: + # 1. Health check + print("1. Health check...") + r = await client.get(f"{BASE_URL}/api/health") + assert r.status_code == 200, f"Health: {r.status_code}" + print(" ✅ Health OK") + + # 2. Login admin + print("2. Login admin...") + r = await client.post(f"{BASE_URL}/api/auth/jwt/login", data={ + "username": "crowsistemas@proton.me", "password": "admin123456" + }) + assert r.status_code == 200, f"Login: {r.status_code} - {r.text}" + token = r.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + print(f" ✅ Login OK (RS256 token)") + + # 3. Persona report (cached) + print("3. Persona report (cached)...") + r = await client.get(f"{BASE_URL}/api/reports/persona/20123456789", headers=headers) + assert r.status_code == 200, f"Persona: {r.status_code} - {r.text}" + print(f" ✅ Persona OK") + + # 4. Empresa report + print("4. Empresa report...") + r = await client.get(f"{BASE_URL}/api/reports/empresa/30123456789", headers=headers) + assert r.status_code == 200, f"Empresa: {r.status_code} - {r.text}" + print(f" ✅ Empresa OK") + + # 5. Vehiculo report (fix 1.4) + print("5. Vehiculo report...") + r = await client.get(f"{BASE_URL}/api/reports/vehiculo/ABC123", headers=headers) + assert r.status_code == 200, f"Vehiculo: {r.status_code} - {r.text}" + print(f" ✅ Vehiculo OK (no AttributeError)") + + # 6. Logout (fix 1.1) + print("6. Logout...") + r = await client.post(f"{BASE_URL}/api/auth/jwt/logout", headers=headers) + assert r.status_code in (200, 204), f"Logout: {r.status_code} - {r.text}" + print(f" ✅ Logout OK (no NameError)") + + # 7. BCRA scraper with TLS (fix 1.2) + print("7. BCRA scraper TLS test...") + from app.scrapers.bcra import BcraScraper + scraper = BcraScraper() + result = await scraper.safe_fetch('20123456789') + assert isinstance(result, dict), "BCRA should return dict" + assert "bcra_situacion_actual" in result + print(f" ✅ BCRA TLS OK") + + print("\n🎉 ALL E2E TESTS PASSED!") + return True + +if __name__ == "__main__": + try: + asyncio.run(run_e2e()) + except Exception as e: + print(f"\n❌ FAILED: {e}") + import traceback + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/final_check.py b/final_check.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff383b57b8037059525ca7f3c698c69a3121466 --- /dev/null +++ b/final_check.py @@ -0,0 +1,7 @@ +import sqlite3 +conn = sqlite3.connect('E:/crowdata/backend/crowdata.db') +cursor = conn.cursor() +cursor.execute('SELECT email, is_verified, is_superuser, is_active FROM users WHERE email IN (?, ?)', ('admin@crowdata.ar', 'crowsistemas@proton.me')) +for row in cursor.fetchall(): + print(f'Email: {row[0]}, verified: {row[1]}, superuser: {row[2]}, active: {row[3]}') +conn.close() \ No newline at end of file diff --git a/find_path.py b/find_path.py new file mode 100644 index 0000000000000000000000000000000000000000..42edcbab7a86d65f740ade534594958509bbd6a4 --- /dev/null +++ b/find_path.py @@ -0,0 +1,4 @@ +import fastapi_users_db_sqlalchemy +import os +path = os.path.dirname(fastapi_users_db_sqlalchemy.__file__) +print('Path:', path) \ No newline at end of file diff --git a/fix_admin.py b/fix_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..f109b2d7c645d5747542199d0ca04ee117189c16 --- /dev/null +++ b/fix_admin.py @@ -0,0 +1,19 @@ +import sqlite3 +conn = sqlite3.connect('E:/crowdata/backend/crowdata.db') +cursor = conn.cursor() + +# Verify current state +cursor.execute('SELECT email, is_verified, is_superuser FROM users WHERE email IN ("admin@crowdata.ar", "crowsistemas@proton.me")') +for row in cursor.fetchall(): + print(f'BEFORE: Email: {row[0]}, verified: {row[1]}, superuser: {row[2]}') + +# Update with explicit 1 values +cursor.execute('UPDATE users SET is_verified = 1, is_superuser = 1 WHERE email = "admin@crowdata.ar"') +cursor.execute('UPDATE users SET is_verified = 1, is_superuser = 1 WHERE email = "crowsistemas@proton.me"') +conn.commit() + +# Verify +cursor.execute('SELECT email, is_verified, is_superuser FROM users WHERE email IN ("admin@crowdata.ar", "crowsistemas@proton.me")') +for row in cursor.fetchall(): + print(f'AFTER: Email: {row[0]}, verified: {row[1]}, superuser: {row[2]}') +conn.close() \ No newline at end of file diff --git a/generate_keys.py b/generate_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..a5671b74ff70daed6d53e88dae630f8d8048f797 --- /dev/null +++ b/generate_keys.py @@ -0,0 +1,23 @@ +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +import os + +private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() +) +with open('private_key.pem', 'wb') as f: + f.write(private_pem) + +public_key = private_key.public_key() +public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo +) +with open('public_key.pem', 'wb') as f: + f.write(public_pem) + +print('Keypair generado en E:/crowdata/backend/') +print('Files:', os.listdir('.')) \ No newline at end of file diff --git a/get_full_hash.py b/get_full_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..1f55aea333a3ffb84524f31787391987d25ef774 --- /dev/null +++ b/get_full_hash.py @@ -0,0 +1,9 @@ +import sqlite3 +conn = sqlite3.connect('E:/crowdata/backend/crowdata.db') +cursor = conn.cursor() +cursor.execute('SELECT email, hashed_password FROM users WHERE email IN ("admin@crowdata.ar", "crowsistemas@proton.me")') +for row in cursor.fetchall(): + print(f'Email: {row[0]}') + print(f'Hash: {row[1]}') + print() +conn.close() \ No newline at end of file diff --git a/get_hashes.py b/get_hashes.py new file mode 100644 index 0000000000000000000000000000000000000000..1f55aea333a3ffb84524f31787391987d25ef774 --- /dev/null +++ b/get_hashes.py @@ -0,0 +1,9 @@ +import sqlite3 +conn = sqlite3.connect('E:/crowdata/backend/crowdata.db') +cursor = conn.cursor() +cursor.execute('SELECT email, hashed_password FROM users WHERE email IN ("admin@crowdata.ar", "crowsistemas@proton.me")') +for row in cursor.fetchall(): + print(f'Email: {row[0]}') + print(f'Hash: {row[1]}') + print() +conn.close() \ No newline at end of file diff --git a/get_token.py b/get_token.py new file mode 100644 index 0000000000000000000000000000000000000000..9df587bf4cbd5cfe24d7bf00a90ae4ac3b6654da --- /dev/null +++ b/get_token.py @@ -0,0 +1,5 @@ +import httpx +import sys + +resp = httpx.post('http://localhost:8000/api/auth/jwt/login', data={'username': 'crowsistemas@proton.me', 'password': 'admin123456'}) +print(resp.json()['access_token']) \ No newline at end of file diff --git a/give_credits.py b/give_credits.py new file mode 100644 index 0000000000000000000000000000000000000000..ada346e5861a41d9b77814bb0ad1e92511c94cfa --- /dev/null +++ b/give_credits.py @@ -0,0 +1,15 @@ +import asyncio +import sys +from app.database import AsyncSessionLocal +from app.auth.models import User +from sqlalchemy import update + +async def give_credits(): + async with AsyncSessionLocal() as db: + result = await db.execute( + update(User).where(User.email == 'crowsistemas@proton.me').values(credits=1000, plan='enterprise') + ) + await db.commit() + print(f'Updated {result.rowcount} rows', flush=True) + +asyncio.run(give_credits()) \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000000000000000000000000000000000000..c62e9d62bfb9f55f116bd73734d5d4f1ed37b089 --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,26 @@ +import sqlite3 +conn = sqlite3.connect('E:/crowdata/backend/crowdata.db') +cursor = conn.cursor() + +# Add missing columns +columns_to_add = [ + ('failed_login_attempts', 'INTEGER DEFAULT 0'), + ('locked_until', 'DATETIME'), + ('oauth_provider', 'VARCHAR(50)'), + ('oauth_provider_id', 'VARCHAR(255)'), + ('password_strength_score', 'INTEGER'), +] + +for col_name, col_type in columns_to_add: + try: + cursor.execute(f'ALTER TABLE users ADD COLUMN {col_name} {col_type}') + print(f'Added column: {col_name}') + except sqlite3.OperationalError as e: + if 'duplicate column name' in str(e).lower(): + print(f'Column {col_name} already exists, skipping') + else: + raise + +conn.commit() +conn.close() +print('Migration complete') \ No newline at end of file diff --git a/migrate_mfa.py b/migrate_mfa.py new file mode 100644 index 0000000000000000000000000000000000000000..fc1670102f8cb237566bc8433e1c026cc3ac9133 --- /dev/null +++ b/migrate_mfa.py @@ -0,0 +1,23 @@ +import sqlite3 +conn = sqlite3.connect('crowdata.db') +cursor = conn.cursor() + +# Add MFA columns +for col, col_type in [ + ('mfa_enabled', 'BOOLEAN DEFAULT FALSE'), + ('mfa_secret', 'VARCHAR(255)'), + ('mfa_backup_codes', 'VARCHAR(500)'), + ('mfa_verified_at', 'DATETIME') +]: + try: + cursor.execute(f'ALTER TABLE users ADD COLUMN {col} {col_type}') + print(f'Added column: {col}') + except Exception as e: + if 'duplicate' in str(e).lower() or 'already exists' in str(e).lower(): + print(f'Column already exists: {col}') + else: + print(f'Error adding {col}: {e}') + +conn.commit() +conn.close() +print('Migration done') \ No newline at end of file diff --git a/packages.txt b/packages.txt new file mode 100644 index 0000000000000000000000000000000000000000..e503a6e6327ad69e33144da2f64ba0f97007e3bc --- /dev/null +++ b/packages.txt @@ -0,0 +1,9 @@ +libgl1-mesa-glx +libglib2.0-0 +libsm6 +libxext6 +libxrender1 +libgomp1 +libpq-dev +chromium +chromium-driver diff --git a/private_key.pem b/private_key.pem new file mode 100644 index 0000000000000000000000000000000000000000..f10541816cc044c9d6e9a0f5efe6681e59982f8a --- /dev/null +++ b/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3Z+UlE13+wP4g +Hv4/o253KfFTravfJXYqIKHBNIEx1n+6+XFF1zXpbWMP5VWgRQg/ZsQ3KgM2yIz0 +jmo3QyeU9PNdPQoc2qfhSXBci3tkRXgHmmI0WiMx5F8DWfnZXMLg0XQb1fYZUCRP +rH2/QWEXLi8TL96lReyZCfk3fTBD7b2hKIOB4BMuGKwUkc1rpH14lYOJVGiqj2GJ +y+Ru1FvJ9wl2V+xxi8PP63H0sL1GIMXV0cnKVA6hjHvLFeIkdgFTH66K32Gokj8b +KaZZPgyGnJDvOC7OxtM0pLj5bo7LSg6ErX0FOMPGiPA0xsVXYJy++a18Dfpo/9PY +GWvhN4W3AgMBAAECggEAJjr88+6GkFzwXAe53dAWbbaLfHLeOcSYTg3BSgHE0Huy +4mmup+1FaqQHmz+lyqO5JWYpjoouY1QItc2d7GkOLimlNRFNM1iM2BQz3MaicPNe +Is+Wmu0TGwpMl+lAgIOqh6yBdG/0PbCL8SO2jpB1SZyx6WD4GyFpQTln/p4U2oOw +rwq7u/HqmJMKl1E/ONqyA6ULr2meQRXOnOMHVgF/rQdepdoszJWwmT2PcXocQwC7 +39fLm9NnZ5Qh28KkKQzU8Uv66IBBsDvb4nnlaZ5gM0cTqa3vHqV/X/9Yf2MDVWfu +kvsV3s2sULd1OCmtNd+Bc47z1QY5JdoP3R5jnl4BeQKBgQDisj8uwi4jn4z5nxB1 +tbK4GxfyjbVc3VqnrxPW5WjdL5kqd7JA6Tg0aBwLs3KHF7zWAl692fFOjiEEQLWb +bo2t3IptRSBwXowMr6AwuPaNuPd5kD6G2quk5iyXNjzwb/ncB6kfRIlegmVneUIs +K+Pl/ojxIfxf//ZWs6vYuJXjDQKBgQDPHRiwnaIcCP8JFbsWa6TZDdPuLzlfN3Bc +VNUFGndwA4gieK6T7N+PM1/GOBGsPOBGip2Ad9pqkWUuWJ0H9TWp+yD5AQZuGocy +gep+nIRAidvnxvyjsFUpEaVqegZ8TJkJtn9WY+CHO8kGDOyYbcFZuk4UQlfyq/1B +3a6H4dJq0wKBgQCwPvRwXfeRKpJn4Arj+Qehqy7LHPFL6ax5gdxizqjgjgj+w2CK +psdTtz1Wu4TnEsV1fRI7eB3rfQSeUdDfruvnp/bXTU8TDe7ETia0upi1RoDgugxi +u8+GvI0eYsSuCeCv+CS8coR6Pdaow9V2kgj03xeIoWudF1tlvPp128xsYQKBgEDt +bS0I7aX+R/1QG6tmqXIF/LdBhKnN1mKLkZAdAO5TnRy5WnkzG85nm5GnSBsHpoNW +txNr/0PDOsXxr6CsBVu5R1foM1zW4iU6RwnUBT26Of8KCW9DOx850fJ0OI5E8QDz +fi3V97BNVLKZ4J3UYnW/ivSc67c+pZE9bpZYe79TAoGBAK7sevtXEZ+0LxfCjfqH +gP6ecByDDVFKfgL5zrIAtAeUK23vW9N2UnZ2mKaRWdVAMZpXdiv7+6Sfoq7eOd2E +m5a4Xd0Zz9TxONKhYUMApkKpI3rRhN/vlfd1nw1rDzomOTrTAIsIo8bD6NmGMbkT +WWvKRZwTO6Q1YQMuRM4WCiqk +-----END PRIVATE KEY----- diff --git a/public_key.pem b/public_key.pem new file mode 100644 index 0000000000000000000000000000000000000000..46a708cbdbe5a0170710083cf9ee55b01d37699a --- /dev/null +++ b/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt2flJRNd/sD+IB7+P6Nu +dynxU62r3yV2KiChwTSBMdZ/uvlxRdc16W1jD+VVoEUIP2bENyoDNsiM9I5qN0Mn +lPTzXT0KHNqn4UlwXIt7ZEV4B5piNFojMeRfA1n52VzC4NF0G9X2GVAkT6x9v0Fh +Fy4vEy/epUXsmQn5N30wQ+29oSiDgeATLhisFJHNa6R9eJWDiVRoqo9hicvkbtRb +yfcJdlfscYvDz+tx9LC9RiDF1dHJylQOoYx7yxXiJHYBUx+uit9hqJI/GymmWT4M +hpyQ7zguzsbTNKS4+W6Oy0oOhK19BTjDxojwNMbFV2CcvvmtfA36aP/T2Blr4TeF +twIDAQAB +-----END PUBLIC KEY----- diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..cdce43ffd31786f64cfc44d599776a606a4a0377 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..aacdd5c7e60cebe43cc8d44957df19e7d71fbe98 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +fastapi>=0.100.0 +uvicorn[standard]>=0.22.0 +pydantic>=2.0 +pydantic-settings>=2.0 +sqlalchemy>=2.0 +asyncpg>=0.28.0 +aiosqlite>=0.19.0 +alembic>=1.11.0 +fastapi-users[sqlalchemy]>=12.0.0 +pyjwt[crypto]>=2.8.0 +cryptography>=41.0.0 +pyotp>=2.9.0 +qrcode>=7.4.2 +pillow>=10.0.0 +playwright>=1.40.0 +ddddocr>=1.4.7 +weasyprint==69.0 +fpdf2>=2.7.0 +mercadopago>=2.0.0 +aiosmtplib>=3.0.0 +jinja2>=3.1.0 +httpx>=0.25.0 +groq>=0.4.0 +python-multipart>=0.0.6 +gradio>=4.0.0 \ No newline at end of file diff --git a/reset_admin_passwords.py b/reset_admin_passwords.py new file mode 100644 index 0000000000000000000000000000000000000000..de311d3a1df5dde9dde5bf8ef2d0aa2c063caaca --- /dev/null +++ b/reset_admin_passwords.py @@ -0,0 +1,27 @@ +import sqlite3 +from fastapi_users.password import PasswordHelper + +conn = sqlite3.connect('E:/crowdata/backend/crowdata.db') +cursor = conn.cursor() + +# Hash new password +helper = PasswordHelper() +new_hash = helper.hash('admin123456') +print(f'New hash for admin123456: {new_hash[:50]}...') + +# Update both admin users +cursor.execute('UPDATE users SET hashed_password = ? WHERE email = "admin@crowdata.ar"', (new_hash,)) +cursor.execute('UPDATE users SET hashed_password = ? WHERE email = "crowsistemas@proton.me"', (new_hash,)) +conn.commit() +print('Passwords updated to admin123456') + +# Verify +cursor.execute('SELECT email, hashed_password FROM users WHERE email IN ("admin@crowdata.ar", "crowsistemas@proton.me")') +for row in cursor.fetchall(): + try: + result = helper.verify_and_update('admin123456', row[1]) + print(f'{row[0]} - admin123456: {result}') + except Exception as e: + print(f'{row[0]} - Error: {e}') + +conn.close() \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000000000000000000000000000000000000..7f9cf224a263f493cdcfce88ba63868639cc3f16 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +import uvicorn + +if __name__ == "__main__": + # Importante: Mantener el ProactorEventLoopPolicy por defecto de Windows + # porque Playwright lo requiere para crear subprocesses correctamente. + uvicorn.run("app.main:app", host="127.0.0.1", port=8000)