diff --git a/Assets/Scripts/Core/GameClock.cs b/Assets/Scripts/Core/GameClock.cs
index 1e62411..eaa846b 100644
--- a/Assets/Scripts/Core/GameClock.cs
+++ b/Assets/Scripts/Core/GameClock.cs
@@ -24,6 +24,9 @@ namespace AfterHours.Core
public int Day => _day.Value;
public bool IsOpen => _isOpen.Value;
+ /// Durchgehende Spielzeit in Stunden seit Tag 1, 0 Uhr. Für Lieferzeiten o. ä.
+ public float TotalHours => (_day.Value - 1) * 24f + _hourOfDay.Value;
+
/// Feuert serverseitig beim Tageswechsel. Für Abrechnung, Lieferungen, Miete.
public event Action DayEnded;
diff --git a/Assets/Scripts/Economy/SupplierService.cs b/Assets/Scripts/Economy/SupplierService.cs
new file mode 100644
index 0000000..f4e2b5e
--- /dev/null
+++ b/Assets/Scripts/Economy/SupplierService.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using AfterHours.Core;
+using AfterHours.Data;
+using AfterHours.Shop;
+using Unity.Netcode;
+using UnityEngine;
+
+namespace AfterHours.Economy
+{
+ ///
+ /// Bestellungen beim Großhandel: Ware wird sofort bezahlt, trifft aber
+ /// erst nach einer Lieferzeit in der passenden Lagerkiste ein. Nur Server.
+ ///
+ public sealed class SupplierService : NetworkBehaviour
+ {
+ public static SupplierService Instance { get; private set; }
+
+ [SerializeField] private ProductCatalog _catalog;
+ [SerializeField] private float _deliveryLeadHours = 4f;
+
+ private readonly List _pending = new();
+
+ private void Awake() => Instance = this;
+
+ public override void OnNetworkDespawn()
+ {
+ if (Instance == this) Instance = null;
+ }
+
+ private void Update()
+ {
+ if (!IsServer || GameClock.Instance == null) return;
+
+ float now = GameClock.Instance.TotalHours;
+ for (int i = _pending.Count - 1; i >= 0; i--)
+ {
+ if (now < _pending[i].DeliveryTime) continue;
+
+ Deliver(_pending[i]);
+ _pending.RemoveAt(i);
+ }
+ }
+
+ /// Server: Bestellung aufgeben. False, wenn Produkt unbekannt oder Geld nicht reicht.
+ public bool PlaceOrder(int productId, int quantity)
+ {
+ if (!IsServer || quantity <= 0) return false;
+ if (!_catalog.TryGet(productId, out var product)) return false;
+
+ float cost = product.WholesalePrice * quantity;
+ if (!ShopEconomy.Instance.TrySpend(cost)) return false;
+
+ var order = new PurchaseOrder { DeliveryTime = GameClock.Instance.TotalHours + _deliveryLeadHours };
+ order.Add(productId, quantity, product.WholesalePrice);
+ _pending.Add(order);
+ return true;
+ }
+
+ private void Deliver(PurchaseOrder order)
+ {
+ order.Delivered = true;
+
+ foreach (var line in order.Lines)
+ {
+ var crate = StorageCrateRegistry.Instance.FindFor(line.ProductId);
+ if (crate == null)
+ {
+ Debug.LogWarning($"[SupplierService] Keine Lagerkiste für ProductId {line.ProductId} — Lieferung verworfen.");
+ continue;
+ }
+ crate.Restock(line.Quantity);
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Economy/SupplierService.cs.meta b/Assets/Scripts/Economy/SupplierService.cs.meta
new file mode 100644
index 0000000..8fadc12
--- /dev/null
+++ b/Assets/Scripts/Economy/SupplierService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a5e238d8d78eca84e8dec7321b910cb2
\ No newline at end of file
diff --git a/Assets/Scripts/Shop/OrderTerminal.cs b/Assets/Scripts/Shop/OrderTerminal.cs
new file mode 100644
index 0000000..fc3a7b6
--- /dev/null
+++ b/Assets/Scripts/Shop/OrderTerminal.cs
@@ -0,0 +1,34 @@
+using AfterHours.Data;
+using AfterHours.Economy;
+using AfterHours.Interaction;
+using Unity.Netcode;
+using UnityEngine;
+
+namespace AfterHours.Shop
+{
+ ///
+ /// Backroom-Terminal zum Nachbestellen eines festen Produkts beim
+ /// Großhandel. Produkt/Menge sind pro Instanz im Inspector konfiguriert.
+ ///
+ public sealed class OrderTerminal : NetworkBehaviour, IInteractable
+ {
+ [SerializeField] private ProductCatalog _catalog;
+ [SerializeField] private int _productId = -1;
+ [SerializeField] private int _orderQuantity = 50;
+
+ public string GetPrompt(ulong clientId)
+ {
+ var product = _catalog.Get(_productId);
+ string name = product != null ? product.DisplayName : "?";
+ float cost = product != null ? product.WholesalePrice * _orderQuantity : 0f;
+ return $"{name} nachbestellen ({_orderQuantity} Stk. — {cost:0.00} €)";
+ }
+
+ /// Server: löst eine Bestellung beim SupplierService aus.
+ public void Interact(NetworkObject actor)
+ {
+ if (!IsServer) return;
+ SupplierService.Instance?.PlaceOrder(_productId, _orderQuantity);
+ }
+ }
+}
diff --git a/Assets/Scripts/Shop/OrderTerminal.cs.meta b/Assets/Scripts/Shop/OrderTerminal.cs.meta
new file mode 100644
index 0000000..7bb75db
--- /dev/null
+++ b/Assets/Scripts/Shop/OrderTerminal.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 045aa93f3792d4847ab62d54134471e5
\ No newline at end of file
diff --git a/Assets/Scripts/Shop/StorageCrate.cs b/Assets/Scripts/Shop/StorageCrate.cs
index 5abde2c..e730dc7 100644
--- a/Assets/Scripts/Shop/StorageCrate.cs
+++ b/Assets/Scripts/Shop/StorageCrate.cs
@@ -25,7 +25,15 @@ namespace AfterHours.Shop
public override void OnNetworkSpawn()
{
- if (IsServer) _quantity.Value = _startingQuantity;
+ if (!IsServer) return;
+
+ _quantity.Value = _startingQuantity;
+ StorageCrateRegistry.Instance.Register(this);
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ if (IsServer) StorageCrateRegistry.Instance.Unregister(this);
}
public string GetPrompt(ulong clientId)
@@ -45,5 +53,12 @@ namespace AfterHours.Shop
int taken = hands.TryTake(_productId, want);
_quantity.Value -= taken;
}
+
+ /// Server: Lieferung trifft ein.
+ public void Restock(int quantity)
+ {
+ if (!IsServer || quantity <= 0) return;
+ _quantity.Value += quantity;
+ }
}
}
diff --git a/Assets/Scripts/Shop/StorageCrateRegistry.cs b/Assets/Scripts/Shop/StorageCrateRegistry.cs
new file mode 100644
index 0000000..63c3f03
--- /dev/null
+++ b/Assets/Scripts/Shop/StorageCrateRegistry.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+
+namespace AfterHours.Shop
+{
+ ///
+ /// Serverseitige Liste aller aktiven Lagerkisten, damit der SupplierService
+ /// eine Lieferung der richtigen Kiste zuordnen kann.
+ ///
+ public sealed class StorageCrateRegistry
+ {
+ public static readonly StorageCrateRegistry Instance = new();
+
+ private readonly List _crates = new();
+
+ public void Register(StorageCrate crate) => _crates.Add(crate);
+
+ public void Unregister(StorageCrate crate) => _crates.Remove(crate);
+
+ /// Erste Kiste, die dieses Produkt führt.
+ public StorageCrate FindFor(int productId)
+ {
+ foreach (var crate in _crates)
+ if (crate != null && crate.ProductId == productId)
+ return crate;
+ return null;
+ }
+ }
+}
diff --git a/Assets/Scripts/Shop/StorageCrateRegistry.cs.meta b/Assets/Scripts/Shop/StorageCrateRegistry.cs.meta
new file mode 100644
index 0000000..f0f248a
--- /dev/null
+++ b/Assets/Scripts/Shop/StorageCrateRegistry.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2d67201bcc28c49428bc6c0ead7f1ff7
\ No newline at end of file
diff --git a/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs
new file mode 100644
index 0000000..ce72105
--- /dev/null
+++ b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs
@@ -0,0 +1,37 @@
+using AfterHours.Economy;
+using NUnit.Framework;
+
+namespace AfterHours.Tests
+{
+ public sealed class PurchaseOrderTests
+ {
+ [Test]
+ public void Total_SumsAllLines()
+ {
+ var order = new PurchaseOrder();
+ order.Add(1, 10, 2f);
+ order.Add(2, 5, 4f);
+
+ Assert.AreEqual(40f, order.Total(), 0.0001f);
+ }
+
+ [Test]
+ public void Add_IgnoresNonPositiveQuantity()
+ {
+ var order = new PurchaseOrder();
+ order.Add(1, 0, 5f);
+ order.Add(1, -3, 5f);
+
+ Assert.AreEqual(0, order.Lines.Count);
+ }
+
+ [Test]
+ public void LineTotal_IsQuantityTimesUnitPrice()
+ {
+ var order = new PurchaseOrder();
+ order.Add(7, 3, 2.5f);
+
+ Assert.AreEqual(7.5f, order.Lines[0].LineTotal, 0.0001f);
+ }
+ }
+}
diff --git a/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs.meta b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs.meta
new file mode 100644
index 0000000..dee18d0
--- /dev/null
+++ b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3635a8816e4d1744cb4d319118ae2768
\ No newline at end of file
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index f83d213..e949277 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -21,7 +21,7 @@
## Meilenstein 3 — Substanz
- [ ] Beratungs-Minispiel (Kundenandeutung → passende Kategorie)
-- [ ] Bestellsystem mit Lieferzeit (`SupplierService`, noch nicht angelegt)
+- [x] Bestellsystem mit Lieferzeit (`SupplierService`, `OrderTerminal`)
- [ ] Tagesabrechnung, Miete, Personalkosten
- [ ] Laden-Upgrades: Umkleide, Vorhang, zweite Kasse, Regalreihen
- [ ] Sichtachsen-System (`ExposedToStreet` automatisch aus Geometrie ableiten)