""" Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering på tvers av FLERE individuelle turneringer (`tournament.format_type = 'individual'`, ADR-037) -- IKKE org-lagturneringer (ADR-011, poeng er der knyttet til hele kamper, ikke enkeltspillere, samme begrensning som konkurranseklassenes leaderboard-splitting, ADR-041). To uavhengige akser (GolfBox-inspirert, se plan-loggen 2026-08-04): HVA telles per lenket turnering (`result_type`: poeng-etter-plassering/ Stableford-sum/brutto/netto/pengeliste) og HVORDAN det summeres over flere turneringer (`aggregation_mode`: sum/snitt/eclectic -- eclectic ikke bygget i denne runden, se FEATURE_BACKLOG.md). Gjenbruker `_compute_individual_standings` fra individual_tournaments.py for HVER lenket turnering sin egen leaderboard/plassering -- ingen duplisering av posisjon-/til-par-beregningen. "Regn ut ved lesing" for selve OOM-leaderboardet også (samme filosofi som Nassau/High-low-high/ individual_leaderboard selv) -- ingen cache-tabell. Bygget i stadier (se plan-loggen): denne runden dekker spiller-OOM med poeng/stableford/brutto/netto/pengeliste-resultattyper, sum/snitt- aggregering, og behold-N-beste/minimum-resultater/aldersgrense-grenser. Lag-OOM og Eclectic-aggregering er bevisst IKKE bygget ennå (egne, senere stadier) -- se FEATURE_BACKLOG.md. """ from __future__ import annotations import json from typing import Literal from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from ..auth import get_authorized_org from ..db import org_connection from ..errors import app_error, translate_db_errors from handicap_engine import ( allocate_strokes_by_index, EclecticHoleValue, eclectic_best_per_hole, order_of_merit_aggregate, order_of_merit_points_for_position, stableford_points_for_hole, ) from .individual_tournaments import _compute_individual_standings router = APIRouter() _RESULT_TYPES = Literal["points", "stableford", "gross", "net", "money"] _AGGREGATION_MODES = Literal["sum", "average", "eclectic"] # --------------------------------------------------------------------------- # CRUD: selve OOM-en # --------------------------------------------------------------------------- class OrderOfMeritCreate(BaseModel): name: str = Field(min_length=1, max_length=200) kind: Literal["player", "team"] result_type: _RESULT_TYPES aggregation_mode: _AGGREGATION_MODES = "sum" count_best_n: int | None = Field(default=None, ge=1) min_results_required: int | None = Field(default=None, ge=1) birth_year_from: int | None = None birth_year_to: int | None = None public_visible: bool = False class OrderOfMeritUpdate(BaseModel): """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som TournamentUpdate/SponsorUpdate/TournamentClassUpdate. `kind` og `result_type` er IKKE med -- begge låses etter opprettelse. `kind` per plan (matcher GolfBox sin egen tilsvarende låsing). `result_type` er strukket til samme regel her: å endre den i etterkant ville gjort eksisterende lenkers `points_table`/ `money_payout_table`-konfig meningsløs uten en egen migreringssti -- opprett en ny OOM i stedet.""" name: str | None = Field(default=None, min_length=1, max_length=200) aggregation_mode: _AGGREGATION_MODES | None = None count_best_n: int | None = None min_results_required: int | None = None birth_year_from: int | None = None birth_year_to: int | None = None public_visible: bool | None = None class OrderOfMeritOut(BaseModel): id: str name: str kind: str result_type: str aggregation_mode: str count_best_n: int | None min_results_required: int | None birth_year_from: int | None birth_year_to: int | None public_visible: bool _OOM_COLUMNS = """ id::text, name, kind, result_type, aggregation_mode, count_best_n, min_results_required, birth_year_from, birth_year_to, public_visible """ @router.get("/orgs/{organization_id}/order-of-merits", response_model=list[OrderOfMeritOut]) async def list_order_of_merits( organization_id: str = Depends(get_authorized_org), ) -> list[OrderOfMeritOut]: async with org_connection(organization_id) as conn: rows = await conn.fetch(f"SELECT {_OOM_COLUMNS} FROM order_of_merit ORDER BY created_at DESC") return [OrderOfMeritOut(**dict(r)) for r in rows] @router.post("/orgs/{organization_id}/order-of-merits", response_model=OrderOfMeritOut, status_code=201) async def create_order_of_merit( body: OrderOfMeritCreate, organization_id: str = Depends(get_authorized_org), ) -> OrderOfMeritOut: if body.kind == "team" and body.aggregation_mode == "eclectic": raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.") async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" INSERT INTO order_of_merit (organization_id, name, kind, result_type, aggregation_mode, count_best_n, min_results_required, birth_year_from, birth_year_to, public_visible) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING {_OOM_COLUMNS} """, organization_id, body.name, body.kind, body.result_type, body.aggregation_mode, body.count_best_n, body.min_results_required, body.birth_year_from, body.birth_year_to, body.public_visible, ) return OrderOfMeritOut(**dict(row)) @router.patch("/orgs/{organization_id}/order-of-merits/{oom_id}", response_model=OrderOfMeritOut) async def update_order_of_merit( oom_id: str, body: OrderOfMeritUpdate, organization_id: str = Depends(get_authorized_org), ) -> OrderOfMeritOut: updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] values = list(updates.values()) values.append(oom_id) async with org_connection(organization_id) as conn, translate_db_errors(): existing = await conn.fetchrow("SELECT kind, result_type FROM order_of_merit WHERE id = $1", oom_id) if existing is None: raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") if updates.get("aggregation_mode") == "eclectic" and existing["kind"] == "team": raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.") result_type = existing["result_type"] effective_mode = updates.get("aggregation_mode") if effective_mode == "eclectic" and result_type not in ("stableford", "gross", "net"): raise app_error( 400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto." ) if effective_mode == "eclectic": # Slår PÅ eclectic mens flere turneringer med ulik bane # allerede er lenket -- avvis her også, ikke bare ved lenking # av en NY turnering (ADR-075). links = await conn.fetch( "SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id ) course_ids = set() for link in links: cid = await _course_id_for_tournament(conn, link["tournament_id"]) if cid is not None: course_ids.add(cid) if len(course_ids) > 1: raise app_error( 400, "VALIDATION_FAILED", "Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.", ) row = await conn.fetchrow( f""" UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now() WHERE id = ${len(values)} RETURNING {_OOM_COLUMNS} """, *values, ) return OrderOfMeritOut(**dict(row)) @router.delete("/orgs/{organization_id}/order-of-merits/{oom_id}", status_code=204) async def delete_order_of_merit( oom_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: result = await conn.execute("DELETE FROM order_of_merit WHERE id = $1", oom_id) if result == "DELETE 0": raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") # --------------------------------------------------------------------------- # Lenkede turneringer # --------------------------------------------------------------------------- class OrderOfMeritLinkCreate(BaseModel): tournament_id: str # Kun gyldig (og påkrevd) når OOM-ens result_type='points': liste av # heltall, indeks 0 = poeng for 1. plass. points_table: list[int] | None = None # Kun gyldig (og påkrevd) når result_type='money'. money_pool_total: float | None = None money_payout_table: list[dict] | None = None class OrderOfMeritLinkOut(BaseModel): id: str tournament_id: str tournament_name: str points_table: list[int] | None money_pool_total: float | None money_payout_table: list[dict] | None _LINK_COLUMNS = """ l.id::text, l.tournament_id::text, t.name AS tournament_name, l.points_table::text AS points_table, l.money_pool_total, l.money_payout_table::text AS money_payout_table """ @router.get( "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments", response_model=list[OrderOfMeritLinkOut], ) async def list_order_of_merit_links( oom_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[OrderOfMeritLinkOut]: async with org_connection(organization_id) as conn: rows = await conn.fetch( f""" SELECT {_LINK_COLUMNS} FROM order_of_merit_link l JOIN tournament t ON t.id = l.tournament_id WHERE l.order_of_merit_id = $1 ORDER BY l.created_at """, oom_id, ) return [OrderOfMeritLinkOut(**_parse_link_row(r)) for r in rows] def _parse_link_row(r) -> dict: d = dict(r) d["points_table"] = json.loads(d["points_table"]) if d.get("points_table") else None d["money_payout_table"] = json.loads(d["money_payout_table"]) if d.get("money_payout_table") else None if d.get("money_pool_total") is not None: d["money_pool_total"] = float(d["money_pool_total"]) return d async def _course_id_for_tournament(conn, tournament_id: str) -> str | None: """Samme mønster som _attach_eclectic_totals sin egen bane-oppslag (individual_tournaments.py) -- LIMIT 1 antar (bevisst, håndhevet av resten av appen ved rundeopprettelse) at en turnerings runder deler samme bane.""" return await conn.fetchval( "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id ) async def _validate_same_course_for_eclectic(conn, oom_id: str, new_tournament_id: str) -> None: """Eclectic-aggregering (ADR-075) plukker beste resultat PER HULL på tvers av alle lenkede turneringer -- meningsløst hvis banene ikke er de samme (hull 7 på én bane er ikke hull 7 på en annen). Håndhevet her (lenke-tidspunkt) OG i update_order_of_merit (modus-endring), samme "fail loudly, ikke stille feil tall"-filosofi som ADR-068/ ADR-019 Beslutning C.""" new_course_id = await _course_id_for_tournament(conn, new_tournament_id) if new_course_id is None: return # ingen runder ennå på den nye turneringen -- ingenting å sammenligne mot links = await conn.fetch( "SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id ) for link in links: existing_course_id = await _course_id_for_tournament(conn, link["tournament_id"]) if existing_course_id is not None and existing_course_id != new_course_id: raise app_error( 400, "VALIDATION_FAILED", "Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.", ) @router.post( "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments", response_model=OrderOfMeritLinkOut, status_code=201, ) async def link_tournament_to_order_of_merit( oom_id: str, body: OrderOfMeritLinkCreate, organization_id: str = Depends(get_authorized_org), ) -> OrderOfMeritLinkOut: async with org_connection(organization_id) as conn, translate_db_errors(): oom = await conn.fetchrow("SELECT result_type, aggregation_mode FROM order_of_merit WHERE id = $1", oom_id) if oom is None: raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") tournament = await conn.fetchrow( "SELECT format_type FROM tournament WHERE id = $1", body.tournament_id ) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if tournament["format_type"] != "individual": raise app_error( 400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit." ) if oom["aggregation_mode"] == "eclectic": await _validate_same_course_for_eclectic(conn, oom_id, body.tournament_id) result_type = oom["result_type"] if result_type == "points" and not body.points_table: raise app_error(400, "VALIDATION_FAILED", "points_table er påkrevd for en poeng-basert Order of Merit.") if result_type != "points" and body.points_table: raise app_error(400, "VALIDATION_FAILED", "points_table er kun gyldig for en poeng-basert Order of Merit.") if result_type == "money" and not (body.money_pool_total and body.money_payout_table): raise app_error( 400, "VALIDATION_FAILED", "money_pool_total og money_payout_table er påkrevd for en pengeliste-basert Order of Merit." ) if result_type != "money" and (body.money_pool_total or body.money_payout_table): raise app_error(400, "VALIDATION_FAILED", "Pengefelt er kun gyldig for en pengeliste-basert Order of Merit.") row = await conn.fetchrow( """ WITH inserted AS ( INSERT INTO order_of_merit_link (organization_id, order_of_merit_id, tournament_id, points_table, money_pool_total, money_payout_table) VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb) RETURNING id, tournament_id, points_table, money_pool_total, money_payout_table ) SELECT inserted.id::text, inserted.tournament_id::text, t.name AS tournament_name, inserted.points_table::text AS points_table, inserted.money_pool_total, inserted.money_payout_table::text AS money_payout_table FROM inserted JOIN tournament t ON t.id = inserted.tournament_id """, organization_id, oom_id, body.tournament_id, json.dumps(body.points_table) if body.points_table else None, body.money_pool_total, json.dumps(body.money_payout_table) if body.money_payout_table else None, ) return OrderOfMeritLinkOut(**_parse_link_row(row)) @router.delete( "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments/{link_id}", status_code=204, ) async def unlink_tournament_from_order_of_merit( oom_id: str, link_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: result = await conn.execute( "DELETE FROM order_of_merit_link WHERE id = $1 AND order_of_merit_id = $2", link_id, oom_id ) if result == "DELETE 0": raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.") # --------------------------------------------------------------------------- # Lag-OOM (ADR-043 Beslutning C, bygget ADR-074): sesong-par satt opp # DIREKTE i OOM-en (IKKE strengmatching mot turnering-lag, ADR-011-lag er # strukturelt urelatert -- se migrasjon 055 sin kommentar). Kun gyldig for # `kind='team'`. Selve leaderboard-beregningen (summerer/velger-beste-N-av # medlemmenes allerede beregnede individuelle OOM-resultater) står lenger # ned, sammen med spiller-leaderboardet den gjenbruker. # --------------------------------------------------------------------------- class OrderOfMeritTeamCreate(BaseModel): name: str = Field(min_length=1, max_length=200) class OrderOfMeritTeamMemberOut(BaseModel): id: str player_id: str player_name: str class OrderOfMeritTeamOut(BaseModel): id: str name: str members: list[OrderOfMeritTeamMemberOut] class OrderOfMeritTeamMemberCreate(BaseModel): player_id: str async def _require_team_kind(conn, oom_id: str) -> None: kind = await conn.fetchval("SELECT kind FROM order_of_merit WHERE id = $1", oom_id) if kind is None: raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") if kind != "team": raise app_error(400, "VALIDATION_FAILED", "Lag kan kun settes opp for en lag-OOM.") @router.get( "/orgs/{organization_id}/order-of-merits/{oom_id}/teams", response_model=list[OrderOfMeritTeamOut], ) async def list_order_of_merit_teams( oom_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[OrderOfMeritTeamOut]: async with org_connection(organization_id) as conn: team_rows = await conn.fetch( "SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1 ORDER BY created_at", oom_id, ) member_rows = await conn.fetch( """ SELECT m.id::text, m.order_of_merit_team_id::text AS team_id, m.player_id::text AS player_id, p.display_name AS player_name FROM order_of_merit_team_member m JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id JOIN player p ON p.id = m.player_id WHERE t.order_of_merit_id = $1 ORDER BY p.display_name """, oom_id, ) members_by_team: dict[str, list[OrderOfMeritTeamMemberOut]] = {} for m in member_rows: members_by_team.setdefault(m["team_id"], []).append( OrderOfMeritTeamMemberOut(id=m["id"], player_id=m["player_id"], player_name=m["player_name"]) ) return [ OrderOfMeritTeamOut(id=t["id"], name=t["name"], members=members_by_team.get(t["id"], [])) for t in team_rows ] @router.post( "/orgs/{organization_id}/order-of-merits/{oom_id}/teams", response_model=OrderOfMeritTeamOut, status_code=201, ) async def create_order_of_merit_team( oom_id: str, body: OrderOfMeritTeamCreate, organization_id: str = Depends(get_authorized_org), ) -> OrderOfMeritTeamOut: async with org_connection(organization_id) as conn, translate_db_errors(): await _require_team_kind(conn, oom_id) row = await conn.fetchrow( """ INSERT INTO order_of_merit_team (organization_id, order_of_merit_id, name) VALUES ($1, $2, $3) RETURNING id::text, name """, organization_id, oom_id, body.name, ) return OrderOfMeritTeamOut(id=row["id"], name=row["name"], members=[]) @router.delete( "/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}", status_code=204, ) async def delete_order_of_merit_team( oom_id: str, team_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: result = await conn.execute( "DELETE FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id ) if result == "DELETE 0": raise app_error(404, "NOT_FOUND", "Laget finnes ikke.") @router.post( "/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members", response_model=OrderOfMeritTeamMemberOut, status_code=201, ) async def add_order_of_merit_team_member( oom_id: str, team_id: str, body: OrderOfMeritTeamMemberCreate, organization_id: str = Depends(get_authorized_org), ) -> OrderOfMeritTeamMemberOut: async with org_connection(organization_id) as conn, translate_db_errors(): team = await conn.fetchval( "SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id ) if team is None: raise app_error(404, "NOT_FOUND", "Laget finnes ikke.") # player.id er IKKE org-scopet i sin FK (migrasjon 055) -- egen # eksplisitt sjekk her, samme forsiktighet som andre steder i # appen som tar imot en player_id fra klienten. player = await conn.fetchrow("SELECT display_name FROM player WHERE id = $1", body.player_id) if player is None: raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.") row = await conn.fetchrow( """ INSERT INTO order_of_merit_team_member (organization_id, order_of_merit_team_id, player_id) VALUES ($1, $2, $3) RETURNING id::text """, organization_id, team_id, body.player_id, ) return OrderOfMeritTeamMemberOut(id=row["id"], player_id=body.player_id, player_name=player["display_name"]) @router.delete( "/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members/{member_id}", status_code=204, ) async def remove_order_of_merit_team_member( oom_id: str, team_id: str, member_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: team = await conn.fetchval( "SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id ) if team is None: raise app_error(404, "NOT_FOUND", "Laget finnes ikke.") result = await conn.execute( "DELETE FROM order_of_merit_team_member WHERE id = $1 AND order_of_merit_team_id = $2", member_id, team_id, ) if result == "DELETE 0": raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.") # --------------------------------------------------------------------------- # Eclectic-aggregering på tvers av lenkede turneringer (ADR-075) -- sesong- # "drømmerunde": beste resultat PER HULL på tvers av ALLE en spillers # lenkede turneringer (bekreftet samme bane av _validate_same_course_for_ # eclectic ved lenking/modus-endring). IKKE det samme som ADR-068 sitt # Eclectic-format (scoping til RUNDENE innenfor ÉN turnering) -- samme # motorfunksjon (`eclectic_best_per_hole`) gjenbrukes UENDRET, men # datainnsamlingen her spenner over FLERE turneringers `tournament_round_ # hole`, ikke bare én. Nøkkelen er `player_id` (samme spiller har ulik # `tournament_participant_id` per turnering), ikke `tournament_ # participant_id` slik innad-i-én-turnering-varianten (`_attach_eclectic_ # totals` i individual_tournaments.py) bruker. # --------------------------------------------------------------------------- async def _compute_eclectic_oom_values( conn, tournament_ids: list[str], result_type: Literal["gross", "net", "stableford"] ) -> tuple[dict[str, float], dict[str, str], dict[str, int]]: """Returnerer (totaler, navn, antall-kilde-turneringer) per spiller. Eclectic-aggregering ER selve tvers-av-turneringer-sammenslåingen -- det finnes ingen ekstra "sum/snitt av N eclectic-totaler" ovenpå (`order_of_merit_aggregate` er derfor bevisst IKKE brukt her, se kalleren).""" if not tournament_ids: return {}, {}, {} course_id = None for tid in tournament_ids: course_id = await _course_id_for_tournament(conn, tid) if course_id is not None: break if course_id is None: return {}, {}, {} hole_rows = await conn.fetch( "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id ) par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows} si_by_hole = [r["stroke_index"] for r in hole_rows] rows = await conn.fetch( """ SELECT tr.tournament_id::text AS tournament_id, tr.sequence, tp.player_id::text AS player_id, p.display_name AS player_name, trp.course_handicap, trh.hole_number, trh.gross_strokes FROM tournament_round_hole trh JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id JOIN tournament_round tr ON tr.id = trp.tournament_round_id JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id WHERE tr.tournament_id = ANY($1::uuid[]) AND trh.gross_strokes IS NOT NULL ORDER BY tr.tournament_id, tr.sequence """, tournament_ids, ) # Sammensatt kilde-indeks: (turnering-id, sekvens) -> global indeks, # siden `sequence` kun er unik INNENFOR én turnering (speiler # _attach_eclectic_totals sin sequence_by_index, men på tvers av flere # turneringer i stedet for kun én). index_by_source: dict[tuple[str, int], int] = {} source_by_index: dict[int, tuple[str, int]] = {} for r in rows: key = (r["tournament_id"], r["sequence"]) if key not in index_by_source: idx = len(index_by_source) index_by_source[key] = idx source_by_index[idx] = key allocation_cache: dict[int, list[int]] = {} values_by_player: dict[str, list[list[EclecticHoleValue]]] = {} names_by_player: dict[str, str] = {} for r in rows: if r["course_handicap"] is None: continue pid = r["player_id"] names_by_player[pid] = r["player_name"] if pid not in values_by_player: values_by_player[pid] = [[] for _ in range(18)] strokes_received = 0 if result_type != "gross": hcp = r["course_handicap"] if hcp not in allocation_cache: allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole) strokes_received = allocation_cache[hcp][r["hole_number"] - 1] if result_type == "gross": value = r["gross_strokes"] elif result_type == "net": value = r["gross_strokes"] - strokes_received else: value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received) source_index = index_by_source[(r["tournament_id"], r["sequence"])] values_by_player[pid][r["hole_number"] - 1].append( EclecticHoleValue(round_index=source_index, value=value) ) totals_by_player: dict[str, float] = {} tournament_count_by_player: dict[str, int] = {} for pid, values_by_hole in values_by_player.items(): result = eclectic_best_per_hole(values_by_hole, result_type) if not result.holes: continue totals_by_player[pid] = float(result.total) tournament_count_by_player[pid] = len({source_by_index[h.round_index][0] for h in result.holes}) return totals_by_player, names_by_player, tournament_count_by_player # --------------------------------------------------------------------------- # Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi # som Nassau/High-low-high/individual_leaderboard selv). # --------------------------------------------------------------------------- class OrderOfMeritLeaderboardEntry(BaseModel): player_id: str player_name: str results_counted: int results_available: int value: float | None position: str | None eligible: bool ineligible_reason: str | None = None def _money_for_position(position_label: str, payout_table: list[dict]) -> float: """Samme delt-plassering-regel som order_of_merit_points_for_position (uavgjorte deler samme utbetaling ved sin felles plassering) -- egen liten oppslagsfunksjon her (ikke i handicap_engine.py) siden dette er et rent JSON-konfig-oppslag, ikke golf-domenematematikk om uavgjort.""" position = int(position_label.lstrip("T")) for entry in payout_table: if entry.get("position") == position: return float(entry.get("amount") or 0) return 0.0 def _player_eligibility(results_available: int, oom, birth_year: int | None) -> tuple[bool, str | None]: """Delt mellom spiller- og lag-leaderboardet (et lags medlem er eligibility-sjekket EKSAKT som en spiller ville vært alene), OG mellom sum/snitt (`results_available = len(raw_values)`) og eclectic (`results_available` = antall KILDE-turneringer som faktisk bidro et hull til sesong-drømmerunden, se _compute_eclectic_oom_values) -- tallet betyr det samme ("hvor mange turneringer talte") uansett hvilken modus som produserte det.""" if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None: if birth_year is None: return False, "Ingen registrert fødselsdato." if oom["birth_year_from"] is not None and birth_year < oom["birth_year_from"]: return False, "Utenfor aldersgrensen." if oom["birth_year_to"] is not None and birth_year > oom["birth_year_to"]: return False, "Utenfor aldersgrensen." if oom["min_results_required"] is not None and results_available < oom["min_results_required"]: return False, f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}." return True, None def _aggregate_value(raw_values: list[float], oom, lower_is_better: bool) -> float | None: """Negerer FØR order_of_merit_aggregate (som alltid regner «størst er best») og negerer tilbake etterpå for brutto/netto -- delt av spiller- OG lag-nivået. Trygt å kalle EN GANG TIL på lag-nivå med allerede korrekt-signerte medlemsverdier: samme frem-og-tilbake-negering gir riktig resultat begge ganger (ADR-074 Beslutning: ett lagret aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig).""" values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values] aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"]) return -aggregated if lower_is_better and aggregated is not None else aggregated @router.get( "/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard", response_model=list[OrderOfMeritLeaderboardEntry], ) async def order_of_merit_leaderboard( oom_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[OrderOfMeritLeaderboardEntry]: async with org_connection(organization_id) as conn: oom = await conn.fetchrow( """ SELECT result_type, aggregation_mode, count_best_n, min_results_required, birth_year_from, birth_year_to, kind FROM order_of_merit WHERE id = $1 """, oom_id, ) if oom is None: raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") result_type = oom["result_type"] lower_is_better = result_type in ("gross", "net") is_eclectic = oom["aggregation_mode"] == "eclectic" links = await conn.fetch( """ SELECT tournament_id::text, points_table::text AS points_table, money_payout_table::text AS money_payout_table FROM order_of_merit_link WHERE order_of_merit_id = $1 """, oom_id, ) # player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i] # (eclectic-modus: nøyaktig ÉN verdi per spiller -- sesongens # ferdig-sammenslåtte drømmerunde-total, se _compute_eclectic_oom_ # values. "results_available" betyr da antall KILDE-turneringer # som bidro et hull, ikke antall verdier i denne listen.) values_by_player: dict[str, list[float]] = {} names_by_player: dict[str, str] = {} results_available_by_player: dict[str, int] = {} if is_eclectic: # ADR-075: krever result_type IN (stableford,gross,net) og # kind='player', begge håndhevet ved opprettelse/PATCH -- # ingen ekstra sjekk nødvendig her. tournament_ids = [link["tournament_id"] for link in links] totals, names_by_player, tournament_counts = await _compute_eclectic_oom_values( conn, tournament_ids, result_type ) for player_id, total in totals.items(): values_by_player[player_id] = [total] results_available_by_player[player_id] = tournament_counts[player_id] else: for link in links: tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"]) points_table = _decode_json(link["points_table"]) money_payout_table = _decode_json(link["money_payout_table"]) for e in entries: contribution = _contribution_for_entry( e, result_type, points_table, money_payout_table, tournament_row["scoring_method"] ) if contribution is None: continue values_by_player.setdefault(e.player_id, []).append(contribution) names_by_player[e.player_id] = e.player_name for player_id, raw_values in values_by_player.items(): results_available_by_player[player_id] = len(raw_values) # Aldersgrense: filtrert FØR aggregering (spillere utenfor # fødselsårs-intervallet er ikke rangert i det hele tatt, ikke # bare gitt 0 poeng -- de skal ikke telle med som "sist"). eligible_player_ids = set(values_by_player.keys()) birth_years: dict[str, int | None] = {} if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None: if eligible_player_ids: rows = await conn.fetch( "SELECT id::text, EXTRACT(YEAR FROM birth_date)::int AS birth_year FROM player WHERE id = ANY($1::uuid[])", list(eligible_player_ids), ) birth_years = {r["id"]: r["birth_year"] for r in rows} results: list[OrderOfMeritLeaderboardEntry] = [] if oom["kind"] == "player": for player_id, raw_values in values_by_player.items(): results_available = results_available_by_player[player_id] eligible, ineligible_reason = _player_eligibility(results_available, oom, birth_years.get(player_id)) # Eclectic: raw_values er allerede ÉN ferdig-sammenslått # sesongtotal (se _compute_eclectic_oom_values) -- ingen # order_of_merit_aggregate-kall (som uansett kun kjenner # sum/snitt, ikke eclectic, se motoren), og count_best_n # gir ingen mening ("beste N av ÉN verdi"). if is_eclectic: value = raw_values[0] counted = results_available else: value = _aggregate_value(raw_values, oom, lower_is_better) counted = ( min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available ) results.append( OrderOfMeritLeaderboardEntry( player_id=player_id, player_name=names_by_player[player_id], results_counted=counted, results_available=results_available, value=value, position=None, eligible=eligible, ineligible_reason=ineligible_reason, ) ) else: # Lag-OOM (ADR-043 Beslutning C, ADR-074): summerer/velger- # beste-N-av medlemmenes allerede beregnede individuelle OOM- # resultater -- IKKE strengmatching mot turnering-lag. # `player_id`/`player_name` på OrderOfMeritLeaderboardEntry # bærer her lagets EGEN id/navn (samme uttrykk, uendret # skjema -- kalleren vet allerede OOM-ens `kind`). team_rows = await conn.fetch( "SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1", oom_id ) member_rows = await conn.fetch( """ SELECT m.order_of_merit_team_id::text AS team_id, m.player_id::text AS player_id FROM order_of_merit_team_member m JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id WHERE t.order_of_merit_id = $1 """, oom_id, ) member_ids_by_team: dict[str, list[str]] = {} for m in member_rows: member_ids_by_team.setdefault(m["team_id"], []).append(m["player_id"]) for team in team_rows: member_ids = member_ids_by_team.get(team["id"], []) member_values: list[float] = [] for pid in member_ids: raw_values = values_by_player.get(pid) if not raw_values: continue member_eligible, _ = _player_eligibility( results_available_by_player[pid], oom, birth_years.get(pid) ) if not member_eligible: continue member_value = _aggregate_value(raw_values, oom, lower_is_better) if member_value is not None: member_values.append(member_value) eligible_members = len(member_values) team_eligible = eligible_members > 0 team_reason = None if team_eligible else "Ingen av lagets medlemmer har tellende resultater." if ( team_eligible and oom["min_results_required"] is not None and eligible_members < oom["min_results_required"] ): team_eligible = False team_reason = ( f"Trenger minst {oom['min_results_required']} medlemmer med tellende resultater, " f"har {eligible_members}." ) value = _aggregate_value(member_values, oom, lower_is_better) if team_eligible else None counted = ( min(oom["count_best_n"], eligible_members) if oom["count_best_n"] is not None else eligible_members ) results.append( OrderOfMeritLeaderboardEntry( player_id=team["id"], player_name=team["name"], results_counted=counted, results_available=len(member_ids), value=value, position=None, eligible=team_eligible, ineligible_reason=team_reason, ) ) # Rangering: kun blant de kvalifiserte, høyest verdi øverst for # poeng/stableford/pengeliste, LAVEST for brutto/netto -- men # `value` er allerede tilbake-negert til ekte visningsverdi over, # så sorter på `value` med riktig retning per resultattype. eligible_results = [r for r in results if r.eligible and r.value is not None] ineligible_results = [r for r in results if not (r.eligible and r.value is not None)] eligible_results.sort(key=lambda r: r.value, reverse=not lower_is_better) i, n = 0, len(eligible_results) while i < n: j = i while j < n and eligible_results[j].value == eligible_results[i].value: j += 1 rank = i + 1 tied = (j - i) > 1 for k in range(i, j): eligible_results[k].position = f"T{rank}" if tied else str(rank) i = j return eligible_results + ineligible_results def _decode_json(value): return json.loads(value) if value else None _ECLECTIC_METHODS = ("eclectic_gross", "eclectic_net", "eclectic_stableford") def _contribution_for_entry( entry, result_type: str, points_table, money_payout_table, scoring_method: str | None = None ) -> float | None: """Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen, allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke denne turneringen" (skal ikke telle som et 0-resultat, kalleren hopper over den). `entry.position` (posisjon i DENNE turneringen) er KUN populert for scoring_method i (stroke_gross, stroke_net, stableford) -- se `_attach_stroke_play_columns` i individual_tournaments.py. Er OOM-en poeng- eller pengeliste-basert og den lenkede turneringen bruker en annen scoring_method (København/BBB/Flagg), er det bevisst ingen posisjon å hente poeng/penger fra -- spilleren telles fortsatt som "deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt. Bug fikset 2026-08-15 (ADR-074): for en lenket turnering med scoring_method IN eclectic_gross/net/stableford, skal stableford/gross/net-resultattype-OOM-er lese `entry.eclectic_total` (turneringens FAKTISKE "drømmerunde"-resultat, det som avgjorde DEN turneringens egen vinner) -- IKKE `entry.stableford_total`/ `gross_total`/`net_total` (rå sum på tvers av rundene, et tall som aldri var det som ble kåret som resultat i den turneringen).""" if entry.rounds_played == 0: return None if result_type == "points": return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0 if result_type == "money": return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0 if scoring_method in _ECLECTIC_METHODS and result_type in ("stableford", "gross", "net"): return float(entry.eclectic_total) if entry.eclectic_total is not None else 0.0 if result_type == "stableford": return float(entry.stableford_total) if entry.stableford_total is not None else 0.0 if result_type == "gross": return float(entry.gross_total) if entry.gross_total is not None else 0.0 if result_type == "net": return float(entry.net_total) if entry.net_total is not None else 0.0 return 0.0