commit a0081f54d2b0daa6055182ec33b67b2dd7f4606d Author: Ernesto Herrera Date: Thu May 21 08:18:13 2026 -0600 chore: initial commit del deploy de utilerias-recon-2 Incluye: - app.ipynb con las 10 pestanas (Conexion, Descargas, Analisis, NLP, KGS, CTM, Saldos Vencidos, DataStage, Estructuras SCAII, Valores). - launcher.py + run_dev.bat para lanzar Voila localmente. - docker-compose.yml para levantar Postgres 16 con healthcheck. - schema_registro.sql con las 27 tablas (Registro501..Registro701, RegistroInci/Resumen/Sel, base_numpartes). - requirements.txt con pandas, pyodbc, psycopg2-binary, sqlalchemy, scikit-learn, openpyxl, voila, ipywidgets, python-docx. - 6 manuales de usuario (CTM, SaldosVencidos, DataStage, EstructurasSCAII, Valores, GENPACT_V2 general) en .docx. - 6 generadores de manuales para regenerar los .docx tras editar. - .gitignore que excluye .env, .venv, outputs generados y caches. - README.md con instrucciones de setup en server. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..017aa79 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copia este archivo como .env y completa los valores reales. +# NO subas el .env al repositorio (esta en .gitignore). + +# Configuracion de conexion a PostgreSQL (DataStage) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=dbSat +DB_USER=servicemanager +DB_PASSWORD=cambia_esto + +# Configuracion de conexion a SQL Server (SCAII) +# SCAII_TRUSTED=yes -> usa Windows Authentication (ignora SCAII_USER/PASSWORD) +# SCAII_TRUSTED=no -> usa SCAII_USER y SCAII_PASSWORD +SCAII_SERVER=localhost +SCAII_DATABASE=" GENPACT-CORRECCION" +SCAII_TRUSTED=no +SCAII_USER=sa +SCAII_PASSWORD=cambia_esto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a8cfe1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Credenciales y entorno local +.env +*.env.local + +# Python +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ + +# Jupyter +.ipynb_checkpoints/ + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# IDE +.vscode/ +.idea/ +*.swp + +# OS +Thumbs.db +.DS_Store + +# Outputs generados por la app +app/Estructura_*.xlsx +app/Estructura_*.csv +app/plan_*.xlsx +app/plan_*.csv +app/Resultados_*.csv +app/Manual_*_[0-9]*.docx +app/Registro551.xlsx +app/_upload_*.xlsx +app/_*.py +app/app.ipynb.bak + +# Docker volumes locales +postgres_data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbc8784 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Utilerias RECON 2 + +Sistema de utilerias 2.0 para automatizacion de procesos aduaneros SCAII + +DataStage. Construido sobre Voila (Jupyter widgets) con backend dual: +SQL Server (SCAII) + PostgreSQL (DataStage en Docker). + +## Pestanas + +- **Conexion** — selecciona la base SCAII a conectar. +- **Descargas** — procesa facturas de exportacion contra saldos IMPO (paso 9/10). +- **Analisis Saldos** — graficas y pivotes de SSaldoTem. +- **Sustitutos NLP** — propone sustitutos en SPartesSustitutos via TF-IDF. +- **Descarga % KGS** — analiza cobertura de descargas en KGS. +- **CTM** — reasigna descargas de Cambio de Regimen a CTM. +- **Saldos Vencidos** — prorratea saldos IMPO vencidos en descargas EXPO. +- **DataStage** — carga archivos .asc del SAAI a tablas Registro* en Postgres. +- **Estructuras SCAII** — reportes CAT/Pedimentos/Facturas/Partidas a Excel. +- **Valores** — ajusta VALORTOTALME/MN de SPartidasExpo por prorrateo. + +## Stack + +| Capa | Tecnologia | +|---|---| +| UI | Voila + ipywidgets | +| Backend | Python 3.11+ (pandas, scikit-learn, pyodbc, psycopg2, sqlalchemy) | +| BD aduanera | SQL Server (SCAII) | +| BD DataStage | PostgreSQL 16 (en Docker) | +| Reportes | openpyxl, python-docx | + +## Requisitos del servidor + +- Windows 10 / 11 / Server con PowerShell. +- **Python 3.11+** con "Add to PATH" durante instalacion. +- **Docker Desktop** (para el contenedor de Postgres). +- **ODBC Driver 18 for SQL Server** (para conectar a SCAII). +- Acceso de red al servidor SQL Server donde vive SCAII. + +## Configuracion inicial + +### 1. Variables de entorno + +Copia `.env.example` a `.env` y completa los valores reales: + +```bash +cp .env.example .env +``` + +Edita `.env` con tu editor favorito y completa: + +- `DB_*` — credenciales del Postgres local (las que va a usar el contenedor). +- `SCAII_*` — credenciales del SQL Server donde vive SCAII. + +> Importante: `.env` NO se sube al repo (esta en `.gitignore`). + +### 2. Levantar Postgres en Docker + +```powershell +cd app +docker compose --env-file ..\.env up -d postgres-datastage +``` + +Espera unos segundos. Verifica con: + +```powershell +docker ps +``` + +Debes ver `postgres-datastage` con estatus `Up X seconds (healthy)`. + +### 3. Aplicar el schema de las tablas Registro* + +```powershell +docker cp schema_registro.sql postgres-datastage:/tmp/schema.sql +docker exec -it postgres-datastage psql -U servicemanager -d dbSat -f /tmp/schema.sql +``` + +(Ajusta `servicemanager` y `dbSat` segun tu `.env`.) + +Verifica: + +```powershell +docker exec -it postgres-datastage psql -U servicemanager -d dbSat -c "\dt" +``` + +Debes ver 27 tablas: `Registro501`, `Registro551`, `Registro701`, ..., `base_numpartes`. + +### 4. Arrancar la app + +```powershell +.\run_dev.bat +``` + +La primera vez tarda 2-3 minutos (crea `.venv`, instala dependencias). + +Cuando termine, abre el navegador en: + +``` +http://127.0.0.1:8866/ +``` + +## Comandos utiles + +```powershell +# Estado del contenedor +docker compose ps + +# Logs en vivo +docker compose logs -f postgres-datastage + +# Detener Postgres +docker compose stop + +# Volver a iniciar +docker compose start + +# Eliminar contenedor (mantiene los datos) +docker compose down + +# Eliminar contenedor + DATOS (CUIDADO) +docker compose down -v +``` + +## Estructura del proyecto + +``` +. +├── .env.example +├── .gitignore +├── README.md +└── app/ + ├── app.ipynb # notebook principal + ├── launcher.py # lanzador Voila + ├── run_dev.bat # script de arranque + ├── requirements.txt # deps Python + ├── docker-compose.yml # contenedor Postgres + ├── schema_registro.sql # DDL tablas Registro* + ├── Manual_*.docx # manuales de usuario + └── generar_manual_*.py # generadores de manuales +``` + +## Manuales + +Los `.docx` en `app/` son los manuales de usuario por modulo: + +- `Manual_GENPACT_V2.docx` — manual general (Conexion, Descargas, Analisis, NLP, KGS). +- `Manual_CTM.docx` — modulo CTM. +- `Manual_SaldosVencidos.docx` — modulo Saldos Vencidos. +- `Manual_DataStage.docx` — modulo DataStage. +- `Manual_EstructurasSCAII.docx` — modulo Estructuras SCAII. +- `Manual_Valores.docx` — modulo Valores. + +Para regenerarlos despues de editar el script correspondiente: + +```powershell +python app\generar_manual_valores.py +# (y los demas generar_manual_*.py segun el caso) +``` + +## Soporte + +Para problemas o cambios, abre un PR en este repositorio. +No hagas push directo a `main`. diff --git a/app/Manual_CTM.docx b/app/Manual_CTM.docx new file mode 100644 index 0000000..05615d7 Binary files /dev/null and b/app/Manual_CTM.docx differ diff --git a/app/Manual_DataStage.docx b/app/Manual_DataStage.docx new file mode 100644 index 0000000..a30eaa5 Binary files /dev/null and b/app/Manual_DataStage.docx differ diff --git a/app/Manual_EstructurasSCAII.docx b/app/Manual_EstructurasSCAII.docx new file mode 100644 index 0000000..a9ab472 Binary files /dev/null and b/app/Manual_EstructurasSCAII.docx differ diff --git a/app/Manual_GENPACT_V2.docx b/app/Manual_GENPACT_V2.docx new file mode 100644 index 0000000..56a62db Binary files /dev/null and b/app/Manual_GENPACT_V2.docx differ diff --git a/app/Manual_SaldosVencidos.docx b/app/Manual_SaldosVencidos.docx new file mode 100644 index 0000000..96ae539 Binary files /dev/null and b/app/Manual_SaldosVencidos.docx differ diff --git a/app/Manual_Valores.docx b/app/Manual_Valores.docx new file mode 100644 index 0000000..7642f7e Binary files /dev/null and b/app/Manual_Valores.docx differ diff --git a/app/app.ipynb b/app/app.ipynb new file mode 100644 index 0000000..88036bf --- /dev/null +++ b/app/app.ipynb @@ -0,0 +1,4701 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "imports", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "import os, sys, time, datetime as _dt, re, warnings\n", + "warnings.filterwarnings('ignore')\n", + "import pandas as pd\n", + "import numpy as np\n", + "import pyodbc\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.ticker as mtick\n", + "import ipywidgets as W\n", + "from IPython.display import display, clear_output, HTML\n", + "from dotenv import load_dotenv\n", + "for env_path in ['.env', '../.env']:\n", + " if os.path.exists(env_path):\n", + " load_dotenv(env_path); break\n", + "SCAII_SERVER = os.getenv('SCAII_SERVER', 'localhost')\n", + "SCAII_DB = os.getenv('SCAII_DATABASE', ' GENPACT-CORRECCION')\n", + "SCAII_USER = os.getenv('SCAII_USER', 'sa')\n", + "SCAII_PASSWORD = os.getenv('SCAII_PASSWORD', 'Soluciones01')\n", + "\n", + "def _make_conn_str(db_name=None):\n", + " db_part = f'DATABASE={{{db_name}}};' if db_name else ''\n", + " return (f\"DRIVER={{ODBC Driver 18 for SQL Server}};\"\n", + " f\"SERVER={SCAII_SERVER};{db_part}\"\n", + " f\"UID={SCAII_USER};PWD={SCAII_PASSWORD};\"\n", + " f\"Encrypt=yes;TrustServerCertificate=yes;MARS_Connection=yes;\")\n", + "\n", + "scaii_conn = None\n", + "DB_ACTUAL = None\n", + "CONEXION_OK = False\n", + "CONEXION_MSG = ''\n", + "\n", + "def conectar_a_db(db_name=None):\n", + " global scaii_conn, DB_ACTUAL, CONEXION_OK, CONEXION_MSG\n", + " try:\n", + " if scaii_conn is not None:\n", + " try: scaii_conn.close()\n", + " except Exception: pass\n", + " scaii_conn = pyodbc.connect(_make_conn_str(db_name))\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute('SELECT DB_NAME()')\n", + " DB_ACTUAL = cur.fetchone()[0]\n", + " CONEXION_OK = True\n", + " CONEXION_MSG = f'Conectado a [{DB_ACTUAL}] @ {SCAII_SERVER}'\n", + " _state.clear()\n", + " return True\n", + " except Exception as e:\n", + " scaii_conn = None; DB_ACTUAL = None\n", + " CONEXION_OK = False; CONEXION_MSG = f'ERROR conexion: {e}'\n", + " return False\n", + "\n", + "def listar_databases():\n", + " if scaii_conn is None:\n", + " try:\n", + " tmp = pyodbc.connect(_make_conn_str('master'))\n", + " except Exception:\n", + " try: tmp = pyodbc.connect(_make_conn_str(None))\n", + " except Exception: return []\n", + " try:\n", + " df = pd.read_sql(\"SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 ORDER BY name\", tmp)\n", + " tmp.close()\n", + " return df['name'].tolist()\n", + " except Exception:\n", + " try: tmp.close()\n", + " except: pass\n", + " return []\n", + " try:\n", + " df = pd.read_sql(\"SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 ORDER BY name\", scaii_conn)\n", + " return df['name'].tolist()\n", + " except Exception:\n", + " return []\n", + "\n", + "EPOCH_CLARION = _dt.date(1801, 1, 1)\n", + "def to_clarion(d):\n", + " if d is None or pd.isna(d): return None\n", + " if isinstance(d, str):\n", + " try: d = pd.to_datetime(d).date()\n", + " except Exception: return None\n", + " elif isinstance(d, pd.Timestamp): d = d.date()\n", + " elif isinstance(d, _dt.datetime): d = d.date()\n", + " return (d - EPOCH_CLARION).days + 4\n", + "\n", + "_state = {}\n", + "\n", + "# === Helper de progreso ===\n", + "class _Progress:\n", + " \"\"\"Wrapper de IntProgress. Si el widget es None, los metodos son no-op.\"\"\"\n", + " def __init__(self, widget=None):\n", + " self.w = widget\n", + " def setup(self, total, desc=''):\n", + " if self.w is None: return\n", + " self.w.min = 0\n", + " self.w.max = max(1, int(total))\n", + " self.w.value = 0\n", + " self.w.bar_style = 'info'\n", + " self.w.description = (desc or '')[:40]\n", + " def step(self, n=1, desc=None):\n", + " if self.w is None: return\n", + " try: self.w.value = min(self.w.value + n, self.w.max)\n", + " except Exception: pass\n", + " if desc is not None: self.w.description = desc[:40]\n", + " def done(self, desc='Listo'):\n", + " if self.w is None: return\n", + " self.w.value = self.w.max\n", + " self.w.bar_style = 'success'\n", + " self.w.description = desc[:40]\n", + " def error(self, desc='Error'):\n", + " if self.w is None: return\n", + " self.w.bar_style = 'danger'\n", + " self.w.description = desc[:40]\n", + "\n", + "conectar_a_db(SCAII_DB)\n", + "# ============================================================\n", + "# Postgres (DataStage)\n", + "# Conexion opcional: si falla, la pestania DataStage se deshabilita\n", + "# ============================================================\n", + "try:\n", + " import psycopg2 as _psycopg2\n", + " from sqlalchemy import create_engine as _create_engine\n", + " _PSYCOPG2_OK = True\n", + "except Exception as _e_imp:\n", + " _PSYCOPG2_OK = False\n", + " _DATASTAGE_IMPORT_ERR = str(_e_imp)\n", + "\n", + "PG_CONFIG = {\n", + " 'host': os.getenv('DB_HOST', '127.0.0.1'),\n", + " 'port': os.getenv('DB_PORT', '5432'),\n", + " 'dbname': os.getenv('DB_NAME', 'dbSat'),\n", + " 'user': os.getenv('DB_USER', 'postgres'),\n", + " 'password': os.getenv('DB_PASSWORD', ''),\n", + "}\n", + "DATASTAGE_ROOT = os.getenv('DATASTAGE_ROOT', '')\n", + "\n", + "pg_engine = None\n", + "DATASTAGE_OK = False\n", + "DATASTAGE_MSG = ''\n", + "\n", + "def conectar_postgres():\n", + " global pg_engine, DATASTAGE_OK, DATASTAGE_MSG\n", + " if not _PSYCOPG2_OK:\n", + " DATASTAGE_OK = False\n", + " DATASTAGE_MSG = f'psycopg2 no instalado: {_DATASTAGE_IMPORT_ERR}'\n", + " return False\n", + " try:\n", + " url = (f\"postgresql+psycopg2://{PG_CONFIG['user']}:{PG_CONFIG['password']}\"\n", + " f\"@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['dbname']}\")\n", + " pg_engine = _create_engine(url, pool_pre_ping=True, pool_recycle=1800)\n", + " with pg_engine.connect() as c:\n", + " c.execute(__import__('sqlalchemy').text('SELECT 1'))\n", + " DATASTAGE_OK = True\n", + " DATASTAGE_MSG = f\"Postgres conectado: {PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['dbname']}\"\n", + " return True\n", + " except Exception as e:\n", + " pg_engine = None\n", + " DATASTAGE_OK = False\n", + " DATASTAGE_MSG = f'Postgres no disponible: {e}'\n", + " return False\n", + "\n", + "conectar_postgres()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "logic-descargas", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "def _load_catalogos():\n", + " if 'df_boms_all' in _state: return\n", + " _state['df_facturas_NA'] = pd.read_sql(\n", + " \"SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO, ESTATUS FROM SFacExp WHERE ESTATUS='NA' ORDER BY FECHAFACTURA_ISO\", scaii_conn)\n", + " _state['df_partidas_all'] = pd.read_sql(\n", + " \"SELECT FACTURAEXPO, LINEA, NUMPARTE AS PT, CANTEXPO, ISNULL(PESONETO,0) AS PESONETO, ISNULL(PESONETOKGS,0) AS PESONETOKGS, MONTOIGIME FROM SPartidasExpo\", scaii_conn)\n", + " _state['df_boms_all'] = pd.read_sql(\"SELECT NUMPARTE AS PT, NUMPARTEBOM AS COMPONENTE_MP, CANTIDAD, UNIMED FROM SMatBOM\", scaii_conn)\n", + " _state['df_sustitutos_all']= pd.read_sql(\"SELECT NUMPARTE, NUMPARTESUSTITUTO AS COMPONENTE_ALTERNO, UNIDADMEDIDA1, UNIDADMEDIDA2 FROM SPartesSustitutos\", scaii_conn)\n", + " _state['df_partepais_all'] = pd.read_sql(\"SELECT FRACCION, PAIS, TIPOFRACCION, TASAIM FROM SPartePais\", scaii_conn)\n", + "\n", + "def _load_saldos_snapshot():\n", + " df = pd.read_sql(\"\"\"\n", + " SELECT NUMPARTE, FACTURAIMPO, PEDIMENTOIMPO, CLASE, PAISORIGEN, FRACCIONIMPO,\n", + " FECHAFACTURA_ISO AS FECHA_ENTRADA, FECHAVENC_ISO AS FECHA_EXPIRACION,\n", + " UMEXITENCIA AS UNIDAD_MEDIDA, CANTEXITENCIA AS CANT_LOTE_ORIG,\n", + " VALORIMPOMN, VALORIMPOME, PESONETO AS PESONETO_LOTE,\n", + " (CANTEXITENCIA - (CANTUSADA + CANTUSADADESP)) AS SALDO_DISPONIBLE\n", + " FROM SSaldoTem\n", + " WHERE (CANTEXITENCIA - (CANTUSADA + CANTUSADADESP)) > 0\n", + " \"\"\", scaii_conn)\n", + " df['UM_KEY'] = df['UNIDAD_MEDIDA'].fillna('').str.strip().str.upper()\n", + " return df\n", + "\n", + "def _consumir(saldos, idx_list, faltante, base_row, tipo, np_usado):\n", + " rows = []\n", + " for idx in idx_list:\n", + " if faltante <= 1e-9: break\n", + " disp = float(saldos.at[idx, 'SALDO_DISPONIBLE'])\n", + " if disp <= 1e-9: continue\n", + " consumo = min(faltante, disp)\n", + " cant_orig = float(saldos.at[idx, 'CANT_LOTE_ORIG'] or 0)\n", + " prop = (consumo / cant_orig) if cant_orig > 0 else 0.0\n", + " rows.append({**base_row,\n", + " 'NUMPARTE_USADO': np_usado, 'TIPO': tipo,\n", + " 'FACTURAIMPO_SALDO': saldos.at[idx, 'FACTURAIMPO'],\n", + " 'PEDIMENTOIMPO': saldos.at[idx, 'PEDIMENTOIMPO'],\n", + " 'CLASE': saldos.at[idx, 'CLASE'],\n", + " 'PAISMERCANCIA': saldos.at[idx, 'PAISORIGEN'],\n", + " 'FRACCION_SALDO': saldos.at[idx, 'FRACCIONIMPO'],\n", + " 'UNIMED_SALDO': saldos.at[idx, 'UNIDAD_MEDIDA'],\n", + " 'FECHA_ENTRADA': saldos.at[idx, 'FECHA_ENTRADA'],\n", + " 'FECHA_EXPIRACION': saldos.at[idx, 'FECHA_EXPIRACION'],\n", + " 'CANT_LOTE_ORIG': cant_orig,\n", + " 'CANT_DESCARGADA': consumo,\n", + " 'VALORMN': float(saldos.at[idx, 'VALORIMPOMN'] or 0) * prop,\n", + " 'VALORME': float(saldos.at[idx, 'VALORIMPOME'] or 0) * prop,\n", + " 'PESONETO_DESC': float(saldos.at[idx, 'PESONETO_LOTE'] or 0) * prop,\n", + " 'STATUS': 'OK'})\n", + " saldos.at[idx, 'SALDO_DISPONIBLE'] = disp - consumo\n", + " faltante -= consumo\n", + " return faltante, rows\n", + "\n", + "def _saldos_idx(saldos, numparte, um_keys, fecha_export):\n", + " um_match = saldos['UM_KEY'] == um_keys if isinstance(um_keys, str) else saldos['UM_KEY'].isin(um_keys)\n", + " mask = ((saldos['NUMPARTE'] == numparte) & um_match\n", + " & (saldos['SALDO_DISPONIBLE'] > 1e-9)\n", + " & (saldos['FECHA_ENTRADA'] <= fecha_export)\n", + " & (saldos['FECHA_EXPIRACION'] >= fecha_export))\n", + " return saldos[mask].sort_values('FECHA_ENTRADA').index.tolist()\n", + "\n", + "def _f(v):\n", + " if pd.isna(v): return None\n", + " if isinstance(v, str):\n", + " v = v.strip()\n", + " if v == '': return None\n", + " try: return float(v)\n", + " except ValueError: return None\n", + " try: return float(v)\n", + " except: return None\n", + "def _adv(v):\n", + " if pd.isna(v): return None\n", + " if isinstance(v, str):\n", + " v = v.strip()\n", + " if v == '': return None\n", + " try: return float(v)\n", + " except ValueError: return v\n", + " return v\n", + "def _s(v): return None if pd.isna(v) else v\n", + "def _f0(v):\n", + " r = _f(v); return 0.0 if r is None else r\n", + "\n", + "INSERT_DESC_SQL = \"\"\"INSERT INTO SDescargaT\n", + " (CONSECUTIVO, FACTEXPO, FACREFERENCIA, FACTIMPO, PEDIMENTOIMPO, PEDIMENTOEXPO, CLASE,\n", + " VALORMN, VALORME, PESONETO, PESOBRUTO, PAISMERCANCIA, FECHADESC, TIPOFRACCION,\n", + " NUMPARTE, CANTDESC, UNIMED, LINEAEXPO, PARTEORIGINAL, PORUTILERIA, TIPOMATEXPO,\n", + " MONTOIGI, ADVALOREMIMPO, TIPODESC)\n", + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\"\"\"\n", + "UPDATE_FACEXP_SQL = \"UPDATE SFacExp SET ESTATUS='AC' WHERE FACTURAEXPO=?\"\n", + "UPDATE_SALDO_SQL = \"\"\"UPDATE SSaldoTem\n", + " SET CANTUSADA=ISNULL(CANTUSADA,0)+?, VALORUSADOMN=ISNULL(VALORUSADOMN,0)+?,\n", + " VALORUSADOME=ISNULL(VALORUSADOME,0)+?, PESOUSADO=ISNULL(PESOUSADO,0)+?,\n", + " PESOBRUTOUSADO=ISNULL(PESOBRUTOUSADO,0)+?\n", + " WHERE FACTURAIMPO=? AND NUMPARTE=? AND UMEXITENCIA=?\"\"\"\n", + "\n", + "def _build_descarga_df(modo_calc='STANDARD', facturas_df=None, partidas_df=None, prev_dict=None, progress=None):\n", + " prog = _Progress(progress)\n", + " _load_catalogos()\n", + " saldos = _load_saldos_snapshot()\n", + " f_use = facturas_df if facturas_df is not None else _state['df_facturas_NA']\n", + " p_use = partidas_df if partidas_df is not None else _state['df_partidas_all']\n", + " b_all, s_all, pp_all = _state['df_boms_all'], _state['df_sustitutos_all'], _state['df_partepais_all']\n", + " partidas_por_factura = p_use.groupby('FACTURAEXPO')\n", + " boms_por_pt = b_all.groupby('PT')\n", + " sustitutos_por_comp = s_all.groupby('NUMPARTE')\n", + " partida_montoigi = p_use.set_index(['FACTURAEXPO','LINEA'])['MONTOIGIME'].to_dict()\n", + " partepais_dict = pp_all.drop_duplicates(subset=['FRACCION','PAIS'], keep='last').set_index(['FRACCION','PAIS'])[['TIPOFRACCION','TASAIM']].to_dict('index')\n", + " prog.setup(len(f_use), 'Procesando facturas')\n", + " all_rows = []\n", + " for i, (_, f) in enumerate(f_use.iterrows()):\n", + " factura, fecha_export, pedimento_ex = f['FACTURAEXPO'], f['FECHAFACTURA_ISO'], f.get('PEDIMENTOEXPO')\n", + " if factura not in partidas_por_factura.groups:\n", + " prog.step(); continue\n", + " for _, p in partidas_por_factura.get_group(factura).iterrows():\n", + " if p['PT'] not in boms_por_pt.groups: continue\n", + " linea = int(p['LINEA']) if pd.notna(p['LINEA']) else 0\n", + " montoigi = partida_montoigi.get((factura, p['LINEA']))\n", + " peso_neto = float(p.get('PESONETO', 0) or 0)\n", + " for _, c in boms_por_pt.get_group(p['PT']).iterrows():\n", + " comp = c['COMPONENTE_MP']\n", + " unimed_bom = (c['UNIMED'] or '').strip().upper()\n", + " if modo_calc == 'KG_PCT':\n", + " cant_req_total = (float(c['CANTIDAD']) / 100.0) * peso_neto\n", + " else:\n", + " cant_req_total = float(p['CANTEXPO']) * float(c['CANTIDAD'])\n", + " cant_prev = 0.0\n", + " if prev_dict is not None:\n", + " cant_prev = float(prev_dict.get((factura, linea, comp), 0.0))\n", + " cant_pend = cant_req_total - cant_prev\n", + " if prev_dict is not None and cant_pend <= 1e-9: continue\n", + " cant_req = cant_pend if prev_dict is not None else cant_req_total\n", + " base_row = {'FACTURAEXPO': factura, 'FECHA_FACTURAEXPO': fecha_export, 'PEDIMENTOEXPO': pedimento_ex,\n", + " 'LINEA': linea, 'PT': p['PT'], 'CANT_PT': float(p['CANTEXPO']),\n", + " 'PESONETO_PARTIDA': peso_neto, 'PCT_BOM': float(c['CANTIDAD']),\n", + " 'MONTOIGI': montoigi, 'COMPONENTE_MP': comp, 'UNIMED_BOM': unimed_bom,\n", + " 'CANT_REQUERIDA': cant_req, 'CANT_PREV': cant_prev}\n", + " faltante = cant_req\n", + " faltante, r = _consumir(saldos, _saldos_idx(saldos, comp, unimed_bom, fecha_export), faltante, base_row, 'ORIGINAL', comp)\n", + " all_rows.extend(r)\n", + " if faltante > 1e-9 and comp in sustitutos_por_comp.groups:\n", + " for _, sub in sustitutos_por_comp.get_group(comp).iterrows():\n", + " if faltante <= 1e-9: break\n", + " um_keys = [str(u).strip().upper() for u in [sub.get('UNIDADMEDIDA1'), sub.get('UNIDADMEDIDA2')] if u and str(u).strip()]\n", + " if not um_keys: continue\n", + " faltante, r = _consumir(saldos, _saldos_idx(saldos, sub['COMPONENTE_ALTERNO'], um_keys, fecha_export),\n", + " faltante, base_row, 'SUSTITUTO', sub['COMPONENTE_ALTERNO'])\n", + " all_rows.extend(r)\n", + " if faltante > 1e-9:\n", + " all_rows.append({**base_row, 'NUMPARTE_USADO': None, 'TIPO': 'FALTANTE',\n", + " 'FACTURAIMPO_SALDO': None, 'PEDIMENTOIMPO': None, 'CLASE': None,\n", + " 'PAISMERCANCIA': None, 'FRACCION_SALDO': None, 'UNIMED_SALDO': None,\n", + " 'FECHA_ENTRADA': None, 'FECHA_EXPIRACION': None, 'CANT_LOTE_ORIG': None,\n", + " 'CANT_DESCARGADA': faltante, 'VALORMN': None, 'VALORME': None, 'PESONETO_DESC': None,\n", + " 'STATUS': 'FALTANTE'})\n", + " prog.step()\n", + " df = pd.DataFrame(all_rows)\n", + " if not df.empty:\n", + " def _pp(row):\n", + " info = partepais_dict.get((row['FRACCION_SALDO'], row['PAISMERCANCIA']))\n", + " if info is None: return pd.Series([None, None])\n", + " return pd.Series([info.get('TIPOFRACCION'), info.get('TASAIM')])\n", + " df[['TIPOFRACCION','ADVALOREMIMPO']] = df.apply(_pp, axis=1)\n", + " df['FECHADESC_CLARION'] = df['FECHA_FACTURAEXPO'].apply(to_clarion)\n", + " prog.done('Pronostico listo')\n", + " return df\n", + "\n", + "def _build_cobertura(df):\n", + " df_d = df.copy()\n", + " df_d['cant_cubierta'] = df_d['CANT_DESCARGADA'].where(df_d['TIPO'].isin(['ORIGINAL','SUSTITUTO']), 0)\n", + " cob_comp = df_d.groupby(['FACTURAEXPO','FECHA_FACTURAEXPO','LINEA','PT','COMPONENTE_MP','UNIMED_BOM','CANT_REQUERIDA'],\n", + " as_index=False, dropna=False).agg(cant_cubierta=('cant_cubierta','sum'))\n", + " cob_comp['pct'] = ((cob_comp['cant_cubierta'] / cob_comp['CANT_REQUERIDA']).fillna(0) * 100).round(2).clip(upper=100)\n", + " cob_fact = cob_comp.groupby(['FACTURAEXPO','FECHA_FACTURAEXPO'], as_index=False, dropna=False).agg(\n", + " componentes_total=('COMPONENTE_MP','count'),\n", + " componentes_100pct=('pct', lambda s: (s >= 99.99).sum()),\n", + " pct_promedio=('pct','mean'))\n", + " cob_fact['pct_promedio'] = cob_fact['pct_promedio'].round(2)\n", + " return cob_fact\n", + "\n", + "def calcular_pronostico_paso8(progress=None):\n", + " df = _build_descarga_df(modo_calc='STANDARD', progress=progress)\n", + " cob = _build_cobertura(df)\n", + " _state['df_descarga_all'] = df\n", + " _state['cobertura_factura'] = cob\n", + " return df, cob\n", + "\n", + "def calcular_pronostico_paso12_kg(progress=None):\n", + " df = _build_descarga_df(modo_calc='KG_PCT', progress=progress)\n", + " cob = _build_cobertura(df)\n", + " _state['df_descarga_kg'] = df\n", + " _state['cobertura_factura_kg']= cob\n", + " return df, cob\n", + "\n", + "def _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=None):\n", + " prog = _Progress(progress)\n", + " if df_ins.empty:\n", + " log('Nada que insertar.'); prog.done('Nada que insertar'); return 0, 0, 0, []\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(\"SELECT ISNULL(MAX(CONSECUTIVO),0) FROM SDescargaT\")\n", + " next_consec = int(cur.fetchone()[0]) + 1\n", + " log(f'Proximo CONSECUTIVO: {next_consec}')\n", + " grupos = list(df_ins.groupby('FACTURAEXPO'))\n", + " prog.setup(len(grupos), 'Insertando facturas')\n", + " inserted, updated, saldos_upd, errores = 0, 0, 0, []\n", + " for factura, df_f in grupos:\n", + " cb, ib, sb = next_consec, inserted, saldos_upd\n", + " ok = True\n", + " try:\n", + " with scaii_conn.cursor() as cur:\n", + " for _, r in df_f.iterrows():\n", + " if not dry_run:\n", + " cur.execute(INSERT_DESC_SQL, (\n", + " next_consec, r['FACTURAEXPO'], r['FACTURAEXPO'],\n", + " r['FACTURAIMPO_SALDO'], _s(r['PEDIMENTOIMPO']),\n", + " _s(r['PEDIMENTOEXPO']), _s(r['CLASE']),\n", + " _f(r['VALORMN']), _f(r['VALORME']),\n", + " _f(r['PESONETO_DESC']), _f(r['PESONETO_DESC']),\n", + " _s(r['PAISMERCANCIA']),\n", + " int(r['FECHADESC_CLARION']) if pd.notna(r['FECHADESC_CLARION']) else None,\n", + " _s(r['TIPOFRACCION']), r['NUMPARTE_USADO'],\n", + " float(r['CANT_DESCARGADA']),\n", + " r['UNIMED_SALDO'] if pd.notna(r['UNIMED_SALDO']) else r['UNIMED_BOM'],\n", + " int(r['LINEA']) if pd.notna(r['LINEA']) else 0,\n", + " r['COMPONENTE_MP'], 1, '',\n", + " _f(r['MONTOIGI']), _adv(r['ADVALOREMIMPO']), '0 PARTE'))\n", + " cur.execute(UPDATE_SALDO_SQL, (\n", + " float(r['CANT_DESCARGADA']),\n", + " _f0(r['VALORMN']), _f0(r['VALORME']),\n", + " _f0(r['PESONETO_DESC']), _f0(r['PESONETO_DESC']),\n", + " r['FACTURAIMPO_SALDO'], r['NUMPARTE_USADO'],\n", + " r['UNIMED_SALDO'] if pd.notna(r['UNIMED_SALDO']) else r['UNIMED_BOM']))\n", + " saldos_upd += 1\n", + " next_consec += 1\n", + " inserted += 1\n", + " if not dry_run and do_update_status:\n", + " cur.execute(UPDATE_FACEXP_SQL, (factura,))\n", + " if not dry_run: scaii_conn.commit()\n", + " except Exception as e:\n", + " ok = False\n", + " if not dry_run: scaii_conn.rollback()\n", + " next_consec, inserted, saldos_upd = cb, ib, sb\n", + " errores.append((factura, str(e)))\n", + " if ok and do_update_status: updated += 1\n", + " prog.step()\n", + " prog.done(f'{inserted} insertados')\n", + " return inserted, updated, saldos_upd, errores\n", + "\n", + "def _ejecutar_descarga_NA(modo, dry_run, fecha_desde, fecha_hasta, log, key_df, key_cob, paso_label, progress=None):\n", + " if key_df not in _state:\n", + " log(f'ERROR: corre primero el pronostico del {paso_label}.'); return\n", + " df_da, cob = _state[key_df], _state[key_cob]\n", + " if modo == 'NATURAL':\n", + " elig = cob[cob['componentes_100pct'] == cob['componentes_total']]['FACTURAEXPO'].tolist()\n", + " else:\n", + " elig = cob['FACTURAEXPO'].tolist()\n", + " if fecha_desde or fecha_hasta:\n", + " df_cf = cob.copy()\n", + " df_cf['_f'] = pd.to_datetime(df_cf['FECHA_FACTURAEXPO'], errors='coerce')\n", + " if fecha_desde: df_cf = df_cf[df_cf['_f'] >= pd.to_datetime(fecha_desde)]\n", + " if fecha_hasta: df_cf = df_cf[df_cf['_f'] <= pd.to_datetime(fecha_hasta)]\n", + " rango = set(df_cf['FACTURAEXPO']); elig = [f for f in elig if f in rango]\n", + " df_na = pd.read_sql(\"SELECT FACTURAEXPO FROM SFacExp WHERE ESTATUS='NA'\", scaii_conn)\n", + " set_na = set(df_na['FACTURAEXPO'].astype(str).str.strip())\n", + " omitidas = [f for f in elig if str(f).strip() not in set_na]\n", + " elig = [f for f in elig if str(f).strip() in set_na]\n", + " if omitidas: log(f' Omitidas (ya AC): {len(omitidas)}')\n", + " log(f'Modo: {modo} | DRY_RUN: {dry_run} | Facturas elegibles: {len(elig)}')\n", + " mask = df_da['FACTURAEXPO'].isin(elig) & df_da['TIPO'].isin(['ORIGINAL','SUSTITUTO'])\n", + " df_ins = df_da[mask].copy()\n", + " log(f'Filas a insertar: {len(df_ins)}')\n", + " inserted, updated, saldos_upd, errores = _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=progress)\n", + " log(f'\\n=== RESUMEN {paso_label} ({modo}, DRY_RUN={dry_run}) ===')\n", + " log(f' Insertados : {inserted}')\n", + " log(f' Updates SSaldoTem : {saldos_upd}')\n", + " log(f' Facturas a AC : {updated}')\n", + " log(f' Errores : {len(errores)}')\n", + " for f, e in errores[:5]: log(f' {f}: {e}')\n", + "\n", + "def ejecutar_paso9(modo, dry_run, fecha_desde=None, fecha_hasta=None, log=print, progress=None):\n", + " _ejecutar_descarga_NA(modo, dry_run, fecha_desde, fecha_hasta, log,\n", + " 'df_descarga_all', 'cobertura_factura', 'paso 9', progress=progress)\n", + "\n", + "def ejecutar_paso12_kg(modo, dry_run, fecha_desde=None, fecha_hasta=None, log=print, progress=None):\n", + " _ejecutar_descarga_NA(modo, dry_run, fecha_desde, fecha_hasta, log,\n", + " 'df_descarga_kg', 'cobertura_factura_kg', 'paso 12 (% KGS)', progress=progress)\n", + "\n", + "def _ejecutar_complementaria(modo_comp, dry_run, fecha_desde, fecha_hasta, facturas_objetivo, log, modo_calc, paso_label, progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(1, 'Cargando facturas AC...')\n", + " sql_fact = \"SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO FROM SFacExp WHERE ESTATUS='AC'\"\n", + " params = []\n", + " if facturas_objetivo:\n", + " sql_fact += f\" AND FACTURAEXPO IN ({','.join(['?']*len(facturas_objetivo))})\"\n", + " params.extend(list(facturas_objetivo))\n", + " if fecha_desde:\n", + " sql_fact += \" AND FECHAFACTURA_ISO >= ?\"; params.append(fecha_desde)\n", + " if fecha_hasta:\n", + " sql_fact += \" AND FECHAFACTURA_ISO <= ?\"; params.append(fecha_hasta)\n", + " sql_fact += \" ORDER BY FECHAFACTURA_ISO\"\n", + " df_fact_ac = pd.read_sql(sql_fact, scaii_conn, params=params)\n", + " log(f'Facturas AC: {len(df_fact_ac)}')\n", + " if df_fact_ac.empty: prog.done('Sin facturas'); return\n", + " factura_list = df_fact_ac['FACTURAEXPO'].tolist()\n", + " prev_parts = []\n", + " for i in range(0, len(factura_list), 500):\n", + " chunk = factura_list[i:i+500]\n", + " ph = ','.join(['?']*len(chunk))\n", + " prev_parts.append(pd.read_sql(f\"\"\"\n", + " SELECT FACTEXPO AS FACTURAEXPO, LINEAEXPO AS LINEA, PARTEORIGINAL AS COMPONENTE_MP,\n", + " SUM(CANTDESC) AS CANT_PREV\n", + " FROM SDescargaT WHERE FACTEXPO IN ({ph})\n", + " GROUP BY FACTEXPO, LINEAEXPO, PARTEORIGINAL\n", + " \"\"\", scaii_conn, params=chunk))\n", + " df_desc_prev = pd.concat(prev_parts) if prev_parts else pd.DataFrame(columns=['FACTURAEXPO','LINEA','COMPONENTE_MP','CANT_PREV'])\n", + " df_desc_prev['LINEA'] = df_desc_prev['LINEA'].astype(int)\n", + " prev_dict = df_desc_prev.set_index(['FACTURAEXPO','LINEA','COMPONENTE_MP'])['CANT_PREV'].to_dict()\n", + " _load_catalogos()\n", + " df_comp = _build_descarga_df(modo_calc=modo_calc, facturas_df=df_fact_ac, prev_dict=prev_dict, progress=progress)\n", + " log(f'Filas calculadas: {len(df_comp)}')\n", + " if df_comp.empty: return\n", + " if modo_comp == 'NATURAL':\n", + " bad = set(df_comp[df_comp['TIPO']=='FALTANTE']['FACTURAEXPO'])\n", + " df_to_ins = df_comp[~df_comp['FACTURAEXPO'].isin(bad) & df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", + " log(f'Excluidas en NATURAL (con faltante): {len(bad)}')\n", + " else:\n", + " df_to_ins = df_comp[df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", + " log(f'Filas a insertar: {len(df_to_ins)}')\n", + " inserted, _, saldos_upd, errores = _do_inserts(df_to_ins, dry_run, log, do_update_status=False, progress=progress)\n", + " log(f'\\n=== RESUMEN {paso_label} ({modo_comp}, DRY_RUN={dry_run}) ===')\n", + " log(f' Insertados : {inserted}')\n", + " log(f' Updates SSaldoTem : {saldos_upd}')\n", + " log(f' Errores : {len(errores)}')\n", + " for f, e in errores[:5]: log(f' {f}: {e}')\n", + "\n", + "def ejecutar_paso10(modo_comp, dry_run, fecha_desde=None, fecha_hasta=None, facturas_objetivo=None, log=print, progress=None):\n", + " _ejecutar_complementaria(modo_comp, dry_run, fecha_desde, fecha_hasta, facturas_objetivo, log, 'STANDARD', 'paso 10', progress=progress)\n", + "\n", + "def ejecutar_paso12_complementaria_kg(modo_comp, dry_run, fecha_desde=None, fecha_hasta=None, facturas_objetivo=None, log=print, progress=None):\n", + " _ejecutar_complementaria(modo_comp, dry_run, fecha_desde, fecha_hasta, facturas_objetivo, log, 'KG_PCT', 'paso 12 complementaria (% KGS)', progress=progress)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "logic-analisis-y-nlp", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "def cargar_analisis_saldos(progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(4, 'Cargando SSaldoTem...')\n", + " df = pd.read_sql(\"\"\"\n", + " SELECT NUMPARTE, FACTURAIMPO, PEDIMENTOIMPO, FRACCIONIMPO, PAISORIGEN, CLASE, SECTOR, DESCRIPCIONE,\n", + " UMEXITENCIA AS UNIDAD_MEDIDA, FECHAFACTURA_ISO AS FECHA_ENTRADA, FECHAVENC_ISO AS FECHA_EXPIRACION,\n", + " CANTEXITENCIA AS CANT_LOTE, ISNULL(CANTUSADA,0) AS CANT_USADA, ISNULL(CANTUSADADESP,0) AS CANT_USADA_DESP,\n", + " (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_CANT,\n", + " ISNULL(VALORIMPOMN,0) AS VALOR_LOTE_MN, ISNULL(VALORIMPOME,0) AS VALOR_LOTE_ME,\n", + " ISNULL(VALORUSADOMN,0) AS VALOR_USADO_MN, ISNULL(VALORUSADOME,0) AS VALOR_USADO_ME,\n", + " (ISNULL(VALORIMPOMN,0)-ISNULL(VALORUSADOMN,0)) AS SALDO_VMN,\n", + " (ISNULL(VALORIMPOME,0)-ISNULL(VALORUSADOME,0)) AS SALDO_VME,\n", + " ISNULL(PESONETO,0) AS PESO_NETO_LOTE, ISNULL(PESOBRUTO,0) AS PESO_BRUTO_LOTE,\n", + " ISNULL(PESOUSADO,0) AS PESO_USADO, (ISNULL(PESONETO,0)-ISNULL(PESOUSADO,0)) AS SALDO_PESO_NETO\n", + " FROM SSaldoTem\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Procesando fechas...')\n", + " df['FECHA_ENTRADA'] = pd.to_datetime(df['FECHA_ENTRADA'], errors='coerce')\n", + " df['ANIO_ENTRADA'] = df['FECHA_ENTRADA'].dt.year\n", + " _state['df_saldos_full'] = df\n", + " prog.step(desc='SSaldoTem listo')\n", + " return df\n", + "\n", + "def calcular_por_anio_saldos(df):\n", + " return (df[df['SALDO_CANT']>0].groupby('ANIO_ENTRADA', as_index=False, dropna=False)\n", + " .agg(lotes=('NUMPARTE','count'), partes_unicas=('NUMPARTE','nunique'),\n", + " saldo_cant=('SALDO_CANT','sum'), saldo_vmn=('SALDO_VMN','sum'),\n", + " saldo_vme=('SALDO_VME','sum'), saldo_peso_neto=('SALDO_PESO_NETO','sum'))\n", + " .sort_values('ANIO_ENTRADA'))\n", + "\n", + "def calcular_impo_expo_anio(progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Cargando IMPO...')\n", + " df_imp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01')) AS ANIO,\n", + " COUNT(*) AS partidas_impo, COUNT(DISTINCT NUMPARTE) AS partes_impo,\n", + " SUM(PESONETO) AS peso_neto_impo, SUM(VALORIMPOME) AS valor_me_impo\n", + " FROM SPartidasImpo WHERE FECHAFACTURA IS NOT NULL\n", + " GROUP BY YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01'))\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Cargando EXPO...')\n", + " df_exp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(f.FECHAFACTURA_ISO) AS ANIO,\n", + " COUNT(*) AS partidas_expo, COUNT(DISTINCT p.NUMPARTE) AS partes_expo,\n", + " SUM(p.PESONETO) AS peso_neto_expo, SUM(p.VALORTOTALME) AS valor_me_expo\n", + " FROM SPartidasExpo p INNER JOIN SFacExp f ON f.FACTURAEXPO = p.FACTURAEXPO\n", + " WHERE f.FECHAFACTURA_ISO IS NOT NULL\n", + " GROUP BY YEAR(f.FECHAFACTURA_ISO)\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Comparando...')\n", + " cmp = (df_imp.merge(df_exp, on='ANIO', how='outer').fillna(0).sort_values('ANIO').reset_index(drop=True))\n", + " cmp['ANIO'] = cmp['ANIO'].astype(int)\n", + " for c in ['partidas_impo','partes_impo','partidas_expo','partes_expo']: cmp[c] = cmp[c].astype(int)\n", + " cmp['dif_peso'] = (cmp['peso_neto_expo'] - cmp['peso_neto_impo']).round(2)\n", + " cmp['dif_valor_me'] = (cmp['valor_me_expo'] - cmp['valor_me_impo']).round(2)\n", + " cmp['ratio_peso_expo_impo'] = (cmp['peso_neto_expo'] / cmp['peso_neto_impo'].replace(0, np.nan)).round(4)\n", + " cmp['ratio_valor_expo_impo'] = (cmp['valor_me_expo'] / cmp['valor_me_impo'].replace(0, np.nan)).round(4)\n", + " prog.done('Comparativo listo')\n", + " return df_imp, df_exp, cmp\n", + "\n", + "\n", + "\n", + "def graficar_impo_expo(cmp):\n", + " plt.close('all')\n", + " fig, axes = plt.subplots(2, 1, figsize=(11, 8))\n", + " fig.suptitle('IMPO vs EXPO por aanio‚±o', fontsize=14, fontweight='bold', y=1.0)\n", + " x = cmp['ANIO'].astype(int).values\n", + " xpos = np.arange(len(x)); ancho = 0.4\n", + " cI, cE = '#1976D2', '#F57C00'\n", + " def lab(ax, bars, color, fmt='{:,.0f}'):\n", + " for b in bars:\n", + " h = b.get_height()\n", + " if h > 0:\n", + " ax.text(b.get_x()+b.get_width()/2, h, fmt.format(h),\n", + " ha='center', va='bottom', fontsize=7, color=color, rotation=90)\n", + " ax = axes[0]\n", + " b1 = ax.bar(xpos-ancho/2, cmp['peso_neto_impo'], ancho, label='IMPO', color=cI)\n", + " b2 = ax.bar(xpos+ancho/2, cmp['peso_neto_expo'], ancho, label='EXPO', color=cE)\n", + " ax.set_title('Peso neto'); ax.set_xticks(xpos); ax.set_xticklabels(x)\n", + " ax.yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + " ax.legend(loc='upper left'); ax.grid(axis='y', linestyle=':', alpha=0.5)\n", + " ax.set_ylim(top=ax.get_ylim()[1]*1.18); lab(ax, b1, cI); lab(ax, b2, cE)\n", + " ax = axes[1]\n", + " b1 = ax.bar(xpos-ancho/2, cmp['valor_me_impo'], ancho, label='IMPO', color=cI)\n", + " b2 = ax.bar(xpos+ancho/2, cmp['valor_me_expo'], ancho, label='EXPO', color=cE)\n", + " ax.set_title('Valor ME (USD)'); ax.set_xticks(xpos); ax.set_xticklabels(x); ax.set_xlabel('Aanio‚±o')\n", + " ax.yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'${v:,.0f}'))\n", + " ax.legend(loc='upper left'); ax.grid(axis='y', linestyle=':', alpha=0.5)\n", + " ax.set_ylim(top=ax.get_ylim()[1]*1.18); lab(ax, b1, cI, '${:,.0f}'); lab(ax, b2, cE, '${:,.0f}')\n", + " plt.tight_layout()\n", + " return fig\n", + "\n", + "def graficar_saldos_anio(por_anio):\n", + " plt.close('all')\n", + " fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n", + " fig.suptitle('Saldo disponible por aanio‚±o de entrada', fontsize=13, fontweight='bold')\n", + " x = por_anio['ANIO_ENTRADA'].astype(int).astype(str).values\n", + " axes[0].bar(x, por_anio['saldo_cant'], color='#42A5F5')\n", + " axes[0].set_title('Cantidad'); axes[0].grid(axis='y', linestyle=':', alpha=0.5)\n", + " axes[0].yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + " for i, v in enumerate(por_anio['saldo_cant'].values):\n", + " if v > 0: axes[0].text(i, v, f'{v:,.0f}', ha='center', va='bottom', fontsize=8, rotation=90)\n", + " axes[1].bar(x, por_anio['saldo_vmn'], color='#FFA726', label='MN', alpha=0.85)\n", + " axes[1].bar(x, por_anio['saldo_vme'], color='#7E57C2', label='ME', alpha=0.55)\n", + " axes[1].set_title('Valor (MN + ME)'); axes[1].legend(); axes[1].grid(axis='y', linestyle=':', alpha=0.5)\n", + " axes[1].yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'${v:,.0f}'))\n", + " plt.tight_layout()\n", + " return fig\n", + "\n", + "def calcular_pesos_por_anio(progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(4, 'Cargando IMPO...')\n", + " df_imp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01')) AS ANIO,\n", + " SUM(PESONETO) AS peso_impo\n", + " FROM SPartidasImpo WHERE FECHAFACTURA IS NOT NULL\n", + " GROUP BY YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01'))\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Cargando EXPO...')\n", + " df_exp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(f.FECHAFACTURA_ISO) AS ANIO,\n", + " SUM(p.PESONETO) AS peso_expo\n", + " FROM SPartidasExpo p INNER JOIN SFacExp f ON f.FACTURAEXPO = p.FACTURAEXPO\n", + " WHERE f.FECHAFACTURA_ISO IS NOT NULL\n", + " GROUP BY YEAR(f.FECHAFACTURA_ISO)\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Cargando CONSUMIDO...')\n", + " df_cons = pd.read_sql(\"\"\"\n", + " SELECT YEAR(FECHAFACTURA_ISO) AS ANIO,\n", + " SUM(ISNULL(PESOUSADO,0)) AS peso_consumido\n", + " FROM SSaldoTem WHERE FECHAFACTURA_ISO IS NOT NULL\n", + " GROUP BY YEAR(FECHAFACTURA_ISO)\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Cargando DESCARGAS...')\n", + " df_desc = pd.read_sql(\"\"\"\n", + " SELECT YEAR(f.FECHAFACTURA_ISO) AS ANIO,\n", + " SUM(ISNULL(d.PESONETO,0)) AS peso_descargas\n", + " FROM SDescargaT d INNER JOIN SFacExp f ON f.FACTURAEXPO = d.FACTEXPO\n", + " WHERE f.FECHAFACTURA_ISO IS NOT NULL\n", + " GROUP BY YEAR(f.FECHAFACTURA_ISO)\n", + " \"\"\", scaii_conn)\n", + " cmp = (df_imp.merge(df_exp, on='ANIO', how='outer')\n", + " .merge(df_cons, on='ANIO', how='outer')\n", + " .merge(df_desc, on='ANIO', how='outer')\n", + " .fillna(0).sort_values('ANIO').reset_index(drop=True))\n", + " cmp['ANIO'] = cmp['ANIO'].astype(int)\n", + " for c in ['peso_impo','peso_expo','peso_consumido','peso_descargas']:\n", + " cmp[c] = cmp[c].round(2)\n", + " prog.done('Pesos por anio listos')\n", + " return cmp\n", + "\n", + "def graficar_pesos_anio(cmp):\n", + " plt.close('all')\n", + " fig, ax = plt.subplots(figsize=(13, 5.5))\n", + " fig.suptitle('Peso por aanio‚±o anio‚ IMPO / EXPO / CONSUMIDO / DESCARGAS', fontsize=13, fontweight='bold')\n", + " x = cmp['ANIO'].astype(int).values\n", + " xpos = np.arange(len(x)); ancho = 0.2\n", + " cI, cE, cC, cD = '#1976D2', '#F57C00', '#43A047', '#8E24AA'\n", + " b1 = ax.bar(xpos-1.5*ancho, cmp['peso_impo'], ancho, label='IMPO', color=cI)\n", + " b2 = ax.bar(xpos-0.5*ancho, cmp['peso_expo'], ancho, label='EXPO', color=cE)\n", + " b3 = ax.bar(xpos+0.5*ancho, cmp['peso_consumido'], ancho, label='CONSUMIDO', color=cC)\n", + " b4 = ax.bar(xpos+1.5*ancho, cmp['peso_descargas'], ancho, label='DESCARGAS', color=cD)\n", + " ax.set_xticks(xpos); ax.set_xticklabels(x); ax.set_xlabel('Aanio‚±o')\n", + " ax.set_ylabel('Peso neto')\n", + " ax.yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + " ax.legend(loc='upper left'); ax.grid(axis='y', linestyle=':', alpha=0.5)\n", + " ax.set_ylim(top=ax.get_ylim()[1]*1.18)\n", + " def lab(bars, color):\n", + " for b in bars:\n", + " h = b.get_height()\n", + " if h > 0:\n", + " ax.text(b.get_x()+b.get_width()/2, h, f'{h:,.0f}',\n", + " ha='center', va='bottom', fontsize=6, color=color, rotation=90)\n", + " lab(b1, cI); lab(b2, cE); lab(b3, cC); lab(b4, cD)\n", + " plt.tight_layout()\n", + " return fig\n", + "\n", + "def calcular_cantidades_por_anio(progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(2, 'Cargando cantidades IMPO...')\n", + " df_imp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01')) AS ANIO,\n", + " COUNT(*) AS partidas_impo,\n", + " SUM(CANTIMPO) AS cantidad_impo\n", + " FROM SPartidasImpo WHERE FECHAFACTURA IS NOT NULL\n", + " GROUP BY YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01'))\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Cargando cantidades EXPO...')\n", + " df_exp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(f.FECHAFACTURA_ISO) AS ANIO,\n", + " COUNT(*) AS partidas_expo,\n", + " SUM(p.CANTEXPO) AS cantidad_expo\n", + " FROM SPartidasExpo p INNER JOIN SFacExp f ON f.FACTURAEXPO = p.FACTURAEXPO\n", + " WHERE f.FECHAFACTURA_ISO IS NOT NULL\n", + " GROUP BY YEAR(f.FECHAFACTURA_ISO)\n", + " \"\"\", scaii_conn)\n", + " cmp = (df_imp.merge(df_exp, on='ANIO', how='outer').fillna(0).sort_values('ANIO').reset_index(drop=True))\n", + " cmp['ANIO'] = cmp['ANIO'].astype(int)\n", + " for c in ['partidas_impo','partidas_expo']: cmp[c] = cmp[c].astype(int)\n", + " cmp['cantidad_impo'] = cmp['cantidad_impo'].round(2)\n", + " cmp['cantidad_expo'] = cmp['cantidad_expo'].round(2)\n", + " cmp['diferencia'] = (cmp['cantidad_expo'] - cmp['cantidad_impo']).round(2)\n", + " prog.done('Cantidades por aanio‚±o listas')\n", + " return cmp\n", + "\n", + "def graficar_cantidades_anio(cmp):\n", + " plt.close('all')\n", + " fig, ax = plt.subplots(figsize=(12, 5.5))\n", + " fig.suptitle('Cantidades por aanio‚±o anio‚ IMPO vs EXPO', fontsize=13, fontweight='bold')\n", + " x = cmp['ANIO'].astype(int).values\n", + " xpos = np.arange(len(x)); ancho = 0.4\n", + " cI, cE = '#1976D2', '#F57C00'\n", + " b1 = ax.bar(xpos-ancho/2, cmp['cantidad_impo'], ancho, label='IMPO', color=cI)\n", + " b2 = ax.bar(xpos+ancho/2, cmp['cantidad_expo'], ancho, label='EXPO', color=cE)\n", + " ax.set_xticks(xpos); ax.set_xticklabels(x); ax.set_xlabel('Aanio‚±o')\n", + " ax.set_ylabel('Cantidad (suma de unidades, varias UM)')\n", + " ax.yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + " ax.legend(loc='upper left'); ax.grid(axis='y', linestyle=':', alpha=0.5)\n", + " ax.set_ylim(top=ax.get_ylim()[1]*1.18)\n", + " def lab(bars, color):\n", + " for b in bars:\n", + " h = b.get_height()\n", + " if h > 0:\n", + " ax.text(b.get_x()+b.get_width()/2, h, f'{h:,.0f}',\n", + " ha='center', va='bottom', fontsize=7, color=color, rotation=90)\n", + " lab(b1, cI); lab(b2, cE)\n", + " plt.tight_layout()\n", + " return fig\n", + "\n", + "def calcular_cantidades_por_anio_um(progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(2, 'Cargando cantidades IMPO por UM...')\n", + " df_imp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01')) AS ANIO,\n", + " UPPER(LTRIM(RTRIM(UNIMED))) AS UM,\n", + " COUNT(*) AS partidas_impo,\n", + " SUM(CANTIMPO) AS cantidad_impo\n", + " FROM SPartidasImpo WHERE FECHAFACTURA IS NOT NULL\n", + " GROUP BY YEAR(DATEADD(DAY, FECHAFACTURA-4, '1801-01-01')),\n", + " UPPER(LTRIM(RTRIM(UNIMED)))\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='Cargando cantidades EXPO por UM...')\n", + " df_exp = pd.read_sql(\"\"\"\n", + " SELECT YEAR(f.FECHAFACTURA_ISO) AS ANIO,\n", + " UPPER(LTRIM(RTRIM(p.UNIMED))) AS UM,\n", + " COUNT(*) AS partidas_expo,\n", + " SUM(p.CANTEXPO) AS cantidad_expo\n", + " FROM SPartidasExpo p INNER JOIN SFacExp f ON f.FACTURAEXPO = p.FACTURAEXPO\n", + " WHERE f.FECHAFACTURA_ISO IS NOT NULL\n", + " GROUP BY YEAR(f.FECHAFACTURA_ISO),\n", + " UPPER(LTRIM(RTRIM(p.UNIMED)))\n", + " \"\"\", scaii_conn)\n", + " cmp = (df_imp.merge(df_exp, on=['ANIO','UM'], how='outer').fillna(0)\n", + " .sort_values(['ANIO','UM']).reset_index(drop=True))\n", + " cmp['ANIO'] = cmp['ANIO'].astype(int)\n", + " cmp['UM'] = cmp['UM'].fillna('').astype(str)\n", + " for c in ['partidas_impo','partidas_expo']: cmp[c] = cmp[c].astype(int)\n", + " cmp['cantidad_impo'] = cmp['cantidad_impo'].round(2)\n", + " cmp['cantidad_expo'] = cmp['cantidad_expo'].round(2)\n", + " cmp['diferencia'] = (cmp['cantidad_expo'] - cmp['cantidad_impo']).round(2)\n", + " prog.done('Cantidades por aanio‚±o + UM listas')\n", + " return cmp\n", + "\n", + "def graficar_cantidades_anio_um(cmp, top_ums=None):\n", + " plt.close('all')\n", + " if top_ums is None:\n", + " # Top 4 UMs por volumen total\n", + " totales = cmp.groupby('UM')[['cantidad_impo','cantidad_expo']].sum().sum(axis=1).sort_values(ascending=False)\n", + " top_ums = totales.head(4).index.tolist()\n", + " filt = cmp[cmp['UM'].isin(top_ums)].copy()\n", + " if filt.empty:\n", + " fig, ax = plt.subplots(figsize=(10, 3))\n", + " ax.text(0.5, 0.5, 'Sin datos', ha='center', va='center')\n", + " ax.axis('off')\n", + " return fig\n", + " n = len(top_ums)\n", + " fig, axes = plt.subplots(n, 1, figsize=(12, 3.2*n), squeeze=False)\n", + " fig.suptitle('Cantidades por aanio‚±o (separado por UM, top {} UMs)'.format(n), fontsize=13, fontweight='bold', y=1.0)\n", + " for idx, um in enumerate(top_ums):\n", + " sub = filt[filt['UM'] == um].sort_values('ANIO')\n", + " ax = axes[idx][0]\n", + " x = sub['ANIO'].astype(int).astype(str).values\n", + " xpos = np.arange(len(x)); ancho = 0.4\n", + " b1 = ax.bar(xpos-ancho/2, sub['cantidad_impo'], ancho, label='IMPO', color='#1976D2')\n", + " b2 = ax.bar(xpos+ancho/2, sub['cantidad_expo'], ancho, label='EXPO', color='#F57C00')\n", + " ax.set_title(f'UM = {um or \"(sin UM)\"}', fontsize=11, fontweight='bold')\n", + " ax.set_xticks(xpos); ax.set_xticklabels(x)\n", + " ax.yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + " ax.legend(loc='upper left'); ax.grid(axis='y', linestyle=':', alpha=0.5)\n", + " ax.set_ylim(top=ax.get_ylim()[1]*1.18 if ax.get_ylim()[1] > 0 else 1)\n", + " def lab(bars, color):\n", + " for b in bars:\n", + " h = b.get_height()\n", + " if h > 0:\n", + " ax.text(b.get_x()+b.get_width()/2, h, f'{h:,.0f}',\n", + " ha='center', va='bottom', fontsize=7, color=color, rotation=90)\n", + " lab(b1, '#1976D2'); lab(b2, '#F57C00')\n", + " plt.tight_layout()\n", + " return fig\n", + "\n", + "def exportar_excel_analisis(df, por_anio, df_imp, df_exp, cmp, cmp_pesos=None, cmp_cantidades=None, cmp_cantidades_um=None):\n", + " out = f'analisis_ssaldotem_{_dt.datetime.now().strftime(\"%Y%m%d_%H%M%S\")}.xlsx'\n", + " with pd.ExcelWriter(out, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='SSaldoTem_Detalle', index=False)\n", + " por_anio.to_excel(w, sheet_name='Por_Anio', index=False)\n", + " df_imp.to_excel(w, sheet_name='IMPO_x_Anio', index=False)\n", + " df_exp.to_excel(w, sheet_name='EXPO_x_Anio', index=False)\n", + " cmp.to_excel(w, sheet_name='IMPO_vs_EXPO', index=False)\n", + " if cmp_pesos is not None and not cmp_pesos.empty:\n", + " cmp_pesos.to_excel(w, sheet_name='Pesos_x_Anio', index=False)\n", + " if cmp_cantidades is not None and not cmp_cantidades.empty:\n", + " cmp_cantidades.to_excel(w, sheet_name='Cantidades_x_Anio', index=False)\n", + " if cmp_cantidades_um is not None and not cmp_cantidades_um.empty:\n", + " cmp_cantidades_um.to_excel(w, sheet_name='Cantidades_Anio_UM', index=False)\n", + " return os.path.abspath(out)\n", + "\n", + "def generar_sustitutos_nlp(min_sim=0.80, top_n=3, dry_run=True, log=print, progress=None):\n", + " from sklearn.feature_extraction.text import TfidfVectorizer\n", + " from sklearn.metrics.pairwise import cosine_similarity\n", + " prog = _Progress(progress)\n", + " prog.setup(5, 'Cargando catalogo SPartes...')\n", + " df_spartes = pd.read_sql(\"\"\"\n", + " SELECT NUMPARTE, DESCRIPCIONE, FRACCION, UNIMED FROM SPartes\n", + " WHERE DESCRIPCIONE IS NOT NULL AND LTRIM(RTRIM(DESCRIPCIONE)) <> ''\n", + " \"\"\", scaii_conn).drop_duplicates(subset='NUMPARTE').reset_index(drop=True)\n", + " log(f'Catalogo SPartes: {len(df_spartes):,}')\n", + " prog.step(desc='Cargando componentes BOM...')\n", + " df_comp = pd.read_sql(\"SELECT DISTINCT NUMPARTEBOM AS NUMPARTE FROM SMatBOM WHERE NUMPARTEBOM IS NOT NULL\", scaii_conn)\n", + " df_comp = df_comp.merge(df_spartes, on='NUMPARTE', how='left')\n", + " df_comp = df_comp[df_comp['DESCRIPCIONE'].notna()].reset_index(drop=True)\n", + " log(f'Componentes con descripcion: {len(df_comp):,}')\n", + " if df_comp.empty:\n", + " prog.done('Sin componentes'); log('Sin componentes para procesar.'); return\n", + " prog.step(desc='Vectorizando TF-IDF...')\n", + " def limpiar(t):\n", + " if pd.isna(t) or str(t).strip() == '': return ''\n", + " t = re.sub(r'[^\\w\\s]', ' ', str(t).upper().strip())\n", + " return re.sub(r'\\s+', ' ', t).strip()\n", + " def texto(row): return f\"{limpiar(row['DESCRIPCIONE'])} {str(row['UNIMED'] or '').upper().strip()}\".strip()\n", + " df_spartes['texto'] = df_spartes.apply(texto, axis=1)\n", + " df_comp['texto'] = df_comp.apply(texto, axis=1)\n", + " vec = TfidfVectorizer(ngram_range=(1,2), sublinear_tf=True, min_df=1, max_features=80000)\n", + " vec.fit(pd.concat([df_spartes['texto'], df_comp['texto']], ignore_index=True))\n", + " cat_mat = vec.transform(df_spartes['texto'])\n", + " upper = df_spartes['NUMPARTE'].astype(str).str.upper().values\n", + " # Reset progreso para el calculo de similitud por lote\n", + " total_batches = max(1, (len(df_comp) + 199) // 200)\n", + " prog.setup(total_batches, 'Calculando similitud...')\n", + " rows = []\n", + " BATCH = 200\n", + " for s in range(0, len(df_comp), BATCH):\n", + " e = min(s+BATCH, len(df_comp))\n", + " bm = vec.transform(df_comp.iloc[s:e]['texto'])\n", + " sims = cosine_similarity(bm, cat_mat)\n", + " for j, (_, comp) in enumerate(df_comp.iloc[s:e].iterrows()):\n", + " sr = sims[j].copy()\n", + " sr[upper == str(comp['NUMPARTE']).upper()] = 0.0\n", + " order = sr.argsort()[::-1]\n", + " rank = 1\n", + " for idx in order:\n", + " if sr[idx] < min_sim or rank > top_n: break\n", + " sust = df_spartes.iloc[idx]\n", + " rows.append({\n", + " 'NUMPARTE': str(comp['NUMPARTE']).strip(),\n", + " 'NUMPARTESUSTITUTO': str(sust['NUMPARTE']).strip(),\n", + " 'UNIMED_COMP': str(comp['UNIMED'] or '').strip(),\n", + " 'UNIMED_SUST': str(sust['UNIMED'] or '').strip(),\n", + " 'SIMILITUD': round(float(sr[idx]),4), 'RANK': rank,\n", + " })\n", + " rank += 1\n", + " prog.step(desc=f'Similitud {e}/{len(df_comp)}')\n", + " df_sust = pd.DataFrame(rows)\n", + " log(f'Sustitutos calculados: {len(df_sust):,}')\n", + " df_exist = pd.read_sql(\"SELECT NUMPARTE, NUMPARTESUSTITUTO FROM SPartesSustitutos\", scaii_conn)\n", + " pares = set(zip(df_exist['NUMPARTE'].astype(str).str.strip(),\n", + " df_exist['NUMPARTESUSTITUTO'].astype(str).str.strip()))\n", + " df_sust['_dup'] = df_sust.apply(lambda r: (r['NUMPARTE'], r['NUMPARTESUSTITUTO']) in pares, axis=1)\n", + " df_nuevos = df_sust[~df_sust['_dup']].drop(columns='_dup').reset_index(drop=True)\n", + " log(f' Duplicados (omitir): {df_sust[\"_dup\"].sum():,}')\n", + " log(f' NUEVOS a insertar: {len(df_nuevos):,}')\n", + " _state['df_nuevos_sust'] = df_nuevos\n", + " if df_nuevos.empty or dry_run:\n", + " if dry_run: log('DRY_RUN=True. Cambia para insertar.')\n", + " prog.done(f'{len(df_nuevos)} nuevos'); return\n", + " INS = \"\"\"INSERT INTO SPartesSustitutos\n", + " (NUMPARTE, NUMPARTESUSTITUTO, FACTORCONVERSION, UNIDADMEDIDA1, UNIDADMEDIDA2)\n", + " VALUES (?, ?, 0, ?, ?)\"\"\"\n", + " cur = scaii_conn.cursor()\n", + " total_lotes = max(1, (len(df_nuevos) + 499) // 500)\n", + " prog.setup(total_lotes, 'Insertando lotes...')\n", + " inserted = 0\n", + " for s in range(0, len(df_nuevos), 500):\n", + " e = min(s+500, len(df_nuevos))\n", + " params = [(r['NUMPARTE'], r['NUMPARTESUSTITUTO'],\n", + " r['UNIMED_COMP'] or r['UNIMED_SUST'],\n", + " r['UNIMED_SUST'] or r['UNIMED_COMP'])\n", + " for _, r in df_nuevos.iloc[s:e].iterrows()]\n", + " try:\n", + " cur.executemany(INS, params)\n", + " scaii_conn.commit()\n", + " inserted += len(params)\n", + " except Exception as e2:\n", + " scaii_conn.rollback()\n", + " log(f' ERROR lote {s}-{e}: {e2}')\n", + " prog.step()\n", + " log(f'Insertados: {inserted:,}')\n", + " prog.done(f'{inserted} insertados')\n", + "\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "logic-ctm", + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "# ============================================================\n", + "# CTM - Reasignacion de descargas de Cambio de Regimen a CTM\n", + "# ============================================================\n", + "\n", + "def cargar_excel_mapping_ctm(file_bytes_or_path):\n", + " \"\"\"Carga el Excel del cliente y normaliza.\n", + " Espera columnas: 'Facturas CTM', 'PEDIMENTO COMPLETO', 'PATENTE', 'ADUANA',\n", + " 'PEDIMENTO', 'Operacion', 'Clave de pedimento'.\n", + " Una sola celda 'Facturas CTM' puede traer varias facturas separadas por coma.\n", + " \"\"\"\n", + " import io\n", + " if isinstance(file_bytes_or_path, (bytes, bytearray)):\n", + " df = pd.read_excel(io.BytesIO(file_bytes_or_path))\n", + " else:\n", + " df = pd.read_excel(file_bytes_or_path)\n", + " # Normalizar nombres de columnas (acentos / mayusc)\n", + " norm = {c: c.strip() for c in df.columns}\n", + " df.rename(columns=norm, inplace=True)\n", + " col_ctm = next((c for c in df.columns if 'CTM' in c.upper() and 'FACTURA' in c.upper()), None)\n", + " col_ped = next((c for c in df.columns if 'PEDIMENTO' in c.upper() and 'COMPLETO' in c.upper()), None)\n", + " if col_ctm is None or col_ped is None:\n", + " raise ValueError('El Excel debe tener columnas Facturas CTM y PEDIMENTO COMPLETO')\n", + " # Expandir CTM separadas por coma\n", + " rows = []\n", + " for _, r in df.iterrows():\n", + " ctm_cell = str(r[col_ctm]) if pd.notna(r[col_ctm]) else ''\n", + " ped_cell = str(r[col_ped]) if pd.notna(r[col_ped]) else ''\n", + " if not ctm_cell.strip() or not ped_cell.strip():\n", + " continue\n", + " for ctm in [c.strip() for c in ctm_cell.split(',') if c.strip()]:\n", + " rows.append({'FACTURA_CTM': ctm, 'PEDIMENTO': ped_cell.strip()})\n", + " mapping = pd.DataFrame(rows).drop_duplicates().reset_index(drop=True)\n", + " return mapping, df\n", + "\n", + "def listar_facturas_ctm():\n", + " \"\"\"Devuelve facturas TIPOFACTURA='CTM' con su estatus.\"\"\"\n", + " return pd.read_sql(\"\"\"\n", + " SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO, ESTATUS, TIPOFACTURA\n", + " FROM SFacExp WHERE TIPOFACTURA='CTM'\n", + " ORDER BY FECHAFACTURA_ISO\n", + " \"\"\", scaii_conn)\n", + "\n", + "def cargar_partidas_ctm_explotadas():\n", + " \"\"\"Partidas CTM con explosion BOM cuando TIPOMAT IN ('PT','SE').\n", + " Una fila por (FACTURA_CTM, LINEA, componente_final).\"\"\"\n", + " return pd.read_sql(\"\"\"\n", + " SELECT PE.FACTURAEXPO AS FACTURA_CTM, PE.LINEA, PE.NUMPARTE AS PT_O_MP,\n", + " PE.CANTEXPO, PE.UNIMED AS UNIMED_PE, PA.TIPOMAT,\n", + " CASE\n", + " WHEN (SELECT COUNT(*) FROM SMatBOM X WHERE X.NUMPARTE = PE.NUMPARTE) = 0 THEN NULL\n", + " WHEN PA.TIPOMAT IN ('PT','SE') THEN B.NUMPARTEBOM\n", + " ELSE PE.NUMPARTE\n", + " END AS COMPONENTE,\n", + " CASE\n", + " WHEN (SELECT COUNT(*) FROM SMatBOM X WHERE X.NUMPARTE = PE.NUMPARTE) = 0 THEN 0\n", + " WHEN PA.TIPOMAT IN ('PT','SE') THEN B.CANTIDAD\n", + " ELSE 1\n", + " END AS CANT_BOM,\n", + " B.UNIMED AS UNIMED_BOM,\n", + " CASE\n", + " WHEN (SELECT COUNT(*) FROM SMatBOM X WHERE X.NUMPARTE = PE.NUMPARTE) = 0 THEN 0\n", + " WHEN PA.TIPOMAT IN ('PT','SE') THEN (B.CANTIDAD * PE.CANTEXPO)\n", + " ELSE PE.CANTEXPO\n", + " END AS CANT_REQUERIDA\n", + " FROM SPartidasExpo PE\n", + " LEFT JOIN SFacExp FE ON FE.FACTURAEXPO = PE.FACTURAEXPO\n", + " LEFT JOIN SPartes PA ON PA.NUMPARTE = PE.NUMPARTE\n", + " LEFT JOIN SMatBOM B ON B.NUMPARTE = PE.NUMPARTE\n", + " WHERE FE.TIPOFACTURA = 'CTM'\n", + " ORDER BY PE.FACTURAEXPO, PE.LINEA, B.NUMPARTEBOM\n", + " \"\"\", scaii_conn)\n", + "\n", + "def cargar_pool_descargas_cr():\n", + " \"\"\"Pool de descargas existentes en facturas de Cambio de Regimen.\n", + " Estas son las candidatas a reasignarse a las CTM.\"\"\"\n", + " return pd.read_sql(\"\"\"\n", + " SELECT D.CONSECUTIVO, D.FACTEXPO AS FACTURA_CR, D.NUMPARTE, D.PARTEORIGINAL,\n", + " D.CANTDESC, D.UNIMED, D.PEDIMENTOIMPO, D.PEDIMENTOEXPO,\n", + " D.FECHADESC, D.LINEAEXPO, D.FACTIMPO, PA.TIPOMAT,\n", + " DATEADD(DAY, D.FECHADESC - 4, '1801-01-01') AS FECHA_DESC_ISO\n", + " FROM SDescargaT D\n", + " INNER JOIN SFacExp FE ON FE.FACTURAEXPO = D.FACTEXPO\n", + " LEFT JOIN SPartes PA ON PA.NUMPARTE = D.NUMPARTE\n", + " WHERE FE.ESCAMBIOREGIMEN = 'S'\n", + " ORDER BY D.FECHADESC, D.CONSECUTIVO\n", + " \"\"\", scaii_conn)\n", + "\n", + "def cargar_sustitutos_dict():\n", + " \"\"\"Devuelve dict {numparte_original: [lista_sustitutos]}.\"\"\"\n", + " df = pd.read_sql('SELECT NUMPARTE, NUMPARTESUSTITUTO FROM SPartesSustitutos', scaii_conn)\n", + " d = {}\n", + " for _, r in df.iterrows():\n", + " d.setdefault(str(r['NUMPARTE']).strip(), []).append(str(r['NUMPARTESUSTITUTO']).strip())\n", + " return d\n", + "\n", + "def analizar_ctm(df_mapping=None, progress=None):\n", + " \"\"\"Analisis Paso A: para cada partida CTM, busca matches en el pool CR.\n", + " Aplica PEPS (FECHA_DESC ascendente). Si df_mapping no es None, prioriza\n", + " descargas cuyo PEDIMENTOIMPO contenga el numero de pedimento mapeado.\n", + "\n", + " Retorna dos DataFrames:\n", + " plan : una fila por descarga CR que se tomaria (o por faltante)\n", + " resumen : agregado por (FACTURA_CTM, LINEA, COMPONENTE)\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(4, 'Cargando facturas CTM...')\n", + " df_part = cargar_partidas_ctm_explotadas()\n", + " prog.step(desc='Cargando pool CR...')\n", + " df_pool = cargar_pool_descargas_cr()\n", + " prog.step(desc='Cargando sustitutos...')\n", + " sust = cargar_sustitutos_dict()\n", + "\n", + " # Dict de pedimentos mapeados por factura CTM (si hay mapping)\n", + " map_dict = {}\n", + " if df_mapping is not None and not df_mapping.empty:\n", + " for _, r in df_mapping.iterrows():\n", + " map_dict.setdefault(str(r['FACTURA_CTM']).strip(),\n", + " set()).add(str(r['PEDIMENTO']).strip())\n", + "\n", + " # Pool mutable: usar saldo virtual por consecutivo\n", + " pool = df_pool.copy()\n", + " pool['SALDO_DISPONIBLE'] = pool['CANTDESC']\n", + "\n", + " # Construir indice rapido: pool por (NUMPARTE) y por (PARTEORIGINAL)\n", + " pool_by_np = pool.groupby('NUMPARTE').groups\n", + " pool_by_po = pool.groupby('PARTEORIGINAL').groups\n", + "\n", + " prog.step(desc='Matching CTM vs CR...')\n", + " plan_rows = []\n", + " total = len(df_part)\n", + " prog.setup(max(total, 1), 'Matching')\n", + " for i, p in df_part.iterrows():\n", + " factura_ctm = p['FACTURA_CTM']\n", + " linea = p['LINEA']\n", + " comp = p['COMPONENTE']\n", + " cant_req = float(p['CANT_REQUERIDA'] or 0)\n", + " unimed = p['UNIMED_BOM'] or p['UNIMED_PE'] or ''\n", + " if cant_req <= 0 or not comp:\n", + " plan_rows.append({\n", + " 'FACTURA_CTM': factura_ctm, 'LINEA': linea, 'PT_O_MP': p['PT_O_MP'],\n", + " 'TIPOMAT': p['TIPOMAT'], 'COMPONENTE': comp, 'CANT_REQUERIDA': cant_req,\n", + " 'UNIMED': unimed, 'STATUS': 'SIN_REQUERIMIENTO', 'CONSECUTIVO_CR': None,\n", + " 'FACTURA_CR': None, 'NUMPARTE_CR': None, 'CANT_DISPONIBLE_CR': 0,\n", + " 'CANT_A_TOMAR': 0, 'FECHA_DESC_CR': None, 'PEDIMENTOIMPO_CR': None,\n", + " 'PRIORIDAD_MAPPING': False,\n", + " })\n", + " prog.step()\n", + " continue\n", + "\n", + " # Candidatos: COMPONENTE directo + sustitutos del COMPONENTE\n", + " candidatos_np = [comp] + sust.get(comp, [])\n", + " # Buscar en pool donde NUMPARTE o PARTEORIGINAL coincida con algun candidato\n", + " idx_match = set()\n", + " for cand in candidatos_np:\n", + " if cand in pool_by_np: idx_match.update(pool_by_np[cand])\n", + " if cand in pool_by_po: idx_match.update(pool_by_po[cand])\n", + " sub = pool.loc[list(idx_match)].copy() if idx_match else pool.iloc[0:0].copy()\n", + " if not sub.empty:\n", + " # Marcar prioridad si su pedimento esta mapeado para esta CTM\n", + " peds_target = map_dict.get(str(factura_ctm).strip(), set())\n", + " def _matches_ped(p_imp):\n", + " if not peds_target: return False\n", + " s = str(p_imp or '')\n", + " # Buscar coincidencia parcial del numero de pedimento mapeado\n", + " for pt in peds_target:\n", + " if pt in s or s in pt: return True\n", + " return False\n", + " sub['PRIORIDAD'] = sub['PEDIMENTOIMPO'].apply(_matches_ped)\n", + " # Ordenar: prioridad descendente, despues fecha ascendente PEPS\n", + " sub = sub.sort_values(['PRIORIDAD','FECHADESC','CONSECUTIVO'], ascending=[False, True, True])\n", + " sub = sub[sub['SALDO_DISPONIBLE'] > 1e-9]\n", + "\n", + " faltante = cant_req\n", + " for idx in sub.index:\n", + " if faltante <= 1e-9: break\n", + " disp = float(pool.at[idx, 'SALDO_DISPONIBLE'])\n", + " if disp <= 1e-9: continue\n", + " toma = min(faltante, disp)\n", + " plan_rows.append({\n", + " 'FACTURA_CTM': factura_ctm, 'LINEA': linea, 'PT_O_MP': p['PT_O_MP'],\n", + " 'TIPOMAT': p['TIPOMAT'], 'COMPONENTE': comp, 'CANT_REQUERIDA': cant_req,\n", + " 'UNIMED': unimed, 'STATUS': 'ASIGNADO',\n", + " 'CONSECUTIVO_CR': int(pool.at[idx, 'CONSECUTIVO']),\n", + " 'FACTURA_CR': pool.at[idx, 'FACTURA_CR'],\n", + " 'NUMPARTE_CR': pool.at[idx, 'NUMPARTE'],\n", + " 'PARTEORIGINAL_CR': pool.at[idx, 'PARTEORIGINAL'],\n", + " 'CANT_DISPONIBLE_CR': disp,\n", + " 'CANT_A_TOMAR': toma,\n", + " 'FECHA_DESC_CR': pool.at[idx, 'FECHA_DESC_ISO'],\n", + " 'PEDIMENTOIMPO_CR': pool.at[idx, 'PEDIMENTOIMPO'],\n", + " 'PRIORIDAD_MAPPING': bool(sub.at[idx, 'PRIORIDAD']) if 'PRIORIDAD' in sub.columns else False,\n", + " })\n", + " pool.at[idx, 'SALDO_DISPONIBLE'] = disp - toma\n", + " faltante -= toma\n", + " if faltante > 1e-9:\n", + " plan_rows.append({\n", + " 'FACTURA_CTM': factura_ctm, 'LINEA': linea, 'PT_O_MP': p['PT_O_MP'],\n", + " 'TIPOMAT': p['TIPOMAT'], 'COMPONENTE': comp, 'CANT_REQUERIDA': cant_req,\n", + " 'UNIMED': unimed, 'STATUS': 'FALTANTE', 'CONSECUTIVO_CR': None,\n", + " 'FACTURA_CR': None, 'NUMPARTE_CR': None, 'PARTEORIGINAL_CR': None,\n", + " 'CANT_DISPONIBLE_CR': 0, 'CANT_A_TOMAR': faltante,\n", + " 'FECHA_DESC_CR': None, 'PEDIMENTOIMPO_CR': None,\n", + " 'PRIORIDAD_MAPPING': False,\n", + " })\n", + " prog.step()\n", + "\n", + " plan = pd.DataFrame(plan_rows)\n", + " if plan.empty:\n", + " prog.done('Sin partidas')\n", + " return plan, plan\n", + " # Resumen por (FACTURA_CTM, LINEA, COMPONENTE)\n", + " resumen = (plan.groupby(['FACTURA_CTM','LINEA','COMPONENTE','UNIMED','CANT_REQUERIDA'], as_index=False, dropna=False)\n", + " .agg(cubierto=('CANT_A_TOMAR', lambda s: s[plan.loc[s.index, 'STATUS']=='ASIGNADO'].sum()),\n", + " faltante=('CANT_A_TOMAR', lambda s: s[plan.loc[s.index, 'STATUS']=='FALTANTE'].sum()),\n", + " filas_cr_usadas=('CONSECUTIVO_CR', lambda s: s.notna().sum())))\n", + " resumen['pct_cobertura'] = ((resumen['cubierto'] / resumen['CANT_REQUERIDA']).fillna(0)*100).round(2).clip(upper=100)\n", + " prog.done('Analisis CTM listo')\n", + " _state['ctm_plan'] = plan\n", + " _state['ctm_resumen'] = resumen\n", + " return plan, resumen\n", + "\n", + "def exportar_excel_ctm(plan, resumen):\n", + " out = f'analisis_ctm_{_dt.datetime.now().strftime(\"%Y%m%d_%H%M%S\")}.xlsx'\n", + " with pd.ExcelWriter(out, engine='openpyxl') as w:\n", + " resumen.to_excel(w, sheet_name='Resumen', index=False)\n", + " plan.to_excel(w, sheet_name='Plan_Detalle', index=False)\n", + " plan[plan['STATUS']=='FALTANTE'].to_excel(w, sheet_name='Faltantes', index=False)\n", + " return os.path.abspath(out)\n", + "def generar_plantilla_excel_ctm():\n", + " \"\"\"Crea un Excel de ejemplo con el formato esperado.\"\"\"\n", + " out = f'plantilla_ctm_{_dt.datetime.now().strftime(\"%Y%m%d_%H%M%S\")}.xlsx'\n", + " df = pd.DataFrame([\n", + " {'Facturas CTM': 'AAU112023RFR0481, NIS112023RFR0035',\n", + " 'PEDIMENTO COMPLETO': '75-3076-4021492',\n", + " 'PATENTE': 3076, 'ADUANA': 75, 'PEDIMENTO': 4021492,\n", + " 'Operacion': 'Importacion', 'Clave de pedimento': 'F4'},\n", + " {'Facturas CTM': 'AAU122023RFR0482',\n", + " 'PEDIMENTO COMPLETO': '75-3076-4033174',\n", + " 'PATENTE': 3076, 'ADUANA': 75, 'PEDIMENTO': 4033174,\n", + " 'Operacion': 'Importacion', 'Clave de pedimento': 'F4'},\n", + " {'Facturas CTM': 'AAU062024RFR0488,NIS062024RFR0030',\n", + " 'PEDIMENTO COMPLETO': '75-3076-4133436',\n", + " 'PATENTE': 3076, 'ADUANA': 75, 'PEDIMENTO': 4133436,\n", + " 'Operacion': 'Importacion', 'Clave de pedimento': 'F4'},\n", + " ])\n", + " df.to_excel(out, index=False)\n", + " return os.path.abspath(out)\n", + "\n", + "def ejecutar_reasignacion_ctm(modo='NATURAL', dry_run=True, log=print, progress=None):\n", + " \"\"\"Paso B - Ejecuta el plan generado por analizar_ctm.\n", + " - Cambia FACTEXPO en SDescargaT (caso total) o divide la fila (caso parcial).\n", + " - Inserta fila espejo en SDescargaM cada vez que algo se asigna a la CTM.\n", + " - Actualiza SFacExp: ESTATUS='AC', APLICADESCMANUAL='S', CANT_PARTIDAS=count.\n", + " Modos: NATURAL (solo CTMs 100% cubiertas) | DIRIGIDA (todas con asignaciones).\n", + " Todo en transaccion atomica por factura CTM.\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " if 'ctm_plan' not in _state or _state['ctm_plan'].empty:\n", + " log('ERROR: corre primero \"Analizar CTM\" para generar el plan.')\n", + " return\n", + " plan = _state['ctm_plan']; resumen = _state.get('ctm_resumen')\n", + " plan_asignado = plan[plan['STATUS'] == 'ASIGNADO'].copy()\n", + " if plan_asignado.empty:\n", + " log('No hay filas ASIGNADAS en el plan.'); return\n", + "\n", + " if modo == 'NATURAL':\n", + " if resumen is None or resumen.empty:\n", + " log('Sin resumen para evaluar NATURAL. Aborto.'); return\n", + " elig = [f for f, g in resumen.groupby('FACTURA_CTM') if (g['pct_cobertura'] >= 99.99).all()]\n", + " plan_asignado = plan_asignado[plan_asignado['FACTURA_CTM'].isin(elig)]\n", + " log(f'Modo NATURAL: {len(elig)} facturas CTM elegibles (cobertura 100%)')\n", + " else:\n", + " log('Modo DIRIGIDA: procesa todas las facturas con asignaciones')\n", + "\n", + " log(f'Filas a procesar: {len(plan_asignado):,}')\n", + " if plan_asignado.empty: prog.done('Nada que procesar'); return\n", + "\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute('SELECT ISNULL(MAX(CONSECUTIVO),0) FROM SDescargaM')\n", + " next_m = int(cur.fetchone()[0]) + 1\n", + " log(f'Proximo CONSECUTIVO SDescargaM: {next_m}')\n", + "\n", + " INSERT_M = (\"INSERT INTO SDescargaM (CONSECUTIVO, CONSECUTIVOEXPO, FACTURAEXPO, LINEA, \"\n", + " \"NUMPARTE, CLASE, CANTIDAD, UNIMED, FACTURAIMPO, NUMPARTEMP, \"\n", + " \"VALORIMPOMN, VALORIMPOME, PAIS, PESONETO, PESOBRUTO, \"\n", + " \"TIPOFRACCION, SECTOR, FACTURADEF, PROCEDENCIA, FACTURA, \"\n", + " \"TIPODESPERDICIO, ORDENVENTA, TOMARSALDOBASEALPT) \"\n", + " \"VALUES (\" + ','.join(['?']*23) + \")\")\n", + "\n", + " UPDATE_SFACEXP = (\"UPDATE SFacExp SET ESTATUS='AC', APLICADESCMANUAL='S', \"\n", + " \"CANT_PARTIDAS = (SELECT COUNT(*) FROM SPartidasExpo \"\n", + " \"WHERE FACTURAEXPO = SFacExp.FACTURAEXPO) WHERE FACTURAEXPO=?\")\n", + "\n", + " facturas = plan_asignado['FACTURA_CTM'].unique()\n", + " prog.setup(len(facturas), 'Procesando CTMs')\n", + " ins_m = stat = 0\n", + " errores = []\n", + "\n", + " for factura_ctm in facturas:\n", + " filas_factura = plan_asignado[plan_asignado['FACTURA_CTM'] == factura_ctm]\n", + " try:\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(\"SELECT CONSECUTIVO FROM SFacExp WHERE FACTURAEXPO=?\", factura_ctm)\n", + " _rf = cur.fetchone()\n", + " consec_factura_ctm = int(_rf[0]) if _rf and _rf[0] is not None else 0\n", + " for _, fila in filas_factura.iterrows():\n", + " consec_cr = int(fila['CONSECUTIVO_CR'])\n", + " cant_tomar = float(fila['CANT_A_TOMAR'])\n", + " linea_ctm = int(fila['LINEA']) if pd.notna(fila['LINEA']) else 0\n", + " numparte_pt_ctm = str(fila['PT_O_MP']).strip() if pd.notna(fila['PT_O_MP']) else None\n", + " cur.execute(\"SELECT FACTIMPO, CLASE, CANTDESC, UNIMED, VALORMN, VALORME, \"\n", + " \"PESONETO, PESOBRUTO, PAISMERCANCIA, TIPOFRACCION, SECTOR, \"\n", + " \"PARTEORIGINAL, ORDENVENTA \"\n", + " \"FROM SDescargaT WHERE CONSECUTIVO=?\", consec_cr)\n", + " r = cur.fetchone()\n", + " if r is None:\n", + " log(f' WARN consec {consec_cr} ya no existe; se salta'); continue\n", + " cantdesc_actual = float(r.CANTDESC or 0)\n", + " if cantdesc_actual <= 1e-9:\n", + " log(f' WARN consec {consec_cr} tiene CANTDESC=0; se salta'); continue\n", + " p = 1.0 if cant_tomar >= cantdesc_actual else (cant_tomar / cantdesc_actual)\n", + "\n", + " def esc(val):\n", + " v = float(val or 0); return v * p\n", + "\n", + " m_cantidad = esc(r.CANTDESC); m_vmn = esc(r.VALORMN); m_vme = esc(r.VALORME)\n", + " m_pneto = esc(r.PESONETO); m_pbruto = esc(r.PESOBRUTO)\n", + "\n", + " # SOLO INSERT espejo en SDescargaM. SDescargaT no se toca.\n", + " if not dry_run:\n", + " cur.execute(INSERT_M,\n", + " next_m, consec_factura_ctm, factura_ctm, linea_ctm,\n", + " numparte_pt_ctm, r.CLASE, m_cantidad, r.UNIMED,\n", + " r.FACTIMPO, r.PARTEORIGINAL,\n", + " m_vmn, m_vme, r.PAISMERCANCIA, m_pneto, m_pbruto,\n", + " r.TIPOFRACCION, r.SECTOR,\n", + " '', 'TEM', r.FACTIMPO, 'N', r.ORDENVENTA, '')\n", + " next_m += 1; ins_m += 1\n", + "\n", + " if not dry_run:\n", + " cur.execute(UPDATE_SFACEXP, factura_ctm)\n", + " stat += 1\n", + " if not dry_run:\n", + " scaii_conn.commit()\n", + " except Exception as e:\n", + " if not dry_run: scaii_conn.rollback()\n", + " errores.append((factura_ctm, str(e)))\n", + " log(f' ERROR {factura_ctm}: {e}')\n", + " prog.step()\n", + "\n", + " prog.done(f'{ins_m} mirror ops')\n", + " log(f'\\n=== RESUMEN Paso B ({modo}, DRY_RUN={dry_run}) ===')\n", + " log(f' SDescargaM insertadas (espejo) : {ins_m:,}')\n", + " log(f' Facturas CTM -> ESTATUS=AC + APLICADESCMANUAL=S + CANT_PARTIDAS: {stat:,}')\n", + " log(f' SDescargaT NO se modifica (solo se usa como referencia)')\n", + " log(f' Errores : {len(errores):,}')\n", + " for f, e in errores[:5]:\n", + " log(f' {f}: {e}')\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "logic-saldos-vencidos", + "metadata": {}, + "outputs": [], + "source": [ + "# Fix completo para SaldosVencidos: usa PK de 6 campos en SSaldoTem\n", + "# y match por PAISMERCANCIA en SDescargaT.\n", + "\n", + "# Las funciones aqui REEMPLAZAN las de la celda logic-saldos-vencidos.\n", + "\n", + "import io as _io_sv\n", + "import datetime as _dt_sv\n", + "\n", + "def cargar_excel_saldos_vencidos(path):\n", + " \"\"\"Lee Excel con 3 columnas: FACTURAIMPO, CANTIDAD_SALDO, FRACCION_IMPO.\"\"\"\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " aliases = {\n", + " 'FACTURAIMPO': ['FACTURAIMPO', 'FACTURA_IMPO', 'FACTURA'],\n", + " 'CANTIDAD_SALDO': ['CANTIDAD_SALDO', 'CANT_SALDO', 'CANTIDAD', 'SALDO'],\n", + " 'FRACCION_IMPO': ['FRACCION_IMPO', 'FRACCIONIMPO', 'FRACCION'],\n", + " }\n", + " out = {}\n", + " for std, opts in aliases.items():\n", + " for o in opts:\n", + " if o in df.columns:\n", + " out[std] = df[o]; break\n", + " if std not in out:\n", + " raise ValueError(f'Falta la columna {std} (acepta: {opts})')\n", + " df2 = pd.DataFrame(out)\n", + " df2['FACTURAIMPO'] = df2['FACTURAIMPO'].astype(str).str.strip()\n", + " df2['FRACCION_IMPO'] = df2['FRACCION_IMPO'].astype(str).str.strip()\n", + " df2['CANTIDAD_SALDO'] = pd.to_numeric(df2['CANTIDAD_SALDO'], errors='coerce').fillna(0)\n", + " df2 = df2[df2['CANTIDAD_SALDO'] > 0]\n", + " return df2.reset_index(drop=True)\n", + "\n", + "\n", + "def generar_plantilla_excel_saldos_vencidos():\n", + " df = pd.DataFrame([\n", + " {'FACTURAIMPO': 'F1234567', 'CANTIDAD_SALDO': 100.0, 'FRACCION_IMPO': '85044010'},\n", + " {'FACTURAIMPO': 'F1234568', 'CANTIDAD_SALDO': 50.5, 'FRACCION_IMPO': '85044010'},\n", + " {'FACTURAIMPO': 'F1234569', 'CANTIDAD_SALDO': 25.0, 'FRACCION_IMPO': '73181500'},\n", + " ])\n", + " buf = _io_sv.BytesIO()\n", + " with pd.ExcelWriter(buf, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='SaldosVencidos', index=False)\n", + " buf.seek(0)\n", + " return buf.read()\n", + "\n", + "\n", + "# PK de 6 campos para identificar univocamente una fila de SSaldoTem\n", + "_PK_SALDO = ['FACTURAIMPO', 'PEDIMENTOIMPO', 'FRACCIONIMPO', 'NUMPARTE', 'UMEXITENCIA', 'PAISORIGEN']\n", + "\n", + "# Llave de match con SDescargaT (las descargas no traen PEDIMENTOIMPO/FRACCIONIMPO\n", + "# necesariamente alineados con SSaldoTem; matchamos por los 4 campos que SI son comparables).\n", + "_MATCH_DESC = ['FACTIMPO', 'NUMPARTE', 'UNIMED', 'PAISMERCANCIA']\n", + "\n", + "\n", + "def _enriquecer_saldos_con_tasas(df):\n", + " if df.empty: return df\n", + " if 'CANTIDAD_SALDO' in df.columns:\n", + " df['SALDO_APLICABLE'] = df[['CANTIDAD_SALDO', 'SALDO_DISPONIBLE']].min(axis=1)\n", + " else:\n", + " df['SALDO_APLICABLE'] = df['SALDO_DISPONIBLE']\n", + " denom = df['CANTEXITENCIA'].replace(0, np.nan)\n", + " df['TASA_VMN'] = (df['VALORIMPOMN'] / denom).fillna(0)\n", + " df['TASA_VME'] = (df['VALORIMPOME'] / denom).fillna(0)\n", + " df['TASA_PNETO'] = (df['PESONETO'] / denom).fillna(0)\n", + " df['TASA_PBRUTO'] = (df['PESOBRUTO'] / denom).fillna(0)\n", + " # Normalizar claves a string strip\n", + " for k in _PK_SALDO:\n", + " if k in df.columns:\n", + " df[k] = df[k].astype(str).str.strip()\n", + " return df.reset_index(drop=True)\n", + "\n", + "\n", + "def cargar_saldos_vencidos_ssaldotem(df_excel, fecha_ini, fecha_fin):\n", + " \"\"\"Modo Excel: filtra SSaldoTem por rango FECHAFACTURA_ISO y matchea con Excel\n", + " por (FACTURAIMPO, FRACCIONIMPO). Trae los 6 campos de PK.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT FACTURAIMPO, PEDIMENTOIMPO, FRACCIONIMPO, NUMPARTE, UMEXITENCIA, PAISORIGEN,\n", + " FECHAFACTURA_ISO, FECHAVENC_ISO,\n", + " CANTEXITENCIA,\n", + " ISNULL(CANTUSADA,0) AS CANTUSADA,\n", + " ISNULL(CANTUSADADESP,0) AS CANTUSADADESP,\n", + " (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_DISPONIBLE,\n", + " ISNULL(VALORIMPOMN,0) AS VALORIMPOMN,\n", + " ISNULL(VALORIMPOME,0) AS VALORIMPOME,\n", + " ISNULL(PESONETO,0) AS PESONETO,\n", + " ISNULL(PESOBRUTO,0) AS PESOBRUTO\n", + " FROM SSaldoTem\n", + " WHERE FECHAFACTURA_ISO BETWEEN ? AND ?\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " \"\"\"\n", + " df = pd.read_sql(sql, scaii_conn, params=(fecha_ini, fecha_fin))\n", + " if df.empty: return df\n", + " pares = df_excel[['FACTURAIMPO', 'FRACCION_IMPO', 'CANTIDAD_SALDO']].copy()\n", + " pares = pares.rename(columns={'FRACCION_IMPO': 'FRACCIONIMPO'})\n", + " pares['FACTURAIMPO'] = pares['FACTURAIMPO'].astype(str).str.strip()\n", + " pares['FRACCIONIMPO'] = pares['FRACCIONIMPO'].astype(str).str.strip()\n", + " df['FACTURAIMPO'] = df['FACTURAIMPO'].astype(str).str.strip()\n", + " df['FRACCIONIMPO'] = df['FRACCIONIMPO'].astype(str).str.strip()\n", + " df = df.merge(pares, on=['FACTURAIMPO', 'FRACCIONIMPO'], how='inner')\n", + " return _enriquecer_saldos_con_tasas(df)\n", + "\n", + "\n", + "def cargar_saldos_vencidos_auto(fecha_ini, fecha_fin, fecha_corte=None):\n", + " \"\"\"Modo Automatico: SALDO_DISPONIBLE > 0 + FECHAVENC_ISO < fecha_corte (default hoy).\"\"\"\n", + " if fecha_corte is None:\n", + " fecha_corte = _dt_sv.date.today().isoformat()\n", + " sql = \"\"\"\n", + " SELECT FACTURAIMPO, PEDIMENTOIMPO, FRACCIONIMPO, NUMPARTE, UMEXITENCIA, PAISORIGEN,\n", + " FECHAFACTURA_ISO, FECHAVENC_ISO,\n", + " CANTEXITENCIA,\n", + " ISNULL(CANTUSADA,0) AS CANTUSADA,\n", + " ISNULL(CANTUSADADESP,0) AS CANTUSADADESP,\n", + " (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_DISPONIBLE,\n", + " ISNULL(VALORIMPOMN,0) AS VALORIMPOMN,\n", + " ISNULL(VALORIMPOME,0) AS VALORIMPOME,\n", + " ISNULL(PESONETO,0) AS PESONETO,\n", + " ISNULL(PESOBRUTO,0) AS PESOBRUTO\n", + " FROM SSaldoTem\n", + " WHERE FECHAFACTURA_ISO BETWEEN ? AND ?\n", + " AND FECHAVENC_ISO IS NOT NULL\n", + " AND FECHAVENC_ISO < ?\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " \"\"\"\n", + " df = pd.read_sql(sql, scaii_conn, params=(fecha_ini, fecha_fin, fecha_corte))\n", + " return _enriquecer_saldos_con_tasas(df)\n", + "\n", + "\n", + "def cargar_descargas_candidatas_sv(df_saldos):\n", + " \"\"\"SDescargaT por las FACTIMPO involucradas; trae PAISMERCANCIA para match completo.\"\"\"\n", + " if df_saldos.empty: return pd.DataFrame()\n", + " facturas = df_saldos['FACTURAIMPO'].astype(str).str.strip().unique().tolist()\n", + " if not facturas: return pd.DataFrame()\n", + " out = []\n", + " LOTE = 1000\n", + " for i in range(0, len(facturas), LOTE):\n", + " sub = facturas[i:i+LOTE]\n", + " placeholders = ','.join(['?'] * len(sub))\n", + " sql = f\"\"\"\n", + " SELECT CONSECUTIVO, FACTIMPO, NUMPARTE, UNIMED, PAISMERCANCIA, FACTEXPO,\n", + " ISNULL(CANTDESC,0) AS CANTDESC\n", + " FROM SDescargaT\n", + " WHERE FACTIMPO IN ({placeholders})\n", + " AND ISNULL(CANTDESC,0) > 0\n", + " \"\"\"\n", + " out.append(pd.read_sql(sql, scaii_conn, params=sub))\n", + " df = pd.concat(out, ignore_index=True) if out else pd.DataFrame()\n", + " if df.empty: return df\n", + " for k in ['FACTIMPO', 'NUMPARTE', 'UNIMED', 'PAISMERCANCIA']:\n", + " df[k] = df[k].fillna('').astype(str).str.strip()\n", + " return df\n", + "\n", + "\n", + "def _prorratear_saldos(df_saldos, df_desc, prog):\n", + " \"\"\"Motor de prorrateo. Match con descargas por (FACTIMPO, NUMPARTE, UNIMED, PAISMERCANCIA).\n", + " Cada fila del plan propaga los 6 campos PK del saldo.\"\"\"\n", + " plan_rows = []\n", + " resumen_rows = []\n", + " desc_by_key = {}\n", + " if not df_desc.empty:\n", + " for key, g in df_desc.groupby(_MATCH_DESC):\n", + " desc_by_key[key] = g\n", + " for _, s in df_saldos.iterrows():\n", + " fimpo = str(s['FACTURAIMPO']).strip()\n", + " pedimpo = str(s['PEDIMENTOIMPO']).strip()\n", + " fraccion = str(s['FRACCIONIMPO']).strip()\n", + " numparte = str(s['NUMPARTE']).strip()\n", + " um = str(s['UMEXITENCIA']).strip()\n", + " pais = str(s['PAISORIGEN']).strip()\n", + " candidatas = desc_by_key.get((fimpo, numparte, um, pais))\n", + " base_resumen = {\n", + " 'FACTURAIMPO': fimpo, 'PEDIMENTOIMPO': pedimpo, 'FRACCIONIMPO': fraccion,\n", + " 'NUMPARTE': numparte, 'UMEXITENCIA': um, 'PAISORIGEN': pais,\n", + " 'FECHAFACTURA_ISO': s.get('FECHAFACTURA_ISO'),\n", + " 'FECHAVENC_ISO': s.get('FECHAVENC_ISO'),\n", + " 'SALDO_APLICABLE': float(s['SALDO_APLICABLE']),\n", + " }\n", + " if candidatas is None or candidatas.empty:\n", + " resumen_rows.append({**base_resumen, 'CANT_DESCARGAS': 0, 'STATUS': 'SIN_DESCARGAS'})\n", + " continue\n", + " total_cantdesc = float(candidatas['CANTDESC'].sum())\n", + " if total_cantdesc <= 1e-9:\n", + " resumen_rows.append({**base_resumen, 'CANT_DESCARGAS': 0, 'STATUS': 'CANTDESC_CERO'})\n", + " continue\n", + " saldo_apl = float(s['SALDO_APLICABLE'])\n", + " tasa_vmn = float(s['TASA_VMN'])\n", + " tasa_vme = float(s['TASA_VME'])\n", + " tasa_pn = float(s['TASA_PNETO'])\n", + " tasa_pb = float(s['TASA_PBRUTO'])\n", + " for _, d in candidatas.iterrows():\n", + " prop = float(d['CANTDESC']) / total_cantdesc\n", + " pc = saldo_apl * prop\n", + " plan_rows.append({\n", + " 'FACTURAIMPO': fimpo, 'PEDIMENTOIMPO': pedimpo, 'FRACCIONIMPO': fraccion,\n", + " 'NUMPARTE': numparte, 'UMEXITENCIA': um, 'PAISORIGEN': pais,\n", + " 'CONSECUTIVO_DESC': int(d['CONSECUTIVO']),\n", + " 'FACTEXPO': d['FACTEXPO'],\n", + " 'CANTDESC_ACTUAL': float(d['CANTDESC']),\n", + " 'PROPORCION': prop,\n", + " 'PORCION_CANT': pc,\n", + " 'PORCION_VMN': pc * tasa_vmn,\n", + " 'PORCION_VME': pc * tasa_vme,\n", + " 'PORCION_PNETO': pc * tasa_pn,\n", + " 'PORCION_PBRUTO': pc * tasa_pb,\n", + " })\n", + " resumen_rows.append({**base_resumen, 'CANT_DESCARGAS': len(candidatas), 'STATUS': 'PRORRATEADO'})\n", + " if prog is not None: prog.done('Analisis listo')\n", + " return pd.DataFrame(plan_rows), pd.DataFrame(resumen_rows)\n", + "\n", + "\n", + "def analizar_saldos_vencidos(df_excel, fecha_ini, fecha_fin, progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Cargando saldos SSaldoTem...')\n", + " df_saldos = cargar_saldos_vencidos_ssaldotem(df_excel, fecha_ini, fecha_fin)\n", + " prog.step(desc=f'Saldos matched: {len(df_saldos)}')\n", + " df_desc = cargar_descargas_candidatas_sv(df_saldos)\n", + " prog.step(desc=f'Descargas candidatas: {len(df_desc)}')\n", + " return _prorratear_saldos(df_saldos, df_desc, prog)\n", + "\n", + "\n", + "def analizar_saldos_vencidos_auto(fecha_ini, fecha_fin, fecha_corte=None, progress=None):\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Buscando saldos vencidos en SSaldoTem...')\n", + " df_saldos = cargar_saldos_vencidos_auto(fecha_ini, fecha_fin, fecha_corte)\n", + " prog.step(desc=f'Saldos vencidos: {len(df_saldos)}')\n", + " df_desc = cargar_descargas_candidatas_sv(df_saldos)\n", + " prog.step(desc=f'Descargas candidatas: {len(df_desc)}')\n", + " return _prorratear_saldos(df_saldos, df_desc, prog)\n", + "\n", + "\n", + "def exportar_excel_saldos_vencidos(plan, resumen, ruta):\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n", + " if not plan.empty: plan.to_excel(w, sheet_name='Plan_Detalle', index=False)\n", + " if not resumen.empty: resumen.to_excel(w, sheet_name='Resumen', index=False)\n", + "\n", + "\n", + "def ejecutar_saldos_vencidos(plan, dry_run=True, progress=None, log=print):\n", + " \"\"\"Paso B: UPDATE SDescargaT por descarga + UPDATE SSaldoTem por saldo.\n", + " Usa los 6 campos PK del saldo para identificar univocamente la fila.\"\"\"\n", + " assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n", + " prog = _Progress(progress)\n", + " if plan is None or plan.empty:\n", + " log('ERROR: plan vacio, corre primero \"Analizar\".')\n", + " return\n", + " UPD_DESC = \"\"\"UPDATE SDescargaT\n", + " SET CANTDESC = ISNULL(CANTDESC,0) + ?,\n", + " VALORMN = ISNULL(VALORMN,0) + ?,\n", + " VALORME = ISNULL(VALORME,0) + ?,\n", + " PESONETO = ISNULL(PESONETO,0) + ?,\n", + " PESOBRUTO= ISNULL(PESOBRUTO,0)+ ?\n", + " WHERE CONSECUTIVO = ?\"\"\"\n", + " SEL_SALDO = \"\"\"SELECT (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0))\n", + " FROM SSaldoTem\n", + " WHERE FACTURAIMPO=? AND PEDIMENTOIMPO=? AND FRACCIONIMPO=?\n", + " AND NUMPARTE=? AND UMEXITENCIA=? AND PAISORIGEN=?\"\"\"\n", + " UPD_SALDO = \"\"\"UPDATE SSaldoTem\n", + " SET CANTUSADA = ISNULL(CANTUSADA,0) + ?,\n", + " VALORUSADOMN = ISNULL(VALORUSADOMN,0) + ?,\n", + " VALORUSADOME = ISNULL(VALORUSADOME,0) + ?,\n", + " PESOUSADO = ISNULL(PESOUSADO,0) + ?,\n", + " PESOBRUTOUSADO = ISNULL(PESOBRUTOUSADO,0) + ?\n", + " WHERE FACTURAIMPO=? AND PEDIMENTOIMPO=? AND FRACCIONIMPO=?\n", + " AND NUMPARTE=? AND UMEXITENCIA=? AND PAISORIGEN=?\"\"\"\n", + " saldos = plan[_PK_SALDO].drop_duplicates().reset_index(drop=True)\n", + " prog.setup(len(saldos), 'Procesando saldos')\n", + " upd_desc = upd_saldo = errores = capados = 0\n", + " err_list = []\n", + " for _, s in saldos.iterrows():\n", + " pk = (s['FACTURAIMPO'], s['PEDIMENTOIMPO'], s['FRACCIONIMPO'],\n", + " s['NUMPARTE'], s['UMEXITENCIA'], s['PAISORIGEN'])\n", + " etiqueta = f\"{pk[0]}/{pk[1]}/{pk[2]}/{pk[3]}/{pk[4]}/{pk[5]}\"\n", + " mask = (plan['FACTURAIMPO']==pk[0]) & (plan['PEDIMENTOIMPO']==pk[1]) & \\\n", + " (plan['FRACCIONIMPO']==pk[2]) & (plan['NUMPARTE']==pk[3]) & \\\n", + " (plan['UMEXITENCIA']==pk[4]) & (plan['PAISORIGEN']==pk[5])\n", + " fil = plan[mask].copy()\n", + " if fil.empty: prog.step(); continue\n", + " sum_c = float(fil['PORCION_CANT'].sum())\n", + " try:\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(SEL_SALDO, *pk)\n", + " row = cur.fetchone()\n", + " if row is None:\n", + " log(f' WARN saldo {etiqueta} no existe; se salta')\n", + " prog.step(); continue\n", + " disp = float(row[0] or 0)\n", + " if sum_c > disp + 1e-6:\n", + " factor = disp / sum_c if sum_c > 0 else 0\n", + " log(f' CAPEO {etiqueta}: sum={sum_c:.4f} > disp={disp:.4f} (factor={factor:.4f})')\n", + " for col in ['PORCION_CANT','PORCION_VMN','PORCION_VME','PORCION_PNETO','PORCION_PBRUTO']:\n", + " fil[col] = fil[col] * factor\n", + " capados += 1\n", + " sum_c = float(fil['PORCION_CANT'].sum())\n", + " sum_vmn = float(fil['PORCION_VMN'].sum())\n", + " sum_vme = float(fil['PORCION_VME'].sum())\n", + " sum_pn = float(fil['PORCION_PNETO'].sum())\n", + " sum_pb = float(fil['PORCION_PBRUTO'].sum())\n", + " if sum_c <= 1e-9:\n", + " log(f' SKIP {etiqueta}: factor=0, no hay nada que aplicar')\n", + " prog.step(); continue\n", + " for _, p in fil.iterrows():\n", + " if not dry_run:\n", + " cur.execute(UPD_DESC,\n", + " float(p['PORCION_CANT']), float(p['PORCION_VMN']), float(p['PORCION_VME']),\n", + " float(p['PORCION_PNETO']), float(p['PORCION_PBRUTO']),\n", + " int(p['CONSECUTIVO_DESC']))\n", + " upd_desc += 1\n", + " if not dry_run:\n", + " cur.execute(UPD_SALDO, sum_c, sum_vmn, sum_vme, sum_pn, sum_pb, *pk)\n", + " upd_saldo += 1\n", + " if not dry_run: scaii_conn.commit()\n", + " except Exception as e:\n", + " if not dry_run: scaii_conn.rollback()\n", + " errores += 1\n", + " err_list.append((etiqueta, str(e)))\n", + " log(f' ERROR {etiqueta}: {e}')\n", + " prog.step()\n", + " prog.done(f'{upd_desc} desc / {upd_saldo} saldos')\n", + " log(f'\\n=== RESUMEN Saldos Vencidos (DRY_RUN={dry_run}) ===')\n", + " log(f' SDescargaT actualizadas: {upd_desc:,}')\n", + " log(f' SSaldoTem actualizadas: {upd_saldo:,}')\n", + " log(f' Saldos capeados al 100%: {capados:,}')\n", + " log(f' Errores : {errores:,}')\n", + " for f, e in err_list[:5]:\n", + " log(f' {f}: {e}')\n", + "\n", + "\n", + "def cargar_saldos_vencidos_por_anio(fecha_corte=None, eje='VENCIMIENTO'):\n", + " if fecha_corte is None:\n", + " fecha_corte = _dt_sv.date.today().isoformat()\n", + " col_fecha = 'FECHAFACTURA_ISO' if eje == 'FACTURA' else 'FECHAVENC_ISO'\n", + " alias_anio = 'ANIO_FACTURA' if eje == 'FACTURA' else 'ANIO_VENC'\n", + " sql = f\"\"\"\n", + " SELECT YEAR(CAST({col_fecha} AS DATE)) AS {alias_anio},\n", + " COUNT(*) AS LOTES,\n", + " COUNT(DISTINCT FACTURAIMPO) AS FACTURAS,\n", + " SUM(CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_CANT,\n", + " SUM((CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) *\n", + " CASE WHEN CANTEXITENCIA > 0\n", + " THEN (ISNULL(VALORIMPOMN,0) * 1.0 / CANTEXITENCIA)\n", + " ELSE 0 END) AS SALDO_VMN,\n", + " SUM((CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) *\n", + " CASE WHEN CANTEXITENCIA > 0\n", + " THEN (ISNULL(VALORIMPOME,0) * 1.0 / CANTEXITENCIA)\n", + " ELSE 0 END) AS SALDO_VME\n", + " FROM SSaldoTem\n", + " WHERE FECHAVENC_ISO IS NOT NULL\n", + " AND FECHAVENC_ISO < ?\n", + " AND {col_fecha} IS NOT NULL\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " GROUP BY YEAR(CAST({col_fecha} AS DATE))\n", + " ORDER BY {alias_anio}\n", + " \"\"\"\n", + " return pd.read_sql(sql, scaii_conn, params=(fecha_corte,))\n", + "\n", + "\n", + "def graficar_saldos_vencidos_por_anio(df, metric='SALDO_VMN'):\n", + " import matplotlib.ticker as _mtick\n", + " if df is None or df.empty:\n", + " print('Sin datos para graficar.')\n", + " return\n", + " fig, ax = plt.subplots(figsize=(10, 5))\n", + " col_x = 'ANIO_FACTURA' if 'ANIO_FACTURA' in df.columns else 'ANIO_VENC'\n", + " x = df[col_x].astype(int).astype(str).tolist()\n", + " y = df[metric].astype(float).tolist()\n", + " bars = ax.bar(x, y, color='#1565C0', edgecolor='#0D47A1')\n", + " for i, b in enumerate(bars):\n", + " lotes = int(df.iloc[i]['LOTES'])\n", + " ax.text(b.get_x() + b.get_width()/2, b.get_height(),\n", + " f'{lotes:,} lotes', ha='center', va='bottom', fontsize=9, color='#333')\n", + " titulos = {\n", + " 'SALDO_VMN': 'Saldos vencidos por anio - Valor MN (pesos)',\n", + " 'SALDO_VME': 'Saldos vencidos por anio - Valor ME (dolares)',\n", + " 'SALDO_CANT': 'Saldos vencidos por anio - Cantidad disponible',\n", + " 'LOTES': 'Saldos vencidos por anio - Cantidad de lotes',\n", + " }\n", + " ax.set_title(titulos.get(metric, f'Saldos vencidos por anio - {metric}'),\n", + " fontsize=13, fontweight='bold', color='#0D47A1')\n", + " ax.set_xlabel('Anio')\n", + " ax.set_ylabel(metric)\n", + " ax.yaxis.set_major_formatter(_mtick.FuncFormatter(lambda v, _: f'{v:,.0f}'))\n", + " ax.grid(axis='y', linestyle='--', alpha=0.5)\n", + " plt.tight_layout()\n", + " plt.show()\n", + " display(df.assign(\n", + " SALDO_CANT=df['SALDO_CANT'].round(2),\n", + " SALDO_VMN=df['SALDO_VMN'].round(2),\n", + " SALDO_VME=df['SALDO_VME'].round(2),\n", + " ))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "logic-valores", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================================================\n", + "# VALORES - Ajuste de VALORTOTALME / VALORTOTALMN en SPartidasExpo\n", + "# Prorrateo proporcional al valor actual de cada partida.\n", + "# =============================================================\n", + "\n", + "import io as _io_val\n", + "\n", + "def cargar_excel_valores(path):\n", + " \"\"\"Lee Excel con 3 columnas: PEDIMENTO, VALOR_ME, VALOR_MN.\"\"\"\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " aliases = {\n", + " 'PEDIMENTO': ['PEDIMENTO', 'PEDIMENTOEXPO', 'PEDIMENTO_EXPO'],\n", + " 'VALOR_ME': ['VALOR_ME', 'VALORME', 'VALOR_M_E', 'VALORTOTALME'],\n", + " 'VALOR_MN': ['VALOR_MN', 'VALORMN', 'VALOR_M_N', 'VALORTOTALMN'],\n", + " }\n", + " out = {}\n", + " for std, opts in aliases.items():\n", + " for o in opts:\n", + " if o in df.columns:\n", + " out[std] = df[o]; break\n", + " if std not in out:\n", + " raise ValueError(f'Falta la columna {std} (acepta: {opts})')\n", + " df2 = pd.DataFrame(out)\n", + " df2['PEDIMENTO'] = df2['PEDIMENTO'].astype(str).str.strip()\n", + " df2['VALOR_ME'] = pd.to_numeric(df2['VALOR_ME'], errors='coerce').fillna(0)\n", + " df2['VALOR_MN'] = pd.to_numeric(df2['VALOR_MN'], errors='coerce').fillna(0)\n", + " df2 = df2[df2['PEDIMENTO'] != ''].reset_index(drop=True)\n", + " return df2\n", + "\n", + "\n", + "def generar_plantilla_excel_valores():\n", + " df = pd.DataFrame([\n", + " {'PEDIMENTO': '07-3429-4015540', 'VALOR_ME': 12345.67, 'VALOR_MN': 234567.89},\n", + " {'PEDIMENTO': '07-3429-4015541', 'VALOR_ME': 8000.00, 'VALOR_MN': 152000.00},\n", + " ])\n", + " buf = _io_val.BytesIO()\n", + " with pd.ExcelWriter(buf, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='Valores', index=False)\n", + " buf.seek(0)\n", + " return buf.read()\n", + "\n", + "\n", + "def cargar_partidas_expo_pedimento(pedimento, db=None, esquema=None):\n", + " \"\"\"Trae partidas SPartidasExpo de un pedimento via SFacExp.\n", + " Si se pasan db y esquema, usa 3-part naming [db].[esquema].SPartidasExpo\n", + " para apuntar a otra BD de la misma instancia SQL Server.\"\"\"\n", + " if db and esquema:\n", + " prefix = f'[{db}].[{esquema}]'\n", + " else:\n", + " prefix = ''\n", + " pa = f'{prefix}.SPartidasExpo' if prefix else 'SPartidasExpo'\n", + " fa = f'{prefix}.SFacExp' if prefix else 'SFacExp'\n", + " sql = f\"\"\"\n", + " SELECT se.FACTURAEXPO, se.LINEA, spe.TIPOCAMBIO,\n", + " ISNULL(se.VALORTOTALME,0) AS VALORTOTALME,\n", + " ISNULL(se.VALORTOTALMN,0) AS VALORTOTALMN,\n", + " ISNULL(se.COSTOUNITARIOME,0) AS COSTOUNITARIOME,\n", + " ISNULL(se.CANTEXPO,0) AS CANTEXPO\n", + " FROM {pa} se\n", + " INNER JOIN {fa} spe ON spe.FACTURAEXPO = se.FACTURAEXPO\n", + " WHERE spe.PEDIMENTOEXPO = ?\n", + " ORDER BY se.FACTURAEXPO, se.LINEA\n", + " \"\"\"\n", + " return pd.read_sql(sql, scaii_conn, params=(str(pedimento).strip(),))\n", + "\n", + "\n", + "def analizar_valores(df_excel, modo='APLICAR_SIEMPRE', umbral_pct=50.0,\n", + " usar_shelter=False, progress=None, log=print):\n", + " \"\"\"Paso A: arma el plan de prorrateo. Devuelve (plan, resumen).\n", + " plan: una fila por partida con AJUSTE_ME, AJUSTE_MN, NUEVO_ME, NUEVO_MN.\n", + " resumen: una fila por pedimento con STATUS, factor, diferencia, etc.\n", + "\n", + " modo='APLICAR_SIEMPRE': escala todo sin importar la magnitud.\n", + " modo='USAR_UMBRAL': si |factor - 1| > umbral_pct/100, marca FUERA_DE_UMBRAL y no se aplica.\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " if df_excel.empty:\n", + " log('Excel vacio.'); return pd.DataFrame(), pd.DataFrame()\n", + " # Localizar pedimentos en otras BDs si se pidio shelter.\n", + " ubic_map = {} # pedimento -> [(db, esquema), ...]\n", + " if usar_shelter:\n", + " try:\n", + " log('Buscando pedimentos en todas las BDs de la instancia...')\n", + " reporte, _, _ = buscar_pedimentos_en_bds(df_excel, progress=progress, log=log)\n", + " # Reconstruir mapping db/esquema (uno por (pedimento, BD))\n", + " sub = pd.read_sql(\"\"\"\n", + " SELECT name FROM sys.databases\n", + " WHERE database_id > 4 AND state_desc = 'ONLINE' AND HAS_DBACCESS(name) = 1\n", + " \"\"\", scaii_conn)\n", + " sub_set = set(sub['name'].astype(str).tolist())\n", + " for _, rr in reporte.iterrows():\n", + " if not rr['BasesEncontradas']: continue\n", + " ped = str(rr['PEDIMENTO']).strip()\n", + " for tok in str(rr['BasesEncontradas']).split(','):\n", + " tok = tok.strip()\n", + " if '.' in tok:\n", + " db, esq = tok.rsplit('.', 1)\n", + " if db in sub_set:\n", + " ubic_map.setdefault(ped, []).append((db, esq))\n", + " except Exception as e:\n", + " log(f'WARN shelter fallo: {e}. Usando BD actual.')\n", + " ubic_map = {}\n", + " prog.setup(len(df_excel), 'Analizando pedimentos...')\n", + " plan_rows = []\n", + " resumen_rows = []\n", + " umb = float(umbral_pct) / 100.0\n", + " for _, r in df_excel.iterrows():\n", + " pedimento = str(r['PEDIMENTO']).strip()\n", + " v_me_esp = float(r['VALOR_ME'] or 0)\n", + " v_mn_esp = float(r['VALOR_MN'] or 0)\n", + " # Lista de (db, esquema) a procesar para este pedimento\n", + " if usar_shelter:\n", + " destinos = ubic_map.get(pedimento, [])\n", + " if not destinos:\n", + " resumen_rows.append({'PEDIMENTO': pedimento, 'BaseDeDatos': '', 'Esquema': '',\n", + " 'SUM_ME_ACTUAL': 0, 'SUM_MN_ACTUAL': 0,\n", + " 'VALOR_ME_ESPERADO': v_me_esp, 'VALOR_MN_ESPERADO': v_mn_esp,\n", + " 'PARTIDAS': 0, 'FACTOR_ME': 0, 'FACTOR_MN': 0,\n", + " 'STATUS': 'NO_ENCONTRADO_EN_BDS'})\n", + " prog.step(desc=pedimento[:30]); continue\n", + " else:\n", + " destinos = [(None, None)]\n", + " # Procesar cada destino\n", + " for (db_dest, esq_dest) in destinos:\n", + " try:\n", + " df_part = cargar_partidas_expo_pedimento(pedimento, db=db_dest, esquema=esq_dest)\n", + " except Exception as e:\n", + " log(f' ERROR query {pedimento} [{db_dest or DB_ACTUAL}]: {e}')\n", + " resumen_rows.append({'PEDIMENTO': pedimento,\n", + " 'BaseDeDatos': db_dest or DB_ACTUAL, 'Esquema': esq_dest or '',\n", + " 'SUM_ME_ACTUAL': 0, 'SUM_MN_ACTUAL': 0,\n", + " 'VALOR_ME_ESPERADO': v_me_esp, 'VALOR_MN_ESPERADO': v_mn_esp,\n", + " 'PARTIDAS': 0, 'FACTOR_ME': 0, 'FACTOR_MN': 0,\n", + " 'STATUS': f'ERROR: {e}'})\n", + " continue\n", + " if df_part.empty:\n", + " resumen_rows.append({'PEDIMENTO': pedimento,\n", + " 'BaseDeDatos': db_dest or DB_ACTUAL, 'Esquema': esq_dest or '',\n", + " 'SUM_ME_ACTUAL': 0, 'SUM_MN_ACTUAL': 0,\n", + " 'VALOR_ME_ESPERADO': v_me_esp, 'VALOR_MN_ESPERADO': v_mn_esp,\n", + " 'PARTIDAS': 0, 'FACTOR_ME': 0, 'FACTOR_MN': 0,\n", + " 'STATUS': 'SIN_PARTIDAS'})\n", + " continue\n", + " sum_me = float(df_part['VALORTOTALME'].astype(float).sum())\n", + " sum_mn = float(df_part['VALORTOTALMN'].astype(float).sum())\n", + " if abs(sum_me) < 1e-9 and v_me_esp > 0:\n", + " status = 'SIN_BASE_ME'\n", + " elif abs(sum_mn) < 1e-9 and v_mn_esp > 0:\n", + " status = 'SIN_BASE_MN'\n", + " else:\n", + " f_me_chk = (v_me_esp / sum_me) if sum_me > 1e-9 else 0.0\n", + " f_mn_chk = (v_mn_esp / sum_mn) if sum_mn > 1e-9 else 0.0\n", + " if modo == 'USAR_UMBRAL':\n", + " if abs(f_me_chk - 1.0) > umb or abs(f_mn_chk - 1.0) > umb:\n", + " status = 'FUERA_DE_UMBRAL'\n", + " else:\n", + " status = 'AJUSTAR'\n", + " else:\n", + " status = 'AJUSTAR'\n", + "\n", + " f_me = (v_me_esp / sum_me) if sum_me > 1e-9 else 0.0\n", + " f_mn = (v_mn_esp / sum_mn) if sum_mn > 1e-9 else 0.0\n", + "\n", + " if status == 'AJUSTAR':\n", + " df_part = df_part.copy()\n", + " df_part['NUEVO_ME'] = (df_part['VALORTOTALME'].astype(float) * f_me).round(6)\n", + " df_part['NUEVO_MN'] = (df_part['VALORTOTALMN'].astype(float) * f_mn).round(6)\n", + " diff_me = round(v_me_esp - df_part['NUEVO_ME'].sum(), 6)\n", + " diff_mn = round(v_mn_esp - df_part['NUEVO_MN'].sum(), 6)\n", + " if abs(diff_me) > 1e-9:\n", + " idx_last = df_part.index[-1]\n", + " df_part.at[idx_last, 'NUEVO_ME'] = round(df_part.at[idx_last, 'NUEVO_ME'] + diff_me, 6)\n", + " if abs(diff_mn) > 1e-9:\n", + " idx_last = df_part.index[-1]\n", + " df_part.at[idx_last, 'NUEVO_MN'] = round(df_part.at[idx_last, 'NUEVO_MN'] + diff_mn, 6)\n", + " df_part['AJUSTE_ME'] = (df_part['NUEVO_ME'] - df_part['VALORTOTALME'].astype(float)).round(6)\n", + " df_part['AJUSTE_MN'] = (df_part['NUEVO_MN'] - df_part['VALORTOTALMN'].astype(float)).round(6)\n", + " df_part['PEDIMENTO'] = pedimento\n", + " for _, p in df_part.iterrows():\n", + " plan_rows.append({\n", + " 'PEDIMENTO': pedimento,\n", + " 'BaseDeDatos': db_dest or DB_ACTUAL,\n", + " 'Esquema': esq_dest or '',\n", + " 'FACTURAEXPO': p['FACTURAEXPO'],\n", + " 'LINEA': int(p['LINEA']),\n", + " 'CANTEXPO': float(p['CANTEXPO']),\n", + " 'TIPOCAMBIO': float(p['TIPOCAMBIO'] or 0),\n", + " 'VALORTOTALME_ACTUAL': float(p['VALORTOTALME']),\n", + " 'VALORTOTALMN_ACTUAL': float(p['VALORTOTALMN']),\n", + " 'AJUSTE_ME': float(p['AJUSTE_ME']),\n", + " 'AJUSTE_MN': float(p['AJUSTE_MN']),\n", + " 'NUEVO_ME': float(p['NUEVO_ME']),\n", + " 'NUEVO_MN': float(p['NUEVO_MN']),\n", + " })\n", + "\n", + " resumen_rows.append({\n", + " 'PEDIMENTO': pedimento,\n", + " 'BaseDeDatos': db_dest or DB_ACTUAL,\n", + " 'Esquema': esq_dest or '',\n", + " 'SUM_ME_ACTUAL': round(sum_me, 6),\n", + " 'SUM_MN_ACTUAL': round(sum_mn, 6),\n", + " 'VALOR_ME_ESPERADO': v_me_esp,\n", + " 'VALOR_MN_ESPERADO': v_mn_esp,\n", + " 'PARTIDAS': len(df_part),\n", + " 'FACTOR_ME': round(f_me, 6),\n", + " 'FACTOR_MN': round(f_mn, 6),\n", + " 'STATUS': status,\n", + " })\n", + " prog.step(desc=pedimento[:30])\n", + " prog.done('Analisis listo')\n", + " return pd.DataFrame(plan_rows), pd.DataFrame(resumen_rows)\n", + "\n", + "\n", + "def exportar_excel_valores(plan, resumen, ruta):\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n", + " if not plan.empty: plan.to_excel(w, sheet_name='Plan_Detalle', index=False)\n", + " if not resumen.empty: resumen.to_excel(w, sheet_name='Resumen', index=False)\n", + "\n", + "\n", + "def ejecutar_valores(plan, dry_run=True, progress=None, log=print):\n", + " \"\"\"Paso B: UPDATE SPartidasExpo por cada partida del plan, usando 3-part\n", + " naming si la fila trae BaseDeDatos/Esquema (cuando se uso shelter).\n", + " Transaccion por (pedimento, BD) — rollback si alguna partida falla.\"\"\"\n", + " assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n", + " prog = _Progress(progress)\n", + " if plan is None or plan.empty:\n", + " log('ERROR: plan vacio, corre primero \"Analizar\".')\n", + " return\n", + " # Si el plan no trae las columnas (compat), las agregamos vacias\n", + " if 'BaseDeDatos' not in plan.columns:\n", + " plan = plan.copy(); plan['BaseDeDatos'] = ''\n", + " if 'Esquema' not in plan.columns:\n", + " plan = plan.copy(); plan['Esquema'] = ''\n", + " # Agrupar por (pedimento, BD, esquema)\n", + " grupos = plan.groupby(['PEDIMENTO', 'BaseDeDatos', 'Esquema'], dropna=False)\n", + " prog.setup(len(grupos), 'Procesando pedimentos')\n", + " upd = errores = 0\n", + " err_list = []\n", + " for (ped, db, esq), fil in grupos:\n", + " if db and esq:\n", + " target = f'[{db}].[{esq}].SPartidasExpo'\n", + " else:\n", + " target = 'SPartidasExpo'\n", + " upd_sql = f\"UPDATE {target} SET VALORTOTALME = ?, VALORTOTALMN = ? WHERE FACTURAEXPO = ? AND LINEA = ?\"\n", + " try:\n", + " with scaii_conn.cursor() as cur:\n", + " for _, p in fil.iterrows():\n", + " if not dry_run:\n", + " cur.execute(upd_sql,\n", + " float(p['NUEVO_ME']), float(p['NUEVO_MN']),\n", + " p['FACTURAEXPO'], int(p['LINEA']))\n", + " upd += 1\n", + " if not dry_run: scaii_conn.commit()\n", + " except Exception as e:\n", + " if not dry_run: scaii_conn.rollback()\n", + " errores += 1\n", + " etq = f'{ped} [{db or DB_ACTUAL}]' if db else str(ped)\n", + " err_list.append((etq, str(e)))\n", + " log(f' ERROR {etq}: {e}')\n", + " prog.step(desc=str(ped)[:30])\n", + " prog.done(f'{upd} partidas')\n", + " bds = plan['BaseDeDatos'].replace('', pd.NA).dropna().unique().tolist()\n", + " log(f'\\n=== RESUMEN Valores (DRY_RUN={dry_run}) ===')\n", + " log(f' Partidas actualizadas: {upd:,}')\n", + " log(f' Pedimentos procesados: {plan[\"PEDIMENTO\"].nunique():,}')\n", + " log(f' BDs tocadas : {bds if bds else \"(solo la actual)\"}')\n", + " log(f' Errores : {errores:,}')\n", + " for f, e in err_list[:5]:\n", + " log(f' {f}: {e}')\n", + "\n", + "\n", + "# =============================================================\n", + "# SHELTER - Busca pedimentos en todas las BDs de la instancia\n", + "# (todas las que tengan SPedimentos.PEDIMENTO)\n", + "# =============================================================\n", + "\n", + "def buscar_pedimentos_en_bds(df_excel, progress=None, log=print):\n", + " \"\"\"Recorre sys.databases, detecta BDs con SPedimentos.PEDIMENTO y busca\n", + " los pedimentos del Excel en cada una. Devuelve (reporte, resumen_bd, faltantes).\"\"\"\n", + " prog = _Progress(progress)\n", + " if df_excel is None or df_excel.empty:\n", + " log('Excel vacio.')\n", + " return pd.DataFrame(), pd.DataFrame(), []\n", + " pedimentos = [str(p).strip() for p in df_excel['PEDIMENTO'].astype(str).tolist() if str(p).strip()]\n", + " if not pedimentos:\n", + " log('Sin pedimentos validos.')\n", + " return pd.DataFrame(), pd.DataFrame(), []\n", + "\n", + " # 1) BDs online accesibles\n", + " try:\n", + " df_dbs = pd.read_sql(\"\"\"\n", + " SELECT name FROM sys.databases\n", + " WHERE database_id > 4 AND state_desc = 'ONLINE' AND HAS_DBACCESS(name) = 1\n", + " ORDER BY name\n", + " \"\"\", scaii_conn)\n", + " except Exception as e:\n", + " log(f'ERROR listando BDs: {e}')\n", + " return pd.DataFrame(), pd.DataFrame(), []\n", + " log(f'BDs online accesibles: {len(df_dbs)}')\n", + " if df_dbs.empty:\n", + " return pd.DataFrame(), pd.DataFrame(), pedimentos\n", + "\n", + " # 2) Por cada BD, detectar SPedimentos.PEDIMENTO + buscar\n", + " prog.setup(len(df_dbs), 'Recorriendo BDs...')\n", + " rows = []\n", + " LOTE = 1000\n", + " for _, r in df_dbs.iterrows():\n", + " db = str(r['name'])\n", + " try:\n", + " sql_check = f\"\"\"\n", + " SELECT s.name AS Esquema\n", + " FROM [{db}].sys.tables t\n", + " JOIN [{db}].sys.schemas s ON s.schema_id = t.schema_id\n", + " JOIN [{db}].sys.columns c ON c.object_id = t.object_id\n", + " WHERE t.name = 'SPedimentos' AND c.name = 'PEDIMENTO'\n", + " \"\"\"\n", + " df_check = pd.read_sql(sql_check, scaii_conn)\n", + " except Exception as e:\n", + " log(f' [{db}] saltada (metadata): {e}')\n", + " prog.step(desc=db[:30]); continue\n", + " if df_check.empty:\n", + " prog.step(desc=db[:30]); continue\n", + " for _, ec in df_check.iterrows():\n", + " esquema = str(ec['Esquema'])\n", + " for i in range(0, len(pedimentos), LOTE):\n", + " sub = pedimentos[i:i+LOTE]\n", + " placeholders = ','.join(['?'] * len(sub))\n", + " sql_search = (f\"SELECT DISTINCT PEDIMENTO \"\n", + " f\"FROM [{db}].[{esquema}].SPedimentos \"\n", + " f\"WHERE PEDIMENTO IN ({placeholders})\")\n", + " try:\n", + " df_found = pd.read_sql(sql_search, scaii_conn, params=tuple(sub))\n", + " for _, f in df_found.iterrows():\n", + " rows.append({\n", + " 'PEDIMENTO': str(f['PEDIMENTO']).strip(),\n", + " 'BaseDeDatos': db,\n", + " 'Esquema': esquema,\n", + " })\n", + " except Exception as e:\n", + " log(f' [{db}].[{esquema}] saltada (busqueda): {e}')\n", + " break\n", + " prog.step(desc=db[:30])\n", + " df_result = pd.DataFrame(rows)\n", + "\n", + " # 3) Reporte por pedimento\n", + " df_in = pd.DataFrame({'PEDIMENTO': pedimentos}).drop_duplicates().reset_index(drop=True)\n", + " if df_result.empty:\n", + " reporte = df_in.assign(BasesEncontradas='', NumDBs=0)\n", + " else:\n", + " df_result['Ubicacion'] = df_result['BaseDeDatos'] + '.' + df_result['Esquema']\n", + " agg = (df_result.groupby('PEDIMENTO', as_index=False)\n", + " .agg(BasesEncontradas=('Ubicacion', lambda s: ', '.join(sorted(set(s)))),\n", + " NumDBs=('Ubicacion', 'nunique')))\n", + " reporte = df_in.merge(agg, on='PEDIMENTO', how='left')\n", + " reporte['BasesEncontradas'] = reporte['BasesEncontradas'].fillna('')\n", + " reporte['NumDBs'] = reporte['NumDBs'].fillna(0).astype(int)\n", + "\n", + " # 4) Resumen por BD\n", + " if df_result.empty:\n", + " resumen_bd = pd.DataFrame(columns=['BaseDeDatos', 'Esquema', 'PedimentosEncontrados'])\n", + " else:\n", + " resumen_bd = (df_result.groupby(['BaseDeDatos', 'Esquema'])\n", + " .size().reset_index(name='PedimentosEncontrados')\n", + " .sort_values('PedimentosEncontrados', ascending=False))\n", + "\n", + " # 5) Faltantes\n", + " faltantes = reporte.loc[reporte['NumDBs'] == 0, 'PEDIMENTO'].tolist()\n", + "\n", + " prog.done(f'{len(df_result)} hits en {df_result[\"BaseDeDatos\"].nunique() if not df_result.empty else 0} BDs')\n", + " return reporte, resumen_bd, faltantes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "logic-datastage", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================================================\n", + "# DATASTAGE - Carga de archivos .asc en Postgres (tablas Registro)\n", + "# Migrado de CONSULTORIA-IMMEX_old/HOME/DATASTAGE/index.php\n", + "# y de carga_datastage_dual_db.ipynb.\n", + "# =============================================================\n", + "\n", + "import re as _re_ds\n", + "import csv as _csv_ds\n", + "from pathlib import Path as _Path_ds\n", + "\n", + "_TIPOS_NUMERICOS_PG = {\n", + " 'numeric', 'decimal', 'real', 'double precision',\n", + " 'integer', 'smallint', 'bigint', 'float',\n", + "}\n", + "\n", + "_BATCH_DS = 100\n", + "\n", + "\n", + "def _ds_conn():\n", + " \"\"\"Devuelve una conexion psycopg2 fresca a partir de PG_CONFIG.\"\"\"\n", + " if not DATASTAGE_OK or not _PSYCOPG2_OK:\n", + " raise RuntimeError(DATASTAGE_MSG or 'Postgres no disponible')\n", + " return _psycopg2.connect(\n", + " host=PG_CONFIG['host'], port=PG_CONFIG['port'], dbname=PG_CONFIG['dbname'],\n", + " user=PG_CONFIG['user'], password=PG_CONFIG['password'])\n", + "\n", + "\n", + "def extraer_tipo_registro(nombre_archivo):\n", + " \"\"\"'725199_501.asc' -> '501' | '725199_Inci.asc' -> 'Inci'.\"\"\"\n", + " m = _re_ds.search(r'_(\\d{3})\\.asc$', nombre_archivo, flags=_re_ds.IGNORECASE)\n", + " if m: return m.group(1)\n", + " m = _re_ds.search(r'_(\\w+)\\.asc$', nombre_archivo, flags=_re_ds.IGNORECASE)\n", + " if m: return m.group(1)\n", + " return None\n", + "\n", + "\n", + "def obtener_esquema_tabla(conn, tabla):\n", + " \"\"\"Columnas y tipos de una tabla de Postgres (information_schema).\"\"\"\n", + " q = \"\"\"\n", + " SELECT column_name, data_type\n", + " FROM information_schema.columns\n", + " WHERE table_name = %s\n", + " ORDER BY ordinal_position\n", + " \"\"\"\n", + " with conn.cursor() as cur:\n", + " cur.execute(q, (tabla,))\n", + " rows = cur.fetchall()\n", + " return [{'column_name': r[0], 'data_type': r[1]} for r in rows]\n", + "\n", + "\n", + "def listar_tablas_registro(conn):\n", + " \"\"\"Tablas que empiezan con 'Registro' (case-insensitive).\"\"\"\n", + " q = \"\"\"\n", + " SELECT table_name\n", + " FROM information_schema.tables\n", + " WHERE table_schema = 'public'\n", + " AND lower(table_name) LIKE 'registro%'\n", + " ORDER BY table_name\n", + " \"\"\"\n", + " with conn.cursor() as cur:\n", + " cur.execute(q)\n", + " return [r[0] for r in cur.fetchall()]\n", + "\n", + "\n", + "def listar_archivos_asc(ruta_raiz):\n", + " \"\"\"Devuelve lista de Path con todos los .asc encontrados.\n", + " Soporta dos layouts:\n", + " - ruta/2020/*.asc, ruta/2021/*.asc, ... (estructura DATASTAGE_HONDA)\n", + " - ruta/*.asc (carpeta plana)\n", + " Ignora subcarpetas de meses para evitar duplicados con la estructura HONDA.\"\"\"\n", + " raiz = _Path_ds(ruta_raiz)\n", + " if not raiz.exists() or not raiz.is_dir():\n", + " return []\n", + " archivos = []\n", + " # Modo HONDA: solo .asc directos en subcarpetas que sean YYYY\n", + " subdirs_anio = [d for d in raiz.iterdir() if d.is_dir() and d.name.isdigit()]\n", + " if subdirs_anio:\n", + " for d in sorted(subdirs_anio):\n", + " for f in sorted(d.glob('*.asc')):\n", + " if f.is_file():\n", + " archivos.append(f)\n", + " # Modo plano: .asc directos en la raiz\n", + " for f in sorted(raiz.glob('*.asc')):\n", + " if f.is_file():\n", + " archivos.append(f)\n", + " return archivos\n", + "\n", + "\n", + "def _limpiar_valor(valor, tipo_dato):\n", + " v = (valor or '').strip()\n", + " # Filtrar NUL (Postgres lo rechaza) y otros caracteres de control\n", + " v = v.replace('\\x00', '').replace('\\r', '')\n", + " if v == '': return None\n", + " if tipo_dato in _TIPOS_NUMERICOS_PG:\n", + " v = v.replace(',', '.')\n", + " v = _re_ds.sub(r'[^0-9.\\-]', '', v)\n", + " if v in ('', '-'): return None\n", + " return v\n", + "\n", + "\n", + "def cargar_archivo_asc(conn, ruta_archivo):\n", + " \"\"\"Carga un .asc en su tabla Registro. Usa SAVEPOINT por archivo:\n", + " un error no rompe los archivos anteriores. Devuelve dict con resultado.\"\"\"\n", + " p = _Path_ds(ruta_archivo)\n", + " nombre = p.name\n", + " registro = extraer_tipo_registro(nombre)\n", + " if registro is None:\n", + " return {'archivo': nombre, 'tabla': None, 'filas': 0,\n", + " 'estatus': 'SKIP', 'mensaje': 'No se reconoce la estructura'}\n", + " tabla = f'Registro{registro}'\n", + " columnas = obtener_esquema_tabla(conn, tabla)\n", + " if not columnas:\n", + " return {'archivo': nombre, 'tabla': tabla, 'filas': 0,\n", + " 'estatus': 'SKIP', 'mensaje': f\"Tabla '{tabla}' no existe\"}\n", + " nombres_cols = [c['column_name'] for c in columnas]\n", + " tipos_cols = {c['column_name']: c['data_type'] for c in columnas}\n", + " n = len(nombres_cols)\n", + " cols_sql = ', '.join(f'\"{c}\"' for c in nombres_cols)\n", + " placeholders = ', '.join(['%s'] * n)\n", + " insert_sql = f'INSERT INTO \"{tabla}\" ({cols_sql}) VALUES ({placeholders})'\n", + " filas_ins = 0\n", + " with conn.cursor() as cur:\n", + " cur.execute('SAVEPOINT archivo_sp')\n", + " try:\n", + " with open(p, 'r', encoding='latin-1') as f:\n", + " reader = _csv_ds.reader(f, delimiter='|')\n", + " next(reader, None) # header\n", + " batch = []\n", + " for fila in reader:\n", + " fila = fila[:n]\n", + " while len(fila) < n:\n", + " fila.append('')\n", + " valores = [_limpiar_valor(v, tipos_cols[nombres_cols[i]])\n", + " for i, v in enumerate(fila)]\n", + " batch.append(tuple(valores))\n", + " if len(batch) >= _BATCH_DS:\n", + " with conn.cursor() as cur:\n", + " _psycopg2.extras.execute_batch(cur, insert_sql, batch)\n", + " filas_ins += len(batch)\n", + " batch = []\n", + " if batch:\n", + " with conn.cursor() as cur:\n", + " _psycopg2.extras.execute_batch(cur, insert_sql, batch)\n", + " filas_ins += len(batch)\n", + " with conn.cursor() as cur:\n", + " cur.execute('RELEASE SAVEPOINT archivo_sp')\n", + " return {'archivo': nombre, 'tabla': tabla, 'filas': filas_ins,\n", + " 'estatus': 'OK', 'mensaje': f'{filas_ins:,} filas insertadas'}\n", + " except Exception as e:\n", + " with conn.cursor() as cur:\n", + " cur.execute('ROLLBACK TO SAVEPOINT archivo_sp')\n", + " return {'archivo': nombre, 'tabla': tabla, 'filas': filas_ins,\n", + " 'estatus': 'ERROR', 'mensaje': str(e)}\n", + "\n", + "\n", + "def cargar_directorio_datastage(ruta_raiz, progress=None, log=print):\n", + " \"\"\"Itera todos los .asc encontrados y carga en su tabla Registro.\n", + " Devuelve DataFrame con resultado por archivo.\"\"\"\n", + " prog = _Progress(progress)\n", + " archivos = listar_archivos_asc(ruta_raiz)\n", + " if not archivos:\n", + " log(f'No se encontraron .asc en: {ruta_raiz}')\n", + " return pd.DataFrame()\n", + " log(f'Encontrados {len(archivos):,} archivos .asc')\n", + " prog.setup(len(archivos), 'Cargando .asc')\n", + " # psycopg2.extras se importa lazy\n", + " import psycopg2.extras\n", + " _psycopg2.extras = psycopg2.extras\n", + " conn = _ds_conn()\n", + " conn.autocommit = False\n", + " resultados = []\n", + " ok = sk = er = 0\n", + " total_filas = 0\n", + " try:\n", + " for p in archivos:\n", + " r = cargar_archivo_asc(conn, p)\n", + " resultados.append(r)\n", + " if r['estatus'] == 'OK':\n", + " ok += 1; total_filas += r['filas']\n", + " elif r['estatus'] == 'SKIP':\n", + " sk += 1\n", + " else:\n", + " er += 1\n", + " log(f\" ERROR {r['archivo']}: {r['mensaje']}\")\n", + " prog.step(desc=f\"{r['estatus']} {r['archivo'][:25]}\")\n", + " conn.commit()\n", + " except Exception as e:\n", + " conn.rollback()\n", + " log(f'EXCEPCION GLOBAL: {e}')\n", + " finally:\n", + " conn.close()\n", + " prog.done(f'{ok} OK / {sk} SKIP / {er} ERR')\n", + " log(f'\\n=== RESUMEN DataStage ===')\n", + " log(f' Archivos OK : {ok:,}')\n", + " log(f' Archivos SKIP : {sk:,}')\n", + " log(f' Archivos ERROR : {er:,}')\n", + " log(f' Filas insertadas: {total_filas:,}')\n", + " return pd.DataFrame(resultados)\n", + "\n", + "\n", + "def previsualizar_archivos_ds(ruta_raiz):\n", + " \"\"\"Devuelve DataFrame con archivos detectados + tabla destino + tamanio.\"\"\"\n", + " archivos = listar_archivos_asc(ruta_raiz)\n", + " rows = []\n", + " for p in archivos:\n", + " reg = extraer_tipo_registro(p.name)\n", + " rows.append({\n", + " 'archivo': p.name,\n", + " 'tabla_destino': f'Registro{reg}' if reg else '(no detectado)',\n", + " 'tamanio_kb': round(p.stat().st_size / 1024, 1),\n", + " 'ruta': str(p),\n", + " })\n", + " return pd.DataFrame(rows)\n", + "\n", + "\n", + "def truncar_tablas_registro(progress=None, log=print):\n", + " \"\"\"TRUNCATE en todas las tablas Registro*. Devuelve dict {tabla: filas_antes}.\"\"\"\n", + " prog = _Progress(progress)\n", + " conn = _ds_conn()\n", + " conn.autocommit = False\n", + " resultado = {}\n", + " try:\n", + " with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT table_name FROM information_schema.tables\n", + " WHERE table_schema = 'public'\n", + " AND lower(table_name) LIKE 'registro%'\n", + " ORDER BY table_name\n", + " \"\"\")\n", + " tablas = [r[0] for r in cur.fetchall()]\n", + " if not tablas:\n", + " log('No hay tablas Registro* en Postgres.')\n", + " return resultado\n", + " prog.setup(len(tablas), 'Truncando tablas')\n", + " for t in tablas:\n", + " with conn.cursor() as cur:\n", + " cur.execute(f'SELECT COUNT(*) FROM \"{t}\"')\n", + " antes = cur.fetchone()[0]\n", + " cur.execute(f'TRUNCATE TABLE \"{t}\"')\n", + " resultado[t] = antes\n", + " log(f' TRUNCATE {t}: {antes:,} filas eliminadas')\n", + " prog.step(desc=t[:30])\n", + " conn.commit()\n", + " prog.done(f'{len(tablas)} tablas truncadas')\n", + " log(f'\\nTotal: {sum(resultado.values()):,} filas eliminadas en {len(tablas)} tablas.')\n", + " except Exception as e:\n", + " conn.rollback()\n", + " log(f'ERROR: {e}')\n", + " prog.error('Error')\n", + " finally:\n", + " conn.close()\n", + " return resultado\n", + "\n", + "\n", + "def estadisticas_tablas_registro(progress=None):\n", + " \"\"\"Cuenta filas por tabla Registro*. Devuelve DataFrame con columnas: tabla, filas.\"\"\"\n", + " prog = _Progress(progress)\n", + " conn = _ds_conn()\n", + " try:\n", + " with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " SELECT table_name FROM information_schema.tables\n", + " WHERE table_schema = 'public'\n", + " AND lower(table_name) LIKE 'registro%'\n", + " ORDER BY table_name\n", + " \"\"\")\n", + " tablas = [r[0] for r in cur.fetchall()]\n", + " if not tablas:\n", + " return pd.DataFrame(columns=['tabla', 'filas'])\n", + " prog.setup(len(tablas), 'Contando filas')\n", + " rows = []\n", + " for t in tablas:\n", + " with conn.cursor() as cur:\n", + " cur.execute(f'SELECT COUNT(*) FROM \"{t}\"')\n", + " n = cur.fetchone()[0]\n", + " rows.append({'tabla': t, 'filas': n})\n", + " prog.step(desc=t[:30])\n", + " prog.done('Listo')\n", + " return pd.DataFrame(rows)\n", + " finally:\n", + " conn.close()\n", + "\n", + "\n", + "def obtener_muestra_tabla(tabla, limit=100, offset=0):\n", + " \"\"\"Devuelve hasta `limit` filas de la tabla (con OFFSET) como DataFrame.\"\"\"\n", + " conn = _ds_conn()\n", + " try:\n", + " sql = f'SELECT * FROM \"{tabla}\" LIMIT %s OFFSET %s'\n", + " return pd.read_sql(sql, conn, params=(limit, offset))\n", + " finally:\n", + " conn.close()\n", + "\n", + "\n", + "# ============================================================\n", + "# REPORTES DataStage (migrados de PHP a Postgres)\n", + "# ============================================================\n", + "\n", + "def _ds_format_pedimento_sql(yy_col, sec_col, pat_col, ped_col):\n", + " \"\"\"Construye PEDIMENTO formato YY-SS-PPPP-NNNNNNN en SQL Postgres.\"\"\"\n", + " return (f\"(RIGHT(LEFT({yy_col}::text, 4), 2) || '-' \"\n", + " f\"|| LEFT({sec_col}, 2) || '-' \"\n", + " f\"|| {pat_col} || '-' \"\n", + " f\"|| {ped_col})\")\n", + "\n", + "\n", + "def cat_pedimentos_ds(fecha_ini, fecha_fin):\n", + " \"\"\"Estructura CAT Pedimentos. Toma Registro501 en el rango y marca si fue\n", + " rectificado (existe en Registro701 como pedimento anterior).\n", + " Migrado de generar_estructuracat.php.\"\"\"\n", + " sql = \"\"\"\n", + " WITH RECURSIVE historial_rect AS (\n", + " SELECT R7.\"Patente\", R7.\"PatenteAnterior\", R7.\"Pedimento\",\n", + " R7.\"SeccionAduanera\", R7.\"SeccionAduaneraAnterior\",\n", + " R7.\"PedimentoAnterior\", R7.\"DocumentoAnterior\",\n", + " R7.\"FechaOperacionAnterior\", R7.\"FechaPagoReal\"\n", + " FROM \"Registro701\" R7\n", + " UNION ALL\n", + " SELECT R7.\"Patente\", R7.\"PatenteAnterior\", R7.\"Pedimento\",\n", + " R7.\"SeccionAduanera\", R7.\"SeccionAduaneraAnterior\",\n", + " R7.\"PedimentoAnterior\", R7.\"DocumentoAnterior\",\n", + " R7.\"FechaOperacionAnterior\", R7.\"FechaPagoReal\"\n", + " FROM \"Registro701\" R7\n", + " INNER JOIN historial_rect HR ON\n", + " (RIGHT(LEFT(R7.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(R7.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " R7.\"PatenteAnterior\" || '-' || R7.\"PedimentoAnterior\")\n", + " =\n", + " (RIGHT(LEFT(HR.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(HR.\"SeccionAduanera\", 2) || '-' ||\n", + " HR.\"Patente\" || '-' || HR.\"Pedimento\")\n", + " )\n", + " SELECT\n", + " (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\") AS \"PEDIMENTO\",\n", + " CASE WHEN Q1.\"TipoOperacion\"::text = '1' THEN 'I'\n", + " WHEN Q1.\"TipoOperacion\"::text = '2' THEN 'E'\n", + " ELSE 'Otro' END AS \"TIPO PEDIMENTO\",\n", + " CASE WHEN EXISTS (\n", + " SELECT 1 FROM historial_rect H\n", + " WHERE (RIGHT(LEFT(H.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(H.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " H.\"PatenteAnterior\" || '-' || H.\"PedimentoAnterior\")\n", + " = (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " ) THEN 'R1' ELSE Q1.\"ClaveDocumento\" END AS \"CLAVE PEDIMENTO\",\n", + " Q1.\"FechaPagoReal\" AS \"FECHA PAGO\",\n", + " Q1.\"SeccionAduaneraEntrada\" AS \"SECCION ADUANERA\",\n", + " Q1.\"MedioTransporteEntrada_Salida\" AS \"MEDIO TRANSPORTE ENTRADA\",\n", + " Q1.\"MedioTransporteArribo\" AS \"MEDIO TRANSPORTE ARRIBO\",\n", + " Q1.\"MedioTransporteSalida\" AS \"MEDIO TRANSPORTE SALIDA\",\n", + " CASE WHEN EXISTS (\n", + " SELECT 1 FROM historial_rect H\n", + " WHERE (RIGHT(LEFT(H.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(H.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " H.\"PatenteAnterior\" || '-' || H.\"PedimentoAnterior\")\n", + " = (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " ) THEN 'Si' ELSE 'No' END AS \"SE RECTIFICO\",\n", + " (SELECT (RIGHT(LEFT(H.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(H.\"SeccionAduanera\", 2) || '-' ||\n", + " H.\"Patente\" || '-' || H.\"Pedimento\")\n", + " FROM historial_rect H\n", + " WHERE (RIGHT(LEFT(H.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(H.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " H.\"PatenteAnterior\" || '-' || H.\"PedimentoAnterior\")\n", + " = (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " ORDER BY H.\"FechaPagoReal\" DESC LIMIT 1) AS \"PEDIMENTO RECTIFICADO\",\n", + " Q1.\"TotalSeguros\" AS \"SEGUROS\",\n", + " Q1.\"TotalEmbalajes\" AS \"EMBALAJES\",\n", + " Q1.\"TotalIncrementables\" AS \"OTROS INCREMENTALES\"\n", + " FROM \"Registro501\" Q1\n", + " WHERE Q1.\"FechaPagoReal\" BETWEEN %s AND %s\n", + " ORDER BY Q1.\"FechaPagoReal\"\n", + " \"\"\"\n", + " return pd.read_sql(sql, pg_engine, params=(fecha_ini, fecha_fin))\n", + "\n", + "\n", + "def cat_pedimentos_rect_ds(fecha_ini, fecha_fin):\n", + " \"\"\"Estructura CAT Pedimentos Rectificados. Desde Registro701 con tipo de\n", + " operacion tomado de Registro501 que lo origino.\n", + " Migrado de generar_estructuracatScaf.php.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT\n", + " (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\") AS \"PEDIMENTO\",\n", + " COALESCE(Q2.\"TipoOperacion\"::text, 'Desconocido') AS \"TIPO PEDIMENTO\",\n", + " Q1.\"ClaveDocumento\" AS \"CLAVE PEDIMENTO\",\n", + " Q1.\"FechaPagoReal\" AS \"FECHA PAGO\",\n", + " Q1.\"SeccionAduanera\" AS \"SECCION ADUANERA\",\n", + " CASE WHEN Q3.\"Pedimento\" IS NOT NULL THEN 'SI' ELSE 'NO' END AS \"SE RECTIFICO\",\n", + " COALESCE(\n", + " (RIGHT(LEFT(Q3.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q3.\"SeccionAduanera\", 2) || '-' ||\n", + " Q3.\"Patente\" || '-' || Q3.\"Pedimento\"), '') AS \"PEDIMENTO RECTIFICADO\",\n", + " Q2.\"MedioTransporteEntrada_Salida\" AS \"MEDIO TRANSPORTE ENTRADA\",\n", + " Q2.\"MedioTransporteArribo\" AS \"MEDIO TRANSPORTE ARRIBO\",\n", + " Q2.\"MedioTransporteSalida\" AS \"MEDIO TRANSPORTE SALIDA\",\n", + " Q2.\"TotalSeguros\" AS \"SEGUROS\",\n", + " Q2.\"TotalEmbalajes\" AS \"EMBALAJES\",\n", + " Q2.\"TotalIncrementables\" AS \"OTROS INCREMENTALES\"\n", + " FROM \"Registro701\" Q1\n", + " LEFT JOIN \"Registro501\" Q2 ON\n", + " (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " =\n", + " (RIGHT(LEFT(Q2.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q2.\"SeccionAduanera\", 2) || '-' ||\n", + " Q2.\"Patente\" || '-' || Q2.\"Pedimento\")\n", + " LEFT JOIN \"Registro701\" Q3 ON\n", + " (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " =\n", + " (RIGHT(LEFT(Q3.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q3.\"SeccionAduanera\", 2) || '-' ||\n", + " Q3.\"Patente\" || '-' || Q3.\"PedimentoAnterior\")\n", + " WHERE Q1.\"FechaPagoReal\" BETWEEN %s AND %s\n", + " ORDER BY Q1.\"FechaPagoReal\"\n", + " \"\"\"\n", + " return pd.read_sql(sql, pg_engine, params=(fecha_ini, fecha_fin))\n", + "\n", + "\n", + "def rectificados_ds(fecha_ini=None, fecha_fin=None, search=''):\n", + " \"\"\"Lista pedimentos de Registro501 que tienen rectificacion en Registro701.\n", + " Filtros opcionales por rango de fechas y por texto libre.\n", + " Migrado de rectificados.php.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT R501.\"Patente\", R501.\"Pedimento\", R501.\"SeccionAduanera\",\n", + " R501.\"ClaveDocumento\", R501.\"FechaPagoReal\"\n", + " FROM \"Registro501\" R501\n", + " WHERE EXISTS (\n", + " SELECT 1 FROM \"Registro701\" R701\n", + " WHERE R701.\"PedimentoAnterior\" = R501.\"Pedimento\"\n", + " AND R701.\"PatenteAnterior\" = R501.\"Patente\"\n", + " AND R701.\"SeccionAduaneraAnterior\"= R501.\"SeccionAduanera\"\n", + " AND EXTRACT(YEAR FROM R501.\"FechaPagoReal\"::date)\n", + " = EXTRACT(YEAR FROM R701.\"FechaOperacionAnterior\"::date)\n", + " )\n", + " \"\"\"\n", + " params = []\n", + " if fecha_ini and fecha_fin:\n", + " sql += ' AND R501.\"FechaPagoReal\" BETWEEN %s AND %s'\n", + " params += [fecha_ini, fecha_fin]\n", + " if search:\n", + " sql += (' AND (R501.\"Pedimento\" LIKE %s OR R501.\"Patente\" LIKE %s '\n", + " 'OR R501.\"ClaveDocumento\" LIKE %s)')\n", + " like = f'%{search}%'\n", + " params += [like, like, like]\n", + " sql += ' ORDER BY R501.\"FechaPagoReal\" DESC'\n", + " return pd.read_sql(sql, pg_engine, params=tuple(params))\n", + "\n", + "\n", + "def historial_rectificaciones_ds(patente, pedimento, seccion_aduanera, anio_operacion):\n", + " \"\"\"Cadena recursiva de rectificaciones para un pedimento dado.\n", + " Migrado de obtener_historial.php.\"\"\"\n", + " sql = \"\"\"\n", + " WITH RECURSIVE historial AS (\n", + " SELECT R7.\"Patente\", R7.\"Pedimento\", R7.\"SeccionAduanera\",\n", + " R7.\"ClaveDocumento\", R7.\"FechaPago\", R7.\"PedimentoAnterior\",\n", + " R7.\"PatenteAnterior\", R7.\"SeccionAduaneraAnterior\",\n", + " R7.\"DocumentoAnterior\", R7.\"FechaOperacionAnterior\",\n", + " R7.\"FechaPagoReal\"\n", + " FROM \"Registro701\" R7\n", + " WHERE R7.\"PedimentoAnterior\" = %s\n", + " AND R7.\"PatenteAnterior\" = %s\n", + " AND R7.\"SeccionAduaneraAnterior\" = %s\n", + " AND EXTRACT(YEAR FROM R7.\"FechaOperacionAnterior\"::date) = %s\n", + " UNION ALL\n", + " SELECT R7.\"Patente\", R7.\"Pedimento\", R7.\"SeccionAduanera\",\n", + " R7.\"ClaveDocumento\", R7.\"FechaPago\", R7.\"PedimentoAnterior\",\n", + " R7.\"PatenteAnterior\", R7.\"SeccionAduaneraAnterior\",\n", + " R7.\"DocumentoAnterior\", R7.\"FechaOperacionAnterior\",\n", + " R7.\"FechaPagoReal\"\n", + " FROM \"Registro701\" R7\n", + " INNER JOIN historial HR ON\n", + " R7.\"PedimentoAnterior\" = HR.\"Pedimento\"\n", + " AND R7.\"PatenteAnterior\" = HR.\"Patente\"\n", + " AND R7.\"SeccionAduaneraAnterior\" = HR.\"SeccionAduanera\"\n", + " AND EXTRACT(YEAR FROM R7.\"FechaOperacionAnterior\"::date)\n", + " = EXTRACT(YEAR FROM HR.\"FechaOperacionAnterior\"::date)\n", + " )\n", + " SELECT * FROM historial ORDER BY \"FechaPagoReal\" ASC\n", + " \"\"\"\n", + " return pd.read_sql(sql, pg_engine, params=(\n", + " str(pedimento), str(patente), str(seccion_aduanera), int(anio_operacion)))\n", + "\n", + "\n", + "def exportar_df_a_excel(df, nombre_prefijo):\n", + " \"\"\"Guarda DataFrame en xlsx con timestamp y devuelve la ruta.\"\"\"\n", + " import datetime as _dt\n", + " ts = _dt.datetime.now().strftime('%Y%m%d_%H%M%S')\n", + " ruta = os.path.join(os.getcwd(), f'{nombre_prefijo}_{ts}.xlsx')\n", + " df.to_excel(ruta, index=False)\n", + " return ruta\n", + "\n", + "\n", + "def encabezado_facturas_ds(fecha_ini, fecha_fin, tipo_op):\n", + " \"\"\"Encabezado de facturas (Impo o Expo). Migrado de\n", + " generar_estructura_factImpo.php y generar_estructura_factExpo.php.\n", + " tipo_op: 1 = Impo, 2 = Expo.\n", + " Filtra Registro501 por TipoOperacion + rango FechaPagoReal y excluye\n", + " los pedimentos que ya fueron rectificados (existen en Registro701).\"\"\"\n", + " if int(tipo_op) not in (1, 2):\n", + " raise ValueError(\"tipo_op debe ser 1 (Impo) o 2 (Expo)\")\n", + " sql = \"\"\"\n", + " WITH RECURSIVE historial_rect AS (\n", + " SELECT R7.\"Patente\", R7.\"PatenteAnterior\", R7.\"Pedimento\",\n", + " R7.\"SeccionAduanera\", R7.\"SeccionAduaneraAnterior\",\n", + " R7.\"PedimentoAnterior\", R7.\"DocumentoAnterior\",\n", + " R7.\"FechaOperacionAnterior\", R7.\"FechaPagoReal\"\n", + " FROM \"Registro701\" R7\n", + " UNION ALL\n", + " SELECT R7.\"Patente\", R7.\"PatenteAnterior\", R7.\"Pedimento\",\n", + " R7.\"SeccionAduanera\", R7.\"SeccionAduaneraAnterior\",\n", + " R7.\"PedimentoAnterior\", R7.\"DocumentoAnterior\",\n", + " R7.\"FechaOperacionAnterior\", R7.\"FechaPagoReal\"\n", + " FROM \"Registro701\" R7\n", + " INNER JOIN historial_rect HR ON\n", + " (RIGHT(LEFT(R7.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(R7.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " R7.\"PatenteAnterior\" || '-' || R7.\"PedimentoAnterior\")\n", + " =\n", + " (RIGHT(LEFT(HR.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(HR.\"SeccionAduanera\", 2) || '-' ||\n", + " HR.\"Patente\" || '-' || HR.\"Pedimento\")\n", + " )\n", + " SELECT\n", + " (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\") AS \"PEDIMENTO\",\n", + " '1' AS \"REMESA\",\n", + " CASE WHEN EXISTS (\n", + " SELECT 1 FROM historial_rect H\n", + " WHERE (RIGHT(LEFT(H.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(H.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " H.\"PatenteAnterior\" || '-' || H.\"PedimentoAnterior\")\n", + " = (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " ) THEN Q1.\"Pedimento\" || '-' || 'R1'\n", + " ELSE Q1.\"Pedimento\" || '-' || Q1.\"ClaveDocumento\"\n", + " END AS \"NUMERO FACTURA\",\n", + " Q1.\"FechaPagoReal\" AS \"FECHA FACTURA\",\n", + " Q1.\"TipoCambio\" AS \"TIPO DE CAMBIO\",\n", + " '1' AS \"CLAVE PROVEEDOR\",\n", + " '8' AS \"CLAVE VENDIDO A\",\n", + " '8' AS \"CLAVE ENVIADO A\",\n", + " Q1.\"Patente\" AS \"AGENTE ADUANAL\",\n", + " '' AS \"CLAVE TRANSPORTISTA\",\n", + " '' AS \"NOMBRE CONDUCTOR\",\n", + " '' AS \"TIPO TRANSPORTE\",\n", + " '' AS \"NUMERO TRANSPORTE\",\n", + " 'ME' AS \"TIPO MONEDA\",\n", + " 'USD' AS \"CLAVE MONEDA\",\n", + " '' AS \"FLETES\",\n", + " '' AS \"VALORE SEGUROS\",\n", + " '' AS \"SEGUROS\",\n", + " '' AS \"EMBALAJES\",\n", + " '' AS \"OTROS INCREMENTALES\",\n", + " '' AS \"CLAVE INTERCOM\",\n", + " '' AS \"PRECINTO\",\n", + " Q1.\"FechaPagoReal\" AS \"FECHA EMISION\",\n", + " 'KILOS' AS \"TIPO PESO\",\n", + " '' AS \"E-DOCUMENT\",\n", + " '' AS \"NUM.OPERACION\",\n", + " Q1.\"SeccionAduanera\" AS \"ADUANA DE CRUCE\",\n", + " '' AS \"OBSERVACIONES E\",\n", + " '' AS \"LOCALIZACION\"\n", + " FROM \"Registro501\" Q1\n", + " WHERE Q1.\"TipoOperacion\"::text = %s\n", + " AND Q1.\"FechaPagoReal\" BETWEEN %s AND %s\n", + " AND NOT EXISTS (\n", + " SELECT 1 FROM \"Registro701\" R7\n", + " WHERE (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " = (RIGHT(LEFT(R7.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(R7.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " R7.\"PatenteAnterior\" || '-' || R7.\"Pedimento\")\n", + " )\n", + " ORDER BY Q1.\"FechaPagoReal\" ASC\n", + " \"\"\"\n", + " return pd.read_sql(sql, pg_engine, params=(str(int(tipo_op)), fecha_ini, fecha_fin))\n", + "\n", + "\n", + "def tipo_cambio_ds(fecha_ini, fecha_fin):\n", + " \"\"\"Estructura Tipo de Cambio 501. Migrado de generar_estructura_tipo_cambio.php.\n", + " Devuelve PEDIMENTO, ClaveDocumento, TipoCambio, FechaPagoReal y una columna\n", + " INCONSISTENTE = True cuando existen distintos TipoCambio para la misma fecha.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT\n", + " (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\") AS \"PEDIMENTO\",\n", + " Q1.\"ClaveDocumento\" AS \"CLAVE DOCUMENTO\",\n", + " Q1.\"TipoCambio\" AS \"TIPO CAMBIO\",\n", + " Q1.\"FechaPagoReal\" AS \"FECHA PAGO REAL\"\n", + " FROM \"Registro501\" Q1\n", + " WHERE Q1.\"FechaPagoReal\" BETWEEN %s AND %s\n", + " ORDER BY Q1.\"FechaPagoReal\" ASC\n", + " \"\"\"\n", + " df = pd.read_sql(sql, pg_engine, params=(fecha_ini, fecha_fin))\n", + " if df.empty:\n", + " df['INCONSISTENTE'] = []\n", + " return df\n", + " # Inconsistencia: fechas (formato d/m/Y) donde hay > 1 valor distinto de TIPO CAMBIO\n", + " fechas_norm = pd.to_datetime(df['FECHA PAGO REAL']).dt.strftime('%d/%m/%Y')\n", + " distintos = df.assign(_fnorm=fechas_norm).groupby('_fnorm')['TIPO CAMBIO'].nunique()\n", + " fechas_incon = set(distintos[distintos > 1].index)\n", + " df['INCONSISTENTE'] = fechas_norm.isin(fechas_incon)\n", + " return df\n", + "\n", + "\n", + "def exportar_tipo_cambio_excel(df, ruta):\n", + " \"\"\"Exporta tipo_cambio_ds() a xlsx con celdas TIPO CAMBIO en rojo cuando\n", + " INCONSISTENTE=True. Usa openpyxl.\"\"\"\n", + " from openpyxl import Workbook\n", + " from openpyxl.styles import PatternFill, Alignment, Font\n", + " wb = Workbook()\n", + " ws = wb.active\n", + " ws.title = 'TipoCambio'\n", + " columnas_out = ['PEDIMENTO', 'CLAVE DOCUMENTO', 'TIPO CAMBIO', 'FECHA PAGO REAL']\n", + " # Header\n", + " for j, h in enumerate(columnas_out, start=1):\n", + " c = ws.cell(row=1, column=j, value=h)\n", + " c.alignment = Alignment(horizontal='center')\n", + " c.font = Font(bold=True)\n", + " rojo = PatternFill(start_color='FFFF0000', end_color='FFFF0000', fill_type='solid')\n", + " fblanca = Font(color='FFFFFFFF')\n", + " for i, fila in enumerate(df.itertuples(index=False), start=2):\n", + " d = fila._asdict() if hasattr(fila, '_asdict') else dict(zip(df.columns, fila))\n", + " ws.cell(row=i, column=1, value=d.get('PEDIMENTO')).alignment = Alignment(horizontal='center')\n", + " ws.cell(row=i, column=2, value=d.get('CLAVE DOCUMENTO')).alignment = Alignment(horizontal='center')\n", + " c_tc = ws.cell(row=i, column=3, value=d.get('TIPO CAMBIO'))\n", + " c_tc.alignment = Alignment(horizontal='center')\n", + " if d.get('INCONSISTENTE'):\n", + " c_tc.fill = rojo\n", + " c_tc.font = fblanca\n", + " fpr = d.get('FECHA PAGO REAL')\n", + " try:\n", + " fpr = pd.to_datetime(fpr).strftime('%d/%m/%Y')\n", + " except Exception:\n", + " fpr = str(fpr)\n", + " ws.cell(row=i, column=4, value=fpr).alignment = Alignment(horizontal='center')\n", + " wb.save(ruta)\n", + " return ruta\n", + "\n", + "\n", + "# ============================================================\n", + "# PARTIDAS - Catalogo de NUMPARTES y asignacion por similitud\n", + "# ============================================================\n", + "\n", + "_UM_MAP_551 = {\n", + " 1: 'KGS', 2: 'GR', 3: 'CM', 4: 'CM2', 5: 'BD FT', 6: 'PZA',\n", + " 7: 'CBZA',8: 'LT', 9: 'PAR', 12:'JGO',14:'TON', 17:'DEC',\n", + " 18:'CIEN',19:'DOCE',20:'CAJA',21:'BTL',22:'CARAT'\n", + "}\n", + "\n", + "\n", + "def _ds_limpiar_texto(t):\n", + " \"\"\"Mismo limpiador que usa el NLP de sustitutos.\"\"\"\n", + " if pd.isna(t) or str(t).strip() == '': return ''\n", + " s = _re_ds.sub(r'[^\\w\\s]', ' ', str(t).upper().strip())\n", + " return _re_ds.sub(r'\\s+', ' ', s).strip()\n", + "\n", + "\n", + "def crear_tabla_base_numpartes():\n", + " \"\"\"DDL idempotente para la tabla del catalogo del cliente.\"\"\"\n", + " conn = _ds_conn()\n", + " conn.autocommit = True\n", + " try:\n", + " with conn.cursor() as cur:\n", + " cur.execute(\"\"\"\n", + " CREATE TABLE IF NOT EXISTS base_numpartes (\n", + " numparte TEXT PRIMARY KEY,\n", + " descripcion TEXT,\n", + " unimed TEXT,\n", + " fraccion TEXT\n", + " )\n", + " \"\"\")\n", + " finally:\n", + " conn.close()\n", + "\n", + "\n", + "def cargar_excel_base_numpartes(path, log=print):\n", + " \"\"\"Lee Excel con columnas NUMPARTE, DESCRIPCION, UNIDAD DE MEDIDA, FRACCION.\n", + " Acepta variantes y hace UPSERT (acumular).\"\"\"\n", + " crear_tabla_base_numpartes()\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " aliases = {\n", + " 'NUMPARTE': ['NUMPARTE', 'NUM_PARTE', 'NUMERO_DE_PARTE', 'NUMERO_PARTE', 'PARTE'],\n", + " 'DESCRIPCION': ['DESCRIPCION', 'DESCRIPCION_PARTE', 'DESC', 'DESCRIPCIONE'],\n", + " 'UNIMED': ['UNIDAD_DE_MEDIDA', 'UNIMED', 'UM', 'UNIDAD'],\n", + " 'FRACCION': ['FRACCION', 'FRACCION_ARANCELARIA', 'FRACC'],\n", + " }\n", + " out = {}\n", + " for std, opts in aliases.items():\n", + " for o in opts:\n", + " if o in df.columns:\n", + " out[std] = df[o]; break\n", + " if std not in out and std != 'FRACCION':\n", + " raise ValueError(f'Falta la columna {std} (acepta: {opts})')\n", + " if std not in out:\n", + " out[std] = ''\n", + " df2 = pd.DataFrame(out)\n", + " for c in df2.columns:\n", + " df2[c] = df2[c].fillna('').astype(str).str.strip()\n", + " df2 = df2[df2['NUMPARTE'] != ''].drop_duplicates(subset='NUMPARTE').reset_index(drop=True)\n", + " log(f'Filas validas en el Excel: {len(df2):,}')\n", + " conn = _ds_conn()\n", + " conn.autocommit = False\n", + " upserted = 0\n", + " try:\n", + " with conn.cursor() as cur:\n", + " for _, r in df2.iterrows():\n", + " cur.execute(\"\"\"\n", + " INSERT INTO base_numpartes (numparte, descripcion, unimed, fraccion)\n", + " VALUES (%s, %s, %s, %s)\n", + " ON CONFLICT (numparte) DO UPDATE SET\n", + " descripcion = EXCLUDED.descripcion,\n", + " unimed = EXCLUDED.unimed,\n", + " fraccion = EXCLUDED.fraccion\n", + " \"\"\", (r['NUMPARTE'], r['DESCRIPCION'], r['UNIMED'], r['FRACCION']))\n", + " upserted += 1\n", + " conn.commit()\n", + " except Exception as e:\n", + " conn.rollback()\n", + " log(f'ERROR: {e}')\n", + " finally:\n", + " conn.close()\n", + " log(f'Upsert completado: {upserted:,} filas')\n", + " return upserted\n", + "\n", + "\n", + "def listar_base_numpartes(limit=500):\n", + " \"\"\"Devuelve DataFrame con el catalogo actual.\"\"\"\n", + " crear_tabla_base_numpartes()\n", + " conn = _ds_conn()\n", + " try:\n", + " return pd.read_sql(\n", + " 'SELECT numparte, descripcion, unimed, fraccion FROM base_numpartes '\n", + " 'ORDER BY numparte LIMIT %s', conn, params=(int(limit),))\n", + " finally:\n", + " conn.close()\n", + "\n", + "\n", + "def truncar_base_numpartes(log=print):\n", + " \"\"\"TRUNCATE base_numpartes. Devuelve filas eliminadas.\"\"\"\n", + " crear_tabla_base_numpartes()\n", + " conn = _ds_conn()\n", + " conn.autocommit = False\n", + " try:\n", + " with conn.cursor() as cur:\n", + " cur.execute('SELECT COUNT(*) FROM base_numpartes')\n", + " n = cur.fetchone()[0]\n", + " cur.execute('TRUNCATE TABLE base_numpartes')\n", + " conn.commit()\n", + " log(f'base_numpartes truncada: {n:,} filas eliminadas')\n", + " return n\n", + " except Exception as e:\n", + " conn.rollback()\n", + " log(f'ERROR: {e}')\n", + " return 0\n", + " finally:\n", + " conn.close()\n", + "\n", + "\n", + "def _ds_um_sigla(num_um):\n", + " \"\"\"Mapea codigo numerico de UM del 551 a sigla. Si ya es string, devuelve uppercase.\"\"\"\n", + " if pd.isna(num_um): return ''\n", + " try:\n", + " n = int(num_um)\n", + " return _UM_MAP_551.get(n, str(num_um).strip().upper())\n", + " except (ValueError, TypeError):\n", + " return str(num_um).strip().upper()\n", + "\n", + "\n", + "def _cargar_partidas_551(fecha_ini, fecha_fin, tipo_op):\n", + " \"\"\"Carga partidas del Registro551 en el rango con LATERAL JOIN a Registro501\n", + " para obtener ClaveDocumento y TipoCambio (toma una sola fila aunque haya\n", + " duplicados en 501), y EXISTS Registro701 para flag rectificado.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT\n", + " Q1.\"Patente\" AS patente,\n", + " Q1.\"Pedimento\" AS pedimento,\n", + " Q1.\"SeccionAduanera\" AS seccion_aduanera,\n", + " Q1.\"Fraccion\" AS fraccion,\n", + " Q1.\"SecuenciaFraccion\" AS secuencia_fraccion,\n", + " Q1.\"DescripcionMercancia\" AS descripcion_mercancia,\n", + " Q1.\"PrecioUnitario\" AS precio_unitario,\n", + " Q1.\"ValorAduana\" AS valor_aduana,\n", + " Q1.\"ValorComercial\" AS valor_comercial,\n", + " Q1.\"ValorDolares\" AS valor_dolares,\n", + " Q1.\"ValorAgregado\" AS valor_agregado,\n", + " Q1.\"CantidadUMComercial\" AS cantidad_um_comercial,\n", + " Q1.\"UnidadMedidaComercial\" AS unidad_medida_comercial,\n", + " Q1.\"CantidadUMTarifa\" AS cantidad_um_tarifa,\n", + " Q1.\"UnidadMedidaTarifa\" AS unidad_medida_tarifa,\n", + " Q1.\"MetodoValorizacion\" AS metodo_valorizacion,\n", + " Q1.\"PaisOrigenDestino\" AS pais_origen_destino,\n", + " Q1.\"ClaveDocumento\" AS clave_documento_551,\n", + " Q1.\"FechaPagoReal\" AS fecha_pago_real,\n", + " Q1.\"TipoOperacion\" AS tipo_operacion,\n", + " R501.clave_documento_501,\n", + " R501.tipo_cambio_501,\n", + " CASE WHEN EXISTS (\n", + " SELECT 1 FROM \"Registro701\" R7\n", + " WHERE (RIGHT(LEFT(Q1.\"FechaPagoReal\"::text, 4), 2) || '-' ||\n", + " LEFT(Q1.\"SeccionAduanera\", 2) || '-' ||\n", + " Q1.\"Patente\" || '-' || Q1.\"Pedimento\")\n", + " = (RIGHT(LEFT(R7.\"FechaOperacionAnterior\"::text, 4), 2) || '-' ||\n", + " LEFT(R7.\"SeccionAduaneraAnterior\", 2) || '-' ||\n", + " R7.\"PatenteAnterior\" || '-' || R7.\"PedimentoAnterior\")\n", + " ) THEN 1 ELSE 0 END AS rectificado\n", + " FROM \"Registro551\" Q1\n", + " LEFT JOIN LATERAL (\n", + " SELECT R501i.\"ClaveDocumento\" AS clave_documento_501,\n", + " R501i.\"TipoCambio\" AS tipo_cambio_501\n", + " FROM \"Registro501\" R501i\n", + " WHERE R501i.\"Patente\" = Q1.\"Patente\"\n", + " AND R501i.\"Pedimento\" = Q1.\"Pedimento\"\n", + " AND R501i.\"SeccionAduanera\" = Q1.\"SeccionAduanera\"\n", + " LIMIT 1\n", + " ) R501 ON TRUE\n", + " WHERE Q1.\"TipoOperacion\"::text = %s\n", + " AND Q1.\"FechaPagoReal\" BETWEEN %s AND %s\n", + " ORDER BY Q1.\"FechaPagoReal\", Q1.\"Patente\", Q1.\"Pedimento\", Q1.\"SecuenciaFraccion\"\n", + " \"\"\"\n", + " df = pd.read_sql(sql, pg_engine, params=(str(int(tipo_op)), fecha_ini, fecha_fin))\n", + " # Dedupe defensivo: si el Registro551 se cargo N veces, no multiplicar partidas\n", + " antes = len(df)\n", + " df = df.drop_duplicates(\n", + " subset=['patente', 'pedimento', 'seccion_aduanera', 'fraccion', 'secuencia_fraccion'],\n", + " keep='first').reset_index(drop=True)\n", + " dups = antes - len(df)\n", + " if dups > 0:\n", + " print(f' [INFO] Se omitieron {dups:,} filas duplicadas del Registro551 '\n", + " f'(misma Patente+Pedimento+SeccionAduanera+Fraccion+SecuenciaFraccion).')\n", + " return df\n", + "\n", + "\n", + "def asignar_numpartes_551(fecha_ini, fecha_fin, tipo_op, umbral_sim=0.80,\n", + " progress=None, log=print):\n", + " \"\"\"Genera la Estructura de Partidas a partir de Registro551, asignando NUMPARTE\n", + " por similitud contra base_numpartes y agrupando huerfanas con 'MP-R'.\"\"\"\n", + " assert int(tipo_op) in (1, 2), 'tipo_op debe ser 1 (Impo) o 2 (Expo)'\n", + " from sklearn.feature_extraction.text import TfidfVectorizer\n", + " from sklearn.metrics.pairwise import cosine_similarity\n", + " prog = _Progress(progress)\n", + " prog.setup(5, 'Cargando Registro551...')\n", + " df = _cargar_partidas_551(fecha_ini, fecha_fin, tipo_op)\n", + " log(f'Partidas Registro551 ({\"IMPO\" if int(tipo_op)==1 else \"EXPO\"}): {len(df):,}')\n", + " if df.empty:\n", + " prog.done('Sin datos'); return df\n", + " prog.step(desc='Preparando textos...')\n", + "\n", + " df['um_sigla'] = df['unidad_medida_comercial'].apply(_ds_um_sigla)\n", + " df['fraccion4'] = df['fraccion'].fillna('').astype(str).str[:4]\n", + " df['desc_norm'] = df['descripcion_mercancia'].apply(_ds_limpiar_texto)\n", + " df['NUMERO_PARTE'] = ''\n", + " df['MATCH_TIPO'] = ''\n", + "\n", + " try:\n", + " df_base = listar_base_numpartes(limit=10_000_000)\n", + " except Exception as e:\n", + " log(f'WARN cargando base_numpartes: {e}')\n", + " df_base = pd.DataFrame(columns=['numparte','descripcion','unimed','fraccion'])\n", + " if not df_base.empty:\n", + " df_base['fraccion4'] = df_base['fraccion'].fillna('').astype(str).str[:4]\n", + " df_base['um_sigla'] = df_base['unimed'].fillna('').astype(str).str.upper().str.strip()\n", + " df_base['desc_norm'] = df_base['descripcion'].apply(_ds_limpiar_texto)\n", + " log(f'Catalogo base_numpartes: {len(df_base):,}')\n", + "\n", + " secuenciales_por_f4 = {}\n", + " grupos = list(df.groupby(['fraccion4', 'um_sigla'], dropna=False))\n", + " total_grupos = len(grupos)\n", + " prog.setup(total_grupos, 'Procesando grupos...')\n", + "\n", + " for procesados, ((f4, um), g) in enumerate(grupos, start=1):\n", + " idxs_g = list(g.index)\n", + " textos_g = g['desc_norm'].tolist()\n", + " base_sub = df_base[(df_base['fraccion4'] == f4) & (df_base['um_sigla'] == um)] if not df_base.empty else df_base\n", + " sin_base_idx = []\n", + " if not base_sub.empty and any(t for t in base_sub['desc_norm'].tolist()):\n", + " try:\n", + " vec = TfidfVectorizer(ngram_range=(1,2), sublinear_tf=True,\n", + " min_df=1, max_features=20000)\n", + " vec.fit(pd.concat([base_sub['desc_norm'],\n", + " pd.Series(textos_g)], ignore_index=True))\n", + " base_mat = vec.transform(base_sub['desc_norm'].tolist())\n", + " grp_mat = vec.transform(textos_g)\n", + " sims = cosine_similarity(grp_mat, base_mat)\n", + " for j, idx in enumerate(idxs_g):\n", + " best_j = int(sims[j].argmax())\n", + " best_s = float(sims[j][best_j])\n", + " if best_s >= umbral_sim and textos_g[j]:\n", + " df.at[idx, 'NUMERO_PARTE'] = str(base_sub.iloc[best_j]['numparte'])\n", + " df.at[idx, 'MATCH_TIPO'] = f'BASE ({best_s:.2f})'\n", + " else:\n", + " sin_base_idx.append(idx)\n", + " except Exception as e:\n", + " log(f' WARN TF-IDF base en grupo ({f4},{um}): {e}')\n", + " sin_base_idx.extend(idxs_g)\n", + " else:\n", + " sin_base_idx.extend(idxs_g)\n", + "\n", + " if sin_base_idx:\n", + " textos_h = [df.at[i, 'desc_norm'] for i in sin_base_idx]\n", + " no_vacios = [(i, t) for i, t in zip(sin_base_idx, textos_h) if t]\n", + " if no_vacios:\n", + " idxs_h = [x[0] for x in no_vacios]\n", + " txts_h = [x[1] for x in no_vacios]\n", + " try:\n", + " vec = TfidfVectorizer(ngram_range=(1,2), sublinear_tf=True,\n", + " min_df=1, max_features=20000)\n", + " mat = vec.fit_transform(txts_h)\n", + " sims = cosine_similarity(mat, mat)\n", + " parent = list(range(len(idxs_h)))\n", + " def _find(x):\n", + " while parent[x] != x:\n", + " parent[x] = parent[parent[x]]; x = parent[x]\n", + " return x\n", + " for a in range(len(idxs_h)):\n", + " for b in range(a+1, len(idxs_h)):\n", + " if sims[a][b] >= umbral_sim:\n", + " ra, rb = _find(a), _find(b)\n", + " if ra != rb: parent[rb] = ra\n", + " grupos_loc = {}\n", + " for a in range(len(idxs_h)):\n", + " grupos_loc.setdefault(_find(a), []).append(idxs_h[a])\n", + " for miembros in grupos_loc.values():\n", + " secuenciales_por_f4[f4] = secuenciales_por_f4.get(f4, 0) + 1\n", + " nuevo_np = f\"MP{f4 or 'XXXX'}-R{secuenciales_por_f4[f4]:03d}\"\n", + " for idx in miembros:\n", + " df.at[idx, 'NUMERO_PARTE'] = nuevo_np\n", + " df.at[idx, 'MATCH_TIPO'] = 'AUTO'\n", + " except Exception as e:\n", + " log(f' WARN union-find grupo ({f4},{um}): {e}')\n", + " for idx in idxs_h:\n", + " secuenciales_por_f4[f4] = secuenciales_por_f4.get(f4, 0) + 1\n", + " df.at[idx, 'NUMERO_PARTE'] = f\"MP{f4 or 'XXXX'}-R{secuenciales_por_f4[f4]:03d}\"\n", + " df.at[idx, 'MATCH_TIPO'] = 'AUTO'\n", + " for idx in sin_base_idx:\n", + " if df.at[idx, 'NUMERO_PARTE'] == '':\n", + " secuenciales_por_f4[f4] = secuenciales_por_f4.get(f4, 0) + 1\n", + " df.at[idx, 'NUMERO_PARTE'] = f\"MP{f4 or 'XXXX'}-R{secuenciales_por_f4[f4]:03d}\"\n", + " df.at[idx, 'MATCH_TIPO'] = 'AUTO_SINDESC'\n", + " prog.step(desc=f'{procesados}/{total_grupos}')\n", + "\n", + " df['anio_corto'] = pd.to_datetime(df['fecha_pago_real'], errors='coerce') .dt.year.astype(str).str[-2:]\n", + " df['ped_full'] = (df['anio_corto'] + '-' + df['seccion_aduanera'].astype(str).str[:2]\n", + " + '-' + df['patente'].astype(str).str.zfill(4)\n", + " + '-' + df['pedimento'].astype(str).str.zfill(7))\n", + " df['clave_eff'] = df.apply(\n", + " lambda r: 'R1' if r['rectificado'] == 1\n", + " else (r['clave_documento_501'] or r['clave_documento_551'] or ''),\n", + " axis=1)\n", + " df['factura'] = df['pedimento'].astype(str).str.zfill(7).str[-7:] + '-' + df['clave_eff'].fillna('')\n", + " df['linea_seq'] = df.groupby('factura').cumcount() + 1\n", + "\n", + " prog.done('Asignacion completa')\n", + " cnt = df['MATCH_TIPO'].apply(lambda s: 'BASE' if str(s).startswith('BASE') else s).value_counts().to_dict()\n", + " log(f'Distribucion de match: {cnt}')\n", + "\n", + " # Costo unitario:\n", + " # Impo: ValorDolares / CantidadUMComercial\n", + " # Expo: (ValorDolares - ValorAgregado * TipoCambio) / CantidadUMComercial\n", + " _vd = pd.to_numeric(df['valor_dolares'], errors='coerce').fillna(0.0)\n", + " _va = pd.to_numeric(df['valor_agregado'], errors='coerce').fillna(0.0)\n", + " _tc = pd.to_numeric(df['tipo_cambio_501'], errors='coerce').fillna(0.0)\n", + " _qty = pd.to_numeric(df['cantidad_um_comercial'], errors='coerce').replace(0, np.nan)\n", + " if int(tipo_op) == 1:\n", + " df['costo_unitario_calc'] = (_vd / _qty).round(6)\n", + " else:\n", + " df['costo_unitario_calc'] = ((_vd - (_va * _tc)) / _qty).round(6)\n", + " df['costo_unitario_calc'] = df['costo_unitario_calc'].fillna(0.0)\n", + "\n", + " out = pd.DataFrame({\n", + " 'NUMERO FACTURA': df['factura'],\n", + " 'FECHA PAGO REAL': df['fecha_pago_real'],\n", + " 'LINEA': df['linea_seq'],\n", + " 'NUMERO DE PARTE': df['NUMERO_PARTE'],\n", + " 'CANTIDAD IMPORTADA': df['cantidad_um_comercial'],\n", + " 'UNIDAD DE MEDIDA': df['um_sigla'],\n", + " 'COSTO UNITARIO': df['costo_unitario_calc'],\n", + " 'PESO NETO': '',\n", + " 'PESO BRUTO': '',\n", + " 'CANTIDAD BULTOS': df['cantidad_um_tarifa'],\n", + " 'CLAVE BULTOS': df['unidad_medida_tarifa'],\n", + " 'PAIS ORIGEN': df['pais_origen_destino'],\n", + " 'FRACCION ARANCELARIA': df['fraccion'],\n", + " 'PREFERENCIA ARANCELARIA':'',\n", + " 'SECTOR': '',\n", + " 'FRACCION AMERICANA': '',\n", + " 'ORDEN DE COMPRA': '',\n", + " 'METODO DE VALORACION': df['metodo_valorizacion'],\n", + " 'NUMERO DE GUIA': '',\n", + " 'NUMERO DE ENTRADA': '',\n", + " 'CLIENTE': '',\n", + " 'FORMA DE PAGO': '',\n", + " 'MONTO IGI': df['valor_aduana'],\n", + " 'LOCALIZACION': '',\n", + " 'PERMISO RO': '',\n", + " 'LINEA RO': '',\n", + " 'VALOR TOTAL': df['valor_comercial'],\n", + " 'LOTE': df['linea_seq'],\n", + " 'INFORMACION ADICIONAL': df['secuencia_fraccion'],\n", + " 'CANTIDAD AUXILIAR': '',\n", + " 'U.M. AUXILIAR': '',\n", + " 'NUMERO DE ENTRADA 2': '',\n", + " 'MATCH_TIPO': df['MATCH_TIPO'],\n", + " })\n", + " return out" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ui", + "metadata": {}, + "outputs": [], + "source": [ + "header = W.HTML()\n", + "def _refresh_header():\n", + " color = '#1565C0' if CONEXION_OK else '#C62828'\n", + " header.value = (\n", + " f'
'\n", + " f'

Sistema de Utilerias 2.0 SCAII

'\n", + " f'
{CONEXION_MSG}
'\n", + " )\n", + "_refresh_header()\n", + "OUT_STYLE = {'border':'1px solid #ddd','padding':'8px','min_height':'120px'}\n", + "BAR_LAYOUT = {'width':'600px', 'height':'25px'}\n", + "BAR_STYLE = {'description_width':'170px'}\n", + "\n", + "def _mkbar(desc='Listo'):\n", + " return W.IntProgress(value=0, min=0, max=1, description=desc, layout=BAR_LAYOUT, style=BAR_STYLE, bar_style='')\n", + "\n", + "# ----- Tab 0: Conexion -----\n", + "out_conn = W.Output(layout=OUT_STYLE)\n", + "db_dropdown = W.Dropdown(options=[], description='Base de datos:', layout={'width':'500px'}, style={'description_width':'120px'})\n", + "btn_refresh = W.Button(description='Refrescar lista', icon='refresh', layout={'width':'180px'})\n", + "btn_conectar = W.Button(description='Conectar a esta DB', button_style='primary', icon='plug', layout={'width':'220px'})\n", + "lbl_actual = W.HTML()\n", + "\n", + "def _refresh_lista():\n", + " with out_conn:\n", + " clear_output()\n", + " print('Cargando lista de bases de datos...')\n", + " try: dbs = listar_databases()\n", + " except Exception as e: print(f'ERROR listando DBs: {e}'); return\n", + " if not dbs: print('No se pudieron obtener bases de datos. Revisa credenciales y permisos.')\n", + " else:\n", + " print(f'Encontradas {len(dbs)} bases:')\n", + " for db in dbs: print(f' - {db}')\n", + " db_dropdown.options = dbs\n", + " if DB_ACTUAL and DB_ACTUAL in dbs: db_dropdown.value = DB_ACTUAL\n", + " lbl_actual.value = f'DB actual: {DB_ACTUAL or \"sin conexion\"}'\n", + "btn_refresh.on_click(lambda _: _refresh_lista())\n", + "\n", + "def _on_conectar(_):\n", + " with out_conn:\n", + " clear_output()\n", + " if not db_dropdown.value: print('Selecciona una base de datos.'); return\n", + " target = db_dropdown.value\n", + " print(f'Conectando a [{target}]...')\n", + " ok = conectar_a_db(target)\n", + " if ok: print(f'OK. Cache reseteado. Ahora todas las pestanas usan [{DB_ACTUAL}].')\n", + " else: print(f'FALLO: {CONEXION_MSG}')\n", + " _refresh_header()\n", + " lbl_actual.value = f'DB actual: {DB_ACTUAL or \"sin conexion\"}'\n", + "btn_conectar.on_click(_on_conectar)\n", + "\n", + "tab_conn = W.VBox([\n", + " W.HTML('

Conexion a SQL Server

'\n", + " f'

Server: {SCAII_SERVER} | Usuario: {SCAII_USER}

'),\n", + " lbl_actual,\n", + " W.HTML('

Selecciona la base de datos. El cambio aplica a todas las pestanas; '\n", + " 'el cache de pronostico/analisis se resetea al cambiar.

'),\n", + " W.HBox([db_dropdown, btn_refresh]),\n", + " btn_conectar, out_conn,\n", + "])\n", + "_refresh_lista()\n", + "\n", + "# ----- Tab 1: Descargas -----\n", + "out_pron = W.Output(layout=OUT_STYLE); bar_pron = _mkbar('Pronostico')\n", + "out_p9 = W.Output(layout=OUT_STYLE); bar_p9 = _mkbar('Paso 9')\n", + "out_p10 = W.Output(layout=OUT_STYLE); bar_p10 = _mkbar('Paso 10')\n", + "btn_pron = W.Button(description='Calcular pronostico (paso 8)', button_style='primary', icon='play', layout={'width':'260px'})\n", + "modo9 = W.Dropdown(options=['NATURAL','DIRIGIDA'], value='NATURAL', description='Modo:', layout={'width':'250px'})\n", + "dry9 = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", + "fd9 = W.Text(placeholder='YYYY-MM-DD', description='Desde:', layout={'width':'250px'})\n", + "fh9 = W.Text(placeholder='YYYY-MM-DD', description='Hasta:', layout={'width':'250px'})\n", + "btn_p9 = W.Button(description='Ejecutar paso 9 (NA->AC)', button_style='warning', icon='check', layout={'width':'260px'})\n", + "modo10 = W.Dropdown(options=['DIRIGIDA','NATURAL'], value='DIRIGIDA', description='Modo:', layout={'width':'250px'})\n", + "dry10 = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", + "fd10 = W.Text(placeholder='YYYY-MM-DD', description='Desde:', layout={'width':'250px'})\n", + "fh10 = W.Text(placeholder='YYYY-MM-DD', description='Hasta:', layout={'width':'250px'})\n", + "facts_obj= W.Text(placeholder=\"factura1,factura2 (opcional)\", description='Facturas:', layout={'width':'480px'})\n", + "btn_p10 = W.Button(description='Ejecutar paso 10 (complementaria)', button_style='warning', icon='plus-square', layout={'width':'320px'})\n", + "\n", + "def _on_pron(_):\n", + " with out_pron:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})'); print('Cargando catalogos y calculando...'); t0 = time.time()\n", + " calcular_pronostico_paso8(progress=bar_pron)\n", + " cob = _state['cobertura_factura']; df = _state['df_descarga_all']\n", + " print(f'Tiempo: {time.time()-t0:.1f}s | Filas: {len(df):,} | Facturas: {len(cob):,}')\n", + " n100 = (cob['componentes_100pct'] == cob['componentes_total']).sum()\n", + " print(f' 100% cobertura: {n100:,} | parcial: {len(cob)-n100:,}')\n", + "btn_pron.on_click(_on_pron)\n", + "\n", + "def _on_p9(_):\n", + " with out_p9:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_paso9(modo9.value, dry9.value, fd9.value or None, fh9.value or None, log=print, progress=bar_p9)\n", + "btn_p9.on_click(_on_p9)\n", + "\n", + "def _on_p10(_):\n", + " with out_p10:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " fobj = [f.strip() for f in (facts_obj.value or '').split(',') if f.strip()] or None\n", + " ejecutar_paso10(modo10.value, dry10.value, fd10.value or None, fh10.value or None, fobj, log=print, progress=bar_p10)\n", + "btn_p10.on_click(_on_p10)\n", + "\n", + "tab_desc = W.VBox([\n", + " W.HTML('

Paso 8 anio‚ Pronostico de cobertura

'),\n", + " btn_pron, bar_pron, out_pron,\n", + " W.HTML('

Paso 9 anio‚ Descargas pendientes (NA anio‚¢ AC)

'),\n", + " W.HBox([modo9, dry9]), W.HBox([fd9, fh9]), btn_p9, bar_p9, out_p9,\n", + " W.HTML('

Paso 10 anio‚ Complementaria (sobre facturas AC)

'),\n", + " W.HBox([modo10, dry10]), W.HBox([fd10, fh10]), facts_obj, btn_p10, bar_p10, out_p10,\n", + "])\n", + "\n", + "# ----- Tab 2: Analisis Saldos -----\n", + "out_an_log = W.Output(layout=OUT_STYLE)\n", + "out_an_anio = W.Output()\n", + "out_an_imp_exp= W.Output()\n", + "out_an_pesos = W.Output()\n", + "out_an_cant = W.Output()\n", + "out_an_cant_um = W.Output()\n", + "bar_an = _mkbar('Analisis')\n", + "btn_an = W.Button(description='Cargar y analizar SSaldoTem', button_style='primary', icon='database', layout={'width':'280px'})\n", + "btn_an_xlsx = W.Button(description='Exportar Excel', button_style='success', icon='file-excel-o', layout={'width':'200px'})\n", + "\n", + "def _on_an(_):\n", + " with out_an_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " print('Cargando SSaldoTem...'); t0 = time.time()\n", + " df = cargar_analisis_saldos(progress=bar_an)\n", + " print(f' {len(df):,} lotes | tiempo: {time.time()-t0:.1f}s')\n", + " por_anio = calcular_por_anio_saldos(df); _state['por_anio_saldos'] = por_anio\n", + " print(f' Saldo cant: {df[\"SALDO_CANT\"].sum():,.2f}')\n", + " print(f' Saldo MN: ${df[\"SALDO_VMN\"].sum():,.2f}')\n", + " print(f' Saldo ME: ${df[\"SALDO_VME\"].sum():,.2f}')\n", + " print('Calculando IMPO vs EXPO...')\n", + " df_imp, df_exp, cmp = calcular_impo_expo_anio(progress=bar_an)\n", + " _state['df_imp'], _state['df_exp'], _state['cmp_impo_expo'] = df_imp, df_exp, cmp\n", + " print(f' IMPO aanio‚±os: {len(df_imp)} | EXPO aanio‚±os: {len(df_exp)} | Comparativo: {len(cmp)}')\n", + " print('Calculando pesos IMPO/EXPO/SALDO por aanio‚±o...')\n", + " cmp_pesos = calcular_pesos_por_anio(progress=bar_an)\n", + " _state['cmp_pesos_anio'] = cmp_pesos\n", + " print(f' Comparativo pesos: {len(cmp_pesos)} aanio‚±os')\n", + " print('Calculando cantidades IMPO/EXPO por aanio‚±o...')\n", + " cmp_cant = calcular_cantidades_por_anio(progress=bar_an)\n", + " _state['cmp_cantidades_anio'] = cmp_cant\n", + " print(f' Comparativo cantidades: {len(cmp_cant)} aanio‚±os')\n", + " print('Calculando cantidades IMPO/EXPO por aanio‚±o + UM...')\n", + " cmp_cant_um = calcular_cantidades_por_anio_um(progress=bar_an)\n", + " _state['cmp_cantidades_anio_um'] = cmp_cant_um\n", + " print(f' Comparativo cantidades por UM: {len(cmp_cant_um)} filas')\n", + " with out_an_anio:\n", + " clear_output()\n", + " display(HTML('

Saldo disponible por aanio‚±o (de entrada del lote)

'))\n", + " display(_state['por_anio_saldos'])\n", + " if not _state['por_anio_saldos'].empty:\n", + " fig = graficar_saldos_anio(_state['por_anio_saldos']); display(fig); plt.close(fig)\n", + " with out_an_imp_exp:\n", + " clear_output()\n", + " display(HTML('

IMPO vs EXPO por aanio‚±o

'))\n", + " display(_state['cmp_impo_expo'])\n", + " if not _state['cmp_impo_expo'].empty:\n", + " fig = graficar_impo_expo(_state['cmp_impo_expo']); display(fig); plt.close(fig)\n", + " with out_an_pesos:\n", + " clear_output()\n", + " display(HTML('

Peso por aanio‚±o anio‚ IMPO / EXPO / CONSUMIDO / DESCARGAS

'))\n", + " display(_state['cmp_pesos_anio'])\n", + " if not _state['cmp_pesos_anio'].empty:\n", + " fig = graficar_pesos_anio(_state['cmp_pesos_anio']); display(fig); plt.close(fig)\n", + " with out_an_cant:\n", + " clear_output()\n", + " display(HTML('

Cantidades por aanio‚±o anio‚ IMPO vs EXPO

'))\n", + " display(_state['cmp_cantidades_anio'])\n", + " if not _state['cmp_cantidades_anio'].empty:\n", + " fig = graficar_cantidades_anio(_state['cmp_cantidades_anio']); display(fig); plt.close(fig)\n", + " with out_an_cant_um:\n", + " clear_output()\n", + " display(HTML('

Cantidades por aanio‚±o + UM (separado por unidad de medida)

'))\n", + " display(_state['cmp_cantidades_anio_um'])\n", + " if not _state['cmp_cantidades_anio_um'].empty:\n", + " fig = graficar_cantidades_anio_um(_state['cmp_cantidades_anio_um']); display(fig); plt.close(fig)\n", + "btn_an.on_click(_on_an)\n", + "\n", + "def _on_an_xlsx(_):\n", + " with out_an_log:\n", + " if 'df_saldos_full' not in _state: print('Corre primero \"Cargar y analizar\".'); return\n", + " path = exportar_excel_analisis(_state['df_saldos_full'], _state['por_anio_saldos'],\n", + " _state['df_imp'], _state['df_exp'], _state['cmp_impo_expo'],\n", + " _state.get('cmp_pesos_anio'),\n", + " _state.get('cmp_cantidades_anio'),\n", + " _state.get('cmp_cantidades_anio_um'))\n", + " print(f'Excel: {path}')\n", + "btn_an_xlsx.on_click(_on_an_xlsx)\n", + "\n", + "tab_an = W.VBox([\n", + " W.HTML('

Ananio‚¡lisis SSaldoTem + IMPO vs EXPO

'),\n", + " W.HBox([btn_an, btn_an_xlsx]), bar_an, out_an_log, out_an_anio, out_an_imp_exp, out_an_pesos, out_an_cant, out_an_cant_um,\n", + "])\n", + "\n", + "# ----- Tab 3: Sustitutos NLP -----\n", + "out_nlp = W.Output(layout=OUT_STYLE); bar_nlp = _mkbar('Sustitutos NLP')\n", + "min_sim = W.FloatSlider(value=0.80, min=0.5, max=1.0, step=0.05, description='Min similitud:', readout_format='.0%', layout={'width':'380px'})\n", + "top_n = W.IntSlider(value=3, min=1, max=10, step=1, description='Top-N:', layout={'width':'380px'})\n", + "dry_nlp = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", + "btn_nlp = W.Button(description='Generar sustitutos NLP', button_style='primary', icon='magic', layout={'width':'260px'})\n", + "\n", + "def _on_nlp(_):\n", + " with out_nlp:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " try:\n", + " generar_sustitutos_nlp(min_sim.value, top_n.value, dry_nlp.value, log=print, progress=bar_nlp)\n", + " if 'df_nuevos_sust' in _state and not _state['df_nuevos_sust'].empty:\n", + " print('\\nMuestra de los primeros 10:')\n", + " display(_state['df_nuevos_sust'].head(10))\n", + " except Exception as e: print(f'ERROR: {e}')\n", + "btn_nlp.on_click(_on_nlp)\n", + "\n", + "tab_nlp = W.VBox([\n", + " W.HTML('

Sustitutos NLP anio‚ TF-IDF + coseno

'),\n", + " min_sim, top_n, dry_nlp, btn_nlp, bar_nlp, out_nlp,\n", + "])\n", + "\n", + "# ----- Tab 4: Descarga % KGS (paso 12) -----\n", + "out_pron12 = W.Output(layout=OUT_STYLE); bar_pron12 = _mkbar('Pronostico KG')\n", + "out_p12 = W.Output(layout=OUT_STYLE); bar_p12 = _mkbar('Paso 12')\n", + "out_p12c = W.Output(layout=OUT_STYLE); bar_p12c = _mkbar('Paso 12 comp')\n", + "btn_pron12 = W.Button(description='Calcular pronostico KG', button_style='primary', icon='play', layout={'width':'260px'})\n", + "modo12 = W.Dropdown(options=['NATURAL','DIRIGIDA'], value='NATURAL', description='Modo:', layout={'width':'250px'})\n", + "dry12 = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", + "fd12 = W.Text(placeholder='YYYY-MM-DD', description='Desde:', layout={'width':'250px'})\n", + "fh12 = W.Text(placeholder='YYYY-MM-DD', description='Hasta:', layout={'width':'250px'})\n", + "btn_p12 = W.Button(description='Ejecutar paso 12 (NA->AC)', button_style='warning', icon='check', layout={'width':'260px'})\n", + "modo12c = W.Dropdown(options=['DIRIGIDA','NATURAL'], value='DIRIGIDA', description='Modo:', layout={'width':'250px'})\n", + "dry12c = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", + "fd12c = W.Text(placeholder='YYYY-MM-DD', description='Desde:', layout={'width':'250px'})\n", + "fh12c = W.Text(placeholder='YYYY-MM-DD', description='Hasta:', layout={'width':'250px'})\n", + "facts_obj12= W.Text(placeholder=\"factura1,factura2 (opcional)\", description='Facturas:', layout={'width':'480px'})\n", + "btn_p12c = W.Button(description='Ejecutar complementaria KG', button_style='warning', icon='plus-square', layout={'width':'320px'})\n", + "\n", + "def _on_pron12(_):\n", + " with out_pron12:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})'); print('Calculando pronostico KG...'); t0 = time.time()\n", + " calcular_pronostico_paso12_kg(progress=bar_pron12)\n", + " cob = _state['cobertura_factura_kg']; df = _state['df_descarga_kg']\n", + " print(f'Tiempo: {time.time()-t0:.1f}s | Filas: {len(df):,} | Facturas: {len(cob):,}')\n", + " n100 = (cob['componentes_100pct'] == cob['componentes_total']).sum()\n", + " print(f' 100% cobertura: {n100:,} | parcial: {len(cob)-n100:,}')\n", + "btn_pron12.on_click(_on_pron12)\n", + "\n", + "def _on_p12(_):\n", + " with out_p12:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_paso12_kg(modo12.value, dry12.value, fd12.value or None, fh12.value or None, log=print, progress=bar_p12)\n", + "btn_p12.on_click(_on_p12)\n", + "\n", + "def _on_p12c(_):\n", + " with out_p12c:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " fobj = [f.strip() for f in (facts_obj12.value or '').split(',') if f.strip()] or None\n", + " ejecutar_paso12_complementaria_kg(modo12c.value, dry12c.value, fd12c.value or None, fh12c.value or None, fobj, log=print, progress=bar_p12c)\n", + "btn_p12c.on_click(_on_p12c)\n", + "\n", + "tab_kg = W.VBox([\n", + " W.HTML('

Paso 12 anio‚ Descarga por % de KGS

'\n", + " '

'\n", + " 'cant_req = (BOM.CANTIDAD / 100) anio‚ PESONETO de la partida. Mismo PEPS, UM y sustitutos.

'),\n", + " btn_pron12, bar_pron12, out_pron12,\n", + " W.HTML('

Paso 12 anio‚ Descargas pendientes KG (NA anio‚¢ AC)

'),\n", + " W.HBox([modo12, dry12]), W.HBox([fd12, fh12]), btn_p12, bar_p12, out_p12,\n", + " W.HTML('

Paso 12 anio‚ Complementaria KG (sobre facturas AC)

'),\n", + " W.HBox([modo12c, dry12c]), W.HBox([fd12c, fh12c]), facts_obj12, btn_p12c, bar_p12c, out_p12c,\n", + "])\n", + "\n", + "\n", + "# ----- Tab 5: CTM (Reasignacion de descargas) -----\n", + "out_ctm_log = W.Output(layout=OUT_STYLE)\n", + "out_ctm_tabla = W.Output()\n", + "bar_ctm = _mkbar('Analisis CTM')\n", + "upload_ctm = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel')\n", + "chk_use_mapping= W.Checkbox(value=True, description='Usar mapping para priorizar')\n", + "btn_plantilla = W.Button(description='Descargar plantilla Excel', button_style='info', icon='download', layout={'width':'260px'})\n", + "btn_ctm_analizar = W.Button(description='Analizar CTM (sin escribir)', button_style='primary', icon='search', layout={'width':'280px'})\n", + "btn_ctm_xlsx = W.Button(description='Exportar Excel completo', button_style='success', icon='file-excel-o', layout={'width':'240px'})\n", + "\n", + "_html_formato = '''\n", + "
\n", + "Formato esperado del Excel del cliente\n", + "

La primera hoja del archivo debe contener al menos estas columnas (solo las dos primeras son obligatorias):

\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
ColumnaEjemploObligatoria
Facturas CTMAAU112023RFR0481, NIS112023RFR0035SI
PEDIMENTO COMPLETO75-3076-4021492SI
PATENTE3076no
ADUANA75no
PEDIMENTO4021492no
OperacionImportacionno
Clave de pedimentoF4no
\n", + "

La celda Facturas CTM puede traer varias facturas separadas por coma; la herramienta las separa automaticamente.

\n", + "
\n", + "'''\n", + "\n", + "def _on_plantilla(_):\n", + " with out_ctm_log:\n", + " clear_output()\n", + " path = generar_plantilla_excel_ctm()\n", + " display(HTML(f'
'\n", + " f'Plantilla generada
'\n", + " f'{path}
'\n", + " f'Abre el archivo, agrega tus datos, guarda y luego subelo con el boton \"Subir Excel\".
'))\n", + "btn_plantilla.on_click(_on_plantilla)\n", + "\n", + "def _on_ctm_analizar(_):\n", + " with out_ctm_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " mapping_df = None\n", + " if chk_use_mapping.value and len(upload_ctm.value) > 0:\n", + " try:\n", + " if isinstance(upload_ctm.value, dict):\n", + " fname = list(upload_ctm.value.keys())[0]\n", + " file_bytes = upload_ctm.value[fname]['content']\n", + " else:\n", + " file_bytes = upload_ctm.value[0]['content']\n", + " mapping_df, raw = cargar_excel_mapping_ctm(file_bytes)\n", + " _state['ctm_mapping'] = mapping_df\n", + " print(f'Excel cargado: {len(mapping_df):,} relaciones CTM<->Pedimento')\n", + " except Exception as e:\n", + " print(f'WARN cargando Excel: {e} (se procesa sin mapping)')\n", + " mapping_df = None\n", + " elif chk_use_mapping.value:\n", + " print('Sin Excel cargado; se procesa sin prioridad de mapping.')\n", + " print('Analizando facturas CTM...')\n", + " plan, resumen = analizar_ctm(df_mapping=mapping_df, progress=bar_ctm)\n", + " print(f'Plan: {len(plan):,} filas | Resumen: {len(resumen):,} combinaciones')\n", + " if not resumen.empty:\n", + " asignados = (plan['STATUS']=='ASIGNADO').sum()\n", + " faltantes = (plan['STATUS']=='FALTANTE').sum()\n", + " print(f' Filas ASIGNADAS: {asignados:,}')\n", + " print(f' Filas FALTANTE : {faltantes:,}')\n", + " display(HTML('

En pantalla se muestran solo las primeras 50 filas del plan. '\n", + " 'Para ver el detalle completo presiona Exportar Excel completo.

'))\n", + " with out_ctm_tabla:\n", + " clear_output()\n", + " if 'ctm_resumen' in _state and not _state['ctm_resumen'].empty:\n", + " display(HTML('

Resumen CTM (por factura + linea + componente)

'))\n", + " display(_state['ctm_resumen'])\n", + " display(HTML('

Plan detalle (primeras 50 filas)

'))\n", + " display(_state['ctm_plan'].head(50))\n", + "btn_ctm_analizar.on_click(_on_ctm_analizar)\n", + "\n", + "def _on_ctm_xlsx(_):\n", + " with out_ctm_log:\n", + " if 'ctm_plan' not in _state:\n", + " display(HTML('
Corre primero Analizar CTM.
'))\n", + " return\n", + " path = exportar_excel_ctm(_state['ctm_plan'], _state['ctm_resumen'])\n", + " display(HTML(f'
'\n", + " f'Excel generado con el detalle completo
'\n", + " f'{path}
'\n", + " f'Contiene tres hojas: Resumen, Plan_Detalle (todas las filas, no solo 50) y Faltantes.
'))\n", + "btn_ctm_xlsx.on_click(_on_ctm_xlsx)\n", + "\n", + "# ---- Paso B - Ejecucion (CTM) ----\n", + "modo_ctm = W.Dropdown(options=['NATURAL','DIRIGIDA'], value='NATURAL', description='Modo:', layout={'width':'250px'})\n", + "dry_ctm = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_ctm_ejecutar = W.Button(description='Ejecutar reasignacion (Paso B)', button_style='warning', icon='play', layout={'width':'300px'})\n", + "bar_ctm_b = _mkbar('Ejecucion CTM')\n", + "out_ctm_ejec = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ctm_ejecutar(_):\n", + " with out_ctm_ejec:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_reasignacion_ctm(modo=modo_ctm.value, dry_run=dry_ctm.value, log=print, progress=bar_ctm_b)\n", + "btn_ctm_ejecutar.on_click(_on_ctm_ejecutar)\n", + "tab_ctm = W.VBox([\n", + " W.HTML('

CTM - Reasignacion de descargas desde Cambio de Regimen

'\n", + " '

'\n", + " 'Analiza las facturas CTM pendientes y propone una asignacion de descargas '\n", + " 'tomadas del pool existente del modulo de Cambio de Regimen. Esta pestana '\n", + " 'es solo lectura (Paso A): no escribe en la base de datos.

'),\n", + " W.HTML(_html_formato),\n", + " btn_plantilla,\n", + " W.HTML('

Sube tu Excel con el mapeo Facturas CTM <-> Pedimento F4:

'),\n", + " W.HBox([upload_ctm, chk_use_mapping]),\n", + " W.HBox([btn_ctm_analizar, btn_ctm_xlsx]), bar_ctm,\n", + " out_ctm_log, out_ctm_tabla,\n", + " W.HTML('

Paso B - Ejecutar reasignacion en la base de datos

'\n", + " '

Toma el plan calculado arriba y aplica los cambios en SDescargaT y SFacExp. Corre primero con DRY_RUN activado para revisar.

'),\n", + " W.HBox([modo_ctm, dry_ctm]),\n", + " btn_ctm_ejecutar, bar_ctm_b,\n", + " out_ctm_ejec,\n", + "])\n", + "\n", + "\n", + "# ===== Tab 7: Saldos Vencidos (Utileria Forma 5) =====\n", + "upload_sv = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel')\n", + "btn_plantilla_sv = W.Button(description='Descargar plantilla Excel', button_style='info', icon='download', layout={'width':'250px'})\n", + "w_fecha_ini_sv = W.DatePicker(description='Fecha inicio:', value=None, layout={'width':'260px'})\n", + "w_fecha_fin_sv = W.DatePicker(description='Fecha fin:', value=None, layout={'width':'260px'})\n", + "btn_sv_analizar = W.Button(description='Analizar (sin escribir)', button_style='primary', icon='search', layout={'width':'280px'})\n", + "btn_sv_xlsx = W.Button(description='Exportar Excel completo', button_style='info', icon='download', layout={'width':'280px'})\n", + "bar_sv = _mkbar('Analisis Saldos Vencidos')\n", + "out_sv_log = W.Output(layout=OUT_STYLE)\n", + "out_sv_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "_html_formato_sv = '''\n", + "
\n", + "Formato esperado del Excel (3 columnas)\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
ColumnaEjemplo
FACTURAIMPOF1234567
CANTIDAD_SALDO100.0
FRACCION_IMPO85044010
\n", + "

Las fechas inicio/fin filtran SSaldoTem por FECHAFACTURA_ISO.

\n", + "
\n", + "'''\n", + "\n", + "def _on_plantilla_sv(_):\n", + " with out_sv_log:\n", + " clear_output()\n", + " bts = generar_plantilla_excel_saldos_vencidos()\n", + " path = os.path.join(os.getcwd(), 'plantilla_saldos_vencidos.xlsx')\n", + " with open(path, 'wb') as f: f.write(bts)\n", + " display(HTML(f'
'\n", + " f'Plantilla generada
'\n", + " f'{path}
'))\n", + "btn_plantilla_sv.on_click(_on_plantilla_sv)\n", + "\n", + "def _on_sv_analizar(_):\n", + " with out_sv_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " if len(upload_sv.value) == 0:\n", + " print('ERROR: sube un Excel primero.'); return\n", + " if w_fecha_ini_sv.value is None or w_fecha_fin_sv.value is None:\n", + " print('ERROR: define fecha inicio y fecha fin.'); return\n", + " try:\n", + " if isinstance(upload_sv.value, dict):\n", + " fname = list(upload_sv.value.keys())[0]\n", + " file_bytes = upload_sv.value[fname]['content']\n", + " else:\n", + " file_bytes = upload_sv.value[0]['content']\n", + " tmp_path = os.path.join(os.getcwd(), '_upload_sv.xlsx')\n", + " with open(tmp_path, 'wb') as f: f.write(file_bytes)\n", + " df_excel = cargar_excel_saldos_vencidos(tmp_path)\n", + " print(f'Excel cargado: {len(df_excel):,} filas')\n", + " except Exception as e:\n", + " print(f'ERROR cargando Excel: {e}'); return\n", + " print('Analizando saldos vencidos...')\n", + " plan, resumen = analizar_saldos_vencidos(df_excel, str(w_fecha_ini_sv.value), str(w_fecha_fin_sv.value), progress=bar_sv)\n", + " _state['sv_plan'] = plan\n", + " _state['sv_resumen'] = resumen\n", + " print(f'Plan: {len(plan):,} filas | Resumen: {len(resumen):,} saldos')\n", + " if not resumen.empty:\n", + " ok = (resumen['STATUS']=='PRORRATEADO').sum()\n", + " sin = (resumen['STATUS']=='SIN_DESCARGAS').sum()\n", + " cz = (resumen['STATUS']=='CANTDESC_CERO').sum()\n", + " print(f' Saldos PRORRATEADOS : {ok:,}')\n", + " print(f' Saldos SIN_DESCARGAS: {sin:,}')\n", + " print(f' Saldos CANTDESC_CERO: {cz:,}')\n", + " with out_sv_tabla:\n", + " clear_output()\n", + " if 'sv_resumen' in _state and not _state['sv_resumen'].empty:\n", + " display(HTML('

Resumen Saldos Vencidos

'))\n", + " display(_state['sv_resumen'])\n", + " display(HTML('

Plan detalle (primeras 50 filas)

'))\n", + " display(_state['sv_plan'].head(50))\n", + "btn_sv_analizar.on_click(_on_sv_analizar)\n", + "\n", + "def _on_sv_xlsx(_):\n", + " with out_sv_log:\n", + " if 'sv_plan' not in _state:\n", + " display(HTML('
Corre primero Analizar.
'))\n", + " return\n", + " path = os.path.join(os.getcwd(), 'plan_saldos_vencidos.xlsx')\n", + " exportar_excel_saldos_vencidos(_state['sv_plan'], _state['sv_resumen'], path)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{path}
'))\n", + "btn_sv_xlsx.on_click(_on_sv_xlsx)\n", + "\n", + "dry_sv = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_sv_ejecutar = W.Button(description='Ejecutar prorrateo (Paso B)', button_style='warning', icon='play', layout={'width':'300px'})\n", + "bar_sv_b = _mkbar('Ejecucion Saldos Vencidos')\n", + "out_sv_ejec = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_sv_ejecutar(_):\n", + " with out_sv_ejec:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if 'sv_plan' not in _state:\n", + " print('ERROR: corre primero Analizar.'); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_saldos_vencidos(_state['sv_plan'], dry_run=dry_sv.value, log=print, progress=bar_sv_b)\n", + "btn_sv_ejecutar.on_click(_on_sv_ejecutar)\n", + "\n", + "tab_sv = W.VBox([\n", + " W.HTML('

Saldos Vencidos - Utileria Forma 5

'\n", + " '

'\n", + " 'Prorratea masivamente saldos IMPO vencidos entre las descargas EXPO que comparten '\n", + " '(FACTURAIMPO, NUMPARTE, UMEXITENCIA). El Paso A es solo lectura; el Paso B aplica '\n", + " 'UPDATE en SDescargaT y SSaldoTem (no toca SFacExp).

'),\n", + " W.HTML(_html_formato_sv),\n", + " btn_plantilla_sv,\n", + " W.HTML('

Sube tu Excel y define el rango de fechas (FECHAFACTURA_ISO):

'),\n", + " W.HBox([upload_sv]),\n", + " W.HBox([w_fecha_ini_sv, w_fecha_fin_sv]),\n", + " W.HBox([btn_sv_analizar, btn_sv_xlsx]), bar_sv,\n", + " out_sv_log, out_sv_tabla,\n", + " W.HTML('

Paso B - Ejecutar prorrateo en la base de datos

'\n", + " '

UPDATE en cada SDescargaT matched + UPDATE en SSaldoTem (CANTUSADA, VALORUSADOMN/ME, PESOUSADO, PESOBRUTOUSADO). Corre primero con DRY_RUN activado.

'),\n", + " W.HBox([dry_sv]),\n", + " btn_sv_ejecutar, bar_sv_b,\n", + " out_sv_ejec,\n", + "])\n", + "\n", + "\n", + "# ===== Saldos Vencidos - Modo Automatico (sin Excel) =====\n", + "w_fecha_ini_sv2 = W.DatePicker(description='Fecha inicio:', value=None, layout={'width':'260px'})\n", + "w_fecha_fin_sv2 = W.DatePicker(description='Fecha fin:', value=None, layout={'width':'260px'})\n", + "btn_sv2_analizar = W.Button(description='Buscar saldos vencidos', button_style='primary', icon='search', layout={'width':'280px'})\n", + "btn_sv2_xlsx = W.Button(description='Exportar Excel completo', button_style='info', icon='download', layout={'width':'280px'})\n", + "bar_sv2 = _mkbar('Busqueda automatica')\n", + "out_sv2_log = W.Output(layout=OUT_STYLE)\n", + "out_sv2_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_sv2_analizar(_):\n", + " with out_sv2_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " if w_fecha_ini_sv2.value is None or w_fecha_fin_sv2.value is None:\n", + " print('ERROR: define fecha inicio y fecha fin.'); return\n", + " import datetime as _dt\n", + " hoy = _dt.date.today().isoformat()\n", + " print(f'Buscando saldos en SSaldoTem con FECHAFACTURA_ISO entre {w_fecha_ini_sv2.value} y {w_fecha_fin_sv2.value}')\n", + " print(f' y FECHAVENC_ISO < {hoy} (vencidos a hoy)')\n", + " plan, resumen = analizar_saldos_vencidos_auto(\n", + " str(w_fecha_ini_sv2.value), str(w_fecha_fin_sv2.value), fecha_corte=hoy, progress=bar_sv2)\n", + " _state['sv2_plan'] = plan\n", + " _state['sv2_resumen'] = resumen\n", + " print(f'Plan: {len(plan):,} filas | Resumen: {len(resumen):,} saldos vencidos')\n", + " if not resumen.empty:\n", + " ok = (resumen['STATUS']=='PRORRATEADO').sum()\n", + " sin = (resumen['STATUS']=='SIN_DESCARGAS').sum()\n", + " cz = (resumen['STATUS']=='CANTDESC_CERO').sum()\n", + " print(f' Saldos PRORRATEADOS : {ok:,}')\n", + " print(f' Saldos SIN_DESCARGAS: {sin:,}')\n", + " print(f' Saldos CANTDESC_CERO: {cz:,}')\n", + " with out_sv2_tabla:\n", + " clear_output()\n", + " if 'sv2_resumen' in _state and not _state['sv2_resumen'].empty:\n", + " display(HTML('

Resumen Saldos Vencidos (busqueda automatica)

'))\n", + " display(_state['sv2_resumen'])\n", + " display(HTML('

Plan detalle (primeras 50 filas)

'))\n", + " display(_state['sv2_plan'].head(50))\n", + "btn_sv2_analizar.on_click(_on_sv2_analizar)\n", + "\n", + "def _on_sv2_xlsx(_):\n", + " with out_sv2_log:\n", + " if 'sv2_plan' not in _state:\n", + " display(HTML('
Corre primero Buscar saldos vencidos.
'))\n", + " return\n", + " path = os.path.join(os.getcwd(), 'plan_saldos_vencidos_auto.xlsx')\n", + " exportar_excel_saldos_vencidos(_state['sv2_plan'], _state['sv2_resumen'], path)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{path}
'))\n", + "btn_sv2_xlsx.on_click(_on_sv2_xlsx)\n", + "\n", + "dry_sv2 = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_sv2_ejecutar = W.Button(description='Ejecutar prorrateo (Paso B)', button_style='warning', icon='play', layout={'width':'300px'})\n", + "bar_sv2_b = _mkbar('Ejecucion Saldos Vencidos (auto)')\n", + "out_sv2_ejec = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_sv2_ejecutar(_):\n", + " with out_sv2_ejec:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if 'sv2_plan' not in _state:\n", + " print('ERROR: corre primero Buscar saldos vencidos.'); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_saldos_vencidos(_state['sv2_plan'], dry_run=dry_sv2.value, log=print, progress=bar_sv2_b)\n", + "btn_sv2_ejecutar.on_click(_on_sv2_ejecutar)\n", + "\n", + "tab_sv.children = tuple(list(tab_sv.children) + [\n", + " W.HTML('

Modo Automatico - sin Excel

'\n", + " '

'\n", + " 'Define el rango de fechas (filtra FECHAFACTURA_ISO) y el sistema busca automaticamente '\n", + " 'los saldos con FECHAVENC_ISO < hoy (vencidos) y SALDO_DISPONIBLE > 0. '\n", + " 'Aplica el mismo prorrateo y los mismos UPDATEs que el modo Excel.

'),\n", + " W.HBox([w_fecha_ini_sv2, w_fecha_fin_sv2]),\n", + " W.HBox([btn_sv2_analizar, btn_sv2_xlsx]), bar_sv2,\n", + " out_sv2_log, out_sv2_tabla,\n", + " W.HTML('

Paso B - Ejecutar prorrateo automatico

'\n", + " '

Mismos UPDATEs: SDescargaT por descarga + SSaldoTem por saldo. Corre primero con DRY_RUN activado.

'),\n", + " W.HBox([dry_sv2]),\n", + " btn_sv2_ejecutar, bar_sv2_b,\n", + " out_sv2_ejec,\n", + "])\n", + "\n", + "\n", + "# ===== Saldos Vencidos - Grafica por anio =====\n", + "w_sv_metric = W.Dropdown(\n", + " options=[('Valor MN','SALDO_VMN'),('Valor ME','SALDO_VME'),\n", + " ('Cantidad','SALDO_CANT'),('Lotes','LOTES')],\n", + " value='SALDO_VMN', description='Metrica:', layout={'width':'260px'})\n", + "w_sv_eje = W.Dropdown(\n", + " options=[('Anio Vencimiento','VENCIMIENTO'),('Anio Factura','FACTURA')],\n", + " value='VENCIMIENTO', description='Eje X:', layout={'width':'260px'})\n", + "btn_sv_grafica = W.Button(description='Ver grafica saldos vencidos por anio',\n", + " button_style='primary', icon='bar-chart', layout={'width':'320px'})\n", + "out_sv_grafica = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_sv_grafica(_):\n", + " with out_sv_grafica:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " import datetime as _dt\n", + " hoy = _dt.date.today().isoformat()\n", + " print(f'Vencidos a {hoy} (FECHAVENC_ISO < hoy y SALDO_DISPONIBLE > 0)')\n", + " df = cargar_saldos_vencidos_por_anio(fecha_corte=hoy, eje=w_sv_eje.value)\n", + " if df.empty:\n", + " print('No hay saldos vencidos.'); return\n", + " print(f'Anios con saldos vencidos: {len(df)}')\n", + " graficar_saldos_vencidos_por_anio(df, metric=w_sv_metric.value)\n", + "btn_sv_grafica.on_click(_on_sv_grafica)\n", + "\n", + "tab_sv.children = tuple(list(tab_sv.children) + [\n", + " W.HTML('

Saldos vencidos por anio (vista general)

'\n", + " '

'\n", + " 'Resumen global de saldos en SSaldoTem cuyo FECHAVENC_ISO < hoy y '\n", + " 'SALDO_DISPONIBLE > 0, agrupados por anio de vencimiento. '\n", + " 'No depende del rango de fechas de arriba.

'),\n", + " W.HBox([w_sv_metric, w_sv_eje, btn_sv_grafica]),\n", + " out_sv_grafica,\n", + "])\n", + "\n", + "\n", + "# ===== Tab 8: DataStage (Subir .asc a Postgres) =====\n", + "w_ds_ruta = W.Text(\n", + " value=DATASTAGE_ROOT or '', placeholder=r'C:\\ruta\\DATASTAGE_HONDA',\n", + " description='Carpeta:', layout={'width':'650px'},\n", + " style={'description_width':'80px'})\n", + "btn_ds_listar = W.Button(description='Listar archivos', button_style='info', icon='search', layout={'width':'200px'})\n", + "btn_ds_cargar = W.Button(description='Cargar todos a Postgres', button_style='warning', icon='upload', layout={'width':'260px'})\n", + "btn_ds_tablas = W.Button(description='Ver tablas Registro en Postgres', button_style='', icon='database', layout={'width':'280px'})\n", + "bar_ds = _mkbar('DataStage')\n", + "out_ds_lista = W.Output(layout=OUT_STYLE)\n", + "out_ds_log = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _ds_msg_conexion():\n", + " if not DATASTAGE_OK:\n", + " display(HTML(f'
'\n", + " f'Postgres no disponible.
'\n", + " f'{DATASTAGE_MSG}
'\n", + " f'Revisa que el contenedor postgres-datastage este levantado y que el .env tenga DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD.
'))\n", + " return False\n", + " display(HTML(f'
{DATASTAGE_MSG}
'))\n", + " return True\n", + "\n", + "def _on_ds_listar(_):\n", + " with out_ds_lista:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " ruta = (w_ds_ruta.value or '').strip()\n", + " if not ruta:\n", + " print('ERROR: define la ruta de la carpeta con los .asc')\n", + " return\n", + " df = previsualizar_archivos_ds(ruta)\n", + " if df.empty:\n", + " print(f'No se encontraron .asc en: {ruta}')\n", + " return\n", + " print(f'Archivos detectados: {len(df):,}')\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " # Resumen por tabla destino\n", + " resumen = (df.groupby('tabla_destino', as_index=False)\n", + " .agg(archivos=('archivo','count'),\n", + " tamanio_kb=('tamanio_kb','sum')))\n", + " display(HTML('

Resumen por tabla destino

'))\n", + " display(resumen)\n", + "btn_ds_listar.on_click(_on_ds_listar)\n", + "\n", + "def _on_ds_cargar(_):\n", + " with out_ds_log:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " ruta = (w_ds_ruta.value or '').strip()\n", + " if not ruta:\n", + " print('ERROR: define la ruta de la carpeta con los .asc')\n", + " return\n", + " print(f'Iniciando carga desde: {ruta}')\n", + " df_res = cargar_directorio_datastage(ruta, progress=bar_ds, log=print)\n", + " if not df_res.empty:\n", + " _state['ds_resultados'] = df_res\n", + " display(HTML('

Resultado por archivo

'))\n", + " display(df_res)\n", + "btn_ds_cargar.on_click(_on_ds_cargar)\n", + "\n", + "def _on_ds_tablas(_):\n", + " with out_ds_lista:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " try:\n", + " with _ds_conn() as c:\n", + " tablas = listar_tablas_registro(c)\n", + " if not tablas:\n", + " print('No hay tablas Registro* en Postgres. Migra el schema primero.')\n", + " return\n", + " print(f'Tablas Registro en Postgres: {len(tablas)}')\n", + " df = pd.DataFrame({'tabla': tablas})\n", + " display(df)\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_tablas.on_click(_on_ds_tablas)\n", + "\n", + "tab_datastage = W.VBox([\n", + " W.HTML('

DataStage - Subir archivos .asc a Postgres

'\n", + " '

'\n", + " 'Carga masiva de archivos .asc del DataStage en sus tablas Registro<NNN>. '\n", + " 'Migrado del modulo PHP HOME/DATASTAGE. La estructura de los archivos '\n", + " 'se infiere del nombre (_NNN.ascRegistroNNN); '\n", + " 'el separador es |, el encoding es latin-1, y se omite el header.

'),\n", + " W.HTML('
'\n", + " 'Estructura esperada de la carpeta'\n", + " '
    '\n", + " '
  • Layout HONDA: RAIZ/2020/*.asc, RAIZ/2021/*.asc, ... (solo .asc directos, sin entrar a subcarpetas de meses)
  • '\n", + " '
  • Layout plano: RAIZ/*.asc
  • '\n", + " '
'\n", + " '

Tablas destino deben existir previamente en Postgres. Esta pestania no crea tablas.

'\n", + " '
'),\n", + " W.HBox([w_ds_ruta]),\n", + " W.HBox([btn_ds_listar, btn_ds_tablas, btn_ds_cargar]), bar_ds,\n", + " out_ds_lista,\n", + " W.HTML('

Log de carga

'),\n", + " out_ds_log,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== DataStage: Limpiar y Estadisticas =====\n", + "chk_ds_confirmar_truncate = W.Checkbox(\n", + " value=False, description='Confirmo limpiar TODAS las tablas Registro*',\n", + " indent=False, layout={'width':'420px'})\n", + "btn_ds_truncate = W.Button(description='Limpiar todas las tablas',\n", + " button_style='danger', icon='trash', layout={'width':'250px'})\n", + "btn_ds_stats = W.Button(description='Ver estadisticas',\n", + " button_style='primary', icon='chart-bar', layout={'width':'200px'})\n", + "w_ds_tabla_sel = W.Dropdown(options=[], description='Tabla:',\n", + " layout={'width':'320px'})\n", + "w_ds_limit = W.IntText(value=100, description='Filas:',\n", + " layout={'width':'180px'}, style={'description_width':'60px'})\n", + "btn_ds_ver_datos = W.Button(description='Ver datos',\n", + " button_style='info', icon='eye', layout={'width':'180px'})\n", + "btn_ds_export = W.Button(description='Exportar a Excel',\n", + " button_style='', icon='file-excel', layout={'width':'200px'})\n", + "bar_ds_stats = _mkbar('Estadisticas')\n", + "out_ds_stats = W.Output(layout=OUT_STYLE)\n", + "out_ds_datos = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_truncate(_):\n", + " with out_ds_log:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not chk_ds_confirmar_truncate.value:\n", + " display(HTML('
Marca el checkbox de confirmacion antes de ejecutar.
'))\n", + " return\n", + " print('Truncando todas las tablas Registro*...')\n", + " res = truncar_tablas_registro(progress=bar_ds, log=print)\n", + " chk_ds_confirmar_truncate.value = False\n", + " if res:\n", + " df = pd.DataFrame([{'tabla': k, 'filas_eliminadas': v} for k, v in res.items()])\n", + " display(HTML('

Resultado del TRUNCATE

'))\n", + " display(df)\n", + "btn_ds_truncate.on_click(_on_ds_truncate)\n", + "\n", + "def _on_ds_stats(_):\n", + " with out_ds_stats:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " df = estadisticas_tablas_registro(progress=bar_ds_stats)\n", + " if df.empty:\n", + " print('No hay tablas Registro* en Postgres.')\n", + " return\n", + " total_filas = df['filas'].sum()\n", + " total_tablas = len(df)\n", + " total_con_datos = (df['filas'] > 0).sum()\n", + " display(HTML(\n", + " f'
'\n", + " f'Total: '\n", + " f'{total_tablas} tablas | {total_con_datos} con datos | '\n", + " f'{total_filas:,} filas en total
'))\n", + " df_show = df.copy()\n", + " df_show['filas'] = df_show['filas'].apply(lambda n: f'{n:,}')\n", + " display(df_show)\n", + " _state['ds_stats'] = df\n", + " # Llenar dropdown para visor\n", + " tablas_con_datos = df[df['filas'] > 0]['tabla'].tolist()\n", + " w_ds_tabla_sel.options = tablas_con_datos\n", + "btn_ds_stats.on_click(_on_ds_stats)\n", + "\n", + "def _on_ds_ver_datos(_):\n", + " with out_ds_datos:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " tabla = w_ds_tabla_sel.value\n", + " if not tabla:\n", + " print('Selecciona una tabla primero (corre \"Ver estadisticas\" antes).')\n", + " return\n", + " limit = max(1, int(w_ds_limit.value or 100))\n", + " try:\n", + " df = obtener_muestra_tabla(tabla, limit=limit, offset=0)\n", + " display(HTML(f'

{tabla} (primeras {limit})

'))\n", + " display(df)\n", + " _state['ds_muestra'] = df\n", + " _state['ds_muestra_tabla'] = tabla\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_ver_datos.on_click(_on_ds_ver_datos)\n", + "\n", + "def _on_ds_export(_):\n", + " with out_ds_datos:\n", + " if 'ds_muestra' not in _state or _state['ds_muestra'].empty:\n", + " display(HTML('
No hay datos cargados. Corre \"Ver datos\" primero.
'))\n", + " return\n", + " tabla = _state.get('ds_muestra_tabla', 'tabla')\n", + " ruta = os.path.join(os.getcwd(), f'{tabla}.xlsx')\n", + " _state['ds_muestra'].to_excel(ruta, index=False)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_export.on_click(_on_ds_export)\n", + "\n", + "tab_datastage.children = tuple(list(tab_datastage.children) + [\n", + " W.HTML('

Estadisticas de tablas Registro*

'\n", + " '

Cuenta de filas por tabla. Equivalente al modulo PHP datastage.php: '\n", + " 'permite ver totales y explorar el contenido cargado.

'),\n", + " W.HBox([btn_ds_stats]), bar_ds_stats,\n", + " out_ds_stats,\n", + " W.HTML('
Explorar contenido
'),\n", + " W.HBox([w_ds_tabla_sel, w_ds_limit, btn_ds_ver_datos, btn_ds_export]),\n", + " out_ds_datos,\n", + " W.HTML('

Zona peligrosa

'\n", + " '

TRUNCATE TABLE en todas las tablas Registro*. '\n", + " 'Elimina TODAS las filas (no se puede deshacer). Util para reiniciar la carga desde cero.

'),\n", + " W.HBox([chk_ds_confirmar_truncate]),\n", + " W.HBox([btn_ds_truncate]),\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== DataStage: Reportes de Pedimentos =====\n", + "def _ds_report_block(titulo_html, fi_widget, ff_widget, btn_widget,\n", + " btn_export_widget, out_widget):\n", + " return W.VBox([\n", + " W.HTML(titulo_html),\n", + " W.HBox([fi_widget, ff_widget, btn_widget, btn_export_widget]),\n", + " out_widget,\n", + " ])\n", + "\n", + "# --- 1) Estructura CAT Pedimentos ---\n", + "w_ds_cat_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_cat_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_ds_cat_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_cat_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "out_ds_cat = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_cat(_):\n", + " with out_ds_cat:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_cat_fi.value or not w_ds_cat_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " print(f'Consultando CAT Pedimentos {w_ds_cat_fi.value} a {w_ds_cat_ff.value}...')\n", + " try:\n", + " df = cat_pedimentos_ds(str(w_ds_cat_fi.value), str(w_ds_cat_ff.value))\n", + " print(f'Filas: {len(df):,}')\n", + " _state['ds_cat_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_cat_run.on_click(_on_ds_cat)\n", + "\n", + "def _on_ds_cat_export(_):\n", + " with out_ds_cat:\n", + " if 'ds_cat_df' not in _state or _state['ds_cat_df'].empty:\n", + " display(HTML('
Corre Generar primero.
'))\n", + " return\n", + " ruta = exportar_df_a_excel(_state['ds_cat_df'], 'Estructura_CAT_Pedimentos')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_cat_export.on_click(_on_ds_cat_export)\n", + "\n", + "# --- 2) Estructura CAT Pedimentos Rectificados ---\n", + "w_ds_catr_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_catr_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_ds_catr_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_catr_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "out_ds_catr = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_catr(_):\n", + " with out_ds_catr:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_catr_fi.value or not w_ds_catr_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " print(f'Consultando CAT Pedimentos Rectificados {w_ds_catr_fi.value} a {w_ds_catr_ff.value}...')\n", + " try:\n", + " df = cat_pedimentos_rect_ds(str(w_ds_catr_fi.value), str(w_ds_catr_ff.value))\n", + " print(f'Filas: {len(df):,}')\n", + " _state['ds_catr_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_catr_run.on_click(_on_ds_catr)\n", + "\n", + "def _on_ds_catr_export(_):\n", + " with out_ds_catr:\n", + " if 'ds_catr_df' not in _state or _state['ds_catr_df'].empty:\n", + " display(HTML('
Corre Generar primero.
'))\n", + " return\n", + " ruta = exportar_df_a_excel(_state['ds_catr_df'], 'Estructura_CAT_Pedimentos_Rectificados')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_catr_export.on_click(_on_ds_catr_export)\n", + "\n", + "# --- 3) Rastreo Rectificaciones (con historial recursivo) ---\n", + "w_ds_rect_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_rect_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "w_ds_rect_search = W.Text(description='Buscar:', placeholder='Pedimento, patente o clave',\n", + " layout={'width':'320px'}, style={'description_width':'70px'})\n", + "btn_ds_rect_run = W.Button(description='Listar', button_style='primary', icon='search', layout={'width':'120px'})\n", + "btn_ds_rect_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "out_ds_rect = W.Output(layout=OUT_STYLE)\n", + "\n", + "w_ds_hist_pat = W.Text(description='Patente:', layout={'width':'220px'}, style={'description_width':'80px'})\n", + "w_ds_hist_ped = W.Text(description='Pedimento:', layout={'width':'260px'}, style={'description_width':'80px'})\n", + "w_ds_hist_sec = W.Text(description='Seccion Ad.:', layout={'width':'220px'}, style={'description_width':'80px'})\n", + "w_ds_hist_anio = W.IntText(value=2024, description='Anio:', layout={'width':'150px'}, style={'description_width':'60px'})\n", + "btn_ds_hist_run = W.Button(description='Ver historial', button_style='info', icon='clock-o', layout={'width':'180px'})\n", + "out_ds_hist = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_rect(_):\n", + " with out_ds_rect:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " try:\n", + " fi = str(w_ds_rect_fi.value) if w_ds_rect_fi.value else None\n", + " ff = str(w_ds_rect_ff.value) if w_ds_rect_ff.value else None\n", + " df = rectificados_ds(fi, ff, (w_ds_rect_search.value or '').strip())\n", + " print(f'Pedimentos rectificados encontrados: {len(df):,}')\n", + " _state['ds_rect_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " display(HTML('Tip: copia Patente / Pedimento / SeccionAduanera / Anio a la seccion de abajo para ver el historial recursivo.'))\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_rect_run.on_click(_on_ds_rect)\n", + "\n", + "def _on_ds_rect_export(_):\n", + " with out_ds_rect:\n", + " if 'ds_rect_df' not in _state or _state['ds_rect_df'].empty:\n", + " display(HTML('
Corre Listar primero.
'))\n", + " return\n", + " ruta = exportar_df_a_excel(_state['ds_rect_df'], 'Pedimentos_Rectificados')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_rect_export.on_click(_on_ds_rect_export)\n", + "\n", + "def _on_ds_hist(_):\n", + " with out_ds_hist:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not (w_ds_hist_pat.value and w_ds_hist_ped.value and w_ds_hist_sec.value):\n", + " print('Completa Patente, Pedimento, Seccion Aduanera y Anio.'); return\n", + " try:\n", + " df = historial_rectificaciones_ds(\n", + " w_ds_hist_pat.value.strip(),\n", + " w_ds_hist_ped.value.strip(),\n", + " w_ds_hist_sec.value.strip(),\n", + " int(w_ds_hist_anio.value))\n", + " print(f'Filas en cadena: {len(df):,}')\n", + " display(df)\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_hist_run.on_click(_on_ds_hist)\n", + "\n", + "tab_estructuras = W.VBox([\n", + " W.HTML('

Estructuras SCAII - Reportes de Pedimentos

'\n", + " '

Migracion de las opciones del menu PHP Pedimentos: '\n", + " 'CAT Pedimentos, CAT Pedimentos Rectificados y Rastreo de Rectificaciones (con historial recursivo). '\n", + " 'Las consultas se ejecutan sobre Postgres (tablas Registro501 y Registro701).

'),\n", + " _ds_report_block(\n", + " '
Estructura CAT Pedimentos
'\n", + " '

Pedimentos de Registro501 en el rango, indicando si fueron rectificados.

',\n", + " w_ds_cat_fi, w_ds_cat_ff, btn_ds_cat_run, btn_ds_cat_export, out_ds_cat),\n", + " _ds_report_block(\n", + " '
Estructura CAT Pedimentos Rectificados
'\n", + " '

Pedimentos rectificados de Registro701 en el rango, '\n", + " 'enlazados con el tipo de operacion de Registro501.

',\n", + " w_ds_catr_fi, w_ds_catr_ff, btn_ds_catr_run, btn_ds_catr_export, out_ds_catr),\n", + " W.HTML('
Rastreo de Rectificaciones
'\n", + " '

Pedimentos de Registro501 que fueron rectificados, con filtros y opcion de ver la cadena historica.

'),\n", + " W.HBox([w_ds_rect_fi, w_ds_rect_ff, w_ds_rect_search]),\n", + " W.HBox([btn_ds_rect_run, btn_ds_rect_export]),\n", + " out_ds_rect,\n", + " W.HTML('
Historial recursivo de un pedimento
'\n", + " '

Equivalente al modal obtener_historial.php.

'),\n", + " W.HBox([w_ds_hist_pat, w_ds_hist_ped, w_ds_hist_sec, w_ds_hist_anio]),\n", + " W.HBox([btn_ds_hist_run]),\n", + " out_ds_hist,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# --- 4) Encabezado Facturas Importacion ---\n", + "w_ds_fimpo_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_fimpo_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_ds_fimpo_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_fimpo_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "out_ds_fimpo = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_fimpo(_):\n", + " with out_ds_fimpo:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_fimpo_fi.value or not w_ds_fimpo_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " print(f'Consultando facturas IMPO {w_ds_fimpo_fi.value} a {w_ds_fimpo_ff.value}...')\n", + " try:\n", + " df = encabezado_facturas_ds(str(w_ds_fimpo_fi.value), str(w_ds_fimpo_ff.value), 1)\n", + " print(f'Filas: {len(df):,}')\n", + " _state['ds_fimpo_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_fimpo_run.on_click(_on_ds_fimpo)\n", + "\n", + "def _on_ds_fimpo_export(_):\n", + " with out_ds_fimpo:\n", + " if 'ds_fimpo_df' not in _state or _state['ds_fimpo_df'].empty:\n", + " display(HTML('
Corre Generar primero.
')); return\n", + " ruta = exportar_df_a_excel(_state['ds_fimpo_df'], 'Estructura_facturasImpo_501')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_fimpo_export.on_click(_on_ds_fimpo_export)\n", + "\n", + "# --- 5) Encabezado Facturas Exportacion ---\n", + "w_ds_fexpo_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_fexpo_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_ds_fexpo_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_fexpo_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "out_ds_fexpo = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_fexpo(_):\n", + " with out_ds_fexpo:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_fexpo_fi.value or not w_ds_fexpo_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " print(f'Consultando facturas EXPO {w_ds_fexpo_fi.value} a {w_ds_fexpo_ff.value}...')\n", + " try:\n", + " df = encabezado_facturas_ds(str(w_ds_fexpo_fi.value), str(w_ds_fexpo_ff.value), 2)\n", + " print(f'Filas: {len(df):,}')\n", + " _state['ds_fexpo_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_fexpo_run.on_click(_on_ds_fexpo)\n", + "\n", + "def _on_ds_fexpo_export(_):\n", + " with out_ds_fexpo:\n", + " if 'ds_fexpo_df' not in _state or _state['ds_fexpo_df'].empty:\n", + " display(HTML('
Corre Generar primero.
')); return\n", + " ruta = exportar_df_a_excel(_state['ds_fexpo_df'], 'Estructura_facturasExpo_501')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_fexpo_export.on_click(_on_ds_fexpo_export)\n", + "\n", + "tab_estructuras.children = tuple(list(tab_estructuras.children) + [\n", + " W.HTML('

Encabezado de Facturas

'\n", + " '

Genera el encabezado de facturas de importacion (TipoOperacion=1) o exportacion '\n", + " '(TipoOperacion=2) excluyendo los pedimentos que ya fueron rectificados.

'),\n", + " _ds_report_block(\n", + " '
Encabezado Facturas Importacion
'\n", + " '

Pedimentos IMPO de Registro501 en el rango (excluye rectificados).

',\n", + " w_ds_fimpo_fi, w_ds_fimpo_ff, btn_ds_fimpo_run, btn_ds_fimpo_export, out_ds_fimpo),\n", + " _ds_report_block(\n", + " '
Encabezado Facturas Exportacion
'\n", + " '

Pedimentos EXPO de Registro501 en el rango (excluye rectificados).

',\n", + " w_ds_fexpo_fi, w_ds_fexpo_ff, btn_ds_fexpo_run, btn_ds_fexpo_export, out_ds_fexpo),\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# --- 6) Estructura Tipo de Cambio 501 ---\n", + "w_ds_tc_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_tc_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_ds_tc_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_tc_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'200px'})\n", + "out_ds_tc = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_tc(_):\n", + " with out_ds_tc:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_tc_fi.value or not w_ds_tc_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " print(f'Consultando Tipo de Cambio {w_ds_tc_fi.value} a {w_ds_tc_ff.value}...')\n", + " try:\n", + " df = tipo_cambio_ds(str(w_ds_tc_fi.value), str(w_ds_tc_ff.value))\n", + " n_incon = int(df['INCONSISTENTE'].sum()) if not df.empty else 0\n", + " n_fechas_incon = df.loc[df['INCONSISTENTE'], 'FECHA PAGO REAL'].astype(str).str[:10].nunique() if n_incon else 0\n", + " print(f'Filas: {len(df):,} | Filas con TipoCambio inconsistente: {n_incon:,} ({n_fechas_incon} fechas distintas)')\n", + " _state['ds_tc_df'] = df\n", + " # Mostrar con celdas resaltadas (Styler)\n", + " styler = (df.head(200).style\n", + " .apply(lambda r: ['background-color:#FF0000;color:white;font-weight:bold' if r['INCONSISTENTE'] and c == 'TIPO CAMBIO' else ''\n", + " for c in df.columns], axis=1))\n", + " display(styler)\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_tc_run.on_click(_on_ds_tc)\n", + "\n", + "def _on_ds_tc_export(_):\n", + " with out_ds_tc:\n", + " if 'ds_tc_df' not in _state or _state['ds_tc_df'].empty:\n", + " display(HTML('
Corre Generar primero.
')); return\n", + " import datetime as _dt_xc\n", + " ts = _dt_xc.datetime.now().strftime('%Y%m%d_%H%M%S')\n", + " ruta = os.path.join(os.getcwd(), f'Estructura_Tipo_Cambio_501_{ts}.xlsx')\n", + " exportar_tipo_cambio_excel(_state['ds_tc_df'], ruta)\n", + " display(HTML(f'
'\n", + " f'Excel generado (celdas con inconsistencia en rojo)
'\n", + " f'{ruta}
'))\n", + "btn_ds_tc_export.on_click(_on_ds_tc_export)\n", + "\n", + "tab_estructuras.children = tuple(list(tab_estructuras.children) + [\n", + " W.HTML('

Tipo de Cambio

'\n", + " '

Estructura de Tipo de Cambio del Registro501. '\n", + " 'Detecta automaticamente inconsistencias: fechas que tienen mas de un valor distinto '\n", + " 'de TIPO CAMBIO en sus pedimentos (resaltadas en rojo).

'),\n", + " _ds_report_block(\n", + " '
Estructura Tipo de Cambio 501
'\n", + " '

Pedimentos de Registro501 en el rango con su Tipo de Cambio.

',\n", + " w_ds_tc_fi, w_ds_tc_ff, btn_ds_tc_run, btn_ds_tc_export, out_ds_tc),\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== Catalogo base de NUMPARTES =====\n", + "upload_basenp = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel')\n", + "btn_basenp_cargar = W.Button(description='Cargar a base (upsert)', button_style='primary', icon='upload', layout={'width':'260px'})\n", + "btn_basenp_ver = W.Button(description='Ver base actual', button_style='info', icon='database', layout={'width':'200px'})\n", + "chk_basenp_confirmar = W.Checkbox(value=False, description='Confirmo TRUNCATE de base_numpartes',\n", + " indent=False, layout={'width':'380px'})\n", + "btn_basenp_truncar = W.Button(description='Limpiar base', button_style='danger', icon='trash', layout={'width':'180px'})\n", + "out_basenp = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_basenp_cargar(_):\n", + " with out_basenp:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if len(upload_basenp.value) == 0:\n", + " print('Sube un Excel primero.'); return\n", + " try:\n", + " if isinstance(upload_basenp.value, dict):\n", + " fname = list(upload_basenp.value.keys())[0]\n", + " fb = upload_basenp.value[fname]['content']\n", + " else:\n", + " fb = upload_basenp.value[0]['content']\n", + " tmp = os.path.join(os.getcwd(), '_upload_basenp.xlsx')\n", + " with open(tmp, 'wb') as f: f.write(fb)\n", + " n = cargar_excel_base_numpartes(tmp, log=print)\n", + " print(f'OK. {n} filas cargadas/actualizadas.')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_basenp_cargar.on_click(_on_basenp_cargar)\n", + "\n", + "def _on_basenp_ver(_):\n", + " with out_basenp:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " try:\n", + " df = listar_base_numpartes(limit=500)\n", + " print(f'base_numpartes: {len(df):,} filas (max 500 mostradas)')\n", + " display(df)\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_basenp_ver.on_click(_on_basenp_ver)\n", + "\n", + "def _on_basenp_truncar(_):\n", + " with out_basenp:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not chk_basenp_confirmar.value:\n", + " display(HTML('
Marca el checkbox de confirmacion antes de ejecutar.
')); return\n", + " try:\n", + " n = truncar_base_numpartes(log=print)\n", + " chk_basenp_confirmar.value = False\n", + " print(f'OK. {n} filas eliminadas.')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_basenp_truncar.on_click(_on_basenp_truncar)\n", + "\n", + "\n", + "# ===== Estructura de Partidas Impo =====\n", + "w_ds_pimpo_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_pimpo_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "w_ds_pimpo_umb = W.FloatSlider(value=0.80, min=0.50, max=1.00, step=0.05,\n", + " description='Umbral sim:', readout_format='.2f', layout={'width':'380px'})\n", + "btn_ds_pimpo_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_pimpo_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "bar_ds_pimpo = _mkbar('Partidas IMPO')\n", + "out_ds_pimpo = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_pimpo(_):\n", + " with out_ds_pimpo:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_pimpo_fi.value or not w_ds_pimpo_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " try:\n", + " df = asignar_numpartes_551(str(w_ds_pimpo_fi.value), str(w_ds_pimpo_ff.value),\n", + " 1, float(w_ds_pimpo_umb.value),\n", + " progress=bar_ds_pimpo, log=print)\n", + " print(f'Filas: {len(df):,}')\n", + " _state['ds_pimpo_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_pimpo_run.on_click(_on_ds_pimpo)\n", + "\n", + "def _on_ds_pimpo_export(_):\n", + " with out_ds_pimpo:\n", + " if 'ds_pimpo_df' not in _state or _state['ds_pimpo_df'].empty:\n", + " display(HTML('
Corre Generar primero.
')); return\n", + " ruta = exportar_df_a_excel(_state['ds_pimpo_df'], 'Estructura_Partidas_Impo')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_pimpo_export.on_click(_on_ds_pimpo_export)\n", + "\n", + "\n", + "# ===== Estructura de Partidas Expo =====\n", + "w_ds_pexpo_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_ds_pexpo_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "w_ds_pexpo_umb = W.FloatSlider(value=0.80, min=0.50, max=1.00, step=0.05,\n", + " description='Umbral sim:', readout_format='.2f', layout={'width':'380px'})\n", + "btn_ds_pexpo_run = W.Button(description='Generar', button_style='primary', icon='play', layout={'width':'120px'})\n", + "btn_ds_pexpo_export = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'180px'})\n", + "bar_ds_pexpo = _mkbar('Partidas EXPO')\n", + "out_ds_pexpo = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_ds_pexpo(_):\n", + " with out_ds_pexpo:\n", + " clear_output()\n", + " if not _ds_msg_conexion(): return\n", + " if not w_ds_pexpo_fi.value or not w_ds_pexpo_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " try:\n", + " df = asignar_numpartes_551(str(w_ds_pexpo_fi.value), str(w_ds_pexpo_ff.value),\n", + " 2, float(w_ds_pexpo_umb.value),\n", + " progress=bar_ds_pexpo, log=print)\n", + " print(f'Filas: {len(df):,}')\n", + " _state['ds_pexpo_df'] = df\n", + " display(df.head(200))\n", + " if len(df) > 200:\n", + " print(f'(mostrando 200 de {len(df)})')\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_ds_pexpo_run.on_click(_on_ds_pexpo)\n", + "\n", + "def _on_ds_pexpo_export(_):\n", + " with out_ds_pexpo:\n", + " if 'ds_pexpo_df' not in _state or _state['ds_pexpo_df'].empty:\n", + " display(HTML('
Corre Generar primero.
')); return\n", + " ruta = exportar_df_a_excel(_state['ds_pexpo_df'], 'Estructura_Partidas_Expo')\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{ruta}
'))\n", + "btn_ds_pexpo_export.on_click(_on_ds_pexpo_export)\n", + "\n", + "\n", + "tab_estructuras.children = tuple(list(tab_estructuras.children) + [\n", + " W.HTML('

Catalogo base de NUMPARTES

'\n", + " '

Catalogo del cliente con columnas NUMPARTE, DESCRIPCION, UNIDAD DE MEDIDA, FRACCION. '\n", + " 'Se usa para asignar NUMPARTE a las partidas del Registro551 por similitud. La subida hace upsert (acumula).

'),\n", + " W.HBox([upload_basenp, btn_basenp_cargar, btn_basenp_ver]),\n", + " W.HBox([chk_basenp_confirmar, btn_basenp_truncar]),\n", + " out_basenp,\n", + " W.HTML('

Estructura de Partidas

'\n", + " '

Genera la estructura de partidas a partir de Registro551. '\n", + " 'Asigna NUMPARTE buscando primero en el catalogo (TF-IDF + cosine sobre descripcion, con fraccion 4d y UM exactas); '\n", + " 'las que no encuentran match se agrupan entre si por similitud y reciben NUMPARTE auto MP<F4>-R<NNN>.

'),\n", + " W.HTML('
Partidas Importacion
'),\n", + " W.HBox([w_ds_pimpo_fi, w_ds_pimpo_ff, w_ds_pimpo_umb]),\n", + " W.HBox([btn_ds_pimpo_run, btn_ds_pimpo_export]), bar_ds_pimpo,\n", + " out_ds_pimpo,\n", + " W.HTML('
Partidas Exportacion
'),\n", + " W.HBox([w_ds_pexpo_fi, w_ds_pexpo_ff, w_ds_pexpo_umb]),\n", + " W.HBox([btn_ds_pexpo_run, btn_ds_pexpo_export]), bar_ds_pexpo,\n", + " out_ds_pexpo,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== Tab Valores: Ajuste de VALORTOTALME / VALORTOTALMN =====\n", + "upload_val = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel')\n", + "btn_plantilla_val = W.Button(description='Descargar plantilla', button_style='info', icon='download', layout={'width':'220px'})\n", + "w_val_modo = W.RadioButtons(\n", + " options=[('Aplicar siempre', 'APLICAR_SIEMPRE'), ('Usar umbral %', 'USAR_UMBRAL')],\n", + " value='APLICAR_SIEMPRE', description='Modo:', layout={'width':'320px'})\n", + "chk_val_shelter = W.Checkbox(value=False,\n", + " description='Buscar en todas las BDs (shelter)', indent=False,\n", + " layout={'width':'380px'})\n", + "w_val_umbral = W.FloatSlider(value=50.0, min=1.0, max=500.0, step=1.0,\n", + " description='Umbral %:', readout_format='.0f', layout={'width':'380px'})\n", + "btn_val_analizar = W.Button(description='Analizar', button_style='primary', icon='search', layout={'width':'180px'})\n", + "btn_val_xlsx = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'200px'})\n", + "bar_val = _mkbar('Valores')\n", + "out_val_log = W.Output(layout=OUT_STYLE)\n", + "out_val_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "_html_formato_val = ('
'\n", + " 'Formato esperado del Excel (3 columnas)'\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " '
ColumnaEjemplo
PEDIMENTO07-3429-4015540
VALOR_ME12345.67
VALOR_MN234567.89
'\n", + " '

Por cada pedimento se buscan sus partidas de exportacion y se prorratean los valores hasta cuadrar al 100%.

'\n", + " '
')\n", + "\n", + "def _on_plantilla_val(_):\n", + " with out_val_log:\n", + " clear_output()\n", + " bts = generar_plantilla_excel_valores()\n", + " path = os.path.join(os.getcwd(), 'plantilla_valores.xlsx')\n", + " with open(path, 'wb') as f: f.write(bts)\n", + " display(HTML(f'
'\n", + " f'Plantilla generada
'\n", + " f'{path}
'))\n", + "btn_plantilla_val.on_click(_on_plantilla_val)\n", + "\n", + "def _on_val_analizar(_):\n", + " with out_val_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " if len(upload_val.value) == 0:\n", + " print('ERROR: sube un Excel primero.'); return\n", + " try:\n", + " if isinstance(upload_val.value, dict):\n", + " fname = list(upload_val.value.keys())[0]\n", + " fb = upload_val.value[fname]['content']\n", + " else:\n", + " fb = upload_val.value[0]['content']\n", + " tmp = os.path.join(os.getcwd(), '_upload_valores.xlsx')\n", + " with open(tmp, 'wb') as f: f.write(fb)\n", + " df_excel = cargar_excel_valores(tmp)\n", + " print(f'Excel cargado: {len(df_excel):,} pedimentos')\n", + " except Exception as e:\n", + " print(f'ERROR cargando Excel: {e}'); return\n", + " plan, resumen = analizar_valores(df_excel,\n", + " modo=w_val_modo.value, umbral_pct=float(w_val_umbral.value),\n", + " usar_shelter=bool(chk_val_shelter.value),\n", + " progress=bar_val, log=print)\n", + " _state['val_plan'] = plan\n", + " _state['val_resumen'] = resumen\n", + " if not resumen.empty:\n", + " cnt = resumen['STATUS'].value_counts().to_dict()\n", + " print(f'Resumen: {cnt}')\n", + " with out_val_tabla:\n", + " clear_output()\n", + " if 'val_resumen' in _state and not _state['val_resumen'].empty:\n", + " display(HTML('

Resumen por pedimento

'))\n", + " display(_state['val_resumen'])\n", + " if 'val_plan' in _state and not _state['val_plan'].empty:\n", + " display(HTML('

Plan detalle (primeras 50 filas)

'))\n", + " display(_state['val_plan'].head(50))\n", + "btn_val_analizar.on_click(_on_val_analizar)\n", + "\n", + "def _on_val_xlsx(_):\n", + " with out_val_log:\n", + " if 'val_plan' not in _state:\n", + " display(HTML('
Corre Analizar primero.
')); return\n", + " path = os.path.join(os.getcwd(), 'plan_valores.xlsx')\n", + " exportar_excel_valores(_state['val_plan'], _state['val_resumen'], path)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{path}
'))\n", + "btn_val_xlsx.on_click(_on_val_xlsx)\n", + "\n", + "dry_val = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_val_ejecutar = W.Button(description='Ejecutar ajuste (Paso B)', button_style='warning', icon='play', layout={'width':'280px'})\n", + "bar_val_b = _mkbar('Ejecucion Valores')\n", + "out_val_ejec = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_val_ejecutar(_):\n", + " with out_val_ejec:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if 'val_plan' not in _state:\n", + " print('ERROR: corre primero Analizar.'); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_valores(_state['val_plan'], dry_run=dry_val.value, log=print, progress=bar_val_b)\n", + "btn_val_ejecutar.on_click(_on_val_ejecutar)\n", + "\n", + "tab_valores = W.VBox([\n", + " W.HTML('

Valores - Ajuste de VALORTOTALME / VALORTOTALMN

'\n", + " '

'\n", + " 'Sube un Excel con los valores ME y MN esperados por pedimento. La herramienta '\n", + " 'busca las partidas de exportacion de cada pedimento y prorratea los valores '\n", + " 'proporcionalmente al actual de cada una hasta cuadrar 100%.

'),\n", + " W.HTML(_html_formato_val),\n", + " btn_plantilla_val,\n", + " W.HTML('

Sube tu Excel:

'),\n", + " W.HBox([upload_val]),\n", + " W.HBox([w_val_modo]),\n", + " W.HBox([chk_val_shelter]),\n", + " W.HBox([w_val_umbral]),\n", + " W.HBox([btn_val_analizar, btn_val_xlsx]), bar_val,\n", + " out_val_log, out_val_tabla,\n", + " W.HTML('

Paso B - Ejecutar UPDATE en SPartidasExpo

'\n", + " '

UPDATE VALORTOTALME y VALORTOTALMN por (FACTURAEXPO, LINEA). '\n", + " 'Corre primero con DRY_RUN activado.

'),\n", + " W.HBox([dry_val]),\n", + " btn_val_ejecutar, bar_val_b,\n", + " out_val_ejec,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== Shelter: buscar pedimentos en todas las BDs =====\n", + "btn_val_shelter = W.Button(description='Buscar pedimentos en todas las BDs',\n", + " button_style='info', icon='search-plus', layout={'width':'320px'})\n", + "bar_val_shelter = _mkbar('Shelter')\n", + "out_val_shelter = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_val_shelter(_):\n", + " with out_val_shelter:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " if len(upload_val.value) == 0:\n", + " print('ERROR: sube un Excel primero.'); return\n", + " try:\n", + " if isinstance(upload_val.value, dict):\n", + " fname = list(upload_val.value.keys())[0]\n", + " fb = upload_val.value[fname]['content']\n", + " else:\n", + " fb = upload_val.value[0]['content']\n", + " tmp = os.path.join(os.getcwd(), '_upload_valores.xlsx')\n", + " with open(tmp, 'wb') as f: f.write(fb)\n", + " df_excel = cargar_excel_valores(tmp)\n", + " print(f'Excel: {len(df_excel):,} pedimentos')\n", + " except Exception as e:\n", + " print(f'ERROR Excel: {e}'); return\n", + " reporte, resumen_bd, faltantes = buscar_pedimentos_en_bds(\n", + " df_excel, progress=bar_val_shelter, log=print)\n", + " _state['val_shelter_reporte'] = reporte\n", + " _state['val_shelter_resumen_bd'] = resumen_bd\n", + " _state['val_shelter_faltantes'] = faltantes\n", + " encontrados = len(reporte) - len(faltantes) if not reporte.empty else 0\n", + " print(f'Encontrados: {encontrados} | Faltantes: {len(faltantes)}')\n", + " if not reporte.empty:\n", + " display(HTML('

Donde esta cada pedimento

'))\n", + " display(reporte)\n", + " if not resumen_bd.empty:\n", + " display(HTML('

Resumen por base de datos

'))\n", + " display(resumen_bd)\n", + " if faltantes:\n", + " display(HTML('

No encontrados en ninguna BD

'))\n", + " display(pd.DataFrame({'PEDIMENTO': faltantes}))\n", + "btn_val_shelter.on_click(_on_val_shelter)\n", + "\n", + "tab_valores.children = tuple(list(tab_valores.children) + [\n", + " W.HTML('

Shelter - Buscar pedimentos en otras BDs

'\n", + " '

'\n", + " 'Recorre todas las bases de datos de la instancia SQL Server (las que el usuario '\n", + " 'puede acceder y tienen la tabla SPedimentos.PEDIMENTO) y reporta en '\n", + " 'cual(es) base(s) vive cada pedimento del Excel. Solo es diagnostico — no '\n", + " 'modifica nada. Util cuando el cliente tiene varias BDs y no sabes a cual '\n", + " 'apuntar el ajuste.

'),\n", + " W.HBox([btn_val_shelter]), bar_val_shelter,\n", + " out_val_shelter,\n", + "])\n", + "\n", + "\n", + "tabs = W.Tab(children=[tab_conn, tab_desc, tab_an, tab_nlp, tab_kg, tab_ctm, tab_sv, tab_datastage, tab_estructuras, tab_valores])\n", + "tabs.set_title(0, 'Conexion')\n", + "tabs.set_title(1, 'Descargas')\n", + "tabs.set_title(2, 'Analisis Saldos')\n", + "tabs.set_title(3, 'Sustitutos NLP')\n", + "tabs.set_title(4, 'Descarga % KGS')\n", + "tabs.set_title(5, 'CTM')\n", + "tabs.set_title(6, 'Saldos Vencidos')\n", + "tabs.set_title(7, 'DataStage')\n", + "tabs.set_title(8, 'Estructuras SCAII')\n", + "tabs.set_title(9, 'Valores')\n", + "display(header, tabs)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/app/docker-compose.yml b/app/docker-compose.yml new file mode 100644 index 0000000..d7c23d5 --- /dev/null +++ b/app/docker-compose.yml @@ -0,0 +1,21 @@ +services: + postgres-datastage: + image: postgres:16 + container_name: postgres-datastage + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-dbSat} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_datastage_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-dbSat}"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_datastage_data: diff --git a/app/generar_manual.py b/app/generar_manual.py new file mode 100644 index 0000000..de6b736 --- /dev/null +++ b/app/generar_manual.py @@ -0,0 +1,645 @@ +""" +Genera el manual de usuario final del Sistema de Utilerias 2.0 SCAII. +Lenguaje accesible, sin tecnicismos. +""" +from docx import Document +from docx.shared import Pt, RGBColor, Inches, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() + +for sec in doc.sections: + sec.top_margin = Cm(2.5) + sec.bottom_margin = Cm(2.5) + sec.left_margin = Cm(2.2) + sec.right_margin = Cm(2.2) + +style = doc.styles['Normal'] +style.font.name = 'Calibri' +style.font.size = Pt(11) + + +# ====== Helpers de formato ====== +def h1(text): + p = doc.add_heading(text, level=1) + for run in p.runs: + run.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +def h2(text): + p = doc.add_heading(text, level=2) + for run in p.runs: + run.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5) + +def h3(text): + p = doc.add_heading(text, level=3) + for run in p.runs: + run.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +def parr(text, bold=False): + p = doc.add_paragraph() + r = p.add_run(text) + r.bold = bold + return p + +def code_block(text): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + r = p.add_run(text) + r.font.name = 'Consolas' + r.font.size = Pt(9.5) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:color'), 'auto') + shd.set(qn('w:fill'), 'F0F0F0') + pPr.append(shd) + return p + +def bullet(text, level=0): + p = doc.add_paragraph(text, style='List Bullet') + p.paragraph_format.left_indent = Cm(0.6 + level*0.6) + return p + +def numbered(text): + p = doc.add_paragraph(text, style='List Number') + p.paragraph_format.left_indent = Cm(0.6) + return p + +def nota(titulo, text): + """Caja de nota con titulo en color.""" + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:color'), 'auto') + shd.set(qn('w:fill'), 'FFF8E1') + pPr.append(shd) + r = p.add_run(titulo + ': ') + r.bold = True + r.font.color.rgb = RGBColor(0xE6, 0x5C, 0x00) + p.add_run(text) + +def tip(titulo, text): + """Caja de tip con verde.""" + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:color'), 'auto') + shd.set(qn('w:fill'), 'E8F5E9') + pPr.append(shd) + r = p.add_run(titulo + ': ') + r.bold = True + r.font.color.rgb = RGBColor(0x2E, 0x7D, 0x32) + p.add_run(text) + +def table_simple(headers, rows, col_widths_cm=None): + t = doc.add_table(rows=1+len(rows), cols=len(headers)) + t.style = 'Light Grid Accent 1' + hdr = t.rows[0].cells + for i, h in enumerate(headers): + hdr[i].text = h + for p in hdr[i].paragraphs: + for r in p.runs: + r.bold = True + r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + tcPr = hdr[i]._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:fill'), '1565C0') + tcPr.append(shd) + for r_idx, row in enumerate(rows, start=1): + cells = t.rows[r_idx].cells + for c_idx, val in enumerate(row): + cells[c_idx].text = str(val) + if col_widths_cm: + for i, w in enumerate(col_widths_cm): + for row in t.rows: + row.cells[i].width = Cm(w) + return t + + +# ====== PORTADA ====== +title = doc.add_paragraph() +title.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = title.add_run('\n\n\nSistema de Utilerías 2.0') +r.bold = True; r.font.size = Pt(36); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +sub = doc.add_paragraph() +sub.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = sub.add_run('SCAII') +r.font.size = Pt(22); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +p = doc.add_paragraph() +p.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = p.add_run('\n\nManual de Usuario\n') +r.font.size = Pt(15) +r = p.add_run(f'\nVersión {datetime.date.today().isoformat()}') +r.font.size = Pt(12); r.italic = True + +doc.add_page_break() + + +# ====== CONTENIDO ====== +h1('Contenido') +indice = [ + '1. ¿Para qué sirve esta aplicación?', + '2. Antes de empezar', + '3. Cómo abrir la aplicación', + '4. La pantalla principal', + '5. Pestaña 1: Conexión (elegir base de datos)', + '6. Pestaña 2: Descargas', + ' • Paso 8 — Pronóstico', + ' • Paso 9 — Generar descargas pendientes', + ' • Paso 10 — Complementar descargas ya hechas', + '7. Pestaña 3: Análisis de Saldos', + ' • Saldo disponible por año', + ' • Comparativo Importaciones vs Exportaciones', + ' • Tabla de pesos: IMPO / EXPO / CONSUMIDO / DESCARGAS', + ' • Tabla de cantidades por año (totales)', + ' • Tabla de cantidades por año + Unidad de Medida', + ' • Exportar a Excel', + '8. Pestaña 4: Sustitutos NLP (sugerencias automáticas)', + '9. Pestaña 5: Descarga por % de KGS', + '10. Barras de progreso — qué significan', + '11. Modo prueba (DRY_RUN) — la herramienta más importante', + '12. Si algo sale mal', + '13. Glosario', +] +for it in indice: + doc.add_paragraph(it) + +doc.add_page_break() + + +# ====== 1. INTRODUCCION ====== +h1('1. ¿Para qué sirve esta aplicación?') +parr('Es una herramienta para automatizar y revisar el proceso de descargas de inventario ' + 'contra facturas de exportación en SCAII. En lugar de hacer las descargas factura por factura ' + 'a mano en el sistema, esta aplicación las procesa por lotes y deja registro de todo.') + +parr('Lo que puedes hacer con la app:') +bullet('Ver cuántas facturas tienes pendientes y si hay suficiente inventario para cubrirlas') +bullet('Generar las descargas de forma masiva') +bullet('Complementar facturas que se cerraron pero quedaron con faltantes') +bullet('Analizar el estado de tus saldos: cuánto te queda, qué tan viejo es, comparar contra lo que importaste/exportaste') +bullet('Encontrar sustitutos automáticamente para componentes con descripciones similares') + +tip('Bueno saber', 'Antes de escribir cualquier cosa en la base de datos, la app te permite ' + 'simular el resultado con el "modo prueba". Así nunca trabajas a ciegas.') + + +# ====== 2. ANTES DE EMPEZAR ====== +h1('2. Antes de empezar') +parr('Tu computadora (o el servidor donde se instaló) debe tener:') +bullet('Python 3.11 o superior') +bullet('Microsoft ODBC Driver 18 for SQL Server') +bullet('Acceso a la base de datos SCAII (usuario y contraseña)') + +parr('Si alguien del área técnica te entregó la aplicación, es probable que ya tenga todo configurado. ' + 'Solo necesitas saber dónde está la carpeta y darle doble clic al archivo de inicio.') + + +# ====== 3. ABRIR LA APP ====== +h1('3. Cómo abrir la aplicación') +parr('En la carpeta de la aplicación verás varios archivos. El que te interesa es:') +code_block('run_dev.bat') + +parr('Pasos:') +numbered('Doble clic en run_dev.bat') +numbered('Se abre una ventana negra de consola con texto. NO LA CIERRES — si la cierras, la app se apaga.') +numbered('Después de unos segundos, tu navegador (Edge, Chrome) se abre automáticamente con la aplicación') +numbered('Si por alguna razón el navegador no abre, puedes escribir esta dirección manualmente:') +code_block('http://127.0.0.1:8866/') + +nota('Importante', 'La consola negra debe quedarse abierta todo el tiempo que estés usando la aplicación. ' + 'Cuando termines de trabajar, cierra primero el navegador y después cierra la consola.') + + +# ====== 4. LA PANTALLA ====== +h1('4. La pantalla principal') +parr('Al abrir la app verás tres cosas:') +bullet('Una barra azul en la parte de arriba con el nombre del sistema y la base de datos a la que estás conectado') +bullet('Cinco pestañas: Conexión, Descargas, Análisis Saldos, Sustitutos NLP, Descarga % KGS') +bullet('El contenido de la pestaña que tengas seleccionada') + +nota('Si la barra está roja', 'Significa que no hay conexión con la base de datos. Revisa con el área de ' + 'sistemas que las credenciales sean correctas y que el servidor esté disponible.') + + +# ====== 5. CONEXION ====== +h1('5. Pestaña 1: Conexión (elegir base de datos)') +parr('La primera pestaña te permite escoger sobre cuál base de datos quieres trabajar. Esto es útil cuando ' + 'tu servidor tiene varias bases (por ejemplo, una de producción y otra de pruebas).') + +h3('Cómo cambiar de base de datos') +numbered('Abre la lista desplegable "Base de datos"') +numbered('Selecciona la base que quieres usar') +numbered('Presiona el botón azul "Conectar a esta DB"') +numbered('Espera unos segundos. Cuando termina, te aparece el mensaje "OK. Cache reseteado."') + +tip('Cómo saber a qué base estás conectado', + 'En cada pestaña, cada vez que ejecutas una operación, la primera línea del resultado dice ' + '"(DB actual: NOMBRE)". También está siempre visible en la barra azul de arriba.') + +nota('Al cambiar de base', + 'La aplicación borra todos los cálculos anteriores. Si ya habías hecho un pronóstico, ' + 'tendrás que volver a hacerlo en la nueva base.') + + +# ====== 6. DESCARGAS ====== +h1('6. Pestaña 2: Descargas') +parr('Esta es la pestaña principal. Contiene tres pasos en orden:') +bullet('Paso 8 — Pronóstico (simulación)') +bullet('Paso 9 — Generar descargas de las facturas pendientes') +bullet('Paso 10 — Complementar facturas que ya fueron cerradas pero con faltantes') + +doc.add_paragraph() +h2('Paso 8 — Pronóstico') +parr('El pronóstico es una simulación: te dice qué pasaría si intentaras descargar todas las facturas ' + 'pendientes en este momento. No escribe nada en la base; solo calcula y muestra resultados.') + +parr('¿Para qué sirve?') +bullet('Saber cuántas facturas se pueden cerrar al 100% con el inventario actual') +bullet('Detectar qué componentes están escasos antes de intentar la descarga real') +bullet('Es obligatorio correrlo antes del Paso 9 (porque el paso 9 usa lo que calculó el 8)') + +parr('Cómo correrlo:') +numbered('Presiona el botón "Calcular pronóstico (paso 8)"') +numbered('Espera mientras la barra de progreso avanza (procesa una factura por cada paso)') +numbered('Al terminar verás un resumen como este:') +code_block('Tiempo: 5.2s | Filas: 2,165 | Facturas: 235\n' + ' 100% cobertura: 56\n' + ' parcial: 179') +parr('Esto significa: 56 facturas se pueden cerrar completamente, las otras 179 quedarían con algún faltante.') + +doc.add_paragraph() +h2('Paso 9 — Generar descargas de las facturas pendientes') +parr('Aquí es donde realmente se generan los movimientos. Toma las facturas que están pendientes y, ' + 'usando el pronóstico que calculó el Paso 8, crea los registros de descarga en SCAII.') + +h3('Las dos formas de descargar: NATURAL vs DIRIGIDA') +table_simple( + ['Modo', 'Qué hace', 'Cuándo usarla'], + [ + ['NATURAL', 'Solo procesa facturas donde TODOS los componentes del BOM se pueden cubrir al 100%. ' + 'Si una factura tiene aunque sea un solo componente con faltante, NO la toca.', + 'Cuando quieres descargas "limpias" sin faltantes. Es lo más conservador.'], + ['DIRIGIDA', 'Procesa TODAS las facturas pendientes. Descarga lo que sí se puede, y deja sin ' + 'descargar los componentes con faltante. La factura igual queda cerrada.', + 'Cuando necesitas cerrar facturas aunque queden incompletas (por ejemplo, ' + 'para reportes o cierre de mes).'], + ], + col_widths_cm=[2.5, 8, 5] +) + +h3('Filtro por fechas') +parr('Si quieres procesar solo un rango de facturas (por ejemplo, las de enero a marzo), ' + 'usa las cajas "Desde" y "Hasta". El formato debe ser:') +code_block('2025-01-01') +parr('Si las dejas vacías, se procesan todas las pendientes.') + +h3('Modo prueba (DRY_RUN)') +parr('La casilla "DRY_RUN (simular)" es tu red de seguridad. Cuando está marcada (palomita), ' + 'la herramienta calcula y te dice qué iba a hacer, PERO NO ESCRIBE NADA en la base. ' + 'Es como ver una previsualización antes de imprimir.') + +tip('Buena práctica', + 'SIEMPRE corre primero con DRY_RUN marcado. Revisa los números. Si todo cuadra, desmarca ' + 'la casilla y vuelve a ejecutar para hacerlo de verdad.') + +parr('Pasos completos:') +numbered('Verifica que ya corriste el Paso 8 (Pronóstico)') +numbered('Elige el modo: NATURAL o DIRIGIDA') +numbered('Deja "DRY_RUN" marcado para la primera corrida') +numbered('Opcionalmente, pon un rango de fechas') +numbered('Presiona "Ejecutar paso 9 (NA->AC)"') +numbered('Revisa el resultado: cuántas facturas se procesaron, cuántas filas se insertarían') +numbered('Si todo está bien, desmarca DRY_RUN y vuelve a presionar el botón') +numbered('Ahora sí se generan las descargas y las facturas pasan a estado cerrado (AC)') + +doc.add_paragraph() +h2('Paso 10 — Complementar descargas ya hechas') +parr('A veces cerraste una factura en modo DIRIGIDA y quedó con faltantes. Tiempo después llega nuevo ' + 'inventario que sí podría cubrir esos faltantes. Aquí es donde Paso 10 entra: agrega descargas ' + 'adicionales a facturas que ya están cerradas (AC).') + +parr('Cómo funciona:') +bullet('Toma todas las facturas en estado AC (o las que filtres por fecha)') +bullet('Calcula cuánto se descargó vs cuánto pedía el BOM') +bullet('Para los faltantes, busca si ahora sí hay inventario disponible') +bullet('Genera las descargas adicionales') + +nota('La factura NO cambia de estado', + 'Las facturas siguen siendo AC después del Paso 10. Solo se agregan más movimientos de descarga.') + +h3('Procesar facturas específicas') +parr('Si solo quieres trabajar con ciertas facturas, escribe sus números separados por comas en la caja "Facturas":') +code_block('DESP19-001,DESP19-002,DESP19-005') +parr('Si la dejas vacía, se procesan todas las AC en el rango de fechas.') + +doc.add_page_break() + + +# ====== 7. ANALISIS ====== +h1('7. Pestaña 3: Análisis de Saldos') +parr('Esta pestaña no genera movimientos. Es solo para ver el estado actual del inventario y compararlo ' + 'contra lo que se ha importado y exportado.') + +h3('Cómo usarla') +numbered('Presiona el botón "Cargar y analizar SSaldoTem"') +numbered('Espera mientras la barra de progreso avanza') +numbered('Aparecen tres secciones con tablas y gráficas') + +h2('Sección 1: Saldo disponible por año (de entrada del lote)') +parr('Te muestra cuánto saldo te queda agrupado por el año en que entró ese lote a inventario. ' + 'Por ejemplo, "del lote que entró en 2023, te quedan tantas piezas disponibles".') +parr('Te ayuda a:') +bullet('Detectar inventario muy viejo que no se ha consumido') +bullet('Ver cómo está distribuido tu saldo en el tiempo') + +h2('Sección 2: Comparativo IMPO vs EXPO por año') +parr('Muestra para cada año cuántas partidas importaste y exportaste, junto con sus pesos y valores. ' + 'Es útil para tener una vista general de la actividad del cliente.') + +h2('Sección 3: Tabla de pesos por año (IMPO / EXPO / CONSUMIDO / DESCARGAS)') +parr('Esta es la más rica para entender el flujo de inventario. Por cada año te muestra cuatro pesos diferentes:') + +table_simple( + ['Columna', 'Qué significa'], + [ + ['peso_impo', 'Cuántos KGs entraron al inventario ese año (por importaciones)'], + ['peso_expo', 'Cuántos KGs salieron en facturas de exportación de ese año'], + ['peso_consumido','De los lotes que entraron ese año, cuánto ya se consumió (acumulado a hoy)'], + ['peso_descargas','Cuántos KGs aparecen registrados como descargas en las facturas de exportación de ese año'], + ], + col_widths_cm=[3.5, 12] +) + +parr('¿Cómo interpretar la tabla?') +bullet('peso_expo vs peso_descargas (mismo año): si son iguales, todas las facturas exportadas en ese año ' + 'ya tienen su descarga generada. Si peso_expo es mayor, significa que hay facturas pendientes (en NA).') +bullet('peso_impo vs peso_consumido (mismo año): te dice qué proporción del inventario que entró ya se utilizó. ' + 'Un peso_consumido bajo indica inventario viejo sin movimiento.') + +tip('Caso típico', + 'Si ves que en un año peso_expo = 50,000 KG pero peso_descargas = 30,000 KG, hay ' + '20,000 KG de facturas exportadas que aún no tienen su descarga registrada. Esas ' + 'son las que el Paso 9 va a procesar.') + +h2('Sección 4: Tabla de cantidades por año (totales)') +parr('Misma idea que la tabla de pesos, pero ahora midiendo cantidades (piezas, litros, kilos, etc.) ' + 'en lugar de peso. Te muestra cuántas partidas y cuánta cantidad se importó y exportó cada año.') + +table_simple( + ['Columna', 'Qué significa'], + [ + ['partidas_impo', 'Número de partidas de importación que entraron ese año'], + ['cantidad_impo', 'Suma total de cantidades importadas (todas las UMs mezcladas)'], + ['partidas_expo', 'Número de partidas de exportación de ese año'], + ['cantidad_expo', 'Suma total de cantidades exportadas (todas las UMs mezcladas)'], + ['diferencia', 'cantidad_expo − cantidad_impo (positivo: se exportó más de lo que entró ese año)'], + ], + col_widths_cm=[3.5, 12] +) + +nota('Cuidado al interpretar', + 'Esta tabla SUMA cantidades sin importar la unidad de medida. Si tienes una partida de ' + '100 PZA y otra de 50 KG, la tabla suma "150" como si fueran lo mismo. Está bien para ' + 'tener un vistazo general, pero si tu inventario maneja UMs mezcladas y necesitas ' + 'precisión, usa la tabla siguiente que separa por unidad.') + +h2('Sección 5: Tabla de cantidades por año + Unidad de Medida') +parr('Versión más precisa de la tabla anterior: una fila por cada combinación de año y unidad ' + 'de medida. Aquí ya no se mezclan UMs entre sí.') + +table_simple( + ['Columna', 'Qué significa'], + [ + ['ANIO', 'Año'], + ['UM', 'Unidad de medida (KGS, PZA, LBS, etc.)'], + ['partidas_impo', 'Partidas IMPO con esa UM ese año'], + ['cantidad_impo', 'Suma de cantidades importadas en esa UM'], + ['partidas_expo', 'Partidas EXPO con esa UM ese año'], + ['cantidad_expo', 'Suma de cantidades exportadas en esa UM'], + ['diferencia', 'cantidad_expo − cantidad_impo (positivo: se exportó más de lo importado en esa UM ese año)'], + ], + col_widths_cm=[3.5, 12] +) + +parr('La UM se normaliza automáticamente: "Kgs", "KGS" y " kgs " se consideran la misma. ' + 'Esto evita filas duplicadas por diferencias de mayúsculas o espacios.') + +tip('Cómo leerla', + 'Por ejemplo: si en 2024, UM = KGS te muestra cantidad_impo = 12,000 y cantidad_expo = 9,500, ' + 'significa que ese año entraron 12,000 KG de productos y se exportaron 9,500 KG. Los 2,500 KG ' + 'restantes están en saldo o se exportarán en años siguientes.') + +parr('La gráfica que acompaña esta tabla muestra las **4 UMs más usadas** (las que tienen mayor ' + 'volumen acumulado). Cada UM tiene su propia gráfica de barras IMPO vs EXPO por año. Las UMs ' + 'menos frecuentes solo aparecen en la tabla, no en la gráfica.') + +h3('Exportar a Excel') +parr('El botón verde "Exportar Excel" genera un archivo con TODO el análisis. El nombre del archivo ' + 'incluye la fecha y hora de generación. Contiene las siguientes hojas:') + +table_simple( + ['Hoja', 'Contenido'], + [ + ['SSaldoTem_Detalle', 'Cada lote de inventario con todos sus campos calculados'], + ['Por_Anio', 'Saldo disponible agrupado por año de entrada'], + ['IMPO_x_Anio', 'Partidas de importación resumidas por año'], + ['EXPO_x_Anio', 'Partidas de exportación resumidas por año'], + ['IMPO_vs_EXPO', 'Comparativo completo IMPO vs EXPO con peso y valor ME'], + ['Pesos_x_Anio', 'Pesos por año: IMPO / EXPO / CONSUMIDO / DESCARGAS'], + ['Cantidades_x_Anio', 'Cantidades totales por año (mezclando UMs)'], + ['Cantidades_Anio_UM', 'Cantidades por año separadas por unidad de medida'], + ], + col_widths_cm=[5, 11] +) + +doc.add_page_break() + + +# ====== 8. SUSTITUTOS NLP ====== +h1('8. Pestaña 4: Sustitutos NLP (sugerencias automáticas)') +parr('Esta pestaña te ayuda a llenar el catálogo de sustitutos de partes (SPartesSustitutos). En lugar ' + 'de capturarlos uno por uno, la aplicación los sugiere automáticamente comparando descripciones.') + +h3('Cómo funciona (sin entrar en detalles técnicos)') +parr('La aplicación lee la descripción de cada componente del BOM y la compara con todas las descripciones ' + 'del catálogo de partes. Para las parejas que se parecen lo suficiente, las sugiere como posibles sustitutos.') +parr('Por ejemplo, si una parte se describe como "TORNILLO HEX M6 ACERO" y existe otra como ' + '"TORNILLO HEXAGONAL M6 INOX", la aplicación detecta la similitud y las sugiere como sustitutos.') + +h3('Controles') +table_simple( + ['Control', 'Qué hace'], + [ + ['Min similitud', 'Qué tan parecidas deben ser las descripciones (50% a 100%). Recomendado: 80%.'], + ['Top-N', 'Cuántos sustitutos máximo guardar por componente. Recomendado: 3.'], + ['DRY_RUN', 'Modo prueba — solo muestra qué sugeriría, no guarda nada.'], + ], + col_widths_cm=[3.5, 12] +) + +parr('Pasos:') +numbered('Ajusta el slider de similitud (más alto = más estricto)') +numbered('Ajusta el slider de Top-N') +numbered('Marca DRY_RUN para ver primero qué sugiere') +numbered('Presiona "Generar sustitutos NLP"') +numbered('Revisa la muestra de los primeros 10 que sugeriría') +numbered('Si las sugerencias se ven bien, desmarca DRY_RUN y vuelve a ejecutar') + +nota('Solo se agregan nuevos', + 'La aplicación nunca duplica. Si un par (componente → sustituto) ya existe en SPartesSustitutos, ' + 'lo omite. Solo agrega los pares que no estaban.') + +doc.add_page_break() + + +# ====== 9. DESCARGA % KGS ====== +h1('9. Pestaña 5: Descarga por % de KGS (Paso 12)') +parr('Esta es una forma alternativa de hacer descargas. Es útil cuando tu BOM en lugar de decir "cantidad de piezas", ' + 'dice "porcentaje del peso". Por ejemplo:') + +table_simple( + ['Componente', 'CANTIDAD en BOM', 'Interpretación'], + [ + ['Componente A', '60', '60% del peso total de la factura'], + ['Componente B', '30', '30% del peso total'], + ['Componente C', '10', '10% del peso total'], + ], + col_widths_cm=[5, 5, 6] +) + +parr('Si la factura tiene PESONETO = 500 KG, este modo descarga:') +bullet('300 KG del Componente A (60% × 500)') +bullet('150 KG del Componente B') +bullet('50 KG del Componente C') + +parr('Funciona exactamente igual que el Paso 9, con los mismos controles (NATURAL/DIRIGIDA, DRY_RUN, ' + 'fechas, complementaria). La única diferencia es la fórmula de cálculo.') + +nota('¿Cuándo usar Paso 12 en lugar de Paso 9?', + 'Si tu BOM está expresado en porcentajes de peso (más común en industrias como química, ' + 'cartón, plásticos), usa Paso 12. Si tu BOM está en cantidades unitarias por pieza ' + '(más común en ensamblaje), usa Paso 9. Pregúntale al área de operaciones cuál aplica.') + +doc.add_page_break() + + +# ====== 10. BARRAS DE PROGRESO ====== +h1('10. Barras de progreso — qué significan') +parr('Cada operación pesada tiene una barra de progreso debajo del botón. Te ayuda a saber:') +bullet('Que la operación está activa (no se trabó)') +bullet('Cuánto falta para terminar') +bullet('En qué etapa va') + +parr('Colores de la barra:') +table_simple( + ['Color', 'Significa'], + [ + ['Azul', 'En proceso — la operación sigue corriendo'], + ['Verde', 'Terminó correctamente'], + ['Rojo', 'Hubo un error. Revisa el cuadro de texto debajo del botón.'], + ], + col_widths_cm=[3, 12] +) + +tip('Si la barra no avanza', + 'Espera un momento. Al cargar los datos por primera vez (especialmente el pronóstico) la ' + 'aplicación primero consulta varias tablas grandes antes de empezar a procesar. Es normal ' + 'que tarde unos segundos antes de que la barra se mueva.') + + +# ====== 11. DRY_RUN ====== +h1('11. Modo prueba (DRY_RUN) — la herramienta más importante') +parr('DRY_RUN es una casilla que aparece en casi todos los pasos. Su propósito es uno solo:') +parr('Cuando está marcada (✓), la aplicación calcula lo que haría pero NO escribe nada en la base de datos. ' + 'Te da la información para que tú decidas si proceder.', bold=True) + +parr('Beneficios de usarla siempre:') +bullet('Ves cuántas facturas se procesarían') +bullet('Ves cuántos movimientos se generarían') +bullet('Detectas anomalías ANTES de hacer cambios irreversibles') +bullet('Si hay un error de configuración o un dato raro, te enteras sin haber roto nada') + +tip('Regla de oro', + 'Toda corrida real debe ir precedida de una corrida en modo prueba. Si los números no ' + 'cuadran, NO desmarques DRY_RUN — primero entiende qué pasa.') + + +# ====== 12. SI ALGO SALE MAL ====== +h1('12. Si algo sale mal') + +h3('La aplicación no abre / la consola se cierra') +bullet('Verifica que Python esté instalado y configurado en PATH') +bullet('Borra la carpeta .venv y vuelve a darle doble clic al run_dev.bat (se reinstala automáticamente)') +bullet('Si el problema persiste, contacta al área técnica') + +h3('Sale "ERROR de conexión" en la barra azul') +bullet('Las credenciales en el archivo .env son incorrectas') +bullet('El servidor de base de datos está caído') +bullet('La base seleccionada no existe o no tienes permisos') + +h3('Un botón devuelve un error largo (traceback rojo)') +bullet('Toma captura de pantalla del error completo') +bullet('Envíala al área técnica indicando qué botón presionaste y qué configuración tenías') + +h3('Las descargas se duplicaron') +parr('Si por accidente corriste Paso 9 dos veces (por ejemplo, una en NATURAL y otra en DIRIGIDA), ' + 'pueden aparecer descargas duplicadas. La aplicación tiene una protección automática que evita ' + 'esto, pero si pasó por algún caso edge, contacta al área técnica — hay un proceso de limpieza.') + +h3('El pronóstico tarda mucho') +parr('Es normal que tarde 30 segundos a 2 minutos según el volumen de datos. La barra de progreso ' + 'te indica avance. Si pasados varios minutos no se mueve, presiona Ctrl+C en la consola negra ' + 'y vuelve a abrir la aplicación.') + +doc.add_page_break() + + +# ====== 13. GLOSARIO ====== +h1('13. Glosario') +table_simple( + ['Término', 'Significado'], + [ + ['SCAII', 'Sistema de Control Aduanero Inteligente — la base de datos de SQL Server donde vive todo'], + ['Factura de exportación (EXPO)', 'Documento que registra una venta al extranjero. Tiene partidas (líneas)'], + ['Factura de importación (IMPO)', 'Documento que registra una compra del extranjero. Genera saldos de inventario'], + ['Partida', 'Una línea dentro de una factura. Cada partida tiene un producto y una cantidad'], + ['BOM', 'Bill of Materials. Receta que indica qué componentes se necesitan para producir cada producto'], + ['PT', 'Producto Terminado — el producto final que se exporta'], + ['MP', 'Materia Prima — los componentes que se consumen para fabricar el PT'], + ['Saldo', 'Cantidad disponible de un componente en inventario'], + ['Descarga', 'Movimiento que reduce un saldo. Cada exportación genera descargas según el BOM'], + ['PEPS / FIFO', 'Primero que Entra, Primero que Sale. Política para decidir qué lote consumir primero'], + ['NA', 'Estado de factura pendiente de descarga'], + ['AC', 'Estado de factura ya cerrada (con sus descargas generadas)'], + ['NATURAL', 'Modo de descarga que solo procesa facturas con cobertura completa'], + ['DIRIGIDA', 'Modo de descarga que acepta cubrir parcialmente'], + ['Sustituto', 'Componente alterno que puede reemplazar a otro cuando no hay saldo del original'], + ['DRY_RUN', 'Modo prueba — calcula sin escribir en la base'], + ['Pronóstico', 'Simulación de las descargas que se podrían hacer en este momento'], + ['NLP', 'Tecnología que permite comparar descripciones de texto para encontrar similitudes'], + ], + col_widths_cm=[4, 12] +) + + +# ====== GUARDAR ====== +import os +out_path = 'Manual_GENPACT_V2.docx' +try: + doc.save(out_path) +except PermissionError: + out_path = f'Manual_GENPACT_V2_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.docx' + doc.save(out_path) + print('(El anterior estaba abierto en Word. Generado con nombre alternativo.)') +print(f'Manual generado: {os.path.abspath(out_path)}') diff --git a/app/generar_manual_ctm.py b/app/generar_manual_ctm.py new file mode 100644 index 0000000..c474261 --- /dev/null +++ b/app/generar_manual_ctm.py @@ -0,0 +1,497 @@ +""" +Genera el manual del módulo CTM (Reasignación de descargas de Cambio de Régimen). +Lenguaje accesible para usuario final + sección técnica al final. +""" +from docx import Document +from docx.shared import Pt, RGBColor, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() + +for sec in doc.sections: + sec.top_margin = Cm(2.5) + sec.bottom_margin = Cm(2.5) + sec.left_margin = Cm(2.2) + sec.right_margin = Cm(2.2) + +style = doc.styles['Normal'] +style.font.name = 'Calibri' +style.font.size = Pt(11) + + +def h1(text): + p = doc.add_heading(text, level=1) + for r in p.runs: r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +def h2(text): + p = doc.add_heading(text, level=2) + for r in p.runs: r.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5) + +def h3(text): + p = doc.add_heading(text, level=3) + for r in p.runs: r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +def parr(text, bold=False): + p = doc.add_paragraph() + r = p.add_run(text); r.bold = bold + return p + +def code_block(text): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + r = p.add_run(text); r.font.name = 'Consolas'; r.font.size = Pt(9.5) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'),'F0F0F0') + pPr.append(shd) + return p + +def bullet(text): + p = doc.add_paragraph(text, style='List Bullet') + p.paragraph_format.left_indent = Cm(0.6) + return p + +def numbered(text): + p = doc.add_paragraph(text, style='List Number') + p.paragraph_format.left_indent = Cm(0.6) + return p + +def caja(titulo, text, color_fill='FFF8E1', color_text=RGBColor(0xE6, 0x5C, 0x00)): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'), color_fill) + pPr.append(shd) + r = p.add_run(titulo + ': '); r.bold = True; r.font.color.rgb = color_text + p.add_run(text) + +def nota(titulo, text): caja(titulo, text, 'FFF8E1', RGBColor(0xE6, 0x5C, 0x00)) +def tip(titulo, text): caja(titulo, text, 'E8F5E9', RGBColor(0x2E, 0x7D, 0x32)) +def alerta(titulo, text): caja(titulo, text, 'FFEBEE', RGBColor(0xC6, 0x28, 0x28)) + +def table_simple(headers, rows, col_widths_cm=None): + t = doc.add_table(rows=1+len(rows), cols=len(headers)) + t.style = 'Light Grid Accent 1' + hdr = t.rows[0].cells + for i, h in enumerate(headers): + hdr[i].text = h + for p in hdr[i].paragraphs: + for r in p.runs: + r.bold = True; r.font.color.rgb = RGBColor(0xFF,0xFF,0xFF) + tcPr = hdr[i]._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:fill'),'1565C0') + tcPr.append(shd) + for r_idx, row in enumerate(rows, start=1): + cells = t.rows[r_idx].cells + for c_idx, val in enumerate(row): + cells[c_idx].text = str(val) + if col_widths_cm: + for i, w in enumerate(col_widths_cm): + for row in t.rows: + row.cells[i].width = Cm(w) + return t + + +# ====== PORTADA ====== +title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = title.add_run('\n\n\nMódulo CTM') +r.bold = True; r.font.size = Pt(38); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = sub.add_run('Reasignación de Descargas de\nCambio de Régimen') +r.font.size = Pt(18); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = p.add_run('\n\nSistema de Utilerías 2.0 SCAII\nManual de Uso') +r.font.size = Pt(14) +r = p.add_run(f'\n\nVersión {datetime.date.today().isoformat()}') +r.font.size = Pt(11); r.italic = True + +doc.add_page_break() + + +# ====== CONTENIDO ====== +h1('Contenido') +for it in [ + '1. ¿Qué es el módulo CTM?', + '2. ¿Por qué se necesita esta herramienta?', + '3. Antes de empezar: lo que la herramienta requiere', + '4. Abrir la pestaña CTM', + '5. Componentes de la pantalla', + '6. Paso a paso: cómo correr un análisis', + '7. Interpretar los resultados', + ' • Tabla Resumen', + ' • Tabla Plan Detalle', + ' • Estatus de cada fila', + '8. Exportar a Excel', + '9. Excel del cliente: formato esperado', + '10. Casos típicos', + '11. Limitaciones actuales (Paso A vs Paso B)', + '12. Lo que viene en el Paso B (ejecución real)', + '13. Preguntas frecuentes', + '14. Glosario CTM', + '15. Apéndice técnico: cómo funciona el matching por dentro', +]: + doc.add_paragraph(it) + +doc.add_page_break() + + +# ====== 1 ====== +h1('1. ¿Qué es el módulo CTM?') +parr('CTM es un tipo especial de factura en SCAII (TIPOFACTURA = \'CTM\') que se usa para registrar ' + 'cambios de régimen aduanal: mover mercancía temporal a definitiva, transferirla a otra IMMEX, ' + 'regresarla al extranjero, o canalizarla a operaciones especiales.') +parr('A diferencia de una factura de exportación normal, donde el inventario se descarga de los lotes ' + 'IMPO directamente, una factura CTM normalmente debería descargar inventario que ya fue ' + '"liberado" en un trámite previo de Cambio de Régimen (módulo CR de SCAII).') + +parr('Para que SCAII pueda hacer ese vínculo correctamente, debe existir un registro en SDescargaT ' + 'que apunte a la factura CTM en lugar de a la factura del Cambio de Régimen. En la práctica, ' + 'muchas facturas CTM quedan sin descarga propia: el inventario "vive" en las descargas del CR ' + 'pero no se reasigna a la CTM. Eso es justamente lo que esta herramienta resuelve.') + + +# ====== 2 ====== +h1('2. ¿Por qué se necesita esta herramienta?') +parr('Sin la herramienta, el área de reconstrucciones debe revisar manualmente:') +bullet('Cada partida de cada factura CTM') +bullet('Explotar el BOM si la partida es un Producto Terminado (PT) o Subensamble (SE)') +bullet('Buscar en las miles de descargas registradas en facturas de Cambio de Régimen cuáles componentes coinciden') +bullet('Validar sustitutos comunes que sí pueden cubrir el faltante') +bullet('Respetar fechas (PEPS) para no asignar lotes "del futuro" a operaciones "del pasado"') +bullet('Distribuir saldos cuando una misma factura CTM toma inventario de varios pedimentos F4') +bullet('No generar sobre-descargas ni duplicados') + +parr('La herramienta automatiza todo eso. Te entrega un plan de reasignación listo para revisar, ' + 'y en una segunda fase, lo aplica a la base de datos con todas las validaciones.') + + +# ====== 3 ====== +h1('3. Antes de empezar: lo que la herramienta requiere') + +h3('En la base de datos') +bullet('Las facturas CTM deben estar registradas en SCAII con TIPOFACTURA = \'CTM\'') +bullet('Las descargas que se quieren reasignar deben existir en facturas marcadas como ESCAMBIOREGIMEN = \'S\'') +bullet('El BOM (SMatBOM) debe estar capturado para los productos PT o SE que aparezcan en las CTM') +bullet('Si se usarán sustitutos, el catálogo SPartesSustitutos debe tenerlos definidos') + +h3('Opcional pero recomendado: Excel del cliente') +parr('El cliente normalmente entrega un Excel con el mapeo de qué factura CTM corresponde a qué ' + 'pedimento F4 de cambio de régimen. Cargar este Excel permite que la herramienta priorice ' + 'descargas del pedimento "correcto" antes de buscar en el pool general.') + +caja('Sin el Excel también funciona', + 'la herramienta usa solo la regla PEPS (más antigua primero) para escoger qué descarga ' + 'tomar. El Excel solo agrega prioridad a las que coinciden con el mapeo del cliente.', + 'E3F2FD', RGBColor(0x15, 0x65, 0xC0)) + + +# ====== 4 ====== +h1('4. Abrir la pestaña CTM') +parr('Inicia la aplicación como siempre (doble clic en run_dev.bat o el ejecutable). Cuando se abre ' + 'el navegador, verás las pestañas en la parte superior:') +bullet('Conexión') +bullet('Descargas') +bullet('Análisis Saldos') +bullet('Sustitutos NLP') +bullet('Descarga % KGS') +bullet('CTM ← esta es la nueva') + +parr('Verifica primero en la pestaña "Conexión" que estés trabajando sobre la base de datos correcta. ' + 'Después cambia a la pestaña "CTM".') + + +# ====== 5 ====== +h1('5. Componentes de la pantalla') +parr('La pestaña CTM tiene tres bloques de arriba abajo:') + +h3('Bloque 1: Excel del cliente') +parr('Un botón "Excel cliente" que abre un selector de archivos. Sirve para subir el archivo ' + 'proporcionado por el cliente con el mapeo Factura CTM ↔ Pedimento.') +parr('Al lado, una casilla "Usar mapping para priorizar". Si la desmarcas, la herramienta ignora el ' + 'Excel aunque lo hayas subido.') + +h3('Bloque 2: Botones de acción') +bullet('"Analizar CTM (sin escribir)" — corre el matching y genera un plan de reasignación. NO modifica la base.') +bullet('"Exportar Excel" — toma el último análisis y lo guarda como archivo Excel con tres hojas.') + +h3('Bloque 3: Resultado') +parr('Una barra de progreso indica el avance. Debajo, dos áreas:') +bullet('Área de log: mensajes de qué está haciendo') +bullet('Área de tablas: resumen y plan detalle del análisis') + +doc.add_page_break() + + +# ====== 6 ====== +h1('6. Paso a paso: cómo correr un análisis') +numbered('Abre la pestaña "CTM"') +numbered('(Opcional) Click en el botón "Excel cliente" y selecciona el archivo .xlsx que te dio el cliente') +numbered('Si subiste el Excel, deja marcada la casilla "Usar mapping para priorizar". Si no quieres usarlo, desmárcala.') +numbered('Click en "Analizar CTM (sin escribir)"') +numbered('Espera mientras la barra de progreso avanza. El análisis típico tarda entre 30 segundos y 2 minutos según el volumen.') +numbered('Cuando termina, revisa el log y las dos tablas que aparecen abajo') +numbered('Si quieres llevarte el resultado a Excel, click en "Exportar Excel"') + +tip('Buena práctica', + 'Antes de ejecutar el Paso B (que sí escribe en la base), corre el análisis varias veces ' + 'con y sin Excel cargado para entender la diferencia. Si el cliente entrega el mapeo, ' + 'cargarlo te dará asignaciones que respetan el origen físico de la mercancía.') + + +# ====== 7 ====== +h1('7. Interpretar los resultados') + +h2('Tabla "Resumen"') +parr('Una fila por cada combinación (Factura CTM, Línea, Componente). Te dice qué tan bien quedó ' + 'cubierto cada requerimiento del BOM.') +table_simple( + ['Columna', 'Qué significa'], + [ + ['FACTURA_CTM', 'Número de factura CTM destino'], + ['LINEA', 'Número de línea/partida dentro de la factura CTM'], + ['COMPONENTE', 'Componente MP que se necesita (puede ser el mismo NUMPARTE o uno del BOM si era PT/SE)'], + ['UNIMED', 'Unidad de medida del requerimiento'], + ['CANT_REQUERIDA', 'Cuánto se necesitaba descargar para cubrir esa línea'], + ['cubierto', 'Cuánto se logró cubrir con descargas existentes del CR'], + ['faltante', 'Cuánto quedó sin cubrir (no hubo suficiente saldo en el pool de CR)'], + ['filas_cr_usadas','Número de descargas CR distintas que se tomaron'], + ['pct_cobertura', 'Porcentaje cubierto = cubierto / requerido × 100'], + ], + col_widths_cm=[3.5, 12] +) + +h2('Tabla "Plan Detalle"') +parr('Es la lista granular: una fila por cada descarga CR que se va a tomar (o por cada faltante). ' + 'En pantalla solo se muestran las primeras 50 filas; el detalle completo está en el Excel.') +table_simple( + ['Columna', 'Qué significa'], + [ + ['FACTURA_CTM', 'CTM destino'], + ['LINEA', 'Línea de la factura CTM'], + ['PT_O_MP', 'NUMPARTE original de la partida CTM (puede ser PT o MP)'], + ['TIPOMAT', 'PT / SE / MP'], + ['COMPONENTE', 'Componente que se busca cubrir'], + ['CANT_REQUERIDA', 'Cantidad total requerida del componente'], + ['STATUS', 'ASIGNADO / FALTANTE / SIN_REQUERIMIENTO'], + ['CONSECUTIVO_CR', 'Si ASIGNADO: el ID de la descarga CR que se toma'], + ['FACTURA_CR', 'Factura del Cambio de Régimen origen'], + ['NUMPARTE_CR', 'NUMPARTE registrado en la descarga CR'], + ['PARTEORIGINAL_CR', 'PARTE ORIGINAL del BOM cuando la descarga CR es de un sustituto'], + ['CANT_DISPONIBLE_CR', 'Cuánto tenía esa descarga CR disponible'], + ['CANT_A_TOMAR', 'Cuánto se va a tomar de esa descarga CR'], + ['FECHA_DESC_CR', 'Fecha (ISO) en que se hizo la descarga CR original'], + ['PEDIMENTOIMPO_CR', 'Pedimento de importación de la descarga CR'], + ['PRIORIDAD_MAPPING', 'True si esa descarga venía del pedimento mapeado en el Excel cliente'], + ], + col_widths_cm=[4, 11.5] +) + +h2('Estatus de cada fila') +table_simple( + ['Estatus', 'Significa', 'Acción típica'], + [ + ['ASIGNADO', 'Se encontró una descarga CR que cubre (total o parcial) el requerimiento', + 'En Paso B, se reasignará esa descarga a la CTM'], + ['FALTANTE', 'No hubo suficiente saldo en el pool CR para cubrir el requerimiento', + 'Revisar manualmente: tal vez falta un sustituto en SPartesSustitutos o la descarga CR no existe aún'], + ['SIN_REQUERIMIENTO', 'La partida CTM no genera consumo (sin BOM o cantidad = 0)', + 'Generalmente se ignora; verificar que el BOM esté capturado si era PT/SE'], + ], + col_widths_cm=[3.5, 7, 5] +) + +doc.add_page_break() + + +# ====== 8 ====== +h1('8. Exportar a Excel') +parr('El botón verde "Exportar Excel" genera un archivo llamado:') +code_block('analisis_ctm_AAAAMMDD_HHMMSS.xlsx') +parr('Con tres hojas:') +table_simple( + ['Hoja', 'Contenido'], + [ + ['Resumen', 'Una fila por (Factura CTM, Línea, Componente) con cubierto/faltante/% cobertura'], + ['Plan_Detalle', 'Cada fila individual del plan, incluyendo cada descarga CR que se va a tomar'], + ['Faltantes', 'Filtrado: solo las filas con STATUS = FALTANTE (las que no se pudieron cubrir)'], + ], + col_widths_cm=[4, 12] +) + + +# ====== 9 ====== +h1('9. Excel del cliente: formato esperado') +parr('El Excel del cliente debe tener al menos estas dos columnas en la primera hoja:') +table_simple( + ['Columna', 'Ejemplo'], + [ + ['Facturas CTM', 'AAU112023RFR0481, NIS112023RFR0035'], + ['PEDIMENTO COMPLETO', '75-3076-4021492'], + ], + col_widths_cm=[5, 11] +) + +parr('Notas:') +bullet('La celda "Facturas CTM" puede traer **varias facturas separadas por coma**. La herramienta las separa automáticamente y crea una fila por cada combinación CTM-Pedimento.') +bullet('Los nombres de las columnas pueden tener mayúsculas o minúsculas. La herramienta busca cualquier columna que contenga "FACTURA" y "CTM" en su título, y cualquiera que contenga "PEDIMENTO" y "COMPLETO".') +bullet('Otras columnas (PATENTE, ADUANA, PEDIMENTO, Operación, Clave de pedimento) se ignoran, pero pueden estar presentes sin causar problemas.') + +nota('Una misma CTM puede tener varios pedimentos', + 'Si en el Excel hay varias filas con la misma CTM pero distinto pedimento, la herramienta ' + 'reconoce a TODOS esos pedimentos como prioritarios para esa CTM.') + + +# ====== 10 ====== +h1('10. Casos típicos') + +h3('Caso 1: CTM con cobertura 100% por mapeo del cliente') +parr('Cargas el Excel cliente, ejecutas el análisis y todas las filas quedan ASIGNADAS con ' + '"PRIORIDAD_MAPPING = True". Esto es lo ideal: cada descarga viene exactamente del pedimento ' + 'que el cliente indicó.') + +h3('Caso 2: CTM cubierta pero por pool genérico (sin mapping)') +parr('No cargas el Excel, o lo cargas pero la CTM no estaba en él. La herramienta igual encuentra ' + 'descargas CR que coinciden por NUMPARTE y cantidad, pero ordenadas solo por PEPS. La columna ' + '"PRIORIDAD_MAPPING" sale en False.') +tip('Recomendación', + 'Acepta este resultado si conoces tu inventario y sabes que cualquier pedimento del mismo MP ' + 'es intercambiable. Si necesitas trazabilidad estricta por pedimento, exige el Excel del cliente.') + +h3('Caso 3: CTM con FALTANTES') +parr('Algunas líneas no se pudieron cubrir. Posibles causas:') +bullet('El BOM del PT no está capturado (revisa con un SELECT en SMatBOM)') +bullet('La descarga CR que debería usarse aún no se ha generado en el sistema') +bullet('La cantidad consumida en el CR es menor a la que pide la CTM (over-allocation)') +bullet('Falta un sustituto en SPartesSustitutos') + +h3('Caso 4: SIN_REQUERIMIENTO') +parr('Una línea aparece con STATUS = SIN_REQUERIMIENTO. Esto puede deberse a:') +bullet('La partida es PT/SE pero no tiene BOM definido — al no haber componentes, no genera requerimiento') +bullet('La cantidad declarada en la partida es 0 (raro pero posible)') + + +# ====== 11 ====== +h1('11. Limitaciones actuales (Paso A vs Paso B)') +parr('La versión actual de la pestaña CTM implementa únicamente el **Paso A: análisis**. Es decir, ' + 'genera un plan de reasignación pero NO modifica la base de datos.') + +alerta('Importante', + 'Al terminar el análisis, las descargas CR siguen exactamente como estaban. Las facturas CTM ' + 'siguen sin descarga propia. El Excel exportado es solo para revisión.') + +parr('Esto es intencional: queremos que valides el matching contra varias facturas reales antes de ' + 'activar la escritura. El Paso B se implementará cuando confirmes que los resultados del Paso A ' + 'están correctos para tu caso.') + + +# ====== 12 ====== +h1('12. Lo que viene en el Paso B (ejecución real)') +parr('Cuando aprobemos el comportamiento del Paso A, se agregará a la misma pestaña:') +bullet('Casilla "DRY_RUN" (modo simulación, igual que en las otras pestañas)') +bullet('Selector de "MODO" (NATURAL vs DIRIGIDA)') +bullet('Botón "Ejecutar reasignación"') + +parr('Al ejecutar, por cada fila ASIGNADA del plan se hará:') +bullet('UPDATE SDescargaT SET FACTEXPO = factura_CTM WHERE CONSECUTIVO = X') +bullet('Si la descarga CR se toma parcial: dividir en dos filas (una queda en la CR con el saldo sobrante, otra apunta a la CTM con lo tomado)') +bullet('UPDATE SFacExp SET ESTATUS = \'AC\' WHERE FACTURAEXPO = factura_CTM') + +parr('Todo dentro de transacciones atómicas por factura: si una falla, no rompe a las demás.') + + +# ====== 13 ====== +h1('13. Preguntas frecuentes') + +h3('¿Puedo correr el análisis varias veces seguidas?') +parr('Sí. Cada corrida es independiente y no modifica nada. Puedes experimentar con y sin Excel, ' + 'comparar resultados, y exportar varias veces a Excel sin riesgo.') + +h3('¿Qué pasa si la factura CTM ya tiene algunas descargas asignadas?') +parr('La versión actual no descuenta lo que ya está asignado a la CTM. Si una CTM ya tiene descargas ' + 'directas, podría sobre-asignarse. Esto se cubrirá en el Paso B con una validación previa que ' + 'reste lo ya descargado.') + +h3('¿La herramienta usa los mismos sustitutos del catálogo SPartesSustitutos?') +parr('Sí. Lee SPartesSustitutos y considera como candidatos al COMPONENTE original y a todos sus ' + 'sustitutos registrados.') + +h3('¿Puedo limitar el análisis a una sola factura CTM?') +parr('Actualmente no — el análisis siempre cubre todas las facturas TIPOFACTURA=\'CTM\'. En el Paso B ' + 'se agregará un filtro por factura específica si lo necesitas.') + +h3('¿Por qué algunas descargas CR no se consideran aunque coinciden por NUMPARTE?') +parr('Si la descarga CR ya quedó "agotada" en una iteración anterior del análisis (otra factura CTM ' + 'la tomó primero por PEPS), no estará disponible. El orden de procesamiento de facturas CTM es ' + 'por FECHA del CR ascendente, no por orden de la CTM.') + + +# ====== 14 ====== +h1('14. Glosario CTM') +table_simple( + ['Término', 'Significado'], + [ + ['CTM', 'Tipo de factura en SCAII para operaciones de cambio de régimen / transferencias / regularizaciones'], + ['Cambio de Régimen', 'Módulo de SCAII donde se registran descargas iniciales que después deben reasignarse a CTM'], + ['Pedimento F4', 'Clave de pedimento aduanero asociada al cambio de régimen'], + ['Pool CR', 'Conjunto de descargas registradas en SDescargaT cuyo factor SFacExp.ESCAMBIOREGIMEN = \'S\''], + ['Reasignación', 'Acción de cambiar el FACTEXPO de una descarga de la factura CR original a la factura CTM destino'], + ['Apartado CTM', 'Campo APARTADOCTM en SDescargaT que identifica el origen (A, B, C1, C2, C3) según el tipo de operación'], + ['Explosión BOM', 'Convertir una partida de PT o SE en sus componentes MP mediante el catálogo SMatBOM'], + ['Mapping cliente', 'Excel que relaciona Factura CTM ↔ Pedimento F4 para priorizar la reasignación'], + ['PEPS', 'Primero en entrar, primero en salir. La descarga CR más antigua se asigna primero'], + ['ASIGNADO', 'Estatus del plan: la descarga CR se va a reasignar a la CTM'], + ['FALTANTE', 'Estatus del plan: no se encontró suficiente saldo en CR para cubrir el requerimiento'], + ['SIN_REQUERIMIENTO', 'Estatus del plan: la partida CTM no genera consumo (sin BOM o cantidad cero)'], + ], + col_widths_cm=[4, 12] +) + + +# ====== 15 ====== +h1('15. Apéndice técnico: cómo funciona el matching por dentro') +parr('Esta sección es para usuarios avanzados o el área de desarrollo. Describe a alto nivel el ' + 'algoritmo que ejecuta el botón "Analizar CTM".') + +h3('15.1 Carga de datos') +parr('Se ejecutan cuatro consultas iniciales:') +bullet('Facturas CTM: SELECT FACTURAEXPO ... FROM SFacExp WHERE TIPOFACTURA=\'CTM\'') +bullet('Partidas CTM con explosión BOM (PT/SE → componentes vía SMatBOM)') +bullet('Pool CR: SDescargaT JOIN SFacExp WHERE ESCAMBIOREGIMEN=\'S\' ordenado por FECHADESC') +bullet('Sustitutos: SPartesSustitutos → diccionario {NUMPARTE: [sustitutos]}') + +h3('15.2 Si hay Excel del cliente') +parr('Se parsea para construir un diccionario {FACTURA_CTM: set(pedimentos)}. Esto se usa después ' + 'para priorizar.') + +h3('15.3 Matching') +parr('Por cada partida CTM (factura, línea, componente):') +numbered('Se calcula la cantidad requerida = CANTEXPO × BOM.CANTIDAD (si era PT/SE) o CANTEXPO (si era MP directo)') +numbered('Se construye la lista de candidatos: el componente original más todos sus sustitutos') +numbered('Se filtra el pool CR por NUMPARTE o PARTEORIGINAL coincidente con algún candidato') +numbered('Si hay mapping, se marca con PRIORIDAD = True las descargas cuyo PEDIMENTOIMPO coincida con los pedimentos mapeados para esa CTM') +numbered('Se ordena: PRIORIDAD descendente → FECHADESC ascendente (PEPS) → CONSECUTIVO ascendente') +numbered('Se va tomando cantidad de cada descarga CR (modificando el saldo virtual en memoria) hasta cubrir el requerimiento o agotar candidatos') +numbered('Cada fila tomada genera un registro ASIGNADO en el plan; si queda faltante, se genera un registro FALTANTE') + +h3('15.4 Salida') +parr('Dos DataFrames en memoria:') +bullet('plan: una fila por cada descarga CR tomada o por cada faltante') +bullet('resumen: agregado por (FACTURA_CTM, LINEA, COMPONENTE) con cantidades cubiertas, faltantes y % de cobertura') + +parr('Ninguna escritura en la base de datos en esta fase.') + + +# ====== GUARDAR ====== +import os +out_path = 'Manual_CTM.docx' +try: + doc.save(out_path) +except PermissionError: + out_path = f'Manual_CTM_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.docx' + doc.save(out_path) + print('(El anterior estaba abierto en Word. Generado con nombre alternativo.)') +print(f'Manual generado: {os.path.abspath(out_path)}') diff --git a/app/generar_manual_datastage.py b/app/generar_manual_datastage.py new file mode 100644 index 0000000..635e8c1 --- /dev/null +++ b/app/generar_manual_datastage.py @@ -0,0 +1,303 @@ +"""Manual de usuario de la pestana DataStage. Lenguaje accesible, sin SQL ni +codigo. Pensado para que cualquier usuario sin conocimientos tecnicos pueda +operar la pestana.""" +from docx import Document +from docx.shared import Pt, RGBColor, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() +for sec in doc.sections: + sec.top_margin = Cm(2.5); sec.bottom_margin = Cm(2.5) + sec.left_margin = Cm(2.2); sec.right_margin = Cm(2.2) +style = doc.styles['Normal']; style.font.name = 'Calibri'; style.font.size = Pt(11) + + +def h1(t): + p = doc.add_heading(t, level=1) + for r in p.runs: r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +def h2(t): + p = doc.add_heading(t, level=2) + for r in p.runs: r.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5) + +def h3(t): + p = doc.add_heading(t, level=3) + for r in p.runs: r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +def parr(t, bold=False): + p = doc.add_paragraph(); r = p.add_run(t); r.bold = bold; return p + +def bullet(t): + p = doc.add_paragraph(t, style='List Bullet') + p.paragraph_format.left_indent = Cm(0.6); return p + +def numbered(t): + p = doc.add_paragraph(t, style='List Number') + p.paragraph_format.left_indent = Cm(0.6); return p + +def caja(titulo, text, fill, color): + p = doc.add_paragraph(); p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'), fill) + pPr.append(shd) + r = p.add_run(titulo + ': '); r.bold = True; r.font.color.rgb = color + p.add_run(text) + +def nota(t, x): caja(t, x, 'FFF8E1', RGBColor(0xE6, 0x5C, 0x00)) +def tip(t, x): caja(t, x, 'E8F5E9', RGBColor(0x2E, 0x7D, 0x32)) +def alerta(t, x): caja(t, x, 'FFEBEE', RGBColor(0xC6, 0x28, 0x28)) +def info(t, x): caja(t, x, 'E3F2FD', RGBColor(0x0D, 0x47, 0xA1)) + +def table_simple(headers, rows, col_widths=None): + t = doc.add_table(rows=1+len(rows), cols=len(headers)) + t.style = 'Light Grid Accent 1' + for i, h in enumerate(headers): + c = t.rows[0].cells[i]; c.text = h + for p in c.paragraphs: + for r in p.runs: r.bold = True; r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + tcPr = c._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:fill'),'1565C0') + tcPr.append(shd) + for ri, row in enumerate(rows, start=1): + for ci, val in enumerate(row): + t.rows[ri].cells[ci].text = str(val) + if col_widths: + for i, w in enumerate(col_widths): + for row in t.rows: row.cells[i].width = Cm(w) + return t + + +# ====== PORTADA ====== +ti = doc.add_paragraph(); ti.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = ti.add_run('\n\n\nPestana DataStage') +r.bold = True; r.font.size = Pt(38); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +su = doc.add_paragraph(); su.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = su.add_run('Subir y consultar archivos\ndel SAAI en la herramienta') +r.font.size = Pt(18); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = p.add_run('\n\nSistema de Utilerias 2.0\nGuia rapida para el usuario') +r.font.size = Pt(14) +r = p.add_run(f'\n\nVersion {datetime.date.today().isoformat()}') +r.font.size = Pt(11); r.italic = True + +doc.add_page_break() + + +# ====== CONTENIDO ====== +h1('Contenido') +for it in [ + '1. ¿Para que sirve esta pestana?', + '2. Antes de empezar', + '3. Como se ve la pantalla', + '4. Como subir tus archivos', + '5. Como ver lo que ya esta cargado', + '6. Como limpiar todo y empezar de cero', + '7. ¿Algo no funciona? Lo mas comun', + '8. Glosario rapido', +]: + parr(it).paragraph_format.left_indent = Cm(0.4) +doc.add_page_break() + + +# ====== 1 ====== +h1('1. ¿Para que sirve esta pestana?') +parr('La pestana DataStage te permite cargar a la herramienta los archivos del SAAI ' + '(los .asc que descargas cada periodo). Una vez cargados, otras pestanas de la ' + 'herramienta los usan para generar reportes, validaciones y estructuras de Excel ' + 'que entregas al cliente o subes a SCAII.') + +info('En palabras simples', + 'Aqui le das de comer a la herramienta. Si los archivos del SAAI no estan cargados, ' + 'las demas pestanas (Estructuras SCAII, Saldos Vencidos, etc.) no tienen de donde ' + 'sacar la informacion.') + + +# ====== 2 ====== +h1('2. Antes de empezar') +parr('Verifica estas tres cosas en la parte superior de la pestana:') +numbered('Que diga "Postgres conectado" en verde. Si dice "Postgres no disponible" en rojo, ' + 'avisale al area de TI o quien instalo la herramienta.') +numbered('Que tengas a la mano la ruta de la carpeta donde estan tus archivos .asc. ' + 'Por ejemplo: C:\\DATASTAGE_HONDA\\2024.') +numbered('Que los archivos sigan la nomenclatura habitual: terminan en _501.asc, _551.asc, ' + '_701.asc, _Inci.asc, etc. La herramienta los reconoce por ese final.') + +tip('Sugerencia para la primera vez', + 'Empieza cargando una sola carpeta de un mes para ver que todo funciona. Una vez que ' + 'verifiques el resultado, puedes subir varios meses o anos juntos.') + + +# ====== 3 ====== +h1('3. Como se ve la pantalla') +parr('La pestana esta dividida en tres bloques, uno debajo del otro:') +table_simple( + ['Bloque', '¿Para que sirve?'], + [ + ['Subir DataStage', 'Apuntar a tu carpeta de archivos .asc y cargarlos a la herramienta.'], + ['Estadisticas', 'Ver cuanto se cargo (totales por tipo de registro) y revisar muestras.'], + ['Zona peligrosa', 'Borrar todo lo que cargaste, si necesitas empezar de cero.'], + ], + col_widths=[4.5, 11]) + +parr('Cada bloque tiene sus propios botones y su propia caja de resultados (la zona gris ' + 'donde aparecen los mensajes despues de cada accion).') + + +# ====== 4 ====== +h1('4. Como subir tus archivos') + +h2('Paso 1 — Indica la carpeta') +parr('En el campo "Carpeta" escribe o pega la ruta donde estan tus archivos .asc. ' + 'Acepta dos formas de organizar la carpeta:') +bullet('Carpeta con subcarpetas por ano (ej. C:\\DATASTAGE_HONDA\\2024, ' + 'C:\\DATASTAGE_HONDA\\2025, etc.). La herramienta entra a cada subcarpeta de ano y ' + 'toma los .asc que esten directamente ahi.') +bullet('Carpeta plana con los .asc directos (sin subcarpetas).') + +h2('Paso 2 — Listar archivos (opcional pero recomendado)') +parr('Da clic en el boton "Listar archivos". La herramienta te muestra una tabla con cada ' + 'archivo detectado, a que tipo de registro pertenece y cuanto pesa.') + +tip('Para que sirve este paso', + 'Es como una "vista previa". Te permite verificar que la herramienta detecto todos tus ' + 'archivos antes de cargar nada. Si ves algun archivo con "(no detectado)" en la columna ' + '"tabla destino", revisa el nombre — es probable que no termine en el formato esperado.') + +h2('Paso 3 — Verifica que las tablas existan') +parr('Da clic en "Ver tablas Registro en Postgres". Debes ver una lista con muchos nombres ' + '(Registro501, Registro502, Registro551, etc.). Si la lista esta vacia, falta que TI ' + 'prepare la base de datos. Avisale antes de continuar.') + +h2('Paso 4 — Cargar todos a Postgres') +parr('Da clic en "Cargar todos a Postgres". La herramienta procesa archivo por archivo. ' + 'Al terminar te muestra:') +bullet('Una tabla de resultado con una fila por archivo: si cargo bien (OK), si se omitio ' + '(SKIP), o si hubo un problema (ERROR).') +bullet('Un resumen total: cuantos archivos cargaron, cuantos se omitieron, cuantos fallaron, ' + 'y cuantas filas en total entraron a la base.') + +alerta('IMPORTANTE', + 'Si vuelves a subir los mismos archivos que ya cargaste antes, los datos se duplican. ' + 'Si quieres recargar un periodo, primero usa la Zona peligrosa para limpiar (ver punto 6).') + +nota('¿Que significa SKIP?', + 'Un archivo se omite cuando: (a) su nombre no es reconocido, o (b) no existe en la base ' + 'de datos una tabla para guardarlo. Avisa a TI si te pasa con archivos que sabes que ' + 'deberian entrar.') + + +# ====== 5 ====== +h1('5. Como ver lo que ya esta cargado') + +h2('Ver estadisticas') +parr('Da clic en "Ver estadisticas". La herramienta cuenta cuantas filas tiene cada ' + 'Registro y te muestra:') +bullet('Un resumen general (cuantas tablas tienes, cuantas con datos, total de filas).') +bullet('Una tabla con cada Registro y su cantidad de filas.') + +h2('Explorar el contenido de una tabla') +parr('Despues de ver estadisticas, en el desplegable "Tabla" puedes elegir cualquier ' + 'Registro que tenga datos. Ajusta cuantas filas quieres ver (por default 100) y da clic ' + 'en "Ver datos". La herramienta te muestra las primeras filas en pantalla.') + +h2('Exportar a Excel') +parr('Despues de cargar la muestra con "Ver datos", da clic en "Exportar a Excel". Se genera ' + 'un archivo .xlsx en la carpeta de la herramienta con las filas que estabas viendo.') + +tip('¿Para que sirve esto?', + 'Para validar rapidamente que la informacion se ve correcta antes de generar reportes ' + 'completos en otras pestanas. Si ves filas con valores extranos, conviene revisar el ' + '.asc original o avisarle a TI.') + + +# ====== 6 ====== +h1('6. Como limpiar todo y empezar de cero') +alerta('USA ESTA SECCION CON CUIDADO', + 'El boton de la Zona peligrosa elimina TODOS los datos de TODOS los Registros. ' + 'No hay forma de recuperarlos. Solo usalo cuando estes seguro de que necesitas ' + 'reiniciar.') + +parr('Cuando usar la Zona peligrosa:') +bullet('Cuando vas a recargar el mismo periodo (para no duplicar datos).') +bullet('Cuando cargaste algo con errores y prefieres limpiar y volver a cargar.') +bullet('Cuando cambias de cliente o periodo y quieres empezar limpio.') + +parr('Como hacerlo:') +numbered('Marca la casilla "Confirmo limpiar TODAS las tablas Registro*".') +numbered('Da clic en el boton rojo "Limpiar todas las tablas".') +numbered('La herramienta cuenta cuantas filas habia en cada tabla y las elimina. Al final ' + 'te muestra cuantas filas se eliminaron por cada Registro.') + +nota('Despues de limpiar', + 'Tendras que volver a subir los archivos .asc que necesites. La carpeta de archivos no ' + 'se borra (la herramienta solo borra los datos de la base de datos, no tus archivos en ' + 'el disco).') + + +# ====== 7 ====== +h1('7. ¿Algo no funciona? Lo mas comun') + +h3('La pestana dice "Postgres no disponible"') +parr('La herramienta no puede conectarse a su base de datos interna. Causas tipicas:') +bullet('El servicio de Docker no esta corriendo (avisar a TI).') +bullet('La herramienta se instalo sin terminar la configuracion.') +parr('En cualquier caso, contacta a quien te instalo la herramienta.') + +h3('Dice "No hay tablas Registro* en Postgres"') +parr('Significa que la base de datos esta vacia, sin estructura. Es algo que TI tiene que ' + 'preparar una sola vez al inicio. Avisales y comparte este texto para que sepan a que ' + 'te refieres.') + +h3('Muchos archivos quedaron como SKIP') +parr('Revisa que los nombres terminen en el formato esperado (algo_NNN.asc o algo_palabra.asc). ' + 'Si los nombres son correctos pero igual quedan como SKIP, es probable que TI no haya ' + 'creado todas las tablas; avisales.') + +h3('Algunos archivos quedaron como ERROR') +parr('Lee el mensaje de error en la columna "mensaje". Las causas mas frecuentes son:') +bullet('El archivo tiene un caracter raro (NUL) que la base de datos no acepta. Esto la ' + 'herramienta lo intenta limpiar automaticamente; si aun asi falla, avisa a TI con el ' + 'archivo problematico.') +bullet('El archivo tiene mas columnas de las que la tabla espera, o menos.') +bullet('La conexion a la base se cayo a mitad del proceso.') +parr('En general, la herramienta te dice cuales archivos fallaron pero los demas SI se ' + 'cargaron correctamente — no tienes que volver a subir todo, solo los que fallaron.') + +h3('Subi los mismos archivos dos veces por error') +parr('Los datos quedaron duplicados. Lo mas seguro es:') +numbered('Usar la Zona peligrosa para limpiar TODAS las tablas.') +numbered('Volver a cargar los archivos del periodo que necesitas, esta vez una sola vez.') + + +# ====== 8 ====== +h1('8. Glosario rapido') +table_simple( + ['Termino', 'Significado'], + [ + ['Archivo .asc', 'Archivo de texto del SAAI con la informacion de pedimentos, partidas, etc. Es lo que cargas en esta pestana.'], + ['Registro NNN', 'Tabla interna de la herramienta donde se guarda un tipo especifico de informacion (501 = pedimentos, 551 = partidas, etc.).'], + ['Carga', 'Accion de tomar tus archivos .asc y meterlos en la herramienta.'], + ['Limpiar', 'Borrar todos los datos cargados. Util para reiniciar desde cero.'], + ['OK', 'Estatus de un archivo que se cargo correctamente.'], + ['SKIP', 'Estatus cuando un archivo se omitio (nombre no reconocido o tabla inexistente).'], + ['ERROR', 'Estatus cuando un archivo no se pudo cargar por algun problema.'], + ['Postgres', 'La base de datos interna donde la herramienta guarda lo que cargas.'], + ], + col_widths=[3.5, 12]) + + +import os +base = os.path.dirname(os.path.abspath(__file__)) +out = os.path.join(base, 'Manual_DataStage.docx') +try: + doc.save(out) +except PermissionError: + ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + out = os.path.join(base, f'Manual_DataStage_{ts}.docx') + doc.save(out) +print(f'Manual generado: {out}') diff --git a/app/generar_manual_estructuras.py b/app/generar_manual_estructuras.py new file mode 100644 index 0000000..269567c --- /dev/null +++ b/app/generar_manual_estructuras.py @@ -0,0 +1,437 @@ +"""Manual de usuario de la pestana Estructuras SCAII. Lenguaje accesible, +sin SQL ni codigo. Para usuarios sin conocimientos tecnicos.""" +from docx import Document +from docx.shared import Pt, RGBColor, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() +for sec in doc.sections: + sec.top_margin = Cm(2.5); sec.bottom_margin = Cm(2.5) + sec.left_margin = Cm(2.2); sec.right_margin = Cm(2.2) +style = doc.styles['Normal']; style.font.name = 'Calibri'; style.font.size = Pt(11) + + +def h1(t): + p = doc.add_heading(t, level=1) + for r in p.runs: r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +def h2(t): + p = doc.add_heading(t, level=2) + for r in p.runs: r.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5) + +def h3(t): + p = doc.add_heading(t, level=3) + for r in p.runs: r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +def parr(t, bold=False): + p = doc.add_paragraph(); r = p.add_run(t); r.bold = bold; return p + +def bullet(t): + p = doc.add_paragraph(t, style='List Bullet') + p.paragraph_format.left_indent = Cm(0.6); return p + +def numbered(t): + p = doc.add_paragraph(t, style='List Number') + p.paragraph_format.left_indent = Cm(0.6); return p + +def caja(titulo, text, fill, color): + p = doc.add_paragraph(); p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'), fill) + pPr.append(shd) + r = p.add_run(titulo + ': '); r.bold = True; r.font.color.rgb = color + p.add_run(text) + +def nota(t, x): caja(t, x, 'FFF8E1', RGBColor(0xE6, 0x5C, 0x00)) +def tip(t, x): caja(t, x, 'E8F5E9', RGBColor(0x2E, 0x7D, 0x32)) +def alerta(t, x): caja(t, x, 'FFEBEE', RGBColor(0xC6, 0x28, 0x28)) +def info(t, x): caja(t, x, 'E3F2FD', RGBColor(0x0D, 0x47, 0xA1)) + +def table_simple(headers, rows, col_widths=None): + t = doc.add_table(rows=1+len(rows), cols=len(headers)) + t.style = 'Light Grid Accent 1' + for i, h in enumerate(headers): + c = t.rows[0].cells[i]; c.text = h + for p in c.paragraphs: + for r in p.runs: r.bold = True; r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + tcPr = c._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:fill'),'1565C0') + tcPr.append(shd) + for ri, row in enumerate(rows, start=1): + for ci, val in enumerate(row): + t.rows[ri].cells[ci].text = str(val) + if col_widths: + for i, w in enumerate(col_widths): + for row in t.rows: row.cells[i].width = Cm(w) + return t + + +# ====== PORTADA ====== +ti = doc.add_paragraph(); ti.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = ti.add_run('\n\n\nPestana Estructuras SCAII') +r.bold = True; r.font.size = Pt(34); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +su = doc.add_paragraph(); su.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = su.add_run('Generacion de reportes y\narchivos para SCAII') +r.font.size = Pt(18); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = p.add_run('\n\nSistema de Utilerias 2.0\nGuia rapida para el usuario') +r.font.size = Pt(14) +r = p.add_run(f'\n\nVersion {datetime.date.today().isoformat()}') +r.font.size = Pt(11); r.italic = True + +doc.add_page_break() + + +# ====== CONTENIDO ====== +h1('Contenido') +for it in [ + '1. ¿Para que sirve esta pestana?', + '2. Antes de empezar', + '3. Como se ve la pantalla', + '4. Reportes de Pedimentos', + ' • Estructura CAT Pedimentos', + ' • Estructura CAT Pedimentos Rectificados', + ' • Rastreo de Rectificaciones (y como ver el historial)', + '5. Encabezado de Facturas (Importacion y Exportacion)', + '6. Tipo de Cambio (con alertas en rojo)', + '7. Catalogo de NUMPARTES (preparar el diccionario)', + '8. Estructura de Partidas (como se asignan los NUMPARTE)', + '9. ¿Algo no funciona? Lo mas comun', + '10. Glosario rapido', +]: + parr(it).paragraph_format.left_indent = Cm(0.4) +doc.add_page_break() + + +# ====== 1 ====== +h1('1. ¿Para que sirve esta pestana?') +parr('La pestana Estructuras SCAII genera los reportes y archivos en Excel que entregas al ' + 'cliente o que cargas en SCAII. Toma la informacion que ya esta en la herramienta ' + '(la que subiste con la pestana DataStage) y la organiza segun los formatos que el ' + 'cliente espera.') + +info('En palabras simples', + 'DataStage es donde le das de comer a la herramienta. Estructuras SCAII es donde le pides ' + 'que cocine y te entregue platillos listos (reportes). Si la pestana DataStage esta ' + 'vacia, aqui no hay nada que generar.') + + +# ====== 2 ====== +h1('2. Antes de empezar') +bullet('Asegurate que la pestana DataStage tenga datos cargados. Si no, ve primero a ' + 'DataStage y carga tus archivos .asc.') +bullet('Identifica el rango de fechas del periodo que quieres reportar. La mayoria de los ' + 'reportes te lo piden.') +bullet('Para la seccion de Partidas vas a necesitar el catalogo del cliente ' + '(Excel con numero de parte, descripcion, unidad de medida y fraccion). Si no lo ' + 'tienes, igual funciona pero el numero de parte sera inventado por la herramienta.') + +tip('Sugerencia', + 'Antes de generar un reporte de un ano entero, prueba con un mes para revisar que el ' + 'resultado se ve como esperas. Despues amplia el rango.') + + +# ====== 3 ====== +h1('3. Como se ve la pantalla') +parr('La pestana esta dividida en cinco bloques, uno debajo del otro:') +table_simple( + ['Bloque', 'Color', '¿Que genera?'], + [ + ['Reportes de Pedimentos', 'Morado', 'CAT Pedimentos, CAT Rectificados, Rastreo de Rectificaciones.'], + ['Encabezado de Facturas', 'Cian', 'Encabezados de facturas para Importacion y Exportacion.'], + ['Tipo de Cambio', 'Naranja', 'Tipo de cambio por pedimento, con alertas en rojo si hay inconsistencias.'], + ['Catalogo de NUMPARTES', 'Morado', 'Subes el catalogo del cliente para que la herramienta lo use.'], + ['Estructura de Partidas', 'Morado', 'Genera las partidas Impo o Expo listas para SCAII.'], + ], + col_widths=[4, 2.5, 9]) + +parr('Cada bloque tiene su(s) campo(s) de fechas, su boton para generar el reporte, su boton ' + 'para exportar a Excel y su area de resultados donde se muestra la informacion.') + + +# ====== 4 ====== +h1('4. Reportes de Pedimentos') + +h2('4.1 Estructura CAT Pedimentos') +parr('Genera el catalogo completo de pedimentos del periodo. Incluye:') +bullet('Numero de pedimento, tipo (importacion o exportacion), clave.') +bullet('Fecha de pago real, seccion aduanera, medios de transporte.') +bullet('Una marca de SI o NO indicando si el pedimento fue rectificado.') +bullet('Si fue rectificado, el numero del pedimento que lo rectifico.') + +parr('Como generarlo:') +numbered('Indica fecha inicio y fecha fin.') +numbered('Da clic en "Generar".') +numbered('Revisa la vista previa (primeras 200 filas).') +numbered('Da clic en "Exportar Excel" para obtener el archivo completo.') + +h2('4.2 Estructura CAT Pedimentos Rectificados') +parr('Es el reporte gemelo del anterior, pero parte desde los pedimentos rectificados. ' + 'Para cada rectificacion muestra a que pedimento original corrigio. Util cuando ' + 'necesitas un reporte enfocado en las rectificaciones.') + +parr('Funciona igual: fecha inicio, fecha fin, Generar, Exportar Excel.') + +h2('4.3 Rastreo de Rectificaciones') +parr('Lista todos los pedimentos del periodo que fueron rectificados al menos una vez. ' + 'Tiene un campo extra de busqueda para filtrar por numero de pedimento, patente o clave.') + +h3('Ver el historial de un pedimento') +parr('Si necesitas ver toda la cadena de rectificaciones de un pedimento especifico:') +numbered('Localiza el pedimento en la lista del Rastreo.') +numbered('Copia los datos a los campos de abajo: Patente, Pedimento, Seccion Aduanera y Ano.') +numbered('Da clic en "Ver historial".') +numbered('La herramienta te muestra todas las rectificaciones ordenadas por fecha.') + +info('Para que sirve', + 'Sirve para reconstruir el "arbol genealogico" de un pedimento: el original, su primera ' + 'rectificacion, la rectificacion de la rectificacion, etc.') + + +# ====== 5 ====== +h1('5. Encabezado de Facturas (Importacion y Exportacion)') + +parr('Genera el encabezado de facturas en el formato que el cliente sube a SCAII. Tiene dos ' + 'sub-bloques, uno para Importacion y otro para Exportacion. Funcionan exactamente igual; ' + 'lo unico que cambia es que filtran pedimentos de importacion o exportacion respectivamente.') + +h2('¿Que columnas trae?') +parr('Las que el cliente espera para SCAII: numero de pedimento, remesa, numero de factura, ' + 'fecha, tipo de cambio, claves de proveedor / vendido a / enviado a, agente aduanal, ' + 'medios de transporte, monedas, fletes, seguros, embalajes, otros incrementables, ' + 'fecha de emision, tipo de peso, aduana de cruce, observaciones y localizacion.') + +nota('Sobre las columnas vacias', + 'Varias columnas salen en blanco (CLAVE TRANSPORTISTA, NOMBRE CONDUCTOR, FLETES, ' + 'SEGUROS, etc.) porque no estan en los archivos del SAAI. El cliente las llena ' + 'manualmente despues. Esto es como funcionaba antes el sistema viejo; no hay cambios.') + +h2('Como generarlo') +numbered('Define fecha inicio y fecha fin.') +numbered('Da clic en "Generar".') +numbered('Revisa la vista previa.') +numbered('Da clic en "Exportar Excel".') + +alerta('Pedimentos rectificados se omiten', + 'Si un pedimento ya fue rectificado, NO aparece en este reporte. Aparece solo el ' + 'pedimento rectificador (con R1 en la clave). Esto es a proposito: SCAII solo necesita ' + 'la version vigente.') + + +# ====== 6 ====== +h1('6. Tipo de Cambio') + +parr('Genera la lista de pedimentos del periodo con su tipo de cambio y la fecha. Util para ' + 'verificar que todos los pedimentos del mismo dia tienen el mismo tipo de cambio.') + +h2('Alertas en rojo') +parr('La herramienta detecta automaticamente cuando un mismo dia tiene mas de un valor de ' + 'tipo de cambio entre sus pedimentos (eso no deberia pasar normalmente). Cuando lo ' + 'detecta:') +bullet('En pantalla: la celda del tipo de cambio aparece resaltada en rojo.') +bullet('En el Excel exportado: las celdas inconsistentes salen con fondo rojo y letra blanca, ' + 'para que sean facilmente visibles cuando el archivo se abre en Excel.') + +alerta('¿Que hacer si ves rojo?', + 'Significa que hay un error de captura o algo raro en el SAAI. Revisa esos pedimentos ' + 'en SCAII y corrige el tipo de cambio antes de entregar el reporte definitivo al ' + 'cliente.') + +h2('Como generarlo') +parr('Define fecha inicio y fecha fin, da clic en "Generar", revisa la lista, exporta con el ' + 'boton "Exportar Excel".') + + +# ====== 7 ====== +h1('7. Catalogo de NUMPARTES (diccionario del cliente)') + +parr('Este bloque sirve para que la herramienta tenga un "diccionario" con los numeros de ' + 'parte oficiales del cliente. Se usa en el siguiente bloque (Estructura de Partidas) ' + 'para que, cuando la herramienta vea una descripcion en los archivos del SAAI, sepa ' + 'que numero de parte oficial asignarle.') + +info('¿Por que se necesita esto?', + 'Los archivos del SAAI no traen el numero de parte que usa el cliente; solo traen una ' + 'descripcion libre y la fraccion. Sin un catalogo, no hay forma de saber a que numero ' + 'de parte corresponde cada linea. Si tienes el catalogo del cliente, dejalo cargado ' + 'una sola vez y se queda guardado.') + +h2('7.1 Formato del Excel que debes subir') +table_simple( + ['Columna', 'Que poner ahi'], + [ + ['NUMPARTE', 'El identificador oficial del cliente (ABC-12345, PT-001, etc.).'], + ['DESCRIPCION', 'La descripcion del producto tal como aparece en los pedimentos.'], + ['UNIDAD DE MEDIDA', 'La sigla de la unidad (PZA, KGS, LT, etc.), NO el numero.'], + ['FRACCION', 'La fraccion arancelaria completa (8 digitos).'], + ], + col_widths=[4, 11]) + +nota('Variantes aceptadas', + 'Los nombres de columna pueden venir con o sin guiones bajos, en mayusculas o ' + 'minusculas. La herramienta acepta variantes razonables (FACTURAIMPO, FACTURA_IMPO, ' + 'FACTURA, etc.). Solo cuida que las cuatro columnas existan en el archivo.') + +h2('7.2 Cargar el catalogo') +numbered('Da clic en "Subir Excel" y selecciona tu archivo.') +numbered('Da clic en "Cargar a base (upsert)".') +numbered('La herramienta te dice cuantas filas se cargaron o actualizaron.') + +info('"Upsert" significa acumular', + 'Cada vez que subes un Excel, la herramienta agrega los numeros de parte nuevos y ' + 'actualiza los que ya existian (por si cambio la descripcion o la fraccion). No borra ' + 'los que ya tienes — los conserva.') + +h2('7.3 Ver el catalogo actual') +parr('Da clic en "Ver base actual" para ver las primeras 500 filas del catalogo que tiene ' + 'la herramienta en este momento.') + +h2('7.4 Limpiar el catalogo (empezar de cero)') +parr('Si necesitas reemplazar todo el catalogo:') +numbered('Marca la casilla "Confirmo TRUNCATE de base_numpartes".') +numbered('Da clic en el boton rojo "Limpiar base".') +numbered('Sube tu nuevo Excel con el catalogo limpio.') + + +# ====== 8 ====== +h1('8. Estructura de Partidas (Importacion y Exportacion)') + +parr('Este es el bloque mas importante de la pestana. Genera las partidas (lineas de cada ' + 'pedimento) en el formato que el cliente carga a SCAII. La parte clave es que la ' + 'herramienta tiene que asignarle un NUMERO DE PARTE a cada linea — y los archivos del ' + 'SAAI no traen esa informacion. Aqui es donde entra la magia.') + +h2('8.1 Como asigna el numero de parte') +parr('Por cada linea del pedimento, la herramienta sigue este orden:') +numbered('Busca en tu catalogo (el de la seccion 7) un registro con la MISMA fraccion (4 ' + 'primeros digitos) y la MISMA unidad de medida.') +numbered('Si encuentra candidatos, compara la descripcion. Si la descripcion del pedimento ' + 'es parecida a la descripcion de algun candidato (por arriba del umbral que ' + 'configures), le asigna ese numero de parte. Esto se marca como BASE en el reporte.') +numbered('Si NO encuentra match en el catalogo, agrupa las partidas similares entre si y ' + 'les inventa un numero de parte automatico con el formato MP-R. ' + 'Esto se marca como AUTO en el reporte.') +numbered('Las partidas que ni siquiera traen descripcion en el SAAI reciben un numero de ' + 'parte inventado individual. Se marcan como AUTO_SINDESC.') + +info('¿Que es el umbral?', + 'Es como un "nivel de exigencia". Va de 0.50 a 1.00. Por default esta en 0.80. ' + 'Mientras mas cercano a 1.00, mas estricta es la comparacion (solo acepta ' + 'descripciones casi identicas). Mientras mas cercano a 0.50, mas permisiva.') + +h2('8.2 ¿Cuando ajustar el umbral?') +table_simple( + ['Si ves...', '...ajusta el umbral asi'], + [ + ['Muchas asignaciones BASE que NO te convencen', 'Subelo a 0.85 o 0.90 (mas estricto).'], + ['Muchas asignaciones AUTO que SI deberian estar en el catalogo', 'Bajalo a 0.70 o 0.65 (mas permisivo).'], + ['Resultado razonable', 'Dejalo en 0.80 (default).'], + ], + col_widths=[8.5, 7]) + +h2('8.3 Como generarlo') +numbered('Elige el sub-bloque: Partidas Importacion o Partidas Exportacion.') +numbered('Define fecha inicio y fecha fin.') +numbered('Ajusta el umbral si quieres (default 0.80 funciona en la mayoria de casos).') +numbered('Da clic en "Generar". El proceso puede tardar varios minutos si son muchas ' + 'partidas; la barra de progreso te indica el avance.') +numbered('Revisa la vista previa. Presta atencion a la columna MATCH_TIPO.') +numbered('Si todo se ve bien, exporta con "Exportar Excel".') + +h2('8.4 ¿Que significa la columna MATCH_TIPO?') +table_simple( + ['Valor', 'Significado'], + [ + ['BASE (0.92)', 'Encontro un numero de parte en tu catalogo con 92% de similitud.'], + ['AUTO', 'No encontro en el catalogo. La herramienta invento un numero MP...-R...'], + ['AUTO_SINDESC', 'La partida no tenia descripcion. Se le invento un numero individual.'], + ], + col_widths=[3.5, 12]) + +alerta('Sobre las columnas vacias', + 'Igual que con Facturas, varias columnas del reporte salen vacias por diseno (CLIENTE, ' + 'FORMA DE PAGO, LOCALIZACION, etc.). El cliente las llena despues o vienen de otro ' + 'cruce que no tenemos en esta herramienta.') + +h2('8.5 ¿Como se ve el numero de parte automatico?') +parr('Cuando la herramienta no encuentra match en tu catalogo, inventa numeros con esta forma:') +bullet('MP8471-R001 (primer producto huerfano de la fraccion 8471)') +bullet('MP8471-R002 (segundo producto huerfano de la fraccion 8471)') +bullet('MP7318-R001 (primer producto huerfano de la fraccion 7318)') +parr('El numero al final se reinicia para cada fraccion (no es un contador global).') + +info('Recomendacion practica', + 'Si vas a usar este reporte para entregar a un cliente, lo ideal es que el catalogo de ' + 'la seccion 7 este lo mas completo posible. Asi la columna NUMERO DE PARTE se llena con ' + 'los numeros oficiales del cliente (BASE) y no con los inventados (AUTO).') + + +# ====== 9 ====== +h1('9. ¿Algo no funciona? Lo mas comun') + +h3('Genere un reporte y salio vacio (cero filas)') +bullet('Verifica que el rango de fechas cubra periodos donde hayas cargado datos en la ' + 'pestana DataStage. Si cargaste datos de 2024 y pones fechas de 2025, sale vacio.') +bullet('Ve a la pestana DataStage → Estadisticas y confirma que las tablas tienen filas.') + +h3('Las Partidas salieron todas con AUTO_SINDESC') +parr('Significa que la columna de descripcion en los archivos del SAAI viene vacia. Revisa ' + 'el archivo original con la pestana DataStage → Ver datos del Registro551 — si la ' + 'columna DescripcionMercancia esta vacia, no hay forma de comparar.') + +h3('Las Partidas salieron todas con AUTO aunque tengo catalogo cargado') +bullet('Verifica que cargaste el catalogo: en la seccion 7, da clic en "Ver base actual".') +bullet('Verifica que las fracciones de tu catalogo empiezan con los mismos 4 digitos que las ' + 'de tus pedimentos.') +bullet('Verifica que la unidad de medida en tu catalogo es SIGLA (PZA, KGS), no un numero.') +bullet('Baja el umbral a 0.65 y vuelve a generar — quizas la similitud es menor a la esperada.') + +h3('El Tipo de Cambio sale TODO en rojo') +parr('Es probable que la fecha en la base se este interpretando mal. Avisa a TI.') + +h3('El boton "Generar" no responde') +parr('Verifica que arriba diga "Postgres conectado". Si no, hay un problema con la base de ' + 'datos interna de la herramienta. Avisa a TI.') + +h3('El Excel exportado se ve raro al abrirlo') +parr('Asegurate de abrirlo con Excel y no con otro programa. Algunos visores muestran mal ' + 'los archivos generados con formato. Si Excel tampoco lo abre bien, copia el archivo ' + 'a tu maquina local y abrelo desde ahi.') + + +# ====== 10 ====== +h1('10. Glosario rapido') +table_simple( + ['Termino', 'Significado'], + [ + ['CAT Pedimentos', 'Catalogo de todos los pedimentos de un periodo, listo para SCAII.'], + ['Rectificacion', 'Un pedimento que corrige a otro anterior.'], + ['R1', 'Clave que indica que un pedimento es una rectificacion.'], + ['Historial', 'Cadena completa de rectificaciones de un pedimento (original → rect 1 → rect 2 → ...).'], + ['NUMPARTE', 'Numero de parte oficial del cliente.'], + ['Catalogo', 'Tabla con los numeros de parte del cliente y sus descripciones. Se usa para asignar NUMPARTE a las partidas.'], + ['Upsert', 'Cuando subes el catalogo: agrega los nuevos y actualiza los existentes, sin borrar lo demas.'], + ['Umbral', 'Nivel de exigencia para comparar descripciones. De 0.50 (permisivo) a 1.00 (estricto).'], + ['BASE', 'Marca que el numero de parte vino del catalogo del cliente.'], + ['AUTO', 'Marca que el numero de parte fue inventado por la herramienta.'], + ['MP-R', 'Formato del numero de parte inventado: MP + primeros 4 de la fraccion + R + secuencial.'], + ['TipoOperacion', 'Codigo que indica si el pedimento es Importacion (1) o Exportacion (2).'], + ], + col_widths=[3.5, 12]) + + +import os +base = os.path.dirname(os.path.abspath(__file__)) +out = os.path.join(base, 'Manual_EstructurasSCAII.docx') +try: + doc.save(out) +except PermissionError: + ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + out = os.path.join(base, f'Manual_EstructurasSCAII_{ts}.docx') + doc.save(out) +print(f'Manual generado: {out}') diff --git a/app/generar_manual_saldos_vencidos.py b/app/generar_manual_saldos_vencidos.py new file mode 100644 index 0000000..35dbf53 --- /dev/null +++ b/app/generar_manual_saldos_vencidos.py @@ -0,0 +1,503 @@ +""" +Genera el manual del módulo Saldos Vencidos (Utilería Forma 5). +Cubre los dos modos de uso: por Excel y automático por rango de fechas. +""" +from docx import Document +from docx.shared import Pt, RGBColor, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() + +for sec in doc.sections: + sec.top_margin = Cm(2.5) + sec.bottom_margin = Cm(2.5) + sec.left_margin = Cm(2.2) + sec.right_margin = Cm(2.2) + +style = doc.styles['Normal'] +style.font.name = 'Calibri' +style.font.size = Pt(11) + + +def h1(text): + p = doc.add_heading(text, level=1) + for r in p.runs: r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +def h2(text): + p = doc.add_heading(text, level=2) + for r in p.runs: r.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5) + +def h3(text): + p = doc.add_heading(text, level=3) + for r in p.runs: r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +def parr(text, bold=False): + p = doc.add_paragraph() + r = p.add_run(text); r.bold = bold + return p + +def code_block(text): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + r = p.add_run(text); r.font.name = 'Consolas'; r.font.size = Pt(9.5) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'),'F0F0F0') + pPr.append(shd) + return p + +def bullet(text): + p = doc.add_paragraph(text, style='List Bullet') + p.paragraph_format.left_indent = Cm(0.6) + return p + +def numbered(text): + p = doc.add_paragraph(text, style='List Number') + p.paragraph_format.left_indent = Cm(0.6) + return p + +def caja(titulo, text, color_fill='FFF8E1', color_text=RGBColor(0xE6, 0x5C, 0x00)): + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'), color_fill) + pPr.append(shd) + r = p.add_run(titulo + ': '); r.bold = True; r.font.color.rgb = color_text + p.add_run(text) + +def nota(titulo, text): caja(titulo, text, 'FFF8E1', RGBColor(0xE6, 0x5C, 0x00)) +def tip(titulo, text): caja(titulo, text, 'E8F5E9', RGBColor(0x2E, 0x7D, 0x32)) +def alerta(titulo, text): caja(titulo, text, 'FFEBEE', RGBColor(0xC6, 0x28, 0x28)) +def info(titulo, text): caja(titulo, text, 'E3F2FD', RGBColor(0x0D, 0x47, 0xA1)) + +def table_simple(headers, rows, col_widths_cm=None): + t = doc.add_table(rows=1+len(rows), cols=len(headers)) + t.style = 'Light Grid Accent 1' + hdr = t.rows[0].cells + for i, h in enumerate(headers): + hdr[i].text = h + for p in hdr[i].paragraphs: + for r in p.runs: + r.bold = True; r.font.color.rgb = RGBColor(0xFF,0xFF,0xFF) + tcPr = hdr[i]._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:fill'),'1565C0') + tcPr.append(shd) + for r_idx, row in enumerate(rows, start=1): + cells = t.rows[r_idx].cells + for c_idx, val in enumerate(row): + cells[c_idx].text = str(val) + if col_widths_cm: + for i, w in enumerate(col_widths_cm): + for row in t.rows: + row.cells[i].width = Cm(w) + return t + + +# ====== PORTADA ====== +title = doc.add_paragraph(); title.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = title.add_run('\n\n\nMódulo Saldos Vencidos') +r.bold = True; r.font.size = Pt(36); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = sub.add_run('Utilería Forma 5\nProrrateo masivo de saldos IMPO vencidos') +r.font.size = Pt(18); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = p.add_run('\n\nSistema de Utilerías 2.0 SCAII\nManual de Uso') +r.font.size = Pt(14) +r = p.add_run(f'\n\nVersión {datetime.date.today().isoformat()}') +r.font.size = Pt(11); r.italic = True + +doc.add_page_break() + + +# ====== CONTENIDO ====== +h1('Contenido') +for it in [ + '1. ¿Qué es el módulo Saldos Vencidos?', + '2. ¿Por qué se necesita esta herramienta?', + '3. Antes de empezar', + '4. Abrir la pestaña Saldos Vencidos', + '5. Los dos modos de uso', + '6. Modo Excel — paso a paso', + ' • Descargar la plantilla', + ' • Subir el Excel y definir fechas', + ' • Analizar (Paso A)', + ' • Exportar el plan a Excel', + ' • Ejecutar el prorrateo (Paso B)', + '7. Modo Automático — paso a paso', + ' • Rango de fechas', + ' • Buscar saldos vencidos', + ' • Exportar y ejecutar', + '8. Cómo funciona el prorrateo', + '9. Validación contra el saldo real', + '10. Estatus de cada saldo en el resumen', + '11. Diferencias entre los dos modos', + '12. Casos típicos y preguntas frecuentes', + '13. Apéndice técnico', + '14. Glosario', +]: + parr(it).paragraph_format.left_indent = Cm(0.4) +doc.add_page_break() + + +# ====== 1 ====== +h1('1. ¿Qué es el módulo Saldos Vencidos?') +parr('Saldos Vencidos es una pestaña dentro del Sistema de Utilerías 2.0 que automatiza ' + 'la Utilería Forma 5: tomar los saldos pendientes de una factura de importación ' + 'que ya vencieron y repartirlos (prorratearlos) entre las descargas de exportación ' + 'donde esa misma mercancía se haya consumido.') + +parr('En vez de hacer el cálculo línea por línea desde la utilería tradicional —que en bases ' + 'grandes puede tardar muchísimo— esta pestaña carga, calcula y aplica los movimientos ' + 'masivamente con un proceso mucho más rápido.') + +nota('En palabras simples', + 'Tienes saldos de importación que ya se vencieron y necesitas "amortizarlos" en las ' + 'descargas de exportación donde el material se fue. Esta herramienta los encuentra, ' + 'los distribuye proporcionalmente y aplica los UPDATEs en SDescargaT y SSaldoTem.') + + +# ====== 2 ====== +h1('2. ¿Por qué se necesita esta herramienta?') +parr('La utilería original recorre todas las facturas de exportación que comparten fracción ' + 'arancelaria con cada saldo y reparte el saldo entre las facturas encontradas. En una base ' + 'pesada (millones de filas) este proceso puede llevar horas o quedarse colgado.') + +parr('Esta herramienta hace lo mismo pero:') +bullet('Solo carga las filas que realmente necesita (filtros por rango de fechas).') +bullet('Agrupa las descargas en lotes para reducir consultas.') +bullet('Hace todos los UPDATEs por transacción atómica por saldo (rollback automático ante error).') +bullet('Permite ver el plan antes de aplicar (DRY_RUN).') + + +# ====== 3 ====== +h1('3. Antes de empezar') +bullet('Necesitas estar conectado a la base SCAII (revísalo en la pestaña Conexión).') +bullet('Define con claridad el rango de fechas que quieres procesar. El rango filtra ' + 'la columna FECHAFACTURA_ISO de la tabla SSaldoTem (la fecha de la factura IMPO).') +bullet('Si vas a usar el modo Excel: prepara el archivo con las 3 columnas obligatorias ' + '(o descarga la plantilla desde la misma pestaña).') + +alerta('Recomendación', + 'La primera vez SIEMPRE corre el Paso B con DRY_RUN activado. Te muestra qué se ' + 'iba a actualizar sin tocar la base. Cuando confirmes los números, lo desactivas y ' + 'aplicas en firme.') + + +# ====== 4 ====== +h1('4. Abrir la pestaña Saldos Vencidos') +parr('En la barra superior de la app verás varias pestañas (Conexión, Descargas, Análisis ' + 'Saldos, Sustitutos NLP, Descarga % KGS, CTM, Saldos Vencidos). Da clic en ' + '"Saldos Vencidos".') + + +# ====== 5 ====== +h1('5. Los dos modos de uso') +parr('La pestaña tiene dos secciones, una arriba de la otra. Ambas hacen exactamente lo ' + 'mismo internamente; lo único que cambia es de dónde sacan la lista de saldos a procesar:') + +table_simple( + ['Modo', '¿De dónde toma los saldos?', '¿Cuándo usarlo?'], + [ + ['Excel (sección superior)', + 'De un archivo .xlsx que tú subes con las facturas IMPO específicas que quieres prorratear.', + 'Cuando ya tienes una lista filtrada del cliente o del área legal con los saldos exactos a tocar.'], + ['Automático (sección inferior)', + 'El sistema busca solo en SSaldoTem todos los saldos con FECHAVENC_ISO menor a hoy y SALDO_DISPONIBLE > 0 dentro del rango que indiques.', + 'Cuando quieres "barrer" todo lo vencido de un periodo sin armar listas previas.'], + ], + col_widths_cm=[3.5, 6.5, 6]) + + +# ====== 6 ====== +h1('6. Modo Excel — paso a paso') + +h2('6.1 Descargar la plantilla') +parr('Si es la primera vez, da clic en el botón "Descargar plantilla Excel". Se generará ' + 'un archivo con 3 columnas de ejemplo. Ábrelo, borra las filas de ejemplo y pega tus datos.') + +h2('6.2 Estructura del Excel') +table_simple( + ['Columna', 'Significado', 'Ejemplo'], + [ + ['FACTURAIMPO', 'Número de la factura de importación cuyo saldo está vencido.', 'F1234567'], + ['CANTIDAD_SALDO', 'Cantidad pendiente a prorratear (en la UM del saldo).', '100.0'], + ['FRACCION_IMPO', 'Fracción arancelaria de importación.', '85044010'], + ], + col_widths_cm=[3.5, 8, 4]) +nota('Sobre los nombres de columna', + 'La herramienta acepta variaciones (FACTURAIMPO / FACTURA_IMPO / FACTURA, ' + 'FRACCION_IMPO / FRACCIONIMPO / FRACCION, CANTIDAD_SALDO / CANT_SALDO / CANTIDAD / SALDO). ' + 'No hace falta que sea exactamente el nombre, pero sí debe coincidir con alguno de los aceptados.') + +h2('6.3 Subir el Excel y definir fechas') +numbered('Da clic en "Subir Excel" y selecciona el archivo.') +numbered('Pon una fecha de inicio y una fecha de fin. Estas fechas filtran SSaldoTem por la ' + 'columna FECHAFACTURA_ISO (la fecha de la factura IMPO).') + +tip('Para qué sirve el rango', + 'Aunque el Excel ya trae las facturas IMPO específicas, el rango limita la consulta a ' + 'SSaldoTem para que sea más rápida. Si no estás seguro, pon un rango amplio que cubra ' + 'todas las fechas de tus facturas.') + +h2('6.4 Analizar (Paso A — solo lectura)') +parr('Da clic en "Analizar (sin escribir)". El sistema:') +bullet('Consulta SSaldoTem dentro del rango definido.') +bullet('Cruza con tu Excel por (FACTURAIMPO, FRACCIONIMPO).') +bullet('Para cada saldo, busca en SDescargaT las descargas que comparten (FACTIMPO, NUMPARTE, UNIMED).') +bullet('Calcula la porción que le toca a cada descarga (proporcional a su CANTDESC).') +bullet('Muestra el resumen y las primeras 50 filas del plan.') + +alerta('Paso A es 100% lectura', + 'No escribe nada en la base. Sirve para verificar números antes de aplicar.') + +h2('6.5 Exportar el plan a Excel') +parr('Si quieres ver TODAS las filas del plan (no solo las 50 que se muestran en pantalla), ' + 'da clic en "Exportar Excel completo". El archivo tiene dos hojas:') +bullet('Plan_Detalle: una fila por cada descarga que recibe prorrateo (con la porción exacta de cantidad, valores y pesos).') +bullet('Resumen: una fila por saldo con su estatus (PRORRATEADO, SIN_DESCARGAS, CANTDESC_CERO).') + +h2('6.6 Ejecutar el prorrateo (Paso B)') +parr('Cuando el análisis se ve bien:') +numbered('Mantén DRY_RUN activado y da clic en "Ejecutar prorrateo (Paso B)". La herramienta ' + 'simula los UPDATEs y te muestra los conteos exactos.') +numbered('Si los números cuadran, desactiva DRY_RUN y vuelve a dar clic.') +numbered('La herramienta corre las transacciones; al terminar verás un resumen con cuántas ' + 'descargas y cuántos saldos se actualizaron, y si hubo capeos (saldos donde el prorrateo ' + 'habría excedido el disponible real).') + +alerta('Importante sobre el capeo', + 'Si entre el momento en que corres el Paso A y el Paso B alguien más consume saldo ' + 'desde otra utilería, la herramienta vuelve a consultar el SALDO_DISPONIBLE real al ' + 'momento de ejecutar y aplica un factor de escala para que NUNCA se rebase el 100%. ' + 'Esto se registra como "CAPEO" en el log.') + + +# ====== 7 ====== +h1('7. Modo Automático — paso a paso') + +parr('La sección inferior de la pestaña hace lo mismo, pero sin Excel: el sistema busca él ' + 'mismo qué saldos están vencidos y los procesa.') + +h2('7.1 Define el rango de fechas') +bullet('Fecha inicio y fecha fin filtran SSaldoTem por FECHAFACTURA_ISO (la fecha de la factura IMPO).') +bullet('Adicionalmente la herramienta filtra por FECHAVENC_ISO < hoy. Es decir, solo trae ' + 'saldos que YA están vencidos al día en que corres el análisis.') + +info('Criterio exacto', + 'Saldo vencido = FECHAFACTURA_ISO dentro del rango + FECHAVENC_ISO menor a la fecha de ' + 'hoy + (CANTEXITENCIA − CANTUSADA − CANTUSADADESP) mayor a cero.') + +h2('7.2 Buscar saldos vencidos') +parr('Da clic en "Buscar saldos vencidos". El sistema muestra cuántos saldos encontró y ' + 'genera el plan de prorrateo (igual que el modo Excel, pero sin cruzar con un archivo).') + +h2('7.3 Exportar y ejecutar') +parr('Los botones "Exportar Excel completo" y "Ejecutar prorrateo (Paso B)" funcionan ' + 'exactamente igual que en el modo Excel. El comportamiento de DRY_RUN y el capeo es idéntico.') + +nota('Estado independiente', + 'El plan del modo Automático se guarda por separado del plan del modo Excel. Puedes ' + 'usar los dos modos en la misma sesión sin que uno pise al otro.') + + +# ====== 8 ====== +h1('8. Cómo funciona el prorrateo') + +parr('Para cada saldo vencido la herramienta hace lo siguiente:') + +numbered('Identifica el saldo en SSaldoTem por (FACTURAIMPO, NUMPARTE, UMEXITENCIA). ' + 'Lee CANTEXITENCIA, CANTUSADA, CANTUSADADESP, VALORIMPOMN/ME, PESONETO/BRUTO.') +numbered('Calcula SALDO_DISPONIBLE = CANTEXITENCIA − CANTUSADA − CANTUSADADESP. ' + 'Si está en cero, no hace nada.') +numbered('Calcula SALDO_APLICABLE = mínimo entre lo que viene en el Excel (modo Excel) ' + 'y el SALDO_DISPONIBLE real. En modo Automático, SALDO_APLICABLE = SALDO_DISPONIBLE.') +numbered('Busca todas las descargas en SDescargaT que cumplan: ' + 'FACTIMPO = FACTURAIMPO, NUMPARTE = NUMPARTE del saldo, UNIMED = UMEXITENCIA del saldo.') +numbered('Suma todos los CANTDESC de esas descargas. Cada descarga recibe una porción ' + 'proporcional a su tamaño: PORCION = SALDO_APLICABLE × (CANTDESC_descarga / SUMA_CANTDESC).') +numbered('Para los valores y pesos: la herramienta usa las tasas del saldo IMPO original ' + '(VALORIMPOMN / CANTEXITENCIA, etc.) en lugar de las de cada descarga. Esto mantiene ' + 'consistencia con el costo real del lote vencido.') + +h2('Ejemplo numérico') +parr('Imagina un saldo vencido de FACTURAIMPO=F1001, NUMPARTE=ABC123, UM=PZ con:') +code_block('CANTEXITENCIA = 1000\n' + 'CANTUSADA + CANTUSADADESP = 700\n' + 'SALDO_DISPONIBLE = 300\n' + 'VALORIMPOMN = 5000 → tasa_vmn = 5000 / 1000 = 5.0 por pieza') + +parr('Y tres descargas EXPO que comparten la llave:') +table_simple( + ['CONSECUTIVO', 'FACTEXPO', 'CANTDESC actual'], + [ + ['101', 'EXP001', '60'], + ['102', 'EXP002', '40'], + ['103', 'EXP003', '100'], + ], + col_widths_cm=[3, 4, 4]) + +parr('Suma de CANTDESC = 200. Cada descarga recibe (suponiendo Excel con CANTIDAD_SALDO=300):') +table_simple( + ['Descarga', 'Proporción', 'PORCION_CANT', 'PORCION_VMN'], + [ + ['101', '60/200 = 30%', '300 × 30% = 90', '90 × 5.0 = 450'], + ['102', '40/200 = 20%', '300 × 20% = 60', '60 × 5.0 = 300'], + ['103', '100/200 = 50%', '300 × 50% = 150', '150 × 5.0 = 750'], + ], + col_widths_cm=[3, 4, 4, 4]) + + +# ====== 9 ====== +h1('9. Validación contra el saldo real (capeo automático)') + +parr('Antes de aplicar cualquier UPDATE en el Paso B, la herramienta vuelve a consultar el ' + 'SALDO_DISPONIBLE real en SSaldoTem. Si la suma de porciones (calculadas en el Paso A) ' + 'es mayor que ese disponible —porque alguien más consumió saldo entre el análisis y la ' + 'ejecución— la herramienta:') + +bullet('Calcula un factor = SALDO_DISPONIBLE_actual / suma_de_porciones.') +bullet('Multiplica todas las porciones de ese saldo por el factor.') +bullet('Registra en el log: "CAPEO {factura}/{parte}/{um}: sum=X > disp=Y (factor=Z)".') + +alerta('Por qué importa', + 'Esto garantiza que NUNCA se rebase el 100% del saldo en SSaldoTem. La utilería ' + 'tradicional no hace esta verificación, por eso a veces dejaba CANTUSADA mayor a ' + 'CANTEXITENCIA. Aquí se previene en automático.') + + +# ====== 10 ====== +h1('10. Estatus de cada saldo en el resumen') + +table_simple( + ['Estatus', '¿Qué significa?'], + [ + ['PRORRATEADO', 'El saldo encontró descargas EXPO con la misma llave y se prorrateó completamente.'], + ['SIN_DESCARGAS', 'El saldo existe en SSaldoTem pero no hay ninguna fila en SDescargaT con esa combinación de FACTIMPO + NUMPARTE + UNIMED. No se puede repartir.'], + ['CANTDESC_CERO', 'Hay descargas con esa llave pero todas tienen CANTDESC = 0. No hay base para prorratear.'], + ], + col_widths_cm=[3.5, 12]) + +tip('Qué hacer con SIN_DESCARGAS', + 'Revísalos manualmente: pueden ser saldos que el cliente quiere amortizar con descargas ' + 'que aún no se cargaron, o casos donde la UM en el saldo no coincide con la UM en la ' + 'descarga (típico cuando hay sustitutos con UNIDADMEDIDA1/UNIDADMEDIDA2).') + + +# ====== 11 ====== +h1('11. Diferencias entre los dos modos') + +table_simple( + ['Aspecto', 'Modo Excel', 'Modo Automático'], + [ + ['Origen de los saldos', 'Tu archivo .xlsx', 'Búsqueda directa en SSaldoTem'], + ['Filtro de vencimiento', 'Tú decides qué incluir', 'FECHAVENC_ISO < hoy'], + ['Filtro de rango', 'FECHAFACTURA_ISO BETWEEN inicio Y fin', 'Igual'], + ['SALDO_APLICABLE', 'min(CANTIDAD_SALDO Excel, SALDO_DISPONIBLE)', 'SALDO_DISPONIBLE completo'], + ['Prorrateo', 'Igual', 'Igual'], + ['Validación 100%', 'Igual', 'Igual'], + ['UPDATEs aplicados', 'SDescargaT + SSaldoTem', 'Igual'], + ['Tablas no tocadas', 'SFacExp', 'Igual'], + ], + col_widths_cm=[3.5, 6, 6]) + + +# ====== 12 ====== +h1('12. Casos típicos y preguntas frecuentes') + +h3('"Corrí el Paso B con DRY_RUN apagado por error pero todo está bien, ¿pasa algo?"') +parr('No, mientras los números del log se vean correctos. Si DRY_RUN está apagado y la ' + 'herramienta dice "X SDescargaT actualizadas, Y SSaldoTem actualizadas, 0 errores", ' + 'la información se aplicó como esperabas.') + +h3('"Quiero deshacer un Paso B"') +parr('La herramienta no tiene botón de "deshacer". El procedimiento correcto es:') +bullet('Antes de ejecutar, exporta el plan a Excel y guárdalo como respaldo.') +bullet('Si necesitas revertir, usa el Excel del plan para construir los UPDATEs inversos ' + '(restar las porciones aplicadas en SDescargaT y SSaldoTem).') +bullet('Considera respaldar la BD antes de Paso B si los volúmenes son grandes.') + +h3('"En el modo Automático no aparecen saldos que yo sé que están vencidos"') +parr('Revisa estos tres puntos en SSaldoTem:') +bullet('FECHAFACTURA_ISO está dentro del rango que pusiste.') +bullet('FECHAVENC_ISO existe (NOT NULL) y es menor a hoy.') +bullet('SALDO_DISPONIBLE = CANTEXITENCIA − CANTUSADA − CANTUSADADESP es mayor a cero.') +parr('Si alguno de estos tres no se cumple, el saldo no aparece.') + +h3('"Las cantidades del plan no me cuadran con lo que esperaba"') +parr('Verifica que las descargas en SDescargaT realmente tengan la misma UNIMED que el ' + 'UMEXITENCIA del saldo. Si hay descargas con UM distinta (típico con sustitutos), no ' + 'matchean y el saldo aparece como SIN_DESCARGAS aunque "haya material".') + + +# ====== 13 ====== +h1('13. Apéndice técnico') + +h2('Columnas leídas de SSaldoTem') +code_block('FACTURAIMPO, FRACCIONIMPO, NUMPARTE, UMEXITENCIA, FECHAFACTURA_ISO, FECHAVENC_ISO,\n' + 'CANTEXITENCIA, CANTUSADA, CANTUSADADESP,\n' + 'VALORIMPOMN, VALORIMPOME, PESONETO, PESOBRUTO') + +h2('Columnas leídas de SDescargaT') +code_block('CONSECUTIVO, FACTIMPO, NUMPARTE, UNIMED, FACTEXPO, CANTDESC') + +h2('UPDATE aplicado a cada descarga prorrateada') +code_block('UPDATE SDescargaT\n' + ' SET CANTDESC = ISNULL(CANTDESC,0) + ?,\n' + ' VALORMN = ISNULL(VALORMN,0) + ?,\n' + ' VALORME = ISNULL(VALORME,0) + ?,\n' + ' PESONETO = ISNULL(PESONETO,0) + ?,\n' + ' PESOBRUTO= ISNULL(PESOBRUTO,0)+ ?\n' + ' WHERE CONSECUTIVO = ?') + +h2('UPDATE aplicado al saldo en SSaldoTem') +code_block('UPDATE SSaldoTem\n' + ' SET CANTUSADA = ISNULL(CANTUSADA,0) + ?,\n' + ' VALORUSADOMN = ISNULL(VALORUSADOMN,0) + ?,\n' + ' VALORUSADOME = ISNULL(VALORUSADOME,0) + ?,\n' + ' PESOUSADO = ISNULL(PESOUSADO,0) + ?,\n' + ' PESOBRUTOUSADO = ISNULL(PESOBRUTOUSADO,0) + ?\n' + ' WHERE FACTURAIMPO=? AND NUMPARTE=? AND UMEXITENCIA=?') + +h2('Transacciones') +bullet('Una transacción por saldo (combinación FACTURAIMPO + NUMPARTE + UMEXITENCIA).') +bullet('Commit automático al terminar todas las descargas + el UPDATE de SSaldoTem.') +bullet('Rollback automático si cualquier UPDATE de la transacción falla.') + +h2('Tablas que NO se tocan') +bullet('SFacExp: no cambia ESTATUS, APLICADESCMANUAL ni CANT_PARTIDAS.') +bullet('SDescargaM: este módulo no genera filas espejo (a diferencia de CTM).') + + +# ====== 14 ====== +h1('14. Glosario') + +table_simple( + ['Término', 'Significado'], + [ + ['SSaldoTem', 'Tabla maestra de saldos de importación temporales en SCAII.'], + ['SDescargaT', 'Tabla de descargas de las facturas de exportación contra saldos IMPO.'], + ['CANTEXITENCIA', 'Cantidad original que entró al lote IMPO (sic, así está escrito en SCAII).'], + ['CANTUSADA', 'Cantidad ya consumida del lote por descargas normales.'], + ['CANTUSADADESP', 'Cantidad usada por desperdicios.'], + ['SALDO_DISPONIBLE', 'CANTEXITENCIA − CANTUSADA − CANTUSADADESP.'], + ['CANTDESC', 'Cantidad descargada en una fila específica de SDescargaT.'], + ['UMEXITENCIA', 'Unidad de medida del saldo en SSaldoTem (sic).'], + ['UNIMED', 'Unidad de medida de la descarga en SDescargaT.'], + ['FECHAFACTURA_ISO', 'Fecha de la factura IMPO en formato ISO.'], + ['FECHAVENC_ISO', 'Fecha de vencimiento del saldo IMPO en formato ISO.'], + ['Prorrateo', 'Repartir un total entre varios destinos proporcionalmente a un peso.'], + ['DRY_RUN', 'Modo de simulación: la herramienta calcula y muestra los UPDATEs sin ejecutarlos.'], + ['Capeo', 'Reducir proporcionalmente las porciones para no rebasar el SALDO_DISPONIBLE real.'], + ], + col_widths_cm=[4, 12]) + + +# ====== Guardar ====== +import os +out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Manual_SaldosVencidos.docx') +doc.save(out) +print(f'Manual generado: {out}') diff --git a/app/generar_manual_valores.py b/app/generar_manual_valores.py new file mode 100644 index 0000000..a601138 --- /dev/null +++ b/app/generar_manual_valores.py @@ -0,0 +1,388 @@ +"""Manual de usuario de la pestana Valores. Lenguaje accesible, sin SQL ni +codigo. Para usuarios sin conocimientos tecnicos.""" +from docx import Document +from docx.shared import Pt, RGBColor, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement +import datetime + +doc = Document() +for sec in doc.sections: + sec.top_margin = Cm(2.5); sec.bottom_margin = Cm(2.5) + sec.left_margin = Cm(2.2); sec.right_margin = Cm(2.2) +style = doc.styles['Normal']; style.font.name = 'Calibri'; style.font.size = Pt(11) + + +def h1(t): + p = doc.add_heading(t, level=1) + for r in p.runs: r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +def h2(t): + p = doc.add_heading(t, level=2) + for r in p.runs: r.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5) + +def h3(t): + p = doc.add_heading(t, level=3) + for r in p.runs: r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +def parr(t, bold=False): + p = doc.add_paragraph(); r = p.add_run(t); r.bold = bold; return p + +def bullet(t): + p = doc.add_paragraph(t, style='List Bullet') + p.paragraph_format.left_indent = Cm(0.6); return p + +def numbered(t): + p = doc.add_paragraph(t, style='List Number') + p.paragraph_format.left_indent = Cm(0.6); return p + +def caja(titulo, text, fill, color): + p = doc.add_paragraph(); p.paragraph_format.left_indent = Cm(0.4) + pPr = p._p.get_or_add_pPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'), fill) + pPr.append(shd) + r = p.add_run(titulo + ': '); r.bold = True; r.font.color.rgb = color + p.add_run(text) + +def nota(t, x): caja(t, x, 'FFF8E1', RGBColor(0xE6, 0x5C, 0x00)) +def tip(t, x): caja(t, x, 'E8F5E9', RGBColor(0x2E, 0x7D, 0x32)) +def alerta(t, x): caja(t, x, 'FFEBEE', RGBColor(0xC6, 0x28, 0x28)) +def info(t, x): caja(t, x, 'E3F2FD', RGBColor(0x0D, 0x47, 0xA1)) + +def table_simple(headers, rows, col_widths=None): + t = doc.add_table(rows=1+len(rows), cols=len(headers)) + t.style = 'Light Grid Accent 1' + for i, h in enumerate(headers): + c = t.rows[0].cells[i]; c.text = h + for p in c.paragraphs: + for r in p.runs: r.bold = True; r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + tcPr = c._tc.get_or_add_tcPr() + shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:fill'),'1565C0') + tcPr.append(shd) + for ri, row in enumerate(rows, start=1): + for ci, val in enumerate(row): + t.rows[ri].cells[ci].text = str(val) + if col_widths: + for i, w in enumerate(col_widths): + for row in t.rows: row.cells[i].width = Cm(w) + return t + + +# ====== PORTADA ====== +ti = doc.add_paragraph(); ti.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = ti.add_run('\n\n\nPestana Valores') +r.bold = True; r.font.size = Pt(38); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0) + +su = doc.add_paragraph(); su.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = su.add_run('Ajuste prorrateado de\nVALORTOTALME y VALORTOTALMN') +r.font.size = Pt(18); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5) + +p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER +r = p.add_run('\n\nSistema de Utilerias 2.0\nGuia rapida para el usuario') +r.font.size = Pt(14) +r = p.add_run(f'\n\nVersion {datetime.date.today().isoformat()}') +r.font.size = Pt(11); r.italic = True + +doc.add_page_break() + + +# ====== CONTENIDO ====== +h1('Contenido') +for it in [ + '1. ¿Para que sirve esta pestana?', + '2. Antes de empezar', + '3. Como se ve la pantalla', + '4. Como subir tu Excel y generar el plan', + '5. Modos: Aplicar siempre vs Usar umbral', + '6. Shelter: buscar pedimentos en otras bases de datos', + '7. Paso A — Analizar (sin escribir)', + '8. Paso B — Ejecutar el ajuste', + '9. Estatus posibles por pedimento', + '10. ¿Algo no funciona? Lo mas comun', + '11. Glosario rapido', +]: + parr(it).paragraph_format.left_indent = Cm(0.4) +doc.add_page_break() + + +# ====== 1 ====== +h1('1. ¿Para que sirve esta pestana?') +parr('La pestana Valores te permite ajustar los valores de exportacion ' + '(VALORTOTALME y VALORTOTALMN) de las partidas en SCAII para que coincidan ' + 'con los valores que el cliente espera. Subes un Excel con los pedimentos ' + 'y sus valores correctos, y la herramienta reparte la diferencia entre las ' + 'partidas proporcionalmente, garantizando que la suma quede exacta.') + +info('En palabras simples', + 'Si la suma de los valores actuales de las partidas de un pedimento es ' + '900 y el cliente dice que debe ser 1,000, la herramienta sube cada ' + 'partida en la proporcion que le corresponde hasta que la suma de ' + 'exactamente 1,000.') + +parr('Ademas, si trabajas con varias bases de datos del cliente en la misma ' + 'instancia SQL Server, la pestana puede buscar automaticamente en cual ' + 'vive cada pedimento (modo Shelter) y aplicar el ajuste en la base ' + 'correcta sin que tu tengas que cambiar la conexion.') + + +# ====== 2 ====== +h1('2. Antes de empezar') +bullet('Asegurate que arriba diga "Conectado" en verde. Si no, ve a la pestana ' + 'Conexion y conecta primero.') +bullet('Ten listo tu Excel con los valores esperados. Debe tener 3 columnas: ' + 'PEDIMENTO, VALOR_ME, VALOR_MN.') +bullet('Identifica si los pedimentos de tu Excel viven en la base de datos ' + 'que tienes conectada o en otra. Si no estas seguro, deja activado el ' + 'modo Shelter y la herramienta lo descubre sola.') + +alerta('Recomendacion fundamental', + 'SIEMPRE corre primero con DRY_RUN activado. La pestana te muestra ' + 'que va a actualizar sin tocar la base. Cuando confirmes que los ' + 'numeros se ven bien, desactivas DRY_RUN y ejecutas en firme.') + + +# ====== 3 ====== +h1('3. Como se ve la pantalla') +parr('La pestana esta dividida en tres bloques de arriba hacia abajo:') +table_simple( + ['Bloque', '¿Que hace?'], + [ + ['Carga y configuracion', 'Subir Excel, descargar plantilla, elegir modo y umbral, activar Shelter.'], + ['Paso A — Analizar', 'Genera el plan sin escribir nada en la base.'], + ['Paso B — Ejecutar', 'Aplica los UPDATE en SCAII (con DRY_RUN como red de seguridad).'], + ], + col_widths=[4.5, 11]) +parr('Al final hay un bloque extra "Shelter" donde puedes hacer la busqueda ' + 'manual de los pedimentos en todas las bases (sin generar el plan, solo ' + 'para ver donde estan).') + + +# ====== 4 ====== +h1('4. Como subir tu Excel y generar el plan') + +h2('Paso 1 — Descarga la plantilla') +parr('Si es la primera vez, da clic en "Descargar plantilla". Se genera un ' + 'archivo de ejemplo con las 3 columnas que la herramienta espera. Abrelo, ' + 'borra las filas de ejemplo y pega los datos reales.') + +h2('Paso 2 — Llena el Excel') +table_simple( + ['Columna', 'Significado', 'Ejemplo'], + [ + ['PEDIMENTO', 'Numero de pedimento completo (con guiones).', '07-3429-4015540'], + ['VALOR_ME', 'Valor en moneda extranjera que debe cuadrar.', '12345.67'], + ['VALOR_MN', 'Valor en moneda nacional que debe cuadrar.', '234567.89'], + ], + col_widths=[3.5, 8, 4]) + +nota('Variantes aceptadas', + 'Los nombres de columna pueden venir con o sin guiones bajos, en ' + 'mayusculas o minusculas (VALOR_ME, VALORME, VALOR ME). La herramienta ' + 'acepta variantes razonables.') + +h2('Paso 3 — Sube el Excel') +parr('Da clic en "Subir Excel" y selecciona tu archivo. No hace nada todavia; ' + 'solo lo deja listo para cuando aprietes Analizar.') + + +# ====== 5 ====== +h1('5. Modos: Aplicar siempre vs Usar umbral') + +parr('Antes de analizar, elige uno de los dos modos:') + +h2('Modo "Aplicar siempre"') +parr('La herramienta ajusta los valores sin importar la magnitud de la ' + 'diferencia. Si los valores actuales son 10 y los esperados 10,000, ' + 'igual los multiplica por 1,000.') +tip('Cuando usar este modo', + 'Cuando estas seguro de los numeros del Excel y quieres que cuadren si ' + 'o si, aunque la diferencia sea enorme.') + +h2('Modo "Usar umbral %"') +parr('La herramienta solo aplica el ajuste si el cambio esta dentro del ' + 'porcentaje que tu definas con el slider. Por ejemplo, con umbral del ' + '50%, los pedimentos donde el valor actual y el esperado difieran en ' + 'mas del 50% se marcan como FUERA_DE_UMBRAL y NO se ajustan.') +tip('Cuando usar este modo', + 'Cuando quieres una proteccion adicional contra errores de captura en ' + 'el Excel. Si alguien escribio 100,000 en lugar de 1,000, el umbral lo ' + 'atrapa y no destroza los valores en la base.') + + +# ====== 6 ====== +h1('6. Shelter: buscar pedimentos en otras bases de datos') + +parr('Si tu cliente tiene varias bases de datos en el mismo servidor SQL ' + 'Server (por ejemplo, una por ano o por planta) y tus pedimentos pueden ' + 'estar en cualquiera, activa la casilla "Buscar en todas las BDs ' + '(shelter)" antes de dar clic en Analizar.') + +parr('Lo que hace el Shelter:') +numbered('Recorre todas las bases de datos online del servidor donde tu ' + 'usuario tenga permisos.') +numbered('Detecta cuales tienen la tabla SPedimentos.') +numbered('Busca cada pedimento de tu Excel en esas bases.') +numbered('Arma el plan poniendo en la columna BaseDeDatos a que base le toca ' + 'el ajuste por cada partida.') +numbered('Al ejecutar el Paso B, los UPDATE se aplican en la base correcta, ' + 'sin que tu hayas cambiado nada en la conexion.') + +info('Pedimento en mas de una base', + 'Si un pedimento existe en varias bases, la herramienta aplica el ' + 'ajuste en TODAS donde aparezca. Esto es lo correcto cuando el cliente ' + 'mantiene copias sincronizadas, pero presta atencion al resumen para ' + 'confirmarlo.') + +parr('Si solo trabajas con una base, deja la casilla DESACTIVADA. La ' + 'herramienta usa la base que esta conectada actualmente y es mas rapido.') + +h2('Boton "Buscar pedimentos en todas las BDs" (al final de la pestana)') +parr('Este boton hace solo el rastreo (sin generar plan, sin ajustar nada). ' + 'Te muestra 3 tablas:') +bullet('En que base(s) esta cada pedimento.') +bullet('Resumen por base: cuantos pedimentos del Excel encontro en cada una.') +bullet('Lista de pedimentos que no aparecen en ninguna base.') +parr('Es util para validar tu Excel antes de ajustar nada.') + + +# ====== 7 ====== +h1('7. Paso A — Analizar (sin escribir)') + +parr('Da clic en el boton "Analizar". La herramienta:') +numbered('Carga tu Excel.') +numbered('Si activaste Shelter, busca cada pedimento en todas las bases.') +numbered('Por cada pedimento (en cada base donde este), trae sus partidas de ' + 'exportacion y suma los VALORTOTALME y VALORTOTALMN actuales.') +numbered('Calcula los factores: cuanto hay que multiplicar para que cuadren.') +numbered('Genera el plan: una fila por cada partida que se va a tocar, con ' + 'el valor actual, el ajuste y el nuevo valor.') +numbered('Te muestra resumen + plan en pantalla.') + +alerta('IMPORTANTE', + 'El Paso A no escribe nada en la base. Es seguro correrlo las veces ' + 'que quieras.') + +h2('Exportar a Excel') +parr('Despues de Analizar, da clic en "Exportar Excel" para guardar el plan ' + 'completo (con todas las filas, no solo las primeras 50) en un archivo ' + 'xlsx. Util para mandarlo por correo, revisarlo con calma o entregarlo ' + 'al cliente para validar antes de aplicar.') + + +# ====== 8 ====== +h1('8. Paso B — Ejecutar el ajuste') + +parr('Cuando el plan se ve bien:') +numbered('Mantén marcado el checkbox "DRY_RUN (simular sin escribir)" la ' + 'primera vez.') +numbered('Da clic en "Ejecutar ajuste (Paso B)". Con DRY_RUN activado solo ' + 'simula los UPDATE; ves los conteos pero la base no cambia.') +numbered('Revisa los numeros del log:') +bullet('"Partidas actualizadas" debe ser igual al numero de filas del plan.') +bullet('"Pedimentos procesados" debe ser igual a los pedimentos de tu Excel ' + '(menos los que dieron error o no fueron encontrados).') +bullet('"BDs tocadas" te dice en cuales bases se aplicaria el cambio.') +bullet('"Errores" debe ser 0.') +numbered('Si todo cuadra, desmarca DRY_RUN y vuelve a dar clic en Ejecutar.') +numbered('Esta vez los cambios SI se aplican en la base. Al terminar veras ' + 'los mismos conteos pero los valores en SCAII ya estan ajustados.') + +alerta('Sobre el rollback', + 'La herramienta usa transacciones por pedimento. Si alguna partida ' + 'falla a mitad del proceso, las que ya se actualizaron de ESE ' + 'pedimento se revierten automaticamente. Las de los pedimentos ' + 'anteriores SI quedan guardadas (el rollback es solo por pedimento, ' + 'no global).') + + +# ====== 9 ====== +h1('9. Estatus posibles por pedimento') + +table_simple( + ['Estatus', '¿Que significa?'], + [ + ['AJUSTAR', 'El pedimento se va a ajustar. Es el caso normal y el unico que produce filas en el plan.'], + ['SIN_PARTIDAS', 'El pedimento existe pero no tiene partidas de exportacion para ajustar. Revisa SCAII manualmente.'], + ['SIN_BASE_ME', 'La suma actual de VALORTOTALME es cero pero el Excel pide un valor positivo. No hay forma de prorratear desde cero.'], + ['SIN_BASE_MN', 'Igual que el anterior pero para VALORTOTALMN.'], + ['FUERA_DE_UMBRAL', 'Solo aparece en modo "Usar umbral". La diferencia entre actual y esperado supera el porcentaje configurado. No se ajusta.'], + ['NO_ENCONTRADO_EN_BDS', 'Solo aparece con Shelter activado. El pedimento no esta en ninguna base accesible. Revisa que este bien escrito.'], + ['ERROR: ...', 'Algo fallo en la consulta a la base. Lee el detalle del mensaje y avisa a TI si no se entiende.'], + ], + col_widths=[4, 12]) + + +# ====== 10 ====== +h1('10. ¿Algo no funciona? Lo mas comun') + +h3('"ERROR: corre primero Analizar"') +parr('Diste clic en Ejecutar (Paso B) sin haber corrido Analizar (Paso A). ' + 'Corre primero Analizar para generar el plan.') + +h3('"Sube un Excel primero"') +parr('No has subido archivo o se borro la seleccion. Usa el boton "Subir ' + 'Excel" otra vez.') + +h3('Todos los pedimentos salen como SIN_PARTIDAS') +bullet('El formato del pedimento en tu Excel no coincide con el de SCAII. ' + 'Verifica que tengan los guiones y los ceros a la izquierda correctos.') +bullet('Estas conectado a una base distinta de donde viven los pedimentos. ' + 'Activa el Shelter para buscarlos en todas.') + +h3('Todos los pedimentos salen como NO_ENCONTRADO_EN_BDS') +bullet('Tu usuario de base de datos no tiene permisos en las bases donde ' + 'viven los pedimentos. Avisa a TI.') +bullet('Los pedimentos del Excel no existen en ninguna base. Verifica la ' + 'lista que te dieron.') + +h3('La suma del plan no cuadra con el Excel por unos centavos') +parr('La herramienta ajusta automaticamente la ultima fila de cada pedimento ' + 'para que la suma sea exacta. Si aun asi ves diferencias muy pequenas, ' + 'es por redondeo a 6 decimales — el efecto en pesos es despreciable.') + +h3('Apliqué el Paso B sin DRY_RUN y se me fue un ajuste mal') +parr('No hay deshacer automatico. Tienes que:') +numbered('Exportar a Excel el plan que se aplico (si lo tienes guardado).') +numbered('Construir el "plan inverso" en otro Excel (restando lo que sumaste ' + 'o viceversa).') +numbered('Subirlo y aplicarlo otra vez para revertir.') +parr('Por eso es CRITICO siempre correr DRY_RUN primero y exportar el plan a ' + 'Excel antes de ejecutar en firme.') + +h3('Un pedimento aparece en mas BDs de las que esperaba') +parr('Significa que el cliente tiene copias en varias bases. Decide si ' + 'realmente quieres ajustar en todas o solo en una. Si solo en una, ' + 'desactiva el Shelter y conecta la pestana a la base especifica ' + 'antes de ejecutar.') + + +# ====== 11 ====== +h1('11. Glosario rapido') +table_simple( + ['Termino', 'Significado'], + [ + ['Prorrateo', 'Repartir una diferencia entre varias partidas, en proporcion al valor actual de cada una.'], + ['VALORTOTALME', 'Valor total de una partida en moneda extranjera.'], + ['VALORTOTALMN', 'Valor total de una partida en moneda nacional.'], + ['Pedimento', 'Documento aduanero. En esta pestana lo identificamos por su numero completo: anio-aduana-patente-numero.'], + ['Partida', 'Renglon dentro de una factura de exportacion (un producto especifico con su cantidad y valor).'], + ['Umbral', 'Porcentaje maximo de diferencia permitido. Solo aplica si elegiste el modo "Usar umbral".'], + ['Shelter', 'Funcion que busca cada pedimento en todas las bases de datos del servidor y aplica el ajuste donde lo encuentre.'], + ['DRY_RUN', 'Modo simulacion: la herramienta calcula y muestra todo pero NO escribe en la base.'], + ['Plan', 'Tabla detallada con una fila por cada partida que se va a actualizar.'], + ['Resumen', 'Tabla con una fila por pedimento, indicando si va a ajustarse o no y por que.'], + ], + col_widths=[3.5, 12]) + + +import os +base = os.path.dirname(os.path.abspath(__file__)) +out = os.path.join(base, 'Manual_Valores.docx') +try: + doc.save(out) +except PermissionError: + ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + out = os.path.join(base, f'Manual_Valores_{ts}.docx') + doc.save(out) +print(f'Manual generado: {out}') diff --git a/app/launcher.py b/app/launcher.py new file mode 100644 index 0000000..b9c8f61 --- /dev/null +++ b/app/launcher.py @@ -0,0 +1,121 @@ +""" +Launcher de la app GENPACT V2. + +Arranca un servidor Voila local en background y abre el browser apuntando al app.ipynb. +Cuando se cierra la consola, el servidor de Voila se mata. + +Funciona tanto desde Python directo como empaquetado con PyInstaller. +""" +import os +import sys +import time +import socket +import threading +import webbrowser +import subprocess +from pathlib import Path + +# ------------------------------------------------------------------ +# Localizar app.ipynb y .env +# ------------------------------------------------------------------ +def base_dir() -> Path: + """Carpeta donde estan los recursos de la app. + - En modo PyInstaller (--onedir), sys._MEIPASS apunta a la carpeta temporal. + - En modo dev, es la carpeta del script. + """ + if getattr(sys, 'frozen', False): + # PyInstaller --onedir: los datos quedan al lado del .exe + return Path(sys.executable).parent + return Path(__file__).resolve().parent + + +BASE = base_dir() +APP_NB = BASE / 'app.ipynb' + +# Si el .env esta un nivel arriba (proyecto), copialo o setea el cwd +if not (BASE / '.env').exists() and (BASE.parent / '.env').exists(): + os.chdir(BASE.parent) +else: + os.chdir(BASE) + + +# ------------------------------------------------------------------ +# Buscar puerto libre +# ------------------------------------------------------------------ +def find_free_port(preferido: int = 8866) -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.bind(('127.0.0.1', preferido)) + sock.close() + return preferido + except OSError: + sock.close() + # Si el preferido esta ocupado, deja que el SO asigne uno + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('127.0.0.1', 0)) + p = s.getsockname()[1] + s.close() + return p + + +# ------------------------------------------------------------------ +# Arrancar Voila +# ------------------------------------------------------------------ +def main(): + if not APP_NB.exists(): + print(f'ERROR: no se encuentra {APP_NB}') + sys.exit(1) + + port = find_free_port(8866) + # Cuando Voila apunta a un notebook especifico, lo sirve en la raiz "/" + url = f'http://127.0.0.1:{port}/' + + print('=' * 60) + print(' GENPACT V2 - Sistema de Descargas SCAII') + print('=' * 60) + print(f' Iniciando servidor Voila en puerto {port}...') + print(f' URL: {url}') + print(' Cierra esta ventana para detener la app.') + print('=' * 60) + + # Construir comando de Voila + if getattr(sys, 'frozen', False): + # En modo empaquetado, llamamos al binario de python integrado + cmd = [sys.executable, '-m', 'voila', + str(APP_NB), + f'--port={port}', + '--no-browser', + '--Voila.ip=127.0.0.1', + '--strip_sources=True'] + else: + cmd = [sys.executable, '-m', 'voila', + str(APP_NB), + f'--port={port}', + '--no-browser', + '--Voila.ip=127.0.0.1', + '--strip_sources=True'] + + # Lanzar proceso + proc = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr) + + # Abrir browser cuando el server este listo + def _open(): + for _ in range(60): + try: + with socket.create_connection(('127.0.0.1', port), timeout=0.5): + break + except OSError: + time.sleep(0.5) + webbrowser.open(url) + + threading.Thread(target=_open, daemon=True).start() + + try: + proc.wait() + except KeyboardInterrupt: + print('\nDeteniendo Voila...') + proc.terminate() + + +if __name__ == '__main__': + main() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..60beffb --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,15 @@ +pandas>=2.0 +numpy>=1.24 +openpyxl>=3.1 +python-dotenv>=1.0 +pyodbc>=5.0 +matplotlib>=3.7 +ipywidgets>=8.0 +voila>=0.5 +nbconvert>=7.0 +jupyter_server>=2.0 +notebook>=7.0 +scikit-learn>=1.3 +pyinstaller>=6.0 +psycopg2-binary>=2.9 +sqlalchemy>=2.0 diff --git a/app/run_dev.bat b/app/run_dev.bat new file mode 100644 index 0000000..1818996 --- /dev/null +++ b/app/run_dev.bat @@ -0,0 +1,97 @@ +@echo off +REM Ejecuta la app en modo desarrollo (sin empaquetar) +REM Requiere Python instalado + +cd /d "%~dp0" +echo Directorio: %CD% +echo. + +REM Verificar Python +where python >nul 2>nul +if errorlevel 1 ( + echo [ERROR] Python no esta en PATH. + echo Instala Python 3.11+ y marca "Add Python to PATH" durante la instalacion. + goto :end +) +python --version + +if not exist .venv goto :crear_venv +call .venv\Scripts\activate.bat +goto :sync_deps + +:crear_venv +echo. +echo Creando venv local, puede tardar 2-3 minutos... +python -m venv .venv +if errorlevel 1 ( + echo [ERROR] No se pudo crear el venv. + goto :end +) +call .venv\Scripts\activate.bat +python -m pip install --upgrade pip + +:sync_deps +REM Reinstala requirements solo si requirements.txt es mas nuevo que el marcador +if not exist .venv\.deps_synced goto :do_install +for /f %%i in ('dir /b /od requirements.txt .venv\.deps_synced 2^>nul') do set _newer=%%i +if /i "%_newer%"==".deps_synced" goto :run_app +:do_install +echo. +echo [INFO] Sincronizando dependencias (requirements.txt)... +python -m pip install -r requirements.txt +if errorlevel 1 ( + echo [ERROR] Fallo la instalacion de dependencias. + goto :end +) +echo. > .venv\.deps_synced + +:run_app + +REM ===== Postgres en Docker (DataStage) ===== +where docker >nul 2>nul +if errorlevel 1 ( + echo [INFO] Docker no esta instalado. La pestaña DataStage no funcionara. + echo Instala Docker Desktop si vas a usarla. + goto :launch_voila +) + +docker ps --format "{{.Names}}" | findstr /B /C:"postgres-datastage" >nul 2>nul +if not errorlevel 1 ( + echo [INFO] Contenedor postgres-datastage ya esta corriendo. + goto :launch_voila +) + +echo [INFO] Levantando contenedor postgres-datastage... +docker compose up -d postgres-datastage +if errorlevel 1 ( + echo [WARN] No se pudo levantar Postgres. La pestaña DataStage no funcionara. + goto :launch_voila +) +echo [INFO] Esperando a que Postgres acepte conexiones... +docker compose exec -T postgres-datastage pg_isready >nul 2>nul +set /a _retries=0 +:wait_pg +set /a _retries+=1 +if %_retries% GTR 20 ( + echo [WARN] Postgres tardo en levantar. Continuamos de todos modos. + goto :launch_voila +) +docker compose exec -T postgres-datastage pg_isready >nul 2>nul +if errorlevel 1 ( + timeout /t 1 /nobreak >nul + goto :wait_pg +) +echo [INFO] Postgres listo. + +:launch_voila +echo. +echo Lanzando la app... +echo (Si el browser no abre, ve a http://127.0.0.1:8866/) +echo. +python launcher.py + +:end +echo. +echo ============================================================ +echo Presiona cualquier tecla para cerrar... +pause >nul diff --git a/app/schema_registro.sql b/app/schema_registro.sql new file mode 100644 index 0000000..c0f9cec --- /dev/null +++ b/app/schema_registro.sql @@ -0,0 +1,558 @@ +-- +-- PostgreSQL database dump +-- + +\restrict NkLZPgiIuy1DEwGxiddM38TIL7f97hE2jK7DUCZtx3dZAdwepDWiHdqf075r6Nt + +-- Dumped from database version 16.13 +-- Dumped by pg_dump version 16.13 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: Registro501; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro501" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "TipoOperacion" character varying(5), + "ClaveDocumento" character varying(10), + "SeccionAduaneraEntrada" character varying(10), + "CurpContribuyente" character varying(25), + "Rfc" character varying(20), + "CurpAgenteA" character varying(25), + "TipoCambio" numeric(15,5), + "TotalFletes" numeric(18,2), + "TotalSeguros" numeric(18,2), + "TotalEmbalajes" numeric(18,2), + "TotalIncrementables" numeric(18,2), + "TotalDeducibles" numeric(18,2), + "PesoBrutoMercancia" numeric(18,3), + "MedioTransporteSalida" character varying(5), + "MedioTransporteArribo" character varying(5), + "MedioTransporteEntrada_Salida" character varying(5), + "DestinoMercancia" character varying(5), + "NombreContribuyente" character varying(200), + "CalleContribuyente" character varying(200), + "NumInteriorContribuyente" character varying(20), + "NumExteriorContribuyente" character varying(20), + "CPContribuyente" character varying(10), + "MunicipioContribuyente" character varying(100), + "EntidadFedContribuyente" character varying(5), + "PaisContribuyente" character varying(5), + "TipoPedimento" character varying(5), + "FechaRecepcionPedimento" timestamp without time zone, + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro502; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro502" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "RfcTransportista" character varying(20), + "CurpTransportista" character varying(25), + "NombreTransportista" character varying(200), + "PaisTransporte" character varying(5), + "IdentificadorTransporte" character varying(50), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro503; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro503" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "NumeroGuia" character varying(50), + "TipoGuia" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro504; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro504" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "NumContenedor" character varying(50), + "TipoContenedor" character varying(10), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro505; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro505" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "FechaFacturacion" character varying(20), + "NumeroFactura" character varying(50), + "TerminoFacturacion" character varying(10), + "MonedaFacturacion" character varying(10), + "ValorDolares" numeric(18,2), + "ValorMonedaExtranjera" numeric(18,2), + "PaisFacturacion" character varying(5), + "EntidadFedFacturacion" character varying(5), + "IndentFiscalProveedor" character varying(50), + "ProveedorMercancia" character varying(200), + "CalleProveedor" character varying(200), + "NumInteriorProveedor" character varying(20), + "NumExteriorProveedor" character varying(20), + "CpProveedor" character varying(20), + "MunicipioProveedor" character varying(100), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro506; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro506" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "TipoFecha" character varying(5), + "FechaOperacion" character varying(20), + "FechaValidacionPagoR" timestamp without time zone +); + + +-- +-- Name: Registro507; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro507" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ClaveCaso" character varying(10), + "IdentificadorCaso" character varying(50), + "TipoPedimento" character varying(5), + "ComplementoCaso" character varying(100), + "FechaValidacionPagoR" timestamp without time zone +); + + +-- +-- Name: Registro508; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro508" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "InstitucionEmisora" character varying(50), + "NumeroCuenta" character varying(50), + "FolioConstancia" character varying(50), + "FechaConstancia" character varying(20), + "TipoCuenta" character varying(10), + "ClaveGarantia" character varying(10), + "ValorUnitarioTitulo" numeric(18,5), + "TotalGarantia" numeric(18,2), + "CantidadUnidades" numeric(18,3), + "TitulosAsignados" character varying(50), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro509; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro509" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ClaveContribucion" character varying(10), + "TasaContribucion" numeric(15,5), + "TipoTasa" character varying(5), + "TipoPedimento" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro510; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro510" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ClaveContribucion" character varying(10), + "FormaPago" character varying(5), + "ImportePago" numeric(18,2), + "TipoPedimento" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro511; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro511" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "SecuenciaObservacion" character varying(10), + "Observaciones" text, + "TipoPedimento" character varying(5), + "FechaValidacionPagoR" timestamp without time zone +); + + +-- +-- Name: Registro512; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro512" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "PatenteAduanalOrig" character varying(10), + "PedimentoOriginal" character varying(20), + "SeccionAduaneraDespOrig" character varying(10), + "DocumentoOriginal" character varying(10), + "FechaOperacionOrig" character varying(20), + "FraccionOriginal" character varying(15), + "UnidadMedida" character varying(5), + "MercanciaDescargada" numeric(18,3), + "TipoPedimento" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro520; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro520" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "IndentFiscalDestinatario" character varying(50), + "NombreDestinatarioMercancia" character varying(200), + "CalleDestinatario" character varying(200), + "NumInteriorDestinatario" character varying(20), + "NumExteriorDestinatario" character varying(20), + "CpDestinatario" character varying(20), + "MunicpioDestinatario" character varying(100), + "PaisDestinatario" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro551; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro551" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "SubdivisionFraccion" character varying(10), + "DescripcionMercancia" text, + "PrecioUnitario" numeric(18,5), + "ValorAduana" numeric(18,2), + "ValorComercial" numeric(18,2), + "ValorDolares" numeric(18,2), + "CantidadUMComercial" numeric(18,3), + "UnidadMedidaComercial" character varying(5), + "CantidadUMTarifa" numeric(18,5), + "UnidadMedidaTarifa" character varying(5), + "ValorAgregado" numeric(18,2), + "ClaveVinculacion" character varying(5), + "MetodoValorizacion" character varying(5), + "CodigoMercanciaProducto" character varying(50), + "MarcaMercanciaProducto" character varying(100), + "ModeloMercanciaProducto" character varying(100), + "PaisOrigenDestino" character varying(5), + "PaisCompradorVendedor" character varying(5), + "EntidadFedOrigen" character varying(5), + "EntidadFedDestino" character varying(5), + "EntidadFedComprador" character varying(5), + "EntidadFedVendedor" character varying(5), + "TipoOperacion" character varying(5), + "ClaveDocumento" character varying(10), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro552; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro552" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "VinNumeroSerie" character varying(50), + "KilometrajeVehiculo" character varying(20), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro553; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro553" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "ClavePermiso" character varying(10), + "FirmaDescargo" character varying(10), + "NumeroPermiso" character varying(50), + "ValorComercialDolares" numeric(18,2), + "CantidadMUMTarifa" numeric(18,5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro554; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro554" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "ClaveCaso" character varying(10), + "IdentificadorCaso" character varying(50), + "ComplementoCaso" character varying(100), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro555; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro555" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "InstitucionEmisora" character varying(50), + "NumeroCuenta" character varying(50), + "FolioConstancia" character varying(50), + "FechaConstancia" character varying(20), + "ClaveGarantia" character varying(10), + "ValorUnitarioTitulo" numeric(18,5), + "TotalGarantia" numeric(18,2), + "CantidadUnidadesMedida" numeric(18,3), + "TitulosAsignados" character varying(50), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro556; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro556" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "ClaveContribucion" character varying(10), + "TasaContribucion" numeric(15,5), + "TipoTasa" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro557; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro557" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "ClaveContribucion" character varying(10), + "FormaPago" character varying(5), + "ImportePago" numeric(18,2), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro558; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro558" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "SecuenciaObservacion" character varying(10), + "Observaciones" text, + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro701; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro701" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ClaveDocumento" character varying(10), + "FechaPago" character varying(20), + "PedimentoAnterior" character varying(20), + "PatenteAnterior" character varying(10), + "SeccionAduaneraAnterior" character varying(10), + "DocumentoAnterior" character varying(10), + "FechaOperacionAnterior" character varying(20), + "PedimentoOriginal" character varying(20), + "PatenteAduanalOrig" character varying(10), + "SeccionAduaneraDespOrig" character varying(10), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: Registro702; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."Registro702" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ClaveContribucion" character varying(10), + "FormaPago" character varying(5), + "ImportePago" numeric(18,2), + "TipoPedimento" character varying(5), + "FechaPagoReal" timestamp without time zone +); + + +-- +-- Name: RegistroInci; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."RegistroInci" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ConsecutivoRemesa" character varying(10), + "NumeroSeleccion" character varying(10), + "FechaInicioReconocimiento" character varying(20), + "HoraInicioReconocimiento" character varying(10), + "FechaFinReconocimiento" character varying(20), + "HoraFinReconocimiento" character varying(10), + "Fraccion" character varying(15), + "SecuenciaFraccion" character varying(10), + "ClaveDocumento" character varying(10), + "TipoOperacion" character varying(5), + "GradoIncidencia" character varying(5), + "FechaSeleccion" character varying(20) +); + + +-- +-- Name: RegistroResumen; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."RegistroResumen" ( + "Folio" character varying(20), + "RFCoPatenteAduanal" character varying(20), + "Fecha_Inicial" character varying(20), + "Fecha_Final" character varying(20), + "Fecha_Ejecucion" character varying(20), + "Total_Fracciones" integer, + "Total_Contribuciones" integer +); + + +-- +-- Name: RegistroSel; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public."RegistroSel" ( + "Patente" character varying(10), + "Pedimento" character varying(20), + "SeccionAduanera" character varying(10), + "ConsecutivoRemesa" character varying(10), + "NumeroSeleccion" character varying(10), + "FechaSeleccion" character varying(20), + "HoraSeleccion" character varying(10), + "SemaforoFiscal" character varying(5), + "ClaveDocumento" character varying(10), + "TipoOperacion" character varying(5) +); + + +-- +-- Name: base_numpartes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.base_numpartes ( + numparte text NOT NULL, + descripcion text, + unimed text, + fraccion text +); + + +-- +-- Name: base_numpartes base_numpartes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.base_numpartes + ADD CONSTRAINT base_numpartes_pkey PRIMARY KEY (numparte); + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict NkLZPgiIuy1DEwGxiddM38TIL7f97hE2jK7DUCZtx3dZAdwepDWiHdqf075r6Nt +