From a0081f54d2b0daa6055182ec33b67b2dd7f4606d Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 21 May 2026 08:18:13 -0600 Subject: [PATCH] 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) --- .env.example | 18 + .gitignore | 43 + README.md | 162 + app/Manual_CTM.docx | Bin 0 -> 47034 bytes app/Manual_DataStage.docx | Bin 0 -> 40937 bytes app/Manual_EstructurasSCAII.docx | Bin 0 -> 46897 bytes app/Manual_GENPACT_V2.docx | Bin 0 -> 50986 bytes app/Manual_SaldosVencidos.docx | Bin 0 -> 43928 bytes app/Manual_Valores.docx | Bin 0 -> 42165 bytes app/app.ipynb | 4701 +++++++++++++++++++++++++ app/docker-compose.yml | 21 + app/generar_manual.py | 645 ++++ app/generar_manual_ctm.py | 497 +++ app/generar_manual_datastage.py | 303 ++ app/generar_manual_estructuras.py | 437 +++ app/generar_manual_saldos_vencidos.py | 503 +++ app/generar_manual_valores.py | 388 ++ app/launcher.py | 121 + app/requirements.txt | 15 + app/run_dev.bat | 97 + app/schema_registro.sql | 558 +++ 21 files changed, 8509 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/Manual_CTM.docx create mode 100644 app/Manual_DataStage.docx create mode 100644 app/Manual_EstructurasSCAII.docx create mode 100644 app/Manual_GENPACT_V2.docx create mode 100644 app/Manual_SaldosVencidos.docx create mode 100644 app/Manual_Valores.docx create mode 100644 app/app.ipynb create mode 100644 app/docker-compose.yml create mode 100644 app/generar_manual.py create mode 100644 app/generar_manual_ctm.py create mode 100644 app/generar_manual_datastage.py create mode 100644 app/generar_manual_estructuras.py create mode 100644 app/generar_manual_saldos_vencidos.py create mode 100644 app/generar_manual_valores.py create mode 100644 app/launcher.py create mode 100644 app/requirements.txt create mode 100644 app/run_dev.bat create mode 100644 app/schema_registro.sql 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 0000000000000000000000000000000000000000..05615d7db1c2a166d3c7c22b15cc31f54f757736 GIT binary patch literal 47034 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 literal 0 HcmV?d00001 diff --git a/app/Manual_DataStage.docx b/app/Manual_DataStage.docx new file mode 100644 index 0000000000000000000000000000000000000000..a30eaa557fbcaa88eb936e2665ebb6ce9e4793e4 GIT binary patch literal 40937 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 literal 0 HcmV?d00001 diff --git a/app/Manual_EstructurasSCAII.docx b/app/Manual_EstructurasSCAII.docx new file mode 100644 index 0000000000000000000000000000000000000000..a9ab4729bf98be0b7e14e63b34da52f786f2abd0 GIT binary patch literal 46897 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

-bZHBG@c<9X;t}c1i^GOq38U#F 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

yRlxe9BS7 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 literal 0 HcmV?d00001 diff --git a/app/Manual_SaldosVencidos.docx b/app/Manual_SaldosVencidos.docx new file mode 100644 index 0000000000000000000000000000000000000000..96ae539f5b24ce8cae0d40ea1644401fea931ca6 GIT binary patch literal 43928 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 literal 0 HcmV?d00001 diff --git a/app/Manual_Valores.docx b/app/Manual_Valores.docx new file mode 100644 index 0000000000000000000000000000000000000000..7642f7e738be932085112fa61afbd459e8257f37 GIT binary patch literal 42165 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%0DX?C5NwJAXCl5dq8q~AR zlH#ou?#!;oVUsfgYoAtM^1L7SUh-6VdSq=`TEbo_UvXsSH6MBCf7Z4<={~OO1|c(+ zd<#y>gXlR1=wY0Z%goFi zW6{flQ|mId2x1l4Sov>hUK>9S40mvVg}sW|*Bj|7DYDE;A4#nlKjPwUlyWH{UOmei zcZt?=$)dz*osbxKlUnOn^tSOsDOXwQ#AQQ4RXfvCZ=hr8a^##Kq1$>;P$OuiRDjK> zFV7yhK_TP!9j@hxRuxV{-D;y<&(x&NKH|V-yu*#VYe5Nom7PwC?L}5K-V{=Fl+xgG zxR73QBge_(;&OrAVfqS?hV`t@j85{UMig`id(DbZvXSehrwI$lozIk|&5Z^d;+_r9 zMTgf2U*x{4CL)qH0{_PSuG{Q=**K2|=kXR?ir^;wX^b9=!Gia{BEMpG1B17%`_a-jzH;a~;xHgr9^%k=i? zwAV?Rfci9Mc!1>Xv6$3NS-)^9x!}4tE`Xasup@FHffL{N*{JnZoxC@<%w>1qVZ-@v3}?jslkHAj6TIS z)Cj0#AkzIfMTv!31k`y`HM3$9b$LW%quLKG_;{5@e_Ge!ATTl+4XIcXK_*yIiG^;HRYdSmGREXpu~E; zaf!CXwEnazK^u@QB}G$MV0+WNH{lF|W-y$k zQ`uu^RXFPHpc8RJAYM5*Fv?CV#hU@dM=Ha)Wli6;=&wmZtcr8ZQH>;Id{D)nvrHdS z(~OpB<`O)+_`ojF;mz3&m_3XzyD0y#>;E^0{hxKec7_Fx*TMXAY^1LpD%1^D<>r=5 z`Pvt_aEp>o)D>~#jp0RjtqQU$odxRfBs?n=_m{5r{#X^w5)84ErN!mR0Z^xNXMtVI z1%HQRsz&Dq)|Xtnj|#63KRoZTX)&`piFhGFL(;-k;#f7rj?Jc#MwK!WEUe98n&V!7 zXks~$*?Kp1)kWRLs80Tp_RQF>keCv6rOr8{J&2;!HeisGrJ)ViR4D9wS{+|x&lbso z>^SzFrjmwYS(~QV?ffm=pAQt&rl_yfH^Hvh!HywCa?RGf(lw52x*ukiBf`xIC zfez($#nb%ItoAN`KH5Q%Y7tRuMB$bo`T-gtx3{a#169*V9GG;cl$+_FPoQ7gEk_$^ zWgP^)kT&}a$cD1TK6ksVMiH_sbKoYKIukQg>bEQS4WiN>5yHMIb@{0w=$v7$Cb{Q2 zHNmrZ2vtbaROT=5?z8r?!@3Z0$7+dq{7XGh zL?RyoUqgYoratf`E$9ruKwP_nU@=wGc zKaYbFRxl&*T?Gz==pj^E%wrVw#i zYD$>2MV1&(7M5hwqwSso1;cyKQ`WVQlWI2XN6S8Iv69nzkAtV)QhYVjiulScuMXx7 zu|rm`$oue6Ckx>awY(b9+rVV|dxuNOZKKRs!1)>>9|%OJ@evbwmbjNgvSzXL##EH2xEvbR*`7Va;T2;Y^=h*KQ2BO z3KP6rR)&Znt}@67e2F=T=+M+F$NPz?wKlmkH}w|$%+aU-6B{U_V%v|XhpiF+s=Xvv zcFzFB*eqKVQ1`R7s6+6b%1GU&88aJLfdC@G(^#<2o2JtkGr=DJ1AU45)J0Y`{R0sL zQ|8z-osuj`mREPWimza7TsEc=e^OK#1B+QY#wpvZB(07Thgvi<9YGKJwHjFTJ;dg& z2KEK675&-HM&=;es~fg{9+@iRvb|tdumn^{WL4C{q?mrouj8jdd>-C z@pRFnV4D++#877m1n1l+3%TjtOGO>rfK2wBE=}4P>b8O47|Qo#7#(slCXC~!;^oO* zUtgzIcglIppZB8Lc1)}tb{s1jya09_ORggK=xgcsZ-jVDb0Rrp6NoHz;C1#yVDr6& z!{|2KRnlq51E*lqjkDaI9r@qgV>UdAFbNk*EYsu%)?E{x_~fB0^FUu%Ut7TOmgUwF zypDuaz@%x=tWx-jY?sHpz3)U+_Mfqt*zu8j+jzPQ*(F)w1gw{elLQLc zYn(&n5|!{W7>wUdrzmH~Ujx2}J@dJ*!+-6T<*YgxDDp~LTm~5%9iO z$(Q9qRMp^k!?FG4#oNxTvt`$DVef5+f`J0VzKK&@Z&WS}cykYl^M=k52T-+>P=)+H zfq)wT+SJm!2C#**!Wb_2IAfyIYS$vSip~}t$Tt|um#T|KBuq{a5M;B~>b!%(g^#83 zYtZI~`}k`&s@|Awh)I*$M8&Bf8ijd+6Ld7jS0K9; zZvvm;wG41VY)vlajP%WuMN$v!P}rUN`EHgI2)vQL#pj-)UZC+m!F(oJ2fRSPA~q~! zBg|z@%_z4&5vmfkEg}uW(`p^282lJtgC{k{!f#ovj*=uY`{T<>?SA-X_jhFYWC9yS z6KuMomtc+?o8g%jvy^B~wQPkH?_2tdv`%(%)eD-fmyMA)miCIS6ZA{+vqBqz*p)!= zhK!dUxS@$RZafGajgEjKK_R+~9Xw(L$Cwm|$c;hc)&4aMpo4IZW_7~*P8@y-X_EPhJP>Xk9nmiQCl~6s;VV)Hy0H$l|@2pOU8&_g3dG?gn2( zQ%5SQw32ZS5rWO8tsZ7Jr*-_&<1JiNlH)1 zxadqsf}2(q^l|mLvWFF8Feo{dCEr)4*GAOlZt;uhirtFM-AM)q))Kt~UQ^Ib0A%Q3 zT@)FwDRXa2bmIaxdmqtvRFECTzJFIULspaGg|r6(JPHDHEphdz+7a8PnU%_Z;x!|2 zd2Ala3?+Gfypk}lPz}Z!IVL&U{c2m*s&jsw|E5g*l?Zw8?EL=yf*nuj2JQ`iTVufC zX0xQ_G{IhEcVT8Z>&)ivFmvp7NoHWDq|Z|IlKUaqYbv_3guSfleUVnJoqYx{<9paP z#XQfgs`8I*CJm1gl{_uZ?9_4m$`KNuLvy%4Z=fM4Mk??`1k!~o1zI9a9OZevFmz*C z6=XOQ?`7abNV`GLd4r7CxQP=mPaja3j;f296G3Z$0L|Ep30JD)%{A@>#I;{}?$)@W z=O~pFOh$Pa>u!{#&Q~d69Dp~;`TPTJE&i9Bv`~0s5Z)heqGPfD4fJID!Qk1u$`c4A`6N*L^4H- zVs?qf(WdPqNrzGrN41=mQ{5SxYsVam_zuU-@Zs+hqp|flv#ojL7+H_PLy=83!hj>} zQ;!WdreY0~bNhk4-j(3S zgee|xPj{t*b^0G(%alF3M`txj8?x%IBfE29lztnNpgl@&pKrw_IeuG<+d2OEt|N1) z27cy{ikgnAImpu$&nDaL>R8d0v!NB=8?oE{kv_xkor~a*SE1^_XY0U-H($*t@IZVLg|BPy>y8B1k~ThAAfUk?~5ed(XR-N9oJ1vFkYRq~5H-`_Q-xa~!A$Jg>wG6&;+LXt1tHmk~ zL?X4yd;gz@d*1MiUNKdLM{xMR!s94_0CA9B)vKcTWSECK42!w_B|HB41;EMx09`3q zFxcF|&G5CSlew$@U*6e60#617@gJ~TS732u^ncK>bHJm17{&kl_`h8HhrsYqbaiXk zEt4=H&L6;SSjqq99#cn`UyRXzf%iu{ktBVZcm7ySP%|2N5hwn91b z7HcFYOeJU_0;y1(d$iEr>NJ~7#Ka3jx#!p25jei(Jen|09 zfB&W!O8ieX{*d6I(EUx2pY(5n|B}5AxgKiA-&`5V|K|ErNj_wGD7AjG)TR90Dt`#C mhxmv7;BUNQ#=pAQpWdOW0wSyj{ne{*0LHNHA)5Es)&BvZSsDlc literal 0 HcmV?d00001 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", + " )\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 +

qZ^kmBCUzeZ#VUs4@pV&02>7`bO!PZ; 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