diff --git a/app/app.ipynb b/app/app.ipynb index a6e1302..48f4dbe 100644 --- a/app/app.ipynb +++ b/app/app.ipynb @@ -521,7 +521,447 @@ " _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}')" ] }, { @@ -4687,6 +5127,279 @@ "])\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", "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",