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:
@@ -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<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
|
||||
// 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<Vec<Election>, DbError> {
|
||||
let mut conn = pool
|
||||
.get()
|
||||
|
||||
@@ -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#"
|
||||
|
||||
Reference in New Issue
Block a user