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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,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=<id>` 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=<id>` 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.

Expand Down
25 changes: 25 additions & 0 deletions backend/app/Enums/PalletStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
public static function labels(): array
{
return array_reduce(
self::cases(),
fn (array $carry, self $c) => $carry + [$c->value => $c->label()],
[],
);
}

/** @return list<array{value: string, label: string}> */
public static function options(): array
{
return array_map(
fn (self $c) => ['value' => $c->value, 'label' => $c->label()],
self::cases(),
);
}
}
28 changes: 4 additions & 24 deletions backend/app/Http/Controllers/Web/Admin/PalletController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}
Expand All @@ -25,7 +25,7 @@ public function create()
{
return Inertia::render('admin/pallets/Create', [
'workOrders' => $this->workOrderOptions(),
'statuses' => $this->statusOptions(),
'statuses' => PalletStatus::options(),
]);
}

Expand All @@ -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(),
]);
}
Expand Down Expand Up @@ -86,26 +86,6 @@ private function workOrderOptions()
]);
}

/** @return array<string, string> */
private function statusLabels(): array
{
$labels = [];
foreach (PalletStatus::cases() as $case) {
$labels[$case->value] = $case->label();
}

return $labels;
}

/** @return list<array{value: string, label: string}> */
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)
Expand Down
14 changes: 7 additions & 7 deletions backend/app/Http/Controllers/Web/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.')],
]);
}

Expand All @@ -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.'));
}
Comment on lines 80 to 84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for locale in backend/lang/en.json backend/lang/pl.json; do
  jq -e '
    has("You must change your password before continuing.") and
    has("Too many attempts. Please wait :seconds seconds.")
  ' "$locale" >/dev/null
done

rg -nF 'Too many attempts. Please wait {$seconds} seconds.' \
  backend/app/Http/Controllers/Web/TwoFactorChallengeController.php

Repository: Mes-Open/OpenMes

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'backend/(app/Http/Controllers/Web/(AuthController|TwoFactorChallengeController)\.php|lang/(en|pl)\.json)$'

echo
echo "AuthController relevant sections:"
sed -n '1,170p' backend/app/Http/Controllers/Web/AuthController.php

echo
echo "TwoFactorChallengeController relevant sections:"
sed -n '1,150p' backend/app/Http/Controllers/Web/TwoFactorChallengeController.php

echo
echo "Translation snippets around known nearby keys:"
python3 - <<'PY'
import json
for f in ["backend/lang/en.json", "backend/lang/pl.json"]:
    data=json.load(open(f))
    print(f"\n{f}")
    needles=["You must change your password before continuing.", "Too many attempts. Please wait :seconds seconds.", "Too many registration attempts. Please try the later.", "Two-factor authentication"]
    for s in sorted(set(k for k in data if any((s in k if isinstance(k,str) else False) for s in ["password", "Too many"])+[("password" in k or "Too many" in k) if isinstance(k,str) else False])):
        if k in needles or "password" in k or "Twó" in k:
            print(k)
PY

echo
echo "Search exact auth strings and rate-limit strings:"
rg -n 'force_password_change|Too many attempts|wait \{\$seconds\} seconds|Two-factor' backend/app backend/lang

Repository: Mes-Open/OpenMes

Length of output: 10491


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Translation snippet search:"
python3 - <<'PY'
import json
paths = ["backend/lang/en.json", "backend/lang/pl.json"]
for f in paths:
    data = json.load(open(f))
    print(f"\n{f}")
    for key in [
        "You must change your password before continuing.",
        "Too many attempts. Please wait :seconds seconds.",
    ]:
        print(key, "=>", data.get(key, "<missing>"))
PY

echo
echo "Relevant exact usages/searches:"
rg -n 'force_password_change|Too many attempts|wait \{\$seconds\} seconds|Two-factor authentication' backend/app backend/lang

Repository: Mes-Open/OpenMes

Length of output: 5231


Make the 2FA rate-limit error translatable.

The forced-password-change messages are already present in English and Polish, but the 2FA attempt-limit message is still built as a hard-coded string, so it cannot be localized.

  • Update backend/app/Http/Controllers/Web/TwoFactorChallengeController.php#51-54 to use a translation string with a :seconds placeholder and add entries to all locale files.
📍 Affects 4 files
  • backend/app/Http/Controllers/Web/AuthController.php#L80-L84 (this comment)
  • backend/app/Http/Controllers/Web/AuthController.php#L129-L132
  • backend/app/Http/Controllers/Web/TwoFactorChallengeController.php#L51-L54
  • backend/app/Http/Controllers/Web/TwoFactorChallengeController.php#L114-L117
  • backend/lang/en.json#L4910-L4933
  • backend/lang/pl.json#L4910-L4933
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/Http/Controllers/Web/AuthController.php` around lines 80 - 84,
Make the 2FA attempt-limit message translatable by updating the rate-limit
branch in
backend/app/Http/Controllers/Web/TwoFactorChallengeController.php:51-54 to use
the translation key with a :seconds placeholder and pass the remaining seconds
for interpolation; add the corresponding entries to
backend/lang/en.json:4910-4933 and backend/lang/pl.json:4910-4933. The
AuthController locations at
backend/app/Http/Controllers/Web/AuthController.php:80-84 and :129-132, and the
secondary TwoFactorChallengeController location at :114-117, require no direct
changes.


// Redirect to appropriate dashboard based on role
Expand All @@ -99,15 +99,15 @@ 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.')],
]);
}

$user = User::where('username', $request->input('username'))->first();

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.')],
]);
}

Expand All @@ -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();
Expand Down Expand Up @@ -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())
Expand All @@ -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.'));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand All @@ -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(),
]);
}

Expand All @@ -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()
{
Expand Down
Loading
Loading