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 24ae5c0..327956a 100644 --- a/app/app.ipynb +++ b/app/app.ipynb @@ -15799,6 +15799,9 @@ " 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", @@ -15970,7 +15973,658 @@ " _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)" + " _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}')\n", + "\n", + "\n", + "# ─────────────────────────────────────────────────────────────────────────────\n", + "# DESCARGA POR FRACCIÓN + UM\n", + "# Match: LEFT(FRACCIONIMPO, 8) = LEFT(FRACCIONEXPO, 8) AND UMEXITENCIA = UNIMED\n", + "# Sin BOM — cantidad directamente de CANTEXPO en SPartidasExpo.\n", + "# ─────────────────────────────────────────────────────────────────────────────\n", + "\n", + "def _saldos_idx_fraccion(saldos, fraccion8, um_key, fecha_export):\n", + " mask = ((saldos['FRACCION8'] == fraccion8)\n", + " & (saldos['UM_KEY'] == um_key)\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", + "\n", + "def _consumir_fraccion(saldos, idx_list, faltante, base_row):\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': saldos.at[idx, 'NUMPARTE'],\n", + " 'TIPO': 'OK',\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", + " '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 _build_cobertura_fraccion(df):\n", + " df_d = df.copy()\n", + " df_d['cant_cubierta'] = df_d['CANT_DESCARGADA'].where(df_d['TIPO'] == 'OK', 0)\n", + " cob_part = df_d.groupby(\n", + " ['FACTURAEXPO', 'FECHA_FACTURAEXPO', 'LINEA', 'FRACCIONEXPO', 'UNIMED_EXPO', 'CANT_REQUERIDA'],\n", + " as_index=False, dropna=False\n", + " ).agg(cant_cubierta=('cant_cubierta', 'sum'))\n", + " cob_part['pct'] = ((cob_part['cant_cubierta'] / cob_part['CANT_REQUERIDA'])\n", + " .fillna(0) * 100).round(2).clip(upper=100)\n", + " cob_fact = cob_part.groupby(['FACTURAEXPO', 'FECHA_FACTURAEXPO'], as_index=False, dropna=False).agg(\n", + " partidas_total=('LINEA', 'count'),\n", + " partidas_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", + "\n", + "def _build_descarga_fraccion_df(facturas_df=None, partidas_df=None, progress=None):\n", + " prog = _Progress(progress)\n", + " _load_catalogos()\n", + " saldos = _load_saldos_snapshot()\n", + " saldos['FRACCION8'] = saldos['FRACCIONIMPO'].fillna('').str.strip().str[:8]\n", + "\n", + " if facturas_df is None:\n", + " facturas_df = pd.read_sql(\n", + " \"SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO FROM SFacExp WHERE ESTATUS='NA'\",\n", + " scaii_conn)\n", + " if partidas_df is None:\n", + " partidas_df = pd.read_sql(\n", + " \"SELECT CONSECUTIVO, FACTURAEXPO, LINEA, NUMPARTE, FRACCIONEXPO, UNIMED, \"\n", + " \"CANTEXPO, PESONETO, MONTOIGIME, PAISORIGEN FROM SPartidasExpo\",\n", + " scaii_conn)\n", + "\n", + " partepais_dict = (_state['df_partepais_all']\n", + " .drop_duplicates(subset=['FRACCION', 'PAIS'], keep='last')\n", + " .set_index(['FRACCION', 'PAIS'])[['TIPOFRACCION', 'TASAIM']]\n", + " .to_dict('index'))\n", + "\n", + " partidas_por_factura = partidas_df.groupby('FACTURAEXPO')\n", + " prog.setup(len(facturas_df), 'Procesando facturas (fraccion/UM)')\n", + " all_rows = []\n", + "\n", + " for _, f in facturas_df.iterrows():\n", + " factura = f['FACTURAEXPO']\n", + " fecha_exp = f['FECHAFACTURA_ISO']\n", + " ped_ex = f.get('PEDIMENTOEXPO')\n", + " fec_clarion = to_clarion(fecha_exp)\n", + "\n", + " if factura not in partidas_por_factura.groups:\n", + " prog.step(); continue\n", + "\n", + " for _, p in partidas_por_factura.get_group(factura).iterrows():\n", + " linea = int(p['LINEA']) if pd.notna(p['LINEA']) else 0\n", + " frac_expo = (p.get('FRACCIONEXPO') or '').strip()\n", + " frac8 = frac_expo[:8]\n", + " um_expo = (p.get('UNIMED') or '').strip().upper()\n", + " cant_req = float(p.get('CANTEXPO') or 0)\n", + "\n", + " base = {\n", + " 'FACTURAEXPO': factura,\n", + " 'FECHA_FACTURAEXPO': fecha_exp,\n", + " 'PEDIMENTOEXPO': ped_ex,\n", + " 'LINEA': linea,\n", + " 'FRACCIONEXPO': frac_expo,\n", + " 'UNIMED_EXPO': um_expo,\n", + " 'UNIMED_BOM': um_expo,\n", + " 'COMPONENTE_MP': frac8,\n", + " 'CANT_REQUERIDA': cant_req,\n", + " 'MONTOIGI': float(p.get('MONTOIGIME') or 0),\n", + " 'FECHADESC_CLARION': fec_clarion,\n", + " 'TIPOFRACCION': '',\n", + " 'ADVALOREMIMPO': None,\n", + " }\n", + "\n", + " _vacio = {**base, 'NUMPARTE_USADO': '', 'FACTURAIMPO_SALDO': '',\n", + " 'PEDIMENTOIMPO': '', 'CLASE': '', 'PAISMERCANCIA': '',\n", + " 'FRACCION_SALDO': frac_expo, 'UNIMED_SALDO': um_expo,\n", + " 'CANT_LOTE_ORIG': 0, 'VALORMN': 0, 'VALORME': 0, 'PESONETO_DESC': 0}\n", + "\n", + " if cant_req <= 1e-9 or not frac8:\n", + " all_rows.append({**_vacio, 'TIPO': 'SIN_CANT', 'CANT_DESCARGADA': 0, 'STATUS': 'SIN_CANT'})\n", + " continue\n", + "\n", + " idx_list = _saldos_idx_fraccion(saldos, frac8, um_expo, fecha_exp)\n", + " faltante, rows = _consumir_fraccion(saldos, idx_list, cant_req, base)\n", + "\n", + " for row in rows:\n", + " pp = partepais_dict.get((row['FRACCION_SALDO'], row['PAISMERCANCIA']), {})\n", + " row['TIPOFRACCION'] = pp.get('TIPOFRACCION', '')\n", + " row['ADVALOREMIMPO'] = _adv(pp.get('TASAIM'))\n", + " row['COMPONENTE_MP'] = row['NUMPARTE_USADO']\n", + " all_rows.extend(rows)\n", + "\n", + " if faltante > 1e-9:\n", + " all_rows.append({**_vacio, 'TIPO': 'FALTANTE',\n", + " 'CANT_DESCARGADA': faltante, 'NUMPARTE_USADO': frac8, 'STATUS': 'FALTANTE'})\n", + " prog.step()\n", + "\n", + " df = pd.DataFrame(all_rows) if all_rows else pd.DataFrame()\n", + " if not df.empty and 'FECHA_FACTURAEXPO' in df.columns:\n", + " prog.done('Pronostico fraccion/UM listo')\n", + " return df\n", + "\n", + "\n", + "def calcular_pronostico_fraccion_um(log=print, progress=None):\n", + " try:\n", + " log('Cargando facturas NA...')\n", + " facturas = pd.read_sql(\n", + " \"SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO FROM SFacExp WHERE ESTATUS='NA'\",\n", + " scaii_conn)\n", + " partidas = pd.read_sql(\n", + " \"SELECT CONSECUTIVO, FACTURAEXPO, LINEA, NUMPARTE, FRACCIONEXPO, UNIMED, \"\n", + " \"CANTEXPO, PESONETO, MONTOIGIME, PAISORIGEN FROM SPartidasExpo\",\n", + " scaii_conn)\n", + " log(f'Facturas NA: {len(facturas)} | Partidas: {len(partidas)}')\n", + " df = _build_descarga_fraccion_df(facturas_df=facturas, partidas_df=partidas, progress=progress)\n", + " _state['df_descarga_fraccion'] = df\n", + " cob = _build_cobertura_fraccion(df)\n", + " _state['cobertura_fraccion'] = cob\n", + " if cob.empty:\n", + " log('Sin facturas con partidas para procesar.'); return\n", + " total = len(cob)\n", + " al_100 = int((cob['partidas_100pct'] == cob['partidas_total']).sum())\n", + " log(f'Tiempo OK | Facturas: {total} | 100% cobertura: {al_100} | Parcial: {total - al_100}')\n", + " log(cob.head(20).to_string(index=False))\n", + " except Exception as e:\n", + " log(f'ERROR pronostico_fraccion_um: {e}')\n", + " raise\n", + "\n", + "\n", + "def ejecutar_fraccion_um(modo, dry_run, fecha_desde=None, fecha_hasta=None, log=print, progress=None):\n", + " if 'df_descarga_fraccion' not in _state:\n", + " log('ERROR: corre primero el pronostico de fraccion/UM.'); return\n", + " df_da = _state['df_descarga_fraccion']\n", + " cob = _state['cobertura_fraccion']\n", + "\n", + " if modo == 'NATURAL':\n", + " elig = cob[cob['partidas_100pct'] == cob['partidas_total']]['FACTURAEXPO'].tolist()\n", + " else:\n", + " elig = cob['FACTURAEXPO'].tolist()\n", + "\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", + "\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", + "\n", + " mask = df_da['FACTURAEXPO'].isin(elig) & (df_da['TIPO'] == 'OK')\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 FRACCION/UM ({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 fac, err in errores[:5]: log(f' {fac}: {err}')\n" ] }, { @@ -16049,7 +16703,7 @@ "def graficar_impo_expo(cmp):\n", " plt.close('all')\n", " fig, axes = plt.subplots(2, 1, figsize=(11, 8))\n", - " fig.suptitle('IMPO vs EXPO por aanio‚±o', fontsize=14, fontweight='bold', y=1.0)\n", + " 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", @@ -16069,7 +16723,7 @@ " ax = axes[1]\n", " b1 = ax.bar(xpos-ancho/2, cmp['valor_me_impo'], ancho, label='IMPO', color=cI)\n", " b2 = ax.bar(xpos+ancho/2, cmp['valor_me_expo'], ancho, label='EXPO', color=cE)\n", - " ax.set_title('Valor ME (USD)'); ax.set_xticks(xpos); ax.set_xticklabels(x); ax.set_xlabel('Aanio‚±o')\n", + " ax.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", @@ -16079,7 +16733,7 @@ "def graficar_saldos_anio(por_anio):\n", " plt.close('all')\n", " fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n", - " fig.suptitle('Saldo disponible por aanio‚±o de entrada', fontsize=13, fontweight='bold')\n", + " 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", @@ -16138,7 +16792,7 @@ "def graficar_pesos_anio(cmp):\n", " plt.close('all')\n", " fig, ax = plt.subplots(figsize=(13, 5.5))\n", - " fig.suptitle('Peso por aanio‚±o anio‚ IMPO / EXPO / CONSUMIDO / DESCARGAS', fontsize=13, fontweight='bold')\n", + " 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", @@ -16146,7 +16800,7 @@ " b2 = ax.bar(xpos-0.5*ancho, cmp['peso_expo'], ancho, label='EXPO', color=cE)\n", " b3 = ax.bar(xpos+0.5*ancho, cmp['peso_consumido'], ancho, label='CONSUMIDO', color=cC)\n", " b4 = ax.bar(xpos+1.5*ancho, cmp['peso_descargas'], ancho, label='DESCARGAS', color=cD)\n", - " ax.set_xticks(xpos); ax.set_xticklabels(x); ax.set_xlabel('Aanio‚±o')\n", + " ax.set_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", @@ -16186,19 +16840,19 @@ " cmp['cantidad_impo'] = cmp['cantidad_impo'].round(2)\n", " cmp['cantidad_expo'] = cmp['cantidad_expo'].round(2)\n", " cmp['diferencia'] = (cmp['cantidad_expo'] - cmp['cantidad_impo']).round(2)\n", - " prog.done('Cantidades por aanio‚±o listas')\n", + " 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 aanio‚±o anio‚ IMPO vs EXPO', fontsize=13, fontweight='bold')\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('Aanio‚±o')\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", @@ -16244,7 +16898,7 @@ " cmp['cantidad_impo'] = cmp['cantidad_impo'].round(2)\n", " cmp['cantidad_expo'] = cmp['cantidad_expo'].round(2)\n", " cmp['diferencia'] = (cmp['cantidad_expo'] - cmp['cantidad_impo']).round(2)\n", - " prog.done('Cantidades por aanio‚±o + UM listas')\n", + " prog.done('Cantidades por a-o + UM listas')\n", " return cmp\n", "\n", "def graficar_cantidades_anio_um(cmp, top_ums=None):\n", @@ -16261,7 +16915,7 @@ " return fig\n", " n = len(top_ums)\n", " fig, axes = plt.subplots(n, 1, figsize=(12, 3.2*n), squeeze=False)\n", - " fig.suptitle('Cantidades por aanio‚±o (separado por UM, top {} UMs)'.format(n), fontsize=13, fontweight='bold', y=1.0)\n", + " 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", @@ -16393,7 +17047,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" ] }, { @@ -17130,39 +17920,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" ] }, { @@ -18729,6 +19674,34 @@ " 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", + "# ----- Fraccion / UM -----\n", + "out_pron_frac = W.Output(layout=OUT_STYLE); bar_pron_frac = _mkbar('Pron.Frac')\n", + "out_frac = W.Output(layout=OUT_STYLE); bar_frac = _mkbar('Frac/UM')\n", + "btn_pron_frac = W.Button(description='Calcular pronostico (Frac/UM)', button_style='primary', icon='play', layout={'width':'280px'})\n", + "modo_frac = W.Dropdown(options=['NATURAL','DIRIGIDA'], value='NATURAL', description='Modo:', layout={'width':'250px'})\n", + "dry_frac = W.Checkbox(value=True, description='DRY_RUN (simular)')\n", + "fd_frac = W.Text(placeholder='YYYY-MM-DD', description='Desde:', layout={'width':'250px'})\n", + "fh_frac = W.Text(placeholder='YYYY-MM-DD', description='Hasta:', layout={'width':'250px'})\n", + "btn_frac = W.Button(description='Ejecutar descarga Frac/UM (NA->AC)', button_style='warning', icon='check', layout={'width':'280px'})\n", + "\n", + "def _on_pron_frac(_):\n", + " with out_pron_frac:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " calcular_pronostico_fraccion_um(log=print, progress=bar_pron_frac)\n", + "btn_pron_frac.on_click(_on_pron_frac)\n", + "\n", + "def _on_frac(_):\n", + " with out_frac:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_fraccion_um(modo_frac.value, dry_frac.value, fd_frac.value or None, fh_frac.value or None, log=print, progress=bar_frac)\n", + "btn_frac.on_click(_on_frac)\n", + "\n", + "\n", + "\n", "def _on_p10(_):\n", " with out_p10:\n", " clear_output()\n", @@ -18739,12 +19712,16 @@ "btn_p10.on_click(_on_p10)\n", "\n", "tab_desc = W.VBox([\n", - " W.HTML('

Paso 8 anio‚ Pronostico de cobertura

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

Paso 8 - Pronostico de cobertura

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

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

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

Paso 9 - Descargas pendientes (NA - AC)

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

Paso 10 anio‚ Complementaria (sobre facturas AC)

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

Paso 10 - Complementaria (sobre facturas AC)

'),\n", " W.HBox([modo10, dry10]), W.HBox([fd10, fh10]), facts_obj, btn_p10, bar_p10, out_p10,\n", + " W.HTML('

Descarga por Fraccion / UM

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

Match por primeros 8 digitos de fraccion arancelaria + unidad de medida. Sin BOM.

'),\n", + " btn_pron_frac, bar_pron_frac, out_pron_frac,\n", + " W.HBox([modo_frac, dry_frac]), W.HBox([fd_frac, fh_frac]), btn_frac, bar_frac, out_frac,\n", "])\n", "\n", "# ----- Tab 2: Analisis Saldos -----\n", @@ -18773,46 +19750,46 @@ " print('Calculando IMPO vs EXPO...')\n", " df_imp, df_exp, cmp = calcular_impo_expo_anio(progress=bar_an)\n", " _state['df_imp'], _state['df_exp'], _state['cmp_impo_expo'] = df_imp, df_exp, cmp\n", - " print(f' IMPO aanio‚±os: {len(df_imp)} | EXPO aanio‚±os: {len(df_exp)} | Comparativo: {len(cmp)}')\n", - " print('Calculando pesos IMPO/EXPO/SALDO por aanio‚±o...')\n", + " 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)} aanio‚±os')\n", - " print('Calculando cantidades IMPO/EXPO por aanio‚±o...')\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)} aanio‚±os')\n", - " print('Calculando cantidades IMPO/EXPO por aanio‚±o + UM...')\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('

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

'))\n", + " display(HTML('

Saldo disponible por a-o (de entrada del lote)

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

IMPO vs EXPO por aanio‚±o

'))\n", + " display(HTML('

IMPO vs EXPO por a-o

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

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

'))\n", + " display(HTML('

Peso por a-o - IMPO / EXPO / CONSUMIDO / DESCARGAS

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

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

'))\n", + " display(HTML('

Cantidades por a-o - IMPO vs EXPO

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

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

'))\n", + " display(HTML('

Cantidades por a-o + UM (separado por unidad de medida)

'))\n", " display(_state['cmp_cantidades_anio_um'])\n", " if not _state['cmp_cantidades_anio_um'].empty:\n", " fig = graficar_cantidades_anio_um(_state['cmp_cantidades_anio_um']); display(fig); plt.close(fig)\n", @@ -18830,7 +19807,7 @@ "btn_an_xlsx.on_click(_on_an_xlsx)\n", "\n", "tab_an = W.VBox([\n", - " W.HTML('

Ananio‚¡lisis SSaldoTem + IMPO vs EXPO

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

An-lisis SSaldoTem + IMPO vs EXPO

'),\n", " W.HBox([btn_an, btn_an_xlsx]), bar_an, out_an_log, out_an_anio, out_an_imp_exp, out_an_pesos, out_an_cant, out_an_cant_um,\n", "])\n", "\n", @@ -18855,7 +19832,7 @@ "btn_nlp.on_click(_on_nlp)\n", "\n", "tab_nlp = W.VBox([\n", - " W.HTML('

Sustitutos NLP anio‚ TF-IDF + coseno

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

Sustitutos NLP - TF-IDF + coseno

'),\n", " min_sim, top_n, dry_nlp, btn_nlp, bar_nlp, out_nlp,\n", "])\n", "\n", @@ -18906,13 +19883,13 @@ "btn_p12c.on_click(_on_p12c)\n", "\n", "tab_kg = W.VBox([\n", - " W.HTML('

Paso 12 anio‚ Descarga por % de KGS

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

Paso 12 - Descarga por % de KGS

'\n", " '

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

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

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

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

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

Paso 12 - Descargas pendientes KG (NA - AC)

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

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

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

Paso 12 - Complementaria KG (sobre facturas AC)

'),\n", " W.HBox([modo12c, dry12c]), W.HBox([fd12c, fh12c]), facts_obj12, btn_p12c, bar_p12c, out_p12c,\n", "])\n", "\n", @@ -19253,13 +20230,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", @@ -19271,11 +20257,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", @@ -19284,19 +20285,22 @@ " '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", "\n", - "# ===== Tab 8: DataStage (Subir .asc a SQLite local) =====\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 SQLite', button_style='warning', icon='upload', layout={'width':'260px'})\n", - "btn_ds_tablas = W.Button(description='Ver tablas Registro', button_style='', icon='database', layout={'width':'280px'})\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", @@ -19304,9 +20308,9 @@ "def _ds_msg_conexion():\n", " if not DATASTAGE_OK:\n", " display(HTML(f'
'\n", - " f'SQLite no disponible.
'\n", + " f'Postgres no disponible.
'\n", " f'{DATASTAGE_MSG}
'\n", - " f'Revisa permisos de escritura en la carpeta del .exe (donde se crea datastage.db).
'))\n", + " f'Revisa que el contenedor postgres-datastage este levantado y que el .env tenga DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD.'))\n", " return False\n", " display(HTML(f'
{DATASTAGE_MSG}
'))\n", " return True\n", @@ -19359,9 +20363,9 @@ " with _ds_conn() as c:\n", " tablas = listar_tablas_registro(c)\n", " if not tablas:\n", - " print('No hay tablas Registro* en la base SQLite.')\n", + " print('No hay tablas Registro* en Postgres. Migra el schema primero.')\n", " return\n", - " print(f'Tablas Registro en la base: {len(tablas)}')\n", + " print(f'Tablas Registro en Postgres: {len(tablas)}')\n", " df = pd.DataFrame({'tabla': tablas})\n", " display(df)\n", " except Exception as e:\n", @@ -19369,7 +20373,7 @@ "btn_ds_tablas.on_click(_on_ds_tablas)\n", "\n", "tab_datastage = W.VBox([\n", - " W.HTML('

DataStage - Subir archivos .asc a SQLite

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

DataStage - Subir archivos .asc a Postgres

'\n", " '

'\n", " 'Carga masiva de archivos .asc del DataStage en sus tablas Registro<NNN>. '\n", " 'Migrado del modulo PHP HOME/DATASTAGE. La estructura de los archivos '\n", @@ -19381,7 +20385,7 @@ " '

  • Layout HONDA: RAIZ/2020/*.asc, RAIZ/2021/*.asc, ... (solo .asc directos, sin entrar a subcarpetas de meses)
  • '\n", " '
  • Layout plano: RAIZ/*.asc
  • '\n", " ''\n", - " '

    Tablas destino se crean automaticamente al primer arranque (base datastage.db).

    '\n", + " '

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

    '\n", " ''),\n", " W.HBox([w_ds_ruta]),\n", " W.HBox([btn_ds_listar, btn_ds_tablas, btn_ds_cargar]), bar_ds,\n", @@ -19435,7 +20439,7 @@ " 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 la base SQLite.')\n", + " print('No hay tablas Registro* en Postgres.')\n", " return\n", " total_filas = df['filas'].sum()\n", " total_tablas = len(df)\n", @@ -19653,7 +20657,7 @@ " W.HTML('

    Estructuras SCAII - Reportes de Pedimentos

    '\n", " '

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

    '),\n", + " 'Las consultas se ejecutan sobre Postgres (tablas Registro501 y Registro701).

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

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

    ',\n", @@ -20072,6 +21076,8 @@ "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", @@ -20083,7 +21089,10 @@ " if 'val_plan' not in _state:\n", " print('ERROR: corre primero Analizar.'); return\n", " print(f'(DB actual: {DB_ACTUAL})')\n", - " ejecutar_valores(_state['val_plan'], dry_run=dry_val.value, log=print, progress=bar_val_b)\n", + " 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", @@ -20105,6 +21114,8 @@ " '

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

    '),\n", " W.HBox([dry_val]),\n", + " W.HTML('Columnas MN a actualizar:'),\n", + " W.HBox([chk_val_vtmn, chk_val_mptemp]),\n", " btn_val_ejecutar, bar_val_b,\n", " out_val_ejec,\n", "])\n", @@ -20160,7 +21171,7 @@ " '

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

    '),\n", " W.HBox([btn_val_shelter]), bar_val_shelter,\n", @@ -20168,6 +21179,330 @@ "])\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 = ('
    '\n", + " 'Formato esperado del Excel (6 columnas)'\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " '
    ColumnaEjemplo
    FACTURA_EXPOEXP240001
    LINEA_EXPO1
    NUMPARTEABC-12345
    UNIMEDPZA
    LINEA_EXPO_NUEVA2
    FACTURA_NUEVAEXP240002
    '\n", + " '

    Las 4 primeras identifican la descarga origen (debe ser UNICA en SDescargaT). Las 2 ultimas son el destino (debe existir en SFacExp+SPartidasExpo).

    '\n", + " '
    ')\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'
    '\n", + " f'Plantilla generada
    '\n", + " f'{path}
    '))\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('

    Plan OK (se aplicaran en Paso B)

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

    Inconsistencias (NO se aplican)

    '))\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('
    Corre Analizar primero.
    ')); 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'
    '\n", + " f'Excel generado
    '\n", + " f'{path}
    '))\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('

    Reasignacion de Lineas EXPO

    '\n", + " '

    Sube un Excel con las descargas a reasignar. La utileria '\n", + " 'cambia FACTEXPO, FACREFERENCIA y LINEAEXPO 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.

    '),\n", + " W.HTML(_html_formato_reasig),\n", + " btn_reasig_plantilla,\n", + " W.HTML('

    Sube tu Excel:

    '),\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('

    Paso B - Ejecutar reasignacion

    '\n", + " '

    Solo aplica las filas del Plan OK. '\n", + " 'Las inconsistencias se ignoran. Corre primero con DRY_RUN activado.

    '),\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 = ('
    '\n", + " 'Formato esperado del Excel (4 columnas)'\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " ''\n", + " '
    ColumnaEjemplo
    NUMPARTE_COMPONENTECOMP-001
    PCT_MATERIAL8.5
    NUMPARTE_PTPT-ABC-123
    UNIMEDKGS
    '\n", + " '

    '\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", + " '

    ')\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'
    '\n", + " f'Plantilla generada
    '\n", + " f'{path}
    '))\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('

    Resumen por PT

    '))\n", + " display(resumen)\n", + " if not detalle.empty:\n", + " display(HTML('

    Partidas EXPO (primeras 200)

    '))\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('

    Resumen por TIPO

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

    Plan detalle (primeras 100)

    '))\n", + " display(_state['csv_plan'].head(100))\n", + " if 'csv_incons' in _state and not _state['csv_incons'].empty:\n", + " display(HTML('

    Inconsistencias

    '))\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('
    Corre Analizar primero.
    ')); 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'
    '\n", + " f'Excel generado
    '\n", + " f'{path}
    '))\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('

    Consumo de Saldos Vencidos por Rango

    '\n", + " '

    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 descargas complementarias usando el mismo motor del Paso 10/12.

    '),\n", + " W.HTML(_html_csv),\n", + " btn_csv_plantilla,\n", + " W.HTML('

    Sube tu Excel y define el rango de vencimiento:

    '),\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('

    Paso B - Ejecutar consumo

    '\n", + " '

    Inserta las descargas como complementarias. '\n", + " 'NATURAL excluye facturas con faltantes; DIRIGIDA inserta lo que se pudo cubrir. '\n", + " 'Corre primero con DRY_RUN activado.

    '),\n", + " W.HBox([w_csv_modo]),\n", + " W.HBox([dry_csv]),\n", + " btn_csv_ejecutar,\n", + "])\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",