Add transport planning functionality in DirectorWorker: Introduced logic for planning transports for inventory items, including new SQL queries for fetching regional worth and available transport vehicles. Enhanced inventory management by updating quantities based on transport outcomes and improved logging for transport planning decisions.

This commit is contained in:
Torsten Schulz (local)
2025-11-26 16:21:11 +01:00
parent 8ee0bbf3cd
commit 407cdd9bdc

View File

@@ -1,4 +1,5 @@
use crate::db::{DbConnection, DbError, Row}; use crate::db::{DbConnection, DbError, Row};
use std::collections::HashMap;
use crate::message_broker::MessageBroker; use crate::message_broker::MessageBroker;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
@@ -46,6 +47,12 @@ struct SalaryItem {
income: i32, income: i32,
} }
#[derive(Debug, Clone)]
struct TransportVehicle {
id: i32,
capacity: i32,
}
pub struct DirectorWorker { pub struct DirectorWorker {
base: BaseWorker, base: BaseWorker,
last_run: Option<Instant>, last_run: Option<Instant>,
@@ -171,6 +178,46 @@ const QUERY_ADD_SELL_LOG: &str = r#"
SET quantity = falukant_log.sell.quantity + EXCLUDED.quantity; SET quantity = falukant_log.sell.quantity + EXCLUDED.quantity;
"#; "#;
// Regionale Verkaufswürdigkeit pro Produkt/Region für alle Branches eines Users
const QUERY_GET_REGION_WORTH_FOR_PRODUCT: &str = r#"
SELECT
tpw.region_id,
tpw.product_id,
tpw.worth_percent
FROM falukant_data.town_product_worth tpw
JOIN falukant_data.branch b
ON b.region_id = tpw.region_id
WHERE b.falukant_user_id = $1
AND tpw.product_id = $2;
"#;
// Verfügbare Transportmittel für eine Route (source_region -> target_region)
const QUERY_GET_TRANSPORT_VEHICLES_FOR_ROUTE: &str = r#"
SELECT
v.id AS vehicle_id,
vt.capacity AS capacity
FROM falukant_data.vehicle v
JOIN falukant_type.vehicle vt
ON vt.id = v.vehicle_type_id
JOIN falukant_data.region_distance rd
ON (
(rd.source_region_id = v.region_id AND rd.target_region_id = $3)
OR (rd.source_region_id = $3 AND rd.target_region_id = v.region_id)
)
AND (rd.transport_mode = vt.transport_mode OR rd.transport_mode IS NULL)
WHERE v.falukant_user_id = $1
AND v.region_id = $2
AND v.condition > 0
AND v.available_from <= NOW();
"#;
// Transport-Eintrag anlegen
const QUERY_INSERT_TRANSPORT: &str = r#"
INSERT INTO falukant_data.transport
(source_region_id, target_region_id, product_id, size, vehicle_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW());
"#;
const QUERY_GET_SALARY_TO_PAY: &str = r#" const QUERY_GET_SALARY_TO_PAY: &str = r#"
SELECT d.id, d.employer_user_id, d.income SELECT d.id, d.employer_user_id, d.income
FROM falukant_data.director d FROM falukant_data.director d
@@ -417,8 +464,48 @@ impl DirectorWorker {
conn.prepare("remove_inventory", QUERY_REMOVE_INVENTORY)?; conn.prepare("remove_inventory", QUERY_REMOVE_INVENTORY)?;
conn.prepare("add_sell_log", QUERY_ADD_SELL_LOG)?; conn.prepare("add_sell_log", QUERY_ADD_SELL_LOG)?;
// Falls es nichts zu verkaufen gibt, können wir sofort zurückkehren.
if items.is_empty() {
return Ok(());
}
// Für alle Items dieses Directors sollten die user_id-Felder identisch
// sein (Arbeitgeber des Directors).
let falukant_user_id = items[0].user_id;
// Vor dem eigentlichen Verkauf versucht der Director, lohnende
// Transporte zu planen. Dabei werden:
// - ggf. Transport-Einträge erzeugt
// - Inventar-Mengen reduziert
// Die zurückgegebenen Mengen werden dann lokal verkauft.
for item in items.iter_mut() {
let shipped = self.plan_transports_for_item(
&mut conn,
falukant_user_id,
item,
)?;
if shipped > 0 {
if shipped >= item.quantity {
// Alles wurde in Transporte umgewandelt, lokal nichts mehr zu verkaufen.
item.quantity = 0;
} else {
// Inventar-Menge in der DB reduzieren und im Item anpassen.
let remaining = item.quantity - shipped;
Self::update_inventory_quantity(&mut conn, item.id, remaining)?;
item.quantity = remaining;
}
}
}
// Anschließend lokale Verkäufe für die verbleibenden Mengen durchführen.
for item in items.drain(..) { for item in items.drain(..) {
self.sell_single_inventory_item(&mut conn, &item)?; if item.quantity > 0 {
self.sell_single_inventory_item(&mut conn, &item)?;
} else {
// Falls die Menge auf 0 gesetzt wurde, das Inventar ggf. aufräumen.
conn.execute("remove_inventory", &[&item.id])?;
}
} }
Ok(()) Ok(())
@@ -483,6 +570,262 @@ impl DirectorWorker {
Ok(()) Ok(())
} }
/// Plant ggf. Transporte für ein einzelnes Inventar-Item und gibt die
/// Menge zurück, die tatsächlich in Transporte umgewandelt wurde.
///
/// Logik:
/// - Ermittle regionale "worth_percent"-Werte für das Produkt in allen
/// Branch-Regionen des Users.
/// - Berechne lokalen Stückpreis (inkl. Qualität) und für jede andere
/// Region einen potentiellen Stückpreis.
/// - Prüfe für jede Zielregion:
/// * Gibt es verfügbare Transportmittel für diese Route?
/// * Ist der Mehrerlös (deltaPrice * Menge) größer als die
/// Transportkosten (max(1, totalValue * 0.01))?
/// - Wähle die Zielregion mit dem größten positiven Nettogewinn und
/// erzeuge entsprechende Transporte (begrenzt durch Fahrzeugkapazität).
fn plan_transports_for_item(
&mut self,
conn: &mut DbConnection,
falukant_user_id: i32,
item: &mut InventoryItem,
) -> Result<i32, DbError> {
// Sicherheitscheck
if item.quantity <= 0 {
return Ok(0);
}
// Regionale worth_percent-Werte für dieses Produkt laden
conn.prepare(
"get_region_worth_for_product",
QUERY_GET_REGION_WORTH_FOR_PRODUCT,
)?;
let rows = conn.execute(
"get_region_worth_for_product",
&[&falukant_user_id, &item.product_id],
)?;
if rows.is_empty() {
return Ok(0);
}
let mut worth_by_region: HashMap<i32, f64> = HashMap::new();
for row in rows {
let region_id = row
.get("region_id")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(-1);
let percent = row
.get("worth_percent")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(100.0);
if region_id >= 0 {
worth_by_region.insert(region_id, percent);
}
}
if worth_by_region.is_empty() {
return Ok(0);
}
// Lokalen Stückpreis berechnen
let local_percent = worth_by_region
.get(&item.region_id)
.copied()
.unwrap_or(100.0);
let local_sell_cost = item.sell_cost * local_percent / 100.0;
let local_min_price = local_sell_cost * 0.6;
let quality_factor = item.quality as f64 / 100.0;
let local_piece_price =
local_min_price + (local_sell_cost - local_min_price) * quality_factor;
let mut best_target_region: Option<i32> = None;
let mut best_quantity: i32 = 0;
let mut best_remote_piece_price: f64 = 0.0;
let mut best_gain: f64 = 0.0;
// Für jede andere Region prüfen, ob sich ein Transport lohnt.
for (&region_id, &remote_percent) in &worth_by_region {
if region_id == item.region_id {
continue;
}
// Remote-Stückpreis
let remote_sell_cost = item.sell_cost * remote_percent / 100.0;
let remote_min_price = remote_sell_cost * 0.6;
let remote_piece_price =
remote_min_price + (remote_sell_cost - remote_min_price) * quality_factor;
let delta_per_unit = remote_piece_price - local_piece_price;
if delta_per_unit <= 0.0 {
continue;
}
// Verfügbare Transportmittel für diese Route abfragen
let vehicles = Self::get_transport_vehicles_for_route(
conn,
falukant_user_id,
item.region_id,
region_id,
)?;
if vehicles.is_empty() {
continue;
}
// Maximale transportierbare Menge anhand der Kapazität ermitteln
let mut max_capacity: i32 = 0;
for v in &vehicles {
max_capacity = max_capacity.saturating_add(v.capacity);
}
if max_capacity <= 0 {
continue;
}
let qty = std::cmp::min(item.quantity, max_capacity);
if qty <= 0 {
continue;
}
let extra_revenue = delta_per_unit * qty as f64;
let total_value = remote_piece_price * qty as f64;
let transport_cost = total_value * 0.01_f64;
// Kostenformel: max(0.01, totalValue * 0.01)
let transport_cost = if transport_cost < 0.01 {
0.01
} else {
transport_cost
};
let net_gain = extra_revenue - transport_cost;
if net_gain <= 0.0 {
continue;
}
if net_gain > best_gain {
best_gain = net_gain;
best_target_region = Some(region_id);
best_quantity = qty;
best_remote_piece_price = remote_piece_price;
}
}
// Kein lohnender Transport gefunden
let target_region = match best_target_region {
Some(r) => r,
None => return Ok(0),
};
if best_quantity <= 0 {
return Ok(0);
}
// Nochmals verfügbare Transportmittel für die gewählte Route laden
let vehicles = Self::get_transport_vehicles_for_route(
conn,
falukant_user_id,
item.region_id,
target_region,
)?;
if vehicles.is_empty() {
return Ok(0);
}
// Transporte anlegen, begrenzt durch best_quantity und Kapazitäten
conn.prepare("insert_transport", QUERY_INSERT_TRANSPORT)?;
let mut remaining = best_quantity;
for v in &vehicles {
if remaining <= 0 {
break;
}
let size = std::cmp::min(remaining, v.capacity);
if size <= 0 {
continue;
}
conn.execute(
"insert_transport",
&[
&item.region_id,
&target_region,
&item.product_id,
&size,
&v.id,
],
)?;
remaining -= size;
}
let shipped = best_quantity - remaining.max(0);
// Optional: Logging zur Nachvollziehbarkeit
if shipped > 0 {
eprintln!(
"[DirectorWorker] Transport geplant: {} Einheiten von Produkt {} von Region {} nach Region {} (Stückpreis lokal {:.2}, remote {:.2})",
shipped, item.product_id, item.region_id, target_region, local_piece_price, best_remote_piece_price
);
}
Ok(shipped)
}
fn get_transport_vehicles_for_route(
conn: &mut DbConnection,
falukant_user_id: i32,
source_region: i32,
target_region: i32,
) -> Result<Vec<TransportVehicle>, DbError> {
conn.prepare(
"get_transport_vehicles_for_route",
QUERY_GET_TRANSPORT_VEHICLES_FOR_ROUTE,
)?;
let rows = conn.execute(
"get_transport_vehicles_for_route",
&[&falukant_user_id, &source_region, &target_region],
)?;
let mut result = Vec::with_capacity(rows.len());
for row in rows {
let id = row
.get("vehicle_id")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(-1);
let capacity = row
.get("capacity")
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(0);
if id >= 0 && capacity > 0 {
result.push(TransportVehicle { id, capacity });
}
}
Ok(result)
}
fn update_inventory_quantity(
conn: &mut DbConnection,
inventory_id: i32,
new_quantity: i32,
) -> Result<(), DbError> {
const QUERY_UPDATE_INVENTORY_QTY: &str = r#"
UPDATE falukant_data.inventory
SET quantity = $2
WHERE id = $1;
"#;
conn.prepare("update_inventory_qty", QUERY_UPDATE_INVENTORY_QTY)?;
conn.execute("update_inventory_qty", &[&inventory_id, &new_quantity])?;
Ok(())
}
fn pay_salary(&mut self) -> Result<(), DbError> { fn pay_salary(&mut self) -> Result<(), DbError> {
self.base.set_current_step("DirectorWorker: pay_salary"); self.base.set_current_step("DirectorWorker: pay_salary");