load(['rootLines.itemMaster.variation', 'rootLines.itemMaster.product']); return $bom->rootLines->map(fn (BomLine $line) => $this->lineToEditorNode($line))->values()->all(); } public function lineToEditorNode(BomLine $line): array { $line->loadMissing(['itemMaster.variation', 'itemMaster.product', 'children']); $item = $line->itemMaster; return [ 'id' => $line->id, 'bom_line_id' => $line->id, 'item_master_id' => $line->item_master_id, 'item_code' => $item?->item_code, 'item_name' => $item?->name, 'item_type' => $item?->item_type, 'position_code' => $line->position_code, 'find_number' => $line->find_number, 'quantity' => (float) $line->quantity, 'line_type' => $line->line_type, 'is_serviceable' => (bool) $line->is_serviceable, 'standard_cost' => (float) ($line->standard_cost ?: $item?->standard_cost ?? 0), 'sort_order' => (int) $line->sort_order, 'variation_id' => $item?->variation_id, 'product_id' => $item?->product_id, 'erp_label' => $this->erpItemLink->erpLabel($item), 'is_assembly' => in_array($item?->item_type, ['assembly', 'subassembly', 'device'], true), 'children' => $line->children->map(fn (BomLine $child) => $this->lineToEditorNode($child))->values()->all(), ]; } /** * Add a BOM node under any parent (null = root). Creates or links ItemMaster. */ public function addNode(Bom $bom, ?int $parentLineId, array $data): BomLine { return DB::transaction(function () use ($bom, $parentLineId, $data) { $this->assertSameBomParent($bom, $parentLineId); $item = $this->resolveItemForNode($bom->business_id, $data); $parentLine = $parentLineId ? BomLine::findOrFail($parentLineId) : null; if ($parentLine) { $this->assertCanContainChildren($parentLine); } $siblingQuery = $bom->lines()->when($parentLineId, fn ($q) => $q->where('parent_line_id', $parentLineId), fn ($q) => $q->whereNull('parent_line_id')); return BomLine::create([ 'business_id' => $bom->business_id, 'bom_id' => $bom->id, 'parent_line_id' => $parentLineId, 'item_master_id' => $item->id, 'quantity' => (float) ($data['quantity'] ?? 1), 'position_code' => $data['position_code'] ?? null, 'find_number' => $data['find_number'] ?? null, 'line_type' => $data['line_type'] ?? 'component', 'is_serviceable' => $data['is_serviceable'] ?? true, 'standard_cost' => $data['standard_cost'] ?? $item->standard_cost ?? 0, 'sort_order' => (int) $siblingQuery->max('sort_order') + 10, 'notes' => $data['notes'] ?? null, ]); }); } public function updateNode(BomLine $line, array $data): BomLine { return DB::transaction(function () use ($line, $data) { if (isset($data['quantity'])) { $line->quantity = (float) $data['quantity']; } foreach (['position_code', 'find_number', 'line_type', 'notes'] as $field) { if (array_key_exists($field, $data)) { $line->{$field} = $data[$field]; } } if (isset($data['is_serviceable'])) { $line->is_serviceable = (bool) $data['is_serviceable']; } if (isset($data['standard_cost'])) { $line->standard_cost = (float) $data['standard_cost']; } $line->save(); if (! empty($data['item_master_id']) && (int) $data['item_master_id'] !== (int) $line->item_master_id) { $item = ItemMaster::forBusiness($line->business_id)->findOrFail($data['item_master_id']); $line->update(['item_master_id' => $item->id]); } if (! empty($data['variation_id']) || ! empty($data['product_id'])) { $item = $line->itemMaster; $this->erpItemLink->linkItem($item, $data); } return $line->fresh(['itemMaster']); }); } public function moveNode(BomLine $line, ?int $newParentId, int $sortOrder = 0): BomLine { return DB::transaction(function () use ($line, $newParentId, $sortOrder) { if ($newParentId === $line->id) { throw new InvalidArgumentException(__('industrialengineering::lang.cannot_move_to_self')); } if ($newParentId && $this->isDescendant($line->id, $newParentId)) { throw new InvalidArgumentException(__('industrialengineering::lang.cannot_move_to_descendant')); } $this->assertSameBomParent($line->bom, $newParentId); if ($newParentId) { $newParent = BomLine::findOrFail($newParentId); $this->assertCanContainChildren($newParent); } $line->update([ 'parent_line_id' => $newParentId, 'sort_order' => $sortOrder, ]); return $line->fresh(); }); } /** * Apply nestable plugin JSON output to BOM structure. * * @param array $nodes */ public function applyNestable(Bom $bom, array $nodes, ?int $parentId = null): void { DB::transaction(function () use ($bom, $nodes, $parentId) { foreach ($nodes as $index => $node) { $lineId = (int) ($node['id'] ?? 0); $line = BomLine::where('bom_id', $bom->id)->findOrFail($lineId); if ($parentId && $this->isDescendant($lineId, $parentId)) { throw new InvalidArgumentException(__('industrialengineering::lang.cannot_move_to_descendant')); } if ($parentId) { $parentLine = BomLine::where('bom_id', $bom->id)->findOrFail($parentId); $this->assertCanContainChildren($parentLine); } $line->update([ 'parent_line_id' => $parentId, 'sort_order' => ($index + 1) * 10, ]); if (! empty($node['children'])) { $this->applyNestable($bom, $node['children'], $lineId); } } }); } public function deleteNode(BomLine $line): void { DB::transaction(function () use ($line) { $this->deleteRecursive($line); }); } /** * Duplicate a subtree (same item references) under a new parent. * * @return array{root_line_id:int, created_count:int} */ public function duplicateSubtree(Bom $bom, BomLine $sourceLine, ?int $targetParentLineId): array { if ((int) $sourceLine->bom_id !== (int) $bom->id) { throw new InvalidArgumentException(__('industrialengineering::lang.parent_bom_mismatch')); } $this->assertSameBomParent($bom, $targetParentLineId); if ($targetParentLineId) { $targetParent = BomLine::findOrFail($targetParentLineId); $this->assertCanContainChildren($targetParent); if ($this->isDescendant($sourceLine->id, $targetParentLineId)) { throw new InvalidArgumentException(__('industrialengineering::lang.cannot_paste_into_descendant')); } } return DB::transaction(function () use ($bom, $sourceLine, $targetParentLineId) { $sourceLine->load(['children.itemMaster', 'itemMaster']); $createdCount = 0; $root = $this->duplicateLineRecursive($bom, $sourceLine, $targetParentLineId, $createdCount); return [ 'root_line_id' => $root->id, 'created_count' => $createdCount, ]; }); } protected function duplicateLineRecursive(Bom $bom, BomLine $source, ?int $parentLineId, int &$createdCount): BomLine { $siblingQuery = $bom->lines()->when( $parentLineId, fn ($q) => $q->where('parent_line_id', $parentLineId), fn ($q) => $q->whereNull('parent_line_id') ); $newLine = BomLine::create([ 'business_id' => $bom->business_id, 'bom_id' => $bom->id, 'parent_line_id' => $parentLineId, 'item_master_id' => $source->item_master_id, 'quantity' => (float) $source->quantity, 'position_code' => $source->position_code, 'find_number' => $source->find_number, 'line_type' => $source->line_type, 'is_serviceable' => (bool) $source->is_serviceable, 'standard_cost' => (float) $source->standard_cost, 'sort_order' => (int) $siblingQuery->max('sort_order') + 10, 'notes' => $source->notes, ]); $createdCount++; foreach ($source->children as $child) { $child->load(['children.itemMaster', 'itemMaster']); $this->duplicateLineRecursive($bom, $child, $newLine->id, $createdCount); } return $newLine; } protected function deleteRecursive(BomLine $line): void { foreach ($line->children as $child) { $this->deleteRecursive($child); } $line->delete(); } public function isDescendant(int $ancestorId, int $nodeId): bool { $current = BomLine::find($nodeId); while ($current && $current->parent_line_id) { if ((int) $current->parent_line_id === $ancestorId) { return true; } $current = $current->parentLine; } return false; } public function flattenForCost(Bom $bom): Collection { return app(BomService::class)->explode($bom); } protected function assertSameBomParent(Bom $bom, ?int $parentLineId): void { if (! $parentLineId) { return; } $parent = BomLine::findOrFail($parentLineId); if ((int) $parent->bom_id !== (int) $bom->id) { throw new InvalidArgumentException(__('industrialengineering::lang.parent_bom_mismatch')); } } protected function resolveItemForNode(int $businessId, array $data): ItemMaster { if (! empty($data['item_master_id'])) { return ItemMaster::forBusiness($businessId)->findOrFail($data['item_master_id']); } $itemCodes = $this->codeGenerator->resolveCode( 'item', $businessId, $data['item_code'] ?? null, new ItemMaster(), 'item_code' ); $itemData = array_merge($itemCodes, [ 'business_id' => $businessId, 'name' => $data['name'] ?? $itemCodes['item_code'], 'item_type' => $data['item_type'] ?? 'part', 'description' => $data['description'] ?? null, 'standard_cost' => $data['standard_cost'] ?? 0, 'is_serviceable' => $data['is_serviceable'] ?? true, ]); $item = ItemMaster::create($itemData); if (! empty($data['variation_id']) || ! empty($data['product_id'])) { $this->erpItemLink->linkItem($item, $data); } else { $this->autoIntegration->afterItemCreated($item, $data); } return $item->fresh(); } protected function assertCanContainChildren(BomLine $parentLine): void { $parentType = $parentLine->itemMaster?->item_type; if (! in_array($parentType, $this->parentAllowedTypes, true)) { throw new InvalidArgumentException(__('industrialengineering::lang.parent_type_cannot_have_children')); } } }