bomService->explodeLeaves($bom, $plannedQty); $lines = []; $totals = [ 'standard_material' => 0, 'live_material' => 0, 'linked_items' => 0, 'shortage_value' => 0, ]; $aggregated = $flat->groupBy('item_master_id')->map(function ($group) { $first = $group->first(); return [ 'bom_line_id' => $first['bom_line_id'], 'item_master_id' => $first['item_master_id'], 'item_code' => $first['item_code'], 'item_name' => $first['item_name'], 'quantity' => $group->sum('quantity'), 'unit_cost' => $first['unit_cost'], ]; })->values(); $itemIds = $aggregated->pluck('item_master_id')->filter()->unique()->values()->all(); $items = ItemMaster::query() ->whereIn('id', $itemIds) ->get() ->keyBy('id'); $variationIds = $items->pluck('variation_id')->filter()->unique()->values()->all(); $variations = Variation::query() ->whereIn('id', $variationIds) ->get() ->keyBy('id'); $stockByItem = $this->preloadStock($bom->business_id, $itemIds, $locationId); foreach ($aggregated as $row) { $item = $items->get($row['item_master_id']); $live = $this->resolveLiveCost($item, $variations); $stock = $stockByItem[$row['item_master_id']] ?? ['available' => 0.0]; $requiredQty = (float) $row['quantity']; $shortage = max(0, $requiredQty - $stock['available']); $standardExtended = $requiredQty * (float) $row['unit_cost']; $liveExtended = $requiredQty * $live['unit_cost']; $shortageExtended = $shortage * $live['unit_cost']; if ($item?->variation_id) { $totals['linked_items']++; } $totals['standard_material'] += $standardExtended; $totals['live_material'] += $liveExtended; $totals['shortage_value'] += $shortageExtended; $lines[] = [ 'bom_line_id' => $row['bom_line_id'], 'item_master_id' => $row['item_master_id'], 'item_code' => $row['item_code'], 'item_name' => $row['item_name'], 'item_type' => $item?->item_type, 'required_qty' => $requiredQty, 'stock_available' => $stock['available'], 'shortage_qty' => $shortage, 'standard_unit_cost' => (float) $row['unit_cost'], 'live_unit_cost' => $live['unit_cost'], 'cost_source' => $live['source'], 'standard_extended' => $standardExtended, 'live_extended' => $liveExtended, 'shortage_extended' => $shortageExtended, 'variation_id' => $item?->variation_id, 'product_id' => $item?->variation_id ? ($variations->get($item->variation_id)?->product_id) : null, 'erp_label' => $this->erpItemLink->erpLabel($item), 'is_purchasable' => (bool) $item?->variation_id, ]; } $leafLines = $this->filterLeafRequirements($lines); return [ 'planned_quantity' => $plannedQty, 'line_count' => count($lines), 'leaf_count' => count($leafLines), 'totals' => $totals, 'delta' => $totals['live_material'] - $totals['standard_material'], 'lines' => $lines, 'leaf_lines' => $leafLines, 'shortages' => collect($leafLines)->where('shortage_qty', '>', 0)->values()->all(), ]; } /** * Leaf purchasable lines only (for MRP / PO generation). */ public function filterLeafRequirements(array $lines): array { return collect($lines) ->filter(fn ($l) => in_array($l['item_type'], ['part', 'consumable', 'tool'], true) || $l['is_purchasable']) ->values() ->all(); } public function compareStandardVsLive(Bom $bom, float $plannedQty = 1.0, ?int $locationId = null): array { $analysis = $this->analyze($bom, $plannedQty, $locationId); return [ 'standard_total' => $analysis['totals']['standard_material'], 'live_total' => $analysis['totals']['live_material'], 'delta' => $analysis['delta'], 'delta_percent' => $analysis['totals']['standard_material'] > 0 ? round(($analysis['delta'] / $analysis['totals']['standard_material']) * 100, 2) : 0, 'shortage_value' => $analysis['totals']['shortage_value'], 'shortage_count' => count($analysis['shortages']), ]; } /** * @param array $itemIds * @return array */ protected function preloadStock(int $businessId, array $itemIds, ?int $locationId): array { $stockByItem = array_fill_keys($itemIds, ['available' => 0.0]); foreach ($itemIds as $itemId) { $stock = $this->sourcingService->findStock($businessId, $itemId); if ($locationId) { $stock = $stock->where('location_id', $locationId); } $stockByItem[$itemId]['available'] = (float) $stock->sum('quantity'); } return $stockByItem; } protected function resolveLiveCost(?ItemMaster $item, Collection $variations): array { if (! $item) { return ['unit_cost' => 0, 'source' => 'none']; } if ($item->variation_id) { $variation = $variations->get($item->variation_id); if ($variation && (float) $variation->dpp_inc_tax > 0) { return ['unit_cost' => (float) $variation->dpp_inc_tax, 'source' => 'erp_purchase_price']; } } $supplierCost = $this->sourcingService->resolveItemCost($item); return [ 'unit_cost' => $supplierCost, 'source' => $supplierCost > 0 ? 'supplier_or_standard' : 'standard', ]; } }