""" DB-bevisst bro mellom handicap_engine.py (ren, uten DB-avhengigheter) og Postgres. Se ADR-005 (allowance er konfig), ADR-014 (fire uavhengige brytere), ADR-007 (handicap_index_snapshot fryses i team_roster). Kjent, bevisst begrensning (se plan): hvis en side aldri når forventet deltakerantall, eller en deltakers tee mangler en matchende tee_rating-rad, beregnes handicap for den siden ALDRI automatisk her -- ingen bakgrunnsjobb prøver på nytt. Dette løses senere (sannsynligvis en organisator-overstyring). """ from __future__ import annotations from dataclasses import dataclass from asyncpg import Connection from handicap_engine import ( AllowanceStrategy, CombinedPercentage, DEFAULT_MATCHPLAY_ALLOWANCES, Format, PerPlayerPercentage, RankedSplit, WeightedLowHigh, course_handicap_raw, match_play_strokes, round_half_up, ) # Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret, # greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er # ikke med her: der er enheten spilleren, og beregnes individuelt. _SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"} FORMAT_UNIT_SIZE: dict[str, int] = { "singles": 1, "fourball": 1, "foursome": 2, "greensome": 2, "scramble_2": 2, "scramble_4": 4, } @dataclass(frozen=True) class AllowanceConfig: use_handicap: bool use_course_handicap: bool use_matchplay_handicap: bool strategy: AllowanceStrategy def _strategy_from_json(format_: str, spec: dict | None) -> AllowanceStrategy: if spec is None: return DEFAULT_MATCHPLAY_ALLOWANCES[Format(format_)] kind = spec["type"] if kind == "per_player": return PerPlayerPercentage(spec["percentage"]) if kind == "combined": return CombinedPercentage(spec["percentage"]) if kind == "weighted_low_high": return WeightedLowHigh(spec["low_weight"], spec["high_weight"]) if kind == "ranked_split": return RankedSplit(tuple(spec["weights"])) raise ValueError(f"Ukjent allowance-strategitype: {kind!r}") def parse_allowance_config(format_: str, allowance_override: dict | None) -> AllowanceConfig: """ADR-014: fire uavhengige brytere. Manglende nøkler = på/standard.""" override = allowance_override or {} return AllowanceConfig( use_handicap=override.get("use_handicap", True), use_course_handicap=override.get("use_course_handicap", True), use_matchplay_handicap=override.get("use_matchplay_handicap", True), strategy=_strategy_from_json(format_, override.get("strategy")), ) async def compute_and_store_side_handicaps( conn: Connection, match_id: str, team_side: str, format_: str, config: AllowanceConfig, ) -> None: """Beregn og lagre course_handicap/playing_handicap for én side i en match. Kalles fra matches.py sin add_participant RETT ETTER hver innsetting. Avgjør selv om den er klar til å beregne: - singles/fourball (enhet = spiller): beregner uansett, for hver deltaker som finnes (idempotent -- trygt å kalle flere ganger). - foursome/greensome/scramble (enhet = side): beregner KUN når siden har nøyaktig FORMAT_UNIT_SIZE[format] deltakere OG alle har en matchende tee_rating -- ellers returnerer den uten å skrive noe (se modul-docstring). Bruker ALLTID tee-ens full_18-rating (ADR-008), uavhengig av øktens hole_config -- front/back-9-fordelingen skjer senere, ved selve slagtildelingen (`allocate_over_played_holes` i scoring.py), ikke her. FIKSET 2026-07-19: joinet tidligere mot øktens hole_config som rating-scope direkte, som gjorde at en front_9/back_9-økt ALDRI fikk beregnet handicap i praksis -- ingen front_9/back_9-scopet tee_rating har noensinne blitt opprettet (kun full_18, se ADR-019 Beslutning D og tee-endepunktet), så joinen fant alltid null rader. Bekreftet mot ekte produksjonsdata (en front_9 foursome-økt med NULL course_handicap/ playing_handicap på alle fire deltakere). ADR-029 (2026-07-19): ratingen er også kjønnsspesifikk nå (`tee_rating. gender`) -- ett fysisk utslag (`tee`) kan ha én rating per kjønn, aldri én felles. Joines derfor også mot spillerens `player.gender`. Krever at `matches.py` sin `add_participant` har validert FØR innsetting at spilleren har et registrert kjønn OG at valgt utslag faktisk har en matchende rating -- denne funksjonen forventer at det alltid stemmer, den validerer ikke selv (samme mønster som for manglende handicap_index_snapshot). """ participants = await conn.fetch( """ SELECT mp.id::text AS id, tr.handicap_index_snapshot::float AS handicap_index_snapshot, tee_rating.course_rating::float AS course_rating, tee_rating.slope_rating::float AS slope_rating, tee_rating.par AS par FROM match_participant mp JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN player p ON p.id = tr.player_id JOIN tee_rating ON tee_rating.tee_id = mp.tee_id AND tee_rating.scope = 'full_18' AND tee_rating.gender = p.gender WHERE mp.match_id = $1 AND mp.team_side = $2::team_side """, match_id, team_side, ) if format_ in _SIDE_IS_UNIT: expected = FORMAT_UNIT_SIZE[format_] total_on_side = await conn.fetchval( "SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side", match_id, team_side, ) if total_on_side != expected or len(participants) != expected: return # ikke komplett ennå, eller mangler tee_rating for noen if not participants: return if not config.use_handicap: for p in participants: await conn.execute( "UPDATE match_participant SET course_handicap = 0, playing_handicap = 0 WHERE id = $1", p["id"], ) return course_handicaps = [ course_handicap_raw(p["handicap_index_snapshot"], p["slope_rating"], p["course_rating"], p["par"]) if config.use_course_handicap else p["handicap_index_snapshot"] for p in participants ] if format_ in _SIDE_IS_UNIT: playing = config.strategy.playing_handicap(course_handicaps) for p, ch in zip(participants, course_handicaps): await conn.execute( "UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3", round_half_up(ch), playing, p["id"], ) else: for p, ch in zip(participants, course_handicaps): playing = config.strategy.playing_handicap([ch]) await conn.execute( "UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3", round_half_up(ch), playing, p["id"], ) async def relative_strokes_for_match( conn: Connection, match_id: str, format_: str, config: AllowanceConfig ) -> dict[str, int]: """Relative slag per scoringsenhet i matchen. Nøkkel = match_participant_id for singles/fourball (fourball: ALLE fire spillerne i matchen samlet i én liste før match_play_strokes -- jf. R&A- regelen om at laveste av alle fire spiller av scratch, ikke side-vis). Nøkkel = team_side ('a'/'b') for foursome/greensome/scramble. Returnerer {} hvis handicap ikke er beregnet for én eller flere enheter ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som "kan ikke avgjøre noen hull ennå". """ if format_ in _SIDE_IS_UNIT: rows = await conn.fetch( """ SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap FROM match_participant WHERE match_id = $1 ORDER BY team_side """, match_id, ) else: rows = await conn.fetch( "SELECT id::text AS unit, playing_handicap FROM match_participant WHERE match_id = $1", match_id, ) if not rows or any(r["playing_handicap"] is None for r in rows): return {} absolutes = [r["playing_handicap"] for r in rows] relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes return {r["unit"]: v for r, v in zip(rows, relative)}