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.
Files changed (81) hide show
  1. catalogmx/__init__.py +56 -0
  2. catalogmx/catalogs/__init__.py +5 -0
  3. catalogmx/catalogs/banxico/__init__.py +24 -0
  4. catalogmx/catalogs/banxico/banks.py +136 -0
  5. catalogmx/catalogs/banxico/codigos_plaza.py +287 -0
  6. catalogmx/catalogs/banxico/instituciones_financieras.py +338 -0
  7. catalogmx/catalogs/banxico/monedas_divisas.py +386 -0
  8. catalogmx/catalogs/banxico/udis.py +279 -0
  9. catalogmx/catalogs/ift/__init__.py +15 -0
  10. catalogmx/catalogs/ift/codigos_lada.py +426 -0
  11. catalogmx/catalogs/ift/operadores_moviles.py +315 -0
  12. catalogmx/catalogs/inegi/__init__.py +21 -0
  13. catalogmx/catalogs/inegi/localidades.py +207 -0
  14. catalogmx/catalogs/inegi/municipios.py +73 -0
  15. catalogmx/catalogs/inegi/municipios_completo.py +236 -0
  16. catalogmx/catalogs/inegi/states.py +148 -0
  17. catalogmx/catalogs/mexico/__init__.py +17 -0
  18. catalogmx/catalogs/mexico/hoy_no_circula.py +215 -0
  19. catalogmx/catalogs/mexico/placas_formatos.py +184 -0
  20. catalogmx/catalogs/mexico/salarios_minimos.py +156 -0
  21. catalogmx/catalogs/mexico/uma.py +207 -0
  22. catalogmx/catalogs/sat/__init__.py +13 -0
  23. catalogmx/catalogs/sat/carta_porte/__init__.py +19 -0
  24. catalogmx/catalogs/sat/carta_porte/aeropuertos.py +76 -0
  25. catalogmx/catalogs/sat/carta_porte/carreteras.py +59 -0
  26. catalogmx/catalogs/sat/carta_porte/config_autotransporte.py +54 -0
  27. catalogmx/catalogs/sat/carta_porte/material_peligroso.py +66 -0
  28. catalogmx/catalogs/sat/carta_porte/puertos_maritimos.py +63 -0
  29. catalogmx/catalogs/sat/carta_porte/tipo_embalaje.py +48 -0
  30. catalogmx/catalogs/sat/carta_porte/tipo_permiso.py +54 -0
  31. catalogmx/catalogs/sat/cfdi_4/__init__.py +42 -0
  32. catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +383 -0
  33. catalogmx/catalogs/sat/cfdi_4/clave_unidad.py +298 -0
  34. catalogmx/catalogs/sat/cfdi_4/exportacion.py +45 -0
  35. catalogmx/catalogs/sat/cfdi_4/forma_pago.py +45 -0
  36. catalogmx/catalogs/sat/cfdi_4/impuesto.py +57 -0
  37. catalogmx/catalogs/sat/cfdi_4/meses.py +34 -0
  38. catalogmx/catalogs/sat/cfdi_4/metodo_pago.py +45 -0
  39. catalogmx/catalogs/sat/cfdi_4/objeto_imp.py +45 -0
  40. catalogmx/catalogs/sat/cfdi_4/periodicidad.py +34 -0
  41. catalogmx/catalogs/sat/cfdi_4/regimen_fiscal.py +57 -0
  42. catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +42 -0
  43. catalogmx/catalogs/sat/cfdi_4/tipo_comprobante.py +45 -0
  44. catalogmx/catalogs/sat/cfdi_4/tipo_factor.py +34 -0
  45. catalogmx/catalogs/sat/cfdi_4/tipo_relacion.py +45 -0
  46. catalogmx/catalogs/sat/cfdi_4/uso_cfdi.py +45 -0
  47. catalogmx/catalogs/sat/comercio_exterior/__init__.py +39 -0
  48. catalogmx/catalogs/sat/comercio_exterior/claves_pedimento.py +77 -0
  49. catalogmx/catalogs/sat/comercio_exterior/estados.py +122 -0
  50. catalogmx/catalogs/sat/comercio_exterior/incoterms.py +226 -0
  51. catalogmx/catalogs/sat/comercio_exterior/monedas.py +107 -0
  52. catalogmx/catalogs/sat/comercio_exterior/motivos_traslado.py +54 -0
  53. catalogmx/catalogs/sat/comercio_exterior/paises.py +88 -0
  54. catalogmx/catalogs/sat/comercio_exterior/registro_ident_trib.py +76 -0
  55. catalogmx/catalogs/sat/comercio_exterior/unidades_aduana.py +54 -0
  56. catalogmx/catalogs/sat/comercio_exterior/validator.py +212 -0
  57. catalogmx/catalogs/sat/nomina/__init__.py +19 -0
  58. catalogmx/catalogs/sat/nomina/banco.py +50 -0
  59. catalogmx/catalogs/sat/nomina/periodicidad_pago.py +48 -0
  60. catalogmx/catalogs/sat/nomina/riesgo_puesto.py +56 -0
  61. catalogmx/catalogs/sat/nomina/tipo_contrato.py +47 -0
  62. catalogmx/catalogs/sat/nomina/tipo_jornada.py +42 -0
  63. catalogmx/catalogs/sat/nomina/tipo_nomina.py +52 -0
  64. catalogmx/catalogs/sat/nomina/tipo_regimen.py +47 -0
  65. catalogmx/catalogs/sepomex/__init__.py +5 -0
  66. catalogmx/catalogs/sepomex/codigos_postales.py +184 -0
  67. catalogmx/cli.py +185 -0
  68. catalogmx/helpers.py +324 -0
  69. catalogmx/utils/text.py +55 -0
  70. catalogmx/validators/__init__.py +0 -0
  71. catalogmx/validators/clabe.py +233 -0
  72. catalogmx/validators/curp.py +623 -0
  73. catalogmx/validators/nss.py +255 -0
  74. catalogmx/validators/rfc.py +1004 -0
  75. catalogmx-0.3.0.dist-info/METADATA +644 -0
  76. catalogmx-0.3.0.dist-info/RECORD +81 -0
  77. catalogmx-0.3.0.dist-info/WHEEL +5 -0
  78. catalogmx-0.3.0.dist-info/entry_points.txt +2 -0
  79. catalogmx-0.3.0.dist-info/licenses/AUTHORS.rst +5 -0
  80. catalogmx-0.3.0.dist-info/licenses/LICENSE +19 -0
  81. 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
+ }