From 18a357bf2a3c5df8c91806c2b278f3bf834fbda2 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Sat, 25 Jul 2026 17:46:28 +0200 Subject: [PATCH 1/4] feat(pallets): finish changing location of pallet --- CHANGELOG.md | 1 + backend/app/Enums/PalletStatus.php | 25 ++ .../Web/Admin/PalletController.php | 28 +- .../Logistics/PalletMovementController.php | 70 ++- .../AssignPalletDestinationRequest.php | 45 ++ .../Concerns/ValidatesPalletLogistics.php | 59 +++ backend/app/Http/Requests/PalletRequest.php | 1 + .../Requests/StorePalletMovementRequest.php | 43 +- backend/app/Models/Pallet.php | 19 + backend/app/Models/PalletMovement.php | 2 + .../Logistics/PalletMovementService.php | 119 +++++- backend/app/Sync/ShapeRegistry.php | 2 +- .../Shapes/PalletMovementsRecentShape.php | 2 + backend/database/factories/PalletFactory.php | 10 + .../factories/PalletMovementFactory.php | 2 + ...00000_add_destination_to_pallets_table.php | 43 ++ ..._destination_to_pallet_movements_table.php | 34 ++ backend/lang/en.json | 17 +- backend/lang/pl.json | 17 +- .../js/Pages/admin/pallet-movements/Index.jsx | 12 + .../js/Pages/admin/pallets/Create.jsx | 1 + .../resources/js/Pages/admin/pallets/Edit.jsx | 1 + .../js/Pages/admin/pallets/Index.jsx | 1 + .../js/Pages/admin/pallets/PalletForm.jsx | 6 + .../js/Pages/logistics/MovePallet.jsx | 30 +- .../resources/js/Pages/logistics/Pallets.jsx | 213 ++++++++++ backend/resources/js/layouts/adminNav.js | 1 + backend/routes/web.php | 5 + .../Web/Logistics/PalletDestinationTest.php | 399 ++++++++++++++++++ 29 files changed, 1128 insertions(+), 80 deletions(-) create mode 100644 backend/app/Http/Requests/AssignPalletDestinationRequest.php create mode 100644 backend/app/Http/Requests/Concerns/ValidatesPalletLogistics.php create mode 100644 backend/database/migrations/2026_07_25_100000_add_destination_to_pallets_table.php create mode 100644 backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php create mode 100644 backend/resources/js/Pages/logistics/Pallets.jsx create mode 100644 backend/tests/Feature/Web/Logistics/PalletDestinationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 95994f59..08027c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- **Pallet status, location & destination for logistics tracking** *([#101](https://github.com/Mes-Open/OpenMes/issues/101))*: a pallet now carries not just **where it is** but **where it should go**. Alongside the existing status (open / closed / shipped) and current **location**, pallets gain a **destination** and an **arrival stamp**, so logistics can tell an idle pallet from one that is **in transit** and one that has **reached its target**. The destination is **cleared on arrival**: moving a pallet onto its destination completes the run and stamps `arrived_at`, and moving on afterwards (or re-routing it) voids the stale arrival — so transit state is a property of the row, not a string comparison the UI has to redo. A new tablet-friendly **Pallet Logistics** view (Packaging → Pallet Logistics, `Operator`/`Supervisor`/`Admin`) lists every pallet live with **status, location, destination, transit state and arrival**, filterable by status and searchable by location/destination, plus a **"Set destination"** card that re-routes a pallet **without moving it** (or clears its target). The **Move Pallet** terminal gains an optional **new destination** field so a re-route can be booked with the move, and flags when the entered location will complete the pallet's assigned destination. **Destination changes share the existing append-only `pallet_movements` ledger** (new `from_destination` / `to_destination` columns), so one timeline answers who moved a pallet *and* who redirected it — a row whose from/to locations match reads as a re-route, one whose destination went from set to empty reads as an arrival; the admin **Pallet Movements** history surfaces both. Every mutation goes through `PalletMovementService` under a row lock, so concurrent terminals can't interleave a stale "from" snapshot. Existing pallets are unaffected (both new columns are nullable, destination defaults to unset). New `pallets.destination` + `pallets.arrived_at` and `pallet_movements.from_destination` + `to_destination` columns, `PalletMovementService::assignDestination()`, `AssignPalletDestinationRequest`, `Pallet::isInTransit()`; `destination`/`arrived_at` added to the `pallets` Electric shape and the destination pair to `PalletMovementsRecentShape`. - **Module selection at first-login onboarding (Lightweight / Advanced / Custom)**: the setup wizard now opens with a **"Choose your modules"** step so a new admin turns on only the feature areas they need instead of getting the full menu. Three one-click choices over the existing module system: **Lightweight** (core production tracking + the **Work-Order History** report only — recommended for small shops), **Advanced** (adds reporting, materials & tracing, product engineering, companies, quality, maintenance, connectivity and packaging — the shop-floor operations set), or **Custom** (tick exactly which optional modules to enable). Core areas (Dashboard, Orders, Production essentials, Admin) are always on. The choice writes to the existing `system_settings.enabled_modules` via `ModuleRegistry`, so disabled areas are hidden from the nav and their routes 404 — and it's **changeable anytime** in Settings → System → Modules. New onboarding **Modules** step (now step 1 of 5) with `ModuleRegistry::PRESETS` + `modulesForPreset()`; reuses `TabRegistry`/`TabAccessMiddleware`. The optional-module catalog is now **fine-grained** so an install can keep, say, **Materials** without **Companies**, or the **Work-Order History** report without the **cost/scrap/non-conformance analytics**. Twelve independent toggles — **Reports**, **Advanced reports**, **Materials & tracing**, **Product engineering**, **Companies**, **Issues & reasons**, **Company structure**, **HR** (incl. employee scheduling), **Maintenance & QC**, **Connectivity**, **Packaging**, **Webhooks** — each maps to its own `TabRegistry` tab and role-access-matrix row, even when its pages render under the (core) Production or Reports nav groups. Existing installs that had explicitly customised their module set are backfilled on upgrade so previously-core areas stay visible (`2026_07_23_120000_expand_enabled_modules_for_granular_split`); installs that never customised default to all-on and are unaffected. - **Machine counter signals drive work-order progress** *([#46](https://github.com/Mes-Open/OpenMes/issues/46))*: a work order now carries a **counting source** — **Operator (manual)**, **Machine (automatic)** or **Both** — chosen on the admin/supervisor create & edit form (default **Operator**, so existing orders are unchanged). On a **machine-counted** order, good-count deltas from the Modbus / OPC UA / MQTT **signal pipeline** now flow through to `work_orders.produced_qty`: the count is attributed to the order via the **batch step currently in progress at the reporting workstation** (`MachineTag → workstation → in-progress `BatchStep` → batch → work order`), and the same **auto-start / auto-complete** side effects fire as for operator entry. The legacy MQTT topic-mapping path (`ActionExecutor::updateWorkOrderQty`) and the new pipeline now funnel through one shared `MachineProductionService`, which **honours the counting source** — so a machine mapping and operator entry can no longer both add to the same order (the **double-count** #46 described). On a machine-counted order the operator's manual **complete / shift-entry** is blocked (the machine is the single source of truth), and the operator queue shows a **"Machine" badge**; reject counts stay in the event log for now (scrap wiring is a follow-up). New `work_orders.counting_source` column (nullable, defaults to `operator`); `App\Services\WorkOrder\MachineProductionService`; `counting_source` added to the work-order Electric shapes. diff --git a/backend/app/Enums/PalletStatus.php b/backend/app/Enums/PalletStatus.php index 81ca0e96..99cf32aa 100644 --- a/backend/app/Enums/PalletStatus.php +++ b/backend/app/Enums/PalletStatus.php @@ -37,4 +37,29 @@ public static function values(): array { return array_map(fn (self $c) => $c->value, self::cases()); } + + /** + * value => label map, for pages that render a status column or filter from + * a raw string. The enum owns its labels so the admin and logistics views + * can't drift apart. + * + * @return array + */ + public static function labels(): array + { + return array_reduce( + self::cases(), + fn (array $carry, self $c) => $carry + [$c->value => $c->label()], + [], + ); + } + + /** @return list */ + public static function options(): array + { + return array_map( + fn (self $c) => ['value' => $c->value, 'label' => $c->label()], + self::cases(), + ); + } } diff --git a/backend/app/Http/Controllers/Web/Admin/PalletController.php b/backend/app/Http/Controllers/Web/Admin/PalletController.php index 36a154b7..4bc306f4 100644 --- a/backend/app/Http/Controllers/Web/Admin/PalletController.php +++ b/backend/app/Http/Controllers/Web/Admin/PalletController.php @@ -16,7 +16,7 @@ public function index() { return Inertia::render('admin/pallets/Index', [ 'workOrderNumbers' => WorkOrder::pluck('order_no', 'id'), - 'statusLabels' => $this->statusLabels(), + 'statusLabels' => PalletStatus::labels(), 'labelTemplates' => $this->activeLabelTemplates(), ]); } @@ -25,7 +25,7 @@ public function create() { return Inertia::render('admin/pallets/Create', [ 'workOrders' => $this->workOrderOptions(), - 'statuses' => $this->statusOptions(), + 'statuses' => PalletStatus::options(), ]); } @@ -41,10 +41,10 @@ public function edit(Pallet $pallet) { return Inertia::render('admin/pallets/Edit', [ 'pallet' => $pallet->only( - 'id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'location', 'erp_reference', + 'id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'location', 'destination', 'erp_reference', ), 'workOrders' => $this->workOrderOptions(), - 'statuses' => $this->statusOptions(), + 'statuses' => PalletStatus::options(), 'labelTemplates' => $this->activeLabelTemplates(), ]); } @@ -86,26 +86,6 @@ private function workOrderOptions() ]); } - /** @return array */ - private function statusLabels(): array - { - $labels = []; - foreach (PalletStatus::cases() as $case) { - $labels[$case->value] = $case->label(); - } - - return $labels; - } - - /** @return list */ - private function statusOptions(): array - { - return array_map( - fn (PalletStatus $c) => ['value' => $c->value, 'label' => $c->label()], - PalletStatus::cases(), - ); - } - private function activeLabelTemplates() { return LabelTemplate::where('is_active', true) diff --git a/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php b/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php index 8d8ddf19..e0a18a0b 100644 --- a/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php +++ b/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php @@ -4,6 +4,7 @@ use App\Enums\PalletStatus; use App\Http\Controllers\Controller; +use App\Http\Requests\AssignPalletDestinationRequest; use App\Http\Requests\StorePalletMovementRequest; use App\Models\Pallet; use App\Models\PalletMovement; @@ -13,9 +14,16 @@ use Inertia\Inertia; /** - * Shop-floor pallet movement (#103): a lightweight terminal where a logistics - * operator identifies themselves, picks a pallet, and records a physical move — - * plus the admin movement-history list surfacing who moved what. + * Everything that changes or reports where a pallet is and where it's going. + * + * #103: a lightweight shop-floor terminal where a logistics operator identifies + * themselves, picks a pallet and records a physical move, plus the admin + * movement-history list surfacing who moved what. + * #101: the logistics overview listing pallets by status/location/destination, + * and the endpoint that re-routes a pallet without moving it. + * + * The two concerns share their pallet and operator option sets (see the private + * helpers below), so they stay in one controller rather than drifting apart. */ class PalletMovementController extends Controller { @@ -24,14 +32,9 @@ public function terminal() { return Inertia::render('logistics/MovePallet', [ // Badge-style picker: active logistics operators only. - 'operators' => Worker::active()->logistics() - ->orderBy('name') - ->get(['id', 'code', 'name']), + 'operators' => $this->logisticsOperators(), // Movable pallets (not yet shipped), most recent first. - 'pallets' => Pallet::where('status', '!=', PalletStatus::Shipped->value) - ->orderByDesc('id') - ->limit(500) - ->get(['id', 'pallet_no', 'location']), + 'pallets' => $this->movablePallets(), ]); } @@ -43,12 +46,59 @@ public function store(StorePalletMovementRequest $request, PalletMovementService $request->string('to_location')->toString(), $request->user(), $request->input('notes'), + $request->toDestination(), ); return redirect()->route('logistics.move-pallet') ->with('success', __('Pallet movement recorded.')); } + /** + * Logistics overview (#101): every pallet with its status, where it is and + * where it should go. Rows stream from the `pallets` shape; the props are + * the lookup maps and the re-route picker's options. + */ + public function pallets() + { + return Inertia::render('logistics/Pallets', [ + 'statusLabels' => PalletStatus::labels(), + 'operators' => $this->logisticsOperators(), + 'movablePallets' => $this->movablePallets(), + ]); + } + + /** Re-route a pallet without moving it. */ + public function assignDestination(AssignPalletDestinationRequest $request, PalletMovementService $service) + { + $service->assignDestination( + Pallet::findOrFail($request->integer('pallet_id')), + $request->destination(), + $request->filled('worker_id') ? Worker::findOrFail($request->integer('worker_id')) : null, + $request->user(), + $request->input('notes'), + ); + + return redirect()->route('logistics.pallets') + ->with('success', __('Pallet destination updated.')); + } + + /** Pallets that can still be moved or re-routed (anything not yet shipped). */ + private function movablePallets() + { + return Pallet::where('status', '!=', PalletStatus::Shipped->value) + ->orderByDesc('id') + ->limit(500) + ->get(['id', 'pallet_no', 'location', 'destination']); + } + + /** The operators both logistics screens offer for attribution. */ + private function logisticsOperators() + { + return Worker::active()->logistics() + ->orderBy('name') + ->get(['id', 'code', 'name']); + } + /** Admin movement history — rows arrive client-side via the Electric shape. */ public function index() { diff --git a/backend/app/Http/Requests/AssignPalletDestinationRequest.php b/backend/app/Http/Requests/AssignPalletDestinationRequest.php new file mode 100644 index 00000000..27806b87 --- /dev/null +++ b/backend/app/Http/Requests/AssignPalletDestinationRequest.php @@ -0,0 +1,45 @@ + ['required', 'integer', $this->movablePalletExists()], + // Null/blank clears the destination — the pallet is no longer + // expected anywhere. + 'destination' => ['nullable', 'string', 'max:100'], + // Attribution is optional here: re-routing is a planning act that a + // dispatcher may do without a forklift operator involved. + 'worker_id' => ['nullable', 'integer', $this->activeLogisticsOperatorExists()], + 'notes' => ['nullable', 'string', 'max:1000'], + ]; + } + + public function messages(): array + { + return $this->palletLogisticsMessages(); + } + + /** The new destination, or null to clear it. */ + public function destination(): ?string + { + return $this->optionalText('destination'); + } +} diff --git a/backend/app/Http/Requests/Concerns/ValidatesPalletLogistics.php b/backend/app/Http/Requests/Concerns/ValidatesPalletLogistics.php new file mode 100644 index 00000000..2d5b670d --- /dev/null +++ b/backend/app/Http/Requests/Concerns/ValidatesPalletLogistics.php @@ -0,0 +1,59 @@ +where(fn ($q) => $q + ->whereNull('deleted_at') + ->where('status', '!=', PalletStatus::Shipped->value)); + } + + /** An active, non-deleted worker flagged as a logistics operator. */ + protected function activeLogisticsOperatorExists(): Exists + { + return Rule::exists('workers', 'id')->where(fn ($q) => $q + ->whereNull('deleted_at') + ->where('is_active', true) + ->where('is_logistics', true)); + } + + /** @return array */ + protected function palletLogisticsMessages(): array + { + return [ + 'pallet_id.exists' => __('Select a movable (not shipped) pallet.'), + 'worker_id.exists' => __('Select an active logistics operator.'), + ]; + } + + /** + * A submitted location/destination, or null when the field was left blank. + * + * Blank and absent must both reach the service as null — an empty string + * would be stored as a real (empty) destination. + */ + protected function optionalText(string $key): ?string + { + return $this->filled($key) ? $this->string($key)->toString() : null; + } +} diff --git a/backend/app/Http/Requests/PalletRequest.php b/backend/app/Http/Requests/PalletRequest.php index 974437ba..4778067f 100644 --- a/backend/app/Http/Requests/PalletRequest.php +++ b/backend/app/Http/Requests/PalletRequest.php @@ -28,6 +28,7 @@ public function rules(): array 'qty' => ['nullable', 'integer', 'min:0'], 'status' => ['required', Rule::enum(PalletStatus::class)], 'location' => ['nullable', 'string', 'max:100'], + 'destination' => ['nullable', 'string', 'max:100'], 'erp_reference' => ['nullable', 'string', 'max:100'], // pallet_no is sequence-generated on create; editable but kept unique on update. 'pallet_no' => [ diff --git a/backend/app/Http/Requests/StorePalletMovementRequest.php b/backend/app/Http/Requests/StorePalletMovementRequest.php index 932dffe9..776e4d34 100644 --- a/backend/app/Http/Requests/StorePalletMovementRequest.php +++ b/backend/app/Http/Requests/StorePalletMovementRequest.php @@ -2,12 +2,13 @@ namespace App\Http\Requests; -use App\Enums\PalletStatus; +use App\Http\Requests\Concerns\ValidatesPalletLogistics; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class StorePalletMovementRequest extends FormRequest { + use ValidatesPalletLogistics; + public function authorize(): bool { // Route is gated by the Operator|Supervisor|Admin role middleware. @@ -17,37 +18,25 @@ public function authorize(): bool public function rules(): array { return [ - // Must be a live, still-movable pallet — the same set the terminal - // offers. Shipped pallets are dispatched (their location and ledger - // are frozen); soft-deleted pallets are gone. Excluding them here - // keeps validation and the controller's findOrFail in agreement, - // so a stale id yields a 422 rather than a 404. - 'pallet_id' => [ - 'required', - 'integer', - Rule::exists('pallets', 'id')->where(fn ($q) => $q - ->whereNull('deleted_at') - ->where('status', '!=', PalletStatus::Shipped->value)), - ], - // The operator must be an active, non-deleted logistics worker. - 'worker_id' => [ - 'required', - 'integer', - Rule::exists('workers', 'id')->where(fn ($q) => $q - ->whereNull('deleted_at') - ->where('is_active', true) - ->where('is_logistics', true)), - ], + 'pallet_id' => ['required', 'integer', $this->movablePalletExists()], + 'worker_id' => ['required', 'integer', $this->activeLogisticsOperatorExists()], 'to_location' => ['required', 'string', 'max:100'], + // Optional re-route booked with the move (#101). Omitted/blank keeps + // whatever destination the pallet already had — clearing one is a + // deliberate act that goes through the destination endpoint. + 'to_destination' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:1000'], ]; } public function messages(): array { - return [ - 'pallet_id.exists' => __('Select a movable (not shipped) pallet.'), - 'worker_id.exists' => __('Select an active logistics operator.'), - ]; + return $this->palletLogisticsMessages(); + } + + /** The re-route booked with this move, or null to keep the standing one. */ + public function toDestination(): ?string + { + return $this->optionalText('to_destination'); } } diff --git a/backend/app/Models/Pallet.php b/backend/app/Models/Pallet.php index 06373166..9fa9324d 100644 --- a/backend/app/Models/Pallet.php +++ b/backend/app/Models/Pallet.php @@ -33,6 +33,8 @@ class Pallet extends Model 'status', 'quality_status', 'location', + 'destination', + 'arrived_at', 'erp_reference', 'tenant_id', ]; @@ -43,6 +45,7 @@ protected function casts(): array 'status' => PalletStatus::class, 'qty' => 'integer', 'shipped_at' => 'datetime', + 'arrived_at' => 'datetime', ]; } @@ -152,8 +155,24 @@ public function stockMovements(): HasMany ->where('source_type', StockMovement::SOURCE_PALLET); } + /** Append-only trail of physical moves and destination changes (#101, #103). */ + public function movements(): HasMany + { + return $this->hasMany(PalletMovement::class); + } + public function isOpen(): bool { return $this->status === PalletStatus::Open; } + + /** + * The pallet has somewhere to be and isn't there yet (#101). Destination is + * cleared on arrival, so a pending destination is the whole test — no string + * comparison against the current location needed. + */ + public function isInTransit(): bool + { + return $this->destination !== null; + } } diff --git a/backend/app/Models/PalletMovement.php b/backend/app/Models/PalletMovement.php index 515c6dba..64d6d0bf 100644 --- a/backend/app/Models/PalletMovement.php +++ b/backend/app/Models/PalletMovement.php @@ -22,6 +22,8 @@ class PalletMovement extends Model 'worker_id', 'from_location', 'to_location', + 'from_destination', + 'to_destination', 'moved_at', 'notes', 'performed_by', diff --git a/backend/app/Services/Logistics/PalletMovementService.php b/backend/app/Services/Logistics/PalletMovementService.php index d546fda4..586f7cf1 100644 --- a/backend/app/Services/Logistics/PalletMovementService.php +++ b/backend/app/Services/Logistics/PalletMovementService.php @@ -9,38 +9,127 @@ use Illuminate\Support\Facades\DB; /** - * Single point of entry for recording a physical pallet movement (#103). + * Single point of entry for changing where a pallet is and where it is going + * (#103, #101). * - * Atomically snapshots the pallet's current location as the move's origin, - * updates the pallet to its new location, and appends an immutable - * pallet_movements row crediting the logistics operator who performed it. + * Every mutation is atomic (the pallet row is locked so the "from" snapshot is + * the real current state under concurrent terminals) and appends an immutable + * pallet_movements row, so location and destination share one auditable + * timeline rather than two. */ class PalletMovementService { + /** + * Record a physical move. + * + * $toDestination re-routes the pallet as part of the same event; passing + * null leaves the standing destination in place. Landing on the destination + * clears it and stamps the arrival — use assignDestination() to explicitly + * unassign one. + */ public function record( Pallet $pallet, Worker $operator, string $toLocation, ?User $recordedBy = null, ?string $notes = null, + ?string $toDestination = null, ): PalletMovement { - return DB::transaction(function () use ($pallet, $operator, $toLocation, $recordedBy, $notes) { + return DB::transaction(function () use ($pallet, $operator, $toLocation, $recordedBy, $notes, $toDestination) { // Lock + re-read so the from_location we capture is the real current // location even under concurrent moves of the same pallet. $locked = Pallet::whereKey($pallet->getKey())->lockForUpdate()->firstOrFail(); $fromLocation = $locked->location; - $locked->update(['location' => $toLocation]); - - return PalletMovement::create([ - 'pallet_id' => $locked->id, - 'worker_id' => $operator->id, - 'from_location' => $fromLocation, - 'to_location' => $toLocation, - 'moved_at' => now(), - 'notes' => $notes, - 'performed_by' => $recordedBy?->id, + $fromDestination = $locked->destination; + + $destination = $toDestination ?? $fromDestination; + $arrivedAt = $locked->arrived_at; + + if ($destination !== null && $destination === $toLocation) { + // Reached its target: the destination is satisfied, not pending. + $destination = null; + $arrivedAt = now(); + } elseif ($toLocation !== $fromLocation || $destination !== $fromDestination) { + // Moved on, or re-routed somewhere new — any earlier arrival no + // longer describes where the pallet stands. + $arrivedAt = null; + } + + $locked->update([ + 'location' => $toLocation, + 'destination' => $destination, + 'arrived_at' => $arrivedAt, + ]); + + return $this->append( + $locked, $operator, $fromLocation, $toLocation, + $fromDestination, $destination, $recordedBy, $notes, + ); + }); + } + + /** + * Re-route a pallet without physically moving it. Passing null clears the + * destination (the pallet is no longer expected anywhere). + * + * Assigning the pallet's current location as its destination is treated as + * an arrival rather than a target no move could ever satisfy. + */ + public function assignDestination( + Pallet $pallet, + ?string $destination, + ?Worker $operator = null, + ?User $recordedBy = null, + ?string $notes = null, + ): PalletMovement { + return DB::transaction(function () use ($pallet, $destination, $operator, $recordedBy, $notes) { + $locked = Pallet::whereKey($pallet->getKey())->lockForUpdate()->firstOrFail(); + + $fromDestination = $locked->destination; + $location = $locked->location; + $reached = $destination !== null && $destination === $location; + + $locked->update([ + 'destination' => $reached ? null : $destination, + // A fresh target voids the previous arrival; unassigning leaves + // the last arrival standing, since the pallet hasn't moved. + 'arrived_at' => match (true) { + $reached => now(), + $destination !== null => null, + default => $locked->arrived_at, + }, ]); + + // from == to location: the ledger reads as a re-route, not a move. + return $this->append( + $locked, $operator, $location, $location, + $fromDestination, $reached ? null : $destination, $recordedBy, $notes, + ); }); } + + /** Append the immutable ledger row describing one location/destination event. */ + private function append( + Pallet $pallet, + ?Worker $operator, + ?string $fromLocation, + ?string $toLocation, + ?string $fromDestination, + ?string $toDestination, + ?User $recordedBy, + ?string $notes, + ): PalletMovement { + return PalletMovement::create([ + 'pallet_id' => $pallet->id, + 'worker_id' => $operator?->id, + 'from_location' => $fromLocation, + 'to_location' => $toLocation, + 'from_destination' => $fromDestination, + 'to_destination' => $toDestination, + 'moved_at' => now(), + 'notes' => $notes, + 'performed_by' => $recordedBy?->id, + ]); + } } diff --git a/backend/app/Sync/ShapeRegistry.php b/backend/app/Sync/ShapeRegistry.php index c990718f..c1f0288c 100644 --- a/backend/app/Sync/ShapeRegistry.php +++ b/backend/app/Sync/ShapeRegistry.php @@ -175,7 +175,7 @@ class ShapeRegistry ], 'pallets' => [ 'table' => 'pallets', - 'columns' => ['id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'quality_status', 'location', 'erp_reference', 'created_at', 'updated_at'], + 'columns' => ['id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'quality_status', 'location', 'destination', 'arrived_at', 'erp_reference', 'created_at', 'updated_at'], ], // Physical pallet movement history (#103) — who moved which pallet where. // Rolling recent window (see the class) so the append-only ledger can't diff --git a/backend/app/Sync/Shapes/PalletMovementsRecentShape.php b/backend/app/Sync/Shapes/PalletMovementsRecentShape.php index bea67093..73962139 100644 --- a/backend/app/Sync/Shapes/PalletMovementsRecentShape.php +++ b/backend/app/Sync/Shapes/PalletMovementsRecentShape.php @@ -37,6 +37,8 @@ public function columns(): array 'worker_id', 'from_location', 'to_location', + 'from_destination', + 'to_destination', 'moved_at', 'notes', 'performed_by', diff --git a/backend/database/factories/PalletFactory.php b/backend/database/factories/PalletFactory.php index c5a0567f..fd140a16 100644 --- a/backend/database/factories/PalletFactory.php +++ b/backend/database/factories/PalletFactory.php @@ -20,10 +20,20 @@ public function definition(): array 'qty' => 0, 'status' => PalletStatus::Open->value, 'location' => fake()->optional()->bothify('A-##-##'), + 'destination' => null, 'erp_reference' => fake()->optional()->bothify('ERP-#####'), ]; } + /** A pallet with somewhere to be and not there yet (#101). */ + public function inTransit(string $destination = 'DOCK-01'): static + { + return $this->state(fn (array $attributes) => [ + 'destination' => $destination, + 'arrived_at' => null, + ]); + } + public function closed(): static { return $this->state(fn (array $attributes) => ['status' => PalletStatus::Closed->value]); diff --git a/backend/database/factories/PalletMovementFactory.php b/backend/database/factories/PalletMovementFactory.php index fe8be937..fa2719c3 100644 --- a/backend/database/factories/PalletMovementFactory.php +++ b/backend/database/factories/PalletMovementFactory.php @@ -18,6 +18,8 @@ public function definition(): array 'worker_id' => Worker::factory()->logistics(), 'from_location' => fake()->optional()->bothify('A-##-##'), 'to_location' => fake()->bothify('B-##-##'), + 'from_destination' => null, + 'to_destination' => null, 'moved_at' => now(), 'notes' => fake()->optional()->sentence(), ]; diff --git a/backend/database/migrations/2026_07_25_100000_add_destination_to_pallets_table.php b/backend/database/migrations/2026_07_25_100000_add_destination_to_pallets_table.php new file mode 100644 index 00000000..bfdba4ea --- /dev/null +++ b/backend/database/migrations/2026_07_25_100000_add_destination_to_pallets_table.php @@ -0,0 +1,43 @@ +string('destination', 100)->nullable()->after('location') + ->comment('Where the pallet should go; null once reached (#101)'); + $table->timestamp('arrived_at')->nullable()->after('destination') + ->comment('When the pallet last reached its assigned destination (#101)'); + + // The logistics view lists pallets by status and location, and + // singles out the ones still in transit. + $table->index('location'); + $table->index('destination'); + }); + } + + public function down(): void + { + Schema::table('pallets', function (Blueprint $table) { + $table->dropIndex(['location']); + $table->dropIndex(['destination']); + $table->dropColumn(['destination', 'arrived_at']); + }); + } +}; diff --git a/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php b/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php new file mode 100644 index 00000000..b56046ca --- /dev/null +++ b/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php @@ -0,0 +1,34 @@ +string('from_destination', 100)->nullable()->after('to_location') + ->comment('Destination before this event (#101)'); + $table->string('to_destination', 100)->nullable()->after('from_destination') + ->comment('Destination after this event; null means unassigned/reached (#101)'); + }); + } + + public function down(): void + { + Schema::table('pallet_movements', function (Blueprint $table) { + $table->dropColumn(['from_destination', 'to_destination']); + }); + } +}; diff --git a/backend/lang/en.json b/backend/lang/en.json index a5b816d2..15ec8e9e 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -4891,5 +4891,20 @@ "Adds:": "Adds:", "Full shop-floor operations: reports, quality & maintenance, machine connectivity and packaging.": "Full shop-floor operations: reports, quality & maintenance, machine connectivity and packaging.", "Pick exactly which modules to enable.": "Pick exactly which modules to enable.", - "Next: Production Line →": "Next: Production Line →" + "Next: Production Line →": "Next: Production Line →", + "Pallet Logistics": "Pallet Logistics", + "Set destination": "Set destination", + "Assign where a pallet should go, or leave the destination blank to clear it.": "Assign where a pallet should go, or leave the destination blank to clear it.", + "Destination": "Destination", + "Current destination": "Current destination", + "e.g. DOCK-01": "e.g. DOCK-01", + "Update destination": "Update destination", + "Transit": "Transit", + "In transit": "In transit", + "At destination": "At destination", + "Arrived": "Arrived", + "New destination": "New destination", + "Leave blank to keep the current destination.": "Leave blank to keep the current destination.", + "This move completes the assigned destination.": "This move completes the assigned destination.", + "Pallet destination updated.": "Pallet destination updated." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index f9369433..ae50824a 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -4891,5 +4891,20 @@ "Adds:": "Dodaje:", "Full shop-floor operations: reports, quality & maintenance, machine connectivity and packaging.": "Pełne operacje na hali: raporty, jakość i utrzymanie ruchu, łączność z maszynami i pakowanie.", "Pick exactly which modules to enable.": "Wybierz dokładnie, które moduły włączyć.", - "Next: Production Line →": "Dalej: Linia produkcyjna →" + "Next: Production Line →": "Dalej: Linia produkcyjna →", + "Pallet Logistics": "Logistyka palet", + "Set destination": "Ustaw cel", + "Assign where a pallet should go, or leave the destination blank to clear it.": "Wskaż, gdzie paleta ma trafić, albo zostaw cel pusty, aby go usunąć.", + "Destination": "Cel", + "Current destination": "Aktualny cel", + "e.g. DOCK-01": "np. DOCK-01", + "Update destination": "Zaktualizuj cel", + "Transit": "Transport", + "In transit": "W transporcie", + "At destination": "Na miejscu", + "Arrived": "Dotarcie", + "New destination": "Nowy cel", + "Leave blank to keep the current destination.": "Zostaw puste, aby zachować aktualny cel.", + "This move completes the assigned destination.": "Ten ruch kończy przypisany cel.", + "Pallet destination updated.": "Cel palety został zaktualizowany." } diff --git a/backend/resources/js/Pages/admin/pallet-movements/Index.jsx b/backend/resources/js/Pages/admin/pallet-movements/Index.jsx index 97889091..6a1d566f 100644 --- a/backend/resources/js/Pages/admin/pallet-movements/Index.jsx +++ b/backend/resources/js/Pages/admin/pallet-movements/Index.jsx @@ -36,6 +36,18 @@ export default function PalletMovementsIndex() { className: 'font-mono text-om-ink', render: (r) => r.to_location || '—', }, + { + // Destination changes share this ledger (#101). A row whose From and + // To locations match is a re-route, not a physical move; a row whose + // destination went from set to empty is an arrival. + key: 'to_destination', + label: __('Destination'), + className: 'font-mono text-om-muted', + render: (r) => { + if (r.from_destination === r.to_destination) return r.to_destination || '—'; + return `${r.from_destination || '—'} → ${r.to_destination || '—'}`; + }, + }, { key: 'operator', label: __('Operator'), diff --git a/backend/resources/js/Pages/admin/pallets/Create.jsx b/backend/resources/js/Pages/admin/pallets/Create.jsx index ca83a06a..f999b269 100644 --- a/backend/resources/js/Pages/admin/pallets/Create.jsx +++ b/backend/resources/js/Pages/admin/pallets/Create.jsx @@ -16,6 +16,7 @@ export default function PalletCreate() { qty: 0, status: 'open', location: '', + destination: '', erp_reference: '', }} submitLabel="Create" diff --git a/backend/resources/js/Pages/admin/pallets/Edit.jsx b/backend/resources/js/Pages/admin/pallets/Edit.jsx index e378142f..e6d639aa 100644 --- a/backend/resources/js/Pages/admin/pallets/Edit.jsx +++ b/backend/resources/js/Pages/admin/pallets/Edit.jsx @@ -25,6 +25,7 @@ export default function PalletEdit() { qty: pallet.qty ?? 0, status: pallet.status ?? 'open', location: pallet.location ?? '', + destination: pallet.destination ?? '', erp_reference: pallet.erp_reference ?? '', }} submitLabel={__('Save Changes')} diff --git a/backend/resources/js/Pages/admin/pallets/Index.jsx b/backend/resources/js/Pages/admin/pallets/Index.jsx index bae80260..c10cfc0e 100644 --- a/backend/resources/js/Pages/admin/pallets/Index.jsx +++ b/backend/resources/js/Pages/admin/pallets/Index.jsx @@ -117,6 +117,7 @@ export default function PalletsIndex() { ), }, { key: 'location', label: 'Location', render: (r) => r.location || '—' }, + { key: 'destination', label: 'Destination', render: (r) => r.destination || '—' }, { key: 'erp_reference', label: 'ERP reference', render: (r) => r.erp_reference || '—' }, { key: 'label', diff --git a/backend/resources/js/Pages/admin/pallets/PalletForm.jsx b/backend/resources/js/Pages/admin/pallets/PalletForm.jsx index 28dd0ba6..03d1e1ba 100644 --- a/backend/resources/js/Pages/admin/pallets/PalletForm.jsx +++ b/backend/resources/js/Pages/admin/pallets/PalletForm.jsx @@ -77,6 +77,12 @@ export default function PalletForm({ action, method, initial, submitLabel }) { error={errors.location} onChange={(v) => setData('location', v)} /> + setData('destination', v)} + /> form.reset('pallet_id', 'to_location', 'notes'), + onSuccess: () => form.reset('pallet_id', 'to_location', 'to_destination', 'notes'), }); }; @@ -90,6 +91,8 @@ export default function MovePallet() { {selectedPallet && (

{__('Current location')}: {selectedPallet.location || '—'} + {' · '} + {__('Destination')}: {selectedPallet.destination || '—'}

)} {errors.pallet_id &&

{errors.pallet_id}

} @@ -109,6 +112,31 @@ export default function MovePallet() { className="w-full bg-om-bg border border-om-line rounded-om-sm px-3 py-3 text-[16px] text-om-ink outline-hidden focus:border-om-accent focus:ring-[3px] focus:ring-[rgba(234,90,43,.12)]" /> {errors.to_location &&

{errors.to_location}

} + {/* Moving the pallet onto its destination completes the run: + the backend clears the destination and stamps the arrival. */} + {selectedPallet?.destination + && data.to_location.trim() === selectedPallet.destination && ( +

+ {__('This move completes the assigned destination.')} +

+ )} + + + {/* Destination — optional re-route booked with this move */} +
+ + setData('to_destination', e.target.value)} + placeholder={__('e.g. DOCK-01')} + className="w-full bg-om-bg border border-om-line rounded-om-sm px-3 py-3 text-[16px] text-om-ink outline-hidden focus:border-om-accent focus:ring-[3px] focus:ring-[rgba(234,90,43,.12)]" + /> +

+ {__('Leave blank to keep the current destination.')} +

+ {errors.to_destination &&

{errors.to_destination}

}
{/* Notes (optional) */} diff --git a/backend/resources/js/Pages/logistics/Pallets.jsx b/backend/resources/js/Pages/logistics/Pallets.jsx new file mode 100644 index 00000000..d86065ef --- /dev/null +++ b/backend/resources/js/Pages/logistics/Pallets.jsx @@ -0,0 +1,213 @@ +import { Head, useForm, usePage } from '@inertiajs/react'; +import { Button, Dropdown } from '@openmes/ui'; +import AppLayout from '../../layouts/AppLayout'; +import ResourceTable from '../../components/ResourceTable'; +import { __, formatDateTime } from '../../lib/i18n'; + +/** + * Pallet logistics overview (#101): where every pallet is, where it should go, + * and what state it's in. Rows stream live from the `pallets` shape, so a move + * booked on the shop-floor terminal lands here without a refresh. + * + * The re-route card above the table changes a pallet's destination without + * moving it — the counterpart to "Move Pallet", which changes its location. + */ + +const STATUS_BADGE = { + open: 'bg-om-running-bg text-om-running', + closed: 'bg-om-chip text-om-accent', + shipped: 'bg-om-chip text-om-muted', +}; + +/** + * Transit state, derived from destination alone: it is cleared on arrival, so a + * pending destination means in transit and a stamped arrival with no pending + * destination means the pallet completed its last assigned run. + */ +function transitOf(row) { + if (row.destination) return 'in_transit'; + return row.arrived_at ? 'arrived' : 'idle'; +} + +const TRANSIT_BADGE = { + in_transit: 'bg-om-downtime-bg text-om-downtime', + arrived: 'bg-om-running-bg text-om-running', + idle: 'bg-om-chip text-om-muted', +}; +const TRANSIT_LABELS = { in_transit: 'In transit', arrived: 'At destination', idle: 'Not assigned' }; + +/** Assign or clear a pallet's destination — an explicit, logged re-route. */ +function RerouteCard() { + const { movablePallets = [], operators = [] } = usePage().props; + + const form = useForm({ pallet_id: '', destination: '', worker_id: '', notes: '' }); + const { data, setData, errors, processing } = form; + + const selected = movablePallets.find((p) => String(p.id) === String(data.pallet_id)); + + const submit = (e) => { + e.preventDefault(); + form.post('/logistics/pallets/destination', { + preserveScroll: true, + onSuccess: () => form.reset('pallet_id', 'destination', 'notes'), + }); + }; + + return ( +
+

{__('Set destination')}

+

+ {__('Assign where a pallet should go, or leave the destination blank to clear it.')} +

+ +
+
+ + setData('pallet_id', v)} + placeholder={__('— Select pallet —')} + options={movablePallets.map((p) => ({ + value: String(p.id), + label: p.location ? `${p.pallet_no} · ${p.location}` : p.pallet_no, + }))} + /> + {selected && ( +

+ {__('Current destination')}:{' '} + {selected.destination || '—'} +

+ )} + {errors.pallet_id &&

{errors.pallet_id}

} +
+ +
+ + setData('destination', e.target.value)} + placeholder={__('e.g. DOCK-01')} + className="form-input w-full" + /> + {errors.destination &&

{errors.destination}

} +
+ +
+ + setData('worker_id', v)} + placeholder={__('— None —')} + options={operators.map((op) => ({ value: String(op.id), label: `${op.code} — ${op.name}` }))} + /> + {errors.worker_id &&

{errors.worker_id}

} +
+ +
+ + setData('notes', e.target.value)} + className="form-input w-full" + /> + {errors.notes &&

{errors.notes}

} +
+
+ +
+ +
+
+ ); +} + +export default function LogisticsPallets() { + const { statusLabels = {} } = usePage().props; + + const columns = [ + { key: 'pallet_no', label: __('Pallet number'), className: 'font-mono font-medium text-om-ink' }, + { + key: 'status', + label: __('Status'), + // 'select', not `true`: DataTable renders a filter control only for + // the literal 'text' / 'select' variants. + filter: 'select', + allLabel: __('All statuses'), + options: Object.entries(statusLabels).map(([value, label]) => ({ value, label })), + render: (r) => ( + + {statusLabels[r.status] ?? r.status} + + ), + }, + { + key: 'location', + label: __('Location'), + filter: 'text', + className: 'font-mono text-om-ink', + render: (r) => r.location || '—', + }, + { + key: 'destination', + label: __('Destination'), + filter: 'text', + className: 'font-mono text-om-muted', + render: (r) => r.destination || '—', + }, + { + key: 'transit', + label: __('Transit'), + // Sort/search still read the raw destination field; the badge is a + // presentation of it plus arrived_at. + sortAccessor: (r) => transitOf(r), + render: (r) => { + const state = transitOf(r); + return ( + + {__(TRANSIT_LABELS[state])} + + ); + }, + }, + { + key: 'arrived_at', + label: __('Arrived'), + className: 'text-om-muted text-xs', + render: (r) => (r.arrived_at ? formatDateTime(r.arrived_at) : '—'), + }, + { key: 'qty', label: __('Quantity') }, + ]; + + // RowActions translates the label itself, so these stay raw English. + const actions = () => [ + { label: 'Move', href: '/logistics/move-pallet', variant: 'secondary' }, + ]; + + return ( + <> + + + + + ); +} + +LogisticsPallets.layout = (page) => {page}; diff --git a/backend/resources/js/layouts/adminNav.js b/backend/resources/js/layouts/adminNav.js index 2066d15c..02830342 100644 --- a/backend/resources/js/layouts/adminNav.js +++ b/backend/resources/js/layouts/adminNav.js @@ -247,6 +247,7 @@ export const ADMIN_GROUPS = [ { label: 'Packaging Overview', href: '/packaging', match: ['/packaging'], exact: true }, { label: 'Shift Handover', href: '/supervisor/shift-handover', match: ['/supervisor/shift-handover'] }, { label: 'Pallets', href: '/admin/pallets', match: ['/admin/pallets'] }, + { label: 'Pallet Logistics', href: '/logistics/pallets', match: ['/logistics/pallets'] }, { label: 'Move Pallet', href: '/logistics/move-pallet', match: ['/logistics/move-pallet'] }, { label: 'Pallet Movements', href: '/admin/pallet-movements', match: ['/admin/pallet-movements'] }, { label: 'EAN Management', href: '/packaging/eans', match: ['/packaging/eans'] }, diff --git a/backend/routes/web.php b/backend/routes/web.php index 84b6d0fa..8af10615 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -791,6 +791,11 @@ ->group(function () { Route::get('/move-pallet', [PalletMovementController::class, 'terminal'])->name('move-pallet'); Route::post('/movements', [PalletMovementController::class, 'store'])->name('movements.store'); + + // Pallet status / location / destination overview (#101). + Route::get('/pallets', [PalletMovementController::class, 'pallets'])->name('pallets'); + Route::post('/pallets/destination', [PalletMovementController::class, 'assignDestination']) + ->name('pallets.destination'); }); // ── Packaging ─────────────────────────────────────────────────────────── diff --git a/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php b/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php new file mode 100644 index 00000000..0d64c220 --- /dev/null +++ b/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php @@ -0,0 +1,399 @@ +operator = User::factory()->create(); + $this->operator->assignRole('Operator'); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Admin'); + + $this->driver = Worker::factory()->logistics()->create(['code' => 'FL-09', 'name' => 'Piotr Zieliński']); + } + + // ── Moving with a destination ──────────────────────────────────────────── + + public function test_a_move_can_assign_a_destination_and_records_it_in_history(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01', 'destination' => null]); + + $this->actingAs($this->operator)->post(route('logistics.movements.store'), [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'to_location' => 'A-05', + 'to_destination' => 'DOCK-01', + ])->assertRedirect(route('logistics.move-pallet')); + + $pallet->refresh(); + $this->assertSame('A-05', $pallet->location); + $this->assertSame('DOCK-01', $pallet->destination); + $this->assertTrue($pallet->isInTransit()); + $this->assertNull($pallet->arrived_at); + + // Both axes of the change are on the one ledger row. + $this->assertDatabaseHas('pallet_movements', [ + 'pallet_id' => $pallet->id, + 'from_location' => 'A-01', + 'to_location' => 'A-05', + 'from_destination' => null, + 'to_destination' => 'DOCK-01', + ]); + } + + public function test_a_blank_destination_on_a_move_keeps_the_standing_destination(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01', 'destination' => 'DOCK-01']); + + $this->actingAs($this->operator)->post(route('logistics.movements.store'), [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'to_location' => 'A-09', + 'to_destination' => '', + ]); + + // Still heading for the dock — an intermediate move must not drop the target. + $this->assertSame('DOCK-01', $pallet->fresh()->destination); + $this->assertDatabaseHas('pallet_movements', [ + 'pallet_id' => $pallet->id, + 'from_destination' => 'DOCK-01', + 'to_destination' => 'DOCK-01', + ]); + } + + public function test_moving_onto_the_destination_clears_it_and_stamps_the_arrival(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01', 'destination' => 'DOCK-01']); + + $this->actingAs($this->operator)->post(route('logistics.movements.store'), [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'to_location' => 'DOCK-01', + ]); + + $pallet->refresh(); + $this->assertSame('DOCK-01', $pallet->location); + $this->assertNull($pallet->destination, 'A reached destination is satisfied, not pending.'); + $this->assertNotNull($pallet->arrived_at); + $this->assertFalse($pallet->isInTransit()); + + // The ledger reads as an arrival: destination went from set to unset. + $this->assertDatabaseHas('pallet_movements', [ + 'pallet_id' => $pallet->id, + 'to_location' => 'DOCK-01', + 'from_destination' => 'DOCK-01', + 'to_destination' => null, + ]); + } + + public function test_moving_on_after_an_arrival_clears_the_stale_arrival_stamp(): void + { + $pallet = Pallet::factory()->create([ + 'location' => 'DOCK-01', + 'destination' => null, + 'arrived_at' => now()->subHour(), + ]); + + $this->actingAs($this->operator)->post(route('logistics.movements.store'), [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'to_location' => 'A-02', + ]); + + // arrived_at describes standing at a reached destination; once the pallet + // leaves, keeping it would misreport where it is. + $this->assertNull($pallet->fresh()->arrived_at); + } + + // ── Re-routing without moving ──────────────────────────────────────────── + + public function test_a_pallet_can_be_re_routed_without_being_moved(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01', 'destination' => 'DOCK-01']); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-07', + 'worker_id' => $this->driver->id, + 'notes' => 'Truck swapped', + ])->assertRedirect(route('logistics.pallets')); + + $pallet->refresh(); + $this->assertSame('A-01', $pallet->location, 'Re-routing must not move the pallet.'); + $this->assertSame('DOCK-07', $pallet->destination); + + // from == to location marks this as a re-route rather than a move. + $this->assertDatabaseHas('pallet_movements', [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'from_location' => 'A-01', + 'to_location' => 'A-01', + 'from_destination' => 'DOCK-01', + 'to_destination' => 'DOCK-07', + 'notes' => 'Truck swapped', + 'performed_by' => $this->operator->id, + ]); + } + + public function test_re_routing_an_arrived_pallet_voids_the_previous_arrival(): void + { + $pallet = Pallet::factory()->create([ + 'location' => 'DOCK-01', + 'destination' => null, + 'arrived_at' => now()->subHour(), + ]); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-07', + ]); + + $pallet->refresh(); + $this->assertSame('DOCK-07', $pallet->destination); + $this->assertNull($pallet->arrived_at, 'A fresh target means the pallet is in transit again.'); + } + + public function test_a_blank_destination_clears_the_target_and_leaves_the_pallet_put(): void + { + $arrivedAt = now()->subDay()->startOfSecond(); + $pallet = Pallet::factory()->create([ + 'location' => 'A-01', + 'destination' => 'DOCK-01', + 'arrived_at' => $arrivedAt, + ]); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => '', + ]); + + $pallet->refresh(); + $this->assertNull($pallet->destination); + $this->assertSame('A-01', $pallet->location); + // The pallet never moved, so its last arrival still stands. + $this->assertTrue($arrivedAt->equalTo($pallet->arrived_at)); + + $this->assertDatabaseHas('pallet_movements', [ + 'pallet_id' => $pallet->id, + 'from_destination' => 'DOCK-01', + 'to_destination' => null, + ]); + } + + public function test_assigning_the_current_location_as_the_destination_counts_as_an_arrival(): void + { + $pallet = Pallet::factory()->create(['location' => 'DOCK-01', 'destination' => null]); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-01', + ]); + + $pallet->refresh(); + // Leaving it pending would create a target no move could ever satisfy. + $this->assertNull($pallet->destination); + $this->assertNotNull($pallet->arrived_at); + } + + public function test_re_routing_is_allowed_without_an_operator(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01']); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-02', + ])->assertSessionHasNoErrors(); + + // Dispatch planning has no forklift operator to credit. + $this->assertDatabaseHas('pallet_movements', [ + 'pallet_id' => $pallet->id, + 'worker_id' => null, + 'to_destination' => 'DOCK-02', + ]); + } + + // ── Validation ─────────────────────────────────────────────────────────── + + public function test_destination_is_length_capped(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01']); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => str_repeat('X', 101), + ])->assertSessionHasErrors('destination'); + + $this->assertDatabaseCount('pallet_movements', 0); + } + + public function test_a_shipped_pallet_cannot_be_re_routed(): void + { + $pallet = Pallet::factory()->shipped()->create(['location' => 'DOCK-01', 'destination' => null]); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-09', + ])->assertSessionHasErrors('pallet_id'); + + $this->assertDatabaseCount('pallet_movements', 0); + $this->assertNull($pallet->fresh()->destination); + } + + public function test_a_soft_deleted_pallet_is_rejected_with_422_not_404(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01']); + $pallet->delete(); + + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-09', + ])->assertSessionHasErrors('pallet_id'); + + $this->assertDatabaseCount('pallet_movements', 0); + } + + // ── Authorization ──────────────────────────────────────────────────────── + + public function test_guest_cannot_view_the_logistics_view_or_re_route(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01']); + + $this->get(route('logistics.pallets'))->assertRedirect(route('login')); + $this->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-09', + ])->assertRedirect(route('login')); + + $this->assertDatabaseCount('pallet_movements', 0); + } + + public function test_user_without_a_logistics_role_is_forbidden(): void + { + $plain = User::factory()->create(); // no roles + + $this->actingAs($plain)->get(route('logistics.pallets'))->assertForbidden(); + $this->actingAs($plain)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => Pallet::factory()->create()->id, + 'destination' => 'DOCK-09', + ])->assertForbidden(); + } + + // ── The logistics view ─────────────────────────────────────────────────── + + public function test_logistics_view_renders_with_status_labels_and_movable_pallets(): void + { + Pallet::factory()->create(['location' => 'A-01', 'destination' => 'DOCK-01']); + Pallet::factory()->shipped()->create(['location' => 'DOCK-09']); + + // Rows themselves stream from the `pallets` shape; the props carry the + // status labels and the re-route picker's options. + $this->actingAs($this->operator)->get(route('logistics.pallets')) + ->assertStatus(200) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('logistics/Pallets') + ->where('statusLabels.open', __('Pallet open')) + // Shipped pallets can't be re-routed, so they're not offered. + ->has('movablePallets', 1) + ->where('movablePallets.0.destination', 'DOCK-01') + ->has('operators', 1)); + } + + public function test_destination_is_exposed_to_the_pallets_shape(): void + { + // A column missing from the allowlist never reaches the browser, so the + // logistics table would render an empty destination for every row. + $columns = app(\App\Sync\ShapeRegistry::class)->find('pallets')->columns(); + + $this->assertContains('destination', $columns); + $this->assertContains('arrived_at', $columns); + + // Destination changes ride the movement ledger, so the history table + // needs them on its shape too. + $movementColumns = app(\App\Sync\ShapeRegistry::class)->find('pallet_movements')->columns(); + $this->assertContains('from_destination', $movementColumns); + $this->assertContains('to_destination', $movementColumns); + } + + public function test_admin_can_edit_a_pallet_destination_directly(): void + { + $pallet = Pallet::factory()->create(['location' => 'A-01', 'destination' => null]); + + $this->actingAs($this->admin)->put(route('admin.pallets.update', $pallet), [ + 'work_order_id' => $pallet->work_order_id, + 'qty' => $pallet->qty, + 'status' => $pallet->status->value, + 'location' => 'A-01', + 'destination' => 'DOCK-04', + ])->assertRedirect(route('admin.pallets.index')); + + $this->assertSame('DOCK-04', $pallet->fresh()->destination); + } + + public function test_history_survives_a_pallets_full_journey(): void + { + $pallet = Pallet::factory()->create(['location' => 'LINE-1', 'destination' => null]); + + // Assigned a target, moved once on the way, then delivered. + $this->actingAs($this->operator)->post(route('logistics.pallets.destination'), [ + 'pallet_id' => $pallet->id, + 'destination' => 'DOCK-01', + ]); + $this->actingAs($this->operator)->post(route('logistics.movements.store'), [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'to_location' => 'BUFFER-3', + ]); + $this->actingAs($this->operator)->post(route('logistics.movements.store'), [ + 'pallet_id' => $pallet->id, + 'worker_id' => $this->driver->id, + 'to_location' => 'DOCK-01', + ]); + + $trail = PalletMovement::where('pallet_id', $pallet->id)->orderBy('id')->get(); + $this->assertCount(3, $trail, 'Re-route and both moves are all on the ledger.'); + + $this->assertSame( + [['LINE-1', 'LINE-1'], ['LINE-1', 'BUFFER-3'], ['BUFFER-3', 'DOCK-01']], + $trail->map(fn (PalletMovement $m) => [$m->from_location, $m->to_location])->all(), + ); + $this->assertSame( + [[null, 'DOCK-01'], ['DOCK-01', 'DOCK-01'], ['DOCK-01', null]], + $trail->map(fn (PalletMovement $m) => [$m->from_destination, $m->to_destination])->all(), + ); + + $pallet->refresh(); + $this->assertSame('DOCK-01', $pallet->location); + $this->assertNull($pallet->destination); + $this->assertNotNull($pallet->arrived_at); + } +} From 72be37d049d5cd02a7998913339ed6491afecaf7 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Sat, 25 Jul 2026 18:25:55 +0200 Subject: [PATCH 2/4] i18n: translate login, 2FA and registration messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth error and status messages were hardcoded English literals rather than translation calls, so they stayed English on a Polish UI no matter what lang/pl.json contained — there was no key for them to match. Wraps all 29 user-facing strings in AuthController, AuthService, TwoFactorController, TwoFactorChallengeController, RegisterController and RegisterRequest in __(), and adds the 24 resulting keys to lang/en.json and lang/pl.json (parity kept). The login screen resolves the locale because SetLocale runs in the web middleware group, so guest requests are covered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YbLa6GeJZACX3tFCE7iNJq --- CHANGELOG.md | 1 + .../Http/Controllers/Web/AuthController.php | 14 +++---- .../Controllers/Web/RegisterController.php | 30 +++++++------- .../Web/TwoFactorChallengeController.php | 23 ++++++----- .../Controllers/Web/TwoFactorController.php | 40 +++++++++---------- backend/app/Http/Requests/RegisterRequest.php | 20 +++++----- backend/app/Services/Auth/AuthService.php | 4 +- backend/lang/en.json | 26 +++++++++++- backend/lang/pl.json | 26 +++++++++++- 9 files changed, 119 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08027c81..6f8715f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - **Customers — master data for work orders** *(phase 1)*: work orders can now be attached to a **customer**. A new **Admin → Orders → Customers** section provides full CRUD (list, create, edit, soft-delete, activate/deactivate) for customers carrying a **name**, optional **code**, a loyalty **tier** (bronze / silver / gold / VIP), a manual **payment score** (0–100), and app-maintained **total orders** / **total revenue** aggregates plus free-text notes. Work orders gain a nullable **customer** selector on the admin and supervisor create/edit forms; the customer name shows on the work-order list and detail (with a tier badge) and in the production **planner tooltip**. Deleting a customer never removes its work orders. Customers are soft-deletable (Trash + audit) and live-synced via a new `customers` Electric shape. New `customers` table, `work_orders.customer_id` FK (null-on-delete), `App\Models\Customer`, `App\Enums\Tier`, `CustomerController`. This is the foundation for automatic priority scoring (phase 2). ### Fixed +- **Login, 2FA and registration errors were never translated**: messages such as "The provided credentials are incorrect.", "Invalid username or PIN." and the registration validation errors stayed English on a Polish (or any non-English) UI. They were **hardcoded English literals** in the auth controllers rather than translation calls, so no `lang/*.json` entry could ever match them — adding a translation had no effect. All 29 user-facing strings across `AuthController`, `AuthService`, `TwoFactorController`, `TwoFactorChallengeController`, `RegisterController` and `RegisterRequest` now go through `__()`, with 24 new keys added to `lang/en.json` and `lang/pl.json`. The login screen is covered because `SetLocale` runs in the `web` group, so a guest request already resolves the configured locale. - **New MQTT connections weren't picked up until a manual listener restart** *([#174](https://github.com/Mes-Open/OpenMes/issues/174))*: `mqtt:listen` was a single-connection process pinned to one `--connection=` at startup, so creating a connection in **Admin → Connectivity → MQTT** left it stuck at `disconnected` — the DB row existed but nothing was subscribed against it until you restarted the listener with that ID. It is now a **supervisor**: one process services **all** active MQTT connections at once (non-blocking `loopOnce()` per client) and **reconciles against the database every few seconds** — a newly created/activated connection is connected + subscribed automatically, a deactivated/deleted one is disconnected, and a connection whose broker settings or topics changed is reconnected with the new config. The `mqtt-listener` container now runs `mqtt:listen` with no pinned ID by default (pass `--connection=` to supervise just one). - **MQTT connection detail — Live Message Log went blank after the first message** *([#174](https://github.com/Mes-Open/OpenMes/issues/174))*: the message log's time formatter was a local `formatTime` that shadowed the imported i18n helper and **recursed into itself**, so the panel rendered fine while empty ("Waiting for messages…") but blew the call stack the moment a message arrived — blanking the log. Renamed the local helper so it calls the imported formatter instead of itself. diff --git a/backend/app/Http/Controllers/Web/AuthController.php b/backend/app/Http/Controllers/Web/AuthController.php index f7eca05b..9b622345 100644 --- a/backend/app/Http/Controllers/Web/AuthController.php +++ b/backend/app/Http/Controllers/Web/AuthController.php @@ -55,7 +55,7 @@ public function login(Request $request) 'password' => $request->input('password'), ], $request->filled('remember'))) { throw ValidationException::withMessages([ - 'username' => ['The provided credentials are incorrect.'], + 'username' => [__('The provided credentials are incorrect.')], ]); } @@ -80,7 +80,7 @@ public function login(Request $request) // Check if user needs to change password if ($user->force_password_change) { return redirect()->route('change-password') - ->with('error', 'You must change your password before continuing.'); + ->with('error', __('You must change your password before continuing.')); } // Redirect to appropriate dashboard based on role @@ -99,7 +99,7 @@ public function loginWithPin(\App\Http\Requests\PinLoginRequest $request) if (! $pinEnabled) { throw ValidationException::withMessages([ - 'pin' => ['PIN login is not enabled.'], + 'pin' => [__('PIN login is not enabled.')], ]); } @@ -107,7 +107,7 @@ public function loginWithPin(\App\Http\Requests\PinLoginRequest $request) if (! $user || empty($user->pin) || ! Hash::check($request->input('pin'), $user->pin)) { throw ValidationException::withMessages([ - 'username' => ['Invalid username or PIN.'], + 'username' => [__('Invalid username or PIN.')], ]); } @@ -128,7 +128,7 @@ public function loginWithPin(\App\Http\Requests\PinLoginRequest $request) if ($user->force_password_change) { return redirect()->route('change-password') - ->with('error', 'You must change your password before continuing.'); + ->with('error', __('You must change your password before continuing.')); } return $this->redirectToDashboard(); @@ -162,7 +162,7 @@ public function changePassword(Request $request) ); return redirect()->route('operator.select-line') - ->with('success', 'Password changed successfully.'); + ->with('success', __('Password changed successfully.')); } catch (ValidationException $e) { return back() ->withErrors($e->errors()) @@ -181,7 +181,7 @@ public function logout(Request $request) $request->session()->regenerateToken(); return redirect()->route('login') - ->with('success', 'You have been logged out successfully.'); + ->with('success', __('You have been logged out successfully.')); } /** diff --git a/backend/app/Http/Controllers/Web/RegisterController.php b/backend/app/Http/Controllers/Web/RegisterController.php index c3df36de..c7c061d4 100644 --- a/backend/app/Http/Controllers/Web/RegisterController.php +++ b/backend/app/Http/Controllers/Web/RegisterController.php @@ -16,7 +16,7 @@ class RegisterController extends Controller { public function show() { - if (!$this->registrationEnabled()) { + if (! $this->registrationEnabled()) { abort(404); } @@ -25,33 +25,33 @@ public function show() public function store(RegisterRequest $request) { - if (!$this->registrationEnabled()) { + if (! $this->registrationEnabled()) { abort(404); } - $key = 'register:' . $request->ip(); + $key = 'register:'.$request->ip(); if (RateLimiter::tooManyAttempts($key, 5)) { - return back()->withErrors(['email' => 'Too many registration attempts. Please try again later.']); + return back()->withErrors(['email' => __('Too many registration attempts. Please try again later.')]); } RateLimiter::hit($key, 60); $user = User::create([ - 'name' => $request->name, - 'username' => $request->username, - 'email' => $request->email, - 'password' => Hash::make($request->password), - 'account_type' => 'user', + 'name' => $request->name, + 'username' => $request->username, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'account_type' => 'user', 'force_password_change' => false, - 'email_verified_at' => now(), + 'email_verified_at' => now(), ]); $user->assignRole('Operator'); RegistrationLog::create([ - 'name' => $request->name, - 'email' => $request->email, - 'username' => $request->username, - 'ip_address' => $request->ip(), + 'name' => $request->name, + 'email' => $request->email, + 'username' => $request->username, + 'ip_address' => $request->ip(), 'registered_at' => now(), ]); @@ -59,7 +59,7 @@ public function store(RegisterRequest $request) $request->session()->regenerate(); return redirect()->route('operator.select-line') - ->with('success', 'Account created successfully. Welcome to OpenMES!'); + ->with('success', __('Account created successfully. Welcome to OpenMES!')); } private function registrationEnabled(): bool diff --git a/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php b/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php index ff36e536..e25022cb 100644 --- a/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php +++ b/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php @@ -19,7 +19,7 @@ class TwoFactorChallengeController extends Controller */ public function show(Request $request) { - if (!$request->session()->has('2fa_user_id')) { + if (! $request->session()->has('2fa_user_id')) { return redirect()->route('login'); } @@ -39,28 +39,30 @@ public function verify(Request $request) $userId = $request->session()->get('2fa_user_id'); $remember = $request->session()->get('2fa_remember', false); - if (!$userId) { + if (! $userId) { return redirect()->route('login'); } // Rate limit: 5 attempts per minute - $key = '2fa-challenge:' . $userId; + $key = '2fa-challenge:'.$userId; if (RateLimiter::tooManyAttempts($key, 5)) { $seconds = RateLimiter::availableIn($key); + return back()->withErrors([ 'code' => "Too many attempts. Please wait {$seconds} seconds.", ]); } $user = User::find($userId); - if (!$user || !$user->two_factor_enabled) { + if (! $user || ! $user->two_factor_enabled) { $request->session()->forget(['2fa_user_id', '2fa_remember']); + return redirect()->route('login'); } // Try TOTP code if ($request->filled('code')) { - $google2fa = new Google2FA(); + $google2fa = new Google2FA; $secret = Crypt::decryptString($user->two_factor_secret); $valid = $google2fa->verifyKey($secret, $request->input('code'), 1); @@ -69,7 +71,8 @@ public function verify(Request $request) } RateLimiter::hit($key, 60); - return back()->withErrors(['code' => 'Invalid authentication code.']); + + return back()->withErrors(['code' => __('Invalid authentication code.')]); } // Try recovery code @@ -90,10 +93,11 @@ public function verify(Request $request) } RateLimiter::hit($key, 60); - return back()->withErrors(['recovery_code' => 'Invalid recovery code.']); + + return back()->withErrors(['recovery_code' => __('Invalid recovery code.')]); } - return back()->withErrors(['code' => 'Please enter an authentication code or recovery code.']); + return back()->withErrors(['code' => __('Please enter an authentication code or recovery code.')]); } /** @@ -109,7 +113,7 @@ protected function completeLogin(Request $request, User $user, bool $remember) if ($user->force_password_change) { return redirect()->route('change-password') - ->with('error', 'You must change your password before continuing.'); + ->with('error', __('You must change your password before continuing.')); } // Redirect based on role (same logic as AuthController) @@ -117,6 +121,7 @@ protected function completeLogin(Request $request, User $user, bool $remember) if (OnboardingController::shouldShowWizard()) { return redirect()->route('onboarding.index'); } + return redirect()->route('admin.dashboard'); } diff --git a/backend/app/Http/Controllers/Web/TwoFactorController.php b/backend/app/Http/Controllers/Web/TwoFactorController.php index 1a85aa60..24f767b5 100644 --- a/backend/app/Http/Controllers/Web/TwoFactorController.php +++ b/backend/app/Http/Controllers/Web/TwoFactorController.php @@ -19,7 +19,7 @@ class TwoFactorController extends Controller public function __construct() { - $this->google2fa = new Google2FA(); + $this->google2fa = new Google2FA; } /** @@ -42,7 +42,7 @@ public function enable(Request $request) // Generate secret (or reuse pending one from session) $secret = $request->session()->get('2fa_setup_secret'); - if (!$secret) { + if (! $secret) { $secret = $this->google2fa->generateSecretKey(); $request->session()->put('2fa_setup_secret', $secret); } @@ -55,7 +55,7 @@ public function enable(Request $request) // Generate QR code image as data URI $result = Builder::create() - ->writer(new PngWriter()) + ->writer(new PngWriter) ->data($qrCodeUrl) ->encoding(new Encoding('UTF-8')) ->size(250) @@ -81,21 +81,21 @@ public function confirm(Request $request) ]); $secret = $request->session()->get('2fa_setup_secret'); - if (!$secret) { - return back()->withErrors(['code' => 'Setup session expired. Please start again.']); + if (! $secret) { + return back()->withErrors(['code' => __('Setup session expired. Please start again.')]); } $valid = $this->google2fa->verifyKey($secret, $request->input('code'), 1); - if (!$valid) { - return back()->withErrors(['code' => 'Invalid code. Please try again.']); + if (! $valid) { + return back()->withErrors(['code' => __('Invalid code. Please try again.')]); } $user = auth()->user(); // Generate 8 recovery codes - $recoveryCodes = collect(range(1, 8))->map(fn() => Str::random(10))->values(); - $hashedCodes = $recoveryCodes->map(fn($c) => Hash::make($c))->toArray(); + $recoveryCodes = collect(range(1, 8))->map(fn () => Str::random(10))->values(); + $hashedCodes = $recoveryCodes->map(fn ($c) => Hash::make($c))->toArray(); $user->update([ 'two_factor_secret' => Crypt::encryptString($secret), @@ -109,7 +109,7 @@ public function confirm(Request $request) // Show the recovery codes once on the 2FA page (flashed → read by enable()). return redirect()->route('settings.two-factor.enable') ->with('recoveryCodes', $recoveryCodes->all()) - ->with('success', 'Two-factor authentication enabled.'); + ->with('success', __('Two-factor authentication enabled.')); } /** @@ -123,8 +123,8 @@ public function disable(Request $request) $user = auth()->user(); - if (!Hash::check($request->input('password'), $user->password)) { - return back()->withErrors(['password' => 'Incorrect password.']); + if (! Hash::check($request->input('password'), $user->password)) { + return back()->withErrors(['password' => __('Incorrect password.')]); } $user->update([ @@ -135,7 +135,7 @@ public function disable(Request $request) ]); return redirect()->route('settings.index') - ->with('success', 'Two-factor authentication has been disabled.'); + ->with('success', __('Two-factor authentication has been disabled.')); } /** @@ -149,16 +149,16 @@ public function regenerateRecoveryCodes(Request $request) $user = auth()->user(); - if (!Hash::check($request->input('password'), $user->password)) { - return back()->withErrors(['password' => 'Incorrect password.']); + if (! Hash::check($request->input('password'), $user->password)) { + return back()->withErrors(['password' => __('Incorrect password.')]); } - if (!$user->two_factor_enabled) { - return back()->with('error', '2FA is not enabled.'); + if (! $user->two_factor_enabled) { + return back()->with('error', __('2FA is not enabled.')); } - $recoveryCodes = collect(range(1, 8))->map(fn() => Str::random(10))->values(); - $hashedCodes = $recoveryCodes->map(fn($c) => Hash::make($c))->toArray(); + $recoveryCodes = collect(range(1, 8))->map(fn () => Str::random(10))->values(); + $hashedCodes = $recoveryCodes->map(fn ($c) => Hash::make($c))->toArray(); $user->update([ 'two_factor_recovery_codes' => Crypt::encryptString(json_encode($hashedCodes)), @@ -166,6 +166,6 @@ public function regenerateRecoveryCodes(Request $request) return redirect()->route('settings.two-factor.enable') ->with('recoveryCodes', $recoveryCodes->all()) - ->with('success', 'Recovery codes regenerated.'); + ->with('success', __('Recovery codes regenerated.')); } } diff --git a/backend/app/Http/Requests/RegisterRequest.php b/backend/app/Http/Requests/RegisterRequest.php index 767fcc2f..796516c8 100644 --- a/backend/app/Http/Requests/RegisterRequest.php +++ b/backend/app/Http/Requests/RegisterRequest.php @@ -14,10 +14,10 @@ public function authorize(): bool public function rules(): array { return [ - 'name' => ['required', 'string', 'max:100', 'regex:/^[\p{L}\s\-\']+$/u'], - 'username' => ['required', 'string', 'min:3', 'max:30', 'alpha_num', 'unique:users,username'], - 'email' => ['required', 'email', 'max:255', 'unique:users,email'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'name' => ['required', 'string', 'max:100', 'regex:/^[\p{L}\s\-\']+$/u'], + 'username' => ['required', 'string', 'min:3', 'max:30', 'alpha_num', 'unique:users,username'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], 'password_confirmation' => ['required', 'string'], ]; } @@ -25,12 +25,12 @@ public function rules(): array public function messages(): array { return [ - 'name.regex' => 'Name may only contain letters, spaces, hyphens and apostrophes.', - 'username.alpha_num' => 'Username may only contain letters and numbers.', - 'username.unique' => 'This username is already taken.', - 'email.unique' => 'An account with this email already exists.', - 'password.min' => 'Password must be at least 8 characters.', - 'password.confirmed' => 'Passwords do not match.', + 'name.regex' => __('Name may only contain letters, spaces, hyphens and apostrophes.'), + 'username.alpha_num' => __('Username may only contain letters and numbers.'), + 'username.unique' => __('This username is already taken.'), + 'email.unique' => __('An account with this email already exists.'), + 'password.min' => __('Password must be at least 8 characters.'), + 'password.confirmed' => __('Passwords do not match.'), ]; } } diff --git a/backend/app/Services/Auth/AuthService.php b/backend/app/Services/Auth/AuthService.php index c40a51d7..55b1ed6d 100644 --- a/backend/app/Services/Auth/AuthService.php +++ b/backend/app/Services/Auth/AuthService.php @@ -20,7 +20,7 @@ public function login(string $username, string $password): array if (! $user || ! Hash::check($password, $user->password)) { throw ValidationException::withMessages([ - 'username' => ['The provided credentials are incorrect.'], + 'username' => [__('The provided credentials are incorrect.')], ]); } @@ -64,7 +64,7 @@ public function changePassword(User $user, string $currentPassword, string $newP { if (! Hash::check($currentPassword, $user->password)) { throw ValidationException::withMessages([ - 'current_password' => ['The current password is incorrect.'], + 'current_password' => [__('The current password is incorrect.')], ]); } diff --git a/backend/lang/en.json b/backend/lang/en.json index 15ec8e9e..733268a3 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -4906,5 +4906,29 @@ "New destination": "New destination", "Leave blank to keep the current destination.": "Leave blank to keep the current destination.", "This move completes the assigned destination.": "This move completes the assigned destination.", - "Pallet destination updated.": "Pallet destination updated." + "Pallet destination updated.": "Pallet destination updated.", + "2FA is not enabled.": "2FA is not enabled.", + "Account created successfully. Welcome to OpenMES!": "Account created successfully. Welcome to OpenMES!", + "An account with this email already exists.": "An account with this email already exists.", + "Incorrect password.": "Incorrect password.", + "Invalid authentication code.": "Invalid authentication code.", + "Invalid code. Please try again.": "Invalid code. Please try again.", + "Invalid recovery code.": "Invalid recovery code.", + "Invalid username or PIN.": "Invalid username or PIN.", + "Name may only contain letters, spaces, hyphens and apostrophes.": "Name may only contain letters, spaces, hyphens and apostrophes.", + "PIN login is not enabled.": "PIN login is not enabled.", + "Password changed successfully.": "Password changed successfully.", + "Password must be at least 8 characters.": "Password must be at least 8 characters.", + "Passwords do not match.": "Passwords do not match.", + "Please enter an authentication code or recovery code.": "Please enter an authentication code or recovery code.", + "Recovery codes regenerated.": "Recovery codes regenerated.", + "Setup session expired. Please start again.": "Setup session expired. Please start again.", + "The current password is incorrect.": "The current password is incorrect.", + "The provided credentials are incorrect.": "The provided credentials are incorrect.", + "This username is already taken.": "This username is already taken.", + "Too many registration attempts. Please try again later.": "Too many registration attempts. Please try again later.", + "Two-factor authentication enabled.": "Two-factor authentication enabled.", + "Two-factor authentication has been disabled.": "Two-factor authentication has been disabled.", + "Username may only contain letters and numbers.": "Username may only contain letters and numbers.", + "You have been logged out successfully.": "You have been logged out successfully." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index ae50824a..6e7d02ec 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -4906,5 +4906,29 @@ "New destination": "Nowy cel", "Leave blank to keep the current destination.": "Zostaw puste, aby zachować aktualny cel.", "This move completes the assigned destination.": "Ten ruch kończy przypisany cel.", - "Pallet destination updated.": "Cel palety został zaktualizowany." + "Pallet destination updated.": "Cel palety został zaktualizowany.", + "2FA is not enabled.": "Uwierzytelnianie dwuskładnikowe nie jest włączone.", + "Account created successfully. Welcome to OpenMES!": "Konto zostało utworzone. Witamy w OpenMES!", + "An account with this email already exists.": "Konto z tym adresem e-mail już istnieje.", + "Incorrect password.": "Nieprawidłowe hasło.", + "Invalid authentication code.": "Nieprawidłowy kod uwierzytelniający.", + "Invalid code. Please try again.": "Nieprawidłowy kod. Spróbuj ponownie.", + "Invalid recovery code.": "Nieprawidłowy kod odzyskiwania.", + "Invalid username or PIN.": "Nieprawidłowa nazwa użytkownika lub PIN.", + "Name may only contain letters, spaces, hyphens and apostrophes.": "Imię i nazwisko może zawierać tylko litery, spacje, myślniki i apostrofy.", + "PIN login is not enabled.": "Logowanie PIN-em jest wyłączone.", + "Password changed successfully.": "Hasło zostało zmienione.", + "Password must be at least 8 characters.": "Hasło musi mieć co najmniej 8 znaków.", + "Passwords do not match.": "Hasła nie są identyczne.", + "Please enter an authentication code or recovery code.": "Podaj kod uwierzytelniający lub kod odzyskiwania.", + "Recovery codes regenerated.": "Kody odzyskiwania zostały wygenerowane ponownie.", + "Setup session expired. Please start again.": "Sesja konfiguracji wygasła. Zacznij od nowa.", + "The current password is incorrect.": "Obecne hasło jest nieprawidłowe.", + "The provided credentials are incorrect.": "Podane dane logowania są nieprawidłowe.", + "This username is already taken.": "Ta nazwa użytkownika jest już zajęta.", + "Too many registration attempts. Please try again later.": "Zbyt wiele prób rejestracji. Spróbuj ponownie później.", + "Two-factor authentication enabled.": "Uwierzytelnianie dwuskładnikowe zostało włączone.", + "Two-factor authentication has been disabled.": "Uwierzytelnianie dwuskładnikowe zostało wyłączone.", + "Username may only contain letters and numbers.": "Nazwa użytkownika może zawierać tylko litery i cyfry.", + "You have been logged out successfully.": "Zostałeś wylogowany." } From 446ea16f4a373767d898edbb8c3a3fa25fd4fe80 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Sat, 25 Jul 2026 22:30:18 +0200 Subject: [PATCH 3/4] fix(logistics): align pallet logistics card and table widths The "Set destination" card sat outside ResourceTable's own max-w-7xl wrapper, so it ran edge-to-edge while the table stayed inset. Adds an opt-in `fullWidth` prop to ResourceTable and uses it here, so both share one left/right edge; every other list keeps the 7xl cap. The card's text inputs also used the global .form-input (min-h-[48px]) next to Dropdown triggers (~39px), leaving the four-field row ragged. They now match the Dropdown metrics. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YbLa6GeJZACX3tFCE7iNJq --- .../resources/js/Pages/logistics/Pallets.jsx | 18 ++++++++++++++++-- .../resources/js/components/ResourceTable.jsx | 7 ++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/resources/js/Pages/logistics/Pallets.jsx b/backend/resources/js/Pages/logistics/Pallets.jsx index d86065ef..88eff02a 100644 --- a/backend/resources/js/Pages/logistics/Pallets.jsx +++ b/backend/resources/js/Pages/logistics/Pallets.jsx @@ -36,6 +36,17 @@ const TRANSIT_BADGE = { }; const TRANSIT_LABELS = { in_transit: 'In transit', arrived: 'At destination', idle: 'Not assigned' }; +/** + * Text-input metrics matched to the shared Dropdown trigger (px-[13px] + * py-[10px], 13.5px text) so the four fields in the re-route row line up. + * The global .form-input is min-h-[48px], which stands ~9px taller than a + * Dropdown and makes a mixed row look ragged. + */ +const FIELD_CLS = + 'w-full rounded-om-sm border border-om-line bg-om-bg px-[13px] py-[10px] text-[13.5px] ' + + 'text-om-ink placeholder:text-om-faint outline-none transition-colors ' + + 'focus:border-om-accent focus:ring-[3px] focus:ring-om-accent/15'; + /** Assign or clear a pallet's destination — an explicit, logged re-route. */ function RerouteCard() { const { movablePallets = [], operators = [] } = usePage().props; @@ -54,6 +65,8 @@ function RerouteCard() { }; return ( + // Uncapped like the table below it (ResourceTable fullWidth), so the two + // share the same left/right edge.

{__('Set destination')}

@@ -92,7 +105,7 @@ function RerouteCard() { value={data.destination} onChange={(e) => setData('destination', e.target.value)} placeholder={__('e.g. DOCK-01')} - className="form-input w-full" + className={FIELD_CLS} /> {errors.destination &&

{errors.destination}

} @@ -116,7 +129,7 @@ function RerouteCard() { name="notes" value={data.notes} onChange={(e) => setData('notes', e.target.value)} - className="form-input w-full" + className={FIELD_CLS} /> {errors.notes &&

{errors.notes}

} @@ -199,6 +212,7 @@ export default function LogisticsPallets() { realtimeCollection(shape, getKey), [shape]); @@ -237,7 +242,7 @@ export default function ResourceTable({ return ( -
+

{__(title)}

From b48888942635d11347b101e2d3514e2b53f095d6 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Sat, 25 Jul 2026 22:32:16 +0200 Subject: [PATCH 4/4] fix(logistics): address CodeRabbit review on #209 - 2FA challenge rate-limit message was a hardcoded interpolated string, so it stayed English on a Polish UI. Now goes through __() with a :seconds placeholder; key added to lang/en.json and lang/pl.json. - Corrected the claim, repeated in the migration docblock, the movements list and CHANGELOG, that an emptied to_destination marks an arrival. Clearing a destination produces the identical ledger shape; only a move landing on the pending destination is an arrival, and pallets.arrived_at is authoritative. - The admin-CRUD test now asserts no ledger row is written, so the deliberate "admin edits are unlogged" behaviour can't regress silently. Not applied: CodeRabbit flagged "Select an active logistics operator." as missing from lang/pl.json. It is present ("Wybierz aktywnego operatora logistyki.") and en/pl parity verified clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YbLa6GeJZACX3tFCE7iNJq --- CHANGELOG.md | 2 +- .../Controllers/Web/TwoFactorChallengeController.php | 2 +- ..._100001_add_destination_to_pallet_movements_table.php | 9 +++++++-- backend/lang/en.json | 3 ++- backend/lang/pl.json | 3 ++- .../resources/js/Pages/admin/pallet-movements/Index.jsx | 6 ++++-- .../Feature/Web/Logistics/PalletDestinationTest.php | 5 +++++ 7 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8715f8..95f8db17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added -- **Pallet status, location & destination for logistics tracking** *([#101](https://github.com/Mes-Open/OpenMes/issues/101))*: a pallet now carries not just **where it is** but **where it should go**. Alongside the existing status (open / closed / shipped) and current **location**, pallets gain a **destination** and an **arrival stamp**, so logistics can tell an idle pallet from one that is **in transit** and one that has **reached its target**. The destination is **cleared on arrival**: moving a pallet onto its destination completes the run and stamps `arrived_at`, and moving on afterwards (or re-routing it) voids the stale arrival — so transit state is a property of the row, not a string comparison the UI has to redo. A new tablet-friendly **Pallet Logistics** view (Packaging → Pallet Logistics, `Operator`/`Supervisor`/`Admin`) lists every pallet live with **status, location, destination, transit state and arrival**, filterable by status and searchable by location/destination, plus a **"Set destination"** card that re-routes a pallet **without moving it** (or clears its target). The **Move Pallet** terminal gains an optional **new destination** field so a re-route can be booked with the move, and flags when the entered location will complete the pallet's assigned destination. **Destination changes share the existing append-only `pallet_movements` ledger** (new `from_destination` / `to_destination` columns), so one timeline answers who moved a pallet *and* who redirected it — a row whose from/to locations match reads as a re-route, one whose destination went from set to empty reads as an arrival; the admin **Pallet Movements** history surfaces both. Every mutation goes through `PalletMovementService` under a row lock, so concurrent terminals can't interleave a stale "from" snapshot. Existing pallets are unaffected (both new columns are nullable, destination defaults to unset). New `pallets.destination` + `pallets.arrived_at` and `pallet_movements.from_destination` + `to_destination` columns, `PalletMovementService::assignDestination()`, `AssignPalletDestinationRequest`, `Pallet::isInTransit()`; `destination`/`arrived_at` added to the `pallets` Electric shape and the destination pair to `PalletMovementsRecentShape`. +- **Pallet status, location & destination for logistics tracking** *([#101](https://github.com/Mes-Open/OpenMes/issues/101))*: a pallet now carries not just **where it is** but **where it should go**. Alongside the existing status (open / closed / shipped) and current **location**, pallets gain a **destination** and an **arrival stamp**, so logistics can tell an idle pallet from one that is **in transit** and one that has **reached its target**. The destination is **cleared on arrival**: moving a pallet onto its destination completes the run and stamps `arrived_at`, and moving on afterwards (or re-routing it) voids the stale arrival — so transit state is a property of the row, not a string comparison the UI has to redo. A new tablet-friendly **Pallet Logistics** view (Packaging → Pallet Logistics, `Operator`/`Supervisor`/`Admin`) lists every pallet live with **status, location, destination, transit state and arrival**, filterable by status and searchable by location/destination, plus a **"Set destination"** card that re-routes a pallet **without moving it** (or clears its target). The **Move Pallet** terminal gains an optional **new destination** field so a re-route can be booked with the move, and flags when the entered location will complete the pallet's assigned destination. **Destination changes share the existing append-only `pallet_movements` ledger** (new `from_destination` / `to_destination` columns), so one timeline answers who moved a pallet *and* who redirected it — a row whose from/to locations match reads as a re-route or an unassignment rather than a physical move, and a move landing on the pending destination reads as an arrival (an emptied destination on its own doesn't prove one — `pallets.arrived_at` is authoritative); the admin **Pallet Movements** history surfaces both. Every mutation goes through `PalletMovementService` under a row lock, so concurrent terminals can't interleave a stale "from" snapshot. Existing pallets are unaffected (both new columns are nullable, destination defaults to unset). New `pallets.destination` + `pallets.arrived_at` and `pallet_movements.from_destination` + `to_destination` columns, `PalletMovementService::assignDestination()`, `AssignPalletDestinationRequest`, `Pallet::isInTransit()`; `destination`/`arrived_at` added to the `pallets` Electric shape and the destination pair to `PalletMovementsRecentShape`. - **Module selection at first-login onboarding (Lightweight / Advanced / Custom)**: the setup wizard now opens with a **"Choose your modules"** step so a new admin turns on only the feature areas they need instead of getting the full menu. Three one-click choices over the existing module system: **Lightweight** (core production tracking + the **Work-Order History** report only — recommended for small shops), **Advanced** (adds reporting, materials & tracing, product engineering, companies, quality, maintenance, connectivity and packaging — the shop-floor operations set), or **Custom** (tick exactly which optional modules to enable). Core areas (Dashboard, Orders, Production essentials, Admin) are always on. The choice writes to the existing `system_settings.enabled_modules` via `ModuleRegistry`, so disabled areas are hidden from the nav and their routes 404 — and it's **changeable anytime** in Settings → System → Modules. New onboarding **Modules** step (now step 1 of 5) with `ModuleRegistry::PRESETS` + `modulesForPreset()`; reuses `TabRegistry`/`TabAccessMiddleware`. The optional-module catalog is now **fine-grained** so an install can keep, say, **Materials** without **Companies**, or the **Work-Order History** report without the **cost/scrap/non-conformance analytics**. Twelve independent toggles — **Reports**, **Advanced reports**, **Materials & tracing**, **Product engineering**, **Companies**, **Issues & reasons**, **Company structure**, **HR** (incl. employee scheduling), **Maintenance & QC**, **Connectivity**, **Packaging**, **Webhooks** — each maps to its own `TabRegistry` tab and role-access-matrix row, even when its pages render under the (core) Production or Reports nav groups. Existing installs that had explicitly customised their module set are backfilled on upgrade so previously-core areas stay visible (`2026_07_23_120000_expand_enabled_modules_for_granular_split`); installs that never customised default to all-on and are unaffected. - **Machine counter signals drive work-order progress** *([#46](https://github.com/Mes-Open/OpenMes/issues/46))*: a work order now carries a **counting source** — **Operator (manual)**, **Machine (automatic)** or **Both** — chosen on the admin/supervisor create & edit form (default **Operator**, so existing orders are unchanged). On a **machine-counted** order, good-count deltas from the Modbus / OPC UA / MQTT **signal pipeline** now flow through to `work_orders.produced_qty`: the count is attributed to the order via the **batch step currently in progress at the reporting workstation** (`MachineTag → workstation → in-progress `BatchStep` → batch → work order`), and the same **auto-start / auto-complete** side effects fire as for operator entry. The legacy MQTT topic-mapping path (`ActionExecutor::updateWorkOrderQty`) and the new pipeline now funnel through one shared `MachineProductionService`, which **honours the counting source** — so a machine mapping and operator entry can no longer both add to the same order (the **double-count** #46 described). On a machine-counted order the operator's manual **complete / shift-entry** is blocked (the machine is the single source of truth), and the operator queue shows a **"Machine" badge**; reject counts stay in the event log for now (scrap wiring is a follow-up). New `work_orders.counting_source` column (nullable, defaults to `operator`); `App\Services\WorkOrder\MachineProductionService`; `counting_source` added to the work-order Electric shapes. diff --git a/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php b/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php index e25022cb..23117b6e 100644 --- a/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php +++ b/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php @@ -49,7 +49,7 @@ public function verify(Request $request) $seconds = RateLimiter::availableIn($key); return back()->withErrors([ - 'code' => "Too many attempts. Please wait {$seconds} seconds.", + 'code' => __('Too many attempts. Please wait :seconds seconds.', ['seconds' => $seconds]), ]); } diff --git a/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php b/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php index b56046ca..c43f13b5 100644 --- a/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php +++ b/backend/database/migrations/2026_07_25_100001_add_destination_to_pallet_movements_table.php @@ -12,8 +12,13 @@ * authority it already answers "who moved it". * * A row where from_location === to_location is a destination-only change - * (the pallet was re-routed without being touched); a row where - * to_destination is null while from_destination was set is an arrival. + * (re-routed or unassigned without being touched). A move that lands on the + * pending destination — to_location === from_destination with to_destination + * back to null — is an arrival. + * + * Note that to_destination === null alone does NOT prove an arrival: + * clearing a destination produces the same shape. The two are only + * distinguishable via pallets.arrived_at, which is authoritative. */ public function up(): void { diff --git a/backend/lang/en.json b/backend/lang/en.json index 733268a3..d1055e57 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -4930,5 +4930,6 @@ "Two-factor authentication enabled.": "Two-factor authentication enabled.", "Two-factor authentication has been disabled.": "Two-factor authentication has been disabled.", "Username may only contain letters and numbers.": "Username may only contain letters and numbers.", - "You have been logged out successfully.": "You have been logged out successfully." + "You have been logged out successfully.": "You have been logged out successfully.", + "Too many attempts. Please wait :seconds seconds.": "Too many attempts. Please wait :seconds seconds." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 6e7d02ec..fbdec50e 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -4930,5 +4930,6 @@ "Two-factor authentication enabled.": "Uwierzytelnianie dwuskładnikowe zostało włączone.", "Two-factor authentication has been disabled.": "Uwierzytelnianie dwuskładnikowe zostało wyłączone.", "Username may only contain letters and numbers.": "Nazwa użytkownika może zawierać tylko litery i cyfry.", - "You have been logged out successfully.": "Zostałeś wylogowany." + "You have been logged out successfully.": "Zostałeś wylogowany.", + "Too many attempts. Please wait :seconds seconds.": "Zbyt wiele prób. Odczekaj :seconds s." } diff --git a/backend/resources/js/Pages/admin/pallet-movements/Index.jsx b/backend/resources/js/Pages/admin/pallet-movements/Index.jsx index 6a1d566f..ceb23e14 100644 --- a/backend/resources/js/Pages/admin/pallet-movements/Index.jsx +++ b/backend/resources/js/Pages/admin/pallet-movements/Index.jsx @@ -38,8 +38,10 @@ export default function PalletMovementsIndex() { }, { // Destination changes share this ledger (#101). A row whose From and - // To locations match is a re-route, not a physical move; a row whose - // destination went from set to empty is an arrival. + // To locations match is a re-route or an unassignment, not a physical + // move. An emptied destination alone doesn't prove an arrival — only a + // move landing on the pending destination does; pallets.arrived_at is + // the authoritative record. key: 'to_destination', label: __('Destination'), className: 'font-mono text-om-muted', diff --git a/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php b/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php index 0d64c220..49d6b6ad 100644 --- a/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php +++ b/backend/tests/Feature/Web/Logistics/PalletDestinationTest.php @@ -357,6 +357,11 @@ public function test_admin_can_edit_a_pallet_destination_directly(): void ])->assertRedirect(route('admin.pallets.index')); $this->assertSame('DOCK-04', $pallet->fresh()->destination); + + // Admin CRUD writes the column directly — deliberately no ledger entry, + // matching how it already treats `location`. Asserted so the documented + // behaviour can't drift silently. + $this->assertDatabaseCount('pallet_movements', 0); } public function test_history_survives_a_pallets_full_journey(): void