Warenwirtschaft: Review-Fix — Indizes + schlanke API-Bestellungen-Seite

Indizes auf shopping_orders.shipped/shipped_at/created_at (Board-Filter,
Tages-Kacheln) und products.wp_number (SKU-Auflösung). Die
API-Bestellungen-Seite lud das komplette JSON-payload für alle 500 Zeilen
und serialisierte es in Inline-Attribute; jetzt persistierte
positions_count, Liste ohne payload, Inhalt on-demand über show-Endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-20 13:02:47 +00:00
parent f4256bfc89
commit 50243a07b4
8 changed files with 172 additions and 20 deletions

View file

@ -20,13 +20,17 @@ class ApiOrderLogController extends Controller
{
$limit = $request->query('all') ? null : self::INDEX_LIMIT;
// Ohne payload (Review 20.07.2026): der Inhalt wird erst im Modal per show()
// nachgeladen, statt für alle Zeilen mehrere MB JSON in die Seite zu rendern.
$logs = ApiOrderLog::query()
->with(['shoppingOrder', 'shoppingUser'])
->with(['shoppingUser:id,billing_firstname,billing_lastname'])
->select(['id', 'shopping_order_id', 'shopping_user_id', 'wp_order_number', 'positions_count', 'has_errors', 'error_summary', 'created_at'])
->orderByDesc('id')
->when($limit !== null, fn ($query) => $query->limit($limit))
->get();
$errorCount = ApiOrderLog::query()->where('has_errors', true)->count();
$logTotal = ApiOrderLog::query()->count();
// SKU-Konsistenz (Plan §4): doppelte WP-Nummern brechen die Zuordnung,
// aktive Produkte ohne WP-Nummer können nie per API zugeordnet werden.
@ -57,9 +61,24 @@ class ApiOrderLogController extends Controller
return view('admin.inventory.api-orders.index', [
'logs' => $logs,
'listLimit' => $limit,
'listTotal' => $logTotal,
'errorCount' => $errorCount,
'duplicateSkuProducts' => $duplicateSkuProducts,
'missingSkuProducts' => $missingSkuProducts,
]);
}
/**
* Inhalt einer Übertragung on-demand fürs Modal (Review 20.07.2026): liefert die
* Positionen bzw. den Roh-Body als JSON, damit die Liste ohne payload auskommt.
*/
public function show(ApiOrderLog $apiOrderLog)
{
return response()->json([
'lines' => $apiOrderLog->payload['lines'] ?? [],
'raw' => isset($apiOrderLog->payload['raw'])
? json_encode($apiOrderLog->payload['raw'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: null,
]);
}
}

View file

@ -19,6 +19,7 @@ class ApiOrderLog extends Model
'shopping_user_id',
'wp_order_number',
'endpoint',
'positions_count',
'payload',
'has_errors',
'error_summary',
@ -51,6 +52,7 @@ class ApiOrderLog extends Model
'shopping_user_id' => $shoppingUser?->id,
'wp_order_number' => is_numeric($wpOrderNumber) ? (int) $wpOrderNumber : null,
'endpoint' => 'order',
'positions_count' => 0,
'payload' => ['raw' => is_string($rawOrder) ? $rawOrder : json_decode(json_encode($rawOrder), true)],
'has_errors' => true,
'error_summary' => $summary,
@ -95,6 +97,7 @@ class ApiOrderLog extends Model
'shopping_user_id' => $shoppingUser->id,
'wp_order_number' => is_numeric($wpOrderNumber) ? (int) $wpOrderNumber : null,
'endpoint' => 'order',
'positions_count' => count($payloadLines),
'payload' => ['lines' => $payloadLines],
'has_errors' => $failedCount > 0,
'error_summary' => $failedCount > 0

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Review 20.07.2026: Indizes für die neuen WaWi-Abfragemuster.
* - shopping_orders.shipped: Abwicklungs-Board filtert whereIn('shipped', [0,1]).
* - shopping_orders.shipped_today/created_today: Tages-Kacheln (whereBetween).
* - products.wp_number: SKU-Auflösung der API-Bestellungen (whereWpNumber).
* Vorher gab es nur Indizes auf txaction/shipping_option bzw. keinen auf wp_number.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->index('shipped');
$table->index('shipped_at');
$table->index('created_at');
});
Schema::table('products', function (Blueprint $table) {
$table->index('wp_number');
});
}
public function down(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->dropIndex(['shipped']);
$table->dropIndex(['shipped_at']);
$table->dropIndex(['created_at']);
});
Schema::table('products', function (Blueprint $table) {
$table->dropIndex(['wp_number']);
});
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Review 20.07.2026: Die Übersicht der API-Bestellungen lud für jede Zeile das
* komplette JSON-payload nur, um die Positionszahl anzuzeigen (und serialisierte
* es zusätzlich in Inline-Attribute). Die Zahl wird jetzt gezählt persistiert,
* die Liste kommt ohne payload aus und der Inhalt wird erst im Modal nachgeladen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('api_order_logs', function (Blueprint $table) {
$table->unsignedSmallInteger('positions_count')->default(0)->after('endpoint');
});
// Backfill aus dem bestehenden payload (Anzahl der Zeilen).
foreach (DB::table('api_order_logs')->select('id', 'payload')->cursor() as $row) {
$payload = json_decode($row->payload ?? '', true);
$count = is_array($payload['lines'] ?? null) ? count($payload['lines']) : 0;
DB::table('api_order_logs')->where('id', $row->id)->update(['positions_count' => $count]);
}
}
public function down(): void
{
Schema::table('api_order_logs', function (Blueprint $table) {
$table->dropColumn('positions_count');
});
}
};

View file

@ -281,6 +281,7 @@ Der Warenkorb kommt also **per Knopfdruck** (Fulfillment-Übergabe), nicht autom
| 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) |
| 20.07.2026 | **Review-Fix: Rohstoffbestand — Badge vs. Rezeptur-Scope** | Der 17.07. gedrehte Default-Scope „recipe" blendet rezepturlose Rohstoffe aus, das Sidebar-Badge (`criticalIngredientCount`) zählt aber alle aktiven — ein kritischer Rohstoff außerhalb der Rezepturen ergab Badge „1", Seite „0 kritisch". Jetzt zeigt die recipe-Ansicht einen **Hinweis** („N kritische außerhalb der Herstellerrezepturen — Alle anzeigen"), sobald das Badge mehr zählt als die Liste; Empty-State-Text scope-abhängig korrigiert. Badge-Zählregel bewusst unverändert (global) — der Hinweis schließt die Lücke, ohne dass eine kritische Neuentwicklung ganz aus dem Badge fällt. | `RawMaterialStockTest` +1 (Hinweis im recipe-Scope, keiner im all-Scope; 20 grün) |
| 20.07.2026 | **Review-Fix: Performance — Indizes + schlanke API-Log-Seite** | **Indizes** (Migration `2026_07_20_100000`): `shopping_orders.shipped`/`shipped_at`/`created_at` (Board-Filter + Tages-Kacheln, vorher nur `txaction`/`shipping_option`) und `products.wp_number` (SKU-Auflösung der API-Bestellungen, vorher ohne Index). **API-Bestellungen-Seite:** lud für jede der bis zu 500 Zeilen das komplette JSON-`payload` und serialisierte es zusätzlich pretty-printed in Inline-`data`-Attribute (Mehr-MB-HTML). Jetzt: neue Spalte `positions_count` (Migration `2026_07_20_110000` mit Backfill), Liste selektiert **ohne payload**, der Inhalt lädt erst beim Öffnen des Modals über neuen `show`-Endpoint (`api-orders/{id}`); Cap-Hinweis „neueste :n von :total". | `ApiOrderLogTest` +2 (positions_count gesetzt, Inhalt on-demand; 11 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

@ -73,7 +73,6 @@
<tbody>
@forelse ($logs as $log)
@php
$lines = $log->payload['lines'] ?? [];
$customer = $log->shoppingUser
? trim(($log->shoppingUser->billing_firstname ?? '').' '.($log->shoppingUser->billing_lastname ?? ''))
: '';
@ -93,7 +92,7 @@
<span class="text-muted">—</span>
@endif
</td>
<td class="text-right">{{ count($lines) ?: '—' }}</td>
<td class="text-right">{{ $log->positions_count ?: '—' }}</td>
<td>
@if ($log->has_errors)
<span class="wawi-pill wawi-pill--danger">{{ __('Fehler') }}</span>
@ -106,14 +105,13 @@
</td>
<td class="text-right">
<button type="button" class="btn btn-sm btn-outline-secondary js-api-log-view"
data-url="{{ route('admin.inventory.api-orders.show', $log->id) }}"
data-meta="{{ json_encode([
'received' => $log->created_at->format('d.m.Y H:i'),
'wp_order_number' => $log->wp_order_number,
'customer' => $customer,
'summary' => $log->error_summary,
'raw' => isset($log->payload['raw']) ? json_encode($log->payload['raw'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : null,
]) }}"
data-lines="{{ json_encode($lines) }}">
]) }}">
<span class="far fa-eye mr-1"></span>{{ __('Ansehen') }}
</button>
</td>
@ -131,9 +129,9 @@
</tbody>
</table>
</div>
@if (($listLimit ?? null) !== null && $logs->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 Übertragungen angezeigt.', ['n' => $listLimit]) }}
{{ __('Es werden die neuesten :n von :total Übertragungen angezeigt.', ['n' => $listLimit, 'total' => $listTotal]) }}
<a href="{{ route('admin.inventory.api-orders.index', ['all' => 1]) }}">{{ __('Alle anzeigen') }}</a>
</p>
@endif
@ -197,17 +195,7 @@
return message;
}
$('.js-api-log-view').on('click', function () {
var meta = $(this).data('meta') || {};
var lines = $(this).data('lines') || [];
$('#api-log-modal-meta').text(
'{{ __('Eingegangen') }}: ' + (meta.received || '—')
+ ' · {{ __('WP-Bestellnr.') }}: ' + (meta.wp_order_number || '—')
+ (meta.customer ? ' · {{ __('Kunde') }}: ' + meta.customer : '')
);
$('#api-log-modal-summary').toggleClass('d-none', !meta.summary).text(meta.summary || '');
function renderLines(lines, raw) {
var $body = $('#api-log-modal-lines').empty();
$('#api-log-modal-lines-wrap').toggleClass('d-none', lines.length === 0);
$.each(lines, function (_, line) {
@ -222,10 +210,26 @@
$tr.append($('<td>').text(messages).addClass(line.failed ? 'text-danger' : 'text-muted'));
$body.append($tr);
});
$('#api-log-modal-raw').toggleClass('d-none', !raw).text(raw || '');
}
$('#api-log-modal-raw').toggleClass('d-none', !meta.raw).text(meta.raw || '');
$('.js-api-log-view').on('click', function () {
var meta = $(this).data('meta') || {};
var url = $(this).data('url');
$('#api-log-modal-meta').text(
'{{ __('Eingegangen') }}: ' + (meta.received || '—')
+ ' · {{ __('WP-Bestellnr.') }}: ' + (meta.wp_order_number || '—')
+ (meta.customer ? ' · {{ __('Kunde') }}: ' + meta.customer : '')
);
$('#api-log-modal-summary').toggleClass('d-none', !meta.summary).text(meta.summary || '');
// Inhalt erst beim Öffnen laden (Review 20.07.2026)
renderLines([], null);
$('#api-log-modal').modal('show');
$.getJSON(url, function (data) {
renderLines(data.lines || [], data.raw || null);
});
});
});
</script>

View file

@ -331,6 +331,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
Route::get('product-development', [InventoryProductDevelopmentController::class, 'index'])->name('product-development');
Route::get('api-orders', [InventoryApiOrderLogController::class, 'index'])->name('api-orders.index');
Route::get('api-orders/{apiOrderLog}', [InventoryApiOrderLogController::class, 'show'])->name('api-orders.show');
Route::get('orders', [InventoryOrderFulfillmentController::class, 'index'])->name('orders.index');
Route::post('orders/documents/batch', [InventoryOrderFulfillmentController::class, 'batchDocuments'])->name('orders.documents.batch');
Route::post('orders/{id}/status', [InventoryOrderFulfillmentController::class, 'updateStatus'])->name('orders.status');

View file

@ -224,6 +224,53 @@ test('uebersicht rendert eintraege mit fehler-markierung', function () {
->assertSee('1 von 1 Positionen ohne Produktzuordnung');
});
test('positions_count wird bei der uebertragung gesetzt', function () {
Mail::fake();
Passport::actingAs(apilogMakeUser());
apilogMakeProduct('Apilog OnDemand', 500);
apilogMakeShoppingUser(4720);
$this->postJson('https://api.'.config('app.api_domain').'/wp/order', [
'wp_order_number' => 4720,
'wp_order' => [
['article' => 500, 'qty' => 2, 'price' => 1990],
['article' => 999, 'qty' => 1, 'price' => 100],
],
])->assertOk();
expect(ApiOrderLog::query()->sole()->positions_count)->toBe(2);
});
test('der inhalt einer uebertragung wird on-demand geliefert', function () {
// Review 20.07.2026: die Liste kommt ohne payload aus, der Inhalt lädt per show()
$shoppingUser = apilogMakeShoppingUser(4721);
$log = ApiOrderLog::query()->create([
'shopping_user_id' => $shoppingUser->id,
'wp_order_number' => 4721,
'endpoint' => 'order',
'positions_count' => 2,
'payload' => ['lines' => [
['article' => 500, 'qty' => 2, 'price' => 1990, 'product_id' => 1, 'product_name' => 'X', 'messages' => [], 'failed' => false],
['article' => 999, 'qty' => 1, 'price' => 100, 'product_id' => null, 'product_name' => null, 'messages' => ['article not found'], 'failed' => true],
]],
'has_errors' => true,
'error_summary' => '1 von 2 Positionen ohne Produktzuordnung',
]);
// Die Liste selektiert bewusst ohne payload
$listed = ApiOrderLog::query()
->select(['id', 'shopping_order_id', 'shopping_user_id', 'wp_order_number', 'positions_count', 'has_errors', 'error_summary', 'created_at'])
->first();
expect($listed->getAttributes())->not->toHaveKey('payload');
$this->actingAs(apilogMakeUser(1))
->getJson(route('admin.inventory.api-orders.show', $log->id))
->assertOk()
->assertJsonPath('lines.0.article', 500)
->assertJsonPath('lines.1.failed', true)
->assertJsonCount(2, 'lines');
});
test('doppelte wp-nummern aktiver produkte werden gewarnt', function () {
apilogMakeProduct('Apilog Duplikat A', 300);
apilogMakeProduct('Apilog Duplikat B', 300);