From 56131f02f56df25380707f6c3d69bdc13e94fd46 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Sun, 26 Jul 2026 22:22:14 +0200 Subject: [PATCH 1/2] feat(bom): multi-level BOMs with recursive requirement explosion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BOM line could only reference a purchased material, so products built from in-house subassemblies had no way to express their real structure and MRP stopped at the first level. Adds make-or-buy to the item master — materials.is_manufactured plus producing_process_template_id — so a line pointing at a manufactured material expands into that material's own BOM, to any depth. bom_items is untouched, which is what keeps existing single-level BOMs and API clients working: a BOM with no manufactured components explodes to exactly the numbers the flat calculation produced (asserted). Quantities and scrap compound down the tree (qty_per_unit x parent requirement x (1 + scrap%)), and a component reached by several paths is summed once. NetRequirementsService now plans against exploded leaves rather than level one. Circular references are rejected at both entry points — a BOM line that would make a template a component of itself, and an item master pointing its producing template at a template that already consumes it — each as a 422 with a translated message, plus a depth backstop so pre-existing bad data cannot hang a request. New App\Services\Material\BomExplosionService: tree() for the nested structure, leafRequirements() for accumulated leaves. Out of scope here (follow-ups): UI tree view, REST endpoints for the hierarchy, work-order snapshotting of the structure, cross-level genealogy, and parent / child work orders. Refs #178 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YbLa6GeJZACX3tFCE7iNJq --- CHANGELOG.md | 1 + .../Requests/Api/V1/StoreMaterialRequest.php | 4 + .../Requests/Api/V1/UpdateMaterialRequest.php | 4 + .../Web/Admin/StoreMaterialRequest.php | 4 + .../Web/Admin/UpdateMaterialRequest.php | 4 + backend/app/Models/Material.php | 82 +++++ .../Services/Material/BomExplosionService.php | 248 +++++++++++++ backend/app/Services/Material/BomService.php | 41 +++ .../Material/NetRequirementsService.php | 49 ++- .../database/factories/MaterialFactory.php | 12 + ...0_add_manufacturing_to_materials_table.php | 51 +++ backend/lang/en.json | 4 +- backend/lang/pl.json | 4 +- .../Feature/Material/HierarchicalBomTest.php | 340 ++++++++++++++++++ 14 files changed, 828 insertions(+), 20 deletions(-) create mode 100644 backend/app/Services/Material/BomExplosionService.php create mode 100644 backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php create mode 100644 backend/tests/Feature/Material/HierarchicalBomTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 95994f59..f32d6f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/app/Http/Requests/Api/V1/StoreMaterialRequest.php b/backend/app/Http/Requests/Api/V1/StoreMaterialRequest.php index 852a5c63..5f86c8f6 100644 --- a/backend/app/Http/Requests/Api/V1/StoreMaterialRequest.php +++ b/backend/app/Http/Requests/Api/V1/StoreMaterialRequest.php @@ -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'], 'default_scrap_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'], 'extra_data' => ['nullable', 'array'], 'external_code' => ['nullable', 'string', 'max:100'], diff --git a/backend/app/Http/Requests/Api/V1/UpdateMaterialRequest.php b/backend/app/Http/Requests/Api/V1/UpdateMaterialRequest.php index 7abd3404..01149f27 100644 --- a/backend/app/Http/Requests/Api/V1/UpdateMaterialRequest.php +++ b/backend/app/Http/Requests/Api/V1/UpdateMaterialRequest.php @@ -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'], diff --git a/backend/app/Http/Requests/Web/Admin/StoreMaterialRequest.php b/backend/app/Http/Requests/Web/Admin/StoreMaterialRequest.php index 09843586..08c66cc5 100644 --- a/backend/app/Http/Requests/Web/Admin/StoreMaterialRequest.php +++ b/backend/app/Http/Requests/Web/Admin/StoreMaterialRequest.php @@ -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'], diff --git a/backend/app/Http/Requests/Web/Admin/UpdateMaterialRequest.php b/backend/app/Http/Requests/Web/Admin/UpdateMaterialRequest.php index aab904ac..1392fafc 100644 --- a/backend/app/Http/Requests/Web/Admin/UpdateMaterialRequest.php +++ b/backend/app/Http/Requests/Web/Admin/UpdateMaterialRequest.php @@ -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'], diff --git a/backend/app/Models/Material.php b/backend/app/Models/Material.php index 4695d1e8..2e4081a0 100644 --- a/backend/app/Models/Material.php +++ b/backend/app/Models/Material.php @@ -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 { @@ -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', @@ -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', @@ -85,11 +100,78 @@ 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 — the pair that lets a BOM line descend a + * level. `is_manufactured` alone doesn't, since the template may be missing. + */ + public function isExplodable(): bool + { + return $this->is_manufactured && $this->producing_process_template_id !== null; + } + 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); diff --git a/backend/app/Services/Material/BomExplosionService.php b/backend/app/Services/Material/BomExplosionService.php new file mode 100644 index 00000000..130d982b --- /dev/null +++ b/backend/app/Services/Material/BomExplosionService.php @@ -0,0 +1,248 @@ +> + */ + public function tree(ProcessTemplate $template, float $quantity = 1.0): array + { + return $this->explode($template, $quantity, 0, []); + } + + /** + * Flat, accumulated requirements for the leaf materials only — what has to + * be bought or drawn from stock once every subassembly is resolved. + * + * Materials reached by more than one path are summed into a single line. + * + * @return list> + */ + public function leafRequirements(ProcessTemplate $template, float $quantity): array + { + $totals = []; + $this->collectLeaves($this->tree($template, $quantity), $totals); + + return array_values($totals); + } + + /** + * Would adding $material to $template's BOM close a loop — i.e. can we get + * back to $template by exploding the material's own structure? + * + * Purchased materials can never close one, since explosion stops at them. + */ + public function wouldCreateCycle(ProcessTemplate $template, Material $material): bool + { + if (! $material->isExplodable()) { + return false; + } + + // Producing itself is the degenerate loop. + if ((int) $material->producing_process_template_id === (int) $template->getKey()) { + return true; + } + + return $this->templateReaches( + (int) $material->producing_process_template_id, + (int) $template->getKey(), + [], + ); + } + + /** + * Would pointing $material at $templateId as its producing template close a + * loop — that is, is $material already a component somewhere beneath it? + * + * The mirror of wouldCreateCycle(), for the item-master side of the link. + */ + public function wouldCreateCycleAsProducer(Material $material, int $templateId): bool + { + return $this->templateUsesMaterial($templateId, (int) $material->getKey(), []); + } + + /** + * @param list $stack templates already open on this path + * @return list> + */ + private function explode(ProcessTemplate $template, float $quantity, int $depth, array $stack): array + { + if ($depth >= self::MAX_DEPTH || in_array((int) $template->getKey(), $stack, true)) { + return []; + } + + $stack[] = (int) $template->getKey(); + + $items = $template->bomItems() + ->with(['material.materialType', 'material.producingTemplate', 'templateStep']) + ->orderBy('sort_order') + ->get(); + + return $items->map(function (BomItem $item) use ($quantity, $depth, $stack) { + $material = $item->material; + + $baseQty = round((float) $item->quantity_per_unit * $quantity, 4); + $scrapQty = round($baseQty * ((float) $item->scrap_percentage / 100), 4); + $requiredQty = round($baseQty + $scrapQty, 4); + + $explodable = $material->isExplodable(); + + return [ + 'bom_item_id' => $item->id, + 'material_id' => $item->material_id, + 'material_code' => $material->code, + 'material_name' => $material->name, + 'material_type' => $material->materialType?->code, + 'unit_of_measure' => $material->unit_of_measure, + 'quantity_per_unit' => (float) $item->quantity_per_unit, + 'scrap_percentage' => (float) $item->scrap_percentage, + 'base_qty' => $baseQty, + 'scrap_qty' => $scrapQty, + 'required_qty' => $requiredQty, + 'step_number' => $item->templateStep?->step_number, + 'consumed_at' => $item->consumed_at, + 'is_manufactured' => (bool) $material->is_manufactured, + 'level' => $depth, + // The subassembly's own structure, scaled by what this level needs. + 'children' => $explodable + ? $this->explode($material->producingTemplate, $requiredQty, $depth + 1, $stack) + : [], + ]; + })->all(); + } + + /** + * @param list> $nodes + * @param array> $totals keyed by material id + */ + private function collectLeaves(array $nodes, array &$totals): void + { + foreach ($nodes as $node) { + if ($node['children'] !== []) { + $this->collectLeaves($node['children'], $totals); + + continue; + } + + $id = $node['material_id']; + + if (isset($totals[$id])) { + $totals[$id]['base_qty'] = round($totals[$id]['base_qty'] + $node['base_qty'], 4); + $totals[$id]['scrap_qty'] = round($totals[$id]['scrap_qty'] + $node['scrap_qty'], 4); + $totals[$id]['required_qty'] = round($totals[$id]['required_qty'] + $node['required_qty'], 4); + + continue; + } + + $totals[$id] = [ + 'material_id' => $id, + 'material_code' => $node['material_code'], + 'material_name' => $node['material_name'], + 'material_type' => $node['material_type'], + 'unit_of_measure' => $node['unit_of_measure'], + 'base_qty' => $node['base_qty'], + 'scrap_qty' => $node['scrap_qty'], + 'required_qty' => $node['required_qty'], + 'is_manufactured' => $node['is_manufactured'], + ]; + } + } + + /** Depth-first: is $targetId reachable by exploding $templateId? */ + private function templateReaches(int $templateId, int $targetId, array $seen): bool + { + if ($templateId === $targetId) { + return true; + } + + if (in_array($templateId, $seen, true) || count($seen) >= self::MAX_DEPTH) { + return false; + } + + $seen[] = $templateId; + + foreach ($this->childTemplateIds($templateId) as $childId) { + if ($this->templateReaches($childId, $targetId, $seen)) { + return true; + } + } + + return false; + } + + /** Depth-first: does $templateId consume $materialId at any level? */ + private function templateUsesMaterial(int $templateId, int $materialId, array $seen): bool + { + if (in_array($templateId, $seen, true) || count($seen) >= self::MAX_DEPTH) { + return false; + } + + $seen[] = $templateId; + + $usesDirectly = BomItem::where('process_template_id', $templateId) + ->where('material_id', $materialId) + ->exists(); + + if ($usesDirectly) { + return true; + } + + foreach ($this->childTemplateIds($templateId) as $childId) { + if ($this->templateUsesMaterial($childId, $materialId, $seen)) { + return true; + } + } + + return false; + } + + /** Producing templates of the manufactured materials this template consumes. */ + private function childTemplateIds(int $templateId): array + { + return BomItem::where('bom_items.process_template_id', $templateId) + ->join('materials', 'materials.id', '=', 'bom_items.material_id') + ->whereNull('materials.deleted_at') + ->where('materials.is_manufactured', true) + ->whereNotNull('materials.producing_process_template_id') + ->pluck('materials.producing_process_template_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->all(); + } +} diff --git a/backend/app/Services/Material/BomService.php b/backend/app/Services/Material/BomService.php index da438764..9b297967 100644 --- a/backend/app/Services/Material/BomService.php +++ b/backend/app/Services/Material/BomService.php @@ -3,11 +3,15 @@ namespace App\Services\Material; use App\Models\BomItem; +use App\Models\Material; use App\Models\ProcessTemplate; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Validation\ValidationException; class BomService { + public function __construct(private readonly BomExplosionService $explosion) {} + /** * Get all BOM items for a process template. */ @@ -23,6 +27,8 @@ public function addItem(ProcessTemplate $template, array $data): BomItem { $data['process_template_id'] = $template->id; + $this->guardAgainstCycle($template, $data['material_id'] ?? null); + if (! isset($data['scrap_percentage']) && isset($data['material_id'])) { $material = \App\Models\Material::find($data['material_id']); if ($material && $material->default_scrap_percentage > 0) { @@ -41,6 +47,11 @@ public function addItem(ProcessTemplate $template, array $data): BomItem public function updateItem(BomItem $item, array $data): BomItem { + // Swapping the material can introduce a loop just as adding one can. + if (array_key_exists('material_id', $data) && (int) $data['material_id'] !== (int) $item->material_id) { + $this->guardAgainstCycle($item->processTemplate, $data['material_id']); + } + // Preserve the existing value when a NOT NULL column's field is cleared, // rather than passing an explicit null (which trips the constraint). $data['scrap_percentage'] ??= $item->scrap_percentage; @@ -56,6 +67,36 @@ public function removeItem(BomItem $item): void $item->delete(); } + /** + * Reject a line that would make a template a component of itself, directly + * or through any number of intermediate subassemblies. Both the web and API + * paths funnel through addItem/updateItem, so this is the single gate. + * + * Thrown as a ValidationException so either surface answers 422 with the + * message against the offending field, rather than a 500. + * + * @throws ValidationException + */ + private function guardAgainstCycle(ProcessTemplate $template, int|string|null $materialId): void + { + if ($materialId === null) { + return; + } + + $material = Material::with('producingTemplate')->find($materialId); + + if (! $material || ! $this->explosion->wouldCreateCycle($template, $material)) { + return; + } + + throw ValidationException::withMessages([ + 'material_id' => __( + 'Adding :material here would create a circular BOM reference.', + ['material' => $material->code], + ), + ]); + } + /** * Calculate material requirements for a given production quantity. * diff --git a/backend/app/Services/Material/NetRequirementsService.php b/backend/app/Services/Material/NetRequirementsService.php index 63657222..5fe17284 100644 --- a/backend/app/Services/Material/NetRequirementsService.php +++ b/backend/app/Services/Material/NetRequirementsService.php @@ -2,7 +2,6 @@ namespace App\Services\Material; -use App\Models\BomItem; use App\Models\Material; use App\Models\ProcessTemplate; use App\Models\WorkOrder; @@ -26,6 +25,8 @@ class NetRequirementsService /** Statuses whose un-started demand MRP plans for. */ public const DEMAND_STATUSES = [WorkOrder::STATUS_PENDING, WorkOrder::STATUS_ACCEPTED]; + public function __construct(private readonly BomExplosionService $explosion) {} + /** * @return array{ * period: array{start: string, end: string}, @@ -44,7 +45,8 @@ public function report(Carbon $from, Carbon $to, ?int $lineId = null): array ->when($lineId, fn ($q) => $q->where('line_id', $lineId)) ->get(['id', 'order_no', 'product_type_id', 'planned_qty', 'line_id', 'due_date']); - // BOM per product type (single-level): material lines off the active template. + // BOM per product type, exploded through every subassembly level to the + // materials that actually have to be bought or drawn from stock. $bomByProductType = $this->bomByProductType($workOrders->pluck('product_type_id')->unique()->filter()); // Accumulate gross requirement + the driving work orders, per material. @@ -53,8 +55,9 @@ public function report(Carbon $from, Carbon $to, ?int $lineId = null): array foreach ($workOrders as $wo) { $lines = $bomByProductType->get($wo->product_type_id, collect()); foreach ($lines as $line) { - $base = (float) $line['quantity_per_unit'] * (float) $wo->planned_qty; - $required = round($base * (1 + ((float) $line['scrap_percentage'] / 100)), 4); + // required_per_unit already carries the scrap compounded through + // every level of the explosion, so it only needs scaling here. + $required = round((float) $line['required_per_unit'] * (float) $wo->planned_qty, 4); if ($required <= 0) { continue; } @@ -111,8 +114,9 @@ public function report(Carbon $from, Carbon $to, ?int $lineId = null): array } /** - * Build a map of product_type_id => collection of BOM lines (material_id, - * quantity_per_unit, scrap_percentage) from each type's active template. + * Build a map of product_type_id => collection of leaf requirement lines + * (material_id, required_per_unit) from each type's active template, + * exploded through every subassembly level. * * @return Collection>> */ @@ -134,20 +138,29 @@ private function bomByProductType(Collection $productTypeIds): Collection return collect(); } - $items = BomItem::whereIn('process_template_id', $templateIds->values()) - ->get(['process_template_id', 'material_id', 'quantity_per_unit', 'scrap_percentage']) - ->groupBy('process_template_id'); - - // template_id => product_type_id (reverse the map above). + // Explode each template for a single unit: leaf quantities come back + // with scrap already compounded through every level, so the caller only + // scales by planned_qty. Subassemblies are resolved into what they are + // made of rather than counted as demand themselves — netting a + // manufactured item against its own stock is a separate MRP concern. + $templates = ProcessTemplate::whereIn('id', $templateIds->values())->get()->keyBy('id'); $productTypeByTemplate = $templateIds->flip(); - return $items->mapWithKeys(fn ($rows, $templateId) => [ - $productTypeByTemplate->get($templateId) => $rows->map(fn (BomItem $i) => [ - 'material_id' => $i->material_id, - 'quantity_per_unit' => (float) $i->quantity_per_unit, - 'scrap_percentage' => (float) $i->scrap_percentage, - ]), - ]); + return $templateIds->values() + ->mapWithKeys(function (int $templateId) use ($templates, $productTypeByTemplate) { + $template = $templates->get($templateId); + + $lines = $template + ? collect($this->explosion->leafRequirements($template, 1.0)) + ->map(fn (array $leaf) => [ + 'material_id' => $leaf['material_id'], + 'required_per_unit' => (float) $leaf['required_qty'], + ]) + : collect(); + + return [$productTypeByTemplate->get($templateId) => $lines]; + }) + ->filter(fn ($lines) => $lines->isNotEmpty()); } private function emptyReport(Carbon $from, Carbon $to, ?int $lineId, int $woCount): array diff --git a/backend/database/factories/MaterialFactory.php b/backend/database/factories/MaterialFactory.php index 7389209e..483f321d 100644 --- a/backend/database/factories/MaterialFactory.php +++ b/backend/database/factories/MaterialFactory.php @@ -28,6 +28,18 @@ public function inactive(): static return $this->state(fn () => ['is_active' => false]); } + /** + * A subassembly made in-house. Without a producing template it is still a + * leaf to BOM explosion — pass one to make it expandable. + */ + public function manufactured(?int $producingTemplateId = null): static + { + return $this->state(fn () => [ + 'is_manufactured' => true, + 'producing_process_template_id' => $producingTemplateId, + ]); + } + public function withExternalCode(string $system = 'subiekt_gt'): static { return $this->state(fn () => [ diff --git a/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php b/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php new file mode 100644 index 00000000..c85371ef --- /dev/null +++ b/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php @@ -0,0 +1,51 @@ +boolean('is_manufactured')->default(false)->after('tracking_type') + ->comment('Produced in-house (subassembly) rather than purchased'); + + // The BOM/routing that produces this material. Nullable even when + // is_manufactured is true: an item can be flagged as made in-house + // before its process template exists. Explosion treats such a + // material as a leaf until the template is set. + $table->foreignId('producing_process_template_id')->nullable() + ->after('is_manufactured') + ->constrained('process_templates') + ->nullOnDelete(); + + $table->index('is_manufactured'); + }); + } + + public function down(): void + { + Schema::table('materials', function (Blueprint $table) { + $table->dropConstrainedForeignId('producing_process_template_id'); + $table->dropIndex(['is_manufactured']); + $table->dropColumn('is_manufactured'); + }); + } +}; diff --git a/backend/lang/en.json b/backend/lang/en.json index a5b816d2..ea5e9c5b 100644 --- a/backend/lang/en.json +++ b/backend/lang/en.json @@ -4891,5 +4891,7 @@ "Adds:": "Adds:", "Full shop-floor operations: reports, quality & maintenance, machine connectivity and packaging.": "Full shop-floor operations: reports, quality & maintenance, machine connectivity and packaging.", "Pick exactly which modules to enable.": "Pick exactly which modules to enable.", - "Next: Production Line →": "Next: Production Line →" + "Next: Production Line →": "Next: Production Line →", + "Adding :material here would create a circular BOM reference.": "Adding :material here would create a circular BOM reference.", + "That template already consumes :material, so it cannot also produce it.": "That template already consumes :material, so it cannot also produce it." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index f9369433..bf0db32c 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -4891,5 +4891,7 @@ "Adds:": "Dodaje:", "Full shop-floor operations: reports, quality & maintenance, machine connectivity and packaging.": "Pełne operacje na hali: raporty, jakość i utrzymanie ruchu, łączność z maszynami i pakowanie.", "Pick exactly which modules to enable.": "Wybierz dokładnie, które moduły włączyć.", - "Next: Production Line →": "Dalej: Linia produkcyjna →" + "Next: Production Line →": "Dalej: Linia produkcyjna →", + "Adding :material here would create a circular BOM reference.": "Dodanie :material w tym miejscu utworzyłoby cykliczne odwołanie w BOM.", + "That template already consumes :material, so it cannot also produce it.": "Ten szablon już zużywa :material, więc nie może go jednocześnie produkować." } diff --git a/backend/tests/Feature/Material/HierarchicalBomTest.php b/backend/tests/Feature/Material/HierarchicalBomTest.php new file mode 100644 index 00000000..bf433221 --- /dev/null +++ b/backend/tests/Feature/Material/HierarchicalBomTest.php @@ -0,0 +1,340 @@ +type = MaterialType::create(['code' => 'raw_material', 'name' => 'Raw Material']); + $this->explosion = app(BomExplosionService::class); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private function template(string $name): ProcessTemplate + { + return ProcessTemplate::factory()->create(['name' => $name]); + } + + private function purchased(string $code): Material + { + return Material::factory()->create([ + 'code' => $code, + 'material_type_id' => $this->type->id, + 'unit_of_measure' => 'pcs', + ]); + } + + /** A subassembly item plus the template that produces it. */ + private function subassembly(string $code, ProcessTemplate $producedBy): Material + { + return Material::factory()->create([ + 'code' => $code, + 'material_type_id' => $this->type->id, + 'unit_of_measure' => 'pcs', + 'is_manufactured' => true, + 'producing_process_template_id' => $producedBy->id, + ]); + } + + private function line(ProcessTemplate $template, Material $material, float $qty, float $scrap = 0): BomItem + { + return BomItem::create([ + 'process_template_id' => $template->id, + 'material_id' => $material->id, + 'quantity_per_unit' => $qty, + 'scrap_percentage' => $scrap, + 'consumed_at' => 'start', + ]); + } + + /** @return array code => required_qty */ + private function byCode(array $rows): array + { + return collect($rows)->mapWithKeys(fn ($r) => [$r['material_code'] => (float) $r['required_qty']])->all(); + } + + /** + * The structure from the issue, with one extra level so three BOM levels + * are exercised: + * + * ASSEMBLY-1000 + * ├── SUBASSEMBLY-1100 × 2 + * │ ├── SUBASSEMBLY-1150 × 3 + * │ │ └── PART-1160 × 5 + * │ ├── PART-1110 × 4 + * │ └── PART-1120 × 1 + * └── PART-1200 × 6 + */ + private function threeLevelStructure(): ProcessTemplate + { + $t1000 = $this->template('ASSEMBLY-1000'); + $t1100 = $this->template('SUBASSEMBLY-1100'); + $t1150 = $this->template('SUBASSEMBLY-1150'); + + $sub1100 = $this->subassembly('SUBASSEMBLY-1100', $t1100); + $sub1150 = $this->subassembly('SUBASSEMBLY-1150', $t1150); + + $this->line($t1000, $sub1100, 2); + $this->line($t1000, $this->purchased('PART-1200'), 6); + + $this->line($t1100, $sub1150, 3); + $this->line($t1100, $this->purchased('PART-1110'), 4); + $this->line($t1100, $this->purchased('PART-1120'), 1); + + $this->line($t1150, $this->purchased('PART-1160'), 5); + + return $t1000; + } + + // ── Structure ──────────────────────────────────────────────────────────── + + public function test_a_product_can_contain_a_manufactured_subassembly(): void + { + $parent = $this->template('ASSEMBLY'); + $childTemplate = $this->template('SUB'); + $this->line($parent, $this->subassembly('SUB-1', $childTemplate), 2); + $this->line($childTemplate, $this->purchased('PART-A'), 3); + + $tree = $this->explosion->tree($parent, 1); + + $this->assertCount(1, $tree); + $this->assertTrue($tree[0]['is_manufactured']); + $this->assertSame('SUB-1', $tree[0]['material_code']); + $this->assertCount(1, $tree[0]['children'], 'The subassembly expands into its own BOM.'); + $this->assertSame('PART-A', $tree[0]['children'][0]['material_code']); + } + + public function test_three_levels_are_created_and_displayed(): void + { + $tree = $this->explosion->tree($this->threeLevelStructure(), 1); + + // level 0 → level 1 → level 2 + $sub1100 = collect($tree)->firstWhere('material_code', 'SUBASSEMBLY-1100'); + $this->assertSame(0, $sub1100['level']); + + $sub1150 = collect($sub1100['children'])->firstWhere('material_code', 'SUBASSEMBLY-1150'); + $this->assertSame(1, $sub1150['level']); + + $part1160 = collect($sub1150['children'])->firstWhere('material_code', 'PART-1160'); + $this->assertSame(2, $part1160['level']); + $this->assertSame([], $part1160['children']); + } + + // ── Recursive requirements ─────────────────────────────────────────────── + + public function test_recursive_explosion_returns_correct_leaf_quantities(): void + { + $leaves = $this->byCode($this->explosion->leafRequirements($this->threeLevelStructure(), 10)); + + // 10 assemblies → 20 subassemblies → 60 of SUB-1150 → 300 of PART-1160. + $this->assertSame(60.0, $leaves['PART-1200'], '10 × 6 direct'); + $this->assertSame(80.0, $leaves['PART-1110'], '10 × 2 × 4'); + $this->assertSame(20.0, $leaves['PART-1120'], '10 × 2 × 1'); + $this->assertSame(300.0, $leaves['PART-1160'], '10 × 2 × 3 × 5'); + + // Subassemblies are resolved away — only buyable leaves remain. + $this->assertArrayNotHasKey('SUBASSEMBLY-1100', $leaves); + $this->assertArrayNotHasKey('SUBASSEMBLY-1150', $leaves); + } + + public function test_scrap_compounds_through_levels(): void + { + $parent = $this->template('P'); + $childTemplate = $this->template('C'); + // 10% scrap on the subassembly line, 20% on the part beneath it. + $this->line($parent, $this->subassembly('SUB', $childTemplate), 2, 10); + $this->line($childTemplate, $this->purchased('PART'), 5, 20); + + $leaves = $this->byCode($this->explosion->leafRequirements($parent, 100)); + + // 100 × 2 × 1.10 = 220 subassemblies; 220 × 5 × 1.20 = 1320 parts. + $this->assertSame(1320.0, $leaves['PART']); + } + + public function test_a_component_used_on_several_branches_is_summed_once(): void + { + $parent = $this->template('P'); + $childTemplate = $this->template('C'); + $shared = $this->purchased('SHARED'); + + $this->line($parent, $this->subassembly('SUB', $childTemplate), 2); + $this->line($parent, $shared, 3); // 3 per unit directly + $this->line($childTemplate, $shared, 4); // and 4 per subassembly + + $leaves = $this->explosion->leafRequirements($parent, 10); + $shared = collect($leaves)->where('material_code', 'SHARED'); + + $this->assertCount(1, $shared, 'One accumulated line, not one per path.'); + // 10 × 3 = 30 direct, plus 10 × 2 × 4 = 80 through the subassembly. + $this->assertSame(110.0, (float) $shared->first()['required_qty']); + } + + public function test_a_manufactured_material_without_a_template_is_a_leaf(): void + { + $parent = $this->template('P'); + $orphan = Material::factory()->manufactured()->create([ + 'code' => 'SUB-NO-BOM', + 'material_type_id' => $this->type->id, + ]); + $this->line($parent, $orphan, 2); + + $leaves = $this->byCode($this->explosion->leafRequirements($parent, 5)); + + // Flagged make-in-house but not explodable yet, so it stays a requirement. + $this->assertSame(10.0, $leaves['SUB-NO-BOM']); + } + + // ── Circular references ────────────────────────────────────────────────── + + public function test_a_material_produced_by_the_template_itself_is_rejected(): void + { + $template = $this->template('SELF'); + $self = $this->subassembly('SELF-1', $template); + + $this->expectException(ValidationException::class); + app(BomService::class)->addItem($template, ['material_id' => $self->id, 'quantity_per_unit' => 1]); + } + + public function test_an_indirect_cycle_is_rejected(): void + { + // A consumes B; adding A to B's template would close the loop A→B→A. + $tA = $this->template('A'); + $tB = $this->template('B'); + $subA = $this->subassembly('SUB-A', $tA); + $subB = $this->subassembly('SUB-B', $tB); + + $this->line($tA, $subB, 1); + + try { + app(BomService::class)->addItem($tB, ['material_id' => $subA->id, 'quantity_per_unit' => 1]); + $this->fail('Expected the circular reference to be rejected.'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('material_id', $e->errors()); + } + + $this->assertDatabaseMissing('bom_items', ['process_template_id' => $tB->id, 'material_id' => $subA->id]); + } + + public function test_switching_an_existing_line_to_a_cycling_material_is_rejected(): void + { + $tA = $this->template('A'); + $tB = $this->template('B'); + $subA = $this->subassembly('SUB-A', $tA); + $subB = $this->subassembly('SUB-B', $tB); + + $this->line($tA, $subB, 1); + $harmless = $this->line($tB, $this->purchased('PART'), 1); + + $this->expectException(ValidationException::class); + app(BomService::class)->updateItem($harmless, ['material_id' => $subA->id]); + } + + public function test_a_template_that_consumes_a_material_cannot_also_produce_it(): void + { + $template = $this->template('T'); + $part = $this->purchased('PART'); + $this->line($template, $part, 1); + + // The mirror entry point: flipping the item master rather than the BOM. + $this->expectException(ValidationException::class); + $part->update(['is_manufactured' => true, 'producing_process_template_id' => $template->id]); + } + + public function test_deeply_nested_but_acyclic_structures_are_allowed(): void + { + $templates = collect(range(0, 4))->map(fn ($i) => $this->template("L{$i}")); + + // L0 → L1 → L2 → L3 → L4(leaf part), 2 of each at every level. + foreach ($templates as $i => $template) { + if ($i === $templates->count() - 1) { + $this->line($template, $this->purchased('LEAF'), 2); + + continue; + } + $this->line($template, $this->subassembly("SUB-{$i}", $templates[$i + 1]), 2); + } + + $leaves = $this->byCode($this->explosion->leafRequirements($templates[0], 1)); + + $this->assertSame(32.0, $leaves['LEAF'], '2^5 through five levels.'); + } + + // ── Backward compatibility ─────────────────────────────────────────────── + + public function test_a_single_level_bom_explodes_to_the_same_numbers_as_before(): void + { + $template = $this->template('FLAT'); + $this->line($template, $this->purchased('P1'), 3, 10); + $this->line($template, $this->purchased('P2'), 1.5); + + $flat = collect(app(BomService::class)->calculateRequirements($template, 200)) + ->mapWithKeys(fn ($r) => [$r['material_code'] => (float) $r['required_qty']])->all(); + $exploded = $this->byCode($this->explosion->leafRequirements($template, 200)); + + $this->assertSame($flat, $exploded, 'No manufactured components → identical result.'); + $this->assertSame(660.0, $exploded['P1']); + $this->assertSame(300.0, $exploded['P2']); + } + + public function test_existing_materials_default_to_purchased(): void + { + $material = $this->purchased('LEGACY'); + + $this->assertFalse($material->is_manufactured); + $this->assertNull($material->producing_process_template_id); + $this->assertFalse($material->isExplodable()); + } + + // ── MRP ────────────────────────────────────────────────────────────────── + + public function test_mrp_explodes_demand_through_subassemblies(): void + { + $parent = $this->threeLevelStructure(); + + WorkOrder::factory()->create([ + 'product_type_id' => $parent->product_type_id, + 'planned_qty' => 10, + 'status' => WorkOrder::STATUS_PENDING, + 'due_date' => now()->addDays(3), + ]); + + $report = app(NetRequirementsService::class) + ->report(now()->subDay(), now()->addDays(30)); + + $required = collect($report['requirements'])->mapWithKeys( + fn ($r) => [$r['code'] => (float) $r['required_qty']], + ); + + // Same leaf figures the explosion produces — MRP no longer stops at level one. + $this->assertSame(300.0, $required['PART-1160']); + $this->assertSame(80.0, $required['PART-1110']); + $this->assertSame(60.0, $required['PART-1200']); + $this->assertFalse($required->has('SUBASSEMBLY-1100')); + } +} From 400ff293adb568cb36b6fb7f7f36e3783e07cf86 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Sun, 26 Jul 2026 22:48:21 +0200 Subject: [PATCH 2/2] fix(bom): address CodeRabbit review on #211 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isExplodable() only checked the FK, but process templates soft-delete and nullOnDelete doesn't fire on a soft delete — so a soft-deleted producing template left the id set while the relation resolved to null, and explosion descended into a null template (TypeError). It now requires the relation to resolve; such a material falls back to a leaf. Reproduced with a failing test first, which is in this commit. - Added is_manufactured and producing_process_template_id to the materials Electric shape, per the project rule that a new column on a synced table must be allowlisted or the UI never sees it. Guarded by a test. Not applied as suggested: CodeRabbit wanted the four material Form Requests to reject a producing template when is_manufactured is false. That combination is meaningful — a make-or-buy item can be bought for now while its routing stays on file — and explosion requires both halves, so neither on its own yields an inconsistent state. Documented the intent in the migration and locked it with a test instead of adding validation that would break the dual-source case. Refs #178 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YbLa6GeJZACX3tFCE7iNJq --- backend/app/Models/Material.php | 14 ++++- backend/app/Sync/ShapeRegistry.php | 2 +- ...0_add_manufacturing_to_materials_table.php | 13 +++-- .../Feature/Material/HierarchicalBomTest.php | 55 +++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/backend/app/Models/Material.php b/backend/app/Models/Material.php index 2e4081a0..ff8abbb7 100644 --- a/backend/app/Models/Material.php +++ b/backend/app/Models/Material.php @@ -147,12 +147,20 @@ public function producingTemplate(): BelongsTo } /** - * Made in-house *and* explodable — the pair that lets a BOM line descend a - * level. `is_manufactured` alone doesn't, since the template may be missing. + * 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; + return $this->is_manufactured + && $this->producing_process_template_id !== null + && $this->producingTemplate !== null; } public function scopeActive($query) diff --git a/backend/app/Sync/ShapeRegistry.php b/backend/app/Sync/ShapeRegistry.php index c990718f..e63e330c 100644 --- a/backend/app/Sync/ShapeRegistry.php +++ b/backend/app/Sync/ShapeRegistry.php @@ -151,7 +151,7 @@ class ShapeRegistry ], 'materials' => [ 'table' => 'materials', - 'columns' => ['id', 'code', 'name', 'description', 'material_type_id', 'unit_of_measure', 'tracking_type', 'default_scrap_percentage', 'external_code', 'external_system', 'stock_quantity', 'is_active', 'custom_fields', 'created_at', 'updated_at'], + 'columns' => ['id', 'code', 'name', 'description', 'material_type_id', 'unit_of_measure', 'tracking_type', 'is_manufactured', 'producing_process_template_id', 'default_scrap_percentage', 'external_code', 'external_system', 'stock_quantity', 'is_active', 'custom_fields', 'created_at', 'updated_at'], ], // Admin-defined custom-field schema. Tenant-scoped (global defs on "g"). diff --git a/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php b/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php index c85371ef..274c4f33 100644 --- a/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php +++ b/backend/database/migrations/2026_07_26_100000_add_manufacturing_to_materials_table.php @@ -27,10 +27,15 @@ public function up(): void $table->boolean('is_manufactured')->default(false)->after('tracking_type') ->comment('Produced in-house (subassembly) rather than purchased'); - // The BOM/routing that produces this material. Nullable even when - // is_manufactured is true: an item can be flagged as made in-house - // before its process template exists. Explosion treats such a - // material as a leaf until the template is set. + // The BOM/routing that produces this material. + // + // The two columns are deliberately independent rather than one + // implying the other. Nullable while is_manufactured is true: an + // item can be flagged as made in-house before its template exists. + // Set while is_manufactured is false: a make-or-buy item can be + // bought for now while its routing stays on file. Explosion needs + // both (see Material::isExplodable), so either half on its own + // simply leaves the material a leaf — never an inconsistent state. $table->foreignId('producing_process_template_id')->nullable() ->after('is_manufactured') ->constrained('process_templates') diff --git a/backend/tests/Feature/Material/HierarchicalBomTest.php b/backend/tests/Feature/Material/HierarchicalBomTest.php index bf433221..57d36e78 100644 --- a/backend/tests/Feature/Material/HierarchicalBomTest.php +++ b/backend/tests/Feature/Material/HierarchicalBomTest.php @@ -210,6 +210,61 @@ public function test_a_manufactured_material_without_a_template_is_a_leaf(): voi $this->assertSame(10.0, $leaves['SUB-NO-BOM']); } + public function test_a_soft_deleted_producing_template_makes_the_material_a_leaf(): void + { + $parent = $this->template('P'); + $childTemplate = $this->template('C'); + $sub = $this->subassembly('SUB', $childTemplate); + $this->line($parent, $sub, 2); + $this->line($childTemplate, $this->purchased('PART'), 5); + + // Soft delete leaves the FK pointing at a row the relation won't resolve + // (nullOnDelete only fires on a hard delete), so explosion must not try + // to descend into a template it can't load. + $childTemplate->delete(); + + $this->assertFalse($sub->fresh()->isExplodable()); + + $leaves = $this->byCode($this->explosion->leafRequirements($parent, 10)); + + $this->assertSame(20.0, $leaves['SUB'], 'Falls back to a requirement for the subassembly itself.'); + $this->assertArrayNotHasKey('PART', $leaves); + } + + public function test_a_producing_template_without_the_manufactured_flag_stays_purchased(): void + { + $parent = $this->template('P'); + $childTemplate = $this->template('C'); + $this->line($childTemplate, $this->purchased('PART'), 5); + + // Make-or-buy item: the routing is on file but the part is bought for + // now. Explosion needs both halves, so this is a leaf, not a subassembly. + $boughtForNow = Material::factory()->create([ + 'code' => 'DUAL-SOURCE', + 'material_type_id' => $this->type->id, + 'is_manufactured' => false, + 'producing_process_template_id' => $childTemplate->id, + ]); + $this->line($parent, $boughtForNow, 2); + + $this->assertFalse($boughtForNow->isExplodable()); + + $leaves = $this->byCode($this->explosion->leafRequirements($parent, 10)); + + $this->assertSame(20.0, $leaves['DUAL-SOURCE']); + $this->assertArrayNotHasKey('PART', $leaves); + } + + public function test_new_columns_are_exposed_to_the_materials_shape(): void + { + // A column missing from the allowlist never reaches the browser, so the + // make-or-buy flag would be invisible to any UI built on the shape. + $columns = app(\App\Sync\ShapeRegistry::class)->find('materials')->columns(); + + $this->assertContains('is_manufactured', $columns); + $this->assertContains('producing_process_template_id', $columns); + } + // ── Circular references ────────────────────────────────────────────────── public function test_a_material_produced_by_the_template_itself_is_rejected(): void