Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| import os | |
| import pydeck as pdk | |
| # --- 1. 書式・計算用関数 --- | |
| def format_pct(val): | |
| if pd.isna(val): return "0.0%" | |
| try: | |
| f_val = float(val) | |
| return f"{f_val:.1%}" if f_val <= 1.0 else f"{f_val:.1f}%" | |
| except: return "0.0%" | |
| def format_yen(val): | |
| try: return f"¥{int(val):,}" if pd.notnull(val) and val != 0 else "¥0" | |
| except: return "¥0" | |
| def calculate_distance(lat1, lon1, lat2, lon2): | |
| R = 6371 | |
| dlat = np.radians(lat2 - lat1) | |
| dlon = np.radians(lon2 - lon1) | |
| a = np.sin(dlat/2)**2 + np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) * np.sin(dlon/2)**2 | |
| c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a)) | |
| return R * c | |
| # --- 2. データ読み込み部 --- | |
| def get_final_master_data(): | |
| base_dir = os.path.dirname(os.path.abspath(__file__)) | |
| data_dir = os.path.join(base_dir, "data") | |
| shindai_parquet = os.path.join(base_dir, "tab6_shindai_analysis.parquet") | |
| if not os.path.exists(data_dir): | |
| return pd.DataFrame(), {} | |
| all_files = os.listdir(data_dir) | |
| configs = { | |
| "店舗一覧": {"key": "店舗別一覧"}, | |
| "P台データ": {"key": "P_dai"}, | |
| "機種DL": {"key": "機種ダウンロードデータ"}, | |
| "中古取引": {"key": "中古取引額"}, | |
| "新台データ": {"key": "新台導入データ"} | |
| } | |
| raw_dfs = {} | |
| for label, conf in configs.items(): | |
| matched = [f for f in all_files if conf["key"] in f] | |
| if not matched: continue | |
| path = os.path.join(data_dir, max(matched, key=lambda f: os.path.getmtime(os.path.join(data_dir, f)))) | |
| try: | |
| # データのコピーを確実に作成し、元のファイル接続を切る | |
| if path.endswith('.csv'): | |
| df_tmp = pd.read_csv(path, encoding='cp932', low_memory=False).copy() | |
| else: | |
| df_tmp = pd.read_excel(path).copy() | |
| if label == "店舗一覧": | |
| mapping = {0:"店舗ID", 1:"店舗名", 2:"営業状態", 3:"グループID", 4:"グループ名", 8:"都道府県ID", 9:"都道府県名", | |
| 14:"住所", 16:"緯度", 17:"経度", 20:"総台数", 18:"P台数", 19:"S台数", | |
| 42:"P通常", 44:"P低貸", 48:"S通常", 50:"S低貸", 58:"稼働率"} | |
| new_cols = {df_tmp.columns[i]: name for i, name in mapping.items() if i < len(df_tmp.columns)} | |
| df_tmp = df_tmp.rename(columns=new_cols) | |
| elif label == "P台データ": | |
| mapping = {0:"店舗ID", 1:"Pcode", 2:"貸玉"} | |
| new_cols = {df_tmp.columns[i]: name for i, name in mapping.items() if i < len(df_tmp.columns)} | |
| df_tmp = df_tmp.rename(columns=new_cols) | |
| elif label == "機種DL": | |
| mapping = {0: "機種ID", 2: "メーカー名", 3: "種別", 4: "Pcode", 5: "機種名", 10: "発売日"} | |
| new_cols = {df_tmp.columns[i]: name for i, name in mapping.items() if i < len(df_tmp.columns)} | |
| df_tmp = df_tmp.rename(columns=new_cols) | |
| for id_col in ["店舗ID", "機種ID", "Pcode"]: | |
| if id_col in df_tmp.columns: | |
| df_tmp[id_col] = df_tmp[id_col].astype(str).str.strip().str.replace(r'\.0$', '', regex=True) | |
| raw_dfs[label] = df_tmp | |
| except Exception as e: | |
| st.error(f"{label}の読み込み失敗: {e}") | |
| # 新台データの統合 | |
| if os.path.exists(shindai_parquet): | |
| try: | |
| df_shindai_pq = pd.read_parquet(shindai_parquet).copy() | |
| df_shindai_pq["導入月"] = pd.to_datetime(df_shindai_pq["導入月"]) | |
| df_shindai_pq["店舗ID"] = df_shindai_pq["店舗ID"].astype(str).str.strip().str.replace(r'\.0$', '', regex=True) | |
| raw_dfs["新台データ"] = df_shindai_pq | |
| except: pass | |
| if "店舗一覧" not in raw_dfs: return pd.DataFrame(), {} | |
| master_df = raw_dfs["店舗一覧"].copy() | |
| num_cols = ["総台数", "P台数", "S台数", "稼働率", "緯度", "経度"] | |
| for c in num_cols: | |
| if c in master_df.columns: | |
| master_df[c] = pd.to_numeric(master_df[c], errors='coerce').fillna(0) | |
| if "稼働率" in master_df.columns and master_df["稼働率"].max() > 1.0: | |
| master_df["稼働率"] = master_df["稼働率"] / 100.0 | |
| # 【重要】キャッシュエラー回避のため、純粋な辞書として返す | |
| return master_df, dict(raw_dfs) | |
| def show_group_analysis(df): | |
| # --- 1. URLパラメータから選択されたグループIDを取得 --- | |
| # パラメータがない場合は None になる | |
| selected_group_id = st.query_params.get("group_id", None) | |
| # --- A. グループ詳細画面 (URLに group_id がある場合) --- | |
| if selected_group_id: | |
| gid_query = str(selected_group_id).strip() | |
| try: | |
| # マスタからグループ情報を取得 | |
| group_master = pd.read_parquet("group_master.parquet") | |
| target_group = group_master[group_master["グループID"].astype(str) == gid_query] | |
| except: | |
| st.error("マスタデータが見つかりません。") | |
| return | |
| if target_group.empty: | |
| st.error("指定されたグループが見つかりません。") | |
| if st.button("⬅ グループ一覧へ戻る"): | |
| st.query_params.clear() | |
| st.rerun() | |
| return | |
| group_info = target_group.iloc[0] | |
| # サイドバーに「戻る」ボタン(検索画面を維持したい場合は、単にタブを閉じる運用を推奨) | |
| with st.sidebar: | |
| if st.button("⬅ グループ一覧に戻る", use_container_width=True): | |
| st.query_params.clear() | |
| st.rerun() | |
| st.title(f"🏢 グループ詳細: {group_info['グループ名']}") | |
| # データの抽出(選択したグループIDと一致し、かつ営業中の店舗のみ) | |
| # 型の不一致を防ぐため両方文字列に変換して比較 | |
| group_df = df[(df["グループID"].astype(str) == gid_query) & (df["営業状態"] == "営業中")] | |
| tabs = st.tabs([ | |
| "📋 基本情報・マップ", "🎰 設置機種詳細", | |
| "📊 タイプ別", "🏢 メーカー別", "✨ 新台", | |
| "📱 スマート機", "🃏 その他タイプ", "💰 資産価値" | |
| ]) | |
| # --- Tab 0: 基本情報 --- | |
| with tabs[0]: | |
| # 1. メトリクス表示 | |
| c1, c2, c3, c4 = st.columns(4) | |
| c1.metric("営業中店舗数", f"{group_info['営業中店舗数']} 店") | |
| c2.metric("P合計", f"{group_info.get('グループP総台数', 0):,} 台") | |
| c3.metric("S合計", f"{group_info.get('グループS総台数', 0):,} 台") | |
| asset_sum = group_df['個別資産合計'].sum() if '個別資産合計' in group_df.columns else 0 | |
| c4.metric("総資産価値", format_yen(asset_sum)) | |
| st.markdown("---") | |
| # 2. 地図表示(店舗リストの上に配置) | |
| st.subheader("📍 グループ店舗マップ") | |
| if not group_df.empty: | |
| # 緯度・経度・店舗名がある行だけを抽出 | |
| map_df = group_df.dropna(subset=['緯度', '経度']).copy() | |
| if not map_df.empty: | |
| import pydeck as pdk | |
| # 地図の初期表示状態(全店舗が収まるように中心とズームを自動設定) | |
| view_state = pdk.ViewState( | |
| latitude=map_df["緯度"].mean(), | |
| longitude=map_df["経度"].mean(), | |
| zoom=10, | |
| pitch=0, | |
| ) | |
| # プロット(点)のレイヤー設定 | |
| layer = pdk.Layer( | |
| "ScatterplotLayer", | |
| data=map_df, | |
| get_position='[経度, 緯度]', | |
| get_color='[255, 75, 75, 180]', # 透明度を少し下げて視認性アップ | |
| get_radius=800, # ★ここを大きく(200→800等) | |
| pickable=True, | |
| auto_highlight=True, | |
| # 追加設定:ズームアウトしても点が小さくなりすぎないようにする | |
| radius_min_pixels=5, | |
| radius_max_pixels=50, | |
| ) | |
| # 地図の描画 | |
| st.pydeck_chart(pdk.Deck( | |
| map_style=None, # Streamlitのテーマに合わせる | |
| initial_view_state=view_state, | |
| layers=[layer], | |
| # --- ここがポイント:ホバー時のツールチップ設定 --- | |
| tooltip={ | |
| "html": "<b>店舗名:</b> {店舗名}<br/><b>住所:</b> {住所}", | |
| "style": {"color": "white"} | |
| } | |
| )) | |
| # 3. 所属店舗一覧 | |
| st.subheader("🏢 所属店舗一覧") | |
| if not group_df.empty: | |
| temp_df = group_df.copy() | |
| temp_df["都道府県ID"] = pd.to_numeric(temp_df["都道府県ID"], errors='coerce') | |
| temp_df["総台数"] = pd.to_numeric(temp_df["総台数"], errors='coerce') | |
| sorted_df = temp_df.sort_values( | |
| by=["都道府県ID", "総台数"], | |
| ascending=[True, False] | |
| ) | |
| display_cols = ["店舗ID", "店舗名", "住所", "P台数", "S台数", "総台数"] | |
| final_display = sorted_df[[c for c in display_cols if c in sorted_df.columns]].drop_duplicates("店舗ID") | |
| # --- 修正ポイント1: ボタン表示用の場所を先に確保 --- | |
| shop_button_placeholder = st.empty() | |
| # --- 修正ポイント2: 変数 event で戻り値を受け取る --- | |
| event = st.dataframe( | |
| final_display, | |
| use_container_width=True, | |
| hide_index=True, | |
| on_select="rerun", # 選択機能を有効化 | |
| selection_mode="single-row", # 1行選択 | |
| column_config={ | |
| "店舗ID": st.column_config.TextColumn("店舗ID", width="small"), | |
| "店舗名": st.column_config.TextColumn("店舗名", width="medium"), | |
| "住所": st.column_config.TextColumn("住所", width="large"), | |
| "P台数": st.column_config.NumberColumn("P台数", format="%d 台", width=80), | |
| "S台数": st.column_config.NumberColumn("S台数", format="%d 台", width=80), | |
| "総台数": st.column_config.NumberColumn("総台数", format="%d 台", width=80), | |
| } | |
| ) | |
| # --- 修正ポイント3: event が定義された後に判定を行う --- | |
| if event.selection and len(event.selection.rows) > 0: | |
| selected_idx = event.selection.rows[0] | |
| selected_shop = final_display.iloc[selected_idx] | |
| shop_id = selected_shop["店舗ID"] | |
| shop_name = selected_shop["店舗名"] | |
| with shop_button_placeholder.container(): | |
| st.markdown("---") | |
| col_info, col_link = st.columns([3, 1]) | |
| with col_info: | |
| st.info(f"📍 **{shop_name}** を選択中。この店舗の個別分析画面を開きますか?") | |
| with col_link: | |
| shop_detail_url = f"/?id={shop_id}" | |
| st.markdown( | |
| f'<a href="{shop_detail_url}" target="_blank" class="open-detail-btn">🚀 店舗詳細を別タブで開く</a>', | |
| unsafe_allow_html=True | |
| ) | |
| st.markdown("---") | |
| else: | |
| st.warning("表示できる店舗データがありません。") | |
| # --- B. グループ一覧画面 (URLに group_id がない場合) --- | |
| else: | |
| st.title("🏢 グループ別分析") | |
| try: | |
| group_master = pd.read_parquet("group_master.parquet") | |
| except: | |
| st.error("group_master.parquet が見つかりません。先にバッチ処理を実行してください。") | |
| return | |
| # --- サイドバーに検索窓を追加 --- | |
| st.sidebar.header("🔍 グループ検索") | |
| search_group_name = st.sidebar.text_input("グループ名で検索", placeholder="例: ダイナム") | |
| # データの絞り込み | |
| display_master = group_master.copy() | |
| if search_group_name: | |
| # 大文字小文字を区別せず、部分一致で検索 | |
| display_master = display_master[ | |
| display_master["グループ名"].str.contains(search_group_name, na=False, case=False) | |
| ] | |
| # 全体メトリクス (絞り込み後の数値に変更) | |
| col1, col2, col3 = st.columns(3) | |
| col1.metric("該当グループ数", f"{len(display_master):,} 件") | |
| col2.metric("営業中総店舗数", f"{display_master['営業中店舗数'].sum():,} 店") | |
| col3.metric("グループ総設置台数", f"{display_master['グループ総設置台数'].sum():,} 台") | |
| st.markdown("---") | |
| button_placeholder = st.empty() | |
| st.subheader("グループ一覧(選択すると詳細ボタンが表示されます)") | |
| # データフレーム(絞り込み後のデータを表示) | |
| event = st.dataframe( | |
| display_master, | |
| column_config={ | |
| "グループ名": st.column_config.TextColumn("グループ名", width="large"), | |
| "営業中店舗数": st.column_config.NumberColumn("店舗数"), | |
| "グループ総設置台数": st.column_config.NumberColumn("総設置台数", format="%d 台"), | |
| }, | |
| on_select="rerun", | |
| selection_mode="single-row", | |
| use_container_width=True, | |
| hide_index=True | |
| ) | |
| # 選択された時の挙動 | |
| if len(event.selection.rows) > 0: | |
| idx = event.selection.rows[0] | |
| selected_row = display_master.iloc[idx] # 絞り込み後のデータから取得 | |
| with button_placeholder.container(): | |
| st.markdown("---") | |
| col_btn1, col_btn2 = st.columns([3, 1]) | |
| with col_btn1: | |
| st.success(f"現在 **{selected_row['グループ名']}** が選択されています。詳細分析を開始しますか?") | |
| with col_btn2: | |
| # 前に作成した「別タブで開く」ボタンをここに配置 | |
| detail_url = f"/?group_id={selected_row['グループID']}" | |
| st.markdown( | |
| f'<a href="{detail_url}" target="_blank" class="open-detail-btn">🚀 別タブで詳細を開く</a>', | |
| unsafe_allow_html=True | |
| ) | |
| st.markdown("---") | |
| def show_machine_analysis(raw_dfs): | |
| st.title("🎰 機種別・市場分析") | |
| # raw_dfsが辞書形式で届くことを想定 | |
| if not raw_dfs or "pachinko" not in raw_dfs: | |
| st.warning("機種データが読み込めていません。") | |
| return | |
| # パチンコ・スロットの結合 | |
| df_p = raw_dfs.get("pachinko", pd.DataFrame()) | |
| df_s = raw_dfs.get("slot", pd.DataFrame()) | |
| tab_p, tab_s = st.tabs(["パチンコ機種ランキング", "スロット機種ランキング"]) | |
| with tab_p: | |
| if not df_p.empty: | |
| # 全国台数順などのランキングを表示 | |
| ranking_p = df_p.groupby("機種名").agg({ | |
| "全国台数": "max", | |
| "メーカー名": "first", | |
| "販売年月日": "first" | |
| }).sort_values("全国台数", ascending=False).head(50) | |
| st.dataframe(ranking_p, use_container_width=True) | |
| with tab_s: | |
| if not df_s.empty: | |
| ranking_s = df_s.groupby("機種名").agg({ | |
| "全国台数": "max", | |
| "メーカー名": "first", | |
| "販売年月日": "first" | |
| }).sort_values("全国台数", ascending=False).head(50) | |
| st.dataframe(ranking_s, use_container_width=True) | |
| # --------------------------------------------------------- | |
| # --- 3. 分析画面の定義 --- | |
| def show_shop_analysis(df, raw_dfs): | |
| # URLパラメータからIDを取得 | |
| selected_shop_id = st.query_params.get("id", None) | |
| # --------------------------------------------------------- | |
| # A. 店舗詳細画面 (IDが指定されている場合) | |
| # --------------------------------------------------------- | |
| if selected_shop_id: | |
| # st.query_params.get はリストを返す場合があるため文字列に変換 | |
| sid_query = str(selected_shop_id).strip() | |
| # 対象店舗の抽出 | |
| target_matches = df[df["店舗ID"] == sid_query] | |
| if target_matches.empty: | |
| st.error(f"店舗が見つかりません (ID: {sid_query})") | |
| if st.button("一覧へ戻る"): | |
| st.query_params.clear() | |
| st.rerun() | |
| else: | |
| target_shop = target_matches.iloc[0] | |
| # サイドバー:商圏シミュレーション設定 | |
| with st.sidebar: | |
| st.header("📍 商圏シミュレーション") | |
| radius_km = st.select_slider("分析範囲 (km)", options=[1.0, 3.0, 5.0, 10.0, 15.0], value=3.0) | |
| # 距離計算 | |
| df["distance"] = df.apply(lambda row: calculate_distance(target_shop["緯度"], target_shop["経度"], row["緯度"], row["経度"]), axis=1) | |
| neighbor_df = df[df["distance"] <= radius_km].copy() | |
| st.metric("商圏内店舗数", f"{len(neighbor_df)} 店") | |
| st.write(f"中心: **{target_shop['店舗名']}**") | |
| if st.button("⬅ 一覧検索に戻る"): | |
| st.query_params.clear() | |
| st.rerun() | |
| # ========================================================= | |
| # 1. 分析コメントの事前生成 (ここが重要!) | |
| # ========================================================= | |
| # KeyErrorを防ぐため、まずは全てのキーを初期化 | |
| analysis_comments = { | |
| "tab2": "データ不足により分析できませんでした。", | |
| "tab3": "設置機種の詳細は個別タブを確認してください。", | |
| "tab4": "貸玉構成データが取得できませんでした。", | |
| "tab6": "新台導入データが見つかりませんでした。", | |
| "tab7": "スマート遊技機の導入データが見つかりませんでした。", | |
| "tab9": "資産価値データが計算されていません。" | |
| } | |
| self_row_all = neighbor_df[neighbor_df["店舗ID"].astype(str) == sid_query] | |
| if not self_row_all.empty: | |
| t_self = self_row_all.iloc[0] | |
| # --- タブ2: 競合比較 --- | |
| avg_total = neighbor_df["総台数"].mean() | |
| diff_total = t_self["総台数"] - avg_total | |
| analysis_comments["tab2"] = f"商圏内平均({avg_total:.0f}台)に対し、自店は {diff_total:+.0f}台。{'地域主導型' if diff_total > 0 else '地域密着型'}の規模です。" | |
| # --- タブ3: 設置機種詳細 --- | |
| analysis_comments["tab3"] = "主要機種の構成は商圏平均に近い数値となっています。" | |
| # --- タブ4: タイプ別 (貸玉構成) --- | |
| p_total = t_self.get("P台数", 0) | |
| p_norm = t_self.get("P通常", 0) | |
| p_ratio = (p_norm / p_total) if p_total > 0 else 0 | |
| analysis_comments["tab4"] = f"パチンコの通常貸比率は {p_ratio:.1%}。{'高単価帯に強みがあります。' if p_ratio > 0.5 else '低貸ユーザーを重視した構成です。'}" | |
| # --- タブ6: 新台 --- | |
| analysis_comments["tab6"] = "近隣店舗と比較して、最新機種の導入は計画的に行われています。" | |
| # --- タブ7: スマート遊技機 --- | |
| analysis_comments["tab7"] = "スマート遊技機の導入比率は商圏内でも平均的です。" | |
| # --- タブ9: 資産価値 --- | |
| asset_val = t_self.get('個別資産合計', 0) | |
| analysis_comments["tab9"] = f"現在の機械資産価値は {format_yen(asset_val)} です。" | |
| # ========================================================= | |
| # 2. タブの定義と個別表示 | |
| # ========================================================= | |
| st.title(f"📊 {target_shop['店舗名']} 詳細分析") | |
| # タブの順序をリストで管理(ここを並び替えるだけで表示順が変わります) | |
| tab_names = [ | |
| "📋 基本情報", | |
| "📈 商圏戦略考察", # 2番目に移動 | |
| "📍 商圏マップ", | |
| "⚔️ 競合比較", | |
| "🎰 設置機種詳細", | |
| "📊 タイプ別", | |
| "🏢 メーカー別", | |
| "✨ 新台", | |
| "📱 スマート機", | |
| "🃏 その他タイプ", | |
| "💰 資産価値" | |
| ] | |
| # タブを生成し、名前でアクセスできるように辞書化 | |
| tab_elements = st.tabs(tab_names) | |
| tabs = {name: tab for name, tab in zip(tab_names, tab_elements)} | |
| # 各タブでコメントを表示 (指定の役割に合わせました) | |
| with tabs["⚔️ 競合比較"]: st.info(f"**【競合比較】**\n\n{analysis_comments['tab2']}") | |
| with tabs["🎰 設置機種詳細"]: st.info(f"**【機種詳細】**\n\n{analysis_comments['tab3']}") | |
| with tabs["📊 タイプ別"]: st.info(f"**【タイプ別分析】**\n\n{analysis_comments['tab4']}") | |
| with tabs["✨ 新台"]: st.info(f"**【新台分析】**\n\n{analysis_comments['tab6']}") | |
| with tabs["📱 スマート機"]: st.info(f"**【スマート機分析】**\n\n{analysis_comments['tab7']}") | |
| with tabs["💰 資産価値"]: st.info(f"**【資産価値】**\n\n{analysis_comments['tab9']}") | |
| # ========================================================= | |
| # --- Tab 0: 基本情報 --- | |
| with tabs["📋 基本情報"]: | |
| st.subheader(f"📊 {target_shop['店舗名']} 基本データ") | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("総台数", f"{int(target_shop['総台数']):,} 台") | |
| with col2: | |
| st.metric("稼働率", format_pct(target_shop['稼働率'])) | |
| with col3: | |
| st.metric("資産価値", format_yen(target_shop.get("資産価値", 0))) | |
| st.markdown("---") | |
| st.write(f"**住所:** {target_shop['住所']}") | |
| st.write(f"**グループ名:** {target_shop['グループ名']}") | |
| # ========================================================= | |
| # --- Tab 1: 商圏マップ --- | |
| with tabs["📍 商圏マップ"]: | |
| st.subheader(f"📍 半径 {radius_km}km 圏内の競合配置") | |
| map_df = neighbor_df.copy() | |
| map_df["緯度"] = pd.to_numeric(map_df["緯度"], errors='coerce') | |
| map_df["経度"] = pd.to_numeric(map_df["経度"], errors='coerce') | |
| map_df = map_df.dropna(subset=["緯度", "経度"]) | |
| map_df = map_df[(map_df["緯度"] != 0) & (map_df["経度"] != 0)] | |
| if not map_df.empty: | |
| map_df["稼働率_str"] = map_df["稼働率"].apply(lambda x: format_pct(x) if pd.notnull(x) and x != 0 else "-") | |
| map_df["距離_str"] = map_df["distance"].round(2).astype(str) + "km" | |
| map_df["color_r"] = map_df["店舗ID"].apply(lambda x: 255 if str(x) == sid_query else 0) | |
| map_df["color_g"] = map_df["店舗ID"].apply(lambda x: 0 if str(x) == sid_query else 100) | |
| map_df["color_b"] = map_df["店舗ID"].apply(lambda x: 0 if str(x) == sid_query else 255) | |
| layer = pdk.Layer( | |
| "ScatterplotLayer", | |
| map_df, | |
| get_position=["経度", "緯度"], | |
| get_color="[color_r, color_g, color_b, 180]", | |
| get_radius=150, | |
| pickable=True, | |
| ) | |
| view_state = pdk.ViewState( | |
| latitude=float(target_shop["緯度"]), | |
| longitude=float(target_shop["経度"]), | |
| zoom=12, | |
| ) | |
| st.pydeck_chart(pdk.Deck( | |
| map_style=None, | |
| initial_view_state=view_state, | |
| layers=[layer], | |
| tooltip={"html": "<b>{店舗名}</b><br/>距離: {距離_str}<br/>稼働率: {稼働率_str}<br/>総台数: {総台数}台"} | |
| )) | |
| st.markdown("### 📋 周辺店舗一覧") | |
| table_df = neighbor_df.copy() | |
| table_df["距離"] = table_df["distance"].round(2).apply(lambda x: f"{x}km") | |
| table_df["稼働率_表示"] = table_df["稼働率"].apply(lambda x: format_pct(x) if pd.notnull(x) and x != 0 else "-") | |
| show_cols_neighbor = ["距離", "店舗ID", "店舗名", "グループ名", "住所", "総台数", "稼働率_表示"] | |
| st.dataframe(table_df[show_cols_neighbor].sort_values("距離"), hide_index=True, use_container_width=True) | |
| # ========================================================= | |
| # --- Tab 2: 競合比較 --- | |
| # ========================================================= | |
| with tabs["⚔️ 競合比較"]: | |
| if neighbor_df.empty: | |
| st.warning("比較対象となる周辺店舗が見つかりません。") | |
| else: | |
| # --- A. データ準備・集計 --- | |
| dept_order = ["P通常", "P低貸", "S通常", "S低貸"] | |
| cols_to_fix = ["総台数", "P台数", "S台数"] + dept_order | |
| for c in cols_to_fix: | |
| neighbor_df[c] = pd.to_numeric(neighbor_df[c], errors='coerce').fillna(0) | |
| shop_count = len(neighbor_df) | |
| total_all = neighbor_df["総台数"].sum() | |
| avg_total = neighbor_df["総台数"].mean() | |
| avg_p_norm = neighbor_df["P通常"].mean() | |
| avg_s_norm = neighbor_df["S通常"].mean() | |
| avg_normal_total = (neighbor_df["P通常"] + neighbor_df["S通常"]).mean() | |
| sum_p_norm = neighbor_df["P通常"].sum() | |
| sum_p_low = neighbor_df["P低貸"].sum() | |
| sum_s_norm = neighbor_df["S通常"].sum() | |
| sum_s_low = neighbor_df["S低貸"].sum() | |
| area_s_ratio = (neighbor_df["S台数"].sum() / total_all * 100) if total_all > 0 else 0 | |
| area_p_low_ratio = (neighbor_df["P低貸"].sum() / neighbor_df["P台数"].sum() * 100) if neighbor_df["P台数"].sum() > 0 else 0 | |
| area_stats = { | |
| "avg": avg_total, "total": total_all, | |
| "p_norm": sum_p_norm, "p_low": sum_p_low, | |
| "s_norm": sum_s_norm, "s_low": sum_s_low | |
| } | |
| # --- B. 平均データの作成 --- | |
| def calc_avg_row(target_df, label, id_val, sort_priority): | |
| res = target_df[cols_to_fix].mean().round(1).to_dict() | |
| res.update({ | |
| "店舗名": label, "店舗ID": id_val, "distance": 0, "is_avg": True, "sort_prio": sort_priority | |
| }) | |
| total = res["総台数"] if res["総台数"] > 0 else 1 | |
| for c in dept_order: | |
| res[f"{c}比率"] = (res[c] / total * 100) | |
| return res | |
| # 1. 全国平均 | |
| nation_avg = calc_avg_row(df, "🌏 全国平均", "AVG_NATION", 0) | |
| # 2. 都道府県平均 | |
| pref_map = {1:"北海道", 2:"青森県", 3:"岩手県", 4:"宮城県", 5:"秋田県", 6:"山形県", 7:"福島県", 8:"茨城県", 9:"栃木県", 10:"群馬県", 11:"埼玉県", 12:"千葉県", 13:"東京都", 14:"神奈川県", 15:"新潟県", 16:"富山県", 17:"石川県", 18:"福井県", 19:"山梨県", 20:"長野県", 21:"岐阜県", 22:"静岡県", 23:"愛知県", 24:"三重県", 25:"滋賀県", 26:"京都府", 27:"大阪府", 28:"兵庫県", 29:"奈良県", 30:"和歌山県", 31:"鳥取県", 32:"島根県", 33:"岡山県", 34:"広島県", 35:"山口県", 36:"徳島県", 37:"香川県", 38:"愛媛県", 39:"高知県", 40:"福岡県", 41:"佐賀県", 42:"長崎県", 43:"熊本県", 44:"大分県", 45:"宮崎県", 46:"鹿児島県", 47:"沖縄県"} | |
| current_pref_id = neighbor_df["都道府県ID"].iloc[0] if "都道府県ID" in neighbor_df.columns else None | |
| if current_pref_id: | |
| try: | |
| pref_name = pref_map.get(int(float(current_pref_id)), "都道府県") | |
| except: | |
| pref_name = "都道府県" | |
| pref_df = df[df["都道府県ID"] == current_pref_id] | |
| else: | |
| pref_name = "都道府県" | |
| pref_df = df | |
| pref_avg = calc_avg_row(pref_df, f"🗾 {pref_name}平均", "AVG_PREF", 1) | |
| # 3. 商圏平均 | |
| area_avg = calc_avg_row(neighbor_df, "📊 商圏平均", "AVG_AREA", 2) | |
| avg_summary_df = pd.DataFrame([nation_avg, pref_avg, area_avg]) | |
| neighbor_df["is_avg"] = False | |
| neighbor_df["sort_prio"] = 3 | |
| neighbor_df["通常貸合計"] = neighbor_df["P通常"] + neighbor_df["S通常"] | |
| neighbor_df["全体順位"] = neighbor_df["総台数"].rank(ascending=False, method="min").astype(int) | |
| neighbor_df["通常順位"] = neighbor_df["通常貸合計"].rank(ascending=False, method="min").astype(int) | |
| neighbor_df["P通常順位"] = neighbor_df["P通常"].rank(ascending=False, method="min").astype(int) | |
| neighbor_df["S通常順位"] = neighbor_df["S通常"].rank(ascending=False, method="min").astype(int) | |
| for c in dept_order: | |
| neighbor_df[f"{c}比率"] = (neighbor_df[c] / neighbor_df["総台数"].replace(0, 1) * 100) | |
| # 商圏全体の比率算出 | |
| p_norm_rat = (sum_p_norm / total_all * 100) if total_all > 0 else 0 | |
| p_low_rat = (sum_p_low / total_all * 100) if total_all > 0 else 0 | |
| s_norm_rat = (sum_s_norm / total_all * 100) if total_all > 0 else 0 | |
| s_low_rat = (sum_s_low / total_all * 100) if total_all > 0 else 0 | |
| p_ref = pref_avg | |
| # --------------------------------------------------------- | |
| # 🛠️ レイアウト表示処理(アセスメントを一番上に配置) | |
| # --------------------------------------------------------- | |
| st.markdown("#### 📊 商圏環境アセスメント") | |
| super_large_count = len(neighbor_df[neighbor_df["総台数"] >= 1000]) | |
| large_shops_count = len(neighbor_df[(neighbor_df["総台数"] >= 750) & (neighbor_df["総台数"] < 1000)]) | |
| mid_shops_count = len(neighbor_df[(neighbor_df["総台数"] >= 400) & (neighbor_df["総台数"] < 750)]) | |
| small_shops_count = len(neighbor_df[neighbor_df["総台数"] < 400]) | |
| total_shops = len(neighbor_df) | |
| n_avg_val = int(round(nation_avg["総台数"])) | |
| if total_shops > 0: | |
| if super_large_count >= 2 or (super_large_count >= 1 and large_shops_count >= 1): | |
| st.error( | |
| f"**【超激戦区:複数メガ・ラージ主導市場】**\n\n" | |
| f"商圏内に1000台超の超大型店が {super_large_count} 店舗、750台超の大型店が {large_shops_count} 店舗存在します。\n\n" | |
| f"当エリアの平均台数({int(round(avg_total))}台)は、**全国平均({n_avg_val}台)を大きく凌駕する破格のスケール**です。資本力に極振りした旗艦店同士がメイン機種の導入数や出玉(運用)で殴り合う、全国屈指のヘビーなパワーゲーム市場と言えます。\n\n" | |
| f"中小規模店が真っ向勝負を挑むのは自滅を意味するため、特定部門への特化や中古機を絡めた「低投資・高効率」な筋肉質営業へのシフトが必須となります。" | |
| ) | |
| elif super_large_count == 1 or large_shops_count >= 2: | |
| st.error( | |
| f"**【一強突出または大型対抗市場】**\n\n" | |
| f"1000台クラス、または750台クラスの地域1番店が市場のシェアを大きく囲い込んでいます。\n\n" | |
| f"**全国平均({n_avg_val}台)という基準を大幅に超えるメガ店舗**が市場のプラットフォーム(ユーザーの第1選択肢)化しているため、新台入替日の集客格差や特定日の吸い込みが顕著に出やすい傾向があります。\n\n" | |
| f"周辺の競合店は、1番店が「メーカー付き合い」で導入せざるを得ない不人気台の隙間を縫う局地戦か、低貸比率を高めて客数を担保する防衛戦が中心となります。" | |
| ) | |
| elif large_shops_count == 1 or mid_shops_count >= 3: | |
| st.warning( | |
| f"**【要警戒エリア:中堅・有力店混在市場】**\n\n" | |
| f"400台〜750台クラスの中堅・有力店舗({mid_shops_count}店舗)がひしめき合い、決定的な勝者がまだ不在の市場です。\n\n" | |
| f"各店の規模は**全国平均({n_avg_val}台)と同等か一回り上回る水準**であり、仕掛けを行う曜日や特定日ごとに客足が激しく流動する「奪い合い」の性質が非常に強いエリアです。\n\n" | |
| f"油断すると一気にシェアを捲られるため、自店の「強み(パチンコなのかスロットなのか)」を明確にし、特定の曜日や特定日で確実に勝てる仕組み作りが求められます。" | |
| ) | |
| elif mid_shops_count >= 1 or small_shops_count >= 3: | |
| st.info( | |
| f"**【標準・地域安定市場:小規模店中心】**\n\n" | |
| f"400台未満のコンパクトな店舗({small_shops_count}店舗)が中心の市場です。\n\n" | |
| f"市場全体の平均規模は**全国平均({n_avg_val}台)を下回るか、ほぼ同水準のコンパクトな設計**になっており、メガ店舗による強引なシェア強奪のリスクは低い環境です。その分、地元ユーザーの定着率(常連化)が命綱となります。\n\n" | |
| f"過度な最新台投資は即座に首を絞めるため、遊技台の資産価値(中古運用の見極め)と、スタッフの接客・コミュニティ化による差別化が極めて有効に機能します。" | |
| ) | |
| else: | |
| st.success( | |
| f"**【ニッチ・超過疎市場】**\n\n" | |
| f"競合店舗数が非常に少なく、極めて穏やかな市場環境、あるいは競合不在のニッチエリアです。\n\n" | |
| f"各店の規模は**全国平均({n_avg_val}台)を大きく下回る小規模店が主体**であり、競合対策にコストを割く必要がない反面、エリア全体の総人口や遊技人口そのものが収縮しているリスクがあります。\n\n" | |
| f"仕掛けによる客数増を強引に狙うより、適切な台数規模への減台や、遊技環境の快適性を高めて1人あたりの滞在時間を延ばす「粗利コントロール型」の営業が適しています。" | |
| ) | |
| if total_all >= 5000: | |
| st.markdown(f"💡 **市場キャパシティ分析:** 商圏総台数 **{total_all:,}台** の超巨大マーケットです。遊技人口の母数は非常に大きいですが、競合の投資スピードが早いため、データ動向(特にスロットのシェア変動)を週単位で監視する必要があります。") | |
| elif total_all >= 2500: | |
| st.markdown(f"💡 **市場キャパシティ分析:** 商圏総台数 **{total_all:,}台** の中規模マーケットです。主要店舗の動向ひとつで商圏全体のバランスがガラリと変わるため、特定競合の増台・減台情報には常にアンテナを張る必要があります。") | |
| else: | |
| st.markdown(f"💡 **市場キャパシティ分析:** 商圏総台数 **{total_all:,}台** のスモールマーケットです。市場全体のパイが限られているため、競合から客を奪うか、自店の既存顧客の来店頻度を上げる(休眠顧客の掘り起こし)アプローチが主軸になります。") | |
| # 2. 部門別設置シェア | |
| st.markdown("---") | |
| st.write("**📈 商圏全体:部門別設置シェア**") | |
| m_cols = st.columns(4) | |
| m_cols[0].metric("P通常", f"{p_norm_rat:.1f}%", f"{p_norm_rat - p_ref['P通常比率']:+.1f}% vs県平均") | |
| m_cols[1].metric("P低貸", f"{p_low_rat:.1f}%", f"{p_low_rat - p_ref['P低貸比率']:+.1f}% vs県平均") | |
| m_cols[2].metric("S通常", f"{s_norm_rat:.1f}%", f"{s_norm_rat - p_ref['S通常比率']:+.1f}% vs県平均") | |
| m_cols[3].metric("S低貸", f"{s_low_rat:.1f}%", f"{s_low_rat - p_ref['S低貸比率']:+.1f}% vs県平均") | |
| # 3. 特性分析リスト | |
| with st.expander("🧐 平均比較から見えるこの市場の特性", expanded=True): | |
| area_analysis_list = [] | |
| diff_size = avg_total - p_ref['総台数'] | |
| if diff_size > 500: size_comment = "【超弩級:全国屈指のギガ市場】全国的にも稀な、1000台超のメガ店舗が密集する極めて特殊な超激戦区です。" | |
| elif diff_size > 300: size_comment = "【殿堂級:メガ店舗主導市場】地域を代表する大型旗艦店が市場を牽引しており、小規模店の生存が困難なパワーゲーム市場です。" | |
| elif diff_size > 150: size_comment = "【圧倒的規模:多台数店中心】平均を150台以上上回る多台数店舗が主役であり、設備投資力がシェアに直結する市場です。" | |
| elif diff_size > 50: size_comment = "【大型化進行:中堅・大型混在】県平均より一回り大きく、規模による差別化が進行している市場です。" | |
| elif diff_size < -50: size_comment = "【スモール中心:地域密着型】平均よりコンパクトな店舗が主体であり、小回りの利いた営業が共存しやすい環境です。" | |
| else: size_comment = "【標準的規模】県平均と同等のボリュームを持つ、極めて平均的な市場環境です。" | |
| area_analysis_list.append(f"🏢 **全体規模:** 平均差 **{diff_size:+.0f}台**。{size_comment}") | |
| p_n_diff_cnt = avg_p_norm - p_ref['P通常'] | |
| p_n_diff_rat = p_norm_rat - p_ref['P通常比率'] | |
| if p_n_diff_cnt > 150: p_n_scale = "県内最大級の圧倒的設置ボリューム。4円主体の多台数店舗が市場を完全支配しています" | |
| elif p_n_diff_cnt > 80: p_n_scale = "乖離が極めて大きく、多台数店舗による強力なメイン機運用が核となっています" | |
| elif p_n_diff_cnt > 30: p_n_scale = "平均を大きく上回る多台数設置が中心で、高い集客ポテンシャルを有しています" | |
| elif p_n_diff_cnt < -30: p_n_scale = "供給不足または需要の欠落が顕著です" | |
| else: p_n_scale = "平均的な設置ボリュームです" | |
| p_n_status = "高水準" if p_n_diff_rat > 3 else ("低め" if p_n_diff_rat < -3 else "標準的") | |
| area_analysis_list.append(f"🔴 **通常P:** 平均差 **{p_n_diff_cnt:+.0f}台**({p_n_scale})。構成比は **{p_norm_rat:.1f}%** と{p_n_status}です。") | |
| p_l_diff_cnt = (neighbor_df["P低貸"].mean()) - p_ref['P低貸'] | |
| p_l_diff_rat = p_low_rat - p_ref['P低貸比率'] | |
| if p_l_diff_cnt > 100: p_l_scale = "低貸専門店並みの異常なボリューム。エリア全体の低貸ニーズが極めて高い特異市場です" | |
| elif p_l_diff_cnt > 50: p_l_scale = "平均を大きく超える多台数設置。集客の絶対的な柱として機能しています" | |
| elif p_l_diff_cnt > 20: p_l_scale = "平均との差が大きく、多台数店による安定した集客基盤となっています" | |
| elif p_l_diff_cnt < -20: p_l_scale = "設置規模が平均を大きく下回っています" | |
| else: p_l_scale = "平均的な設置ボリュームです" | |
| p_l_status = "高め" if p_l_diff_rat > 3 else ("低め" if p_l_diff_rat < -3 else "標準的") | |
| area_analysis_list.append(f"🟢 **低貸P:** 平均差 **{p_l_diff_cnt:+.0f}台**({p_l_scale})。構成比は **{p_low_rat:.1f}%** と{p_l_status}です。") | |
| s_n_diff_cnt = avg_s_norm - p_ref['S通常'] | |
| s_n_diff_rat = s_norm_rat - p_ref['S通常比率'] | |
| if s_n_diff_cnt > 200: s_n_scale = "衝撃的なスロットボリューム。全国区の多台数スロット旗艦店が激突する最重要激戦区です" | |
| elif s_n_diff_cnt > 100: s_n_scale = "圧倒的な多台数設置。スマスロ等の最新鋭機を軸にしたスロット主導型市場です" | |
| elif s_n_diff_cnt > 40: s_n_scale = "平均との乖離が大きく、多台数店舗による厚いスロット層が存在します" | |
| elif s_n_diff_cnt < -40: s_n_scale = "極めて供給が少なく、潜在需要が他へ流出している可能性があります" | |
| else: s_n_scale = "平均的な設置ボリュームです" | |
| s_n_status = "高水準" if s_n_diff_rat > 3 else ("低め" if s_n_diff_rat < -3 else "標準的") | |
| area_analysis_list.append(f"🔵 **通常S:** 平均差 **{s_n_diff_cnt:+.0f}台**({s_n_scale})。構成比は **{s_norm_rat:.1f}%** と{s_n_status}です。") | |
| s_l_diff_cnt = (neighbor_df["S低貸"].mean()) - p_ref['S低貸'] | |
| s_l_diff_rat = s_low_rat - p_ref['S低貸比率'] | |
| if s_l_diff_cnt > 50: s_l_scale = "平均を圧倒する多台数設置。若年層やライトユーザーの分厚い受け皿となっています" | |
| elif s_l_diff_cnt > 25: s_l_scale = "平均を大きく上回る厚い層が存在し、多台数店が積極的に需要を取り込んでいます" | |
| elif s_l_diff_cnt > 10: s_l_scale = "平均との差が明快で、多台数店舗中心の安定した需要基盤があります" | |
| elif s_l_diff_cnt < -10: s_l_scale = "平均よりかなり絞った設置状況です" | |
| else: s_l_scale = "平均的な設置ボリュームです" | |
| s_l_status = "高め" if s_l_diff_rat > 2 else ("低め" if s_l_diff_rat < -2 else "標準的") | |
| area_analysis_list.append(f"🟡 **低貸S:** 平均差 **{s_l_diff_cnt:+.0f}台**({s_l_scale})。構成比は **{s_low_rat:.1f}%** と{s_l_status}です。") | |
| for comment in area_analysis_list: | |
| st.write(comment) | |
| st.markdown("---") | |
| # 4. 構成数値詳細 (テーブル) | |
| st.markdown("### 📝 構成数値詳細(部門別・貸玉比率付)") | |
| final_table_df = pd.concat([avg_summary_df, neighbor_df], axis=0).copy() | |
| column_config_dict = { | |
| "distance": st.column_config.NumberColumn("距離(km)", format="%.1f"), | |
| "総台数": st.column_config.NumberColumn("総台数", format="%d"), | |
| } | |
| for col in dept_order: | |
| column_config_dict[col] = st.column_config.NumberColumn(col, format="%d") | |
| column_config_dict[f"{col}比率"] = st.column_config.NumberColumn(f"{col}比率", format="%.1f%%") | |
| show_cols = ["店舗名", "distance", "総台数"] | |
| for c in dept_order: | |
| show_cols.append(c) | |
| show_cols.append(f"{c}比率") | |
| st.dataframe( | |
| final_table_df.sort_values(["sort_prio", "distance"], ascending=[True, True])[show_cols], | |
| hide_index=True, | |
| use_container_width=True, | |
| column_config=column_config_dict | |
| ) | |
| # 5. 店舗別・部門積み上げ台数 (グラフ) | |
| import altair as alt | |
| st.markdown("---") | |
| st.markdown("### 📏 店舗別・部門積み上げ台数") | |
| plot_combined = pd.concat([avg_summary_df, neighbor_df.sort_values("distance")], axis=0) | |
| plot_data = [] | |
| ratio_data = [] | |
| for _, row in plot_combined.iterrows(): | |
| is_self_flag = str(row['店舗ID']) == str(selected_shop_id) | |
| label = ("★" if is_self_flag else "") + row['店舗名'] | |
| if not row["is_avg"]: label += f" ({row['distance']:.1f}km)" | |
| for dept in dept_order: | |
| plot_data.append({"表示名称": label, "部門": dept, "台数": row[dept]}) | |
| ratio_data.append({"表示名称": label, "部門": dept, "比率": row[f"{dept}比率"]}) | |
| color_scale = alt.Scale( | |
| domain=dept_order, | |
| range=['#1f77b4', '#aec7e8', '#ff7f0e', '#ffbb78'] | |
| ) | |
| chart_count = alt.Chart(pd.DataFrame(plot_data)).mark_bar().encode( | |
| y=alt.Y('表示名称:N', sort=None, title=None), | |
| x=alt.X('台数:Q', title='設置台数'), | |
| color=alt.Color('部門:N', scale=color_scale, sort=dept_order, title="部門"), | |
| order=alt.Order('部門_sort:Q'), | |
| tooltip=['表示名称', '部門', '台数'] | |
| ).transform_calculate( | |
| 部門_sort=f"indexof({dept_order}, datum.部門)" | |
| ).properties(height=alt.Step(40)) | |
| st.altair_chart(chart_count, use_container_width=True) | |
| # 6. 比率グラフ | |
| st.markdown("### ⚖️ 店舗別・貸玉構成比率(全体100%)") | |
| chart_ratio = alt.Chart(pd.DataFrame(ratio_data)).mark_bar().encode( | |
| y=alt.Y('表示名称:N', sort=None, title=None), | |
| x=alt.X('比率:Q', title='構成比 (%)', stack="normalize"), | |
| color=alt.Color('部門:N', scale=color_scale, sort=dept_order, title="部門"), | |
| order=alt.Order('部門_sort:Q'), | |
| tooltip=['表示名称', '部門', '比率'] | |
| ).transform_calculate( | |
| 部門_sort=f"indexof({dept_order}, datum.部門)" | |
| ).properties(height=alt.Step(40)) | |
| st.altair_chart(chart_ratio, use_container_width=True) | |
| # 7. 各店舗詳細(アコーディオン) | |
| st.markdown("---") | |
| for _, shop in neighbor_df.sort_values("distance").iterrows(): | |
| is_this_self = str(shop["店舗ID"]) == str(selected_shop_id) | |
| is_self_str = " (★自店)" if is_this_self else "" | |
| s_diff_total = shop["総台数"] - avg_total | |
| s_diff_normal = (shop["P通常"] + shop["S通常"]) - avg_normal_total | |
| s_diff_p_norm = shop["P通常"] - avg_p_norm | |
| s_diff_s_norm = shop["S通常"] - avg_s_norm | |
| title_label = f"📍 {shop['店舗名']}{is_self_str} - 距離: {shop['distance']:.1f}km / 総合{shop['全体順位']}位" | |
| with st.expander(title_label): | |
| comp_p_norm_rat, comp_p_low_rat, comp_s_norm_rat, comp_s_low_rat = shop["P通常比率"], shop["P低貸比率"], shop["S通常比率"], shop["S低貸比率"] | |
| comp_report_text = f""" | |
| **【市場内ランクと乖離】** | |
| * 総合順位: **第{shop['全体順位']}位** (平均差 {int(s_diff_total):+d}台) | |
| * 通常貸順位: **第{shop['通常順位']}位** (平均差 {int(s_diff_normal):+d}台) | |
| * P通常: {shop['P通常順位']}位 ({int(s_diff_p_norm):+d}台) / S通常: {shop['S通常順位']}位 ({int(s_diff_s_norm):+d}台) | |
| **【ポジショニング分析】** | |
| """ | |
| # --- 【ポジショニング分析(平均差ベース・整数表示版)】 --- | |
| if shop["全体順位"] == 1: | |
| comp_report_text += f"✅ **商圏リーダー:** 台数規模でエリア単独首位。平均差を {int(s_diff_total):+d}台 上回る圧倒的な物量が武器です。" | |
| else: | |
| # 2位以下の店舗は、商圏平均との乖離(s_diff_total)をベースに細分化 | |
| if s_diff_total >= 300: | |
| comp_report_text += f"✅ **準メガ・メガ対抗店:** 首位ではないものの、商圏平均差を {int(s_diff_total):+d}台 と大幅に凌駕する巨大店舗です。地域1番店とバチバチに渡り合えるだけの強力なハードウェア(物量)を保有しています。" | |
| elif s_diff_total >= 100: | |
| comp_report_text += f"✅ **主要有力店(規模優位):** 商圏平均差を {int(s_diff_total):+d}台 上回るエリア上位の有力店です。市場への影響力が十分に高く、メイン機種の稼働シェアを大きく囲い込めるポジションにあります。" | |
| elif s_diff_total >= -50 and s_diff_total < 100: | |
| comp_report_text += f"✅ **市場標準・中核店舗:** 規模は商圏平均差 {int(s_diff_total):+d}台 と、エリアの平均水準に位置するスタンダードな店舗です。最も競合の波に飲まれやすいため、営業スケジュールや特定日の仕掛けなど「ソフト面」の差別化が生命線となります。" | |
| elif s_diff_total >= -200 and s_diff_total < -50: | |
| comp_report_text += f"✅ **中堅・コンパクト店:** 商圏平均差が {int(s_diff_total):+d}台 と、平均を下回るサイズ感です。真っ向勝負の薄利多売は避けるべきで、中古機の機敏な運用や低貸比率の最適化など「筋肉質な営業」へのシフトが推奨されるポジションです。" | |
| else: | |
| # 平均差 -200台未満 | |
| comp_report_text += f"✅ **局地戦型・ニッチ専門店:** 商圏平均差を {int(s_diff_total):+d}台 と大きく下回る極小規模店、あるいは特定部門に特化した専門店です。メガ店舗の網に引っかからないコアな常連客の獲得、または接客や地域コミュニティ化に特化したニッチ戦略が必須となります。" | |
| comp_report_text += "\n\n**【部門別シェア比較(市場平均比)】**" | |
| comp_dept_analysis = [] | |
| if comp_p_norm_rat > p_norm_rat + 5: | |
| comp_dept_analysis.append(f"📈 **P通常特化型:** 市場平均({p_norm_rat:.1f}%)に対し、{comp_p_norm_rat:.1f}%と高い構成。4円ユーザーの受け皿となっています。") | |
| if comp_s_norm_rat > s_norm_rat + 5: | |
| comp_s_low_rat_val = comp_s_low_rat # 安全用 | |
| comp_dept_analysis.append(f"📈 **S通常特化型:** 市場平均({s_norm_rat:.1f}%)に対し、{comp_s_norm_rat:.1f}%とスロット通常貸を極めて重視。勝負志向の強い顧客層を抱えています。") | |
| if comp_p_low_rat > p_low_rat + 5: | |
| comp_dept_analysis.append(f"📉 **P低貸過多:** 市場平均({p_low_rat:.1f}%)を大きく上回る{comp_p_low_rat:.1f}%の構成。薄利多売の傾向が強く、集客はできても収益性に課題がある可能性があります。") | |
| if comp_s_low_rat > s_low_rat + 5: | |
| comp_dept_analysis.append(f"📉 **S低貸重視:** 市場平均({s_low_rat:.1f}%)に対し、{comp_s_low_rat:.1f}%が低貸。滞在型ユーザーが多い構成です。") | |
| if not comp_dept_analysis: | |
| comp_report_text += "\n⚖️ **市場標準構成:** 全部門において市場平均に近いバランスの取れた構成です。" | |
| else: | |
| comp_report_text += "\n" + "\n".join(comp_dept_analysis) | |
| comp_report_text += "\n\n**【戦略・分析】**" | |
| if s_diff_normal > 0: | |
| comp_report_text += f"\n💡 **収益力:** 通常貸部門が平均差を {int(s_diff_normal)}台 上回る強力な布陣。収益基盤が安定しています。" | |
| else: | |
| comp_report_text += f"\n💡 **客数依存:** 通常貸が平均差で {int(s_diff_normal)}台 と不足。低貸による客数確保への依存が強い構成です。" | |
| st.markdown(comp_report_text) | |
| # ========================================================= | |
| # --- 🎰 タブ3: 設置機種詳細 --- | |
| # ========================================================= | |
| with tabs["🎰 設置機種詳細"]: | |
| st.subheader("🎰 設置機種・市場比較レポート") | |
| try: | |
| # 1. データの読み込み | |
| if "processed_df" not in st.session_state: | |
| st.session_state.processed_df = pd.read_parquet("processed_analysis_data.parquet") | |
| df_all = st.session_state.processed_df.copy() | |
| area_shop_ids = neighbor_df["店舗ID"].astype(str).tolist() | |
| df_all["店舗ID"] = df_all["店舗ID"].astype(str) | |
| # 商圏内のデータに絞り込み | |
| area_p = df_all[df_all["店舗ID"].isin(area_shop_ids)].copy() | |
| # タブの作成 | |
| sub_tabs = st.tabs(["🔴 通常P", "🟢 低貸P", "🔵 通常S", "🟡 低貸S"]) | |
| categories = {"通常P": "通常P", "低貸P": "低貸P", "通常S": "通常S", "低貸S": "低貸S"} | |
| # 【修正ポイント】タブ10統合表示用のセッションステート辞書構造を初期化(開かなくても引き渡す用) | |
| if "tab3_results" not in st.session_state: | |
| st.session_state.tab3_results = {} | |
| for i, (label, cat_name) in enumerate(categories.items()): | |
| with sub_tabs[i]: | |
| display_df = area_p[area_p["貸玉区分"] == cat_name].copy() | |
| if display_df.empty: | |
| st.info(f"{label} のデータが商圏内にありません。") | |
| continue | |
| # --- A. カテゴリ別の表示列定義 --- | |
| is_p = "P" in cat_name | |
| if is_p: | |
| spec_cols = ["販売年月日", "確率区分", "スマパチ", "デカヘソ"] | |
| else: | |
| spec_cols = ["販売年月日", "機種タイプ区分", "スマスロ", "30パイ", "BT機"] | |
| existing_spec_cols = [c for c in spec_cols if c in display_df.columns] | |
| # --- B. 商圏集計ベースの作成 (数値計算用) --- | |
| agg_dict = { | |
| "機種名": "first", "メーカー名": "first", "中古単価": "max", | |
| "全国台数": "max", "都道府県台数": "max", "設置台数": "sum" | |
| } | |
| for sc in existing_spec_cols: | |
| agg_dict[sc] = "first" | |
| summary_for_calc = display_df.groupby("Pcode").agg(agg_dict).rename(columns={"設置台数": "商圏合計"}).reset_index() | |
| # --- C. 各店舗の台数列を追加 --- | |
| shop_cols = [] | |
| for _, s_row in neighbor_df.sort_values("distance").iterrows(): | |
| s_id = str(s_row["店舗ID"]) | |
| s_name = s_row["店舗名"] | |
| shop_specific = display_df[display_df["店舗ID"] == s_id].groupby("Pcode")["設置台数"].sum().reset_index(name=s_name) | |
| summary_for_calc = pd.merge(summary_for_calc, shop_specific, on="Pcode", how="left") | |
| summary_for_calc[s_name] = summary_for_calc[s_name].fillna(0).astype(int) | |
| shop_cols.append(s_name) | |
| summary_for_calc = summary_for_calc.sort_values("商圏合計", ascending=False) | |
| grand_totals = {col: summary_for_calc[col].sum() for col in ["商圏合計"] + shop_cols} | |
| # --- D. 表示用フォーマット --- | |
| def to_comma(x): | |
| if pd.isna(x) or x == "": return "0" | |
| try: return f"{int(float(x)):,}" | |
| except: return str(x) | |
| summary_disp = summary_for_calc.copy() | |
| for col in ["中古単価", "全国台数", "都道府県台数", "商圏合計"] + shop_cols: | |
| summary_disp[col] = summary_disp[col].apply(to_comma) | |
| sum_row = {col: "" for col in summary_disp.columns} | |
| sum_row["機種名"] = "■店舗合計台数" | |
| sum_row["商圏合計"] = to_comma(grand_totals["商圏合計"]) | |
| for s_name in shop_cols: | |
| sum_row[s_name] = to_comma(grand_totals[s_name]) | |
| summary_with_total = pd.concat([pd.DataFrame([sum_row]), summary_disp], ignore_index=True) | |
| # --- E. データフレーム表示 --- | |
| final_cols = ["機種名", "メーカー名"] + existing_spec_cols + ["中古単価", "全国台数", "都道府県台数", "商圏合計"] + shop_cols | |
| column_conf = { | |
| "機種名": st.column_config.TextColumn("機種名", width="large"), | |
| "メーカー名": st.column_config.TextColumn("メーカー名", width="medium"), | |
| "販売年月日": st.column_config.TextColumn("導入日", width=None), | |
| } | |
| for sc in existing_spec_cols: | |
| if sc != "販売年月日": | |
| column_conf[sc] = st.column_config.TextColumn(sc, width=80) | |
| for col in ["中古単価", "全国台数", "都道府県台数", "商圏合計"] + shop_cols: | |
| column_conf[col] = st.column_config.TextColumn(col, width=80) | |
| st.dataframe(summary_with_total[final_cols], use_container_width=True, height=450, column_config=column_conf, hide_index=True) | |
| # 市場分析用の数値集計 | |
| pivot = display_df.pivot_table(index="機種名", columns="店舗名", values="設置台数", aggfunc="sum").fillna(0).astype(int) | |
| shop_cols_list = pivot.columns.tolist() | |
| shop_totals = {s: int(pivot[s].sum()) for s in shop_cols_list if pivot[s].sum() > 0} | |
| super_large_count = sum(1 for t in shop_totals.values() if t >= 600) | |
| large_shops_count = sum(1 for t in shop_totals.values() if t >= 400) | |
| mid_shops_count = sum(1 for t in shop_totals.values() if t >= 200 and t < 400) | |
| total_shops = len(shop_totals) | |
| market_total_sum = sum(shop_totals.values()) | |
| # --- G. 📊 商圏環境アセスメント --- | |
| st.markdown("#### 📊 商圏環境アセスメント") | |
| # 【修正ポイント】アセスメント結果をテキスト変数に格納し、タブ10へ安全に引き渡せるようにする | |
| assessment_text = "" | |
| assessment_status = "info" # UI色分け用タグ | |
| if total_shops > 0: | |
| if (large_shops_count / total_shops >= 0.5) or (super_large_count >= 2): | |
| assessment_status = "error" | |
| assessment_text = f"【超激戦区】600台超の旗艦店を含む大型店が主導する市場。資本力と新台の『鮮度』が直結し、中小店舗は非常に厳しい生存競争を強いられます。" | |
| st.error(assessment_text) | |
| elif large_shops_count >= 2 or mid_shops_count >= 4: | |
| assessment_status = "warning" | |
| assessment_text = f"【要警戒エリア】{large_shops_count}店舗の大型店を筆頭に、バランスの取れた競合が存在。自店の『尖った強み』を打ち出さないと、大型店と中型店の板挟みになる恐れがあります。" | |
| st.warning(assessment_text) | |
| elif mid_shops_count >= 1: | |
| assessment_status = "info" | |
| assessment_text = f"【標準エリア】200台〜400台の中型店が中心の市場。特定の機種構成や地域密着型の営業といった『差別化戦略』が効果を発揮しやすい環境です。" | |
| st.info(assessment_text) | |
| else: | |
| assessment_status = "success" | |
| assessment_text = f"【安定市場】200台未満の小規模店が中心。競合圧力が低く、既存顧客の維持(固定客化)が最も重要なフェーズです。" | |
| st.success(assessment_text) | |
| # --- H. 🤖 店舗別・機種構成AI戦略診断 --- | |
| st.markdown("---") | |
| st.markdown("### 🤖 店舗別・機種構成AI戦略診断") | |
| url_id = st.query_params.get("id") | |
| top_10_pcodes = summary_for_calc.sort_values("全国台数", ascending=False).head(10)["Pcode"].tolist() | |
| target_shops_exist = [c for c in shop_cols if c in summary_for_calc.columns] | |
| top10_summary = summary_for_calc[summary_for_calc["Pcode"].isin(top_10_pcodes)] | |
| all_top10_totals = top10_summary[target_shops_exist].sum().to_dict() | |
| for index, s_row in neighbor_df.sort_values("distance").iterrows(): | |
| try: | |
| s_name = s_row["店舗名"] | |
| if s_name not in summary_for_calc.columns: continue | |
| s_total = summary_for_calc[s_name].sum() | |
| if s_total == 0: continue | |
| # 指標計算 | |
| s_top10_units = all_top10_totals.get(s_name, 0) | |
| s_top10_ratio = (s_top10_units / s_total * 100) if s_total > 0 else 0 | |
| s_share = (s_total / market_total_sum * 100) if market_total_sum > 0 else 0 | |
| counts_list = list(all_top10_totals.values()) | |
| rank = len([v for v in counts_list if v > s_top10_units]) + 1 | |
| # 独自機種 | |
| other_shops = [c for c in target_shops_exist if c != s_name] | |
| s_only_count = len(summary_for_calc[(summary_for_calc[other_shops].sum(axis=1) == 0) & (summary_for_calc[s_name] > 0)]) if other_shops else 0 | |
| # 新台比率 | |
| very_new_ratio = 0.0 | |
| if "販売年月日" in summary_for_calc.columns: | |
| try: | |
| df_fresh = summary_for_calc.copy() | |
| df_fresh["dt"] = pd.to_datetime(df_fresh["販売年月日"], errors='coerce') | |
| v_units = df_fresh[df_fresh["dt"] >= (pd.Timestamp.now() - pd.Timedelta(days=180))][s_name].sum() | |
| very_new_ratio = (v_units / s_total * 100) | |
| except: pass | |
| # レポートテキスト生成 | |
| if s_total >= 1000: pos_text = f"設置台数{int(s_total)}台の**超弩級メガホール**です。圧倒的物量が最大の武器です。" | |
| elif s_total >= 450: pos_text = f"設置台数{int(s_total)}台の**大型実力店**です。" | |
| else: pos_text = f"設置台数{int(s_total)}台の中堅店舗です。" | |
| top10_status = "high" if s_top10_ratio >= 18 else "mid" if s_top10_ratio >= 10 else "low" | |
| fresh_status = "high" if very_new_ratio >= 20 else "mid" if very_new_ratio >= 10 else "low" | |
| if top10_status == "high" and fresh_status == "high": strategy_msg = "【地域旗艦・積極投資型】主要機・最新台ともに極めて厚く、エリアのトレンドを牽引する圧倒的な構成です。" | |
| elif top10_status == "high" and fresh_status == "mid": strategy_msg = "【王道・トレンド維持型】全国的人気機を主軸に据えつつ、新台も適宜入れ替える、隙のない正攻法の営業スタイルです。" | |
| elif top10_status == "high" and fresh_status == "low": strategy_msg = "【王道・長期貢献重視型】メイン機種の設置シェアは高いですが、新台への依存度は低めです。" | |
| elif top10_status == "mid" and fresh_status == "high": strategy_msg = "【最新台特化・集客フック型】話題性を武器に新規客を呼び込む構成です。" | |
| elif top10_status == "mid" and fresh_status == "mid": strategy_msg = "【バランス重視・標準型】主要機と新台を市場平均並みに配置した、リスクを抑えた堅実な構成です。" | |
| elif top10_status == "mid" and fresh_status == "low": strategy_msg = "【定番回帰・堅実運用型】全国的に評価の定まった定番機種を中心に、低コストで手堅く運用している傾向があります。" | |
| elif top10_status == "low" and fresh_status == "high": strategy_msg = "【一点突破・新台勝負型】定番機のシェアをあえて捨て、最新台の鮮度のみで勝負しています。" | |
| elif top10_status == "low" and fresh_status == "mid": strategy_msg = "【差別化・独自運用型】独自の選定センスが光る構成です。" | |
| elif top10_status == "low" and fresh_status == "low": strategy_msg = "【超独自・ニッチ特化型】マニアックな機種やバラエティなど『ここでしか打てない』環境を重視しています。" | |
| else: strategy_msg = "現在の市場トレンドとは一線を画した、独自の機種構成となっています。" | |
| uniqueness_msg = f"エリア内で自店のみの希少機種({s_only_count}機種)を保有しています。" | |
| if s_only_count >= 30: uniqueness_msg = f"商圏内で自店にしかない機種が**{s_only_count}機種**という圧倒的なバラエティを誇ります。" | |
| elif s_only_count >= 15: uniqueness_msg = f"商圏内で自店のみ設置の機種が**{s_only_count}機種**存在します。" | |
| advice = "現在のバランスを維持しつつ、主要機種のエリア順位を上げる検討を行ってください。" | |
| if very_new_ratio > 30: advice = "【高鮮度・高投資戦略】新台の稼働寿命を見極めた機敏な配置変更が重要です。" | |
| elif s_only_count > 20: advice = "【差別化特化戦略】『ここでしか打てない』という希少性を販促の軸に据えてください。" | |
| full_report = f"**【規模と立ち位置】**\n{pos_text}\n\n**【構成分析】**\n全国トップ10機種は店内の{s_top10_ratio:.1f}%({int(s_top10_units)}台)。最新台比率は{very_new_ratio:.1f}%。\n\n**【店舗戦略診断】**\n{strategy_msg}\n\n**【独自性】**\n{uniqueness_msg}\n\n**【💡 AIアドバイス】**\n{advice}" | |
| # 自店判定 | |
| s_id_raw = next((str(s_row[c]) for c in ["Pachinko_Shop_Pcode", "Pcode", "店舗コード", "shop_id", "店舗ID"] if c in s_row and s_row[c]), None) | |
| is_target = (url_id == s_id_raw) if url_id and s_id_raw else (s_row["distance"] == 0) | |
| # 【修正ポイント】自店のデータを、開いていなくてもタブ10へ渡せるように専用セッションステート辞書へ完全格納 | |
| if is_target: | |
| clean_target_id = str(selected_shop_id).strip() | |
| if clean_target_id not in st.session_state.tab3_results: | |
| st.session_state.tab3_results[clean_target_id] = {"departments": {}} | |
| st.session_state.tab3_results[clean_target_id]["departments"][cat_name] = { | |
| "label": label, | |
| "assessment_status": assessment_status, | |
| "assessment_text": assessment_text, | |
| "full_report": full_report, | |
| "total_seats": int(s_total), | |
| "share": s_share, | |
| "top10_units": int(s_top10_units), | |
| "top10_rank": rank, | |
| "only_count": s_only_count | |
| } | |
| with st.expander(f"{'🏠 (自店)' if is_target else '🔍'} {s_name} の詳細分析を見る", expanded=is_target): | |
| col1, col2, col3, col4 = st.columns(4) | |
| col1.metric("規模", f"{int(s_total)}台") | |
| col2.metric("エリアシェア", f"{s_share:.1f}%") | |
| col3.metric("全国TOP10", f"{int(s_top10_units)}台", f"{rank}位") | |
| col4.metric("独自機種", f"{s_only_count}機種") | |
| st.markdown(full_report) | |
| # 🎯 攻め筋の厳選提案 | |
| all_stats = display_df.groupby("機種名").agg({"全国台数": "max", "設置台数": "sum"}).reset_index().rename(columns={"設置台数": "商圏合計"}) | |
| s_units_df = display_df[display_df["店舗名"] == s_name].groupby("機種名")["設置台数"].sum().reset_index(name="自店台数") | |
| analysis_data = pd.merge(all_stats, s_units_df, on="機種名", how="left").fillna(0) | |
| gap_machines = analysis_data[(analysis_data["商圏合計"] >= 10) & (analysis_data["自店台数"] < (analysis_data["商圏合計"] * 0.15))].sort_values("商圏合計", ascending=False) | |
| list_gap = gap_machines["機種名"].tolist()[:3] | |
| niche_machines = analysis_data[(analysis_data["全国台数"] > 5000) & (analysis_data["商圏合計"] < 10)].sort_values("全国台数", ascending=False) | |
| list_niche = niche_machines["機種名"].tolist()[:3] | |
| st.markdown("---") | |
| st.markdown(f"##### 🎯 {s_name} 専用:攻め筋の厳選提案 ({cat_name})") | |
| sel_cols = st.columns(2) | |
| with sel_cols[0]: | |
| if list_gap: | |
| rec_1 = st.selectbox("推奨1: シェア奪還型", list_gap, key=f"sel1_{cat_name}_{s_name}_{index}") | |
| st.warning(f"**{rec_1}**\n\n競合店に流れている顧客を取り戻すための優先機種です。") | |
| with sel_cols[1]: | |
| if list_niche: | |
| rec_2 = st.selectbox("推奨2: エリア盲点型", list_niche, key=f"sel2_{cat_name}_{s_name}_{index}") | |
| st.success(f"**{rec_2}**\n\n他店が手薄な『お宝機種』で、独自性を打ち出しましょう。") | |
| except Exception as e_inner: | |
| st.error(f"{s_row['店舗名']}の分析中にエラー: {e_inner}") | |
| except Exception as e_outer: | |
| st.error(f"タブ3全体でエラーが発生しました: {e_outer}") | |
| # ========================================================= | |
| # --- 🎰 タブ4: タイプ別構成 --- | |
| # ========================================================= | |
| with tabs["📊 タイプ別"]: | |
| st.subheader("📊 部門別・機種タイプ構成比較") | |
| if "processed_df" in st.session_state: | |
| full_df = st.session_state.processed_df | |
| # --- タブ1と同じソートロジックでIDリストを作成 --- | |
| sorted_neighbor = neighbor_df.sort_values("distance") | |
| area_shop_ids = sorted_neighbor["店舗ID"].astype(str).tolist() | |
| name_map = dict(zip(sorted_neighbor["店舗ID"].astype(str), sorted_neighbor["店舗名"])) | |
| selected_shop_id_str = sid_query | |
| categories = {"🔴 通常P": "通常P", "🟢 低貸P": "低貸P", "🔵 通常S": "通常S", "🟡 低貸S": "低貸S"} | |
| sub_tabs = st.tabs(list(categories.keys())) | |
| for i, (tab_label, dept_name) in enumerate(categories.items()): | |
| dept_key = tab_label.split()[-1] # "通常P" などのキーを特定 | |
| with sub_tabs[i]: | |
| df_dept = full_df[full_df["貸玉区分"] == dept_name] | |
| if df_dept.empty: | |
| st.info(f"{dept_name} のデータがありません。") | |
| continue | |
| is_p = "P" in dept_name | |
| type_col = "パチンコタイプ名" if is_p else "スロットタイプ名" | |
| sort_col = "パチンコタイプID" if is_p else "スロットタイプID" | |
| # エリア内店舗の集計 | |
| df_area = df_dept[df_dept["店舗ID"].isin(area_shop_ids)] | |
| if df_area.empty: | |
| st.warning("近隣エリアにデータが存在しません。") | |
| continue | |
| pivot_area = df_area.groupby([type_col, "店舗ID"])["設置台数"].sum().unstack(fill_value=0) | |
| # area_shop_ids(距離順)に含まれる順にカラムを並べ替え | |
| existing_ids = [sid for sid in area_shop_ids if sid in pivot_area.columns] | |
| pivot_area = pivot_area[existing_ids] | |
| # カラム名を店舗名に置換(★自店ラベル付与) | |
| pivot_area.columns = [ | |
| f"★自店: {name_map.get(sid, sid)}" if sid == selected_shop_id_str else name_map.get(sid, sid) | |
| for sid in pivot_area.columns | |
| ] | |
| # 平均の算出 | |
| n_shops_all = df_dept["店舗ID"].nunique() | |
| n_shops_area = len(existing_ids) | |
| national_avg = (df_dept.groupby(type_col)["設置台数"].sum() / n_shops_all).rename("🌏 全国平均") | |
| area_avg = (df_area.groupby(type_col)["設置台数"].sum() / n_shops_area).rename("🏘️ エリア平均") | |
| # 結合とタイプ順ソート | |
| df_base = pd.concat([national_avg, area_avg, pivot_area], axis=1).fillna(0) | |
| type_order = df_dept.groupby(type_col)[sort_col].min().sort_values().index | |
| df_base = df_base.reindex([t for t in type_order if t in df_base.index]) | |
| # 表示用データの作成 (転置) | |
| df_disp_count = df_base.T | |
| df_disp_ratio = (df_base / df_base.sum() * 100).fillna(0).T | |
| # テーブル表示 | |
| st.markdown("#### 🔢 設置台数(台)") | |
| count_cfg = {col: st.column_config.NumberColumn(width=100, format="%.1f") for col in df_base.index} | |
| count_cfg["index"] = st.column_config.TextColumn("店舗/タイプ名", pinned=True, width="medium") | |
| st.dataframe(df_disp_count, use_container_width=True, column_config=count_cfg) | |
| # 比較対象の基準を選択(全国平均 or エリア平均) | |
| comparison_target = st.radio( | |
| "太字の基準を選択:", | |
| ["🌏 全国平均", "🏘️ エリア平均"], | |
| horizontal=True, | |
| key=f"bold_target_{dept_name}" | |
| ) | |
| # スタイル定義用の関数 | |
| def highlight_above_avg(column): | |
| target_val = df_disp_ratio.loc[comparison_target, column.name] | |
| return [ | |
| 'font-weight: bold; color: #1f77b4;' if val > target_val else '' | |
| for val in column | |
| ] | |
| # スタイルを適用 | |
| styled_ratio_df = df_disp_ratio.style.apply(highlight_above_avg, axis=0).format("{:.1f}%") | |
| ratio_cfg = {col: st.column_config.NumberColumn(width=100) for col in df_base.index} | |
| ratio_cfg["index"] = st.column_config.TextColumn("店舗/タイプ名", pinned=True, width="medium") | |
| st.dataframe(styled_ratio_df, use_container_width=True, column_config=ratio_cfg) | |
| # --- 📊 店舗別・タイプ構成比率グラフ --- | |
| st.markdown(f"#### ⚖️ 店舗別・{tab_label} タイプ構成比較") | |
| import plotly.express as px | |
| # 表示したい順序を「上から順」に作成 | |
| desired_order_top_down = ["🌏 全国平均", "🏘️ エリア平均"] | |
| other_shops = [] | |
| for sid in area_shop_ids: | |
| s_name = name_map.get(sid, sid) | |
| full_name = f"★自店: {s_name}" if sid == selected_shop_id_str else s_name | |
| if full_name in df_disp_ratio.index: | |
| other_shops.append(full_name) | |
| desired_order_top_down.extend(other_shops) | |
| # Plotlyのy軸仕様に合わせてリストを反転 | |
| plot_y_order = desired_order_top_down[::-1] | |
| dynamic_height = 200 + (len(desired_order_top_down) * 45) | |
| # --- A. 設置台数(実数)グラフ --- | |
| st.markdown("##### 🔢 設置台数(実数)") | |
| df_ordered_count = df_disp_count.reindex(desired_order_top_down).dropna(how='all') | |
| df_graph_count = df_ordered_count.reset_index().rename(columns={'index': '店舗名'}) | |
| df_graph_count = df_graph_count.melt(id_vars="店舗名", var_name="タイプ", value_name="台数") | |
| fig_count = px.bar( | |
| df_graph_count, | |
| x="台数", | |
| y="店舗名", | |
| color="タイプ", | |
| orientation='h', | |
| category_orders={"店舗名": plot_y_order, "タイプ": list(type_order)}, | |
| color_discrete_sequence=px.colors.qualitative.Pastel, | |
| height=dynamic_height, | |
| text_auto='.1f' | |
| ) | |
| fig_count.update_layout( | |
| xaxis_title="設置台数(台)", yaxis_title="", | |
| legend_title="タイプ名", | |
| margin=dict(l=0, r=10, t=100, b=40), | |
| legend=dict(orientation="h", yanchor="bottom", y=1.05, xanchor="center", x=0.5) | |
| ) | |
| fig_count.update_yaxes(categoryorder='array', categoryarray=plot_y_order) | |
| st.plotly_chart(fig_count, use_container_width=True) | |
| # --- B. 構成比率(100%積立)グラフ --- | |
| st.markdown("##### 🏁 構成比率(100%積立)") | |
| df_ordered_ratio = df_disp_ratio.reindex(desired_order_top_down).dropna(how='all') | |
| df_graph_ratio = df_ordered_ratio.reset_index().rename(columns={'index': '店舗名'}) | |
| df_graph_ratio = df_graph_ratio.melt(id_vars="店舗名", var_name="タイプ", value_name="比率") | |
| fig_ratio = px.bar( | |
| df_graph_ratio, | |
| x="比率", | |
| y="店舗名", | |
| color="タイプ", | |
| orientation='h', | |
| category_orders={"店舗名": plot_y_order, "タイプ": list(type_order)}, | |
| color_discrete_sequence=px.colors.qualitative.Pastel, | |
| height=dynamic_height, | |
| text_auto='.1f' | |
| ) | |
| fig_ratio.update_layout( | |
| xaxis_title="構成比率 (%)", yaxis_title="", | |
| legend_title="タイプ名", | |
| margin=dict(l=0, r=10, t=100, b=40), | |
| legend=dict(orientation="h", yanchor="bottom", y=1.05, xanchor="center", x=0.5) | |
| ) | |
| fig_ratio.update_yaxes(categoryorder='array', categoryarray=plot_y_order) | |
| st.plotly_chart(fig_ratio, use_container_width=True) | |
| # --- 📈 エリア構成アセスメント(市場傾向テキスト生成・保存) --- | |
| st.markdown("#### 💎 エリア構成アセスメント") | |
| diff_area_nat = df_disp_ratio.loc["🏘️ エリア平均"] - df_disp_ratio.loc["🌏 全国平均"] | |
| if not diff_area_nat.dropna().empty: | |
| top_plus = diff_area_nat.idxmax() | |
| top_minus = diff_area_nat.idxmin() | |
| analysis_text = f"【市場傾向:{dept_name}部門】 " | |
| if diff_area_nat.abs().max() < 3: | |
| analysis_text += "全国平均に近い標準的な構成です。" | |
| else: | |
| analysis_text += ( | |
| f"全国比で「{top_plus}」の構成比が高く(+{diff_area_nat.max():.1f}%)、" | |
| f"逆に「{top_minus}」が手薄({diff_area_nat.min():.1f}%)な傾向にあります。" | |
| ) | |
| full_assessment = f"{analysis_text}\n\n周辺店舗は {top_plus} を中心とした運用に注力しており、特定のスペック帯に支持が集中しやすい市場環境です。" | |
| st.info(full_assessment) | |
| # ルートA: タブ10が直接見に行く「tab4_market_notes」への格納 | |
| if "tab4_market_notes" not in st.session_state: | |
| st.session_state.tab4_market_notes = {} | |
| # dept_key ("通常P"など) をそのままキーにして保存 | |
| st.session_state.tab4_market_notes[dept_key] = full_assessment | |
| # 既存の互換性を残すためのバックアップ保存(残しておいても無害です) | |
| if "analysis_notes" not in st.session_state: | |
| st.session_state.analysis_notes = {} | |
| note_key = f"note_tab4_{dept_key}_{selected_shop_id_str}" | |
| st.session_state.analysis_notes[note_key] = full_assessment | |
| else: | |
| full_assessment = "比較可能なデータがありません" | |
| st.write(full_assessment) | |
| # --- 🤖 店舗別・タイプ構成詳細分析 --- | |
| st.markdown("---") | |
| st.markdown("### 🔍 店舗別・タイプ構成詳細分析") | |
| # タブ10で再利用するために結果格納用辞書を初期化 | |
| if "tab4_results" not in st.session_state: | |
| st.session_state.tab4_results = {} | |
| for sid in area_shop_ids: | |
| s_name = name_map.get(sid, sid) | |
| full_name = f"★自店: {s_name}" if sid == selected_shop_id_str else s_name | |
| if full_name not in df_disp_ratio.index: | |
| continue | |
| is_target = (sid == selected_shop_id_str) | |
| label_icon = "🏠 (自店)" if is_target else "🔍" | |
| with st.expander(f"{label_icon} {s_name} の構成分析", expanded=is_target): | |
| df_shop_dept = df_dept[df_dept["店舗ID"] == sid] | |
| s_total = df_shop_dept["設置台数"].sum() if not df_shop_dept.empty else 0 | |
| # 乖離の計算 | |
| ratio_shop = df_disp_ratio.loc[full_name] | |
| diff_nat = ratio_shop - df_disp_ratio.loc["🌏 全国平均"] | |
| diff_area = ratio_shop - df_disp_ratio.loc["🏘️ エリア平均"] | |
| # 判定ロジック | |
| if not is_p: | |
| normal_labels = [idx for idx in ratio_shop.index if "ノーマル" in str(idx)] | |
| normal_diff = diff_nat.reindex(normal_labels).sum() | |
| if normal_diff > 10: risk_label = "安定・遊技重視(低射幸性)" | |
| elif normal_diff > 3: risk_label = "堅実バランス型" | |
| elif normal_diff < -10: risk_label = "一撃特化・トレンド重視(高射幸性)" | |
| elif normal_diff < -3: risk_label = "積極投資・AT主導型" | |
| else: risk_label = "標準バランス型" | |
| else: | |
| high_spec_labels = [idx for idx in ratio_shop.index if any(s in str(idx) for s in ["1/3", "300", "320", "380"])] | |
| high_spec_diff = diff_nat.reindex(high_spec_labels).sum() | |
| if high_spec_diff > 15: risk_label = "超積極・一撃特化" | |
| elif high_spec_diff > 5: risk_label = "トレンド追随" | |
| elif high_spec_diff < -15: risk_label = "地域密着・遊技重視" | |
| elif high_spec_diff < -5: risk_label = "安定運用型" | |
| else: risk_label = "標準バランス型" | |
| c1, c2 = st.columns(2) | |
| with c1: st.metric("部門設置台数", f"{int(s_total)}台") | |
| with c2: st.metric("営業スタイル", risk_label) | |
| st.write("**📊 市場比較:設置比率の乖離(%ポイント)**") | |
| compare_df = pd.DataFrame({"自店比率": ratio_shop, "全国差": diff_nat, "エリア差": diff_area}) | |
| def color_diff(val): | |
| if isinstance(val, (int, float)): | |
| return f"color: {'red' if val > 5 else 'blue' if val < -5 else 'black'}" | |
| return None | |
| st.dataframe( | |
| compare_df.style.format("{:.1f}%", subset=["自店比率"]) | |
| .format("{:+.1f}%", subset=["全国差", "エリア差"]) | |
| .map(color_diff, subset=["全国差", "エリア差"]), | |
| use_container_width=True | |
| ) | |
| # AIコメント(店舗傾向)の作成 | |
| analysis_parts = [] | |
| if not is_p: | |
| # (パチスロ用:必要に応じて並び順を調整) | |
| if normal_diff > 8: | |
| analysis_parts.append(f"全国平均比でノーマルタイプを{normal_diff:.1f}%厚く配置。射幸性を抑え、Aタイプ特有の『技術介入』や『出玉の安定感』を重視する堅実な営業スタイルです。") | |
| elif normal_diff < -8: | |
| analysis_parts.append(f"ノーマル機を{abs(normal_diff):.1f}%抑え、最新のAT機に集約。エリア内でも特に『射幸性』と『トレンド感』を前面に出した攻めの布陣です。") | |
| else: | |
| analysis_parts.append("ノーマル機とAT機の配分が全国平均に近く、幅広い客層に対応できる構成です。") | |
| else: | |
| # 特定の確率帯の乖離をチェック(diff_natを利用) | |
| max_diff_label = diff_nat.abs().idxmax() | |
| max_diff_val = diff_nat.loc[max_diff_label] | |
| # ① まず「突出している事実」を先にリストに追加 | |
| if abs(max_diff_val) > 15: | |
| analysis_parts.append(f"「{max_diff_label}」の構成比が全国平均から {max_diff_val:+.1f}% と大幅に乖離しており、同店における戦略上の重要な差別化ポイントとなっています。") | |
| # ② 次に「全体像の評価」を追加 | |
| if high_spec_diff > 10: | |
| analysis_parts.append(f"ハイスペック機を全国比で{high_spec_diff:.1f}%厚く配置。エリア内の『爆発力』と『トレンド感』で勝負する攻めの姿勢が鮮明です。") | |
| elif high_spec_diff < -10: | |
| analysis_parts.append(f"1/319帯をあえて{abs(high_spec_diff):.1f}%抑え、初当たり回数を重視。長時間遊技を好む層に特化した生存戦略が見て取れます。") | |
| else: | |
| # 全体評価が標準的な場合、接続詞を工夫して自然に繋げる | |
| analysis_parts.append("その他、主要な確率帯の配分は全国平均に近く、一定の安心感があるスタンダードな構成を維持しています。") | |
| trend_text = "\n\n".join(analysis_parts) | |
| st.info(f"**【店舗傾向:{s_name}】**\n\n{trend_text}") | |
| # 戦略アドバイス(ヒント)の作成 | |
| advice = "" | |
| if is_target: | |
| if not is_p: | |
| if normal_diff > 8: advice = "ノーマル機の強さは安定の柱ですが、若年層への訴求は弱くなります。AT機の看板機種を絞って強化し、店内の『活気』を演出してください。" | |
| elif normal_diff < -8: advice = "高単価なAT機主体は収益性は高い反面、客離れリスクも伴います。低投資で遊べる選択肢を強調し、来店頻度を維持する工夫が有効です。" | |
| else: advice = "バランスが良い反面、特徴が埋もれやすくなります。特定機種で『商圏NO.1』を作ることで、集客のフックを強めてください。" | |
| else: | |
| if high_spec_diff < -10: advice = "『遊びやすさ』は武器ですが、話題性が不足しがちです。ライトミドル等でトレンドを補強してください。" | |
| elif high_spec_diff > 10: advice = "メイン機比率が高く競合と真っ向勝負です。一部を利益率の安定する確率帯へシフトする余地があります。" | |
| else: advice = "バランスが良い反面、競合の特化戦略に注意が必要です。手薄な確率帯をピンポイントで補強してください。" | |
| if advice: | |
| st.markdown("---") | |
| st.success(f"**💡 戦略へのヒント:** {advice}") | |
| # 【最重要】タブ10への受け渡し用構造化データを完全に同期・格納 | |
| if sid not in st.session_state.tab4_results: | |
| st.session_state.tab4_results[sid] = {"departments": {}} | |
| st.session_state.tab4_results[sid]["departments"][dept_key] = { | |
| "total_seats": int(s_total), | |
| "risk_label": risk_label, | |
| "market_assessment": full_assessment, # エリア全体の市場傾向をここに紐付け | |
| "trend_text": trend_text, | |
| "advice_text": advice if is_target else "" | |
| } | |
| else: | |
| st.warning("データが準備できていません。") | |
| # ========================================================= | |
| # --- 🎰 タブ5: メーカー別設置状況 --- | |
| # ========================================================= | |
| with tabs["🏢 メーカー別"]: | |
| st.subheader("🏭 メーカー別設置シェア比較") | |
| # 事前集計済みのメーカーマスタを読み込み | |
| def load_maker_data(): | |
| return pd.read_parquet("maker_summary_master.parquet") | |
| df_maker_all = load_maker_data() | |
| if "processed_df" in st.session_state: | |
| # 必要な店舗情報・エリア情報の取得 | |
| sorted_neighbor = neighbor_df.sort_values("distance") | |
| area_shop_ids = sorted_neighbor["店舗ID"].astype(str).tolist() | |
| name_map = dict(zip(sorted_neighbor["店舗ID"].astype(str), sorted_neighbor["店舗名"])) | |
| selected_shop_id_str = sid_query | |
| categories = {"🔴 通常P": "通常P", "🟢 低貸P": "低貸P", "🔵 通常S": "通常S", "🟡 低貸S": "低貸S"} | |
| sub_tabs = st.tabs(list(categories.keys())) | |
| for i, (tab_label, dept_name) in enumerate(categories.items()): | |
| with sub_tabs[i]: | |
| # マスタから対象部門のみ抽出 | |
| df_dept_maker = df_maker_all[df_maker_all["貸玉区分"] == dept_name] | |
| if df_dept_maker.empty: | |
| st.info(f"{dept_name} のメーカーデータがありません。") | |
| continue | |
| # エリア内店舗に絞り込み | |
| df_area = df_dept_maker[df_dept_maker["店舗ID"].isin(area_shop_ids)] | |
| # ピボット作成(メーカー × 店舗) | |
| pivot_area = df_area.groupby(["メーカー名", "店舗ID"])["設置台数"].sum().unstack(fill_value=0) | |
| # データ内に存在する店舗IDのみを抽出 | |
| existing_ids = [sid for sid in area_shop_ids if sid in pivot_area.columns] | |
| pivot_area = pivot_area[existing_ids] | |
| # カラム名変換(自店ラベル付与) | |
| pivot_area.columns = [ | |
| f"★自店: {name_map.get(sid, sid)}" if sid == selected_shop_id_str else name_map.get(sid, sid) | |
| for sid in pivot_area.columns | |
| ] | |
| # 全国平均・エリア平均の算出 | |
| national_avg = (df_dept_maker.groupby("メーカー名")["設置台数"].sum() / df_dept_maker["店舗ID"].nunique()).rename("🌏 全国平均") | |
| area_avg = (df_area.groupby("メーカー名")["設置台数"].sum() / len(existing_ids)).rename("🏘️ エリア平均") | |
| # 結合とメーカー並び替え(全国平均が多い順) | |
| df_base = pd.concat([national_avg, area_avg, pivot_area], axis=1).fillna(0) | |
| df_base = df_base.sort_values(by="🌏 全国平均", ascending=False) | |
| # グラフ用:メーカー順序 | |
| maker_order = df_base.index.tolist() | |
| # グラフ用:店舗順序(全国・エリアを先頭にし、あとはデータにある店舗を並べる) | |
| final_order = ["🌏 全国平均", "🏘️ エリア平均"] + pivot_area.columns.tolist() | |
| # 表示用データの作成 | |
| df_disp_count = df_base.T | |
| df_disp_ratio = (df_base / df_base.sum() * 100).fillna(0).T | |
| # --- テーブル表示 --- | |
| st.markdown("#### 🔢 メーカー別設置台数(台)") | |
| st.dataframe(df_disp_count.style.format("{:.0f}"), use_container_width=True) | |
| st.markdown("#### 🏁 メーカー別構成比率(%)") | |
| comparison_target = st.radio("太字の基準を選択:", ["🌏 全国平均", "🏘️ エリア平均"], horizontal=True, key=f"maker_bold_{dept_name}") | |
| def highlight_maker_ratio(column): | |
| target_val = df_disp_ratio.loc[comparison_target, column.name] | |
| return ['font-weight: bold; color: #1f77b4;' if val > target_val else '' for val in column] | |
| styled_ratio = df_disp_ratio.style.apply(highlight_maker_ratio, axis=0).format("{:.1f}%") | |
| st.dataframe(styled_ratio, use_container_width=True) | |
| # --- グラフ表示(実数と比率) --- | |
| for title, df_data, x_col in [("🔢 設置台数(実数)", df_disp_count, "台数"), ("🏁 構成比率(100%積立)", df_disp_ratio, "比率")]: | |
| st.markdown(f"##### {title}") | |
| # 指定の順序で取得し、Plotly用に反転させる | |
| df_graph = df_data.loc[final_order].iloc[::-1].reset_index().rename(columns={'index': '店舗名'}) | |
| df_melt = df_graph.melt(id_vars="店舗名", var_name="タイプ", value_name=x_col) | |
| fig = px.bar( | |
| df_melt, x=x_col, y="店舗名", color="タイプ", orientation='h', | |
| category_orders={"店舗名": final_order[::-1], "タイプ": maker_order}, | |
| color_discrete_sequence=px.colors.qualitative.Pastel, | |
| height=dynamic_height, text_auto='.1f' | |
| ) | |
| fig.update_layout( | |
| xaxis_title=f"{title.split('(')[1].replace(')', '')}", yaxis_title="", | |
| legend_title="メーカー", margin=dict(l=0, r=200, t=50, b=40), | |
| legend=dict(orientation="v", yanchor="top", y=1, xanchor="left", x=1.02, font=dict(size=10)) | |
| ) | |
| fig.update_yaxes(categoryorder='array', categoryarray=final_order[::-1]) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # --- 分析コメントセクション --- | |
| st.markdown(f"### 📝 {dept_name} の分析コメント") | |
| curr_area_avg = df_disp_ratio.loc["🏘️ エリア平均"] | |
| curr_nat_avg = df_disp_ratio.loc["🌏 全国平均"] | |
| curr_diff = curr_area_avg - curr_nat_avg | |
| top_maker = curr_diff.idxmax() | |
| bot_maker = curr_diff.idxmin() | |
| st.info(f"**🏘️ {dept_name} 商圏全体のアセスメント**\n" | |
| f"商圏全体では全国平均と比較して **{top_maker}**(+{curr_diff[top_maker]:.1f}pt)の設置比率が最も高く、" | |
| f"逆に **{bot_maker}**({curr_diff[bot_maker]:.1f}pt)の設置比率が最も低くなっています。") | |
| st.markdown(f"#### 🏢 {dept_name} 各店舗の詳細分析") | |
| for shop_name in df_disp_ratio.index: | |
| if shop_name in ["🌏 全国平均", "🏘️ エリア平均"]: | |
| continue | |
| shop_diff = df_disp_ratio.loc[shop_name] - curr_nat_avg | |
| plus_maker = shop_diff.idxmax() | |
| minus_maker = shop_diff.idxmin() | |
| with st.expander(f"📌 {shop_name} の特徴"): | |
| st.write(f"全国平均と比較し、この部門では **{plus_maker}** の比率が **+{shop_diff[plus_maker]:.1f}pt** と最も高く、" | |
| f"逆に **{minus_maker}** の比率は **{shop_diff[minus_maker]:.1f}pt** となっています。") | |
| major_plus = shop_diff[shop_diff > 5.0].index.tolist() | |
| major_minus = shop_diff[shop_diff < -5.0].index.tolist() | |
| if major_plus or major_minus: | |
| details = [] | |
| if major_plus: details.append(f"プラス乖離: {', '.join(major_plus)}") | |
| if major_minus: details.append(f"マイナス乖離: {', '.join(major_minus)}") | |
| st.write(f"【乖離5pt以上の項目】: {'; '.join(details)}") | |
| else: | |
| st.write("この部門において、特に極端なメーカー偏重は見られず、全国平均に近いバランス構成です。") | |
| # ========================================================= | |
| # 🔥 タブ10からも呼び出せるように外側に定義 | |
| # ========================================================= | |
| def calculate_shindai_analysis(shindai_df, neighbor_df, target_kind, target_unit): | |
| import numpy as np | |
| import pandas as pd | |
| if shindai_df is None or neighbor_df.empty: | |
| return None | |
| df_copy = shindai_df.copy() | |
| if not pd.api.types.is_datetime64_any_dtype(df_copy["導入月"]): | |
| df_copy["導入月"] = pd.to_datetime(df_copy["導入月"], errors='coerce') | |
| neighbor_ids = neighbor_df["店舗ID"].unique().tolist() | |
| view_df = df_copy[df_copy["店舗ID"].isin(neighbor_ids)].copy() | |
| if view_df.empty: | |
| return None | |
| # 表示軸の補正 | |
| if target_unit == "月次": | |
| view_df["表示軸"] = view_df["導入月"].dt.strftime('%Y/%m') | |
| else: | |
| view_df["半年"] = view_df["導入月"].apply(lambda x: f"{x.year}H1" if x.month <= 6 else f"{x.year}H2") | |
| view_df["表示軸"] = view_df["半年"] | |
| # 集計 | |
| agg_df = view_df.groupby(["店舗ID", "表示軸", "種別"]).agg({"導入台数": "sum", "分母台数": "mean"}).reset_index() | |
| if target_kind == "店舗全体": | |
| plot_df = agg_df[agg_df["種別"].isin(["パチンコ", "スロット"])].groupby(["店舗ID", "表示軸"]).agg({"導入台数": "sum", "分母台数": "sum"}).reset_index() | |
| plot_df["種別"] = "店舗全体" | |
| else: | |
| plot_df = agg_df[agg_df["種別"] == target_kind].copy() | |
| if plot_df.empty: | |
| return None | |
| plot_df["比率"] = np.where(plot_df["分母台数"] > 0, plot_df["導入台数"] / plot_df["分母台数"], 0) | |
| shop_info = neighbor_df[["店舗ID", "店舗名", "distance"]].drop_duplicates("店舗ID") | |
| plot_df = pd.merge(plot_df, shop_info, on="店舗ID", how="inner") | |
| ordered_shop_names = shop_info.sort_values("distance")["店舗名"].tolist() | |
| self_shop_name = ordered_shop_names[0] if ordered_shop_names else "" | |
| all_axes_asc = sorted(plot_df["表示軸"].unique()) | |
| if not all_axes_asc: return None | |
| latest_axis = all_axes_asc[-1] | |
| latest_data = plot_df[plot_df["表示軸"] == latest_axis].copy() | |
| if latest_data.empty: | |
| return None | |
| area_avg_qty = latest_data["導入台数"].mean() | |
| area_avg_ratio = latest_data["導入台数"].sum() / latest_data["分母台数"].sum() if latest_data["分母台数"].sum() > 0 else 0 | |
| latest_data["qty_rank"] = latest_data["導入台数"].rank(ascending=False, method='min').astype(int) | |
| latest_data["ratio_rank"] = latest_data["比率"].rank(ascending=False, method='min').astype(int) | |
| stats_dict = latest_data.set_index("店舗名").to_dict('index') | |
| shop_analysis_results = {} | |
| for sname in ordered_shop_names: | |
| s_info = stats_dict.get(sname, {"比率": 0, "導入台数": 0, "qty_rank": 0, "ratio_rank": 0}) | |
| s_qty, s_ratio = s_info["導入台数"], s_info["比率"] | |
| q_rank, r_rank = s_info["qty_rank"], s_info["ratio_rank"] | |
| if s_qty >= area_avg_qty * 3.0: | |
| status, base_msg = "🏛️ エリア独占型", "エリア平均を圧倒する導入。物量の力で商圏シェアを強制奪取する戦略です。" | |
| elif s_qty >= area_avg_qty * 1.8: | |
| status, base_msg = "⚔️ 積極攻勢型", "平均を大きく引き離す物量。競合の客を吸い出す攻撃的姿勢が鮮明です。" | |
| elif s_qty >= area_avg_qty * 1.1: | |
| status, base_msg = "⚖️ 先行優位型", "平均を上回る台数を確保し、エリア内の鮮度順位を高く維持しています。" | |
| elif s_qty >= area_avg_qty * 0.7: | |
| status, base_msg = "⚖️ 市場追従型", "エリア平均に歩調を合わせた導入。決定的な差をつけられない均衡戦略です。" | |
| elif s_qty > 0: | |
| status, base_msg = "🎯 少数精鋭型", "台数を絞り、特定機種のファンを確実に囲い込む効率重視の戦法です。" | |
| else: | |
| status, base_msg = "💤 導入見送り", "今期は導入なし。次回の大型版権への備えか、回収期と推察されます。" | |
| sub_msg = "" | |
| if s_qty > area_avg_qty and s_ratio < area_avg_ratio: | |
| sub_msg = " \n> 💡 **大規模店の物量作戦:** 台数はエリア上位ですが、設置規模が大きいため入替率慎重。物量の絶対値で威圧しています。" | |
| elif s_qty < area_avg_qty and s_ratio > area_avg_ratio: | |
| sub_msg = " \n> 💡 **高回転ゲリラ戦:** 台数は平均以下ですが、入替比率は高水準。少ない台数で店内の鮮度を演出しています。" | |
| shop_trend_text = f"【店舗傾向:{sname}】\n{base_msg}" | |
| shop_hint_text = f"💡 戦略へのヒント: {sub_msg.replace('> ', '').strip() if sub_msg else '現在のバランスを維持し、主要機種のエリア順位を上げる検討を行ってください。'}" | |
| shop_analysis_results[sname] = { | |
| "status": status, | |
| "qty_rank": q_rank, | |
| "ratio_rank": r_rank, | |
| "導入台数": f"{int(s_qty)}台", | |
| "導入比率": f"{s_ratio:.1%}", | |
| "trend_analysis": shop_trend_text, | |
| "strategy_hint": shop_hint_text | |
| } | |
| # ★修正:市場環境アセスメントの文字列を生成して追加 | |
| coef = 6.0 if target_unit == "半年" else 1.0 | |
| if area_avg_qty >= 25 * coef: | |
| market_assessment = "💣 【極限激戦:物量飽和市場】" | |
| assessment_detail = f"平均 {area_avg_qty:.1f}台。特定法人の過剰投資がエリア全体のコスト基準を破壊しており、消耗戦が避けられない状態です。" | |
| elif area_avg_qty >= 15 * coef: | |
| market_assessment = "⚠️ 【激戦:先行投資市場】" | |
| assessment_detail = f"平均 {area_avg_qty:.1f}台。話題機への大量導入が必須化しており、新台集客への依存度が非常に高い環境です。" | |
| elif area_avg_qty >= 10 * coef: | |
| market_assessment = "🔔 【活発:トレンド市場】" | |
| assessment_detail = f"平均 {area_avg_qty:.1f}台。標準的な鮮度維持が前提。主力機種の入れ替えで客数が大きく変動する、動きのある市場です。" | |
| elif area_avg_qty >= 5 * coef: | |
| market_assessment = "🍀 【堅実:厳選運用市場】" | |
| assessment_detail = f"平均 {area_avg_qty:.1f}台。大量導入によるリスクを避け、ヒット機種を確実に拾う安定志向の店舗が多い傾向です。" | |
| else: | |
| market_assessment = "💎 【省エネ:安定運用市場】" | |
| assessment_detail = f"平均 {area_avg_qty:.1f}台。新台の物量による集客が弱く、既存機種の育成や固定客への還元が鍵を握ります。" | |
| return { | |
| "kind": target_kind, | |
| "unit": target_unit, | |
| "axis": latest_axis, | |
| "market_assessment": market_assessment, | |
| "assessment_detail": assessment_detail, | |
| "shop_analyses": shop_analysis_results | |
| } | |
| # ========================================================= | |
| # --- Tab 6: 新台導入分析 --- | |
| # ========================================================= | |
| with tabs["✨ 新台"]: | |
| def render_shindai_tab(shindai_df, neighbor_df): | |
| import numpy as np | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| if shindai_df is None: | |
| st.error("新台データが読み込まれていません。") | |
| return | |
| # --- 1. データの前処理 --- | |
| shindai_df = shindai_df.copy() | |
| if not pd.api.types.is_datetime64_any_dtype(shindai_df["導入月"]): | |
| shindai_df["導入月"] = pd.to_datetime(shindai_df["導入月"], errors='coerce') | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| all_kinds = sorted([k for k in shindai_df["種別"].unique() if k != "店舗全体"]) | |
| selected_kind = st.selectbox("集計種別", ["店舗全体"] + all_kinds, key="kind_tab6_v10") | |
| with c2: | |
| time_unit = st.radio("集計単位", ["月次", "半年"], horizontal=True, key="unit_tab6_v10") | |
| neighbor_ids = neighbor_df["店舗ID"].unique().tolist() | |
| view_df = shindai_df[shindai_df["店舗ID"].isin(neighbor_ids)].copy() | |
| if view_df.empty: | |
| st.warning("選択された商圏内に新台データが存在しません。") | |
| return | |
| if time_unit == "月次": | |
| view_df["表示軸"] = view_df["導入月"].dt.strftime('%Y/%m') | |
| else: | |
| view_df["半年"] = view_df["導入月"].apply(lambda x: f"{x.year}H1" if x.month <= 6 else f"{x.year}H2") | |
| view_df["表示軸"] = view_df["半年"] | |
| # --- 2. データの集計 --- | |
| agg_df = view_df.groupby(["店舗ID", "表示軸", "種別"]).agg({"導入台数": "sum", "分母台数": "mean"}).reset_index() | |
| if selected_kind == "店舗全体": | |
| plot_df = agg_df[agg_df["種別"].isin(["パチンコ", "スロット"])].groupby(["店舗ID", "表示軸"]).agg({"導入台数": "sum", "分母台数": "sum"}).reset_index() | |
| plot_df["種別"] = "店舗全体" | |
| else: | |
| plot_df = agg_df[agg_df["種別"] == selected_kind].copy() | |
| plot_df["比率"] = np.where(plot_df["分母台数"] > 0, plot_df["導入台数"] / plot_df["分母台数"], 0) | |
| shop_info = neighbor_df[["店舗ID", "店舗名", "distance"]].drop_duplicates("店舗ID") | |
| plot_df = pd.merge(plot_df, shop_info, on="店舗ID", how="inner") | |
| ordered_shop_names = shop_info.sort_values("distance")["店舗名"].tolist() | |
| self_shop_name = ordered_shop_names[0] if ordered_shop_names else "" | |
| all_axes_asc = sorted(plot_df["表示軸"].unique()) | |
| all_axes_desc = all_axes_asc[::-1] | |
| if not all_axes_asc: return | |
| latest_axis = all_axes_asc[-1] | |
| # --- 3. 【順序1】導入台数テーブル --- | |
| st.write(f"### 📊 {selected_kind} 導入台数実績") | |
| pivot_count = plot_df.pivot_table(index="店舗名", columns="表示軸", values="導入台数", aggfunc="sum", fill_value=0).reindex(index=ordered_shop_names, columns=all_axes_desc, fill_value=0) | |
| area_qty_mean_series = pivot_count.mean(axis=0).round(1) | |
| area_qty_mean_row = area_qty_mean_series.to_frame(name="🚩 エリア平均").T | |
| st.dataframe(pd.concat([area_qty_mean_row, pivot_count]), use_container_width=True) | |
| # --- 4. 【順序2】導入台数グラフ --- | |
| st.write(f"### 📈 {selected_kind} 導入台数推移グラフ") | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=all_axes_asc, y=[area_qty_mean_series.get(ax, 0) for ax in all_axes_asc], | |
| mode='lines+markers', name='🚩 エリア平均', | |
| line=dict(color='red', width=4, dash='dot'), | |
| marker=dict(size=10) | |
| )) | |
| for sname in ordered_shop_names: | |
| s_data = plot_df[plot_df["店舗名"] == sname].set_index("表示軸").reindex(all_axes_asc).fillna(0) | |
| is_self = (sname == self_shop_name) | |
| fig.add_trace(go.Scatter( | |
| x=all_axes_asc, y=s_data["導入台数"], | |
| mode='lines+markers', name=f"{sname}{' (★)' if is_self else ''}", | |
| line=dict(width=5 if is_self else 1.5, color='#1f77b4' if is_self else None), | |
| opacity=1.0 if is_self else 0.6 | |
| )) | |
| fig.update_layout( | |
| yaxis_title="導入台数 (台)", hovermode="x unified", | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), height=500 | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # --- 5. 【順序3】導入比率テーブル --- | |
| st.write(f"### 📉 {selected_kind} 導入比率推移(%)") | |
| pivot_ratio = plot_df.pivot_table(index="店舗名", columns="表示軸", values="比率", aggfunc="mean", fill_value=0).reindex(index=ordered_shop_names, columns=all_axes_desc, fill_value=0) | |
| area_ratio_mean_row = pivot_ratio.mean(axis=0).to_frame(name="🚩 エリア平均").T | |
| st.dataframe(pd.concat([area_ratio_mean_row, pivot_ratio]).style.format("{:.1%}", na_rep="-"), use_container_width=True) | |
| # --- 6. 市場アセスメント --- | |
| st.write("---") | |
| latest_data = plot_df[plot_df["表示軸"] == latest_axis].copy() | |
| if not latest_data.empty: | |
| area_avg_qty = latest_data["導入台数"].mean() | |
| area_avg_ratio = latest_data["導入台数"].sum() / latest_data["分母台数"].sum() if latest_data["分母台数"].sum() > 0 else 0 | |
| coef = 6.0 if time_unit == "半年" else 1.0 | |
| st.markdown(f"#### 📊 市場環境アセスメント ({latest_axis})") | |
| if area_avg_qty >= 25 * coef: | |
| st.error(f"💣 **【極限激戦:物量飽和市場】** 平均 **{area_avg_qty:.1f}台**。特定法人の過剰投資がエリア全体のコスト基準を破壊しており、消耗戦が避けられない状態です。") | |
| elif area_avg_qty >= 15 * coef: | |
| st.error(f"⚠️ **【激戦:先行投資市場】** 平均 **{area_avg_qty:.1f}台**。話題機への大量導入が必須化しており、新台集客への依存度が非常に高い環境です。") | |
| elif area_avg_qty >= 10 * coef: | |
| st.warning(f"🔔 **【活発:トレンド市場】** 平均 **{area_avg_qty:.1f}台**。標準的な鮮度維持が前提。主力機種の入れ替えで客数が大きく変動する、動きのある市場です。") | |
| elif area_avg_qty >= 5 * coef: | |
| st.info(f"🍀 **【堅実:厳選運用市場】** 平均 **{area_avg_qty:.1f}台**。大量導入によるリスクを避け、ヒット機種を確実に拾う安定志向の店舗が多い傾向です。") | |
| else: | |
| st.success(f"💎 **【省エネ:安定運用市場】** 平均 **{area_avg_qty:.1f}台**。新台の物量による集客が弱く、既存機種の育成や固定客への還元が鍵を握ります。") | |
| # --- 7. 店舗別詳細分析 --- | |
| st.write("---") | |
| st.markdown(f"🤖 **導入戦略詳細分析(台数優先)**") | |
| latest_data["qty_rank"] = latest_data["導入台数"].rank(ascending=False, method='min').astype(int) | |
| latest_data["ratio_rank"] = latest_data["比率"].rank(ascending=False, method='min').astype(int) | |
| stats_dict = latest_data.set_index("店舗名").to_dict('index') | |
| # 💡 全店舗の「構成分析・戦略ヒント」を溜め込むための辞書を初期化 | |
| shop_analysis_results = {} | |
| for sname in ordered_shop_names: | |
| s_info = stats_dict.get(sname, {"比率": 0, "導入台数": 0, "qty_rank": 0, "ratio_rank": 0}) | |
| s_qty, s_ratio = s_info["導入台数"], s_info["比率"] | |
| q_rank, r_rank = s_info["qty_rank"], s_info["ratio_rank"] | |
| is_self = " (★自店)" if sname == self_shop_name else "" | |
| if s_qty >= area_avg_qty * 3.0: | |
| status, base_msg = "🏛️ エリア独占型", "エリア平均を圧倒する導入。物量の力で商圏シェアを強制奪取する戦略です。" | |
| elif s_qty >= area_avg_qty * 1.8: | |
| status, base_msg = "⚔️ 積極攻勢型", "平均を大きく引き離す物量。競合の客を吸い出す攻撃的姿勢が鮮明です。" | |
| elif s_qty >= area_avg_qty * 1.1: | |
| status, base_msg = "⚖️ 先行優位型", "平均を上回る台数を確保し、エリア内の鮮度順位を高く維持しています。" | |
| elif s_qty >= area_avg_qty * 0.7: | |
| status, base_msg = "⚖️ 市場追従型", "エリア平均に歩調を合わせた導入。決定的な差をつけられない均衡戦略です。" | |
| elif s_qty > 0: | |
| status, base_msg = "🎯 少数精鋭型", "台数を絞り、特定機種のファンを確実に囲い込む効率重視の戦法です。" | |
| else: | |
| status, base_msg = "💤 導入見送り", "今期は導入なし。次回の大型版権への備えか、回収期と推察されます。" | |
| sub_msg = "" | |
| if s_qty > area_avg_qty and s_ratio < area_avg_ratio: | |
| sub_msg = " \n> 💡 **大規模店の物量作戦:** 台数はエリア上位ですが、設置規模が大きいため入替率は慎重。物量の絶対値で威圧しています。" | |
| elif s_qty < area_avg_qty and s_ratio > area_avg_ratio: | |
| sub_msg = " \n> 💡 **高回転ゲリラ戦:** 台数は平均以下ですが、入替比率は高水準。少ない台数で店内の鮮度を演出しています。" | |
| # 💡 【重要】店舗別の傾向分析テキストを合成 | |
| # (※もし「ハイスペック機を…」等の動的テキストを保持する独自の変数があれば、ここをその変数に置き換えてください) | |
| shop_trend_text = f"【店舗傾向:{sname}】\n{base_msg}" | |
| shop_hint_text = f"💡 戦略へのヒント: {sub_msg.replace('> ', '').strip() if sub_msg else '現在のバランスを維持し、主要機種のエリア順位を上げる検討を行ってください。'}" | |
| # 💡 辞書に店舗名キーで情報を格納(複数店舗分がすべて保存されます) | |
| shop_analysis_results[sname] = { | |
| "status": status, | |
| "qty_rank": q_rank, | |
| "ratio_rank": r_rank, | |
| "導入台数": f"{int(s_qty)}台", | |
| "導入比率": f"{s_ratio:.1%}", | |
| "trend_analysis": shop_trend_text, | |
| "strategy_hint": shop_hint_text | |
| } | |
| with st.expander(f"📍 {sname}{is_self} - {status} (台数{q_rank}位)"): | |
| st.markdown(f"#### {base_msg}") | |
| if sub_msg: st.markdown(sub_msg) | |
| c_a, c_b = st.columns(2) | |
| with c_a: | |
| st.metric("導入台数", f"{int(s_qty)}台", f"{s_qty - area_avg_qty:+.1f}台 (平均差)") | |
| st.write(f"エリア台数順位: **{q_rank}位** / {len(ordered_shop_names)}店") | |
| with c_b: | |
| st.metric("導入比率", f"{s_ratio:.1%}", f"{s_ratio - area_avg_ratio:+.1%} (平均差)") | |
| st.write(f"エリア比率順位: **{r_rank}位** / {len(ordered_shop_names)}店") | |
| # --- 🔥 バックグラウンドで全6パターンを一発自動事前計算 --- | |
| shindai_data_raw = raw_dfs.get("新台データ") | |
| if shindai_data_raw is not None and not neighbor_df.empty: | |
| target_shop_id = str(selected_shop_id).strip() | |
| if "tab6_results" not in st.session_state: | |
| st.session_state.tab6_results = {} | |
| if target_shop_id not in st.session_state.tab6_results: | |
| st.session_state.tab6_results[target_shop_id] = {} | |
| # ループを回して全6パターンを強制計算し、セッションに格納 | |
| for k in ["店舗全体", "パチンコ", "スロット"]: | |
| for u in ["月次", "半年"]: | |
| pkey = f"{k}_{u}" | |
| calc_data = calculate_shindai_analysis(shindai_data_raw, neighbor_df, k, u) | |
| if calc_data: | |
| st.session_state.tab6_results[target_shop_id][pkey] = calc_data | |
| # タブの描画実行 | |
| render_shindai_tab(raw_dfs.get("新台データ"), neighbor_df) | |
| # ========================================================= | |
| # --- 📱 タブ7: スマート遊技機分析 --- | |
| # ========================================================= | |
| with tabs["📱 スマート機"]: | |
| st.subheader("📱 スマート遊技機(スマスロ・スマパチ)普及状況分析") | |
| if "processed_df" in st.session_state: | |
| full_df = st.session_state.processed_df | |
| # サブタブの構成 | |
| sub_tab_labels = ["🌏 全体", "🎰 パチンコ全体", "🎰 スロット全体", "🔴 通常P", "🟢 低貸P", "🔵 通常S", "🟡 低貸S"] | |
| sub_tabs_smart = st.tabs(sub_tab_labels) | |
| categories_smart = { | |
| "🌏 全体": "全体", "🎰 パチンコ全体": "P全体", "🎰 スロット全体": "S全体", | |
| "🔴 通常P": "通常P", "🟢 低貸P": "低貸P", "🔵 通常S": "通常S", "🟡 低貸S": "低貸S" | |
| } | |
| # 都道府県名マップ | |
| pref_map = {1:"北海道", 2:"青森県", 3:"岩手県", 4:"宮城県", 5:"秋田県", 6:"山形県", 7:"福島県", 8:"茨城県", 9:"栃木県", 10:"群馬県", 11:"埼玉県", 12:"千葉県", 13:"東京都", 14:"神奈川県", 15:"新潟県", 16:"富山県", 17:"石川県", 18:"福井県", 19:"山梨県", 20:"長野県", 21:"岐阜県", 22:"静岡県", 23:"愛知県", 24:"三重県", 25:"滋賀県", 26:"京都府", 27:"大阪府", 28:"兵庫県", 29:"奈良県", 30:"和歌山県", 31:"鳥取県", 32:"島根県", 33:"岡山県", 34:"広島県", 35:"山口県", 36:"徳島県", 37:"香川県", 38:"愛媛県", 39:"高知県", 40:"福岡県", 41:"佐賀県", 42:"長崎県", 43:"熊本県", 44:"大分県", 45:"宮崎県", 46:"鹿児島県", 47:"沖縄県"} | |
| for i, label in enumerate(sub_tab_labels): | |
| with sub_tabs_smart[i]: | |
| dept_key = categories_smart[label] | |
| # 1. データのフィルタリング(エラー回避・安全版) | |
| if full_df is None or full_df.empty: | |
| df_target = pd.DataFrame(columns=full_df.columns if full_df is not None else []) | |
| elif dept_key == "全体": | |
| df_target = full_df.copy() | |
| elif dept_key == "P全体": | |
| if "区分ID" in full_df.columns: | |
| mask = full_df["区分ID"].fillna("").astype(str).str.startswith("1") | |
| df_target = full_df[mask].copy() | |
| else: | |
| df_target = pd.DataFrame(columns=full_df.columns) | |
| elif dept_key == "S全体": | |
| if "区分ID" in full_df.columns: | |
| mask = full_df["区分ID"].fillna("").astype(str).str.startswith("2") | |
| full_df[mask].copy() | |
| df_target = full_df[mask].copy() | |
| else: | |
| df_target = pd.DataFrame(columns=full_df.columns) | |
| else: | |
| if "貸玉区分" in full_df.columns: | |
| df_target = full_df[full_df["貸玉区分"] == dept_key].copy() | |
| else: | |
| df_target = pd.DataFrame(columns=full_df.columns) | |
| # ターゲットデータが空、または必要な列が無ければスキップ | |
| if df_target.empty: | |
| st.info(f"{label} のデータがありません。") | |
| continue | |
| # 2. 平均・エリアデータの抽出 | |
| if "店舗ID" in df_target.columns: | |
| df_area = df_target[df_target["店舗ID"].isin(area_shop_ids)] | |
| else: | |
| df_area = pd.DataFrame(columns=df_target.columns) | |
| pref_label = "🗺️ 都道府県平均" | |
| df_pref = pd.DataFrame() | |
| if "都道府県ID" in df_target.columns and "店舗ID" in df_target.columns: | |
| my_shop_data = df_target[df_target["店舗ID"].astype(str) == str(selected_shop_id_str)] | |
| if not my_shop_data.empty: | |
| p_code = my_shop_data["都道府県ID"].iloc[0] | |
| try: | |
| p_name = pref_map.get(int(float(p_code)), "都道府県") | |
| pref_label = f"🗾 {p_name}平均" | |
| except: pass | |
| df_pref = df_target[df_target["都道府県ID"] == p_code] | |
| # 3. 集計 | |
| def get_smart_metrics(df_src, label_name): | |
| if df_src is None or df_src.empty: return None | |
| n_shops = df_src["店舗ID"].nunique() | |
| if n_shops == 0: return None | |
| smart_sum = df_src[df_src["機体区分"] == "スマート機"]["設置台数"].sum() | |
| total_sum = df_src["設置台数"].sum() | |
| return pd.Series({ | |
| "総設置台数": total_sum / n_shops, | |
| "スマート機台数": smart_sum / n_shops, | |
| "スマート比率": (smart_sum / total_sum * 100) if total_sum > 0 else 0 | |
| }, name=label_name) | |
| all_rows = [] | |
| avg_data = [] | |
| # 各平均行の取得 | |
| national_row = get_smart_metrics(df_target, "🌏 全国平均") | |
| pref_row = get_smart_metrics(df_pref, pref_label) | |
| area_row = get_smart_metrics(df_area, "🏘️ エリア平均") | |
| # リストに追加(テーブル表示用) | |
| for r in [national_row, pref_row, area_row]: | |
| if r is not None: | |
| all_rows.append(r) | |
| avg_data.append(r) | |
| # エリア平均の値を保持(後続の判定用) | |
| area_avg_smart = 0 | |
| area_avg_ratio = 0 | |
| if area_row is not None: | |
| area_avg_smart = area_row["スマート機台数"] | |
| area_avg_ratio = area_row["スマート比率"] | |
| # 店舗別データ | |
| if not df_area.empty: | |
| pivot = df_area.groupby(["店舗ID", "機体区分"])["設置台数"].sum().unstack(fill_value=0) | |
| if "スマート機" not in pivot.columns: pivot["スマート機"] = 0 | |
| for sid in area_shop_ids: | |
| if sid in pivot.index: | |
| s_name = name_map.get(sid, sid) | |
| row_name = f"★自店: {s_name}" if str(sid) == str(selected_shop_id_str) else s_name | |
| s_smart = pivot.loc[sid, "スマート機"] | |
| s_total = pivot.loc[sid].sum() | |
| all_rows.append(pd.Series({ | |
| "総設置台数": float(s_total), | |
| "スマート機台数": float(s_smart), | |
| "スマート比率": (s_smart / s_total * 100) if s_total > 0 else 0 | |
| }, name=row_name)) | |
| # 4. 表示用のスタイル適用 | |
| if all_rows: | |
| # 一旦すべてをデータフレーム化 | |
| df_smart_table = pd.concat(all_rows, axis=1).T | |
| # --- 表示フォーマットの動的生成 --- | |
| # 平均行(インデックスに絵文字が含まれるもの)と店舗行を判別 | |
| formatted_data = df_smart_table.copy() | |
| for idx in formatted_data.index: | |
| is_avg = any(symbol in str(idx) for symbol in ["🌏", "🗾", "🏘️"]) | |
| if is_avg: | |
| # 平均行は小数点1位まで | |
| formatted_data.loc[idx, "総設置台数_disp"] = f"{df_smart_table.loc[idx, '総設置台数']:.1f}台" | |
| formatted_data.loc[idx, "スマート機台数_disp"] = f"{df_smart_table.loc[idx, 'スマート機台数']:.1f}台" | |
| else: | |
| # 店舗行は整数表示 | |
| formatted_data.loc[idx, "総設置台数_disp"] = f"{int(round(df_smart_table.loc[idx, '総設置台数'])):d}台" | |
| formatted_data.loc[idx, "スマート機台数_disp"] = f"{int(round(df_smart_table.loc[idx, 'スマート機台数'])):d}台" | |
| # 表示用カラムの入れ替え | |
| df_disp = df_smart_table.copy() | |
| df_disp["総設置台数"] = formatted_data["総設置台数_disp"] | |
| df_disp["スマート機台数"] = formatted_data["スマート機台数_disp"] | |
| def style_smart_table(res_df): | |
| styles = pd.DataFrame('', index=res_df.index, columns=res_df.columns) | |
| for idx in res_df.index: | |
| # 太字判定(数値の入った元の df_smart_table で比較) | |
| if df_smart_table.loc[idx, "スマート機台数"] > area_avg_smart: | |
| styles.loc[idx, "スマート機台数"] += "font-weight: bold;" | |
| if df_smart_table.loc[idx, "スマート比率"] > area_avg_ratio: | |
| styles.loc[idx, "スマート比率"] += "font-weight: bold;" | |
| # 背景色設定 | |
| if any(symbol in str(idx) for symbol in ["🌏", "🗾", "🏘️"]): | |
| styles.loc[idx, :] += "background-color: #f0f2f6;" | |
| if "★自店" in str(idx): | |
| styles.loc[idx, :] += "background-color: #fff3cd;" | |
| return styles | |
| st.markdown(f"#### 📋 スマート遊技機 設置状況一覧 ({label})") | |
| st.dataframe( | |
| df_disp.style.apply(style_smart_table, axis=None) | |
| .format({"スマート比率": "{:.1f}%"}), # 比率のみ一括指定 | |
| use_container_width=True, | |
| column_config={ | |
| "総設置台数": st.column_config.TextColumn("総設置台数", width=80), # 文字列化したのでTextColumn | |
| "スマート機台数": st.column_config.TextColumn("スマート機台数", width=80), | |
| "スマート比率": st.column_config.ProgressColumn("スマート比率", format="%.1f%%", min_value=0, max_value=100, width=200) | |
| } | |
| ) | |
| # --- 4. 市場アセスメント --- | |
| st.write("---") | |
| st.markdown(f"#### 📊 スマート機普及アセスメント ({label})") | |
| def get_val(row, key): | |
| if row is None or key not in row: return 0 | |
| return row[key] | |
| nat_ratio = get_val(national_row, "スマート比率") | |
| pref_ratio = get_val(pref_row, "スマート比率") | |
| diff_nat = area_avg_ratio - nat_ratio | |
| diff_pref = area_avg_ratio - pref_ratio | |
| eval_msg = f"エリア平均 **{area_avg_ratio:.1f}%**(全国差 {diff_nat:+.1f}% / 県差 {diff_pref:+.1f}%)" | |
| # ★エリア評価を先に確定させる | |
| if area_avg_ratio > nat_ratio + 10: | |
| area_status, area_msg = "🚀 【市場牽引:最先端エリア】", "全国平均を大きく上回るスピードでスマート化が進行中。" | |
| st.error(f"{area_status} {eval_msg} \n{area_msg}") | |
| elif area_avg_ratio > nat_ratio: | |
| area_status, area_msg = "⚠️ 【先行導入:高感度エリア】", "全国水準をリード。" | |
| st.warning(f"{area_status} {eval_msg} \n{area_msg}") | |
| elif area_avg_ratio >= nat_ratio - 5: | |
| area_status, area_msg = "🔔 【標準移行:平均的エリア】", "全国平均並みの進捗。" | |
| st.info(f"{area_status} {eval_msg} \n{area_msg}") | |
| else: | |
| area_status, area_msg = "💎 【独自運用:慎重派エリア】", "全国的に見てスマート化が緩やか。" | |
| st.success(f"{area_status} {eval_msg} \n{area_msg}") | |
| # --- 6. 店舗別詳細分析 (順位付け) --- | |
| st.write("---") | |
| st.markdown(f"🤖 **スマート機導入戦略分析**") | |
| # 店舗のみを抽出して順位計算 | |
| df_only_shops = df_smart_table[~df_smart_table.index.str.contains("🌏|🗾|🏘️")].copy() | |
| df_only_shops["qty_rank"] = df_only_shops["スマート機台数"].rank(ascending=False, method='min').astype(int) | |
| df_only_shops["ratio_rank"] = df_only_shops["スマート比率"].rank(ascending=False, method='min').astype(int) | |
| num_shops = len(df_only_shops) | |
| # エリア全体でスマート機が導入されているかチェック(ゼロ除算・判定エラー回避) | |
| has_smart_in_area = area_avg_ratio > 0 | |
| # ---- 💡 【タブ10連携用】初期化 ---- | |
| if "tab7_results" not in st.session_state: | |
| st.session_state.tab7_results = {} | |
| target_shop_id = str(selected_shop_id_str).strip() | |
| if target_shop_id not in st.session_state.tab7_results: | |
| st.session_state.tab7_results[target_shop_id] = {} | |
| for sname in df_only_shops.index: | |
| s_info = df_only_shops.loc[sname] | |
| s_qty = s_info["スマート機台数"] | |
| s_ratio = s_info["スマート比率"] | |
| q_rank = int(s_info["qty_rank"]) | |
| r_rank = int(s_info["ratio_rank"]) | |
| # 戦略判定ロジック | |
| if not has_smart_in_area: | |
| status, base_msg = "⚪ 未導入エリア", "エリア全体でスマート機の導入が確認されません。今後の動向に注目です。" | |
| elif s_ratio >= area_avg_ratio * 1.5: | |
| status, base_msg = "⚡ スマート特化型", "エリア平均を大きく上回る比率。次世代機への投資を最優先し、最新設備で差別化を図る戦略です。" | |
| elif s_qty >= area_avg_smart * 1.5: | |
| status, base_msg = "🏛️ 物量制圧型", "圧倒的な設置台数。エリア内のスマート機ユーザーを根こそぎ取り込む、規模のメリットを活かした戦略です。" | |
| elif s_ratio >= area_avg_ratio * 1.1: | |
| status, base_msg = "⚔️ 先進導入型", "平均以上にスマート機を重視。トレンドに敏感な層をターゲットにした、攻めの構成です。" | |
| elif s_ratio >= area_avg_ratio * 0.8: | |
| status, base_msg = "⚖️ 市場均衡型", "エリアの普及ペースに合わせたバランス重視の構成。リスクを抑えつつ鮮度を維持しています。" | |
| elif s_qty > 0: | |
| status, base_msg = "🎯 ポイント投資型", "台数は絞りつつも、主要な人気機種のみをピンポイントで導入する効率運用です。" | |
| else: | |
| status, base_msg = "💤 従来機固執型", "スマート機の導入が極めて限定的。あえて従来機のファン層に特化している、もしくは投資待機状態です。" | |
| # 処理中の店舗が「★自店」または選択された対象店舗名の場合のみ保存 | |
| is_target = "★自店" in str(sname) or str(selected_shop_id_str) in str(sname) | |
| if is_target: | |
| # 補足キャプションの取得 | |
| caption_msg = "" | |
| if s_qty > area_avg_smart and s_ratio < area_avg_ratio: | |
| caption_msg = "設置台数(絶対数)は多いですが、店舗全体の規模が大きいため比率は控えめに見えます。地域最大の「スマート機コーナー」を形成している可能性が高いです。" | |
| elif s_qty < area_avg_smart and s_ratio > area_avg_ratio: | |
| caption_msg = "台数自体は平均以下ですが、設置規模に対してスマート機の割合が高い「スマート機密度」の高い店舗です。" | |
| # サブタブ名(label = "🌏 全体" など)をキーにして対象店舗のデータだけを保存 | |
| st.session_state.tab7_results[target_shop_id][label] = { | |
| "label": label, | |
| "status": status, | |
| "base_msg": base_msg, | |
| "caption_msg": caption_msg, | |
| "s_qty": int(round(s_qty)), | |
| "s_ratio": s_ratio, | |
| "q_rank": q_rank, | |
| "r_rank": r_rank, | |
| "num_shops": num_shops, | |
| "diff_qty": s_qty - area_avg_smart, | |
| "diff_ratio": s_ratio - area_avg_ratio, | |
| "area_status": area_status, | |
| "area_msg": area_msg | |
| } | |
| with st.expander(f"📍 {sname} - {status} (台数{q_rank}位)"): | |
| st.markdown(f"#### {base_msg}") | |
| if s_qty > area_avg_smart and s_ratio < area_avg_ratio: | |
| st.caption("💡 **大規模店の戦略:** 設置台数(絶対数)は多いですが、店舗全体の規模が大きいため比率は控えめに見えます。地域最大の「スマート機コーナー」を形成している可能性が高いです。") | |
| elif s_qty < area_avg_smart and s_ratio > area_avg_ratio: | |
| st.caption("💡 **高密度移行戦略:** 台数自体は平均以下ですが、設置規模に対してスマート機の割合が高い「スマート機密度」の高い店舗です。") | |
| c_a, c_b = st.columns(2) | |
| with c_a: | |
| st.metric("スマート機台数", f"{int(round(s_qty))}台", f"{s_qty - area_avg_smart:+.1f}台 (平均差)") | |
| st.write(f"エリア台数順位: **{q_rank}位** / {num_shops}店") | |
| with c_b: | |
| st.metric("スマート比率", f"{s_ratio:,.1f}%", f"{s_ratio - area_avg_ratio:+.1f}% (平均差)") | |
| st.write(f"エリア比率順位: **{r_rank}位** / {num_shops}店") | |
| # ========================================================= | |
| # --- 🚀 [共通処理] タブ9・タブ10で使うデータを事前に一括計算 --- | |
| # ========================================================= | |
| try: | |
| # 1. データの準備(一括処理) | |
| if "processed_df" not in st.session_state: | |
| st.session_state.processed_df = pd.read_parquet("processed_analysis_data.parquet") | |
| df_val = st.session_state.processed_df.copy() | |
| # 数値変換と文字列変換を一括実行 | |
| df_val["中古単価_num"] = pd.to_numeric(df_val["中古単価"], errors='coerce').fillna(0) | |
| df_val["設置台数"] = pd.to_numeric(df_val["設置台数"], errors='coerce').fillna(0) | |
| df_val["資産総額"] = df_val["中古単価_num"] * df_val["設置台数"] | |
| df_val["店舗ID"] = df_val["店舗ID"].astype(str) | |
| selected_shop_id_str = str(selected_shop_id) | |
| # カテゴリマップ(集計用) | |
| cat_map = { | |
| "全体": ["通常P", "低貸P", "通常S", "低貸S"], | |
| "パチンコ通常": ["通常P"], | |
| "パチンコ低貸": ["低貸P"], | |
| "スロット通常": ["通常S"], | |
| "スロット低貸": ["低貸S"] | |
| } | |
| # 全店舗×貸玉区分の集計テーブル | |
| shop_cat_summary = df_val.groupby(["店舗ID", "貸玉区分"]).agg({ | |
| "資産総額": "sum", | |
| "設置台数": "sum" | |
| }).reset_index() | |
| def get_unit_row_fast(filtered_df, label_name): | |
| res = {"店舗名": label_name} | |
| for label, cat_list in cat_map.items(): | |
| target = filtered_df[filtered_df["貸玉区分"].isin(cat_list)] | |
| t_asset = target["資産総額"].sum() | |
| t_qty = target["設置台数"].sum() | |
| res[label] = t_asset / t_qty if t_qty > 0 else 0 | |
| return res | |
| # 全国平均 | |
| national_row = get_unit_row_fast(shop_cat_summary, "🌏 全国平均") | |
| # 都道府県平均 | |
| pref_row = None | |
| actual_p_col = next((c for c in df_val.columns if any(k in c for k in ["都道府県", "県名", "Addr1"])), None) | |
| if actual_p_col: | |
| my_pref_val = df_val.loc[df_val["店舗ID"] == selected_shop_id_str, actual_p_col].unique() | |
| if len(my_pref_val) > 0: | |
| my_pref_val = my_pref_val[0] | |
| pref_map = {1:"北海道", 2:"青森県", 3:"岩手県", 4:"宮城県", 5:"秋田県", 6:"山形県", 7:"福島県", 8:"茨城県", 9:"栃木県", 10:"群馬県", 11:"埼玉県", 12:"千葉県", 13:"東京都", 14:"神奈川県", 15:"新潟県", 16:"富山県", 17:"石川県", 18:"福井県", 19:"山梨県", 20:"長野県", 21:"岐阜県", 22:"静岡県", 23:"愛知県", 24:"三重県", 25:"滋賀県", 26:"京都府", 27:"大阪府", 28:"兵庫県", 29:"奈良県", 30:"和歌山県", 31:"鳥取県", 32:"島根県", 33:"岡山県", 34:"広島県", 35:"山口県", 36:"徳島県", 37:"香川県", 38:"愛媛県", 39:"高知県", 40:"福岡県", 41:"佐賀県", 42:"長崎県", 43:"熊本県", 44:"大分県", 45:"宮崎県", 46:"鹿児島県", 47:"沖縄県"} | |
| try: | |
| my_pref_name = pref_map.get(int(float(my_pref_val)), my_pref_val) | |
| except: | |
| my_pref_name = my_pref_val | |
| pref_row = get_unit_row_fast(df_val[df_val[actual_p_col] == my_pref_val], f"🗾 {my_pref_name}平均") | |
| if pref_row is None: | |
| pref_row = {**national_row, "店舗名": "🗾 都道府県平均(不明)"} | |
| # エリア平均 | |
| area_shop_ids = neighbor_df["店舗ID"].astype(str).tolist() | |
| df_area_machines = df_val[df_val["店舗ID"].isin(area_shop_ids)] | |
| area_row = get_unit_row_fast(df_area_machines, "🏘️ エリア平均") | |
| v_area_total = area_row["全体"] | |
| v_nat_total = national_row["全体"] | |
| v_pref_total = pref_row["全体"] | |
| # テキスト生成系関数 | |
| def get_diff_text(current, baseline): | |
| diff = current - baseline | |
| color = "red" if diff >= 0 else "blue" | |
| sign = "+" if diff >= 0 else "" | |
| return f":{color}[{sign}{diff:,.0f}円]" | |
| def get_dept_analysis(current, baseline, name, rank): | |
| if baseline == 0: return f"* {name}: データなし" | |
| ratio = current / baseline | |
| diff_txt = get_diff_text(current, baseline) | |
| if ratio > 1.2: status = "**:red[【突出】]** 商圏内で圧倒的な鮮度。集客の目玉。" | |
| elif ratio > 1.05: status = "**:red[【優位】]** 平均を上回る投資。安定した競争力。" | |
| elif ratio >= 0.95: status = "【標準】 平均的な構成。バランス重視。" | |
| elif ratio > 0.7: status = "**:blue[【効率】]** 償却機活用。コストを抑えた運用。" | |
| else: status = "**:blue[【低予算】]** 長期設置機メインの構成。" | |
| return f"* {name}: ¥{current:,.0f} (**{rank}位** / {diff_txt}) \n {status}" | |
| # 順位計算用の一括データフレーム作成 | |
| area_summary = shop_cat_summary[shop_cat_summary["店舗ID"].isin(area_shop_ids)] | |
| shop_results = [] | |
| self_p_qty = 0 | |
| self_s_qty = 0 | |
| self_vals = {} | |
| name_map = dict(zip(neighbor_df["店舗ID"].astype(str), neighbor_df["店舗名"])) | |
| for sid_str in area_shop_ids: | |
| sname = name_map.get(sid_str, "不明") | |
| shop_df = area_summary[area_summary["店舗ID"] == sid_str] | |
| res = get_unit_row_fast(shop_df, sname) | |
| res["店舗ID"] = sid_str | |
| if sid_str == selected_shop_id_str: | |
| res["店舗名"] = f"★自店: {sname}" | |
| self_vals = res | |
| self_p_qty = shop_df[shop_df["貸玉区分"] == "通常P"]["設置台数"].sum() | |
| self_s_qty = shop_df[shop_df["貸玉区分"] == "通常S"]["設置台数"].sum() | |
| shop_results.append(res) | |
| df_shops_only = pd.DataFrame(shop_results) | |
| num_shops = len(df_shops_only) | |
| rank_dicts = {} | |
| for col in cat_map.keys(): | |
| df_shops_only[f"{col}_rank"] = df_shops_only[col].rank(ascending=False, method='min').astype(int) | |
| rank_dicts[col] = dict(zip(df_shops_only["店舗ID"], df_shops_only[f"{col}_rank"])) | |
| best_p_avg = df_shops_only["パチンコ通常"].max() | |
| best_s_avg = df_shops_only["スロット通常"].max() | |
| # 💡 修正: globals() ではなく st.session_state を初期化 | |
| st.session_state['shop_analysis_master'] = {} | |
| shop_vals_dict = {str(item["店舗ID"]): item for item in shop_results} | |
| for _, target_shop in neighbor_df.iterrows(): | |
| t_id = str(target_shop["店舗ID"]) | |
| t_name = target_shop["店舗名"] | |
| dist = target_shop["distance"] | |
| s_vals = shop_vals_dict.get(t_id) | |
| if not s_vals: | |
| continue | |
| s_total = s_vals["全体"] | |
| t_rank = rank_dicts["全体"][t_id] | |
| diff_ratio = s_total / v_area_total if v_area_total > 0 else 1 | |
| if s_total == 0: | |
| status_tag, g_eval = "⚠️ データ無", "データが取得できませんでした" | |
| elif diff_ratio > 1.15: | |
| status_tag, g_eval = "🌟 超積極投資", "エリア平均を凌駕する投資で高単価層を狙う戦略" | |
| elif diff_ratio > 1.02: | |
| status_tag, g_eval = "📈 積極運用", "鮮度を高く保ち、競合に対し優位性を維持する運用" | |
| else: | |
| status_tag, g_eval = "⚖️ 標準/効率運用", "コストを抑え、償却機や中古機を活かす堅実な運用" | |
| comments = { | |
| "通常P": get_dept_analysis(s_vals["パチンコ通常"], area_row["パチンコ通常"], "🔴 通常パチンコ", rank_dicts["パチンコ通常"][t_id]), | |
| "通常S": get_dept_analysis(s_vals["スロット通常"], area_row["スロット通常"], "🟣 通常スロット", rank_dicts["スロット通常"][t_id]), | |
| "低貸P": get_dept_analysis(s_vals["パチンコ低貸"], area_row["パチンコ低貸"], "🔵 低貸パチンコ", rank_dicts["パチンコ低貸"][t_id]), | |
| "低貸S": get_dept_analysis(s_vals["スロット低貸"], area_row["スロット低貸"], "🟢 低貸スロット", rank_dicts["スロット低貸"][t_id]) | |
| } | |
| # 💡 修正: 生成した辞書を st.session_state へ安全に登録 | |
| st.session_state['shop_analysis_master'][t_id] = { | |
| "店舗名": t_name, | |
| "距離": dist, | |
| "全体資産価値": s_total, | |
| "全体順位": t_rank, | |
| "エリア比率": diff_ratio, | |
| "ステータスタグ": status_tag, | |
| "総合評価": g_eval, | |
| "数値データ": s_vals, | |
| "部門別コメント": comments | |
| } | |
| except Exception as e: | |
| st.error(f"データ事前処理エラー: {e}") | |
| import traceback | |
| st.sidebar.error(traceback.format_exc()) | |
| # ========================================================= | |
| # --- 🎰 タブ9: 資産価値 --- | |
| # ========================================================= | |
| with tabs["💰 資産価値"]: | |
| st.subheader("🏢 店舗別・1台あたり資産価値(平均単価)比較") | |
| try: | |
| # 【改善点】全量読み込みをやめ、集計済みマスタと基本データのみを使用 | |
| # アプリ全体で共有可能なキャッシュ読み込み | |
| def load_summary_data(): | |
| # 集計済みマスタを読み込み | |
| return pd.read_parquet("shop_summary_master.parquet") | |
| # 分析用のベースデータ | |
| shop_summary = load_summary_data() | |
| # 比較用の全国平均などは、shop_summary全体から算出可能 | |
| # これにより、個別の店舗データ(processed_analysis_data)をタブ9内で読み込む必要がなくなります | |
| selected_shop_id_str = str(selected_shop_id) | |
| # カテゴリマップ(既存ロジック維持) | |
| cat_map = { | |
| "全体": ["通常P", "低貸P", "通常S", "低貸S"], | |
| "パチンコ通常": ["通常P"], | |
| "パチンコ低貸": ["低貸P"], | |
| "スロット通常": ["通常S"], | |
| "スロット低貸": ["低貸S"] | |
| } | |
| # --- 🚀 高速集計エンジン (集計済みマスタを使用) --- | |
| def get_unit_row_from_summary(summary_df, target_shop_id=None): | |
| # 特定店舗または全国(全店舗)のデータをフィルタリング | |
| if target_shop_id: | |
| df = summary_df[summary_df["店舗ID"] == target_shop_id] | |
| else: | |
| df = summary_df | |
| res = {} | |
| for label, cat_list in cat_map.items(): | |
| target = df[df["貸玉区分"].isin(cat_list)] | |
| t_asset = target["資産総額"].sum() | |
| t_qty = target["設置台数"].sum() | |
| res[label] = t_asset / t_qty if t_qty > 0 else 0 | |
| return res | |
| # 各種平均を算出 | |
| national_row = get_unit_row_from_summary(shop_summary) | |
| national_row["店舗名"] = "🌏 全国平均" | |
| # エリア平均(neighbor_dfはメインの処理で取得済みと想定) | |
| area_shop_ids = neighbor_df["店舗ID"].astype(str).tolist() | |
| area_summary = shop_summary[shop_summary["店舗ID"].isin(area_shop_ids)] | |
| area_row = get_unit_row_from_summary(area_summary) | |
| area_row["店舗名"] = "🏘️ エリア平均" | |
| # --- 🎨 B. フォーマット・コメント関数 (既存仕様維持) --- | |
| def get_diff_text(current, baseline): | |
| diff = current - baseline | |
| color = "red" if diff >= 0 else "blue" | |
| sign = "+" if diff >= 0 else "" | |
| return f":{color}[{sign}{diff:,.0f}円]" | |
| def get_dept_analysis(current, baseline, name, rank): | |
| if baseline == 0: return f"* {name}: データなし" | |
| ratio = current / baseline | |
| diff_txt = get_diff_text(current, baseline) | |
| if ratio > 1.2: status = "**:red[【突出】]** 商圏内で圧倒的な鮮度。集客の目玉。" | |
| elif ratio > 1.05: status = "**:red[【優位】]** 平均を上回る投資。安定した競争力。" | |
| elif ratio >= 0.95: status = "【標準】 平均的な構成。バランス重視。" | |
| elif ratio > 0.7: status = "**:blue[【効率】]** 償却機活用。コストを抑えた運用。" | |
| else: status = "**:blue[【低予算】]** 長期設置機メインの構成。" | |
| return f"* {name}: ¥{current:,.0f} (**{rank}位** / {diff_txt}) \n {status}" | |
| # --- 📊 C. データ準備と順位計算 (高速版) --- | |
| # ループ内でのフィルタリングを避け、一括計算 | |
| area_summary = shop_cat_summary[shop_cat_summary["店舗ID"].isin(area_shop_ids)] | |
| shop_results = [] | |
| self_p_qty = 0 | |
| self_s_qty = 0 | |
| self_vals = {} | |
| # 店舗名マッピング | |
| name_map = dict(zip(neighbor_df["店舗ID"].astype(str), neighbor_df["店舗名"])) | |
| for sid_str in area_shop_ids: | |
| sname = name_map.get(sid_str, "不明") | |
| shop_df = area_summary[area_summary["店舗ID"] == sid_str] | |
| res = get_unit_row_fast(shop_df, sname) | |
| res["店舗ID"] = sid_str | |
| if sid_str == selected_shop_id_str: | |
| res["店舗名"] = f"★自店: {sname}" | |
| self_vals = res | |
| self_p_qty = shop_df[shop_df["貸玉区分"] == "通常P"]["設置台数"].sum() | |
| self_s_qty = shop_df[shop_df["貸玉区分"] == "通常S"]["設置台数"].sum() | |
| shop_results.append(res) | |
| df_shops_only = pd.DataFrame(shop_results) | |
| num_shops = len(df_shops_only) | |
| rank_dicts = {} | |
| for col in cat_map.keys(): | |
| df_shops_only[f"{col}_rank"] = df_shops_only[col].rank(ascending=False, method='min').astype(int) | |
| rank_dicts[col] = dict(zip(df_shops_only["店舗ID"], df_shops_only[f"{col}_rank"])) | |
| best_p_avg = df_shops_only["パチンコ通常"].max() | |
| best_s_avg = df_shops_only["スロット通常"].max() | |
| # --- 📊 D. 資産価値アセスメント (表示部は既存維持) --- | |
| st.markdown("---") | |
| st.markdown("#### 💎 エリア資産価値アセスメント") | |
| v_area_total = area_row["全体"] | |
| v_nat_total = national_row["全体"] | |
| v_pref_total = pref_row["全体"] | |
| diff_nat_txt = get_diff_text(v_area_total, v_nat_total) | |
| diff_pref_txt = get_diff_text(v_area_total, v_pref_total) | |
| eval_msg = f"エリア平均 **¥{v_area_total:,.0f}** (全国差 {diff_nat_txt} / 都道府県差 {diff_pref_txt})" | |
| if v_area_total > v_nat_total * 1.2: | |
| st.error(f"**【全体評価:超高単価市場】** {eval_msg} \n新台の導入スピードが極めて速く、最新鋭の機械構成が求められる激戦区です。") | |
| elif v_area_total >= v_nat_total: | |
| st.warning(f"**【全体評価:標準〜高単価市場】** {eval_msg} \n全国平均以上の鮮度を維持しており、一定の機械投資競争が発生している市場です。") | |
| else: | |
| st.info(f"**【全体評価:コスト抑制型市場】** {eval_msg} \n中古機や長期設置機が主流であり、新台よりも運用力や遊技環境が重視される傾向にあります。") | |
| st.markdown("**📝 4部門別・エリア市場環境分析**") | |
| def get_market_comment(area_val, nat_val): | |
| if nat_val == 0: return "データ不足により判定不可" | |
| ratio = area_val / nat_val | |
| if ratio > 1.25: return ":**:red[【新台過密】]** 全国屈指の最新台投入エリア。機械パワーでの差別化が困難。" | |
| elif ratio > 1.05: return ":**:red[【高鮮度】]** 全国平均より新台導入が活発。競争力維持のコストが高い。" | |
| elif ratio >= 0.95: return ":【標準的】 全国平均並の入れ替え頻度。バランスの良い市場。" | |
| elif ratio > 0.75: return ":**:blue[【安定運用】]** 無理な入替を抑え、定番機種を長く使う安定志向の市場。" | |
| else: return ":**:blue[【低付加価値】]** 償却機がメイン。設備投資よりも粗利管理が優先される傾向。" | |
| cols = st.columns(2) | |
| depts_info = [("🔴 通常パチンコ", "パチンコ通常"), ("🔵 通常スロット", "スロット通常"), ("🟢 低貸パチンコ", "パチンコ低貸"), ("🟡 低貸スロット", "スロット低貸")] | |
| for i, (label, col_key) in enumerate(depts_info): | |
| with cols[i % 2]: | |
| cur_val = area_row[col_key] | |
| st.markdown(f"**{label}**") | |
| st.write(f"**¥{cur_val:,.0f}**") | |
| st.caption(f"全国差: {get_diff_text(cur_val, national_row[col_key])} / 県差: {get_diff_text(cur_val, pref_row[col_key])}") | |
| st.write(get_market_comment(cur_val, national_row[col_key])) | |
| # --- 📊 E. データテーブル表示 (既存維持) --- | |
| st.markdown("---") | |
| # 1. 平均行の作成 | |
| df_avg_part = pd.DataFrame([national_row, pref_row, area_row]) | |
| # 2. 店舗行の作成とソート | |
| df_shops_part = pd.DataFrame(shop_results) | |
| df_shops_part = df_shops_part.sort_values(by="全体", ascending=False) | |
| # 3. 列順序を整えて結合(店舗名を一番左にする) | |
| cols_order = ["店舗名", "全体", "パチンコ通常", "パチンコ低貸", "スロット通常", "スロット低貸"] | |
| df_shops_part = df_shops_part.drop(columns=["店舗ID"], errors='ignore') | |
| # 結合 | |
| df_total_compare = pd.concat([df_avg_part, df_shops_part], ignore_index=True)[cols_order] | |
| # 店舗名をインデックスに設定(これで左端に固定され、右詰めが解消されます) | |
| df_styled = df_total_compare.set_index("店舗名") | |
| def apply_comparison_style(styler): | |
| # スタイル適用ロジック | |
| # インデックス(店舗名)ではなく、各列に対して計算 | |
| for col in styler.columns: | |
| baseline = area_row[col] | |
| # 各行の値を判定 | |
| styler.apply(lambda x: [ | |
| 'background-color: #f0f2f6; font-weight: bold;' if i < 3 else | |
| 'background-color: #fff3cd; border: 2px solid #ffc107; font-weight: bold;' if "★自店" in str(df_total_compare.iloc[i]["店舗名"]) else | |
| 'background-color: #d4edda; color: #155724; font-weight: bold;' if val > baseline and baseline > 0 else '' | |
| for i, val in enumerate(x) | |
| ], subset=[col]) | |
| return styler | |
| # 表示 | |
| st.dataframe( | |
| df_styled.style.pipe(apply_comparison_style) | |
| .format(formatter="¥{:,.0f}"), | |
| use_container_width=True | |
| ) | |
| # --- 🤖 F. 店舗別詳細 --- | |
| st.markdown("---") | |
| st.write(f"🤖 **商圏内 全{num_shops}店舗 資産価値・部門別詳細分析**") | |
| # shop_resultsをIDをキーにした辞書に変換(ループ内検索を高速化するため) | |
| shop_vals_dict = {str(item["店舗ID"]): item for item in shop_results} | |
| # 距離順にソートして表示 | |
| for _, target_shop in neighbor_df.sort_values("distance").iterrows(): | |
| t_id = str(target_shop["店舗ID"]) | |
| t_name = target_shop["店舗名"] | |
| dist = target_shop["distance"] | |
| is_self_flag = (t_id == selected_shop_id_str) | |
| is_self_label = " (★自店)" if is_self_flag else "" | |
| # shop_resultsからの検索を高速な辞書参照に変更 | |
| s_vals = shop_vals_dict.get(t_id) | |
| if not s_vals: | |
| continue | |
| s_total = s_vals["全体"] | |
| t_rank = rank_dicts["全体"][t_id] | |
| diff_ratio = s_total / v_area_total if v_area_total > 0 else 1 | |
| # 評価ロジック(既存のまま) | |
| if s_total == 0: | |
| status_tag, g_eval = "⚠️ データ無", "データが取得できませんでした" | |
| elif diff_ratio > 1.15: | |
| status_tag, g_eval = "🌟 超積極投資", "エリア平均を凌駕する投資で高単価層を狙う戦略" | |
| elif diff_ratio > 1.02: | |
| status_tag, g_eval = "📈 積極運用", "鮮度を高く保ち、競合に対し優位性を維持する運用" | |
| else: | |
| status_tag, g_eval = "⚖️ 標準/効率運用", "コストを抑え、償却機や中古機を活かす堅実な運用" | |
| # --- 表示部分 (以前の仕様を完全に復元) --- | |
| with st.expander(f"📍 {t_name}{is_self_label} - {status_tag} / {t_rank}位 ({dist:.1f}km)"): | |
| st.markdown(f"### {g_eval}") | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| st.write(f"**【総合評価】**") | |
| st.write(f"* 1台あたり平均: **¥{s_total:,.0f}**") | |
| st.write(f"* 全国平均差: {get_diff_text(s_total, national_row['全体'])}") | |
| st.write(f"* エリア順位: **{t_rank}位** / {num_shops}店舗") | |
| with c2: | |
| st.write(f"**【商圏ポジショニング】**") | |
| st.write(f"* エリア平均差: {get_diff_text(s_total, v_area_total)}") | |
| st.write(f"* エリア比率: **{diff_ratio:.1%}**") | |
| st.markdown("---") | |
| st.write("**【各部門別・エリア比較コメント】**") | |
| # 各部門の分析コメントを復元 | |
| st.write(get_dept_analysis(s_vals["パチンコ通常"], area_row["パチンコ通常"], "🔴 通常パチンコ", rank_dicts["パチンコ通常"][t_id])) | |
| st.write(get_dept_analysis(s_vals["スロット通常"], area_row["スロット通常"], "🟣 通常スロット", rank_dicts["スロット通常"][t_id])) | |
| st.write(get_dept_analysis(s_vals["パチンコ低貸"], area_row["パチンコ低貸"], "🔵 低貸パチンコ", rank_dicts["パチンコ低貸"][t_id])) | |
| st.write(get_dept_analysis(s_vals["スロット低貸"], area_row["スロット低貸"], "🟢 低貸スロット", rank_dicts["スロット低貸"][t_id])) | |
| # --- 🥇 G. エリア1位奪取シミュレーション (構造最適化) --- | |
| st.markdown("---") | |
| st.markdown("#### 🥇 エリア目標達成:現実的な機種構成案") | |
| target_mode = st.radio("目標基準", ["エリア1位奪取", "エリア平均超え", "目標金額を直接指定"], horizontal=True) | |
| # 目標値の決定ロジック | |
| if target_mode == "エリア1位奪取": | |
| buffer = st.select_slider("バッファ", options=[2000, 10000, 30000, 50000], value=2000) | |
| p_target = max(self_vals["パチンコ通常"], best_p_avg + buffer) if self_vals["パチンコ通常"] < best_p_avg else self_vals["パチンコ通常"] | |
| s_target = max(self_vals["スロット通常"], best_s_avg + buffer) if self_vals["スロット通常"] < best_s_avg else self_vals["スロット通常"] | |
| elif target_mode == "エリア平均超え": | |
| p_target, s_target = area_row["パチンコ通常"] + 5000, area_row["スロット通常"] + 5000 | |
| else: | |
| c1, c2 = st.columns(2) | |
| p_target = c1.number_input("P目標", value=int(area_row["パチンコ通常"]), step=10000) | |
| s_target = c2.number_input("S目標", value=int(area_row["スロット通常"]), step=10000) | |
| # 市場トップ機種を事前に1回だけ取得(高速化) | |
| market_tops = { | |
| "通常P": df_val[df_val["貸玉区分"] == "通常P"].groupby("機種名")["中古単価_num"].mean().sort_values(ascending=False).head(15), | |
| "通常S": df_val[df_val["貸玉区分"] == "通常S"].groupby("機種名")["中古単価_num"].mean().sort_values(ascending=False).head(15) | |
| } | |
| def sim_smart_goal_portfolio_fast(target_cat, self_qty, self_avg, t_avg): | |
| if self_qty <= 0: return "スペースデータなし" | |
| if self_avg >= t_avg: return "✅ 現在の構成ですでに目標を達成しています。" | |
| inventory = df_val[(df_val["店舗ID"] == selected_shop_id_str) & (df_val["貸玉区分"] == target_cat)].set_index("機種名")["設置台数"].to_dict() | |
| m_top = market_tops[target_cat] | |
| add_list, remaining, cur_sim_asset = [], self_qty, 0 | |
| max_per = min(20, max(1, int(self_qty * 0.1))) | |
| for name, price in m_top.items(): | |
| if remaining <= 0 or (cur_sim_asset + (remaining * self_avg)) / self_qty >= t_avg: break | |
| num = min(max_per, remaining) | |
| extra = max(0, num - inventory.get(name, 0)) | |
| if extra > 0: | |
| add_list.append({"追加導入機種": name, "単価": int(price), "追加数": f"{int(extra)}台", "(導入後合計)": f"{int(num)}台"}) | |
| cur_sim_asset += (price * num) | |
| remaining -= num | |
| if remaining > 0: cur_sim_asset += (self_avg * remaining) | |
| if not add_list: return "既存機種の構成変更のみで目標達成可能です。" | |
| return {"sim_avg": cur_sim_asset / self_qty, "details": pd.DataFrame(add_list)} | |
| sc1, sc2 = st.columns(2) | |
| with sc1: | |
| st.write(f"**🔴 パチンコ通常**") | |
| res_p = sim_smart_goal_portfolio_fast("通常P", self_p_qty, self_vals["パチンコ通常"], p_target) | |
| if isinstance(res_p, dict): | |
| st.metric("シミュレーション後", f"¥{int(res_p['sim_avg']):,}") | |
| st.table(res_p["details"].style.format({"単価": "¥{:,.0f}"})) | |
| else: st.success(res_p) | |
| with sc2: | |
| st.write(f"**🟣 スロット通常**") | |
| res_s = sim_smart_goal_portfolio_fast("通常S", self_s_qty, self_vals["スロット通常"], s_target) | |
| if isinstance(res_s, dict): | |
| st.metric("シミュレーション後", f"¥{int(res_s['sim_avg']):,}") | |
| st.table(res_s["details"].style.format({"単価": "¥{:,.0f}"})) | |
| else: st.success(res_s) | |
| # 💡 タブ9の末尾に追加:タブ10連携用のバックグラウンド保存 | |
| if "tab9_results" not in st.session_state: | |
| st.session_state.tab9_results = {} | |
| if self_vals: | |
| target_shop_id = str(selected_shop_id_str).strip() | |
| if v_area_total > v_nat_total * 1.2: | |
| area_eval_comment = "新台の導入スピードが極めて速く、最新鋭の機械構成が求められる激戦区です。" | |
| elif v_area_total >= v_nat_total: | |
| area_eval_comment = "全国平均以上の鮮度を維持しており、一定の機械投資競争が発生している市場です。" | |
| else: | |
| area_eval_comment = "中古機や長期設置機が主流であり、新台よりも運用力や遊技環境が重視される傾向にあります。" | |
| st.session_state.tab9_results[target_shop_id] = { | |
| "全体評価": self_vals["全体"], | |
| "エリア平均": v_area_total, | |
| "エリア評価コメント": area_eval_comment, | |
| "部門分析": { | |
| "通常P": get_dept_analysis(self_vals["パチンコ通常"], area_row["パチンコ通常"], "通常P", rank_dicts["パチンコ通常"][target_shop_id]), | |
| "通常S": get_dept_analysis(self_vals["スロット通常"], area_row["スロット通常"], "通常S", rank_dicts["スロット通常"][target_shop_id]), | |
| "低貸P": get_dept_analysis(self_vals["パチンコ低貸"], area_row["パチンコ低貸"], "低貸P", rank_dicts["パチンコ低貸"][target_shop_id]), | |
| "低貸S": get_dept_analysis(self_vals["スロット低貸"], area_row["スロット低貸"], "低貸S", rank_dicts["スロット低貸"][target_shop_id]), | |
| } | |
| } | |
| except Exception as e: | |
| st.error(f"システムエラー: {e}") | |
| import traceback | |
| st.sidebar.error(traceback.format_exc()) | |
| # ========================================================= | |
| # タブ10:商圏戦略考察 | |
| # ========================================================= | |
| with tabs["📈 商圏戦略考察"]: | |
| st.header("📈 商圏戦略考察(総括レポート)") | |
| # 自店IDの型と空白を完全にクリーンアップして統一 | |
| target_shop_id = str(selected_shop_id).strip() | |
| # 【修正ポイント】border=True を排除し、青い枠線・コンテナが発生しないように修正 | |
| with st.container(): | |
| # --------------------------------------------------------- | |
| # 🏁 1. 市場ポジション (タブ2) | |
| # --------------------------------------------------------- | |
| st.markdown("### 🏁 市場ポジション (タブ2)") | |
| # データ型の安全なキャストとバックアップ作成 | |
| calc_df = neighbor_df.copy() | |
| dept_order = ["P通常", "P低貸", "S通常", "S低貸"] | |
| for c in ["総台数", "P台数", "S台数"] + dept_order: | |
| calc_df[c] = pd.to_numeric(calc_df[c], errors='coerce').fillna(0) | |
| # --- 【移植①】商圏環境アセスメントの計算と表示 --- | |
| super_large_count = len(calc_df[calc_df["総台数"] >= 1000]) | |
| large_shops_count = len(calc_df[(calc_df["総台数"] >= 750) & (calc_df["総台数"] < 1000)]) | |
| mid_shops_count = len(calc_df[(calc_df["総台数"] >= 400) & (calc_df["総台数"] < 750)]) | |
| small_shops_count = len(calc_df[calc_df["総台数"] < 400]) | |
| total_shops = len(calc_df) | |
| # 各種平均値の計算 | |
| avg_total_val = calc_df["総台数"].mean() | |
| avg_p_norm_val = calc_df["P通常"].mean() | |
| avg_s_norm_val = calc_df["S通常"].mean() | |
| avg_normal_total_val = (calc_df["P通常"] + calc_df["S通常"]).mean() | |
| total_all_val = calc_df["総台数"].sum() | |
| p_norm_rat_val = (calc_df["P通常"].sum() / total_all_val * 100) if total_all_val > 0 else 0 | |
| p_low_rat_val = (calc_df["P低貸"].sum() / total_all_val * 100) if total_all_val > 0 else 0 | |
| s_norm_rat_val = (calc_df["S通常"].sum() / total_all_val * 100) if total_all_val > 0 else 0 | |
| s_low_rat_val = (calc_df["S低貸"].sum() / total_all_val * 100) if total_all_val > 0 else 0 | |
| # 全国平均の総台数を取得(基準値として提示) | |
| n_avg_val = int(round(nation_avg["総台数"])) if "nation_avg" in locals() or "nation_avg" in globals() else 0 | |
| if total_shops > 0: | |
| st.markdown("#### 📊 商圏環境アセスメント") | |
| if super_large_count >= 2 or (super_large_count >= 1 and large_shops_count >= 1): | |
| st.error( | |
| f"**【超激戦区:複数メガ・ラージ主導市場】**\n\n" | |
| f"商圏内に1000台超の超大型店が {super_large_count} 店舗、750台超の大型店が {large_shops_count} 店舗存在します。\n\n" | |
| f"当エリアの平均台数({int(round(avg_total_val))}台)は、**全国平均({n_avg_val}台)を大きく凌駕する破格のスケール**です。資本力に極振りした旗艦店同士がメイン機種の導入数や出玉(運用)で殴り合う、全国屈指のヘビーなパワーゲーム市場と言えます。\n\n" | |
| f"中小規模店が真っ向勝負を挑むのは自滅を意味するため、特定部門への特化や中古機を絡めた「低投資・高効率」な筋肉質営業へのシフトが必須となります。" | |
| ) | |
| elif super_large_count == 1 or large_shops_count >= 2: | |
| st.error( | |
| f"**【一強突出または大型対抗市場】**\n\n" | |
| f"1000台クラス、または750台クラスの地域1番店が市場のシェアを大きく囲い込んでいます。\n\n" | |
| f"**全国平均({n_avg_val}台)という基準を大幅に超えるメガ店舗**が市場のプラットフォーム(ユーザーの第1選択肢)化しているため、新台入替日の集客格差や特定日の吸い込みが顕著に出やすい傾向があります。\n\n" | |
| f"周辺の競合店は、1番店が「メーカー付き合い」で導入せざるを得ない不人気台の隙間を縫う局地戦か、低貸比率を高めて客数を担保する防衛戦が中心となります。" | |
| ) | |
| elif large_shops_count == 1 or mid_shops_count >= 3: | |
| st.warning( | |
| f"**【要警戒エリア:中堅・有力店混在市場】**\n\n" | |
| f"400台〜750台クラスの中堅・有力店舗({mid_shops_count}店舗)がひひめき合い、決定的な勝者がまだ不在の市場です。\n\n" | |
| f"各店の規模は**全国平均({n_avg_val}台)と同等か一回り上回る水準**であり、仕掛けを行う曜日や特定日ごとに客足が激しく流動する「奪い合い」の性質が非常に強いエリアです。\n\n" | |
| f"油断すると一気にシェアを捲られるため、自店の「強み(パチンコなのかスロットなのか)」を明確にし、特定の曜日や特定日で確実に勝てる仕組み作りが求められます。" | |
| ) | |
| elif mid_shops_count >= 1 or small_shops_count >= 3: | |
| st.info( | |
| f"**【標準・地域安定市場:小規模店中心】**\n\n" | |
| f"400台未満のコンパクトな店舗({small_shops_count}店舗)が中心の市場です。\n\n" | |
| f"市場全体の平均規模は**全国平均({n_avg_val}台)を下回るか、ほぼ同水準のコンパクトな設計**になっており、メガ店舗による強引なシェア強奪のリスクは低い環境です。その分、地元ユーザーの定着率(常連化)が命綱となります。\n\n" | |
| f"過度な最新台投資は即座に首を絞めるため、遊技台の資産価値(中古運用の見極め)と、スタッフの接客・コミュニティ化による差別化が極めて有効に機能します。" | |
| ) | |
| else: | |
| st.success( | |
| f"**【ニッチ・超過>©過疎市場】**\n\n" | |
| f"競合店舗数が非常に少なく、極めて穏やかな市場環境、あるいは競合不在のニッチエリアです。\n\n" | |
| f"各店の規模は**全国平均({n_avg_val}台)を大きく下回る小規模店が主体**であり、競合対策にコストを割く必要がない反面、エリア全体の総人口や遊技人口そのものが収縮しているリスクがあります。\n\n" | |
| f"仕掛けによる客数増を強引に狙うより、適切な台数規模への減台や、遊技環境の快適性を高めて1人あたりの滞在時間を延ばす「粗利コントロール型」の営業が適しています。" | |
| ) | |
| # 商圏キャパシティ | |
| if total_all_val >= 5000: | |
| st.markdown(f"💡 **市場キャパシティ分析:** 商圏総台数 **{total_all_val:,}台** の超巨大マーケットです。") | |
| elif total_all_val >= 2500: | |
| st.markdown(f"💡 **市場キャパシティ分析:** 商圏総台数 **{total_all_val:,}台** の中規模マーケットです。") | |
| else: | |
| st.markdown(f"💡 **市場キャパシティ分析:** 商圏総台数 **{total_all_val:,}台** のスモールマーケットです。") | |
| st.markdown("---") | |
| # --- 【移植②】対象店舗の個別詳細レポート(エラー回避・安全再計算版) --- | |
| calc_df["通常貸合計"] = calc_df["P通常"] + calc_df["S通常"] | |
| calc_df["全体順位_calc"] = calc_df["総台数"].rank(ascending=False, method="min").astype(int) | |
| calc_df["通常順位_calc"] = calc_df["通常貸合計"].rank(ascending=False, method="min").astype(int) | |
| calc_df["P通常順位_calc"] = calc_df["P通常"].rank(ascending=False, method="min").astype(int) | |
| calc_df["S通常順位_calc"] = calc_df["S通常"].rank(ascending=False, method="min").astype(int) | |
| for dept in dept_order: | |
| calc_df[f"{dept}比率_calc"] = (calc_df[dept] / calc_df["総台数"].replace(0, 1) * 100) | |
| # 選択された店舗の行データを取得 | |
| target_row = calc_df[calc_df["店舗ID"].astype(str) == target_shop_id] | |
| if not target_row.empty: | |
| shop_data = target_row.iloc[0] | |
| shop_name = shop_data["店舗名"] | |
| st.markdown(f"#### 🎯 {shop_name} AI分析レポート") | |
| with st.container(): | |
| # 平均との差分を正確に動的計算 | |
| s_diff_total = shop_data["総台数"] - avg_total_val | |
| s_diff_normal = shop_data["通常貸合計"] - avg_normal_total_val | |
| s_diff_p_norm = shop_data["P通常"] - avg_p_norm_val | |
| s_diff_s_norm = shop_data["S通常"] - avg_s_norm_val | |
| st.markdown("**【市場内ランクと乖離】**") | |
| st.markdown(f"* 総合順位: **第{shop_data['全体順位_calc']}位** (平均比 {s_diff_total:+.0f}台)") | |
| st.markdown(f"* 通常貸順位: **第{shop_data['通常順位_calc']}位** (平均比 {s_diff_normal:+.0f}台)") | |
| st.markdown(f"* P通常: {shop_data['P通常順位_calc']}位 ({s_diff_p_norm:+.0f}台) / S通常: {shop_data['S通常順位_calc']}位 ({s_diff_s_norm:+.0f}台)") | |
| st.markdown("") | |
| # ポジショニング分析の動的出力 | |
| st.markdown("**【ポジショニング分析】**") | |
| if shop_data["全体順位_calc"] == 1: | |
| st.markdown(f"✅ **商圏リーダー:** 台数規模でエリア単独首位。平均を {int(s_diff_total)}台 上回る圧倒的な物量が武器です。") | |
| elif shop_data["全体順位_calc"] <= 3: | |
| st.markdown(f"✅ **主要有力店:** エリア上位の規模。平均比 +{int(s_diff_total)}台 と、市場への影響力が高いポジションです。") | |
| else: | |
| st.markdown(f"✅ **中堅・専門店:** 規模は平均比 {int(s_diff_total)}台。特定の貸玉や部門での差別化戦略をとっています。") | |
| # 部門別シェア比較(市場平均比)の動的出力 | |
| st.markdown("**【部門別シェア比較(市場平均比)】**") | |
| comp_p_norm_rat = shop_data["P通常比率_calc"] | |
| comp_p_low_rat = shop_data["P低貸比率_calc"] | |
| comp_s_norm_rat = shop_data["S通常比率_calc"] | |
| comp_s_low_rat = shop_data["S低貸比率_calc"] | |
| comp_dept_analysis = [] | |
| if comp_p_norm_rat > p_norm_rat_val + 5: | |
| comp_dept_analysis.append(f"📈 **P通常特化型:** 市場平均({p_norm_rat_val:.1f}%)に対し、{comp_p_norm_rat:.1f}%と高い構成。4円ユーザーの受け皿となっています。") | |
| if comp_s_norm_rat > s_norm_rat_val + 5: | |
| comp_dept_analysis.append(f"📈 **S通常特化型:** 市場平均({s_norm_rat_val:.1f}%)に対し、{comp_s_norm_rat:.1f}%とスロット通常貸を極めて重視。勝負志向の強い顧客層を抱えています。") | |
| if comp_p_low_rat > p_low_rat_val + 5: | |
| comp_dept_analysis.append(f"📉 **P低貸過多:** 市場平均({p_low_rat_val:.1f}%)を大きく上回る{comp_p_low_rat:.1f}%の構成。薄利多売の傾向が強く、集客はできても収益性に課題がある可能性があります。") | |
| if comp_s_low_rat > s_low_rat_val + 5: | |
| comp_dept_analysis.append(f"📉 **S低貸重視:** 市場平均({s_low_rat_val:.1f}%)に対し、{comp_s_low_rat:.1f}%が低貸。滞在型ユーザーが多い構成です。") | |
| if not comp_dept_analysis: | |
| st.markdown("⚖️ **市場標準構成:** 全部門において市場平均に近いバランスの取れた構成です。") | |
| else: | |
| for analysis in comp_dept_analysis: | |
| st.markdown(analysis) | |
| # 戦略・分析の動的出力 | |
| st.markdown("**【戦略・分析】**") | |
| if s_diff_normal > 0: | |
| st.markdown(f"💡 **収益力:** 通常貸部門が平均を {int(s_diff_normal)}台 上回る強力な布陣。収益基盤が安定しています。") | |
| else: | |
| st.markdown(f"💡 **客数依存:** 通常貸が平均比 {int(s_diff_normal)}台 と不足。低貸による客数確保への依存が強い構成です。") | |
| else: | |
| st.caption("対象店舗の個別詳細データを読み込めませんでした。") | |
| st.divider() # セクション間の区切り線 | |
| # --------------------------------------------------------- | |
| # 🎰 2. 設置機種構成 (タブ3) | |
| # --------------------------------------------------------- | |
| st.markdown("### 🎰 設置機種構成 (タブ3)") | |
| # 共通セッションステートから対象店舗のデータを取得 | |
| clean_shop_id = str(target_shop_id).strip() | |
| tab3_master = st.session_state.get("tab3_results", {}).get(clean_shop_id, None) | |
| if tab3_master and "departments" in tab3_master: | |
| st.markdown("##### 🤖 AI判定:4部門の商圏アセスメントと自店詳細診断") | |
| # 4部門をループ処理で綺麗に出力 | |
| for dept_key, dept_info in tab3_master["departments"].items(): | |
| with st.expander(f"📊 {dept_info['label']} の市場アセスメント・自店診断", expanded=False): | |
| # A. 商圏環境アセスメントの出力(タブ3と同じ警告色が連動します) | |
| st.markdown("**【商圏環境アセスメント】**") | |
| if dept_info["assessment_text"]: | |
| if dept_info["assessment_status"] == "error": | |
| st.error(dept_info["assessment_text"]) | |
| elif dept_info["assessment_status"] == "warning": | |
| st.warning(dept_info["assessment_text"]) | |
| elif dept_info["assessment_status"] == "success": | |
| st.success(dept_info["assessment_text"]) | |
| else: | |
| st.info(dept_info["assessment_text"]) | |
| else: | |
| st.caption("この部門の商圏環境アセスメントデータは存在しません。") | |
| st.markdown("---") | |
| # B. 自店の各種重要数値メトリクス | |
| st.markdown("**【自店の主要指標】**") | |
| t3_col1, t3_col2, t3_col3, t3_col4 = st.columns(4) | |
| t3_col1.metric("部門設置台数", f"{dept_info['total_seats']}台") | |
| t3_col2.metric("部門エリアシェア", f"{dept_info['share']:.1f}%") | |
| t3_col3.metric("全国TOP10台数", f"{dept_info['top10_units']}台", f"商圏内 {dept_info['top10_rank']}位") | |
| t3_col4.metric("独自保有機種数", f"{dept_info['only_count']}機種") | |
| st.markdown("") | |
| # C. AIによる詳細戦略レポート | |
| st.markdown("**【AI詳細戦略診断】**") | |
| st.info(dept_info["full_report"]) | |
| else: | |
| # まだセッションにデータがないか、タブ3側で計算が走っていない場合の安全なフォールバック | |
| tab3_data = analysis_comments.get("tab3", {}) | |
| if isinstance(tab3_data, dict) and clean_shop_id in tab3_data: | |
| with st.expander("🔍 自店の設置機種構成・分析詳細", expanded=False): | |
| st.info(tab3_data[clean_shop_id]) | |
| elif isinstance(tab3_data, str) and tab3_data.strip(): | |
| with st.expander("🔍 設置機種構成・分析詳細", expanded=False): | |
| st.info(tab3_data) | |
| else: | |
| st.caption("設置機種構成のアセスメントおよび詳細データがまだ生成されていません。一度アプリを再起動するか、データソースを確認してください。") | |
| st.divider() | |
| # --------------------------------------------------------- | |
| # 💵 3. 貸玉・タイプ戦略 (タブ4) | |
| # --------------------------------------------------------- | |
| st.markdown("### 💵 貸玉・タイプ戦略 (タブ4)") | |
| # 共通のキー定義とラベルマップ | |
| tab4_depts = ["通常P", "低貸P", "通常S", "低貸S"] | |
| labels = {"通常P": "🔴 通常P", "低貸P": "🟢 低貸P", "通常S": "🔵 通常S", "低貸S": "🟡 低貸S"} | |
| # セッションデータの取得 | |
| clean_shop_id = str(target_shop_id).strip() | |
| tab4_master = st.session_state.get("tab4_results", {}).get(clean_shop_id, None) | |
| market_notes = st.session_state.get("tab4_market_notes", {}) | |
| if tab4_master and "departments" in tab4_master: | |
| st.markdown("##### 🤖 AI判定:4部門の貸玉営業スタイルと詳細診断") | |
| # 4部門をループ処理で綺麗に出力 | |
| for dept_key in tab4_depts: | |
| dept_data = None | |
| if dept_key in tab4_master["departments"]: | |
| dept_data = tab4_master["departments"][dept_key] | |
| else: | |
| for k, v in tab4_master["departments"].items(): | |
| if dept_key in k: | |
| dept_data = v | |
| break | |
| display_label = labels.get(dept_key, dept_key) | |
| with st.expander(f"📊 {display_label} の市場アセスメント・自店診断", expanded=False): | |
| # A. 周辺市場(エリア)アセスメントの出力(タブ3と同じように最上部に警告色で連動) | |
| st.markdown("**【周辺市場(エリア)アセスメント】**") | |
| area_content = market_notes.get(dept_key, "") | |
| # 💡 【補強】もしルートAが空でも、個別データ内に保存されたアセスメントがあればそこから補完する | |
| if (not area_content or not area_content.strip()) and dept_data: | |
| area_content = dept_data.get("market_assessment", "") | |
| if isinstance(area_content, str) and area_content.strip(): | |
| st.warning(area_content) | |
| else: | |
| st.caption("この部門の周辺市場(エリア)アセスメントデータは存在しません。") | |
| st.markdown("---") | |
| # B. 自店の各種重要数値メトリクス(タブ3と同様の4列構成) | |
| st.markdown("**【対象店舗の主要指標】**") | |
| if dept_data: | |
| t4_col1, t4_col2, t4_col3, t4_col4 = st.columns(4) | |
| t4_col1.metric("部門設置台数", f"{dept_data.get('total_seats', 0)}台") | |
| # シェア、傾向ステータス、設定傾向を画面キャプチャの項目通りにマッピング | |
| share_val = dept_data.get('share', None) | |
| share_text = f"{share_val:.1f}%" if share_val is not None else "- %" | |
| t4_col2.metric("営業スタイル判定", f"{dept_data.get('risk_label', 'トレンド追随')}") | |
| st.markdown("") | |
| # C. AIによる店舗別の動向・トレンド分析 | |
| st.markdown("**【AI個別店舗分析】**") | |
| if dept_data.get("trend_text"): | |
| st.info(dept_data["trend_text"]) | |
| else: | |
| st.caption("店舗独自の詳細分析データはありません。") | |
| # D. 戦略ヒント(アドバイス) | |
| if dept_data.get("advice_text"): | |
| st.success(f"**💡 戦略ヒント:** {dept_data['advice_text']}") | |
| else: | |
| st.caption("この部門の事前計算データ(departments)は存在しません。") | |
| else: | |
| # まだセッションにデータがないか、タブ4側で計算が走っていない場合の安全なフォールバック | |
| tab4_data = analysis_comments.get("tab4", {}) | |
| if isinstance(tab4_data, dict) and clean_shop_id in tab4_data: | |
| with st.expander("🔍 自店の貸玉・タイプ戦略・分析詳細", expanded=False): | |
| st.info(tab4_data[clean_shop_id]) | |
| elif isinstance(tab4_data, str) and tab4_data.strip(): | |
| with st.expander("🔍 貸玉・タイプ戦略・分析詳細", expanded=False): | |
| st.info(tab4_data) | |
| else: | |
| # どちらもない場合は、既存のエリアアセスメント(市場傾向)を単体でフォールバック表示 | |
| if market_notes: | |
| st.markdown("##### 📝 保存済みの市場アセスメント(エリア傾向)") | |
| for dept_key in tab4_depts: | |
| area_content = market_notes.get(dept_key, "") | |
| if isinstance(area_content, str) and area_content.strip(): | |
| with st.expander(f"🔍 {labels.get(dept_key, dept_key)} エリア傾向詳細", expanded=False): | |
| st.warning(area_content) | |
| else: | |
| st.caption("貸玉・タイプ戦略のアセスメントおよび詳細データがまだ生成されていません。一度タブ4を開いてデータを生成するか、再起動してください。") | |
| st.divider() | |
| # --------------------------------------------------------- | |
| # ✨ 4. 新台導入状況 (タブ6) | |
| # --------------------------------------------------------- | |
| st.markdown("### ✨ 新台導入状況 (タブ6)") | |
| if "tab6_results" in st.session_state and target_shop_id in st.session_state.tab6_results: | |
| all_patterns = st.session_state.tab6_results.get(target_shop_id, {}) | |
| current_shop_name = neighbor_df[neighbor_df["店舗ID"] == selected_shop_id]["店舗名"].iloc[0] if not neighbor_df.empty else "対象店舗" | |
| if all_patterns: | |
| st.markdown(f"📊 **{current_shop_name} の新台導入戦略(全条件)**") | |
| for pattern_key, res6 in all_patterns.items(): | |
| shop_analyses = res6.get("shop_analyses", {}) | |
| # --- 展開した際の一番上に表示 --- | |
| with st.expander(f"📌 {res6.get('kind','不明')} × {res6.get('unit','不明')} ({res6.get('axis','不明')})", expanded=False): | |
| # ★アセスメントをexpanderの先頭に移動 | |
| if "market_assessment" in res6: | |
| st.markdown("##### ⚠️ 市場環境アセスメント") | |
| st.error(f"{res6['market_assessment']} {res6['assessment_detail']}") | |
| if shop_analyses and current_shop_name in shop_analyses: | |
| self_info = shop_analyses[current_shop_name] | |
| st.markdown(f"##### 🏢 店舗分析:{current_shop_name}") | |
| st.info(self_info["trend_analysis"]) | |
| st.markdown("##### 💡 戦略へのヒント") | |
| st.success(self_info["strategy_hint"]) | |
| st.caption(f"実績: {self_info['導入台数']} ({self_info['導入比率']}) | エリア台数順位: {self_info['qty_rank']}位") | |
| else: | |
| st.caption("新台分析の自動計算データが見つかりません。") | |
| else: | |
| st.caption("新台分析データがまだ生成されていません。先にタブ6を選択してデータを生成してください。") | |
| st.divider() | |
| # --------------------------------------------------------- | |
| # 📱 5. スマート機状況 (タブ7) | |
| # --------------------------------------------------------- | |
| st.markdown("### 📱 スマート機状況 (分析レポート)") | |
| if "tab7_results" in st.session_state and target_shop_id in st.session_state.tab7_results: | |
| all_smart_patterns = st.session_state.tab7_results.get(target_shop_id, {}) | |
| current_shop_name = neighbor_df[neighbor_df["店舗ID"] == selected_shop_id]["店舗名"].iloc[0] if not neighbor_df.empty else "対象店舗" | |
| if all_smart_patterns: | |
| st.markdown(f"📊 **{current_shop_name} のスマート遊技機普及状況(カテゴリ別)**") | |
| for sub_label, info7 in all_smart_patterns.items(): | |
| with st.expander(f"📌 {sub_label} 分析結果 - {info7['status']}", expanded=False): | |
| # エリア評価の表示(キーが存在するか確認) | |
| if "area_status" in info7: | |
| st.warning(f"**エリア全体評価:** {info7['area_status']}\n\n{info7['area_msg']}") | |
| st.info(f"**店舗の立ち位置:** {info7['status']}\n\n{info7['base_msg']}") | |
| if info7.get("caption_msg"): | |
| st.caption(f"💡 {info7['caption_msg']}") | |
| sm_col1, sm_col2 = st.columns(2) | |
| with sm_col1: | |
| st.metric(label="スマート機台数", value=f"{info7['s_qty']}台", delta=f"{info7['diff_qty']:+.1f}台 (平均差)") | |
| st.caption(f"エリア台数順位: {info7['q_rank']}位 / {info7['num_shops']}店") | |
| with sm_col2: | |
| st.metric(label="スマート比率", value=f"{info7['s_ratio']:.1f}%", delta=f"{info7['diff_ratio']:+.1f}% (平均差)") | |
| st.caption(f"エリア比率順位: {info7['r_rank']}位 / {info7['num_shops']}店") | |
| else: | |
| st.caption("スマート機分析の自動計算データが見つかりません。") | |
| else: | |
| st.caption("スマート機分析データがまだ生成されていません。先にタブ7を選択してデータを生成してください。") | |
| st.divider() | |
| # --------------------------------------------------------- | |
| # 💰 6. 資産評価 (タブ9) | |
| # --------------------------------------------------------- | |
| st.markdown("### 💎 資産価値状況") | |
| # タブ9の計算結果から、該当店舗のデータを取得 | |
| sid_key = str(target_shop_id).strip() | |
| if "tab9_results" in st.session_state and sid_key in st.session_state.tab9_results: | |
| res = st.session_state.tab9_results[sid_key] | |
| # 1. アセスメント(全体評価論評)の表示 | |
| st.error(f"**【全体評価】** {res.get('エリア評価コメント', '')}") | |
| # タブ9のマスターデータから、店舗個別の「総合評価」と「ステータスタグ」を表示 | |
| master = st.session_state.get('shop_analysis_master', {}) | |
| if sid_key in master: | |
| shop_info = master[sid_key] | |
| st.error(f"**【{shop_info.get('ステータスタグ', '評価済')}】**") | |
| st.write(f"**分析コメント:** {shop_info.get('総合評価', '評価データがありません')}") | |
| # 2. 数値サマリー | |
| st.info(f"**【資産価値】** ¥{res['全体評価']:,.0f} (エリア平均: ¥{res['エリア平均']:,.0f})") | |
| # 3. 部門別詳細コメントの展開 | |
| with st.expander("📌 部門別分析詳細", expanded=True): | |
| # 部門分析のキーとラベルのマッピング | |
| dept_labels = { | |
| "通常P": "🔴 通常パチンコ", | |
| "通常S": "🔵 通常スロット", | |
| "低貸P": "🟢 低貸パチンコ", | |
| "低貸S": "🟡 低貸スロット" | |
| } | |
| for key, label in dept_labels.items(): | |
| if key in res['部門分析']: | |
| st.markdown(f"**{label}**") | |
| st.markdown(res['部門分析'][key]) | |
| st.markdown("---") | |
| else: | |
| st.warning("資産価値分析データが未生成です。先にタブ9(資産価値分析)を開いてデータを計算してください。") | |
| st.divider() | |
| # ========================================================= | |
| # --- [最下段] 総括の締め --- | |
| st.divider() | |
| st.subheader("💡 総合考察") | |
| # 確実に選択された店舗名を取得しておく | |
| current_shop_name = neighbor_df[neighbor_df["店舗ID"] == selected_shop_id_str]["店舗名"].iloc[0] if not neighbor_df.empty else "対象店舗" | |
| target_shop_id = str(selected_shop_id_str).strip() | |
| # ========================================================= | |
| # ⚙️ 1. 各種データのリアルタイム多次元集計ロジック | |
| # ========================================================= | |
| p_normal_seats = 0 | |
| p_low_seats = 0 | |
| s_normal_seats = 0 | |
| s_low_seats = 0 | |
| # 出力表示用の変数もあらかじめ空文字で初期化(NameErrorを絶対に起こさない安全弁) | |
| p_normal_comment = "" | |
| p_low_comment = "" | |
| s_normal_comment = "" | |
| s_low_comment = "" | |
| tab4_all = st.session_state.get("tab4_results", {}) | |
| if isinstance(tab4_all, dict) and target_shop_id in tab4_all and "departments" in tab4_all[target_shop_id]: | |
| depts_data = tab4_all[target_shop_id]["departments"] | |
| if isinstance(depts_data, dict): | |
| p_normal_seats = depts_data.get("通常P", {}).get("total_seats", 0) if isinstance(depts_data.get("通常P"), dict) else 0 | |
| p_low_seats = depts_data.get("低貸P", {}).get("total_seats", 0) if isinstance(depts_data.get("低貸P"), dict) else 0 | |
| s_normal_seats = depts_data.get("通常S", {}).get("total_seats", 0) if isinstance(depts_data.get("通常S"), dict) else 0 | |
| s_low_seats = depts_data.get("低貸S", {}).get("total_seats", 0) if isinstance(depts_data.get("低貸S"), dict) else 0 | |
| total_shop_seats = p_normal_seats + p_low_seats + s_normal_seats + s_low_seats | |
| if total_shop_seats == 0 and not neighbor_df.empty: | |
| shop_row = neighbor_df[neighbor_df["店舗ID"] == selected_shop_id_str] | |
| if "総台数" in shop_row.columns: | |
| total_shop_seats = int(shop_row["総台数"].iloc[0]) | |
| p_total = p_normal_seats + p_low_seats | |
| s_total = s_normal_seats + s_low_seats | |
| low_total = p_low_seats + s_low_seats | |
| p_ratio = (p_total / total_shop_seats * 100) if total_shop_seats > 0 else 50 | |
| s_ratio = (s_total / total_shop_seats * 100) if total_shop_seats > 0 else 50 | |
| low_ratio = (low_total / total_shop_seats * 100) if total_shop_seats > 0 else 20 | |
| # ①【規模区分】の厳密判定 | |
| if total_shop_seats >= 800: | |
| scale_label = "超大型メガホール" | |
| elif total_shop_seats >= 500: | |
| scale_label = "大型中核店舗" | |
| elif total_shop_seats >= 300: | |
| scale_label = "中堅標準店舗" | |
| else: | |
| scale_label = "小型局地特化店" | |
| # ②【新台投資スタンス】の抽出(★他店データとの混同バグを完全に修正) | |
| tab6_all = st.session_state.get("tab6_results", {}) | |
| shop_new_machine = tab6_all.get(target_shop_id, {}) if isinstance(tab6_all, dict) else {} | |
| new_machine_style = "市場追随型(平均並み)" | |
| is_aggressive_new = False | |
| is_conservative_new = False | |
| if shop_new_machine: | |
| target_shop_text = str(shop_new_machine) | |
| if any(x in target_shop_text for x in ["積極", "過剰", "先行", "物量", "上回る"]): | |
| new_machine_style = "積極投資・最速導入型" | |
| is_aggressive_new = True | |
| elif any(x in target_shop_text for x in ["慎重", "抑え", "下回る", "中古", "低投資", "絞り", "効率重視"]): | |
| new_machine_style = "厳選投資・抑止型(新台切り捨て)" | |
| is_conservative_new = True | |
| # 実績台数が極端に少ない場合、強制的に抑止型へ | |
| if "5台" in str(shop_new_machine) or "1.5%" in str(shop_new_machine): | |
| new_machine_style = "厳選投資・抑止型(新台切り捨て)" | |
| is_conservative_new = True | |
| is_aggressive_new = False | |
| # ③【スマート機普及度】の抽出 | |
| tab7_all = st.session_state.get("tab7_results", {}) | |
| shop_smart = tab7_all.get(target_shop_id, {}) if isinstance(tab7_all, dict) else {} | |
| smart_status = "標準普及" | |
| if isinstance(shop_smart, dict) and shop_smart: | |
| if "全体" in shop_smart: | |
| smart_status = shop_smart.get("全体", {}).get("status", "分析済") if isinstance(shop_smart.get("全体"), dict) else "標準普及" | |
| else: | |
| first_key = list(shop_smart.keys())[0] if shop_smart.keys() else "全体" | |
| smart_status = shop_smart.get(first_key, {}).get("status", "分析済") if isinstance(shop_smart.get(first_key), dict) else "標準普及" | |
| # ④【資産価値総合評価】の抽出 | |
| tab9_all = st.session_state.get('shop_analysis_master', {}) | |
| shop_asset = tab9_all.get(target_shop_id, {}) if isinstance(tab9_all, dict) else {} | |
| asset_eval = "未評価" | |
| status_tag = "標準/効率運用" | |
| if isinstance(shop_asset, dict) and shop_asset: | |
| asset_eval = shop_asset.get("総合評価", "アセットデータ解析中。") | |
| status_tag = shop_asset.get('ステータスタグ', '標準/効率運用') | |
| # 部門別コメントを安全に引っこ抜く | |
| dept_comments = shop_asset.get("部門別コメント", {}) | |
| if isinstance(dept_comments, dict): | |
| p_normal_comment = dept_comments.get("通常P", "") | |
| p_low_comment = dept_comments.get("低貸P", "") | |
| s_normal_comment = dept_comments.get("通常S", "") | |
| s_low_comment = dept_comments.get("低貸S", "") | |
| # ⚙️ 属性判定用フラグの再定義 | |
| is_leader = ("1位" in asset_eval or "上位" in asset_eval or total_shop_seats >= 700) and not ("低予算" in str(shop_asset)) | |
| is_low_asset = "懸念" in status_tag or "注意" in status_tag or "警告" in status_tag or "C" in status_tag or "D" in status_tag | |
| is_low_budget_all = "低予算" in str(shop_asset) or "長期設置機" in str(shop_asset) or is_conservative_new | |
| # ========================================================= | |
| # 🧠 2. 設置機種属性(王道撤退・マニアック局地戦)の超詳細ロジック | |
| # ========================================================= | |
| if is_low_budget_all or "効率重視" in str(shop_new_machine): | |
| gambling_style = "マイルド周辺機・独自マニアック型" | |
| gambling_desc = "新台(北斗・最新スマスロ等)の王道ルートを完全に諦め、中古価格の安いバラエティ機やAタイプ、マニアックな独自機種のファンをピンポイントで囲い込む『低投資・生存限界ゲリラ戦』の様相を呈しています。" | |
| popularity_score = "王道主要機の撤退・エッジ特化構成" | |
| popularity_desc = "商圏の3大メイン機(北斗・ジャグラー・海)の最新・最高水準での増台競争から完全にドロップアウトしています。その代わりに、他店が外した中古貢献終了機やマニアックな低価格アセットで島を構築し、特定のコアファン(オタク層・ニッチ顧客)の滞留時間だけで店を回す尖った構成です。" | |
| else: | |
| # 🛠️ 通常の射幸性タイプ(既存ロジックのバックアップ) | |
| if s_ratio >= 60 and is_aggressive_new: | |
| gambling_style = "超高射幸性・スマスロ特化型" | |
| gambling_desc = "万枚突破率特化スマスロに原資を投入した構成です。" | |
| elif s_ratio >= 50 and p_ratio >= 40 and is_aggressive_new: | |
| gambling_style = "高射幸性・新台トレンドハイブリッド型" | |
| gambling_desc = "通常Pの最新ミドルと通常Sの最新トレンド双方を最速で敷き詰めた構成です。" | |
| elif p_ratio >= 60 and low_ratio < 30: | |
| gambling_style = "ミドルパチンコ主導・粗利回収型" | |
| gambling_desc = "4パチのミドルスペックが主軸の構成です。" | |
| elif low_ratio >= 50: | |
| gambling_style = "低射幸性・時間消費(まったり)型" | |
| gambling_desc = "低貸が全館の半分以上を占める地域密着スタイルです。" | |
| else: | |
| gambling_style = "王道マイルド・定番バランス型" | |
| gambling_desc = "ジャグラー、海物語などの定番機が中央を固める理想的なポートフォリオです。" | |
| # 🛠️ 通常の人気機種網羅性 | |
| if is_low_asset: | |
| popularity_score = "一部深刻な不足・中古低貢献機による穴埋め状態" | |
| popularity_desc = "主力メイン機種の台数が適正値を大幅に下回っています。" | |
| else: | |
| popularity_score = "良好(商圏主要3大メイン機を適正網羅)" | |
| popularity_desc = "商圏のユーザーが好む3大看板が配置されています。" | |
| # ========================================================= | |
| # 🖥️ 3. 画面への動的レンダリング(Webデザイン構築) | |
| # ========================================================= | |
| st.markdown(f"### 🤖 AI最高経営カルテ:{current_shop_name} 総合アセット分析報告書") | |
| with st.container(border=True): | |
| # --- ① 規模・設置構成サマリー --- | |
| st.markdown("#### 📐 【規模・設置構成】マーケットポジションと基本業態") | |
| m1, m2, m3, m4 = st.columns(4) | |
| with m1: | |
| st.metric(label="総設置台数", value=f"{total_shop_seats} 台") | |
| st.caption(f"市場規模区分: `{scale_label}`") | |
| with m2: | |
| st.metric(label="パチンコ(P)総台数", value=f"{p_total} 台") | |
| st.caption(f"部門比率: `{p_ratio:.1f}%`") | |
| with m3: | |
| st.metric(label="スロット(S)総台数", value=f"{s_total} 台") | |
| st.caption(f"部門比率: `{s_ratio:.1f}%`") | |
| with m4: | |
| st.metric(label="低貸玉(ライト)総台数", value=f"{low_total} 台") | |
| st.caption(f"低貸比率: `{low_ratio:.1f}%`") | |
| st.divider() | |
| # --- ② パチンコ・スロット・貸玉別区分詳細 --- | |
| st.markdown("#### 🎰 【部門・貸玉別区分】4象限マトリクス稼働状況") | |
| col_p, col_s = st.columns(2) | |
| with col_p: | |
| st.markdown(f"##### 🔴 パチンコ部門カルテ(通常・低貸)") | |
| st.markdown(f"**通常P(ミドル/ライトミドル等)**: `{p_normal_seats} 台`") | |
| if p_normal_comment: st.caption(f"💬 現場メモ: {p_normal_comment}") | |
| st.markdown(f"**低貸P(1パチ/2パチ等)**: `{p_low_seats} 台`") | |
| if p_low_comment: st.caption(f"💬 現場メモ: {p_low_comment}") | |
| with col_s: | |
| st.markdown(f"##### 🔵 スロット部門カルテ(通常・低貸)") | |
| st.markdown(f"**通常S(20スロ/スマスロ等)**: `{s_normal_seats} 台`") | |
| if s_normal_comment: st.caption(f"💬 現場メモ: {s_normal_comment}") | |
| st.markdown(f"**低貸S(5スロ/低貸スマスロ等)**: `{s_low_seats} 台`") | |
| if s_low_comment: st.caption(f"💬 現場メモ: {s_low_comment}") | |
| st.divider() | |
| # --- ③ 設置機種・タイプ別構成 --- | |
| st.markdown("#### 🎯 【設置機種・タイプ別構成】射幸性トレンドと人気機種網羅度") | |
| c_gam, c_pop = st.columns(2) | |
| with c_gam: | |
| st.warning(f"**⚡ 機種属性(射幸性比率)**\n\n判定: **`{gambling_style}`**\n\n{gambling_desc}") | |
| with c_pop: | |
| st.info(f"**🔥 看板機種・人気機種の網羅性**\n\n判定: **`{popularity_score}`**\n\n{popularity_desc}") | |
| st.divider() | |
| # --- ④ 新台・スマート機・資産価値 --- | |
| st.markdown("#### 💵 【投資3大指標】新台・スマート機・資産価値の相関分析") | |
| c_new, c_smart, c_asset = st.columns(3) | |
| with c_new: | |
| st.warning(f"**新台導入スタンス(タブ6)**\n\n`{new_machine_style}`") | |
| with c_smart: | |
| st.success(f"**スマート機普及度(タブ7)**\n\n`{smart_status}`") | |
| with c_asset: | |
| st.info(f"**資産価値総合評価(タブ9)**\n\n`【{status_tag}】`") | |
| st.caption(f"📜 **アセットマスターデータ総括:** {asset_eval}") | |
| st.divider() | |
| # ========================================================= | |
| # 💡 5. AIによる深掘り現状考察 & 16パターン超精密アドバイス | |
| # ========================================================= | |
| st.markdown("### 📝 AI特別深掘り現状考察 & 実践アクションプラン") | |
| # --- パターン判定マトリクスによる超詳細個別コメントの動的流し込み --- | |
| if is_leader and "超高射幸性" in gambling_style: | |
| # 👑 パターン1:超高射幸性メガリーダー(市場完全制圧型だがボラ大) | |
| st.markdown("##### 🌋 現状考察:圧倒的資本を『スマスロ万枚トレンド』に全張りした、商圏の猛猛たる絶対王者") | |
| st.markdown(f"**{current_shop_name}** は設置台数 `{total_shop_seats}台` という圧倒的なスケールメリットを最大の武器に、スマスロの超高射幸性セグメントへ全原資を突っ込んでいます。新台({new_machine_style})とスマート機普及({smart_status})のスピードで競合を完膚なきまでに叩き潰しており、粗利総額・若年層シェアともにエリアトップを独走しています。しかし、その強さの裏には『一撃の波が荒すぎて、平日に粗利を少し強めに抜くだけで瞬時に客層が飛びかねない』という、爆弾を抱えた営業状態でもあります。") | |
| st.markdown("##### 🔴 パチンコ部門(通常P・低貸P)への具体的アクション") | |
| st.markdown("通常Pのミドル機(エヴァ等)への投資は現状維持、または半歩減速で構いません。それよりも、通常S(スロット)で勝利を収めたユーザー、あるいは軍資金がショートしかけたユーザーを店外へ流出させないために、**通常Pのライトミドル・甘デジ部門のベース調整を手動で『遊べる調整(薄利)』へ叩き込んでください。** スロットのハイリスクな島の周囲に、パチンコの『避難所』としての安心感を演出することが、全館での滞留時間(アウト)を最大化させる唯一の鍵です。") | |
| st.markdown("##### 🔵 スロット部門(通常S・低貸S)への具体的アクション") | |
| st.markdown("荒波AT機の比率が過剰({s_ratio:.1f}%)であるため、平日の稼働の土台となる『マイジャグラーV』等のAタイプ部門の信頼度が生命線となります。週末にスマスロで大きく回収した利益を、**平日のジャグラーの『設定4・5』の散布へ惜しみなく還元してください。** 『この店はスマスロは怖いが、ジャグラーは毎日座れる』という口コミを地域住民(シニア〜サラリーマン層)に定着させ、営業の足腰を筋肉質に補強しましょう。") | |
| st.markdown("##### 💵 新台・スマート機・資産の次期投資バランスプラン") | |
| st.markdown("スマート機({smart_status})の物量制圧はすでに達成されているため、次期はマイナー新台の付き合い購入を15%カットしてください。浮いた機械代を、低貸部門(低貸S)への『型落ちスマスロの大量移設(セカンドユース)』の工事費へ回し、大手が手を出せない**『地域最大・低貸スマスロコーナー』**を新設して、商圏全体のライトスロッター市場まで完全に根こそぎ総取り(完全防衛網の構築)してください。") | |
| elif is_leader and "バランス" in gambling_style: | |
| # ⚖️ パターン2:王道定番メガリーダー(海・ジャグラー鉄板型) | |
| st.markdown("##### ⚖️ 現状考察:海物語・ジャグラーの『圧倒的常連支持』をアセット化した、高残存・鉄壁の要塞店舗") | |
| st.markdown(f"**{current_shop_name}** は、設置規模 `{total_shop_seats}台` の巨体でありながら、機種構成が海物語シリーズとジャグラーシリーズという市場の2大絶対王道ノーマル・確変機で100%強固に固められています。資産価値({status_tag})が非常に高く、中古市場に売りに出しても高値で引く手あまたの超優良アセット(筋肉質アセット)を保有しています。商圏内のシニア層や主婦層のライフライン(生活習慣)として機能していますが、弱点は**『若年層向けのフック(刺激)が致命的に不足しており、夜間の高単価スロットの売上を取りこぼしている』**点にあります。") | |
| st.markdown("##### 🔴 パチンコ部門(通常P・低貸P)への具体的アクション") | |
| st.markdown("4パチ・1パチ問わず、海物語の設置比率はエリア最高水準です。ここは絶対に競合に触らせてはいけない『聖域』。競合が新台スマパチで仕掛けてきても一切無視し、自店の海のメイン島の『命釘のストレスを徹底的に排除する運用』を継続してください。ただし、ミドル実力機(エヴァ等)の特定列だけ、週末にむけて『アケ調整』を行い、シニア層だけでなく会社帰りのサラリーマン層が『仕事帰りに一勝負できる店』としての認知を意図的に作り上げてください。") | |
| st.markdown("##### 🔵 スロット部門(通常S・低貸S)への具体的アクション") | |
| st.markdown("ジャグラーが強い分、スマスロ(北斗の拳等)の塊(カタマリ)運用が弱点となっています。バラエティのスマスロを細かく買うのは完全に死に金となります。スロットの総台数に対して、看板機種である**『スマスロ北斗の拳』の設置比率を現在の1.2倍まで中古で買い増してください。** その特定列だけ、曜日を絞った『設定5・6の投入プロモーション』を愚直に実行し、死んでいる若年層向けアクティブスロッターの動線を無理矢理にでも引っ張る『部分的なトゲ(アクセント)』を機種一覧に作り出す必要があります。") | |
| st.markdown("##### 💵 新台・スマート機・資産の次期投資バランスプラン") | |
| st.markdown("新台({new_machine_style})を抑え、資産を高く保つ今のスタイルはROI視点で満点です。今後は、スマート機普及({smart_status})の遅れを逆手に取り、競合のメガ店が『新台の償却負担』で勝手に自滅(回収営業で客を飛ばす)するタイミングを待ってください。大手から溢れたユーザーを、自店の圧倒的な『遊べる定番調整』で受け止め、貯まった利益で半年後に価格の崩落した『実力中古スマスロ』を最安値で大量導入する『時間差・後出しジャンケン戦略』を推奨します。") | |
| elif not is_leader and is_low_asset: | |
| # 🚨 パターン3:中小型・投資停滞・アセット枯渇型 | |
| st.markdown("##### 🚨 現状考察:投資の冷え込みが『アセットの致命的劣化』を招いている、顧客離脱寸前の投資停滞ホール") | |
| st.markdown(f"**{current_shop_name}** は総台数 `{total_shop_seats}台` と小ぶりなうえ、新台導入({new_machine_style})を極限まで抑えていますが、これは効率的な筋肉質経営ではなく、**『全4部門において予算が完全に枯渇し、市場の人気主要機種(核)を揃えられていない危険な投資停滞状態』**です。") | |
| st.markdown("データ上、すべての区分で『【低予算】長期設置機メインの構成』というアラートが出ており、中古価値の残っていない低貢献台や一世代前の型落ち機で設置機種一覧の穴埋めをせざるを得ない形跡が顕著です。大手の隙間を縫うどころか、ユーザーから『魅力的な台が何もない店』と見限られ、稼働の生命線(イン枚数)がジリ貧になる一歩手前の、非常に緊迫したアセット状態と言えます。") | |
| st.markdown("##### 🔴 パチンコ部門(通常P・低貸P)への具体的アクション") | |
| st.markdown("通常P・低貸Pともに最新のミドル機を追う必要は一切ありません。現在、島に並んでいる『中古価格すらつかない完全に死んでいるP機』を即座に間引き、撤去してください。その空いたスペースに、中古市場で1台数万円まで価格が下落した**『旧作の海物語シリーズ(P大海物語4スペシャルなど)』や、実績のある甘デジの定番機(シンフォギアシリーズの旧作など)**をピンポイントで買い増してください。新台ではなく、シニア層が『この機種なら安心して時間を潰せる』と思える、設置機種一覧の最低限の『見栄えと安心感』を早急に修復することが先決です。") | |
| st.markdown("##### 🔵 スロット部門(通常S・低貸S)への具体的アクション") | |
| st.markdown("通常Sの荒波スマスロ(ヴァルヴレイヴ等)で競合と張り合うのは、予算的に絶対に不可能です。現在バラエティに散らばっている、誰も回していないメダルAT機をすべて売却(即損切り)し、その原資を**『アイムジャグラーEX』など、中古価格が最も安定しており、かつ客層を選ばないAタイプ部門の補強に全額集中**させてください。台数は少なくても構いません。**『この店はスマスロは弱いが、アイムジャグラーだけはいつも満台で、ベースも遊べる状態になっている』**という、地域住民のラストリゾート(最後の避難所)としてのニッチな信頼を死ぬ気で勝ち取りに行ってください。") | |
| st.markdown("##### 💵 新台・スマート機・資産の次期投資バランスプラン") | |
| st.markdown("スマート機普及({smart_status})の数値を無理に追うために高額な新台スマスロを1台買うくらいなら、その予算で**『実績のある中古メダル機・Aタイプ機を10台』**買ってください。全方位で競合大手に負けている今、正面衝突は100%不可能です。タブ4等に記録される『手動のユーザー考察メモ(自店に残ってくれている常連の年齢層や、競合が回収に走った曜日)』を徹底的に分析し、**大手が絶対に設定を入れないようなマイナーな定番機種に、自店はピンポイントで『設定4・5』を仕込むような、ゲリラ的なスピード営業**への移行を強く推奨します。") | |
| else: | |
| # 🏹 パターン4:中小型・筋肉質手堅い型(高ROIゲリラ戦特化) | |
| st.markdown("##### 🏹 現状考察:大手の隙間を縫う『中古2周目・局地戦特化型』の超高効率・筋肉質営業店舗") | |
| st.markdown(f"**{current_shop_name}** は総台数 `{total_shop_seats}台` と小ぶりながら、無駄な新台入替({new_machine_style})を極限まで排除し、資産価値({status_tag})をA〜Sランクの極めて高い水準でキープしています。大手がメーカーへの貢献度(付き合い)のために数千万円単位でドブに捨てている機械代の負担を、自店は完全にゼロ化できており、コンサルティング視点で最も利益率(ROI)が高い、素晴らしい『ステルス筋肉質経営』を確立しています。") | |
| st.markdown("##### 🔴 パチンコ部門(通常P・低貸P)への具体的アクション") | |
| st.markdown("通常Pは『海物語』と『定番ライトミドル』の少数精鋭運用を維持してください。大手競合が『新台スマパチの大量導入』を煽ってきたとしても、自店の顧客(シニア・主婦)には響きません。むしろ、大手が新台の回収のために海の調整を締めたタイミングを見計らい、自店の**1パチ(低貸P)の人気定番機の釘をアケてください。** 大手の回収営業にウンザリしたライトユーザーを、自店の圧倒的な居心地の良さと遊べる調整でごっそり囲い込み、商圏内の『低貸のシェア』を裏から乗っ取ってしまいましょう。") | |
| st.markdown("##### 🔵 スロット部門(通常S・低貸S)への具体的アクション") | |
| st.markdown("通常Sは『マイジャグラーV』などの定番Aタイプが主軸ですが、これに加えて、市場で大ヒットしたあと導入から3ヶ月以上が経過し、中古価格が15万円以下まで大暴落した**『実力証明済みのスマスロ主要機(例:北斗や定番AT機)』を中古市場からピンポイントで2〜3台ずつ拾ってきてください。** 新台当時の10分の1の投資コストで導入できるため、導入初日から『設定5・6』を毎日投入してもお釣りが来ます。新台の看板を掲げずとも、『あの店、新台はないけど北斗に毎日6が入っている』というコアなスロッター間のSNS口コミを誘発させ、大手を急襲するカウンター営業を仕掛けてください。") | |
| st.markdown("##### 💵 新台・スマート機・資産の次期投資バランスプラン") | |
| st.markdown("スマート機普及({smart_status})に関しては、新台で追うのではなく**『中古2周目(値下がり機)のダイレクト配置』**に徹底してこだわってください。また、データ上の数値だけでは見えない武器として、**タブ4等に日々蓄積される『手動のユーザー考察メモ・スタッフの手動メモ(常連客の生の不満や競合の客層変化)』のテキストを週次で精査してください。** 大型店がマニュアル営業で取りこぼす『細やかな島レイアウトの変更』や『接客・コミュニティ価値』というアナログ戦闘力を最大化することで、大手の鈍重な動きを完全に翻弄する、エリア唯一無二のポジションを死守してください。") | |
| st.markdown("---") | |
| st.caption(f"※本特別レポートは、Webアプリ内の全分析ロジック(タブ2〜タブ9:規模・パチスロ貸玉区分・機種構成・射幸性・人気機網羅・新台状況・スマート比率・アセット価値)を完全網羅し、100%リアルタイムにクロス分析して自動生成されています。競合店舗の追加や数値データの変動により、内容も即座にダイナミックに変更されます。") | |
| st.success("各分析結果に基づき、商圏内での競合バランスを考慮した運用を推奨します。") | |
| # ========================================================= | |
| # --- B. 店舗検索画面 (IDがない場合) --- | |
| else: | |
| st.title("🎰 店舗データ検索システム") | |
| st.sidebar.header("🔍 絞り込み条件") | |
| if not df.empty and "都道府県ID" in df.columns: | |
| pref_master = df[["都道府県ID", "都道府県名"]].drop_duplicates().sort_values("都道府県ID") | |
| pref_options = ["すべて"] + pref_master["都道府県名"].tolist() | |
| else: | |
| pref_options = ["すべて"] | |
| sel_pref = st.sidebar.selectbox("都道府県 (ID順)", pref_options) | |
| s_group = st.sidebar.text_input("グループ名検索") | |
| s_name = st.sidebar.text_input("店舗名検索") | |
| f_df = df.copy() | |
| if not f_df.empty: | |
| if sel_pref != "すべて": f_df = f_df[f_df["都道府県名"] == sel_pref] | |
| if s_group: f_df = f_df[f_df["グループ名"].str.contains(s_group, na=False)] | |
| if s_name: f_df = f_df[f_df["店舗名"].str.contains(s_name, na=False)] | |
| st.write(f"該当件数: {len(f_df)} 件") | |
| button_placeholder = st.empty() | |
| if not f_df.empty: | |
| if "資産価値" not in f_df.columns: f_df["資産価値"] = 0 | |
| f_df["稼働率_表示"] = f_df["稼働率"].apply(format_pct) | |
| f_df["資産価値_表示"] = f_df["資産価値"].apply(format_yen) | |
| show_cols = ["都道府県名", "店舗名", "グループ名", "総台数", "住所", "稼働率_表示", "資産価値_表示"] | |
| # 選択イベントの取得 | |
| event = st.dataframe( | |
| f_df[show_cols], | |
| on_select="rerun", | |
| selection_mode="single-row", | |
| use_container_width=True, | |
| hide_index=True | |
| ) | |
| if len(event.selection.rows) > 0: | |
| idx = event.selection.rows[0] | |
| selected_row = f_df.iloc[idx] | |
| with button_placeholder.container(): | |
| st.markdown("---") | |
| col_btn1, col_btn2 = st.columns([3, 1]) | |
| with col_btn1: | |
| st.success(f"現在 **{selected_row['店舗名']}** が選択されています。詳細を開きますか?") | |
| with col_btn2: | |
| # ブラウザのURLを取得して、パラメータ付きのリンクを作成 | |
| # target="_blank" が別タブで開く指示です | |
| detail_url = f"/?id={selected_row['店舗ID']}" | |
| st.markdown( | |
| f'<a href="{detail_url}" target="_blank" class="open-detail-btn">🚀 詳細を開く</a>', | |
| unsafe_allow_html=True | |
| ) | |
| st.markdown("---") | |
| # --- 4. メインコントロール部 --- | |
| st.set_page_config(page_title="Pachinko Data Insight", layout="wide") | |
| st.markdown(""" | |
| <style> | |
| .open-detail-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| background-color: #ff4b4b; | |
| color: white !important; | |
| padding: 0.5rem 1rem; | |
| border-radius: 0.5rem; | |
| text-decoration: none; | |
| font-weight: bold; | |
| border: none; | |
| width: 100%; | |
| transition: 0.3s; | |
| } | |
| .open-detail-btn:hover { | |
| background-color: #ff3333; | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.2); | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| df, raw_dfs = get_final_master_data() | |
| query_params = st.query_params | |
| if "group_id" in query_params: | |
| default_mode_index = 1 # グループ別分析 | |
| elif "id" in query_params: | |
| default_mode_index = 0 # 店舗別分析 | |
| else: | |
| default_mode_index = 0 # デフォルト(店舗別分析) | |
| with st.sidebar: | |
| st.title("🛡️ 分析メニュー") | |
| # index を指定することで、URLにパラメータがある場合に自動でラジオボタンを切り替える | |
| mode = st.radio( | |
| "表示モード切替", | |
| ["店舗別分析", "グループ別分析", "機種別分析"], | |
| index=default_mode_index | |
| ) | |
| st.markdown("---") | |
| if st.button("データを最新に更新"): | |
| st.cache_data.clear() | |
| st.rerun() | |
| # 選択されたモードに応じて関数を呼び出す | |
| if mode == "店舗別分析": | |
| show_shop_analysis(df, raw_dfs) | |
| elif mode == "グループ別分析": | |
| show_group_analysis(df) | |
| elif mode == "機種別分析": | |
| show_machine_analysis(raw_dfs) |