""" 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, } # Nye formater denne runden (2026-07-30) som er strukturelt IDENTISKE med et # eksisterende format -- ingen egen HCP-/allowance-logikk, bare et annet # selvdeklarert navn. Løses opp HELT FØRST i parse_allowance_config/ # compute_and_store_side_handicaps/relative_strokes_for_match, slik at resten # av disse funksjonene (og SIDE_IS_UNIT/FORMAT_UNIT_SIZE/Format-enumet) # aldri ser aliasnavnet. Chapman/Pinehurst ("Try all", bekreftet av bruker): # delt ball, én score per hull per side, CombinedPercentage 50% -- identisk # med foursome, kun regler for HVEM som slår hvilket slag (ikke sporet i # skjemaet, verken her eller for foursome). ENGINE_FORMAT_ALIASES: dict[str, str] = { "chapman": "foursome", # Shamble (2026-07-30) -- variabel lagstørrelse (2-4), enheten er # SPILLEREN (som fourball/singles), ikke siden -- "singles" sin # PerPlayerPercentage(1.00) er telle-agnostisk (regner ett tall per # spiller uavhengig av hvor mange andre er på laget), i motsetning til # foursome/greensome/scramble sine faste antalls-strategier. # Kildens hullscore-regel (beste N av M) bruker rå brutto, ikke HCP- # justert netto -- course_handicap/playing_handicap beregnes og lagres # likevel her (for visning/et fremtidig steg), men brukes IKKE av # selve hullscore-utregningen (se scoring.py sin _side_net shamble-gren). "shamble": "singles", # Money Ball/Lone Ranger (2026-07-30) -- FAST 4-manns lag, enheten er # SPILLEREN (som Shamble/fourball), ikke siden. Rå brutto brukes for # selve hullscoren (se scoring.py sin _compute_money_ball_hole_results), # course_handicap/playing_handicap beregnes og lagres likevel for # visning (samme begrunnelse som Shamble). "money_ball": "singles", # High-low-high (2026-07-30) -- 2 lag à 2, individuell ball. course_ # handicap/playing_handicap beregnes som fourball (samme "alle fire # samlet i én match_play_strokes-pool"-regel, R&A) -- selve poeng- # rangeringen (high/low per hull) er en HELT NY, egen beregning (se # app/routers/rounds.py sin _build_high_low_high_result), ikke del av # denne aliaseringen. "high_low_high": "fourball", } @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.""" format_ = ENGINE_FORMAT_ALIASES.get(format_, format_) 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). """ format_ = ENGINE_FORMAT_ALIASES.get(format_, format_) 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å". """ format_ = ENGINE_FORMAT_ALIASES.get(format_, format_) 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)}