Add office gap management: Introduced new queries and logic in PoliticsWorker to identify and create elections for office type/region combinations with insufficient seats. Implemented trimming of excess offices to ensure compliance with configured seat limits. Updated UserCharacterWorker to delete political offices and maintain seat integrity post-deletion.

This commit is contained in:
Torsten Schulz (local)
2025-11-24 09:25:30 +01:00
parent 0968ab6b0b
commit b11148b499
2 changed files with 262 additions and 5 deletions

View File

@@ -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 GapsListe
/// 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<OfficeGap> = 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
// RustCode, statt alles in eine riesige DBFunktion 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 GapWahlen \
(office_type_id={}, region_id={}): {err}",
gap.office_type_id, gap.region_id
);
}
}
Ok(())
}
fn schedule_elections(pool: &ConnectionPool) -> Result<Vec<Election>, DbError> {
let mut conn = pool
.get()

View File

@@ -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;
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 id IN (SELECT id FROM to_delete);
"#;
const QUERY_DELETE_ELECTION_CANDIDATE: &str = r#"