Spaces:
Running
Running
Upload app.py
Browse files
app.py
CHANGED
|
@@ -33,6 +33,10 @@ load_dotenv() # 加载本地 .env 文件
|
|
| 33 |
|
| 34 |
# --- 全局配置和常量 ---
|
| 35 |
TOKEN_FILE = 'token_data.json'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
# --- 环境变量获取 (替代硬编码) ---
|
| 37 |
# 使用 os.getenv 获取,如果获取不到默认为空字符串或特定默认值
|
| 38 |
GAODE_API_KEY = os.getenv("GAODE_API_KEY", "")
|
|
@@ -821,12 +825,24 @@ def process_api_data(schedule_list, hall_seat_map, token=None, show_date=None):
|
|
| 821 |
df.rename(columns={'movieName': '影片名称', 'showStartTime': '放映时间', 'soldBoxOffice': '总收入',
|
| 822 |
'soldTicketNum': '总人次', 'hallName': '影厅名称'}, inplace=True)
|
| 823 |
|
| 824 |
-
# 获取标准电影名列表
|
| 825 |
canonical_names = []
|
| 826 |
if token and show_date:
|
| 827 |
canonical_names = fetch_canonical_movie_names(token, show_date)
|
| 828 |
|
| 829 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
# 在 required_cols 中增加 '影厅名称'
|
| 832 |
required_cols = ['影片名称', '放映时间', '座位数', '总收入', '总人次', '影厅名称']
|
|
@@ -1006,6 +1022,75 @@ def clean_movie_title(raw_title, canonical_names=None):
|
|
| 1006 |
return base_name
|
| 1007 |
|
| 1008 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
def get_available_movie_name_pool(show_date):
|
| 1010 |
token_data = load_token()
|
| 1011 |
token = token_data.get('token') if token_data else None
|
|
@@ -1031,7 +1116,11 @@ def get_available_movie_name_pool(show_date):
|
|
| 1031 |
|
| 1032 |
canonical_names = fetch_canonical_movie_names(token, show_date) if token else []
|
| 1033 |
cleaned_movie_names = {
|
| 1034 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1035 |
for item in available_movies
|
| 1036 |
if item.get('movieName')
|
| 1037 |
}
|
|
@@ -1469,7 +1558,7 @@ def render_sales_summary_section(df, selected_date, key_prefix):
|
|
| 1469 |
return
|
| 1470 |
|
| 1471 |
st.markdown("#### 销售总览 (套餐 Top 10 + 单品 Top 5)")
|
| 1472 |
-
st.dataframe(final_summary,
|
| 1473 |
st.markdown("##### 复制到 Excel")
|
| 1474 |
st.code(copy_text, language='text')
|
| 1475 |
|
|
@@ -1775,7 +1864,18 @@ def build_daily_report_from_source_df(source_df, selected_date_str=None, token=N
|
|
| 1775 |
if token and display_date_str:
|
| 1776 |
canonical_names = fetch_canonical_movie_names(token, display_date_str)
|
| 1777 |
|
| 1778 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1779 |
df['影厅'] = df['影厅名称']
|
| 1780 |
df['人数合计'] = df['总人次']
|
| 1781 |
|
|
@@ -1804,6 +1904,9 @@ def process_and_filter_data_for_report(schedule_list, hall_seat_map, selected_da
|
|
| 1804 |
'soldTicketNum': '总人次'
|
| 1805 |
}, inplace=True)
|
| 1806 |
df['放映日期'] = selected_date_str
|
|
|
|
|
|
|
|
|
|
| 1807 |
|
| 1808 |
result_df, _ = build_daily_report_from_source_df(df, selected_date_str=selected_date_str, token=token)
|
| 1809 |
return result_df
|
|
@@ -2678,6 +2781,7 @@ def is_time_in_schedule_check_exempt_ranges(ts, ranges):
|
|
| 2678 |
|
| 2679 |
def load_schedule_check_settings():
|
| 2680 |
"""从 JSON 文件加载场次合理性检查设置"""
|
|
|
|
| 2681 |
if os.path.exists(SCHEDULE_CHECK_SETTINGS_FILE):
|
| 2682 |
try:
|
| 2683 |
with open(SCHEDULE_CHECK_SETTINGS_FILE, 'r', encoding='utf-8') as f:
|
|
@@ -2768,6 +2872,7 @@ def save_schedule_check_settings(config=None, show_toast=True):
|
|
| 2768 |
try:
|
| 2769 |
with open(SCHEDULE_CHECK_SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
| 2770 |
json.dump(settings_to_save, f, ensure_ascii=False, indent=4)
|
|
|
|
| 2771 |
if show_toast:
|
| 2772 |
st.toast("场次合理性检查设置已保存!", icon="💾")
|
| 2773 |
return True
|
|
@@ -2834,7 +2939,7 @@ def render_schedule_check_settings():
|
|
| 2834 |
rule1_enabled = st.checkbox("规则一:同影片最小开场间隔", key="schedule_check_rule1_enabled")
|
| 2835 |
with col2:
|
| 2836 |
st.number_input(
|
| 2837 |
-
"规则一间隔(分钟)", min_value=
|
| 2838 |
key="schedule_check_rule1_min_interval_minutes",
|
| 2839 |
disabled=not rule1_enabled
|
| 2840 |
)
|
|
@@ -2844,13 +2949,13 @@ def render_schedule_check_settings():
|
|
| 2844 |
rule2_enabled = st.checkbox("规则二:X分钟内影片开场超过Y场", key="schedule_check_rule2_enabled")
|
| 2845 |
with col2:
|
| 2846 |
st.number_input(
|
| 2847 |
-
"规则二阈值(场)", min_value=
|
| 2848 |
key="schedule_check_rule2_threshold_sessions",
|
| 2849 |
disabled=not rule2_enabled
|
| 2850 |
)
|
| 2851 |
with col3:
|
| 2852 |
st.number_input(
|
| 2853 |
-
"规则二时间窗口(分钟)", min_value=
|
| 2854 |
key="schedule_check_rule2_window_minutes",
|
| 2855 |
disabled=not rule2_enabled
|
| 2856 |
)
|
|
@@ -2867,7 +2972,7 @@ def render_schedule_check_settings():
|
|
| 2867 |
rule3_enabled = st.checkbox("规则三:场次开场断档", key="schedule_check_rule3_enabled")
|
| 2868 |
with col2:
|
| 2869 |
st.number_input(
|
| 2870 |
-
"规则三断档阈值(分钟)", min_value=
|
| 2871 |
key="schedule_check_rule3_gap_threshold_minutes",
|
| 2872 |
disabled=not rule3_enabled
|
| 2873 |
)
|
|
@@ -2896,7 +3001,7 @@ def render_schedule_check_settings():
|
|
| 2896 |
rule6_enabled = st.checkbox("规则六:影厅场次转换时间检查", key="schedule_check_rule6_enabled")
|
| 2897 |
with col2:
|
| 2898 |
st.number_input(
|
| 2899 |
-
"场次转换基准(分钟)", min_value=
|
| 2900 |
key="schedule_check_rule6_min_conversion_minutes",
|
| 2901 |
disabled=not rule6_enabled
|
| 2902 |
)
|
|
@@ -2918,7 +3023,7 @@ def render_schedule_check_settings():
|
|
| 2918 |
col1, col2 = st.columns([1, 2])
|
| 2919 |
with col1:
|
| 2920 |
st.number_input(
|
| 2921 |
-
"规则九热门TopN", min_value=
|
| 2922 |
key="schedule_check_rule9_hot_top_n",
|
| 2923 |
disabled=not rule9_enabled
|
| 2924 |
)
|
|
@@ -2942,7 +3047,7 @@ def render_schedule_check_settings():
|
|
| 2942 |
rule12_enabled = st.checkbox("规则十二:次日票房前N影片必须有黄金场", key="schedule_check_rule12_enabled")
|
| 2943 |
with col2:
|
| 2944 |
st.number_input(
|
| 2945 |
-
"规则十二票房TopN", min_value=
|
| 2946 |
key="schedule_check_rule12_top_n",
|
| 2947 |
disabled=not rule12_enabled
|
| 2948 |
)
|
|
@@ -3065,7 +3170,17 @@ def generate_schedule_check_logs(schedule_list, date_str, today_analysis_df=None
|
|
| 3065 |
|
| 3066 |
df_original['simpleHallName'] = df_original['Hall'].apply(simplify_hall)
|
| 3067 |
df_check = df_original.sort_values(by='startTime').reset_index(drop=True)
|
| 3068 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3069 |
|
| 3070 |
next_day_bo_data = fetch_realtime_box_office(date_str)
|
| 3071 |
movie_box_office = {}
|
|
@@ -3855,7 +3970,7 @@ def build_api_gantt_rows(schedule_list, date_str, token, hall_seat_map=None):
|
|
| 3855 |
if movie_length <= 0:
|
| 3856 |
movie_length = int((end_dt - start_dt).total_seconds() / 60)
|
| 3857 |
|
| 3858 |
-
film_name =
|
| 3859 |
film_language = item.get('movieLanguage', '') or ''
|
| 3860 |
imagery = item.get('movieMediaType', '') or ''
|
| 3861 |
movie_num = item.get('movieNum', '') or ''
|
|
@@ -4094,7 +4209,103 @@ def render_nextday_pool_history_table(base_date_str):
|
|
| 4094 |
|
| 4095 |
st.dataframe(
|
| 4096 |
summary_df.style.format({'总票房': '{:,.2f}'}),
|
| 4097 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4098 |
hide_index=True,
|
| 4099 |
)
|
| 4100 |
|
|
@@ -4142,7 +4353,18 @@ def display_analysis_results(df_raw, data_source_name, date_for_display, query_t
|
|
| 4142 |
date_str = f"{date_for_display} " if date_for_display else ""
|
| 4143 |
total_revenue, total_attendance, total_sessions = df_raw['总收入'].sum(), df_raw['总人次'].sum(), len(df_raw)
|
| 4144 |
st.markdown(
|
| 4145 |
-
f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4146 |
|
| 4147 |
format_config = {'座位数': '{:,.0f}', '场次': '{:,.0f}', '人次': '{:,.0f}', '票房': '{:,.2f}', '均价': '{:.2f}',
|
| 4148 |
'座次比': '{:.2%}', '场次比': '{:.2%}', '票房比': '{:.2%}', '座次效率': '{:.2f}',
|
|
@@ -4169,10 +4391,10 @@ def display_analysis_results(df_raw, data_source_name, date_for_display, query_t
|
|
| 4169 |
|
| 4170 |
st.markdown("#### 全天排片效率分析");
|
| 4171 |
st.dataframe(full_day_analysis.style.format(format_config).apply(style_efficiency, axis=1),
|
| 4172 |
-
|
| 4173 |
st.markdown("#### 黄金时段排片效率分析 (14:00-21:00)");
|
| 4174 |
st.dataframe(prime_time_analysis.style.format(format_config).apply(style_efficiency, axis=1),
|
| 4175 |
-
|
| 4176 |
if not full_day_analysis.empty:
|
| 4177 |
st.markdown("### 排片效率汇总")
|
| 4178 |
full_day_summary = full_day_analysis.rename(
|
|
@@ -4189,10 +4411,10 @@ def display_analysis_results(df_raw, data_source_name, date_for_display, query_t
|
|
| 4189 |
'全部座次效率': '{:.2f}', '全部场次效率': '{:.2f}', '黄金时段座次效率': '{:.2f}',
|
| 4190 |
'黄金时段场次效率': '{:.2f}'}
|
| 4191 |
st.dataframe(summary_df.style.format(summary_format_config).apply(style_summary_efficiency, axis=1),
|
| 4192 |
-
|
| 4193 |
|
| 4194 |
|
| 4195 |
-
st.markdown("## 📈 全国大盘实时票房查询")
|
| 4196 |
|
| 4197 |
# 增加手续费复选框,默认不包含
|
| 4198 |
include_fee = st.checkbox("包含手续费", value=False)
|
|
@@ -4244,7 +4466,7 @@ def display_analysis_results(df_raw, data_source_name, date_for_display, query_t
|
|
| 4244 |
'总场次': '{:,.0f}',
|
| 4245 |
'总人次': '{:,.0f}'
|
| 4246 |
}
|
| 4247 |
-
st.dataframe(today_df.style.format(format_dict),
|
| 4248 |
else:
|
| 4249 |
st.info(f"未能获取到 {today_str} 的全国大盘数据。")
|
| 4250 |
|
|
@@ -4282,11 +4504,13 @@ def display_analysis_results(df_raw, data_source_name, date_for_display, query_t
|
|
| 4282 |
'总场次': '{:,.0f}',
|
| 4283 |
'总人次': '{:,.0f}'
|
| 4284 |
}
|
| 4285 |
-
st.dataframe(tomorrow_df.style.format(format_dict),
|
| 4286 |
else:
|
| 4287 |
st.info(f"未能获取到 {tomorrow_str} 的全国大盘预售数据。")
|
| 4288 |
|
| 4289 |
-
|
|
|
|
|
|
|
| 4290 |
render_today_api_gantt_chart(today_str)
|
| 4291 |
|
| 4292 |
|
|
@@ -4313,7 +4537,18 @@ def fetch_and_process_daily_sessions(date_str, quiet=False):
|
|
| 4313 |
if token:
|
| 4314 |
canonical_names = fetch_canonical_movie_names(token, date_str)
|
| 4315 |
|
| 4316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4317 |
|
| 4318 |
sessions_map = df.groupby('影片名称_清理后').size().to_dict()
|
| 4319 |
# 返回 (映射表, 总场次, 原始排片列表)
|
|
@@ -4634,7 +4869,7 @@ def main():
|
|
| 4634 |
'座次效率': '{:.2f}', '场次效率': '{:.2f}', '次日场数': '{:,.0f}'}
|
| 4635 |
display_df['均价'] = pd.to_numeric(display_df['均价'], errors='coerce').replace([np.inf, -np.inf],
|
| 4636 |
np.nan)
|
| 4637 |
-
st.dataframe(display_df.style.format(report_format, na_rep="#DIV/0!"),
|
| 4638 |
hide_index=True)
|
| 4639 |
|
| 4640 |
n_diff = st.session_state.today_movie_count - st.session_state.previous_day_movie_count
|
|
@@ -4824,7 +5059,7 @@ def main():
|
|
| 4824 |
st.session_state.daily_report_df.style.format({
|
| 4825 |
'人数合计': '{:,.0f}', '座位数': '{:,.0f}', '上座率%': '{:.2f}%'
|
| 4826 |
}),
|
| 4827 |
-
|
| 4828 |
)
|
| 4829 |
total_attendance = pd.to_numeric(
|
| 4830 |
st.session_state.daily_report_df.get('人数合计', 0),
|
|
@@ -5020,7 +5255,7 @@ def main():
|
|
| 5020 |
with tab_views[0]:
|
| 5021 |
st.markdown(display_pdf(led_output['pdf']), unsafe_allow_html=True)
|
| 5022 |
if 'png' in led_output:
|
| 5023 |
-
with tab_views[1]: st.image(led_output['png'],
|
| 5024 |
else:
|
| 5025 |
st.error("未能成功生成 '修改 LED 屏排片表'。请检查数据源。")
|
| 5026 |
|
|
@@ -5087,7 +5322,7 @@ def main():
|
|
| 5087 |
'时长(分钟)': format_play_time(item['play_time']),
|
| 5088 |
'文件名': format_content_name_with_explanation(item['content_name'])}
|
| 5089 |
for item in movie_list_sorted]
|
| 5090 |
-
st.dataframe(pd.DataFrame(view2_data), hide_index=True,
|
| 5091 |
st.markdown("#### 按影厅查看影片内容")
|
| 5092 |
hall_tabs = st.tabs(list(halls_data.keys()))
|
| 5093 |
for tab, hall_name in zip(hall_tabs, halls_data.keys()):
|
|
@@ -5097,7 +5332,7 @@ def main():
|
|
| 5097 |
'时长(分钟)': format_play_time(item['details']['play_time']),
|
| 5098 |
'文件名': format_content_name_with_explanation(item['content_name'])} for item in
|
| 5099 |
halls_data[hall_name]]
|
| 5100 |
-
st.dataframe(pd.DataFrame(view1_data), hide_index=True,
|
| 5101 |
except Exception as e:
|
| 5102 |
st.error(f"查询TMS服务器时出错: {e}")
|
| 5103 |
|
|
|
|
| 33 |
|
| 34 |
# --- 全局配置和常量 ---
|
| 35 |
TOKEN_FILE = 'token_data.json'
|
| 36 |
+
ROOT_DIR = Path(__file__).resolve().parent
|
| 37 |
+
CINEMA_CACHE_DIR = ROOT_DIR / "cinema_cache"
|
| 38 |
+
NATIONAL_MARKET_MOVIE_FILE = CINEMA_CACHE_DIR / "national_market_movie_daily.csv"
|
| 39 |
+
NATIONAL_MARKET_DAILY_FILE = CINEMA_CACHE_DIR / "national_market_daily_summary.csv"
|
| 40 |
# --- 环境变量获取 (替代硬编码) ---
|
| 41 |
# 使用 os.getenv 获取,如果获取不到默认为空字符串或特定默认值
|
| 42 |
GAODE_API_KEY = os.getenv("GAODE_API_KEY", "")
|
|
|
|
| 825 |
df.rename(columns={'movieName': '影片名称', 'showStartTime': '放映时间', 'soldBoxOffice': '总收入',
|
| 826 |
'soldTicketNum': '总人次', 'hallName': '影厅名称'}, inplace=True)
|
| 827 |
|
| 828 |
+
# 获取标准电影名列表(用于 movieNum 映射未命中时的回退)
|
| 829 |
canonical_names = []
|
| 830 |
if token and show_date:
|
| 831 |
canonical_names = fetch_canonical_movie_names(token, show_date)
|
| 832 |
|
| 833 |
+
# 使用 movieNum 映射优先解析影片名(不含制式后缀,仅正式片名)
|
| 834 |
+
movie_num_col = df.get('movieNum')
|
| 835 |
+
if movie_num_col is not None:
|
| 836 |
+
df['影片名称'] = df.apply(
|
| 837 |
+
lambda row: resolve_movie_name_from_schedule_item(
|
| 838 |
+
row['影片名称'],
|
| 839 |
+
movie_num=row.get('movieNum'),
|
| 840 |
+
canonical_names=canonical_names
|
| 841 |
+
),
|
| 842 |
+
axis=1
|
| 843 |
+
)
|
| 844 |
+
else:
|
| 845 |
+
df['影片名称'] = df['影片名称'].apply(lambda x: clean_movie_title(x, canonical_names))
|
| 846 |
|
| 847 |
# 在 required_cols 中增加 '影厅名称'
|
| 848 |
required_cols = ['影片名称', '放映时间', '座位数', '总收入', '总人次', '影厅名称']
|
|
|
|
| 1022 |
return base_name
|
| 1023 |
|
| 1024 |
|
| 1025 |
+
# --- movieNum 影片名映射解析(与 history_sessions_monitor.py 策略一致) ---
|
| 1026 |
+
MOVIE_NUM_NAME_MAP_FILE = CINEMA_CACHE_DIR / "movie_num_name_map.json"
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
def _normalize_movie_num_key(movie_num):
|
| 1030 |
+
"""将 movieNum 标准化为纯大写字母数字"""
|
| 1031 |
+
return re.sub(r'[^A-Z0-9]', '', str(movie_num or '').strip().upper())
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
@st.cache_data(show_spinner=False, ttl=120)
|
| 1035 |
+
def _load_movie_num_name_map():
|
| 1036 |
+
"""加载 movie_num_name_map.json 并返回 {normalized_key: official_name} 字典"""
|
| 1037 |
+
if not MOVIE_NUM_NAME_MAP_FILE.exists():
|
| 1038 |
+
return {}
|
| 1039 |
+
try:
|
| 1040 |
+
with open(MOVIE_NUM_NAME_MAP_FILE, 'r', encoding='utf-8') as f:
|
| 1041 |
+
payload = json.load(f)
|
| 1042 |
+
except Exception:
|
| 1043 |
+
return {}
|
| 1044 |
+
movie_num_map = payload.get('movie_num_map', {})
|
| 1045 |
+
if not isinstance(movie_num_map, dict):
|
| 1046 |
+
return {}
|
| 1047 |
+
result = {}
|
| 1048 |
+
for movie_num, entry in movie_num_map.items():
|
| 1049 |
+
normalized_key = _normalize_movie_num_key(movie_num)
|
| 1050 |
+
if not normalized_key:
|
| 1051 |
+
continue
|
| 1052 |
+
if isinstance(entry, str):
|
| 1053 |
+
official_name = entry.strip()
|
| 1054 |
+
elif isinstance(entry, dict):
|
| 1055 |
+
official_name = str(entry.get('official_name') or '').strip()
|
| 1056 |
+
else:
|
| 1057 |
+
continue
|
| 1058 |
+
if official_name:
|
| 1059 |
+
result[normalized_key] = official_name
|
| 1060 |
+
return result
|
| 1061 |
+
|
| 1062 |
+
|
| 1063 |
+
def resolve_movie_name_from_schedule_item(raw_movie_name, movie_num=None, canonical_names=None):
|
| 1064 |
+
"""
|
| 1065 |
+
使用 movieNum 映射表解析影片正式名称(不含制式后缀,仅用于效率分析展示)。
|
| 1066 |
+
优先级:movieNum 映射 > canonical_names 匹配 > 空格分割回退。
|
| 1067 |
+
"""
|
| 1068 |
+
if not isinstance(raw_movie_name, str):
|
| 1069 |
+
return raw_movie_name
|
| 1070 |
+
|
| 1071 |
+
# 1. 尝试 movieNum 映射
|
| 1072 |
+
if movie_num:
|
| 1073 |
+
num_key = _normalize_movie_num_key(movie_num)
|
| 1074 |
+
if num_key:
|
| 1075 |
+
name_map = _load_movie_num_name_map()
|
| 1076 |
+
official_name = name_map.get(num_key)
|
| 1077 |
+
if official_name:
|
| 1078 |
+
return official_name
|
| 1079 |
+
|
| 1080 |
+
# 2. 回退到旧的 clean_movie_title 逻辑(不含制式后缀)
|
| 1081 |
+
base_name = None
|
| 1082 |
+
if canonical_names:
|
| 1083 |
+
sorted_names = sorted(canonical_names, key=len, reverse=True)
|
| 1084 |
+
for name in sorted_names:
|
| 1085 |
+
if name in raw_movie_name:
|
| 1086 |
+
base_name = name
|
| 1087 |
+
break
|
| 1088 |
+
if not base_name:
|
| 1089 |
+
base_name = raw_movie_name.split(' ', 1)[0]
|
| 1090 |
+
|
| 1091 |
+
return base_name
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
def get_available_movie_name_pool(show_date):
|
| 1095 |
token_data = load_token()
|
| 1096 |
token = token_data.get('token') if token_data else None
|
|
|
|
| 1116 |
|
| 1117 |
canonical_names = fetch_canonical_movie_names(token, show_date) if token else []
|
| 1118 |
cleaned_movie_names = {
|
| 1119 |
+
resolve_movie_name_from_schedule_item(
|
| 1120 |
+
item.get('movieName'),
|
| 1121 |
+
movie_num=item.get('movieNum'),
|
| 1122 |
+
canonical_names=canonical_names if canonical_names else None
|
| 1123 |
+
)
|
| 1124 |
for item in available_movies
|
| 1125 |
if item.get('movieName')
|
| 1126 |
}
|
|
|
|
| 1558 |
return
|
| 1559 |
|
| 1560 |
st.markdown("#### 销售总览 (套餐 Top 10 + 单品 Top 5)")
|
| 1561 |
+
st.dataframe(final_summary, width="stretch", hide_index=True)
|
| 1562 |
st.markdown("##### 复制到 Excel")
|
| 1563 |
st.code(copy_text, language='text')
|
| 1564 |
|
|
|
|
| 1864 |
if token and display_date_str:
|
| 1865 |
canonical_names = fetch_canonical_movie_names(token, display_date_str)
|
| 1866 |
|
| 1867 |
+
# 使用 movieNum 映射优先解析影片名(不含制式后缀)
|
| 1868 |
+
if 'movieNum' in df.columns:
|
| 1869 |
+
df['影片'] = df.apply(
|
| 1870 |
+
lambda row: resolve_movie_name_from_schedule_item(
|
| 1871 |
+
row['影片名称'],
|
| 1872 |
+
movie_num=row.get('movieNum'),
|
| 1873 |
+
canonical_names=canonical_names
|
| 1874 |
+
),
|
| 1875 |
+
axis=1
|
| 1876 |
+
)
|
| 1877 |
+
else:
|
| 1878 |
+
df['影片'] = df['影片名称'].apply(lambda x: clean_movie_title(x, canonical_names))
|
| 1879 |
df['影厅'] = df['影厅名称']
|
| 1880 |
df['人数合计'] = df['总人次']
|
| 1881 |
|
|
|
|
| 1904 |
'soldTicketNum': '总人次'
|
| 1905 |
}, inplace=True)
|
| 1906 |
df['放映日期'] = selected_date_str
|
| 1907 |
+
# 保留 movieNum 列供 build_daily_report_from_source_df 使用
|
| 1908 |
+
if 'movieNum' not in df.columns:
|
| 1909 |
+
df['movieNum'] = ''
|
| 1910 |
|
| 1911 |
result_df, _ = build_daily_report_from_source_df(df, selected_date_str=selected_date_str, token=token)
|
| 1912 |
return result_df
|
|
|
|
| 2781 |
|
| 2782 |
def load_schedule_check_settings():
|
| 2783 |
"""从 JSON 文件加载场次合理性检查设置"""
|
| 2784 |
+
ensure_settings_file_synced(SCHEDULE_CHECK_SETTINGS_FILE)
|
| 2785 |
if os.path.exists(SCHEDULE_CHECK_SETTINGS_FILE):
|
| 2786 |
try:
|
| 2787 |
with open(SCHEDULE_CHECK_SETTINGS_FILE, 'r', encoding='utf-8') as f:
|
|
|
|
| 2872 |
try:
|
| 2873 |
with open(SCHEDULE_CHECK_SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
| 2874 |
json.dump(settings_to_save, f, ensure_ascii=False, indent=4)
|
| 2875 |
+
upload_settings_file_to_r2(SCHEDULE_CHECK_SETTINGS_FILE)
|
| 2876 |
if show_toast:
|
| 2877 |
st.toast("场次合理性检查设置已保存!", icon="💾")
|
| 2878 |
return True
|
|
|
|
| 2939 |
rule1_enabled = st.checkbox("规则一:同影片最小开场间隔", key="schedule_check_rule1_enabled")
|
| 2940 |
with col2:
|
| 2941 |
st.number_input(
|
| 2942 |
+
"规则一间隔(分钟)", min_value=30, step=5,
|
| 2943 |
key="schedule_check_rule1_min_interval_minutes",
|
| 2944 |
disabled=not rule1_enabled
|
| 2945 |
)
|
|
|
|
| 2949 |
rule2_enabled = st.checkbox("规则二:X分钟内影片开场超过Y场", key="schedule_check_rule2_enabled")
|
| 2950 |
with col2:
|
| 2951 |
st.number_input(
|
| 2952 |
+
"规则二阈值(场)", min_value=4, step=1,
|
| 2953 |
key="schedule_check_rule2_threshold_sessions",
|
| 2954 |
disabled=not rule2_enabled
|
| 2955 |
)
|
| 2956 |
with col3:
|
| 2957 |
st.number_input(
|
| 2958 |
+
"规则二时间窗口(分钟)", min_value=20, step=5,
|
| 2959 |
key="schedule_check_rule2_window_minutes",
|
| 2960 |
disabled=not rule2_enabled
|
| 2961 |
)
|
|
|
|
| 2972 |
rule3_enabled = st.checkbox("规则三:场次开场断档", key="schedule_check_rule3_enabled")
|
| 2973 |
with col2:
|
| 2974 |
st.number_input(
|
| 2975 |
+
"规则三断档阈值(分钟)", min_value=30, step=5,
|
| 2976 |
key="schedule_check_rule3_gap_threshold_minutes",
|
| 2977 |
disabled=not rule3_enabled
|
| 2978 |
)
|
|
|
|
| 3001 |
rule6_enabled = st.checkbox("规则六:影厅场次转换时间检查", key="schedule_check_rule6_enabled")
|
| 3002 |
with col2:
|
| 3003 |
st.number_input(
|
| 3004 |
+
"场次转换基准(分钟)", min_value=10, step=1,
|
| 3005 |
key="schedule_check_rule6_min_conversion_minutes",
|
| 3006 |
disabled=not rule6_enabled
|
| 3007 |
)
|
|
|
|
| 3023 |
col1, col2 = st.columns([1, 2])
|
| 3024 |
with col1:
|
| 3025 |
st.number_input(
|
| 3026 |
+
"规则九热门TopN", min_value=3, step=1,
|
| 3027 |
key="schedule_check_rule9_hot_top_n",
|
| 3028 |
disabled=not rule9_enabled
|
| 3029 |
)
|
|
|
|
| 3047 |
rule12_enabled = st.checkbox("规则十二:次日票房前N影片必须有黄金场", key="schedule_check_rule12_enabled")
|
| 3048 |
with col2:
|
| 3049 |
st.number_input(
|
| 3050 |
+
"规则十二票房TopN", min_value=5, step=1,
|
| 3051 |
key="schedule_check_rule12_top_n",
|
| 3052 |
disabled=not rule12_enabled
|
| 3053 |
)
|
|
|
|
| 3170 |
|
| 3171 |
df_original['simpleHallName'] = df_original['Hall'].apply(simplify_hall)
|
| 3172 |
df_check = df_original.sort_values(by='startTime').reset_index(drop=True)
|
| 3173 |
+
# 使用 movieNum 映射优先解析影片名(不含制式后缀)
|
| 3174 |
+
if 'movieNum' in df_check.columns:
|
| 3175 |
+
df_check['clean_filmName'] = df_check.apply(
|
| 3176 |
+
lambda row: resolve_movie_name_from_schedule_item(
|
| 3177 |
+
row['filmName'],
|
| 3178 |
+
movie_num=row.get('movieNum'),
|
| 3179 |
+
),
|
| 3180 |
+
axis=1
|
| 3181 |
+
)
|
| 3182 |
+
else:
|
| 3183 |
+
df_check['clean_filmName'] = df_check['filmName'].apply(clean_movie_title)
|
| 3184 |
|
| 3185 |
next_day_bo_data = fetch_realtime_box_office(date_str)
|
| 3186 |
movie_box_office = {}
|
|
|
|
| 3970 |
if movie_length <= 0:
|
| 3971 |
movie_length = int((end_dt - start_dt).total_seconds() / 60)
|
| 3972 |
|
| 3973 |
+
film_name = resolve_movie_name_from_schedule_item(raw_movie_name, movie_num=item.get('movieNum'), canonical_names=canonical_names if canonical_names else None)
|
| 3974 |
film_language = item.get('movieLanguage', '') or ''
|
| 3975 |
imagery = item.get('movieMediaType', '') or ''
|
| 3976 |
movie_num = item.get('movieNum', '') or ''
|
|
|
|
| 4209 |
|
| 4210 |
st.dataframe(
|
| 4211 |
summary_df.style.format({'总票房': '{:,.2f}'}),
|
| 4212 |
+
width="stretch",
|
| 4213 |
+
hide_index=True,
|
| 4214 |
+
)
|
| 4215 |
+
|
| 4216 |
+
|
| 4217 |
+
|
| 4218 |
+
def load_nextday_pool_cache_for_app():
|
| 4219 |
+
"""加载次日电影池统计缓存 CSV 和 JSON 元数据"""
|
| 4220 |
+
nextday_pool_csv = CINEMA_CACHE_DIR / "history_sessions_monitor_nextday_pool_stats.csv"
|
| 4221 |
+
nextday_pool_json = CINEMA_CACHE_DIR / "history_sessions_monitor_nextday_pool_stats.json"
|
| 4222 |
+
if not nextday_pool_csv.exists():
|
| 4223 |
+
return pd.DataFrame(), {}
|
| 4224 |
+
try:
|
| 4225 |
+
df = pd.read_csv(nextday_pool_csv)
|
| 4226 |
+
except Exception:
|
| 4227 |
+
return pd.DataFrame(), {}
|
| 4228 |
+
meta = {}
|
| 4229 |
+
if nextday_pool_json.exists():
|
| 4230 |
+
try:
|
| 4231 |
+
with open(nextday_pool_json, 'r', encoding='utf-8') as f:
|
| 4232 |
+
meta = json.load(f)
|
| 4233 |
+
except Exception:
|
| 4234 |
+
pass
|
| 4235 |
+
return df, meta
|
| 4236 |
+
|
| 4237 |
+
|
| 4238 |
+
def render_nextday_pool_cache_table():
|
| 4239 |
+
"""渲染次日电影池统计缓存表"""
|
| 4240 |
+
pool_df, meta = load_nextday_pool_cache_for_app()
|
| 4241 |
+
if pool_df.empty:
|
| 4242 |
+
return
|
| 4243 |
+
|
| 4244 |
+
nextday_date = meta.get('nextday_date', '')
|
| 4245 |
+
reference_date = meta.get('reference_date', '')
|
| 4246 |
+
generated_at = meta.get('generated_at', '')
|
| 4247 |
+
status = meta.get('status', '')
|
| 4248 |
+
|
| 4249 |
+
title_parts = ["### 📊 次日电影池统计数据"]
|
| 4250 |
+
if nextday_date:
|
| 4251 |
+
title_parts[0] += f"(目标日期:{nextday_date})"
|
| 4252 |
+
st.markdown(title_parts[0])
|
| 4253 |
+
|
| 4254 |
+
caption_parts = []
|
| 4255 |
+
if reference_date:
|
| 4256 |
+
caption_parts.append(f"统计截止:{reference_date}")
|
| 4257 |
+
if generated_at:
|
| 4258 |
+
caption_parts.append(f"生成时间:{generated_at}")
|
| 4259 |
+
if status:
|
| 4260 |
+
caption_parts.append(status)
|
| 4261 |
+
if caption_parts:
|
| 4262 |
+
st.caption(" | ".join(caption_parts))
|
| 4263 |
+
|
| 4264 |
+
# 定义次日电影池的列顺序
|
| 4265 |
+
nextday_pool_windows = [
|
| 4266 |
+
("最近一天", 1),
|
| 4267 |
+
("最近三天", 3),
|
| 4268 |
+
("最近一周", 7),
|
| 4269 |
+
("最近一个月", 30),
|
| 4270 |
+
]
|
| 4271 |
+
display_columns = ["影片", "历史票房", "历史场次", "历史效率"]
|
| 4272 |
+
for label, _days in nextday_pool_windows:
|
| 4273 |
+
display_columns.extend([f"{label}票房", f"{label}场次", f"{label}效率"])
|
| 4274 |
+
|
| 4275 |
+
available_columns = [col for col in display_columns if col in pool_df.columns]
|
| 4276 |
+
remaining_columns = [col for col in pool_df.columns if col not in available_columns]
|
| 4277 |
+
display_df = pool_df[available_columns + remaining_columns].copy()
|
| 4278 |
+
|
| 4279 |
+
def _format_nextday_metric(value, pattern):
|
| 4280 |
+
if value is None:
|
| 4281 |
+
return 'n/a'
|
| 4282 |
+
if isinstance(value, str):
|
| 4283 |
+
if value.strip().lower() in {'', 'n/a', 'na', 'nan', '<na>'}:
|
| 4284 |
+
return 'n/a'
|
| 4285 |
+
try:
|
| 4286 |
+
if pd.isna(value):
|
| 4287 |
+
return 'n/a'
|
| 4288 |
+
except Exception:
|
| 4289 |
+
pass
|
| 4290 |
+
try:
|
| 4291 |
+
return pattern.format(float(value))
|
| 4292 |
+
except Exception:
|
| 4293 |
+
return str(value)
|
| 4294 |
+
|
| 4295 |
+
format_config = {
|
| 4296 |
+
'历史票房': lambda v: _format_nextday_metric(v, '{:,.2f}'),
|
| 4297 |
+
'历史场次': lambda v: _format_nextday_metric(v, '{:,.0f}'),
|
| 4298 |
+
'历史效率': lambda v: _format_nextday_metric(v, '{:.2f}'),
|
| 4299 |
+
}
|
| 4300 |
+
for label, _days in nextday_pool_windows:
|
| 4301 |
+
format_config[f'{label}票房'] = lambda v: _format_nextday_metric(v, '{:,.2f}')
|
| 4302 |
+
format_config[f'{label}场次'] = lambda v: _format_nextday_metric(v, '{:,.0f}')
|
| 4303 |
+
format_config[f'{label}效率'] = lambda v: _format_nextday_metric(v, '{:.2f}')
|
| 4304 |
+
|
| 4305 |
+
format_config = {k: v for k, v in format_config.items() if k in display_df.columns}
|
| 4306 |
+
st.dataframe(
|
| 4307 |
+
display_df.style.format(format_config),
|
| 4308 |
+
width="stretch",
|
| 4309 |
hide_index=True,
|
| 4310 |
)
|
| 4311 |
|
|
|
|
| 4353 |
date_str = f"{date_for_display} " if date_for_display else ""
|
| 4354 |
total_revenue, total_attendance, total_sessions = df_raw['总收入'].sum(), df_raw['总人次'].sum(), len(df_raw)
|
| 4355 |
st.markdown(
|
| 4356 |
+
f"""
|
| 4357 |
+
<div style="margin: 12px 0 18px; padding: 14px 18px; border-left: 6px solid #D83B01; background: #FFF4ED;">
|
| 4358 |
+
<span style="font-size: 18px; font-weight: 700; color: #5C1F00;">{date_str}总票房:</span>
|
| 4359 |
+
<span style="font-size: 26px; font-weight: 800; color: #D83B01;">¥{total_revenue:,.2f}</span>
|
| 4360 |
+
<span style="font-size: 18px; font-weight: 700; color: #5C1F00;"> | 总人次:</span>
|
| 4361 |
+
<span style="font-size: 26px; font-weight: 800; color: #D83B01;">{total_attendance:,.0f}</span>
|
| 4362 |
+
<span style="font-size: 18px; font-weight: 700; color: #5C1F00;"> | 总场次:</span>
|
| 4363 |
+
<span style="font-size: 26px; font-weight: 800; color: #D83B01;">{total_sessions:,.0f}</span>
|
| 4364 |
+
</div>
|
| 4365 |
+
""",
|
| 4366 |
+
unsafe_allow_html=True,
|
| 4367 |
+
)
|
| 4368 |
|
| 4369 |
format_config = {'座位数': '{:,.0f}', '场次': '{:,.0f}', '人次': '{:,.0f}', '票房': '{:,.2f}', '均价': '{:.2f}',
|
| 4370 |
'座次比': '{:.2%}', '场次比': '{:.2%}', '票房比': '{:.2%}', '座次效率': '{:.2f}',
|
|
|
|
| 4391 |
|
| 4392 |
st.markdown("#### 全天排片效率分析");
|
| 4393 |
st.dataframe(full_day_analysis.style.format(format_config).apply(style_efficiency, axis=1),
|
| 4394 |
+
width="stretch", hide_index=True)
|
| 4395 |
st.markdown("#### 黄金时段排片效率分析 (14:00-21:00)");
|
| 4396 |
st.dataframe(prime_time_analysis.style.format(format_config).apply(style_efficiency, axis=1),
|
| 4397 |
+
width="stretch", hide_index=True)
|
| 4398 |
if not full_day_analysis.empty:
|
| 4399 |
st.markdown("### 排片效率汇总")
|
| 4400 |
full_day_summary = full_day_analysis.rename(
|
|
|
|
| 4411 |
'全部座次效率': '{:.2f}', '全部场次效率': '{:.2f}', '黄金时段座次效率': '{:.2f}',
|
| 4412 |
'黄金时段场次效率': '{:.2f}'}
|
| 4413 |
st.dataframe(summary_df.style.format(summary_format_config).apply(style_summary_efficiency, axis=1),
|
| 4414 |
+
width="stretch", hide_index=True)
|
| 4415 |
|
| 4416 |
|
| 4417 |
+
st.markdown("### 📈 全国大盘实时票房查询")
|
| 4418 |
|
| 4419 |
# 增加手续费复选框,默认不包含
|
| 4420 |
include_fee = st.checkbox("包含手续费", value=False)
|
|
|
|
| 4466 |
'总场次': '{:,.0f}',
|
| 4467 |
'总人次': '{:,.0f}'
|
| 4468 |
}
|
| 4469 |
+
st.dataframe(today_df.style.format(format_dict), width="stretch", hide_index=True)
|
| 4470 |
else:
|
| 4471 |
st.info(f"未能获取到 {today_str} 的全国大盘数据。")
|
| 4472 |
|
|
|
|
| 4504 |
'总场次': '{:,.0f}',
|
| 4505 |
'总人次': '{:,.0f}'
|
| 4506 |
}
|
| 4507 |
+
st.dataframe(tomorrow_df.style.format(format_dict), width="stretch", hide_index=True)
|
| 4508 |
else:
|
| 4509 |
st.info(f"未能获取到 {tomorrow_str} 的全国大盘预售数据。")
|
| 4510 |
|
| 4511 |
+
render_nextday_pool_cache_table()
|
| 4512 |
+
|
| 4513 |
+
st.markdown(f"### 🎬 {today_str} 排片甘特图")
|
| 4514 |
render_today_api_gantt_chart(today_str)
|
| 4515 |
|
| 4516 |
|
|
|
|
| 4537 |
if token:
|
| 4538 |
canonical_names = fetch_canonical_movie_names(token, date_str)
|
| 4539 |
|
| 4540 |
+
# 使用 movieNum 映射优先解析影片名(不含制式后缀)
|
| 4541 |
+
if 'movieNum' in df.columns:
|
| 4542 |
+
df['影片名称_清理后'] = df.apply(
|
| 4543 |
+
lambda row: resolve_movie_name_from_schedule_item(
|
| 4544 |
+
row.get('movieName', ''),
|
| 4545 |
+
movie_num=row.get('movieNum'),
|
| 4546 |
+
canonical_names=canonical_names
|
| 4547 |
+
),
|
| 4548 |
+
axis=1
|
| 4549 |
+
)
|
| 4550 |
+
else:
|
| 4551 |
+
df['影片名称_清理后'] = df['movieName'].apply(lambda x: clean_movie_title(x, canonical_names))
|
| 4552 |
|
| 4553 |
sessions_map = df.groupby('影片名称_清理后').size().to_dict()
|
| 4554 |
# 返回 (映射表, 总场次, 原始排片列表)
|
|
|
|
| 4869 |
'座次效率': '{:.2f}', '场次效率': '{:.2f}', '次日场数': '{:,.0f}'}
|
| 4870 |
display_df['均价'] = pd.to_numeric(display_df['均价'], errors='coerce').replace([np.inf, -np.inf],
|
| 4871 |
np.nan)
|
| 4872 |
+
st.dataframe(display_df.style.format(report_format, na_rep="#DIV/0!"), width="stretch",
|
| 4873 |
hide_index=True)
|
| 4874 |
|
| 4875 |
n_diff = st.session_state.today_movie_count - st.session_state.previous_day_movie_count
|
|
|
|
| 5059 |
st.session_state.daily_report_df.style.format({
|
| 5060 |
'人数合计': '{:,.0f}', '座位数': '{:,.0f}', '上座率%': '{:.2f}%'
|
| 5061 |
}),
|
| 5062 |
+
width="stretch", hide_index=True
|
| 5063 |
)
|
| 5064 |
total_attendance = pd.to_numeric(
|
| 5065 |
st.session_state.daily_report_df.get('人数合计', 0),
|
|
|
|
| 5255 |
with tab_views[0]:
|
| 5256 |
st.markdown(display_pdf(led_output['pdf']), unsafe_allow_html=True)
|
| 5257 |
if 'png' in led_output:
|
| 5258 |
+
with tab_views[1]: st.image(led_output['png'], width="stretch")
|
| 5259 |
else:
|
| 5260 |
st.error("未能成功生成 '修改 LED 屏排片表'。请检查数据源。")
|
| 5261 |
|
|
|
|
| 5322 |
'时长(分钟)': format_play_time(item['play_time']),
|
| 5323 |
'文件名': format_content_name_with_explanation(item['content_name'])}
|
| 5324 |
for item in movie_list_sorted]
|
| 5325 |
+
st.dataframe(pd.DataFrame(view2_data), hide_index=True, width="stretch")
|
| 5326 |
st.markdown("#### 按影厅查看影片内容")
|
| 5327 |
hall_tabs = st.tabs(list(halls_data.keys()))
|
| 5328 |
for tab, hall_name in zip(hall_tabs, halls_data.keys()):
|
|
|
|
| 5332 |
'时长(分钟)': format_play_time(item['details']['play_time']),
|
| 5333 |
'文件名': format_content_name_with_explanation(item['content_name'])} for item in
|
| 5334 |
halls_data[hall_name]]
|
| 5335 |
+
st.dataframe(pd.DataFrame(view1_data), hide_index=True, width="stretch")
|
| 5336 |
except Exception as e:
|
| 5337 |
st.error(f"查询TMS服务器时出错: {e}")
|
| 5338 |
|