diff --git a/app.py b/app.py index a091190..8558427 100644 --- a/app.py +++ b/app.py @@ -105,6 +105,15 @@ from repositories.match_event_repository import ( clear_events, update_event, ) +from repositories.match_penalty_repository import ( + ensure_match_penalty_tables, + get_penalty_state, + set_penalty_shot, + delete_penalty_shot, + add_penalty_round, + delete_last_penalty_round, + clear_penalties, +) from repositories.auth_repository import get_user_by_username from services.auth_service import ( create_auth_session, @@ -193,6 +202,7 @@ def start_scheduler(): ensure_project_settings_tables() ensure_player_photo_enabled_column() ensure_match_source_key_column() + ensure_match_penalty_tables() except Exception: traceback.print_exc() @@ -212,6 +222,12 @@ class PublishVmixCommandPayload(BaseModel): meta: dict | None = None +class PenaltyShotPayload(BaseModel): + side: str + shot_number: int = Field(..., ge=1) + result: str + + class RegisterVmixClientPayload(BaseModel): client_id: str match_id: int | None = None @@ -739,6 +755,13 @@ def session_workspace( return RedirectResponse(url="/admin/matches", status_code=303) match_id = session_row[1] + source_key = resolve_match_source_key(match_id, session_row[21] if len(session_row) > 21 else None) + is_russian_cup = str(source_key or "").upper() == "RUSSIAN_CUP" + if is_russian_cup and tab == "standings": + tab = "penalties" + if (not is_russian_cup) and tab == "penalties": + tab = "game" + home_team_id = session_row[13] away_team_id = session_row[17] referees = get_match_referees(match_id) @@ -776,6 +799,8 @@ def session_workspace( "home_formations": home_formations, "away_formations": away_formations, "current_user": getattr(request.state, "current_user", None), + "source_key": source_key, + "is_russian_cup": is_russian_cup, "auth_idle_timeout_seconds": 7200, }, ) @@ -1343,6 +1368,85 @@ def api_delete_event(session_token: str, event_id: int): return {"ok": True} + + +@app.get("/admin/session/{session_token}/penalties") +def api_get_penalties(session_token: str): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + return get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + + +@app.post("/admin/session/{session_token}/penalties/shot") +def api_set_penalty_shot(session_token: str, payload: PenaltyShotPayload): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + result = str(payload.result or "").strip().lower() + try: + if result in {"clear", "empty", "delete", ""}: + delete_penalty_shot(session_row[1], payload.side, payload.shot_number) + else: + set_penalty_shot(session_row[1], payload.side, payload.shot_number, result) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + + return get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + + +@app.post("/admin/session/{session_token}/penalties/round") +def api_add_penalty_round(session_token: str): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + add_penalty_round(session_row[1]) + return get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + + +@app.delete("/admin/session/{session_token}/penalties/round") +def api_delete_last_penalty_round(session_token: str): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + delete_last_penalty_round(session_row[1]) + return get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + + +@app.delete("/admin/session/{session_token}/penalties") +def api_clear_penalties(session_token: str): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + clear_penalties(session_row[1]) + return get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + + @app.get("/admin/session/{session_token}/squad-editor-data") def api_get_squad_editor_data(session_token: str): session_row = get_match_session_by_token(session_token) @@ -1620,6 +1724,14 @@ class MatchChannelPayload(BaseModel): channel: str = "" +class MatchSchedulePayload(BaseModel): + match_external_id: str + home_score: int | None = None + away_score: int | None = None + live: bool = False + status: str | None = None + + @app.post("/admin/session/{session_token}/schedule/channel") def update_schedule_channel(session_token: str, payload: MatchChannelPayload): session_row = get_match_session_by_token(session_token) @@ -1648,6 +1760,62 @@ def update_schedule_channel(session_token: str, payload: MatchChannelPayload): return JSONResponse({"error": str(e)}, status_code=500) finally: conn.close() + + +@app.post("/admin/session/{session_token}/schedule/match") +def update_schedule_match(session_token: str, payload: MatchSchedulePayload): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + home_score = payload.home_score + away_score = payload.away_score + + if (home_score is None) != (away_score is None): + return JSONResponse( + {"error": "score_pair_required", "message": "Нужно заполнить оба значения счёта или очистить оба."}, + status_code=400, + ) + + if home_score is not None and (home_score < 0 or away_score < 0): + return JSONResponse({"error": "invalid_score"}, status_code=400) + + allowed_statuses = {"scheduled", "live", "finished"} + if payload.status is not None: + status = str(payload.status or "scheduled").strip().lower() + if status not in allowed_statuses: + return JSONResponse({"error": "invalid_status"}, status_code=400) + elif payload.live: + status = "live" + elif home_score is not None and away_score is not None: + status = "finished" + else: + status = "scheduled" + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE matches + SET home_score = %s, + away_score = %s, + status = %s, + updated_at = NOW() + WHERE external_id = %s + """, + (home_score, away_score, status, payload.match_external_id), + ) + if cur.rowcount == 0: + conn.rollback() + return JSONResponse({"error": "match_not_found"}, status_code=404) + conn.commit() + return {"success": True, "status": status, "home_score": home_score, "away_score": away_score} + except Exception as e: + conn.rollback() + return JSONResponse({"error": str(e)}, status_code=500) + finally: + conn.close() @app.get("/vmix/session/{session_token}/schedule") @@ -1792,10 +1960,19 @@ def vmix_scoreboard(session_token: str): row = get_vmix_scoreboard_info(session_row) if not row: return JSONResponse({"error": "data_not_found"}, status_code=404) + + penalty_state = get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + return [ { "home_score": row[0], "away_score": row[1], + "home_penalty_score": penalty_state["totals"]["home"], + "away_penalty_score": penalty_state["totals"]["away"], "red_home_1": row[2], "red_home_2": row[3], "red_home_3": row[4], @@ -1834,6 +2011,88 @@ def vmix_match_events(session_token: str): return new_events + + +@app.get("/vmix/session/{session_token}/penalties") +def vmix_penalties(session_token: str): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + state = get_penalty_state( + match_id=session_row[1], + home_team_name=session_row[14], + away_team_name=session_row[18], + ) + + def mark(value: str) -> str: + if value == "scored": + return "●" + if value == "missed": + return "×" + return " " + + def color(value: str) -> str: + if value == "scored": + return "#00FF00" + if value == "missed": + return "#FF0000" + return "#FFFFFF00" + + rounds = state.get("rounds") or [] + rows = [] + + # vMix-титр рассчитан на 5 строк. В базе удары хранятся как 1-5, 6-10, + # 11-15 и дальше, но в data source всегда отдаем только текущий блок из 5. + # Например: удар 6 попадает в строку 1, удар 7 — в строку 2 и т.д. + shot_numbers = [] + for shot in state.get("shots") or []: + try: + shot_numbers.append(int(shot.get("shot_number") or 0)) + except Exception: + continue + + max_shot_number = max(shot_numbers) if shot_numbers else 1 + configured_max_rounds = max(5, int(state.get("max_rounds") or 5)) + + # Важный момент для vMix: + # если оператор добавил новую серию/новый удар после 5, то max_rounds становится 6, + # даже если сам 6-й удар еще не заполнен. Поэтому активный блок считаем не только + # по последнему заполненному удару, но и по max_rounds. Это сразу очищает 5 строк + # data source в vMix перед первым ударом новой серии: 6-10, 11-15 и т.д. + active_shot_number = max(max_shot_number, configured_max_rounds, 1) + visible_start = ((active_shot_number - 1) // 5) * 5 + 1 + visible_end = visible_start + 4 + visible_group = ((visible_start - 1) // 5) + 1 + + by_number = {int(r.get("number") or 0): r for r in rounds} + display_row = 1 + for shot_number in range(visible_start, visible_end + 1): + round_row = by_number.get(shot_number, {"number": shot_number, "home": "", "away": ""}) + home_value = round_row.get("home") or "" + away_value = round_row.get("away") or "" + rows.append({ + # Строка для vMix: всегда 1-5. + "round": display_row, + # Реальный номер удара в базе: 1-5, 6-10, 11-15... + "shot_number": shot_number, + "penalty_group": visible_group, + "home_penalty": mark(home_value), + "away_penalty": mark(away_value), + "home_penalty_color": color(home_value), + "away_penalty_color": color(away_value), + "home_penalty_result": home_value, + "away_penalty_result": away_value, + "home_penalty_score": state["totals"]["home"], + "away_penalty_score": state["totals"]["away"], + "home_team": state.get("home_team") or "", + "away_team": state.get("away_team") or "", + }) + display_row += 1 + + return rows + + @app.websocket("/ws/vmix-client") async def ws_vmix_client( websocket: WebSocket, @@ -2300,14 +2559,12 @@ def render_admin_db_index( "title": "Суперлига 2026", "logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos", "photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo", - "channel_logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Лого каналов", }, { "key": "RUSSIAN_CUP", "title": "Кубок России 2026", "logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos", "photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo", - "channel_logo_base_path": r"D:\Графика\ФУТБОЛ\ЖФЛ Кубок России 2026\Лого каналов", }, ] default_parser_source_key = "SUPERLEAGUE" diff --git a/repositories/coach_repository.py b/repositories/coach_repository.py index df7d04f..402be54 100644 --- a/repositories/coach_repository.py +++ b/repositories/coach_repository.py @@ -122,6 +122,82 @@ def get_coach_id_by_external_id(external_id: str) -> int | None: + +def _split_coach_name_for_autocreate(full_name: str) -> tuple[str, str]: + parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()] + if not parts: + return "", "" + if len(parts) == 1: + return "", parts[0] + first_name = parts[0] + last_name = " ".join(parts[1:]) + return first_name, last_name + + +def create_coach_from_lineup( + team_id: int, + external_id: str = "", + full_name: str = "", + role: str = "", +) -> int | None: + """Создаёт минимальную карточку тренера из протокола матча.""" + full_name = str(full_name or "").strip() + if not full_name: + return None + + first_name, last_name = _split_coach_name_for_autocreate(full_name) + external_id = str(external_id or "").strip() + role = str(role or "").strip() + + query = """ + INSERT INTO coaches ( + external_id, + team_id, + player, + lastname, + name, + amplua, + is_active, + created_at, + updated_at + ) + VALUES (NULLIF(%s, ''), %s, %s, %s, %s, %s, TRUE, NOW(), NOW()) + ON CONFLICT (external_id) + DO UPDATE SET + team_id = EXCLUDED.team_id, + player = COALESCE(NULLIF(EXCLUDED.player, ''), coaches.player), + lastname = COALESCE(NULLIF(EXCLUDED.lastname, ''), coaches.lastname), + name = COALESCE(NULLIF(EXCLUDED.name, ''), coaches.name), + amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), coaches.amplua), + is_active = TRUE, + updated_at = NOW() + RETURNING id; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + external_id, + team_id, + full_name, + last_name, + first_name, + role, + ), + ) + row = cur.fetchone() + conn.commit() + return row[0] if row else None + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def search_coaches_for_admin(q: str = "") -> list[dict]: conn = get_connection() try: diff --git a/repositories/match_penalty_repository.py b/repositories/match_penalty_repository.py new file mode 100644 index 0000000..0a0aa10 --- /dev/null +++ b/repositories/match_penalty_repository.py @@ -0,0 +1,295 @@ +from db import get_connection + + +VALID_SIDES = {"home", "away"} +VALID_RESULTS = {"scored", "missed"} + + +def ensure_match_penalty_tables() -> None: + """Создает таблицы для серии пенальти.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS match_penalty_settings ( + match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE, + max_rounds INTEGER NOT NULL DEFAULT 5, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS match_penalties ( + id SERIAL PRIMARY KEY, + match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')), + shot_number INTEGER NOT NULL CHECK (shot_number > 0), + result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE (match_id, side, shot_number) + ); + """ + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _normalize_side(side: str) -> str: + side = str(side or "").strip().lower() + if side not in VALID_SIDES: + raise ValueError("Некорректная сторона пенальти") + return side + + +def _normalize_result(result: str) -> str: + result = str(result or "").strip().lower() + if result not in VALID_RESULTS: + raise ValueError("Некорректный результат пенальти") + return result + + +def _ensure_penalty_rounds(cur, match_id: int, max_rounds: int) -> None: + safe_rounds = max(5, int(max_rounds or 5)) + cur.execute( + """ + INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at) + VALUES (%s, %s, NOW(), NOW()) + ON CONFLICT (match_id) + DO UPDATE SET + max_rounds = GREATEST(match_penalty_settings.max_rounds, EXCLUDED.max_rounds), + updated_at = NOW(); + """, + (match_id, safe_rounds), + ) + + +def get_penalty_state(match_id: int, home_team_name: str = "", away_team_name: str = "") -> dict: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT COALESCE(max_rounds, 5) + FROM match_penalty_settings + WHERE match_id = %s; + """, + (match_id,), + ) + settings_row = cur.fetchone() + + cur.execute( + """ + SELECT side, shot_number, result + FROM match_penalties + WHERE match_id = %s + ORDER BY shot_number, side; + """, + (match_id,), + ) + rows = cur.fetchall() + finally: + conn.close() + + max_rounds = int(settings_row[0]) if settings_row else 5 + for _, shot_number, _ in rows: + max_rounds = max(max_rounds, int(shot_number or 0), 5) + + shots_map = { + (str(side), int(shot_number)): str(result) + for side, shot_number, result in rows + } + + rounds = [] + totals = {"home": 0, "away": 0} + completed = {"home": 0, "away": 0} + + for number in range(1, max_rounds + 1): + row = {"number": number} + for side in ("home", "away"): + result = shots_map.get((side, number), "") + if result: + completed[side] += 1 + if result == "scored": + totals[side] += 1 + row[side] = result + rounds.append(row) + + shots = [ + {"side": str(side), "shot_number": int(shot_number), "result": str(result)} + for side, shot_number, result in rows + ] + + return { + "match_id": match_id, + "home_team": home_team_name or "Хозяева", + "away_team": away_team_name or "Гости", + "max_rounds": max_rounds, + "totals": totals, + "completed": completed, + "rounds": rounds, + "shots": shots, + } + + +def set_penalty_shot(match_id: int, side: str, shot_number: int, result: str) -> None: + side = _normalize_side(side) + result = _normalize_result(result) + safe_shot_number = max(1, int(shot_number or 1)) + + conn = get_connection() + try: + with conn.cursor() as cur: + _ensure_penalty_rounds(cur, match_id, safe_shot_number) + cur.execute( + """ + INSERT INTO match_penalties (match_id, side, shot_number, result, created_at, updated_at) + VALUES (%s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (match_id, side, shot_number) + DO UPDATE SET + result = EXCLUDED.result, + updated_at = NOW(); + """, + (match_id, side, safe_shot_number, result), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def delete_penalty_shot(match_id: int, side: str, shot_number: int) -> None: + side = _normalize_side(side) + safe_shot_number = max(1, int(shot_number or 1)) + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM match_penalties + WHERE match_id = %s AND side = %s AND shot_number = %s; + """, + (match_id, side, safe_shot_number), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def add_penalty_round(match_id: int) -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at) + VALUES (%s, 6, NOW(), NOW()) + ON CONFLICT (match_id) + DO UPDATE SET + max_rounds = GREATEST(match_penalty_settings.max_rounds + 1, 6), + updated_at = NOW() + RETURNING max_rounds; + """, + (match_id,), + ) + max_rounds = int(cur.fetchone()[0]) + conn.commit() + return max_rounds + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def delete_last_penalty_round(match_id: int) -> int: + """Удаляет последнюю добавленную строку серии пенальти и ее данные. + + Первые 5 строк считаются базовыми и не удаляются. Если добавлена 6-я, + 7-я и т.д. строка, удаляется самая последняя строка вместе с ударами + обеих команд в этой строке. + """ + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT COALESCE(max_rounds, 5) + FROM match_penalty_settings + WHERE match_id = %s; + """, + (match_id,), + ) + settings_row = cur.fetchone() + settings_max = int(settings_row[0]) if settings_row else 5 + + cur.execute( + """ + SELECT COALESCE(MAX(shot_number), 0) + FROM match_penalties + WHERE match_id = %s; + """, + (match_id,), + ) + shots_max = int(cur.fetchone()[0] or 0) + + current_max = max(5, settings_max, shots_max) + if current_max <= 5: + _ensure_penalty_rounds(cur, match_id, 5) + conn.commit() + return 5 + + new_max = max(5, current_max - 1) + + cur.execute( + """ + DELETE FROM match_penalties + WHERE match_id = %s AND shot_number = %s; + """, + (match_id, current_max), + ) + cur.execute( + """ + INSERT INTO match_penalty_settings (match_id, max_rounds, created_at, updated_at) + VALUES (%s, %s, NOW(), NOW()) + ON CONFLICT (match_id) + DO UPDATE SET + max_rounds = EXCLUDED.max_rounds, + updated_at = NOW(); + """, + (match_id, new_max), + ) + conn.commit() + return new_max + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def clear_penalties(match_id: int) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM match_penalties WHERE match_id = %s;", (match_id,)) + cur.execute("DELETE FROM match_penalty_settings WHERE match_id = %s;", (match_id,)) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/repositories/match_repository.py b/repositories/match_repository.py index 08dcefa..b68bf5b 100644 --- a/repositories/match_repository.py +++ b/repositories/match_repository.py @@ -355,7 +355,7 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]: with conn.cursor() as cur: cur.execute( """ - SELECT m.tour, m.season, m.source_key + SELECT m.tour FROM matches m WHERE m.id = %s """, @@ -366,8 +366,6 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]: return [] tour = row[0] - season = row[1] - source_key = resolve_match_source_key(match_id, row[2] if len(row) > 2 else None) cur.execute( """ @@ -386,13 +384,11 @@ def get_tour_schedule_by_match_id(match_id: int) -> list[dict]: LEFT JOIN teams ht ON ht.id = m.home_team_id LEFT JOIN teams at ON at.id = m.away_team_id WHERE m.tour = %s - AND (%s IS NULL OR m.season = %s) - AND COALESCE(NULLIF(m.source_key, ''), %s) = %s ORDER BY m.match_date NULLS LAST, m.id """, - (tour, season, season, source_key, source_key), + (tour,), ) rows = cur.fetchall() diff --git a/repositories/player_repository.py b/repositories/player_repository.py index 4df58ca..47329f3 100644 --- a/repositories/player_repository.py +++ b/repositories/player_repository.py @@ -222,6 +222,104 @@ def get_player_id_by_external_id(external_id: str) -> int | None: conn.close() + +def _split_full_name_for_autocreate(full_name: str) -> tuple[str, str]: + """Аккуратно делит имя с сайта на first_name / last_name для новых записей.""" + parts = [p.strip() for p in str(full_name or "").replace("\xa0", " ").split() if p.strip()] + if not parts: + return "", "" + if len(parts) == 1: + return "", parts[0] + + # На сайте чаще приходит "Имя Фамилия". Полное имя всё равно сохраняем отдельно, + # поэтому даже при другом порядке данные можно быстро поправить в справочнике. + first_name = parts[0] + last_name = " ".join(parts[1:]) + return first_name, last_name + + +def create_player_from_lineup( + team_id: int, + external_id: str = "", + full_name: str = "", + number: str = "", + position: str = "", +) -> tuple[int | None, str | None]: + """Создаёт минимальную карточку игрока из протокола матча и возвращает (id, position). + + Используется при загрузке составов с сайта, когда игрока ещё нет в справочнике. + """ + full_name = str(full_name or "").strip() + if not full_name: + return None, None + + first_name, last_name = _split_full_name_for_autocreate(full_name) + external_id = str(external_id or "").strip() + number = str(number or "").strip() + position = str(position or "").strip() + + query = """ + INSERT INTO players ( + external_id, + team_id, + full_name, + first_name, + last_name, + number, + position, + pos, + amplua, + is_active, + photo_enabled, + created_at, + updated_at + ) + VALUES ( + NULLIF(%s, ''), %s, %s, %s, %s, %s, %s, %s, %s, TRUE, FALSE, + NOW(), NOW() + ) + ON CONFLICT (external_id) + DO UPDATE SET + team_id = EXCLUDED.team_id, + full_name = COALESCE(NULLIF(EXCLUDED.full_name, ''), players.full_name), + first_name = COALESCE(NULLIF(EXCLUDED.first_name, ''), players.first_name), + last_name = COALESCE(NULLIF(EXCLUDED.last_name, ''), players.last_name), + number = COALESCE(NULLIF(EXCLUDED.number, ''), players.number), + position = COALESCE(NULLIF(EXCLUDED.position, ''), players.position), + pos = COALESCE(NULLIF(EXCLUDED.pos, ''), players.pos), + amplua = COALESCE(NULLIF(EXCLUDED.amplua, ''), players.amplua), + is_active = TRUE, + updated_at = NOW() + RETURNING id, position; + """ + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + query, + ( + external_id, + team_id, + full_name, + first_name, + last_name, + number, + position, + position, + position, + ), + ) + row = cur.fetchone() + conn.commit() + return row if row else (None, None) + except Exception: + conn.rollback() + raise + finally: + conn.close() + + PLAYER_ADMIN_SORT_COLUMNS = { "id": "p.id", "full_name": "p.full_name", diff --git a/services/game_service.py b/services/game_service.py index 11a2c97..b11f2ca 100644 --- a/services/game_service.py +++ b/services/game_service.py @@ -7,10 +7,12 @@ from repositories.match_repository import ( from repositories.player_repository import ( get_player_id_by_name_and_team, get_player_id_by_external_id, + create_player_from_lineup, ) from repositories.coach_repository import ( get_coach_id_by_name_and_team, get_coach_id_by_external_id, + create_coach_from_lineup, ) from repositories.referee_repository import get_referee_id_by_name, upsert_referee from repositories.match_lineup_repository import replace_match_lineups, save_match_lineup_for_editor @@ -35,158 +37,117 @@ def sync_match_page( match_id, _, home_team_id, away_team_id = match_row clear_match_squad_data(match_id) + created_players_count = 0 + created_coaches_count = 0 + + def resolve_player(player: dict, team_id: int) -> tuple[int | None, str | None]: + """Находит игрока из протокола или создаёт его в справочнике автоматически.""" + nonlocal created_players_count + + player_name = str(player.get("player_name") or "").strip() + player_external_id = str(player.get("player_external_id") or "").strip() + number = str(player.get("number") or "").strip() + position = str(player.get("position") or "").strip() + + player_id = None + player_position = None + + if player_external_id: + player_row = get_player_id_by_external_id(player_external_id) + if player_row: + player_id, player_position = player_row + + if player_id is None and player_name: + player_id = get_player_id_by_name_and_team(player_name, team_id) + + if player_id is None and player_name: + player_row = create_player_from_lineup( + team_id=team_id, + external_id=player_external_id, + full_name=player_name, + number=number, + position=position, + ) + if player_row: + player_id, player_position = player_row + created_players_count += 1 + + return player_id, player_position or position + + def resolve_coach(coach: dict, team_id: int) -> int | None: + """Находит тренера из протокола или создаёт его в справочнике автоматически.""" + nonlocal created_coaches_count + + coach_name = str(coach.get("coach_name") or "").strip() + coach_external_id = str(coach.get("coach_external_id") or "").strip() + role = str(coach.get("role") or "").strip() + + coach_id = None + + if coach_external_id: + coach_id = get_coach_id_by_external_id(coach_external_id) + + if coach_id is None and coach_name: + coach_id = get_coach_id_by_name_and_team(coach_name, team_id) + + if coach_id is None and coach_name: + coach_id = create_coach_from_lineup( + team_id=team_id, + external_id=coach_external_id, + full_name=coach_name, + role=role, + ) + if coach_id: + created_coaches_count += 1 + + return coach_id + lineup_rows = [] - for player in home_starting: - player_id = None - player_position = None - if player.get("player_external_id"): - player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) + def append_players(players: list[dict], team_id: int, lineup_type: str) -> None: + for player in players: + player_id, player_position = resolve_player(player, team_id) - if player_id is None: - player_id = get_player_id_by_name_and_team( - player["player_name"], home_team_id - ) - print(player_id, player_position) - - - lineup_rows.append( - { - "match_id": match_id, - "team_id": home_team_id, - "player_id": player_id, - "player_name": player["player_name"], - "number": player.get("number"), - "position": player.get("position"), - "position_full": player_position, - "is_captain": bool(player.get("is_captain")), - "lineup_type": "starting", - "source": "parser", - } - ) - - for player in away_starting: - player_id = None - player_position = None - - if player.get("player_external_id"): - player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) - - if player_id is None: - player_id = get_player_id_by_name_and_team( - player["player_name"], away_team_id + lineup_rows.append( + { + "match_id": match_id, + "team_id": team_id, + "player_id": player_id, + "player_name": player.get("player_name") or "", + "number": player.get("number"), + "position": player.get("position"), + "position_full": player_position, + "is_captain": bool(player.get("is_captain")), + "lineup_type": lineup_type, + "source": "parser", + } ) - lineup_rows.append( - { - "match_id": match_id, - "team_id": away_team_id, - "player_id": player_id, - "player_name": player["player_name"], - "number": player.get("number"), - "position": player.get("position"), - "position_full": player_position, - "is_captain": bool(player.get("is_captain")), - "lineup_type": "starting", - "source": "parser", - } - ) - - for player in home_bench: - player_id = None - player_position = None - - if player.get("player_external_id"): - player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) - - if player_id is None: - player_id = get_player_id_by_name_and_team( - player["player_name"], home_team_id - ) - - lineup_rows.append( - { - "match_id": match_id, - "team_id": home_team_id, - "player_id": player_id, - "player_name": player["player_name"], - "number": player.get("number"), - "position": player.get("position"), - "position_full": player_position, - "is_captain": bool(player.get("is_captain")), - "lineup_type": "bench", - "source": "parser", - } - ) - - for player in away_bench: - player_id = None - player_position = None - - if player.get("player_external_id"): - player_id, player_position = get_player_id_by_external_id(player["player_external_id"]) - - if player_id is None: - player_id = get_player_id_by_name_and_team( - player["player_name"], away_team_id - ) - - lineup_rows.append( - { - "match_id": match_id, - "team_id": away_team_id, - "player_id": player_id, - "player_name": player["player_name"], - "number": player.get("number"), - "position": player.get("position"), - "position_full": player_position, - "is_captain": bool(player.get("is_captain")), - "lineup_type": "bench", - "source": "parser", - } - ) + append_players(home_starting, home_team_id, "starting") + append_players(away_starting, away_team_id, "starting") + append_players(home_bench, home_team_id, "bench") + append_players(away_bench, away_team_id, "bench") coach_rows = [] - for coach in home_coaches: - coach_id = None + def append_coaches(coaches: list[dict], team_id: int, side: str) -> None: + for coach in coaches: + coach_id = resolve_coach(coach, team_id) - if coach.get("coach_external_id"): - coach_id = get_coach_id_by_external_id(coach["coach_external_id"]) + coach_rows.append( + { + "match_id": match_id, + "team_id": team_id, + "side": side, + "coach_id": coach_id, + "coach_name": coach.get("coach_name") or "", + "role": coach.get("role"), + "source": "parser", + } + ) - if coach_id is None: - coach_id = get_coach_id_by_name_and_team(coach["coach_name"], home_team_id) - - coach_rows.append( - { - "match_id": match_id, - "team_id": home_team_id, - "coach_id": coach_id, - "coach_name": coach["coach_name"], - "role": coach.get("role"), - "source": "parser", - } - ) - - for coach in away_coaches: - coach_id = None - - if coach.get("coach_external_id"): - coach_id = get_coach_id_by_external_id(coach["coach_external_id"]) - - if coach_id is None: - coach_id = get_coach_id_by_name_and_team(coach["coach_name"], away_team_id) - - coach_rows.append( - { - "match_id": match_id, - "team_id": away_team_id, - "coach_id": coach_id, - "coach_name": coach["coach_name"], - "role": coach.get("role"), - "source": "parser", - } - ) + append_coaches(home_coaches, home_team_id, "home") + append_coaches(away_coaches, away_team_id, "away") referee_rows = [] @@ -209,13 +170,15 @@ def sync_match_page( try: replace_match_lineups(match_id, lineup_rows) - for row in coach_rows: - row["side"] = "home" if row["team_id"] == home_team_id else "away" replace_match_coaches(match_id, coach_rows) - - replace_match_referees(match_id, referee_rows) mark_match_parsed(match_external_id) + + if created_players_count or created_coaches_count: + print( + f"[parser_game] auto-created for match={match_external_id}: " + f"players={created_players_count}, coaches={created_coaches_count}" + ) except Exception as e: mark_match_parse_error(match_external_id, str(e)) raise diff --git a/sql/007_match_penalties.sql b/sql/007_match_penalties.sql new file mode 100644 index 0000000..132e38c --- /dev/null +++ b/sql/007_match_penalties.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS match_penalty_settings ( + match_id INTEGER PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE, + max_rounds INTEGER NOT NULL DEFAULT 5, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS match_penalties ( + id SERIAL PRIMARY KEY, + match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + side VARCHAR(10) NOT NULL CHECK (side IN ('home', 'away')), + shot_number INTEGER NOT NULL CHECK (shot_number > 0), + result VARCHAR(20) NOT NULL CHECK (result IN ('scored', 'missed')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE (match_id, side, shot_number) +); diff --git a/static/script.js b/static/script.js index 03891dd..6a95578 100644 --- a/static/script.js +++ b/static/script.js @@ -1069,6 +1069,8 @@ function setEditMode(enabled) { el.disabled = !editModeEnabled; } }); + + setTourScheduleGlobalEditMode(editModeEnabled); renderRefereesEditor(); if (editModeEnabled && isGameTabActive()) { @@ -1099,6 +1101,8 @@ function updateEditModeUI() { el.disabled = !editModeEnabled; } }); + + setTourScheduleGlobalEditMode(editModeEnabled); } function stepExtraTime(step) { @@ -4064,6 +4068,113 @@ function buildChannelValue(matchId) { return ""; } +function resetTourScheduleRowEditValues(matchId) { + document.querySelectorAll(`.tour-score-input[data-match-id="${matchId}"]`).forEach((input) => { + input.value = input.dataset.initialValue || ""; + }); + + const statusSelect = document.querySelector(`.tour-status-select[data-match-id="${matchId}"]`); + if (statusSelect) { + statusSelect.value = statusSelect.dataset.initialStatus || "scheduled"; + } +} + +function setTourScheduleRowEditMode(matchId, enabled, resetValues = false) { + const display = document.querySelector(`[data-schedule-display="${matchId}"]`); + const edit = document.querySelector(`[data-schedule-edit="${matchId}"]`); + const saveActions = document.querySelector(`[data-schedule-save-actions="${matchId}"]`); + + if (resetValues) { + resetTourScheduleRowEditValues(matchId); + } + + if (display) display.hidden = enabled; + if (edit) edit.hidden = !enabled; + if (saveActions) saveActions.hidden = !enabled; + + document + .querySelectorAll(`.tour-score-input[data-match-id="${matchId}"], .tour-status-select[data-match-id="${matchId}"]`) + .forEach((el) => { + el.disabled = !enabled; + }); +} + +function setTourScheduleGlobalEditMode(enabled) { + document.querySelectorAll("[data-schedule-row]").forEach((row) => { + const matchId = row.dataset.scheduleRow; + if (!matchId) return; + setTourScheduleRowEditMode(matchId, enabled, !enabled); + }); +} + +function enableTourScheduleEdit(matchId) { + setTourScheduleRowEditMode(matchId, true); +} + +function cancelTourScheduleEdit(matchId) { + resetTourScheduleRowEditValues(matchId); + setTourScheduleRowEditMode(matchId, editModeEnabled); +} + +function readTourScoreValue(matchId, side) { + const input = document.querySelector(`.tour-score-input[data-match-id="${matchId}"][data-score-side="${side}"]`); + if (!input) return null; + + const value = String(input.value || "").trim(); + if (value === "") return null; + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error("invalid_score"); + } + return parsed; +} + +async function saveTourScheduleMatch(matchExternalId) { + let homeScore; + let awayScore; + + try { + homeScore = readTourScoreValue(matchExternalId, "home"); + awayScore = readTourScoreValue(matchExternalId, "away"); + } catch (err) { + alert("Счёт должен быть целым числом 0 или больше"); + return; + } + + if ((homeScore === null) !== (awayScore === null)) { + alert("Заполни оба значения счёта или очисти оба поля"); + return; + } + + const status = document.querySelector(`.tour-status-select[data-match-id="${matchExternalId}"]`)?.value || "scheduled"; + + const res = await fetch(`/admin/session/${MATCH_DATA.sessionToken}/schedule/match`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + match_external_id: String(matchExternalId), + home_score: homeScore, + away_score: awayScore, + status + }) + }); + + if (!res.ok) { + let message = "Не удалось сохранить счёт / статус"; + try { + const data = await res.json(); + message = data.message || data.error || message; + } catch (_) {} + alert(message); + return; + } + + window.location.reload(); +} + function openClearMatchModal() { const modal = document.getElementById("clearMatchModal"); if (modal) modal.classList.add("show"); @@ -4078,3 +4189,206 @@ function confirmClearMatch() { closeClearMatchModal(); clearMatchEvents(); } + +// -------------------- Пенальти Кубка России -------------------- +let penaltyState = null; +let penaltyRequestInFlight = false; + +function penaltyResultMark(result) { + if (result === "scored") return "●"; + if (result === "missed") return "×"; + return ""; +} + +function penaltyResultTitle(result) { + if (result === "scored") return "Забил"; + if (result === "missed") return "Не забил"; + return "Не выбран"; +} + +function normalizePenaltyRounds(state) { + const maxRounds = Math.max(5, Number(state?.max_rounds || 5)); + const inputRounds = Array.isArray(state?.rounds) ? state.rounds : []; + const byNumber = new Map(inputRounds.map(row => [Number(row.number), row])); + const rounds = []; + + for (let number = 1; number <= maxRounds; number += 1) { + const row = byNumber.get(number) || { number, home: "", away: "" }; + rounds.push({ + number, + home: row.home || "", + away: row.away || "" + }); + } + + return rounds; +} + +function renderPenaltyShootout(state) { + penaltyState = state || penaltyState || { max_rounds: 5, rounds: [], totals: { home: 0, away: 0 } }; + penaltyState.rounds = normalizePenaltyRounds(penaltyState); + + const homeScore = document.getElementById("penaltyHomeScore"); + const awayScore = document.getElementById("penaltyAwayScore"); + const homeNext = document.getElementById("penaltyHomeNext"); + const awayNext = document.getElementById("penaltyAwayNext"); + const body = document.getElementById("penaltyRoundsBody"); + + if (homeScore) homeScore.textContent = penaltyState?.totals?.home ?? 0; + if (awayScore) awayScore.textContent = penaltyState?.totals?.away ?? 0; + if (homeNext) homeNext.textContent = `Следующий удар: ${getNextPenaltyShotNumber("home")}`; + if (awayNext) awayNext.textContent = `Следующий удар: ${getNextPenaltyShotNumber("away")}`; + + if (!body) return; + + body.innerHTML = penaltyState.rounds.map(row => { + return ` +
| Серия | +{{ session[14] }} | +{{ session[18] }} | +
|---|---|---|
| Загрузка серии пенальти… | +||