Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
6ce596f
feat: required operators
Svannte Jun 23, 2026
f93bd38
fix: styles
Svannte Jun 25, 2026
509a2cb
feat: merge
Svannte Jun 25, 2026
0df379a
test: fix e2e disposition flow and create English scenario variant
SoiBien-AI Jun 30, 2026
dcb2493
[feat] Per-order machines to run supervisor view #89
ElNinio978 Jun 30, 2026
7fe83c2
Update tests and localization
SoiBien-AI Jun 30, 2026
4f302a8
fix to test can capture all step from multi users by logout and login
SoiBien-AI Jul 1, 2026
1ea24e8
fix test error
SoiBien-AI Jul 1, 2026
b5da428
clean: remove temporary development files and compiled output
SoiBien-AI Jul 1, 2026
ac660a7
merge: origin/develop into recreate-test (resolved conflict on specs …
SoiBien-AI Jul 1, 2026
957e84d
translate: translate Operator Workstation and Work Order details to V…
SoiBien-AI Jul 1, 2026
e269789
fix: address code review comments for i18n localization and backend l…
SoiBien-AI Jul 2, 2026
73cb643
fix: address remaining PR comments on docker-compose mount, reset scr…
SoiBien-AI Jul 2, 2026
25b5aa8
fix: adjust lot pick layout font sizes and make E2E dropdown lot sele…
SoiBien-AI Jul 2, 2026
e552f61
fix: localize Start and Complete step buttons under Operator panel
SoiBien-AI Jul 2, 2026
743b518
style: polish LotPickModal row spacing and fonts to avoid vertical ov…
SoiBien-AI Jul 2, 2026
5332a55
fix: wait for start API response before expecting step Complete butto…
SoiBien-AI Jul 2, 2026
59f2203
fix: refine confirmBtn locator in E2E spec to avoid partial text matc…
SoiBien-AI Jul 2, 2026
7c3dc6e
fix: unconditionally scan and fill selects in E2E lot picking modal
SoiBien-AI Jul 2, 2026
a54a00d
fix: refine createBtn button locator regex pattern
SoiBien-AI Jul 2, 2026
22c36de
fix: use explicit workstation button label in E2E spec to avoid stric…
SoiBien-AI Jul 2, 2026
6b70b04
fix: streamline startBtn visibility and click handler logic in E2E spec
SoiBien-AI Jul 2, 2026
e3bdcee
fix: add robust re-click startBtn fallback to start batch step in E2E…
SoiBien-AI Jul 2, 2026
2c0587c
translate: localize work order operator modals and actions to support…
SoiBien-AI Jul 2, 2026
0fa55f3
fix(materials): make material_type_id optional in StoreMaterialRequest
SoiBien-AI Jul 2, 2026
bbba570
chore: remove reset-test cmd files
SoiBien-AI Jul 2, 2026
371910f
fix(i18n): address PR reviews for operator controllers translation gaps
SoiBien-AI Jul 2, 2026
f3888f9
chore: remove car-production-buildout spec files
SoiBien-AI Jul 2, 2026
3f9d593
fix(i18n): align media type badge localization mapping key with selec…
SoiBien-AI Jul 2, 2026
69d4627
feat: mobile
Svannte Jul 5, 2026
3131c5a
fix: restore removed migration 2026_06_18_120000 (make material_type_…
SoiBien-AI Jul 7, 2026
6026084
Merge pull request #169 from SoiBien-AI/feat/translate-develop
jakub-przepiora Jul 7, 2026
3762d59
Address order machines review feedback
ElNinio978 Jul 7, 2026
0906143
Merge pull request #171 from ElNinio978/per-order-machines-supervisor…
jakub-przepiora Jul 7, 2026
3ff8bc2
fix: logs
Svannte Jul 8, 2026
51b40ce
merge
Svannte Jul 8, 2026
3539d17
feat: accepnt informations
JanKolo04 Jul 8, 2026
8045645
feat: finish up whole feature
JanKolo04 Jul 9, 2026
49267fe
fix: address CodeRabbit review on customer/priority PR
JanKolo04 Jul 10, 2026
db15817
Merge pull request #173 from Mes-Open/feature/37-customer-management-…
jakub-przepiora Jul 10, 2026
2f0238a
Merge origin/develop into feature/95-read-confirmation
JanKolo04 Jul 10, 2026
bb5a818
Merge pull request #172 from Mes-Open/feature/95-read-confirmation
jakub-przepiora Jul 10, 2026
69f99f9
merge
Svannte Jul 16, 2026
f0468ac
Merge pull request #148 from Mes-Open/schedule-capacity-view
Svannte Jul 16, 2026
6ff6dee
feat(workstation): shop-floor device registration + live admin roster…
jakub-przepiora Jul 19, 2026
988e209
ci(release): build desktop installers (win/mac/linux) + Android apk (…
jakub-przepiora Jul 19, 2026
2ff45e8
feat(desktop): OpenMES Desktop app (Tauri) + unattended install prese…
jakub-przepiora Jul 19, 2026
2201038
feat(work-orders): add elapsed-time "Age" column to work-order lists …
JanKolo04 Jul 19, 2026
761e1e6
fix(routes): drop dangling __e2e__ block; 404 (not 500) on non-numeri…
jakub-przepiora Jul 19, 2026
acd007e
feat(bom): select multi bom in orders (#183)
JanKolo04 Jul 19, 2026
8ec1ffa
fix(e2e): close unterminated test.describe in material-hold-release s…
jakub-przepiora Jul 19, 2026
1498faa
feat: Identification of logistics operators / forklift drivers on phy…
JanKolo04 Jul 22, 2026
58a3f27
feat(product): product revision management with lifecycle + work-orde…
jakub-przepiora Jul 22, 2026
a8fa3b7
feat(machine): drive produced_qty from machine counter signals via co…
jakub-przepiora Jul 22, 2026
78feeb1
docs(mqtt): clarify OpenMES is a client not a broker; add WebSocket l…
jakub-przepiora Jul 22, 2026
4b51eb8
fix(mqtt): auto-pick up new connections + stop Live Message Log crash…
jakub-przepiora Jul 23, 2026
a960fe7
feat(onboarding): choose modules at first login (Lightweight / Advanc…
jakub-przepiora Jul 23, 2026
4e3d2ba
feat(onboarding): gate Employee scheduling by the HR module (hidden i…
jakub-przepiora Jul 23, 2026
6857eb7
fix(schedule): hide planner Employees shortcut when HR module is off
jakub-przepiora Jul 23, 2026
3458892
feat(onboarding): gate Materials/Process Segments/Revisions/Companies…
jakub-przepiora Jul 23, 2026
7958cfc
feat(onboarding): also gate Material Lots and Traceability by the Str…
jakub-przepiora Jul 23, 2026
f42ca2e
feat(onboarding): gate analytical reports (cost/scrap/non-conformance…
jakub-przepiora Jul 23, 2026
205b1b0
fix(onboarding): update module descriptions to match new tab gating (…
jakub-przepiora Jul 23, 2026
8fe7707
feat(onboarding): split modules into 12 fine-grained toggles in Setti…
jakub-przepiora Jul 23, 2026
c8e30ee
Merge pull request #202 from Mes-Open/feat/onboarding-module-selection
jakub-przepiora Jul 24, 2026
f934641
Merge main (0.17.x fixes) into release/v0.18.0
jakub-przepiora Jul 24, 2026
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
24 changes: 17 additions & 7 deletions CHANGELOG.md

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions backend/app/Enums/RevisionLifecycle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Enums;

/**
* Product revision lifecycle. A revision starts as DRAFT (editable, not
* selectable for production), is RELEASED once its configuration is approved
* (immutable, selectable for new work orders), and finally OBSOLETE (retained
* for historical traceability, no longer selectable). Ordered draft < released
* < obsolete.
*/
enum RevisionLifecycle: string
{
case Draft = 'draft';
case Released = 'released';
case Obsolete = 'obsolete';

public function label(): string
{
return match ($this) {
self::Draft => __('Draft'),
self::Released => __('Released'),
self::Obsolete => __('Obsolete'),
};
}

/** Tailwind badge classes used by the front-end lifecycle badge. */
public function badgeColor(): string
{
return match ($this) {
self::Draft => 'bg-gray-200 text-gray-700',
self::Released => 'bg-green-100 text-green-800',
self::Obsolete => 'bg-amber-100 text-amber-800',
};
}

/** Only released revisions may be selected for new production orders. */
public function isSelectable(): bool
{
return $this === self::Released;
}
}
129 changes: 129 additions & 0 deletions backend/app/Http/Controllers/Web/Admin/ProductRevisionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace App\Http\Controllers\Web\Admin;

use App\Enums\RevisionLifecycle;
use App\Http\Controllers\Controller;
use App\Http\Requests\Web\Admin\StoreProductRevisionRequest;
use App\Http\Requests\Web\Admin\UpdateProductRevisionRequest;
use App\Models\ProcessTemplate;
use App\Models\ProductRevision;
use App\Models\ProductType;
use Inertia\Inertia;

class ProductRevisionController extends Controller
{
/**
* List revisions. Rows live-sync via the `product_revisions` shape; product
* type + template names and work-order counts come as props.
*/
public function index()
{
$counts = ProductRevision::withCount('workOrders')
->get(['id'])
->mapWithKeys(fn ($r) => [$r->id => $r->work_orders_count]);

return Inertia::render('admin/product-revisions/Index', [
'productTypes' => ProductType::orderBy('name')->get(['id', 'code', 'name']),
'processTemplates' => ProcessTemplate::orderBy('name')->get(['id', 'name', 'version', 'product_type_id', 'is_active']),
'counts' => $counts,
]);
}

public function create()
{
return Inertia::render('admin/product-revisions/Create', $this->formOptions());
}

public function store(StoreProductRevisionRequest $request)
{
$validated = $request->validated();
$validated['lifecycle_status'] = RevisionLifecycle::Draft;

$revision = ProductRevision::create($validated);

return redirect()->route('admin.product-revisions.index')
->with('success', __('Product revision :code created.', ['code' => $revision->revision_code]));
}

public function edit(ProductRevision $productRevision)
{
return Inertia::render('admin/product-revisions/Edit', array_merge($this->formOptions(), [
'revision' => $productRevision->only(
'id', 'product_type_id', 'revision_code', 'description',
'process_template_id', 'change_reason', 'external_ref',
'lifecycle_status', 'effective_from', 'effective_to'
),
]));
}

public function update(UpdateProductRevisionRequest $request, ProductRevision $productRevision)
{
// A released or obsolete revision is immutable — its configuration is
// frozen so historical work orders stay consistent. Only DRAFT edits.
if (! $productRevision->isDraft()) {
return back()->with('error', __('Only draft revisions can be edited. Released revisions are immutable.'));
}

$productRevision->update($request->validated());

return redirect()->route('admin.product-revisions.index')
->with('success', __('Product revision :code updated.', ['code' => $productRevision->revision_code]));
}

public function destroy(ProductRevision $productRevision)
{
// A revision already used by a work order is kept for traceability.
if ($productRevision->workOrders()->exists()) {
return back()->with('error', __('Cannot delete a revision that is used by work orders.'));
}

$productRevision->delete();

return redirect()->route('admin.product-revisions.index')
->with('success', __('Product revision :code deleted.', ['code' => $productRevision->revision_code]));
}

/** DRAFT → RELEASED. Requires a process template so production has a config. */
public function release(ProductRevision $productRevision)
{
if (! $productRevision->isDraft()) {
return back()->with('error', __('Only draft revisions can be released.'));
}

if (! $productRevision->process_template_id) {
return back()->with('error', __('Select a process template before releasing the revision.'));
}

$productRevision->update([
'lifecycle_status' => RevisionLifecycle::Released,
'released_at' => now(),
'released_by_id' => auth()->id(),
]);

return back()->with('success', __('Product revision :code released.', ['code' => $productRevision->revision_code]));
}

/** RELEASED → OBSOLETE. Kept for historical traceability, no longer selectable. */
public function obsolete(ProductRevision $productRevision)
{
if (! $productRevision->isReleased()) {
return back()->with('error', __('Only released revisions can be made obsolete.'));
}

$productRevision->update([
'lifecycle_status' => RevisionLifecycle::Obsolete,
'obsolete_at' => now(),
]);

return back()->with('success', __('Product revision :code marked obsolete.', ['code' => $productRevision->revision_code]));
}

private function formOptions(): array
{
return [
'productTypes' => ProductType::orderBy('name')->get(['id', 'code', 'name']),
'processTemplates' => ProcessTemplate::orderBy('name')->get(['id', 'name', 'version', 'product_type_id', 'is_active']),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public function create(CustomFieldService $customFields)
'lines' => Line::where('is_active', true)->orderBy('name')->get(['id', 'name']),
'productTypes' => ProductType::where('is_active', true)->orderBy('name')->get(['id', 'name']),
'bomTemplates' => $this->bomTemplateOptions(),
'productRevisions' => $this->productRevisionOptions(),
'customers' => Customer::active()->orderBy('name')->get(['id', 'name', 'tier']),
'customFields' => $customFields->clientConfig('work_order'),
]);
Expand Down Expand Up @@ -71,6 +72,26 @@ protected function bomTemplateOptions()
]);
}

/**
* Released product revisions (#180) selectable on the work-order forms. Each
* option carries its product_type_id so the form can scope it to the order's
* product type.
*
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
*/
protected function productRevisionOptions()
{
return \App\Models\ProductRevision::selectable()
->orderBy('product_type_id')
->orderBy('revision_code')
->get(['id', 'revision_code', 'product_type_id'])
->map(fn ($r) => [
'id' => $r->id,
'revision_code' => $r->revision_code,
'product_type_id' => $r->product_type_id,
]);
}

public function store(StoreWorkOrderRequest $request, CustomFieldService $cf)
{
$validated = $request->validated();
Expand Down Expand Up @@ -164,7 +185,7 @@ public function edit(WorkOrder $workOrder, CustomFieldService $customFields)
{
return Inertia::render('admin/work-orders/Edit', [
'workOrder' => [
...$workOrder->only('id', 'order_no', 'customer_order_no', 'customer_id', 'line_id', 'product_type_id', 'planned_qty', 'unit_price', 'priority', 'description', 'status', 'custom_fields'),
...$workOrder->only('id', 'order_no', 'customer_order_no', 'customer_id', 'line_id', 'product_type_id', 'product_revision_id', 'planned_qty', 'unit_price', 'counting_source', 'priority', 'description', 'status', 'custom_fields'),
'due_date' => $workOrder->due_date?->format('Y-m-d'),
// Current BOM selection (empty for legacy single-BOM orders).
'bom_template_ids' => $workOrder->bomTemplates()->pluck('process_templates.id')->all(),
Expand All @@ -174,6 +195,7 @@ public function edit(WorkOrder $workOrder, CustomFieldService $customFields)
'lines' => Line::where('is_active', true)->orderBy('name')->get(['id', 'name']),
'productTypes' => ProductType::where('is_active', true)->orderBy('name')->get(['id', 'name']),
'bomTemplates' => $this->bomTemplateOptions(),
'productRevisions' => $this->productRevisionOptions(),
'customers' => Customer::active()->orderBy('name')->get(['id', 'name', 'tier']),
'customFields' => $customFields->clientConfig('work_order'),
]);
Expand Down Expand Up @@ -229,6 +251,16 @@ public function update(UpdateWorkOrderRequest $request, WorkOrder $workOrder, Cu
->with('error', 'Cannot change BOMs after production has started.');
}

// A product revision (#180) may be changed freely before production, but
// once batches exist the change must go through the controlled change
// workflow (#182) — reject it here to keep the as-built revision honest.
$revisionChanged = array_key_exists('product_revision_id', $validated)
&& (int) $validated['product_revision_id'] !== (int) $workOrder->product_revision_id;
if ($revisionChanged && $workOrder->batches()->exists()) {
return redirect()->back()->withInput()
->with('error', 'Cannot change the product revision after production has started.');
}

// Field edits and the BOM re-selection commit together (or not at all).
try {
DB::transaction(function () use ($workOrder, $validated, $requested) {
Expand Down
3 changes: 3 additions & 0 deletions backend/app/Http/Controllers/Web/Admin/WorkerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public function store(StoreWorkerRequest $request, CustomFieldService $cf)
$validated = $request->validated();

$validated['is_active'] = $request->boolean('is_active', true);
$validated['is_logistics'] = $request->boolean('is_logistics');
unset($validated['custom_field_files']);
if ($cf->touched($request)) {
$validated['custom_fields'] = $cf->fromRequest($request, 'worker') ?: null;
Expand Down Expand Up @@ -141,6 +142,7 @@ public function edit(Worker $worker, CustomFieldService $cf)
'pay_rate' => $worker->pay_rate,
'pay_currency' => $worker->pay_currency,
'is_active' => $worker->is_active,
'is_logistics' => $worker->is_logistics,
'custom_fields' => $worker->custom_fields,
'skills' => $worker->skills->map(fn ($s) => [
'id' => $s->id,
Expand All @@ -163,6 +165,7 @@ public function update(UpdateWorkerRequest $request, Worker $worker, CustomField
$validated = $request->validated();

$validated['is_active'] = $request->boolean('is_active');
$validated['is_logistics'] = $request->boolean('is_logistics');
unset($validated['custom_field_files']);
if ($cf->touched($request)) {
$validated['custom_fields'] = $cf->fromRequest($request, 'worker', $worker->custom_fields) ?: null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

namespace App\Http\Controllers\Web\Logistics;

use App\Enums\PalletStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePalletMovementRequest;
use App\Models\Pallet;
use App\Models\PalletMovement;
use App\Models\Worker;
use App\Services\Logistics\PalletMovementService;
use App\Sync\Shapes\PalletMovementsRecentShape;
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.
*/
class PalletMovementController extends Controller
{
/** The tablet/terminal "Move Pallet" screen. */
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']),
// Movable pallets (not yet shipped), most recent first.
'pallets' => Pallet::where('status', '!=', PalletStatus::Shipped->value)
->orderByDesc('id')
->limit(500)
->get(['id', 'pallet_no', 'location']),
]);
}

public function store(StorePalletMovementRequest $request, PalletMovementService $service)
{
$service->record(
Pallet::findOrFail($request->integer('pallet_id')),
Worker::findOrFail($request->integer('worker_id')),
$request->string('to_location')->toString(),
$request->user(),
$request->input('notes'),
);

return redirect()->route('logistics.move-pallet')
->with('success', __('Pallet movement recorded.'));
}

/** Admin movement history — rows arrive client-side via the Electric shape. */
public function index()
{
// Match the live shape's rolling window: only movements the browser can
// actually receive need a resolved label, so the lookup maps stay
// bounded even as the append-only ledger grows without limit.
$since = now()->subDays(PalletMovementsRecentShape::WINDOW_DAYS)->toDateString();
$recent = PalletMovement::query()->where('moved_at', '>=', $since);

return Inertia::render('admin/pallet-movements/Index', [
// Lookup maps for the live table (rows stream via the shape). Bound
// to the ids the in-window ledger actually references so the payload
// can't balloon to the whole pallets/workers tables. withTrashed so
// soft-deleted pallets/operators still resolve to a label.
'operatorNames' => Worker::withTrashed()
->whereIn('id', (clone $recent)->select('worker_id'))
->get(['id', 'code', 'name'])
->mapWithKeys(fn (Worker $w) => [$w->id => $w->code.' — '.$w->name]),
'palletNumbers' => Pallet::withTrashed()
->whereIn('id', (clone $recent)->select('pallet_id'))
->pluck('pallet_no', 'id'),
]);
}
}
Loading
Loading