tokiospg commited on
Commit
1dfc3e9
·
verified ·
1 Parent(s): 8dee463

Upload 4 files

Browse files
Files changed (2) hide show
  1. app.py +684 -76
  2. database.py +287 -10
app.py CHANGED
@@ -582,6 +582,33 @@ def get_usage_summary_cached(days, username, module):
582
  def get_api_pricing_cached():
583
  return db.get_api_pricing_df()
584
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  @st.cache_data(ttl=20, show_spinner=False)
586
  def get_seguimientos_cached():
587
  return db.get_seguimientos()
@@ -1166,7 +1193,9 @@ def current_role_mode():
1166
  username = str(st.session_state.get("username", "") or "").lower()
1167
  if username == "admin" or role == "Admin":
1168
  return "Admin"
1169
- if role in ["Supervisor", "Gerencia"]:
 
 
1170
  return role
1171
  return "Analista"
1172
 
@@ -1186,6 +1215,11 @@ ROLE_PROFILES = {
1186
  "scope": "Visión operativa, Radar, histórico corporativo y configuración propia.",
1187
  "badge": "Gerencia",
1188
  },
 
 
 
 
 
1189
  "Admin": {
1190
  "title": "Administración Global",
1191
  "scope": "Usuarios, roles, métricas, llaves y salud del sistema.",
@@ -1194,9 +1228,10 @@ ROLE_PROFILES = {
1194
  }
1195
 
1196
  ROLE_PERMISSIONS = {
1197
- "Analista": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico"},
1198
- "Supervisor": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico", "radar"},
1199
- "Gerencia": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "radar", "historico", "settings"},
 
1200
  "Admin": {"admin"},
1201
  }
1202
 
@@ -1210,11 +1245,12 @@ def get_role_profile():
1210
  if current_role_mode() == "Admin":
1211
  st.markdown("""<style>[data-testid="stSidebar"] { display: none !important; }</style>""", unsafe_allow_html=True)
1212
  df_users_admin = get_all_users_cached()
1213
- role_admin_series = df_users_admin["role"] if "role" in df_users_admin.columns else pd.Series(dtype=str)
1214
  total_admin_users = len(df_users_admin)
1215
  total_admin_supervisors = int((role_admin_series == "Supervisor").sum()) if not role_admin_series.empty else 0
1216
  total_admin_analysts = int((role_admin_series == "Analista").sum()) if not role_admin_series.empty else 0
1217
  total_admin_gerencia = int((role_admin_series == "Gerencia").sum()) if not role_admin_series.empty else 0
 
1218
 
1219
  st.markdown("""
1220
  <div class="admin-hero">
@@ -1227,11 +1263,12 @@ if current_role_mode() == "Admin":
1227
  </div>
1228
  """, unsafe_allow_html=True)
1229
 
1230
- a1, a2, a3, a4 = st.columns(4)
1231
  a1.metric("Usuarios", total_admin_users)
1232
  a2.metric("Analistas", total_admin_analysts)
1233
  a3.metric("Supervisores", total_admin_supervisors)
1234
  a4.metric("Gerencia", total_admin_gerencia)
 
1235
 
1236
  admin_view = st.radio(
1237
  "Vista admin",
@@ -1277,15 +1314,15 @@ if current_role_mode() == "Admin":
1277
  st.markdown("---")
1278
  st.subheader("Cambiar Rol de Usuario")
1279
  role_col1, role_col2, role_col3 = st.columns([0.45, 0.35, 0.2])
1280
- role_users = df_users["username"].tolist() if "username" in df_users.columns else []
1281
  with role_col1:
1282
  role_user = st.selectbox("Usuario", role_users, key="admin_role_user")
1283
  current_role = ""
1284
  if role_user and not df_users.empty:
1285
- role_match = df_users[df_users["username"] == role_user]
1286
  if not role_match.empty:
1287
- current_role = str(role_match.iloc[0].get("role", "Analista") or "Analista")
1288
- role_options = ["Analista", "Supervisor", "Gerencia"]
1289
  with role_col2:
1290
  role_index = role_options.index(current_role) if current_role in role_options else 0
1291
  new_role_admin = st.selectbox("Nuevo rol", role_options, index=role_index, key="admin_new_role")
@@ -1307,7 +1344,7 @@ if current_role_mode() == "Admin":
1307
  """, unsafe_allow_html=True)
1308
  new_u = st.text_input("Nombre de Usuario (Login)")
1309
  new_p = st.text_input("Contraseña Temporal", type="password")
1310
- new_r = st.selectbox("Nivel de Acceso", ["Analista", "Supervisor", "Gerencia"])
1311
  if st.button("Crear Cuenta", use_container_width=True, type="primary"):
1312
  if new_u and new_p:
1313
  if db.create_user(new_u, new_p, new_r):
@@ -1429,6 +1466,15 @@ def get_navigation_items():
1429
  "hint": "Busqueda global de proveedores, precios y evidencia",
1430
  "permission": "providers",
1431
  },
 
 
 
 
 
 
 
 
 
1432
  {
1433
  "group": "Seguimiento",
1434
  "view": "🏛️ Monitor ACP",
@@ -1542,69 +1588,77 @@ with st.sidebar:
1542
  st.rerun()
1543
 
1544
  st.divider()
1545
-
1546
- st.markdown('<div class="sidebar-section-label">Nuevo RFQ</div>', unsafe_allow_html=True)
1547
- st.markdown("""
1548
- <div class="sidebar-analysis-card">
1549
- <div class="analysis-card-title">Analizar pliego</div>
1550
- <div class="analysis-card-copy">PDF principal y anexos.</div>
1551
- <div class="sidebar-flow">
1552
- <span>1 RFQ</span>
1553
- <span>2 Renglones</span>
1554
- <span>3 Proveedores</span>
1555
- <span>4 Seguimiento</span>
 
 
1556
  </div>
1557
- </div>
1558
- """, unsafe_allow_html=True)
1559
- archivos_pdf = st.file_uploader("Archivos PDF", type=["pdf"], label_visibility="collapsed", accept_multiple_files=True)
1560
- if archivos_pdf:
1561
- st.caption(f"{len(archivos_pdf)} archivo(s) listo(s) para analizar")
1562
-
1563
- if st.button("Procesar RFQ", type="primary", use_container_width=True):
1564
- if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
1565
- elif not archivos_pdf: st.error("⚠️ Falta subir al menos un documento.")
1566
- else:
1567
- with st.status("Conectando con Motor IA...", expanded=True) as status:
1568
- try:
1569
- archivos = [("archivos_pdf", (f.name, f.getvalue(), "application/pdf")) for f in archivos_pdf]
1570
- datos_formulario = {
1571
- "gemini_key": st.session_state.gemini_key,
1572
- "username": st.session_state.username,
1573
- "role": st.session_state.role
1574
- }
1575
- respuesta_api = requests.post(f"{API_URL_BASE}/analizar-pliego", files=archivos, data=datos_formulario, headers=API_HEADERS)
1576
-
1577
- if respuesta_api.status_code == 200:
1578
- datos_crudos = respuesta_api.json()
1579
- cg = datos_crudos.get("condiciones_generales", {})
1580
- df_exportar = normalize_item_codes(normalize_technical_fields(pd.DataFrame(datos_crudos.get("items", []))))
1581
-
1582
- try:
1583
- df_historico_match = load_historical_prices()
1584
- if df_historico_match is None:
1585
- st.warning("⚠️ El histórico de Supabase aún no tiene datos. Procesando sin precios base.")
1586
- else:
1587
- # El código ACP válido usa formato AAA-AAA-00000; se normaliza antes del cruce histórico.
1588
- df_exportar['codigo_match'] = df_exportar['codigo_articulo'].apply(acp_code_match)
1589
-
1590
- df_cruzado = pd.merge(df_exportar, df_historico_match, left_on='codigo_match', right_on='codigo_match', how='left')
1591
- df_cruzado = df_cruzado.drop(columns=['codigo_match']).rename(columns={'PRECIO COMPETENCIA': "precio_comp_hist", 'PRECIO PROYELEC': "precio_proy_hist"})
1592
-
1593
- df_cruzado['precio_comp_hist'] = pd.to_numeric(df_cruzado['precio_comp_hist'], errors='coerce')
1594
- df_cruzado['precio_proy_hist'] = pd.to_numeric(df_cruzado['precio_proy_hist'], errors='coerce')
1595
- df_cruzado['margen_$'] = df_cruzado['precio_proy_hist'] - df_cruzado['precio_comp_hist']
1596
- df_exportar = normalize_history_columns(df_cruzado)
1597
- except Exception as hist_e:
1598
- st.warning(f"⚠️ Error consultando histórico en Supabase: {hist_e}")
1599
-
1600
- save_history(st.session_state.username, str(cg.get('numero_licitacion', 'Desconocida')), len(df_exportar))
1601
- save_workspace_state(st.session_state.username, cg, df_exportar)
1602
-
1603
- st.session_state.df_exportar, st.session_state.cg, st.session_state.procesado = df_exportar, cg, True
1604
- status.update(label=" Análisis Completado", state="complete", expanded=False)
1605
- st.toast("Analisis completado. Resultados listos.")
1606
- else: status.update(label=f"❌ Error en API: {respuesta_api.text}", state="error")
1607
- except Exception as e: status.update(label=f"❌ Error crítico: {e}", state="error")
 
 
 
 
 
 
1608
 
1609
  st.divider()
1610
 
@@ -2227,6 +2281,389 @@ def build_radar_table_view(radar_df):
2227
  view["Estado"] = radar_df["estado_radar"].fillna("").astype(str) if "estado_radar" in radar_df.columns else ""
2228
  return view
2229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2230
  if active_view == "🌐 Proveedores":
2231
  render_page_header(
2232
  "Sourcing global",
@@ -3674,9 +4111,13 @@ if active_view == "🚀 Tablero de Operaciones":
3674
  if "acepta_equivalente" in df_render.columns:
3675
  df_render["equivalente_txt"] = df_render["acepta_equivalente"].apply(bool_label)
3676
 
 
 
 
 
3677
  result_view = st.radio(
3678
  "Vista de licitacion",
3679
- ["📋 1. Matriz de Productos", "📨 2. Emisión de RFQs", "🤖 3. Centro de Mando AI", "📈 4. Análisis de Costos"],
3680
  horizontal=True,
3681
  label_visibility="collapsed",
3682
  key="result_view",
@@ -4532,5 +4973,172 @@ Responde de forma clara y profesional. Si puedes dar un número o dato exacto de
4532
 
4533
  st.markdown("#### Volumen Solicitado por Renglón")
4534
  fig = go.Figure(data=[go.Bar(x=df_render['renglon'], y=df_render['cantidad'], marker_color='#238636')])
4535
- fig.update_layout(template="plotly_dark", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)')
4536
- st.plotly_chart(fig, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  def get_api_pricing_cached():
583
  return db.get_api_pricing_df()
584
 
585
+ @st.cache_data(ttl=60, show_spinner=False)
586
+ def get_logistics_freight_rates_cached():
587
+ return db.get_logistics_freight_rates()
588
+
589
+ @st.cache_data(ttl=60, show_spinner=False)
590
+ def get_logistics_local_rates_cached():
591
+ return db.get_logistics_local_rates()
592
+
593
+ @st.cache_data(ttl=60, show_spinner=False)
594
+ def get_logistics_forwarders_cached():
595
+ return db.get_logistics_forwarders()
596
+
597
+ @st.cache_data(ttl=300, show_spinner=False)
598
+ def get_logistics_incoterms_cached():
599
+ return db.get_logistics_incoterms()
600
+
601
+ @st.cache_data(ttl=30, show_spinner=False)
602
+ def get_logistics_calculations_cached(limit=100):
603
+ return db.get_logistics_calculations(limit=limit)
604
+
605
+ def clear_logistics_cache():
606
+ get_logistics_freight_rates_cached.clear()
607
+ get_logistics_local_rates_cached.clear()
608
+ get_logistics_forwarders_cached.clear()
609
+ get_logistics_incoterms_cached.clear()
610
+ get_logistics_calculations_cached.clear()
611
+
612
  @st.cache_data(ttl=20, show_spinner=False)
613
  def get_seguimientos_cached():
614
  return db.get_seguimientos()
 
1193
  username = str(st.session_state.get("username", "") or "").lower()
1194
  if username == "admin" or role == "Admin":
1195
  return "Admin"
1196
+ if role in ["Supervisor", "Gerencia", "Logística", "Logistica"]:
1197
+ if role == "Logistica":
1198
+ return "Logística"
1199
  return role
1200
  return "Analista"
1201
 
 
1215
  "scope": "Visión operativa, Radar, histórico corporativo y configuración propia.",
1216
  "badge": "Gerencia",
1217
  },
1218
+ "Logística": {
1219
+ "title": "Centro Logístico",
1220
+ "scope": "Tarifas, forwarders, incoterms, cálculos logísticos e histórico corporativo.",
1221
+ "badge": "Logística",
1222
+ },
1223
  "Admin": {
1224
  "title": "Administración Global",
1225
  "scope": "Usuarios, roles, métricas, llaves y salud del sistema.",
 
1228
  }
1229
 
1230
  ROLE_PERMISSIONS = {
1231
+ "Analista": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico", "logistics_calc"},
1232
+ "Supervisor": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "historico", "radar", "logistics_calc"},
1233
+ "Gerencia": {"dashboard", "rfq_upload", "providers", "monitor", "workspaces", "radar", "historico", "settings", "logistics", "logistics_calc"},
1234
+ "Logística": {"logistics", "historico"},
1235
  "Admin": {"admin"},
1236
  }
1237
 
 
1245
  if current_role_mode() == "Admin":
1246
  st.markdown("""<style>[data-testid="stSidebar"] { display: none !important; }</style>""", unsafe_allow_html=True)
1247
  df_users_admin = get_all_users_cached()
1248
+ role_admin_series = df_users_admin["Nivel"] if "Nivel" in df_users_admin.columns else pd.Series(dtype=str)
1249
  total_admin_users = len(df_users_admin)
1250
  total_admin_supervisors = int((role_admin_series == "Supervisor").sum()) if not role_admin_series.empty else 0
1251
  total_admin_analysts = int((role_admin_series == "Analista").sum()) if not role_admin_series.empty else 0
1252
  total_admin_gerencia = int((role_admin_series == "Gerencia").sum()) if not role_admin_series.empty else 0
1253
+ total_admin_logistica = int(role_admin_series.isin(["Logística", "Logistica"]).sum()) if not role_admin_series.empty else 0
1254
 
1255
  st.markdown("""
1256
  <div class="admin-hero">
 
1263
  </div>
1264
  """, unsafe_allow_html=True)
1265
 
1266
+ a1, a2, a3, a4, a5 = st.columns(5)
1267
  a1.metric("Usuarios", total_admin_users)
1268
  a2.metric("Analistas", total_admin_analysts)
1269
  a3.metric("Supervisores", total_admin_supervisors)
1270
  a4.metric("Gerencia", total_admin_gerencia)
1271
+ a5.metric("Logística", total_admin_logistica)
1272
 
1273
  admin_view = st.radio(
1274
  "Vista admin",
 
1314
  st.markdown("---")
1315
  st.subheader("Cambiar Rol de Usuario")
1316
  role_col1, role_col2, role_col3 = st.columns([0.45, 0.35, 0.2])
1317
+ role_users = df_users["Usuario"].tolist() if "Usuario" in df_users.columns else []
1318
  with role_col1:
1319
  role_user = st.selectbox("Usuario", role_users, key="admin_role_user")
1320
  current_role = ""
1321
  if role_user and not df_users.empty:
1322
+ role_match = df_users[df_users["Usuario"] == role_user]
1323
  if not role_match.empty:
1324
+ current_role = str(role_match.iloc[0].get("Nivel", "Analista") or "Analista")
1325
+ role_options = ["Analista", "Supervisor", "Gerencia", "Logística"]
1326
  with role_col2:
1327
  role_index = role_options.index(current_role) if current_role in role_options else 0
1328
  new_role_admin = st.selectbox("Nuevo rol", role_options, index=role_index, key="admin_new_role")
 
1344
  """, unsafe_allow_html=True)
1345
  new_u = st.text_input("Nombre de Usuario (Login)")
1346
  new_p = st.text_input("Contraseña Temporal", type="password")
1347
+ new_r = st.selectbox("Nivel de Acceso", ["Analista", "Supervisor", "Gerencia", "Logística"])
1348
  if st.button("Crear Cuenta", use_container_width=True, type="primary"):
1349
  if new_u and new_p:
1350
  if db.create_user(new_u, new_p, new_r):
 
1466
  "hint": "Busqueda global de proveedores, precios y evidencia",
1467
  "permission": "providers",
1468
  },
1469
+ {
1470
+ "group": "Operaciones",
1471
+ "view": "🚚 Centro Logístico",
1472
+ "key": "logistica",
1473
+ "label": "Logística",
1474
+ "icon": "▣",
1475
+ "hint": "Tarifas, forwarders, incoterms y calculadora logística",
1476
+ "permission": "logistics",
1477
+ },
1478
  {
1479
  "group": "Seguimiento",
1480
  "view": "🏛️ Monitor ACP",
 
1588
  st.rerun()
1589
 
1590
  st.divider()
1591
+
1592
+ if role_can("rfq_upload"):
1593
+ st.markdown('<div class="sidebar-section-label">Nuevo RFQ</div>', unsafe_allow_html=True)
1594
+ st.markdown("""
1595
+ <div class="sidebar-analysis-card">
1596
+ <div class="analysis-card-title">Analizar pliego</div>
1597
+ <div class="analysis-card-copy">PDF principal y anexos.</div>
1598
+ <div class="sidebar-flow">
1599
+ <span>1 RFQ</span>
1600
+ <span>2 Renglones</span>
1601
+ <span>3 Proveedores</span>
1602
+ <span>4 Seguimiento</span>
1603
+ </div>
1604
  </div>
1605
+ """, unsafe_allow_html=True)
1606
+ archivos_pdf = st.file_uploader("Archivos PDF", type=["pdf"], label_visibility="collapsed", accept_multiple_files=True)
1607
+ if archivos_pdf:
1608
+ st.caption(f"{len(archivos_pdf)} archivo(s) listo(s) para analizar")
1609
+
1610
+ if st.button("Procesar RFQ", type="primary", use_container_width=True):
1611
+ if not st.session_state.gemini_key: st.error("⚠️ Verifica tus API Keys en la configuración.")
1612
+ elif not archivos_pdf: st.error("⚠️ Falta subir al menos un documento.")
1613
+ else:
1614
+ with st.status("Conectando con Motor IA...", expanded=True) as status:
1615
+ try:
1616
+ archivos = [("archivos_pdf", (f.name, f.getvalue(), "application/pdf")) for f in archivos_pdf]
1617
+ datos_formulario = {
1618
+ "gemini_key": st.session_state.gemini_key,
1619
+ "username": st.session_state.username,
1620
+ "role": st.session_state.role
1621
+ }
1622
+ respuesta_api = requests.post(f"{API_URL_BASE}/analizar-pliego", files=archivos, data=datos_formulario, headers=API_HEADERS)
1623
+
1624
+ if respuesta_api.status_code == 200:
1625
+ datos_crudos = respuesta_api.json()
1626
+ cg = datos_crudos.get("condiciones_generales", {})
1627
+ df_exportar = normalize_item_codes(normalize_technical_fields(pd.DataFrame(datos_crudos.get("items", []))))
1628
+
1629
+ try:
1630
+ df_historico_match = load_historical_prices()
1631
+ if df_historico_match is None:
1632
+ st.warning("⚠️ El histórico de Supabase aún no tiene datos. Procesando sin precios base.")
1633
+ else:
1634
+ # El código ACP válido usa formato AAA-AAA-00000; se normaliza antes del cruce histórico.
1635
+ df_exportar['codigo_match'] = df_exportar['codigo_articulo'].apply(acp_code_match)
1636
+
1637
+ df_cruzado = pd.merge(df_exportar, df_historico_match, left_on='codigo_match', right_on='codigo_match', how='left')
1638
+ df_cruzado = df_cruzado.drop(columns=['codigo_match']).rename(columns={'PRECIO COMPETENCIA': "precio_comp_hist", 'PRECIO PROYELEC': "precio_proy_hist"})
1639
+
1640
+ df_cruzado['precio_comp_hist'] = pd.to_numeric(df_cruzado['precio_comp_hist'], errors='coerce')
1641
+ df_cruzado['precio_proy_hist'] = pd.to_numeric(df_cruzado['precio_proy_hist'], errors='coerce')
1642
+ df_cruzado['margen_$'] = df_cruzado['precio_proy_hist'] - df_cruzado['precio_comp_hist']
1643
+ df_exportar = normalize_history_columns(df_cruzado)
1644
+ except Exception as hist_e:
1645
+ st.warning(f"⚠️ Error consultando histórico en Supabase: {hist_e}")
1646
+
1647
+ save_history(st.session_state.username, str(cg.get('numero_licitacion', 'Desconocida')), len(df_exportar))
1648
+ save_workspace_state(st.session_state.username, cg, df_exportar)
1649
+
1650
+ st.session_state.df_exportar, st.session_state.cg, st.session_state.procesado = df_exportar, cg, True
1651
+ status.update(label="✅ Análisis Completado", state="complete", expanded=False)
1652
+ st.toast("Analisis completado. Resultados listos.")
1653
+ else: status.update(label=f" Error en API: {respuesta_api.text}", state="error")
1654
+ except Exception as e: status.update(label=f"❌ Error crítico: {e}", state="error")
1655
+ else:
1656
+ st.markdown("""
1657
+ <div class="sidebar-analysis-card">
1658
+ <div class="analysis-card-title">Centro Logístico</div>
1659
+ <div class="analysis-card-copy">Tarifas, forwarders e histórico corporativo.</div>
1660
+ </div>
1661
+ """, unsafe_allow_html=True)
1662
 
1663
  st.divider()
1664
 
 
2281
  view["Estado"] = radar_df["estado_radar"].fillna("").astype(str) if "estado_radar" in radar_df.columns else ""
2282
  return view
2283
 
2284
+ def logistics_local_cost(local_row, peso_kg):
2285
+ if local_row is None or local_row.empty:
2286
+ return 0.0
2287
+ if peso_kg <= 400:
2288
+ return float(local_row.get("hasta_400kg", 0) or 0)
2289
+ if peso_kg <= 1000:
2290
+ return float(local_row.get("kg_500_1000", 0) or 0)
2291
+ return float(local_row.get("mayor_1000kg", 0) or 0)
2292
+
2293
+ LOGISTICS_ROUTE_ORIGIN = "USA / Miami"
2294
+ LOGISTICS_ROUTE_DESTINATION = "Panamá / ACP"
2295
+ LOGISTICS_ROUTE_LABEL = f"{LOGISTICS_ROUTE_ORIGIN} → {LOGISTICS_ROUTE_DESTINATION}"
2296
+ LOGISTICS_ROUTE_SCOPE = (
2297
+ "Las tarifas actuales están calibradas para envíos desde USA/Miami hacia Panamá/ACP. "
2298
+ "Para otros orígenes como China, Europa o México se debe cargar una ruta logística distinta."
2299
+ )
2300
+
2301
+ def logistics_calc_summary(freight_row, local_row, peso_libras, incoterm="FOB",
2302
+ largo=0, ancho=0, alto=0, unidad_dimensional="in", bultos=1):
2303
+ peso_libras = float(peso_libras or 0)
2304
+ largo = float(largo or 0)
2305
+ ancho = float(ancho or 0)
2306
+ alto = float(alto or 0)
2307
+ bultos = max(float(bultos or 1), 1.0)
2308
+ unidad_dimensional = (unidad_dimensional or "in").lower()
2309
+ peso_volumetrico_libras = 0.0
2310
+ volumen_pies_cubicos = 0.0
2311
+ if largo > 0 and ancho > 0 and alto > 0:
2312
+ if unidad_dimensional == "cm":
2313
+ volumen_pies_cubicos = ((largo / 2.54) * (ancho / 2.54) * (alto / 2.54) * bultos) / 1728
2314
+ peso_volumetrico_libras = ((largo * ancho * alto * bultos) / 6000) * 2.20462
2315
+ else:
2316
+ volumen_pies_cubicos = (largo * ancho * alto * bultos) / 1728
2317
+ peso_volumetrico_libras = (largo * ancho * alto * bultos) / 166
2318
+ peso_facturable_libras = max(peso_libras, peso_volumetrico_libras)
2319
+ peso_kg = peso_libras * 0.453592
2320
+ peso_facturable_kg = peso_facturable_libras * 0.453592
2321
+ tarifa = float(freight_row.get("tarifa_por_libra", 0) or 0)
2322
+ minimo = float(freight_row.get("minimo_envio", 0) or 0)
2323
+ costo_internacional = max(peso_facturable_libras * tarifa, minimo)
2324
+ costo_local = logistics_local_cost(local_row, peso_facturable_kg)
2325
+ total = costo_internacional + costo_local
2326
+ dias = int(freight_row.get("tiempo_transito_dias", 0) or 0)
2327
+ return {
2328
+ "peso_libras": peso_libras,
2329
+ "peso_kg": peso_kg,
2330
+ "peso_facturable_libras": peso_facturable_libras,
2331
+ "peso_facturable_kg": peso_facturable_kg,
2332
+ "peso_volumetrico_libras": peso_volumetrico_libras,
2333
+ "largo": largo,
2334
+ "ancho": ancho,
2335
+ "alto": alto,
2336
+ "unidad_dimensional": unidad_dimensional,
2337
+ "bultos": bultos,
2338
+ "volumen_pies_cubicos": volumen_pies_cubicos,
2339
+ "costo_internacional": costo_internacional,
2340
+ "costo_local": costo_local,
2341
+ "costo_total": total,
2342
+ "tiempo_transito_dias": dias,
2343
+ "incoterm": incoterm,
2344
+ }
2345
+
2346
+ if active_view == "🚚 Centro Logístico":
2347
+ render_page_header(
2348
+ "Operación logística",
2349
+ "Centro Logístico",
2350
+ "Tarifas, forwarders, incoterms y cálculos para estimar costos antes de cotizar.",
2351
+ ["Supabase", "Tarifas ACP", "Beta logística"]
2352
+ )
2353
+
2354
+ freight_df = get_logistics_freight_rates_cached()
2355
+ local_df = get_logistics_local_rates_cached()
2356
+ forwarders_df = get_logistics_forwarders_cached()
2357
+ incoterms_df = get_logistics_incoterms_cached()
2358
+
2359
+ render_summary_strip([
2360
+ {"label": "Tarifas flete", "value": len(freight_df), "tone": "green" if len(freight_df) else "orange"},
2361
+ {"label": "Destinos locales", "value": len(local_df), "tone": "green" if len(local_df) else "orange"},
2362
+ {"label": "Forwarders", "value": len(forwarders_df), "tone": "blue"},
2363
+ {"label": "Incoterms", "value": len(incoterms_df), "tone": "amber"},
2364
+ ])
2365
+
2366
+ st.markdown("""
2367
+ <div class="work-panel">
2368
+ <div class="panel-label">Uso operativo</div>
2369
+ <div class="panel-copy">
2370
+ Logística mantiene las tarifas y rutas. Analistas y supervisores usarán estos datos para estimar costo logístico,
2371
+ lead time y margen real de participación.
2372
+ </div>
2373
+ </div>
2374
+ """, unsafe_allow_html=True)
2375
+
2376
+ st.markdown(f"""
2377
+ <div class="work-panel">
2378
+ <div class="panel-label">Ruta activa</div>
2379
+ <div class="panel-copy">
2380
+ <strong>{escape(LOGISTICS_ROUTE_LABEL)}</strong><br>
2381
+ {escape(LOGISTICS_ROUTE_SCOPE)}
2382
+ </div>
2383
+ </div>
2384
+ """, unsafe_allow_html=True)
2385
+
2386
+ tab_calc, tab_rates, tab_local, tab_forwarders, tab_incoterms, tab_history = st.tabs([
2387
+ "Calculadora", "Flete internacional", "Entrega local", "Forwarders", "Incoterms", "Cálculos guardados"
2388
+ ])
2389
+
2390
+ with tab_calc:
2391
+ if freight_df.empty:
2392
+ render_empty_state("Sin tarifas", "Aún no hay tarifas internacionales cargadas para calcular.")
2393
+ else:
2394
+ calc_left, calc_right = st.columns([0.58, 0.42])
2395
+ with calc_left:
2396
+ st.markdown("#### Parámetros del cálculo")
2397
+ route_a, route_b = st.columns(2)
2398
+ with route_a:
2399
+ st.text_input("Origen tarifario", value=LOGISTICS_ROUTE_ORIGIN, disabled=True, key="log_calc_origin")
2400
+ with route_b:
2401
+ st.text_input("Destino tarifario", value=LOGISTICS_ROUTE_DESTINATION, disabled=True, key="log_calc_route_dest")
2402
+ modo_peso_log = st.radio(
2403
+ "Forma de cálculo",
2404
+ ["Envío completo", "Por paquete"],
2405
+ horizontal=True,
2406
+ key="log_calc_modo_peso",
2407
+ )
2408
+ c1, c2 = st.columns(2)
2409
+ with c1:
2410
+ tipo_flete_calc = st.selectbox("Tipo de flete", sorted(freight_df["tipo_flete"].dropna().unique().tolist()), key="log_calc_tipo")
2411
+ filtered_freight = freight_df[freight_df["tipo_flete"] == tipo_flete_calc].copy()
2412
+ with c2:
2413
+ agente_calc = st.selectbox("Agente internacional", filtered_freight["agente"].dropna().unique().tolist(), key="log_calc_agente")
2414
+ freight_row = filtered_freight[filtered_freight["agente"] == agente_calc].iloc[0]
2415
+
2416
+ if modo_peso_log == "Por paquete":
2417
+ c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28])
2418
+ with c3:
2419
+ peso_paquete_log = st.number_input("Peso por paquete (lb)", min_value=0.0, value=10.0, step=1.0, key="log_calc_pkg_lb")
2420
+ with c4:
2421
+ bultos_log = st.number_input("Paquetes", min_value=1, value=1, step=1, key="log_calc_pkg_count")
2422
+ with c5:
2423
+ incoterm_calc = st.selectbox("Incoterm base", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="log_calc_incoterm")
2424
+ with c6:
2425
+ lic_log = st.text_input("Licitación", value=str(st.session_state.get("cg", {}).get("numero_licitacion", "")), key="log_calc_lic")
2426
+ peso_libras = peso_paquete_log * bultos_log
2427
+ else:
2428
+ c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28])
2429
+ with c3:
2430
+ peso_libras = st.number_input("Peso total (lb)", min_value=0.0, value=10.0, step=1.0, key="log_calc_lb")
2431
+ with c4:
2432
+ bultos_log = st.number_input("Paquetes / bultos", min_value=1, value=1, step=1, key="log_calc_bultos")
2433
+ with c5:
2434
+ incoterm_calc = st.selectbox("Incoterm base", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="log_calc_incoterm")
2435
+ with c6:
2436
+ lic_log = st.text_input("Licitación", value=str(st.session_state.get("cg", {}).get("numero_licitacion", "")), key="log_calc_lic")
2437
+ peso_paquete_log = peso_libras / bultos_log if bultos_log else peso_libras
2438
+
2439
+ st.caption("Dimensiones por paquete/bulto para calcular peso volumétrico. Si no aplican, déjalas en cero.")
2440
+ d1, d2, d3, d4, d5 = st.columns([0.2, 0.2, 0.2, 0.18, 0.22])
2441
+ with d1:
2442
+ largo_log = st.number_input("Largo", min_value=0.0, value=0.0, step=1.0, key="log_calc_largo")
2443
+ with d2:
2444
+ ancho_log = st.number_input("Ancho", min_value=0.0, value=0.0, step=1.0, key="log_calc_ancho")
2445
+ with d3:
2446
+ alto_log = st.number_input("Alto", min_value=0.0, value=0.0, step=1.0, key="log_calc_alto")
2447
+ with d4:
2448
+ unidad_log = st.selectbox("Unidad", ["in", "cm"], key="log_calc_unidad_dim")
2449
+ with d5:
2450
+ st.metric("Peso total", f"{peso_libras:,.2f} lb")
2451
+
2452
+ local_row = pd.Series(dtype=object)
2453
+ destino_calc = "Sin entrega local"
2454
+ if not local_df.empty:
2455
+ destino_calc = st.selectbox("Destino ACP / entrega local", ["Sin entrega local"] + local_df["destino"].dropna().unique().tolist(), key="log_calc_destino")
2456
+ if destino_calc != "Sin entrega local":
2457
+ local_candidates = local_df[local_df["destino"] == destino_calc]
2458
+ if not local_candidates.empty:
2459
+ local_row = local_candidates.iloc[0]
2460
+ renglon_log = st.text_input("Renglón / referencia", placeholder="Opcional", key="log_calc_renglon")
2461
+
2462
+ calc = logistics_calc_summary(
2463
+ freight_row,
2464
+ local_row,
2465
+ peso_libras,
2466
+ incoterm=incoterm_calc,
2467
+ largo=largo_log,
2468
+ ancho=ancho_log,
2469
+ alto=alto_log,
2470
+ unidad_dimensional=unidad_log,
2471
+ bultos=bultos_log,
2472
+ )
2473
+ if st.button("Guardar cálculo logístico", type="primary", use_container_width=True):
2474
+ db.save_logistics_calculation(
2475
+ username=st.session_state.username,
2476
+ licitacion=lic_log,
2477
+ renglon=renglon_log,
2478
+ agente=agente_calc,
2479
+ tipo_flete=tipo_flete_calc,
2480
+ incoterm=incoterm_calc,
2481
+ peso_libras=calc["peso_libras"],
2482
+ peso_kg=calc["peso_kg"],
2483
+ costo_internacional=calc["costo_internacional"],
2484
+ costo_local=calc["costo_local"],
2485
+ costo_total=calc["costo_total"],
2486
+ tiempo_transito_dias=calc["tiempo_transito_dias"],
2487
+ peso_facturable_libras=calc["peso_facturable_libras"],
2488
+ peso_volumetrico_libras=calc["peso_volumetrico_libras"],
2489
+ largo=calc["largo"],
2490
+ ancho=calc["ancho"],
2491
+ alto=calc["alto"],
2492
+ unidad_dimensional=calc["unidad_dimensional"],
2493
+ metadata={
2494
+ "destino": destino_calc,
2495
+ "ruta": LOGISTICS_ROUTE_LABEL,
2496
+ "origen_tarifario": LOGISTICS_ROUTE_ORIGIN,
2497
+ "destino_tarifario": LOGISTICS_ROUTE_DESTINATION,
2498
+ "modo_calculo": modo_peso_log,
2499
+ "peso_por_paquete_lb": peso_paquete_log,
2500
+ "tarifa_por_libra": float(freight_row.get("tarifa_por_libra", 0) or 0),
2501
+ "bultos": calc["bultos"],
2502
+ "volumen_pies_cubicos": calc["volumen_pies_cubicos"],
2503
+ }
2504
+ )
2505
+ clear_logistics_cache()
2506
+ st.success("Cálculo logístico guardado.")
2507
+
2508
+ with calc_right:
2509
+ st.markdown("#### Resultado estimado")
2510
+ st.metric("Costo total", f"$ {calc['costo_total']:,.2f}")
2511
+ st.metric("Flete internacional", f"$ {calc['costo_internacional']:,.2f}")
2512
+ st.metric("Entrega local", f"$ {calc['costo_local']:,.2f}")
2513
+ st.metric("Peso convertido", f"{calc['peso_kg']:,.2f} kg")
2514
+ st.metric("Peso volumétrico", f"{calc['peso_volumetrico_libras']:,.2f} lb")
2515
+ st.metric("Peso facturable", f"{calc['peso_facturable_libras']:,.2f} lb")
2516
+ st.metric("Volumen", f"{calc['volumen_pies_cubicos']:,.2f} ft³")
2517
+ render_notice_panel(
2518
+ "Ruta y lead time",
2519
+ f"{LOGISTICS_ROUTE_LABEL}. {agente_calc} / {tipo_flete_calc}: {calc['tiempo_transito_dias']} día(s) estimados. Día de corte: {freight_row.get('dia_corte', 'N/A') or 'N/A'}.",
2520
+ "blue",
2521
+ )
2522
+
2523
+ with tab_rates:
2524
+ st.dataframe(
2525
+ freight_df,
2526
+ use_container_width=True,
2527
+ hide_index=True,
2528
+ column_config={
2529
+ "tarifa_por_libra": st.column_config.NumberColumn("Tarifa/lb", format="$ %.2f"),
2530
+ "minimo_envio": st.column_config.NumberColumn("Mínimo", format="$ %.2f"),
2531
+ "tiempo_transito_dias": st.column_config.NumberColumn("Días", format="%d"),
2532
+ },
2533
+ )
2534
+ with st.expander("Agregar o actualizar tarifa internacional"):
2535
+ f1, f2, f3 = st.columns(3)
2536
+ with f1:
2537
+ agente = st.text_input("Agente", key="log_rate_agente")
2538
+ tipo_servicio = st.text_input("Tipo de servicio", value="Door-To-Door", key="log_rate_servicio")
2539
+ with f2:
2540
+ tipo_flete = st.selectbox("Tipo flete", ["Aereo", "Maritimo"], key="log_rate_tipo")
2541
+ tarifa = st.number_input("Tarifa por libra", min_value=0.0, step=0.5, key="log_rate_tarifa")
2542
+ with f3:
2543
+ dias = st.number_input("Tiempo tránsito días", min_value=0, step=1, key="log_rate_dias")
2544
+ minimo = st.number_input("Mínimo por envío", min_value=0.0, step=5.0, key="log_rate_minimo")
2545
+ dia_corte = st.text_input("Día de corte", value="-", key="log_rate_corte")
2546
+ salidas = st.text_input("Salidas", value="Según disponibilidad", key="log_rate_salidas")
2547
+ if st.button("Guardar tarifa internacional", type="primary"):
2548
+ if agente.strip():
2549
+ db.upsert_logistics_freight_rate(agente, tipo_servicio, tipo_flete, tarifa, dias, minimo, dia_corte, salidas)
2550
+ clear_logistics_cache()
2551
+ st.success("Tarifa internacional guardada.")
2552
+ st.rerun()
2553
+ else:
2554
+ st.warning("Indica el agente.")
2555
+
2556
+ with tab_local:
2557
+ st.dataframe(
2558
+ local_df,
2559
+ use_container_width=True,
2560
+ hide_index=True,
2561
+ column_config={
2562
+ "hasta_400kg": st.column_config.NumberColumn("Hasta 400 kg", format="$ %.2f"),
2563
+ "kg_500_1000": st.column_config.NumberColumn("500 a 1000 kg", format="$ %.2f"),
2564
+ "mayor_1000kg": st.column_config.NumberColumn("> 1000 kg", format="$ %.2f"),
2565
+ },
2566
+ )
2567
+ with st.expander("Agregar o actualizar entrega local"):
2568
+ l1, l2, l3 = st.columns(3)
2569
+ with l1:
2570
+ agente_local = st.text_input("Agente local", value="Ariel Nunez", key="log_local_agente")
2571
+ destino_local = st.text_input("Destino", key="log_local_destino")
2572
+ with l2:
2573
+ hasta_400 = st.number_input("Hasta 400 kg", min_value=0.0, step=10.0, key="log_local_400")
2574
+ de_500_1000 = st.number_input("500 a 1000 kg", min_value=0.0, step=10.0, key="log_local_1000")
2575
+ with l3:
2576
+ mayor_1000 = st.number_input("Mayor a 1000 kg", min_value=0.0, step=10.0, key="log_local_mayor")
2577
+ tipo_local = st.text_input("Tipo", value="Terrestre", key="log_local_tipo")
2578
+ if st.button("Guardar entrega local", type="primary"):
2579
+ if destino_local.strip():
2580
+ db.upsert_logistics_local_rate(agente_local, destino_local, tipo_local, hasta_400, de_500_1000, mayor_1000)
2581
+ clear_logistics_cache()
2582
+ st.success("Tarifa local guardada.")
2583
+ st.rerun()
2584
+ else:
2585
+ st.warning("Indica el destino.")
2586
+
2587
+ with tab_forwarders:
2588
+ st.dataframe(forwarders_df, use_container_width=True, hide_index=True)
2589
+ with st.expander("Agregar o actualizar forwarder"):
2590
+ nombre_fwd = st.text_input("Nombre", key="log_fwd_nombre")
2591
+ direccion_fwd = st.text_area("Dirección", key="log_fwd_direccion")
2592
+ obs_fwd = st.text_area("Observación", key="log_fwd_obs")
2593
+ if st.button("Guardar forwarder", type="primary"):
2594
+ if nombre_fwd.strip():
2595
+ db.upsert_logistics_forwarder(nombre_fwd, direccion_fwd, obs_fwd)
2596
+ clear_logistics_cache()
2597
+ st.success("Forwarder guardado.")
2598
+ st.rerun()
2599
+ else:
2600
+ st.warning("Indica el nombre.")
2601
+
2602
+ with tab_incoterms:
2603
+ if incoterms_df.empty:
2604
+ render_empty_state("Sin incoterms", "No hay incoterms cargados.")
2605
+ else:
2606
+ incoterm_view = incoterms_df[["sigla", "incoterm"]].copy()
2607
+ st.dataframe(incoterm_view, use_container_width=True, hide_index=True)
2608
+ render_notice_panel(
2609
+ "Cómo usar esta matriz",
2610
+ "El incoterm define qué costos asume el proveedor y qué costos debe estimar Proyelec. La tabla inferior muestra el responsable por tramo.",
2611
+ "blue",
2612
+ )
2613
+ selected_inc = st.selectbox("Ver responsabilidad por incoterm", incoterms_df["sigla"].tolist())
2614
+ inc_row = incoterms_df[incoterms_df["sigla"] == selected_inc].iloc[0]
2615
+ responsabilidades = inc_row.get("responsabilidades", {}) or {}
2616
+ if isinstance(responsabilidades, str):
2617
+ try:
2618
+ responsabilidades = json.loads(responsabilidades)
2619
+ except Exception:
2620
+ responsabilidades = {}
2621
+ resp_df = pd.DataFrame([
2622
+ {"Tramo": key.replace("_", " ").title(), "Responsable": value}
2623
+ for key, value in responsabilidades.items()
2624
+ ])
2625
+ st.dataframe(resp_df, use_container_width=True, hide_index=True)
2626
+
2627
+ with tab_history:
2628
+ calc_df = get_logistics_calculations_cached(100)
2629
+ if calc_df.empty:
2630
+ render_empty_state("Sin cálculos guardados", "Los cálculos logísticos guardados aparecerán aquí.")
2631
+ else:
2632
+ st.dataframe(
2633
+ calc_df,
2634
+ use_container_width=True,
2635
+ hide_index=True,
2636
+ column_config={
2637
+ "costo_internacional": st.column_config.NumberColumn("Internacional", format="$ %.2f"),
2638
+ "costo_local": st.column_config.NumberColumn("Local", format="$ %.2f"),
2639
+ "costo_total": st.column_config.NumberColumn("Total", format="$ %.2f"),
2640
+ "peso_libras": st.column_config.NumberColumn("lb", format="%.2f"),
2641
+ "peso_kg": st.column_config.NumberColumn("kg", format="%.2f"),
2642
+ "peso_facturable_libras": st.column_config.NumberColumn("Peso facturable lb", format="%.2f"),
2643
+ "peso_volumetrico_libras": st.column_config.NumberColumn("Peso volumétrico lb", format="%.2f"),
2644
+ },
2645
+ )
2646
+ st.divider()
2647
+ delete_options = calc_df["id"].tolist()
2648
+ selected_delete = st.selectbox(
2649
+ "Cálculo guardado a borrar",
2650
+ delete_options,
2651
+ format_func=lambda calc_id: (
2652
+ f"#{calc_id} | "
2653
+ f"{calc_df.loc[calc_df['id'] == calc_id, 'licitacion'].iloc[0] or 'Sin licitación'} | "
2654
+ f"$ {float(calc_df.loc[calc_df['id'] == calc_id, 'costo_total'].iloc[0] or 0):,.2f}"
2655
+ ),
2656
+ key="log_delete_calc_id",
2657
+ )
2658
+ if st.button("Borrar cálculo seleccionado", type="secondary", use_container_width=True):
2659
+ deleted = db.delete_logistics_calculation(selected_delete)
2660
+ clear_logistics_cache()
2661
+ if deleted:
2662
+ st.success("Cálculo borrado.")
2663
+ st.rerun()
2664
+ else:
2665
+ st.warning("No se encontró el cálculo seleccionado.")
2666
+
2667
  if active_view == "🌐 Proveedores":
2668
  render_page_header(
2669
  "Sourcing global",
 
4111
  if "acepta_equivalente" in df_render.columns:
4112
  df_render["equivalente_txt"] = df_render["acepta_equivalente"].apply(bool_label)
4113
 
4114
+ result_views = ["📋 1. Matriz de Productos", "📨 2. Emisión de RFQs", "🤖 3. Centro de Mando AI", "📈 4. Análisis de Costos"]
4115
+ if role_can("logistics_calc"):
4116
+ result_views.append("🚚 5. Costos Logísticos")
4117
+
4118
  result_view = st.radio(
4119
  "Vista de licitacion",
4120
+ result_views,
4121
  horizontal=True,
4122
  label_visibility="collapsed",
4123
  key="result_view",
 
4973
 
4974
  st.markdown("#### Volumen Solicitado por Renglón")
4975
  fig = go.Figure(data=[go.Bar(x=df_render['renglon'], y=df_render['cantidad'], marker_color='#238636')])
4976
+ fig.update_layout(template="plotly_dark", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)')
4977
+ st.plotly_chart(fig, use_container_width=True)
4978
+
4979
+ if result_view == "🚚 5. Costos Logísticos":
4980
+ st.markdown("### 🚚 Cálculo logístico por renglón")
4981
+ render_notice_panel(
4982
+ "Ruta tarifaria vigente",
4983
+ f"{LOGISTICS_ROUTE_LABEL}. Estas tarifas no estiman costos desde China, Europa u otros orígenes hasta que Logística cargue una ruta específica.",
4984
+ "blue",
4985
+ )
4986
+ freight_df = get_logistics_freight_rates_cached()
4987
+ local_df = get_logistics_local_rates_cached()
4988
+ incoterms_df = get_logistics_incoterms_cached()
4989
+ if freight_df.empty:
4990
+ render_empty_state("Sin tarifas logísticas", "Solicita a Logística cargar las tarifas base antes de calcular costos.")
4991
+ else:
4992
+ log_left, log_right = st.columns([0.58, 0.42])
4993
+ with log_left:
4994
+ def _log_item_label(idx):
4995
+ row = df_render.iloc[idx]
4996
+ return f"Renglón {row.get('renglon', idx + 1)} · {row.get('codigo_articulo', 'S/C')} · {str(row.get('termino_de_busqueda_corto', ''))[:55]}"
4997
+
4998
+ route_a, route_b = st.columns(2)
4999
+ with route_a:
5000
+ st.text_input("Origen tarifario", value=LOGISTICS_ROUTE_ORIGIN, disabled=True, key="rfq_log_origin")
5001
+ with route_b:
5002
+ st.text_input("Destino tarifario", value=LOGISTICS_ROUTE_DESTINATION, disabled=True, key="rfq_log_route_dest")
5003
+
5004
+ item_idx = st.selectbox(
5005
+ "Renglón",
5006
+ list(range(len(df_render))),
5007
+ format_func=_log_item_label,
5008
+ key="rfq_log_item_idx",
5009
+ )
5010
+ selected_item = df_render.iloc[item_idx]
5011
+ qty_for_hint = pd.to_numeric(selected_item.get("cantidad", 1), errors="coerce")
5012
+ qty_for_hint = float(qty_for_hint) if pd.notna(qty_for_hint) and float(qty_for_hint) > 0 else 1.0
5013
+ modo_peso_rfq = st.radio(
5014
+ "Forma de cálculo",
5015
+ ["Por unidad del renglón", "Por paquete"],
5016
+ horizontal=True,
5017
+ key="rfq_log_modo_peso",
5018
+ )
5019
+
5020
+ c1, c2 = st.columns(2)
5021
+ with c1:
5022
+ tipo_flete = st.selectbox("Tipo de flete", sorted(freight_df["tipo_flete"].dropna().unique().tolist()), key="rfq_log_tipo")
5023
+ freight_filtered = freight_df[freight_df["tipo_flete"] == tipo_flete].copy()
5024
+ with c2:
5025
+ agente_flete = st.selectbox("Agente", freight_filtered["agente"].dropna().unique().tolist(), key="rfq_log_agente")
5026
+ freight_row = freight_filtered[freight_filtered["agente"] == agente_flete].iloc[0]
5027
+
5028
+ if modo_peso_rfq == "Por paquete":
5029
+ c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28])
5030
+ with c3:
5031
+ peso_paquete_rfq = st.number_input("Peso por paquete lb", min_value=0.0, value=1.0, step=0.5, key="rfq_log_pkg_lb")
5032
+ with c4:
5033
+ bultos_rfq = st.number_input("Paquetes", min_value=1, value=1, step=1, key="rfq_log_pkg_count")
5034
+ with c5:
5035
+ cantidad_log = st.number_input("Cantidad renglón", min_value=1.0, value=max(qty_for_hint, 1.0), step=1.0, key="rfq_log_qty")
5036
+ with c6:
5037
+ incoterm_log = st.selectbox("Incoterm", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="rfq_log_incoterm")
5038
+ peso_unit_lb = peso_paquete_rfq
5039
+ peso_total_lb = peso_paquete_rfq * bultos_rfq
5040
+ else:
5041
+ c3, c4, c5, c6 = st.columns([0.24, 0.24, 0.24, 0.28])
5042
+ with c3:
5043
+ peso_unit_lb = st.number_input("Peso unitario lb", min_value=0.0, value=1.0, step=0.5, key="rfq_log_unit_lb")
5044
+ with c4:
5045
+ cantidad_log = st.number_input("Cantidad", min_value=1.0, value=max(qty_for_hint, 1.0), step=1.0, key="rfq_log_qty")
5046
+ with c5:
5047
+ bultos_rfq = st.number_input("Paquetes / bultos", min_value=1, value=1, step=1, key="rfq_log_bultos")
5048
+ with c6:
5049
+ incoterm_log = st.selectbox("Incoterm", incoterms_df["sigla"].tolist() if not incoterms_df.empty else ["FOB"], key="rfq_log_incoterm")
5050
+ peso_paquete_rfq = peso_unit_lb
5051
+ peso_total_lb = peso_unit_lb * cantidad_log
5052
+
5053
+ st.caption("Dimensiones por paquete/bulto para estimar peso volumétrico.")
5054
+ d1, d2, d3, d4, d5 = st.columns([0.2, 0.2, 0.2, 0.18, 0.22])
5055
+ with d1:
5056
+ largo_rfq = st.number_input("Largo", min_value=0.0, value=0.0, step=1.0, key="rfq_log_largo")
5057
+ with d2:
5058
+ ancho_rfq = st.number_input("Ancho", min_value=0.0, value=0.0, step=1.0, key="rfq_log_ancho")
5059
+ with d3:
5060
+ alto_rfq = st.number_input("Alto", min_value=0.0, value=0.0, step=1.0, key="rfq_log_alto")
5061
+ with d4:
5062
+ unidad_rfq = st.selectbox("Unidad", ["in", "cm"], key="rfq_log_unidad_dim")
5063
+ with d5:
5064
+ st.metric("Peso total", f"{peso_total_lb:,.2f} lb")
5065
+
5066
+ local_row = pd.Series(dtype=object)
5067
+ destino_log = "Sin entrega local"
5068
+ if not local_df.empty:
5069
+ destino_log = st.selectbox("Entrega local", ["Sin entrega local"] + local_df["destino"].dropna().unique().tolist(), key="rfq_log_destino")
5070
+ if destino_log != "Sin entrega local":
5071
+ local_candidates = local_df[local_df["destino"] == destino_log]
5072
+ if not local_candidates.empty:
5073
+ local_row = local_candidates.iloc[0]
5074
+
5075
+ calc = logistics_calc_summary(
5076
+ freight_row,
5077
+ local_row,
5078
+ peso_total_lb,
5079
+ incoterm=incoterm_log,
5080
+ largo=largo_rfq,
5081
+ ancho=ancho_rfq,
5082
+ alto=alto_rfq,
5083
+ unidad_dimensional=unidad_rfq,
5084
+ bultos=bultos_rfq,
5085
+ )
5086
+ costo_unit_log = calc["costo_total"] / cantidad_log if cantidad_log else 0
5087
+
5088
+ if st.button("Guardar costo logístico del renglón", type="primary", use_container_width=True):
5089
+ db.save_logistics_calculation(
5090
+ username=st.session_state.username,
5091
+ licitacion=str(cg_render.get("numero_licitacion", "")),
5092
+ renglon=str(selected_item.get("renglon", "")),
5093
+ agente=agente_flete,
5094
+ tipo_flete=tipo_flete,
5095
+ incoterm=incoterm_log,
5096
+ peso_libras=calc["peso_libras"],
5097
+ peso_kg=calc["peso_kg"],
5098
+ costo_internacional=calc["costo_internacional"],
5099
+ costo_local=calc["costo_local"],
5100
+ costo_total=calc["costo_total"],
5101
+ tiempo_transito_dias=calc["tiempo_transito_dias"],
5102
+ peso_facturable_libras=calc["peso_facturable_libras"],
5103
+ peso_volumetrico_libras=calc["peso_volumetrico_libras"],
5104
+ largo=calc["largo"],
5105
+ ancho=calc["ancho"],
5106
+ alto=calc["alto"],
5107
+ unidad_dimensional=calc["unidad_dimensional"],
5108
+ metadata={
5109
+ "destino": destino_log,
5110
+ "ruta": LOGISTICS_ROUTE_LABEL,
5111
+ "origen_tarifario": LOGISTICS_ROUTE_ORIGIN,
5112
+ "destino_tarifario": LOGISTICS_ROUTE_DESTINATION,
5113
+ "modo_calculo": modo_peso_rfq,
5114
+ "cantidad": cantidad_log,
5115
+ "peso_unitario_lb": peso_unit_lb,
5116
+ "peso_por_paquete_lb": peso_paquete_rfq,
5117
+ "bultos": calc["bultos"],
5118
+ "volumen_pies_cubicos": calc["volumen_pies_cubicos"],
5119
+ "codigo_articulo": str(selected_item.get("codigo_articulo", "")),
5120
+ }
5121
+ )
5122
+ clear_logistics_cache()
5123
+ st.success("Costo logístico guardado para trazabilidad.")
5124
+
5125
+ with log_right:
5126
+ st.metric("Costo logístico total", f"$ {calc['costo_total']:,.2f}")
5127
+ st.metric("Costo logístico unitario", f"$ {costo_unit_log:,.2f}")
5128
+ st.metric("Flete internacional", f"$ {calc['costo_internacional']:,.2f}")
5129
+ st.metric("Entrega local", f"$ {calc['costo_local']:,.2f}")
5130
+ render_notice_panel(
5131
+ "Lead time calculado",
5132
+ f"{LOGISTICS_ROUTE_LABEL}. {agente_flete} / {tipo_flete}: {calc['tiempo_transito_dias']} día(s). Peso real: {calc['peso_libras']:,.2f} lb. Peso facturable: {calc['peso_facturable_libras']:,.2f} lb.",
5133
+ "blue",
5134
+ )
5135
+
5136
+ calc_df = get_logistics_calculations_cached(50)
5137
+ if not calc_df.empty:
5138
+ st.caption("Últimos cálculos logísticos guardados")
5139
+ st.dataframe(
5140
+ calc_df[["created_at", "licitacion", "renglon", "agente", "tipo_flete", "incoterm", "costo_total", "tiempo_transito_dias"]].head(10),
5141
+ use_container_width=True,
5142
+ hide_index=True,
5143
+ column_config={"costo_total": st.column_config.NumberColumn("Total", format="$ %.2f")},
5144
+ )
database.py CHANGED
@@ -265,16 +265,89 @@ def init_db():
265
  )''')
266
 
267
  # Log de escaneos del radar
268
- c.execute('''CREATE TABLE IF NOT EXISTS radar_escaneos (
269
- id SERIAL PRIMARY KEY,
270
- fecha TEXT,
271
- total_encontradas INTEGER DEFAULT 0,
272
- nuevas INTEGER DEFAULT 0,
273
- errores TEXT
274
- )''')
275
-
276
- conn.commit()
277
- conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
  # =============================================
280
  # WORKSPACES (MÚLTIPLES POR USUARIO)
@@ -428,6 +501,210 @@ def get_historico_licitaciones_df(limit=5000, search=None, anio=None):
428
  conn.close()
429
  return df
430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  def get_historico_anios():
432
  conn = get_connection()
433
  df = pd.read_sql_query("""
 
265
  )''')
266
 
267
  # Log de escaneos del radar
268
+ c.execute('''CREATE TABLE IF NOT EXISTS radar_escaneos (
269
+ id SERIAL PRIMARY KEY,
270
+ fecha TEXT,
271
+ total_encontradas INTEGER DEFAULT 0,
272
+ nuevas INTEGER DEFAULT 0,
273
+ errores TEXT
274
+ )''')
275
+
276
+ # === MODULO LOGISTICO ===
277
+ c.execute('''CREATE TABLE IF NOT EXISTS logistics_incoterms (
278
+ sigla TEXT PRIMARY KEY,
279
+ incoterm TEXT,
280
+ responsabilidades JSONB DEFAULT '{}'::jsonb,
281
+ notas TEXT,
282
+ updated_at TIMESTAMPTZ DEFAULT NOW()
283
+ )''')
284
+ c.execute('''CREATE TABLE IF NOT EXISTS logistics_freight_rates (
285
+ id SERIAL PRIMARY KEY,
286
+ agente TEXT NOT NULL,
287
+ tipo_servicio TEXT,
288
+ tipo_flete TEXT NOT NULL,
289
+ tarifa_por_libra NUMERIC(12, 4) DEFAULT 0,
290
+ tiempo_transito_dias INTEGER DEFAULT 0,
291
+ minimo_envio NUMERIC(12, 4) DEFAULT 0,
292
+ dia_corte TEXT,
293
+ salidas TEXT,
294
+ activo BOOLEAN DEFAULT TRUE,
295
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
296
+ UNIQUE(agente, tipo_flete, tipo_servicio)
297
+ )''')
298
+ c.execute('''CREATE TABLE IF NOT EXISTS logistics_local_delivery_rates (
299
+ id SERIAL PRIMARY KEY,
300
+ agente TEXT NOT NULL,
301
+ destino TEXT NOT NULL,
302
+ tipo_flete TEXT DEFAULT 'Terrestre',
303
+ hasta_400kg NUMERIC(12, 4) DEFAULT 0,
304
+ kg_500_1000 NUMERIC(12, 4) DEFAULT 0,
305
+ mayor_1000kg NUMERIC(12, 4) DEFAULT 0,
306
+ activo BOOLEAN DEFAULT TRUE,
307
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
308
+ UNIQUE(agente, destino)
309
+ )''')
310
+ c.execute('''CREATE TABLE IF NOT EXISTS logistics_forwarders (
311
+ id SERIAL PRIMARY KEY,
312
+ nombre TEXT UNIQUE NOT NULL,
313
+ direccion TEXT,
314
+ observacion TEXT,
315
+ activo BOOLEAN DEFAULT TRUE,
316
+ updated_at TIMESTAMPTZ DEFAULT NOW()
317
+ )''')
318
+ c.execute('''CREATE TABLE IF NOT EXISTS logistics_calculations (
319
+ id SERIAL PRIMARY KEY,
320
+ created_at TIMESTAMPTZ DEFAULT NOW(),
321
+ username TEXT,
322
+ licitacion TEXT,
323
+ renglon TEXT,
324
+ agente TEXT,
325
+ tipo_flete TEXT,
326
+ incoterm TEXT,
327
+ peso_libras NUMERIC(14, 4) DEFAULT 0,
328
+ peso_kg NUMERIC(14, 4) DEFAULT 0,
329
+ costo_internacional NUMERIC(14, 4) DEFAULT 0,
330
+ costo_local NUMERIC(14, 4) DEFAULT 0,
331
+ costo_total NUMERIC(14, 4) DEFAULT 0,
332
+ tiempo_transito_dias INTEGER DEFAULT 0,
333
+ metadata JSONB DEFAULT '{}'::jsonb
334
+ )''')
335
+ for ddl in [
336
+ "ALTER TABLE logistics_calculations ADD COLUMN IF NOT EXISTS peso_facturable_libras NUMERIC(14, 4) DEFAULT 0",
337
+ "ALTER TABLE logistics_calculations ADD COLUMN IF NOT EXISTS peso_volumetrico_libras NUMERIC(14, 4) DEFAULT 0",
338
+ "ALTER TABLE logistics_calculations ADD COLUMN IF NOT EXISTS largo NUMERIC(14, 4) DEFAULT 0",
339
+ "ALTER TABLE logistics_calculations ADD COLUMN IF NOT EXISTS ancho NUMERIC(14, 4) DEFAULT 0",
340
+ "ALTER TABLE logistics_calculations ADD COLUMN IF NOT EXISTS alto NUMERIC(14, 4) DEFAULT 0",
341
+ "ALTER TABLE logistics_calculations ADD COLUMN IF NOT EXISTS unidad_dimensional TEXT DEFAULT 'in'",
342
+ ]:
343
+ c.execute(ddl)
344
+ c.execute("CREATE INDEX IF NOT EXISTS idx_logistics_calculations_created ON logistics_calculations(created_at DESC)")
345
+ c.execute("CREATE INDEX IF NOT EXISTS idx_logistics_calculations_licitacion ON logistics_calculations(licitacion)")
346
+
347
+ _seed_logistics_defaults(c)
348
+
349
+ conn.commit()
350
+ conn.close()
351
 
352
  # =============================================
353
  # WORKSPACES (MÚLTIPLES POR USUARIO)
 
501
  conn.close()
502
  return df
503
 
504
+
505
+ # =============================================
506
+ # LOGISTICA
507
+ # =============================================
508
+
509
+ INCOTERM_STEPS = [
510
+ "embalaje_verificacion",
511
+ "carga_almacen",
512
+ "transporte_interno_origen",
513
+ "tramites_aduaneros_exportacion",
514
+ "costo_terminal_origen",
515
+ "transporte_principal",
516
+ "seguro_transporte",
517
+ "costo_terminal_destino",
518
+ "tramites_aduaneros_importacion",
519
+ "transporte_interior_destino",
520
+ "descarga_almacen_comprador",
521
+ ]
522
+
523
+ def _seed_logistics_defaults(c):
524
+ incoterms = {
525
+ "EXW": ("Ex Works", ["Vendedor", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador"]),
526
+ "FCA": ("Free Carrier", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador"]),
527
+ "FAS": ("Free Alongside Ship", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador"]),
528
+ "FOB": ("Free On Board", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador"]),
529
+ "CPT": ("Carriage Paid To", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador"]),
530
+ "CFR": ("Cost and Freight", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador", "Comprador"]),
531
+ "CIP": ("Carriage and Insurance Paid To", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador"]),
532
+ "CIF": ("Cost, Insurance and Freight", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Comprador", "Comprador", "Comprador"]),
533
+ "DAP": ("Delivered at Place", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Vendedor", "Comprador"]),
534
+ "DPU": ("Delivered at Place Unloaded", ["Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Vendedor", "Comprador", "Vendedor", "Vendedor"]),
535
+ }
536
+ for sigla, (nombre, valores) in incoterms.items():
537
+ responsabilidades = dict(zip(INCOTERM_STEPS, valores))
538
+ c.execute("""INSERT INTO logistics_incoterms (sigla, incoterm, responsabilidades, notas)
539
+ VALUES (%s, %s, %s::jsonb, %s)
540
+ ON CONFLICT (sigla) DO NOTHING""",
541
+ (sigla, nombre, json.dumps(responsabilidades, ensure_ascii=False), ""))
542
+
543
+ freight_rows = [
544
+ ("Southcargo", "Door-To-Door", "Aereo", 3, 3, 10, "-", "Segun disponibilidad de la aerolinea"),
545
+ ("ABMCARGO", "Door-To-Door", "Aereo", 50, 3, 35, "-", "Segun disponibilidad de la aerolinea"),
546
+ ("Southcargo", "Door-To-Door", "Maritimo", 3, 6, 55, "Martes", "Semanales"),
547
+ ("ABMCARGO", "Door-To-Door", "Maritimo", 50, 6, 90, "Martes", "Semanales"),
548
+ ]
549
+ for row in freight_rows:
550
+ c.execute("""INSERT INTO logistics_freight_rates
551
+ (agente, tipo_servicio, tipo_flete, tarifa_por_libra, tiempo_transito_dias, minimo_envio, dia_corte, salidas)
552
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
553
+ ON CONFLICT (agente, tipo_flete, tipo_servicio) DO NOTHING""", row)
554
+
555
+ local_rows = [
556
+ ("Ariel Nunez", "Corozal", "Terrestre", 50, 200, 230),
557
+ ("Ariel Nunez", "Miraflores", "Terrestre", 50, 200, 230),
558
+ ("Ariel Nunez", "Balboa", "Terrestre", 50, 200, 230),
559
+ ("Ariel Nunez", "Gamboa", "Terrestre", 70, 200, 230),
560
+ ("Ariel Nunez", "Colon", "Terrestre", 110, 200, 230),
561
+ ("Ariel Nunez", "Atlantico Panama Pacifico", "Terrestre", 60, 200, 450),
562
+ ]
563
+ for row in local_rows:
564
+ c.execute("""INSERT INTO logistics_local_delivery_rates
565
+ (agente, destino, tipo_flete, hasta_400kg, kg_500_1000, mayor_1000kg)
566
+ VALUES (%s, %s, %s, %s, %s, %s)
567
+ ON CONFLICT (agente, destino) DO NOTHING""", row)
568
+
569
+ forwarders = [
570
+ ("SOUTH CARGO", "6708 NW 82ND AVE. MIAMI, FL. 33166", "Agente de envio / Centro de inspeccion."),
571
+ ("ABM LOGISTICS", "9372 NW 101 ST MEDLEY, FL 33178 UNITED STATES", "Agente de envio / Centro de inspeccion."),
572
+ ("MERCOSTAR", "8012 NW 68th Street Miami - FL 33166", "Centro de inspeccion."),
573
+ ("ARIEL NUNEZ", "LAS CUMBRES CAIMITILLO CALLE SEGOVIA OESTE CASA 35J", "Entrega a cliente / Inspector."),
574
+ ("DHL", "AV CENTENARIO, PANAMA CITY", "Agente de envio / Centro de inspeccion."),
575
+ ]
576
+ for row in forwarders:
577
+ c.execute("""INSERT INTO logistics_forwarders (nombre, direccion, observacion)
578
+ VALUES (%s, %s, %s)
579
+ ON CONFLICT (nombre) DO NOTHING""", row)
580
+
581
+ def get_logistics_freight_rates():
582
+ conn = get_connection()
583
+ try:
584
+ return pd.read_sql_query("SELECT * FROM logistics_freight_rates ORDER BY tipo_flete, agente", conn)
585
+ finally:
586
+ conn.close()
587
+
588
+ def get_logistics_local_rates():
589
+ conn = get_connection()
590
+ try:
591
+ return pd.read_sql_query("SELECT * FROM logistics_local_delivery_rates ORDER BY agente, destino", conn)
592
+ finally:
593
+ conn.close()
594
+
595
+ def get_logistics_forwarders():
596
+ conn = get_connection()
597
+ try:
598
+ return pd.read_sql_query("SELECT * FROM logistics_forwarders ORDER BY nombre", conn)
599
+ finally:
600
+ conn.close()
601
+
602
+ def get_logistics_incoterms():
603
+ conn = get_connection()
604
+ try:
605
+ return pd.read_sql_query("SELECT sigla, incoterm, responsabilidades, notas FROM logistics_incoterms ORDER BY sigla", conn)
606
+ finally:
607
+ conn.close()
608
+
609
+ def upsert_logistics_freight_rate(agente, tipo_servicio, tipo_flete, tarifa_por_libra,
610
+ tiempo_transito_dias, minimo_envio, dia_corte, salidas, activo=True):
611
+ conn = get_connection()
612
+ try:
613
+ c = conn.cursor()
614
+ c.execute("""INSERT INTO logistics_freight_rates
615
+ (agente, tipo_servicio, tipo_flete, tarifa_por_libra, tiempo_transito_dias, minimo_envio, dia_corte, salidas, activo)
616
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
617
+ ON CONFLICT (agente, tipo_flete, tipo_servicio) DO UPDATE SET
618
+ tarifa_por_libra=EXCLUDED.tarifa_por_libra,
619
+ tiempo_transito_dias=EXCLUDED.tiempo_transito_dias,
620
+ minimo_envio=EXCLUDED.minimo_envio,
621
+ dia_corte=EXCLUDED.dia_corte,
622
+ salidas=EXCLUDED.salidas,
623
+ activo=EXCLUDED.activo,
624
+ updated_at=NOW()""",
625
+ (agente, tipo_servicio, tipo_flete, tarifa_por_libra, tiempo_transito_dias, minimo_envio, dia_corte, salidas, activo))
626
+ conn.commit()
627
+ finally:
628
+ conn.close()
629
+
630
+ def upsert_logistics_local_rate(agente, destino, tipo_flete, hasta_400kg, kg_500_1000, mayor_1000kg, activo=True):
631
+ conn = get_connection()
632
+ try:
633
+ c = conn.cursor()
634
+ c.execute("""INSERT INTO logistics_local_delivery_rates
635
+ (agente, destino, tipo_flete, hasta_400kg, kg_500_1000, mayor_1000kg, activo)
636
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
637
+ ON CONFLICT (agente, destino) DO UPDATE SET
638
+ tipo_flete=EXCLUDED.tipo_flete,
639
+ hasta_400kg=EXCLUDED.hasta_400kg,
640
+ kg_500_1000=EXCLUDED.kg_500_1000,
641
+ mayor_1000kg=EXCLUDED.mayor_1000kg,
642
+ activo=EXCLUDED.activo,
643
+ updated_at=NOW()""",
644
+ (agente, destino, tipo_flete, hasta_400kg, kg_500_1000, mayor_1000kg, activo))
645
+ conn.commit()
646
+ finally:
647
+ conn.close()
648
+
649
+ def upsert_logistics_forwarder(nombre, direccion, observacion, activo=True):
650
+ conn = get_connection()
651
+ try:
652
+ c = conn.cursor()
653
+ c.execute("""INSERT INTO logistics_forwarders (nombre, direccion, observacion, activo)
654
+ VALUES (%s, %s, %s, %s)
655
+ ON CONFLICT (nombre) DO UPDATE SET
656
+ direccion=EXCLUDED.direccion,
657
+ observacion=EXCLUDED.observacion,
658
+ activo=EXCLUDED.activo,
659
+ updated_at=NOW()""",
660
+ (nombre, direccion, observacion, activo))
661
+ conn.commit()
662
+ finally:
663
+ conn.close()
664
+
665
+ def save_logistics_calculation(username="", licitacion="", renglon="", agente="", tipo_flete="", incoterm="",
666
+ peso_libras=0, peso_kg=0, costo_internacional=0, costo_local=0,
667
+ costo_total=0, tiempo_transito_dias=0, metadata=None,
668
+ peso_facturable_libras=0, peso_volumetrico_libras=0,
669
+ largo=0, ancho=0, alto=0, unidad_dimensional="in"):
670
+ metadata = metadata or {}
671
+ conn = get_connection()
672
+ try:
673
+ c = conn.cursor()
674
+ c.execute("""INSERT INTO logistics_calculations
675
+ (username, licitacion, renglon, agente, tipo_flete, incoterm, peso_libras, peso_kg,
676
+ costo_internacional, costo_local, costo_total, tiempo_transito_dias, metadata,
677
+ peso_facturable_libras, peso_volumetrico_libras, largo, ancho, alto, unidad_dimensional)
678
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s)""",
679
+ (username, licitacion, renglon, agente, tipo_flete, incoterm, peso_libras, peso_kg,
680
+ costo_internacional, costo_local, costo_total, tiempo_transito_dias,
681
+ json.dumps(metadata, ensure_ascii=False), peso_facturable_libras, peso_volumetrico_libras,
682
+ largo, ancho, alto, unidad_dimensional))
683
+ conn.commit()
684
+ finally:
685
+ conn.close()
686
+
687
+ def get_logistics_calculations(limit=100):
688
+ conn = get_connection()
689
+ try:
690
+ return pd.read_sql_query(
691
+ f"SELECT * FROM logistics_calculations ORDER BY created_at DESC LIMIT {int(limit)}",
692
+ conn
693
+ )
694
+ finally:
695
+ conn.close()
696
+
697
+ def delete_logistics_calculation(calculation_id):
698
+ conn = get_connection()
699
+ try:
700
+ c = conn.cursor()
701
+ c.execute("DELETE FROM logistics_calculations WHERE id = %s", (int(calculation_id),))
702
+ deleted = c.rowcount
703
+ conn.commit()
704
+ return deleted
705
+ finally:
706
+ conn.close()
707
+
708
  def get_historico_anios():
709
  conn = get_connection()
710
  df = pd.read_sql_query("""