tablas-python 0.1.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.
utils/data_helpers.py ADDED
@@ -0,0 +1,500 @@
1
+ """
2
+ Módulo de utilidades y helpers para cálculos rápidos, limpieza y formateo de datos en pandas.
3
+ Permite ahorrar código en main.py para formateo de monedas, porcentajes, agregación de totales,
4
+ cálculo de variaciones, márgenes e impuestos.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional, Union
8
+ import re
9
+ import pandas as pd
10
+ import numpy as np
11
+
12
+
13
+ # ==========================================
14
+ # 1. PARSEO Y LIMPIEZA DE NÚMEROS Y FECHAS
15
+ # ==========================================
16
+
17
+ def limpiar_numero(val: Any) -> Union[float, int, Any]:
18
+ """
19
+ Convierte cualquier cadena con formato numérico, moneda o porcentaje a float o int.
20
+ Maneja formatos latinoamericanos (1.234,56) y anglosajones (1,234.56),
21
+ símbolos ($ / € / USD / CLP / %), y negativos entre paréntesis (100) -> -100.
22
+
23
+ Ejemplos:
24
+ limpiar_numero("$ 1.250.000") -> 1250000.0
25
+ limpiar_numero("18,5 %") -> 18.5
26
+ limpiar_numero("(450.50)") -> -450.50
27
+ """
28
+ if pd.isna(val) or val is None:
29
+ return np.nan
30
+ if isinstance(val, (int, float, np.number)):
31
+ return val
32
+
33
+ s = str(val).strip()
34
+ if not s:
35
+ return np.nan
36
+
37
+ # Detectar negativos con paréntesis: (123.45)
38
+ es_negativo = False
39
+ if s.startswith("(") and s.endswith(")"):
40
+ es_negativo = True
41
+ s = s[1:-1].strip()
42
+ elif s.startswith("-"):
43
+ es_negativo = True
44
+ s = s[1:].strip()
45
+
46
+ # Remover símbolos de monedas y palabras comunes
47
+ s = re.sub(r'[\$\€\£\¥\s]|USD|CLP|EUR|UF', '', s, flags=re.IGNORECASE)
48
+ # Remover %
49
+ s = s.replace('%', '').strip()
50
+
51
+ if not s:
52
+ return np.nan
53
+
54
+ # Determinar si el separador decimal es coma o punto
55
+ # Caso 1: Tiene comas y puntos (ej: 1.234.567,89 o 1,234,567.89)
56
+ if '.' in s and ',' in s:
57
+ if s.rfind(',') > s.rfind('.'):
58
+ # Formato latino: 1.234,56 -> quitar puntos y coma a punto
59
+ s = s.replace('.', '').replace(',', '.')
60
+ else:
61
+ # Formato anglo: 1,234.56 -> quitar comas
62
+ s = s.replace(',', '')
63
+ elif ',' in s:
64
+ # Solo tiene comas. Si tiene 1 coma y max 2 decimales al final: 1234,56
65
+ partes = s.split(',')
66
+ if len(partes) == 2 and len(partes[1]) <= 2:
67
+ s = s.replace(',', '.')
68
+ else:
69
+ # Es separador de miles: 1,000,000
70
+ s = s.replace(',', '')
71
+ elif '.' in s:
72
+ # Solo tiene puntos. Si tiene múltiples puntos: 1.000.000
73
+ if s.count('.') > 1:
74
+ s = s.replace('.', '')
75
+ # Si tiene 1 punto y exactamente 3 dígitos después, puede ser miles (ej: 1.500)
76
+ # pero en float estándar suele ser decimal. Lo dejamos a float directo.
77
+
78
+ try:
79
+ num = float(s)
80
+ if es_negativo:
81
+ num = -num
82
+ # Si no tiene decimales reales, retornar float o int
83
+ return num
84
+ except ValueError:
85
+ return val
86
+
87
+
88
+ def limpiar_columnas_numericas(df: pd.DataFrame, columnas: Union[str, List[str]]) -> pd.DataFrame:
89
+ """
90
+ Convierte una o más columnas a tipo numérico (float) limpiando caracteres extra.
91
+ """
92
+ df_out = df.copy()
93
+ if isinstance(columnas, str):
94
+ columnas = [columnas]
95
+ for col in columnas:
96
+ if col in df_out.columns:
97
+ df_out[col] = df_out[col].apply(limpiar_numero)
98
+ df_out[col] = pd.to_numeric(df_out[col], errors='coerce')
99
+ return df_out
100
+
101
+
102
+ def normalizar_fechas(df: pd.DataFrame, columnas: Union[str, List[str]], formato_salida: str = "%Y-%m-%d") -> pd.DataFrame:
103
+ """
104
+ Estandariza columnas de fechas al formato deseado (por defecto YYYY-MM-DD).
105
+ """
106
+ df_out = df.copy()
107
+ if isinstance(columnas, str):
108
+ columnas = [columnas]
109
+ for col in columnas:
110
+ if col in df_out.columns:
111
+ fechas = pd.to_datetime(df_out[col], errors='coerce', dayfirst=True)
112
+ df_out[col] = fechas.dt.strftime(formato_salida).fillna(df_out[col])
113
+ return df_out
114
+
115
+
116
+ # ==========================================
117
+ # 2. FORMATEO DE MONEDAS, PORCENTAJES Y MILES
118
+ # ==========================================
119
+
120
+ def formato_moneda(val: Any, simbolo: str = "$", decimales: int = 0, separador_miles: str = ".") -> str:
121
+ """
122
+ Formatea un número a representación de moneda.
123
+ Ejemplo:
124
+ formato_moneda(1500000) -> "$ 1.500.000"
125
+ formato_moneda(1250.75, decimales=2) -> "$ 1.250,75"
126
+ """
127
+ num = limpiar_numero(val)
128
+ if pd.isna(num) or not isinstance(num, (int, float)):
129
+ return "" if pd.isna(val) else str(val)
130
+
131
+ if decimales == 0:
132
+ texto = f"{round(num):,}".replace(",", separador_miles)
133
+ else:
134
+ fmt = f"{{:,.{decimales}f}}"
135
+ partes = fmt.format(num).split(".")
136
+ entero = partes[0].replace(",", separador_miles)
137
+ dec = partes[1]
138
+ sep_dec = "," if separador_miles == "." else "."
139
+ texto = f"{entero}{sep_dec}{dec}"
140
+
141
+ return f"{simbolo} {texto}" if simbolo else texto
142
+
143
+
144
+ def formato_porcentaje(val: Any, decimales: int = 1) -> str:
145
+ """
146
+ Formatea un valor a porcentaje.
147
+ Ejemplo:
148
+ formato_porcentaje(15.42) -> "15.4%"
149
+ formato_porcentaje(0.1542, es_ratio=True)
150
+ """
151
+ num = limpiar_numero(val)
152
+ if pd.isna(num) or not isinstance(num, (int, float)):
153
+ return "" if pd.isna(val) else str(val)
154
+
155
+ # Si el valor está entre 0 y 1 (ratio), convertir a base 100
156
+ if -1.0 <= num <= 1.0 and num != 0:
157
+ num = num * 100
158
+
159
+ return f"{num:.{decimales}f}%"
160
+
161
+
162
+ def formato_miles(val: Any, decimales: int = 0) -> str:
163
+ """Formatea un número con separador de miles."""
164
+ return formato_moneda(val, simbolo="", decimales=decimales)
165
+
166
+
167
+ def formatear_dataframe(
168
+ df: pd.DataFrame,
169
+ reglas: Dict[str, str]
170
+ ) -> pd.DataFrame:
171
+ """
172
+ Aplica formatos a múltiples columnas de un DataFrame para presentación o exportación.
173
+
174
+ Ejemplo:
175
+ df_fmt = formatear_dataframe(df, {
176
+ 'Ventas': 'moneda',
177
+ 'Precio': 'moneda_2dec',
178
+ 'Margen': 'porcentaje',
179
+ 'Cantidad': 'miles'
180
+ })
181
+ """
182
+ df_out = df.copy()
183
+ for col, tipo in reglas.items():
184
+ if col not in df_out.columns:
185
+ continue
186
+ if tipo == 'moneda':
187
+ df_out[col] = df_out[col].apply(lambda x: formato_moneda(x, decimales=0))
188
+ elif tipo == 'moneda_2dec':
189
+ df_out[col] = df_out[col].apply(lambda x: formato_moneda(x, decimales=2))
190
+ elif tipo == 'porcentaje':
191
+ df_out[col] = df_out[col].apply(formato_porcentaje)
192
+ elif tipo == 'miles':
193
+ df_out[col] = df_out[col].apply(formato_miles)
194
+ return df_out
195
+
196
+
197
+ # ==========================================
198
+ # 3. CÁLCULOS RÁPIDOS Y RESÚMENES EN DATAFRAME
199
+ # ==========================================
200
+
201
+ def agregar_fila_totales(
202
+ df: pd.DataFrame,
203
+ columnas_sumar: Optional[List[str]] = None,
204
+ columnas_promedio: Optional[List[str]] = None,
205
+ etiqueta: str = "TOTAL",
206
+ columna_etiqueta: Optional[str] = None
207
+ ) -> pd.DataFrame:
208
+ """
209
+ Agrega una fila final con la suma o promedio de las columnas especificadas.
210
+
211
+ Ejemplo:
212
+ df_con_total = agregar_fila_totales(df, columnas_sumar=['Subtotal', 'Cantidad'])
213
+ """
214
+ df_out = df.copy()
215
+ if df_out.empty:
216
+ return df_out
217
+
218
+ # Determinar columnas numéricas si no se especificaron
219
+ if columnas_sumar is None and columnas_promedio is None:
220
+ columnas_sumar = [c for c in df_out.columns if pd.api.types.is_numeric_dtype(df_out[c])]
221
+
222
+ fila_total = {}
223
+ for col in df_out.columns:
224
+ if columnas_sumar and col in columnas_sumar:
225
+ fila_total[col] = df_out[col].sum()
226
+ elif columnas_promedio and col in columnas_promedio:
227
+ fila_total[col] = df_out[col].mean()
228
+ else:
229
+ fila_total[col] = ""
230
+
231
+ # Asignar etiqueta 'TOTAL' en la primera columna o la indicada
232
+ target_label_col = columna_etiqueta if columna_etiqueta else df_out.columns[0]
233
+ fila_total[target_label_col] = etiqueta
234
+
235
+ df_total = pd.DataFrame([fila_total])
236
+ return pd.concat([df_out, df_total], ignore_index=True)
237
+
238
+
239
+ def calcular_participacion(
240
+ df: pd.DataFrame,
241
+ columna_valor: str,
242
+ nombre_col: str = "% Participación",
243
+ decimales: int = 1
244
+ ) -> pd.DataFrame:
245
+ """
246
+ Calcula el porcentaje de participación de cada fila sobre el total de la columna.
247
+ """
248
+ df_out = df.copy()
249
+ total = df_out[columna_valor].sum()
250
+ if total != 0 and pd.notna(total):
251
+ df_out[nombre_col] = ((df_out[columna_valor] / total) * 100).round(decimales)
252
+ else:
253
+ df_out[nombre_col] = 0.0
254
+ return df_out
255
+
256
+
257
+ def calcular_variacion(
258
+ df: pd.DataFrame,
259
+ col_actual: str,
260
+ col_anterior: str,
261
+ nombre_col: str = "% Variación",
262
+ decimales: int = 1
263
+ ) -> pd.DataFrame:
264
+ """
265
+ Calcula la variación porcentual entre dos columnas: ((actual - anterior) / anterior) * 100.
266
+ """
267
+ df_out = df.copy()
268
+ ant = df_out[col_anterior]
269
+ act = df_out[col_actual]
270
+ var = np.where(ant != 0, ((act - ant) / ant.abs()) * 100, 0.0)
271
+ df_out[nombre_col] = np.round(var, decimales)
272
+ return df_out
273
+
274
+
275
+ def aplicar_impuesto(
276
+ df: pd.DataFrame,
277
+ col_neto: str,
278
+ tasa: float = 0.19,
279
+ col_iva: str = "IVA (19%)",
280
+ col_total: str = "Total Bruto"
281
+ ) -> pd.DataFrame:
282
+ """
283
+ Calcula el IVA (o impuesto) y el valor bruto a partir de una columna neta.
284
+ """
285
+ df_out = df.copy()
286
+ df_out[col_iva] = (df_out[col_neto] * tasa).round(2)
287
+ df_out[col_total] = (df_out[col_neto] + df_out[col_iva]).round(2)
288
+ return df_out
289
+
290
+
291
+ def agrupar_y_resumir(
292
+ df: pd.DataFrame,
293
+ por: Union[str, List[str]],
294
+ metricas: Dict[str, Union[str, List[str]]]
295
+ ) -> pd.DataFrame:
296
+ """
297
+ Agrupa un DataFrame y calcula sumas, promedios o conteos en una sola línea.
298
+
299
+ Ejemplo:
300
+ resumen = agrupar_y_resumir(df, por='Categoria', metricas={'Subtotal': 'sum', 'Cantidad': 'sum'})
301
+ """
302
+ agrupado = df.groupby(por).agg(metricas).reset_index()
303
+ return agrupado
304
+
305
+
306
+ def obtener_celda(
307
+ df: pd.DataFrame,
308
+ fila: Any,
309
+ columna: str,
310
+ columna_identificador: Optional[str] = None
311
+ ) -> Any:
312
+ """
313
+ Obtiene el valor de una celda puntual indicando el nombre/etiqueta de la fila y el nombre de la columna.
314
+
315
+ Parámetros:
316
+ -----------
317
+ df : pd.DataFrame
318
+ El DataFrame a consultar.
319
+ fila : Any (str, int)
320
+ El nombre o valor que identifica la fila (ej: 'PROD-101', 'Distribuidora Los Andes', 'Enero').
321
+ columna : str
322
+ El nombre de la columna deseada (ej: 'Precio Unitario', 'monto_neto').
323
+ columna_identificador : str, opcional
324
+ Si el DataFrame no tiene como índice los nombres de filas, especifica qué columna
325
+ contiene el nombre buscado (ej: 'Codigo' o 'Producto'). Si es None, busca automáticamente.
326
+
327
+ Ejemplos:
328
+ ---------
329
+ # Caso 1: Con índice asignado
330
+ precio = obtener_celda(df_con_indice, fila="PROD-101", columna="Precio Unitario")
331
+
332
+ # Caso 2: Sin cambiar índice (busca en la columna 'Codigo')
333
+ precio = obtener_celda(df, fila="PROD-101", columna="Precio Unitario", columna_identificador="Codigo")
334
+ """
335
+ if columna not in df.columns and columna != df.index.name:
336
+ raise KeyError(f"La columna '{columna}' no existe en el DataFrame. Columnas disponibles: {list(df.columns)}")
337
+
338
+ # 1. Si la fila coincide directamente con el índice de pandas
339
+ if fila in df.index:
340
+ return df.at[fila, columna]
341
+
342
+ # 2. Si se especificó una columna identificadora
343
+ if columna_identificador and columna_identificador in df.columns:
344
+ coincidencias = df[df[columna_identificador] == fila]
345
+ if not coincidencias.empty:
346
+ return coincidencias.iloc[0][columna]
347
+ raise KeyError(f"No se encontró ninguna fila con {columna_identificador}='{fila}'")
348
+
349
+ # 3. Búsqueda automática en la primera columna o cualquier columna de texto
350
+ for col in df.columns:
351
+ coincidencias = df[df[col].astype(str) == str(fila)]
352
+ if not coincidencias.empty:
353
+ return coincidencias.iloc[0][columna]
354
+
355
+ raise KeyError(f"No se encontró la fila identificada con '{fila}' en el DataFrame.")
356
+
357
+
358
+ def modificar_celda(
359
+ df: pd.DataFrame,
360
+ fila: Any,
361
+ columna: str,
362
+ nuevo_valor: Any,
363
+ columna_identificador: Optional[str] = None
364
+ ) -> pd.DataFrame:
365
+ """
366
+ Modifica el valor de una celda puntual buscando por nombre de fila y nombre de columna.
367
+ """
368
+ df_out = df.copy()
369
+ if fila in df_out.index:
370
+ df_out.at[fila, columna] = nuevo_valor
371
+ return df_out
372
+
373
+ target_col = columna_identificador or df_out.columns[0]
374
+ idx = df_out.index[df_out[target_col].astype(str) == str(fila)].tolist()
375
+ if idx:
376
+ df_out.at[idx[0], columna] = nuevo_valor
377
+ return df_out
378
+
379
+ raise KeyError(f"No se encontró la fila '{fila}' para modificar.")
380
+
381
+
382
+ def buscar_v(
383
+ df_origen: pd.DataFrame,
384
+ df_destino: pd.DataFrame,
385
+ clave: str,
386
+ columna_a_traer: str,
387
+ clave_destino: Optional[str] = None,
388
+ nombre_columna: Optional[str] = None,
389
+ default: Any = np.nan
390
+ ) -> Union[pd.Series, pd.DataFrame]:
391
+ """
392
+ Equivalente al BUSCARV / VLOOKUP de Excel para cruzar dos tablas en una sola línea.
393
+
394
+ Parámetros:
395
+ -----------
396
+ df_origen : pd.DataFrame
397
+ El DataFrame donde quieres insertar el nuevo valor (ej: df_ventas).
398
+ df_destino : pd.DataFrame
399
+ El DataFrame que contiene la tabla maestra con el dato buscado (ej: df_clientes).
400
+ clave : str
401
+ Nombre de la columna común en df_origen (ej: 'id_cliente').
402
+ columna_a_traer : str
403
+ Nombre de la columna que deseas extraer de df_destino (ej: 'nombre').
404
+ clave_destino : str, opcional
405
+ Nombre de la columna clave en df_destino si se llama distinto a 'clave'.
406
+ nombre_columna : str, opcional
407
+ Si se especifica, agrega la columna a df_origen y devuelve el DataFrame completo.
408
+ Si es None, devuelve una pd.Series lista para asignar.
409
+ default : Any (por defecto np.nan)
410
+ Valor a colocar si no se encuentra coincidencia.
411
+
412
+ Ejemplos:
413
+ ---------
414
+ # Forma 1: Asignación directa a una nueva columna
415
+ df_ventas["Nombre_Cliente"] = buscar_v(df_ventas, df_clientes, clave="id_cliente", columna_a_traer="nombre")
416
+
417
+ # Forma 2: Retornar DataFrame actualizado
418
+ df_resultado = buscar_v(df_ventas, df_clientes, clave="id_cliente", columna_a_traer="nombre", nombre_columna="Cliente")
419
+ """
420
+ target_key = clave_destino or clave
421
+
422
+ if clave not in df_origen.columns:
423
+ raise KeyError(f"La clave '{clave}' no existe en df_origen.")
424
+ if target_key not in df_destino.columns:
425
+ raise KeyError(f"La clave '{target_key}' no existe en df_destino.")
426
+ if columna_a_traer not in df_destino.columns:
427
+ raise KeyError(f"La columna '{columna_a_traer}' no existe en df_destino.")
428
+
429
+ # Mapeo rápido usando diccionario para máxima velocidad
430
+ mapeo = df_destino.drop_duplicates(subset=[target_key]).set_index(target_key)[columna_a_traer].to_dict()
431
+ serie_resultado = df_origen[clave].map(mapeo).fillna(default)
432
+
433
+ if nombre_columna:
434
+ df_out = df_origen.copy()
435
+ df_out[nombre_columna] = serie_resultado
436
+ return df_out
437
+
438
+ return serie_resultado
439
+
440
+
441
+ def conciliar_tablas(
442
+ df_a: pd.DataFrame,
443
+ df_b: pd.DataFrame,
444
+ clave: str,
445
+ columnas_comparar: Optional[List[str]] = None,
446
+ sufijo_a: str = "_A",
447
+ sufijo_b: str = "_B"
448
+ ) -> Dict[str, pd.DataFrame]:
449
+ """
450
+ Concilia y audita dos tablas (ej: sistema vs extracto bancario, o inventario teórico vs físico).
451
+
452
+ Retorna un diccionario con 4 DataFrames:
453
+ - 'coincidentes': Filas idénticas en clave y valores comparados.
454
+ - 'diferencias': Filas que existen en ambas tablas pero con valores distintos.
455
+ - 'solo_en_A': Filas que solo existen en la primera tabla.
456
+ - 'solo_en_B': Filas que solo existen en la segunda tabla.
457
+ """
458
+ if clave not in df_a.columns or clave not in df_b.columns:
459
+ raise KeyError(f"La columna clave '{clave}' debe existir en ambas tablas.")
460
+
461
+ # Unir ambas tablas con outer join
462
+ merged = pd.merge(df_a, df_b, on=clave, how='outer', suffixes=(sufijo_a, sufijo_b), indicator=True)
463
+
464
+ solo_en_a = merged[merged['_merge'] == 'left_only'].drop(columns=['_merge']).reset_index(drop=True)
465
+ solo_en_b = merged[merged['_merge'] == 'right_only'].drop(columns=['_merge']).reset_index(drop=True)
466
+ ambos = merged[merged['_merge'] == 'both'].drop(columns=['_merge']).reset_index(drop=True)
467
+
468
+ if not columnas_comparar:
469
+ # Detectar columnas comunes con sufijo
470
+ columnas_comparar = [c for c in df_a.columns if c != clave and c in df_b.columns]
471
+
472
+ if not columnas_comparar or ambos.empty:
473
+ return {
474
+ "coincidentes": ambos,
475
+ "diferencias": pd.DataFrame(),
476
+ "solo_en_A": solo_en_a,
477
+ "solo_en_B": solo_en_b,
478
+ }
479
+
480
+ # Verificar discrepancias en las columnas a comparar
481
+ mascara_coincide = pd.Series(True, index=ambos.index)
482
+ for col in columnas_comparar:
483
+ col_a = f"{col}{sufijo_a}" if f"{col}{sufijo_a}" in ambos.columns else col
484
+ col_b = f"{col}{sufijo_b}" if f"{col}{sufijo_b}" in ambos.columns else col
485
+
486
+ # Comparar numérico o texto
487
+ coincide_col = (ambos[col_a] == ambos[col_b]) | (ambos[col_a].isna() & ambos[col_b].isna())
488
+ mascara_coincide = mascara_coincide & coincide_col
489
+
490
+ coincidentes = ambos[mascara_coincide].reset_index(drop=True)
491
+ diferencias = ambos[~mascara_coincide].reset_index(drop=True)
492
+
493
+ return {
494
+ "coincidentes": coincidentes,
495
+ "diferencias": diferencias,
496
+ "solo_en_A": solo_en_a,
497
+ "solo_en_B": solo_en_b,
498
+ }
499
+
500
+