Files
utilerias-recon-2-exe/app/app.ipynb
2026-05-25 08:20:43 -06:00

5431 lines
335 KiB
Plaintext

{
"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",
" if df is None or df.empty or 'CANT_DESCARGADA' not in df.columns:\n",
" return pd.DataFrame(columns=['FACTURAEXPO','FECHA_FACTURAEXPO',\n",
" 'componentes_total','componentes_100pct','pct_promedio'])\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)\n",
"\n",
"\n",
"# =============================================================\n",
"# REASIGNACION DE LINEAS EXPO\n",
"# Mueve filas de SDescargaT (FACTEXPO, FACREFERENCIA, LINEAEXPO) a otra\n",
"# factura/linea EXPO indicada en un Excel.\n",
"# =============================================================\n",
"\n",
"import io as _io_reasig\n",
"\n",
"def generar_plantilla_reasignacion():\n",
" \"\"\"Devuelve bytes de un xlsx con la estructura esperada.\"\"\"\n",
" df = pd.DataFrame([\n",
" {'FACTURA_EXPO': 'EXP240001', 'LINEA_EXPO': 1, 'NUMPARTE': 'ABC-12345',\n",
" 'UNIMED': 'PZA', 'LINEA_EXPO_NUEVA': 2, 'FACTURA_NUEVA': 'EXP240002'},\n",
" {'FACTURA_EXPO': 'EXP240003', 'LINEA_EXPO': 5, 'NUMPARTE': 'DEF-67890',\n",
" 'UNIMED': 'KGS', 'LINEA_EXPO_NUEVA': 5, 'FACTURA_NUEVA': 'EXP240003'},\n",
" ])\n",
" buf = _io_reasig.BytesIO()\n",
" with pd.ExcelWriter(buf, engine='openpyxl') as w:\n",
" df.to_excel(w, sheet_name='Reasignacion', index=False)\n",
" buf.seek(0)\n",
" return buf.read()\n",
"\n",
"\n",
"def cargar_excel_reasignacion(path):\n",
" \"\"\"Lee Excel con 6 columnas. Acepta variantes razonables de nombres.\"\"\"\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",
" 'FACTURA_EXPO': ['FACTURA_EXPO', 'FACTURAEXPO', 'FACTURA_ORIGEN', 'FACTEXPO'],\n",
" 'LINEA_EXPO': ['LINEA_EXPO', 'LINEAEXPO', 'LINEA_ORIGEN', 'LINEA'],\n",
" 'NUMPARTE': ['NUMPARTE', 'NUM_PARTE', 'NUMERO_PARTE', 'PARTE'],\n",
" 'UNIMED': ['UNIMED', 'UNIDAD_DE_MEDIDA', 'UM', 'UNIDAD'],\n",
" 'LINEA_EXPO_NUEVA': ['LINEA_EXPO_NUEVA', 'LINEANUEVA', 'LINEA_NUEVA', 'LINEA_DESTINO'],\n",
" 'FACTURA_NUEVA': ['FACTURA_NUEVA', 'FACTURANUEVA', 'FACTURA_DESTINO'],\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",
" for c in ['FACTURA_EXPO','NUMPARTE','UNIMED','FACTURA_NUEVA']:\n",
" df2[c] = df2[c].fillna('').astype(str).str.strip()\n",
" for c in ['LINEA_EXPO','LINEA_EXPO_NUEVA']:\n",
" df2[c] = pd.to_numeric(df2[c], errors='coerce').fillna(0).astype(int)\n",
" df2 = df2[df2['FACTURA_EXPO'] != ''].reset_index(drop=True)\n",
" return df2\n",
"\n",
"\n",
"def analizar_reasignacion(df_excel, progress=None, log=print):\n",
" \"\"\"Paso A: para cada fila del Excel verifica que (a) la descarga origen\n",
" sea unica en SDescargaT, (b) el destino exista en SFacExp+SPartidasExpo.\n",
" Devuelve (plan_ok, inconsistencias).\"\"\"\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",
" prog.setup(len(df_excel), 'Validando filas...')\n",
" ok_rows = []\n",
" inc_rows = []\n",
" for _, r in df_excel.iterrows():\n",
" f_ori = r['FACTURA_EXPO']\n",
" l_ori = int(r['LINEA_EXPO'])\n",
" npart = r['NUMPARTE']\n",
" umed = r['UNIMED']\n",
" l_new = int(r['LINEA_EXPO_NUEVA'])\n",
" f_new = r['FACTURA_NUEVA']\n",
" base = {'FACTURA_EXPO': f_ori, 'LINEA_EXPO': l_ori, 'NUMPARTE': npart,\n",
" 'UNIMED': umed, 'LINEA_EXPO_NUEVA': l_new, 'FACTURA_NUEVA': f_new}\n",
" # 1) Buscar descarga origen (debe ser UNICA)\n",
" try:\n",
" df_d = pd.read_sql(\"\"\"\n",
" SELECT CONSECUTIVO FROM SDescargaT\n",
" WHERE FACTEXPO=? AND LINEAEXPO=? AND NUMPARTE=? AND UNIMED=?\n",
" \"\"\", scaii_conn, params=(f_ori, l_ori, npart, umed))\n",
" except Exception as e:\n",
" inc_rows.append({**base, 'STATUS': 'ERROR_QUERY_ORIGEN', 'DETALLE': str(e),\n",
" 'CONSECUTIVO_DESC': None})\n",
" prog.step(); continue\n",
" if df_d.empty:\n",
" inc_rows.append({**base, 'STATUS': 'ORIGEN_NO_ENCONTRADO', 'DETALLE': 'Sin filas en SDescargaT',\n",
" 'CONSECUTIVO_DESC': None})\n",
" prog.step(); continue\n",
" if len(df_d) > 1:\n",
" inc_rows.append({**base, 'STATUS': 'ORIGEN_DUPLICADO',\n",
" 'DETALLE': f'{len(df_d)} filas en SDescargaT con esa combinacion',\n",
" 'CONSECUTIVO_DESC': ', '.join(str(c) for c in df_d['CONSECUTIVO'].tolist())})\n",
" prog.step(); continue\n",
" consec = int(df_d.iloc[0]['CONSECUTIVO'])\n",
" # 2) Validar destino existe en SFacExp + SPartidasExpo (factura + linea)\n",
" try:\n",
" df_dest = pd.read_sql(\"\"\"\n",
" SELECT pe.LINEA\n",
" FROM SPartidasExpo pe\n",
" INNER JOIN SFacExp fe ON fe.FACTURAEXPO = pe.FACTURAEXPO\n",
" WHERE pe.FACTURAEXPO = ? AND pe.LINEA = ?\n",
" \"\"\", scaii_conn, params=(f_new, l_new))\n",
" except Exception as e:\n",
" inc_rows.append({**base, 'STATUS': 'ERROR_QUERY_DESTINO', 'DETALLE': str(e),\n",
" 'CONSECUTIVO_DESC': consec})\n",
" prog.step(); continue\n",
" if df_dest.empty:\n",
" inc_rows.append({**base, 'STATUS': 'DESTINO_NO_EXISTE',\n",
" 'DETALLE': f'No existe FACTURA_NUEVA={f_new} con LINEA_EXPO_NUEVA={l_new}',\n",
" 'CONSECUTIVO_DESC': consec})\n",
" prog.step(); continue\n",
" # Todo OK\n",
" ok_rows.append({**base, 'CONSECUTIVO_DESC': consec, 'STATUS': 'OK'})\n",
" prog.step()\n",
" prog.done(f'{len(ok_rows)} OK / {len(inc_rows)} inconsistencias')\n",
" log(f'Validacion: {len(ok_rows)} OK, {len(inc_rows)} inconsistencias.')\n",
" return pd.DataFrame(ok_rows), pd.DataFrame(inc_rows)\n",
"\n",
"\n",
"def exportar_excel_reasignacion(plan, inconsistencias, ruta):\n",
" \"\"\"Guarda Plan_OK + Inconsistencias en hojas separadas.\"\"\"\n",
" with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n",
" if not plan.empty:\n",
" plan.to_excel(w, sheet_name='Plan_OK', index=False)\n",
" if not inconsistencias.empty:\n",
" inconsistencias.to_excel(w, sheet_name='Inconsistencias', index=False)\n",
"\n",
"\n",
"def ejecutar_reasignacion(plan_ok, dry_run=True, progress=None, log=print):\n",
" \"\"\"Paso B: UPDATE SDescargaT SET FACTEXPO, FACREFERENCIA, LINEAEXPO\n",
" para cada fila del plan OK. Transaccion por fila.\"\"\"\n",
" assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n",
" prog = _Progress(progress)\n",
" if plan_ok is None or plan_ok.empty:\n",
" log('ERROR: plan vacio. Corre primero Analizar.')\n",
" return\n",
" prog.setup(len(plan_ok), 'Reasignando descargas')\n",
" UPD = \"\"\"UPDATE SDescargaT\n",
" SET FACTEXPO = ?, FACREFERENCIA = ?, LINEAEXPO = ?\n",
" WHERE CONSECUTIVO = ?\"\"\"\n",
" upd = errores = 0\n",
" err_list = []\n",
" for _, r in plan_ok.iterrows():\n",
" consec = int(r['CONSECUTIVO_DESC'])\n",
" f_new = str(r['FACTURA_NUEVA']).strip()\n",
" l_new = int(r['LINEA_EXPO_NUEVA'])\n",
" try:\n",
" with scaii_conn.cursor() as cur:\n",
" if not dry_run:\n",
" cur.execute(UPD, f_new, f_new, l_new, consec)\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",
" err_list.append((consec, str(e)))\n",
" log(f' ERROR consec {consec}: {e}')\n",
" prog.step()\n",
" prog.done(f'{upd} reasignaciones')\n",
" log(f'\\n=== RESUMEN Reasignacion (DRY_RUN={dry_run}) ===')\n",
" log(f' Descargas reasignadas: {upd:,}')\n",
" log(f' Errores : {errores:,}')\n",
" for c, e in err_list[:5]:\n",
" log(f' {c}: {e}')\n",
"\n",
"\n",
"# =============================================================\n",
"# CONSUMO DE SALDOS VENCIDOS POR RANGO (complementaria con BOM del Excel)\n",
"# =============================================================\n",
"\n",
"import io as _io_csv\n",
"import datetime as _dt_csv\n",
"\n",
"_UMS_PESO_CSV = {'KGS', 'KG', 'TON'}\n",
"\n",
"def generar_plantilla_consumo_vencidos():\n",
" df = pd.DataFrame([\n",
" {'NUMPARTE_COMPONENTE': 'COMP-001', 'PCT_MATERIAL': 8.5, 'NUMPARTE_PT': 'PT-ABC-123', 'UNIMED': 'KGS'},\n",
" {'NUMPARTE_COMPONENTE': 'COMP-002', 'PCT_MATERIAL': 2.0, 'NUMPARTE_PT': 'PT-ABC-123', 'UNIMED': 'PZA'},\n",
" {'NUMPARTE_COMPONENTE': 'COMP-003', 'PCT_MATERIAL': 12.0, 'NUMPARTE_PT': 'PT-XYZ-789', 'UNIMED': 'TON'},\n",
" ])\n",
" buf = _io_csv.BytesIO()\n",
" with pd.ExcelWriter(buf, engine='openpyxl') as w:\n",
" df.to_excel(w, sheet_name='ConsumoVencidos', index=False)\n",
" buf.seek(0)\n",
" return buf.read()\n",
"\n",
"\n",
"def cargar_excel_consumo_vencidos(path):\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_COMPONENTE': ['NUMPARTE_COMPONENTE', 'COMPONENTE', 'NUMPARTE_COMP', 'COMP'],\n",
" 'PCT_MATERIAL': ['PCT_MATERIAL', 'PCT', '%_MATERIAL', '%MATERIAL', 'PORCENTAJE'],\n",
" 'NUMPARTE_PT': ['NUMPARTE_PT', 'PT', 'NUMPARTE_PRODUCTO_TERMINADO', 'PRODUCTO_TERMINADO'],\n",
" 'UNIMED': ['UNIMED', 'UNIDAD_DE_MEDIDA', 'UM', 'UNIDAD'],\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['NUMPARTE_COMPONENTE'] = df2['NUMPARTE_COMPONENTE'].astype(str).str.strip()\n",
" df2['NUMPARTE_PT'] = df2['NUMPARTE_PT'].astype(str).str.strip()\n",
" df2['UNIMED'] = df2['UNIMED'].astype(str).str.strip().str.upper()\n",
" df2['PCT_MATERIAL'] = pd.to_numeric(df2['PCT_MATERIAL'], errors='coerce').fillna(0)\n",
" df2 = df2[(df2['NUMPARTE_COMPONENTE'] != '') & (df2['NUMPARTE_PT'] != '')].reset_index(drop=True)\n",
" return df2\n",
"\n",
"\n",
"def listar_partidas_expo_por_pt(pts):\n",
" \"\"\"Lista las partidas EXPO en facturas AC cuyo NUMPARTE esta en pts.\n",
" Devuelve un resumen por PT y la lista detallada.\"\"\"\n",
" pts = list({str(p).strip() for p in pts if str(p).strip()})\n",
" if not pts:\n",
" return pd.DataFrame(), pd.DataFrame()\n",
" LOTE = 1000\n",
" detalle_parts = []\n",
" for i in range(0, len(pts), LOTE):\n",
" sub = pts[i:i+LOTE]\n",
" ph = ','.join(['?'] * len(sub))\n",
" detalle_parts.append(pd.read_sql(f\"\"\"\n",
" SELECT pe.FACTURAEXPO, pe.LINEA, pe.NUMPARTE AS PT, pe.CANTEXPO,\n",
" ISNULL(pe.PESONETO,0) AS PESONETO, fe.FECHAFACTURA_ISO,\n",
" fe.PEDIMENTOEXPO, fe.ESTATUS\n",
" FROM SPartidasExpo pe\n",
" INNER JOIN SFacExp fe ON fe.FACTURAEXPO = pe.FACTURAEXPO\n",
" WHERE fe.ESTATUS = 'AC' AND pe.NUMPARTE IN ({ph})\n",
" ORDER BY fe.FECHAFACTURA_ISO, pe.FACTURAEXPO, pe.LINEA\n",
" \"\"\", scaii_conn, params=sub))\n",
" detalle = pd.concat(detalle_parts, ignore_index=True) if detalle_parts else pd.DataFrame()\n",
" if detalle.empty:\n",
" resumen = pd.DataFrame(columns=['PT','partidas','facturas','peso_neto_total','cantexpo_total'])\n",
" else:\n",
" resumen = (detalle.groupby('PT', as_index=False)\n",
" .agg(partidas=('LINEA', 'count'),\n",
" facturas=('FACTURAEXPO', 'nunique'),\n",
" peso_neto_total=('PESONETO', 'sum'),\n",
" cantexpo_total=('CANTEXPO', 'sum')))\n",
" return detalle, resumen\n",
"\n",
"\n",
"def _cargar_saldos_vencidos_rango(fecha_ini, fecha_fin, fecha_corte=None):\n",
" \"\"\"SSaldoTem con FECHAVENC_ISO entre rango Y < fecha_corte (hoy).\"\"\"\n",
" if fecha_corte is None:\n",
" fecha_corte = _dt_csv.date.today().isoformat()\n",
" 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",
" ISNULL(VALORIMPOMN,0) AS VALORIMPOMN,\n",
" ISNULL(VALORIMPOME,0) AS VALORIMPOME,\n",
" ISNULL(PESONETO,0) AS PESONETO_LOTE,\n",
" (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_DISPONIBLE\n",
" FROM SSaldoTem\n",
" WHERE FECHAVENC_ISO IS NOT NULL\n",
" AND FECHAVENC_ISO BETWEEN ? AND ?\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",
" df['UM_KEY'] = df['UNIDAD_MEDIDA'].fillna('').str.strip().str.upper()\n",
" return df.reset_index(drop=True)\n",
"\n",
"\n",
"def _consumir_saldos_local(saldos, idx_list, faltante, base_row, tipo, np_usado):\n",
" \"\"\"Variante de _consumir que opera sobre saldos cargados localmente.\n",
" Devuelve (faltante_restante, lista_filas).\"\"\"\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 > 1e-9 else 0.0\n",
" rows.append({**base_row,\n",
" 'NUMPARTE_USADO': np_usado,\n",
" '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",
"\n",
"def analizar_consumo_vencidos(df_excel, fecha_ini, fecha_fin, progress=None, log=print):\n",
" \"\"\"Paso A: arma df_comp + resumen + inconsistencias. NO escribe.\"\"\"\n",
" prog = _Progress(progress)\n",
" if df_excel is None or df_excel.empty:\n",
" log('Excel vacio.')\n",
" return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n",
"\n",
" # 1) Partidas EXPO con PT del Excel\n",
" pts = df_excel['NUMPARTE_PT'].unique().tolist()\n",
" detalle_partidas, _ = listar_partidas_expo_por_pt(pts)\n",
" if detalle_partidas.empty:\n",
" log('No hay partidas EXPO con esos PT en facturas AC.')\n",
" return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n",
" log(f'Partidas EXPO afectadas: {len(detalle_partidas):,}')\n",
"\n",
" # 2) Descargas previas\n",
" facturas = detalle_partidas['FACTURAEXPO'].unique().tolist()\n",
" prev_parts = []\n",
" for i in range(0, len(facturas), 500):\n",
" chunk = facturas[i:i+500]\n",
" ph = ','.join(['?']*len(chunk))\n",
" prev_parts.append(pd.read_sql(f\"\"\"\n",
" SELECT FACTEXPO AS FACTURAEXPO, LINEAEXPO AS LINEA,\n",
" PARTEORIGINAL AS COMPONENTE_MP, 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_prev = pd.concat(prev_parts, ignore_index=True) if prev_parts else pd.DataFrame(columns=['FACTURAEXPO','LINEA','COMPONENTE_MP','CANT_PREV'])\n",
" if not df_prev.empty:\n",
" df_prev['LINEA'] = df_prev['LINEA'].astype(int)\n",
" prev_dict = (df_prev.set_index(['FACTURAEXPO','LINEA','COMPONENTE_MP'])['CANT_PREV'].to_dict()\n",
" if not df_prev.empty else {})\n",
" log(f'Descargas previas indexadas: {len(prev_dict):,}')\n",
"\n",
" # 3) Saldos vencidos en el rango\n",
" saldos = _cargar_saldos_vencidos_rango(fecha_ini, fecha_fin)\n",
" log(f'Saldos vencidos en rango: {len(saldos):,}')\n",
"\n",
" # 4) Recorrer partidas, aplicar BOM del Excel\n",
" excel_por_pt = df_excel.groupby('NUMPARTE_PT')\n",
" inconsistencias = []\n",
" all_rows = []\n",
" prog.setup(len(detalle_partidas), 'Procesando partidas...')\n",
" for _, p in detalle_partidas.iterrows():\n",
" factura, linea, pt = p['FACTURAEXPO'], int(p['LINEA']), p['PT']\n",
" peso_neto = float(p['PESONETO'] or 0)\n",
" cantexpo = float(p['CANTEXPO'] or 0)\n",
" if pt not in excel_por_pt.groups:\n",
" prog.step(); continue\n",
" for _, comp in excel_por_pt.get_group(pt).iterrows():\n",
" componente = comp['NUMPARTE_COMPONENTE']\n",
" pct = float(comp['PCT_MATERIAL'] or 0) / 100.0\n",
" unimed = (comp['UNIMED'] or '').strip().upper()\n",
" if unimed in _UMS_PESO_CSV:\n",
" cant_req_total = pct * peso_neto\n",
" else:\n",
" cant_req_total = pct * cantexpo\n",
" if cant_req_total <= 1e-9:\n",
" inconsistencias.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n",
" 'COMPONENTE': componente, 'UNIMED': unimed, 'PCT': comp['PCT_MATERIAL'],\n",
" 'STATUS': 'CANT_REQ_CERO',\n",
" 'DETALLE': 'PESONETO/CANTEXPO en 0 o PCT en 0'})\n",
" continue\n",
" cant_prev = float(prev_dict.get((factura, linea, componente), 0.0))\n",
" cant_pend = cant_req_total - cant_prev\n",
" if cant_pend <= 1e-9:\n",
" continue # ya estaba cubierto\n",
" # Buscar saldos del componente, FIFO por FECHA_ENTRADA\n",
" saldos_idx = saldos.index[saldos['NUMPARTE'].astype(str).str.strip() == componente].tolist()\n",
" saldos_idx = sorted(saldos_idx, key=lambda i: (saldos.at[i, 'FECHA_ENTRADA'] or ''))\n",
" base_row = {'FACTURAEXPO': factura, 'FECHA_FACTURAEXPO': p['FECHAFACTURA_ISO'],\n",
" 'PEDIMENTOEXPO': p['PEDIMENTOEXPO'],\n",
" 'LINEA': linea, 'PT': pt, 'CANT_PT': cantexpo,\n",
" 'PESONETO_PARTIDA': peso_neto, 'PCT_BOM': comp['PCT_MATERIAL'],\n",
" 'MONTOIGI': None, 'COMPONENTE_MP': componente, 'UNIMED_BOM': unimed,\n",
" 'CANT_REQUERIDA': cant_pend, 'CANT_PREV': cant_prev}\n",
" faltante, rows = _consumir_saldos_local(saldos, saldos_idx, cant_pend, base_row,\n",
" 'ORIGINAL', componente)\n",
" all_rows.extend(rows)\n",
" if faltante > 1e-9:\n",
" inconsistencias.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n",
" 'COMPONENTE': componente, 'UNIMED': unimed, 'PCT': comp['PCT_MATERIAL'],\n",
" 'CANT_REQUERIDA': cant_pend,\n",
" 'CANT_DESCARGADA': cant_pend - faltante, 'CANT_FALTANTE': faltante,\n",
" 'STATUS': 'SIN_SALDO_SUFICIENTE',\n",
" 'DETALLE': f'Faltan {faltante:.4f} {unimed} del componente'})\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,\n",
" 'PESONETO_DESC': None, 'STATUS': 'FALTANTE'})\n",
" prog.step()\n",
" prog.done('Analisis listo')\n",
" df_comp = pd.DataFrame(all_rows)\n",
" # Catalogos PartePais para TIPOFRACCION/ADVALOREMIMPO (igual que _build_descarga_df)\n",
" if not df_comp.empty:\n",
" try:\n",
" _load_catalogos()\n",
" pp = _state.get('df_partepais_all', pd.DataFrame())\n",
" if not pp.empty:\n",
" pp_d = pp.drop_duplicates(subset=['FRACCION','PAIS'], keep='last') .set_index(['FRACCION','PAIS'])[['TIPOFRACCION','TASAIM']].to_dict('index')\n",
" def _pp(row):\n",
" info = pp_d.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_comp[['TIPOFRACCION','ADVALOREMIMPO']] = df_comp.apply(_pp, axis=1)\n",
" except Exception as e:\n",
" log(f' WARN catalogos: {e}')\n",
" df_comp['FECHADESC_CLARION'] = df_comp['FECHA_FACTURAEXPO'].apply(to_clarion)\n",
" resumen = pd.DataFrame()\n",
" if not df_comp.empty:\n",
" agg = (df_comp.groupby('TIPO').size().reset_index(name='filas'))\n",
" resumen = agg\n",
" log(f'Filas calculadas: {len(df_comp):,} | Inconsistencias: {len(inconsistencias):,}')\n",
" return df_comp, resumen, pd.DataFrame(inconsistencias)\n",
"\n",
"\n",
"def ejecutar_consumo_vencidos(df_comp, modo_comp='DIRIGIDA', dry_run=True,\n",
" progress=None, log=print):\n",
" \"\"\"Paso B: reutiliza _do_inserts (mismo INSERT que el Paso 10/12 complementaria).\"\"\"\n",
" assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n",
" if df_comp is None or df_comp.empty:\n",
" log('ERROR: plan vacio.'); 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,\n",
" do_update_status=False, progress=progress)\n",
" log(f'\\n=== RESUMEN consumo vencidos ({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}')"
]
},
{
"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 a-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('A-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 a-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 a-o - 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('A-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 a-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 a-o - 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('A-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 a-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 a-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, aplicar_vtmn=True, aplicar_mptemp=False, 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",
" sets, params_tmpl = ['VALORTOTALME = ?'], ['ME']\n",
" if aplicar_vtmn:\n",
" sets.append('VALORTOTALMN = ?'); params_tmpl.append('MN')\n",
" if aplicar_mptemp:\n",
" sets.append('ValorMPTempMN = ?'); params_tmpl.append('MN')\n",
" upd_sql = f\"UPDATE {target} SET {', '.join(sets)} 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",
" vals = []\n",
" for t in params_tmpl:\n",
" vals.append(float(p['NUEVO_ME']) if t == 'ME' else float(p['NUEVO_MN']))\n",
" vals.extend([p['FACTURAEXPO'], int(p['LINEA'])])\n",
" cur.execute(upd_sql, *vals)\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<NNN>)\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<NNN>. 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<NNN>.\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<F4>-R<NNN>'.\"\"\"\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'<div style=\"background:{color};color:white;padding:12px 18px;border-radius:6px;\">'\n",
" f'<h2 style=\"margin:0;\">Sistema de Utilerias 2.0 SCAII</h2>'\n",
" f'<div style=\"font-size:12px;opacity:0.9;\">{CONEXION_MSG}</div></div>'\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'<b>DB actual:</b> <span style=\"color:#1565C0;\">{DB_ACTUAL or \"sin conexion\"}</span>'\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'<b>DB actual:</b> <span style=\"color:#1565C0;\">{DB_ACTUAL or \"sin conexion\"}</span>'\n",
"btn_conectar.on_click(_on_conectar)\n",
"\n",
"tab_conn = W.VBox([\n",
" W.HTML('<h3>Conexion a SQL Server</h3>'\n",
" f'<p style=\"color:#555;margin:0 0 8px 0;\">Server: <b>{SCAII_SERVER}</b> | Usuario: <b>{SCAII_USER}</b></p>'),\n",
" lbl_actual,\n",
" W.HTML('<p style=\"margin-top:10px;\">Selecciona la base de datos. El cambio aplica a todas las pestanas; '\n",
" 'el cache de pronostico/analisis se resetea al cambiar.</p>'),\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('<h3>Paso 8 - Pronostico de cobertura</h3>'),\n",
" btn_pron, bar_pron, out_pron,\n",
" W.HTML('<h3 style=\"margin-top:18px;\">Paso 9 - Descargas pendientes (NA - AC)</h3>'),\n",
" W.HBox([modo9, dry9]), W.HBox([fd9, fh9]), btn_p9, bar_p9, out_p9,\n",
" W.HTML('<h3 style=\"margin-top:18px;\">Paso 10 - Complementaria (sobre facturas AC)</h3>'),\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 a-os: {len(df_imp)} | EXPO a-os: {len(df_exp)} | Comparativo: {len(cmp)}')\n",
" print('Calculando pesos IMPO/EXPO/SALDO por a-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)} a-os')\n",
" print('Calculando cantidades IMPO/EXPO por a-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)} a-os')\n",
" print('Calculando cantidades IMPO/EXPO por a-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('<h4>Saldo disponible por a-o (de entrada del lote)</h4>'))\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('<h4>IMPO vs EXPO por a-o</h4>'))\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('<h4>Peso por a-o - IMPO / EXPO / CONSUMIDO / DESCARGAS</h4>'))\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('<h4>Cantidades por a-o - IMPO vs EXPO</h4>'))\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('<h4>Cantidades por a-o + UM (separado por unidad de medida)</h4>'))\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('<h3>An-lisis SSaldoTem + IMPO vs EXPO</h3>'),\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('<h3>Sustitutos NLP - TF-IDF + coseno</h3>'),\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('<h3>Paso 12 - Descarga por % de KGS</h3>'\n",
" '<p style=\"margin:0 0 10px 0;color:#555;\">'\n",
" 'cant_req = (BOM.CANTIDAD / 100) - PESONETO de la partida. Mismo PEPS, UM y sustitutos.</p>'),\n",
" btn_pron12, bar_pron12, out_pron12,\n",
" W.HTML('<h3 style=\"margin-top:18px;\">Paso 12 - Descargas pendientes KG (NA - AC)</h3>'),\n",
" W.HBox([modo12, dry12]), W.HBox([fd12, fh12]), btn_p12, bar_p12, out_p12,\n",
" W.HTML('<h3 style=\"margin-top:18px;\">Paso 12 - Complementaria KG (sobre facturas AC)</h3>'),\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",
"<div style=\"background:#E3F2FD;border:1px solid #90CAF9;padding:12px;border-radius:6px;margin:8px 0;\">\n",
"<b style=\"color:#0D47A1;\">Formato esperado del Excel del cliente</b>\n",
"<p style=\"margin:6px 0;color:#333;font-size:12px;\">La primera hoja del archivo debe contener al menos estas columnas (solo las dos primeras son obligatorias):</p>\n",
"<table style=\"border-collapse:collapse;font-size:12px;\">\n",
"<tr style=\"background:#1565C0;color:white;\"><th style=\"padding:4px 10px;text-align:left;\">Columna</th><th style=\"padding:4px 10px;text-align:left;\">Ejemplo</th><th style=\"padding:4px 10px;text-align:left;\">Obligatoria</th></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>Facturas CTM</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">AAU112023RFR0481, NIS112023RFR0035</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#C62828;\">SI</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>PEDIMENTO COMPLETO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">75-3076-4021492</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#C62828;\">SI</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\">PATENTE</td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">3076</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#888;\">no</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\">ADUANA</td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">75</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#888;\">no</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\">PEDIMENTO</td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">4021492</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#888;\">no</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\">Operacion</td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">Importacion</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#888;\">no</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\">Clave de pedimento</td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">F4</td><td style=\"padding:4px 10px;border:1px solid #ddd;color:#888;\">no</td></tr>\n",
"</table>\n",
"<p style=\"margin:8px 0 0 0;color:#555;font-size:11px;\">La celda <b>Facturas CTM</b> puede traer varias facturas separadas por coma; la herramienta las separa automaticamente.</p>\n",
"</div>\n",
"'''\n",
"\n",
"def _on_plantilla(_):\n",
" with out_ctm_log:\n",
" clear_output()\n",
" path = generar_plantilla_excel_ctm()\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Plantilla generada</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span><br>'\n",
" f'<small style=\"color:#555;\">Abre el archivo, agrega tus datos, guarda y luego subelo con el boton \"Subir Excel\".</small></div>'))\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('<p style=\"color:#555;margin-top:8px;font-size:12px;\"><i>En pantalla se muestran solo las primeras 50 filas del plan. '\n",
" 'Para ver el detalle completo presiona <b>Exportar Excel completo</b>.</i></p>'))\n",
" with out_ctm_tabla:\n",
" clear_output()\n",
" if 'ctm_resumen' in _state and not _state['ctm_resumen'].empty:\n",
" display(HTML('<h4>Resumen CTM (por factura + linea + componente)</h4>'))\n",
" display(_state['ctm_resumen'])\n",
" display(HTML('<h4>Plan detalle (primeras 50 filas)</h4>'))\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('<div style=\"color:#C62828;\">Corre primero <b>Analizar CTM</b>.</div>'))\n",
" return\n",
" path = exportar_excel_ctm(_state['ctm_plan'], _state['ctm_resumen'])\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado con el detalle completo</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:13px;color:#333;\">{path}</span><br>'\n",
" f'<small style=\"color:#555;\">Contiene tres hojas: <b>Resumen</b>, <b>Plan_Detalle</b> (todas las filas, no solo 50) y <b>Faltantes</b>.</small></div>'))\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('<h3>CTM - Reasignacion de descargas desde Cambio de Regimen</h3>'\n",
" '<p style=\"margin:0 0 10px 0;color:#555;\">'\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 <b>solo lectura</b> (Paso A): no escribe en la base de datos.</p>'),\n",
" W.HTML(_html_formato),\n",
" btn_plantilla,\n",
" W.HTML('<p style=\"margin-top:14px;\"><b>Sube tu Excel con el mapeo Facturas CTM <-> Pedimento F4:</b></p>'),\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('<h4 style=\"margin-top:20px;border-top:2px solid #FFA726;padding-top:14px;\">Paso B - Ejecutar reasignacion en la base de datos</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Toma el plan calculado arriba y aplica los cambios en SDescargaT y SFacExp. <b>Corre primero con DRY_RUN activado para revisar.</b></p>'),\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",
"<div style=\"background:#E3F2FD;border:1px solid #1565C0;padding:12px;border-radius:6px;margin:8px 0;\">\n",
"<b style=\"color:#0D47A1;\">Formato esperado del Excel (3 columnas)</b>\n",
"<table style=\"border-collapse:collapse;font-size:12px;margin-top:6px;\">\n",
"<tr style=\"background:#1565C0;color:white;\"><th style=\"padding:4px 10px;text-align:left;\">Columna</th><th style=\"padding:4px 10px;text-align:left;\">Ejemplo</th></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>FACTURAIMPO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">F1234567</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>CANTIDAD_SALDO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">100.0</td></tr>\n",
"<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>FRACCION_IMPO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">85044010</td></tr>\n",
"</table>\n",
"<p style=\"margin:8px 0 0 0;color:#555;font-size:11px;\">Las fechas inicio/fin filtran SSaldoTem por <b>FECHAFACTURA_ISO</b>.</p>\n",
"</div>\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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Plantilla generada</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\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('<h4>Resumen Saldos Vencidos</h4>'))\n",
" display(_state['sv_resumen'])\n",
" display(HTML('<h4>Plan detalle (primeras 50 filas)</h4>'))\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('<div style=\"color:#C62828;\">Corre primero <b>Analizar</b>.</div>'))\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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:13px;color:#333;\">{path}</span></div>'))\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('<h3>Saldos Vencidos - Utileria Forma 5</h3>'\n",
" '<p style=\"margin:0 0 10px 0;color:#555;\">'\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).</p>'),\n",
" W.HTML(_html_formato_sv),\n",
" btn_plantilla_sv,\n",
" W.HTML('<p style=\"margin-top:14px;\"><b>Sube tu Excel y define el rango de fechas (FECHAFACTURA_ISO):</b></p>'),\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('<h4 style=\"margin-top:20px;border-top:2px solid #FFA726;padding-top:14px;\">Paso B - Ejecutar prorrateo en la base de datos</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">UPDATE en cada SDescargaT matched + UPDATE en SSaldoTem (CANTUSADA, VALORUSADOMN/ME, PESOUSADO, PESOBRUTOUSADO). <b>Corre primero con DRY_RUN activado.</b></p>'),\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('<h4>Resumen Saldos Vencidos (busqueda automatica)</h4>'))\n",
" display(_state['sv2_resumen'])\n",
" display(HTML('<h4>Plan detalle (primeras 50 filas)</h4>'))\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('<div style=\"color:#C62828;\">Corre primero <b>Buscar saldos vencidos</b>.</div>'))\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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:13px;color:#333;\">{path}</span></div>'))\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('<h4 style=\"margin-top:24px;border-top:2px solid #1976D2;padding-top:14px;\">Modo Automatico - sin Excel</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">'\n",
" 'Define el rango de fechas (filtra <b>FECHAFACTURA_ISO</b>) y el sistema busca automaticamente '\n",
" 'los saldos con <b>FECHAVENC_ISO &lt; hoy</b> (vencidos) y SALDO_DISPONIBLE &gt; 0. '\n",
" 'Aplica el mismo prorrateo y los mismos UPDATEs que el modo Excel.</p>'),\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('<h4 style=\"margin-top:18px;border-top:2px solid #FFA726;padding-top:14px;\">Paso B - Ejecutar prorrateo automatico</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Mismos UPDATEs: SDescargaT por descarga + SSaldoTem por saldo. <b>Corre primero con DRY_RUN activado.</b></p>'),\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('<h4 style=\"margin-top:24px;border-top:2px solid #4CAF50;padding-top:14px;\">Saldos vencidos por anio (vista general)</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">'\n",
" 'Resumen global de saldos en SSaldoTem cuyo <b>FECHAVENC_ISO &lt; hoy</b> y '\n",
" 'SALDO_DISPONIBLE &gt; 0, agrupados por anio de vencimiento. '\n",
" 'No depende del rango de fechas de arriba.</p>'),\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'<div style=\"background:#FFEBEE;border:1px solid #C62828;padding:10px;border-radius:6px;\">'\n",
" f'<b style=\"color:#C62828;\">Postgres no disponible.</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{DATASTAGE_MSG}</span><br>'\n",
" f'<small>Revisa que el contenedor postgres-datastage este levantado y que el .env tenga DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD.</small></div>'))\n",
" return False\n",
" display(HTML(f'<div style=\"color:#1B5E20;font-size:12px;\">{DATASTAGE_MSG}</div>'))\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('<h4>Resumen por tabla destino</h4>'))\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('<h4>Resultado por archivo</h4>'))\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('<h3>DataStage - Subir archivos .asc a Postgres</h3>'\n",
" '<p style=\"margin:0 0 10px 0;color:#555;\">'\n",
" 'Carga masiva de archivos .asc del DataStage en sus tablas <b>Registro&lt;NNN&gt;</b>. '\n",
" 'Migrado del modulo PHP <code>HOME/DATASTAGE</code>. La estructura de los archivos '\n",
" 'se infiere del nombre (<code>_NNN.asc</code> &rarr; <code>RegistroNNN</code>); '\n",
" 'el separador es <code>|</code>, el encoding es latin-1, y se omite el header.</p>'),\n",
" W.HTML('<div style=\"background:#E3F2FD;border:1px solid #1565C0;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" '<b style=\"color:#0D47A1;\">Estructura esperada de la carpeta</b>'\n",
" '<ul style=\"margin:6px 0 0 20px;font-size:13px;color:#333;\">'\n",
" '<li>Layout HONDA: <code>RAIZ/2020/*.asc</code>, <code>RAIZ/2021/*.asc</code>, ... (solo .asc directos, sin entrar a subcarpetas de meses)</li>'\n",
" '<li>Layout plano: <code>RAIZ/*.asc</code></li>'\n",
" '</ul>'\n",
" '<p style=\"margin:6px 0 0 0;font-size:11px;color:#555;\">Tablas destino <b>deben existir previamente</b> en Postgres. Esta pestania no crea tablas.</p>'\n",
" '</div>'),\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('<h4 style=\"margin-top:14px;border-top:2px solid #FFA726;padding-top:10px;\">Log de carga</h4>'),\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('<div style=\"color:#C62828;\"><b>Marca el checkbox de confirmacion antes de ejecutar.</b></div>'))\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('<h4>Resultado del TRUNCATE</h4>'))\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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:6px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Total:</b> '\n",
" f'{total_tablas} tablas | {total_con_datos} con datos | '\n",
" f'<b>{total_filas:,}</b> filas en total</div>'))\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'<h4>{tabla} (primeras {limit})</h4>'))\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('<div style=\"color:#C62828;\">No hay datos cargados. Corre \"Ver datos\" primero.</div>'))\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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:6px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\n",
"btn_ds_export.on_click(_on_ds_export)\n",
"\n",
"tab_datastage.children = tuple(list(tab_datastage.children) + [\n",
" W.HTML('<h4 style=\"margin-top:20px;border-top:2px solid #1976D2;padding-top:14px;\">Estadisticas de tablas Registro*</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Cuenta de filas por tabla. Equivalente al modulo PHP <code>datastage.php</code>: '\n",
" 'permite ver totales y explorar el contenido cargado.</p>'),\n",
" W.HBox([btn_ds_stats]), bar_ds_stats,\n",
" out_ds_stats,\n",
" W.HTML('<h5 style=\"margin-top:10px;\">Explorar contenido</h5>'),\n",
" W.HBox([w_ds_tabla_sel, w_ds_limit, btn_ds_ver_datos, btn_ds_export]),\n",
" out_ds_datos,\n",
" W.HTML('<h4 style=\"margin-top:20px;border-top:2px solid #C62828;padding-top:14px;\">Zona peligrosa</h4>'\n",
" '<p style=\"color:#C62828;margin:0 0 8px 0;\"><b>TRUNCATE TABLE</b> en todas las tablas Registro*. '\n",
" 'Elimina TODAS las filas (no se puede deshacer). Util para reiniciar la carga desde cero.</p>'),\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('<div style=\"color:#C62828;\">Corre Generar primero.</div>'))\n",
" return\n",
" ruta = exportar_df_a_excel(_state['ds_cat_df'], 'Estructura_CAT_Pedimentos')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\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('<div style=\"color:#C62828;\">Corre Generar primero.</div>'))\n",
" return\n",
" ruta = exportar_df_a_excel(_state['ds_catr_df'], 'Estructura_CAT_Pedimentos_Rectificados')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\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('<small style=\"color:#555;\">Tip: copia <b>Patente / Pedimento / SeccionAduanera / Anio</b> a la seccion de abajo para ver el historial recursivo.</small>'))\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('<div style=\"color:#C62828;\">Corre Listar primero.</div>'))\n",
" return\n",
" ruta = exportar_df_a_excel(_state['ds_rect_df'], 'Pedimentos_Rectificados')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\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('<h3>Estructuras SCAII - Reportes de Pedimentos</h3>'\n",
" '<p style=\"margin:0 0 10px 0;color:#555;\">Migracion de las opciones del menu PHP <i>Pedimentos</i>: '\n",
" 'CAT Pedimentos, CAT Pedimentos Rectificados y Rastreo de Rectificaciones (con historial recursivo). '\n",
" 'Las consultas se ejecutan sobre Postgres (tablas <code>Registro501</code> y <code>Registro701</code>).</p>'),\n",
" _ds_report_block(\n",
" '<h5>Estructura CAT Pedimentos</h5>'\n",
" '<p style=\"color:#555;font-size:12px;\">Pedimentos de <b>Registro501</b> en el rango, indicando si fueron rectificados.</p>',\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",
" '<h5 style=\"margin-top:12px;\">Estructura CAT Pedimentos Rectificados</h5>'\n",
" '<p style=\"color:#555;font-size:12px;\">Pedimentos rectificados de <b>Registro701</b> en el rango, '\n",
" 'enlazados con el tipo de operacion de Registro501.</p>',\n",
" w_ds_catr_fi, w_ds_catr_ff, btn_ds_catr_run, btn_ds_catr_export, out_ds_catr),\n",
" W.HTML('<h5 style=\"margin-top:12px;\">Rastreo de Rectificaciones</h5>'\n",
" '<p style=\"color:#555;font-size:12px;\">Pedimentos de Registro501 que fueron rectificados, con filtros y opcion de ver la cadena historica.</p>'),\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('<h6 style=\"margin-top:10px;color:#0D47A1;\">Historial recursivo de un pedimento</h6>'\n",
" '<p style=\"color:#555;font-size:11px;\">Equivalente al modal <i>obtener_historial.php</i>.</p>'),\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('<div style=\"color:#C62828;\">Corre Generar primero.</div>')); return\n",
" ruta = exportar_df_a_excel(_state['ds_fimpo_df'], 'Estructura_facturasImpo_501')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\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('<div style=\"color:#C62828;\">Corre Generar primero.</div>')); return\n",
" ruta = exportar_df_a_excel(_state['ds_fexpo_df'], 'Estructura_facturasExpo_501')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\n",
"btn_ds_fexpo_export.on_click(_on_ds_fexpo_export)\n",
"\n",
"tab_estructuras.children = tuple(list(tab_estructuras.children) + [\n",
" W.HTML('<h4 style=\"margin-top:24px;border-top:2px solid #00838F;padding-top:14px;\">Encabezado de Facturas</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Genera el encabezado de facturas de importacion (TipoOperacion=1) o exportacion '\n",
" '(TipoOperacion=2) excluyendo los pedimentos que ya fueron rectificados.</p>'),\n",
" _ds_report_block(\n",
" '<h5>Encabezado Facturas Importacion</h5>'\n",
" '<p style=\"color:#555;font-size:12px;\">Pedimentos IMPO de <b>Registro501</b> en el rango (excluye rectificados).</p>',\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",
" '<h5 style=\"margin-top:12px;\">Encabezado Facturas Exportacion</h5>'\n",
" '<p style=\"color:#555;font-size:12px;\">Pedimentos EXPO de <b>Registro501</b> en el rango (excluye rectificados).</p>',\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('<div style=\"color:#C62828;\">Corre Generar primero.</div>')); 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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b> (celdas con inconsistencia en rojo)<br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\n",
"btn_ds_tc_export.on_click(_on_ds_tc_export)\n",
"\n",
"tab_estructuras.children = tuple(list(tab_estructuras.children) + [\n",
" W.HTML('<h4 style=\"margin-top:24px;border-top:2px solid #EF6C00;padding-top:14px;\">Tipo de Cambio</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Estructura de Tipo de Cambio del Registro501. '\n",
" 'Detecta automaticamente <b>inconsistencias</b>: fechas que tienen mas de un valor distinto '\n",
" 'de TIPO CAMBIO en sus pedimentos (resaltadas en rojo).</p>'),\n",
" _ds_report_block(\n",
" '<h5>Estructura Tipo de Cambio 501</h5>'\n",
" '<p style=\"color:#555;font-size:12px;\">Pedimentos de <b>Registro501</b> en el rango con su Tipo de Cambio.</p>',\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('<div style=\"color:#C62828;\"><b>Marca el checkbox de confirmacion antes de ejecutar.</b></div>')); 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('<div style=\"color:#C62828;\">Corre Generar primero.</div>')); return\n",
" ruta = exportar_df_a_excel(_state['ds_pimpo_df'], 'Estructura_Partidas_Impo')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\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('<div style=\"color:#C62828;\">Corre Generar primero.</div>')); return\n",
" ruta = exportar_df_a_excel(_state['ds_pexpo_df'], 'Estructura_Partidas_Expo')\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:8px;border-radius:6px;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;\">{ruta}</span></div>'))\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('<h4 style=\"margin-top:24px;border-top:2px solid #8E24AA;padding-top:14px;\">Catalogo base de NUMPARTES</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Catalogo del cliente con columnas <b>NUMPARTE, DESCRIPCION, UNIDAD DE MEDIDA, FRACCION</b>. '\n",
" 'Se usa para asignar NUMPARTE a las partidas del Registro551 por similitud. La subida hace <b>upsert</b> (acumula).</p>'),\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('<h4 style=\"margin-top:20px;border-top:2px solid #6A1B9A;padding-top:14px;\">Estructura de Partidas</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Genera la estructura de partidas a partir de <b>Registro551</b>. '\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 <code>MP&lt;F4&gt;-R&lt;NNN&gt;</code>.</p>'),\n",
" W.HTML('<h5>Partidas Importacion</h5>'),\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('<h5 style=\"margin-top:12px;\">Partidas Exportacion</h5>'),\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 = ('<div style=\"background:#E3F2FD;border:1px solid #1565C0;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" '<b style=\"color:#0D47A1;\">Formato esperado del Excel (3 columnas)</b>'\n",
" '<table style=\"border-collapse:collapse;font-size:12px;margin-top:6px;\">'\n",
" '<tr style=\"background:#1565C0;color:white;\"><th style=\"padding:4px 10px;text-align:left;\">Columna</th><th style=\"padding:4px 10px;text-align:left;\">Ejemplo</th></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>PEDIMENTO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">07-3429-4015540</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>VALOR_ME</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">12345.67</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>VALOR_MN</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">234567.89</td></tr>'\n",
" '</table>'\n",
" '<p style=\"margin:8px 0 0 0;color:#555;font-size:11px;\">Por cada pedimento se buscan sus partidas de exportacion y se prorratean los valores hasta cuadrar al 100%.</p>'\n",
" '</div>')\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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Plantilla generada</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\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('<h4>Resumen por pedimento</h4>'))\n",
" display(_state['val_resumen'])\n",
" if 'val_plan' in _state and not _state['val_plan'].empty:\n",
" display(HTML('<h4>Plan detalle (primeras 50 filas)</h4>'))\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('<div style=\"color:#C62828;\">Corre Analizar primero.</div>')); 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'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\n",
"btn_val_xlsx.on_click(_on_val_xlsx)\n",
"\n",
"dry_val = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n",
"chk_val_vtmn = W.Checkbox(value=True, description='Aplicar a VALORTOTALMN', indent=False, layout={'width':'280px'})\n",
"chk_val_mptemp = W.Checkbox(value=False, description='Aplicar a ValorMPTempMN', indent=False, layout={'width':'280px'})\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,\n",
" aplicar_vtmn=bool(chk_val_vtmn.value),\n",
" aplicar_mptemp=bool(chk_val_mptemp.value),\n",
" log=print, progress=bar_val_b)\n",
"btn_val_ejecutar.on_click(_on_val_ejecutar)\n",
"\n",
"tab_valores = W.VBox([\n",
" W.HTML('<h3>Valores - Ajuste de VALORTOTALME / VALORTOTALMN</h3>'\n",
" '<p style=\"margin:0 0 10px 0;color:#555;\">'\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%.</p>'),\n",
" W.HTML(_html_formato_val),\n",
" btn_plantilla_val,\n",
" W.HTML('<p style=\"margin-top:14px;\"><b>Sube tu Excel:</b></p>'),\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('<h4 style=\"margin-top:20px;border-top:2px solid #FFA726;padding-top:14px;\">Paso B - Ejecutar UPDATE en SPartidasExpo</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">UPDATE VALORTOTALME y VALORTOTALMN por (FACTURAEXPO, LINEA). '\n",
" '<b>Corre primero con DRY_RUN activado.</b></p>'),\n",
" W.HBox([dry_val]),\n",
" W.HTML('<small style=\"color:#555;\">Columnas MN a actualizar:</small>'),\n",
" W.HBox([chk_val_vtmn, chk_val_mptemp]),\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('<h4>Donde esta cada pedimento</h4>'))\n",
" display(reporte)\n",
" if not resumen_bd.empty:\n",
" display(HTML('<h4>Resumen por base de datos</h4>'))\n",
" display(resumen_bd)\n",
" if faltantes:\n",
" display(HTML('<h4 style=\"color:#C62828;\">No encontrados en ninguna BD</h4>'))\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('<h4 style=\"margin-top:20px;border-top:2px solid #00838F;padding-top:14px;\">Shelter - Buscar pedimentos en otras BDs</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">'\n",
" 'Recorre todas las bases de datos de la instancia SQL Server (las que el usuario '\n",
" 'puede acceder y tienen la tabla <code>SPedimentos.PEDIMENTO</code>) y reporta en '\n",
" 'cual(es) base(s) vive cada pedimento del Excel. <b>Solo es diagnostico no '\n",
" 'modifica nada.</b> Util cuando el cliente tiene varias BDs y no sabes a cual '\n",
" 'apuntar el ajuste.</p>'),\n",
" W.HBox([btn_val_shelter]), bar_val_shelter,\n",
" out_val_shelter,\n",
"])\n",
"\n",
"\n",
"\n",
"\n",
"# ===== Reasignacion de Lineas EXPO (en pestania Descargas) =====\n",
"upload_reasig = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel')\n",
"btn_reasig_plantilla = W.Button(description='Descargar plantilla', button_style='info', icon='download', layout={'width':'220px'})\n",
"btn_reasig_analizar = W.Button(description='Analizar', button_style='primary', icon='search', layout={'width':'180px'})\n",
"btn_reasig_xlsx = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'200px'})\n",
"dry_reasig = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n",
"btn_reasig_ejecutar = W.Button(description='Ejecutar reasignacion', button_style='warning', icon='play', layout={'width':'260px'})\n",
"bar_reasig = _mkbar('Reasignacion')\n",
"out_reasig_log = W.Output(layout=OUT_STYLE)\n",
"out_reasig_tabla = W.Output(layout=OUT_STYLE)\n",
"\n",
"_html_formato_reasig = ('<div style=\"background:#E3F2FD;border:1px solid #1565C0;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" '<b style=\"color:#0D47A1;\">Formato esperado del Excel (6 columnas)</b>'\n",
" '<table style=\"border-collapse:collapse;font-size:12px;margin-top:6px;\">'\n",
" '<tr style=\"background:#1565C0;color:white;\"><th style=\"padding:4px 10px;text-align:left;\">Columna</th><th style=\"padding:4px 10px;text-align:left;\">Ejemplo</th></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>FACTURA_EXPO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">EXP240001</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>LINEA_EXPO</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">1</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>NUMPARTE</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">ABC-12345</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>UNIMED</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">PZA</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>LINEA_EXPO_NUEVA</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">2</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>FACTURA_NUEVA</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">EXP240002</td></tr>'\n",
" '</table>'\n",
" '<p style=\"margin:8px 0 0 0;color:#555;font-size:11px;\">Las 4 primeras identifican la descarga origen (debe ser UNICA en SDescargaT). Las 2 ultimas son el destino (debe existir en SFacExp+SPartidasExpo).</p>'\n",
" '</div>')\n",
"\n",
"def _on_reasig_plantilla(_):\n",
" with out_reasig_log:\n",
" clear_output()\n",
" bts = generar_plantilla_reasignacion()\n",
" path = os.path.join(os.getcwd(), 'plantilla_reasignacion_lineas_expo.xlsx')\n",
" with open(path, 'wb') as f: f.write(bts)\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Plantilla generada</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\n",
"btn_reasig_plantilla.on_click(_on_reasig_plantilla)\n",
"\n",
"def _on_reasig_analizar(_):\n",
" with out_reasig_log:\n",
" clear_output()\n",
" if not CONEXION_OK: print(CONEXION_MSG); return\n",
" print(f'(DB actual: {DB_ACTUAL})')\n",
" if len(upload_reasig.value) == 0:\n",
" print('ERROR: sube un Excel primero.'); return\n",
" try:\n",
" if isinstance(upload_reasig.value, dict):\n",
" fname = list(upload_reasig.value.keys())[0]\n",
" fb = upload_reasig.value[fname]['content']\n",
" else:\n",
" fb = upload_reasig.value[0]['content']\n",
" tmp = os.path.join(os.getcwd(), '_upload_reasig.xlsx')\n",
" with open(tmp, 'wb') as f: f.write(fb)\n",
" df_excel = cargar_excel_reasignacion(tmp)\n",
" print(f'Excel cargado: {len(df_excel):,} filas')\n",
" except Exception as e:\n",
" print(f'ERROR Excel: {e}'); return\n",
" plan_ok, incons = analizar_reasignacion(df_excel, progress=bar_reasig, log=print)\n",
" _state['reasig_plan_ok'] = plan_ok\n",
" _state['reasig_incons'] = incons\n",
" with out_reasig_tabla:\n",
" clear_output()\n",
" if 'reasig_plan_ok' in _state and not _state['reasig_plan_ok'].empty:\n",
" display(HTML('<h4>Plan OK (se aplicaran en Paso B)</h4>'))\n",
" display(_state['reasig_plan_ok'])\n",
" if 'reasig_incons' in _state and not _state['reasig_incons'].empty:\n",
" display(HTML('<h4 style=\"color:#C62828;\">Inconsistencias (NO se aplican)</h4>'))\n",
" display(_state['reasig_incons'])\n",
"btn_reasig_analizar.on_click(_on_reasig_analizar)\n",
"\n",
"def _on_reasig_xlsx(_):\n",
" with out_reasig_log:\n",
" if 'reasig_plan_ok' not in _state and 'reasig_incons' not in _state:\n",
" display(HTML('<div style=\"color:#C62828;\">Corre Analizar primero.</div>')); return\n",
" path = os.path.join(os.getcwd(), 'reasignacion_lineas_expo.xlsx')\n",
" exportar_excel_reasignacion(_state.get('reasig_plan_ok', pd.DataFrame()),\n",
" _state.get('reasig_incons', pd.DataFrame()), path)\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\n",
"btn_reasig_xlsx.on_click(_on_reasig_xlsx)\n",
"\n",
"def _on_reasig_ejecutar(_):\n",
" with out_reasig_log:\n",
" if not CONEXION_OK: print(CONEXION_MSG); return\n",
" if 'reasig_plan_ok' not in _state:\n",
" print('ERROR: corre primero Analizar.'); return\n",
" print(f'(DB actual: {DB_ACTUAL})')\n",
" ejecutar_reasignacion(_state['reasig_plan_ok'], dry_run=dry_reasig.value,\n",
" log=print, progress=bar_reasig)\n",
"btn_reasig_ejecutar.on_click(_on_reasig_ejecutar)\n",
"\n",
"tab_desc.children = tuple(list(tab_desc.children) + [\n",
" W.HTML('<h3 style=\"margin-top:24px;border-top:2px solid #00838F;padding-top:14px;\">Reasignacion de Lineas EXPO</h3>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Sube un Excel con las descargas a reasignar. La utileria '\n",
" 'cambia <b>FACTEXPO, FACREFERENCIA y LINEAEXPO</b> en SDescargaT (no crea nuevas filas, no '\n",
" 'toca SDescargaM ni SFacExp). Valida que cada descarga origen sea unica y que el destino exista; '\n",
" 'las que no cumplen quedan en la hoja de inconsistencias.</p>'),\n",
" W.HTML(_html_formato_reasig),\n",
" btn_reasig_plantilla,\n",
" W.HTML('<p style=\"margin-top:14px;\"><b>Sube tu Excel:</b></p>'),\n",
" W.HBox([upload_reasig]),\n",
" W.HBox([btn_reasig_analizar, btn_reasig_xlsx]), bar_reasig,\n",
" out_reasig_log, out_reasig_tabla,\n",
" W.HTML('<h4 style=\"margin-top:20px;border-top:2px solid #FFA726;padding-top:14px;\">Paso B - Ejecutar reasignacion</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Solo aplica las filas del <b>Plan OK</b>. '\n",
" 'Las inconsistencias se ignoran. <b>Corre primero con DRY_RUN activado.</b></p>'),\n",
" W.HBox([dry_reasig]),\n",
" btn_reasig_ejecutar,\n",
"])\n",
"\n",
"\n",
"\n",
"\n",
"# ===== Consumo de Saldos Vencidos por Rango (en pestania Descargas) =====\n",
"upload_csv_ = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel')\n",
"btn_csv_plantilla = W.Button(description='Descargar plantilla', button_style='info', icon='download', layout={'width':'220px'})\n",
"w_csv_fi = W.DatePicker(description='Vence desde:', layout={'width':'260px'})\n",
"w_csv_ff = W.DatePicker(description='Vence hasta:', layout={'width':'260px'})\n",
"w_csv_modo = W.RadioButtons(options=[('NATURAL','NATURAL'),('DIRIGIDA','DIRIGIDA')],\n",
" value='DIRIGIDA', description='Modo:', layout={'width':'320px'})\n",
"btn_csv_listar = W.Button(description='Listar exportaciones', button_style='', icon='list', layout={'width':'220px'})\n",
"btn_csv_analizar = W.Button(description='Analizar', button_style='primary', icon='search', layout={'width':'180px'})\n",
"btn_csv_xlsx = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'200px'})\n",
"dry_csv = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n",
"btn_csv_ejecutar = W.Button(description='Ejecutar (Paso B)', button_style='warning', icon='play', layout={'width':'260px'})\n",
"bar_csv = _mkbar('Consumo vencidos')\n",
"out_csv_log = W.Output(layout=OUT_STYLE)\n",
"out_csv_tabla = W.Output(layout=OUT_STYLE)\n",
"\n",
"_html_csv = ('<div style=\"background:#E3F2FD;border:1px solid #1565C0;padding:12px;border-radius:6px;margin:8px 0;\">'\n",
" '<b style=\"color:#0D47A1;\">Formato esperado del Excel (4 columnas)</b>'\n",
" '<table style=\"border-collapse:collapse;font-size:12px;margin-top:6px;\">'\n",
" '<tr style=\"background:#1565C0;color:white;\"><th style=\"padding:4px 10px;text-align:left;\">Columna</th><th style=\"padding:4px 10px;text-align:left;\">Ejemplo</th></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>NUMPARTE_COMPONENTE</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">COMP-001</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>PCT_MATERIAL</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">8.5</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>NUMPARTE_PT</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">PT-ABC-123</td></tr>'\n",
" '<tr><td style=\"padding:4px 10px;border:1px solid #ddd;\"><b>UNIMED</b></td><td style=\"padding:4px 10px;border:1px solid #ddd;font-family:Consolas;\">KGS</td></tr>'\n",
" '</table>'\n",
" '<p style=\"margin:8px 0 0 0;color:#555;font-size:11px;\">'\n",
" 'Si UNIMED es KGS/TON, la cantidad requerida = (PCT/100) x PESONETO de la partida. '\n",
" 'Si es PZA/LT/etc, = (PCT/100) x CANTEXPO. El rango filtra saldos en SSaldoTem por '\n",
" 'FECHAVENC_ISO; solo se consideran los vencidos a hoy.'\n",
" '</p></div>')\n",
"\n",
"def _on_csv_plantilla(_):\n",
" with out_csv_log:\n",
" clear_output()\n",
" bts = generar_plantilla_consumo_vencidos()\n",
" path = os.path.join(os.getcwd(), 'plantilla_consumo_vencidos.xlsx')\n",
" with open(path, 'wb') as f: f.write(bts)\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Plantilla generada</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\n",
"btn_csv_plantilla.on_click(_on_csv_plantilla)\n",
"\n",
"def _on_csv_listar(_):\n",
" with out_csv_tabla:\n",
" clear_output()\n",
" if not CONEXION_OK: print(CONEXION_MSG); return\n",
" if len(upload_csv_.value) == 0:\n",
" print('Sube primero el Excel.'); return\n",
" try:\n",
" if isinstance(upload_csv_.value, dict):\n",
" fname = list(upload_csv_.value.keys())[0]\n",
" fb = upload_csv_.value[fname]['content']\n",
" else:\n",
" fb = upload_csv_.value[0]['content']\n",
" tmp = os.path.join(os.getcwd(), '_upload_csv.xlsx')\n",
" with open(tmp, 'wb') as f: f.write(fb)\n",
" df_excel = cargar_excel_consumo_vencidos(tmp)\n",
" print(f'Excel: {len(df_excel):,} combinaciones (componente, PT)')\n",
" detalle, resumen = listar_partidas_expo_por_pt(df_excel['NUMPARTE_PT'].unique().tolist())\n",
" print(f'Partidas EXPO totales en facturas AC con esos PT: {len(detalle):,}')\n",
" if not resumen.empty:\n",
" display(HTML('<h4>Resumen por PT</h4>'))\n",
" display(resumen)\n",
" if not detalle.empty:\n",
" display(HTML('<h4>Partidas EXPO (primeras 200)</h4>'))\n",
" display(detalle.head(200))\n",
" except Exception as e:\n",
" print(f'ERROR: {e}')\n",
"btn_csv_listar.on_click(_on_csv_listar)\n",
"\n",
"def _on_csv_analizar(_):\n",
" with out_csv_log:\n",
" clear_output()\n",
" if not CONEXION_OK: print(CONEXION_MSG); return\n",
" if len(upload_csv_.value) == 0:\n",
" print('Sube primero el Excel.'); return\n",
" if not w_csv_fi.value or not w_csv_ff.value:\n",
" print('Define el rango (Vence desde / Vence hasta).'); return\n",
" try:\n",
" if isinstance(upload_csv_.value, dict):\n",
" fname = list(upload_csv_.value.keys())[0]\n",
" fb = upload_csv_.value[fname]['content']\n",
" else:\n",
" fb = upload_csv_.value[0]['content']\n",
" tmp = os.path.join(os.getcwd(), '_upload_csv.xlsx')\n",
" with open(tmp, 'wb') as f: f.write(fb)\n",
" df_excel = cargar_excel_consumo_vencidos(tmp)\n",
" print(f'Excel: {len(df_excel):,} combinaciones')\n",
" except Exception as e:\n",
" print(f'ERROR Excel: {e}'); return\n",
" df_comp, resumen, incons = analizar_consumo_vencidos(\n",
" df_excel, str(w_csv_fi.value), str(w_csv_ff.value),\n",
" progress=bar_csv, log=print)\n",
" _state['csv_plan'] = df_comp\n",
" _state['csv_resumen']= resumen\n",
" _state['csv_incons'] = incons\n",
" with out_csv_tabla:\n",
" clear_output()\n",
" if 'csv_resumen' in _state and not _state['csv_resumen'].empty:\n",
" display(HTML('<h4>Resumen por TIPO</h4>'))\n",
" display(_state['csv_resumen'])\n",
" if 'csv_plan' in _state and not _state['csv_plan'].empty:\n",
" display(HTML('<h4>Plan detalle (primeras 100)</h4>'))\n",
" display(_state['csv_plan'].head(100))\n",
" if 'csv_incons' in _state and not _state['csv_incons'].empty:\n",
" display(HTML('<h4 style=\"color:#C62828;\">Inconsistencias</h4>'))\n",
" display(_state['csv_incons'])\n",
"btn_csv_analizar.on_click(_on_csv_analizar)\n",
"\n",
"def _on_csv_xlsx(_):\n",
" with out_csv_log:\n",
" if 'csv_plan' not in _state:\n",
" display(HTML('<div style=\"color:#C62828;\">Corre Analizar primero.</div>')); return\n",
" path = os.path.join(os.getcwd(), 'plan_consumo_vencidos.xlsx')\n",
" with pd.ExcelWriter(path, engine='openpyxl') as w:\n",
" if not _state['csv_plan'].empty:\n",
" _state['csv_plan'].to_excel(w, sheet_name='Plan_Detalle', index=False)\n",
" if 'csv_resumen' in _state and not _state['csv_resumen'].empty:\n",
" _state['csv_resumen'].to_excel(w, sheet_name='Resumen', index=False)\n",
" if 'csv_incons' in _state and not _state['csv_incons'].empty:\n",
" _state['csv_incons'].to_excel(w, sheet_name='Inconsistencias', index=False)\n",
" display(HTML(f'<div style=\"background:#E8F5E9;border:1px solid #66BB6A;padding:10px;border-radius:6px;margin:8px 0;\">'\n",
" f'<b style=\"color:#1B5E20;\">Excel generado</b><br>'\n",
" f'<span style=\"font-family:Consolas;font-size:12px;color:#333;\">{path}</span></div>'))\n",
"btn_csv_xlsx.on_click(_on_csv_xlsx)\n",
"\n",
"def _on_csv_ejecutar(_):\n",
" with out_csv_log:\n",
" if not CONEXION_OK: print(CONEXION_MSG); return\n",
" if 'csv_plan' not in _state:\n",
" print('Corre Analizar primero.'); return\n",
" print(f'(DB actual: {DB_ACTUAL})')\n",
" ejecutar_consumo_vencidos(_state['csv_plan'], modo_comp=w_csv_modo.value,\n",
" dry_run=dry_csv.value, log=print, progress=bar_csv)\n",
"btn_csv_ejecutar.on_click(_on_csv_ejecutar)\n",
"\n",
"tab_desc.children = tuple(list(tab_desc.children) + [\n",
" W.HTML('<h3 style=\"margin-top:24px;border-top:2px solid #9C27B0;padding-top:14px;\">Consumo de Saldos Vencidos por Rango</h3>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Sube un Excel con el BOM puntual (componente, %, PT, UM) '\n",
" 'y un rango de fechas. La utileria recorre las partidas EXPO en facturas AC con cada PT, calcula la cantidad '\n",
" 'requerida del componente segun la UM, descuenta lo ya descargado, consume saldos vencidos en el rango (FIFO) '\n",
" 'y genera <b>descargas complementarias</b> usando el mismo motor del Paso 10/12.</p>'),\n",
" W.HTML(_html_csv),\n",
" btn_csv_plantilla,\n",
" W.HTML('<p style=\"margin-top:14px;\"><b>Sube tu Excel y define el rango de vencimiento:</b></p>'),\n",
" W.HBox([upload_csv_]),\n",
" W.HBox([w_csv_fi, w_csv_ff]),\n",
" W.HBox([btn_csv_listar, btn_csv_analizar, btn_csv_xlsx]), bar_csv,\n",
" out_csv_log, out_csv_tabla,\n",
" W.HTML('<h4 style=\"margin-top:20px;border-top:2px solid #FFA726;padding-top:14px;\">Paso B - Ejecutar consumo</h4>'\n",
" '<p style=\"color:#555;margin:0 0 8px 0;\">Inserta las descargas como complementarias. '\n",
" 'NATURAL excluye facturas con faltantes; DIRIGIDA inserta lo que se pudo cubrir. '\n",
" '<b>Corre primero con DRY_RUN activado.</b></p>'),\n",
" W.HBox([w_csv_modo]),\n",
" W.HBox([dry_csv]),\n",
" btn_csv_ejecutar,\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
}