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:
parent
f28f4b4b68
commit
80d0687f95
5 changed files with 61 additions and 17 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | – |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue