diff --git a/app/Manual_Descargas.docx b/app/Manual_Descargas.docx new file mode 100644 index 0000000..8dce6bd Binary files /dev/null and b/app/Manual_Descargas.docx differ diff --git a/app/app.ipynb b/app/app.ipynb index 48f4dbe..5791ad2 100644 --- a/app/app.ipynb +++ b/app/app.ipynb @@ -1384,7 +1384,143 @@ " prog.done(f'{inserted} insertados')\n", "\n", "\n", - "\n" + "def calcular_saldos_descargas_por_anio_fraccion(top_n=30, fraccion_digitos=4, progress=None):\n", + " \"\"\"Calcula peso/cantidad/valor IMPORTADO y DESCARGADO por (anio, fraccion).\n", + " fraccion_digitos: 4, 6 u 8. top_n: limita a las N fracciones con mas actividad.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(2, 'Cargando IMPO por fraccion...')\n", + " fdig = int(fraccion_digitos)\n", + " sql_impo = f\"\"\"\n", + " SELECT YEAR(FECHAFACTURA_ISO) AS ANIO,\n", + " LEFT(FRACCIONIMPO, {fdig}) AS FRACCION,\n", + " SUM(ISNULL(PESONETO,0)) AS peso_impo,\n", + " SUM(ISNULL(CANTEXITENCIA,0)) AS cant_impo,\n", + " SUM(ISNULL(VALORIMPOMN,0)) AS valor_impo_mn\n", + " FROM SSaldoTem\n", + " WHERE FECHAFACTURA_ISO IS NOT NULL AND FRACCIONIMPO IS NOT NULL\n", + " GROUP BY YEAR(FECHAFACTURA_ISO), LEFT(FRACCIONIMPO, {fdig})\n", + " \"\"\"\n", + " df_i = pd.read_sql(sql_impo, scaii_conn)\n", + " prog.step(desc='Cargando DESCARGAS por fraccion...')\n", + " sql_desc = f\"\"\"\n", + " SELECT YEAR(f.FECHAFACTURA_ISO) AS ANIO,\n", + " LEFT(sp.FRACCION, {fdig}) AS FRACCION,\n", + " SUM(ISNULL(d.PESONETO,0)) AS peso_descargas,\n", + " SUM(ISNULL(d.CANTDESC,0)) AS cant_descargas,\n", + " SUM(ISNULL(d.VALORMN,0)) AS valor_descargas_mn\n", + " FROM SDescargaT d\n", + " INNER JOIN SFacExp f ON f.FACTURAEXPO = d.FACTEXPO\n", + " INNER JOIN SPartes sp ON sp.NUMPARTE = d.NUMPARTE\n", + " WHERE f.FECHAFACTURA_ISO IS NOT NULL AND sp.FRACCION IS NOT NULL\n", + " GROUP BY YEAR(f.FECHAFACTURA_ISO), LEFT(sp.FRACCION, {fdig})\n", + " \"\"\"\n", + " df_d = pd.read_sql(sql_desc, scaii_conn)\n", + " cmp = df_i.merge(df_d, on=['ANIO','FRACCION'], how='outer').fillna(0)\n", + " if cmp.empty:\n", + " prog.done('Sin datos'); return cmp\n", + " cmp['ANIO'] = cmp['ANIO'].astype(int)\n", + " tot = (cmp.groupby('FRACCION')[['peso_impo','peso_descargas']].sum().sum(axis=1)\n", + " .sort_values(ascending=False).head(int(top_n)))\n", + " cmp = cmp[cmp['FRACCION'].isin(tot.index)].copy()\n", + " for c in ['peso_impo','peso_descargas','cant_impo','cant_descargas',\n", + " 'valor_impo_mn','valor_descargas_mn']:\n", + " if c in cmp.columns: cmp[c] = cmp[c].round(2)\n", + " prog.done(f'{len(tot)} fracciones')\n", + " return cmp.sort_values(['ANIO','FRACCION']).reset_index(drop=True)\n", + "\n", + "\n", + "\n", + "def graficar_saldos_descargas_anio_fraccion(cmp, metric='peso'):\n", + " \"\"\"Por cada anio, dos barras separadas (IMPO solido y DESC con hatch),\n", + " apiladas internamente por fraccion con % en cada segmento. Totales rotados 90.\n", + " Las barras estan visualmente agrupadas dentro de un 'bloque' por anio y los\n", + " anios estan bien separados entre si.\"\"\"\n", + " plt.close('all')\n", + " if cmp is None or cmp.empty:\n", + " print('Sin datos para graficar.'); return None\n", + " if metric == 'cantidad':\n", + " col_i, col_d, ylab = 'cant_impo', 'cant_descargas', 'Cantidad'\n", + " elif metric == 'valor_mn':\n", + " col_i, col_d, ylab = 'valor_impo_mn', 'valor_descargas_mn', 'Valor MN'\n", + " else:\n", + " col_i, col_d, ylab = 'peso_impo', 'peso_descargas', 'Peso neto'\n", + " anios = sorted(cmp['ANIO'].astype(int).unique())\n", + " total_por_frac = (cmp.groupby('FRACCION')[[col_i, col_d]].sum().sum(axis=1)\n", + " .sort_values(ascending=False))\n", + " fracs = total_por_frac.index.tolist()\n", + " pv_i = (cmp.pivot_table(index='ANIO', columns='FRACCION', values=col_i, aggfunc='sum')\n", + " .reindex(anios).fillna(0))\n", + " pv_d = (cmp.pivot_table(index='ANIO', columns='FRACCION', values=col_d, aggfunc='sum')\n", + " .reindex(anios).fillna(0))\n", + " tot_i = pv_i.sum(axis=1).values\n", + " tot_d = pv_d.sum(axis=1).values\n", + " # Mas espacio entre anios para que IMPO/DESC no se empalmen entre vecinos\n", + " sep_anios = 1.6 # multiplicador de separacion entre anios\n", + " xpos = np.arange(len(anios), dtype=float) * sep_anios\n", + " width = 0.55\n", + " gap = 0.65 # separacion entre IMPO y DESC dentro del mismo anio\n", + " fig, ax = plt.subplots(figsize=(max(14, len(anios)*1.6), 8))\n", + " fig.suptitle(f'IMPO vs DESCARGAS por anio - apilado por fraccion ({ylab})',\n", + " fontsize=13, fontweight='bold')\n", + " cmap = plt.cm.tab20\n", + " colors = [cmap(i % 20) for i in range(len(fracs))]\n", + " bot_i = np.zeros(len(anios)); bot_d = np.zeros(len(anios))\n", + " for i, frac in enumerate(fracs):\n", + " iv = pv_i[frac].astype(float).values if frac in pv_i.columns else np.zeros(len(anios))\n", + " dv = pv_d[frac].astype(float).values if frac in pv_d.columns else np.zeros(len(anios))\n", + " ax.bar(xpos - gap/2, iv, width, bottom=bot_i, color=colors[i],\n", + " edgecolor='white', linewidth=0.5, label=frac)\n", + " ax.bar(xpos + gap/2, dv, width, bottom=bot_d, color=colors[i],\n", + " edgecolor='white', linewidth=0.5, hatch='///')\n", + " for j in range(len(anios)):\n", + " # % en segmentos significativos (>= 5%)\n", + " if tot_i[j] > 0 and iv[j]/tot_i[j] >= 0.05:\n", + " ax.text(xpos[j] - gap/2, bot_i[j] + iv[j]/2,\n", + " f'{iv[j]/tot_i[j]*100:.0f}%',\n", + " ha='center', va='center', fontsize=7, color='black')\n", + " if tot_d[j] > 0 and dv[j]/tot_d[j] >= 0.05:\n", + " ax.text(xpos[j] + gap/2, bot_d[j] + dv[j]/2,\n", + " f'{dv[j]/tot_d[j]*100:.0f}%',\n", + " ha='center', va='center', fontsize=7, color='black')\n", + " bot_i += iv; bot_d += dv\n", + " ymax = max(bot_i.max() if len(bot_i) else 0, bot_d.max() if len(bot_d) else 0, 1)\n", + " # Totales arriba, rotados 90 grados para que no se traslapen\n", + " for j, x in enumerate(xpos):\n", + " if tot_i[j] > 0:\n", + " ax.text(x - gap/2, bot_i[j] + ymax*0.005,\n", + " f'{tot_i[j]:,.0f}',\n", + " ha='center', va='bottom', fontsize=8, color='#1976D2',\n", + " fontweight='bold', rotation=90)\n", + " if tot_d[j] > 0:\n", + " ax.text(x + gap/2, bot_d[j] + ymax*0.005,\n", + " f'{tot_d[j]:,.0f}',\n", + " ha='center', va='bottom', fontsize=8, color='#8E24AA',\n", + " fontweight='bold', rotation=90)\n", + " # XTicks: solo el anio, centrado entre las dos barras\n", + " ax.set_xticks(xpos)\n", + " ax.set_xticklabels(anios, fontsize=10, fontweight='bold')\n", + " # Sub-etiquetas IMPO/DESC abajo de cada barra (mas separadas del eje X)\n", + " yoff = -ymax * 0.045\n", + " for j, x in enumerate(xpos):\n", + " ax.text(x - gap/2, yoff, 'IMPO', ha='center', va='top',\n", + " fontsize=7, color='#1976D2', fontweight='bold')\n", + " ax.text(x + gap/2, yoff, 'DESC', ha='center', va='top',\n", + " fontsize=7, color='#8E24AA', fontweight='bold')\n", + " ax.set_xlabel('\\nAnio (IMPO = importado solido, DESC = descargado con rayas)', fontsize=10)\n", + " ax.set_ylabel(ylab)\n", + " ax.yaxis.set_major_formatter(mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + " # Dejar espacio extra arriba para los totales rotados\n", + " ax.set_ylim(top=ymax * 1.25)\n", + " max_leg = 30\n", + " handles, labels = ax.get_legend_handles_labels()\n", + " if len(labels) > max_leg:\n", + " handles = handles[:max_leg]; labels = labels[:max_leg] + [f'... (+{len(fracs)-max_leg})']\n", + " handles.append(plt.Rectangle((0,0),1,1, color='lightgray'))\n", + " ax.legend(handles, labels, title='Fraccion', bbox_to_anchor=(1.02, 1),\n", + " loc='upper left', fontsize=7, ncol=1)\n", + " ax.grid(axis='y', linestyle=':', alpha=0.4)\n", + " plt.tight_layout()\n", + " return fig" ] }, { @@ -2121,39 +2257,194 @@ " return pd.read_sql(sql, scaii_conn, params=(fecha_corte,))\n", "\n", "\n", - "def graficar_saldos_vencidos_por_anio(df, metric='SALDO_VMN'):\n", + "def cargar_saldos_vencidos_por_fraccion(fecha_corte=None, fraccion_digitos=4, top_n=30):\n", + " \"\"\"Saldos vencidos agrupados por LEFT(FRACCIONIMPO, N). Top N fracciones.\"\"\"\n", + " if fecha_corte is None:\n", + " fecha_corte = _dt_sv.date.today().isoformat()\n", + " fdig = int(fraccion_digitos)\n", + " sql = f\"\"\"\n", + " SELECT LEFT(FRACCIONIMPO, {fdig}) AS FRACCION,\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", + " SUM((CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) *\n", + " CASE WHEN CANTEXITENCIA > 0\n", + " THEN (ISNULL(PESONETO,0) * 1.0 / CANTEXITENCIA)\n", + " ELSE 0 END) AS SALDO_PESONETO\n", + " FROM SSaldoTem\n", + " WHERE FECHAVENC_ISO IS NOT NULL\n", + " AND FECHAVENC_ISO < ?\n", + " AND FRACCIONIMPO IS NOT NULL\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " GROUP BY LEFT(FRACCIONIMPO, {fdig})\n", + " ORDER BY SALDO_VMN DESC\n", + " \"\"\"\n", + " df = pd.read_sql(sql, scaii_conn, params=(fecha_corte,))\n", + " if int(top_n) and len(df) > int(top_n):\n", + " df = df.head(int(top_n))\n", + " return df\n", + "\n", + "\n", + "def cargar_saldos_vencidos_por_unimed(fecha_corte=None):\n", + " \"\"\"Saldos vencidos agrupados por UMEXITENCIA.\"\"\"\n", + " if fecha_corte is None:\n", + " fecha_corte = _dt_sv.date.today().isoformat()\n", + " sql = \"\"\"\n", + " SELECT ISNULL(NULLIF(LTRIM(RTRIM(UMEXITENCIA)),''),'(sin UM)') AS UMEXITENCIA,\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", + " SUM((CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) *\n", + " CASE WHEN CANTEXITENCIA > 0\n", + " THEN (ISNULL(PESONETO,0) * 1.0 / CANTEXITENCIA)\n", + " ELSE 0 END) AS SALDO_PESONETO\n", + " FROM SSaldoTem\n", + " WHERE FECHAVENC_ISO IS NOT NULL\n", + " AND FECHAVENC_ISO < ?\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " GROUP BY ISNULL(NULLIF(LTRIM(RTRIM(UMEXITENCIA)),''),'(sin UM)')\n", + " ORDER BY SALDO_VMN DESC\n", + " \"\"\"\n", + " return pd.read_sql(sql, scaii_conn, params=(fecha_corte,))\n", + "\n", + "\n", + "def _fmt_sv_df(df):\n", + " \"\"\"Devuelve un Styler con formato de dinero y cantidades.\"\"\"\n", + " if df is None or df.empty: return df\n", + " fmt = {}\n", + " if 'SALDO_VMN' in df.columns: fmt['SALDO_VMN'] = '${:,.2f}'\n", + " if 'SALDO_VME' in df.columns: fmt['SALDO_VME'] = 'US${:,.2f}'\n", + " if 'SALDO_CANT' in df.columns: fmt['SALDO_CANT'] = '{:,.2f}'\n", + " if 'SALDO_PESONETO' in df.columns: fmt['SALDO_PESONETO'] = '{:,.2f}'\n", + " if 'LOTES' in df.columns: fmt['LOTES'] = '{:,}'\n", + " if 'FACTURAS' in df.columns: fmt['FACTURAS'] = '{:,}'\n", + " try:\n", + " return df.style.format(fmt).set_properties(**{'text-align':'right'})\n", + " except Exception:\n", + " return df\n", + "\n", + "\n", + "def _fmt_eje_y(metric, ax):\n", + " \"\"\"Aplica formato $ o numerico al eje Y segun la metrica.\"\"\"\n", " import matplotlib.ticker as _mtick\n", + " if metric in ('SALDO_VMN',):\n", + " ax.yaxis.set_major_formatter(_mtick.FuncFormatter(lambda v,_: f'${v:,.0f}'))\n", + " elif metric in ('SALDO_VME',):\n", + " ax.yaxis.set_major_formatter(_mtick.FuncFormatter(lambda v,_: f'US${v:,.0f}'))\n", + " else:\n", + " ax.yaxis.set_major_formatter(_mtick.FuncFormatter(lambda v,_: f'{v:,.0f}'))\n", + "\n", + "\n", + "def _fmt_etq_metric(metric, value):\n", + " if metric == 'SALDO_VMN': return f'${value:,.0f}'\n", + " if metric == 'SALDO_VME': return f'US${value:,.0f}'\n", + " return f'{value:,.0f}'\n", + "\n", + "\n", + "_TITULOS_METRIC_SV = {\n", + " 'SALDO_VMN': 'Valor MN (pesos mexicanos)',\n", + " 'SALDO_VME': 'Valor ME (dolares)',\n", + " 'SALDO_CANT': 'Cantidad disponible',\n", + " 'SALDO_PESONETO': 'Peso neto',\n", + " 'LOTES': 'Cantidad de lotes',\n", + " 'FACTURAS': 'Cantidad de facturas',\n", + "}\n", + "\n", + "\n", + "def graficar_saldos_vencidos_por_anio(df, metric='SALDO_VMN'):\n", + " plt.close('all')\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", + " print('Sin datos para graficar.'); return None\n", " col_x = 'ANIO_FACTURA' if 'ANIO_FACTURA' in df.columns else 'ANIO_VENC'\n", + " fig, ax = plt.subplots(figsize=(11, 5.5))\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", + " f'{lotes:,} lotes\\n{_fmt_etq_metric(metric, b.get_height())}',\n", + " ha='center', va='bottom', fontsize=8, color='#333')\n", + " titulo_metric = _TITULOS_METRIC_SV.get(metric, metric)\n", + " ax.set_title(f'Saldos vencidos por anio - {titulo_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.set_xlabel('Anio'); ax.set_ylabel(titulo_metric)\n", + " _fmt_eje_y(metric, ax)\n", " ax.grid(axis='y', linestyle='--', alpha=0.5)\n", + " ax.set_ylim(top=max(y) * 1.18 if y else 1)\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", - " ))" + " display(_fmt_sv_df(df))\n", + " return fig\n", + "\n", + "\n", + "def graficar_saldos_vencidos_por_fraccion(df, metric='SALDO_VMN'):\n", + " plt.close('all')\n", + " if df is None or df.empty:\n", + " print('Sin datos para graficar.'); return None\n", + " fig, ax = plt.subplots(figsize=(max(11, len(df)*0.6), 6.5))\n", + " x = df['FRACCION'].astype(str).tolist()\n", + " y = df[metric].astype(float).tolist()\n", + " bars = ax.bar(x, y, color='#6A1B9A', edgecolor='#4A148C')\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:,}\\n{_fmt_etq_metric(metric, b.get_height())}',\n", + " ha='center', va='bottom', fontsize=7, color='#333', rotation=90)\n", + " titulo_metric = _TITULOS_METRIC_SV.get(metric, metric)\n", + " ax.set_title(f'Saldos vencidos por fraccion - {titulo_metric}',\n", + " fontsize=13, fontweight='bold', color='#4A148C')\n", + " ax.set_xlabel('Fraccion arancelaria'); ax.set_ylabel(titulo_metric)\n", + " _fmt_eje_y(metric, ax)\n", + " ax.grid(axis='y', linestyle='--', alpha=0.5)\n", + " ax.set_ylim(top=max(y) * 1.25 if y else 1)\n", + " plt.xticks(rotation=45, ha='right', fontsize=8)\n", + " plt.tight_layout()\n", + " plt.show()\n", + " display(_fmt_sv_df(df))\n", + " return fig\n", + "\n", + "\n", + "def graficar_saldos_vencidos_por_unimed(df, metric='SALDO_VMN'):\n", + " plt.close('all')\n", + " if df is None or df.empty:\n", + " print('Sin datos para graficar.'); return None\n", + " fig, ax = plt.subplots(figsize=(max(11, len(df)*0.7), 5.5))\n", + " x = df['UMEXITENCIA'].astype(str).tolist()\n", + " y = df[metric].astype(float).tolist()\n", + " bars = ax.bar(x, y, color='#00838F', edgecolor='#006064')\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\\n{_fmt_etq_metric(metric, b.get_height())}',\n", + " ha='center', va='bottom', fontsize=8, color='#333')\n", + " titulo_metric = _TITULOS_METRIC_SV.get(metric, metric)\n", + " ax.set_title(f'Saldos vencidos por unidad de medida - {titulo_metric}',\n", + " fontsize=13, fontweight='bold', color='#006064')\n", + " ax.set_xlabel('Unidad de medida'); ax.set_ylabel(titulo_metric)\n", + " _fmt_eje_y(metric, ax)\n", + " ax.grid(axis='y', linestyle='--', alpha=0.5)\n", + " ax.set_ylim(top=max(y) * 1.18 if y else 1)\n", + " plt.tight_layout()\n", + " plt.show()\n", + " display(_fmt_sv_df(df))\n", + " return fig" ] }, { @@ -4205,13 +4496,22 @@ "\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", + " options=[('Valor MN ($)','SALDO_VMN'),('Valor ME (US$)','SALDO_VME'),\n", + " ('Cantidad disponible','SALDO_CANT'),('Peso neto','SALDO_PESONETO'),\n", + " ('Lotes','LOTES')],\n", + " value='SALDO_VMN', description='Metrica:', layout={'width':'280px'})\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", + "w_sv_agrup = W.Dropdown(\n", + " options=[('Por Anio','ANIO'),('Por Fraccion','FRACCION'),('Por Unidad de medida','UNIMED')],\n", + " value='ANIO', description='Agrupar:', layout={'width':'260px'})\n", + "w_sv_fdig = W.RadioButtons(\n", + " options=[('4 digitos', 4), ('6 digitos', 6), ('8 digitos', 8)],\n", + " value=4, description='Frac. digitos:', layout={'width':'260px'})\n", + "w_sv_topn = W.IntSlider(value=30, min=5, max=100, step=1,\n", + " description='Top N:', layout={'width':'360px'})\n", + "btn_sv_grafica = W.Button(description='Ver grafica de saldos vencidos',\n", " button_style='primary', icon='bar-chart', layout={'width':'320px'})\n", "out_sv_grafica = W.Output(layout=OUT_STYLE)\n", "\n", @@ -4223,11 +4523,26 @@ " 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", + " try:\n", + " if w_sv_agrup.value == 'FRACCION':\n", + " df = cargar_saldos_vencidos_por_fraccion(\n", + " fecha_corte=hoy, fraccion_digitos=int(w_sv_fdig.value),\n", + " top_n=int(w_sv_topn.value))\n", + " if df.empty: print('No hay saldos vencidos.'); return\n", + " print(f'Fracciones con saldos vencidos: {len(df)} (top {int(w_sv_topn.value)})')\n", + " graficar_saldos_vencidos_por_fraccion(df, metric=w_sv_metric.value)\n", + " elif w_sv_agrup.value == 'UNIMED':\n", + " df = cargar_saldos_vencidos_por_unimed(fecha_corte=hoy)\n", + " if df.empty: print('No hay saldos vencidos.'); return\n", + " print(f'Unidades de medida con saldos vencidos: {len(df)}')\n", + " graficar_saldos_vencidos_por_unimed(df, metric=w_sv_metric.value)\n", + " else:\n", + " df = cargar_saldos_vencidos_por_anio(fecha_corte=hoy, eje=w_sv_eje.value)\n", + " if df.empty: 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", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", "btn_sv_grafica.on_click(_on_sv_grafica)\n", "\n", "tab_sv.children = tuple(list(tab_sv.children) + [\n", @@ -4236,7 +4551,10 @@ " 'Resumen global de saldos en SSaldoTem cuyo FECHAVENC_ISO < hoy y '\n", " 'SALDO_DISPONIBLE > 0, agrupados por anio de vencimiento. '\n", " 'No depende del rango de fechas de arriba.

'),\n", - " W.HBox([w_sv_metric, w_sv_eje, btn_sv_grafica]),\n", + " W.HBox([w_sv_agrup, w_sv_metric]),\n", + " W.HBox([w_sv_eje, w_sv_fdig, w_sv_topn]),\n", + " W.HTML('Eje X aplica solo a \"Por Anio\". Fraccion digitos y Top N aplican solo a \"Por Fraccion\".'),\n", + " W.HBox([btn_sv_grafica]),\n", " out_sv_grafica,\n", "])\n", "\n", @@ -5400,6 +5718,57 @@ "])\n", "\n", "\n", + "\n", + "\n", + "# ===== Saldo IMPO vs Descargas por fraccion =====\n", + "w_frac_topn = W.IntSlider(value=30, min=3, max=100, step=1, description='Top fracciones:',\n", + " layout={'width':'380px'})\n", + "w_frac_dig = W.RadioButtons(options=[('4 digitos (Capitulo+Partida)', 4),\n", + " ('6 digitos (Subpartida)', 6),\n", + " ('8 digitos (Completa)', 8)],\n", + " value=4, description='Granularidad:', layout={'width':'380px'})\n", + "w_frac_met = W.Dropdown(options=[('Peso neto','peso'),('Cantidad','cantidad'),('Valor MN','valor_mn')],\n", + " value='peso', description='Metrica:', layout={'width':'260px'})\n", + "btn_frac = W.Button(description='Calcular saldos vs descargas por fraccion',\n", + " button_style='primary', icon='bar-chart', layout={'width':'380px'})\n", + "bar_frac = _mkbar('Por fraccion')\n", + "out_frac_log = W.Output(layout=OUT_STYLE)\n", + "out_frac_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "def _on_frac(_):\n", + " with out_frac_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " df = calcular_saldos_descargas_por_anio_fraccion(\n", + " top_n=int(w_frac_topn.value),\n", + " fraccion_digitos=int(w_frac_dig.value),\n", + " progress=bar_frac)\n", + " _state['frac_df'] = df\n", + " if df.empty:\n", + " print('Sin datos.'); return\n", + " print(f'Filas: {len(df):,} | Anios: {df[\"ANIO\"].nunique()} | Fracciones: {df[\"FRACCION\"].nunique()}')\n", + " fig = graficar_saldos_descargas_anio_fraccion(df, metric=w_frac_met.value)\n", + " if fig is not None: display(fig); plt.close(fig)\n", + " with out_frac_tabla:\n", + " clear_output()\n", + " if 'frac_df' in _state and not _state['frac_df'].empty:\n", + " display(HTML('

Tabla detalle por anio y fraccion

'))\n", + " display(_state['frac_df'])\n", + "btn_frac.on_click(_on_frac)\n", + "\n", + "tab_an.children = tuple(list(tab_an.children) + [\n", + " W.HTML('

Saldo IMPO disponible vs Descargas por anio y fraccion

'\n", + " '

Cruza SSaldoTem (saldo IMPO disponible) y '\n", + " 'SDescargaT (descargas) agrupando por anio y fraccion arancelaria. '\n", + " 'Muestra solo las fracciones con mas actividad (Top N).

'),\n", + " W.HBox([w_frac_topn]),\n", + " W.HBox([w_frac_dig]),\n", + " W.HBox([w_frac_met, btn_frac]),\n", + " bar_frac, out_frac_log, out_frac_tabla,\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",