diff --git a/CHANGELOG.md b/CHANGELOG.md index f870ce6a..2c30dfda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- Settings → Import no longer sends a live update per imported row. A plant file with its production scenario touches a thousand rows or more, each update a synchronous call to the websocket server after the commit; on a slower host that ran the request past its time limit and answered an empty error 500, although the data had been saved. Open screens pick the new data up when they next load, as after a data import. A failed import now names its reason on the page (the screen is admin-only) instead of a generic message, and a log that cannot be written no longer turns that answer into an empty 500. +- The audit log page no longer trips a PHP deprecation on rows that name no entity (a login, a settings change), and the `deprecations` log channel is declared in `config/logging.php`: under Octane the channel the framework added at runtime never reached the log manager, so every deprecation notice ended in "Log [deprecations] is not defined" through the emergency logger. + - SN label station per workstation: the workstation form picks what the station offers there - start units on PSN, bind the serial label, issue numbers, components, sub-assemblies - so the first bench shows only "Start unit on PSN" and the labelling bench only the two scans. Nothing ticked keeps everything; supervisors, admins and the whole-line view always see everything. - The operator's work order view lists the order's serial units - serial number, PSN, status, last event and carton/pallet - next to the step quantities. - Packing station: on "auto" a unit of another order than the bench's open carton goes into a carton of its own order (the operator's open one, else a new one) instead of being refused; the scan field reads "PSN or serial number", and the EAN cards show only when EAN packing is in use. diff --git a/backend/app/Http/Controllers/Web/Admin/AuditLogController.php b/backend/app/Http/Controllers/Web/Admin/AuditLogController.php index 50aa7c04..4c320b2d 100644 --- a/backend/app/Http/Controllers/Web/Admin/AuditLogController.php +++ b/backend/app/Http/Controllers/Web/Admin/AuditLogController.php @@ -31,15 +31,17 @@ public function index(Request $request) if ($request->filled('start_date') && $request->filled('end_date')) { $query->whereBetween('created_at', [ - $request->start_date . ' 00:00:00', - $request->end_date . ' 23:59:59', + $request->start_date.' 00:00:00', + $request->end_date.' 23:59:59', ]); } $auditLogs = $query->paginate(20); // Get distinct entity types for filter + // A row with no entity (a login, a settings change) has no type to list. $entityTypes = AuditLog::selectRaw('DISTINCT entity_type') + ->whereNotNull('entity_type') ->pluck('entity_type') ->map(function ($type) { return class_basename($type); @@ -52,15 +54,15 @@ public function index(Request $request) $users = User::select('id', 'name', 'username')->orderBy('name')->get(); return \Inertia\Inertia::render('admin/AuditLogs', [ - 'auditLogs' => $auditLogs, + 'auditLogs' => $auditLogs, 'entityTypes' => $entityTypes, - 'users' => $users, - 'filters' => [ + 'users' => $users, + 'filters' => [ 'entity_type' => $request->input('entity_type', ''), - 'user_id' => $request->input('user_id', ''), - 'action' => $request->input('action', ''), - 'start_date' => $request->input('start_date', ''), - 'end_date' => $request->input('end_date', ''), + 'user_id' => $request->input('user_id', ''), + 'action' => $request->input('action', ''), + 'start_date' => $request->input('start_date', ''), + 'end_date' => $request->input('end_date', ''), ], ]); } @@ -85,8 +87,8 @@ public function export(Request $request) if ($request->filled('start_date') && $request->filled('end_date')) { $query->whereBetween('created_at', [ - $request->start_date . ' 00:00:00', - $request->end_date . ' 23:59:59', + $request->start_date.' 00:00:00', + $request->end_date.' 23:59:59', ]); } @@ -101,7 +103,7 @@ public function export(Request $request) $csv .= Csv::row([ $log->created_at->toIso8601String(), $log->user ? $log->user->username : 'System', - class_basename($log->entity_type) . ' #' . $log->entity_id, + class_basename($log->entity_type).' #'.$log->entity_id, $log->action, $log->ip_address ?? 'N/A', $changes, @@ -110,14 +112,14 @@ class_basename($log->entity_type) . ' #' . $log->entity_id, return Response::make($csv, 200, [ 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="audit_log_' . date('Y-m-d_H-i-s') . '.csv"', + 'Content-Disposition' => 'attachment; filename="audit_log_'.date('Y-m-d_H-i-s').'.csv"', ]); } protected function formatChanges(AuditLog $log): string { if ($log->action === 'created') { - return 'Created with ' . count($log->after_state ?? []) . ' fields'; + return 'Created with '.count($log->after_state ?? []).' fields'; } if ($log->action === 'deleted') { @@ -130,6 +132,7 @@ protected function formatChanges(AuditLog $log): string $oldValue = $log->before_state[$field] ?? 'null'; $changes[] = "{$field}: {$oldValue} -> {$newValue}"; } + return implode('; ', $changes); } diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index cfa5529e..ee4a03b5 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -11,6 +11,7 @@ use App\Services\Settings\ScenarioException; use App\Support\TabRegistry; use App\Support\TimezoneRegistry; +use App\Sync\CollectionBroadcaster; use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; @@ -711,10 +712,16 @@ public function importSettings(ImportSettingsRequest $request, ConfigurationImpo } try { - [$imported, $events] = DB::transaction(fn () => [ + // Without live deltas: a plant file and its scenario touch a thousand + // rows or more, and each delta is a synchronous call to the websocket + // server made after the commit - on a slower host enough of them to + // run the request past its time limit (an empty 500, the data saved). + // The admin's page reloads after the import; other screens refresh + // from their snapshot, as after the data import (which mutes too). + [$imported, $events] = CollectionBroadcaster::muted(fn () => DB::transaction(fn () => [ $importer->import($data), is_array($data['scenario'] ?? null) ? $scenario->import($data['scenario'], $request->user()) : null, - ]); + ])); Cache::flush(); return back()->with('success', $events === null @@ -723,9 +730,15 @@ public function importSettings(ImportSettingsRequest $request, ConfigurationImpo } catch (ScenarioException $e) { return back()->with('error', __('The production scenario was not imported - nothing from the file was saved. :message', ['message' => $e->getMessage()])); } catch (\Throwable $e) { - report($e); - - return back()->with('error', __('Failed to import settings. Please check the file and try again.')); + // Logged when the log can be written; a broken log must not turn the + // answer into an empty 500 that hides why nothing was imported. + rescue(fn () => report($e), report: false); + + // Admins only reach this screen: the reason is theirs to see, so a + // failed import on a server can be read without its logs. + return back()->with('error', __('Failed to import settings - nothing from the file was saved. :message', [ + 'message' => class_basename($e).': '.\Illuminate\Support\Str::limit($e->getMessage(), 300), + ])); } } diff --git a/backend/config/logging.php b/backend/config/logging.php index 9e998a49..9b8a199d 100644 --- a/backend/config/logging.php +++ b/backend/config/logging.php @@ -118,6 +118,16 @@ 'replace_placeholders' => true, ], + // Declared here rather than left for the framework to add at runtime: + // under Octane that runtime copy lands in the request's config, not the + // one the log manager reads, and every deprecation notice then failed + // with "Log [deprecations] is not defined" through the emergency logger. + 'deprecations' => [ + 'driver' => 'stack', + 'channels' => [env('LOG_DEPRECATIONS_CHANNEL', 'null')], + 'ignore_exceptions' => false, + ], + 'null' => [ 'driver' => 'monolog', 'handler' => NullHandler::class, diff --git a/backend/lang/en.json b/backend/lang/en.json index 2d568ee9..c8c1781f 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -1215,7 +1215,6 @@ "Failed to download update package. Check server internet connection.": "Failed to download update package. Check server internet connection.", "Failed to download update: :error": "Failed to download update: :error", "Failed to enter maintenance mode before update: :error": "Failed to enter maintenance mode before update: :error", - "Failed to import settings. Please check the file and try again.": "Failed to import settings. Please check the file and try again.", "Failed to load": "Failed to load", "Failed to open update package.": "Failed to open update package.", "Failed to save correction. Please try again.": "Failed to save correction. Please try again.", @@ -6876,5 +6875,6 @@ "Enter EAN code": "Enter EAN code", "Module \":name\" requires OpenMES :required; this installation is :current.": "Module \":name\" requires OpenMES :required; this installation is :current.", "Module \":name\" could not be uninstalled: :msg": "Module \":name\" could not be uninstalled: :msg", - "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data." + "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.", + "Failed to import settings - nothing from the file was saved. :message": "Failed to import settings - nothing from the file was saved. :message" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 74141ecd..9985b3b6 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1188,7 +1188,6 @@ "Members": "Członkowie", "Meeting": "Spotkanie", "no WO": "brak zlecenia", - "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie.", "No results": "Brak wyników", "Quantity per Unit": "Ilość na jednostkę", "system is configured for": "system jest skonfigurowany na", @@ -6876,5 +6875,6 @@ "Enter EAN code": "Wpisz kod EAN", "Module \":name\" requires OpenMES :required; this installation is :current.": "Moduł \":name\" wymaga OpenMES :required; ta instalacja to :current.", "Module \":name\" could not be uninstalled: :msg": "Nie udało się odinstalować modułu \":name\": :msg", - "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Moduł \":name\" odinstalowany. Jego tabele w bazie zostały nienaruszone; ponowna instalacja zachowa dane." + "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Moduł \":name\" odinstalowany. Jego tabele w bazie zostały nienaruszone; ponowna instalacja zachowa dane.", + "Failed to import settings - nothing from the file was saved. :message": "Import ustawień nie powiódł się - nic z pliku nie zostało zapisane. :message" } diff --git a/backend/tests/Feature/Web/Admin/AuditLogPageTest.php b/backend/tests/Feature/Web/Admin/AuditLogPageTest.php new file mode 100644 index 00000000..920e5784 --- /dev/null +++ b/backend/tests/Feature/Web/Admin/AuditLogPageTest.php @@ -0,0 +1,38 @@ +create(); + $admin->assignRole('Admin'); + AuditLog::create(['user_id' => $admin->id, 'entity_type' => \App\Models\WorkOrder::class, 'entity_id' => 1, 'action' => 'updated']); + AuditLog::create(['user_id' => $admin->id, 'entity_type' => null, 'entity_id' => null, 'action' => 'login']); + + $this->actingAs($admin)->get(route('admin.audit-logs')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->where('entityTypes', ['WorkOrder'])); + } + + public function test_the_deprecations_log_channel_is_declared(): void + { + // Declared in config, not added at runtime: under Octane the runtime copy + // never reached the log manager ("Log [deprecations] is not defined"). + $this->assertNotNull(config('logging.channels.deprecations')); + app('log')->channel('deprecations')->warning('probe'); + $this->assertTrue(true); + } +} diff --git a/backend/tests/Feature/Web/ProductionScenarioImportTest.php b/backend/tests/Feature/Web/ProductionScenarioImportTest.php index a674961e..548f2390 100644 --- a/backend/tests/Feature/Web/ProductionScenarioImportTest.php +++ b/backend/tests/Feature/Web/ProductionScenarioImportTest.php @@ -100,7 +100,7 @@ public function test_a_scenario_is_replayed_through_the_line_at_its_own_times(): ['at' => '+1m', 'do' => 'step_log', 'order' => 'WO-SC-1', 'step' => 1, 'good' => 2, 'by' => 'op-a'], ['do' => 'step_complete', 'order' => 'WO-SC-1', 'step' => 1, 'by' => 'op-a'], ['at' => '+10m', 'do' => 'step_start', 'order' => 'WO-SC-1', 'step' => 2], - ['at' => '+2m', 'do' => 'test', 'unit' => 'u1', 'workstation' => 'SC-T', 'result' => 'pass', 'steps' => [['name' => 'Voltage', 'value' => 3.9, 'unit' => 'V', 'low' => 3.6, 'high' => 4.2]]], + ['at' => '+2m', 'do' => 'test', 'unit' => 'u1', 'workstation' => 'SC-T', 'result' => 'pass', 'steps' => [['name' => 'Voltage', 'value' => 5.02, 'unit' => 'V', 'low' => 4.75, 'high' => 5.25]]], ['at' => '+2m', 'do' => 'test', 'unit' => 'u2', 'workstation' => 'SC-T', 'result' => 'fail'], ['at' => '+5m', 'do' => 'test', 'unit' => 'u2', 'workstation' => 'SC-T', 'result' => 'pass'], ['at' => '+1m', 'do' => 'block', 'unit' => 'u2', 'reason' => 'SC-NC', 'note' => 'Scratch', 'workstation' => 'SC-T'], @@ -171,6 +171,19 @@ public function test_a_scenario_is_replayed_through_the_line_at_its_own_times(): $this->assertSame('2026-09-20 12:00:00', now()->format('Y-m-d H:i:s')); } + public function test_the_import_sends_no_live_deltas(): void + { + // A plant file touches a thousand rows or more; one synchronous websocket + // call each, after the commit, ran a slower host past its request limit. + \Illuminate\Support\Facades\Event::fake([\App\Events\CollectionChanged::class]); + + $this->import(['scenario' => ['requires' => ['production_flow_mode' => 'transfer'], 'events' => $this->order()]]) + ->assertSessionHas('success'); + + $this->assertDatabaseHas('work_orders', ['order_no' => 'WO-SC-1']); + \Illuminate\Support\Facades\Event::assertNotDispatched(\App\Events\CollectionChanged::class); + } + public function test_orders_are_planned_ahead_on_the_schedule_and_overlaps_are_refused(): void { $plan = fn (array $second) => ['scenario' => ['events' => [ diff --git a/backend/tests/Feature/Web/SettingsConfigurationImportTest.php b/backend/tests/Feature/Web/SettingsConfigurationImportTest.php index e6207486..d865ddac 100644 --- a/backend/tests/Feature/Web/SettingsConfigurationImportTest.php +++ b/backend/tests/Feature/Web/SettingsConfigurationImportTest.php @@ -88,6 +88,22 @@ public function test_guests_and_non_admins_cannot_import_and_a_file_is_required( $this->actingAs($this->admin)->post(route('settings.import'), ['settings_file' => UploadedFile::fake()->createWithContent('x.json', '{broken')])->assertSessionHas('error'); } + public function test_a_failed_import_says_why_and_saves_nothing(): void + { + // Something the importer did not foresee: the admin reads the reason on + // the page (a server's logs may be out of reach), and nothing is kept. + $this->mock(\App\Services\Settings\ConfigurationImporter::class, function ($mock) { + $mock->shouldReceive('import')->andReturnUsing(function () { + \App\Models\Line::factory()->create(['code' => 'HALF-DONE']); + throw new \RuntimeException('column "surprise" does not exist'); + }); + }); + + $this->actingAs($this->admin)->post(route('settings.import'), ['settings_file' => $this->file(['lines' => []])]) + ->assertSessionHas('error', fn ($m) => str_contains($m, 'RuntimeException') && str_contains($m, 'column "surprise" does not exist')); + $this->assertDatabaseMissing('lines', ['code' => 'HALF-DONE']); + } + public function test_references_land_on_the_rows_the_file_created_whatever_ids_they_get(): void { // Rows already in the database push the new ids away from the file's.