Warenwirtschaft: Review-Fix — Abwicklungs-Board (Copyreader-Links, 500er-Cap, Details)

Bestellnummern-Links auf admin_sales_detail (admin >= 7) waren für
Versand-Mitarbeiter tote Links (stiller Redirect) — jetzt rollenabhängig.
Kachel-Zähler und pick/ready-Filter rechnen über alle offenen
Bestellungen statt über die neuesten 500; gekappt wird nur die Anzeige,
der Hinweis-Banner zeigt 'neueste n von total' korrekt. whereDate durch
indexfähiges whereBetween ersetzt; Packlisten-Toggle über data-order-id;
Gewicht über formatNumber().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-20 12:40:03 +00:00
parent f28f4b4b68
commit 80d0687f95
5 changed files with 61 additions and 17 deletions

View file

@ -31,34 +31,46 @@ class OrderFulfillmentController extends Controller
$withRelations = ['shopping_user', 'shopping_payment_last', 'shipping_country.country', 'shopping_order_items.product.setItems'];
// Offene Abwicklung (Status offen/in Bearbeitung) — Basis für Liste und Kachel-Zähler
// Offene Abwicklung (Status offen/in Bearbeitung) — Basis für Liste und Kachel-Zähler.
// Bewusst OHNE Limit (Review 20.07.2026): das Cap galt vorher schon für die Zähler
// und Filter-Listen, wodurch bei > 500 offenen Bestellungen ausgerechnet die
// ältesten versandfertigen unsichtbar wurden — und der Hinweis-Banner verglich
// den Post-Filter-Count, erschien also nie. Gekappt wird jetzt nur die Anzeige.
$open = ShoppingOrder::query()
->with($withRelations)
->whereIn('shipped', [0, 1])
->orderByDesc('id')
->when($limit !== null, fn ($query) => $query->limit($limit))
->get();
$pick = $open->where('shipped', 0)->filter->isReadyToShip()->values();
$ready = $open->where('shipped', 1)->filter->isReadyToShip()->values();
$counts = [
'today' => ShoppingOrder::query()->whereDate('created_at', today())->count(),
'pick' => $open->where('shipped', 0)->filter->isReadyToShip()->count(),
'ready' => $open->where('shipped', 1)->filter->isReadyToShip()->count(),
'shipped_today' => ShoppingOrder::query()->whereDate('shipped_at', today())->count(),
'today' => ShoppingOrder::query()->whereBetween('created_at', [today(), today()->endOfDay()])->count(),
'pick' => $pick->count(),
'ready' => $ready->count(),
'shipped_today' => ShoppingOrder::query()->whereBetween('shipped_at', [today(), today()->endOfDay()])->count(),
];
$orders = match ($filter) {
'today' => ShoppingOrder::query()->with($withRelations)->whereDate('created_at', today())->orderByDesc('id')->get(),
'pick' => $open->where('shipped', 0)->filter->isReadyToShip()->values(),
'ready' => $open->where('shipped', 1)->filter->isReadyToShip()->values(),
'shipped_today' => ShoppingOrder::query()->with($withRelations)->whereDate('shipped_at', today())->orderByDesc('id')->get(),
'today' => ShoppingOrder::query()->with($withRelations)->whereBetween('created_at', [today(), today()->endOfDay()])->orderByDesc('id')->get(),
'pick' => $pick,
'ready' => $ready,
'shipped_today' => ShoppingOrder::query()->with($withRelations)->whereBetween('shipped_at', [today(), today()->endOfDay()])->orderByDesc('id')->get(),
default => $open,
};
$listTotal = $orders->count();
if ($limit !== null) {
$orders = $orders->take($limit)->values();
}
return view('admin.inventory.orders.index', [
'orders' => $orders,
'counts' => $counts,
'filter' => $filter,
'listLimit' => $limit,
'listTotal' => $listTotal,
]);
}

View file

@ -279,6 +279,7 @@ Der Warenkorb kommt also **per Knopfdruck** (Fulfillment-Übergabe), nicht autom
| 20.07.2026 | **Review-Fix: AP-32-Brücke auf Base64 umgestellt (Connector 1.5.0)** | Code-Review vor Live-Release, drei Befunde in einem Zug behoben: **(1) Die Brücke hätte in Produktion nicht funktioniert** — der Connector 1.4.0 übertrug die rohe `label_url` aus `wp-content/uploads/woocommerce_dhl_label/`, den das DHL-Plugin per „deny from all" schützt → der Laravel-Download lief immer auf 403, das Label wurde nie gespeichert, und der View-Fallback verlinkte dieselbe 403-URL. **(2) SSRF:** der Server rief eine Client-URL ohne Host-/Schema-Prüfung ab. **(3) Timeout-Mismatch:** Download bis 20 s im Request-Pfad vs. 5 s WordPress-Default → Übertragung wäre shopseitig dauerhaft „fehlgeschlagen" (Exists-400 bei jedem Retry). Jetzt: Connector 1.5.0 liest die PDF vom Shop-Dateisystem (`label_path`, realpath-Pinning auf wp-uploads, `%PDF`-Check) und sendet sie **Base64** (`wp_label_pdf`); `storeWpLabel()` dekodiert + validiert (max. 10 MB, `%PDF`), **kein `Http::get` mehr**; `wp_label_url` bleibt reines Herkunfts-Metadatum, View-Fallback-Links entfernt; Connector-Timeout 30 s. Doku `ap-32-brueckenloesung-connector-update.md` + Hinweise überarbeitet (Deployment: Label kommt erst mit Connector 1.5.0 im Shop an). | `WpLabelTransferTest` auf Base64 umgestellt (10 grün; neu: ungültiges Base64/Nicht-PDF verworfen, URL-only = Altconnector blockiert nicht) |
| 20.07.2026 | **Review-Fix: API-Protokoll — falsch-grüne Einträge behoben** | `ApiOrderLog::recordTransfer()` löste die SKU **erneut** auf und markierte nur unbekannte Artikel als Fehler — eine Zeile mit gültiger SKU, aber **fehlender Menge/Preis** wurde von `prepareOrder()` verworfen (Position fehlt in der Bestellung!) und trotzdem grün protokolliert. Jetzt liefert `prepareOrder()` je Zeile `included`/`product_id`/`product_name` mit und das Protokoll konsumiert nur noch diese Wahrheit — `failed` = „Position nicht übernommen" (deckt SKU unbekannt UND Parameter fehlen ab); nebenbei entfallen die N+1-Produkt-Queries je Position. | `ApiOrderLogTest` +1: bekannte SKU ohne Menge ⇒ rot markiert, Position fehlt in Bestellung (9 grün) |
| 20.07.2026 | **Review-Fix: PDF-Altdaten-Robustheit + Dedup** | Zwei 500er-Szenarien behoben: **(1)** `document('combined')` (Kombi-Druck im Board) prüfte keine Datei-Existenz — bei fehlender Alt-PDF flog eine `PDFMerger`-Exception statt 404/Regeneration. **(2)** Der Sammel-Druck regenerierte nur bei fehlendem **Lieferschein**; fehlte nur die Rechnungs-PDF, platzte der gesamte Batch (bis 100 Bestellungen). Kern: neue zentrale Methode **`InvoiceRepository::ensureFilesOnDisk()`** (prüft/regeneriert Rechnung UND Lieferschein mit bestehender Nummer) — ersetzt den dreifach kopierten Altdaten-Check (`MailLogistic` 2x, `batchDocuments`) und wird jetzt auch in `document()` genutzt. Dazu die vierfach duplizierte Merge-/Response-Sequenz im `OrderFulfillmentController` zu `mergedPdf()` + `pdfResponse()` zusammengezogen. | `OrderFulfillmentTest` +2: Kombi-Download und Sammel-Druck regenerieren fehlende Rechnungs-PDF (16 grün); `LogisticMailTest` unverändert grün |
| 20.07.2026 | **Review-Fix: Abwicklungs-Board (Rechte-Links, 500er-Cap, Kleinigkeiten)** | **(1) Copyreader-Links:** Bestellnummern in Abwicklung + API-Bestellungen verlinkten auf `admin_sales_detail` (admin ≥ 7) — für Versand-Mitarbeiter (copyreader, Kundenentscheid AP-33 Punkt 4) ein stiller Redirect nach `/home`; Link jetzt nur für Admins, sonst reiner Text. **(2) 500er-Cap:** Kacheln „Kommissionieren/Versandfertig" und deren Filter-Listen rechneten auf den neuesten 500 offenen Bestellungen — bei Rückstau wären ausgerechnet die **ältesten versandfertigen unsichtbar** gewesen, und der Hinweis-Banner verglich den Post-Filter-Count (erschien nie). Jetzt: Zähler + Filter über **alle** offenen Bestellungen, gekappt wird nur die Anzeige („neueste :n von :total" + „Alle anzeigen"). `whereDate()` → `whereBetween` (indexfähig, Vorbereitung Index-AP). **(3)** Packlisten-Toggle nutzt `data-order-id` statt Link-Text-Scraping; Gewicht über `formatNumber()`. | `OrderFulfillmentTest` +2 (copyreader ohne Link / admin mit Link; 18 grün) |
| _offen_ | AP-32 | Versand im System: DHL-Etiketten per API — **neu aufgenommen + Ansatz dokumentiert (§4, 15.07.2026)**; Klärungspunkte (DHL-Konto, Versandprodukte, Druck-Setup) für nächste Anforderungsrunde notiert. **Zwischenstand 17.07.2026: Brückenlösung aktiv** (Label aus dem Shop, s. Protokollzeile) — deckt den Versand-Flow ab, bis die DHL-Klärungen da sind | – |
| _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – |

View file

@ -83,8 +83,12 @@
<td>{{ $log->wp_order_number ?? '—' }}</td>
<td>{{ $customer !== '' ? $customer : '—' }}</td>
<td>
@if ($log->shopping_order_id)
{{-- Verkäufe-Detail verlangt admin ≥ 7 — Versand-Mitarbeiter (copyreader)
bekämen sonst einen stillen Redirect (Review 20.07.2026) --}}
@if ($log->shopping_order_id && auth()->user()->isAdmin())
<a href="{{ route('admin_sales_detail', $log->shopping_order_id) }}">#{{ $log->shopping_order_id }}</a>
@elseif ($log->shopping_order_id)
<span class="font-weight-bold">#{{ $log->shopping_order_id }}</span>
@else
<span class="text-muted">—</span>
@endif

View file

@ -132,7 +132,7 @@
$readyToShip = $order->isReadyToShip();
$itemCount = $order->shopping_order_items->sum('qty');
@endphp
<tr class="of-row" data-search="{{ mb_strtolower($order->id.' '.$customer) }}">
<tr class="of-row" data-order-id="{{ $order->id }}" data-search="{{ mb_strtolower($order->id.' '.$customer) }}">
<td>
@if ($order->isApiOrder())
<input type="checkbox" disabled title="{{ __('API-Bestellung — Dokumente liegen im externen Shop') }}">
@ -141,7 +141,13 @@
@endif
</td>
<td>
<a href="{{ route('admin_sales_detail', $order->id) }}">#{{ $order->id }}</a>
{{-- Verkäufe-Detail verlangt admin ≥ 7 — Versand-Mitarbeiter (copyreader)
bekämen sonst einen stillen Redirect (Review 20.07.2026) --}}
@if (auth()->user()->isAdmin())
<a href="{{ route('admin_sales_detail', $order->id) }}">#{{ $order->id }}</a>
@else
<span class="font-weight-bold">#{{ $order->id }}</span>
@endif
@if ($order->isApiOrder())
<span class="wawi-pill" style="background:rgba(24,28,33,.06);color:#6c757d;">API</span>
@elseif ($order->user_shop_id)
@ -243,7 +249,7 @@
<td colspan="7" class="pl-4" style="background:rgba(24,28,33,.02);">
<strong class="small text-muted">{{ __('Packliste') }}</strong>
@if ($order->weight)
<span class="small text-muted">· {{ __('Gewicht') }}: {{ number_format($order->weight / 1000, 2, ',', '.') }} kg</span>
<span class="small text-muted">· {{ __('Gewicht') }}: {{ formatNumber($order->weight / 1000) }} kg</span>
@endif
<ul class="mb-1 small">
@foreach ($order->shopping_order_items as $item)
@ -275,9 +281,9 @@
</tbody>
</table>
</div>
@if (($listLimit ?? null) !== null && $orders->count() >= $listLimit)
@if (($listLimit ?? null) !== null && ($listTotal ?? 0) > $listLimit)
<p class="text-muted small px-3 pb-2 mb-0">
{{ __('Es werden die neuesten :n Bestellungen angezeigt.', ['n' => $listLimit]) }}
{{ __('Es werden die neuesten :n von :total Bestellungen angezeigt.', ['n' => $listLimit, 'total' => $listTotal]) }}
<a href="{{ route('admin.inventory.orders.index', array_filter(['all' => 1, 'filter' => $filter])) }}">{{ __('Alle anzeigen') }}</a>
</p>
@endif
@ -295,7 +301,7 @@
var show = term === '' || ($(this).data('search') || '').toString().indexOf(term) !== -1;
$(this).toggle(show);
if (!show) {
$('#of-items-' + $(this).find('a').first().text().replace('#', '')).addClass('d-none');
$('#of-items-' + $(this).data('order-id')).addClass('d-none');
}
});
});

View file

@ -139,6 +139,27 @@ test('board rendert fuer versand-mitarbeiter mit kacheln und packliste', functio
->assertSee('versandfertig');
});
test('versand-mitarbeiter sehen bestellnummer ohne verkaeufe-link', function () {
// Review 20.07.2026: admin_sales_detail verlangt admin >= 7 — für copyreader
// wäre der Link ein stiller Redirect nach /home
$order = ofMakeOrder();
$this->actingAs(ofMakeUser(1))
->get(route('admin.inventory.orders.index'))
->assertOk()
->assertSee('#'.$order->id)
->assertDontSee(route('admin_sales_detail', $order->id), false);
});
test('admins sehen den verkaeufe-link zur bestellung', function () {
$order = ofMakeOrder();
$this->actingAs(ofMakeUser(7))
->get(route('admin.inventory.orders.index'))
->assertOk()
->assertSee(route('admin_sales_detail', $order->id), false);
});
test('ohne copyreader-rechte kein zugriff', function () {
$this->actingAs(ofMakeUser(0))
->get(route('admin.inventory.orders.index'))