lines() ->with(['itemMaster', 'alternates.alternateItem', 'children']) ->when($parentLineId === null, fn ($q) => $q->whereNull('parent_line_id')) ->when($parentLineId !== null, fn ($q) => $q->where('parent_line_id', $parentLineId)) ->orderBy('sort_order') ->get(); $result = collect(); foreach ($lines as $line) { $qty = (float) $line->quantity * $parentQty; $result->push([ 'bom_line_id' => $line->id, 'parent_line_id' => $line->parent_line_id, 'item_master_id' => $line->item_master_id, 'item_code' => $line->itemMaster?->item_code, 'item_name' => $line->itemMaster?->name, 'position_code' => $line->position_code, 'quantity' => $qty, 'unit_cost' => (float) ($line->standard_cost ?: $line->itemMaster?->standard_cost ?? 0), 'extended_cost' => $qty * (float) ($line->standard_cost ?: $line->itemMaster?->standard_cost ?? 0), 'alternate_group' => $line->alternate_group, 'alternates' => $line->alternates->map(fn ($alt) => [ 'item_id' => $alt->alternate_item_id, 'item_code' => $alt->alternateItem?->item_code, 'item_name' => $alt->alternateItem?->name, 'priority' => $alt->priority, 'cost_delta' => (float) $alt->cost_delta, 'is_approved' => $alt->is_approved, ])->values(), 'is_serviceable' => $line->is_serviceable, 'lead_time_days' => $line->lead_time_days ?: $line->itemMaster?->lead_time_days ?? 0, ]); $children = $this->explode($bom, $qty, $line->id); $result = $result->merge($children); } return $result; } /** * Explode BOM to leaf components only (no double-counting assemblies). */ public function explodeLeaves(Bom $bom, float $parentQty = 1.0, ?int $parentLineId = null): Collection { $lines = $bom->lines() ->with(['itemMaster', 'children']) ->when($parentLineId === null, fn ($q) => $q->whereNull('parent_line_id')) ->when($parentLineId !== null, fn ($q) => $q->where('parent_line_id', $parentLineId)) ->orderBy('sort_order') ->get(); $result = collect(); foreach ($lines as $line) { $qty = (float) $line->quantity * $parentQty; if ($line->children()->exists()) { $result = $result->merge($this->explodeLeaves($bom, $qty, $line->id)); } else { $result->push([ 'bom_line_id' => $line->id, 'item_master_id' => $line->item_master_id, 'item_code' => $line->itemMaster?->item_code, 'item_name' => $line->itemMaster?->name, 'quantity' => $qty, 'unit_cost' => (float) ($line->standard_cost ?: $line->itemMaster?->standard_cost ?? 0), ]); } } return $result; } /** * Build hierarchical tree for UI display. */ public function buildTree(Bom $bom): array { $bom->load(['rootLines.itemMaster', 'rootLines.alternates.alternateItem', 'rootLines.children']); return $bom->rootLines->map(fn (BomLine $line) => $this->lineToTreeNode($line))->values()->all(); } protected function lineToTreeNode(BomLine $line): array { $line->loadMissing(['itemMaster', 'alternates.alternateItem', 'children.itemMaster']); return [ 'id' => $line->id, 'item_code' => $line->itemMaster?->item_code, 'item_name' => $line->itemMaster?->name, 'position_code' => $line->position_code, 'quantity' => (float) $line->quantity, 'alternate_group' => $line->alternate_group, 'alternates' => $line->alternates, 'children' => $line->children->map(fn ($child) => $this->lineToTreeNode($child))->values()->all(), ]; } /** * Create SBOM from MBOM by filtering serviceable lines. */ public function deriveServiceBom(Bom $mbom, int $businessId, string $bomCode): Bom { $sbom = Bom::create([ 'business_id' => $businessId, 'line_revision_id' => $mbom->line_revision_id, 'item_master_id' => $mbom->item_master_id, 'bom_type' => 'sbom', 'bom_code' => $bomCode, 'name' => $mbom->name.' (Service)', 'revision_code' => $mbom->revision_code, 'status' => 'draft', ]); $this->copyServiceableLines($mbom, $sbom, null, null); return $sbom->fresh(['lines']); } protected function copyServiceableLines(Bom $source, Bom $target, ?int $sourceParentId, ?int $targetParentId): void { $lines = BomLine::where('bom_id', $source->id) ->where('parent_line_id', $sourceParentId) ->where('is_serviceable', true) ->orderBy('sort_order') ->get(); foreach ($lines as $line) { $newLine = $line->replicate(['bom_id', 'parent_line_id']); $newLine->bom_id = $target->id; $newLine->parent_line_id = $targetParentId; $newLine->save(); foreach ($line->alternates as $alt) { $alt->replicate(['bom_line_id'])->fill(['bom_line_id' => $newLine->id])->save(); } $this->copyServiceableLines($source, $target, $line->id, $newLine->id); } } public function snapshot(Bom $bom): array { return [ 'bom_id' => $bom->id, 'bom_code' => $bom->bom_code, 'bom_type' => $bom->bom_type, 'revision_code' => $bom->revision_code, 'tree' => $this->buildTree($bom), 'flat' => $this->explode($bom)->values()->all(), 'snapshot_at' => now()->toIso8601String(), ]; } }