*/ public function explodeBOM(int|string $bomId): array { $bom = Bom::with(['lines.itemMaster', 'lineRevision'])->findOrFail((int) $bomId); return $this->liveCosting->analyze($bom, 1.0); } public function run( int $businessId, Bom $bom, LineRevision $revision, float $plannedQty = 1, ?int $locationId = null, bool $createPurchaseOrders = true, bool $createWorkOrder = true, ?int $supplierContactId = null, bool $forceMrp = false ): MrpRun { $this->connectionGate->assertMrpAllowed($bom, $plannedQty, $forceMrp); return DB::transaction(function () use ($businessId, $bom, $revision, $plannedQty, $locationId, $createPurchaseOrders, $createWorkOrder, $supplierContactId) { $analysis = $this->liveCosting->analyze($bom, $plannedQty, $locationId); $analysis = $this->applySupplyChainDemandToAnalysis($businessId, $analysis); $runCode = 'MRP-'.now()->format('Ymd-His'); $purchaseOrderIds = []; if ($createPurchaseOrders && ! empty($analysis['shortages'])) { $purchaseOrderIds = $this->createPurchaseOrdersForShortages( $businessId, $analysis['shortages'], $locationId, $supplierContactId ); } $workOrder = null; if ($createWorkOrder) { $workOrder = $this->createProductionWorkOrder($businessId, $revision, $bom, $plannedQty, $locationId); } $run = MrpRun::create([ 'business_id' => $businessId, 'line_revision_id' => $revision->id, 'bom_id' => $bom->id, 'run_code' => $runCode, 'planned_quantity' => $plannedQty, 'location_id' => $locationId, 'status' => 'completed', 'shortage_analysis' => $analysis, 'purchase_order_ids' => $purchaseOrderIds, 'work_order_id' => $workOrder?->id, 'created_by' => ActorResolver::id($businessId), 'notes' => __('industrialengineering::lang.mrp_auto_run'), ]); $this->notifySupplyChainAfterMrpRun($run); return $run; }); } protected function notifySupplyChainAfterMrpRun(MrpRun $run): void { try { $moduleUtil = app(\App\Utils\ModuleUtil::class); if ($moduleUtil->isModuleInstalled('SupplyChain')) { $moduleUtil->getModuleData('after_mrp_run', $run, ['SupplyChain']); } } catch (\Throwable $e) { \Log::warning('MRP SupplyChain hook failed: '.$e->getMessage()); } } /** * @param array $analysis * @return array */ protected function applySupplyChainDemandToAnalysis(int $businessId, array $analysis): array { try { if (! class_exists(\Modules\SupplyChain\Services\SupplyChainIntegrationService::class)) { return $analysis; } $moduleUtil = app(\App\Utils\ModuleUtil::class); if (! $moduleUtil->isModuleInstalled('SupplyChain')) { return $analysis; } return app(\Modules\SupplyChain\Services\SupplyChainIntegrationService::class) ->enrichMrpAnalysis($businessId, $analysis); } catch (\Throwable $e) { \Log::warning('MRP SupplyChain demand enrichment failed: '.$e->getMessage()); return $analysis; } } /** * @param array> $shortages * @return array */ public function createPurchaseOrdersForShortages( int $businessId, array $shortages, ?int $locationId, ?int $defaultSupplierId = null ): array { $locationId = $locationId ?? BusinessLocation::where('business_id', $businessId)->value('id'); if (! $locationId) { throw new \RuntimeException(__('industrialengineering::lang.no_business_location')); } $supplierCache = $this->buildSupplierCache($businessId, $shortages, $defaultSupplierId); $grouped = collect($shortages) ->filter(fn ($s) => ! empty($s['variation_id']) && (float) $s['shortage_qty'] > 0) ->groupBy(fn ($s) => $this->resolveSupplierIdFromCache( (int) $s['item_master_id'], $defaultSupplierId, $supplierCache )); $createdIds = []; foreach ($grouped as $supplierId => $items) { if (! $supplierId) { foreach ($items as $item) { $this->createProcurementTask($businessId, $item); } continue; } $transaction = $this->createPurchaseOrderTransaction( $businessId, (int) $supplierId, (int) $locationId, $items->all() ); $createdIds[] = $transaction->id; foreach ($items as $item) { ProcurementTask::create([ 'business_id' => $businessId, 'item_master_id' => $item['item_master_id'], 'purchase_requisition_id' => $transaction->id, 'task_type' => 'create_po', 'title' => __('industrialengineering::lang.po_line_for', ['item' => $item['item_name']]), 'status' => 'completed', 'assigned_to' => ActorResolver::id($businessId), 'completed_at' => now(), 'notes' => 'MRP auto-generated PO #'.$transaction->ref_no, ]); } } return $createdIds; } public function createProductionWorkOrder( int $businessId, LineRevision $revision, Bom $mbom, float $plannedQty, ?int $locationId ): ManufacturingWorkOrder { $releasePackage = $revision->releasePackages()->where('status', 'released')->latest('id')->first(); if (! $releasePackage) { $releasePackage = ReleasePackage::create([ 'business_id' => $businessId, 'line_revision_id' => $revision->id, 'mbom_id' => $mbom->id, 'package_code' => 'MRP-REL-'.now()->format('YmdHis'), 'name' => $revision->name.' — MRP Auto Release', 'status' => 'released', 'mbom_snapshot' => $this->bomService->snapshot($mbom), 'released_by' => ActorResolver::id($businessId), 'released_at' => now(), ]); } $woNumber = $this->nextWorkOrderNumber($businessId); return ManufacturingWorkOrder::create([ 'business_id' => $businessId, 'release_package_id' => $releasePackage->id, 'line_revision_id' => $revision->id, 'location_id' => $locationId, 'work_order_number' => $woNumber, 'planned_quantity' => $plannedQty, 'status' => 'planned', 'bom_snapshot' => $this->bomService->snapshot($mbom), 'routing_snapshot' => $this->safeRoutingSnapshot($revision), 'assigned_to' => ActorResolver::id($businessId), ]); } protected function createPurchaseOrderTransaction( int $businessId, int $supplierId, int $locationId, array $items ): Transaction { $currency = $this->transactionUtil->purchaseCurrencyDetails($businessId); $purchases = []; $totalBeforeTax = 0; $variationIds = collect($items)->pluck('variation_id')->filter()->unique()->values()->all(); $variations = Variation::with('product')->whereIn('id', $variationIds)->get()->keyBy('id'); foreach ($items as $row) { $variation = $variations->get($row['variation_id']); if (! $variation) { continue; } $qty = (float) $row['shortage_qty']; $unitCost = (float) $row['live_unit_cost']; $lineTotal = $qty * $unitCost; $totalBeforeTax += $lineTotal; $purchases[] = [ 'product_id' => $variation->product_id, 'product_unit_id' => $variation->product->unit_id, 'variation_id' => $variation->id, 'quantity' => $qty, 'pp_without_discount' => $unitCost, 'purchase_price' => $unitCost, 'purchase_price_inc_tax' => $unitCost, 'item_tax' => 0, 'tax_id' => null, 'purchase_line_tax_id' => null, 'discount_percent' => 0, ]; } if (empty($purchases)) { throw new \RuntimeException(__('industrialengineering::lang.no_purchasable_shortages')); } $refCount = $this->productUtil->setAndGetReferenceCount('purchase_order'); $refNo = $this->productUtil->generateReferenceNumber('purchase_order', $refCount); $transaction = Transaction::create([ 'business_id' => $businessId, 'location_id' => $locationId, 'type' => 'purchase_order', 'status' => 'ordered', 'contact_id' => $supplierId, 'transaction_date' => now()->toDateTimeString(), 'total_before_tax' => $totalBeforeTax, 'tax_amount' => 0, 'discount_type' => 'fixed', 'discount_amount' => 0, 'final_total' => $totalBeforeTax, 'created_by' => ActorResolver::id($businessId), 'ref_no' => $refNo, 'additional_notes' => __('industrialengineering::lang.mrp_po_note'), ]); $this->productUtil->createOrUpdatePurchaseLines($transaction, $purchases, $currency, false); $this->transactionUtil->activityLog($transaction, 'added'); return $transaction->fresh(['purchase_lines']); } protected function resolveSupplierId(int $itemMasterId, ?int $defaultSupplierId): ?int { return $this->resolveSupplierIdFromCache($itemMasterId, $defaultSupplierId, []); } /** * @param array $cache */ protected function resolveSupplierIdFromCache(int $itemMasterId, ?int $defaultSupplierId, array $cache): ?int { if (array_key_exists($itemMasterId, $cache)) { return $cache[$itemMasterId]; } if ($defaultSupplierId) { return $defaultSupplierId; } $spn = SupplierPartNumber::with('supplier') ->where('item_master_id', $itemMasterId) ->where('is_approved', true) ->orderByDesc('is_preferred') ->first(); if ($spn?->supplier?->contact_id) { return (int) $spn->supplier->contact_id; } $businessId = ItemMaster::where('id', $itemMasterId)->value('business_id'); return Contact::where('type', 'supplier') ->where('business_id', $businessId) ->value('id'); } /** * @param array> $shortages * @return array */ protected function buildSupplierCache(int $businessId, array $shortages, ?int $defaultSupplierId): array { if ($defaultSupplierId) { return []; } $itemMasterIds = collect($shortages)->pluck('item_master_id')->filter()->unique()->values()->all(); $cache = array_fill_keys($itemMasterIds, null); $preferredSuppliers = SupplierPartNumber::with('supplier') ->whereIn('item_master_id', $itemMasterIds) ->where('is_approved', true) ->orderByDesc('is_preferred') ->get() ->groupBy('item_master_id'); foreach ($itemMasterIds as $itemMasterId) { $spn = $preferredSuppliers->get($itemMasterId)?->first(); if ($spn?->supplier?->contact_id) { $cache[$itemMasterId] = (int) $spn->supplier->contact_id; } } $fallbackSupplierId = Contact::where('type', 'supplier') ->where('business_id', $businessId) ->value('id'); foreach ($cache as $itemMasterId => $supplierId) { if ($supplierId === null && $fallbackSupplierId) { $cache[$itemMasterId] = (int) $fallbackSupplierId; } } return $cache; } protected function nextWorkOrderNumber(int $businessId): string { $prefix = 'WO-'.now()->format('Ymd').'-'; $latest = ManufacturingWorkOrder::forBusiness($businessId) ->where('work_order_number', 'like', $prefix.'%') ->lockForUpdate() ->orderByDesc('id') ->value('work_order_number'); $sequence = 1; if ($latest && preg_match('/-(\d+)$/', (string) $latest, $matches)) { $sequence = ((int) $matches[1]) + 1; } return $prefix.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT); } protected function createProcurementTask(int $businessId, array $item): void { $this->sourcingService->createSourcingCase( $businessId, (int) $item['item_master_id'], (float) $item['shortage_qty'], null, now()->addDays(14)->toDateString(), 'high' ); } protected function safeRoutingSnapshot(LineRevision $revision): array { try { return $revision->routingOperations()->with('workCenter')->get()->values()->all(); } catch (\Throwable) { return []; } } }