Ethscriptions commited on
Commit
c16c1d7
·
verified ·
1 Parent(s): 82fc1d0

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +29 -0
  2. app.py +1114 -0
  3. requirements.txt +11 -0
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 使用官方 Python 基础镜像
2
+ FROM python:3.13-slim
3
+
4
+ # 创建非 root 用户 (UID 1000)
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+
8
+ # 设置环境变量与工作目录
9
+ ENV HOME=/home/user \
10
+ PATH=/home/user/.local/bin:$PATH \
11
+ NEXTDAY_OPT_PORT=7860 \
12
+ NEXTDAY_OPT_HOST=0.0.0.0
13
+
14
+ WORKDIR $HOME/app
15
+
16
+ # 复制依赖文件并安装
17
+ COPY --chown=user requirements.txt $HOME/app/
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+ # 安装 gunicorn 用于生产环境运行
20
+ RUN pip install --no-cache-dir gunicorn
21
+
22
+ # 复制当前目录下的所有代码到容器中,并赋予 user 权限
23
+ COPY --chown=user . $HOME/app/
24
+
25
+ # 暴露 7860 端口
26
+ EXPOSE 7860
27
+
28
+ # 启动命令:通过 gunicorn 运行 app.py 中的 app 实例
29
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "--timeout", "120", "app:app"]
app.py ADDED
@@ -0,0 +1,1114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import html
2
+ import os
3
+ import re
4
+ import secrets
5
+ import sys
6
+ import threading
7
+ import uuid
8
+ from collections import Counter
9
+ from datetime import date, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import pandas as pd
14
+ from flask import Flask, jsonify, render_template, request, session
15
+
16
+ # Ensure project root is importable when this file runs directly.
17
+ BASE_DIR = Path(__file__).resolve().parent
18
+ if str(BASE_DIR) not in sys.path:
19
+ sys.path.insert(0, str(BASE_DIR))
20
+
21
+ import optimizer_core as opt # noqa: E402
22
+
23
+
24
+ app = Flask(
25
+ __name__,
26
+ template_folder=str(Path(__file__).resolve().parent / "templates"),
27
+ static_folder=str(Path(__file__).resolve().parent / "static"),
28
+ )
29
+ app.secret_key = os.getenv("NEXTDAY_OPTIMIZER_WEB_SECRET", secrets.token_hex(32))
30
+
31
+
32
+ _STATE_LOCK = threading.Lock()
33
+ _SESSION_STATE: Dict[str, Dict[str, Any]] = {}
34
+
35
+
36
+ TUNING_COLUMNS = [
37
+ "选中",
38
+ "影片",
39
+ "今日场次",
40
+ "今日黄金场次",
41
+ "今日全天效率",
42
+ "今日黄金效率",
43
+ "最少场次",
44
+ "最多场次",
45
+ "固定场次",
46
+ "最少黄金场次",
47
+ "最多黄金场次",
48
+ "最低场次占比",
49
+ "最高场次占比",
50
+ ]
51
+
52
+
53
+ def _get_sid() -> str:
54
+ sid = session.get("nextday_optimizer_sid")
55
+ if not sid:
56
+ sid = uuid.uuid4().hex
57
+ session["nextday_optimizer_sid"] = sid
58
+ return sid
59
+
60
+
61
+ def _get_user_state() -> Dict[str, Any]:
62
+ sid = _get_sid()
63
+ with _STATE_LOCK:
64
+ return _SESSION_STATE.setdefault(sid, {})
65
+
66
+
67
+ def _to_bool(v: Any, default: bool = False) -> bool:
68
+ if isinstance(v, bool):
69
+ return v
70
+ if v in (None, ""):
71
+ return default
72
+ if isinstance(v, (int, float)):
73
+ return bool(v)
74
+ s = str(v).strip().lower()
75
+ if s in {"1", "true", "yes", "y", "on"}:
76
+ return True
77
+ if s in {"0", "false", "no", "n", "off"}:
78
+ return False
79
+ return default
80
+
81
+
82
+ def _to_int(v: Any, default: int = 0) -> int:
83
+ try:
84
+ if v in (None, "", "None"):
85
+ return int(default)
86
+ return int(float(v))
87
+ except Exception:
88
+ return int(default)
89
+
90
+
91
+ def _to_float(v: Any, default: float = 0.0) -> float:
92
+ try:
93
+ if v in (None, "", "None"):
94
+ return float(default)
95
+ return float(v)
96
+ except Exception:
97
+ return float(default)
98
+
99
+
100
+ def _safe_value(v: Any) -> Any:
101
+ if v is None:
102
+ return None
103
+ if isinstance(v, (datetime, date)):
104
+ return v.isoformat()
105
+ if isinstance(v, bool):
106
+ return v
107
+ if isinstance(v, (int, float, str)):
108
+ if isinstance(v, float) and (pd.isna(v) or v in (float("inf"), float("-inf"))):
109
+ return None
110
+ return v
111
+ try:
112
+ if pd.isna(v):
113
+ return None
114
+ except Exception:
115
+ pass
116
+ if isinstance(v, (list, tuple)):
117
+ return [_safe_value(x) for x in v]
118
+ if isinstance(v, dict):
119
+ return {str(k): _safe_value(val) for k, val in v.items()}
120
+ return str(v)
121
+
122
+
123
+ def _df_to_records(df: Optional[pd.DataFrame]) -> List[Dict[str, Any]]:
124
+ if df is None or not isinstance(df, pd.DataFrame) or df.empty:
125
+ return []
126
+ out: List[Dict[str, Any]] = []
127
+ for _, row in df.iterrows():
128
+ item: Dict[str, Any] = {}
129
+ for col in df.columns:
130
+ item[str(col)] = _safe_value(row.get(col))
131
+ out.append(item)
132
+ return out
133
+
134
+
135
+ def _normalize_tuning_rows(rows: List[Dict[str, Any]]) -> pd.DataFrame:
136
+ if not rows:
137
+ return pd.DataFrame(columns=TUNING_COLUMNS)
138
+ df = pd.DataFrame(rows)
139
+ for c in TUNING_COLUMNS:
140
+ if c not in df.columns:
141
+ df[c] = None
142
+ df = df[TUNING_COLUMNS]
143
+ return opt.coerce_tuning_editor_df(df)
144
+
145
+
146
+ def _tuning_df_to_rows(df: Optional[pd.DataFrame]) -> List[Dict[str, Any]]:
147
+ if df is None or df.empty:
148
+ return []
149
+ return _df_to_records(df[TUNING_COLUMNS].copy())
150
+
151
+
152
+ def _build_runtime_cfg(cfg: Dict[str, Any], payload: Dict[str, Any]) -> Dict[str, Any]:
153
+ raw = payload or {}
154
+
155
+ # maintenance blocks keep list records; parser in opt handles stricter normalization.
156
+ maintenance_blocks = raw.get("maintenance_blocks")
157
+ if maintenance_blocks is None:
158
+ maintenance_blocks = cfg.get("maintenance_blocks", [])
159
+
160
+ widgets = {
161
+ "business_start": opt.parse_hm(str(raw.get("business_start", cfg.get("business_start", "09:30"))), "09:30"),
162
+ "business_end": opt.parse_hm(str(raw.get("business_end", cfg.get("business_end", "01:30"))), "01:30"),
163
+ "turnaround_base": _to_int(raw.get("turnaround_base", cfg.get("turnaround_base", 10)), 10),
164
+ "golden_start": opt.parse_hm(str(raw.get("golden_start", cfg.get("golden_start", "14:00"))), "14:00"),
165
+ "golden_end": opt.parse_hm(str(raw.get("golden_end", cfg.get("golden_end", "21:00"))), "21:00"),
166
+ "efficiency_enabled": _to_bool(raw.get("efficiency_enabled", cfg.get("efficiency_enabled", True)), True),
167
+ "efficiency_penalty_coef": _to_float(raw.get("efficiency_penalty_coef", cfg.get("efficiency_penalty_coef", 1.0)), 1.0),
168
+ "eff_daily_delta_cap": _to_int(raw.get("eff_daily_delta_cap", cfg.get("eff_daily_delta_cap", 5)), 5),
169
+ "rule1_enabled": _to_bool(raw.get("rule1_enabled", cfg.get("rule1_enabled", True)), True),
170
+ "rule1_gap": _to_int(raw.get("rule1_gap", cfg.get("rule1_gap", 30)), 30),
171
+ "rule2_enabled": _to_bool(raw.get("rule2_enabled", cfg.get("rule2_enabled", True)), True),
172
+ "rule2_threshold": _to_int(raw.get("rule2_threshold", cfg.get("rule2_threshold", 4)), 4),
173
+ "rule2_window_minutes": _to_int(raw.get("rule2_window_minutes", cfg.get("rule2_window_minutes", 30)), 30),
174
+ "rule2_penalty": _to_float(raw.get("rule2_penalty", cfg.get("rule2_penalty", 15.0)), 15.0),
175
+ "rule2_exempt_ranges": str(raw.get("rule2_exempt_ranges", ", ".join(cfg.get("rule2_exempt_ranges", [])))),
176
+ "rule3_enabled": _to_bool(raw.get("rule3_enabled", cfg.get("rule3_enabled", True)), True),
177
+ "rule3_gap_minutes": _to_int(raw.get("rule3_gap_minutes", cfg.get("rule3_gap_minutes", 30)), 30),
178
+ "rule3_penalty": _to_float(raw.get("rule3_penalty", cfg.get("rule3_penalty", 12.0)), 12.0),
179
+ "rule4_enabled": _to_bool(raw.get("rule4_enabled", cfg.get("rule4_enabled", True)), True),
180
+ "rule4_earliest": opt.parse_hm(str(raw.get("rule4_earliest", cfg.get("rule4_earliest", "10:00"))), "10:00"),
181
+ "rule4_latest": opt.parse_hm(str(raw.get("rule4_latest", cfg.get("rule4_latest", "22:30"))), "22:30"),
182
+ "rule9_enabled": _to_bool(raw.get("rule9_enabled", cfg.get("rule9_enabled", True)), True),
183
+ "rule9_hot_top_n": _to_int(raw.get("rule9_hot_top_n", cfg.get("rule9_hot_top_n", 3)), 3),
184
+ "rule9_min_ratio": _to_float(raw.get("rule9_min_ratio", cfg.get("rule9_min_ratio", 0.30)), 0.30),
185
+ "rule9_penalty": _to_float(raw.get("rule9_penalty", cfg.get("rule9_penalty", 20.0)), 20.0),
186
+ "rule11_enabled": _to_bool(raw.get("rule11_enabled", cfg.get("rule11_enabled", True)), True),
187
+ "rule11_after_time": opt.parse_hm(str(raw.get("rule11_after_time", cfg.get("rule11_after_time", "22:00"))), "22:00"),
188
+ "rule11_penalty": _to_float(raw.get("rule11_penalty", cfg.get("rule11_penalty", 30.0)), 30.0),
189
+ "rule12_enabled": _to_bool(raw.get("rule12_enabled", cfg.get("rule12_enabled", True)), True),
190
+ "rule12_penalty_each": _to_float(raw.get("rule12_penalty_each", cfg.get("rule12_penalty_each", 25.0)), 25.0),
191
+ "rule13_enabled": _to_bool(raw.get("rule13_enabled", cfg.get("rule13_enabled", True)), True),
192
+ "rule13_forbidden_halls": str(raw.get("rule13_forbidden_halls", ",".join(cfg.get("rule13_forbidden_halls", [])))),
193
+ "tms_allowance": _to_int(raw.get("tms_allowance", cfg.get("tms_allowance", 0)), 0),
194
+ "maintenance_blocks": maintenance_blocks,
195
+ "iterations": _to_int(raw.get("iterations", cfg.get("iterations", 300)), 300),
196
+ "random_seed": _to_int(raw.get("random_seed", cfg.get("random_seed", 20260331)), 20260331),
197
+ }
198
+ return opt.build_runtime_config_from_widgets(cfg, widgets)
199
+
200
+
201
+ def _runtime_cfg_for_json(cfg: Dict[str, Any]) -> Dict[str, Any]:
202
+ return {
203
+ "business_start": str(cfg.get("business_start", "09:30")),
204
+ "business_end": str(cfg.get("business_end", "01:30")),
205
+ "turnaround_base": _to_int(cfg.get("turnaround_base", 10), 10),
206
+ "golden_start": str(cfg.get("golden_start", "14:00")),
207
+ "golden_end": str(cfg.get("golden_end", "21:00")),
208
+ "efficiency_enabled": _to_bool(cfg.get("efficiency_enabled", True), True),
209
+ "efficiency_penalty_coef": _to_float(cfg.get("efficiency_penalty_coef", 1.0), 1.0),
210
+ "eff_daily_delta_cap": _to_int(cfg.get("eff_daily_delta_cap", 5), 5),
211
+ "rule1_enabled": _to_bool(cfg.get("rule1_enabled", True), True),
212
+ "rule1_gap": _to_int(cfg.get("rule1_gap", 30), 30),
213
+ "rule2_enabled": _to_bool(cfg.get("rule2_enabled", True), True),
214
+ "rule2_threshold": _to_int(cfg.get("rule2_threshold", 4), 4),
215
+ "rule2_window_minutes": _to_int(cfg.get("rule2_window_minutes", 30), 30),
216
+ "rule2_penalty": _to_float(cfg.get("rule2_penalty", 15.0), 15.0),
217
+ "rule2_exempt_ranges": ", ".join(cfg.get("rule2_exempt_ranges", [])),
218
+ "rule3_enabled": _to_bool(cfg.get("rule3_enabled", True), True),
219
+ "rule3_gap_minutes": _to_int(cfg.get("rule3_gap_minutes", 30), 30),
220
+ "rule3_penalty": _to_float(cfg.get("rule3_penalty", 12.0), 12.0),
221
+ "rule4_enabled": _to_bool(cfg.get("rule4_enabled", True), True),
222
+ "rule4_earliest": str(cfg.get("rule4_earliest", "10:00")),
223
+ "rule4_latest": str(cfg.get("rule4_latest", "22:30")),
224
+ "rule9_enabled": _to_bool(cfg.get("rule9_enabled", True), True),
225
+ "rule9_hot_top_n": _to_int(cfg.get("rule9_hot_top_n", 3), 3),
226
+ "rule9_min_ratio": _to_float(cfg.get("rule9_min_ratio", 0.30), 0.30),
227
+ "rule9_penalty": _to_float(cfg.get("rule9_penalty", 20.0), 20.0),
228
+ "rule11_enabled": _to_bool(cfg.get("rule11_enabled", True), True),
229
+ "rule11_after_time": str(cfg.get("rule11_after_time", "22:00")),
230
+ "rule11_penalty": _to_float(cfg.get("rule11_penalty", 30.0), 30.0),
231
+ "rule12_enabled": _to_bool(cfg.get("rule12_enabled", True), True),
232
+ "rule12_penalty_each": _to_float(cfg.get("rule12_penalty_each", 25.0), 25.0),
233
+ "rule13_enabled": _to_bool(cfg.get("rule13_enabled", True), True),
234
+ "rule13_forbidden_halls": ",".join(str(x) for x in cfg.get("rule13_forbidden_halls", ["2", "8", "9"])),
235
+ "tms_allowance": _to_int(cfg.get("tms_allowance", 0), 0),
236
+ "maintenance_blocks": list(cfg.get("maintenance_blocks", [])),
237
+ "iterations": _to_int(cfg.get("iterations", 300), 300),
238
+ "random_seed": _to_int(cfg.get("random_seed", 20260331), 20260331),
239
+ }
240
+
241
+
242
+ def _base_and_target(base_str: str) -> Tuple[date, date, str, str]:
243
+ try:
244
+ base_date = datetime.strptime(base_str, "%Y-%m-%d").date()
245
+ except Exception:
246
+ base_date = date.today()
247
+ target_date = base_date + timedelta(days=1)
248
+ return base_date, target_date, base_date.strftime("%Y-%m-%d"), target_date.strftime("%Y-%m-%d")
249
+
250
+
251
+ def _normalize_string_list(raw: Any) -> List[str]:
252
+ if not isinstance(raw, list):
253
+ return []
254
+ out: List[str] = []
255
+ seen: set[str] = set()
256
+ for x in raw:
257
+ v = str(x).strip()
258
+ if not v or v in seen:
259
+ continue
260
+ seen.add(v)
261
+ out.append(v)
262
+ return out
263
+
264
+
265
+ def _normalize_exclude_rules(raw: Any) -> Dict[str, bool]:
266
+ obj = raw if isinstance(raw, dict) else {}
267
+ return {
268
+ "zero_sales": _to_bool(obj.get("zero_sales", False), False),
269
+ "early_morning": _to_bool(obj.get("early_morning", False), False),
270
+ }
271
+
272
+
273
+ def _session_start_text(session: Dict[str, Any]) -> str:
274
+ return str(session.get("showStartTime") or session.get("startTime") or "").strip()
275
+
276
+
277
+ def _session_end_text(session: Dict[str, Any]) -> str:
278
+ return str(session.get("showEndTime") or session.get("endTime") or "").strip()
279
+
280
+
281
+ def _session_hall_name(session: Dict[str, Any]) -> str:
282
+ return str(session.get("hallName") or session.get("hallId") or "").strip()
283
+
284
+
285
+ def _session_movie_name(session: Dict[str, Any]) -> str:
286
+ return str(session.get("movieName") or "").strip()
287
+
288
+
289
+ def _session_sold_tickets(session: Dict[str, Any]) -> int:
290
+ return _to_int(
291
+ session.get("soldTicketNum", session.get("buyTicketNum", session.get("ticketNum", 0))),
292
+ 0,
293
+ )
294
+
295
+
296
+ def _session_box_office(session: Dict[str, Any]) -> float:
297
+ return _to_float(session.get("soldBoxOffice", 0.0), 0.0)
298
+
299
+
300
+ def _hm_to_minutes(text: str) -> Optional[int]:
301
+ if not text:
302
+ return None
303
+ try:
304
+ t = datetime.strptime(str(text), "%H:%M")
305
+ except Exception:
306
+ return None
307
+ return t.hour * 60 + t.minute
308
+
309
+
310
+ def _is_zero_sales_session(session: Dict[str, Any]) -> bool:
311
+ return _session_sold_tickets(session) <= 0 and _session_box_office(session) <= 0
312
+
313
+
314
+ def _is_early_morning_session(session: Dict[str, Any]) -> bool:
315
+ mins = _hm_to_minutes(_session_start_text(session))
316
+ return mins is not None and mins < 10 * 60
317
+
318
+
319
+ def _session_special_tags(session: Dict[str, Any]) -> List[str]:
320
+ tags: List[str] = []
321
+ if _is_zero_sales_session(session):
322
+ tags.append("零票零票房")
323
+ if _is_early_morning_session(session):
324
+ tags.append("早场")
325
+ sold = _session_sold_tickets(session)
326
+ box_office = _session_box_office(session)
327
+ if sold <= 1 and 0 < box_office <= 20:
328
+ tags.append("低票低收")
329
+ return tags
330
+
331
+
332
+ def _build_exclusion_options(
333
+ raw_today_schedule: List[Dict[str, Any]],
334
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
335
+ options: List[Dict[str, Any]] = []
336
+ movie_counter: Counter[str] = Counter()
337
+ hall_counter: Counter[str] = Counter()
338
+
339
+ for idx, session in enumerate(raw_today_schedule):
340
+ movie = _session_movie_name(session) or "未知影片"
341
+ hall = _session_hall_name(session) or "未知影厅"
342
+ start = _session_start_text(session)
343
+ end = _session_end_text(session)
344
+ sold = _session_sold_tickets(session)
345
+ box_office = _session_box_office(session)
346
+ tags = _session_special_tags(session)
347
+ tag_text = f" | {'/'.join(tags)}" if tags else ""
348
+ time_span = f"{start}-{end}" if end else start
349
+ options.append(
350
+ {
351
+ "key": str(idx),
352
+ "label": f"{time_span} | {hall} | {movie} | 票{sold} 票房{box_office:.0f}{tag_text}",
353
+ "movie": movie,
354
+ "hall": hall,
355
+ "suspected": bool(tags),
356
+ "legacy_label": opt.session_display_label(session),
357
+ }
358
+ )
359
+ movie_counter[movie] += 1
360
+ hall_counter[hall] += 1
361
+
362
+ movie_options = [
363
+ {"value": name, "label": f"{name}({cnt}场)", "count": int(cnt)}
364
+ for name, cnt in sorted(movie_counter.items(), key=lambda kv: (-kv[1], kv[0]))
365
+ ]
366
+ hall_options = [
367
+ {"value": name, "label": f"{name}({cnt}场)", "count": int(cnt)}
368
+ for name, cnt in sorted(hall_counter.items(), key=lambda kv: (-kv[1], _hall_sort_key(kv[0])))
369
+ ]
370
+ return options, movie_options, hall_options
371
+
372
+
373
+ def _apply_exclusion_filters(
374
+ schedule_list: List[Dict[str, Any]],
375
+ excluded_session_keys: List[str],
376
+ excluded_movies: List[str],
377
+ excluded_halls: List[str],
378
+ exclude_rules: Dict[str, bool],
379
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any], List[str]]:
380
+ if not schedule_list:
381
+ return [], {"total_count": 0, "removed_count": 0, "remaining_count": 0, "affected_movies": 0, "reason_breakdown": {}}, []
382
+
383
+ key_set = {int(x) for x in excluded_session_keys if str(x).isdigit()}
384
+ movie_set = set(excluded_movies)
385
+ hall_set = set(excluded_halls)
386
+ rules = _normalize_exclude_rules(exclude_rules)
387
+
388
+ filtered: List[Dict[str, Any]] = []
389
+ removed_labels: List[str] = []
390
+ removed_movies: set[str] = set()
391
+ reason_counter: Counter[str] = Counter()
392
+
393
+ for idx, session in enumerate(schedule_list):
394
+ reasons: List[str] = []
395
+ movie = _session_movie_name(session)
396
+ hall = _session_hall_name(session)
397
+
398
+ if idx in key_set:
399
+ reasons.append("手动场次")
400
+ if movie and movie in movie_set:
401
+ reasons.append("按影片剔除")
402
+ if hall and hall in hall_set:
403
+ reasons.append("按影厅剔除")
404
+ if rules["zero_sales"] and _is_zero_sales_session(session):
405
+ reasons.append("零票零票房")
406
+ if rules["early_morning"] and _is_early_morning_session(session):
407
+ reasons.append("早场(10:00前)")
408
+
409
+ if reasons:
410
+ removed_labels.append(opt.session_display_label(session))
411
+ if movie:
412
+ removed_movies.add(movie)
413
+ for reason in sorted(set(reasons)):
414
+ reason_counter[reason] += 1
415
+ else:
416
+ filtered.append(session)
417
+
418
+ effect = {
419
+ "total_count": len(schedule_list),
420
+ "removed_count": len(schedule_list) - len(filtered),
421
+ "remaining_count": len(filtered),
422
+ "affected_movies": len(removed_movies),
423
+ "reason_breakdown": dict(reason_counter.most_common()),
424
+ }
425
+ return filtered, effect, removed_labels
426
+
427
+
428
+ def _bundle_payload(state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
429
+ bundle = state.get("bundle")
430
+ if not isinstance(bundle, dict):
431
+ return None
432
+
433
+ raw_today_schedule = bundle.get("today_schedule_raw") or bundle.get("today_schedule") or []
434
+ exclude_options, exclude_movie_options, exclude_hall_options = _build_exclusion_options(raw_today_schedule)
435
+
436
+ key_to_legacy = {item["key"]: item["legacy_label"] for item in exclude_options}
437
+ legacy_to_keys: Dict[str, List[str]] = {}
438
+ for item in exclude_options:
439
+ legacy_to_keys.setdefault(item["legacy_label"], []).append(item["key"])
440
+
441
+ valid_key_set = {item["key"] for item in exclude_options}
442
+ valid_movie_set = {item["value"] for item in exclude_movie_options}
443
+ valid_hall_set = {item["value"] for item in exclude_hall_options}
444
+
445
+ excluded_session_keys = _normalize_string_list(bundle.get("today_schedule_excluded_keys") or [])
446
+ if not excluded_session_keys:
447
+ legacy_labels = _normalize_string_list(bundle.get("today_schedule_excluded_labels") or [])
448
+ mapped: List[str] = []
449
+ for label in legacy_labels:
450
+ mapped.extend(legacy_to_keys.get(label, []))
451
+ excluded_session_keys = _normalize_string_list(mapped)
452
+ excluded_session_keys = [x for x in excluded_session_keys if x in valid_key_set]
453
+
454
+ excluded_movies = [x for x in _normalize_string_list(bundle.get("today_schedule_excluded_movies") or []) if x in valid_movie_set]
455
+ excluded_halls = [x for x in _normalize_string_list(bundle.get("today_schedule_excluded_halls") or []) if x in valid_hall_set]
456
+ exclude_rules = _normalize_exclude_rules(bundle.get("today_schedule_exclude_rules") or {})
457
+ exclude_effect = bundle.get("today_schedule_exclusion_effect") or {
458
+ "total_count": len(raw_today_schedule),
459
+ "removed_count": 0,
460
+ "remaining_count": len(raw_today_schedule),
461
+ "affected_movies": 0,
462
+ "reason_breakdown": {},
463
+ }
464
+
465
+ return {
466
+ "target_str": str(bundle.get("target_str", "")),
467
+ "today_str": str(bundle.get("today_str", "")),
468
+ "excluded_labels": [key_to_legacy.get(k, "") for k in excluded_session_keys if key_to_legacy.get(k, "")],
469
+ "excluded_session_keys": excluded_session_keys,
470
+ "excluded_movies": excluded_movies,
471
+ "excluded_halls": excluded_halls,
472
+ "exclude_rules": exclude_rules,
473
+ "exclude_options": [{k: v for k, v in item.items() if k != "legacy_label"} for item in exclude_options],
474
+ "exclude_movie_options": exclude_movie_options,
475
+ "exclude_hall_options": exclude_hall_options,
476
+ "exclude_effect": _safe_value(exclude_effect),
477
+ "movies_count": len(bundle.get("movies", [])),
478
+ "locked_count": len(bundle.get("locked_sessions", [])),
479
+ "tuning_rows": _tuning_df_to_rows(state.get("tuning_df")),
480
+ "today_eff_rows": _df_to_records(bundle.get("today_eff")),
481
+ }
482
+
483
+
484
+ def _repair_broken_state() -> Dict[str, Any]:
485
+ state = opt.read_job_state()
486
+ if state.get("status") in {"running", "paused"} and opt._find_live_worker() is None: # noqa: SLF001
487
+ if _to_int(state.get("iter_done", 0), 0) < _to_int(state.get("iterations", 0), 0):
488
+ state = opt.write_job_state(status="failed", control="run", message="后台任务已中断,请重新启动。")
489
+ return state
490
+
491
+
492
+ def _job_state_payload(state: Dict[str, Any]) -> Dict[str, Any]:
493
+ return {
494
+ "status": str(state.get("status", "idle")),
495
+ "control": str(state.get("control", "run")),
496
+ "job_id": str(state.get("job_id", "")),
497
+ "started_at": str(state.get("started_at", "")),
498
+ "ended_at": str(state.get("ended_at", "")),
499
+ "target_date": str(state.get("target_date", "")),
500
+ "iterations": _to_int(state.get("iterations", 0), 0),
501
+ "iter_done": _to_int(state.get("iter_done", 0), 0),
502
+ "progress": _to_float(state.get("progress", 0.0), 0.0),
503
+ "elapsed_seconds": _to_float(state.get("elapsed_seconds", 0.0), 0.0),
504
+ "feasible_count": _to_int(state.get("feasible_count", 0), 0),
505
+ "hard_reject": _to_int(state.get("hard_reject", 0), 0),
506
+ "build_reject": _to_int(state.get("build_reject", 0), 0),
507
+ "rule_reject": _to_int(state.get("rule_reject", 0), 0),
508
+ "reject_reason_top": _safe_value(state.get("reject_reason_top") or {}),
509
+ "reject_detail_top": _safe_value(state.get("reject_detail_top") or {}),
510
+ "message": str(state.get("message", "")),
511
+ "result_count": _to_int(state.get("result_count", 0), 0),
512
+ }
513
+
514
+
515
+ def _hall_sort_key(hall_name: str) -> Tuple[int, str]:
516
+ nums = re.findall(r"\d+", str(hall_name))
517
+ return (int(nums[0]), str(hall_name)) if nums else (9999, str(hall_name))
518
+
519
+
520
+ GANTT_MOVIE_COLORS = [
521
+ "#9D6BFF", "#FF6699", "#FF6666", "#2CD52C", "#99CCFF", "#FFCC99",
522
+ "#FF6600", "#CCFF99", "#CC9999", "#FF9900", "#2CB5FB", "#CC66CC",
523
+ "#9981B1", "#FFFF66", "#009999", "#3366CC", "#996633",
524
+ ]
525
+ GANTT_TEXT_COLOR = "#000000"
526
+
527
+
528
+ def _gantt_color_key(movie_name: Any, movie_num: Any, media_type: Any, movie_language: Any) -> str:
529
+ movie_num_value = str(movie_num or "").strip()
530
+ base_key = movie_num_value if movie_num_value else str(movie_name or "").strip()
531
+ language_value = str(movie_language or "").strip()
532
+ imagery_value = str(media_type or "").strip()
533
+ return "||".join([base_key, language_value, imagery_value])
534
+
535
+
536
+ def render_gantt_html(schedule: List[Dict[str, Any]], date_str: str) -> str:
537
+ if not schedule:
538
+ return '<div class="empty-msg">无排片数据</div>'
539
+
540
+ df = pd.DataFrame(schedule).copy()
541
+ if df.empty:
542
+ return '<div class="empty-msg">无排片数据</div>'
543
+
544
+ df["startTime"] = pd.to_datetime(df["startTime"], errors="coerce")
545
+ df["endTime"] = pd.to_datetime(df["endTime"], errors="coerce")
546
+ df = df.dropna(subset=["hallName", "movieName", "startTime", "endTime"]).copy()
547
+ overnight_mask = df["endTime"] <= df["startTime"]
548
+ if overnight_mask.any():
549
+ df.loc[overnight_mask, "endTime"] = df.loc[overnight_mask, "endTime"] + timedelta(days=1)
550
+ if df.empty:
551
+ return '<div class="empty-msg">无有效排片数据</div>'
552
+
553
+ df["movieColorKey"] = df.apply(
554
+ lambda r: _gantt_color_key(
555
+ r.get("movieName"),
556
+ r.get("movieNum"),
557
+ r.get("movieMediaType"),
558
+ r.get("movieLanguage"),
559
+ ),
560
+ axis=1,
561
+ )
562
+ hall_order = sorted(df["hallName"].astype(str).unique().tolist(), key=_hall_sort_key)
563
+ t_min = df["startTime"].min().replace(minute=0, second=0, microsecond=0)
564
+ t_max = (df["endTime"].max() + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
565
+ total_minutes = max(60.0, (t_max - t_min).total_seconds() / 60.0)
566
+ total_hours = max(1, int((t_max - t_min).total_seconds() / 3600))
567
+
568
+ color_keys = sorted(df["movieColorKey"].astype(str).unique().tolist())
569
+ color_map = {k: GANTT_MOVIE_COLORS[i % len(GANTT_MOVIE_COLORS)] for i, k in enumerate(color_keys)}
570
+ legend_map: Dict[str, str] = {}
571
+ for _, r in df.iterrows():
572
+ key = str(r.get("movieColorKey") or "")
573
+ film_name = str(r.get("movieName") or "")
574
+ media_type = str(r.get("movieMediaType") or "").strip()
575
+ if key not in legend_map:
576
+ legend_map[key] = f"{film_name} {media_type}".strip()
577
+ legend_items = [
578
+ f'<span class="gantt-legend-item"><i style="background:{color_map.get(k, "#778899")}"></i>{html.escape(legend_map.get(k, k))}</span>'
579
+ for k in color_keys[:16]
580
+ ]
581
+ if len(color_keys) > 16:
582
+ legend_items.append(f'<span class="gantt-legend-more">+{len(color_keys) - 16} 个制式/影片</span>')
583
+ legend_html = "".join(legend_items)
584
+
585
+ labels = []
586
+ for i in range(total_hours + 1):
587
+ labels.append(f'<div class="gantt-time-label">{(t_min + timedelta(hours=i)).strftime("%H:%M")}</div>')
588
+ time_labels_html = "".join(labels)
589
+
590
+ halls_html = ""
591
+ for hall in hall_order:
592
+ row_df = df[df["hallName"].astype(str) == hall].sort_values("startTime")
593
+ blocks = ""
594
+ for _, r in row_df.iterrows():
595
+ start = r["startTime"]
596
+ end = r["endTime"]
597
+ left = ((start - t_min).total_seconds() / 60.0 / total_minutes) * 100.0
598
+ width = ((end - start).total_seconds() / 60.0 / total_minutes) * 100.0
599
+ if left < 0:
600
+ width += left
601
+ left = 0
602
+ width = max(0.4, width)
603
+ if left + width > 100:
604
+ width = max(0.4, 100 - left)
605
+ movie_name = str(r.get("movieName") or "")
606
+ media_type = str(r.get("movieMediaType") or "").strip()
607
+ film_title = f"{movie_name} {media_type}".strip()
608
+ hall_name = str(r.get("hallName") or "")
609
+ sold = _to_int(r.get("sold", r.get("soldTicketNum", 0)), 0)
610
+ duration_min = int((end - start).total_seconds() / 60)
611
+ tooltip = html.escape(
612
+ f"{film_title}\n{hall_name}\n{start.strftime('%H:%M')} - {end.strftime('%H:%M')} ({duration_min}min)\n已售:{sold}",
613
+ quote=True,
614
+ ).replace("\n", "&#10;")
615
+ lock_tag = '<span class="gantt-lock">已售锁定</span>' if _to_bool(r.get("is_presold", False), False) else ""
616
+ blocks += (
617
+ f'<div class="gantt-block" style="left:{left:.3f}%;width:{width:.3f}%;'
618
+ f'background-color:{color_map.get(str(r.get("movieColorKey") or ""), "#778899")};" title="{tooltip}">'
619
+ f'<div class="gantt-film">{html.escape(movie_name)}</div>'
620
+ f'<div class="gantt-meta">{start.strftime("%H:%M")}-{end.strftime("%H:%M")} · {duration_min}m</div>'
621
+ f"{lock_tag}"
622
+ "</div>"
623
+ )
624
+ halls_html += (
625
+ f'<div class="gantt-hall-row"><div class="gantt-hall-name">{html.escape(str(hall))}</div>'
626
+ f'<div class="gantt-timeline">{blocks}</div></div>'
627
+ )
628
+
629
+ dt_obj = datetime.strptime(date_str, "%Y-%m-%d")
630
+ weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
631
+ date_display = f"{dt_obj.strftime('%Y.%m.%d')} {weekdays[dt_obj.weekday()]} · 场次 {len(df)}"
632
+ half_hour_grid_size = 100 / max(1, total_hours * 2)
633
+ min_width = max(1320, int(total_hours * 96))
634
+
635
+ return f"""
636
+ <div class="gantt-scroll-wrapper">
637
+ <div class="gantt-container" style="min-width:{min_width}px">
638
+ <div class="gantt-header">
639
+ <div class="gantt-title">{html.escape(date_display)}</div>
640
+ <div class="gantt-legend">{legend_html}</div>
641
+ </div>
642
+ <div class="gantt-grid">
643
+ <div class="gantt-corner"></div>
644
+ <div class="gantt-time-axis">{time_labels_html}</div>
645
+ {halls_html}
646
+ </div>
647
+ </div>
648
+ </div>
649
+ <style>
650
+ .gantt-scroll-wrapper {{width:100%;overflow-x:auto;border:1px solid #dbe2ec;border-radius:10px;margin-bottom:1rem;background:#fff;}}
651
+ .gantt-container {{font-family:"PingFang SC","Noto Sans CJK SC","Microsoft YaHei",sans-serif;background:#fff;}}
652
+ .gantt-header {{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;padding:10px 14px;background:#f7fbff;border-bottom:1px solid #dee6f2;}}
653
+ .gantt-title {{font-size:14px;font-weight:700;color:#173b72;white-space:nowrap;}}
654
+ .gantt-legend {{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;}}
655
+ .gantt-legend-item {{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border:1px solid #d8e3f0;border-radius:999px;font-size:11px;color:#305275;background:#fff;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
656
+ .gantt-legend-item i {{display:inline-block;width:9px;height:9px;border-radius:2px;flex:0 0 9px;}}
657
+ .gantt-legend-more {{font-size:11px;color:#4e6786;align-self:center;}}
658
+ .gantt-grid {{display:grid;grid-template-columns:146px 1fr;}}
659
+ .gantt-corner {{grid-column:1;grid-row:1;border-bottom:1px solid #dee6f2;border-right:1px solid #dee6f2;background:#f7fbff;}}
660
+ .gantt-time-axis {{grid-column:2;display:flex;background:#f7fbff;border-bottom:1px solid #dee6f2;}}
661
+ .gantt-time-label {{flex:1;text-align:center;padding:8px 0;font-size:12px;color:#4f6684;border-left:1px solid #d3dbe7;}}
662
+ .gantt-hall-row {{display:contents;}}
663
+ .gantt-hall-name {{grid-column:1;padding:10px 8px;font-size:13px;font-weight:600;border-right:1px solid #e3eaf5;border-top:1px solid #e3eaf5;background:#fbfdff;text-align:center;display:flex;align-items:center;justify-content:center;line-height:1.2;}}
664
+ .gantt-timeline {{grid-column:2;position:relative;border-top:1px solid #e3eaf5;background-image:linear-gradient(to right,#eef3fa 1px,transparent 1px),linear-gradient(to right,#d7dfeb 1px,transparent 1px);background-size:{half_hour_grid_size}% 100%, {100 / max(1, total_hours)}% 100%;min-height:68px;}}
665
+ .gantt-block {{position:absolute;top:5px;bottom:5px;border-radius:6px;padding:5px 8px;color:{GANTT_TEXT_COLOR};overflow:hidden;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;box-sizing:border-box;border:1px solid rgba(0,0,0,0.16);box-shadow:0 1px 2px rgba(0,0,0,0.08);}}
666
+ .gantt-film,.gantt-meta {{width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
667
+ .gantt-film {{font-weight:700;font-size:12px;line-height:1.2;}}
668
+ .gantt-meta {{font-size:11px;opacity:0.9;line-height:1.2;margin-top:2px;}}
669
+ .gantt-lock {{display:inline-block;margin-top:2px;padding:1px 5px;border-radius:10px;font-size:10px;background:rgba(255,255,255,0.55);border:1px solid rgba(0,0,0,0.15);}}
670
+ .empty-msg {{padding:12px;color:#666;}}
671
+ </style>
672
+ """
673
+
674
+
675
+ @app.route("/")
676
+ def index() -> str:
677
+ return render_template("index.html")
678
+
679
+
680
+ @app.get("/api/session")
681
+ def api_session() -> Any:
682
+ cfg = opt.load_config()
683
+ state = _get_user_state()
684
+ today = date.today()
685
+
686
+ bundle = state.get("bundle")
687
+ if isinstance(bundle, dict) and bundle.get("today_str"):
688
+ try:
689
+ base_date = datetime.strptime(str(bundle.get("today_str")), "%Y-%m-%d").date()
690
+ except Exception:
691
+ base_date = today
692
+ else:
693
+ base_date = today
694
+
695
+ _, _, base_str, target_str = _base_and_target(base_date.strftime("%Y-%m-%d"))
696
+ job_state = _repair_broken_state()
697
+
698
+ return jsonify(
699
+ {
700
+ "success": True,
701
+ "base_date": base_str,
702
+ "target_date": target_str,
703
+ "runtime_cfg": _runtime_cfg_for_json(state.get("runtime_cfg") or cfg),
704
+ "bundle": _bundle_payload(state),
705
+ "job_state": _job_state_payload(job_state),
706
+ }
707
+ )
708
+
709
+
710
+ @app.post("/api/config/save")
711
+ def api_config_save() -> Any:
712
+ body = request.get_json(silent=True) or {}
713
+ cfg = opt.load_config()
714
+ runtime_cfg = _build_runtime_cfg(cfg, body.get("runtime_cfg") or {})
715
+ opt.save_config(runtime_cfg)
716
+
717
+ state = _get_user_state()
718
+ state["runtime_cfg"] = runtime_cfg
719
+
720
+ return jsonify({"success": True, "runtime_cfg": _runtime_cfg_for_json(runtime_cfg)})
721
+
722
+
723
+ @app.post("/api/config/reset")
724
+ def api_config_reset() -> Any:
725
+ opt.save_config(dict(opt.DEFAULT_CONFIG))
726
+ cfg = opt.load_config()
727
+
728
+ state = _get_user_state()
729
+ state["runtime_cfg"] = cfg
730
+
731
+ return jsonify({"success": True, "runtime_cfg": _runtime_cfg_for_json(cfg)})
732
+
733
+
734
+ @app.post("/api/load-data")
735
+ def api_load_data() -> Any:
736
+ body = request.get_json(silent=True) or {}
737
+ base_date, target_date, today_str, target_str = _base_and_target(str(body.get("base_date", date.today().strftime("%Y-%m-%d"))))
738
+
739
+ current_cfg = opt.load_config()
740
+ runtime_cfg = _build_runtime_cfg(current_cfg, body.get("runtime_cfg") or {})
741
+ opt.save_config(runtime_cfg)
742
+
743
+ next_day_schedule, hall_seat_map, err_next = opt.fetch_schedule_and_halls(target_str)
744
+ today_schedule, _, err_today = opt.fetch_schedule_and_halls(today_str)
745
+
746
+ if err_next:
747
+ return jsonify({"success": False, "error": f"次日排片拉取失败:{err_next}"}), 400
748
+ if err_today:
749
+ return jsonify({"success": False, "error": f"当日排片拉取失败:{err_today}"}), 400
750
+
751
+ hall_name_map = opt.build_hall_name_map(next_day_schedule, hall_seat_map)
752
+ locked_sessions = opt.build_locked_sessions(next_day_schedule, target_date)
753
+
754
+ movies = opt.fetch_movie_info_for_date(target_str)
755
+ if not movies:
756
+ return jsonify({"success": False, "error": "getMovieInfo 接口未返回可放映电影,无法生成排片。"}), 400
757
+ movies = opt.dedupe_movies_by_policy_key(movies)
758
+ preview_windows_by_identity = opt.build_preview_windows_for_movies(target_date, movies)
759
+
760
+ blockouts = opt.parse_blockouts_from_config(target_date, runtime_cfg.get("maintenance_blocks", []))
761
+ blockouts_by_hall = opt.build_hall_blockouts(blockouts, hall_name_map)
762
+
763
+ biz_start_t = opt.parse_hm(runtime_cfg["business_start"], "09:30")
764
+ biz_end_t = opt.parse_hm(runtime_cfg["business_end"], "01:30")
765
+ golden_start_t = opt.parse_hm(runtime_cfg["golden_start"], "14:00")
766
+ golden_end_t = opt.parse_hm(runtime_cfg["golden_end"], "21:00")
767
+
768
+ biz_start_dt = opt.parse_operating_dt(target_date, biz_start_t)
769
+ biz_end_dt = opt.parse_operating_dt(target_date, biz_end_t)
770
+ if biz_end_dt <= biz_start_dt:
771
+ biz_end_dt += timedelta(days=1)
772
+
773
+ golden_start_dt = opt.parse_operating_dt(target_date, golden_start_t)
774
+ golden_end_dt = opt.parse_operating_dt(target_date, golden_end_t)
775
+ if golden_end_dt < golden_start_dt:
776
+ golden_end_dt += timedelta(days=1)
777
+
778
+ box_office_data = opt.fetch_realtime_box_office(target_str)
779
+ if not box_office_data:
780
+ box_office_data = opt.fetch_realtime_box_office(today_str)
781
+
782
+ tms_rows = opt.fetch_tms_server_movies_raw()
783
+ tms_by_hall = opt.build_tms_index_by_hall(tms_rows)
784
+
785
+ today_eff = opt.build_today_efficiency(today_schedule, hall_seat_map, golden_start_t, golden_end_t)
786
+ movie_targets = opt.build_movie_targets(
787
+ movies=movies,
788
+ today_eff=today_eff,
789
+ locked_sessions=locked_sessions,
790
+ box_office_data=box_office_data,
791
+ rule12_enabled=bool(runtime_cfg["rule12_enabled"]),
792
+ )
793
+ movie_weights = opt.build_movie_weights(movies, movie_targets, box_office_data)
794
+ tuning_df = opt.build_default_tuning_table(
795
+ movies=movies,
796
+ movie_targets=movie_targets,
797
+ today_eff=today_eff,
798
+ next_day_schedule=next_day_schedule,
799
+ box_office_data=box_office_data,
800
+ efficiency_enabled=bool(runtime_cfg["efficiency_enabled"]),
801
+ rule12_enabled=bool(runtime_cfg["rule12_enabled"]),
802
+ daily_delta_cap=int(runtime_cfg.get("eff_daily_delta_cap", 5)),
803
+ )
804
+
805
+ state = _get_user_state()
806
+ state["runtime_cfg"] = runtime_cfg
807
+ state["bundle"] = {
808
+ "target_date": target_date,
809
+ "target_str": target_str,
810
+ "today_str": today_str,
811
+ "next_day_schedule": next_day_schedule,
812
+ "today_schedule_raw": list(today_schedule),
813
+ "today_schedule_excluded_labels": [],
814
+ "today_schedule_excluded_keys": [],
815
+ "today_schedule_excluded_movies": [],
816
+ "today_schedule_excluded_halls": [],
817
+ "today_schedule_exclude_rules": {"zero_sales": False, "early_morning": False},
818
+ "today_schedule_exclusion_effect": {
819
+ "total_count": len(today_schedule),
820
+ "removed_count": 0,
821
+ "remaining_count": len(today_schedule),
822
+ "affected_movies": 0,
823
+ "reason_breakdown": {},
824
+ },
825
+ "today_schedule": today_schedule,
826
+ "hall_seat_map": hall_seat_map,
827
+ "hall_name_map": hall_name_map,
828
+ "locked_sessions": locked_sessions,
829
+ "movies": movies,
830
+ "preview_windows_by_identity": preview_windows_by_identity,
831
+ "blockouts_by_hall": blockouts_by_hall,
832
+ "biz_start_dt": biz_start_dt,
833
+ "biz_end_dt": biz_end_dt,
834
+ "golden_start_dt": golden_start_dt,
835
+ "golden_end_dt": golden_end_dt,
836
+ "today_eff": today_eff,
837
+ "movie_targets": movie_targets,
838
+ "movie_weights": movie_weights,
839
+ "box_office_data": box_office_data,
840
+ "tms_by_hall": tms_by_hall,
841
+ }
842
+ state["tuning_df"] = opt.coerce_tuning_editor_df(tuning_df)
843
+
844
+ return jsonify(
845
+ {
846
+ "success": True,
847
+ "runtime_cfg": _runtime_cfg_for_json(runtime_cfg),
848
+ "bundle": _bundle_payload(state),
849
+ "message": f"数据加载完成,目标日期 {target_str}。",
850
+ }
851
+ )
852
+
853
+
854
+ @app.post("/api/update-exclusions")
855
+ def api_update_exclusions() -> Any:
856
+ body = request.get_json(silent=True) or {}
857
+ labels = _normalize_string_list(body.get("excluded_labels") or [])
858
+ excluded_session_keys = _normalize_string_list(body.get("excluded_session_keys") or [])
859
+ excluded_movies = _normalize_string_list(body.get("excluded_movies") or [])
860
+ excluded_halls = _normalize_string_list(body.get("excluded_halls") or [])
861
+ exclude_rules = _normalize_exclude_rules(body.get("exclude_rules") or {})
862
+
863
+ state = _get_user_state()
864
+ bundle = state.get("bundle")
865
+ runtime_cfg = state.get("runtime_cfg") or opt.load_config()
866
+ if not isinstance(bundle, dict):
867
+ return jsonify({"success": False, "error": "请先加载数据。"}), 400
868
+
869
+ raw_today_schedule = bundle.get("today_schedule_raw") or bundle.get("today_schedule") or []
870
+ option_rows, movie_rows, hall_rows = _build_exclusion_options(raw_today_schedule)
871
+ valid_key_set = {str(item["key"]) for item in option_rows}
872
+ valid_movie_set = {str(item["value"]) for item in movie_rows}
873
+ valid_hall_set = {str(item["value"]) for item in hall_rows}
874
+
875
+ if labels and not excluded_session_keys:
876
+ legacy_to_keys: Dict[str, List[str]] = {}
877
+ for item in option_rows:
878
+ legacy_to_keys.setdefault(str(item["legacy_label"]), []).append(str(item["key"]))
879
+ mapped_keys: List[str] = []
880
+ for label in labels:
881
+ mapped_keys.extend(legacy_to_keys.get(label, []))
882
+ excluded_session_keys = _normalize_string_list(mapped_keys)
883
+
884
+ excluded_session_keys = [x for x in excluded_session_keys if x in valid_key_set]
885
+ excluded_movies = [x for x in excluded_movies if x in valid_movie_set]
886
+ excluded_halls = [x for x in excluded_halls if x in valid_hall_set]
887
+
888
+ filtered_today_schedule, exclusion_effect, removed_legacy_labels = _apply_exclusion_filters(
889
+ raw_today_schedule,
890
+ excluded_session_keys,
891
+ excluded_movies,
892
+ excluded_halls,
893
+ exclude_rules,
894
+ )
895
+
896
+ golden_start_t = opt.parse_hm(runtime_cfg["golden_start"], "14:00")
897
+ golden_end_t = opt.parse_hm(runtime_cfg["golden_end"], "21:00")
898
+
899
+ today_eff = opt.build_today_efficiency(
900
+ filtered_today_schedule,
901
+ bundle["hall_seat_map"],
902
+ golden_start_t,
903
+ golden_end_t,
904
+ )
905
+ movie_targets = opt.build_movie_targets(
906
+ movies=bundle["movies"],
907
+ today_eff=today_eff,
908
+ locked_sessions=bundle["locked_sessions"],
909
+ box_office_data=bundle["box_office_data"],
910
+ rule12_enabled=bool(runtime_cfg["rule12_enabled"]),
911
+ )
912
+ movie_weights = opt.build_movie_weights(bundle["movies"], movie_targets, bundle["box_office_data"])
913
+ tuning_df = opt.build_default_tuning_table(
914
+ movies=bundle["movies"],
915
+ movie_targets=movie_targets,
916
+ today_eff=today_eff,
917
+ next_day_schedule=bundle["next_day_schedule"],
918
+ box_office_data=bundle["box_office_data"],
919
+ efficiency_enabled=bool(runtime_cfg["efficiency_enabled"]),
920
+ rule12_enabled=bool(runtime_cfg["rule12_enabled"]),
921
+ daily_delta_cap=int(runtime_cfg.get("eff_daily_delta_cap", 5)),
922
+ )
923
+
924
+ bundle["today_schedule_excluded_labels"] = removed_legacy_labels
925
+ bundle["today_schedule_excluded_keys"] = excluded_session_keys
926
+ bundle["today_schedule_excluded_movies"] = excluded_movies
927
+ bundle["today_schedule_excluded_halls"] = excluded_halls
928
+ bundle["today_schedule_exclude_rules"] = exclude_rules
929
+ bundle["today_schedule_exclusion_effect"] = exclusion_effect
930
+ bundle["today_schedule"] = filtered_today_schedule
931
+ bundle["today_eff"] = today_eff
932
+ bundle["movie_targets"] = movie_targets
933
+ bundle["movie_weights"] = movie_weights
934
+ state["bundle"] = bundle
935
+ state["tuning_df"] = opt.coerce_tuning_editor_df(tuning_df)
936
+
937
+ msg = (
938
+ f"剔除已应用:共剔除 {exclusion_effect['removed_count']}/{exclusion_effect['total_count']} 场,"
939
+ f"影响 {exclusion_effect['affected_movies']} 部影片。"
940
+ )
941
+ return jsonify({"success": True, "bundle": _bundle_payload(state), "effect": _safe_value(exclusion_effect), "message": msg})
942
+
943
+
944
+ @app.post("/api/job/start")
945
+ def api_job_start() -> Any:
946
+ body = request.get_json(silent=True) or {}
947
+
948
+ state = _get_user_state()
949
+ bundle = state.get("bundle")
950
+ runtime_cfg = state.get("runtime_cfg") or opt.load_config()
951
+
952
+ if not isinstance(bundle, dict):
953
+ return jsonify({"success": False, "error": "请先加载数据并生成微调约束。"}), 400
954
+
955
+ rows = list(body.get("tuning_rows") or [])
956
+ tuning_df = _normalize_tuning_rows(rows)
957
+ state["tuning_df"] = tuning_df
958
+
959
+ manual_constraints = opt.parse_movie_tuning_constraints(tuning_df)
960
+ allowed_movies = opt.extract_allowed_movies_from_tuning_df(tuning_df)
961
+ allowed_movies |= opt.build_locked_movie_policy_set(bundle.get("locked_sessions", []))
962
+
963
+ payload = opt.build_job_payload(
964
+ bundle=bundle,
965
+ runtime_cfg=runtime_cfg,
966
+ manual_constraints=manual_constraints,
967
+ allowed_movies=allowed_movies,
968
+ )
969
+ ok, msg = opt.start_background_job(payload)
970
+
971
+ code = 200 if ok else 400
972
+ return jsonify({"success": bool(ok), "message": msg, "job_state": _job_state_payload(opt.read_job_state())}), code
973
+
974
+
975
+ @app.post("/api/job/control")
976
+ def api_job_control() -> Any:
977
+ body = request.get_json(silent=True) or {}
978
+ action = str(body.get("action") or "").strip().lower()
979
+
980
+ if action == "pause":
981
+ state = opt.write_job_state(control="pause", message="收到暂停请求")
982
+ elif action == "resume":
983
+ state = opt.write_job_state(control="run", status="running", message="任务继续")
984
+ elif action == "stop":
985
+ state = opt.write_job_state(control="stop", message="收到停止请求")
986
+ else:
987
+ return jsonify({"success": False, "error": "不支持的动作"}), 400
988
+
989
+ return jsonify({"success": True, "job_state": _job_state_payload(state)})
990
+
991
+
992
+ @app.get("/api/job/state")
993
+ def api_job_state() -> Any:
994
+ state = _repair_broken_state()
995
+ return jsonify({"success": True, "job_state": _job_state_payload(state)})
996
+
997
+
998
+ @app.get("/api/results")
999
+ def api_results() -> Any:
1000
+ state = _get_user_state()
1001
+ bundle = state.get("bundle") or {}
1002
+ target_str = str(request.args.get("target_str") or bundle.get("target_str") or "")
1003
+
1004
+ job_state = _repair_broken_state()
1005
+ job_result = opt._read_pickle(opt.JOB_RESULT_FILE, {}) # noqa: SLF001
1006
+
1007
+ if not isinstance(job_result, dict):
1008
+ job_result = {}
1009
+
1010
+ # Failed/stopped summary
1011
+ if (
1012
+ job_result.get("target_str") == target_str
1013
+ and job_state.get("status") in {"failed", "stopped"}
1014
+ and job_result.get("reject_reason_stats")
1015
+ ):
1016
+ return jsonify(
1017
+ {
1018
+ "success": True,
1019
+ "target_str": target_str,
1020
+ "status": str(job_state.get("status")),
1021
+ "has_results": False,
1022
+ "reject_summary": {
1023
+ "elapsed_seconds": _to_float(job_result.get("elapsed_seconds", 0.0), 0.0),
1024
+ "build_reject": _to_int(job_result.get("build_reject", 0), 0),
1025
+ "rule_reject": _to_int(job_result.get("rule_reject", 0), 0),
1026
+ "reject_reason_stats": _safe_value(job_result.get("reject_reason_stats") or {}),
1027
+ "reject_detail_stats": _safe_value(job_result.get("reject_detail_stats") or {}),
1028
+ },
1029
+ }
1030
+ )
1031
+
1032
+ if not (
1033
+ isinstance(job_result, dict)
1034
+ and job_result.get("target_str") == target_str
1035
+ and job_state.get("status") == "completed"
1036
+ ):
1037
+ return jsonify(
1038
+ {
1039
+ "success": True,
1040
+ "target_str": target_str,
1041
+ "status": str(job_state.get("status", "idle")),
1042
+ "has_results": False,
1043
+ }
1044
+ )
1045
+
1046
+ raw_results = list(job_result.get("results") or [])
1047
+ results = [x for x in (opt.deserialize_candidate(r) for r in raw_results) if x is not None]
1048
+
1049
+ today_eff = job_result.get("today_eff", pd.DataFrame())
1050
+ g_st = job_result.get("golden_start_dt")
1051
+ g_et = job_result.get("golden_end_dt")
1052
+ runtime_cfg = job_result.get("runtime_cfg", state.get("runtime_cfg") or opt.load_config())
1053
+ box_office_data = job_result.get("box_office_data", [])
1054
+
1055
+ candidates: List[Dict[str, Any]] = []
1056
+ for idx, cand in enumerate(results):
1057
+ schedule_table = opt.df_schedule_for_display(cand.schedule)
1058
+ summary_df = opt.build_candidate_summary_table(cand.schedule, today_eff, g_st, g_et)
1059
+ log_target_date = datetime.strptime(target_str, "%Y-%m-%d").date()
1060
+ log_text = opt.generate_schedule_check_logs_text(
1061
+ schedule=cand.schedule,
1062
+ target_date=log_target_date,
1063
+ params=runtime_cfg,
1064
+ today_eff=today_eff if isinstance(today_eff, pd.DataFrame) else pd.DataFrame(),
1065
+ box_office_data=box_office_data,
1066
+ )
1067
+ breakdown = [
1068
+ {"规则": str(name), "分值": _to_float(delta, 0.0), "说明": str(msg)}
1069
+ for name, delta, msg in (cand.score_breakdown or [])
1070
+ ]
1071
+
1072
+ candidates.append(
1073
+ {
1074
+ "index": idx,
1075
+ "title": f"方案{idx + 1}|分数 {cand.score:.1f}",
1076
+ "score": _to_float(cand.score, 0.0),
1077
+ "score_breakdown": breakdown,
1078
+ "schedule_table": _df_to_records(schedule_table),
1079
+ "summary_table": _df_to_records(summary_df),
1080
+ "gantt_html": render_gantt_html(cand.schedule, target_str),
1081
+ "log_text": str(log_text or ""),
1082
+ "hard_violations": [str(x) for x in (cand.hard_violations or [])],
1083
+ }
1084
+ )
1085
+
1086
+ return jsonify(
1087
+ {
1088
+ "success": True,
1089
+ "target_str": target_str,
1090
+ "status": "completed",
1091
+ "has_results": True,
1092
+ "summary": {
1093
+ "total_feasible": _to_int(job_result.get("all_results_count", 0), 0),
1094
+ "hard_reject": _to_int(job_result.get("hard_reject", 0), 0),
1095
+ "build_reject": _to_int(job_result.get("build_reject", 0), 0),
1096
+ "rule_reject": _to_int(job_result.get("rule_reject", 0), 0),
1097
+ "elapsed_seconds": _to_float(job_result.get("elapsed_seconds", 0.0), 0.0),
1098
+ "locked_count": _to_int(job_result.get("locked_count", 0), 0),
1099
+ "reject_reason_stats": _safe_value(job_result.get("reject_reason_stats") or {}),
1100
+ "reject_detail_stats": _safe_value(job_result.get("reject_detail_stats") or {}),
1101
+ "reject_phase_stats": _safe_value(job_result.get("reject_phase_stats") or {}),
1102
+ "reject_examples": _safe_value(job_result.get("reject_examples") or {}),
1103
+ "movie_targets": _safe_value(job_result.get("movie_targets") or {}),
1104
+ "today_eff_rows": _df_to_records(today_eff if isinstance(today_eff, pd.DataFrame) else pd.DataFrame()),
1105
+ },
1106
+ "candidates": candidates,
1107
+ }
1108
+ )
1109
+
1110
+
1111
+ if __name__ == "__main__":
1112
+ host = os.getenv("NEXTDAY_OPT_HOST", "0.0.0.0")
1113
+ port = _to_int(os.getenv("NEXTDAY_OPT_PORT", "8502"), 8502)
1114
+ app.run(host=host, port=port, debug=False, threaded=True)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pandas
2
+ matplotlib
3
+ xlrd
4
+ pypinyin
5
+ dotenv
6
+ openpyxl
7
+ numpy
8
+ streamlit-autorefresh
9
+ html2image
10
+ Pillow
11
+ flask