diff --git a/app.py b/app.py index 2f69d58..92b07e7 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.26.8" +BUILD_VERSION = "2026.08.26.9" # compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" diff --git a/hockey_data/models.py b/hockey_data/models.py index 37b3130..8837a43 100644 --- a/hockey_data/models.py +++ b/hockey_data/models.py @@ -683,6 +683,12 @@ class GameRoster(Base): position_ru: Mapped[str] = mapped_column(String(64), nullable=False, default="") position_en: Mapped[str] = mapped_column(String(64), nullable=False, default="") line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") + # BUILD122: the API line stays available as a baseline, while operators may + # override the effective line/order without a later Stat2TV refresh erasing it. + api_line_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") + api_line_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + line_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + line_manual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) captain_role: Mapped[str] = mapped_column(String(32), nullable=False, default="") lineup_status: Mapped[str] = mapped_column(String(64), nullable=False, default="") raw_payload: Mapped[str] = mapped_column(Text, nullable=False, default="") diff --git a/hockey_data/router.py b/hockey_data/router.py index ad9c0ca..52edc87 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -123,6 +123,23 @@ class GameScorePayload(BaseModel): language: str = Field(default="ru", pattern="^(ru|en)$") +class GameLineAssignmentPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + player_external_id: str = Field(min_length=1, max_length=64) + line_number: str = Field(default="", max_length=16) + sort_order: int = Field(default=0, ge=0, le=999) + + +class GameLinesPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + side: str = Field(pattern="^(home|away)$") + assignments: list[GameLineAssignmentPayload] = Field(default_factory=list) + reset: bool = False + language: str = Field(default="ru", pattern="^(ru|en)$") + + class ShootoutSetupPayload(BaseModel): initial_attempts: int = Field(default=3) reset: bool = False @@ -1030,6 +1047,30 @@ def create_hockey_router( except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error + @router.put("/games/{external_id}/lines") + async def update_game_lines( + external_id: str, + payload: GameLinesPayload, + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + try: + if payload.reset: + return service.reset_game_lines( + external_id, + side=payload.side, + updated_by=user.id, + language=payload.language, + ) + return service.save_game_lines( + external_id, + side=payload.side, + assignments=[item.model_dump() for item in payload.assignments], + updated_by=user.id, + language=payload.language, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + @router.put("/games/{external_id}/control/values") async def update_game_values( external_id: str, diff --git a/hockey_data/service.py b/hockey_data/service.py index 3076bf9..63365d5 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -3050,6 +3050,7 @@ class HockeyDataService: for row in existing_rosters } seen_rosters: set[tuple[str, str]] = set() + api_line_order: dict[tuple[str, str], int] = {} for entry in details.rosters: profile = player_profiles.get(entry.player_external_id) if profile is None: @@ -3080,7 +3081,14 @@ class HockeyDataService: roster.role = entry.role roster.position_ru = entry.position_ru roster.position_en = entry.position_en - roster.line_number = entry.line_number + api_line = str(entry.line_number or "").strip() + api_order_key = (entry.side, api_line) + api_line_order[api_order_key] = api_line_order.get(api_order_key, 0) + 1 + roster.api_line_number = api_line + roster.api_line_sort_order = api_line_order[api_order_key] + if not bool(roster.line_manual): + roster.line_number = api_line + roster.line_sort_order = roster.api_line_sort_order roster.captain_role = entry.captain_role roster.lineup_status = entry.lineup_status roster.raw_payload = entry.raw_payload @@ -4917,6 +4925,10 @@ class HockeyDataService: ), "role": roster.role, "line": roster.line_number, + "line_sort_order": int(roster.line_sort_order or 0), + "line_manual": bool(roster.line_manual), + "api_line": roster.api_line_number, + "api_line_sort_order": int(roster.api_line_sort_order or 0), "captain_role": roster.captain_role, "lineup_status": roster.lineup_status, "birth_date": ( @@ -4937,12 +4949,29 @@ class HockeyDataService: ) for items in serialized_players.values(): - items.sort( - key=lambda item: ( - self._number_sort_value(str(item["number"])), - str(item["name"]), + has_lines = any(str(item.get("line") or "").strip() for item in items) + if has_lines: + def line_sort_value(item: dict[str, Any]) -> tuple[Any, ...]: + raw_line = str(item.get("line") or "").strip() + try: + line_number = int(raw_line) + except (TypeError, ValueError): + line_number = 10_000 if not raw_line else 9_000 + order = int(item.get("line_sort_order") or 0) + return ( + line_number, + order if order > 0 else 10_000, + self._number_sort_value(str(item.get("number") or "")), + str(item.get("name") or ""), + ) + items.sort(key=line_sort_value) + else: + items.sort( + key=lambda item: ( + self._number_sort_value(str(item["number"])), + str(item["name"]), + ) ) - ) serialized_officials: list[dict[str, Any]] = [] role_labels = { @@ -6102,6 +6131,102 @@ class HockeyDataService: raise ValueError("Матч не найден") return result + def save_game_lines( + self, + game_external_id: str, + *, + side: str, + assignments: list[dict[str, Any]], + updated_by: str = "", + language: str = "ru", + ) -> dict[str, Any]: + """Persist the operator's effective line order for one team. + + BUILD122 deliberately stores the result on ``hockey_game_rosters`` so + broadcast SQL can read the same roster table directly. ``line_manual`` + protects these values from later Stat2TV roster refreshes while the + parallel api_* fields keep the newest API baseline available for reset. + """ + side = str(side or "").strip().lower() + if side not in {"home", "away"}: + raise ValueError("Неизвестная сторона команды") + + normalised: dict[str, tuple[str, int]] = {} + for raw in assignments or []: + player_id = str(raw.get("player_external_id") or raw.get("player_id") or "").strip() + if not player_id: + continue + line = str(raw.get("line_number") or "").strip() + if line: + if not line.isdigit() or not (1 <= int(line) <= 99): + raise ValueError("Номер звена должен быть от 1 до 99") + line = str(int(line)) + try: + sort_order = max(0, int(raw.get("sort_order") or 0)) + except (TypeError, ValueError): + sort_order = 0 + normalised[player_id] = (line, sort_order) + + with self.database.session() as session: + game = session.scalar(select(Game).where(Game.external_id == str(game_external_id))) + if game is None: + raise ValueError("Матч не найден") + rosters = list(session.scalars(select(GameRoster).where(and_( + GameRoster.game_external_id == game.external_id, + GameRoster.side == side, + )))) + roster_by_id = {str(row.player_external_id): row for row in rosters} + unknown = sorted(set(normalised) - set(roster_by_id)) + if unknown: + raise ValueError(f"Игрок не найден в составе: {unknown[0]}") + for player_id, (line, sort_order) in normalised.items(): + roster = roster_by_id[player_id] + # First manual edit after upgrading from an older build: keep the + # already stored API line as a reset baseline before overwriting it. + if not str(roster.api_line_number or "").strip() and str(roster.line_number or "").strip(): + roster.api_line_number = str(roster.line_number or "").strip() + roster.api_line_sort_order = int(roster.line_sort_order or 0) + roster.line_number = line + roster.line_sort_order = sort_order + roster.line_manual = True + roster.synced_at = datetime.utcnow() + + result = self.game_details(game_external_id, language=language) + if result is None: + raise ValueError("Матч не найден") + return result + + def reset_game_lines( + self, + game_external_id: str, + *, + side: str, + updated_by: str = "", + language: str = "ru", + ) -> dict[str, Any]: + """Return one team's effective line layout to the latest API baseline.""" + side = str(side or "").strip().lower() + if side not in {"home", "away"}: + raise ValueError("Неизвестная сторона команды") + with self.database.session() as session: + game = session.scalar(select(Game).where(Game.external_id == str(game_external_id))) + if game is None: + raise ValueError("Матч не найден") + rosters = list(session.scalars(select(GameRoster).where(and_( + GameRoster.game_external_id == game.external_id, + GameRoster.side == side, + )))) + for roster in rosters: + roster.line_number = str(roster.api_line_number or "").strip() + roster.line_sort_order = int(roster.api_line_sort_order or 0) + roster.line_manual = False + roster.synced_at = datetime.utcnow() + + result = self.game_details(game_external_id, language=language) + if result is None: + raise ValueError("Матч не найден") + return result + def set_game_period( self, game_external_id: str, diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 8aba320..576267b 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -2597,7 +2597,9 @@ function startCustomTooltips() { // BUILD86: "Заготовки" is always the final top-level runtime tab. // Reorder it on every config normalisation as older published configs may // already contain the tab near "Игра" from BUILD84/85. - state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles"); + state.config.tabs = state.config.tabs.filter((tab) => !["lines", "prepared_titles"].includes(tab?.id)); + // BUILD122: line/five sorting is a first-class operator workspace. + state.config.tabs.push({ id: "lines", label: "Пятёрки" }); state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" }); if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; } @@ -6817,6 +6819,10 @@ function openTimerQuickEditor(focusActionId = "") { name: get("nameField", `Игрок ${index + 1}`), position: get("positionField", ""), role: String(row?.role || ""), + line: String(row?.line || row?.line_number || "").trim(), + lineSortOrder: Number(row?.line_sort_order || 0), + lineManual: Boolean(row?.line_manual), + apiLine: String(row?.api_line || row?.api_line_number || "").trim(), raw: clone(row) }; } @@ -6825,19 +6831,33 @@ function openTimerQuickEditor(focusActionId = "") { const props = component.props || {}; const rows = getByPath(state.data, side === "home" ? props.homePlayersPath : props.awayPlayersPath); const limit = Math.max(1, Number(props.rosterLimit) || 40); - return (Array.isArray(rows) ? rows : []) - .map((row, index) => normalizeHockeyPlayer(component, row, side, index)) - .sort((left, right) => { - const leftNumber = String(left.number || "").replace(/^#/, ""); - const rightNumber = String(right.number || "").replace(/^#/, ""); - if (!leftNumber && rightNumber) return 1; - if (leftNumber && !rightNumber) return -1; - return leftNumber.localeCompare(rightNumber, undefined, { - numeric: true, - sensitivity: "base", - }) || left.name.localeCompare(right.name); - }) - .slice(0, limit); + const players = (Array.isArray(rows) ? rows : []) + .map((row, index) => normalizeHockeyPlayer(component, row, side, index)); + const hasLineData = players.some((player) => String(player.line || "").trim()); + players.sort((left, right) => { + if (hasLineData) { + const lineValue = (player) => { + const raw = String(player.line || "").trim(); + if (!raw) return 10000; + const number = Number(raw); + return Number.isFinite(number) ? number : 9000; + }; + const lineDiff = lineValue(left) - lineValue(right); + if (lineDiff) return lineDiff; + const leftOrder = Number(left.lineSortOrder || 0) || 10000; + const rightOrder = Number(right.lineSortOrder || 0) || 10000; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + } + const leftNumber = String(left.number || "").replace(/^#/, ""); + const rightNumber = String(right.number || "").replace(/^#/, ""); + if (!leftNumber && rightNumber) return 1; + if (leftNumber && !rightNumber) return -1; + return leftNumber.localeCompare(rightNumber, undefined, { + numeric: true, + sensitivity: "base", + }) || left.name.localeCompare(right.name); + }); + return players.slice(0, limit); } function hockeyRosterCounts(players) { @@ -6860,6 +6880,296 @@ function openTimerQuickEditor(focusActionId = "") { return `${counts.total}${counts.forwards}${counts.defenders}${counts.goalkeepers}`; } + + // BUILD122 — operator line/five sorting. + function hockeyLinesGame() { + return getByPath(state.data, "hockey.selected_game") || null; + } + + function hockeyLinesPlayers(side) { + const game = hockeyLinesGame(); + const rows = game?.[side]?.players; + return Array.isArray(rows) ? rows : []; + } + + function hockeyLineNumericValue(value) { + const raw = String(value || "").trim(); + if (!raw) return 10000; + const number = Number(raw); + return Number.isFinite(number) ? number : 9000; + } + + function hockeyLinePlayerNumberValue(player) { + const raw = String(player?.number || "").replace(/^#/, "").trim(); + const number = Number(raw); + return Number.isFinite(number) ? number : 10000; + } + + function hockeyLinePlayersSorted(side, lineNumber) { + const line = String(lineNumber || "").trim(); + return hockeyLinesPlayers(side) + .filter((player) => String(player?.line || "").trim() === line) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.line_sort_order || 0) || 10000; + const rightOrder = Number(right?.line_sort_order || 0) || 10000; + return leftOrder - rightOrder + || hockeyLinePlayerNumberValue(left) - hockeyLinePlayerNumberValue(right) + || String(left?.name || "").localeCompare(String(right?.name || ""), undefined, { sensitivity: "base" }); + }); + } + + function hockeyLineVisibleCount(side) { + const key = `hockeyLinesVisible:${side}`; + const maxAssigned = hockeyLinesPlayers(side).reduce((maximum, player) => { + const value = Number(String(player?.line || "").trim()); + return Number.isFinite(value) ? Math.max(maximum, value) : maximum; + }, 0); + const current = Number(state.formValues[key] || 0); + const next = Math.max(4, maxAssigned, current); + state.formValues[key] = next; + return next; + } + + function hockeyReindexLine(side, lineNumber) { + hockeyLinePlayersSorted(side, lineNumber).forEach((player, index) => { + player.line_sort_order = index + 1; + }); + } + + function hockeyMoveLinePlayer(side, playerId, targetLine, beforePlayerId = "") { + const players = hockeyLinesPlayers(side); + const player = players.find((item) => String(item?.external_id || item?.id || "") === String(playerId || "")); + if (!player) return false; + const sourceLine = String(player.line || "").trim(); + const nextLine = String(targetLine || "").trim(); + const targetPlayers = hockeyLinePlayersSorted(side, nextLine).filter((item) => item !== player); + let insertIndex = targetPlayers.length; + if (beforePlayerId) { + const found = targetPlayers.findIndex((item) => String(item?.external_id || item?.id || "") === String(beforePlayerId)); + if (found >= 0) insertIndex = found; + } + player.line = nextLine; + targetPlayers.splice(insertIndex, 0, player); + targetPlayers.forEach((item, index) => { item.line_sort_order = index + 1; }); + if (sourceLine !== nextLine) hockeyReindexLine(side, sourceLine); + return true; + } + + async function hockeySaveLineAssignments(side) { + const game = hockeyLinesGame(); + const gameId = String(game?.external_id || game?.id || "").trim(); + if (!gameId) throw new Error("Сначала выберите матч"); + const assignments = hockeyLinesPlayers(side).map((player) => ({ + player_external_id: String(player?.external_id || player?.id || ""), + line_number: String(player?.line || "").trim(), + sort_order: Number(player?.line_sort_order || 0), + })).filter((item) => item.player_external_id); + const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, { + method: "PUT", + body: JSON.stringify({ + side, + assignments, + language: hockeyGameControlLanguage(), + }), + }); + state.data.hockey ||= {}; + const current = state.data.hockey.selected_game || {}; + current[side] ||= {}; + current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players; + if (payload?.player_statistics) current.player_statistics = payload.player_statistics; + if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics; + state.data.hockey.selected_game = current; + return payload; + } + + async function hockeyResetLineAssignments(side) { + const game = hockeyLinesGame(); + const gameId = String(game?.external_id || game?.id || "").trim(); + if (!gameId) throw new Error("Сначала выберите матч"); + const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/lines`, { + method: "PUT", + body: JSON.stringify({ + side, + assignments: [], + reset: true, + language: hockeyGameControlLanguage(), + }), + }); + state.data.hockey ||= {}; + const current = state.data.hockey.selected_game || {}; + current[side] ||= {}; + current[side].players = Array.isArray(payload?.[side]?.players) ? payload[side].players : current[side].players; + if (payload?.player_statistics) current.player_statistics = payload.player_statistics; + if (payload?.season_player_statistics) current.season_player_statistics = payload.season_player_statistics; + state.data.hockey.selected_game = current; + state.formValues[`hockeyLinesVisible:${side}`] = 0; + return payload; + } + + function hockeyLinePlayerCard(side, player) { + const node = div("hockey-line-player"); + const playerId = String(player?.external_id || player?.id || ""); + node.draggable = true; + node.dataset.playerId = playerId; + node.dataset.side = side; + node.innerHTML = ` + ⋮⋮ + ${escapeHtml(player?.number ? `#${player.number}` : "—")} + ${escapeHtml(player?.name || "Игрок")} + ${escapeHtml(player?.position || "")} + `; + node.addEventListener("dragstart", (event) => { + const payload = JSON.stringify({ side, playerId }); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-hockey-line-player", payload); + event.dataTransfer.setData("text/plain", payload); + node.classList.add("is-dragging"); + }); + node.addEventListener("dragend", () => node.classList.remove("is-dragging")); + return node; + } + + function hockeyReadLineDrag(event) { + try { + const raw = event.dataTransfer?.getData("application/x-hockey-line-player") || event.dataTransfer?.getData("text/plain") || ""; + const payload = JSON.parse(raw); + if (!payload?.side || !payload?.playerId) return null; + return payload; + } catch (_) { + return null; + } + } + + function hockeyLineDropZone(side, lineNumber, players) { + const zone = div("hockey-line-dropzone"); + zone.dataset.side = side; + zone.dataset.line = String(lineNumber || ""); + if (!players.length) zone.innerHTML = `Перетащите игрока сюда`; + players.forEach((player) => { + const card = hockeyLinePlayerCard(side, player); + card.addEventListener("dragover", (event) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + card.classList.add("is-drop-before"); + }); + card.addEventListener("dragleave", () => card.classList.remove("is-drop-before")); + card.addEventListener("drop", async (event) => { + event.preventDefault(); + event.stopPropagation(); + card.classList.remove("is-drop-before"); + const dragged = hockeyReadLineDrag(event); + if (!dragged || dragged.side !== side) return; + if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber, String(player?.external_id || player?.id || ""))) return; + renderRuntime(); + try { + await hockeySaveLineAssignments(side); + renderRuntime(); + } catch (error) { + toast(`Не удалось сохранить пятёрки: ${error.message}`, true); + } + }); + zone.appendChild(card); + }); + zone.addEventListener("dragover", (event) => { + const dragged = hockeyReadLineDrag(event); + if (!dragged || dragged.side !== side) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + zone.classList.add("is-over"); + }); + zone.addEventListener("dragleave", (event) => { + if (!zone.contains(event.relatedTarget)) zone.classList.remove("is-over"); + }); + zone.addEventListener("drop", async (event) => { + if (event.target.closest(".hockey-line-player")) return; + event.preventDefault(); + zone.classList.remove("is-over"); + const dragged = hockeyReadLineDrag(event); + if (!dragged || dragged.side !== side) return; + if (!hockeyMoveLinePlayer(side, dragged.playerId, lineNumber)) return; + renderRuntime(); + try { + await hockeySaveLineAssignments(side); + renderRuntime(); + } catch (error) { + toast(`Не удалось сохранить пятёрки: ${error.message}`, true); + } + }); + return zone; + } + + function hockeyLineTeamColumn(side) { + const game = hockeyLinesGame() || {}; + const team = game?.[side] || {}; + const column = div(`hockey-lines-team side-${side}`); + const players = hockeyLinesPlayers(side); + const manualCount = players.filter((player) => Boolean(player?.line_manual)).length; + const header = div("hockey-lines-team-header"); + header.innerHTML = ` +