Spaces:
Runtime error
Runtime error
Upload 4 files
Browse files- app.py +684 -76
- 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["
|
| 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(
|
| 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["
|
| 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["
|
| 1286 |
if not role_match.empty:
|
| 1287 |
-
current_role = str(role_match.iloc[0].get("
|
| 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 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
<div class="analysis-card
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
<
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
|
|
|
|
|
|
| 1556 |
</div>
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
|
| 1590 |
-
|
| 1591 |
-
|
| 1592 |
-
|
| 1593 |
-
|
| 1594 |
-
|
| 1595 |
-
|
| 1596 |
-
|
| 1597 |
-
|
| 1598 |
-
|
| 1599 |
-
|
| 1600 |
-
|
| 1601 |
-
|
| 1602 |
-
|
| 1603 |
-
|
| 1604 |
-
|
| 1605 |
-
|
| 1606 |
-
|
| 1607 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("""
|