diff --git a/CHANGELOG.md b/CHANGELOG.md index df8ce026..fbe3ecd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.18.0] - 2026-07-25 + +### Added +- **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. +- **Product revision management** *([#180](https://github.com/Mes-Open/OpenMes/issues/180))*: product types now carry formal, versioned **revisions** (e.g. `PART-1000` rev A/B/C) under **Admin → Production → Product Revisions**. Each revision has a **lifecycle** — **Draft** (editable, not for production) → **Released** (immutable, selectable) → **Obsolete** (kept for history) — and points at the released **process template** (its BOM + steps). The `(product type, revision code)` pair is unique among live rows (partial index, so a code frees up after a soft delete). Work orders can reference a **released** revision on the admin/supervisor create & edit form (scoped to the order's product type); the chosen revision is **snapshotted immutably** into `process_snapshot.revision` at creation, so releasing or obsoleting a revision later never rewrites historical orders. A revision may be re-selected **before** production, but is **locked once batches exist** (that path is the controlled change workflow, [#182](https://github.com/Mes-Open/OpenMes/issues/182)). Everything is optional and backward-compatible — existing/revision-less orders are unchanged (`product_revision_id` is nullable). New `product_revisions` table + `work_orders.product_revision_id`, `App\Models\ProductRevision`, `App\Enums\RevisionLifecycle`, `ProductRevisionController`; soft-deletable (Trash + audit) and live-synced via a new `product_revisions` Electric shape. `ResourceForm` selects now support `filterByField` for dependent dropdowns. +- **Identification of logistics operators on physical movements** *([#103](https://github.com/Mes-Open/OpenMes/issues/103))*: every **physical pallet movement** is now **attributable to the logistics operator / forklift driver** who performed it. Workers gain an **"Logistics operator"** flag (worker form); flagged, active workers appear on a new tablet-friendly **Move Pallet** terminal (Packaging → Move Pallet, `Operator`/`Supervisor`/`Admin`) where an operator **identifies themselves with a badge-style tap**, picks a movable (not-shipped) pallet, enters its new location and confirms. The move is written to an **append-only `pallet_movements` ledger** capturing **who moved it, from → to, when, and optional notes** (plus the terminal account that recorded it, distinct from the credited operator); the pallet's live location follows the move, snapshotted atomically under a row lock so concurrent moves stay consistent. An admin **Pallet Movements** history (Packaging → Pallet Movements) surfaces the trail live via a new Electric shape. To keep the append-only ledger from growing the synced payload without bound, the live history and its label lookups are bounded to a **rolling 90-day window** (older movements are historical). New `pallet_movements` table, `workers.is_logistics` column, `App\Services\Logistics\PalletMovementService`, `App\Sync\Shapes\PalletMovementsRecentShape`. +- **Multiple BOMs per work order** *([#104](https://github.com/Mes-Open/OpenMes/issues/104))*: a work order can now reference **more than one bill of materials** (variant / alternative BOMs) instead of a single fixed one. On the admin create/edit form a new **"Bills of Materials"** picker lets you select **one or more process templates (BOMs)** for the order; the order's requirements and consumption then follow the **union of the selected BOMs**. Overlapping materials are **summed** (a material listed in several BOMs contributes its combined requirement exactly once - so the allocation engine, which keys consumption per material, never silently drops a line); process structure (steps) comes from the first selected BOM. The selection is recorded on a new `work_order_boms` pivot (`is_active` per link) and can be **switched later** from the edit form - but is **locked once production starts** (batches exist), keeping the frozen snapshot consistent with what was already allocated. **Leaving the picker empty is unchanged single-BOM behaviour**: the order auto-uses the single active template for its product type. New `work_order_boms` table, `WorkOrder::bomTemplates()`, `WorkOrderService::buildProcessSnapshot()` + `updateBomSelection()`; no change to the downstream requirements/consumption engines (they read the same flat, now-merged snapshot `bom`). +- **Elapsed-time ("Age") column on the work-order list** *([#100](https://github.com/Mes-Open/OpenMes/issues/100))*: the admin and supervisor **Work Orders** lists gain an **"Age"** column showing how long each order has been open (now - created_at) as a compact human-readable duration (**"just now", "5m", "3h", "2d", "1y"**), with the exact creation timestamp on hover. It is **sortable** (sorts by `created_at`, which is monotonic with age) and **ticks live** - a shared 30s clock refreshes the value without needing a data change. Implemented as a reusable `elapsed()` formatter in `resources/js/lib/i18n.js` and an opt-in **`live: true`** column flag on the shared `ResourceTable` (a single per-table interval feeds the cells via context, so other tables schedule no timer). `created_at` was already synced on the work-order Electric shapes, so no schema/shape change. Adds a minimal **Vitest** setup (`npm test`) covering the duration formatting. +- **Read-confirmation on critical instructions** *([#95](https://github.com/Mes-Open/OpenMes/issues/95))*: a process step can now be flagged as carrying **critical instructions the operator must explicitly acknowledge having read** before the step can be completed. The flag is set once on the **template step** (Admin → Process Templates editor — "Require operator to confirm they read the instructions"), snapshotted onto the work order and **mirrored onto the runtime batch step**, so it only applies **where configured**. At the operator station a flagged step shows a blocked banner and an **"I have read the instructions"** action under its instructions; **Complete stays disabled until acknowledged**, and the acknowledgement **records who confirmed and when** (idempotent and race-safe — the first acknowledger is kept). The gate lives in `BatchService::completeStep` alongside the existing document/checklist gates, so it applies on every surface. Steps not flagged are unaffected. Reuses the existing `template_steps.requires_confirmation` + `batch_steps.confirmed_at`/`confirmed_by` columns; one additive `batch_steps.requires_confirmation` column, no breaking change. Acknowledge from the operator panel (`POST /operator/batch-step/{step}/confirm-instructions`) or the API (`POST /api/v1/batch-steps/{step}/confirm-instructions`), so API-driven clients can clear the gate the shared service enforces. +- **Planner integration for customers & priority** *(phase 4)*: the production planner is now customer- and priority-aware. The **backlog is ranked by the computed priority score** (not just the coarse 1–5 priority), each **backlog card shows its customer and tier badge**, and the backlog can be **filtered by tier** (and searched by customer name). In the weekly Gantt, scheduled orders carry a small **tier dot** (customer name/tier in the tooltip). A red **banner warns when high-tier (Gold/VIP) customer orders are overdue**, linking straight to them. Backlog ordering, the overdue-banner data and the tier metadata are computed in `SchedulePlannerController`; no schema change. +- **Customer auto-scoring — order counts, revenue & tier promotion** *(phase 3)*: completing a work order now rolls its value into the customer's aggregate metrics. Work orders gain an optional **unit price**; when an order first reaches **DONE**, its **produced qty × unit price** is added to the customer's **total revenue** and its **completed-order count** ticks up (idempotently — a reopen/re-complete never double-counts). Customers are then **auto-promoted** by completed-order count (**≥5 → Silver, ≥20 → Gold, ≥50 → VIP**, upgrade-only; thresholds stored in settings), and because a tier change feeds the phase-2 scoring, the customer's other active orders are re-prioritised automatically. A new **Top Customers** admin-dashboard widget shows the highest-revenue customers (with tier badges) and total revenue. `payment_score` remains a manual 0–100 rating for now (no invoicing module yet). New `work_orders.unit_price` + `customer_totals_counted` columns, `App\Services\Customer\CustomerMetricsService`, `App\Support\TierPromotionRegistry`, and the `top_customers` dashboard widget. +- **Automatic work-order priority scoring** *(phase 2)*: a new **Admin → Orders → Priority Settings** page lets you build **scoring rules** that add (or subtract) points based on the customer or the order — sources are **customer tier**, **payment score**, **total orders**, **planned quantity** and **hours until due**, compared with *equals / greater-than / less-than / between / is-true*. Each order's points are summed into a **priority score** and mapped to a **1–5 priority** via a configurable **score→priority band** table (defaults ≤20→P1 … >80→P5). Scoring is **opt-in and non-destructive**: with no active rule, manual priorities are left untouched; once rules exist the score is recomputed on every work-order **create/update**, when a **customer's tier/payment/order-count changes** (re-scores that customer's active orders), and **hourly** via a scheduled `priority:recalculate` command (so "hours until due" rules stay current). The score shows on the work-order list and detail, and the planner/backlog ranks by it. New `priority_rules` table (soft-deletable), `work_orders.priority_score` column, `App\Services\WorkOrder\PriorityScoringService`, `App\Support\PriorityBandRegistry` (bands stored in `system_settings.priority_bands`), `PriorityRuleController` and the `PriorityRuleSource` / `PriorityCondition` enums. Builds on the phase-1 customer entity. +- **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). + ## [0.17.2] - 2026-07-24 ### Fixed @@ -29,13 +45,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - **Shop-floor workstation device registration + live admin roster** *([#175](https://github.com/Mes-Open/OpenMes/issues/175))*: shop-floor PCs self-register against the app by IP and heartbeat; **Admin → Structure → Workstation Devices** shows them live (online/offline from `last_seen_at`) via an Electric shape. Public, rate-limited `POST /api/workstations/register` + `/heartbeat`; new soft-deletable `workstation_devices` table + a "forget" action. - **OpenMES mobile app (Android)**: a new **Expo / React Native** client (`mobile/`, `com.openmes.mobile`) that talks to an OpenMES server's API — shop-floor operations on a phone/tablet. - **Multi-platform release pipeline** *(CI)*: the release workflow now builds and attaches, to each tagged GitHub Release, the self-contained web ZIP **plus desktop installers** (Windows `.exe`, macOS `.dmg`, Linux `.deb`/`.rpm`) and the **Android `.apk`** — desktop jobs fan out across a win/mac/linux matrix, mobile via Expo prebuild + Gradle. -- **Multiple BOMs per work order** *([#104](https://github.com/Mes-Open/OpenMes/issues/104))*: a work order can now reference **more than one bill of materials** (variant / alternative BOMs) instead of a single fixed one. On the admin create/edit form a new **"Bills of Materials"** picker lets you select **one or more process templates (BOMs)** for the order; the order's requirements and consumption then follow the **union of the selected BOMs**. Overlapping materials are **summed** (a material listed in several BOMs contributes its combined requirement exactly once - so the allocation engine, which keys consumption per material, never silently drops a line); process structure (steps) comes from the first selected BOM. The selection is recorded on a new `work_order_boms` pivot (`is_active` per link) and can be **switched later** from the edit form - but is **locked once production starts** (batches exist), keeping the frozen snapshot consistent with what was already allocated. **Leaving the picker empty is unchanged single-BOM behaviour**: the order auto-uses the single active template for its product type. New `work_order_boms` table, `WorkOrder::bomTemplates()`, `WorkOrderService::buildProcessSnapshot()` + `updateBomSelection()`; no change to the downstream requirements/consumption engines (they read the same flat, now-merged snapshot `bom`). -- **Elapsed-time ("Age") column on the work-order list** *([#100](https://github.com/Mes-Open/OpenMes/issues/100))*: the admin and supervisor **Work Orders** lists gain an **"Age"** column showing how long each order has been open (now - created_at) as a compact human-readable duration (**"just now", "5m", "3h", "2d", "1y"**), with the exact creation timestamp on hover. It is **sortable** (sorts by `created_at`, which is monotonic with age) and **ticks live** - a shared 30s clock refreshes the value without needing a data change. Implemented as a reusable `elapsed()` formatter in `resources/js/lib/i18n.js` and an opt-in **`live: true`** column flag on the shared `ResourceTable` (a single per-table interval feeds the cells via context, so other tables schedule no timer). `created_at` was already synced on the work-order Electric shapes, so no schema/shape change. Adds a minimal **Vitest** setup (`npm test`) covering the duration formatting. -- **Read-confirmation on critical instructions** *([#95](https://github.com/Mes-Open/OpenMes/issues/95))*: a process step can now be flagged as carrying **critical instructions the operator must explicitly acknowledge having read** before the step can be completed. The flag is set once on the **template step** (Admin → Process Templates editor — "Require operator to confirm they read the instructions"), snapshotted onto the work order and **mirrored onto the runtime batch step**, so it only applies **where configured**. At the operator station a flagged step shows a blocked banner and an **"I have read the instructions"** action under its instructions; **Complete stays disabled until acknowledged**, and the acknowledgement **records who confirmed and when** (idempotent and race-safe — the first acknowledger is kept). The gate lives in `BatchService::completeStep` alongside the existing document/checklist gates, so it applies on every surface. Steps not flagged are unaffected. Reuses the existing `template_steps.requires_confirmation` + `batch_steps.confirmed_at`/`confirmed_by` columns; one additive `batch_steps.requires_confirmation` column, no breaking change. Acknowledge from the operator panel (`POST /operator/batch-step/{step}/confirm-instructions`) or the API (`POST /api/v1/batch-steps/{step}/confirm-instructions`), so API-driven clients can clear the gate the shared service enforces. -- **Planner integration for customers & priority** *(phase 4)*: the production planner is now customer- and priority-aware. The **backlog is ranked by the computed priority score** (not just the coarse 1–5 priority), each **backlog card shows its customer and tier badge**, and the backlog can be **filtered by tier** (and searched by customer name). In the weekly Gantt, scheduled orders carry a small **tier dot** (customer name/tier in the tooltip). A red **banner warns when high-tier (Gold/VIP) customer orders are overdue**, linking straight to them. Backlog ordering, the overdue-banner data and the tier metadata are computed in `SchedulePlannerController`; no schema change. -- **Customer auto-scoring — order counts, revenue & tier promotion** *(phase 3)*: completing a work order now rolls its value into the customer's aggregate metrics. Work orders gain an optional **unit price**; when an order first reaches **DONE**, its **produced qty × unit price** is added to the customer's **total revenue** and its **completed-order count** ticks up (idempotently — a reopen/re-complete never double-counts). Customers are then **auto-promoted** by completed-order count (**≥5 → Silver, ≥20 → Gold, ≥50 → VIP**, upgrade-only; thresholds stored in settings), and because a tier change feeds the phase-2 scoring, the customer's other active orders are re-prioritised automatically. A new **Top Customers** admin-dashboard widget shows the highest-revenue customers (with tier badges) and total revenue. `payment_score` remains a manual 0–100 rating for now (no invoicing module yet). New `work_orders.unit_price` + `customer_totals_counted` columns, `App\Services\Customer\CustomerMetricsService`, `App\Support\TierPromotionRegistry`, and the `top_customers` dashboard widget. -- **Automatic work-order priority scoring** *(phase 2)*: a new **Admin → Orders → Priority Settings** page lets you build **scoring rules** that add (or subtract) points based on the customer or the order — sources are **customer tier**, **payment score**, **total orders**, **planned quantity** and **hours until due**, compared with *equals / greater-than / less-than / between / is-true*. Each order's points are summed into a **priority score** and mapped to a **1–5 priority** via a configurable **score→priority band** table (defaults ≤20→P1 … >80→P5). Scoring is **opt-in and non-destructive**: with no active rule, manual priorities are left untouched; once rules exist the score is recomputed on every work-order **create/update**, when a **customer's tier/payment/order-count changes** (re-scores that customer's active orders), and **hourly** via a scheduled `priority:recalculate` command (so "hours until due" rules stay current). The score shows on the work-order list and detail, and the planner/backlog ranks by it. New `priority_rules` table (soft-deletable), `work_orders.priority_score` column, `App\Services\WorkOrder\PriorityScoringService`, `App\Support\PriorityBandRegistry` (bands stored in `system_settings.priority_bands`), `PriorityRuleController` and the `PriorityRuleSource` / `PriorityCondition` enums. Builds on the phase-1 customer entity. -- **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). + ## [0.16.2] - 2026-07-13 ### Fixed diff --git a/backend/app/Enums/RevisionLifecycle.php b/backend/app/Enums/RevisionLifecycle.php new file mode 100644 index 00000000..7847b410 --- /dev/null +++ b/backend/app/Enums/RevisionLifecycle.php @@ -0,0 +1,42 @@ + __('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; + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/ProductRevisionController.php b/backend/app/Http/Controllers/Web/Admin/ProductRevisionController.php new file mode 100644 index 00000000..ce5f743e --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/ProductRevisionController.php @@ -0,0 +1,129 @@ +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']), + ]; + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php b/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php index 599f9996..3d22b466 100644 --- a/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php +++ b/backend/app/Http/Controllers/Web/Admin/WorkOrderManagementController.php @@ -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'), ]); @@ -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> + */ + 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(); @@ -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(), @@ -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'), ]); @@ -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) { diff --git a/backend/app/Http/Controllers/Web/Admin/WorkerController.php b/backend/app/Http/Controllers/Web/Admin/WorkerController.php index 611dc55b..e8500c0e 100644 --- a/backend/app/Http/Controllers/Web/Admin/WorkerController.php +++ b/backend/app/Http/Controllers/Web/Admin/WorkerController.php @@ -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; @@ -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, @@ -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; diff --git a/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php b/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php new file mode 100644 index 00000000..8d8ddf19 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Logistics/PalletMovementController.php @@ -0,0 +1,75 @@ + 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'), + ]); + } +} diff --git a/backend/app/Http/Controllers/Web/OnboardingController.php b/backend/app/Http/Controllers/Web/OnboardingController.php index 49ca84e0..e7b367c3 100644 --- a/backend/app/Http/Controllers/Web/OnboardingController.php +++ b/backend/app/Http/Controllers/Web/OnboardingController.php @@ -8,8 +8,10 @@ use App\Models\ProductType; use App\Models\TemplateStep; use App\Services\WorkOrder\WorkOrderService; +use App\Support\ModuleRegistry; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; use Inertia\Inertia; class OnboardingController extends Controller @@ -20,12 +22,42 @@ public function index() return redirect()->route('admin.dashboard'); } + return redirect()->route('onboarding.modules'); + } + + /** + * Step 1 — choose which optional feature modules the MES exposes. Reuses the + * existing ModuleRegistry / enabled_modules mechanism (changeable later in + * Settings → System → Modules). + */ + public function modules() + { + return Inertia::render('onboarding/Modules', [ + 'step' => 1, + 'modules' => ModuleRegistry::forForm(), + 'presets' => ModuleRegistry::PRESETS, + ]); + } + + public function storeModules(Request $request) + { + $validated = $request->validate([ + 'preset' => ['required', Rule::in(['light', 'advanced', 'custom'])], + 'enabled_modules' => ['nullable', 'array'], + 'enabled_modules.*' => ['string', Rule::in(ModuleRegistry::optionalKeys())], + ]); + + ModuleRegistry::save(ModuleRegistry::modulesForPreset( + $validated['preset'], + $validated['enabled_modules'] ?? [], + )); + return redirect()->route('onboarding.step1'); } public function step1() { - return Inertia::render('onboarding/Step1', ['step' => 1]); + return Inertia::render('onboarding/Step1', ['step' => 2]); } public function storeStep1(Request $request) @@ -50,7 +82,7 @@ public function step2(Request $request) return redirect()->route('onboarding.step1'); } - return Inertia::render('onboarding/Step2', ['step' => 2]); + return Inertia::render('onboarding/Step2', ['step' => 3]); } public function storeStep2(Request $request) @@ -82,7 +114,7 @@ public function step3(Request $request) return redirect()->route('onboarding.step1'); } - return Inertia::render('onboarding/Step3', ['step' => 3]); + return Inertia::render('onboarding/Step3', ['step' => 4]); } public function storeStep3(Request $request) @@ -123,7 +155,7 @@ public function step4(Request $request) return redirect()->route('onboarding.step1'); } - return Inertia::render('onboarding/Step4', ['step' => 4]); + return Inertia::render('onboarding/Step4', ['step' => 5]); } public function storeStep4(Request $request, WorkOrderService $workOrderService) @@ -150,7 +182,7 @@ public function complete(Request $request) $this->markCompleted(); $request->session()->forget('onboarding'); - return Inertia::render('onboarding/Complete', ['step' => 5]); + return Inertia::render('onboarding/Complete', ['step' => 6]); } public function skip(Request $request) diff --git a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php index 3da9ad40..b8326d5b 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php @@ -257,6 +257,10 @@ public function complete(Request $request, WorkOrder $workOrder) return back()->with('error', 'Work order is already completed.'); } + if (! $workOrder->allowsOperatorEntry()) { + return back()->with('error', 'This work order is machine-counted; quantities are recorded automatically from the machine.'); + } + $validated = $request->validate([ 'produced_qty' => 'required|numeric|min:1', ]); @@ -300,6 +304,10 @@ public function shiftEntry(Request $request, WorkOrder $workOrder) return back()->with('error', 'Work order is already completed.'); } + if (! $workOrder->allowsOperatorEntry()) { + return back()->with('error', 'This work order is machine-counted; quantities are recorded automatically from the machine.'); + } + $validated = $request->validate([ 'shift_id' => 'required|exists:shifts,id', 'quantity' => 'required|numeric|min:1', diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 474b14b2..0a4793f7 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -408,12 +408,10 @@ public function updateSystemSettings(Request $request) Cache::forget('cors_allowed_origins'); - // Only realign the session locale when the language actually changed, - // so saving an unrelated setting does not clobber a per-session - // language the user picked via the switcher. - if ($map['language'] !== $previousLanguage) { - $request->session()->put('locale', $map['language']); - } + // Always sync session locale with the saved language so the UI + // reflects the choice immediately — even when the session had a + // stale override from the login-screen switcher (#205). + $request->session()->put('locale', $map['language']); return redirect()->route('settings.system') ->with('success', 'System settings updated.'); diff --git a/backend/app/Http/Requests/StorePalletMovementRequest.php b/backend/app/Http/Requests/StorePalletMovementRequest.php new file mode 100644 index 00000000..932dffe9 --- /dev/null +++ b/backend/app/Http/Requests/StorePalletMovementRequest.php @@ -0,0 +1,53 @@ + [ + '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)), + ], + 'to_location' => ['required', '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.'), + ]; + } +} diff --git a/backend/app/Http/Requests/StoreWorkerRequest.php b/backend/app/Http/Requests/StoreWorkerRequest.php index 29115e40..2e324a19 100644 --- a/backend/app/Http/Requests/StoreWorkerRequest.php +++ b/backend/app/Http/Requests/StoreWorkerRequest.php @@ -36,6 +36,7 @@ public function rules(): array 'pay_rate' => ['nullable', 'numeric', 'min:0'], 'pay_currency' => ['nullable', 'string', 'size:3'], 'is_active' => ['boolean'], + 'is_logistics' => ['boolean'], 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', 'exists:skills,id'], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], diff --git a/backend/app/Http/Requests/UpdateWorkerRequest.php b/backend/app/Http/Requests/UpdateWorkerRequest.php index 6f4d8377..01d104b1 100644 --- a/backend/app/Http/Requests/UpdateWorkerRequest.php +++ b/backend/app/Http/Requests/UpdateWorkerRequest.php @@ -38,6 +38,7 @@ public function rules(): array 'pay_rate' => ['nullable', 'numeric', 'min:0'], 'pay_currency' => ['nullable', 'string', 'size:3'], 'is_active' => ['boolean'], + 'is_logistics' => ['boolean'], 'skills' => ['nullable', 'array'], 'skills.*.id' => ['required', 'exists:skills,id'], 'skills.*.level' => ['nullable', 'integer', 'min:1', 'max:5'], diff --git a/backend/app/Http/Requests/Web/Admin/StoreProductRevisionRequest.php b/backend/app/Http/Requests/Web/Admin/StoreProductRevisionRequest.php new file mode 100644 index 00000000..924ec5ac --- /dev/null +++ b/backend/app/Http/Requests/Web/Admin/StoreProductRevisionRequest.php @@ -0,0 +1,41 @@ + ['required', Rule::exists('product_types', 'id')->whereNull('deleted_at')], + 'revision_code' => [ + 'required', 'string', 'max:50', + // Letters, digits, dot, hyphen — e.g. A, 01, C.2, REV-3. + 'regex:/^[A-Za-z0-9.\-]+$/', + // Unique per product type among live rows only (partial index). + Rule::unique('product_revisions', 'revision_code') + ->where('product_type_id', $this->input('product_type_id')) + ->whereNull('deleted_at'), + ], + 'description' => ['nullable', 'string', 'max:255'], + 'process_template_id' => [ + 'nullable', + Rule::exists('process_templates', 'id') + ->where('product_type_id', $this->input('product_type_id')) + ->whereNull('deleted_at'), + ], + 'change_reason' => ['nullable', 'string', 'max:255'], + 'external_ref' => ['nullable', 'string', 'max:255'], + 'effective_from' => ['nullable', 'date'], + 'effective_to' => ['nullable', 'date', 'after_or_equal:effective_from'], + ]; + } +} diff --git a/backend/app/Http/Requests/Web/Admin/StoreWorkOrderRequest.php b/backend/app/Http/Requests/Web/Admin/StoreWorkOrderRequest.php index 5143fa6a..fa299e44 100644 --- a/backend/app/Http/Requests/Web/Admin/StoreWorkOrderRequest.php +++ b/backend/app/Http/Requests/Web/Admin/StoreWorkOrderRequest.php @@ -28,6 +28,15 @@ public function rules(): array 'customer_id' => ['nullable', Rule::exists('customers', 'id')->whereNull('deleted_at')], 'line_id' => ['nullable', 'exists:lines,id'], 'product_type_id' => ['nullable', 'exists:product_types,id'], + // Optional product revision (#180) — must belong to the order's + // product type and be RELEASED (only released revisions produce). + 'product_revision_id' => [ + 'nullable', + Rule::exists('product_revisions', 'id') + ->where('product_type_id', $this->input('product_type_id')) + ->where('lifecycle_status', 'released') + ->whereNull('deleted_at'), + ], // Optional multi-BOM selection: which process templates (BOMs) apply // to this order. Empty = auto-pick the single active template. Each // selected BOM must be a live template of the order's product type. @@ -40,6 +49,7 @@ public function rules(): array ], 'planned_qty' => ['required', 'numeric', 'min:0.01', 'max:99999999'], 'unit_price' => ['nullable', 'numeric', 'min:0', 'max:99999999'], + 'counting_source' => ['nullable', Rule::in(\App\Models\WorkOrder::COUNTING_SOURCES)], 'priority' => ['nullable', 'integer', 'min:0', 'max:100'], 'due_date' => ['nullable', 'date'], 'description' => ['nullable', 'string', 'max:2000'], diff --git a/backend/app/Http/Requests/Web/Admin/UpdateProductRevisionRequest.php b/backend/app/Http/Requests/Web/Admin/UpdateProductRevisionRequest.php new file mode 100644 index 00000000..a5ee1333 --- /dev/null +++ b/backend/app/Http/Requests/Web/Admin/UpdateProductRevisionRequest.php @@ -0,0 +1,42 @@ +route('product_revision'); + + return [ + // product_type is fixed for the life of the revision — not editable. + 'revision_code' => [ + 'required', 'string', 'max:50', + 'regex:/^[A-Za-z0-9.\-]+$/', + Rule::unique('product_revisions', 'revision_code') + ->where('product_type_id', $revision->product_type_id) + ->whereNull('deleted_at') + ->ignore($revision->id), + ], + 'description' => ['nullable', 'string', 'max:255'], + 'process_template_id' => [ + 'nullable', + Rule::exists('process_templates', 'id') + ->where('product_type_id', $revision->product_type_id) + ->whereNull('deleted_at'), + ], + 'change_reason' => ['nullable', 'string', 'max:255'], + 'external_ref' => ['nullable', 'string', 'max:255'], + 'effective_from' => ['nullable', 'date'], + 'effective_to' => ['nullable', 'date', 'after_or_equal:effective_from'], + ]; + } +} diff --git a/backend/app/Http/Requests/Web/Admin/UpdateWorkOrderRequest.php b/backend/app/Http/Requests/Web/Admin/UpdateWorkOrderRequest.php index 4a9685aa..ecf4247d 100644 --- a/backend/app/Http/Requests/Web/Admin/UpdateWorkOrderRequest.php +++ b/backend/app/Http/Requests/Web/Admin/UpdateWorkOrderRequest.php @@ -28,6 +28,16 @@ public function rules(): array 'customer_id' => ['nullable', Rule::exists('customers', 'id')->whereNull('deleted_at')], 'line_id' => ['nullable', 'exists:lines,id'], 'product_type_id' => ['nullable', 'exists:product_types,id'], + // Product revision (#180) — must belong to the product type and be + // RELEASED. A change after production starts is rejected in the + // controller (that path needs the controlled change workflow, #182). + 'product_revision_id' => [ + 'nullable', + Rule::exists('product_revisions', 'id') + ->where('product_type_id', $this->input('product_type_id')) + ->where('lifecycle_status', 'released') + ->whereNull('deleted_at'), + ], // Multi-BOM selection (which process templates back this order). // Only applied while the order has no batches - see the controller. // Each selected BOM must be a live template of the order's product type. @@ -40,6 +50,7 @@ public function rules(): array ], 'planned_qty' => ['required', 'numeric', 'min:0.01', 'max:99999999'], 'unit_price' => ['nullable', 'numeric', 'min:0', 'max:99999999'], + 'counting_source' => ['nullable', Rule::in(\App\Models\WorkOrder::COUNTING_SOURCES)], 'priority' => ['nullable', 'integer', 'min:0', 'max:100'], 'due_date' => ['nullable', 'date'], 'description' => ['nullable', 'string', 'max:2000'], diff --git a/backend/app/Models/PalletMovement.php b/backend/app/Models/PalletMovement.php new file mode 100644 index 00000000..515c6dba --- /dev/null +++ b/backend/app/Models/PalletMovement.php @@ -0,0 +1,54 @@ + 'datetime', + ]; + } + + public function pallet(): BelongsTo + { + return $this->belongsTo(Pallet::class); + } + + /** The logistics operator credited with performing the move. */ + public function worker(): BelongsTo + { + return $this->belongsTo(Worker::class); + } + + /** The account that recorded the move on the terminal. */ + public function performedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'performed_by'); + } +} diff --git a/backend/app/Models/ProductRevision.php b/backend/app/Models/ProductRevision.php new file mode 100644 index 00000000..511159df --- /dev/null +++ b/backend/app/Models/ProductRevision.php @@ -0,0 +1,95 @@ + RevisionLifecycle::class, + 'effective_from' => 'datetime', + 'effective_to' => 'datetime', + 'released_at' => 'datetime', + 'obsolete_at' => 'datetime', + ]; + } + + public function productType(): BelongsTo + { + return $this->belongsTo(ProductType::class); + } + + public function processTemplate(): BelongsTo + { + return $this->belongsTo(ProcessTemplate::class); + } + + public function releasedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'released_by_id'); + } + + /** + * Work orders produced under this revision. The FK nulls out on delete + * (see migration), so it is intentionally not cascaded on soft delete. + */ + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + public function isDraft(): bool + { + return $this->lifecycle_status === RevisionLifecycle::Draft; + } + + public function isReleased(): bool + { + return $this->lifecycle_status === RevisionLifecycle::Released; + } + + /** Only released revisions may be selected for new production orders. */ + public function isSelectable(): bool + { + return $this->lifecycle_status?->isSelectable() ?? false; + } + + /** Scope to revisions selectable for new work orders (released only). */ + public function scopeSelectable(Builder $query): Builder + { + return $query->where('lifecycle_status', RevisionLifecycle::Released->value); + } +} diff --git a/backend/app/Models/WorkOrder.php b/backend/app/Models/WorkOrder.php index c9fd0b3c..dd7de881 100644 --- a/backend/app/Models/WorkOrder.php +++ b/backend/app/Models/WorkOrder.php @@ -71,6 +71,15 @@ protected static function booted(): void const STATUS_CANCELLED = 'CANCELLED'; + /** Counting source — which path is authoritative for produced_qty. */ + const COUNTING_OPERATOR = 'operator'; + + const COUNTING_MACHINE = 'machine'; + + const COUNTING_BOTH = 'both'; + + const COUNTING_SOURCES = [self::COUNTING_OPERATOR, self::COUNTING_MACHINE, self::COUNTING_BOTH]; + /** Statuses that allow operators to work on the order */ const ACTIVE_STATUSES = [self::STATUS_PENDING, self::STATUS_ACCEPTED, self::STATUS_IN_PROGRESS, self::STATUS_BLOCKED]; @@ -83,10 +92,12 @@ protected static function booted(): void 'customer_id', 'line_id', 'product_type_id', + 'product_revision_id', 'process_snapshot', 'planned_qty', 'unit_price', 'produced_qty', + 'counting_source', 'status', 'line_status_id', 'priority', @@ -236,6 +247,26 @@ public function line(): BelongsTo return $this->belongsTo(Line::class); } + /** + * Whether machine counter signals should drive produced_qty for this order + * (counting_source is machine or both). + */ + public function isMachineCounted(): bool + { + return in_array($this->counting_source, [self::COUNTING_MACHINE, self::COUNTING_BOTH], true); + } + + /** + * Whether operators may manually enter produced quantities for this order + * (counting_source is operator or both). Machine-only orders block manual + * entry so the machine stays the single source of truth. + */ + public function allowsOperatorEntry(): bool + { + return in_array($this->counting_source, [self::COUNTING_OPERATOR, self::COUNTING_BOTH], true) + || $this->counting_source === null; + } + /** * Extra schedule segments beyond the primary placement — the order also * runs on these lines/dates (a multi-line staircase or concurrent runs). @@ -253,6 +284,12 @@ public function productType(): BelongsTo return $this->belongsTo(ProductType::class); } + /** The product revision this order is producing (#180); null for legacy orders. */ + public function productRevision(): BelongsTo + { + return $this->belongsTo(ProductRevision::class); + } + /** * BOMs (process templates) linked to this work order. An order may reference * more than one BOM (variant / alternative bills of materials); all linked diff --git a/backend/app/Models/Worker.php b/backend/app/Models/Worker.php index be7c3702..6dcb23a7 100644 --- a/backend/app/Models/Worker.php +++ b/backend/app/Models/Worker.php @@ -32,12 +32,14 @@ class Worker extends Model 'pay_currency', 'workstation_id', 'is_active', + 'is_logistics', ]; protected function casts(): array { return [ 'is_active' => 'boolean', + 'is_logistics' => 'boolean', 'pay_rate' => 'decimal:4', ]; } @@ -159,6 +161,15 @@ public function scopeActive($query) return $query->where('is_active', true); } + /** + * Scope to logistics operators / forklift drivers — the workers eligible to + * perform physical pallet movements (#103). + */ + public function scopeLogistics($query) + { + return $query->where('is_logistics', true); + } + /** Children soft-deleted/restored together with this model (mirrors DB FK cascades). */ public function softDeleteCascades(): array { diff --git a/backend/app/Services/Connectivity/ActionExecutor.php b/backend/app/Services/Connectivity/ActionExecutor.php index 2a1ed945..c8c6e6d9 100644 --- a/backend/app/Services/Connectivity/ActionExecutor.php +++ b/backend/app/Services/Connectivity/ActionExecutor.php @@ -2,7 +2,6 @@ namespace App\Services\Connectivity; -use App\Models\Batch; use App\Models\BatchStep; use App\Models\Issue; use App\Models\Line; @@ -10,14 +9,15 @@ use App\Models\MachineTopic; use App\Models\TopicMapping; use App\Models\WorkOrder; -use Illuminate\Support\Facades\DB; +use App\Services\WorkOrder\MachineProductionService; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ActionExecutor { public function __construct( - private readonly MqttMessageParser $parser + private readonly MqttMessageParser $parser, + private readonly MachineProductionService $production, ) {} /** @@ -42,10 +42,10 @@ public function executeAll(MachineTopic $topic, array $parsedData): array public function executeSingle(TopicMapping $mapping, array $parsedData): array { $result = [ - 'mapping_id' => $mapping->id, + 'mapping_id' => $mapping->id, 'action_type' => $mapping->action_type, - 'status' => 'skipped', - 'message' => null, + 'status' => 'skipped', + 'message' => null, ]; try { @@ -53,31 +53,32 @@ public function executeSingle(TopicMapping $mapping, array $parsedData): array $fieldValue = $this->parser->resolvePath($mapping->field_path, $parsedData); // Evaluate condition - if (!$this->parser->evaluateCondition($mapping->condition_expr, $fieldValue)) { + if (! $this->parser->evaluateCondition($mapping->condition_expr, $fieldValue)) { $result['message'] = 'Condition not met'; + return $result; } $params = $mapping->action_params ?? []; $outcome = match ($mapping->action_type) { - TopicMapping::ACTION_UPDATE_BATCH_STEP => $this->updateBatchStep($params, $parsedData, $fieldValue), + TopicMapping::ACTION_UPDATE_BATCH_STEP => $this->updateBatchStep($params, $parsedData, $fieldValue), TopicMapping::ACTION_UPDATE_WORK_ORDER_QTY => $this->updateWorkOrderQty($params, $parsedData, $fieldValue), - TopicMapping::ACTION_CREATE_ISSUE => $this->createIssue($params, $parsedData, $fieldValue), - TopicMapping::ACTION_UPDATE_LINE_STATUS => $this->updateLineStatus($params, $parsedData, $fieldValue), + TopicMapping::ACTION_CREATE_ISSUE => $this->createIssue($params, $parsedData, $fieldValue), + TopicMapping::ACTION_UPDATE_LINE_STATUS => $this->updateLineStatus($params, $parsedData, $fieldValue), TopicMapping::ACTION_SET_WORK_ORDER_STATUS => $this->setWorkOrderStatus($params, $parsedData, $fieldValue), - TopicMapping::ACTION_WEBHOOK_FORWARD => $this->webhookForward($params, $parsedData), - TopicMapping::ACTION_LOG_EVENT => ['logged' => true], + TopicMapping::ACTION_WEBHOOK_FORWARD => $this->webhookForward($params, $parsedData), + TopicMapping::ACTION_LOG_EVENT => ['logged' => true], default => throw new \InvalidArgumentException("Unknown action: {$mapping->action_type}"), }; - $result['status'] = 'ok'; + $result['status'] = 'ok'; $result['message'] = json_encode($outcome); } catch (\Throwable $e) { - $result['status'] = 'error'; + $result['status'] = 'error'; $result['message'] = $e->getMessage(); Log::warning('ActionExecutor error', [ 'mapping_id' => $mapping->id, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); } @@ -89,10 +90,10 @@ public function executeSingle(TopicMapping $mapping, array $parsedData): array private function updateBatchStep(array $params, array $data, mixed $fieldValue): array { // params: { step_id_path, result_path, result (static), batch_id_path, step_order } - $stepId = $this->resolveParam($params, 'step_id_path', $data); - $batchId = $this->resolveParam($params, 'batch_id_path', $data); + $stepId = $this->resolveParam($params, 'step_id_path', $data); + $batchId = $this->resolveParam($params, 'batch_id_path', $data); $stepOrder = $this->resolveParam($params, 'step_order_path', $data) ?? ($params['step_order'] ?? null); - $result = $this->resolveParam($params, 'result_path', $data) ?? ($params['result'] ?? 'done'); + $result = $this->resolveParam($params, 'result_path', $data) ?? ($params['result'] ?? 'done'); $step = null; if ($stepId) { @@ -103,18 +104,18 @@ private function updateBatchStep(array $params, array $data, mixed $fieldValue): ->first(); } - if (!$step) { + if (! $step) { throw new \RuntimeException("BatchStep not found (step_id={$stepId}, batch_id={$batchId})"); } $newStatus = match ($result) { 'done', 'completed', '1', 'true' => 'done', - 'failed', 'error', '0', 'false' => 'failed', - default => 'done', + 'failed', 'error', '0', 'false' => 'failed', + default => 'done', }; $step->update([ - 'status' => $newStatus, + 'status' => $newStatus, 'completed_at' => $newStatus === 'done' ? now() : null, ]); @@ -124,9 +125,9 @@ private function updateBatchStep(array $params, array $data, mixed $fieldValue): private function updateWorkOrderQty(array $params, array $data, mixed $fieldValue): array { // params: { order_no_path, order_id (static), qty_path, qty_increment (bool) } - $orderNo = $this->resolveParam($params, 'order_no_path', $data) ?? ($params['order_no'] ?? null); - $orderId = $this->resolveParam($params, 'order_id_path', $data) ?? ($params['order_id'] ?? null); - $qty = $this->resolveParam($params, 'qty_path', $data) ?? $fieldValue; + $orderNo = $this->resolveParam($params, 'order_no_path', $data) ?? ($params['order_no'] ?? null); + $orderId = $this->resolveParam($params, 'order_id_path', $data) ?? ($params['order_id'] ?? null); + $qty = $this->resolveParam($params, 'qty_path', $data) ?? $fieldValue; $increment = (bool) ($params['qty_increment'] ?? false); $workOrder = null; @@ -136,25 +137,34 @@ private function updateWorkOrderQty(array $params, array $data, mixed $fieldValu $workOrder = WorkOrder::find($orderId); } - if (!$workOrder) { + if (! $workOrder) { throw new \RuntimeException("WorkOrder not found (order_no={$orderNo})"); } - if ($increment) { - $workOrder->increment('produced_qty', (float) $qty); - \App\Sync\CollectionBroadcaster::flush($workOrder); // increment() bypasses model events - } else { - $workOrder->update(['produced_qty' => (float) $qty]); - } - - return ['order_no' => $workOrder->order_no, 'produced_qty' => $workOrder->fresh()->produced_qty]; + // Route through the shared machine-count path so counting_source is + // honoured (an operator-counted order is not touched — this is what + // eliminates the double-count when both a machine mapping and operator + // entry target the same order) and the auto-start / auto-complete side + // effects stay identical to the signal pipeline. + $applied = $increment + ? $this->production->recordGoodCount($workOrder, (float) $qty) + : $this->production->recordAbsoluteCount($workOrder, (float) $qty); + + $workOrder->refresh(); + + return [ + 'order_no' => $workOrder->order_no, + 'produced_qty' => $workOrder->produced_qty, + 'applied' => $applied, + 'skipped' => $applied ? null : 'work order is not machine-counted (counting_source)', + ]; } private function createIssue(array $params, array $data, mixed $fieldValue): array { // params: { issue_type_id, work_order_no_path, description_path, description (static) } $issueTypeId = $this->resolveParam($params, 'issue_type_id_path', $data) ?? ($params['issue_type_id'] ?? null); - $orderNo = $this->resolveParam($params, 'work_order_no_path', $data) ?? ($params['work_order_no'] ?? null); + $orderNo = $this->resolveParam($params, 'work_order_no_path', $data) ?? ($params['work_order_no'] ?? null); $description = $this->resolveParam($params, 'description_path', $data) ?? ($params['description'] ?? 'Machine-generated issue'); @@ -164,11 +174,11 @@ private function createIssue(array $params, array $data, mixed $fieldValue): arr } $issue = Issue::create([ - 'issue_type_id' => $issueTypeId, - 'work_order_id' => $workOrderId, - 'description' => (string) $description, - 'status' => 'open', - 'reported_by' => null, // machine-generated + 'issue_type_id' => $issueTypeId, + 'work_order_id' => $workOrderId, + 'description' => (string) $description, + 'status' => 'open', + 'reported_by' => null, // machine-generated ]); return ['issue_id' => $issue->id]; @@ -177,16 +187,16 @@ private function createIssue(array $params, array $data, mixed $fieldValue): arr private function updateLineStatus(array $params, array $data, mixed $fieldValue): array { // params: { line_id (static), line_code_path, status_id (static), status_code_path } - $lineId = $this->resolveParam($params, 'line_id_path', $data) ?? ($params['line_id'] ?? null); - $lineCode = $this->resolveParam($params, 'line_code_path', $data) ?? ($params['line_code'] ?? null); - $statusId = $this->resolveParam($params, 'status_id_path', $data) ?? ($params['status_id'] ?? null); - $statusCode = $this->resolveParam($params, 'status_code_path', $data) ?? ($params['status_code'] ?? null); + $lineId = $this->resolveParam($params, 'line_id_path', $data) ?? ($params['line_id'] ?? null); + $lineCode = $this->resolveParam($params, 'line_code_path', $data) ?? ($params['line_code'] ?? null); + $statusId = $this->resolveParam($params, 'status_id_path', $data) ?? ($params['status_id'] ?? null); + $statusCode = $this->resolveParam($params, 'status_code_path', $data) ?? ($params['status_code'] ?? null); $line = $lineId ? Line::find($lineId) : Line::where('code', $lineCode)->first(); - if (!$line) { + if (! $line) { throw new \RuntimeException("Line not found (id={$lineId}, code={$lineCode})"); } @@ -194,7 +204,7 @@ private function updateLineStatus(array $params, array $data, mixed $fieldValue) ? LineStatus::find($statusId) : LineStatus::where('code', $statusCode)->first(); - if (!$lineStatus) { + if (! $lineStatus) { throw new \RuntimeException("LineStatus not found (id={$statusId}, code={$statusCode})"); } @@ -214,12 +224,12 @@ private function updateLineStatus(array $params, array $data, mixed $fieldValue) private function setWorkOrderStatus(array $params, array $data, mixed $fieldValue): array { // params: { order_no_path, order_id (static), status (static), status_path } - $orderNo = $this->resolveParam($params, 'order_no_path', $data) ?? ($params['order_no'] ?? null); - $orderId = $this->resolveParam($params, 'order_id_path', $data) ?? ($params['order_id'] ?? null); - $status = $this->resolveParam($params, 'status_path', $data) ?? ($params['status'] ?? null); + $orderNo = $this->resolveParam($params, 'order_no_path', $data) ?? ($params['order_no'] ?? null); + $orderId = $this->resolveParam($params, 'order_id_path', $data) ?? ($params['order_id'] ?? null); + $status = $this->resolveParam($params, 'status_path', $data) ?? ($params['status'] ?? null); $allowed = ['pending', 'accepted', 'in_progress', 'completed', 'paused', 'rejected']; - if (!in_array($status, $allowed)) { + if (! in_array($status, $allowed)) { throw new \RuntimeException("Invalid work order status: {$status}"); } @@ -227,8 +237,8 @@ private function setWorkOrderStatus(array $params, array $data, mixed $fieldValu ? WorkOrder::where('order_no', $orderNo)->first() : WorkOrder::find($orderId); - if (!$workOrder) { - throw new \RuntimeException("WorkOrder not found"); + if (! $workOrder) { + throw new \RuntimeException('WorkOrder not found'); } $workOrder->update(['status' => $status]); @@ -239,12 +249,12 @@ private function setWorkOrderStatus(array $params, array $data, mixed $fieldValu private function webhookForward(array $params, array $data): array { // params: { url, method (GET/POST), headers (object) } - $url = $params['url'] ?? null; - $method = strtolower($params['method'] ?? 'post'); + $url = $params['url'] ?? null; + $method = strtolower($params['method'] ?? 'post'); $headers = $params['headers'] ?? []; - if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) { - throw new \RuntimeException("Invalid or missing webhook URL"); + if (! $url || ! filter_var($url, FILTER_VALIDATE_URL)) { + throw new \RuntimeException('Invalid or missing webhook URL'); } // Block SSRF — reject requests to private/loopback/metadata addresses @@ -256,13 +266,13 @@ private function webhookForward(array $params, array $data): array FILTER_FLAG_NO_PRIV_RANGE, FILTER_FLAG_NO_RES_RANGE, ] as $flag) { - if (!filter_var($ip, FILTER_VALIDATE_IP, $flag)) { - throw new \RuntimeException("Webhook URL resolves to a private/reserved address"); + if (! filter_var($ip, FILTER_VALIDATE_IP, $flag)) { + throw new \RuntimeException('Webhook URL resolves to a private/reserved address'); } } // Block AWS/GCP/Azure metadata endpoints explicitly if (in_array($ip, ['169.254.169.254', '169.254.170.2', '100.100.100.200'])) { - throw new \RuntimeException("Webhook URL resolves to a private/reserved address"); + throw new \RuntimeException('Webhook URL resolves to a private/reserved address'); } } } @@ -282,9 +292,10 @@ private function webhookForward(array $params, array $data): array */ private function resolveParam(array $params, string $pathKey, array $data): mixed { - if (!isset($params[$pathKey])) { + if (! isset($params[$pathKey])) { return null; } + return $this->parser->resolvePath($params[$pathKey], $data); } } diff --git a/backend/app/Services/Logistics/PalletMovementService.php b/backend/app/Services/Logistics/PalletMovementService.php new file mode 100644 index 00000000..d546fda4 --- /dev/null +++ b/backend/app/Services/Logistics/PalletMovementService.php @@ -0,0 +1,46 @@ +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, + ]); + }); + } +} diff --git a/backend/app/Services/Machine/MachineSignalIngestor.php b/backend/app/Services/Machine/MachineSignalIngestor.php index 5049a85c..7023a0ec 100644 --- a/backend/app/Services/Machine/MachineSignalIngestor.php +++ b/backend/app/Services/Machine/MachineSignalIngestor.php @@ -5,6 +5,7 @@ use App\Models\MachineEvent; use App\Models\MachineTag; use App\Models\Workstation; +use App\Services\WorkOrder\MachineProductionService; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -24,7 +25,10 @@ */ class MachineSignalIngestor { - public function __construct(private readonly WorkstationStateMachine $stateMachine) {} + public function __construct( + private readonly WorkstationStateMachine $stateMachine, + private readonly MachineProductionService $production, + ) {} public function ingest(MachineTag $tag, mixed $rawValue, ?Carbon $at = null): void { @@ -91,6 +95,19 @@ private function handleCounter(MachineTag $tag, ?Workstation $ws, mixed $value, 'value' => $value, 'delta' => $delta, ]); + + // Close the loop to work order progress: a good-count delta drives + // produced_qty on the order currently running at this workstation, but + // only when that order is machine-counted (the service enforces this). + // Reject counts stay in the event log for now (scrap wiring is a + // follow-up). No active machine-counted order → the event above is the + // full record, exactly as before. + if ($kind === 'good') { + $workOrder = $this->production->resolveActiveWorkOrder($ws); + if ($workOrder) { + $this->production->recordGoodCount($workOrder, $delta); + } + } } private function handleTelemetry(MachineTag $tag, ?Workstation $ws, mixed $value, Carbon $at): void diff --git a/backend/app/Services/WorkOrder/MachineProductionService.php b/backend/app/Services/WorkOrder/MachineProductionService.php new file mode 100644 index 00000000..25528a1c --- /dev/null +++ b/backend/app/Services/WorkOrder/MachineProductionService.php @@ -0,0 +1,105 @@ +id) + ->where('status', BatchStep::STATUS_IN_PROGRESS) + ->orderByDesc('started_at') + ->orderByDesc('id') + ->first(); + + if ($workOrder = $step?->batch?->workOrder) { + return $workOrder; + } + + $batch = Batch::forWorkstation($workstation->id) + ->where('status', Batch::STATUS_IN_PROGRESS) + ->orderByDesc('id') + ->first(); + + return $batch?->workOrder; + } + + /** + * Add a positive good-count delta to produced_qty, honouring counting_source. + * Returns true when the count was applied, false when it was ignored (order + * is operator-counted, terminal, or the delta is non-positive). + */ + public function recordGoodCount(WorkOrder $workOrder, float $delta): bool + { + if ($delta <= 0 || ! $workOrder->isMachineCounted()) { + return false; + } + + $this->setProducedQty($workOrder, (float) $workOrder->produced_qty + $delta); + + return true; + } + + /** + * Set produced_qty to an absolute machine-reported value, honouring + * counting_source. Used by the legacy MQTT path when a mapping reports the + * cumulative total rather than a delta. + */ + public function recordAbsoluteCount(WorkOrder $workOrder, float $value): bool + { + if (! $workOrder->isMachineCounted()) { + return false; + } + + $this->setProducedQty($workOrder, $value); + + return true; + } + + /** + * Persist a new produced_qty and mirror the operator flow's status side + * effects: auto-start a not-yet-started order, auto-complete once produced + * reaches planned. Uses update() (not increment()) so model events fire and + * the Electric shape broadcast happens automatically. + */ + private function setProducedQty(WorkOrder $workOrder, float $newProduced): void + { + if (in_array($workOrder->status, WorkOrder::TERMINAL_STATUSES, true)) { + return; + } + + $newProduced = max(0.0, $newProduced); + $planned = (float) $workOrder->planned_qty; + $updates = ['produced_qty' => $newProduced]; + + if (in_array($workOrder->status, [WorkOrder::STATUS_PENDING, WorkOrder::STATUS_ACCEPTED], true)) { + $updates['status'] = WorkOrder::STATUS_IN_PROGRESS; + } + + if ($planned > 0 && $newProduced >= $planned) { + $updates['status'] = WorkOrder::STATUS_DONE; + $updates['completed_at'] = now(); + } + + $workOrder->update($updates); + } +} diff --git a/backend/app/Services/WorkOrder/WorkOrderService.php b/backend/app/Services/WorkOrder/WorkOrderService.php index 026f8fab..faf3a42f 100644 --- a/backend/app/Services/WorkOrder/WorkOrderService.php +++ b/backend/app/Services/WorkOrder/WorkOrderService.php @@ -27,6 +27,11 @@ public function createWorkOrder(array $data): WorkOrder $data['bom_template_ids'] ?? [], ); + // Embed an immutable revision block (#180) so the order always shows + // which product revision it was released under, even if a newer + // revision is released later or the revision is edited/obsoleted. + $processSnapshot = $this->attachRevisionSnapshot($processSnapshot, $data['product_revision_id'] ?? null); + // Create work order $workOrder = WorkOrder::create([ 'order_no' => $data['order_no'], @@ -34,10 +39,12 @@ public function createWorkOrder(array $data): WorkOrder 'customer_id' => $data['customer_id'] ?? null, 'line_id' => $data['line_id'] ?? null, 'product_type_id' => $data['product_type_id'] ?? null, + 'product_revision_id' => $data['product_revision_id'] ?? null, 'process_snapshot' => $processSnapshot, 'planned_qty' => $data['planned_qty'], 'unit_price' => $data['unit_price'] ?? null, 'produced_qty' => 0, + 'counting_source' => $data['counting_source'] ?? WorkOrder::COUNTING_OPERATOR, 'status' => WorkOrder::STATUS_PENDING, 'priority' => $data['priority'] ?? 0, 'due_date' => $data['due_date'] ?? null, @@ -56,6 +63,35 @@ public function createWorkOrder(array $data): WorkOrder }); } + /** + * Merge an immutable `revision` block into the process snapshot for the given + * product revision (#180). Returns the snapshot unchanged when no revision is + * selected; initialises an array snapshot when the order has no BOM/template. + */ + private function attachRevisionSnapshot(?array $snapshot, ?int $revisionId): ?array + { + if (! $revisionId) { + return $snapshot; + } + + $revision = \App\Models\ProductRevision::find($revisionId); + if (! $revision) { + return $snapshot; + } + + $snapshot ??= []; + $snapshot['revision'] = [ + 'revision_id' => $revision->id, + 'revision_code' => $revision->revision_code, + 'lifecycle_at_release' => $revision->lifecycle_status?->value, + 'process_template_id' => $revision->process_template_id, + 'released_at' => $revision->released_at?->toIso8601String(), + 'snapshotted_at' => now()->toIso8601String(), + ]; + + return $snapshot; + } + /** * Re-select the BOM(s) backing an existing work order and rebuild its * snapshot. An empty selection falls back to the single active template for diff --git a/backend/app/Support/ModuleRegistry.php b/backend/app/Support/ModuleRegistry.php index da5acceb..91bddbe7 100644 --- a/backend/app/Support/ModuleRegistry.php +++ b/backend/app/Support/ModuleRegistry.php @@ -22,11 +22,38 @@ class ModuleRegistry { public const SETTING_KEY = 'enabled_modules'; - /** key => [label, description]; key is the TabRegistry tab it controls. */ + /** + * key => [label, description]; key is the TabRegistry tab it controls. + * + * Fine-grained feature toggles: whole areas are split so an install can keep, + * say, Materials without Companies, or the Work-Order History report without + * the cost/scrap analytics. Core essentials (Product Types, Lines, LOT + * Sequences, the Planner, Orders, Admin) are never listed here — always on. + */ public const OPTIONAL = [ 'reports' => [ 'label' => 'Reports', - 'description' => 'Work-order history, production cost, scrap, non-conformance and net-requirements reports.', + 'description' => 'Work-order history report.', + ], + 'advanced_reports' => [ + 'label' => 'Advanced reports', + 'description' => 'Production cost, scrap, non-conformance and net-requirements reports.', + ], + 'materials' => [ + 'label' => 'Materials & tracing', + 'description' => 'Materials, material lots and traceability.', + ], + 'product_engineering' => [ + 'label' => 'Product engineering', + 'description' => 'Process segments and product revisions.', + ], + 'companies' => [ + 'label' => 'Companies', + 'description' => 'Customer and supplier company records.', + ], + 'quality' => [ + 'label' => 'Issues & reasons', + 'description' => 'Issues and scrap / anomaly reason codes.', ], 'structure' => [ 'label' => 'Company structure', @@ -34,10 +61,10 @@ class ModuleRegistry ], 'hr' => [ 'label' => 'HR', - 'description' => 'Workers, crews, skills, wage groups and absences.', + 'description' => 'Workers, crews, skills, wage groups, absences and employee scheduling.', ], 'maintenance' => [ - 'label' => 'Maintenance & Quality', + 'label' => 'Maintenance & QC', 'description' => 'Maintenance events, tools, inspection plans, quality controls and OEE.', ], 'connectivity' => [ @@ -54,12 +81,45 @@ class ModuleRegistry ], ]; + /** + * Onboarding presets — one-click starting points for the module set the + * admin chooses at first login. `custom` is not listed: it means "use the + * admin's own checkbox selection". Keys must be a subset of OPTIONAL. + */ + public const PRESETS = [ + // Minimal: core production tracking + the work-order history report only. + 'light' => ['reports'], + // Shop-floor operations: reporting + product/material data + quality + + // machine connectivity + packaging. Leaves multi-site structure, HR and + // webhooks off (an admin adds those via Custom or later in Settings). + 'advanced' => [ + 'reports', 'advanced_reports', 'materials', 'product_engineering', + 'companies', 'quality', 'maintenance', 'connectivity', 'packaging', + ], + ]; + /** @return array */ public static function optionalKeys(): array { return array_keys(self::OPTIONAL); } + /** + * Resolve a preset name to its enabled-module set. `custom` (or any unknown + * name) returns the explicit selection passed in, filtered to valid keys. + * + * @param array $customSelection + * @return array + */ + public static function modulesForPreset(string $preset, array $customSelection = []): array + { + if ($preset === 'custom') { + return array_values(array_intersect($customSelection, self::optionalKeys())); + } + + return self::PRESETS[$preset] ?? self::optionalKeys(); + } + /** * Enabled optional-module keys. A missing/invalid setting means "all * enabled" (back-compat); during install the DB may not exist yet. diff --git a/backend/app/Support/SoftDeleteRegistry.php b/backend/app/Support/SoftDeleteRegistry.php index 46f9efeb..ec35dfa3 100644 --- a/backend/app/Support/SoftDeleteRegistry.php +++ b/backend/app/Support/SoftDeleteRegistry.php @@ -39,6 +39,7 @@ class SoftDeleteRegistry 'shifts' => Models\Shift::class, 'inspection_plans' => Models\InspectionPlan::class, 'priority_rules' => Models\PriorityRule::class, + 'product_revisions' => Models\ProductRevision::class, // Structure 'customers' => Models\Customer::class, diff --git a/backend/app/Support/TabRegistry.php b/backend/app/Support/TabRegistry.php index 8eff00a6..5f0b7a9d 100644 --- a/backend/app/Support/TabRegistry.php +++ b/backend/app/Support/TabRegistry.php @@ -22,12 +22,38 @@ class TabRegistry 'schedule' => ['label' => 'Schedule', 'prefixes' => ['/admin/schedule']], 'orders' => ['label' => 'Orders', 'prefixes' => ['/admin/work-orders', '/admin/csv-import']], 'production' => ['label' => 'Production', 'prefixes' => [ - '/admin/product-types', '/admin/materials', '/admin/material-lots', '/admin/traceability', - '/admin/lot-sequences', '/admin/process-segments', '/admin/lines', '/admin/line-statuses', - '/admin/view-templates', '/admin/shifts', '/admin/issues', '/admin/companies', - '/admin/anomaly-reasons', '/admin/scrap-reasons', + '/admin/product-types', '/admin/lot-sequences', '/admin/lines', '/admin/line-statuses', + '/admin/view-templates', '/admin/shifts', + // Note: Materials, Process Segments, Product Revisions and Companies + // are gated by the Structure module; Issues, Anomaly Reasons and + // Scrap Reasons by Maintenance & Quality. They render under the + // Production nav group but live on those tabs so a Lightweight + // install (Reports only) hides them. + ]], + // Fine-grained feature tabs. Several render under the (core) Production + // and Reports nav groups but live on their own tab so each can be toggled + // independently in Settings → Modules (and granted per-role in Access). + // + // Base work-order history report — the one report Lightweight keeps. + 'reports' => ['label' => 'Reports', 'prefixes' => ['/admin/reports']], + // Analytical reports (render under the Reports nav group). + 'advanced_reports' => ['label' => 'Advanced reports', 'prefixes' => [ + '/admin/cost-reports', '/admin/scrap-reports', '/admin/non-conformance-reports', '/admin/net-requirements', + ]], + // Material master + tracing (render under the Production nav group). + 'materials' => ['label' => 'Materials & tracing', 'prefixes' => [ + '/admin/materials', '/admin/material-lots', '/admin/traceability', + ]], + // Product engineering data (render under the Production nav group). + 'product_engineering' => ['label' => 'Product engineering', 'prefixes' => [ + '/admin/process-segments', '/admin/product-revisions', + ]], + // Customer/supplier companies (render under the Production nav group). + 'companies' => ['label' => 'Companies', 'prefixes' => ['/admin/companies']], + // Issues + quality reason codes (render under the Production nav group). + 'quality' => ['label' => 'Issues & reasons', 'prefixes' => [ + '/admin/issues', '/admin/anomaly-reasons', '/admin/scrap-reasons', ]], - 'reports' => ['label' => 'Reports', 'prefixes' => ['/admin/reports', '/admin/cost-reports', '/admin/scrap-reports', '/admin/non-conformance-reports', '/admin/net-requirements']], 'structure' => ['label' => 'Structure', 'prefixes' => [ '/admin/sites', '/admin/areas', '/admin/factories', '/admin/divisions', '/admin/workstation-types', '/admin/subassemblies', @@ -35,6 +61,10 @@ class TabRegistry 'hr' => ['label' => 'HR', 'prefixes' => [ '/admin/workers', '/admin/worker-absences', '/admin/personnel-classes', '/admin/crews', '/admin/crew-break-windows', '/admin/skills', '/admin/wage-groups', + // Employee scheduling is labour planning — gated by HR, even though it + // lives under the (core) Schedule area. Longer than the 'schedule' + // prefix, so tabForPath's most-specific match routes it here. + '/admin/schedule/employees', ]], 'maintenance' => ['label' => 'Maintenance', 'prefixes' => [ '/admin/maintenance-events', '/admin/maintenance-schedules', '/admin/tools', '/admin/cost-sources', @@ -45,7 +75,7 @@ class TabRegistry 'webhooks' => ['label' => 'Webhooks', 'prefixes' => ['/admin/webhooks']], 'admin' => ['label' => 'Admin', 'prefixes' => ['/admin/users', '/admin/logs', '/admin/audit-logs', '/admin/trash']], 'modules' => ['label' => 'Modules', 'prefixes' => ['/admin/modules']], - 'packaging' => ['label' => 'Packaging', 'prefixes' => ['/admin/pallets']], + 'packaging' => ['label' => 'Packaging', 'prefixes' => ['/admin/pallets', '/admin/pallet-movements']], ]; /** @return array */ @@ -113,15 +143,22 @@ public static function tabForPath(string $path): ?string { $path = '/'.ltrim($path, '/'); + // Most-specific (longest) matching prefix wins, so a nested path can be + // owned by a different tab than its parent area — e.g. + // /admin/schedule/employees → hr, even though /admin/schedule → schedule. + $best = null; + $bestLen = -1; + foreach (self::TABS as $key => $tab) { foreach ($tab['prefixes'] as $prefix) { - if ($path === $prefix || str_starts_with($path, $prefix.'/')) { - return $key; + if (($path === $prefix || str_starts_with($path, $prefix.'/')) && strlen($prefix) > $bestLen) { + $best = $key; + $bestLen = strlen($prefix); } } } - return null; + return $best; } /** diff --git a/backend/app/Sync/CollectionBroadcaster.php b/backend/app/Sync/CollectionBroadcaster.php index 5f5385b2..983d9491 100644 --- a/backend/app/Sync/CollectionBroadcaster.php +++ b/backend/app/Sync/CollectionBroadcaster.php @@ -70,6 +70,7 @@ private static function map(): array 'material_lots' => [Models\MaterialLot::class, null], 'lot_sequences' => [Models\LotSequence::class, null], 'pallets' => [Models\Pallet::class, null], + 'pallet_movements' => [Models\PalletMovement::class, null], 'process_segments' => [Models\ProcessSegment::class, null], 'view_templates' => [Models\ViewTemplate::class, null], 'inspection_plans' => [Models\InspectionPlan::class, null], diff --git a/backend/app/Sync/ShapeRegistry.php b/backend/app/Sync/ShapeRegistry.php index 41d91cf0..c990718f 100644 --- a/backend/app/Sync/ShapeRegistry.php +++ b/backend/app/Sync/ShapeRegistry.php @@ -6,6 +6,7 @@ use App\Sync\Shapes\IssueTypesShape; use App\Sync\Shapes\LinesActiveShape; use App\Sync\Shapes\OeeRecordsRecentShape; +use App\Sync\Shapes\PalletMovementsRecentShape; use App\Sync\Shapes\ProductTypesShape; use App\Sync\Shapes\WorkOrdersActiveShape; @@ -54,6 +55,10 @@ class ShapeRegistry 'table' => 'priority_rules', 'columns' => ['id', 'name', 'field_source', 'condition_type', 'condition_value', 'condition_value_max', 'points', 'is_active', 'sort_order', 'created_at', 'updated_at'], ], + 'product_revisions' => [ + 'table' => 'product_revisions', + 'columns' => ['id', 'product_type_id', 'revision_code', 'description', 'lifecycle_status', 'process_template_id', 'change_reason', 'external_ref', 'effective_from', 'effective_to', 'released_at', 'obsolete_at', 'created_at', 'updated_at'], + ], 'cost_sources' => [ 'table' => 'cost_sources', 'columns' => ['id', 'code', 'name', 'description', 'unit_cost', 'unit', 'currency', 'is_active', 'created_at', 'updated_at'], @@ -172,6 +177,10 @@ class ShapeRegistry 'table' => 'pallets', 'columns' => ['id', 'pallet_no', 'work_order_id', 'batch_id', 'qty', 'status', 'quality_status', 'location', '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 + // grow the synced payload without bound. + 'pallet_movements' => PalletMovementsRecentShape::class, // integration_configs: exclude api_config (may hold credentials). 'integration_configs' => [ 'table' => 'integration_configs', @@ -179,7 +188,7 @@ class ShapeRegistry ], 'workers' => [ 'table' => 'workers', - 'columns' => ['id', 'code', 'name', 'email', 'phone', 'crew_id', 'wage_group_id', 'personnel_class_id', 'workstation_id', 'is_active', 'custom_fields', 'created_at', 'updated_at'], + 'columns' => ['id', 'code', 'name', 'email', 'phone', 'crew_id', 'wage_group_id', 'personnel_class_id', 'workstation_id', 'is_active', 'is_logistics', 'custom_fields', 'created_at', 'updated_at'], ], 'process_segments' => [ 'table' => 'process_segments', @@ -189,7 +198,7 @@ class ShapeRegistry // work_orders_active excludes done/cancelled/rejected. 'work_orders_all' => [ 'table' => 'work_orders', - 'columns' => ['id', 'order_no', 'customer_order_no', 'customer_id', 'line_id', 'product_type_id', 'planned_qty', 'unit_price', 'produced_qty', 'status', 'priority', 'priority_score', 'due_date', 'completed_at', 'custom_fields', 'created_at', 'updated_at'], + 'columns' => ['id', 'order_no', 'customer_order_no', 'customer_id', 'line_id', 'product_type_id', 'product_revision_id', 'planned_qty', 'unit_price', 'produced_qty', 'counting_source', 'status', 'priority', 'priority_score', 'due_date', 'completed_at', 'custom_fields', 'created_at', 'updated_at'], ], // All lines (incl. inactive) for the admin list — lines_active is active-only. 'lines_all' => [ diff --git a/backend/app/Sync/Shapes/PalletMovementsRecentShape.php b/backend/app/Sync/Shapes/PalletMovementsRecentShape.php new file mode 100644 index 00000000..bea67093 --- /dev/null +++ b/backend/app/Sync/Shapes/PalletMovementsRecentShape.php @@ -0,0 +1,54 @@ +subDays(self::WINDOW_DAYS)->toDateString(); + + return "moved_at >= '{$since}'"; + } +} diff --git a/backend/app/Sync/Shapes/WorkOrdersActiveShape.php b/backend/app/Sync/Shapes/WorkOrdersActiveShape.php index 123f83cb..bf1ac1e9 100644 --- a/backend/app/Sync/Shapes/WorkOrdersActiveShape.php +++ b/backend/app/Sync/Shapes/WorkOrdersActiveShape.php @@ -29,12 +29,14 @@ public function columns(): array 'customer_id', 'line_id', 'product_type_id', + 'product_revision_id', 'status', 'priority', 'priority_score', 'planned_qty', 'unit_price', 'produced_qty', + 'counting_source', 'due_date', 'completed_at', 'planned_start_at', diff --git a/backend/database/factories/PalletMovementFactory.php b/backend/database/factories/PalletMovementFactory.php new file mode 100644 index 00000000..fe8be937 --- /dev/null +++ b/backend/database/factories/PalletMovementFactory.php @@ -0,0 +1,25 @@ + + */ +class PalletMovementFactory extends Factory +{ + public function definition(): array + { + return [ + 'pallet_id' => Pallet::factory(), + 'worker_id' => Worker::factory()->logistics(), + 'from_location' => fake()->optional()->bothify('A-##-##'), + 'to_location' => fake()->bothify('B-##-##'), + 'moved_at' => now(), + 'notes' => fake()->optional()->sentence(), + ]; + } +} diff --git a/backend/database/factories/ProductRevisionFactory.php b/backend/database/factories/ProductRevisionFactory.php new file mode 100644 index 00000000..c990d608 --- /dev/null +++ b/backend/database/factories/ProductRevisionFactory.php @@ -0,0 +1,41 @@ + ProductType::factory(), + 'revision_code' => $codes[$counter++ % count($codes)].fake()->unique()->numberBetween(1, 99999), + 'description' => fake()->optional()->sentence(), + 'lifecycle_status' => RevisionLifecycle::Draft, + 'process_template_id' => null, + ]; + } + + public function released(): static + { + return $this->state(fn (array $attributes) => [ + 'lifecycle_status' => RevisionLifecycle::Released, + 'released_at' => now(), + ]); + } + + public function obsolete(): static + { + return $this->state(fn (array $attributes) => [ + 'lifecycle_status' => RevisionLifecycle::Obsolete, + 'released_at' => now()->subMonth(), + 'obsolete_at' => now(), + ]); + } +} diff --git a/backend/database/factories/WorkerFactory.php b/backend/database/factories/WorkerFactory.php index fc63d147..b695b6bb 100644 --- a/backend/database/factories/WorkerFactory.php +++ b/backend/database/factories/WorkerFactory.php @@ -28,6 +28,12 @@ public function inactive(): static return $this->state(fn () => ['is_active' => false]); } + /** A logistics operator / forklift driver (eligible to move pallets, #103). */ + public function logistics(): static + { + return $this->state(fn () => ['is_logistics' => true]); + } + public function paidHourly(float $rate = 40, string $currency = 'PLN'): static { return $this->state(fn () => [ diff --git a/backend/database/migrations/2026_07_19_100000_add_is_logistics_to_workers_table.php b/backend/database/migrations/2026_07_19_100000_add_is_logistics_to_workers_table.php new file mode 100644 index 00000000..478a9530 --- /dev/null +++ b/backend/database/migrations/2026_07_19_100000_add_is_logistics_to_workers_table.php @@ -0,0 +1,25 @@ +boolean('is_logistics')->default(false)->after('is_active'); + }); + } + + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn('is_logistics'); + }); + } +}; diff --git a/backend/database/migrations/2026_07_19_100001_create_pallet_movements_table.php b/backend/database/migrations/2026_07_19_100001_create_pallet_movements_table.php new file mode 100644 index 00000000..6a6d09f4 --- /dev/null +++ b/backend/database/migrations/2026_07_19_100001_create_pallet_movements_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('pallet_id')->constrained()->cascadeOnDelete(); + + // The logistics operator (Worker) who physically performed the move. + // Kept even if the worker record is later removed (nullOnDelete) so + // history isn't silently rewritten. + $table->foreignId('worker_id')->nullable()->constrained('workers')->nullOnDelete(); + + // Where the pallet came from / went to. from_location is null for the + // very first move of a pallet that had no prior location. + $table->string('from_location', 100)->nullable(); + $table->string('to_location', 100)->nullable(); + + $table->timestamp('moved_at'); + $table->text('notes')->nullable(); + + // The authenticated account that recorded the move (terminal login), + // distinct from the operator credited with performing it. + $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->index(['pallet_id', 'moved_at']); + $table->index(['worker_id', 'moved_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pallet_movements'); + } +}; diff --git a/backend/database/migrations/2026_07_20_120000_add_counting_source_to_work_orders.php b/backend/database/migrations/2026_07_20_120000_add_counting_source_to_work_orders.php new file mode 100644 index 00000000..9bf9d5ff --- /dev/null +++ b/backend/database/migrations/2026_07_20_120000_add_counting_source_to_work_orders.php @@ -0,0 +1,35 @@ +string('counting_source', 20) + ->nullable() + ->default('operator') + ->after('produced_qty'); + }); + } + + public function down(): void + { + Schema::table('work_orders', function (Blueprint $table) { + $table->dropColumn('counting_source'); + }); + } +}; diff --git a/backend/database/migrations/2026_07_21_100000_create_product_revisions_table.php b/backend/database/migrations/2026_07_21_100000_create_product_revisions_table.php new file mode 100644 index 00000000..f6ebf8ee --- /dev/null +++ b/backend/database/migrations/2026_07_21_100000_create_product_revisions_table.php @@ -0,0 +1,62 @@ +id(); + $table->foreignId('product_type_id')->constrained()->cascadeOnDelete(); + $table->string('revision_code', 50); + $table->string('description')->nullable(); + $table->string('lifecycle_status', 20)->default('draft'); + + // The released configuration this revision points at. Nullable while + // DRAFT; required to be set before RELEASED (enforced in the request). + $table->foreignId('process_template_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('change_reason')->nullable(); + $table->string('external_ref')->nullable(); + + // Optional effectivity window (informational for now). + $table->timestamp('effective_from')->nullable(); + $table->timestamp('effective_to')->nullable(); + + $table->timestamp('released_at')->nullable(); + $table->timestamp('obsolete_at')->nullable(); + $table->foreignId('released_by_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + $table->softDeletes(); + $table->foreignId('deleted_by_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->index(['product_type_id', 'lifecycle_status']); + }); + + // Partial unique: a revision code is unique per product type among live + // (non-deleted) rows only, so it can be re-used after a soft delete. + DB::statement( + 'CREATE UNIQUE INDEX product_revisions_type_code_unique ' + .'ON product_revisions (product_type_id, revision_code) ' + .'WHERE deleted_at IS NULL' + ); + } + + public function down(): void + { + Schema::dropIfExists('product_revisions'); + } +}; diff --git a/backend/database/migrations/2026_07_21_100100_add_product_revision_to_work_orders.php b/backend/database/migrations/2026_07_21_100100_add_product_revision_to_work_orders.php new file mode 100644 index 00000000..caa50561 --- /dev/null +++ b/backend/database/migrations/2026_07_21_100100_add_product_revision_to_work_orders.php @@ -0,0 +1,31 @@ +foreignId('product_revision_id') + ->nullable() + ->after('product_type_id') + ->constrained('product_revisions') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('work_orders', function (Blueprint $table) { + $table->dropConstrainedForeignId('product_revision_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_07_23_120000_expand_enabled_modules_for_granular_split.php b/backend/database/migrations/2026_07_23_120000_expand_enabled_modules_for_granular_split.php new file mode 100644 index 00000000..a2c4dcb4 --- /dev/null +++ b/backend/database/migrations/2026_07_23_120000_expand_enabled_modules_for_granular_split.php @@ -0,0 +1,54 @@ +where('key', ModuleRegistry::SETTING_KEY)->first(); + if (! $row) { + return; // unset → every module on, nothing to migrate + } + + $set = json_decode($row->value, true); + if (! is_array($set) || $set === []) { + return; + } + + // Formerly-core areas (Production nav group) — keep them visible. + $set = array_merge($set, ['materials', 'product_engineering', 'companies', 'quality']); + + // Cost / scrap / non-conformance / net-requirements used to live on the Reports tab. + if (in_array('reports', $set, true)) { + $set[] = 'advanced_reports'; + } + + $clean = array_values(array_intersect(array_unique($set), ModuleRegistry::optionalKeys())); + + DB::table('system_settings') + ->where('key', ModuleRegistry::SETTING_KEY) + ->update(['value' => json_encode($clean), 'updated_at' => now()]); + } + + public function down(): void + { + // One-way data backfill; the expanded set is harmless to leave in place. + } +}; diff --git a/backend/lang/en.json b/backend/lang/en.json index 9e339805..90d9e575 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -1,4 +1,18 @@ { + "Advanced reports": "Advanced reports", + "Production cost, scrap, non-conformance and net-requirements reports.": "Production cost, scrap, non-conformance and net-requirements reports.", + "Materials & tracing": "Materials & tracing", + "Materials, material lots and traceability.": "Materials, material lots and traceability.", + "Product engineering": "Product engineering", + "Process segments and product revisions.": "Process segments and product revisions.", + "Customer and supplier company records.": "Customer and supplier company records.", + "Issues & reasons": "Issues & reasons", + "Issues and scrap / anomaly reason codes.": "Issues and scrap / anomaly reason codes.", + "Sites, areas, factories, divisions, workstation types and subassemblies.": "Sites, areas, factories, divisions, workstation types and subassemblies.", + "Workers, crews, skills, wage groups, absences and employee scheduling.": "Workers, crews, skills, wage groups, absences and employee scheduling.", + "Maintenance & QC": "Maintenance & QC", + "Maintenance events, tools, inspection plans, quality controls and OEE.": "Maintenance events, tools, inspection plans, quality controls and OEE.", + "Send HTTP notifications to external systems on work-order, issue and batch events.": "Send HTTP notifications to external systems on work-order, issue and batch events.", "% of Total": "% of Total", "(default)": "(default)", "(leave blank to keep current)": "(leave blank to keep current)", @@ -1680,13 +1694,11 @@ "Main": "Main", "Maint": "Maint", "Maintenance": "Maintenance", - "Maintenance & Quality": "Maintenance & Quality", "Maintenance Event": "Maintenance Event", "Maintenance Events": "Maintenance Events", "Maintenance Reminder": "Maintenance Reminder", "Maintenance Schedules": "Maintenance Schedules", "Maintenance event created successfully.": "Maintenance event created successfully.", - "Maintenance events, tools, inspection plans, quality controls and OEE.": "Maintenance events, tools, inspection plans, quality controls and OEE.", "Maintenance schedule created successfully.": "Maintenance schedule created successfully.", "Maintenance schedule deleted successfully.": "Maintenance schedule deleted successfully.", "Maintenance schedule updated successfully.": "Maintenance schedule updated successfully.", @@ -3021,7 +3033,6 @@ "Site": "Site", "Sites": "Sites", "Sites & areas": "Sites & areas", - "Sites, areas, factories, divisions, workstation types and subassemblies.": "Sites, areas, factories, divisions, workstation types and subassemblies.", "Size": "Size", "Skill": "Skill", "Skills": "Skills", @@ -3512,7 +3523,7 @@ "Work orders to pack": "Work orders to pack", "Work orders, issues, and lines summary cards": "Work orders, issues, and lines summary cards", "Work patterns": "Work patterns", - "Work-order history, production cost, scrap, non-conformance and net-requirements reports.": "Work-order history, production cost, scrap, non-conformance and net-requirements reports.", + "Work-order history report.": "Work-order history report.", "Worker": "Worker", "Worker Code": "Worker Code", "Worker Profile": "Worker Profile", @@ -3523,7 +3534,6 @@ "Workers & crews": "Workers & crews", "Workers in this class": "Workers in this class", "Workers regularly operating at this workstation.": "Workers regularly operating at this workstation.", - "Workers, crews, skills, wage groups and absences.": "Workers, crews, skills, wage groups and absences.", "Workflow Mode": "Workflow Mode", "Workstation": "Workstation", "Workstation (Optional)": "Workstation (Optional)", @@ -4817,10 +4827,71 @@ "Confirm you have read the instructions before completing this step.": "Confirm you have read the instructions before completing this step.", "Instructions acknowledged by :name on :date": "Instructions acknowledged by :name on :date", "I have read the instructions": "I have read the instructions", + "Move Pallet": "Move Pallet", + "Record a physical pallet movement.": "Record a physical pallet movement.", + "No logistics operators configured. Mark a worker as a logistics operator first.": "No logistics operators configured. Mark a worker as a logistics operator first.", + "— Select pallet —": "— Select pallet —", + "Current location": "Current location", + "New location": "New location", + "e.g. B-07-02": "e.g. B-07-02", + "Confirm move": "Confirm move", + "Pallet Movements": "Pallet Movements", + "No pallet movements recorded yet.": "No pallet movements recorded yet.", + "Logistics operator (can move pallets)": "Logistics operator (can move pallets)", + "Select an active logistics operator.": "Select an active logistics operator.", + "Pallet movement recorded.": "Pallet movement recorded.", + "Select a movable (not shipped) pallet.": "Select a movable (not shipped) pallet.", "Bills of Materials": "Bills of Materials", "Select one or more BOMs. Requirements sum across the selected BOMs. Leave empty to auto-use the active BOM for the product type.": "Select one or more BOMs. Requirements sum across the selected BOMs. Leave empty to auto-use the active BOM for the product type.", "Select a product type first.": "Select a product type first.", "No BOMs are available for the selected product type.": "No BOMs are available for the selected product type.", "Age": "Age", + "just now": "just now", + "Counting Source": "Counting Source", + "Operator (manual)": "Operator (manual)", + "Machine (automatic)": "Machine (automatic)", + "Both (machine + operator)": "Both (machine + operator)", + "Where produced quantity comes from. Machine-counted orders are driven by machine counter signals and block manual operator entry.": "Where produced quantity comes from. Machine-counted orders are driven by machine counter signals and block manual operator entry.", + "Produced quantity is recorded automatically from the machine.": "Produced quantity is recorded automatically from the machine.", + "Obsolete": "Obsolete", + "Revision Code": "Revision Code", + "Letters, digits, dot or hyphen — e.g. A, 01, C.2.": "Letters, digits, dot or hyphen — e.g. A, 01, C.2.", + "Process Template (released config)": "Process Template (released config)", + "The process + BOM this revision releases. Required before the revision can be released.": "The process + BOM this revision releases. Required before the revision can be released.", + "Change Reason": "Change Reason", + "External Reference": "External Reference", + "Product type cannot be changed after creation.": "Product type cannot be changed after creation.", + "Revision": "Revision", + "Lifecycle": "Lifecycle", + "Release revision \":code\"? It becomes immutable and selectable for production.": "Release revision \":code\"? It becomes immutable and selectable for production.", + "Mark obsolete": "Mark obsolete", + "Mark revision \":code\" obsolete? It stays for history but is no longer selectable.": "Mark revision \":code\" obsolete? It stays for history but is no longer selectable.", + "Delete revision \":code\"?": "Delete revision \":code\"?", + "Product Revisions": "Product Revisions", + "+ New Revision": "+ New Revision", + "No product revisions yet.": "No product revisions yet.", + "New Product Revision": "New Product Revision", + "Edit Product Revision": "Edit Product Revision", + "Product Revision": "Product Revision", + "Only released revisions of the selected product type. Locked once production starts.": "Only released revisions of the selected product type. Locked once production starts.", + "Product revision :code created.": "Product revision :code created.", + "Product revision :code updated.": "Product revision :code updated.", + "Product revision :code deleted.": "Product revision :code deleted.", + "Product revision :code released.": "Product revision :code released.", + "Product revision :code marked obsolete.": "Product revision :code marked obsolete.", + "Only draft revisions can be edited. Released revisions are immutable.": "Only draft revisions can be edited. Released revisions are immutable.", + "Cannot delete a revision that is used by work orders.": "Cannot delete a revision that is used by work orders.", + "Only draft revisions can be released.": "Only draft revisions can be released.", + "Select a process template before releasing the revision.": "Select a process template before releasing the revision.", + "Only released revisions can be made obsolete.": "Only released revisions can be made obsolete.", + "Step 1 — Modules": "Step 1 — Modules", + "Choose your modules": "Choose your modules", + "Turn on only the feature areas your team needs. Core areas (Dashboard, Orders, Production, Admin) are always on. You can change this anytime in Settings → System → Modules.": "Turn on only the feature areas your team needs. Core areas (Dashboard, Orders, Production, Admin) are always on. You can change this anytime in Settings → System → Modules.", + "Lightweight": "Lightweight", + "Core production tracking + Reports. The simplest setup — recommended for small shops.": "Core production tracking + Reports. The simplest setup — recommended for small shops.", + "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 →", "just now": "just now" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 3306b8b3..f9369433 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1,4 +1,18 @@ { + "Advanced reports": "Raporty zaawansowane", + "Production cost, scrap, non-conformance and net-requirements reports.": "Raporty kosztów produkcji, odpadów, niezgodności i zapotrzebowania netto.", + "Materials & tracing": "Materiały i identyfikowalność", + "Materials, material lots and traceability.": "Materiały, partie materiałów i identyfikowalność.", + "Product engineering": "Inżynieria produktu", + "Process segments and product revisions.": "Segmenty procesu i rewizje produktów.", + "Customer and supplier company records.": "Rekordy firm klientów i dostawców.", + "Issues & reasons": "Problemy i przyczyny", + "Issues and scrap / anomaly reason codes.": "Problemy oraz kody przyczyn odpadów / anomalii.", + "Sites, areas, factories, divisions, workstation types and subassemblies.": "Lokalizacje, obszary, fabryki, działy, typy stanowisk i podzespoły.", + "Workers, crews, skills, wage groups, absences and employee scheduling.": "Pracownicy, brygady, umiejętności, grupy płacowe, nieobecności i harmonogram pracowników.", + "Maintenance & QC": "Utrzymanie i QC", + "Maintenance events, tools, inspection plans, quality controls and OEE.": "Zdarzenia utrzymania, narzędzia, plany inspekcji, kontrole jakości i OEE.", + "Send HTTP notifications to external systems on work-order, issue and batch events.": "Wysyłaj powiadomienia HTTP do systemów zewnętrznych przy zdarzeniach zleceń, problemów i partii.", "Converts a weekly salary into an hourly cost (salary / hours).": "Przelicza pensję tygodniową na koszt godzinowy (pensja / godziny).", "it will be automatically deleted after": "zostanie automatycznie usunięte po", "Delete status \":name\"?": "Usunąć status \":name\"?", @@ -444,7 +458,6 @@ "Current Password": "Aktualne hasło", "Current production qty": "Aktualna ilość produkcji", "ITEMS": "POZYCJE", - "Maintenance events, tools, inspection plans, quality controls and OEE.": "Zdarzenia utrzymania, narzędzia, plany inspekcji, kontrole jakości i OEE.", "Linear barcode (CODE 128 / EAN-13).": "Liniowy kod kreskowy (CODE 128 / EAN-13).", "Non-conformances": "Niezgodności", "Deactivate": "Dezaktywuj", @@ -547,7 +560,7 @@ "Port": "Port", "Unique identifier, uppercase recommended": "Unikalny identyfikator, zalecane wielkie litery", "Verifying...": "Weryfikowanie...", - "Work-order history, production cost, scrap, non-conformance and net-requirements reports.": "Raporty: historia zleceń, koszty produkcji, odpady, niezgodności i zapotrzebowanie netto.", + "Work-order history report.": "Raport historii zleceń produkcyjnych.", "No active product types defined yet.": "Brak zdefiniowanych aktywnych typów produktów.", "Who": "Kto", "No available lots": "Brak dostępnych partii", @@ -1611,7 +1624,6 @@ "Delete anomaly reason \":name\"?": "Usunąć przyczynę anomalii \":name\"?", "Role / Station": "Rola / Stanowisko", "Urgent": "Pilne", - "Workers, crews, skills, wage groups and absences.": "Pracownicy, brygady, umiejętności, grupy płacowe i nieobecności.", "Released": "Zwolniono", "Today OEE": "Dzisiejsze OEE", "Action Required:": "Wymagana akcja:", @@ -1919,7 +1931,6 @@ "No label template configured for type :type. Configure one in Packaging → Label Templates.": "Brak skonfigurowanego szablonu etykiety dla typu :type. Skonfiguruj go w Pakowanie → Szablony etykiet.", "No workstations are wired to a machine connection yet.": "Żadne stanowisko nie jest jeszcze podłączone do połączenia maszynowego.", "ERP integration": "Integracja z ERP", - "Sites, areas, factories, divisions, workstation types and subassemblies.": "Lokalizacje, obszary, fabryki, działy, typy stanowisk i podzespoły.", "Generate this many days before due": "Generuj tyle dni przed terminem", "No lines mapped under this site yet.": "Brak linii przypisanych do tego zakładu.", "Work patterns": "Wzorce pracy", @@ -2712,7 +2723,6 @@ "Lot is not available for consumption (status=:status).": "Partia nie jest dostępna do zużycia (status=:status).", "Lots": "Partie", "Low stock": "Niski stan", - "Maintenance & Quality": "Utrzymanie i jakość", "Payload rules": "Reguły ładunku", "slots": "sloty", "Fault": "Awaria", @@ -4292,7 +4302,6 @@ "No shift configured": "Brak skonfigurowanej zmiany", "Not found": "Nie znaleziono", "ONE SEQUENCE PER PRODUCT TYPE · DEFAULT IS THE GLOBAL FALLBACK": "JEDNA SEKWENCJA NA TYP PRODUKTU · DOMYŚLNA JEST GLOBALNA", - "Offline": "Offline", "Open API in browser": "Otwórz API w przeglądarce", "PARAM": "PARAMETR", "PICK A SUPERVISOR": "WYBIERZ NADZORCĘ", @@ -4817,10 +4826,70 @@ "Edit Workstation Type: :name": "Edytuj typ stanowiska: :name", "Edit Workstation: :name": "Edytuj stanowisko: :name", "(currently at: :station)": "(obecnie na: :station)", + "Move Pallet": "Przesuń paletę", + "Record a physical pallet movement.": "Zarejestruj fizyczne przemieszczenie palety.", + "No logistics operators configured. Mark a worker as a logistics operator first.": "Brak skonfigurowanych operatorów logistyki. Najpierw oznacz pracownika jako operatora logistyki.", + "— Select pallet —": "— Wybierz paletę —", + "Current location": "Bieżąca lokalizacja", + "New location": "Nowa lokalizacja", + "e.g. B-07-02": "np. B-07-02", + "Confirm move": "Potwierdź przesunięcie", + "Pallet Movements": "Przemieszczenia palet", + "No pallet movements recorded yet.": "Brak zarejestrowanych przemieszczeń palet.", + "Logistics operator (can move pallets)": "Operator logistyki (może przemieszczać palety)", + "Select an active logistics operator.": "Wybierz aktywnego operatora logistyki.", + "Pallet movement recorded.": "Zarejestrowano przemieszczenie palety.", + "Select a movable (not shipped) pallet.": "Wybierz paletę do przemieszczenia (nie wysłaną).", "Bills of Materials": "Zestawienia materiałowe (BOM)", "Select one or more BOMs. Requirements sum across the selected BOMs. Leave empty to auto-use the active BOM for the product type.": "Wybierz jeden lub więcej BOM-ów. Zapotrzebowanie sumuje się po wybranych BOM-ach. Zostaw puste, aby użyć aktywnego BOM-u dla typu produktu.", "Select a product type first.": "Najpierw wybierz typ produktu.", "No BOMs are available for the selected product type.": "Brak dostępnych BOM-ów dla wybranego typu produktu.", "Age": "Wiek", - "just now": "przed chwilą" + "just now": "przed chwilą", + "Counting Source": "Źródło liczenia", + "Operator (manual)": "Operator (ręcznie)", + "Machine (automatic)": "Maszyna (automatycznie)", + "Both (machine + operator)": "Oba (maszyna + operator)", + "Where produced quantity comes from. Machine-counted orders are driven by machine counter signals and block manual operator entry.": "Skąd pochodzi wyprodukowana ilość. Zlecenia liczone maszynowo są sterowane sygnałami licznika maszyny i blokują ręczny wpis operatora.", + "Produced quantity is recorded automatically from the machine.": "Wyprodukowana ilość jest zapisywana automatycznie z maszyny.", + "Obsolete": "Wycofana", + "Revision Code": "Kod rewizji", + "Letters, digits, dot or hyphen — e.g. A, 01, C.2.": "Litery, cyfry, kropka lub myślnik — np. A, 01, C.2.", + "Process Template (released config)": "Szablon procesu (wydana konfiguracja)", + "The process + BOM this revision releases. Required before the revision can be released.": "Proces + BOM wydawany przez tę rewizję. Wymagany przed wydaniem rewizji.", + "Change Reason": "Powód zmiany", + "External Reference": "Referencja zewnętrzna", + "Product type cannot be changed after creation.": "Typu produktu nie można zmienić po utworzeniu.", + "Revision": "Rewizja", + "Lifecycle": "Cykl życia", + "Release revision \":code\"? It becomes immutable and selectable for production.": "Wydać rewizję \":code\"? Stanie się niezmienna i wybieralna do produkcji.", + "Mark obsolete": "Oznacz jako wycofaną", + "Mark revision \":code\" obsolete? It stays for history but is no longer selectable.": "Oznaczyć rewizję \":code\" jako wycofaną? Zostanie w historii, ale nie będzie już wybieralna.", + "Delete revision \":code\"?": "Usunąć rewizję \":code\"?", + "Product Revisions": "Rewizje produktu", + "+ New Revision": "+ Nowa rewizja", + "No product revisions yet.": "Brak rewizji produktu.", + "New Product Revision": "Nowa rewizja produktu", + "Edit Product Revision": "Edytuj rewizję produktu", + "Product Revision": "Rewizja produktu", + "Only released revisions of the selected product type. Locked once production starts.": "Tylko wydane rewizje wybranego typu produktu. Zablokowane po rozpoczęciu produkcji.", + "Product revision :code created.": "Rewizja produktu :code utworzona.", + "Product revision :code updated.": "Rewizja produktu :code zaktualizowana.", + "Product revision :code deleted.": "Rewizja produktu :code usunięta.", + "Product revision :code released.": "Rewizja produktu :code wydana.", + "Product revision :code marked obsolete.": "Rewizja produktu :code oznaczona jako wycofana.", + "Only draft revisions can be edited. Released revisions are immutable.": "Tylko rewizje w wersji roboczej można edytować. Wydane rewizje są niezmienne.", + "Cannot delete a revision that is used by work orders.": "Nie można usunąć rewizji używanej przez zlecenia.", + "Only draft revisions can be released.": "Tylko rewizje robocze można wydać.", + "Select a process template before releasing the revision.": "Wybierz szablon procesu przed wydaniem rewizji.", + "Only released revisions can be made obsolete.": "Tylko wydane rewizje można wycofać.", + "Step 1 — Modules": "Krok 1 — Moduły", + "Choose your modules": "Wybierz moduły", + "Turn on only the feature areas your team needs. Core areas (Dashboard, Orders, Production, Admin) are always on. You can change this anytime in Settings → System → Modules.": "Włącz tylko obszary funkcji, których potrzebuje Twój zespół. Obszary podstawowe (Pulpit, Zamówienia, Produkcja, Admin) są zawsze włączone. Możesz to zmienić w każdej chwili w Ustawienia → System → Moduły.", + "Lightweight": "Lekki", + "Core production tracking + Reports. The simplest setup — recommended for small shops.": "Podstawowe śledzenie produkcji + Raporty. Najprostsza konfiguracja — zalecana dla małych zakładów.", + "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 →" } diff --git a/backend/resources/js/Pages/admin/pallet-movements/Index.jsx b/backend/resources/js/Pages/admin/pallet-movements/Index.jsx new file mode 100644 index 00000000..97889091 --- /dev/null +++ b/backend/resources/js/Pages/admin/pallet-movements/Index.jsx @@ -0,0 +1,69 @@ +import { Head, usePage } from '@inertiajs/react'; +import AppLayout from '../../../layouts/AppLayout'; +import ResourceTable from '../../../components/ResourceTable'; +import { __, formatDateTime } from '../../../lib/i18n'; + +/** + * Physical pallet movement history (#103). Rows stream live from the + * `pallet_movements` Electric shape; operator and pallet names are resolved + * from the lookup maps the controller passes as props (same pattern as Crews). + */ +export default function PalletMovementsIndex() { + const { operatorNames = {}, palletNumbers = {} } = usePage().props; + + const columns = [ + { + key: 'moved_at', + label: __('When'), + className: 'font-mono text-om-muted text-xs', + render: (r) => (r.moved_at ? formatDateTime(r.moved_at) : '—'), + }, + { + key: 'pallet', + label: __('Pallet'), + className: 'font-medium text-om-ink', + render: (r) => palletNumbers[r.pallet_id] ?? `#${r.pallet_id}`, + }, + { + key: 'from_location', + label: __('From'), + className: 'font-mono text-om-muted', + render: (r) => r.from_location || '—', + }, + { + key: 'to_location', + label: __('To'), + className: 'font-mono text-om-ink', + render: (r) => r.to_location || '—', + }, + { + key: 'operator', + label: __('Operator'), + className: 'text-om-muted', + render: (r) => operatorNames[r.worker_id] ?? '—', + }, + { + key: 'notes', + label: __('Notes'), + className: 'text-om-muted', + render: (r) => r.notes || '—', + }, + ]; + + return ( + <> + + []} + emptyText={__('No pallet movements recorded yet.')} + /> + + ); +} + +PalletMovementsIndex.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/admin/product-revisions/Create.jsx b/backend/resources/js/Pages/admin/product-revisions/Create.jsx new file mode 100644 index 00000000..2bb2a06f --- /dev/null +++ b/backend/resources/js/Pages/admin/product-revisions/Create.jsx @@ -0,0 +1,24 @@ +import { Head } from '@inertiajs/react'; +import AppLayout from '../../../layouts/AppLayout'; +import ResourceForm from '../../../components/ResourceForm'; +import { productRevisionFields } from './fields'; +import { __ } from '../../../lib/i18n'; + +export default function ProductRevisionCreate({ productTypes = [], processTemplates = [] }) { + return ( +
+ +

{__('New Product Revision')}

+ +
+ ); +} + +ProductRevisionCreate.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/admin/product-revisions/Edit.jsx b/backend/resources/js/Pages/admin/product-revisions/Edit.jsx new file mode 100644 index 00000000..a813c4b1 --- /dev/null +++ b/backend/resources/js/Pages/admin/product-revisions/Edit.jsx @@ -0,0 +1,31 @@ +import { Head } from '@inertiajs/react'; +import AppLayout from '../../../layouts/AppLayout'; +import ResourceForm from '../../../components/ResourceForm'; +import { productRevisionFields } from './fields'; +import { __ } from '../../../lib/i18n'; + +export default function ProductRevisionEdit({ revision, productTypes = [], processTemplates = [] }) { + return ( +
+ +

{__('Edit Product Revision')}

+ +
+ ); +} + +ProductRevisionEdit.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/admin/product-revisions/Index.jsx b/backend/resources/js/Pages/admin/product-revisions/Index.jsx new file mode 100644 index 00000000..d72e534a --- /dev/null +++ b/backend/resources/js/Pages/admin/product-revisions/Index.jsx @@ -0,0 +1,93 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import AppLayout from '../../../layouts/AppLayout'; +import ResourceTable from '../../../components/ResourceTable'; +import { LIFECYCLE_BADGE_STYLES, lifecycleLabel } from './fields'; +import { __ } from '../../../lib/i18n'; + +export default function ProductRevisionsIndex() { + const { productTypes = [], processTemplates = [], counts = {} } = usePage().props; + + const typeById = Object.fromEntries(productTypes.map((p) => [String(p.id), p])); + const templateById = Object.fromEntries(processTemplates.map((t) => [String(t.id), t])); + + const columns = [ + { + key: 'product_type_id', label: __('Product Type'), className: 'text-om-ink', + render: (r) => { + const p = typeById[String(r.product_type_id)]; + return p ? (p.code ? `${p.code} — ${p.name}` : p.name) : '—'; + }, + }, + { key: 'revision_code', label: __('Revision'), className: 'font-mono font-medium text-om-ink' }, + { + key: 'lifecycle_status', label: __('Lifecycle'), + render: (r) => ( + + {lifecycleLabel(r.lifecycle_status)} + + ), + }, + { + key: 'process_template_id', label: __('Process Template'), className: 'text-om-muted', + render: (r) => { + const t = templateById[String(r.process_template_id)]; + return t ? `${t.name} v${t.version}` : '—'; + }, + }, + { key: 'description', label: __('Description'), className: 'text-om-muted', render: (r) => r.description ?? '—' }, + { key: 'work_orders', label: __('Work orders'), align: 'right', render: (r) => counts[r.id] ?? 0 }, + ]; + + const actions = (r) => { + const items = []; + if (r.lifecycle_status === 'draft') { + items.push({ label: __('Edit'), href: `/admin/product-revisions/${r.id}/edit` }); + items.push({ + label: __('Release'), + onClick: () => { + if (confirm(__('Release revision ":code"? It becomes immutable and selectable for production.', { code: r.revision_code }))) { + router.post(`/admin/product-revisions/${r.id}/release`, {}, { preserveScroll: true }); + } + }, + }); + } + if (r.lifecycle_status === 'released') { + items.push({ + label: __('Mark obsolete'), + onClick: () => { + if (confirm(__('Mark revision ":code" obsolete? It stays for history but is no longer selectable.', { code: r.revision_code }))) { + router.post(`/admin/product-revisions/${r.id}/obsolete`, {}, { preserveScroll: true }); + } + }, + }); + } + items.push({ + label: __('Delete'), + className: 'text-om-blocked hover:underline', + onClick: () => { + if (confirm(__('Delete revision ":code"?', { code: r.revision_code }))) { + router.delete(`/admin/product-revisions/${r.id}`, { preserveScroll: true }); + } + }, + }); + return items; + }; + + return ( + <> + + + + ); +} + +ProductRevisionsIndex.layout = (page) => {page}; diff --git a/backend/resources/js/Pages/admin/product-revisions/fields.js b/backend/resources/js/Pages/admin/product-revisions/fields.js new file mode 100644 index 00000000..15a8fc8a --- /dev/null +++ b/backend/resources/js/Pages/admin/product-revisions/fields.js @@ -0,0 +1,47 @@ +import { __ } from '../../../lib/i18n'; + +// Lifecycle states — values must match App\Enums\RevisionLifecycle. +export const LIFECYCLE_BADGE_STYLES = { + draft: 'bg-gray-200 text-gray-700', + released: 'bg-green-100 text-green-800', + obsolete: 'bg-amber-100 text-amber-800', +}; + +export function lifecycleLabel(status) { + return { + draft: __('Draft'), + released: __('Released'), + obsolete: __('Obsolete'), + }[status] ?? status; +} + +/** Label for a process-template option: "Name v3 (inactive)". */ +function templateLabel(t) { + const inactive = t.is_active ? '' : ` (${__('inactive')})`; + return `${t.name} v${t.version}${inactive}`; +} + +// Editable fields for create/edit. On edit, product type is fixed (not shown as +// editable) — pass `lockProductType` to render it read-only-ish via a single option. +export function productRevisionFields(productTypes = [], processTemplates = [], { lockProductType = false } = {}) { + return [ + { + name: 'product_type_id', label: __('Product Type'), type: 'select', required: true, + options: productTypes.map((p) => ({ value: String(p.id), label: p.code ? `${p.code} — ${p.name}` : p.name })), + disabled: lockProductType, + help: lockProductType ? __('Product type cannot be changed after creation.') : undefined, + }, + { name: 'revision_code', label: __('Revision Code'), required: true, help: __('Letters, digits, dot or hyphen — e.g. A, 01, C.2.') }, + { name: 'description', label: __('Description') }, + { + name: 'process_template_id', label: __('Process Template (released config)'), type: 'select', + options: [ + { value: '', label: __('— None —') }, + ...processTemplates.map((t) => ({ value: String(t.id), label: templateLabel(t) })), + ], + help: __('The process + BOM this revision releases. Required before the revision can be released.'), + }, + { name: 'change_reason', label: __('Change Reason') }, + { name: 'external_ref', label: __('External Reference') }, + ]; +} diff --git a/backend/resources/js/Pages/admin/schedule/planner/panels.jsx b/backend/resources/js/Pages/admin/schedule/planner/panels.jsx index 860c154f..e2bf1160 100644 --- a/backend/resources/js/Pages/admin/schedule/planner/panels.jsx +++ b/backend/resources/js/Pages/admin/schedule/planner/panels.jsx @@ -1,7 +1,7 @@ // Toolbar + Backlog rail (with the Changes/undo tab), following the OpenMES // Schedule design. import { useState, useEffect } from 'react'; -import { Link } from '@inertiajs/react'; +import { Link, usePage } from '@inertiajs/react'; import { Dropdown } from '@openmes/ui'; import { __, formatDate } from '../../../../lib/i18n'; import { apiGet, apiCall } from '../../../../lib/http'; @@ -25,6 +25,10 @@ function NavBtn({ children, onClick, title }) { export function Toolbar({ ctx, view, setView, lineFilter, setLineFilter, live, onPrev, onNext, onToday, rangeLabel }) { const { data } = ctx; + // Employee scheduling lives under the (core) Schedule area but is gated by + // the HR module — hide the shortcut when HR is off, mirroring the nav. + const accessibleTabs = usePage().props?.auth?.user?.accessibleTabs ?? []; + const hrEnabled = accessibleTabs.includes('hr'); const tabs = [['weekly', __('Weekly')], ['daily', __('Daily')], ['hourly', __('Hourly')], ['monthly', __('Monthly')]]; const seg = (active) => ({ fontSize: 12, fontWeight: 500, padding: '6px 12px', borderRadius: 6, cursor: 'pointer', ...(active ? { background: 'var(--om-ink)', color: 'var(--om-on-ink)' } : { color: 'var(--om-muted)' }) }); @@ -49,7 +53,9 @@ export function Toolbar({ ctx, view, setView, lineFilter, setLineFilter, live, o /> {__('Capacity')} → - {__('Employees')} + {hrEnabled && ( + {__('Employees')} + )}
{LEGEND.map(([label, clr]) => ( diff --git a/backend/resources/js/Pages/admin/work-orders/Create.jsx b/backend/resources/js/Pages/admin/work-orders/Create.jsx index 4b3f8dea..00cd7dee 100644 --- a/backend/resources/js/Pages/admin/work-orders/Create.jsx +++ b/backend/resources/js/Pages/admin/work-orders/Create.jsx @@ -4,12 +4,12 @@ import AppLayout from '../../../layouts/AppLayout'; import WorkOrderForm from './WorkOrderForm'; export default function WorkOrderCreate() { - const { lines = [], productTypes = [], customers = [], bomTemplates = [], customFields = [] } = usePage().props; + const { lines = [], productTypes = [], customers = [], bomTemplates = [], productRevisions = [], customFields = [] } = usePage().props; return (

{__("New Work Order")}

- +
); } diff --git a/backend/resources/js/Pages/admin/work-orders/Edit.jsx b/backend/resources/js/Pages/admin/work-orders/Edit.jsx index dc840db3..afe4ef18 100644 --- a/backend/resources/js/Pages/admin/work-orders/Edit.jsx +++ b/backend/resources/js/Pages/admin/work-orders/Edit.jsx @@ -5,7 +5,7 @@ import ResourceForm from '../../../components/ResourceForm'; import { woFields } from './fields'; export default function WorkOrderEdit() { - const { workOrder, lines = [], productTypes = [], customers = [], bomTemplates = [], customFields = [] } = usePage().props; + const { workOrder, lines = [], productTypes = [], customers = [], bomTemplates = [], productRevisions = [], customFields = [] } = usePage().props; return (
@@ -13,7 +13,7 @@ export default function WorkOrderEdit() { ({ value: String(r.id), label: r.revision_code, group: r.product_type_id })), + ], + help: __('Only released revisions of the selected product type. Locked once production starts.'), + }); + } + // Optional multi-BOM picker: select one or more bills of materials (process // templates) for the chosen product type. Left empty, the order auto-uses the // single active BOM for its product type (unchanged single-BOM behaviour). @@ -69,6 +83,15 @@ export function woFields(lines, productTypes, { withStatus = false, customers = fields.push( { name: 'planned_qty', label: __('Planned Qty'), type: 'number', required: true }, { name: 'unit_price', label: __('Unit Price'), type: 'number', help: __('Price per produced unit. Adds to the customer\'s revenue when the order completes.') }, + { + name: 'counting_source', label: __('Counting Source'), type: 'select', + options: [ + { value: 'operator', label: __('Operator (manual)') }, + { value: 'machine', label: __('Machine (automatic)') }, + { value: 'both', label: __('Both (machine + operator)') }, + ], + help: __('Where produced quantity comes from. Machine-counted orders are driven by machine counter signals and block manual operator entry.'), + }, { name: 'priority', label: __('Priority'), type: 'number', help: __('Auto-calculated from priority rules when any are active; otherwise set manually.') }, { name: 'due_date', label: __('Due Date'), type: 'date' }, { name: 'description', label: __('Description'), type: 'textarea' }, diff --git a/backend/resources/js/Pages/admin/workers/Create.jsx b/backend/resources/js/Pages/admin/workers/Create.jsx index f8c0b892..9c238983 100644 --- a/backend/resources/js/Pages/admin/workers/Create.jsx +++ b/backend/resources/js/Pages/admin/workers/Create.jsx @@ -17,6 +17,7 @@ export default function WorkerCreate() { pay_type: '', pay_rate: '', is_active: true, + is_logistics: false, skills: [], ...customFieldInitial(), }); diff --git a/backend/resources/js/Pages/admin/workers/Edit.jsx b/backend/resources/js/Pages/admin/workers/Edit.jsx index d8de9135..eeb7cdc4 100644 --- a/backend/resources/js/Pages/admin/workers/Edit.jsx +++ b/backend/resources/js/Pages/admin/workers/Edit.jsx @@ -18,6 +18,7 @@ export default function WorkerEdit() { pay_type: worker.pay_type ?? '', pay_rate: worker.pay_rate != null ? String(worker.pay_rate) : '', is_active: !!worker.is_active, + is_logistics: !!worker.is_logistics, skills: worker.skills ?? [], ...customFieldInitial(worker.custom_fields), }); diff --git a/backend/resources/js/Pages/admin/workers/WorkerForm.jsx b/backend/resources/js/Pages/admin/workers/WorkerForm.jsx index fbb89f47..ed9485bd 100644 --- a/backend/resources/js/Pages/admin/workers/WorkerForm.jsx +++ b/backend/resources/js/Pages/admin/workers/WorkerForm.jsx @@ -93,6 +93,11 @@ export default function WorkerForm({ form, crews, wageGroups, personnelClasses,
setData('is_active', next)} label={__('Active')} /> + setData('is_logistics', next)} + label={__('Logistics operator (can move pallets)')} + />
diff --git a/backend/resources/js/Pages/logistics/MovePallet.jsx b/backend/resources/js/Pages/logistics/MovePallet.jsx new file mode 100644 index 00000000..39a5fb70 --- /dev/null +++ b/backend/resources/js/Pages/logistics/MovePallet.jsx @@ -0,0 +1,135 @@ +import { Head, useForm, usePage } from '@inertiajs/react'; +import { Button, Dropdown } from '@openmes/ui'; +import AppLayout from '../../layouts/AppLayout'; +import { __ } from '../../lib/i18n'; + +/** + * Shop-floor "Move Pallet" terminal (#103). A logistics operator identifies + * themselves (badge-style tap), picks a pallet, enters its new location, and + * confirms — recording an attributable physical movement. Optimised for a + * tablet: large tap targets, minimal typing. + */ +export default function MovePallet() { + const { operators = [], pallets = [] } = usePage().props; + + const form = useForm({ + worker_id: '', + pallet_id: '', + to_location: '', + notes: '', + }); + const { data, setData, errors, processing } = form; + + const selectedPallet = pallets.find((p) => String(p.id) === String(data.pallet_id)); + + const submit = (e) => { + e.preventDefault(); + form.post('/logistics/movements', { + preserveScroll: true, + onSuccess: () => form.reset('pallet_id', 'to_location', 'notes'), + }); + }; + + return ( +
+ + +

{__('Move Pallet')}

+

{__('Record a physical pallet movement.')}

+ +
+ {/* Operator — badge-style picker */} +
+ + {operators.length === 0 ? ( +

+ {__('No logistics operators configured. Mark a worker as a logistics operator first.')} +

+ ) : ( +
+ {operators.map((op) => { + const active = String(data.worker_id) === String(op.id); + return ( + + ); + })} +
+ )} + {errors.worker_id &&

{errors.worker_id}

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

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

+ )} + {errors.pallet_id &&

{errors.pallet_id}

} +
+ + {/* New location */} +
+ + setData('to_location', e.target.value)} + placeholder={__('e.g. B-07-02')} + 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}

} +
+ + {/* Notes (optional) */} +
+ +