g360-cli 1.7.1 → 1.10.0

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 (54) hide show
  1. package/README.md +83 -8
  2. package/package.json +16 -6
  3. package/py/pyproject.toml +4 -4
  4. package/py/requirements.txt +4 -0
  5. package/py/src/g360_core/__init__.py +67 -4
  6. package/py/src/g360_core/__pycache__/__init__.cpython-312.pyc +0 -0
  7. package/py/src/g360_core/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/py/src/g360_core/__pycache__/batch_processor.cpython-312.pyc +0 -0
  9. package/py/src/g360_core/__pycache__/batch_processor.cpython-314.pyc +0 -0
  10. package/py/src/g360_core/__pycache__/commercial_engine.cpython-314.pyc +0 -0
  11. package/py/src/g360_core/__pycache__/logger.cpython-312.pyc +0 -0
  12. package/py/src/g360_core/__pycache__/logger.cpython-314.pyc +0 -0
  13. package/py/src/g360_core/__pycache__/pipeline.cpython-312.pyc +0 -0
  14. package/py/src/g360_core/__pycache__/pipeline.cpython-314.pyc +0 -0
  15. package/py/src/g360_core/__pycache__/processor.cpython-312.pyc +0 -0
  16. package/py/src/g360_core/__pycache__/processor.cpython-314.pyc +0 -0
  17. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-312.pyc +0 -0
  18. package/py/src/g360_core/__pycache__/processor_segmentacion.cpython-314.pyc +0 -0
  19. package/py/src/g360_core/__pycache__/processor_sku.cpython-312.pyc +0 -0
  20. package/py/src/g360_core/__pycache__/processor_sku.cpython-314.pyc +0 -0
  21. package/py/src/g360_core/__pycache__/scanner.cpython-312.pyc +0 -0
  22. package/py/src/g360_core/__pycache__/scanner.cpython-314.pyc +0 -0
  23. package/py/src/g360_core/__pycache__/utils.cpython-312.pyc +0 -0
  24. package/py/src/g360_core/__pycache__/utils.cpython-314.pyc +0 -0
  25. package/py/src/g360_core/batch_processor.py +120 -0
  26. package/py/src/g360_core/commercial_engine.py +305 -0
  27. package/py/src/g360_core/logger.py +40 -0
  28. package/py/src/g360_core/pipeline.py +578 -0
  29. package/py/src/g360_core/processor.py +634 -0
  30. package/py/src/g360_core/processor_segmentacion.py +859 -0
  31. package/py/src/g360_core/processor_sku.py +427 -0
  32. package/py/src/g360_core/scanner.py +218 -0
  33. package/py/src/g360_core/utils.py +435 -0
  34. package/src/cli.js +35 -2
  35. package/src/commands/addon.js +188 -0
  36. package/src/commands/ingest.js +187 -0
  37. package/src/commands/scan.js +90 -0
  38. package/src/commands/validate.js +150 -0
  39. package/src/lib/python_runner.js +89 -0
  40. package/py/src/g360_core/flet/__init__.py +0 -3
  41. package/py/src/g360_core/flet/ingestion_panel.py +0 -218
  42. package/py/src/g360_core/ingestion.py +0 -480
  43. package/src/assets/engine/g360-data-validator.js +0 -44
  44. package/src/assets/engine/g360-engine.js +0 -12
  45. package/src/assets/engine/g360-field-mapper.js +0 -35
  46. package/src/assets/engine/g360-skill-audit.mjs +0 -37
  47. package/src/assets/engine/g360-skill-meta-evaluator.mjs +0 -33
  48. package/src/lib/assets.js +0 -38
  49. package/src/lib/checksum.js +0 -27
  50. package/src/lib/config.js +0 -23
  51. package/src/lib/offline.js +0 -33
  52. package/src/lib/presenter.js +0 -24
  53. package/src/lib/rollback.js +0 -49
  54. package/src/lib/theme.js +0 -30
@@ -1,218 +0,0 @@
1
- import threading
2
- from pathlib import Path
3
-
4
- import flet as ft
5
- import pandas as pd
6
-
7
- from g360_core.ingestion import estabilizar_excel_crudo
8
-
9
-
10
- class IngestionPanel(ft.Container):
11
- def __init__(self, theme, on_data_loaded=None):
12
- super().__init__()
13
- self.theme = theme
14
- self.on_data_loaded = on_data_loaded
15
- self.df: pd.DataFrame | None = None
16
- self.metadata: dict | None = None
17
- self._build()
18
-
19
- def _build(self):
20
- self.file_picker = ft.FilePicker(on_result=self._on_file_result)
21
- self.status_text = ft.Text(
22
- "Selecciona un archivo .xls o .xlsx del ERP",
23
- size=14,
24
- color=self.theme.muted,
25
- )
26
- self.stats_container = ft.Column(spacing=4, visible=False)
27
- self.alertas_container = ft.Column(spacing=2, visible=False)
28
- self.preview_table = ft.Column(scroll=ft.ScrollMode.AUTO, visible=False)
29
-
30
- self.content = ft.Column(
31
- controls=[
32
- self.file_picker,
33
- ft.Row(
34
- controls=[
35
- self.theme.accent_button(
36
- text="Cargar Archivo Excel",
37
- on_click=self._open_picker,
38
- ),
39
- ft.Container(width=12),
40
- self.status_text,
41
- ],
42
- alignment=ft.MainAxisAlignment.START,
43
- vertical_alignment=ft.CrossAxisAlignment.CENTER,
44
- ),
45
- ft.Container(height=12),
46
- self.stats_container,
47
- ft.Container(height=8),
48
- self.alertas_container,
49
- ft.Container(height=8),
50
- self.preview_table,
51
- ],
52
- spacing=0,
53
- )
54
- self.bgcolor = self.theme.surface
55
- self.border_radius = self.theme.rounded
56
- self.padding = 20
57
- self.expand = True
58
-
59
- def _open_picker(self, e):
60
- self.file_picker.pick_files(
61
- file_type=ft.FilePickerFileType.CUSTOM,
62
- allowed_extensions=["xls", "xlsx", "xlsm"],
63
- dialog_title="Seleccionar reporte del ERP",
64
- )
65
-
66
- def _on_file_result(self, e: ft.FilePickerResultEvent):
67
- if not e.files:
68
- self.status_text.value = "No se selecciono ningun archivo."
69
- self.status_text.color = self.theme.warning
70
- self.update()
71
- return
72
-
73
- archivo = e.files[0]
74
- ruta = archivo.path
75
- nombre = archivo.name
76
- self.status_text.value = f"Procesando: {nombre}..."
77
- self.status_text.color = self.theme.text
78
- self.update()
79
-
80
- self._show_loading(True)
81
-
82
- def _procesar():
83
- try:
84
- df, metadata = estabilizar_excel_crudo(ruta)
85
- self.df = df
86
- self.metadata = metadata
87
- self._mostrar_resultado(df, metadata, nombre)
88
- if self.on_data_loaded:
89
- self.on_data_loaded(df, metadata)
90
- except Exception as exc:
91
- self.status_text.value = f"Error: {exc}"
92
- self.status_text.color = self.theme.error
93
- self._show_loading(False)
94
- self.update()
95
-
96
- thread = threading.Thread(target=_procesar, daemon=True)
97
- thread.start()
98
-
99
- def _show_loading(self, visible: bool):
100
- pass
101
-
102
- def _mostrar_resultado(self, df: pd.DataFrame, metadata: dict, nombre: str):
103
- self.status_text.value = f"OK: {nombre} ({len(df):,} filas)"
104
- self.status_text.color = self.theme.success
105
-
106
- columnas = metadata.get("columnas_finales", metadata.get("columnas", []))
107
- transformaciones = metadata.get("transformaciones", [])
108
- alertas = metadata.get("alertas", [])
109
- columnas_nuevas = metadata.get("columnas_nuevas", [])
110
-
111
- self.stats_container.controls = [
112
- ft.Text("Resumen de Ingesta", size=16, weight=ft.FontWeight.BOLD, color=self.theme.text),
113
- ft.Text(f"Filas estabilizadas: {len(df):,}", size=13, color=self.theme.muted),
114
- ft.Text(f"Columnas originales: {metadata.get('filas_originales', 0)}", size=13, color=self.theme.muted),
115
- ft.Text(f"Columnas finales: {len(columnas)}", size=13, color=self.theme.muted),
116
- ft.Text(f"Archivo: {metadata.get('archivo', 'N/A')}", size=13, color=self.theme.muted),
117
- ft.Text(f"Moneda: {metadata.get('moneda', 'N/A')}", size=13, color=self.theme.muted),
118
- ft.Divider(color=self.theme.bg, height=8),
119
- ]
120
-
121
- if columnas_nuevas:
122
- nuevas_text = ft.Text(
123
- "Columnas derivadas:",
124
- size=13, weight=ft.FontWeight.BOLD, color=self.theme.accent,
125
- )
126
- chips = ft.Row(
127
- controls=[
128
- ft.Container(
129
- content=ft.Text(col, size=10, color=self.theme.bg),
130
- bgcolor=self.theme.accent,
131
- border_radius=12,
132
- padding=ft.padding.only(left=8, right=8, top=3, bottom=3),
133
- )
134
- for col in columnas_nuevas
135
- ],
136
- spacing=6,
137
- wrap=True,
138
- )
139
- self.stats_container.controls.extend([nuevas_text, chips])
140
-
141
- if transformaciones:
142
- self.stats_container.controls.append(
143
- ft.Divider(color=self.theme.bg, height=8)
144
- )
145
- self.stats_container.controls.append(
146
- ft.Text("Transformaciones:", size=13, weight=ft.FontWeight.BOLD, color=self.theme.text)
147
- )
148
- for t in transformaciones[-8:]:
149
- self.stats_container.controls.append(
150
- ft.Row(
151
- controls=[
152
- ft.Container(
153
- content=ft.Text(">", size=11, color=self.theme.accent),
154
- width=16,
155
- ),
156
- ft.Text(t, size=11, color=self.theme.muted),
157
- ],
158
- spacing=0,
159
- )
160
- )
161
-
162
- self.stats_container.visible = True
163
-
164
- if alertas:
165
- self.alertas_container.controls = [
166
- ft.Divider(color=self.theme.bg, height=4),
167
- ft.Text("Alertas:", size=13, weight=ft.FontWeight.BOLD, color=self.theme.warning),
168
- ]
169
- for a in alertas:
170
- self.alertas_container.controls.append(
171
- ft.Row(
172
- controls=[
173
- ft.Icon(ft.icons.WARNING_AMBER_ROUNDED, size=14, color=self.theme.warning),
174
- ft.Text(a, size=11, color=self.theme.warning),
175
- ],
176
- spacing=4,
177
- )
178
- )
179
- self.alertas_container.visible = True
180
-
181
- filas_preview = df.head(5)
182
- columnas_preview = columnas[:8]
183
-
184
- data_rows = []
185
- for _, row in filas_preview.iterrows():
186
- cells = []
187
- for col in columnas_preview:
188
- val = row.get(col, "")
189
- v_str = str(val)[:30] if not pd.isna(val) else "-"
190
- cells.append(
191
- ft.DataCell(ft.Text(v_str, size=10, color=self.theme.text))
192
- )
193
- data_rows.append(ft.DataRow(cells=cells))
194
-
195
- header_cells = [
196
- ft.DataColumn(ft.Text(col[:16], size=10, color=self.theme.accent, weight=ft.FontWeight.BOLD))
197
- for col in columnas_preview
198
- ]
199
-
200
- tabla = ft.DataTable(
201
- columns=header_cells,
202
- rows=data_rows,
203
- heading_text_color=self.theme.accent,
204
- horizontal_margin=4,
205
- column_spacing=16,
206
- )
207
-
208
- self.preview_table.controls = [
209
- ft.Text("Vista previa (5 primeras filas):", size=13, weight=ft.FontWeight.BOLD, color=self.theme.text),
210
- ft.Container(
211
- content=tabla,
212
- bgcolor=self.theme.bg,
213
- border_radius=8,
214
- padding=8,
215
- ),
216
- ]
217
- self.preview_table.visible = True
218
- self.update()
@@ -1,480 +0,0 @@
1
- import re
2
- import unicodedata
3
- from pathlib import Path
4
-
5
- import numpy as np
6
- import pandas as pd
7
-
8
-
9
- _KEYWORDS_ID = [
10
- "sku", "codigo", "cod_art", "articulo", "id_cliente", "doc_cliente",
11
- "id_vendedor", "id_linea", "id_grupo", "id_tipo", "id_familia",
12
- "id_articulo", "cod_sucursal", "serie_doc", "nro_doc", "id_guia",
13
- "id_pedido", "id_localidad_ubigeo",
14
- ]
15
- _KEYWORDS_TEXTO = [
16
- "linea", "nombre", "razon_social", "descripcion", "departamento",
17
- "provincia", "distrito", "vendedor", "condicion", "division", "estado",
18
- "canal", "moneda", "referencia", "grupo", "tipo", "familia", "sucursal",
19
- ]
20
- _KEYWORDS_DINERO = [
21
- "neto", "bruto", "venta", "valor", "total", "soles", "dolares",
22
- ]
23
- _KEYWORDS_CANTIDAD = [
24
- "cantidad",
25
- ]
26
- _KEYWORDS_FECHA = ["fecha", "fec_", "venc"]
27
-
28
- _MAPA_TPO_DOC = {
29
- "NCR": "NOTA DE CREDITO",
30
- "NDB": "NOTA DE DEBITO",
31
- "BDI": "BIEN / BOLETA",
32
- "FAC": "FACTURA",
33
- "NV": "NOTA DE VENTA",
34
- "GV": "GUIA DE VENTA",
35
- "FC": "FACTURA CREDITO",
36
- "NC": "NOTA DE CREDITO",
37
- "ND": "NOTA DE DEBITO",
38
- "BO": "BOLETA",
39
- }
40
-
41
- _MAPA_MESES = {
42
- "ENERO": 1, "FEBRERO": 2, "MARZO": 3, "ABRIL": 4, "MAYO": 5, "JUNIO": 6,
43
- "JULIO": 7, "AGOSTO": 8, "SETIEMBRE": 9, "OCTUBRE": 10, "NOVIEMBRE": 11, "DICIEMBRE": 12,
44
- "ENE": 1, "FEB": 2, "MAR": 3, "ABR": 4, "MAY": 5, "JUN": 6,
45
- "JUL": 7, "AGO": 8, "SET": 9, "OCT": 10, "NOV": 11, "DIC": 12,
46
- "ENERO": 1, "FEBRERO": 2, "MARZO": 3, "ABRIL": 4, "MAYO": 5, "JUNIO": 6,
47
- "JULIO": 7, "AGOSTO": 8, "SEPTIEMBRE": 9, "OCTUBRE": 10, "NOVIEMBRE": 11, "DICIEMBRE": 12,
48
- }
49
-
50
- _PATRON_DIRECCION = re.compile(
51
- r'^(JR\.?\s|AV\.?\s|AVENIDA\s|CALLE\s|PASAJE\s|PSJE\.?\s|'
52
- r'CARRETERA\s|CARRE\.?\s|MZA\.?\s|LOTE\s|URB\.?\s|'
53
- r'FUNDO\s|SECTOR\s|PARQUE\s|PLAZA\s|ALAMEDA\s|JR|AV)\b',
54
- re.IGNORECASE,
55
- )
56
-
57
- _PATRON_REFERENCIA = re.compile(
58
- r'^([A-Za-z]+)(\d+)[/.-]?(\d{1,4})?[/.-]?(\d+)?$'
59
- )
60
-
61
-
62
- def _limpiar_cabecera(col: str) -> str:
63
- if not isinstance(col, str):
64
- col = str(col)
65
- col = col.strip().lower()
66
- col = "".join(
67
- c for c in unicodedata.normalize("NFD", col)
68
- if unicodedata.category(c) != "Mn"
69
- )
70
- col = col.replace(" ", "_").replace(".", "").replace("/", "_")
71
- col = re.sub(r"_+", "_", col)
72
- col = col.strip("_")
73
- return col
74
-
75
-
76
- def _coincide_keywords(nombre_col: str, keywords: list[str]) -> bool:
77
- return any(kw in nombre_col for kw in keywords)
78
-
79
-
80
- def _detectar_formato_numero(series: pd.Series) -> str:
81
- muestras = series.dropna().astype(str).head(50)
82
- coma_decimal = muestras.str.contains(r',\d{2}$', regex=True).sum()
83
- punto_decimal = muestras.str.contains(r'\.\d{2}$', regex=True).sum()
84
- if coma_decimal > punto_decimal:
85
- return "spring"
86
- if punto_decimal > coma_decimal:
87
- return "sap"
88
- return "simple"
89
-
90
-
91
- def _normalizar_monetario(series: pd.Series) -> pd.Series:
92
- if series.dtype != object:
93
- return pd.to_numeric(series, errors="coerce").fillna(0.0)
94
- fmt = _detectar_formato_numero(series)
95
- if fmt == "spring":
96
- series = series.astype(str).str.replace(".", "", regex=False)
97
- series = series.str.replace(",", ".", regex=False)
98
- elif fmt == "sap":
99
- series = series.astype(str).str.replace(",", "", regex=False)
100
- series = (
101
- series.astype(str)
102
- .str.replace("S/.", "", regex=False)
103
- .str.replace("S//.", "", regex=False)
104
- .str.replace("$", "", regex=False)
105
- .str.replace("US$", "", regex=False)
106
- .str.strip()
107
- )
108
- return pd.to_numeric(series, errors="coerce").fillna(0.0)
109
-
110
-
111
- def _parsear_referencia(series: pd.Series) -> pd.DataFrame:
112
- tipos: list[str | None] = []
113
- series_doc: list[str | None] = []
114
- periodos: list[str | None] = []
115
- numeros: list[str | None] = []
116
- for val in series:
117
- if pd.isna(val):
118
- tipos.append(None)
119
- series_doc.append(None)
120
- periodos.append(None)
121
- numeros.append(None)
122
- continue
123
- m = _PATRON_REFERENCIA.match(str(val).strip())
124
- if m:
125
- tipos.append(m.group(1).upper())
126
- series_doc.append(m.group(2))
127
- periodos.append(m.group(3))
128
- numeros.append(m.group(4))
129
- else:
130
- tipos.append(None)
131
- series_doc.append(None)
132
- periodos.append(None)
133
- numeros.append(None)
134
- return pd.DataFrame({
135
- "ref_tipo_doc": tipos,
136
- "ref_serie": series_doc,
137
- "ref_periodo": periodos,
138
- "ref_nro": numeros,
139
- })
140
-
141
-
142
- def _extraer_nombre_desde_direccion(
143
- direccion: str,
144
- lugares: list[str],
145
- ) -> str | None:
146
- tokens = re.split(r'[,\s]+', direccion.strip())
147
- tokens = [t for t in tokens if t]
148
- for t in reversed(tokens):
149
- t_clean = t.strip(".").upper()
150
- if t_clean in lugares:
151
- return t_clean.capitalize()
152
- if tokens:
153
- return tokens[-1].capitalize()
154
- return None
155
-
156
-
157
- def _es_direccion(val: object) -> bool:
158
- if pd.isna(val):
159
- return False
160
- s = str(val).strip()
161
- if not s:
162
- return False
163
- return bool(_PATRON_DIRECCION.match(s)) or "N°" in s or "NRO" in s.upper()
164
-
165
-
166
- def _parsear_sucursal_fila(
167
- val: object, lugares: list[str]
168
- ) -> tuple[str | None, str | None]:
169
- if pd.isna(val):
170
- return None, None
171
- s = str(val).strip()
172
- if not s:
173
- return None, None
174
- if _es_direccion(s):
175
- nombre = _extraer_nombre_desde_direccion(s, lugares)
176
- return nombre, s
177
- return s, None
178
-
179
-
180
- def _parsear_sucursal(
181
- df: pd.DataFrame,
182
- col_nom: str = "nom_sucursal",
183
- col_depto: str = "nom_departamento",
184
- col_prov: str = "nom_provincia",
185
- col_dist: str = "nom_distrito",
186
- ) -> pd.DataFrame:
187
- if col_nom not in df.columns:
188
- df["sucursal_nombre"] = None
189
- df["sucursal_direccion"] = None
190
- return df
191
-
192
- lugares: list[str] = []
193
- for c in [col_depto, col_prov, col_dist]:
194
- if c in df.columns:
195
- lugares.extend(
196
- str(v).strip().upper()
197
- for v in df[c].dropna().unique()
198
- if str(v).strip()
199
- )
200
- lugares = list(set(lugares))
201
-
202
- resultados = df[col_nom].apply(lambda v: _parsear_sucursal_fila(v, lugares))
203
- df["sucursal_nombre"] = resultados.apply(lambda x: x[0])
204
- df["sucursal_direccion"] = resultados.apply(lambda x: x[1])
205
-
206
- return df
207
-
208
-
209
- def _clasificar_doc_cliente(val: object) -> tuple[str, str]:
210
- limpio = re.sub(r"\D", "", str(val))
211
- if len(limpio) == 11:
212
- return limpio, "RUC"
213
- elif len(limpio) == 8:
214
- return limpio.zfill(8), "DNI"
215
- elif len(limpio) > 0:
216
- return limpio, "OTRO"
217
- return "", "SIN_DOC"
218
-
219
-
220
- def _parsear_mes(val: object) -> tuple[int | None, str | None]:
221
- if pd.isna(val):
222
- return None, None
223
- s = str(val).strip().upper()
224
- m = re.match(r'^(\d{1,2})[-/](\w+)$', s)
225
- if m:
226
- num = int(m.group(1))
227
- nombre = m.group(2).capitalize()
228
- return num, nombre
229
- if s.isdigit():
230
- return int(s), None
231
- if s in _MAPA_MESES:
232
- return _MAPA_MESES[s], s.capitalize()
233
- return None, None
234
-
235
-
236
- def _clasificar_transaccion(row: dict) -> str:
237
- cant = row.get("cantidad", 0)
238
- cant_fae = row.get("cantidad_fae", 0)
239
- if cant > 0 and cant_fae == 0:
240
- return "venta"
241
- if cant < 0 and cant_fae == 0:
242
- return "devolucion"
243
- if cant == 0 and cant_fae != 0:
244
- return "regularizacion"
245
- if cant != 0 and cant_fae != 0:
246
- return "mixto"
247
- return "indefinido"
248
-
249
-
250
- def estabilizar_excel_crudo(ruta_archivo: str | Path) -> tuple[pd.DataFrame, dict]:
251
- ruta = Path(ruta_archivo)
252
- if not ruta.exists():
253
- raise FileNotFoundError(f"Archivo no encontrado: {ruta}")
254
-
255
- sufijo = ruta.suffix.lower()
256
- if sufijo == ".xls":
257
- engine = "xlrd"
258
- elif sufijo in (".xlsx", ".xlsm"):
259
- engine = "openpyxl"
260
- else:
261
- engine = None
262
-
263
- transformaciones: list[str] = []
264
- alertas: list[str] = []
265
-
266
- df = pd.read_excel(ruta, engine=engine)
267
- transformaciones.append(f"lectura: {len(df)} filas, {len(df.columns)} columnas")
268
-
269
- cols_originales = list(df.columns)
270
-
271
- # ── 1. Normalizar cabeceras ──
272
- df.columns = [_limpiar_cabecera(c) for c in df.columns]
273
- transformaciones.append("cabeceras: normalizadas (lower, sin tildes, snake_case)")
274
-
275
- # ── 2. Resolver duplicados en cabeceras ──
276
- duplicados = [c for c in df.columns.tolist() if df.columns.tolist().count(c) > 1]
277
- if duplicados:
278
- vistos: dict[str, int] = {}
279
- nuevas: list[str] = []
280
- for c in df.columns:
281
- if c in vistos:
282
- vistos[c] += 1
283
- nuevas.append(f"{c}_{vistos[c]}")
284
- else:
285
- vistos[c] = 0
286
- nuevas.append(c)
287
- df.columns = nuevas
288
- transformaciones.append(f"cabeceras: {len(set(duplicados))} columna(s) duplicada(s) renombrada(s)")
289
-
290
- # ── 3. Eliminar filas totalmente vacías ──
291
- df = df.dropna(how="all").copy()
292
- transformaciones.append(f"filas: {len(df)} tras eliminar vacias totales")
293
-
294
- # ── 4. Parsear REFERENCIA ──
295
- if "referencia" in df.columns:
296
- ref_parsed = _parsear_referencia(df["referencia"])
297
- for c in ref_parsed.columns:
298
- df[c] = ref_parsed[c].values
299
- transformaciones.append("referencia: parseada en ref_tipo_doc, ref_serie, ref_periodo, ref_nro")
300
-
301
- # ── 5. Parsear NOM_SUCURSAL ──
302
- df = _parsear_sucursal(df)
303
- if "sucursal_nombre" in df.columns and "sucursal_direccion" in df.columns:
304
- transformaciones.append("sucursal: separada nombre/direccion")
305
-
306
- if ("nom_sucursal" in df.columns and "sucursal_nombre" in df.columns and
307
- df["sucursal_nombre"].isna().any()):
308
- alertas.append("sucursal: algunos nombres no pudieron determinarse")
309
-
310
- # ── 6. Normalizar MES ──
311
- if "mes" in df.columns:
312
- parsed = df["mes"].apply(_parsear_mes)
313
- df["mes_num"] = parsed.apply(lambda x: x[0])
314
- df["mes_nombre"] = parsed.apply(lambda x: x[1])
315
- transformaciones.append("mes: separado en mes_num y mes_nombre")
316
-
317
- # ── 7. Mapear TPO_DOC ──
318
- if "tpo_doc" in df.columns:
319
- df["tipo_doc_nombre"] = df["tpo_doc"].map(_MAPA_TPO_DOC)
320
- codigos_no_mapeados = df["tpo_doc"].dropna().unique()
321
- no_map = [c for c in codigos_no_mapeados if c not in _MAPA_TPO_DOC]
322
- if no_map:
323
- for c in no_map:
324
- limpio = re.sub(r'\d', '', str(c)).strip()
325
- _MAPA_TPO_DOC[c] = limpio if limpio else c
326
- df["tipo_doc_nombre"] = df["tpo_doc"].map(_MAPA_TPO_DOC).fillna("OTRO")
327
- alertas.append(f"tpo_doc: codigos sin mapeo: {', '.join(sorted(str(c) for c in no_map))}")
328
- transformaciones.append("tpo_doc: mapeado a nombre legible")
329
-
330
- # ── 8. Columnas ID ──
331
- for col in df.columns:
332
- if _coincide_keywords(col, _KEYWORDS_ID):
333
- was_numeric = pd.api.types.is_numeric_dtype(df[col])
334
- df[col] = (
335
- df[col].astype(str)
336
- .str.replace(r"\.0$", "", regex=True)
337
- .str.strip()
338
- )
339
- if was_numeric:
340
- vals_clean = df[col].replace("nan", np.nan).dropna()
341
- if len(vals_clean) > 0:
342
- max_len = int(vals_clean.str.len().max())
343
- has_shorter = (vals_clean.str.len() < max_len).any()
344
- if has_shorter and max_len > 1:
345
- df[col] = df[col].str.zfill(max_len)
346
- transformaciones.append(f"{col}: leading zeros restaurados (zfill={max_len})")
347
- df[col] = df[col].replace("nan", np.nan)
348
-
349
- # ── 9. Normalizar DOC_CLIENTE (despues de ID para tener strings limpios) ──
350
- for col in [c for c in df.columns if _coincide_keywords(c, ["doc_cliente"])]:
351
- parsed = df[col].apply(_clasificar_doc_cliente)
352
- tipo_col = f"tipo_{col}"
353
- clean_col = f"{col}_clean"
354
- df[clean_col] = parsed.apply(lambda x: x[0])
355
- df[tipo_col] = parsed.apply(lambda x: x[1])
356
- ruc_count = (df[tipo_col] == "RUC").sum()
357
- dni_count = (df[tipo_col] == "DNI").sum()
358
- transformaciones.append(
359
- f"{col}: normalizado ({ruc_count} RUC, {dni_count} DNI, "
360
- f"{(~df[tipo_col].isin(['RUC','DNI'])).sum()} otros)"
361
- )
362
-
363
- # ── 10. Columnas TEXTO ──
364
- for col in df.columns:
365
- if _coincide_keywords(col, _KEYWORDS_TEXTO):
366
- df[col] = df[col].astype(str).str.strip()
367
- df[col] = df[col].replace("nan", np.nan)
368
- df[col] = df[col].apply(
369
- lambda v: unicodedata.normalize("NFC", v) if isinstance(v, str) else v
370
- )
371
-
372
- # ── 11. Columnas DINERO ──
373
- for col in df.columns:
374
- if _coincide_keywords(col, _KEYWORDS_DINERO):
375
- antes = df[col].dtype
376
- df[col] = _normalizar_monetario(df[col])
377
- if str(antes) != str(df[col].dtype):
378
- transformaciones.append(f"{col}: monetario normalizado a float64")
379
- nulos = (df[col] == 0.0).sum()
380
- if nulos > 0:
381
- transformaciones.append(f"{col}: {nulos} valor(es) zero por NaN original")
382
-
383
- # ── 12. Columnas CANTIDAD ──
384
- cols_cantidad = [c for c in df.columns if _coincide_keywords(c, _KEYWORDS_CANTIDAD)]
385
- for col in cols_cantidad:
386
- df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0)
387
- transformaciones.append(f"{col}: forzado a float64, NaN → 0.0")
388
-
389
- # ── 13. cantidad + cantidad_fae → cantidad_total, tipo_transaccion ──
390
- if "cantidad" in df.columns:
391
- if "cantidad_fae" in df.columns:
392
- df["cantidad_total"] = df["cantidad"] + df["cantidad_fae"]
393
- transformaciones.append("cantidad_total: suma de cantidad + cantidad_fae")
394
- else:
395
- df["cantidad_total"] = df["cantidad"]
396
- df["tipo_transaccion"] = df.apply(_clasificar_transaccion, axis=1)
397
- t_counts = df["tipo_transaccion"].value_counts().to_dict()
398
- transformaciones.append(f"tipo_transaccion: {t_counts}")
399
-
400
- # ── 14. Columnas FECHA ──
401
- for col in df.columns:
402
- if _coincide_keywords(col, _KEYWORDS_FECHA) and col != "fec_":
403
- if pd.api.types.is_datetime64_any_dtype(df[col]):
404
- continue
405
- antes_nulos = df[col].isna().sum()
406
- df[col] = pd.to_datetime(df[col], errors="coerce", dayfirst=True)
407
- ahora_nulos = df[col].isna().sum()
408
- if ahora_nulos > antes_nulos:
409
- alertas.append(f"{col}: {ahora_nulos - antes_nulos} fecha(s) invalida(s) → NaT")
410
- df[col] = df[col].fillna(pd.Timestamp.now().normalize())
411
- transformaciones.append(f"{col}: datetime + NaT → today")
412
-
413
- # ── 15. Validar MONEDA ──
414
- if "moneda" in df.columns:
415
- monedas = df["moneda"].dropna().unique()
416
- monedas_str = [str(m).strip().upper() for m in monedas]
417
- validas = {"SOL", "S/.", "PEN", "S/."}
418
- if not all(m in validas for m in monedas_str):
419
- raras = [m for m in monedas_str if m not in validas]
420
- alertas.append(f"moneda: valores no esperados: {raras}")
421
- transformaciones.append(f"moneda: todas en soles ({', '.join(monedas_str)})")
422
- df["moneda_cod"] = "PEN"
423
-
424
- # ── 16. Purga filas basura ──
425
- for col in df.columns:
426
- if df[col].dtype == object:
427
- muestra = df[col].dropna().head(20).astype(str).tolist()
428
- if any(
429
- isinstance(v, str) and v.lower().strip() in ("total", "general", "totales", "acumulado")
430
- for v in muestra
431
- ):
432
- mascara = df[col].astype(str).str.lower().str.strip().isin(
433
- ["total", "general", "totales", "acumulado"]
434
- )
435
- n = mascara.sum()
436
- df = df[~mascara].copy()
437
- if n:
438
- transformaciones.append(f"basura: {n} fila(s) 'total/general' eliminadas")
439
-
440
- cols_id_presentes = [c for c in df.columns if _coincide_keywords(c, _KEYWORDS_ID)]
441
- if cols_id_presentes:
442
- mascara_ids_nulos = df[cols_id_presentes].isna().all(axis=1) | (
443
- df[cols_id_presentes].astype(str).replace("nan", "") == ""
444
- ).all(axis=1)
445
- mascara_dinero_cero = True
446
- cols_dinero_presentes = [
447
- c for c in df.columns if _coincide_keywords(c, _KEYWORDS_DINERO)
448
- ]
449
- if cols_dinero_presentes:
450
- mascara_dinero_cero = (df[cols_dinero_presentes] == 0.0).all(axis=1)
451
- cols_cant_presentes = [c for c in df.columns if _coincide_keywords(c, _KEYWORDS_CANTIDAD)]
452
- if cols_cant_presentes:
453
- mascara_dinero_cero = mascara_dinero_cero & (df[cols_cant_presentes] == 0.0).all(axis=1)
454
- mascara_fila_total = mascara_ids_nulos & (
455
- ~mascara_dinero_cero if isinstance(mascara_dinero_cero, pd.Series) else True
456
- )
457
- n_purge = mascara_fila_total.sum()
458
- df = df[~mascara_fila_total].copy()
459
- if n_purge:
460
- transformaciones.append(f"basura: {n_purge} fila(s) sin ID y con dinero eliminadas")
461
-
462
- df = df.reset_index(drop=True)
463
-
464
- metadata = {
465
- "archivo": str(ruta),
466
- "filas_originales": len(df) + sum(
467
- 1 for t in transformaciones if "eliminadas" in t
468
- ),
469
- "filas_estabilizadas": len(df),
470
- "columnas_originales": cols_originales,
471
- "columnas_finales": list(df.columns),
472
- "columnas_nuevas": [
473
- c for c in df.columns if c not in [_limpiar_cabecera(x) for x in cols_originales]
474
- ],
475
- "transformaciones": transformaciones,
476
- "alertas": alertas,
477
- "moneda": "PEN",
478
- }
479
-
480
- return df, metadata