/
Egor19731973
/
InfPanel
Обзор
Документация
Войти
/
Egor19731973
/
InfPanel
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.py
788 строк
36 KB
Egor19731973
upload files
23 фев 2026, 13:16
Верифицирован
23 фев 2026, 13:16
c985e3f
Код
Авторство
О чём код?
import streamlit as st import pandas as pd import plotly.express as px import os import io # ============================================================================= # 1. НАСТРОЙКА СТРАНИЦЫ + УЛУЧШЕННЫЙ CSS # ============================================================================= st.set_page_config(page_title="Информационный портал КФД НСО", layout="wide", page_icon="🏦") st.markdown(""" <style> /* ─────────────────────────────────────────────── МАКСИМАЛЬНО КРУПНЫЕ И ЖИРНЫЕ НАЗВАНИЯ МЕТРИК ─────────────────────────────────────────────── */ /* Основной контейнер метрики */ [data-testid="stMetric"] { min-height: 200px !important; padding: 24px 16px !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; } /* Блок с меткой (название + иконка) */ [data-testid="stMetricLabel"] { font-size: 36px !important; /* очень крупно */ font-weight: 600 !important; line-height: 1.1 !important; color: #000000 !important; margin: 0 0 16px 0 !important; text-align: center !important; letter-spacing: -0.5px !important; } /* Все внутренние элементы метки — текст и иконки */ [data-testid="stMetricLabel"] *, [data-testid="stMetricLabel"] span, [data-testid="stMetricLabel"] div, [data-testid="stMetricLabel"] svg { font-size: 36px !important; font-weight: 600 !important; color: #000000 !important; vertical-align: middle !important; } /* Иконки/эмодзи отдельно — ещё чуть крупнее */ [data-testid="stMetricLabel"] [data-testid^="stEmoji"], [data-testid="stMetricLabel"] svg, [data-testid="stMetricLabel"] img { font-size: 44px !important; line-height: 1 !important; margin-right: 12px !important; vertical-align: -4px !important; } /* Значение (число) — крупное, но не перебивает название */ [data-testid="stMetricValue"] { font-size: 68px !important; font-weight: 600 !important; line-height: 1.0 !important; color: #111827 !important; margin: 8px 0 4px 0 !important; } /* Дельта / изменение */ [data-testid="stMetricDelta"] { font-size: 24px !important; font-weight: 600 !important; margin-top: 8px !important; } /* Страховка от Streamlit-обновлений структуры */ div.element-container [data-testid="stMetric"] label, div.stMetric label, .stMetric > div > div:first-child, [data-baseweb="metric"] label { font-size: 36px !important; font-weight: 600 !important; color: #000000 !important; } /* Убираем любой возможный серый/маленький стиль */ .stMetric label * { color: #000 !important; font-weight: 600 !important; } /* ЧЁРНЫЕ заголовки таблиц */ div[data-testid="stDataFrame"] thead th, [data-testid="stDataFrame"] thead th, thead tr th, .stDataFrame th { color: #000000 !important; font-weight: 800 !important; background-color: #dee2e6 !important; font-size: 15px !important; } </style> """, unsafe_allow_html=True) # ============================================================================= # Функции # ============================================================================= def get_kfd_status(val): if val < 0.3: return "Низкий", "#ff4b4b" elif 0.3 <= val <= 0.45: return "Ниже среднего", "#ffa500" elif 0.46 <= val <= 0.65: return "Средний", "#f1c40f" elif 0.66 <= val <= 0.85: return "Выше среднего", "#3498db" else: return "Высокий", "#1e8449" COLOR_MAP = { "Низкий": "#ff4b4b", "Ниже среднего": "#ffa500", "Средний": "#f1c40f", "Выше среднего": "#3498db", "Высокий": "#1e8449" } @st.cache_data(ttl=3600) def load_data(file_path): if not os.path.exists(file_path): st.error(f"❌ Файл '{file_path}' не найден!") st.stop() try: df = pd.read_excel(file_path) except Exception as e: st.error(f"❌ Ошибка чтения файла: {e}") st.stop() df.columns = [str(c).strip().lower() for c in df.columns] required = ['locality_nm', 'index_f_fin', 'population'] missing = [c for c in required if c not in df.columns] if missing: st.error(f"❌ В файле отсутствуют обязательные колонки: {missing}") st.stop() bkko_fields = ['bkko_nvinpibo_cnt', 'bkko_nvibo_np_cnt', 'bkko_nvinp_bo_cnt', 'bkko_nv_cnt', 'bkko_cnt', 'bkko_1_cnt', 'bpko_cnt'] term_fields = ['t_boinvinp_cnt', 't_nv_cnt', 't_nonv_cnt', 't_bo_cnt'] post_fields = ['service_reception_b1', 'service_reception_p1', 'service_reception_c1', 'service_reception_p2', 'stat_post_office_cnt'] mobile_fields = ['mobile_post_office_cnt', 'mob_managers_rshb_cnt', 'mob_offices_cnt'] all_numeric = (bkko_fields + term_fields + post_fields + mobile_fields + ['index_f_fin', 'population', 'n_4g_flg']) for col in all_numeric: if col in df.columns: df[col] = df[col].astype(str).str.replace(',', '.', regex=False) df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0) df['total_bkko'] = df[[f for f in bkko_fields if f in df.columns]].sum(axis=1) df['total_terms'] = df[[f for f in term_fields if f in df.columns]].sum(axis=1) df['has_post'] = df[[f for f in post_fields if f in df.columns]].sum(axis=1) > 0 df['population'] = (df['population'] * 1000).astype(int) return df # ============================================================================= # ЗАГРУЗКА ДАННЫХ # ============================================================================= FILE_NAME = "DB_01.07.2025.xlsx" df = load_data(FILE_NAME) # ============================================================================= # SIDEBAR: НАСТРОЙКИ + СИМУЛЯТОР + РЕКОМЕНДАЦИИ # ============================================================================= st.sidebar.header("🛠 Инструменты анализа") with st.sidebar.expander("🎯 Настройки приоритетных НП", expanded=True): st.markdown("**Параметры для расчёта метрики 'Приоритетные':**") if 'priority_kfd_threshold' not in st.session_state: st.session_state.priority_kfd_threshold = 0.65 if 'priority_pop_threshold' not in st.session_state: st.session_state.priority_pop_threshold = 500 priority_kfd_threshold = st.number_input( "Максимальный КФД:", min_value=0.0, max_value=1.0, value=st.session_state.priority_kfd_threshold, step=0.01, help="НП с КФД ниже этого значения считаются приоритетными", key="kfd_input" ) priority_pop_threshold = st.number_input( "Минимальная численность населения:", min_value=0, max_value=10000, value=st.session_state.priority_pop_threshold, step=50, help="НП с населением выше этого значения считаются приоритетными", key="pop_input" ) st.session_state.priority_kfd_threshold = priority_kfd_threshold st.session_state.priority_pop_threshold = priority_pop_threshold st.info(f"💡 Текущие критерии:\n\nКФД < **{priority_kfd_threshold:.2f}**\n\nНаселение > **{priority_pop_threshold:,}** чел.") priority_df = df[(df['index_f_fin'] < priority_kfd_threshold) & (df['population'] > priority_pop_threshold)].copy() potential = len(priority_df) with st.sidebar.expander("🎮 Симулятор доступности", expanded=False): simulator_scope = st.radio( "Показывать населённые пункты:", options=["Все населённые пункты", "Только приоритетные"], index=0, help="Выберите, из какого списка выбирать НП для симуляции", key="simulator_scope" ) if simulator_scope == "Только приоритетные": sim_options = sorted(priority_df['locality_nm'].unique()) st.caption(f"📋 Доступно {len(sim_options)} приоритетных НП") else: sim_options = sorted(df['locality_nm'].unique()) st.caption(f"📋 Доступно {len(sim_options)} всех НП") target_loc = st.selectbox("Выберите пункт:", options=sim_options, key="sim_select") t_select = df[df['locality_nm'] == target_loc] if not t_select.empty: t_data = t_select.iloc[0] st.markdown(f"### {target_loc}") st.caption(f"Район: {t_data.get('mun_lev_1', '—')} | Население: {t_data['population']:,}") if target_loc in priority_df['locality_nm'].values: st.markdown("<span style='background-color: #fff3cd; padding: 3px 8px; border-radius: 4px; font-size: 12px;'>🎯 Приоритетный НП</span>", unsafe_allow_html=True) s_lte = st.checkbox("Провести связь 4G", value=bool(t_data['n_4g_flg']), key="sim_4g") s_term = st.checkbox("Добавить терминал оплаты", value=bool(t_data['total_terms']), key="sim_term") s_postbank = st.checkbox("Дооснастить Почту (П2 → Б1)", value=bool(t_data.get('service_reception_b1', 0)), key="sim_post") s_m_manager = st.checkbox("Направить моб. менеджера", value=bool(t_data.get('mob_managers_rshb_cnt', 0)), key="sim_mgr") s_m_office = st.checkbox("Запустить моб. офис", value=bool(t_data.get('mob_offices_cnt', 0)), key="sim_office") sim_res = t_data['index_f_fin'] if s_lte and t_data['n_4g_flg'] == 0: sim_res += 0.15 if s_term and t_data['total_terms'] == 0: sim_res += 0.10 if s_postbank and t_data.get('service_reception_b1', 0) == 0: sim_res += 0.12 if s_m_manager and t_data.get('mob_managers_rshb_cnt', 0) == 0: sim_res += 0.07 if s_m_office and t_data.get('mob_offices_cnt', 0) == 0: sim_res += 0.14 sim_res = min(round(sim_res, 2), 1.0) p_st, p_col = get_kfd_status(sim_res) orig_st, orig_col = get_kfd_status(t_data['index_f_fin']) st.sidebar.divider() st.metric("Прогноз КФД", f"{sim_res:.2f}", delta=f"{sim_res - t_data['index_f_fin']:+.2f}") st.markdown(f""" <div style='background:{orig_col}; color:white; padding:8px; border-radius:4px; margin:5px 0; text-align:center;'> <small>Текущий:</small><br><b>{orig_st}</b> </div> <div style='background:{p_col}; color:white; padding:8px; border-radius:4px; text-align:center;'> <small>Прогноз:</small><br><b>{p_st}</b> </div> """, unsafe_allow_html=True) with st.sidebar.expander("💡 Умные рекомендации", expanded=False): rec_loc = st.selectbox("Населённый пункт:", sorted(df['locality_nm'].unique()), key="rec_select", index=0 if len(df) > 0 else None) row = df[df['locality_nm'] == rec_loc].iloc[0] if not df[df['locality_nm'] == rec_loc].empty else None if row is not None: st.markdown(f"### {rec_loc}") st.caption(f"КФД: {row['index_f_fin']:.2f} | Население: {row['population']:,}") recommendations = [] if row['n_4g_flg'] == 0: recommendations.append({ "мера": "📡 Подключение 4G", "эффект": 0.15, "сложность": "высокая", "cost_est": "₽₽₽", "описание": "Запрос оператору связи на расширение покрытия" }) if row['total_terms'] == 0 and row['has_post']: recommendations.append({ "мера": "💳 Терминал в отделении Почты", "эффект": 0.12, "сложность": "низкая", "cost_est": "₽", "описание": "Установка платёжного терминала в существующем отделении" }) if row['total_bkko'] == 0 and row['population'] > 300: recommendations.append({ "мера": "🏦 Выездная касса банка", "эффект": 0.18, "сложность": "средняя", "cost_est": "₽₽", "описание": "Организация регулярного выездного обслуживания" }) if row.get('mob_managers_rshb_cnt', 0) == 0 and row['population'] > 200: recommendations.append({ "мера": "👤 Мобильный менеджер", "эффект": 0.07, "сложность": "низкая", "cost_est": "₽", "описание": "Направление специалиста для консультирования жителей" }) if row.get('mob_offices_cnt', 0) == 0 and row['population'] > 500: recommendations.append({ "мера": "🚐 Мобильный офис", "эффект": 0.14, "сложность": "средняя", "cost_est": "₽₽", "описание": "Запуск мобильного офиса банка по расписанию" }) if recommendations: rec_df = pd.DataFrame(recommendations) complexity_map = {'низкая': 1, 'средняя': 2, 'высокая': 3} rec_df['приоритет'] = rec_df['эффект'] / rec_df['сложность'].map(complexity_map) rec_df = rec_df.sort_values('приоритет', ascending=False).reset_index(drop=True) st.write("**Рекомендуемые меры:**") for idx, r in rec_df.iterrows(): with st.container(): st.markdown(f""" <div style='border-left: 4px solid {COLOR_MAP.get("Выше среднего", "#3498db")}; padding: 8px 12px; margin: 5px 0; background: #f8f9fa; border-radius: 0 4px 4px 0;'> <b>{idx+1}. {r['мера']}</b><br> <small>📈 КФД +{r['эффект']:.2f} | 💰 {r['cost_est']} | ⚙️ {r['сложность']}</small><br> <small>💡 {r['описание']}</small> </div> """, unsafe_allow_html=True) if st.button("📋 Экспорт плана мероприятий", key="btn_export_plan"): plan_text = f"ПЛАН МЕРОПРИЯТИЙ\n{'='*50}\n" plan_text += f"Населённый пункт: {rec_loc}\n" plan_text += f"Текущий КФД: {row['index_f_fin']:.2f} ({get_kfd_status(row['index_f_fin'])[0]})\n" plan_text += f"Население: {row['population']:,}\n" plan_text += f"Район: {row.get('mun_lev_1', '—')}\n\n" plan_text += f"Рекомендуемые меры (по приоритету):\n{'-'*50}\n" for idx, r in rec_df.iterrows(): new_kfd = min(row['index_f_fin'] + r['эффект'], 1.0) plan_text += f"{idx+1}. {r['мера']}\n" plan_text += f" → Прогноз КФД: {new_kfd:.2f} ({get_kfd_status(new_kfd)[0]})\n" plan_text += f" → Сложность: {r['сложность']}, Оценка затрат: {r['cost_est']}\n" plan_text += f" → Описание: {r['описание']}\n\n" st.download_button("💾 Скачать .txt", plan_text, f"plan_{rec_loc.replace(' ', '_')}.txt", key="btn_download_plan") else: st.success("✅ Инфраструктура соответствует высокому уровню КФД") # ============================================================================= # ОСНОВНОЙ КОНТЕНТ # ============================================================================= st.title("🏛 Информационный портал финансовой доступности") st.subheader("Мониторинг Новосибирской области") st.markdown("<div style='margin-bottom: 40px;'></div>", unsafe_allow_html=True) # KPI-панель kpi1, kpi2, kpi3, kpi4 = st.columns(4) with kpi1: kfd_counts = { "Низкий": len(df[df['index_f_fin'] < 0.3]), "Ниже среднего": len(df[(df['index_f_fin'] >= 0.3) & (df['index_f_fin'] <= 0.45)]), "Средний": len(df[(df['index_f_fin'] > 0.45) & (df['index_f_fin'] <= 0.65)]), "Выше среднего": len(df[(df['index_f_fin'] > 0.65) & (df['index_f_fin'] <= 0.85)]), "Высокий": len(df[df['index_f_fin'] > 0.85]) } kfd_df = pd.DataFrame({ 'Уровень': list(kfd_counts.keys()), 'Количество': list(kfd_counts.values()), }) fig_kfd = px.bar( kfd_df, x='Уровень', y='Количество', color='Уровень', color_discrete_map=COLOR_MAP, text='Количество', height=380 ) fig_kfd.update_traces( textposition='outside', textfont_size=13, textfont_family="Arial", marker_line_width=0 ) fig_kfd.update_layout( margin=dict(t=60, b=40, l=30, r=30), xaxis_title="", yaxis_title="", xaxis=dict(tickangle=0, tickfont_size=11), yaxis=dict(tickfont_size=11, range=[0, kfd_df['Количество'].max() * 1.25]), showlegend=False, plot_bgcolor='white', bargap=0.18, font=dict(size=13) ) st.plotly_chart(fig_kfd, use_container_width=True, config={'displayModeBar': False}) with kpi2: avg_kfd = df['index_f_fin'].mean() st.metric("📊 Средний КФД", f"{avg_kfd:.2f}") with kpi3: no_4g = len(df[df['n_4g_flg'] == 0]) st.metric("📶 Без 4G", f"{no_4g}", delta=f"{no_4g/len(df)*100:.1f}% охвата") with kpi4: st.metric("🎯 Приоритетные", f"{potential}", help=f"КФД < {priority_kfd_threshold:.2f} и население > {priority_pop_threshold:,}") st.markdown("---") # ============================================================================= # ФИЛЬТРЫ # ============================================================================= if "filter_population_value" not in st.session_state: st.session_state.filter_population_value = 0 if "filter_pop_slider" not in st.session_state: st.session_state.filter_pop_slider = 0 if "filter_pop_input" not in st.session_state: st.session_state.filter_pop_input = 0 def slider_changed(): value = min(st.session_state.filter_pop_slider, 3000) st.session_state.filter_population_value = value st.session_state.filter_pop_input = value def input_changed(): value = min(st.session_state.filter_pop_input, 3000) st.session_state.filter_population_value = value st.session_state.filter_pop_slider = value f1, f2, f3, f4 = st.columns([1.5, 1, 1, 1]) with f1: sel_reg = st.multiselect( "📍 Районы:", sorted(df['mun_lev_1'].dropna().unique()), placeholder="Все районы", key="filter_regions" ) with f2: st.slider( "👥 Население от (чел.):", min_value=0, max_value=3000, step=50, key="filter_pop_slider", on_change=slider_changed ) with f3: st.number_input( "🔢 Точный ввод:", min_value=0, max_value=3000, step=10, key="filter_pop_input", on_change=input_changed ) f_df = df[df['population'] >= st.session_state.filter_population_value].copy() if sel_reg: f_df = f_df[f_df['mun_lev_1'].isin(sel_reg)] with f4: buffer = io.BytesIO() with pd.ExcelWriter(buffer, engine='xlsxwriter') as writer: f_df.to_excel(writer, index=False) st.download_button( "📥 Экспорт Excel", buffer.getvalue(), "filtered_report.xlsx", use_container_width=True, key="btn_export" ) # ============================================================================= # АНАЛИТИКА # ============================================================================= st.markdown("---") st.header("📈 Аналитика охвата и инфраструктуры") col_a1, col_a2 = st.columns(2) with col_a1: st.subheader("👥 Доля населения по уровням КФД") pop_dist = f_df.copy() pop_dist['Уровень'] = pop_dist['index_f_fin'].apply(lambda x: get_kfd_status(x)[0]) pop_sum = pop_dist.groupby('Уровень')['population'].sum().reset_index() level_order = ["Низкий", "Ниже среднего", "Средний", "Выше среднего", "Высокий"] pop_sum = pop_sum[pop_sum['Уровень'].isin(level_order)] if not pop_sum.empty: fig_pie = px.pie(pop_sum, values='population', names='Уровень', hole=0.4, color='Уровень', color_discrete_map=COLOR_MAP, category_orders={"Уровень": level_order}) fig_pie.update_traces(textinfo='percent+label', textposition='inside', textfont_size=13) fig_pie.update_layout(showlegend=True, legend_font_size=13) st.plotly_chart(fig_pie, use_container_width=True) else: st.warning("Нет данных для отображения диаграммы") with col_a2: st.subheader("📶 Влияние 4G на доступность") if 'n_4g_flg' in f_df.columns and f_df['n_4g_flg'].notna().any(): conn_data = f_df.groupby('n_4g_flg')['index_f_fin'].agg(['mean', 'count']).reset_index() conn_data['Статус связи'] = conn_data['n_4g_flg'].map({1.0: "Связь 4G есть", 0.0: "Связь 4G отсутствует"}) conn_data.rename(columns={'mean': 'КФД', 'count': 'Количество НП'}, inplace=True) fig_conn = px.bar(conn_data, x='Статус связи', y='КФД', color='Статус связи', color_discrete_map={"Связь 4G есть": "#27ae60", "Связь 4G отсутствует": "#e74c3c"}, text_auto='.2f') fig_conn.update_traces(textfont_size=13, textposition='outside') fig_conn.update_layout(yaxis_title="Средний КФД", showlegend=False, font_size=13) st.plotly_chart(fig_conn, use_container_width=True) else: st.info("Данные о 4G-покрытии отсутствуют") # ============================================================================= # Структура населения с НЕ высоким КФД # ============================================================================= st.markdown("---") st.markdown("<h3 style='text-align: center;'>📊 Структура населения с НЕ высоким КФД (<0.86)</h3>", unsafe_allow_html=True) non_high_kfd = f_df[f_df['index_f_fin'] < 0.86].copy() if not non_high_kfd.empty: non_high_kfd['Уровень'] = non_high_kfd['index_f_fin'].apply(lambda x: get_kfd_status(x)[0]) non_high_sum = non_high_kfd.groupby('Уровень')['population'].sum().reset_index() level_order = ["Низкий", "Ниже среднего", "Средний", "Выше среднего"] non_high_sum = non_high_sum[non_high_sum['Уровень'].isin(level_order)] non_high_sum['sort_order'] = non_high_sum['Уровень'].map({level: idx for idx, level in enumerate(level_order)}) non_high_sum = non_high_sum.sort_values('sort_order').reset_index(drop=True) total_non_high_pop = non_high_sum['population'].sum() if not non_high_sum.empty: col_cov1, col_cov2 = st.columns([1, 1]) with col_cov1: fig_non_high = px.pie( non_high_sum, values='population', names='Уровень', hole=0.5, color='Уровень', color_discrete_map=COLOR_MAP, category_orders={"Уровень": level_order} ) fig_non_high.update_traces( textinfo='percent+label', textposition='inside', hovertemplate='<b>%{label}</b><br>Население: %{value:,.0f}<br>Доля: %{percent:.1%}<extra></extra>', textfont_size=12 ) fig_non_high.update_layout( height=400, showlegend=True, legend=dict(orientation="h", yanchor="bottom", y=-0.2, xanchor="center", x=0.5), margin=dict(t=50, b=100, l=50, r=50), font_size=13 ) st.plotly_chart(fig_non_high, use_container_width=True) with col_cov2: fig_bar = px.bar( non_high_sum, x='Уровень', y='population', color='Уровень', color_discrete_map=COLOR_MAP, text='population', category_orders={"Уровень": level_order} ) fig_bar.update_traces( texttemplate='%{y:,.0f}', textposition='outside', hovertemplate='<b>%{x}</b><br>Население: %{y:,.0f} чел.<extra></extra>', textfont_size=12 ) fig_bar.update_layout( yaxis_title="Количество населения", xaxis_title="Уровень КФД", showlegend=False, height=400, margin=dict(t=50, b=50, l=50, r=50), yaxis=dict(range=[0, non_high_sum['population'].max() * 1.15]), font_size=13 ) st.plotly_chart(fig_bar, use_container_width=True) st.markdown("---") st.caption(f"**Всего населения с НЕ высоким КФД:** {total_non_high_pop:,.0f} человек ({len(non_high_kfd)} НП)") col_det1, col_det2, col_det3, col_det4 = st.columns(4) for idx, level in enumerate(level_order): level_data = non_high_sum[non_high_sum['Уровень'] == level] if not level_data.empty: pop_count = level_data['population'].values[0] pct = (pop_count / total_non_high_pop * 100) if total_non_high_pop > 0 else 0 with [col_det1, col_det2, col_det3, col_det4][idx]: st.metric( level, f"{pop_count:,.0f}", delta=f"{pct:.1f}% от группы" ) else: st.success("✅ Все населённые пункты имеют высокий уровень КФД!") else: st.success("✅ Все населённые пункты имеют высокий уровень КФД!") # ============================================================================= # ПРИОРИТЕТНЫЕ НАСЕЛЁННЫЕ ПУНКТЫ # ============================================================================= st.markdown("---") st.header("🎯 Приоритетные населённые пункты НСО") if potential > 0: st.markdown(f""" <div style='background-color: #fff3cd; padding: 15px; border-radius: 8px; margin-bottom: 20px; border-left: 5px solid #ffc107;'> <b>📋 Критерии отбора:</b> КФД < {priority_kfd_threshold:.2f} И население > {priority_pop_threshold:,} чел.<br> <b>📊 Найдено:</b> {potential} населённых пунктов </div> """, unsafe_allow_html=True) priority_display = priority_df[['locality_nm', 'mun_lev_1', 'population', 'index_f_fin', 'total_bkko', 'total_terms', 'n_4g_flg', 'has_post']].copy() priority_display.columns = ['Населённый пункт', 'Район', 'Население', 'КФД', 'Банкоматы/Кассы', 'Терминалы', '4G', 'Почта'] priority_display = priority_display.sort_values(['КФД', 'Население'], ascending=[True, False]) def highlight_kfd_priority(val): if pd.isna(val): return '' _, color = get_kfd_status(val) return f'background-color: {color}; color: white; font-weight: bold' def status_icon(val): if pd.isna(val): return '—' return '✅' if val > 0 else '❌' formatted_priority = priority_display.copy() formatted_priority['4G'] = formatted_priority['4G'].apply(status_icon) formatted_priority['Почта'] = formatted_priority['Почта'].apply(status_icon) st.dataframe( formatted_priority.style .map(highlight_kfd_priority, subset=['КФД']) .format({ 'КФД': '{:.2f}', 'Население': '{:,.0f}', 'Банкоматы/Кассы': '{:.0f}', 'Терминалы': '{:.0f}' }), use_container_width=True, height=500, hide_index=True ) buffer_priority = io.BytesIO() with pd.ExcelWriter(buffer_priority, engine='xlsxwriter') as writer: priority_df.to_excel(writer, index=False) st.download_button( "📥 Экспорт приоритетных НП (Excel)", buffer_priority.getvalue(), "priority_localities.xlsx", key="btn_export_priority" ) else: st.success(f"✅ При заданных критериях приоритетных населённых пунктов не найдено!") # ============================================================================= # ТАБЛИЦЫ: БЫСТРЫЕ ПОБЕДЫ + ПОЛНЫЙ РЕЕСТР # ============================================================================= st.markdown("---") tab1, tab2 = st.tabs(["🚀 Быстрые победы", "📋 Полный реестр"]) with tab1: st.subheader("НП с наибольшим потенциалом роста КФД") st.caption("Показаны пункты, где минимальные вложения дадут переход на следующий уровень КФД") wins = [] for _, r in f_df[f_df['index_f_fin'] < 0.86].iterrows(): curr_status, _ = get_kfd_status(r['index_f_fin']) for step in [1, 2, 3, 5, 10]: sim = min(r['index_f_fin'] + step/100, 1.0) new_status, _ = get_kfd_status(sim) if new_status != curr_status: priority = "Высокий" if r['population'] > 500 else "Средний" priority_order = 1 if priority == "Высокий" else 2 wins.append({ "Населённый пункт": r['locality_nm'], "Район": r.get('mun_lev_1', '—'), "Население": r['population'], "Текущий КФД": f"{r['index_f_fin']:.2f} - {curr_status}", "Прогноз КФД": f"{sim:.2f} - {new_status}", "Необходимый рост": f"+{step}%", "Приоритет": priority, "_priority_order": priority_order }) break if wins: wins_df = pd.DataFrame(wins) wins_df = wins_df.sort_values( ["_priority_order", "Население"], ascending=[True, False] ).drop(columns=['_priority_order']) st.dataframe( wins_df.style.applymap(lambda x: "background-color: #fff3cd" if "Высокий" in str(x) else "", subset=["Приоритет"]) .format({"Население": "{:,.0f}"}), use_container_width=True, hide_index=True ) else: st.success("✅ В выборке нет НП с потенциалом быстрого улучшения КФД") with tab2: st.subheader("📋 Полный реестр населённых пунктов") disp_cols = ['mun_lev_1', 'locality_nm', 'population', 'index_f_fin', 'total_bkko', 'total_terms', 'n_4g_flg', 'has_post'] disp_cols = [c for c in disp_cols if c in f_df.columns] display_df = f_df[disp_cols].copy() display_df.columns = ['Район', 'Населённый пункт', 'Население', 'КФД', 'Банкоматы/Кассы', 'Терминалы', '4G', 'Почта'] def highlight_kfd(val): if pd.isna(val): return '' _, color = get_kfd_status(val) return f'background-color: {color}; color: white; font-weight: bold' def status_icon(val): if pd.isna(val): return '—' return '✅' if val > 0 else '❌' formatted_df = display_df.copy() formatted_df['4G'] = formatted_df['4G'].apply(status_icon) formatted_df['Почта'] = formatted_df['Почта'].apply(status_icon) st.dataframe( formatted_df.style .map(highlight_kfd, subset=['КФД']) .format({ 'КФД': '{:.2f}', 'Население': '{:,.0f}', 'Банкоматы/Кассы': '{:.0f}', 'Терминалы': '{:.0f}' }), use_container_width=True, height=500 ) # ============================================================================= # ФУТЕР # ============================================================================= st.markdown("---") st.caption(""" 🏦 Информационный портал КФД НСО | Данные актуальны на 01.07.2025 | Разработано для органов государственной власти Новосибирской области """)