Spaces:
Sleeping
Sleeping
| """ | |
| import_excel.py | |
| -------------- | |
| Importa datos de ventas desde cualquier Excel o CSV. | |
| Claude detecta automΓ‘ticamente quΓ© columna es fecha, producto, cantidad, etc. | |
| No requiere configuraciΓ³n previa β funciona con cualquier estructura. | |
| Uso: | |
| python -m scripts.import_excel --file ventas_2024.xlsx | |
| python -m scripts.import_excel --file ventas.csv --dry-run | |
| Requisitos: | |
| pip install -r requirements-ml.txt (incluye openpyxl, pandas) | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import logging | |
| import argparse | |
| from pathlib import Path | |
| from datetime import datetime | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| # Silenciar logs ruidosos | |
| logging.getLogger("httpx").setLevel(logging.WARNING) | |
| logging.getLogger("anthropic").setLevel(logging.WARNING) | |
| logger = logging.getLogger(__name__) | |
| # ββ DetecciΓ³n de columnas con Claude βββββββββββββββββββββββββββββββββββββββββ | |
| DETECTION_PROMPT = """Analiza los encabezados y datos de muestra de esta hoja de cΓ‘lculo e identifica quΓ© columna corresponde a cada campo de ventas. | |
| ENCABEZADOS: | |
| {headers} | |
| FILAS DE MUESTRA (primeras {n} filas): | |
| {sample_rows} | |
| Identifica quΓ© columna (usando el nombre exacto del encabezado) corresponde a: | |
| - date: columna con fechas completas (formato YYYY-MM-DD, DD/MM/YYYY, etc.). Si no existe una sola columna de fecha pero hay columnas separadas de aΓ±o y mes, usa null aquΓ. | |
| - year: columna con el aΓ±o (ANIO, AΓO, YEAR, etc.). null si no aplica. | |
| - month: columna con el mes numΓ©rico (MES, MONTH, etc.). null si no aplica. | |
| - product: columna con nombre del producto o Γtem (obligatoria) | |
| - quantity: columna con cantidad vendida β unidades, cajas, litros, kg, toneladas, etc. (obligatoria). Si hay varias columnas de cantidad, elige la mΓ‘s granular (cajas sobre toneladas). | |
| - unit_price: columna con precio unitario (opcional, null si no existe) | |
| - total_amount: columna con monto total de la venta (opcional, null si no existe) | |
| - channel: columna con canal de venta β retail, mayorista, distribuidor, etc. (opcional, null si no existe) | |
| - region: columna con regiΓ³n geogrΓ‘fica, zona o ciudad (opcional, null si no existe) | |
| IMPORTANTE sobre nΓΊmeros: algunas hojas usan coma como separador de miles (ej: "2,522" = dos mil quinientos veintidΓ³s). Indica si este es el caso en _thousands_sep. | |
| Devuelve ΓNICAMENTE un objeto JSON con este formato exacto: | |
| {{ | |
| "date": "<nombre_columna_o_null>", | |
| "year": "<nombre_columna_o_null>", | |
| "month": "<nombre_columna_o_null>", | |
| "product": "<nombre_columna_o_null>", | |
| "quantity": "<nombre_columna_o_null>", | |
| "unit_price": "<nombre_columna_o_null>", | |
| "total_amount": "<nombre_columna_o_null>", | |
| "channel": "<nombre_columna_o_null>", | |
| "region": "<nombre_columna_o_null>", | |
| "_unit": "<unidad de la cantidad: units/kg/liters/boxes/tons/other>", | |
| "_thousands_sep": true, | |
| "_reasoning": "<explicaciΓ³n breve de tus decisiones>" | |
| }} | |
| Si no puedes identificar product o quantity, devuelve null para ese campo. | |
| No inventes columnas β usa solo los nombres exactos que aparecen en los encabezados.""" | |
| def detect_columns(headers: list[str], sample_rows: list[list], filename: str) -> dict: | |
| """ | |
| Usa Claude para identificar quΓ© columna tiene quΓ© informaciΓ³n. | |
| Retorna un dict con el mapeo {campo_estΓ‘ndar: nombre_columna_original}. | |
| """ | |
| import anthropic | |
| client = anthropic.Anthropic() | |
| sample_text = "\n".join( | |
| f" Fila {i+1}: {dict(zip(headers, row))}" | |
| for i, row in enumerate(sample_rows[:8]) | |
| ) | |
| prompt = DETECTION_PROMPT.format( | |
| headers=headers, | |
| n=min(8, len(sample_rows)), | |
| sample_rows=sample_text, | |
| ) | |
| logger.info(f"[import_excel] enviando {len(headers)} columnas a Claude para detecciΓ³n...") | |
| response = client.messages.create( | |
| model="claude-haiku-4-5-20251001", # Haiku es suficiente para esta tarea | |
| max_tokens=512, | |
| messages=[{"role": "user", "content": prompt}], | |
| ) | |
| raw = response.content[0].text.strip() | |
| # Extraer JSON limpio (Claude puede aΓ±adir texto antes/despuΓ©s) | |
| start = raw.find("{") | |
| end = raw.rfind("}") + 1 | |
| if start == -1 or end == 0: | |
| raise ValueError(f"Claude no devolviΓ³ JSON vΓ‘lido: {raw[:200]}") | |
| mapping = json.loads(raw[start:end]) | |
| logger.info(f"[import_excel] columnas detectadas: {mapping}") | |
| return mapping | |
| # ββ Lectura del archivo βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def read_file(file_path: str) -> tuple[list[str], list[list]]: | |
| """Lee Excel o CSV y retorna (headers, rows).""" | |
| path = Path(file_path) | |
| suffix = path.suffix.lower() | |
| if suffix in (".xlsx", ".xls", ".xlsm"): | |
| try: | |
| import openpyxl | |
| except ImportError: | |
| raise ImportError("Instala openpyxl: pip install openpyxl") | |
| wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True) | |
| ws = wb.active | |
| rows = list(ws.iter_rows(values_only=True)) | |
| wb.close() | |
| if not rows: | |
| raise ValueError("El archivo Excel estΓ‘ vacΓo") | |
| headers = [str(h).strip() if h is not None else f"col_{i}" for i, h in enumerate(rows[0])] | |
| data_rows = [list(r) for r in rows[1:] if any(v is not None for v in r)] | |
| elif suffix == ".csv": | |
| import csv | |
| with open(file_path, newline="", encoding="utf-8-sig") as f: | |
| reader = csv.reader(f) | |
| rows = list(reader) | |
| if not rows: | |
| raise ValueError("El archivo CSV estΓ‘ vacΓo") | |
| headers = [h.strip() for h in rows[0]] | |
| data_rows = [r for r in rows[1:] if any(v.strip() for v in r)] | |
| else: | |
| raise ValueError(f"Formato no soportado: {suffix}. Usa .xlsx, .xls o .csv") | |
| logger.info(f"[import_excel] archivo leΓdo: {len(data_rows)} filas, {len(headers)} columnas") | |
| return headers, data_rows | |
| # ββ NormalizaciΓ³n de datos ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _parse_date(value) -> str | None: | |
| """Convierte cualquier formato de fecha a ISO (YYYY-MM-DD).""" | |
| if value is None: | |
| return None | |
| if isinstance(value, datetime): | |
| return value.date().isoformat() | |
| if hasattr(value, "isoformat"): # date object | |
| return value.isoformat() | |
| s = str(value).strip() | |
| if not s or s.lower() in ("none", "null", "nan"): | |
| return None | |
| formats = [ | |
| "%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", | |
| "%d-%m-%Y", "%Y/%m/%d", "%d.%m.%Y", | |
| "%Y-%m-%d %H:%M:%S", "%d/%m/%Y %H:%M", | |
| ] | |
| for fmt in formats: | |
| try: | |
| return datetime.strptime(s, fmt).date().isoformat() | |
| except ValueError: | |
| continue | |
| logger.warning(f"[import_excel] no se pudo parsear fecha: {value!r}") | |
| return None | |
| def _build_date_from_year_month(year_val, month_val) -> str | None: | |
| """Construye una fecha ISO desde columnas separadas de aΓ±o y mes.""" | |
| try: | |
| year = int(str(year_val).strip().split(".")[0]) | |
| month = int(str(month_val).strip().split(".")[0]) | |
| if 1 <= month <= 12 and 1990 <= year <= 2100: | |
| return f"{year:04d}-{month:02d}-01" | |
| except (ValueError, TypeError): | |
| pass | |
| return None | |
| def _parse_number(value, thousands_sep: bool = False) -> float | None: | |
| """ | |
| Convierte a float. | |
| thousands_sep=True: la coma es separador de miles (2,522 -> 2522). | |
| thousands_sep=False: la coma es separador decimal (2,52 -> 2.52). | |
| """ | |
| if value is None: | |
| return None | |
| if isinstance(value, (int, float)): | |
| return float(value) if value == value else None # NaN check | |
| s = str(value).strip().replace(" ", "") | |
| if not s or s.lower() in ("none", "null", "nan", "-"): | |
| return None | |
| if thousands_sep: | |
| s = s.replace(",", "") # 2,522 -> 2522 | |
| else: | |
| s = s.replace(",", ".") # 2,52 -> 2.52 | |
| try: | |
| return float(s) | |
| except ValueError: | |
| return None | |
| def normalize_rows( | |
| headers: list[str], | |
| data_rows: list[list], | |
| mapping: dict, | |
| filename: str, | |
| ) -> list[dict]: | |
| """ | |
| Convierte las filas crudas al esquema estΓ‘ndar de sales_data. | |
| Maneja fechas ΓΊnicas, fechas separadas aΓ±o/mes, y miles con coma. | |
| """ | |
| col_idx = {col: headers.index(col) for col in headers} | |
| thousands_sep = bool(mapping.get("_thousands_sep", False)) | |
| def get(row, field_name): | |
| col = mapping.get(field_name) | |
| if not col or col not in col_idx: | |
| return None | |
| idx = col_idx[col] | |
| return row[idx] if idx < len(row) else None | |
| records = [] | |
| skipped = 0 | |
| for i, row in enumerate(data_rows): | |
| # ββ Fecha ββββββββββββββββββββββββββββββββββββββββββββ | |
| if mapping.get("date"): | |
| date_val = _parse_date(get(row, "date")) | |
| elif mapping.get("year") and mapping.get("month"): | |
| date_val = _build_date_from_year_month(get(row, "year"), get(row, "month")) | |
| else: | |
| date_val = None | |
| product_val = str(get(row, "product") or "").strip() | |
| qty_val = _parse_number(get(row, "quantity"), thousands_sep=thousands_sep) | |
| if not date_val or not product_val or qty_val is None: | |
| skipped += 1 | |
| continue | |
| record = { | |
| "date": date_val, | |
| "product_name": product_val, | |
| "quantity": qty_val, | |
| "unit_price": _parse_number(get(row, "unit_price"), thousands_sep=thousands_sep), | |
| "total_amount": _parse_number(get(row, "total_amount"), thousands_sep=thousands_sep), | |
| "channel": str(get(row, "channel") or "").strip() or None, | |
| "region": str(get(row, "region") or "").strip() or None, | |
| "metadata": { | |
| "source_file": filename, | |
| "row_index": i + 2, | |
| "unit": mapping.get("_unit", "units"), | |
| }, | |
| } | |
| if record["total_amount"] is None and record["unit_price"] and record["quantity"]: | |
| record["total_amount"] = round(record["unit_price"] * record["quantity"], 4) | |
| records.append(record) | |
| if skipped: | |
| logger.warning(f"[import_excel] {skipped} filas omitidas (sin fecha, producto o cantidad)") | |
| return records | |
| # ββ Guardado en Supabase ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_to_supabase( | |
| records: list[dict], | |
| mapping: dict, | |
| filename: str, | |
| dry_run: bool = False, | |
| ) -> dict: | |
| """Inserta los registros normalizados en sales_data y registra en excel_imports.""" | |
| if dry_run: | |
| products = list({r["product_name"] for r in records}) | |
| logger.info(f"[import_excel] DRY RUN β {len(records)} filas no guardadas") | |
| return { | |
| "dry_run": True, | |
| "rows_would_import": len(records), | |
| "products_found": products, | |
| "mapping": mapping, | |
| } | |
| from database.supabase_client import get_supabase | |
| db = get_supabase() | |
| # Insertar en lotes de 200 | |
| batch_size = 200 | |
| inserted = 0 | |
| for i in range(0, len(records), batch_size): | |
| batch = records[i: i + batch_size] | |
| db.table("sales_data").insert(batch).execute() | |
| inserted += len(batch) | |
| logger.info(f"[import_excel] insertado lote {i // batch_size + 1} ({inserted}/{len(records)})") | |
| # Registrar importaciΓ³n | |
| products_found = list({r["product_name"] for r in records}) | |
| db.table("excel_imports").insert({ | |
| "filename": filename, | |
| "rows_imported": inserted, | |
| "column_mapping": mapping, | |
| "products_found": products_found, | |
| "status": "success", | |
| }).execute() | |
| return { | |
| "rows_imported": inserted, | |
| "products_found": products_found, | |
| "mapping": mapping, | |
| } | |
| # ββ Punto de entrada ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Importa Excel/CSV de ventas a Supabase con detecciΓ³n automΓ‘tica de columnas." | |
| ) | |
| parser.add_argument("--file", required=True, help="Ruta al archivo .xlsx, .xls o .csv") | |
| parser.add_argument("--dry-run", action="store_true", | |
| help="Solo muestra quΓ© importarΓa, sin guardar nada") | |
| args = parser.parse_args() | |
| file_path = args.file | |
| filename = Path(file_path).name | |
| if not Path(file_path).exists(): | |
| logger.error(f"Archivo no encontrado: {file_path}") | |
| sys.exit(1) | |
| print(f"\n{'='*60}") | |
| print(f" Importando: {filename}") | |
| print(f" Modo: {'DRY RUN (no guarda)' if args.dry_run else 'PRODUCCIΓN (guarda en Supabase)'}") | |
| print(f"{'='*60}\n") | |
| # 1. Leer archivo | |
| headers, data_rows = read_file(file_path) | |
| print(f"OK Archivo leΓdo: {len(data_rows):,} filas, {len(headers)} columnas") | |
| print(f" Columnas encontradas: {headers}\n") | |
| # 2. Detectar columnas con Claude | |
| mapping = detect_columns(headers, data_rows[:10], filename) | |
| print("OK Columnas detectadas por Claude:") | |
| for field, col in mapping.items(): | |
| if not field.startswith("_"): | |
| status = "OK" if col else "β" | |
| print(f" {status} {field:15} -> {col or '(no encontrado)'}") | |
| print(f"\n Unidad detectada: {mapping.get('_unit', 'desconocida')}") | |
| print(f" Razonamiento: {mapping.get('_reasoning', '')}\n") | |
| # Verificar campos obligatorios | |
| # Para fecha: puede ser columna ΓΊnica (date) o aΓ±o+mes (year+month) | |
| has_date = mapping.get("date") or (mapping.get("year") and mapping.get("month")) | |
| missing = [] | |
| if not has_date: | |
| missing.append("date (o year+month)") | |
| if not mapping.get("product"): | |
| missing.append("product") | |
| if not mapping.get("quantity"): | |
| missing.append("quantity") | |
| if missing: | |
| logger.error(f"No se detectaron columnas obligatorias: {missing}") | |
| logger.error("Verifica que el archivo tenga columnas de fecha, producto y cantidad.") | |
| sys.exit(1) | |
| # 3. Normalizar filas | |
| records = normalize_rows(headers, data_rows, mapping, filename) | |
| print(f"OK Filas normalizadas: {len(records):,}") | |
| # Mostrar muestra | |
| if records: | |
| print("\n Muestra de 3 filas procesadas:") | |
| for r in records[:3]: | |
| print(f" {r['date']} | {r['product_name'][:30]} | qty: {r['quantity']} | " | |
| f"total: {r.get('total_amount')}") | |
| # 4. Guardar (o simular) | |
| print() | |
| result = save_to_supabase(records, mapping, filename, dry_run=args.dry_run) | |
| if args.dry_run: | |
| print(f"OK DRY RUN completado β se importarΓan {result['rows_would_import']:,} filas") | |
| else: | |
| print(f"OK ImportaciΓ³n completada: {result['rows_imported']:,} filas en Supabase") | |
| print(f"\n Productos encontrados ({len(result.get('products_found', []))}):") | |
| for p in sorted(result.get("products_found", []))[:20]: | |
| print(f" β’ {p}") | |
| print(f"\n{'='*60}") | |
| print(" Siguiente paso: entrenar el modelo de demanda") | |
| print(" python -m scripts.train_demand_model") | |
| print(f"{'='*60}\n") | |
| if __name__ == "__main__": | |
| main() | |