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.
- helpers/__init__.py +27 -0
- helpers/display_helper.py +135 -0
- helpers/table_manager.py +418 -0
- tablas_python/__init__.py +68 -0
- tablas_python/cli.py +49 -0
- tablas_python-0.1.0.dist-info/METADATA +375 -0
- tablas_python-0.1.0.dist-info/RECORD +22 -0
- tablas_python-0.1.0.dist-info/WHEEL +5 -0
- tablas_python-0.1.0.dist-info/entry_points.txt +3 -0
- tablas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- tablas_python-0.1.0.dist-info/top_level.txt +3 -0
- utils/__init__.py +71 -0
- utils/batch_processor.py +111 -0
- utils/data_helpers.py +500 -0
- utils/excel_extractor.py +425 -0
- utils/excel_writer.py +188 -0
- utils/exporter.py +158 -0
- utils/file_utils.py +65 -0
- utils/pdf_extractor.py +129 -0
- utils/sqlite_extractor.py +133 -0
- utils/table_cleaner.py +246 -0
- utils/validator.py +155 -0
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
|
+
|