Quellcode durchsuchen

feat: implem backorder logic

Medowar vor 6 Tagen
Ursprung
Commit
e57f2f48a0
7 geänderte Dateien mit 814 neuen und 17 gelöschten Zeilen
  1. 207 3
      admin/backorders.php
  2. 21 0
      admin/index.php
  3. 65 2
      admin/orders.php
  4. 35 0
      assets/css/style.css
  5. 103 11
      data/orders.json
  6. 31 1
      docs/ORDER_PROCESS.md
  7. 352 0
      includes/functions.php

+ 207 - 3
admin/backorders.php

@@ -1,5 +1,209 @@
 <?php
-require_once __DIR__ . '/../config.php';
+require_once __DIR__ . "/../config.php";
+require_once __DIR__ . "/../includes/functions.php";
 
-header('Location: orders.php');
-exit;
+if (empty($_SESSION['admin_logged_in'])) {
+    header("Location: login.php");
+    exit();
+}
+
+expirePendingOrders();
+
+$pageTitle = "Nachbestellungen";
+$message = "";
+$messageType = "";
+
+if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['mark_ordered'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $result = markBackorderItemsOrdered(
+            $_POST['product_id'] ?? 0,
+            $_POST['size'] ?? "",
+            $_POST['quantity'] ?? 0,
+        );
+        $message = $result["success"]
+            ? ($result["updated"] ?? 0) .
+                " Position(en) als bestellt markiert."
+            : $result["message"];
+        $messageType = $result["success"] ? "success" : "error";
+
+        if ($result["success"]) {
+            logAccess("Admin marked backorder items ordered", [
+                "admin" => $_SESSION['admin_username'] ?? "unknown",
+                "product_id" => $_POST['product_id'] ?? 0,
+                "size" => $_POST['size'] ?? "",
+                "quantity" => $_POST['quantity'] ?? 0,
+            ]);
+        }
+    }
+}
+
+if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['mark_delivered'])) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $result = markBackorderItemsDelivered(
+            $_POST['product_id'] ?? 0,
+            $_POST['size'] ?? "",
+            $_POST['quantity'] ?? 0,
+        );
+        $message = $result["success"]
+            ? ($result["updated"] ?? 0) .
+                " Position(en) als geliefert markiert."
+            : $result["message"];
+        $messageType = $result["success"] ? "success" : "error";
+
+        if ($result["success"]) {
+            logAccess("Admin marked backorder items delivered", [
+                "admin" => $_SESSION['admin_username'] ?? "unknown",
+                "product_id" => $_POST['product_id'] ?? 0,
+                "size" => $_POST['size'] ?? "",
+                "quantity" => $_POST['quantity'] ?? 0,
+            ]);
+        }
+    }
+}
+
+$groups = getBackorderGroups();
+
+$bodyClass = "admin-page";
+include __DIR__ . "/../includes/header.php";
+?>
+
+<div class="admin-header">
+    <h2>Nachbestellungen</h2>
+    <div>
+        <a href="index.php" class="btn btn-secondary">Zurück zum Dashboard</a>
+        <a href="orders.php" class="btn btn-secondary">Bestellungen</a>
+    </div>
+</div>
+
+<?php if ($message !== ""): ?>
+    <div class="alert alert-<?php echo escape($messageType); ?>">
+        <?php echo escape($message); ?>
+    </div>
+<?php endif; ?>
+
+<p class="text-muted">
+    Artikel werden nach Produkt und Größe zusammengefasst. Aktionen bearbeiten die ältesten Bestellungen zuerst (FIFO).
+</p>
+
+<?php if (empty($groups)): ?>
+    <div class="alert alert-info">
+        <p>Keine Nachbestellungen vorhanden.</p>
+    </div>
+<?php else: ?>
+    <div class="table-responsive">
+        <table class="responsive-table">
+            <thead>
+                <tr>
+                    <th>Artikel</th>
+                    <th>Größe</th>
+                    <th>Nachzubestellen</th>
+                    <th>Wartet auf Lieferung</th>
+                    <th>Als bestellt markieren</th>
+                    <th>Lieferung eingetroffen</th>
+                </tr>
+            </thead>
+            <tbody>
+                <?php foreach ($groups as $group): ?>
+                    <tr>
+                        <td data-label="Artikel"><?php echo escape(
+                            $group["product_name"],
+                        ); ?></td>
+                        <td data-label="Größe"><?php echo $group["size"] !== ""
+                            ? escape($group["size"])
+                            : "-"; ?></td>
+                        <td data-label="Nachzubestellen">
+                            <strong><?php echo (int) $group[
+                                "to_be_backordered"
+                            ]; ?></strong>
+                        </td>
+                        <td data-label="Wartet auf Lieferung">
+                            <strong><?php echo (int) $group["ordered"]; ?></strong>
+                        </td>
+                        <td data-label="Als bestellt markieren">
+                            <?php if ($group["to_be_backordered"] > 0): ?>
+                                <form method="POST" class="backorder-action-form">
+                                    <?php echo csrfField(); ?>
+                                    <input type="hidden" name="product_id" value="<?php echo (int) $group[
+                                        "product_id"
+                                    ]; ?>">
+                                    <input type="hidden" name="size" value="<?php echo escape(
+                                        $group["size"],
+                                    ); ?>">
+                                    <label class="sr-only" for="qty_ordered_<?php echo (int) $group[
+                                        "product_id"
+                                    ]; ?>_<?php echo escape(
+    preg_replace("/[^a-z0-9]/i", "_", $group["size"]),
+); ?>">Menge</label>
+                                    <input
+                                        type="number"
+                                        id="qty_ordered_<?php echo (int) $group[
+                                            "product_id"
+                                        ]; ?>_<?php echo escape(
+    preg_replace("/[^a-z0-9]/i", "_", $group["size"]),
+); ?>"
+                                        name="quantity"
+                                        min="1"
+                                        max="<?php echo (int) $group[
+                                            "to_be_backordered"
+                                        ]; ?>"
+                                        value="1"
+                                        class="backorder-qty-input"
+                                    >
+                                    <button type="submit" name="mark_ordered" class="btn btn-small">
+                                        Als bestellt markieren
+                                    </button>
+                                </form>
+                            <?php else: ?>
+                                -
+                            <?php endif; ?>
+                        </td>
+                        <td data-label="Lieferung eingetroffen">
+                            <?php if ($group["ordered"] > 0): ?>
+                                <form method="POST" class="backorder-action-form">
+                                    <?php echo csrfField(); ?>
+                                    <input type="hidden" name="product_id" value="<?php echo (int) $group[
+                                        "product_id"
+                                    ]; ?>">
+                                    <input type="hidden" name="size" value="<?php echo escape(
+                                        $group["size"],
+                                    ); ?>">
+                                    <label class="sr-only" for="qty_delivered_<?php echo (int) $group[
+                                        "product_id"
+                                    ]; ?>_<?php echo escape(
+    preg_replace("/[^a-z0-9]/i", "_", $group["size"]),
+); ?>">Menge</label>
+                                    <input
+                                        type="number"
+                                        id="qty_delivered_<?php echo (int) $group[
+                                            "product_id"
+                                        ]; ?>_<?php echo escape(
+    preg_replace("/[^a-z0-9]/i", "_", $group["size"]),
+); ?>"
+                                        name="quantity"
+                                        min="1"
+                                        max="<?php echo (int) $group["ordered"]; ?>"
+                                        value="1"
+                                        class="backorder-qty-input"
+                                    >
+                                    <button type="submit" name="mark_delivered" class="btn btn-small btn-secondary">
+                                        Lieferung eingetroffen
+                                    </button>
+                                </form>
+                            <?php else: ?>
+                                -
+                            <?php endif; ?>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+<?php endif; ?>
+
+<?php include __DIR__ . "/../includes/footer.php"; ?>

+ 21 - 0
admin/index.php

@@ -14,6 +14,12 @@ $orders = getOrders();
 $products = getProducts();
 $organizations = getOrganizations(false);
 
+$backorderGroups = getBackorderGroups();
+$backorderCount = 0;
+foreach ($backorderGroups as $group) {
+    $backorderCount += (int) $group['to_be_backordered'] + (int) $group['ordered'];
+}
+
 $stats = [
     'products' => count($products),
     'organizations' => count($organizations),
@@ -22,6 +28,7 @@ $stats = [
     'partial' => 0,
     'processed' => 0,
     'cancelled' => 0,
+    'backorder' => $backorderCount,
 ];
 
 foreach ($orders as $order) {
@@ -62,6 +69,7 @@ foreach ($orders as $order) {
             'product_name' => $item['product_name'],
             'size' => $item['size'],
             'availability_label' => $item['availability_label'],
+            'backorder_status' => $item['backorder_status'] ?? '',
         ];
     }
 }
@@ -93,6 +101,7 @@ include __DIR__ . '/../includes/header.php';
                 <a href="organizations.php">Organisationen verwalten</a>
                 <a href="settings.php">Einstellungen</a>
                 <a href="faq.php">FAQ bearbeiten</a>
+                <a href="backorders.php">Nachbestellungen verwalten</a>
                 <a href="admins.php">Admins verwalten</a>
                 <form method="POST" action="login.php" class="inline-form">
                     <?php echo csrfField(); ?>
@@ -132,6 +141,10 @@ include __DIR__ . '/../includes/header.php';
         <h3>Storniert</h3>
         <div class="stat-value"><?php echo $stats['cancelled']; ?></div>
     </div>
+    <div class="stat-card">
+        <h3>Nachbestellung</h3>
+        <div class="stat-value"><?php echo $stats['backorder']; ?></div>
+    </div>
 </div>
 
 <h3 class="section-title mt-4">Letzte Bestellungen</h3>
@@ -182,6 +195,7 @@ include __DIR__ . '/../includes/header.php';
                     <th>Artikel</th>
                     <th>Größe</th>
                     <th>Lieferhinweis</th>
+                    <th>Nachbestellung</th>
                     <th>Erstellt</th>
                     <th>Aktionen</th>
                 </tr>
@@ -195,6 +209,13 @@ include __DIR__ . '/../includes/header.php';
                         <td data-label="Artikel"><?php echo escape($row['product_name']); ?></td>
                         <td data-label="Größe"><?php echo $row['size'] !== '' ? escape($row['size']) : '-'; ?></td>
                         <td data-label="Lieferhinweis"><?php echo $row['availability_label'] !== '' ? escape($row['availability_label']) : '-'; ?></td>
+                        <td data-label="Nachbestellung">
+                            <?php if (($row['backorder_status'] ?? '') !== ''): ?>
+                                <span class="status <?php echo escape(getBackorderStatusClass($row['backorder_status'])); ?>"><?php echo escape(getBackorderStatusLabel($row['backorder_status'])); ?></span>
+                            <?php else: ?>
+                                -
+                            <?php endif; ?>
+                        </td>
                         <td data-label="Erstellt"><?php echo escape(formatDate($row['created_at'])); ?></td>
                         <td data-label="Aktionen"><a href="orders.php?details=<?php echo urlencode($row['order_id']); ?>" class="btn btn-small">Details</a></td>
                     </tr>

+ 65 - 2
admin/orders.php

@@ -13,6 +13,33 @@ $pageTitle = "Bestellungen";
 $message = "";
 $messageType = "";
 
+if (
+    $_SERVER['REQUEST_METHOD'] === "POST" &&
+    isset($_POST['toggle_item_backorder'])
+) {
+    if (!validateCsrfToken($_POST['csrf_token'] ?? "")) {
+        $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
+        $messageType = "error";
+    } else {
+        $result = toggleOrderItemBackorder(
+            $_POST['order_id'] ?? "",
+            (int) ($_POST['item_index'] ?? -1),
+        );
+        $message = $result["success"]
+            ? "Nachbestellstatus wurde aktualisiert."
+            : $result["message"];
+        $messageType = $result["success"] ? "success" : "error";
+
+        if ($result["success"]) {
+            logAccess("Admin toggled order item backorder", [
+                "admin" => $_SESSION['admin_username'] ?? "unknown",
+                "order_id" => $_POST['order_id'] ?? "",
+                "item_index" => $_POST['item_index'] ?? -1,
+            ]);
+        }
+    }
+}
+
 if (
     $_SERVER['REQUEST_METHOD'] === "POST" &&
     isset($_POST['toggle_item_processed'])
@@ -228,7 +255,11 @@ include __DIR__ . "/../includes/header.php";
             getOrderStatusClass($selectedOrder),
         ); ?>"><?php echo escape(
     getOrderStatusLabel($selectedOrder),
-); ?></span></p>
+); ?></span>
+        <?php if (orderHasBackorder($selectedOrder)): ?>
+            <span class="status status-backorder">Nachbestellung</span>
+        <?php endif; ?>
+        </p>
         <p><strong>Name:</strong> <?php echo escape(
             $selectedOrder["customer_name"],
         ); ?></p>
@@ -287,6 +318,7 @@ include __DIR__ . "/../includes/header.php";
                         <th>Größe</th>
                         <th>Lieferhinweis</th>
                         <th>Bearbeitet</th>
+                        <th>Nachbestellung</th>
                         <th>Aktion</th>
                     </tr>
                 </thead>
@@ -319,6 +351,19 @@ include __DIR__ . "/../includes/header.php";
                                         : "Nein"; ?>
                                 </span>
                             </td>
+                            <td data-label="Nachbestellung">
+                                <?php
+                                $backorderStatus = (string) ($item["backorder_status"] ?? "");
+                                if ($backorderStatus !== ""): ?>
+                                    <span class="status <?php echo escape(
+                                        getBackorderStatusClass($backorderStatus),
+                                    ); ?>"><?php echo escape(
+    getBackorderStatusLabel($backorderStatus),
+); ?></span>
+                                <?php else: ?>
+                                    -
+                                <?php endif; ?>
+                            </td>
                             <td data-label="Aktionen">
                                 <?php if (
                                     $selectedOrder["status"] !== "cancelled" &&
@@ -327,7 +372,7 @@ include __DIR__ . "/../includes/header.php";
                                     $selectedOrder["confirmation_status"] !==
                                         "expired"
                                 ): ?>
-                                     <form method="POST">
+                                    <form method="POST" class="inline-form">
                                         <?php echo csrfField(); ?>
                                         <input type="hidden" name="order_id" value="<?php echo escape(
                                             $selectedOrder["id"],
@@ -341,6 +386,24 @@ include __DIR__ . "/../includes/header.php";
                                                 : "Als bearbeitet markieren"; ?>
                                         </button>
                                     </form>
+                                    <?php
+                                    $canToggleBackorder =
+                                        $backorderStatus === "" ||
+                                        $backorderStatus === "to_be_backordered";
+                                    if ($canToggleBackorder): ?>
+                                    <form method="POST" class="inline-form">
+                                        <?php echo csrfField(); ?>
+                                        <input type="hidden" name="order_id" value="<?php echo escape(
+                                            $selectedOrder["id"],
+                                        ); ?>">
+                                        <input type="hidden" name="item_index" value="<?php echo (int) $index; ?>">
+                                        <button type="submit" name="toggle_item_backorder" class="btn btn-small btn-secondary">
+                                            <?php echo $backorderStatus === "to_be_backordered"
+                                                ? "Nachbestellung aufheben"
+                                                : "Als Nachbestellung markieren"; ?>
+                                        </button>
+                                    </form>
+                                    <?php endif; ?>
                                 <?php else: ?>
                                     -
                                 <?php endif; ?>

+ 35 - 0
assets/css/style.css

@@ -754,6 +754,18 @@ body.admin-page .container {
     border-color: #e2ce7f;
 }
 
+.status-backorder {
+    color: #8b4513;
+    background: #fde8d8;
+    border-color: #e8b896;
+}
+
+.status-backorder-waiting {
+    color: #5c4d8a;
+    background: #ebe6f7;
+    border-color: #c4b8e8;
+}
+
 .status-cancelled {
     color: var(--brand-danger);
     background: #fff3f1;
@@ -893,6 +905,29 @@ body.admin-page .container {
     display: inline;
 }
 
+.backorder-action-form {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    align-items: center;
+}
+
+.backorder-qty-input {
+    width: 4rem;
+}
+
+.sr-only {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    margin: -1px;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    white-space: nowrap;
+    border: 0;
+}
+
 .admin-filter-form {
     display: flex;
     gap: 1rem;

+ 103 - 11
data/orders.json

@@ -13,14 +13,20 @@
                     "product_name": "Beil",
                     "size": "Universalgröße",
                     "availability_label": "",
-                    "is_processed": true
+                    "is_processed": true,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 },
                 {
                     "product_id": 17,
                     "product_name": "Allwetterjacke",
                     "size": "M",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 }
             ],
             "status": "cancelled",
@@ -48,14 +54,20 @@
                     "product_name": "Fleece",
                     "size": "M",
                     "availability_label": "",
-                    "is_processed": true
+                    "is_processed": true,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 },
                 {
                     "product_id": 5,
                     "product_name": "Handschuhe AT",
                     "size": "9",
                     "availability_label": "",
-                    "is_processed": true
+                    "is_processed": true,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 }
             ],
             "status": "processed",
@@ -83,14 +95,20 @@
                     "product_name": "Beil",
                     "size": "Universalgröße",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 },
                 {
                     "product_id": 4,
                     "product_name": "Fleece",
                     "size": "M",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 }
             ],
             "status": "open",
@@ -118,21 +136,30 @@
                     "product_name": "Handschuhe AT",
                     "size": "9",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 },
                 {
                     "product_id": 10,
                     "product_name": "Beil",
                     "size": "Universalgröße",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 },
                 {
                     "product_id": 4,
                     "product_name": "Fleece",
                     "size": "M",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "",
+                    "ordered_at": ""
                 }
             ],
             "status": "open",
@@ -160,7 +187,10 @@
                     "product_name": "Jacke THL",
                     "size": "L",
                     "availability_label": "",
-                    "is_processed": false
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "2026-05-30 10:44:18",
+                    "ordered_at": ""
                 }
             ],
             "status": "open",
@@ -169,11 +199,73 @@
             "confirmation_expires_at": "",
             "confirmed_at": "2026-05-30 10:11:13",
             "created_at": "2026-05-30 10:11:13",
-            "updated_at": "2026-05-30 10:11:13",
+            "updated_at": "2026-05-30 10:46:52",
             "cancelled_at": "",
             "cancelled_by": "",
             "cancellation_reason": "",
             "admin_notified_at": ""
+        },
+        {
+            "id": "FS-2026-002",
+            "customer_name": "Backorder test2",
+            "customer_email": "1235@mailpit.medowar.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 27,
+                    "product_name": "Krawatte",
+                    "size": "Universalgröße",
+                    "availability_label": "",
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "2026-05-30 10:43:31",
+                    "ordered_at": ""
+                }
+            ],
+            "status": "open",
+            "confirmation_status": "not_required",
+            "confirmation_token": "",
+            "confirmation_expires_at": "",
+            "confirmed_at": "2026-05-30 10:28:37",
+            "created_at": "2026-05-30 10:28:37",
+            "updated_at": "2026-05-30 10:45:18",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": "2026-05-30 10:28:37"
+        },
+        {
+            "id": "FS-2026-003",
+            "customer_name": "bsudfzb",
+            "customer_email": "bisdfb@mailpit.medowar.de",
+            "organization_id": "feuerwehr-freising",
+            "organization_label": "Feuerwehr Freising TEST",
+            "comment": "",
+            "items": [
+                {
+                    "product_id": 27,
+                    "product_name": "Krawatte",
+                    "size": "Universalgröße",
+                    "availability_label": "",
+                    "is_processed": false,
+                    "backorder_status": "",
+                    "backordered_at": "2026-05-30 10:45:30",
+                    "ordered_at": ""
+                }
+            ],
+            "status": "open",
+            "confirmation_status": "not_required",
+            "confirmation_token": "",
+            "confirmation_expires_at": "",
+            "confirmed_at": "2026-05-30 10:44:49",
+            "created_at": "2026-05-30 10:44:49",
+            "updated_at": "2026-05-30 10:46:52",
+            "cancelled_at": "",
+            "cancelled_by": "",
+            "cancellation_reason": "",
+            "admin_notified_at": "2026-05-30 10:44:49"
         }
     ]
 }

+ 31 - 1
docs/ORDER_PROCESS.md

@@ -8,6 +8,7 @@ Das System bildet keinen klassischen E-Commerce-Checkout ab, sondern einen inter
 - Persistenz: `data/orders.json`
 - Kernlogik: `includes/functions.php`
 - Interne Bearbeitung: `admin/orders.php`
+- Nachbestellungen: `admin/backorders.php`
 - Einstellungen für Bestätigung/Weiterleitung: `admin/settings.php`
 
 Hinweis:
@@ -65,6 +66,35 @@ Regeln:
 - Stornierung ist jederzeit möglich (solange noch nicht storniert).
 - Zeitstempel für interne Weiterleitung (`admin_notified_at`) wird gesetzt, wenn die Admin-Benachrichtigungsmail erfolgreich versendet wurde.
 
+## Nachbestellungen (nur Admin)
+
+Wenn Artikel nicht auf Lager sind, können Admins Positionen als Nachbestellung markieren. Der Vorgang ist nur im Admin-Bereich sichtbar, nicht im Frontend.
+
+### Daten pro Position
+
+Jede Bestellposition kann optional folgende Felder haben:
+
+- `backorder_status`: `""` (keine), `to_be_backordered`, `ordered`
+- `backordered_at`, `ordered_at` (Zeitstempel)
+
+### Zustände
+
+1. **Nachzubestellen** (`to_be_backordered`): Position ist als Nachbestellung markiert, externe Bestellung steht noch aus.
+2. **Wartet auf Lieferung** (`ordered`): Extern bestellt, Lieferung ausstehend.
+
+### Aktionen
+
+| Aktion | Ort | Wirkung |
+| --- | --- | --- |
+| Als Nachbestellung markieren | `admin/orders.php` (pro Position) | Setzt `to_be_backordered` |
+| Nachbestellung aufheben | `admin/orders.php` | Setzt `backorder_status` zurück auf `""` (nur aus Zustand 1) |
+| Als bestellt markieren | `admin/backorders.php` | Verschiebt N Positionen von `to_be_backordered` nach `ordered` (FIFO) |
+| Lieferung eingetroffen | `admin/backorders.php` | Setzt N Positionen von `ordered` zurück auf `""` (FIFO); Position bleibt offen zur normalen Abarbeitung |
+
+In `admin/backorders.php` werden Positionen nach Produkt und Größe gruppiert. Die Spalten **Nachzubestellen** und **Wartet auf Lieferung** werden getrennt gezählt, damit neu markierte Artikel nicht mit bereits bestellten vermischt werden.
+
+Nachbestellte Positionen erscheinen weiterhin in **Offene Positionen** auf dem Dashboard, zusätzlich mit einem Nachbestell-Badge.
+
 ## Abweichungen zum Standard-Webshop
 
 - Kein Payment-Schritt (kein Warenwert, keine Zahlungsarten, keine Zahlungsfreigabe).
@@ -73,4 +103,4 @@ Regeln:
 - Keine Endnutzer-Bestellhistorie oder Kundenkonto-Workflow.
 - Optionaler Double-Opt-in-ähnlicher Schritt per E-Mail-Bestätigung vor interner Weiterleitung.
 - Bearbeitung ist positionsbasiert im Admin (operativer Abarbeitungsstatus statt klassischer Versandstatus).
-- Legacy-Routen `reservation.php` und `orders.php` (Frontend) sowie `admin/reservations.php` und `admin/backorders.php` leiten auf die Bestellverwaltung um und sind kein eigener Prozess mehr.
+- Legacy-Routen `reservation.php` und `orders.php` (Frontend) sowie `admin/reservations.php` leiten auf die Bestellverwaltung um.

+ 352 - 0
includes/functions.php

@@ -1156,6 +1156,12 @@ function normalizeOrderItem($item)
         $size = "";
     }
 
+    $backorderStatus = trim((string) ($item["backorder_status"] ?? ""));
+    $allowedBackorderStatuses = ["", "to_be_backordered", "ordered"];
+    if (!in_array($backorderStatus, $allowedBackorderStatuses, true)) {
+        $backorderStatus = "";
+    }
+
     return [
         "product_id" => $productId,
         "product_name" => $product["name"],
@@ -1163,6 +1169,9 @@ function normalizeOrderItem($item)
         "availability_label" =>
             $size !== "" ? getAvailabilityLabel($product, $size) : "",
         "is_processed" => !empty($item["is_processed"]),
+        "backorder_status" => $backorderStatus,
+        "backordered_at" => trim((string) ($item["backordered_at"] ?? "")),
+        "ordered_at" => trim((string) ($item["ordered_at"] ?? "")),
     ];
 }
 
@@ -1746,6 +1755,349 @@ function cancelOrder($orderId, $adminUsername, $reason = "")
     return ["success" => false, "message" => "Bestellung nicht gefunden."];
 }
 
+function orderItemCanBeManaged($order)
+{
+    if (($order["status"] ?? "") === "cancelled") {
+        return [
+            "success" => false,
+            "message" =>
+                "Stornierte Bestellungen können nicht mehr bearbeitet werden.",
+        ];
+    }
+    if (($order["confirmation_status"] ?? "") === "pending") {
+        return [
+            "success" => false,
+            "message" =>
+                "Unbestätigte Bestellungen können noch nicht bearbeitet werden.",
+        ];
+    }
+    if (($order["confirmation_status"] ?? "") === "expired") {
+        return [
+            "success" => false,
+            "message" =>
+                "Abgelaufene unbestätigte Bestellungen können nicht bearbeitet werden.",
+        ];
+    }
+
+    return ["success" => true];
+}
+
+function setOrderItemBackorderStatus($orderId, $itemIndex, $status)
+{
+    $allowedStatuses = ["", "to_be_backordered"];
+    if (!in_array($status, $allowedStatuses, true)) {
+        return ["success" => false, "message" => "Ungültiger Nachbestellstatus."];
+    }
+
+    $orders = getOrders();
+    $now = date("Y-m-d H:i:s");
+
+    foreach ($orders as &$order) {
+        if ($order["id"] !== $orderId) {
+            continue;
+        }
+
+        $guard = orderItemCanBeManaged($order);
+        if (!$guard["success"]) {
+            return $guard;
+        }
+
+        if (!isset($order["items"][$itemIndex])) {
+            return [
+                "success" => false,
+                "message" => "Position nicht gefunden.",
+            ];
+        }
+
+        if ($status === "to_be_backordered") {
+            $order["items"][$itemIndex]["backorder_status"] = "to_be_backordered";
+            $order["items"][$itemIndex]["backordered_at"] = $now;
+            $order["items"][$itemIndex]["ordered_at"] = "";
+        } else {
+            $order["items"][$itemIndex]["backorder_status"] = "";
+            $order["items"][$itemIndex]["backordered_at"] = "";
+            $order["items"][$itemIndex]["ordered_at"] = "";
+        }
+
+        $order["updated_at"] = $now;
+        saveOrders($orders);
+
+        return ["success" => true, "order" => $order];
+    }
+    unset($order);
+
+    return ["success" => false, "message" => "Bestellung nicht gefunden."];
+}
+
+function toggleOrderItemBackorder($orderId, $itemIndex)
+{
+    $orders = getOrders();
+
+    foreach ($orders as $order) {
+        if ($order["id"] !== $orderId) {
+            continue;
+        }
+
+        if (!isset($order["items"][$itemIndex])) {
+            return [
+                "success" => false,
+                "message" => "Position nicht gefunden.",
+            ];
+        }
+
+        $current = (string) ($order["items"][$itemIndex]["backorder_status"] ?? "");
+        $newStatus = $current === "to_be_backordered" ? "" : "to_be_backordered";
+
+        return setOrderItemBackorderStatus($orderId, $itemIndex, $newStatus);
+    }
+
+    return ["success" => false, "message" => "Bestellung nicht gefunden."];
+}
+
+function collectBackorderItemRefs()
+{
+    $refs = [];
+
+    foreach (getOrders() as $order) {
+        if (($order["status"] ?? "") === "cancelled") {
+            continue;
+        }
+        if (in_array($order["confirmation_status"] ?? "", ["pending", "expired"], true)) {
+            continue;
+        }
+
+        foreach ($order["items"] as $itemIndex => $item) {
+            $status = (string) ($item["backorder_status"] ?? "");
+            if ($status === "") {
+                continue;
+            }
+
+            $refs[] = [
+                "order_id" => $order["id"],
+                "item_index" => (int) $itemIndex,
+                "product_id" => (int) $item["product_id"],
+                "product_name" => $item["product_name"],
+                "size" => $item["size"],
+                "backorder_status" => $status,
+                "created_at" => $order["created_at"],
+                "sort_at" =>
+                    $status === "ordered"
+                        ? ($item["ordered_at"] !== ""
+                            ? $item["ordered_at"]
+                            : $order["created_at"])
+                        : ($item["backordered_at"] !== ""
+                            ? $item["backordered_at"]
+                            : $order["created_at"]),
+            ];
+        }
+    }
+
+    return $refs;
+}
+
+function getBackorderGroups()
+{
+    $refs = collectBackorderItemRefs();
+    $groups = [];
+
+    foreach ($refs as $ref) {
+        $key = $ref["product_id"] . "|" . $ref["size"];
+        if (!isset($groups[$key])) {
+            $groups[$key] = [
+                "product_id" => $ref["product_id"],
+                "product_name" => $ref["product_name"],
+                "size" => $ref["size"],
+                "to_be_backordered" => 0,
+                "ordered" => 0,
+                "items_to_be_backordered" => [],
+                "items_ordered" => [],
+            ];
+        }
+
+        if ($ref["backorder_status"] === "to_be_backordered") {
+            $groups[$key]["to_be_backordered"]++;
+            $groups[$key]["items_to_be_backordered"][] = $ref;
+        } elseif ($ref["backorder_status"] === "ordered") {
+            $groups[$key]["ordered"]++;
+            $groups[$key]["items_ordered"][] = $ref;
+        }
+    }
+
+    foreach ($groups as &$group) {
+        usort($group["items_to_be_backordered"], function ($left, $right) {
+            $cmp = strcmp($left["created_at"], $right["created_at"]);
+            if ($cmp !== 0) {
+                return $cmp;
+            }
+            return strcmp($left["order_id"], $right["order_id"]);
+        });
+        usort($group["items_ordered"], function ($left, $right) {
+            $cmp = strcmp($left["sort_at"], $right["sort_at"]);
+            if ($cmp !== 0) {
+                return $cmp;
+            }
+            return strcmp($left["order_id"], $right["order_id"]);
+        });
+    }
+    unset($group);
+
+    $result = array_values($groups);
+    usort($result, function ($left, $right) {
+        $cmp = strcmp($left["product_name"], $right["product_name"]);
+        if ($cmp !== 0) {
+            return $cmp;
+        }
+        return strcmp($left["size"], $right["size"]);
+    });
+
+    return $result;
+}
+
+function applyBackorderBulkUpdate($productId, $size, $fromStatus, $toStatus, $quantity)
+{
+    $productId = (int) $productId;
+    $size = trim((string) $size);
+    $quantity = (int) $quantity;
+
+    if ($productId <= 0 || $quantity <= 0) {
+        return [
+            "success" => false,
+            "message" => "Ungültige Menge oder Artikel.",
+        ];
+    }
+
+    $refs = collectBackorderItemRefs();
+    $candidates = [];
+
+    foreach ($refs as $ref) {
+        if (
+            $ref["product_id"] !== $productId ||
+            $ref["size"] !== $size ||
+            $ref["backorder_status"] !== $fromStatus
+        ) {
+            continue;
+        }
+        $candidates[] = $ref;
+    }
+
+    usort($candidates, function ($left, $right) {
+        $cmp = strcmp($left["created_at"], $right["created_at"]);
+        if ($cmp !== 0) {
+            return $cmp;
+        }
+        return strcmp($left["order_id"], $right["order_id"]);
+    });
+
+    if ($quantity > count($candidates)) {
+        return [
+            "success" => false,
+            "message" =>
+                "Nur " .
+                count($candidates) .
+                " Position(en) verfügbar, " .
+                $quantity .
+                " angefordert.",
+        ];
+    }
+
+    $targets = array_slice($candidates, 0, $quantity);
+    $orders = getOrders();
+    $now = date("Y-m-d H:i:s");
+    $updated = 0;
+
+    foreach ($targets as $target) {
+        foreach ($orders as &$order) {
+            if ($order["id"] !== $target["order_id"]) {
+                continue;
+            }
+
+            $itemIndex = $target["item_index"];
+            if (!isset($order["items"][$itemIndex])) {
+                continue 2;
+            }
+
+            if ($toStatus === "ordered") {
+                $order["items"][$itemIndex]["backorder_status"] = "ordered";
+                $order["items"][$itemIndex]["ordered_at"] = $now;
+            } else {
+                $order["items"][$itemIndex]["backorder_status"] = "";
+                $order["items"][$itemIndex]["ordered_at"] = "";
+            }
+
+            $order["updated_at"] = $now;
+            $updated++;
+            break;
+        }
+        unset($order);
+    }
+
+    if ($updated === 0) {
+        return [
+            "success" => false,
+            "message" => "Keine Positionen aktualisiert.",
+        ];
+    }
+
+    saveOrders($orders);
+
+    return ["success" => true, "updated" => $updated];
+}
+
+function markBackorderItemsOrdered($productId, $size, $quantity)
+{
+    return applyBackorderBulkUpdate(
+        $productId,
+        $size,
+        "to_be_backordered",
+        "ordered",
+        $quantity,
+    );
+}
+
+function markBackorderItemsDelivered($productId, $size, $quantity)
+{
+    return applyBackorderBulkUpdate($productId, $size, "ordered", "", $quantity);
+}
+
+function orderHasBackorder($order)
+{
+    if (!is_array($order["items"] ?? null)) {
+        return false;
+    }
+
+    foreach ($order["items"] as $item) {
+        if (($item["backorder_status"] ?? "") !== "") {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+function getBackorderStatusLabel($status)
+{
+    switch ((string) $status) {
+        case "to_be_backordered":
+            return "Nachzubestellen";
+        case "ordered":
+            return "Wartet auf Lieferung";
+        default:
+            return "-";
+    }
+}
+
+function getBackorderStatusClass($status)
+{
+    switch ((string) $status) {
+        case "to_be_backordered":
+            return "status-backorder";
+        case "ordered":
+            return "status-backorder-waiting";
+        default:
+            return "";
+    }
+}
+
 function getOrderStatusLabel($order)
 {
     if (($order["status"] ?? "") === "cancelled") {