-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathExportarExcelTurbo.py
More file actions
1618 lines (1429 loc) · 68.8 KB
/
ExportarExcelTurbo.py
File metadata and controls
1618 lines (1429 loc) · 68.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# ExportarExcelTurbo.py
import pandas as pd
import os
import logging
import re
import time
from decimal import Decimal
from datetime import datetime
import xlsxwriter
import numpy as np # Necesario para CalculadoraColumnas
logger = logging.getLogger(__name__)
class CalculadoraColumnas:
"""
Procesador de columnas calculadas para DataFrames.
Ejecuta expresiones Python sobre las columnas del DataFrame y retorna
los valores calculados como una nueva Serie de pandas.
Características:
- Sintaxis Python estándar (no Excel)
- Acceso directo a columnas del DataFrame
- Evaluación segura de expresiones
- Soporte para funciones auxiliares personalizadas
"""
def __init__(self, df):
"""
Inicializa el calculador con el DataFrame base.
Args:
df (DataFrame): DataFrame con los datos originales
"""
self.df = df
self.columnas_disponibles = list(df.columns)
def calcular_columna(self, nombre_columna, expresion_python):
"""
Calcula una nueva columna basada en una expresión Python.
Args:
nombre_columna (str): Nombre de la nueva columna
expresion_python (str): Expresión Python a evaluar
Returns:
pandas.Series: Serie con los valores calculados
Ejemplo:
# Para una expresión como: "df['Precio'] * df['Cantidad']"
# O más simple: "Precio * Cantidad"
serie = calculadora.calcular_columna('Total', 'Precio * Cantidad')
"""
try:
logger.info(f"Calculando columna '{nombre_columna}' con expresión: {expresion_python}")
# Preparar el contexto de ejecución
contexto = self._crear_contexto_calculo()
# Evaluar la expresión de forma segura
resultado = self._evaluar_expresion(expresion_python, contexto)
# Convertir resultado a Serie si no lo es ya
if not isinstance(resultado, pd.Series):
# Si es un valor escalar, replicarlo para todas las filas
if pd.api.types.is_scalar(resultado):
resultado = pd.Series([resultado] * len(self.df),
name=nombre_columna,
index=self.df.index)
else:
# Si es una lista o array, convertir a Serie
# Asegurarse de que tenga la longitud correcta
if hasattr(resultado, '__len__'):
len_resultado = len(resultado)
if len_resultado == len(self.df):
resultado = pd.Series(resultado,
name=nombre_columna,
index=self.df.index)
elif len_resultado < len(self.df):
# Rellenar con NaN si es más corto
resultado_padded = [None] * len(self.df)
resultado_padded[:len_resultado] = list(resultado)
resultado = pd.Series(resultado_padded,
name=nombre_columna,
index=self.df.index)
else: # len_resultado > len(self.df)
# Truncar si es más largo
resultado = pd.Series(list(resultado)[:len(self.df)],
name=nombre_columna,
index=self.df.index)
else:
# Si no es iterable ni escalar, tratar como escalar
resultado = pd.Series([resultado] * len(self.df),
name=nombre_columna,
index=self.df.index)
# Asegurar que la Serie tiene el nombre correcto
resultado.name = nombre_columna
logger.info(f"Columna '{nombre_columna}' calculada exitosamente ({len(resultado)} valores)")
return resultado
except Exception as e:
logger.error(f"Error calculando columna '{nombre_columna}': {str(e)}")
# Retornar serie con valores None en caso de error
return pd.Series([None] * len(self.df), name=nombre_columna, index=self.df.index)
def _crear_contexto_calculo(self):
"""
Crea el contexto de variables y funciones disponibles para el cálculo.
Returns:
dict: Diccionario con variables y funciones disponibles
"""
# Contexto base con el DataFrame completo
contexto = {
'df': self.df,
'pd': pd,
'np': np, # Agregar numpy al contexto
}
# Agregar cada columna como variable individual para sintaxis más simple
# Esto permite usar "Precio * Cantidad" en lugar de "df['Precio'] * df['Cantidad']"
for columna in self.columnas_disponibles:
# Crear nombre de variable válido (reemplazar espacios y caracteres especiales)
nombre_var = self._crear_nombre_variable_valido(columna)
if nombre_var != columna:
logger.debug(f"Columna '{columna}' disponible como variable '{nombre_var}'")
contexto[nombre_var] = self.df[columna]
# Agregar funciones auxiliares útiles
contexto.update({
'len': len,
# Redefinir str para manejar Series de pandas correctamente
'str': lambda x: x.astype(str) if isinstance(x, pd.Series) else str(x),
'int': int,
'float': float,
'bool': bool,
'abs': abs,
'min': min,
'max': max,
'round': round,
'sum': sum,
'any': any,
'all': all,
'datetime': datetime,
# Funciones auxiliares personalizadas
'si': self._funcion_si,
'concatenar': self._funcion_concatenar,
'es_nulo': pd.isna,
'no_es_nulo': pd.notna,
'llenar_nulos': lambda serie, valor: serie.fillna(valor),
})
return contexto
def _crear_nombre_variable_valido(self, nombre_columna):
"""
Convierte un nombre de columna en un nombre de variable Python válido.
Args:
nombre_columna (str): Nombre original de la columna
Returns:
str: Nombre de variable válido
"""
import re
# Reemplazar espacios y caracteres especiales con guiones bajos
nombre_limpio = re.sub(r'[^\w]', '_', str(nombre_columna))
# Asegurar que no comience con número
if nombre_limpio and nombre_limpio[0].isdigit():
nombre_limpio = 'col_' + nombre_limpio
# Evitar nombres vacíos
if not nombre_limpio:
nombre_limpio = 'col_sin_nombre'
return nombre_limpio
def _evaluar_expresion(self, expresion, contexto):
"""
Evalúa una expresión Python de forma segura.
Args:
expresion (str): Expresión a evaluar
contexto (dict): Contexto con variables disponibles
Returns:
any: Resultado de la evaluación
"""
# Lista limitada de built-ins permitidos por seguridad
builtins_seguros = {
'__builtins__': {
'len', 'str', 'int', 'float', 'bool', 'abs', 'min', 'max',
'round', 'sum', 'any', 'all', 'zip', 'enumerate', 'range',
'list', 'tuple', 'dict', 'set', 'True', 'False', 'None'
}
}
try:
# Evaluar con contexto restringido por seguridad
resultado = eval(expresion, builtins_seguros, contexto)
return resultado
except Exception as e:
raise RuntimeError(f"Error evaluando expresión '{expresion}': {str(e)}")
# Funciones auxiliares personalizadas
def _funcion_si(self, condicion, valor_verdadero, valor_falso):
"""
Función condicional tipo IF de Excel pero para pandas.
Args:
condicion: Serie o array de condiciones booleanas
valor_verdadero: Valor(es) cuando la condición es verdadera
valor_falso: Valor(es) cuando la condición es falsa
Returns:
pandas.Series o valor: Serie con valores condicionales o un valor escalar
"""
if isinstance(condicion, pd.Series):
# Usar np.where para vectorizar la operación
return pd.Series(np.where(condicion, valor_verdadero, valor_falso), index=condicion.index)
else:
return valor_verdadero if condicion else valor_falso
def _funcion_concatenar(self, *args):
"""
Concatena múltiples valores o series.
Returns:
pandas.Series o str: Resultado de la concatenación
"""
if len(args) == 0:
return ""
# Si algún argumento es una Serie, convertir todo a Series
series_args = []
for arg in args:
if isinstance(arg, pd.Series):
series_args.append(arg.astype(str))
else:
# Crear una Serie con el valor repetido
series_args.append(pd.Series([str(arg)] * len(self.df), index=self.df.index))
if series_args:
# Concatenar series elemento a elemento
resultado = series_args[0]
for serie in series_args[1:]:
resultado = resultado + serie
return resultado
else:
# Concatenar valores escalares
return ''.join(str(arg) for arg in args)
class HojaBase:
"""
Clase base para propiedades compartidas entre HojaExcelTurbo y HojaExcelPlaceholder.
Reduce redundancia en setters y getters.
"""
def __init__(self):
self.datos = None
self.columnas_a_mostrar = []
self._renombra_encabezados = []
self.formato_columnas = []
self.cambia_resultado_a = []
self.alineaciones_horizontales = []
self.columnas_calculadas = []
self.sangrias_columnas = []
self.mostrar_encabezados = True # Por defecto, mostrar encabezados
self.fila_inicial = 1 # Fila inicial por defecto
@property
def Columnas_a_mostrar(self):
return self.columnas_a_mostrar
@Columnas_a_mostrar.setter
def Columnas_a_mostrar(self, columnas):
self.columnas_a_mostrar = columnas if columnas else []
@property
def Renombra_Encabezados(self):
return self._renombra_encabezados
@Renombra_Encabezados.setter
def Renombra_Encabezados(self, encabezados):
self._renombra_encabezados = encabezados if encabezados else []
@property
def Formato_Columnas(self):
return self.formato_columnas
@Formato_Columnas.setter
def Formato_Columnas(self, formatos):
self.formato_columnas = formatos if formatos else []
@property
def Cambia_Resultado_A(self):
return self.cambia_resultado_a
@Cambia_Resultado_A.setter
def Cambia_Resultado_A(self, reemplazos):
self.cambia_resultado_a = reemplazos if reemplazos else []
@property
def Alineaciones_Horizontales(self):
return self.alineaciones_horizontales
@Alineaciones_Horizontales.setter
def Alineaciones_Horizontales(self, alineaciones):
self.alineaciones_horizontales = alineaciones if alineaciones else []
@property
def ColumnasCalculadas(self):
return self.columnas_calculadas
@ColumnasCalculadas.setter
def ColumnasCalculadas(self, definiciones):
if not isinstance(definiciones, list):
definiciones = [definiciones] if definiciones else []
definiciones_validas = []
for def_col in definiciones:
if isinstance(def_col, dict) and 'nombre_columna' in def_col and 'expresion' in def_col:
definiciones_validas.append(def_col)
else:
logger.warning(f"Definición de columna calculada inválida ignorada: {def_col}")
self.columnas_calculadas = definiciones_validas
@property
def Sangrias_Columnas(self):
return self.sangrias_columnas
@Sangrias_Columnas.setter
def Sangrias_Columnas(self, sangrias):
self.sangrias_columnas = sangrias if sangrias else []
@property
def Mostrar_Encabezados(self):
"""Obtiene si se mostrarán los encabezados de columna en la exportación Excel."""
return self.mostrar_encabezados
@Mostrar_Encabezados.setter
def Mostrar_Encabezados(self, value):
"""Establece si se mostrarán los encabezados. True para mostrar, False para omitir."""
self.mostrar_encabezados = bool(value)
@property
def Fila_Inicial(self):
"""Obtiene la fila inicial donde se empezará a escribir el reporte."""
return self.fila_inicial
@Fila_Inicial.setter
def Fila_Inicial(self, value):
"""Establece la fila inicial. Debe ser un entero positivo."""
if isinstance(value, int) and value >= 1:
self.fila_inicial = value
else:
logger.warning(f"Fila inicial inválida: {value}. Debe ser un entero >= 1. Usando valor por defecto: 1")
self.fila_inicial = 1
class HojaExcelTurbo(HojaBase):
"""
Clase para manejar una hoja individual dentro de un libro Excel usando XlsxWriter.
Proporciona funcionalidades para:
- Carga de datos desde diversos formatos
- Selección de columnas específicas
- Personalización de encabezados
- Configuración opcional para mostrar encabezados
- Aplicación de formatos automáticos y personalizados
- Optimización de rendimiento para grandes volúmenes de datos
- Reemplazo de valores específicos en columnas
- Cálculo de columnas personalizadas (integrado)
- Configuración de sangrías para columnas
"""
def __init__(self, workbook, worksheet, nombre_hoja="Hoja1"):
"""
Inicializa una nueva hoja Excel con estilos predefinidos.
Args:
workbook: Instancia del workbook de XlsxWriter
worksheet: Instancia del worksheet de XlsxWriter
nombre_hoja (str): Nombre de la hoja
"""
super().__init__() # Initialize base class
self.workbook = workbook
self.worksheet = worksheet
self.nombre_hoja = nombre_hoja
self.fila_actual = 0
# Inicializar estilos de formato para mejor rendimiento
self._inicializar_estilos()
def _inicializar_estilos(self):
"""
Crea y almacena todos los estilos de formato necesarios.
XlsxWriter es más eficiente cuando los formatos se crean una sola vez
y se reutilizan en lugar de crear nuevos formatos para cada celda.
"""
# Formato para encabezados con fondo azul y texto blanco en negrita
self.formato_encabezado = self.workbook.add_format({
'bold': True,
'font_color': 'white',
'bg_color': '#366092',
'align': 'center',
'valign': 'vcenter',
})
# Formato para fechas en formato DD/MMM/YYYY
self.formato_fecha = self.workbook.add_format({
'num_format': 'dd/mmm/yyyy',
'align': 'center',
})
# Formato contable para números con separadores de miles y decimales
self.formato_contabilidad = self.workbook.add_format({
'num_format': '#,##0.00',
'align': 'center',
})
# Formato para porcentajes con dos decimales
self.formato_porcentaje = self.workbook.add_format({
'num_format': '0.00%',
'align': 'center',
})
# Formato para números enteros con separadores de miles
self.formato_entero = self.workbook.add_format({
'num_format': '0',
'align': 'center',
})
# Formato para texto con alineación izquierda
self.formato_texto = self.workbook.add_format({
'num_format': '@',
'align': 'left',
'valign': 'center',
})
# Formato general para celdas normales
self.formato_general = self.workbook.add_format({
'valign': 'center'
})
# Formatos adicionales para alineaciones específicas
self.formato_izquierda = self.workbook.add_format({
'align': 'left',
'valign': 'center'
})
self.formato_centro = self.workbook.add_format({
'align': 'center',
'valign': 'center'
})
self.formato_derecha = self.workbook.add_format({
'align': 'right',
'valign': 'center'
})
def Carga_Datos(self, datos):
"""
Carga los datos que serán exportados a la hoja Excel.
Args:
datos: Lista de diccionarios, DataFrame de pandas, o cualquier estructura
iterable que pueda convertirse en DataFrame
Returns:
self: Permite el encadenamiento de métodos (method chaining)
"""
if datos is None:
logger.info(f"No se proporcionaron datos para la hoja '{self.nombre_hoja}'; se exportará hoja vacía.")
self.datos = []
else:
self.datos = datos
logger.info(f"Cargados {len(datos) if hasattr(datos, '__len__') else 'N/A'} registros en la hoja '{self.nombre_hoja}'")
return self
# --- INICIO Propiedad para columnas calculadas ---
@property
def ColumnasCalculadas(self):
"""Getter para las definiciones de columnas calculadas."""
return self.columnas_calculadas
@ColumnasCalculadas.setter
def ColumnasCalculadas(self, definiciones):
"""
Define columnas calculadas que se agregarán al DataFrame.
Args:
definiciones (list o dict): Lista de definiciones o definición única.
Formato de definición:
{
'nombre_columna': 'NombreDeLaColumna',
'expresion': 'Precio * Cantidad', # Expresión Python
'entre_columna': 'ColumnaAnterior', # Opcional: insertar después de esta
'y_columna': 'ColumnaPosterior', # Opcional: insertar antes de esta
'formato': 'CONTABILIDAD' # Opcional: formato específico
}
Ejemplos de expresiones:
- 'Precio * Cantidad'
- 'si(Estado == "Activo", "SI", "NO")'
- 'df["Precio"].apply(lambda x: x * 1.16)' # Más complejo
- 'concatenar(Nombre, " - ", Apellido)'
- 'Precio * (1 + IVA/100)'
"""
if not isinstance(definiciones, list):
definiciones = [definiciones] if definiciones else []
# Validar definiciones (opcional)
definiciones_validas = []
for def_col in definiciones:
if isinstance(def_col, dict) and 'nombre_columna' in def_col and 'expresion' in def_col:
definiciones_validas.append(def_col)
else:
logger.warning(f"Definición de columna calculada inválida ignorada: {def_col}")
self.columnas_calculadas = definiciones_validas
logger.info(f"Definidas {len(self.columnas_calculadas)} columnas calculadas")
# --- FIN Propiedad para columnas calculadas ---
def _parsear_asignacion(self, asignacion_str):
"""
Analiza y separa las asignaciones de encabezados.
Args:
asignacion_str (str): String en formato 'CampoOriginal = NombreMostrado'
o simplemente 'NombreCampo'
Returns:
tuple: (nombre_original, nombre_a_mostrar)
"""
asignacion_str = asignacion_str.strip()
if '=' in asignacion_str:
partes = asignacion_str.split('=', 1) # Dividir solo en el primer =
original = partes[0].strip()
mostrado = partes[1].strip()
return original, mostrado
else:
# Si no hay =, usar el mismo nombre para ambos
return asignacion_str, asignacion_str
def _parsear_formato(self, formato_str):
"""
Analiza y separa las definiciones de formato de columna.
Args:
formato_str (str): String en formato 'NombreColumna = TIPO_FORMATO'
o simplemente 'TIPO_FORMATO'
Returns:
tuple: (nombre_columna, tipo_formato)
"""
formato_str = formato_str.strip()
if '=' in formato_str:
partes = formato_str.split('=', 1)
columna = partes[0].strip()
formato = partes[1].strip()
return columna, formato
else:
# Si no hay =, asumir que es solo el tipo de formato
return formato_str, 'AUTO'
def _parsear_alineacion(self, alineacion_str):
"""
Analiza y separa las definiciones de alineación horizontal.
Args:
alineacion_str (tuple): Tupla en formato (nombre_columna, tipo_alineacion)
Returns:
tuple: (nombre_columna, tipo_alineacion)
"""
if isinstance(alineacion_str, tuple) and len(alineacion_str) == 2:
return alineacion_str[0], alineacion_str[1].upper()
return None, None
def _parsear_sangria(self, sangria_str):
"""
Analiza y separa las definiciones de sangría.
Args:
sangria_str (tuple): Tupla en formato (nombre_columna, nivel_sangria)
Returns:
tuple: (nombre_columna, nivel_sangria)
"""
if isinstance(sangria_str, tuple) and len(sangria_str) == 2:
return sangria_str[0], int(sangria_str[1])
return None, None
def _convertir_a_numero(self, valor):
"""
Convierte diversos tipos de datos a número flotante para cálculos.
Args:
valor: Valor a convertir (string, int, float, Decimal, etc.)
Returns:
float or None: Número convertido o None si no es posible la conversión
"""
if pd.isna(valor):
return None
if isinstance(valor, (int, float, Decimal)):
return float(valor)
if isinstance(valor, str):
try:
# Limpiar el string de caracteres comunes en números monetarios
valor_limpio = valor.replace(',', '').replace('$', '').replace('€', '').strip()
return float(valor_limpio)
except (ValueError, TypeError):
return None
return None
def _detectar_formato_automatico(self, serie_valores):
"""
Analiza una muestra de valores para determinar automáticamente el mejor formato.
Args:
serie_valores: Serie o lista de valores a analizar
Returns:
str: Tipo de formato detectado (FECHA_ESTANDAR, CONTABILIDAD, etc.)
"""
# Filtrar valores válidos (no nulos)
valores_validos = [v for v in serie_valores if pd.notna(v) and v is not None]
if not valores_validos:
return 'TEXTO'
# Tomar una muestra representativa para análisis
muestra_size = min(20, len(valores_validos))
muestra = valores_validos[:muestra_size]
# Contadores para diferentes tipos de datos detectados
fechas_detectadas = 0
numeros_detectados = 0
porcentajes_detectados = 0
enteros_detectados = 0
# Patrones de expresiones regulares para fechas
patrones_fecha = [
r'\d{1,2}/\d{1,2}/\d{4}', # DD/MM/YYYY o MM/DD/YYYY
r'\d{4}-\d{1,2}-\d{1,2}', # YYYY-MM-DD
r'\d{1,2}-\d{1,2}-\d{4}', # DD-MM-YYYY
r'\d{1,2}/\w{3}/\d{4}', # DD/MMM/YYYY
]
for valor in muestra:
# Verificar si es datetime primero
if isinstance(valor, (datetime, pd.Timestamp)):
fechas_detectadas += 1
elif isinstance(valor, str):
# Verificar patrones de fecha solo si es string
es_fecha = False
try:
valor_strip = valor.strip()
for patron in patrones_fecha:
# Asegurarse de que el patrón y el valor sean strings
if isinstance(patron, str) and isinstance(valor_strip, str):
if re.match(patron, valor_strip):
fechas_detectadas += 1
es_fecha = True
break
except (TypeError, re.error):
# Si hay error en regex, continuar con otras verificaciones
pass
if not es_fecha:
# Intentar convertir a número
numero = self._convertir_a_numero(valor)
if numero is not None:
numeros_detectados += 1
# Verificar si es un porcentaje (entre 0 y 1, excluyendo exactamente 0 y 1)
if 0 < numero < 1:
porcentajes_detectados += 1
# Verificar si es un entero
elif numero == int(numero):
enteros_detectados += 1
elif isinstance(valor, (int, float, Decimal)):
numeros_detectados += 1
# Para números, verificar patrones de porcentaje y enteros
if isinstance(valor, float) and 0 < valor < 1:
porcentajes_detectados += 1
elif isinstance(valor, (int, Decimal)) or (isinstance(valor, float) and valor.is_integer()):
enteros_detectados += 1
# Ignorar tipos booleanos y otros tipos no reconocidos
# Determinar el formato basado en la mayoría de la muestra
total_muestra = len(muestra)
umbral_mayoria = total_muestra * 0.6 # 60% de la muestra
if fechas_detectadas >= umbral_mayoria:
return 'FECHA_ESTANDAR'
elif porcentajes_detectados >= total_muestra * 0.5: # 50% para porcentajes
return 'PORCENTAJE'
elif numeros_detectados >= umbral_mayoria:
# Decidir entre entero y decimal
if enteros_detectados >= numeros_detectados * 0.8: # 80% son enteros
return 'ENTERO'
else:
return 'CONTABILIDAD'
else:
return 'TEXTO'
def _obtener_formato_por_tipo(self, tipo_formato):
"""
Retorna el formato XlsxWriter correspondiente al tipo solicitado.
Args:
tipo_formato (str): Tipo de formato solicitado
Returns:
XlsxWriter Format object: Formato correspondiente
"""
formatos_mapa = {
'FECHA_ESTANDAR': self.formato_fecha,
'CONTABILIDAD': self.formato_contabilidad,
'PORCENTAJE': self.formato_porcentaje,
'ENTERO': self.formato_entero,
'TEXTO': self.formato_texto
}
return formatos_mapa.get(tipo_formato, self.formato_general)
def _obtener_formato_alineacion(self, tipo_alineacion):
"""
Retorna el formato XlsxWriter correspondiente a la alineación solicitada.
Args:
tipo_alineacion (str): Tipo de alineación solicitada (LEFT, CENTER, RIGHT)
Returns:
XlsxWriter Format object: Formato correspondiente
"""
alineaciones_mapa = {
'LEFT': self.formato_izquierda,
'CENTER': self.formato_centro,
'RIGHT': self.formato_derecha
}
return alineaciones_mapa.get(tipo_alineacion.upper(), self.formato_general)
def _crear_formato_combinado(self, formato_base, alineacion):
"""
Crea un formato combinado que incluye tanto el formato base como la alineación.
Args:
formato_base: Formato XlsxWriter base
alineacion (str): Tipo de alineación (LEFT, CENTER, RIGHT)
Returns:
XlsxWriter Format object: Formato combinado
"""
# Obtener las propiedades del formato base
props_base = formato_base.__dict__.get('_format_properties', {})
# Obtener las propiedades de alineación
formato_alineacion = self._obtener_formato_alineacion(alineacion)
props_alineacion = formato_alineacion.__dict__.get('_format_properties', {})
# Combinar propiedades
props_combinadas = props_base.copy()
props_combinadas.update(props_alineacion)
# Crear nuevo formato con propiedades combinadas
return self.workbook.add_format(props_combinadas)
def _mostrar_encabezados(self, df):
"""
Muestra los encabezados de las columnas con formato especial (si está habilitado).
Args:
df (DataFrame): DataFrame con los datos y columnas finales
"""
if not self.mostrar_encabezados:
logger.info(f"Saltando escritura de encabezados para hoja '{self.nombre_hoja}' según configuración.")
# self.fila_actual ya está configurado en _escribir_y_formatear
return
encabezados = list(df.columns)
for col_idx, encabezado in enumerate(encabezados):
self.worksheet.write(self.fila_actual, col_idx, encabezado, self.formato_encabezado)
self.fila_actual += 1 # La siguiente fila disponible después de los encabezados
def _obtener_formatos_para_columnas(self, df):
"""
Obtiene los formatos correspondientes para cada columna.
Args:
df (DataFrame): DataFrame con los datos
Returns:
dict: Diccionario con índice de columna y su formato correspondiente
"""
# Si no se han definido formatos de columna, no aplicar ningún formato
if not self.formato_columnas and not self.alineaciones_horizontales and not self.sangrias_columnas:
# Retornar formato general para todas las columnas
formatos_resultado = {}
for col_idx in range(len(df.columns)):
formatos_resultado[col_idx] = self.formato_general
return formatos_resultado
encabezados = list(df.columns)
formatos_resultado = {}
# Crear mapeo de formatos definidos por el usuario
formatos_usuario = {}
for formato_str in self.formato_columnas:
nombre_columna, tipo_formato = self._parsear_formato(formato_str)
formatos_usuario[nombre_columna] = tipo_formato
# Crear mapeo de alineaciones definidas por el usuario
alineaciones_usuario = {}
for alineacion_tupla in self.alineaciones_horizontales:
nombre_columna, tipo_alineacion = self._parsear_alineacion(alineacion_tupla)
if nombre_columna and tipo_alineacion:
alineaciones_usuario[nombre_columna] = tipo_alineacion
# Crear mapeo de sangrías definidas por el usuario
sangrias_usuario = {}
for sangria_tupla in self.sangrias_columnas:
nombre_columna, nivel_sangria = self._parsear_sangria(sangria_tupla)
if nombre_columna is not None and nivel_sangria is not None:
sangrias_usuario[nombre_columna] = nivel_sangria
# Crear mapeo de columnas originales a nombres mostrados
mapeo_columnas = {}
if self._renombra_encabezados:
for asignacion in self._renombra_encabezados:
original, mostrado = self._parsear_asignacion(asignacion)
mapeo_columnas[original] = mostrado
# Aplicar formatos columna por columna
for col_idx, nombre_columna in enumerate(encabezados):
# Buscar el nombre original de la columna
nombre_original = None
# Primero buscar si el nombre mostrado corresponde a algún nombre original
for original, mostrado in mapeo_columnas.items():
if mostrado == nombre_columna:
nombre_original = original
break
# Si no se encontró correspondencia, usar el nombre mostrado como original
if nombre_original is None:
nombre_original = nombre_columna
# Determinar el tipo de formato para esta columna
if nombre_original in formatos_usuario:
tipo_formato = formatos_usuario[nombre_original]
elif nombre_columna in formatos_usuario:
tipo_formato = formatos_usuario[nombre_columna]
else:
# Si no se especifica formato, usar AUTO
tipo_formato = 'AUTO'
# Para AUTO, detectar el formato basado en los datos
if tipo_formato == 'AUTO':
columna_datos = df[nombre_columna]
tipo_formato = self._detectar_formato_automatico(columna_datos)
# Obtener el formato XlsxWriter correspondiente
formato_base = self._obtener_formato_por_tipo(tipo_formato)
# Verificar si hay alineación personalizada para esta columna
alineacion_personalizada = None
if nombre_original in alineaciones_usuario:
alineacion_personalizada = alineaciones_usuario[nombre_original]
elif nombre_columna in alineaciones_usuario:
alineacion_personalizada = alineaciones_usuario[nombre_columna]
# Si hay alineación personalizada, crear formato combinado
if alineacion_personalizada:
formato_final = self._crear_formato_combinado(formato_base, alineacion_personalizada)
else:
formato_final = formato_base
# Verificar si hay sangría personalizada para esta columna
sangria_personalizada = None
if nombre_original in sangrias_usuario:
sangria_personalizada = sangrias_usuario[nombre_original]
elif nombre_columna in sangrias_usuario:
sangria_personalizada = sangrias_usuario[nombre_columna]
# Si hay sangría personalizada, crear formato con sangría
if sangria_personalizada is not None:
props = formato_final.__dict__.get('_format_properties', {}).copy()
props['indent'] = sangria_personalizada
formato_final = self.workbook.add_format(props)
# Guardar el formato para esta columna
formatos_resultado[col_idx] = formato_final
return formatos_resultado
def _escribir_datos_optimizado(self, df):
"""
Escribe los datos del DataFrame aplicando formatos durante la escritura.
Args:
df (DataFrame): DataFrame con los datos a escribir
"""
# Primero, obtener los formatos para cada columna
formatos_columnas = self._obtener_formatos_para_columnas(df)
# Convertir DataFrame a lista de listas para escritura más rápida
datos_como_listas = df.values.tolist()
encabezados = list(df.columns)
# Escribir fila por fila
for fila_idx, fila_datos in enumerate(datos_como_listas):
fila_excel = self.fila_actual + fila_idx
# Escribir celda por celda aplicando el formato correspondiente
for col_idx, valor in enumerate(fila_datos):
# Convertir datetime a formato apropiado para Excel
if isinstance(valor, pd.Timestamp):
valor = valor.to_pydatetime()
elif isinstance(valor, datetime):
# Ya es datetime, dejarlo como está
pass
elif pd.isna(valor):
valor = ""
# Obtener el formato para esta columna
formato = formatos_columnas.get(col_idx, self.formato_general)
# Escribir con formato
self.worksheet.write(fila_excel, col_idx, valor, formato)
def _ajustar_ancho_columnas(self, df, incluir_encabezados=True):
"""
Ajusta automáticamente el ancho de las columnas basado en el contenido y tipo de dato.
Aplica lógica específica para diferentes tipos de datos para evitar anchos exagerados.
Args:
df (DataFrame): DataFrame con los datos para calcular anchos
incluir_encabezados (bool): Si True, incluye el ancho de los encabezados en el cálculo
"""
limites = {
'fecha': {'min': 10, 'max': 15},
'numero_entero': {'min': 6, 'max': 18},
'numero_decimal': {'min': 8, 'max': 20},
'porcentaje': {'min': 6, 'max': 10},
'numero_como_texto': {'min': 8, 'max': 20},
'texto': {'min': 5, 'max': 40}
}
for col_idx, columna in enumerate(df.columns):
# Calcular el ancho base del encabezado
if incluir_encabezados:
ancho_encabezado = len(str(columna))
else:
ancho_encabezado = 0
# Obtener muestra de datos para análisis
muestra_datos = df[columna].dropna().head(200) # Muestra más grande, sin valores nulos
if muestra_datos.empty:
# Si no hay datos, usar solo el ancho del encabezado
ancho_ajustado = max(ancho_encabezado + 2, 8)
self.worksheet.set_column(col_idx, col_idx, ancho_ajustado)
continue
# Detectar el tipo de datos de la columna
tipo_columna = self._detectar_tipo_columna_para_ancho(muestra_datos)
# Calcular ancho según el tipo de datos
ancho_contenido = self._calcular_ancho_por_tipo(muestra_datos, tipo_columna)
# Aplicar límite al ancho del contenido
limite = limites.get(tipo_columna, {'min': 5, 'max': 50})
ancho_contenido_limited = min(ancho_contenido, limite['max'])
# Combinar ancho de encabezado y contenido limitado
ancho_total = max(ancho_encabezado, ancho_contenido_limited)
# Aplicar límites finales (solo mínimo, ya que el máximo ya se aplicó al contenido)
ancho_ajustado = max(limite['min'], ancho_total)
# Agregar padding mínimo
ancho_final = ancho_ajustado + 1
self.worksheet.set_column(col_idx, col_idx, ancho_final)
logger.debug(f"Columna '{columna}' ({tipo_columna}): ancho={ancho_final}")
def _detectar_tipo_columna_para_ancho(self, muestra_datos):
"""
Detecta el tipo de datos de una columna para optimizar el cálculo de ancho.
Args:
muestra_datos: Serie de pandas con muestra de datos
Returns:
str: Tipo detectado ('fecha', 'numero_entero', 'numero_decimal', 'porcentaje', 'texto')
"""
if muestra_datos.empty:
return 'texto'
# Verificar si son fechas/datetime
if muestra_datos.dtype.name.startswith('datetime') or isinstance(muestra_datos.iloc[0], (datetime, pd.Timestamp)):
return 'fecha'
# Verificar si son números
if pd.api.types.is_numeric_dtype(muestra_datos):
# Analizar si son enteros o decimales
valores_numericos = muestra_datos[pd.to_numeric(muestra_datos, errors='coerce').notna()]
if valores_numericos.empty:
return 'texto'
# Verificar si todos son enteros
son_enteros = all(isinstance(val, (int, np.integer)) or
(isinstance(val, (float, np.floating)) and val.is_integer())
for val in valores_numericos)
if son_enteros:
return 'numero_entero'
# Verificar si parecen porcentajes (valores entre 0 y 1)
valores_float = valores_numericos.astype(float)
if (valores_float >= 0).all() and (valores_float <= 1).all() and len(valores_numericos) > 5:
return 'porcentaje'
return 'numero_decimal'
# Para strings, verificar patrones específicos
if muestra_datos.dtype == 'object':
# Intentar convertir a números para detectar números como strings
valores_como_numeros = pd.to_numeric(muestra_datos, errors='coerce')
if not valores_como_numeros.isna().all():
# Si más del 80% se pueden convertir a números, tratarlos como números
porcentaje_numericos = valores_como_numeros.notna().sum() / len(muestra_datos)
if porcentaje_numericos > 0.8:
return 'numero_como_texto'
return 'texto'
def _calcular_ancho_por_tipo(self, muestra_datos, tipo_columna):
"""
Calcula el ancho necesario basado en el tipo de datos específico.
Args:
muestra_datos: Serie con muestra de datos
tipo_columna: Tipo de datos detectado
Returns:
int: Ancho calculado para el contenido
"""
if tipo_columna == 'fecha':
# Las fechas suelen tener formato fijo: "DD/MMM/YYYY" = 11 caracteres
return 12
elif tipo_columna == 'numero_entero':
# Para enteros, calcular basado en el valor máximo en la muestra
valores_numericos = pd.to_numeric(muestra_datos, errors='coerce').dropna()
if valores_numericos.empty:
return 8
max_valor = abs(valores_numericos).max()
if max_valor < 1000:
return 6 # Números pequeños