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:
parent
f4256bfc89
commit
50243a07b4
8 changed files with 172 additions and 20 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 | – |
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue