build 122 - стартовые пятерки

This commit is contained in:
2026-08-26 14:56:15 +03:00
parent 41ca064b3b
commit fe164c17f2
6 changed files with 529 additions and 22 deletions

View File

@@ -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="")

View File

@@ -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,

View File

@@ -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,