Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 17 additions & 14 deletions backend/app/Http/Controllers/Web/Admin/AuditLogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', ''),
],
]);
}
Expand All @@ -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',
]);
}

Expand All @@ -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,
Expand All @@ -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') {
Expand All @@ -130,6 +132,7 @@ protected function formatChanges(AuditLog $log): string
$oldValue = $log->before_state[$field] ?? 'null';
$changes[] = "{$field}: {$oldValue} -> {$newValue}";
}

return implode('; ', $changes);
}

Expand Down
23 changes: 18 additions & 5 deletions backend/app/Http/Controllers/Web/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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),
]));
}
}

Expand Down
10 changes: 10 additions & 0 deletions backend/config/logging.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
}
4 changes: 2 additions & 2 deletions backend/lang/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
38 changes: 38 additions & 0 deletions backend/tests/Feature/Web/Admin/AuditLogPageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Tests\Feature\Web\Admin;

use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/** The audit log page lists what was changed; some rows name no entity at all. */
class AuditLogPageTest extends TestCase
{
use RefreshDatabase;

public function test_rows_without_an_entity_do_not_break_the_type_filter(): void
{
Role::findOrCreate('Admin', 'web');
$admin = User::factory()->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);
}
}
15 changes: 14 additions & 1 deletion backend/tests/Feature/Web/ProductionScenarioImportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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' => [
Expand Down
16 changes: 16 additions & 0 deletions backend/tests/Feature/Web/SettingsConfigurationImportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading