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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]

### Added
- **Multi-level (hierarchical) BOMs** *([#178](https://github.com/Mes-Open/OpenMes/issues/178))*: a bill of materials can now contain **internally manufactured subassemblies**, not just purchased components, and requirement calculations **explode recursively through every level**. A material gains a **make-or-buy flag** (`materials.is_manufactured`) and a **producing process template** (`producing_process_template_id`); a BOM line pointing at such a material expands into that material's own BOM, to any depth. Quantities and scrap **compound down the tree** — `quantity_per_unit x parent requirement x (1 + scrap%)` at each level — so scrap on a subassembly correctly inflates demand for everything beneath it, and a component reached by several paths is summed into one line. **MRP** (`NetRequirementsService`) now plans against the exploded leaf materials instead of stopping at level one. **Circular references are rejected** at both entry points: adding/switching a BOM line that would make a template a component of itself (directly or through intermediates), and pointing a material's producing template at a template that already consumes it — each answered as a 422 with a translated message, plus a depth backstop so pre-existing bad data can't hang a request. **Fully backward compatible**: `bom_items` is unchanged, so existing single-level BOMs and API clients carry on working, and a BOM without manufactured components explodes to exactly the numbers the flat calculation produced (asserted in tests). New `App\Services\Material\BomExplosionService` (`tree()` for the nested structure, `leafRequirements()` for accumulated leaves); `Material::isExplodable()` plus `manufactured()`/`purchased()` scopes. UI tree view, REST endpoints for the hierarchy, work-order snapshotting of the structure and cross-level genealogy are follow-ups; this lands the model, the recursion and the safety rails.
- **Module selection at first-login onboarding (Lightweight / Advanced / Custom)**: the setup wizard now opens with a **"Choose your modules"** step so a new admin turns on only the feature areas they need instead of getting the full menu. Three one-click choices over the existing module system: **Lightweight** (core production tracking + the **Work-Order History** report only — recommended for small shops), **Advanced** (adds reporting, materials & tracing, product engineering, companies, quality, maintenance, connectivity and packaging — the shop-floor operations set), or **Custom** (tick exactly which optional modules to enable). Core areas (Dashboard, Orders, Production essentials, Admin) are always on. The choice writes to the existing `system_settings.enabled_modules` via `ModuleRegistry`, so disabled areas are hidden from the nav and their routes 404 — and it's **changeable anytime** in Settings → System → Modules. New onboarding **Modules** step (now step 1 of 5) with `ModuleRegistry::PRESETS` + `modulesForPreset()`; reuses `TabRegistry`/`TabAccessMiddleware`.
The optional-module catalog is now **fine-grained** so an install can keep, say, **Materials** without **Companies**, or the **Work-Order History** report without the **cost/scrap/non-conformance analytics**. Twelve independent toggles — **Reports**, **Advanced reports**, **Materials & tracing**, **Product engineering**, **Companies**, **Issues & reasons**, **Company structure**, **HR** (incl. employee scheduling), **Maintenance & QC**, **Connectivity**, **Packaging**, **Webhooks** — each maps to its own `TabRegistry` tab and role-access-matrix row, even when its pages render under the (core) Production or Reports nav groups. Existing installs that had explicitly customised their module set are backfilled on upgrade so previously-core areas stay visible (`2026_07_23_120000_expand_enabled_modules_for_granular_split`); installs that never customised default to all-on and are unaffected.
- **Machine counter signals drive work-order progress** *([#46](https://github.com/Mes-Open/OpenMes/issues/46))*: a work order now carries a **counting source** — **Operator (manual)**, **Machine (automatic)** or **Both** — chosen on the admin/supervisor create & edit form (default **Operator**, so existing orders are unchanged). On a **machine-counted** order, good-count deltas from the Modbus / OPC UA / MQTT **signal pipeline** now flow through to `work_orders.produced_qty`: the count is attributed to the order via the **batch step currently in progress at the reporting workstation** (`MachineTag → workstation → in-progress `BatchStep` → batch → work order`), and the same **auto-start / auto-complete** side effects fire as for operator entry. The legacy MQTT topic-mapping path (`ActionExecutor::updateWorkOrderQty`) and the new pipeline now funnel through one shared `MachineProductionService`, which **honours the counting source** — so a machine mapping and operator entry can no longer both add to the same order (the **double-count** #46 described). On a machine-counted order the operator's manual **complete / shift-entry** is blocked (the machine is the single source of truth), and the operator queue shows a **"Machine" badge**; reject counts stay in the event log for now (scrap wiring is a follow-up). New `work_orders.counting_source` column (nullable, defaults to `operator`); `App\Services\WorkOrder\MachineProductionService`; `counting_source` added to the work-order Electric shapes.
Expand Down
4 changes: 4 additions & 0 deletions backend/app/Http/Requests/Api/V1/StoreMaterialRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public function rules(): array
'material_type_id' => ['nullable', 'exists:material_types,id'],
'unit_of_measure' => ['nullable', 'string', 'max:20'],
'tracking_type' => ['nullable', 'string', 'in:none,batch,serial'],
// Make-or-buy: a manufactured material is a subassembly that BOM
// explosion descends into via its producing template.
'is_manufactured' => ['nullable', 'boolean'],
'producing_process_template_id' => ['nullable', 'integer', 'exists:process_templates,id'],
Comment thread
JanKolo04 marked this conversation as resolved.
'default_scrap_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'extra_data' => ['nullable', 'array'],
'external_code' => ['nullable', 'string', 'max:100'],
Expand Down
4 changes: 4 additions & 0 deletions backend/app/Http/Requests/Api/V1/UpdateMaterialRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public function rules(): array
'material_type_id' => ['sometimes', 'exists:material_types,id'],
'unit_of_measure' => ['sometimes', 'string', 'max:20'],
'tracking_type' => ['sometimes', 'string', 'in:none,batch,serial'],
// Make-or-buy: a manufactured material is a subassembly that BOM
// explosion descends into via its producing template.
'is_manufactured' => ['nullable', 'boolean'],
'producing_process_template_id' => ['nullable', 'integer', 'exists:process_templates,id'],
'default_scrap_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'extra_data' => ['nullable', 'array'],
'external_code' => ['nullable', 'string', 'max:100'],
Expand Down
4 changes: 4 additions & 0 deletions backend/app/Http/Requests/Web/Admin/StoreMaterialRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public function rules(): array
'material_type_id' => ['nullable', 'exists:material_types,id'],
'unit_of_measure' => ['nullable', 'string', 'max:20'],
'tracking_type' => ['nullable', 'in:none,batch,serial'],
// Make-or-buy: a manufactured material is a subassembly that BOM
// explosion descends into via its producing template.
'is_manufactured' => ['nullable', 'boolean'],
'producing_process_template_id' => ['nullable', 'integer', 'exists:process_templates,id'],
'default_scrap_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'external_code' => ['nullable', 'string', 'max:100'],
'external_system' => ['nullable', 'string', 'max:50'],
Expand Down
4 changes: 4 additions & 0 deletions backend/app/Http/Requests/Web/Admin/UpdateMaterialRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ public function rules(): array
'material_type_id' => ['nullable', 'exists:material_types,id'],
'unit_of_measure' => ['nullable', 'string', 'max:20'],
'tracking_type' => ['nullable', 'in:none,batch,serial'],
// Make-or-buy: a manufactured material is a subassembly that BOM
// explosion descends into via its producing template.
'is_manufactured' => ['nullable', 'boolean'],
'producing_process_template_id' => ['nullable', 'integer', 'exists:process_templates,id'],
'default_scrap_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'],
'external_code' => ['nullable', 'string', 'max:100'],
'external_system' => ['nullable', 'string', 'max:50'],
Expand Down
90 changes: 90 additions & 0 deletions backend/app/Models/Material.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
use App\Models\Concerns\HasCustomFields;
use App\Models\Concerns\HasTenant;
use App\Models\Concerns\SoftDeletesWithAudit;
use App\Services\Material\BomExplosionService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Validation\ValidationException;

class Material extends Model
{
Expand All @@ -22,6 +25,8 @@ class Material extends Model
'material_type_id',
'unit_of_measure',
'tracking_type',
'is_manufactured',
'producing_process_template_id',
'default_scrap_percentage',
'stock_quantity',
'reserved_quantity',
Expand All @@ -39,10 +44,20 @@ class Material extends Model
'tenant_id',
];

/**
* The column default lives in the DB, but a freshly built model wouldn't
* reflect it until reloaded — leaving `is_manufactured` null in memory and
* make-or-buy checks reading a null instead of false.
*/
protected $attributes = [
'is_manufactured' => false,
];

protected function casts(): array
{
return [
'is_active' => 'boolean',
'is_manufactured' => 'boolean',
'default_scrap_percentage' => 'decimal:2',
'stock_quantity' => 'decimal:3',
'reserved_quantity' => 'decimal:3',
Expand Down Expand Up @@ -85,11 +100,86 @@ public function lots(): HasMany
return $this->hasMany(MaterialLot::class);
}

protected static function booted(): void
{
// The other way to close a BOM loop: point an item at a producing
// template that already consumes it, at any depth. Enforced on the
// model so every write path is covered (the BOM-line side is gated in
// BomService). ValidationException so HTTP surfaces answer 422.
static::saving(function (self $material): void {
// The column is NOT NULL with a DB default, so a form field that
// arrives blank (ConvertEmptyStringsToNull) must not be written
// through as an explicit null.
if ($material->is_manufactured === null) {
$material->is_manufactured = false;
}

// Only an existing row can already be a component somewhere, so a
// freshly created material can't close a loop.
if (! $material->isDirty('producing_process_template_id')
|| $material->producing_process_template_id === null
|| ! $material->exists) {
return;
}

$creates = app(BomExplosionService::class)
->wouldCreateCycleAsProducer($material, (int) $material->producing_process_template_id);

if ($creates) {
throw ValidationException::withMessages([
'producing_process_template_id' => __(
'That template already consumes :material, so it cannot also produce it.',
['material' => $material->code],
),
]);
}
});
}

/**
* The BOM/routing that produces this material, for items made in-house.
* Null on purchased components — and on manufactured ones whose template
* hasn't been created yet, which BOM explosion treats as a leaf.
*/
public function producingTemplate(): BelongsTo
{
return $this->belongsTo(ProcessTemplate::class, 'producing_process_template_id');
}

/**
* Made in-house *and* explodable — what lets a BOM line descend a level.
*
* The relation itself has to resolve, not just the FK: process templates
* soft-delete, and `nullOnDelete` only fires on a hard delete, so a
* soft-deleted template leaves the id pointing at a row the relation
* returns null for. Without this check explosion would descend into a null
* template. Such a material falls back to being a leaf — a requirement for
* the subassembly itself — which is the safe reading.
*/
public function isExplodable(): bool
{
return $this->is_manufactured
&& $this->producing_process_template_id !== null
&& $this->producingTemplate !== null;
}
Comment thread
JanKolo04 marked this conversation as resolved.

public function scopeActive($query)
{
return $query->where('is_active', true);
}

/** Subassemblies produced in-house. */
public function scopeManufactured(Builder $query): Builder
{
return $query->where('is_manufactured', true);
}

/** Purchased components and raw materials. */
public function scopePurchased(Builder $query): Builder
{
return $query->where('is_manufactured', false);
}

public function scopeByExternalCode($query, string $code, ?string $system = null)
{
$query->where('external_code', $code);
Expand Down
Loading
Loading