diff --git a/src/worker/politics.rs b/src/worker/politics.rs index 098b7c4..44d7d2e 100644 --- a/src/worker/politics.rs +++ b/src/worker/politics.rs @@ -25,6 +25,13 @@ struct Election { posts_to_fill: i32, } +#[derive(Debug, Clone)] +struct OfficeGap { + office_type_id: i32, + region_id: i32, + gaps: i32, +} + #[derive(Debug, Clone)] struct Office { office_id: i32, @@ -72,6 +79,42 @@ const QUERY_COUNT_OFFICES_PER_REGION: &str = r#" GROUP BY region_id; "#; +/// Findet alle Kombinationen aus Amtstyp und Region, für die laut +/// `seats_per_region` mehr Sitze existieren sollten, als aktuell in +/// `falukant_data.political_office` belegt sind. Es werden ausschließlich +/// positive Differenzen (Gaps) zurückgegeben – wenn `occupied > required` +/// (z. B. nach Reduktion der Sitzzahl), wird **nichts** gelöscht und die +/// Kombination erscheint hier nicht. +const QUERY_FIND_OFFICE_GAPS: &str = r#" + WITH + seats AS ( + SELECT + pot.id AS office_type_id, + rt.id AS region_id, + pot.seats_per_region AS seats_total + FROM falukant_type.political_office_type AS pot + JOIN falukant_type.region AS rt + ON pot.region_type = rt.label_tr + ), + occupied AS ( + SELECT + po.office_type_id, + po.region_id, + COUNT(*) AS occupied_count + FROM falukant_data.political_office AS po + GROUP BY po.office_type_id, po.region_id + ) + SELECT + s.office_type_id, + s.region_id, + (s.seats_total - COALESCE(o.occupied_count, 0)) AS gaps + FROM seats AS s + LEFT JOIN occupied AS o + ON s.office_type_id = o.office_type_id + AND s.region_id = o.region_id + WHERE (s.seats_total - COALESCE(o.occupied_count, 0)) > 0; +"#; + const QUERY_SELECT_NEEDED_ELECTIONS: &str = r#" WITH target_date AS ( @@ -397,6 +440,48 @@ const QUERY_PROCESS_ELECTIONS: &str = r#" FROM falukant_data.process_elections(); "#; +/// Schneidet für alle Amtstyp/Region-Kombinationen überzählige Einträge in +/// `falukant_data.political_office` ab, so dass höchstens +/// `seats_per_region` Ämter pro Kombination übrig bleiben. +/// +/// Die Auswahl, welche Ämter entfernt werden, erfolgt deterministisch über +/// `created_at DESC`: die **neuesten** Ämter bleiben bevorzugt im Amt, +/// ältere Einträge werden zuerst entfernt. Damit lässt sich das Verhalten +/// später leicht anpassen (z. B. nach bestimmten Prioritäten). +const QUERY_TRIM_EXCESS_OFFICES_GLOBAL: &str = r#" + WITH seats AS ( + SELECT + pot.id AS office_type_id, + rt.id AS region_id, + pot.seats_per_region AS seats_total + FROM falukant_type.political_office_type AS pot + JOIN falukant_type.region AS rt + ON pot.region_type = rt.label_tr + ), + ranked AS ( + SELECT + po.id, + po.office_type_id, + po.region_id, + s.seats_total, + ROW_NUMBER() OVER ( + PARTITION BY po.office_type_id, po.region_id + ORDER BY po.created_at DESC + ) AS rn + FROM falukant_data.political_office AS po + JOIN seats AS s + ON s.office_type_id = po.office_type_id + AND s.region_id = po.region_id + ), + to_delete AS ( + SELECT id + FROM ranked + WHERE rn > seats_total + ) + DELETE FROM falukant_data.political_office + WHERE id IN (SELECT id FROM to_delete); +"#; + impl PoliticsWorker { pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self { Self { @@ -438,16 +523,20 @@ impl PoliticsWorker { // 1) Optional: Positionen evaluieren (aktuell nur Logging/Struktur) let _ = Self::evaluate_political_positions(pool)?; - // 2) Ämter, die bald auslaufen, benachrichtigen + // 2) Schema-Änderungen abgleichen: neue / zusätzliche Ämter anlegen, + // ohne bestehende Amtsinhaber bei Reduktion zu entfernen. + let _ = Self::sync_offices_with_types(pool)?; + + // 3) Ämter, die bald auslaufen, benachrichtigen Self::notify_office_expirations(pool, broker)?; - // 3) Abgelaufene Ämter verarbeiten und neue besetzen + // 4) Abgelaufene Ämter verarbeiten und neue besetzen let new_offices_direct = Self::process_expired_offices_and_fill(pool)?; if !new_offices_direct.is_empty() { Self::notify_office_filled(pool, broker, &new_offices_direct)?; } - // 4) Neue Wahlen planen und Kandidaten eintragen + // 5) Neue Wahlen planen und Kandidaten eintragen let elections = Self::schedule_elections(pool)?; if !elections.is_empty() { Self::insert_candidates_for_elections(pool, &elections)?; @@ -460,12 +549,18 @@ impl PoliticsWorker { Self::notify_election_created(pool, broker, &user_ids)?; } - // 5) Wahlen auswerten und neu besetzte Ämter melden + // 6) Wahlen auswerten und neu besetzte Ämter melden let new_offices_from_elections = Self::process_elections(pool)?; if !new_offices_from_elections.is_empty() { Self::notify_office_filled(pool, broker, &new_offices_from_elections)?; } + // 7) Als letzter Schritt sicherstellen, dass es für keinen + // Amtstyp/Region-Kombi mehr besetzte Ämter gibt als laut + // `seats_per_region` erlaubt. Dieser Abgleich wird nach allen + // Lösch- und Besetzungsvorgängen ausgeführt. + Self::trim_excess_offices_global(pool)?; + Ok(()) } @@ -506,6 +601,121 @@ impl PoliticsWorker { Ok(result) } + /// Entfernt global alle überzähligen Ämter in Relation zur + /// konfigurierten Sitzzahl pro Amtstyp/Region. + fn trim_excess_offices_global(pool: &ConnectionPool) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare( + "trim_excess_offices_global", + QUERY_TRIM_EXCESS_OFFICES_GLOBAL, + )?; + + if let Err(err) = conn.execute("trim_excess_offices_global", &[]) { + eprintln!( + "[PoliticsWorker] Fehler bei trim_excess_offices_global: {err}" + ); + } + + Ok(()) + } + + /// Gleicht die konfigurierte Anzahl der Ämter (seats_per_region pro + /// Amtstyp/Region) mit den tatsächlich vorhandenen Einträgen in + /// `falukant_data.political_office` ab und legt für fehlende Sitze + /// zusätzliche Wahlen an. + /// + /// Wichtig: Wenn `seats_per_region` gesenkt wurde und damit aktuell + /// mehr Amtsinhaber existieren als Sitze vorgesehen sind, werden + /// **keine** Amtsinhaber entfernt oder Wahlen zum Abbau erzeugt – die + /// entsprechenden Kombinationen tauchen schlicht nicht in der Gaps‑Liste + /// auf. Erst wenn durch natürliches Auslaufen weniger Ämter existieren + /// als vorgesehen, entstehen wieder Gaps. + fn sync_offices_with_types(pool: &ConnectionPool) -> Result<(), DbError> { + let mut conn = pool + .get() + .map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?; + + conn.prepare("find_office_gaps", QUERY_FIND_OFFICE_GAPS)?; + let rows = conn.execute("find_office_gaps", &[])?; + + if rows.is_empty() { + return Ok(()); + } + + let mut gaps: Vec = Vec::with_capacity(rows.len()); + for row in rows { + let office_type_id = parse_i32(&row, "office_type_id", -1); + let region_id = parse_i32(&row, "region_id", -1); + let gaps_count = parse_i32(&row, "gaps", 0); + if office_type_id >= 0 && region_id >= 0 && gaps_count > 0 { + gaps.push(OfficeGap { + office_type_id, + region_id, + gaps: gaps_count, + }); + } + } + + if gaps.is_empty() { + return Ok(()); + } + + // Für jede Lücke eine Wahl am aktuellen Tag anlegen, sofern es nicht + // bereits eine Wahl für diesen Amtstyp/Region an einem heutigen oder + // zukünftigen Datum gibt. Das eigentliche Anlegen der Wahl und das + // Eintragen von Kandidaten läuft weiterhin über die bestehende + // Logik in der Datenbank (process_elections / schedule_elections). + // + // Hier nutzen wir bewusst eine einfache, wiederholbare Logik im + // Rust‑Code, statt alles in eine riesige DB‑Funktion zu packen. + + // Wir lehnen uns an die Struktur von `schedule_elections` an, + // erzeugen aber unsere eigenen Einträge in `falukant_data.election`. + let insert_sql = r#" + INSERT INTO falukant_data.election + (office_type_id, date, posts_to_fill, created_at, updated_at, region_id) + SELECT + $1::int AS office_type_id, + CURRENT_DATE AS date, + $2::int AS posts_to_fill, + NOW() AS created_at, + NOW() AS updated_at, + $3::int AS region_id + WHERE NOT EXISTS ( + SELECT 1 + FROM falukant_data.election e + WHERE e.office_type_id = $1::int + AND e.region_id = $3::int + AND e.date::date >= CURRENT_DATE + ); + "#; + + conn.prepare("insert_gap_election", insert_sql)?; + + for gap in gaps { + // Sicherheitshalber nur positive Gaps berücksichtigen. + if gap.gaps <= 0 { + continue; + } + + if let Err(err) = conn.execute( + "insert_gap_election", + &[&gap.office_type_id, &gap.gaps, &gap.region_id], + ) { + eprintln!( + "[PoliticsWorker] Fehler beim Anlegen von Gap‑Wahlen \ + (office_type_id={}, region_id={}): {err}", + gap.office_type_id, gap.region_id + ); + } + } + + Ok(()) + } + fn schedule_elections(pool: &ConnectionPool) -> Result, DbError> { let mut conn = pool .get() diff --git a/src/worker/user_character.rs b/src/worker/user_character.rs index 6b970ea..87aa8b0 100644 --- a/src/worker/user_character.rs +++ b/src/worker/user_character.rs @@ -378,9 +378,56 @@ const QUERY_DELETE_DEBTORS_PRISM: &str = r#" WHERE character_id = $1; "#; +/// Löscht alle Ämter eines Charakters und stellt anschließend sicher, dass +/// für die betroffenen Amtstyp/Region-Kombinationen nicht mehr Ämter +/// besetzt sind als durch `seats_per_region` vorgesehen. +/// +/// Die überzähligen Ämter werden deterministisch nach `created_at DESC` +/// gekappt, d. h. neuere Amtsinhaber bleiben bevorzugt im Amt. const QUERY_DELETE_POLITICAL_OFFICE: &str = r#" + WITH removed AS ( + DELETE FROM falukant_data.political_office + WHERE character_id = $1 + RETURNING office_type_id, region_id + ), + affected AS ( + SELECT DISTINCT office_type_id, region_id + FROM removed + ), + seats AS ( + SELECT + pot.id AS office_type_id, + rt.id AS region_id, + pot.seats_per_region AS seats_total + FROM falukant_type.political_office_type AS pot + JOIN falukant_type.region AS rt + ON pot.region_type = rt.label_tr + JOIN affected AS a + ON a.office_type_id = pot.id + AND a.region_id = rt.id + ), + ranked AS ( + SELECT + po.id, + po.office_type_id, + po.region_id, + s.seats_total, + ROW_NUMBER() OVER ( + PARTITION BY po.office_type_id, po.region_id + ORDER BY po.created_at DESC + ) AS rn + FROM falukant_data.political_office AS po + JOIN seats AS s + ON s.office_type_id = po.office_type_id + AND s.region_id = po.region_id + ), + to_delete AS ( + SELECT id + FROM ranked + WHERE rn > seats_total + ) DELETE FROM falukant_data.political_office - WHERE character_id = $1; + WHERE id IN (SELECT id FROM to_delete); "#; const QUERY_DELETE_ELECTION_CANDIDATE: &str = r#"