Explorar o código

Fix backorder dashboard visibility, FIFO sorting, and cancel cleanup.

Show backordered open orders on the admin dashboard with Nachbestellung
columns, clear item backorder fields on cancel, unify bulk FIFO via
sort_at, and add MANUAL_BACKORDERS_FILE to config.sample.

Co-authored-by: Cursor <cursoragent@cursor.com>
Medowar hai 6 días
pai
achega
f9eb644e12
Modificáronse 5 ficheiros con 83 adicións e 53 borrados
  1. 13 10
      admin/index.php
  2. 1 0
      config.sample.php
  3. 15 22
      docs/ADMIN_BUSINESS_LOGIC.md
  4. 1 0
      docs/CONFIG_REFERENCE.md
  5. 53 21
      includes/functions.php

+ 13 - 10
admin/index.php

@@ -42,13 +42,9 @@ $recentOrders = array_values(array_filter($orders, function ($order) {
         return false;
     }
     foreach ($order['items'] as $item) {
-        if (!empty($item['is_processed'])) {
-            continue;
-        }
-        if (trim((string) ($item['backorder_status'] ?? '')) !== '') {
-            continue;
+        if (empty($item['is_processed'])) {
+            return true;
         }
-        return true;
     }
     return false;
 }));
@@ -67,9 +63,6 @@ foreach ($orders as $order) {
         if (!empty($item['is_processed'])) {
             continue;
         }
-        if (trim((string) ($item['backorder_status'] ?? '')) !== '') {
-            continue;
-        }
         $outstandingItems[] = [
             'order_id' => $order['id'],
             'customer_name' => $order['customer_name'],
@@ -158,17 +151,27 @@ include __DIR__ . '/../includes/header.php';
                     <th>Organisation</th>
                     <th>Erstellt</th>
                     <th>Status</th>
+                    <th>Nachbestellung</th>
                     <th>Aktionen</th>
                 </tr>
             </thead>
             <tbody>
-                <?php foreach ($recentOrders as $order): ?>
+                <?php foreach ($recentOrders as $order):
+                    $backorderSummary = getOrderOpenBackorderSummary($order);
+                    ?>
                     <tr>
                         <td data-label="Bestellnummer"><strong><?php echo escape($order['id']); ?></strong></td>
                         <td data-label="Name"><?php echo escape($order['customer_name']); ?></td>
                         <td data-label="Organisation"><?php echo escape($order['organization_label']); ?></td>
                         <td data-label="Erstellt"><?php echo escape(formatDate($order['created_at'])); ?></td>
                         <td data-label="Status"><span class="status <?php echo escape(getOrderStatusClass($order)); ?>"><?php echo escape(getOrderStatusLabel($order)); ?></span></td>
+                        <td data-label="Nachbestellung">
+                            <?php if ($backorderSummary['label'] !== ''): ?>
+                                <span class="status <?php echo escape($backorderSummary['class']); ?>"><?php echo escape($backorderSummary['label']); ?></span>
+                            <?php else: ?>
+                                -
+                            <?php endif; ?>
+                        </td>
                         <td data-label="Aktionen"><a href="order.php?id=<?php echo urlencode($order['id']); ?>" class="btn btn-small">Details</a></td>
                     </tr>
                 <?php endforeach; ?>

+ 1 - 0
config.sample.php

@@ -40,6 +40,7 @@ define('SETTINGS_FILE', DATA_DIR . 'settings.json');
 define('ADMINS_FILE', DATA_DIR . 'admins.json');
 define('CATEGORIES_FILE', DATA_DIR . 'categories.json');
 define('FAQ_FILE', DATA_DIR . 'faq.json');
+define('MANUAL_BACKORDERS_FILE', DATA_DIR . 'manual_backorders.json');
 define('UPLOADS_URL', SITE_URL . '/data/uploads');
 
 // Session settings

+ 15 - 22
docs/ADMIN_BUSINESS_LOGIC.md

@@ -111,7 +111,9 @@ Stornierte Bestellungen werden **nicht** automatisch aus Positionen neu berechne
 Eine Bestellung erscheint hier nur, wenn:
 
 1. Das Anzeige-Label **Offen** oder **Teilweise bearbeitet** ist (nicht Bearbeitet, nicht Storniert), **und**
-2. Mindestens **eine Position** existiert, die **nicht** bearbeitet ist **und** **keinen** Nachbestell-Status hat.
+2. Mindestens **eine Position** existiert, die **nicht** bearbeitet ist (unabhängig vom Nachbestell-Status).
+
+Spalte **Nachbestellung:** Zusammenfassung der Nachbestell-Status aller **offenen** Positionen (`-`, ein Status-Badge, oder **Gemischt**).
 
 Sortierung: neueste zuerst.
 
@@ -120,11 +122,13 @@ Sortierung: neueste zuerst.
 Eine Position erscheint hier nur, wenn:
 
 1. Die zugehörige Bestellung das Label **Offen** oder **Teilweise bearbeitet** hat, **und**
-2. Die Position **nicht** bearbeitet ist **und** **keinen** Nachbestell-Status hat.
+2. Die Position **nicht** bearbeitet ist (auch mit Nachbestell-Status).
+
+Spalte **Nachbestellung:** pro Zeile **Nachzubestellen**, **Wartet auf Lieferung** oder `-`.
 
 Sortierung: **älteste zuerst** (FIFO-Arbeitsliste).
 
-**Operator-Hinweis:** Positionen mit Nachbestell-Status (**Nachzubestellen** oder **Wartet auf Lieferung**) erscheinen **nirgends** auf dem Dashboard. Diese werden ausschließlich unter **Nachbestellungen** verwaltet.
+Bulk-Aktionen für Nachbestellungen (als bestellt markieren, Lieferung eingetroffen) bleiben auf **Nachbestellungen**; das Dashboard dient der Übersicht und dem Sprung zur Bestelldetailseite.
 
 ---
 
@@ -152,10 +156,12 @@ Jede Bestellung ist immer über die Bestellliste oder Dashboard-Links erreichbar
 | --- | --- |
 | **Als bearbeitet / offen markieren** | Bestellung nicht storniert |
 | **Als Nachbestellung markieren / aufheben** | Bestellung nicht storniert; nur Wechsel zwischen *(leer)* und **Nachzubestellen** — **nicht** von **Wartet auf Lieferung** |
-| **Bestellung stornieren** | Nicht storniert; nicht vollständig Bearbeitet |
+| **Bestellung stornieren** | Nicht storniert; nicht vollständig Bearbeitet; löscht auf allen Positionen Nachbestell-Status und zugehörige Zeitstempel |
 | **Stornierung aufheben** | Bestellung storniert |
 
-Zusätzliches Badge **„Nachbestellung"**, wenn mindestens eine Position einen Nachbestell-Status hat.
+Zusätzliches Badge **„Nachbestellung"**, wenn mindestens eine Position einen Nachbestell-Status hat (nicht bei stornierten Bestellungen, sofern beim Stornieren zurückgesetzt).
+
+**Bearbeitet + Nachbestellung:** Eine Position kann als **bearbeitet** markiert sein und gleichzeitig einen Nachbestell-Status behalten (z. B. Retoure deckt die Bestellung ab, während die Nachbestellung auf Lieferantenware wartet). „Als bearbeitet markieren" löscht den Nachbestell-Status **nicht**. Umgekehrt: **Nachzubestellen** kann nicht auf bereits bearbeitete Positionen gesetzt werden.
 
 Positionen im Status **Wartet auf Lieferung** (`ordered`): Kein Nachbestell-Toggle auf der Detailseite — Weiterbearbeitung nur über **Nachbestellungen** („Lieferung eingetroffen").
 
@@ -172,6 +178,8 @@ Positionen werden nach **Produkt und Größe** gruppiert. Spalten **Nachzubestel
 
 #### Bulk-Aktionen (FIFO — älteste zuerst)
 
+Kandidaten und Anzeige-Reihenfolge nutzen dieselbe Sortierung nach `sort_at` (u. a. `backordered_at` / `ordered_at`, sonst Bestelldatum).
+
 | Aktion | Wirkung |
 | --- | --- |
 | **Als bestellt markieren** | Verschiebt N Positionen von **Nachzubestellen** → **Wartet auf Lieferung** |
@@ -253,26 +261,11 @@ Unter **Einstellungen** (`admin/settings.php`):
 
 ## Bekannte Unstimmigkeiten
 
-Die folgenden Punkte sind **keine Bedienanleitung**, sondern dokumentierte Abweichungen in der Anzeige-Logik. Sie können später im Code behoben werden.
-
-1. **Bestellung nur mit Nachbestellpositionen**  
-   Operativer Status kann **Offen** bleiben (0 Positionen bearbeitet), aber alle Positionen haben Nachbestell-Status → Bestellung erscheint **nicht** in Dashboard-Tabellen, nur in **Alle** und **Nachbestellungen**.
+Die folgenden Punkte sind **keine Bedienanleitung**, sondern dokumentierte bewusste Einschränkungen.
 
-2. **Status „Wartet auf Lieferung" nicht auf Detailseite änderbar**  
+1. **Status „Wartet auf Lieferung" nicht auf Detailseite änderbar**  
    Bewusste Trennung: Weiterführung nur über **Nachbestellungen**. Operatoren, die nur die Detailseite nutzen, finden keinen Button dafür.
 
-3. **Stornierte Bestellungen können Nachbestell-Flags in den Daten behalten**  
-   In der Nachbestellungen-Ansicht ausgeblendet, in den Rohdaten ggf. noch vorhanden.
-
-4. **Position bearbeitbar trotz Nachbestell-Flag**  
-   „Als bearbeitet markieren" löscht den Nachbestell-Status nicht automatisch. Umgekehrt blockiert ein gesetzter Nachbestell-Status auf bereits bearbeiteten Positionen keine erneute Markierung als offen/bearbeitet.
-
-5. **FIFO-Sortierung bei Bulk-Aktionen inkonsistent**  
-   Anzeige in Nachbestellungen sortiert u. a. nach `sort_at` (Bestell-/Bestellzeitpunkt). Bulk-Updates sortieren Kandidaten nur nach `created_at` — Reihenfolge kann bei „Wartet auf Lieferung" abweichen.
-
-6. **`MANUAL_BACKORDERS_FILE` fehlt in `config.sample.php`**  
-    Manuelle Nachbestellungen setzen die Konstante voraus; fehlt sie in der produktiven `config.php`, kann die Nachbestellungen-Seite fehlschlagen.
-
 ---
 
 ## Querverweise

+ 1 - 0
docs/CONFIG_REFERENCE.md

@@ -27,6 +27,7 @@
 | `ADMINS_FILE` | JSON-Datei für Adminkonten |
 | `CATEGORIES_FILE` | JSON-Datei für Kategorien |
 | `FAQ_FILE` | JSON-Datei für FAQ-Inhalte |
+| `MANUAL_BACKORDERS_FILE` | JSON-Datei für manuelle Nachbestell-Einträge (ohne Bestellbezug) |
 
 ## Hinweis
 

+ 53 - 21
includes/functions.php

@@ -1495,6 +1495,12 @@ function cancelOrder($orderId, $adminUsername, $reason = "")
         $order["cancelled_by"] = $adminUsername;
         $order["cancellation_reason"] = $reason;
         $order["updated_at"] = $now;
+        foreach ($order["items"] as &$item) {
+            $item["backorder_status"] = "";
+            $item["backordered_at"] = "";
+            $item["ordered_at"] = "";
+        }
+        unset($item);
         saveOrders($orders);
 
         return ["success" => true, "order" => $order];
@@ -1802,6 +1808,19 @@ function collectBackorderItemRefs()
     return $refs;
 }
 
+function compareBackorderRefs(array $left, array $right): int
+{
+    $cmp = strcmp((string) ($left["sort_at"] ?? ""), (string) ($right["sort_at"] ?? ""));
+    if ($cmp !== 0) {
+        return $cmp;
+    }
+    $cmp = strcmp((string) ($left["order_id"] ?? ""), (string) ($right["order_id"] ?? ""));
+    if ($cmp !== 0) {
+        return $cmp;
+    }
+    return strcmp((string) ($left["manual_id"] ?? ""), (string) ($right["manual_id"] ?? ""));
+}
+
 function getBackorderGroups()
 {
     $refs = collectBackorderItemRefs();
@@ -1831,20 +1850,8 @@ function getBackorderGroups()
     }
 
     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"]);
-        });
+        usort($group["items_to_be_backordered"], "compareBackorderRefs");
+        usort($group["items_ordered"], "compareBackorderRefs");
     }
     unset($group);
 
@@ -1887,13 +1894,7 @@ function applyBackorderBulkUpdate($productId, $size, $fromStatus, $toStatus, $qu
         $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"]);
-    });
+    usort($candidates, "compareBackorderRefs");
 
     if ($quantity > count($candidates)) {
         return [
@@ -2020,6 +2021,37 @@ function orderHasBackorder($order)
     return false;
 }
 
+function getOrderOpenBackorderSummary($order)
+{
+    $statuses = [];
+    if (!is_array($order["items"] ?? null)) {
+        return ["label" => "", "class" => ""];
+    }
+
+    foreach ($order["items"] as $item) {
+        if (!empty($item["is_processed"])) {
+            continue;
+        }
+        $status = trim((string) ($item["backorder_status"] ?? ""));
+        if ($status !== "") {
+            $statuses[$status] = true;
+        }
+    }
+
+    if ($statuses === []) {
+        return ["label" => "", "class" => ""];
+    }
+    if (count($statuses) > 1) {
+        return ["label" => "Gemischt", "class" => "status-backorder"];
+    }
+
+    $status = array_key_first($statuses);
+    return [
+        "label" => getBackorderStatusLabel($status),
+        "class" => getBackorderStatusClass($status),
+    ];
+}
+
 function getBackorderStatusLabel($status)
 {
     switch ((string) $status) {