""" Scoring (ADR-012): to moduser per økt, cachet matchstatus. 'stroke' -> hole_score (brutto per hull, motoren utleder netto/hull-resultat). 'hole_result' -> match_hole_result (bare hvem som vant hullet, ingen motor for selve hull-resultatet -- handicapen er allerede bakt inn i match_participant fra oppsettsrunden). Autorisasjon: begrenset til den enkelte matchens FAKTISKE deltakere (en match_participant-rad for brukeren i akkurat denne matchen), ikke bare "noen på laget" og UAVHENGIG av kapteinmerket -- se app/team_authz.py sin user_is_match_participant. Byttet fra "rostret på laget" 2026-07-19 (Brukerroller-runden, ADR-023). Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her): singles/fourball MÅ ha match_participant_id; foursome/greensome/scramble MÅ IKKE ha det. Ingenting i skjemaet håndhever dette. Upsert i stedet for avvisning ved dobbel-innsending -- bevisst valg for at live-scoring skal tåle rettelser. Ingen audit-trail på korrigeringer ennå. Matchstatus-recompute låser match-raden FØRST (FOR UPDATE) for å serialisere samtidige scoreinnsendinger for SAMME match (typisk to fourball-partnere som taster inn ulike hull samtidig) -- uten det kan to transaksjoner lese hverandres data før commit og siste UPDATE kan overskrive en mer komplett status med en mindre komplett. "Hopp over uferdige hull": compute_match_state teller POSISJON i listen (len(hole_results)), ikke hullnummer. Derfor tas kun den SAMMENHENGENDE prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen. Walkover/konsesjon (ADR-024): `apply_concession` er en egen, enklere vei til akkurat de samme fire kolonnene (`status_text`/`points_side_a/b`/ `leading_side`) som `recompute_and_cache_match_state` skriver -- brukt når matchen avgjøres ved at en side GIR SEG, ikke ved utledning fra faktiske hull. Delt med tournaments.py sin turnering-nivå-konsesjon (gir opp ALLE ikke-avgjorte matcher laget har, i én operasjon). """ import json from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors from ..handicap import parse_allowance_config, relative_strokes_for_match from ..team_authz import user_is_match_participant, user_is_team_captain router = APIRouter() _INDIVIDUAL_FORMATS = {"singles", "fourball"} def _played_hole_numbers(hole_config: str) -> list[int]: if hole_config == "front_9": return list(range(1, 10)) if hole_config == "back_9": return list(range(10, 19)) return list(range(1, 19)) def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int) -> int | None: """Sidens NETTO for ett hull, eller None hvis ikke avgjørbart ennå.""" if format_ not in _INDIVIDUAL_FORMATS: if len(rows) != 1: return None r = rows[0] per_hole = strokes_per_hole.get(r["team_side"]) if per_hole is None or hole_number not in per_hole: return None return r["gross_strokes"] - per_hole[hole_number] # singles: forventer nøyaktig 1 rad. fourball: forventer nøyaktig 2 # (begge partnere) og tar den BESTE (laveste) netto -- klassisk # better-ball-regel. Ufullstendig antall rader = ikke avgjørbart ennå. expected_rows = 1 if format_ == "singles" else 2 if len(rows) != expected_rows: return None nets = [] for r in rows: per_hole = strokes_per_hole.get(r["match_participant_id"]) if per_hole is None or hole_number not in per_hole: return None nets.append(r["gross_strokes"] - per_hole[hole_number]) return min(nets) async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]: played = _played_hole_numbers(match["hole_config"]) if match["scoring_mode"] == "hole_result": rows = await conn.fetch( """ SELECT hole_number, winning_side::text AS winning_side FROM match_hole_result WHERE match_id = $1 """, match_id, ) by_hole = {r["hole_number"]: r["winning_side"] for r in rows} results = [] for h in played: if h not in by_hole: break ws = by_hole[h] results.append( HoleResult.SIDE_A if ws == "a" else HoleResult.SIDE_B if ws == "b" else HoleResult.HALVED ) return results # 'stroke'-modus allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None config = parse_allowance_config(match["format"], allowance_override) relative = await relative_strokes_for_match(conn, match_id, match["format"], config) if not relative: return [] # handicap ikke klart for én eller flere enheter ennå stroke_index_rows = await conn.fetch( "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", match["course_id"], ) all_18_si = [r["stroke_index"] for r in stroke_index_rows] strokes_per_hole: dict[str, dict[int, int]] = { unit: dict(zip(played, allocate_over_played_holes(total, all_18_si, played))) for unit, total in relative.items() } score_rows = await conn.fetch( """ SELECT hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, gross_strokes FROM hole_score WHERE match_id = $1 """, match_id, ) by_hole_side: dict[tuple[int, str], list] = {} for r in score_rows: by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r) results = [] for h in played: net_a = _side_net(by_hole_side.get((h, "a"), []), strokes_per_hole, match["format"], h) net_b = _side_net(by_hole_side.get((h, "b"), []), strokes_per_hole, match["format"], h) if net_a is None or net_b is None: break if net_a < net_b: results.append(HoleResult.SIDE_A) elif net_b < net_a: results.append(HoleResult.SIDE_B) else: results.append(HoleResult.HALVED) return results async def recompute_and_cache_match_state(conn, match_id: str) -> None: match = await conn.fetchrow( """ SELECT m.id::text, s.format, s.scoring_mode, s.hole_config::text AS hole_config, s.course_id::text AS course_id, s.allowance_override::text AS allowance_override, s.points_per_match::float AS points_per_match FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 FOR UPDATE OF m """, match_id, ) results = await _compute_hole_results(conn, match_id, match) played = _played_hole_numbers(match["hole_config"]) state = compute_match_state(results, total_holes=len(played)) # ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne # (i tillegg til den menneskelesbare status_text), slik at frontend kan # style etter et strukturert felt i stedet for å parse tekst. Gjelder # BÅDE pågående og avgjorte matcher -- vinnersiden i en avgjort match # er alltid den samme som lead sitt fortegn allerede pekte på. if state.lead > 0: leading_side = "a" elif state.lead < 0: leading_side = "b" else: leading_side = None complete = state.is_closed or state.holes_remaining == 0 if complete: ppm = match["points_per_match"] if state.lead > 0: points_a, points_b = ppm, 0.0 elif state.lead < 0: points_a, points_b = 0.0, ppm else: points_a = points_b = ppm / 2 await conn.execute( """ UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4 WHERE id = $5 """, state.describe(), points_a, points_b, leading_side, match_id, ) else: await conn.execute( "UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3", state.describe(), leading_side, match_id, ) class ConcedeOut(BaseModel): match_id: str status_text: str points_side_a: float points_side_b: float leading_side: str async def apply_concession(conn, match_id: str, conceding_side: str, points_per_match: float) -> ConcedeOut: """Setter matchen som avgjort ved walkover -- full poengsum til motstanderen, uansett hvor mange hull som ev. allerede er registrert (marginen betyr ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller MÅ selv ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er avgjort FØR denne kalles -- se de to kallstedene (matches.py sin concede_match, tournaments.py sin concede_tournament).""" winner = "b" if conceding_side == "a" else "a" points_a = 0.0 if conceding_side == "a" else points_per_match points_b = points_per_match if conceding_side == "a" else 0.0 status_text = f"Walkover ({winner.upper()})" await conn.execute( """ UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4 WHERE id = $5 """, status_text, points_a, points_b, winner, match_id, ) return ConcedeOut( match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner ) class ConcedeRequest(BaseModel): conceding_team_id: str @router.post( "/orgs/{organization_id}/matches/{match_id}/concede", response_model=ConcedeOut, status_code=201, ) async def concede_match( match_id: str, body: ConcedeRequest, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> ConcedeOut: """Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget som selv gir seg -- speiler ekte golf-etikette, du kan ikke kreve seier på motstanderens vegne) eller org-eier/admin kan erklære. Ensidig, ingen bekreftelse fra motparten kreves -- samme tillitsnivå som all annen scoring. Kan erklæres uansett hvor mange hull som allerede er registrert.""" async with org_connection(organization_id) as conn, translate_db_errors(): match = await conn.fetchrow( """ SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, m.points_side_a::float AS points_side_a, s.points_per_match::float AS points_per_match FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 FOR UPDATE OF m """, match_id, ) if match is None: raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") if match["points_side_a"] is not None: raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.") if body.conceding_team_id == match["team_a_id"]: conceding_side = "a" elif body.conceding_team_id == match["team_b_id"]: conceding_side = "b" else: raise app_error(400, "VALIDATION_FAILED", "Laget hører ikke til denne matchen.") if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id): raise app_error( 403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.", ) result = await apply_concession(conn, match_id, conceding_side, match["points_per_match"]) return result class HoleScoreCreate(BaseModel): team_side: str = Field(pattern="^[ab]$") match_participant_id: str | None = None hole_number: int = Field(ge=1, le=18) gross_strokes: int = Field(ge=1, le=20) class HoleScoreOut(BaseModel): hole_number: int team_side: str match_participant_id: str | None gross_strokes: int @router.post( "/orgs/{organization_id}/matches/{match_id}/hole-scores", response_model=HoleScoreOut, status_code=201, ) async def submit_hole_score( match_id: str, body: HoleScoreCreate, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> HoleScoreOut: async with org_connection(organization_id) as conn, translate_db_errors(): match = await conn.fetchrow( """ SELECT m.points_side_a::float AS points_side_a, s.format, s.scoring_mode, s.hole_config::text AS hole_config FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 """, match_id, ) if match is None: raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") if match["points_side_a"] is not None: raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.") if match["scoring_mode"] != "stroke": raise app_error( 400, "WRONG_SCORING_MODE", "Denne økten bruker hull-resultat-modus, ikke slagregistrering.", ) played = _played_hole_numbers(match["hole_config"]) if body.hole_number not in played: raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.") is_individual = match["format"] in _INDIVIDUAL_FORMATS if is_individual and body.match_participant_id is None: raise app_error( 400, "WRONG_PARTICIPANT_MODE", "Dette formatet krever match_participant_id (individuell ball).", ) if not is_individual and body.match_participant_id is not None: raise app_error( 400, "WRONG_PARTICIPANT_MODE", "Dette formatet bruker delt ball -- oppgi ikke match_participant_id.", ) if body.match_participant_id is not None: participant = await conn.fetchrow( """ SELECT team_side::text AS team_side FROM match_participant WHERE id = $1 AND match_id = $2 """, body.match_participant_id, match_id, ) if participant is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.") if participant["team_side"] != body.team_side: raise app_error( 400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side." ) if not await user_is_match_participant( conn, organization_id, match_id, user.user_id, team_side=body.team_side ): raise app_error( 403, "NOT_MATCH_PARTICIPANT", "Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).", ) if body.match_participant_id is not None: row = await conn.fetchrow( """ INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now() RETURNING hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, gross_strokes """, organization_id, match_id, body.team_side, body.match_participant_id, body.hole_number, body.gross_strokes, ) else: row = await conn.fetchrow( """ INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes) VALUES ($1, $2, $3, NULL, $4, $5) ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now() RETURNING hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, gross_strokes """, organization_id, match_id, body.team_side, body.hole_number, body.gross_strokes, ) await recompute_and_cache_match_state(conn, match_id) return HoleScoreOut(**dict(row)) class HoleResultCreate(BaseModel): hole_number: int = Field(ge=1, le=18) winning_side: str | None = Field(default=None, pattern="^[ab]$") class HoleResultOut(BaseModel): hole_number: int winning_side: str | None @router.post( "/orgs/{organization_id}/matches/{match_id}/hole-results", response_model=HoleResultOut, status_code=201, ) async def submit_hole_result( match_id: str, body: HoleResultCreate, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> HoleResultOut: async with org_connection(organization_id) as conn, translate_db_errors(): match = await conn.fetchrow( """ SELECT m.points_side_a::float AS points_side_a, s.scoring_mode, s.hole_config::text AS hole_config FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 """, match_id, ) if match is None: raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") if match["points_side_a"] is not None: raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.") if match["scoring_mode"] != "hole_result": raise app_error( 400, "WRONG_SCORING_MODE", "Denne økten bruker slagregistrering, ikke hull-resultat-modus.", ) played = _played_hole_numbers(match["hole_config"]) if body.hole_number not in played: raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.") # Begge sider kan rapportere et hull-resultat (hvilken som helst side kan # vinne/tape/dele) -- brukeren må selv være deltaker i DENNE matchen, # på hvilken som helst av de to sidene (team_side=None). if not await user_is_match_participant(conn, organization_id, match_id, user.user_id): raise app_error( 403, "NOT_MATCH_PARTICIPANT", "Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).", ) row = await conn.fetchrow( """ INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side) VALUES ($1, $2, $3, $4) ON CONFLICT (match_id, hole_number) DO UPDATE SET winning_side = EXCLUDED.winning_side RETURNING hole_number, winning_side::text AS winning_side """, organization_id, match_id, body.hole_number, body.winning_side, ) await recompute_and_cache_match_state(conn, match_id) return HoleResultOut(**dict(row)) class ScorecardHole(BaseModel): hole_number: int result: str class Scorecard(BaseModel): match_id: str status_text: str | None points_side_a: float | None points_side_b: float | None holes: list[ScorecardHole] # Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som # faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet # (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de # to feltene er fylt ut, avhengig av øktens scoring_mode. stroke_entries: list[HoleScoreOut] | None = None hole_result_entries: list[HoleResultOut] | None = None _RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"} @router.get( "/orgs/{organization_id}/matches/{match_id}/scorecard", response_model=Scorecard, ) async def get_scorecard( match_id: str, organization_id: str = Depends(get_authorized_org), ) -> Scorecard: async with org_connection(organization_id) as conn: match = await conn.fetchrow( """ SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b, s.format, s.scoring_mode, s.hole_config::text AS hole_config, s.course_id::text AS course_id, s.allowance_override::text AS allowance_override FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 """, match_id, ) if match is None: raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") results = await _compute_hole_results(conn, match_id, match) stroke_entries = None hole_result_entries = None if match["scoring_mode"] == "stroke": rows = await conn.fetch( """ SELECT hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, gross_strokes FROM hole_score WHERE match_id = $1 ORDER BY hole_number """, match_id, ) stroke_entries = [HoleScoreOut(**dict(r)) for r in rows] else: rows = await conn.fetch( """ SELECT hole_number, winning_side::text AS winning_side FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number """, match_id, ) hole_result_entries = [HoleResultOut(**dict(r)) for r in rows] played = _played_hole_numbers(match["hole_config"]) holes = [ ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results) ] return Scorecard( stroke_entries=stroke_entries, hole_result_entries=hole_result_entries, match_id=match["id"], status_text=match["status_text"], points_side_a=match["points_side_a"], points_side_b=match["points_side_b"], holes=holes, )