"""도메인 헬퍼 — 투표 단계(phase) 계산 + ORM→응답 스키마 직렬화. phase 는 now 기준 동적 계산 (lib/schedule.ts matchPhase 와 동일 규칙): result 존재 → finished now >= lockAt → locked now < opensAt → scheduled 그 외 → open """ from __future__ import annotations from datetime import datetime, timedelta, timezone from .config import settings from .models import AIPrediction, CrowdStats, Match from .schemas import ( AIPredictionOut, CrowdStatsOut, MatchOut, MatchResult, Team, ) def now_utc() -> datetime: return datetime.now(timezone.utc) KST = timezone(timedelta(hours=9)) def ensure_aware(dt: datetime) -> datetime: """naive datetime(일부 DB 드라이버 반환)을 UTC aware 로 보정.""" return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt def kst_iso(dt: datetime) -> str: """저장값(UTC)을 KST(+09:00) ISO 문자열로 직렬화 — 프론트 표시 기준 통일.""" return ensure_aware(dt).astimezone(KST).isoformat() def compute_phase(m: Match, now: datetime | None = None) -> str: """투표/경기 단계: finished 결과 입력됨 → "종료" live 킥오프 이후, 결과 입력 전 → "경기중" locked 투표 마감(킥오프 1h 전) ~ 킥오프 → "투표 종료"(비활성) scheduled 오픈 전 → "오픈 예정" open 투표 중 """ now = now or now_utc() if m.result_outcome is not None: return "finished" if now >= ensure_aware(m.kickoff_at): return "live" if now >= ensure_aware(m.lock_at): return "locked" if now < ensure_aware(m.opens_at): return "scheduled" return "open" def is_votable(m: Match) -> bool: """AI 예측·투표 대상 조인지. featured_group 만 투표 가능, 그 외는 일정/결과만.""" return m.group == settings.featured_group def is_open_for_voting(m: Match, now: datetime | None = None) -> bool: """제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈.""" now = now or now_utc() if not is_votable(m): return False if m.result_outcome is not None: return False if now >= ensure_aware(m.lock_at): return False if settings.demo_force_open: return True return now >= ensure_aware(m.opens_at) def _team_a(m: Match) -> Team: return Team( name=m.team_a_name, shortName=m.team_a_short, code=m.team_a_code, flag=m.team_a_flag ) def _team_b(m: Match) -> Team: return Team( name=m.team_b_name, shortName=m.team_b_short, code=m.team_b_code, flag=m.team_b_flag ) def prediction_out(p: AIPrediction, lang: str = "ko") -> AIPredictionOut: reason = p.reason_en if lang == "en" and p.reason_en else p.reason_ko return AIPredictionOut( matchId=p.match_id, model=p.model, # type: ignore[arg-type] outcome=p.outcome, # type: ignore[arg-type] scoreA=p.score_a, scoreB=p.score_b, confidencePct=p.confidence_pct, reasonShort=reason, generatedAt=p.generated_at.date().isoformat() if p.generated_at else "", ) def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut: if c is None: return CrowdStatsOut(matchId=match_id, total=0, teamAWin=0, draw=0, teamBWin=0) return CrowdStatsOut( matchId=match_id, total=c.total, teamAWin=c.team_a_win, draw=c.draw, teamBWin=c.team_b_win, ) def match_out( m: Match, lang: str = "ko", include_predictions: bool = True, now: datetime | None = None, ) -> MatchOut: result = None if m.result_outcome is not None: result = MatchResult( scoreA=m.result_score_a or 0, scoreB=m.result_score_b or 0, outcome=m.result_outcome, # type: ignore[arg-type] ) preds: list[AIPredictionOut] = [] if include_predictions: # 모델 순서 고정: GPT, Claude, Gemini order = {"GPT": 0, "Claude": 1, "Gemini": 2} for p in sorted(m.predictions, key=lambda x: order.get(x.model, 9)): preds.append(prediction_out(p, lang)) # status·phase 를 동일한 실시간 계산값으로 통일 — 워커 틱 지연과 무관하게 일관. phase = compute_phase(m, now) return MatchOut( matchId=m.match_id, roundLabel=m.round_label, group=m.group, teamA=_team_a(m), teamB=_team_b(m), kickoffKst=kst_iso(m.kickoff_at), venue=m.venue, opensAt=kst_iso(m.opens_at), lockAt=kst_iso(m.lock_at), status=phase, phase=phase, votable=is_votable(m), votingOpen=is_open_for_voting(m, now), hookText=m.hook_text, result=result, predictions=preds, crowd=crowd_out(m.crowd, m.match_id), )