catalogmx 0.3.0__py3-none-any.whl
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.
- catalogmx/__init__.py +56 -0
- catalogmx/catalogs/__init__.py +5 -0
- catalogmx/catalogs/banxico/__init__.py +24 -0
- catalogmx/catalogs/banxico/banks.py +136 -0
- catalogmx/catalogs/banxico/codigos_plaza.py +287 -0
- catalogmx/catalogs/banxico/instituciones_financieras.py +338 -0
- catalogmx/catalogs/banxico/monedas_divisas.py +386 -0
- catalogmx/catalogs/banxico/udis.py +279 -0
- catalogmx/catalogs/ift/__init__.py +15 -0
- catalogmx/catalogs/ift/codigos_lada.py +426 -0
- catalogmx/catalogs/ift/operadores_moviles.py +315 -0
- catalogmx/catalogs/inegi/__init__.py +21 -0
- catalogmx/catalogs/inegi/localidades.py +207 -0
- catalogmx/catalogs/inegi/municipios.py +73 -0
- catalogmx/catalogs/inegi/municipios_completo.py +236 -0
- catalogmx/catalogs/inegi/states.py +148 -0
- catalogmx/catalogs/mexico/__init__.py +17 -0
- catalogmx/catalogs/mexico/hoy_no_circula.py +215 -0
- catalogmx/catalogs/mexico/placas_formatos.py +184 -0
- catalogmx/catalogs/mexico/salarios_minimos.py +156 -0
- catalogmx/catalogs/mexico/uma.py +207 -0
- catalogmx/catalogs/sat/__init__.py +13 -0
- catalogmx/catalogs/sat/carta_porte/__init__.py +19 -0
- catalogmx/catalogs/sat/carta_porte/aeropuertos.py +76 -0
- catalogmx/catalogs/sat/carta_porte/carreteras.py +59 -0
- catalogmx/catalogs/sat/carta_porte/config_autotransporte.py +54 -0
- catalogmx/catalogs/sat/carta_porte/material_peligroso.py +66 -0
- catalogmx/catalogs/sat/carta_porte/puertos_maritimos.py +63 -0
- catalogmx/catalogs/sat/carta_porte/tipo_embalaje.py +48 -0
- catalogmx/catalogs/sat/carta_porte/tipo_permiso.py +54 -0
- catalogmx/catalogs/sat/cfdi_4/__init__.py +42 -0
- catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +383 -0
- catalogmx/catalogs/sat/cfdi_4/clave_unidad.py +298 -0
- catalogmx/catalogs/sat/cfdi_4/exportacion.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/forma_pago.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/impuesto.py +57 -0
- catalogmx/catalogs/sat/cfdi_4/meses.py +34 -0
- catalogmx/catalogs/sat/cfdi_4/metodo_pago.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/objeto_imp.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/periodicidad.py +34 -0
- catalogmx/catalogs/sat/cfdi_4/regimen_fiscal.py +57 -0
- catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +42 -0
- catalogmx/catalogs/sat/cfdi_4/tipo_comprobante.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/tipo_factor.py +34 -0
- catalogmx/catalogs/sat/cfdi_4/tipo_relacion.py +45 -0
- catalogmx/catalogs/sat/cfdi_4/uso_cfdi.py +45 -0
- catalogmx/catalogs/sat/comercio_exterior/__init__.py +39 -0
- catalogmx/catalogs/sat/comercio_exterior/claves_pedimento.py +77 -0
- catalogmx/catalogs/sat/comercio_exterior/estados.py +122 -0
- catalogmx/catalogs/sat/comercio_exterior/incoterms.py +226 -0
- catalogmx/catalogs/sat/comercio_exterior/monedas.py +107 -0
- catalogmx/catalogs/sat/comercio_exterior/motivos_traslado.py +54 -0
- catalogmx/catalogs/sat/comercio_exterior/paises.py +88 -0
- catalogmx/catalogs/sat/comercio_exterior/registro_ident_trib.py +76 -0
- catalogmx/catalogs/sat/comercio_exterior/unidades_aduana.py +54 -0
- catalogmx/catalogs/sat/comercio_exterior/validator.py +212 -0
- catalogmx/catalogs/sat/nomina/__init__.py +19 -0
- catalogmx/catalogs/sat/nomina/banco.py +50 -0
- catalogmx/catalogs/sat/nomina/periodicidad_pago.py +48 -0
- catalogmx/catalogs/sat/nomina/riesgo_puesto.py +56 -0
- catalogmx/catalogs/sat/nomina/tipo_contrato.py +47 -0
- catalogmx/catalogs/sat/nomina/tipo_jornada.py +42 -0
- catalogmx/catalogs/sat/nomina/tipo_nomina.py +52 -0
- catalogmx/catalogs/sat/nomina/tipo_regimen.py +47 -0
- catalogmx/catalogs/sepomex/__init__.py +5 -0
- catalogmx/catalogs/sepomex/codigos_postales.py +184 -0
- catalogmx/cli.py +185 -0
- catalogmx/helpers.py +324 -0
- catalogmx/utils/text.py +55 -0
- catalogmx/validators/__init__.py +0 -0
- catalogmx/validators/clabe.py +233 -0
- catalogmx/validators/curp.py +623 -0
- catalogmx/validators/nss.py +255 -0
- catalogmx/validators/rfc.py +1004 -0
- catalogmx-0.3.0.dist-info/METADATA +644 -0
- catalogmx-0.3.0.dist-info/RECORD +81 -0
- catalogmx-0.3.0.dist-info/WHEEL +5 -0
- catalogmx-0.3.0.dist-info/entry_points.txt +2 -0
- catalogmx-0.3.0.dist-info/licenses/AUTHORS.rst +5 -0
- catalogmx-0.3.0.dist-info/licenses/LICENSE +19 -0
- catalogmx-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SAT CFDI 4.0 - Clave de Producto o Servicio (c_ClaveProdServ)
|
|
3
|
+
|
|
4
|
+
Catálogo de claves de productos y servicios para facturación CFDI.
|
|
5
|
+
Contiene ~52,000 códigos oficiales basados en UNSPSC (United Nations
|
|
6
|
+
Standard Products and Services Code).
|
|
7
|
+
|
|
8
|
+
Este módulo usa SQLite con FTS5 para búsqueda eficiente de texto completo.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypedDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClaveProdServ(TypedDict):
|
|
17
|
+
"""Estructura de una clave de producto/servicio"""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
descripcion: str
|
|
21
|
+
incluirIVATrasladado: str
|
|
22
|
+
incluirIEPSTrasladado: str
|
|
23
|
+
complementoQueDebeIncluir: str
|
|
24
|
+
palabrasSimilares: str
|
|
25
|
+
fechaInicioVigencia: str
|
|
26
|
+
fechaFinVigencia: str
|
|
27
|
+
estimuloFranjaFronteriza: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClaveProdServCatalog:
|
|
31
|
+
"""
|
|
32
|
+
Catálogo de claves de productos y servicios SAT CFDI 4.0.
|
|
33
|
+
|
|
34
|
+
Implementación basada en SQLite con búsqueda FTS5 para alto rendimiento.
|
|
35
|
+
|
|
36
|
+
Características:
|
|
37
|
+
- ~52,000 códigos de productos y servicios
|
|
38
|
+
- Basado en estándar UNSPSC (ONU)
|
|
39
|
+
- Búsqueda full-text con FTS5
|
|
40
|
+
- Estructura jerárquica (segmento → familia → clase → producto)
|
|
41
|
+
- Indicadores de IVA e IEPS
|
|
42
|
+
|
|
43
|
+
WARNING: Este es un catálogo grande. Use search() o get_by_prefix()
|
|
44
|
+
en lugar de get_all() para mejor rendimiento.
|
|
45
|
+
|
|
46
|
+
Ejemplo:
|
|
47
|
+
>>> from catalogmx.catalogs.sat.cfdi_4 import ClaveProdServCatalog
|
|
48
|
+
>>>
|
|
49
|
+
>>> # Buscar productos
|
|
50
|
+
>>> results = ClaveProdServCatalog.search("computadora", limit=10)
|
|
51
|
+
>>> for item in results:
|
|
52
|
+
... print(f"{item['id']}: {item['descripcion']}")
|
|
53
|
+
>>>
|
|
54
|
+
>>> # Obtener por clave exacta
|
|
55
|
+
>>> producto = ClaveProdServCatalog.get_clave("43211500")
|
|
56
|
+
>>> print(producto['descripcion'])
|
|
57
|
+
>>>
|
|
58
|
+
>>> # Buscar por prefijo (navegación jerárquica)
|
|
59
|
+
>>> familia = ClaveProdServCatalog.get_by_prefix("4321", limit=50)
|
|
60
|
+
>>> print(f"Productos en familia 4321: {len(familia)}")
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_db_path: Path | None = None
|
|
64
|
+
_connection: sqlite3.Connection | None = None
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _get_db_path(cls) -> Path:
|
|
68
|
+
"""Obtiene la ruta a la base de datos SQLite"""
|
|
69
|
+
if cls._db_path is None:
|
|
70
|
+
# Path: catalogmx/packages/python/catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py
|
|
71
|
+
# Target: catalogmx/packages/shared-data/sqlite/clave_prod_serv.db
|
|
72
|
+
cls._db_path = (
|
|
73
|
+
Path(__file__).parent.parent.parent.parent.parent.parent
|
|
74
|
+
/ "shared-data"
|
|
75
|
+
/ "sqlite"
|
|
76
|
+
/ "clave_prod_serv.db"
|
|
77
|
+
)
|
|
78
|
+
return cls._db_path
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def _get_connection(cls) -> sqlite3.Connection:
|
|
82
|
+
"""Obtiene conexión a la base de datos (singleton)"""
|
|
83
|
+
if cls._connection is None:
|
|
84
|
+
db_path = cls._get_db_path()
|
|
85
|
+
if not db_path.exists():
|
|
86
|
+
raise FileNotFoundError(
|
|
87
|
+
f"Database not found at {db_path}. "
|
|
88
|
+
"Please ensure the clave_prod_serv.db file exists."
|
|
89
|
+
)
|
|
90
|
+
cls._connection = sqlite3.connect(str(db_path))
|
|
91
|
+
cls._connection.row_factory = sqlite3.Row
|
|
92
|
+
return cls._connection
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def _row_to_clave(cls, row: sqlite3.Row) -> ClaveProdServ:
|
|
96
|
+
"""Convierte una fila de SQLite a ClaveProdServ"""
|
|
97
|
+
return {
|
|
98
|
+
"id": row["clave"],
|
|
99
|
+
"descripcion": row["descripcion"],
|
|
100
|
+
"incluirIVATrasladado": "Sí" if row["incluye_iva"] == 1 else "No",
|
|
101
|
+
"incluirIEPSTrasladado": "Sí" if row["incluye_ieps"] == 1 else "No",
|
|
102
|
+
"complementoQueDebeIncluir": row["complemento"] or "",
|
|
103
|
+
"palabrasSimilares": row["palabras_similares"] or "",
|
|
104
|
+
"fechaInicioVigencia": row["fecha_inicio_vigencia"] or "",
|
|
105
|
+
"fechaFinVigencia": row["fecha_fin_vigencia"] or "",
|
|
106
|
+
"estimuloFranjaFronteriza": "",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def get_all(cls) -> list[ClaveProdServ]:
|
|
111
|
+
"""
|
|
112
|
+
Obtiene todas las claves de productos/servicios.
|
|
113
|
+
|
|
114
|
+
WARNING: Retorna ~52,000 productos/servicios. Para mejor rendimiento,
|
|
115
|
+
use search() o get_by_prefix() en su lugar.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Lista completa de productos/servicios
|
|
119
|
+
|
|
120
|
+
Ejemplo:
|
|
121
|
+
>>> all_claves = ClaveProdServCatalog.get_all()
|
|
122
|
+
>>> print(f"Total: {len(all_claves)}") # ~52,000
|
|
123
|
+
"""
|
|
124
|
+
conn = cls._get_connection()
|
|
125
|
+
cursor = conn.cursor()
|
|
126
|
+
cursor.execute("SELECT * FROM clave_prod_serv")
|
|
127
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def get_clave(cls, id: str) -> ClaveProdServ | None:
|
|
131
|
+
"""
|
|
132
|
+
Obtiene una clave por su ID.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
id: Clave de 8 dígitos (ej: "10101500", "43211500")
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Producto/servicio o None si no existe
|
|
139
|
+
|
|
140
|
+
Ejemplo:
|
|
141
|
+
>>> producto = ClaveProdServCatalog.get_clave("43211500")
|
|
142
|
+
>>> if producto:
|
|
143
|
+
... print(producto['descripcion'])
|
|
144
|
+
"""
|
|
145
|
+
conn = cls._get_connection()
|
|
146
|
+
cursor = conn.cursor()
|
|
147
|
+
cursor.execute("SELECT * FROM clave_prod_serv WHERE clave = ?", (id,))
|
|
148
|
+
row = cursor.fetchone()
|
|
149
|
+
return cls._row_to_clave(row) if row else None
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def is_valid(cls, id: str) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Verifica si una clave de producto/servicio existe.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
id: Clave de 8 dígitos
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True si existe, False en caso contrario
|
|
161
|
+
|
|
162
|
+
Ejemplo:
|
|
163
|
+
>>> ClaveProdServCatalog.is_valid("43211500") # True
|
|
164
|
+
>>> ClaveProdServCatalog.is_valid("99999999") # False
|
|
165
|
+
"""
|
|
166
|
+
return cls.get_clave(id) is not None
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def search(cls, keyword: str, limit: int = 100) -> list[ClaveProdServ]:
|
|
170
|
+
"""
|
|
171
|
+
Busca productos/servicios usando FTS5 full-text search.
|
|
172
|
+
|
|
173
|
+
Busca en: descripción, complemento y palabras similares.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
keyword: Palabra clave a buscar
|
|
177
|
+
limit: Máximo número de resultados (default: 100)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Lista de productos/servicios que coinciden
|
|
181
|
+
|
|
182
|
+
Ejemplo:
|
|
183
|
+
>>> resultados = ClaveProdServCatalog.search("computadora", limit=20)
|
|
184
|
+
>>> for item in resultados:
|
|
185
|
+
... print(f"{item['id']}: {item['descripcion']}")
|
|
186
|
+
"""
|
|
187
|
+
conn = cls._get_connection()
|
|
188
|
+
cursor = conn.cursor()
|
|
189
|
+
|
|
190
|
+
# Use FTS5 for fast full-text search
|
|
191
|
+
query = """
|
|
192
|
+
SELECT cps.*
|
|
193
|
+
FROM clave_prod_serv_fts fts
|
|
194
|
+
JOIN clave_prod_serv cps ON fts.clave = cps.clave
|
|
195
|
+
WHERE clave_prod_serv_fts MATCH ?
|
|
196
|
+
LIMIT ?
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
cursor.execute(query, (keyword, limit))
|
|
200
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def search_simple(cls, keyword: str, limit: int = 100) -> list[ClaveProdServ]:
|
|
204
|
+
"""
|
|
205
|
+
Búsqueda simple sin FTS5 (fallback o búsqueda parcial).
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
keyword: Palabra clave a buscar en descripción y palabras similares
|
|
209
|
+
limit: Máximo número de resultados (default: 100)
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Lista de productos/servicios que coinciden
|
|
213
|
+
|
|
214
|
+
Ejemplo:
|
|
215
|
+
>>> resultados = ClaveProdServCatalog.search_simple("comput", limit=10)
|
|
216
|
+
"""
|
|
217
|
+
conn = cls._get_connection()
|
|
218
|
+
cursor = conn.cursor()
|
|
219
|
+
|
|
220
|
+
keyword_pattern = f"%{keyword}%"
|
|
221
|
+
query = """
|
|
222
|
+
SELECT * FROM clave_prod_serv
|
|
223
|
+
WHERE descripcion LIKE ? OR palabras_similares LIKE ?
|
|
224
|
+
LIMIT ?
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
cursor.execute(query, (keyword_pattern, keyword_pattern, limit))
|
|
228
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def get_by_prefix(cls, prefix: str, limit: int = 500) -> list[ClaveProdServ]:
|
|
232
|
+
"""
|
|
233
|
+
Obtiene productos/servicios por prefijo de clave.
|
|
234
|
+
|
|
235
|
+
Útil para navegación jerárquica del catálogo UNSPSC:
|
|
236
|
+
- 2 dígitos: Segmento (ej: "43" = Tecnología de información)
|
|
237
|
+
- 4 dígitos: Familia (ej: "4321" = Computadoras)
|
|
238
|
+
- 6 dígitos: Clase (ej: "432115" = Computadoras personales)
|
|
239
|
+
- 8 dígitos: Producto específico
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
prefix: Prefijo de la clave (2, 4, 6 u 8 dígitos)
|
|
243
|
+
limit: Máximo número de resultados (default: 500)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Lista de productos/servicios con ese prefijo
|
|
247
|
+
|
|
248
|
+
Ejemplo:
|
|
249
|
+
>>> # Todos los productos en segmento 43 (TI)
|
|
250
|
+
>>> ti = ClaveProdServCatalog.get_by_prefix("43", limit=100)
|
|
251
|
+
>>>
|
|
252
|
+
>>> # Familia 4321 (Computadoras)
|
|
253
|
+
>>> comps = ClaveProdServCatalog.get_by_prefix("4321", limit=50)
|
|
254
|
+
"""
|
|
255
|
+
conn = cls._get_connection()
|
|
256
|
+
cursor = conn.cursor()
|
|
257
|
+
|
|
258
|
+
query = """
|
|
259
|
+
SELECT * FROM clave_prod_serv
|
|
260
|
+
WHERE clave LIKE ?
|
|
261
|
+
LIMIT ?
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
cursor.execute(query, (f"{prefix}%", limit))
|
|
265
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def get_con_iva(cls, limit: int = 1000) -> list[ClaveProdServ]:
|
|
269
|
+
"""
|
|
270
|
+
Obtiene productos/servicios que incluyen IVA trasladado.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
limit: Máximo número de resultados (default: 1000)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Lista de productos/servicios con IVA
|
|
277
|
+
|
|
278
|
+
Ejemplo:
|
|
279
|
+
>>> con_iva = ClaveProdServCatalog.get_con_iva(limit=100)
|
|
280
|
+
>>> print(f"Productos con IVA: {len(con_iva)}")
|
|
281
|
+
"""
|
|
282
|
+
conn = cls._get_connection()
|
|
283
|
+
cursor = conn.cursor()
|
|
284
|
+
cursor.execute("SELECT * FROM clave_prod_serv WHERE incluye_iva = 1 LIMIT ?", (limit,))
|
|
285
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def get_con_ieps(cls, limit: int = 1000) -> list[ClaveProdServ]:
|
|
289
|
+
"""
|
|
290
|
+
Obtiene productos/servicios que incluyen IEPS trasladado.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
limit: Máximo número de resultados (default: 1000)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Lista de productos/servicios con IEPS
|
|
297
|
+
|
|
298
|
+
Ejemplo:
|
|
299
|
+
>>> con_ieps = ClaveProdServCatalog.get_con_ieps(limit=100)
|
|
300
|
+
>>> print(f"Productos con IEPS: {len(con_ieps)}")
|
|
301
|
+
"""
|
|
302
|
+
conn = cls._get_connection()
|
|
303
|
+
cursor = conn.cursor()
|
|
304
|
+
cursor.execute("SELECT * FROM clave_prod_serv WHERE incluye_ieps = 1 LIMIT ?", (limit,))
|
|
305
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def get_vigentes(cls, limit: int = 10000) -> list[ClaveProdServ]:
|
|
309
|
+
"""
|
|
310
|
+
Obtiene productos/servicios vigentes (sin fecha de fin de vigencia).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
limit: Máximo número de resultados (default: 10000)
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Lista de productos/servicios vigentes
|
|
317
|
+
|
|
318
|
+
Ejemplo:
|
|
319
|
+
>>> vigentes = ClaveProdServCatalog.get_vigentes(limit=100)
|
|
320
|
+
"""
|
|
321
|
+
conn = cls._get_connection()
|
|
322
|
+
cursor = conn.cursor()
|
|
323
|
+
cursor.execute(
|
|
324
|
+
"SELECT * FROM clave_prod_serv WHERE fecha_fin_vigencia IS NULL OR fecha_fin_vigencia = '' LIMIT ?",
|
|
325
|
+
(limit,),
|
|
326
|
+
)
|
|
327
|
+
return [cls._row_to_clave(row) for row in cursor.fetchall()]
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def get_total_count(cls) -> int:
|
|
331
|
+
"""
|
|
332
|
+
Obtiene el total de productos/servicios en el catálogo.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Número total de productos/servicios (~52,000)
|
|
336
|
+
|
|
337
|
+
Ejemplo:
|
|
338
|
+
>>> total = ClaveProdServCatalog.get_total_count()
|
|
339
|
+
>>> print(f"Total productos/servicios: {total:,}")
|
|
340
|
+
"""
|
|
341
|
+
conn = cls._get_connection()
|
|
342
|
+
cursor = conn.cursor()
|
|
343
|
+
cursor.execute("SELECT COUNT(*) FROM clave_prod_serv")
|
|
344
|
+
return cursor.fetchone()[0]
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def get_estadisticas(cls) -> dict[str, int]:
|
|
348
|
+
"""
|
|
349
|
+
Obtiene estadísticas del catálogo.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Diccionario con estadísticas
|
|
353
|
+
|
|
354
|
+
Ejemplo:
|
|
355
|
+
>>> stats = ClaveProdServCatalog.get_estadisticas()
|
|
356
|
+
>>> print(f"Total: {stats['total']:,}")
|
|
357
|
+
>>> print(f"Con IVA: {stats['con_iva']:,}")
|
|
358
|
+
>>> print(f"Con IEPS: {stats['con_ieps']:,}")
|
|
359
|
+
"""
|
|
360
|
+
conn = cls._get_connection()
|
|
361
|
+
cursor = conn.cursor()
|
|
362
|
+
|
|
363
|
+
cursor.execute("SELECT COUNT(*) FROM clave_prod_serv")
|
|
364
|
+
total = cursor.fetchone()[0]
|
|
365
|
+
|
|
366
|
+
cursor.execute("SELECT COUNT(*) FROM clave_prod_serv WHERE incluye_iva = 1")
|
|
367
|
+
con_iva = cursor.fetchone()[0]
|
|
368
|
+
|
|
369
|
+
cursor.execute("SELECT COUNT(*) FROM clave_prod_serv WHERE incluye_ieps = 1")
|
|
370
|
+
con_ieps = cursor.fetchone()[0]
|
|
371
|
+
|
|
372
|
+
cursor.execute(
|
|
373
|
+
"SELECT COUNT(*) FROM clave_prod_serv WHERE fecha_fin_vigencia IS NULL OR fecha_fin_vigencia = ''"
|
|
374
|
+
)
|
|
375
|
+
vigentes = cursor.fetchone()[0]
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"total": total,
|
|
379
|
+
"con_iva": con_iva,
|
|
380
|
+
"con_ieps": con_ieps,
|
|
381
|
+
"vigentes": vigentes,
|
|
382
|
+
"obsoletos": total - vigentes,
|
|
383
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SAT CFDI 4.0 - Clave de Unidad (c_ClaveUnidad)
|
|
3
|
+
|
|
4
|
+
Catálogo de unidades de medida para productos y servicios.
|
|
5
|
+
Contiene ~2,400 unidades oficiales del SAT basadas en las
|
|
6
|
+
recomendaciones 20 y 21 de UN/ECE.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TypedDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClaveUnidad(TypedDict):
|
|
15
|
+
"""Estructura de una unidad de medida"""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
nombre: str
|
|
19
|
+
descripcion: str
|
|
20
|
+
nota: str
|
|
21
|
+
fechaDeInicioDeVigencia: str
|
|
22
|
+
fechaDeFinDeVigencia: str
|
|
23
|
+
simbolo: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClaveUnidadCatalog:
|
|
27
|
+
"""
|
|
28
|
+
Catálogo de claves de unidad SAT CFDI 4.0.
|
|
29
|
+
|
|
30
|
+
Características:
|
|
31
|
+
- ~2,400 unidades de medida oficiales
|
|
32
|
+
- Basado en UN/ECE Recommendation 20 y 21
|
|
33
|
+
- Incluye peso, longitud, volumen, tiempo, piezas, etc.
|
|
34
|
+
- Distingue entre unidades vigentes y obsoletas
|
|
35
|
+
- Búsqueda por ID, nombre, símbolo y categoría
|
|
36
|
+
|
|
37
|
+
Ejemplo:
|
|
38
|
+
>>> from catalogmx.catalogs.sat.cfdi_4 import ClaveUnidadCatalog
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Obtener unidad por ID
|
|
41
|
+
>>> metro = ClaveUnidadCatalog.get_unidad("MTR")
|
|
42
|
+
>>> print(metro['nombre']) # "Metro"
|
|
43
|
+
>>>
|
|
44
|
+
>>> # Buscar por nombre
|
|
45
|
+
>>> kilos = ClaveUnidadCatalog.search_by_name("kilogramo")
|
|
46
|
+
>>> for unidad in kilos:
|
|
47
|
+
... print(f"{unidad['id']}: {unidad['nombre']}")
|
|
48
|
+
>>>
|
|
49
|
+
>>> # Validar unidad
|
|
50
|
+
>>> if ClaveUnidadCatalog.is_valid("KGM"):
|
|
51
|
+
... print("Unidad válida")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_data: list[ClaveUnidad] | None = None
|
|
55
|
+
_by_id: dict[str, ClaveUnidad] | None = None
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def _load_data(cls) -> None:
|
|
59
|
+
"""Carga lazy de datos desde JSON"""
|
|
60
|
+
if cls._data is not None:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Path: catalogmx/packages/python/catalogmx/catalogs/sat/cfdi_4/clave_unidad.py
|
|
64
|
+
# Target: catalogmx/packages/shared-data/sat/cfdi_4.0/clave_unidad.json
|
|
65
|
+
data_path = (
|
|
66
|
+
Path(__file__).parent.parent.parent.parent.parent.parent
|
|
67
|
+
/ "shared-data"
|
|
68
|
+
/ "sat"
|
|
69
|
+
/ "cfdi_4.0"
|
|
70
|
+
/ "clave_unidad.json"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
with open(data_path, encoding="utf-8") as f:
|
|
74
|
+
cls._data = json.load(f)
|
|
75
|
+
|
|
76
|
+
# Crear índice por ID
|
|
77
|
+
cls._by_id = {item["id"]: item for item in cls._data}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def get_all(cls) -> list[ClaveUnidad]:
|
|
81
|
+
"""
|
|
82
|
+
Obtiene todas las unidades.
|
|
83
|
+
|
|
84
|
+
WARNING: Retorna ~2,400 unidades. Considere usar búsqueda o paginación.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Lista completa de unidades
|
|
88
|
+
|
|
89
|
+
Ejemplo:
|
|
90
|
+
>>> unidades = ClaveUnidadCatalog.get_all()
|
|
91
|
+
>>> print(f"Total unidades: {len(unidades)}")
|
|
92
|
+
"""
|
|
93
|
+
cls._load_data()
|
|
94
|
+
return cls._data.copy() # type: ignore
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_unidad(cls, id: str) -> ClaveUnidad | None:
|
|
98
|
+
"""
|
|
99
|
+
Obtiene una unidad por su ID/clave.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
id: Clave de la unidad (ej: "MTR", "KGM", "H87")
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Unidad o None si no existe
|
|
106
|
+
|
|
107
|
+
Ejemplo:
|
|
108
|
+
>>> metro = ClaveUnidadCatalog.get_unidad("MTR")
|
|
109
|
+
>>> print(metro['nombre']) # "Metro"
|
|
110
|
+
>>> print(metro['simbolo']) # "m"
|
|
111
|
+
"""
|
|
112
|
+
cls._load_data()
|
|
113
|
+
return cls._by_id.get(id) # type: ignore
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def is_valid(cls, id: str) -> bool:
|
|
117
|
+
"""
|
|
118
|
+
Verifica si una clave de unidad existe.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
id: Clave de la unidad
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True si existe, False en caso contrario
|
|
125
|
+
|
|
126
|
+
Ejemplo:
|
|
127
|
+
>>> ClaveUnidadCatalog.is_valid("KGM") # True
|
|
128
|
+
>>> ClaveUnidadCatalog.is_valid("INVALID") # False
|
|
129
|
+
"""
|
|
130
|
+
return cls.get_unidad(id) is not None
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def search_by_name(cls, keyword: str) -> list[ClaveUnidad]:
|
|
134
|
+
"""
|
|
135
|
+
Busca unidades por nombre (búsqueda parcial, case-insensitive).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
keyword: Palabra clave a buscar en el nombre
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Lista de unidades que coinciden
|
|
142
|
+
|
|
143
|
+
Ejemplo:
|
|
144
|
+
>>> unidades = ClaveUnidadCatalog.search_by_name("kilogramo")
|
|
145
|
+
>>> for u in unidades:
|
|
146
|
+
... print(f"{u['id']}: {u['nombre']}")
|
|
147
|
+
"""
|
|
148
|
+
cls._load_data()
|
|
149
|
+
keyword_lower = keyword.lower()
|
|
150
|
+
return [u for u in cls._data if keyword_lower in u["nombre"].lower()] # type: ignore
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def search_by_symbol(cls, simbolo: str) -> list[ClaveUnidad]:
|
|
154
|
+
"""
|
|
155
|
+
Busca unidades por símbolo (ej: "kg", "m", "l").
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
simbolo: Símbolo a buscar
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Lista de unidades con ese símbolo
|
|
162
|
+
|
|
163
|
+
Ejemplo:
|
|
164
|
+
>>> metros = ClaveUnidadCatalog.search_by_symbol("m")
|
|
165
|
+
>>> for u in metros:
|
|
166
|
+
... print(f"{u['id']}: {u['nombre']} ({u['simbolo']})")
|
|
167
|
+
"""
|
|
168
|
+
cls._load_data()
|
|
169
|
+
simbolo_lower = simbolo.lower()
|
|
170
|
+
return [u for u in cls._data if u["simbolo"].lower() == simbolo_lower] # type: ignore
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def get_vigentes(cls) -> list[ClaveUnidad]:
|
|
174
|
+
"""
|
|
175
|
+
Obtiene unidades vigentes (sin fecha de fin de vigencia).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Lista de unidades vigentes
|
|
179
|
+
|
|
180
|
+
Ejemplo:
|
|
181
|
+
>>> vigentes = ClaveUnidadCatalog.get_vigentes()
|
|
182
|
+
>>> print(f"Unidades vigentes: {len(vigentes)}")
|
|
183
|
+
"""
|
|
184
|
+
cls._load_data()
|
|
185
|
+
return [
|
|
186
|
+
u
|
|
187
|
+
for u in cls._data # type: ignore
|
|
188
|
+
if not u["fechaDeFinDeVigencia"] or u["fechaDeFinDeVigencia"] == ""
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def get_obsoletas(cls) -> list[ClaveUnidad]:
|
|
193
|
+
"""
|
|
194
|
+
Obtiene unidades obsoletas (con fecha de fin de vigencia).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Lista de unidades obsoletas
|
|
198
|
+
|
|
199
|
+
Ejemplo:
|
|
200
|
+
>>> obsoletas = ClaveUnidadCatalog.get_obsoletas()
|
|
201
|
+
>>> print(f"Unidades obsoletas: {len(obsoletas)}")
|
|
202
|
+
"""
|
|
203
|
+
cls._load_data()
|
|
204
|
+
return [
|
|
205
|
+
u
|
|
206
|
+
for u in cls._data # type: ignore
|
|
207
|
+
if u["fechaDeFinDeVigencia"] and u["fechaDeFinDeVigencia"] != ""
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def search_by_category(cls, categoria: str) -> list[ClaveUnidad]:
|
|
212
|
+
"""
|
|
213
|
+
Busca unidades por categoría (en el nombre).
|
|
214
|
+
|
|
215
|
+
Categorías soportadas: peso, longitud, volumen, tiempo, pieza
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
categoria: Categoría a buscar
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Lista de unidades en esa categoría
|
|
222
|
+
|
|
223
|
+
Ejemplo:
|
|
224
|
+
>>> pesos = ClaveUnidadCatalog.search_by_category("peso")
|
|
225
|
+
>>> for u in pesos:
|
|
226
|
+
... print(f"{u['id']}: {u['nombre']}")
|
|
227
|
+
"""
|
|
228
|
+
cls._load_data()
|
|
229
|
+
cat_lower = categoria.lower()
|
|
230
|
+
|
|
231
|
+
keywords: dict[str, list[str]] = {
|
|
232
|
+
"peso": ["kilogramo", "gramo", "tonelada", "libra", "onza"],
|
|
233
|
+
"longitud": [
|
|
234
|
+
"metro",
|
|
235
|
+
"centímetro",
|
|
236
|
+
"milímetro",
|
|
237
|
+
"kilómetro",
|
|
238
|
+
"pulgada",
|
|
239
|
+
"pie",
|
|
240
|
+
"yarda",
|
|
241
|
+
],
|
|
242
|
+
"volumen": ["litro", "mililitro", "metro cúbico", "galón", "barril"],
|
|
243
|
+
"tiempo": ["hora", "minuto", "segundo", "día", "semana", "mes", "año"],
|
|
244
|
+
"pieza": ["pieza", "unidad", "paquete", "caja", "docena"],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
search_words = keywords.get(cat_lower, [cat_lower])
|
|
248
|
+
|
|
249
|
+
results = []
|
|
250
|
+
for u in cls._data: # type: ignore
|
|
251
|
+
nombre_lower = u["nombre"].lower()
|
|
252
|
+
if any(word in nombre_lower for word in search_words):
|
|
253
|
+
results.append(u)
|
|
254
|
+
|
|
255
|
+
return results
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def get_total_count(cls) -> int:
|
|
259
|
+
"""
|
|
260
|
+
Obtiene el total de unidades en el catálogo.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Número total de unidades
|
|
264
|
+
|
|
265
|
+
Ejemplo:
|
|
266
|
+
>>> total = ClaveUnidadCatalog.get_total_count()
|
|
267
|
+
>>> print(f"Total: {total} unidades")
|
|
268
|
+
"""
|
|
269
|
+
cls._load_data()
|
|
270
|
+
return len(cls._data) # type: ignore
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def get_statistics(cls) -> dict[str, int]:
|
|
274
|
+
"""
|
|
275
|
+
Obtiene estadísticas del catálogo.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Diccionario con estadísticas de unidades
|
|
279
|
+
|
|
280
|
+
Ejemplo:
|
|
281
|
+
>>> stats = ClaveUnidadCatalog.get_statistics()
|
|
282
|
+
>>> print(f"Total: {stats['total']}")
|
|
283
|
+
>>> print(f"Vigentes: {stats['vigentes']}")
|
|
284
|
+
>>> print(f"Obsoletas: {stats['obsoletas']}")
|
|
285
|
+
"""
|
|
286
|
+
cls._load_data()
|
|
287
|
+
|
|
288
|
+
con_simbolo = sum(
|
|
289
|
+
1 for u in cls._data if u["simbolo"] and u["simbolo"] != "" # type: ignore
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"total": len(cls._data), # type: ignore
|
|
294
|
+
"vigentes": len(cls.get_vigentes()),
|
|
295
|
+
"obsoletas": len(cls.get_obsoletas()),
|
|
296
|
+
"con_simbolo": con_simbolo,
|
|
297
|
+
"sin_simbolo": len(cls._data) - con_simbolo, # type: ignore
|
|
298
|
+
}
|