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,
|
posts_to_fill: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct OfficeGap {
|
||||||
|
office_type_id: i32,
|
||||||
|
region_id: i32,
|
||||||
|
gaps: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct Office {
|
struct Office {
|
||||||
office_id: i32,
|
office_id: i32,
|
||||||
@@ -72,6 +79,42 @@ const QUERY_COUNT_OFFICES_PER_REGION: &str = r#"
|
|||||||
GROUP BY region_id;
|
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#"
|
const QUERY_SELECT_NEEDED_ELECTIONS: &str = r#"
|
||||||
WITH
|
WITH
|
||||||
target_date AS (
|
target_date AS (
|
||||||
@@ -397,6 +440,48 @@ const QUERY_PROCESS_ELECTIONS: &str = r#"
|
|||||||
FROM falukant_data.process_elections();
|
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 {
|
impl PoliticsWorker {
|
||||||
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -438,16 +523,20 @@ impl PoliticsWorker {
|
|||||||
// 1) Optional: Positionen evaluieren (aktuell nur Logging/Struktur)
|
// 1) Optional: Positionen evaluieren (aktuell nur Logging/Struktur)
|
||||||
let _ = Self::evaluate_political_positions(pool)?;
|
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)?;
|
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)?;
|
let new_offices_direct = Self::process_expired_offices_and_fill(pool)?;
|
||||||
if !new_offices_direct.is_empty() {
|
if !new_offices_direct.is_empty() {
|
||||||
Self::notify_office_filled(pool, broker, &new_offices_direct)?;
|
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)?;
|
let elections = Self::schedule_elections(pool)?;
|
||||||
if !elections.is_empty() {
|
if !elections.is_empty() {
|
||||||
Self::insert_candidates_for_elections(pool, &elections)?;
|
Self::insert_candidates_for_elections(pool, &elections)?;
|
||||||
@@ -460,12 +549,18 @@ impl PoliticsWorker {
|
|||||||
Self::notify_election_created(pool, broker, &user_ids)?;
|
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)?;
|
let new_offices_from_elections = Self::process_elections(pool)?;
|
||||||
if !new_offices_from_elections.is_empty() {
|
if !new_offices_from_elections.is_empty() {
|
||||||
Self::notify_office_filled(pool, broker, &new_offices_from_elections)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +601,121 @@ impl PoliticsWorker {
|
|||||||
Ok(result)
|
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> {
|
fn schedule_elections(pool: &ConnectionPool) -> Result<Vec<Election>, DbError> {
|
||||||
let mut conn = pool
|
let mut conn = pool
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -378,9 +378,56 @@ const QUERY_DELETE_DEBTORS_PRISM: &str = r#"
|
|||||||
WHERE character_id = $1;
|
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#"
|
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
|
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#"
|
const QUERY_DELETE_ELECTION_CANDIDATE: &str = r#"
|
||||||
|
|||||||
Reference in New Issue
Block a user