vektra-erp 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vektra_erp-1.0.0/LICENSE +21 -0
- vektra_erp-1.0.0/PKG-INFO +113 -0
- vektra_erp-1.0.0/README.md +95 -0
- vektra_erp-1.0.0/pyproject.toml +30 -0
- vektra_erp-1.0.0/setup.cfg +4 -0
- vektra_erp-1.0.0/tests/test_cliente.py +78 -0
- vektra_erp-1.0.0/vektra_erp/__init__.py +7 -0
- vektra_erp-1.0.0/vektra_erp/cliente.py +160 -0
- vektra_erp-1.0.0/vektra_erp/errores.py +83 -0
- vektra_erp-1.0.0/vektra_erp/operaciones.py +690 -0
- vektra_erp-1.0.0/vektra_erp/py.typed +0 -0
- vektra_erp-1.0.0/vektra_erp/tipos.py +620 -0
- vektra_erp-1.0.0/vektra_erp.egg-info/PKG-INFO +113 -0
- vektra_erp-1.0.0/vektra_erp.egg-info/SOURCES.txt +14 -0
- vektra_erp-1.0.0/vektra_erp.egg-info/dependency_links.txt +1 -0
- vektra_erp-1.0.0/vektra_erp.egg-info/top_level.txt +1 -0
vektra_erp-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Vektra Digital Solutions S.A.S.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vektra-erp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK de Python de la API v1 de Vektra ERP (facturación electrónica con ARCA), generado desde el OpenAPI.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://vektra.digital/api-factura-electronica-arca
|
|
7
|
+
Keywords: vektra,erp,arca,afip,factura-electronica,api,sdk
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Natural Language :: Spanish
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# SDK de Python — API v1 de Vektra ERP
|
|
20
|
+
|
|
21
|
+
Cliente de la API v1 generado desde el OpenAPI (`/api/v1/openapi.json`), con type hints (`TypedDict` para los cuerpos). **Sin dependencias**: usa `urllib` de la biblioteca estándar. Python 3.9 o más.
|
|
22
|
+
|
|
23
|
+
> **Estado:** vive en el repositorio (`sdk/python/`); todavía no está publicado en PyPI. Para usarlo, copiá la carpeta `vektra_erp/` a tu proyecto o instalalo desde el repo (`pip install ./sdk/python`).
|
|
24
|
+
> `tipos.py` y `operaciones.py` son **generados**: no se editan, se regeneran con `npm run sdk:generar` (desde la raíz del repo). El resto (`cliente.py`, `errores.py`) es código a mano.
|
|
25
|
+
|
|
26
|
+
## Empezar
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from vektra_erp import ClienteVektra
|
|
31
|
+
|
|
32
|
+
vektra = ClienteVektra(
|
|
33
|
+
clave=os.environ["VEKTRA_CLAVE"], # erp_... (producción) o erp_test_... (sandbox)
|
|
34
|
+
base_url="https://tuempresa.erp.vektra.digital", # la dirección de tu instalación
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Producción y sandbox usan **la misma dirección**; lo que cambia es la clave. Con una clave `erp_test_...` (`vektra.es_sandbox`) las facturas son simuladas: nada llega a ARCA ni a tu contabilidad.
|
|
39
|
+
|
|
40
|
+
Opciones: `max_reintentos` (429; 2 por defecto), `espera_maxima` (60 s), `timeout` (30 s).
|
|
41
|
+
|
|
42
|
+
## Emitir una factura (con idempotencia)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from vektra_erp import nueva_idempotency_key
|
|
46
|
+
|
|
47
|
+
# Guardá la clave junto a tu pedido: si se corta, repetís con la MISMA clave y no se factura dos veces.
|
|
48
|
+
idempotency_key = nueva_idempotency_key()
|
|
49
|
+
|
|
50
|
+
factura = vektra.emitir_factura(
|
|
51
|
+
{
|
|
52
|
+
"referencia": "pedido-1001",
|
|
53
|
+
"cliente": {"documento": "30715974823", "razonSocial": "Cliente de ejemplo S.A.", "condicionIva": 1},
|
|
54
|
+
"puntoVenta": 1,
|
|
55
|
+
"renglones": [{"descripcion": "Servicio mensual", "cantidad": 1, "precioUnitario": 1000, "iva": 21}],
|
|
56
|
+
},
|
|
57
|
+
idempotency_key=idempotency_key,
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Repetir el pedido con la misma `Idempotency-Key` y el mismo cuerpo devuelve la misma factura; con otro cuerpo falla con `clave_reutilizada`.
|
|
62
|
+
|
|
63
|
+
## Errores
|
|
64
|
+
|
|
65
|
+
Todo error de la API se lanza como `ErrorApi` (formato estable `{error, code, campo, request_id}`); un corte de red, como `ErrorDeRed`. Ambas heredan de `ErrorVektra`.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from vektra_erp import ErrorApi, ErrorDeRed
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
|
|
72
|
+
except ErrorApi as e:
|
|
73
|
+
print(e.status, e.code, e.message, e.campo, e.request_id) # 422 validacion «...» renglones.0.iva req_...
|
|
74
|
+
except ErrorDeRed:
|
|
75
|
+
# No sabés si el servidor llegó a procesar el pedido: repetí con la misma idempotency_key.
|
|
76
|
+
raise
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 422 con `pendiente: true` (ARCA no confirmó)
|
|
80
|
+
|
|
81
|
+
Si ARCA no contesta a tiempo, la API responde 422 `pendiente_arca` con `pendiente: true`: el comprobante **puede haberse autorizado igual**. No lo repitas con otra clave ni sin `referencia`: usá la misma `idempotency_key` o consultá la factura.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
try:
|
|
85
|
+
vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
|
|
86
|
+
except ErrorApi as e:
|
|
87
|
+
if e.pendiente:
|
|
88
|
+
# e.datos trae el comprobante tal como quedó (con su id); e.request_id sirve para soporte.
|
|
89
|
+
quedo = vektra.listar_facturas(referencia="pedido-1001")
|
|
90
|
+
else:
|
|
91
|
+
raise
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Reintentos ante 429
|
|
95
|
+
|
|
96
|
+
Un 429 (`limite_pedidos`) se reintenta solo, esperando los segundos de `Retry-After`. Si pide esperar más de `espera_maxima`, o se agotan los reintentos, se lanza `ErrorApi` (`e.retry_after` trae los segundos). El 503 `servicio_no_disponible` no se reintenta solo.
|
|
97
|
+
|
|
98
|
+
## Más ejemplos
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
pagina = vektra.listar_facturas(desde="2026-01-01", hasta="2026-01-31", estado="autorizado", limit=50)
|
|
102
|
+
pdf = vektra.descargar_pdf_factura(factura["id"]) # bytes
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Los métodos van en snake_case (`listar_clientes`, `crear_punto_venta`, `obtener_cotizacion`...). Los parámetros de ruta son posicionales, el cuerpo va después y los filtros de consulta, por nombre. Los objetos anidados sin nombre en el OpenAPI se tipan como `Dict[str, Any]`.
|
|
106
|
+
|
|
107
|
+
## Pruebas
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cd sdk/python && python3 -m unittest discover -s tests -t .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`src/app/api/v1/sdk.test.ts` (Vitest) falla si lo generado no coincide con el OpenAPI actual y corre estas pruebas si hay `python3`.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SDK de Python — API v1 de Vektra ERP
|
|
2
|
+
|
|
3
|
+
Cliente de la API v1 generado desde el OpenAPI (`/api/v1/openapi.json`), con type hints (`TypedDict` para los cuerpos). **Sin dependencias**: usa `urllib` de la biblioteca estándar. Python 3.9 o más.
|
|
4
|
+
|
|
5
|
+
> **Estado:** vive en el repositorio (`sdk/python/`); todavía no está publicado en PyPI. Para usarlo, copiá la carpeta `vektra_erp/` a tu proyecto o instalalo desde el repo (`pip install ./sdk/python`).
|
|
6
|
+
> `tipos.py` y `operaciones.py` son **generados**: no se editan, se regeneran con `npm run sdk:generar` (desde la raíz del repo). El resto (`cliente.py`, `errores.py`) es código a mano.
|
|
7
|
+
|
|
8
|
+
## Empezar
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import os
|
|
12
|
+
from vektra_erp import ClienteVektra
|
|
13
|
+
|
|
14
|
+
vektra = ClienteVektra(
|
|
15
|
+
clave=os.environ["VEKTRA_CLAVE"], # erp_... (producción) o erp_test_... (sandbox)
|
|
16
|
+
base_url="https://tuempresa.erp.vektra.digital", # la dirección de tu instalación
|
|
17
|
+
)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Producción y sandbox usan **la misma dirección**; lo que cambia es la clave. Con una clave `erp_test_...` (`vektra.es_sandbox`) las facturas son simuladas: nada llega a ARCA ni a tu contabilidad.
|
|
21
|
+
|
|
22
|
+
Opciones: `max_reintentos` (429; 2 por defecto), `espera_maxima` (60 s), `timeout` (30 s).
|
|
23
|
+
|
|
24
|
+
## Emitir una factura (con idempotencia)
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from vektra_erp import nueva_idempotency_key
|
|
28
|
+
|
|
29
|
+
# Guardá la clave junto a tu pedido: si se corta, repetís con la MISMA clave y no se factura dos veces.
|
|
30
|
+
idempotency_key = nueva_idempotency_key()
|
|
31
|
+
|
|
32
|
+
factura = vektra.emitir_factura(
|
|
33
|
+
{
|
|
34
|
+
"referencia": "pedido-1001",
|
|
35
|
+
"cliente": {"documento": "30715974823", "razonSocial": "Cliente de ejemplo S.A.", "condicionIva": 1},
|
|
36
|
+
"puntoVenta": 1,
|
|
37
|
+
"renglones": [{"descripcion": "Servicio mensual", "cantidad": 1, "precioUnitario": 1000, "iva": 21}],
|
|
38
|
+
},
|
|
39
|
+
idempotency_key=idempotency_key,
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Repetir el pedido con la misma `Idempotency-Key` y el mismo cuerpo devuelve la misma factura; con otro cuerpo falla con `clave_reutilizada`.
|
|
44
|
+
|
|
45
|
+
## Errores
|
|
46
|
+
|
|
47
|
+
Todo error de la API se lanza como `ErrorApi` (formato estable `{error, code, campo, request_id}`); un corte de red, como `ErrorDeRed`. Ambas heredan de `ErrorVektra`.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from vektra_erp import ErrorApi, ErrorDeRed
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
|
|
54
|
+
except ErrorApi as e:
|
|
55
|
+
print(e.status, e.code, e.message, e.campo, e.request_id) # 422 validacion «...» renglones.0.iva req_...
|
|
56
|
+
except ErrorDeRed:
|
|
57
|
+
# No sabés si el servidor llegó a procesar el pedido: repetí con la misma idempotency_key.
|
|
58
|
+
raise
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 422 con `pendiente: true` (ARCA no confirmó)
|
|
62
|
+
|
|
63
|
+
Si ARCA no contesta a tiempo, la API responde 422 `pendiente_arca` con `pendiente: true`: el comprobante **puede haberse autorizado igual**. No lo repitas con otra clave ni sin `referencia`: usá la misma `idempotency_key` o consultá la factura.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
try:
|
|
67
|
+
vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
|
|
68
|
+
except ErrorApi as e:
|
|
69
|
+
if e.pendiente:
|
|
70
|
+
# e.datos trae el comprobante tal como quedó (con su id); e.request_id sirve para soporte.
|
|
71
|
+
quedo = vektra.listar_facturas(referencia="pedido-1001")
|
|
72
|
+
else:
|
|
73
|
+
raise
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Reintentos ante 429
|
|
77
|
+
|
|
78
|
+
Un 429 (`limite_pedidos`) se reintenta solo, esperando los segundos de `Retry-After`. Si pide esperar más de `espera_maxima`, o se agotan los reintentos, se lanza `ErrorApi` (`e.retry_after` trae los segundos). El 503 `servicio_no_disponible` no se reintenta solo.
|
|
79
|
+
|
|
80
|
+
## Más ejemplos
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
pagina = vektra.listar_facturas(desde="2026-01-01", hasta="2026-01-31", estado="autorizado", limit=50)
|
|
84
|
+
pdf = vektra.descargar_pdf_factura(factura["id"]) # bytes
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Los métodos van en snake_case (`listar_clientes`, `crear_punto_venta`, `obtener_cotizacion`...). Los parámetros de ruta son posicionales, el cuerpo va después y los filtros de consulta, por nombre. Los objetos anidados sin nombre en el OpenAPI se tipan como `Dict[str, Any]`.
|
|
88
|
+
|
|
89
|
+
## Pruebas
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd sdk/python && python3 -m unittest discover -s tests -t .
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`src/app/api/v1/sdk.test.ts` (Vitest) falla si lo generado no coincide con el OpenAPI actual y corre estas pruebas si hay `python3`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vektra-erp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SDK de Python de la API v1 de Vektra ERP (facturación electrónica con ARCA), generado desde el OpenAPI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["vektra", "erp", "arca", "afip", "factura-electronica", "api", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Natural Language :: Spanish",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://vektra.digital/api-factura-electronica-arca"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
packages = ["vektra_erp"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-data]
|
|
30
|
+
vektra_erp = ["py.typed"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Pruebas del SDK de Python con un transporte simulado (sin red). Se corren con: python -m unittest discover -s tests"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from vektra_erp import ClienteVektra, ErrorApi
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def respuesta(status, cuerpo=None, **cabeceras):
|
|
10
|
+
datos = b"" if cuerpo is None else json.dumps(cuerpo).encode("utf-8")
|
|
11
|
+
return status, {k.lower().replace("_", "-"): str(v) for k, v in cabeceras.items()}, datos
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Simulado:
|
|
15
|
+
def __init__(self, *respuestas):
|
|
16
|
+
self.respuestas = list(respuestas)
|
|
17
|
+
self.pedidos = []
|
|
18
|
+
|
|
19
|
+
def __call__(self, metodo, url, cabeceras, cuerpo, timeout):
|
|
20
|
+
self.pedidos.append((metodo, url, cabeceras, cuerpo))
|
|
21
|
+
return self.respuestas.pop(0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cliente(transporte, **kw):
|
|
25
|
+
return ClienteVektra("erp_abc_secreto", "https://erp.ejemplo.com/", transporte=transporte, dormir=lambda s: None, **kw)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PruebasCliente(unittest.TestCase):
|
|
29
|
+
def test_emite_una_factura_con_idempotency_key(self):
|
|
30
|
+
t = Simulado(respuesta(201, {"id": "f1", "estado": "autorizado"}))
|
|
31
|
+
f = cliente(t).emitir_factura({"cliente": {"documento": "30715974823"}, "renglones": []}, idempotency_key="k-1")
|
|
32
|
+
self.assertEqual(f["id"], "f1")
|
|
33
|
+
metodo, url, cab, cuerpo = t.pedidos[0]
|
|
34
|
+
self.assertEqual((metodo, url), ("POST", "https://erp.ejemplo.com/api/v1/facturas"))
|
|
35
|
+
self.assertEqual(cab["Authorization"], "Bearer erp_abc_secreto")
|
|
36
|
+
self.assertEqual(cab["Idempotency-Key"], "k-1")
|
|
37
|
+
self.assertEqual(json.loads(cuerpo)["cliente"]["documento"], "30715974823")
|
|
38
|
+
|
|
39
|
+
def test_consulta_y_ruta(self):
|
|
40
|
+
t = Simulado(respuesta(200, {"paging": {}, "resultados": []}), respuesta(200, {"id": "x"}))
|
|
41
|
+
c = cliente(t)
|
|
42
|
+
c.listar_facturas(punto_venta=2, estado="autorizado", cae=None)
|
|
43
|
+
c.obtener_factura("a b")
|
|
44
|
+
self.assertEqual(t.pedidos[0][1], "https://erp.ejemplo.com/api/v1/facturas?estado=autorizado&puntoVenta=2")
|
|
45
|
+
self.assertTrue(t.pedidos[1][1].endswith("/api/v1/facturas/a%20b"))
|
|
46
|
+
|
|
47
|
+
def test_422_pendiente(self):
|
|
48
|
+
cuerpo = {"error": "ARCA no confirmó", "code": "pendiente_arca", "request_id": "req_1", "pendiente": True, "id": "f9"}
|
|
49
|
+
with self.assertRaises(ErrorApi) as ctx:
|
|
50
|
+
cliente(Simulado(respuesta(422, cuerpo))).emitir_factura({"cliente": {}, "renglones": []})
|
|
51
|
+
e = ctx.exception
|
|
52
|
+
self.assertEqual((e.status, e.code, e.request_id, e.pendiente), (422, "pendiente_arca", "req_1", True))
|
|
53
|
+
self.assertEqual(e.datos["id"], "f9")
|
|
54
|
+
|
|
55
|
+
def test_reintenta_el_429_con_retry_after(self):
|
|
56
|
+
esperas = []
|
|
57
|
+
t = Simulado(respuesta(429, {"error": "x", "code": "limite_pedidos"}, Retry_After=3), respuesta(200, {"paging": {}}))
|
|
58
|
+
c = ClienteVektra("erp_abc_x", "https://e.com", transporte=t, dormir=esperas.append)
|
|
59
|
+
c.listar_facturas()
|
|
60
|
+
self.assertEqual((len(t.pedidos), esperas), (2, [3.0]))
|
|
61
|
+
|
|
62
|
+
def test_429_sin_reintentos_restantes_falla(self):
|
|
63
|
+
t = Simulado(*[respuesta(429, {"error": "x", "code": "limite_pedidos"}, Retry_After=1)] * 3)
|
|
64
|
+
with self.assertRaises(ErrorApi) as ctx:
|
|
65
|
+
cliente(t).listar_facturas()
|
|
66
|
+
self.assertEqual((ctx.exception.code, len(t.pedidos)), ("limite_pedidos", 3))
|
|
67
|
+
|
|
68
|
+
def test_sandbox(self):
|
|
69
|
+
self.assertTrue(ClienteVektra("erp_test_abc_x", "https://e.com").es_sandbox)
|
|
70
|
+
self.assertFalse(ClienteVektra("erp_abc_x", "https://e.com").es_sandbox)
|
|
71
|
+
|
|
72
|
+
def test_pdf_devuelve_bytes(self):
|
|
73
|
+
t = Simulado((200, {}, b"%PDF-1.4"))
|
|
74
|
+
self.assertEqual(cliente(t).descargar_pdf_factura("f1"), b"%PDF-1.4")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
unittest.main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""SDK de Python de la API v1 de Vektra ERP (generado desde el OpenAPI)."""
|
|
2
|
+
|
|
3
|
+
from . import tipos
|
|
4
|
+
from .cliente import ClienteVektra, nueva_idempotency_key
|
|
5
|
+
from .errores import ErrorApi, ErrorDeRed, ErrorVektra
|
|
6
|
+
|
|
7
|
+
__all__ = ["ClienteVektra", "ErrorApi", "ErrorDeRed", "ErrorVektra", "nueva_idempotency_key", "tipos"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Cliente HTTP de la API v1 de Vektra ERP, sobre ``urllib`` (sin dependencias)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
import uuid
|
|
11
|
+
from email.utils import parsedate_to_datetime
|
|
12
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .errores import ErrorApi, ErrorDeRed
|
|
15
|
+
from .operaciones import OperacionesApi
|
|
16
|
+
|
|
17
|
+
PREFIJO_SANDBOX = "erp_test_"
|
|
18
|
+
|
|
19
|
+
#: ``(status, cabeceras, cuerpo)``; las cabeceras con las claves en minúscula.
|
|
20
|
+
Respuesta = Tuple[int, Dict[str, str], bytes]
|
|
21
|
+
#: ``(metodo, url, cabeceras, cuerpo, timeout) -> Respuesta``. Se puede reemplazar (pruebas, proxies).
|
|
22
|
+
Transporte = Callable[[str, str, Dict[str, str], Optional[bytes], Optional[float]], Respuesta]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def nueva_idempotency_key() -> str:
|
|
26
|
+
"""Una ``Idempotency-Key`` nueva (UUID v4). Guardala junto al pedido para poder repetirlo sin duplicarlo."""
|
|
27
|
+
return str(uuid.uuid4())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _transporte_urllib(
|
|
31
|
+
metodo: str, url: str, cabeceras: Dict[str, str], cuerpo: Optional[bytes], timeout: Optional[float]
|
|
32
|
+
) -> Respuesta:
|
|
33
|
+
pedido = urllib.request.Request(url, data=cuerpo, headers=cabeceras, method=metodo)
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(pedido, timeout=timeout) as r: # noqa: S310 (la URL la arma el cliente con http/https)
|
|
36
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
37
|
+
except urllib.error.HTTPError as e:
|
|
38
|
+
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
|
39
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
40
|
+
raise ErrorDeRed(f"No se pudo completar el pedido {metodo} {url}: {e}") from e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _leer_retry_after(valor: Optional[str]) -> Optional[float]:
|
|
44
|
+
"""Segundos de un ``Retry-After`` (número o fecha HTTP), o ``None`` si no vino o no se entiende."""
|
|
45
|
+
if not valor:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
segundos = float(valor)
|
|
49
|
+
return segundos if segundos >= 0 else None
|
|
50
|
+
except ValueError:
|
|
51
|
+
pass
|
|
52
|
+
try:
|
|
53
|
+
return max(0.0, parsedate_to_datetime(valor).timestamp() - time.time())
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ClienteVektra(OperacionesApi):
|
|
59
|
+
"""Cliente de la API v1. Los métodos (``emitir_factura``, ``listar_clientes``...) salen del OpenAPI.
|
|
60
|
+
|
|
61
|
+
Los errores de la API se lanzan como :class:`ErrorApi`; un 429 se reintenta solo, esperando lo que pide ``Retry-After``.
|
|
62
|
+
|
|
63
|
+
:param clave: la clave de la API (``erp_...`` de producción, ``erp_test_...`` de sandbox).
|
|
64
|
+
:param base_url: la dirección de tu instalación. Producción y sandbox usan la misma.
|
|
65
|
+
:param max_reintentos: reintentos ante 429 (por defecto 2; 0 los desactiva).
|
|
66
|
+
:param espera_maxima: tope de la espera entre reintentos, en segundos; si el servidor pide más, falla con el 429.
|
|
67
|
+
:param timeout: tiempo máximo de cada pedido, en segundos (``None``: sin límite).
|
|
68
|
+
:param transporte: reemplaza el envío HTTP (pruebas).
|
|
69
|
+
:param dormir: reemplaza la espera (pruebas).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
clave: str,
|
|
75
|
+
base_url: str,
|
|
76
|
+
*,
|
|
77
|
+
max_reintentos: int = 2,
|
|
78
|
+
espera_maxima: float = 60.0,
|
|
79
|
+
timeout: Optional[float] = 30.0,
|
|
80
|
+
transporte: Optional[Transporte] = None,
|
|
81
|
+
dormir: Callable[[float], None] = time.sleep,
|
|
82
|
+
) -> None:
|
|
83
|
+
if not clave:
|
|
84
|
+
raise ValueError("Falta la clave de la API (erp_... o erp_test_...).")
|
|
85
|
+
if not base_url.startswith(("http://", "https://")):
|
|
86
|
+
raise ValueError("base_url tiene que empezar con http:// o https://.")
|
|
87
|
+
self._clave = clave
|
|
88
|
+
self.base_url = base_url.rstrip("/")
|
|
89
|
+
#: La clave es de prueba: las facturas son simuladas, nada llega a ARCA ni a la contabilidad.
|
|
90
|
+
self.es_sandbox = clave.startswith(PREFIJO_SANDBOX)
|
|
91
|
+
self._max_reintentos = max_reintentos
|
|
92
|
+
self._espera_maxima = espera_maxima
|
|
93
|
+
self._timeout = timeout
|
|
94
|
+
self._transporte = transporte or _transporte_urllib
|
|
95
|
+
self._dormir = dormir
|
|
96
|
+
|
|
97
|
+
def _url(self, ruta: str, en_ruta: Optional[Dict[str, Any]], consulta: Optional[Dict[str, Any]]) -> str:
|
|
98
|
+
valores = en_ruta or {}
|
|
99
|
+
for nombre, valor in valores.items():
|
|
100
|
+
if valor is None:
|
|
101
|
+
raise ValueError(f"Falta el parámetro de ruta «{nombre}».")
|
|
102
|
+
ruta = ruta.replace("{" + nombre + "}", urllib.parse.quote(str(valor), safe=""))
|
|
103
|
+
pares = []
|
|
104
|
+
for k, v in (consulta or {}).items():
|
|
105
|
+
for x in v if isinstance(v, (list, tuple)) else [v]:
|
|
106
|
+
if x is not None:
|
|
107
|
+
pares.append((k, "true" if x is True else "false" if x is False else str(x)))
|
|
108
|
+
texto = urllib.parse.urlencode(pares)
|
|
109
|
+
return f"{self.base_url}{ruta}{'?' + texto if texto else ''}"
|
|
110
|
+
|
|
111
|
+
def _pedir(
|
|
112
|
+
self,
|
|
113
|
+
metodo: str,
|
|
114
|
+
ruta: str,
|
|
115
|
+
*,
|
|
116
|
+
ruta_params: Optional[Dict[str, Any]] = None,
|
|
117
|
+
consulta: Optional[Dict[str, Any]] = None,
|
|
118
|
+
cabecera: Optional[Dict[str, Any]] = None,
|
|
119
|
+
cuerpo: Any = None,
|
|
120
|
+
respuesta: str = "json",
|
|
121
|
+
idempotency_key: Optional[str] = None,
|
|
122
|
+
) -> Any:
|
|
123
|
+
cabeceras = {
|
|
124
|
+
"Authorization": f"Bearer {self._clave}",
|
|
125
|
+
"Accept": "*/*" if respuesta == "binario" else "application/json",
|
|
126
|
+
"User-Agent": "vektra-erp-python/1.0",
|
|
127
|
+
}
|
|
128
|
+
for k, v in (cabecera or {}).items():
|
|
129
|
+
if v is not None:
|
|
130
|
+
cabeceras[k] = str(v)
|
|
131
|
+
datos = None if cuerpo is None else json.dumps(cuerpo).encode("utf-8")
|
|
132
|
+
if datos is not None:
|
|
133
|
+
cabeceras["Content-Type"] = "application/json"
|
|
134
|
+
if idempotency_key:
|
|
135
|
+
cabeceras["Idempotency-Key"] = idempotency_key
|
|
136
|
+
url = self._url(ruta, ruta_params, consulta)
|
|
137
|
+
|
|
138
|
+
intento = 0
|
|
139
|
+
while True:
|
|
140
|
+
status, recibidas, bytes_ = self._transporte(metodo, url, cabeceras, datos, self._timeout)
|
|
141
|
+
request_id = recibidas.get("x-request-id")
|
|
142
|
+
retry_after = _leer_retry_after(recibidas.get("retry-after"))
|
|
143
|
+
if status == 429 and intento < self._max_reintentos:
|
|
144
|
+
espera = retry_after if retry_after is not None else float(2**intento)
|
|
145
|
+
if espera <= self._espera_maxima:
|
|
146
|
+
self._dormir(espera)
|
|
147
|
+
intento += 1
|
|
148
|
+
continue
|
|
149
|
+
if status >= 400:
|
|
150
|
+
texto = bytes_.decode("utf-8", errors="replace")
|
|
151
|
+
try:
|
|
152
|
+
cuerpo_error: Any = json.loads(texto) if texto else {}
|
|
153
|
+
except ValueError:
|
|
154
|
+
cuerpo_error = texto
|
|
155
|
+
raise ErrorApi.desde_respuesta(status, cuerpo_error, request_id, retry_after)
|
|
156
|
+
if respuesta == "binario":
|
|
157
|
+
return bytes_
|
|
158
|
+
if respuesta == "vacio" or status == 204 or not bytes_:
|
|
159
|
+
return None
|
|
160
|
+
return json.loads(bytes_.decode("utf-8"))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Errores del SDK.
|
|
2
|
+
|
|
3
|
+
La API responde todo error como ``{ error, code, campo?, request_id }`` (más los datos que la respuesta ya llevaba, por ejemplo la
|
|
4
|
+
factura que quedó pendiente). También se entiende la forma anidada ``{ error: { code, message, field, request_id } }``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ErrorVektra(Exception):
|
|
13
|
+
"""Base de todos los errores del SDK."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ErrorDeRed(ErrorVektra):
|
|
17
|
+
"""No hubo respuesta: corte de red, tiempo agotado o servidor inalcanzable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErrorApi(ErrorVektra):
|
|
21
|
+
"""La API contestó con un error (HTTP 4xx o 5xx)."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
status: int,
|
|
26
|
+
code: str,
|
|
27
|
+
message: str,
|
|
28
|
+
campo: Optional[str] = None,
|
|
29
|
+
request_id: Optional[str] = None,
|
|
30
|
+
retry_after: Optional[float] = None,
|
|
31
|
+
datos: Optional[Dict[str, Any]] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status = status
|
|
35
|
+
#: Código estable para programar contra él (``validacion``, ``limite_pedidos``...).
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
#: El campo del pedido que falló (``cliente.documento``), si corresponde.
|
|
39
|
+
self.campo = campo
|
|
40
|
+
#: El ``X-Request-Id`` del pedido: mandalo a soporte.
|
|
41
|
+
self.request_id = request_id
|
|
42
|
+
#: Segundos que pidió esperar el servidor (``Retry-After``), si mandó.
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
#: El cuerpo completo de la respuesta (con la factura en un 422 ``pendiente_arca``).
|
|
45
|
+
self.datos: Dict[str, Any] = datos or {}
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def pendiente(self) -> bool:
|
|
49
|
+
"""ARCA no confirmó el comprobante (422 ``pendiente_arca``): puede haberse autorizado igual.
|
|
50
|
+
|
|
51
|
+
No lo repitas sin la misma ``referencia`` o ``Idempotency-Key``; ``datos`` trae el comprobante tal como quedó.
|
|
52
|
+
"""
|
|
53
|
+
return self.datos.get("pendiente") is True
|
|
54
|
+
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
extra = f" (campo {self.campo})" if self.campo else ""
|
|
57
|
+
return f"[{self.status} {self.code}] {self.message}{extra}"
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def desde_respuesta(
|
|
61
|
+
cls, status: int, cuerpo: Any, request_id: Optional[str] = None, retry_after: Optional[float] = None
|
|
62
|
+
) -> "ErrorApi":
|
|
63
|
+
"""Arma el error desde el cuerpo ya leído de una respuesta no exitosa."""
|
|
64
|
+
datos = cuerpo if isinstance(cuerpo, dict) else {}
|
|
65
|
+
anidado = datos.get("error") if isinstance(datos.get("error"), dict) else None
|
|
66
|
+
|
|
67
|
+
def texto(valor: Any) -> Optional[str]:
|
|
68
|
+
return valor if isinstance(valor, str) and valor else None
|
|
69
|
+
|
|
70
|
+
mensaje = (
|
|
71
|
+
texto(anidado.get("message") if anidado else datos.get("error"))
|
|
72
|
+
or (cuerpo if isinstance(cuerpo, str) and cuerpo else None)
|
|
73
|
+
or f"HTTP {status}"
|
|
74
|
+
)
|
|
75
|
+
return cls(
|
|
76
|
+
status=status,
|
|
77
|
+
code=texto(anidado.get("code") if anidado else datos.get("code")) or "error_interno",
|
|
78
|
+
message=mensaje,
|
|
79
|
+
campo=texto(anidado.get("field") if anidado else datos.get("campo")),
|
|
80
|
+
request_id=texto(anidado.get("request_id") if anidado else datos.get("request_id")) or request_id,
|
|
81
|
+
retry_after=retry_after,
|
|
82
|
+
datos=datos,
|
|
83
|
+
)
|