"""야구 일정 DB 반영 — (리그, KST 날짜, 팀쌍, 차수) 키 매칭. 워커가 매일 호출. - 기존 경기: 키 매칭 → 시각/venue/투표시간 갱신 (결과·예측·crowd 보존) - 신규 경기: 삽입 + crowd 초기화. match_id = {LEAGUE}_{away}_{home}_{yyyymmdd} (더블헤더 2차전은 _2 접미) - 취소(우천 등): 삭제하지 않고 status="cancelled" 로 보존 — 투표·예측 기록 유지, 정산·투표·AI 생성에서 제외. 소스가 취소를 번복하면 scheduled 로 복귀. (보강 경기는 새 날짜의 신규 경기로 재등장) - 더블헤더: 같은 (날짜, 팀쌍) 복수 경기를 시작시각순 seq(1,2)로 구분해 모두 등록. - 투표창: 리그 공통 오픈 오프셋(vote_open_hours_before, 기본 -168h) """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..models import CrowdStats, Match from ..teams_baseball import team_info log = logging.getLogger("triplepick.baseball") KST = timezone(timedelta(hours=9)) def baseball_opens_at(kickoff: datetime) -> datetime: # 축구와 동일 정책(vote_open_hours_before=168h) — 야구는 일정을 7일치만 # 수집하므로 사실상 동기화 즉시 투표 오픈된다. return kickoff - timedelta(hours=settings.vote_open_hours_before) def baseball_lock_at(kickoff: datetime) -> datetime: return kickoff - timedelta(minutes=settings.vote_lock_minutes_before) def _date_of(m: Match) -> str: return m.kickoff_at.astimezone(KST).strftime("%Y%m%d") if m.kickoff_at else "" def match_seq(match_id: str) -> int: """match_id 의 더블헤더 차수. `..._20260722` → 1, `..._20260722_2` → 2.""" tail = match_id.rsplit("_", 1)[-1] return int(tail) if len(tail) <= 2 and tail.isdigit() else 1 def assign_seq(records: list[dict]) -> None: """같은 (날짜, 팀쌍) 레코드에 시작시각순 seq(1,2,…)를 부여 — 더블헤더 구분.""" groups: dict[tuple, list[dict]] = {} for rec in records: groups.setdefault((rec["dateKst"], rec["teamA"], rec["teamB"]), []).append(rec) for recs in groups.values(): recs.sort(key=lambda r: r["kickoffKst"]) for i, rec in enumerate(recs, start=1): rec["seq"] = i async def sync_baseball_schedule( db: AsyncSession, league: str, records: list[dict] ) -> dict: if not records: return {"updated": 0, "inserted": 0, "skipped": 0, "removed": 0} assign_seq(records) existing = ( await db.execute(select(Match).where(Match.league == league)) ).scalars().all() by_key: dict[tuple, Match] = {} for m in existing: d, s = _date_of(m), match_seq(m.match_id) by_key[(d, m.team_a_code, m.team_b_code, s)] = m by_key[(d, m.team_b_code, m.team_a_code, s)] = m updated = inserted = skipped = removed = 0 for rec in records: a, b, d = rec["teamA"], rec["teamB"], rec["dateKst"] seq = rec.get("seq", 1) m = by_key.get((d, a, b, seq)) if rec.get("cancelled"): # 소프트 취소 — 투표/예측 기록 보존, 정산·투표·AI 대상에서 제외. if m is not None and m.result_outcome is None and m.status != "cancelled": m.status = "cancelled" removed += 1 continue kickoff = datetime.fromisoformat(rec["kickoffKst"]).astimezone(timezone.utc) if m is not None: if m.result_outcome is not None: skipped += 1 continue if m.status == "cancelled": m.status = "scheduled" # 취소 번복 — 시간 기준 상태는 tick 이 복원 m.kickoff_at = kickoff m.opens_at = baseball_opens_at(kickoff) m.lock_at = baseball_lock_at(kickoff) if rec.get("venue"): m.venue = rec["venue"] updated += 1 else: ta, tb = team_info(league, a), team_info(league, b) match_id = f"{league.upper()}_{a}_{b}_{d}" + (f"_{seq}" if seq > 1 else "") new = Match( match_id=match_id, league=league, round_label="정규시즌", group="", team_a_name=ta["name"], team_a_short=ta["shortName"], team_a_code=ta["code"], team_a_flag=ta["flag"], team_b_name=tb["name"], team_b_short=tb["shortName"], team_b_code=tb["code"], team_b_flag=tb["flag"], venue=rec.get("venue", ""), hook_text=f"{ta['shortName']} vs {tb['shortName']}", kickoff_at=kickoff, opens_at=baseball_opens_at(kickoff), lock_at=baseball_lock_at(kickoff), status="scheduled", ) db.add(new) db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0)) by_key[(d, a, b, seq)] = new by_key[(d, b, a, seq)] = new inserted += 1 await db.commit() result = {"updated": updated, "inserted": inserted, "skipped": skipped, "removed": removed} log.info("baseball sync(%s): %s", league, result) return result