From 5a0b1e1ce8183441aaa5f11e03a133b2b1727b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Fri, 2 Oct 2026 15:54:02 +0300 Subject: [PATCH] =?UTF-8?q?184=20build=20-=20=D0=B1=D1=83=D0=BB=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8B,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=B0=20=D1=81=D0=BB=D0=B5=D0=B4=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B0=D1=8F=20=D1=81=D0=B5=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README_BUILD187_RU.txt | 69 ++++++++++++++++++++++++++++++++++++ app.py | 2 +- hockey_data/service.py | 23 ++++++++++++ shootout_3_home_3_away.sql | 40 +++++++++++++++++++++ ui_builder/static/app.js | 52 ++++++++++++++++++++++++--- ui_builder/static/styles.css | 6 +++- 6 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 README_BUILD187_RU.txt create mode 100644 shootout_3_home_3_away.sql diff --git a/README_BUILD187_RU.txt b/README_BUILD187_RU.txt new file mode 100644 index 0000000..9224c1b --- /dev/null +++ b/README_BUILD187_RU.txt @@ -0,0 +1,69 @@ +BUILD187 — 2026.10.02.7 +Буллиты: следующая серия и SQL для шести картинок + +Устанавливается поверх BUILD186 (2026.10.02.6). +В архиве только изменённые файлы приложения, SQL и эта инструкция: + app.py + ui_builder/static/app.js + ui_builder/static/styles.css + hockey_data/service.py + shootout_3_home_3_away.sql + +Установка +1. Остановите сервер. +2. Замените четыре файла приложения, сохранив пути внутри проекта. +3. Запустите сервер и обновите браузер через Ctrl+F5. +Версия /api/health: 2026.10.02.7. +Миграция базы не нужна. Agent менять не требуется. + +Вкладка «Буллиты» +Кнопка «Следующая серия» теперь видна постоянно. Под ней показано, что она +добавляет по одному буллиту каждой команде. Кнопка доступна оператору после +завершения текущей серии обеими командами при равном счёте. Иначе рядом показана +причина, почему следующую серию пока нельзя открыть. +Отображается основная серия или номер дополнительной серии. +Повторные нажатия во время запроса не добавляют несколько раундов. +Основная серия на 3 или 5 буллитов продолжает работать. + +SQL +Содержимое shootout_3_home_3_away.sql вставьте в SQL-источник Mapping. +Для этого титра с тремя ячейками каждой команды выберите основную серию на 3 буллита. +Единственная переменная запроса — :game_id, внешний идентификатор выбранного матча. + +Связи с шестью Image/Source полями титра: + pic1 — хозяева, первая ячейка + pic2 — хозяева, вторая ячейка + pic3 — хозяева, третья ячейка + pic4 — гости, первая ячейка + pic5 — гости, вторая ячейка + pic6 — гости, третья ячейка + +Забил (scored = TRUE) → Green-01.png. +Не забил (scored = FALSE) → Red-01.png. +Попытка ещё не выполнена → Line-01.png. +scored — логическое поле базы, а не строки «Да»/«Нет». + +Основная серия заполняет три ячейки каждой команды по порядку. +При нажатии «Следующая серия» текущая серия в базе меняется сразу, до первого +нового броска. SQL возвращает Line-01.png для всех шести ячеек. +Новые дополнительные броски заполняют только pic1 (хозяева) и pic4 (гости). +pic2, pic3, pic5, pic6 в дополнительных сериях всегда Line-01.png. +Следующая дополнительная серия снова очищает pic1/pic4 до нового результата. +Полный журнал прошлых бросков остаётся в базе и во вкладке «Буллиты». +SQL возвращает одну строку с шестью полями даже без выполненных попыток, +поэтому старые картинки не сохраняются из-за пустого результата запроса. + +SQL обновляет плашки при выполнении источника Mapping. Для автоматической смены +картинок после регистрации броска включите автообновление именно этого SQL-источника. +При отключённом автообновлении выполните Mapping или вызовите настроенный титр +кнопкой, которая обновляет его данные перед выдачей. + +Удаление попытки +Исправлена нумерация оставшегося журнала после удаления ранней попытки: +номера команды и серии пересчитываются, следующий бросок не занимает уже занятое +место. Уже открытая дополнительная серия сохраняется. + +Проверки выполнялись на изолированной тестовой базе и в браузере с имитацией API. +Проверены пустой титр, гол/промах, порядок хозяева/гости, дополнительные серии, +удаление и повторная запись попыток, режимы 3/5, ограничения кнопки и повторные клики. +Доступ к рабочей базе и реальному vMix при подготовке не использовался. diff --git a/app.py b/app.py index b7f34f7..6f50dac 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,7 @@ from khl_site.khl_data_center import APP as khl_site_app, configure_storage, syn from hockey_data.khl_career import KhlCareerService BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.10.02.6" +BUILD_VERSION = "2026.10.02.7" # compatibility: BUILD_VERSION = "2026.09.24.2" # compatibility: BUILD_VERSION = "2026.09.23.8" # compatibility: BUILD_VERSION = "2026.09.23.7" diff --git a/hockey_data/service.py b/hockey_data/service.py index ddb3164..8da5636 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -8533,6 +8533,29 @@ class HockeyDataService: raise ValueError("Буллит не найден") session.delete(attempt) row = self._get_or_create_game_control(session, str(game_external_id)) + session.flush() + remaining = list(session.scalars( + select(ShootoutAttempt) + .where(ShootoutAttempt.game_external_id == str(game_external_id)) + .order_by(ShootoutAttempt.sequence_number, ShootoutAttempt.id) + )) + # Deleting an earlier attempt shifts the journal slots. Rebuild the + # stored numbering too, so the next attempt cannot reuse a team's + # slot or keep an old result in the currently opened extra round. + # Move all sequence numbers aside before writing their final values + # to satisfy the game's unique sequence constraint during the flush. + for index, item in enumerate(remaining, 1): + item.sequence_number = -index + session.flush() + initial = 5 if row.shootout_initial_attempts == 5 else 3 + side_attempt_numbers: dict[str, int] = {} + for index, item in enumerate(remaining, 1): + side_number = side_attempt_numbers.get(item.side, 0) + 1 + side_attempt_numbers[item.side] = side_number + item.sequence_number = index + item.team_attempt_number = side_number + item.series_number = 1 if side_number <= initial else 1 + side_number - initial + session.flush() row.updated_by = str(updated_by or "")[:128] row.updated_at = datetime.utcnow() result = self.game_control(game_external_id, language=language) diff --git a/shootout_3_home_3_away.sql b/shootout_3_home_3_away.sql new file mode 100644 index 0000000..6f2b8ee --- /dev/null +++ b/shootout_3_home_3_away.sql @@ -0,0 +1,40 @@ +WITH current_series AS ( + SELECT + 1 + COALESCE(c.shootout_extra_rounds, 0) AS series_number + FROM (SELECT CAST(:game_id AS text) AS game_id) p + LEFT JOIN hockey_game_control_states c + ON c.game_external_id = p.game_id +), +attempt_images AS ( + SELECT + a.side, + CASE + WHEN s.series_number = 1 THEN a.team_attempt_number + ELSE 1 + END AS slot, + CASE + WHEN a.scored IS TRUE + THEN 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Green-01.png' + WHEN a.scored IS FALSE + THEN 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Red-01.png' + ELSE 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png' + END AS image_path + FROM hockey_shootout_attempts a + CROSS JOIN current_series s + WHERE a.game_external_id = :game_id + AND a.series_number = s.series_number +) +SELECT + COALESCE(MAX(image_path) FILTER (WHERE side = 'home' AND slot = 1), + 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic1, + COALESCE(MAX(image_path) FILTER (WHERE side = 'home' AND slot = 2), + 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic2, + COALESCE(MAX(image_path) FILTER (WHERE side = 'home' AND slot = 3), + 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic3, + COALESCE(MAX(image_path) FILTER (WHERE side = 'away' AND slot = 1), + 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic4, + COALESCE(MAX(image_path) FILTER (WHERE side = 'away' AND slot = 2), + 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic5, + COALESCE(MAX(image_path) FILTER (WHERE side = 'away' AND slot = 3), + 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic6 +FROM attempt_images; diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index ebade88..b456db0 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -14806,6 +14806,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { return node; } const shootout = control.shootout || {}; + state.hockeyShootoutRoundBusy ||= new Set(); const attempts = Array.isArray(shootout.attempts) ? shootout.attempts : []; const homeAttempts = attempts.filter((item) => item.side === "home"); const awayAttempts = attempts.filter((item) => item.side === "away"); @@ -14888,8 +14889,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${language === "en" ? "Main series" : "Основная серия"}
- ${shootout.can_add_round ? `` : ""} +
${language === "en" ? "+1 attempt for each team" : "+1 буллит каждой команде"}
${allowed}${language === "en" ? "attempts per team available" : "буллитов доступно каждой команде"}
+
${escapeHtml(home.name || "—")}
${hockeyShootoutSlots(homeAttempts, allowed, language)}
@@ -14923,9 +14925,51 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { }); state.formValues[selectedKey] = null; }); - node.querySelector("[data-shootout-round]")?.addEventListener("click", async () => { - if (!runtime) return; - await hockeyUpdateGameControl(gameId, `/shootout/round?language=${language}`, { method: "POST" }); + node.dataset.shootoutGame = gameId; + const roundButton = node.querySelector("[data-shootout-round]"); + const sameGame = () => { + const current = getByPath(state.data, props.gamePath || "hockey.selected_game") || {}; + return String(current.external_id || current.id || "").trim() === gameId; + }; + const refreshRoundAction = () => { + const current = state.hockeyGameControl[gameId]?.shootout || {}; + const busy = state.hockeyShootoutRoundBusy.has(gameId); + const canControl = runtime && hockeyCanControl() && sameGame(); + const extraRounds = Math.max(0, Number(current.extra_rounds || 0)); + const seriesState = extraRounds > 0 + ? `${language === "en" ? "Extra series" : "Дополнительная серия"} №${extraRounds}` + : language === "en" ? "Main series" : "Основная серия"; + const status = busy ? (language === "en" ? "Adding one attempt for each team…" : "Добавляем по одному буллиту каждой команде…") + : !canControl ? (language === "en" ? "Next series is available to the match operator." : "Следующая серия доступна оператору матча.") + : !current.started ? (language === "en" ? "Start the main series first." : "Сначала начните основную серию.") + : !current.both_complete ? (language === "en" ? "Complete the current series for both teams." : "Завершите текущую серию обеих команд.") + : !current.tied ? (language === "en" ? "An extra series is available when the score is tied." : "Дополнительная серия доступна при равном счёте.") + : current.can_add_round === true ? (language === "en" ? "The score is tied. Add one attempt for each team." : "Счёт равный: можно добавить по одному буллиту каждой команде.") + : language === "en" ? "Wait for the shootout data to update." : "Дождитесь обновления данных буллитов."; + roundButton.disabled = busy || !canControl || current.can_add_round !== true; + roundButton.textContent = busy ? (language === "en" ? "Adding…" : "Добавляем…") : (language === "en" ? "Next series" : "Следующая серия"); + roundButton.title = status; + roundButton.setAttribute("aria-busy", String(busy)); + node.querySelector("[data-shootout-series-state]").textContent = seriesState; + node.querySelector("[data-shootout-round-status]").textContent = status; + }; + node.refreshShootoutRoundAction = refreshRoundAction; + refreshRoundAction(); + roundButton.addEventListener("click", async () => { + if (!runtime || !hockeyCanControl() || !sameGame() || state.hockeyShootoutRoundBusy.has(gameId) + || state.hockeyGameControl[gameId]?.shootout?.can_add_round !== true) return; + state.hockeyShootoutRoundBusy.add(gameId); + refreshRoundAction(); + try { + await hockeyUpdateGameControl(gameId, `/shootout/round?language=${language}`, { method: "POST" }); + } catch (error) { + console.error("Shootout next series failed", error); + } finally { + state.hockeyShootoutRoundBusy.delete(gameId); + document.querySelectorAll(".hockey-shootout-control").forEach((current) => { + if (current.dataset.shootoutGame === gameId) current.refreshShootoutRoundAction?.(); + }); + } }); const applyShootoutSearch = (side, rawValue) => { const query = normalizePlayerSearch(rawValue); diff --git a/ui_builder/static/styles.css b/ui_builder/static/styles.css index 010665f..218d12a 100644 --- a/ui_builder/static/styles.css +++ b/ui_builder/static/styles.css @@ -5687,7 +5687,7 @@ body.hockey-navigation-open .runtime-viewport > .runtime-sizer { margin-inline: .hso-score { display: flex; align-items: center; gap: 12px; } .hso-score span { max-width: 160px; overflow: hidden; color: #8ca0b6; font-size: 10px; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; } .hso-score strong { min-width: 92px; padding: 7px 12px; color: #fff; border: 1px solid #334b65; border-radius: 11px; background: #0e1d2d; font-size: 23px; font-weight: 950; text-align: center; font-variant-numeric: tabular-nums; } -.hso-setup { min-height: 52px; display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #1d3147; background: rgba(255,255,255,.018); } +.hso-setup { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #1d3147; background: rgba(255,255,255,.018); } .hso-series-size { display: flex; align-items: center; gap: 5px; } .hso-series-size>span { margin-right: 4px; color: #8296ad; font-size: 9px; font-weight: 850; } .hso-series-size button, @@ -5697,6 +5697,10 @@ body.hockey-navigation-open .runtime-viewport > .runtime-sizer { margin-inline: .hso-series-size button.active { color: #07150f; border-color: #48dfbd; background: #48dfbd; } .hso-start { color: #dbe8f5; } .hso-next-series { color: #ffe2a3; border-color: rgba(240,189,101,.55); background: rgba(240,189,101,.09); } +.hso-round-action { display: grid; gap: 3px; text-align: center; } +.hso-round-action small { color: #8296ad; font-size: 8px; } +.hso-round-state { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; color: #8296ad; font-size: 9px; } +.hso-round-state strong { color: #dce8f4; font-size: 10px; } .hso-series-size button:disabled, .hso-start:disabled, .hso-next-series:disabled { opacity: .45; cursor: default; }