assertSourceExists($business->id, $sourceType, $sourceId); return MaterialList::firstOrCreate( [ 'business_id' => $business->id, 'source_type' => $sourceType, 'source_id' => $sourceId, ], [ 'destination_location_id' => $this->stockService->resolveDestinationLocationId($business->id), 'status' => 'active', 'created_by' => $user?->id, ] ); } public function updateDestination(MaterialList $list, ?int $locationId): MaterialList { $list->update(['destination_location_id' => $locationId]); return $list->fresh(); } public function addLine(MaterialList $list, array $data): MaterialLine { $sparePartId = $data['spare_part_id'] ?? null; $productId = $data['product_id'] ?? null; $variationId = $data['variation_id'] ?? null; $partName = $data['part_name'] ?? null; if ($sparePartId) { $part = SparePart::where('business_id', $list->business_id)->findOrFail($sparePartId); $productId = $part->product_id; $variationId = $variationId ?: app(SparePartInventoryService::class)->resolveVariationId($part); $partName = $partName ?: $part->name; $data['part_number'] = $data['part_number'] ?? $part->part_number; $data['unit'] = $data['unit'] ?? $part->unit; $data['suggested_supplier_id'] = $data['suggested_supplier_id'] ?? $this->suggestionService->resolveDefaultSupplierId($list->business_id, $variationId, $part->id); } if (! $sparePartId && empty($partName)) { throw new InvalidArgumentException('نام قطعه یا قطعه یدکی الزامی است.'); } $lineType = $data['line_type'] ?? 'supply'; if (! in_array($lineType, ['supply', 'repair'], true)) { $lineType = 'supply'; } return MaterialLine::create([ 'material_list_id' => $list->id, 'business_id' => $list->business_id, 'line_type' => $lineType, 'spare_part_id' => $sparePartId, 'product_id' => $productId, 'variation_id' => $variationId, 'part_name' => $partName, 'part_number' => $data['part_number'] ?? null, 'quantity_required' => $data['quantity_required'] ?? 1, 'unit' => $data['unit'] ?? null, 'suggested_supplier_id' => $data['suggested_supplier_id'] ?? null, 'notes' => $data['notes'] ?? null, 'status' => 'planned', ]); } public function importFromEquipmentBom(MaterialList $list, int $equipmentId): int { Equipment::where('business_id', $list->business_id)->findOrFail($equipmentId); $parts = EquipmentPart::where('business_id', $list->business_id) ->where('equipment_id', $equipmentId) ->where(function ($q) { $q->whereNotNull('spare_part_id')->orWhereNotNull('part_name'); }) ->get(); $count = 0; foreach ($parts as $bom) { if ($bom->spare_part_id) { $exists = $list->lines()->where('spare_part_id', $bom->spare_part_id)->exists(); if ($exists) { continue; } } $this->addLine($list, [ 'spare_part_id' => $bom->spare_part_id, 'part_name' => $bom->part_name, 'part_number' => $bom->part_code, 'quantity_required' => $bom->quantity ?: 1, 'unit' => $bom->unit, ]); $count++; } return $count; } /** * @return Collection> */ public function analyzeList(MaterialList $list): Collection { $list->load(['lines.sparePart', 'lines.suggestedSupplier']); return $list->lines->map(fn (MaterialLine $line) => $this->stockService->analyzeLine( $list->business_id, $line, $list->destination_location_id )); } /** * @return array> */ public function buildPurchaseShortfall(MaterialList $list): array { $items = []; foreach ($this->analyzeList($list) as $analysis) { if ($analysis['shortfall'] <= 0 || ! $analysis['has_product_link']) { continue; } $line = MaterialLine::find($analysis['line_id']); $supplierId = $line->suggested_supplier_id ?? $this->suggestionService->resolveDefaultSupplierId( $list->business_id, $line->variation_id, $line->spare_part_id ); $items[] = array_merge($analysis, [ 'shortfall_qty' => $analysis['shortfall'], 'variation_id' => $line->variation_id, 'product_id' => $line->product_id, 'suggested_supplier_id' => $supplierId, 'suggested_supplier_name' => $this->suggestionService->supplierName($supplierId), 'supplier_suggestions' => $line->variation_id ? $this->suggestionService->suggestSuppliers($list->business_id, (int) $line->variation_id)->all() : [], ]); } return $items; } public function requestTransfer( MaterialList $list, MaterialLine $line, int $sourceLocationId, float $quantity, ?User $user = null ): MaterialProcurement { $ids = $this->stockService->resolveProductIds($line); if (! $ids['product_id'] || ! $ids['variation_id']) { throw new InvalidArgumentException('این قطعه به محصول انبار متصل نیست.'); } $destinationId = $list->destination_location_id ?: $this->stockService->resolveDestinationLocationId($list->business_id); if (! $destinationId) { throw new InvalidArgumentException('انبار مقصد (محل تعمیر) مشخص نشده است.'); } if ($sourceLocationId === $destinationId) { throw new InvalidArgumentException('انبار مبدأ و مقصد نمی‌توانند یکسان باشند.'); } $stock = $this->stockService->stockByLocation($list->business_id, $line); $sourceRow = $stock->firstWhere('location_id', $sourceLocationId); if (! $sourceRow || $sourceRow['qty'] < $quantity) { throw new InvalidArgumentException('موجودی کافی در انبار مبدأ وجود ندارد.'); } $product = Product::where('business_id', $list->business_id)->findOrFail($ids['product_id']); $unitPrice = $this->stockService->getUnitPrice($ids['variation_id'], $sourceLocationId); return DB::transaction(function () use ( $list, $line, $sourceLocationId, $destinationId, $quantity, $ids, $product, $unitPrice, $user ) { $businessId = $list->business_id; $userId = $user?->id ?? auth()->id(); $refCount = $this->productUtil->setAndGetReferenceCount('stock_transfer'); $refNo = $this->productUtil->generateReferenceNumber('stock_transfer', $refCount); $sellData = [ 'business_id' => $businessId, 'location_id' => $sourceLocationId, 'type' => 'sell_transfer', 'status' => 'in_transit', 'payment_status' => 'paid', 'ref_no' => $refNo, 'transaction_date' => now()->toDateTimeString(), 'created_by' => $userId, 'additional_notes' => 'Maintenance material line #'.$line->id, 'final_total' => $unitPrice * $quantity, 'total_before_tax' => $unitPrice * $quantity, 'shipping_charges' => 0, ]; $sellTransfer = Transaction::create($sellData); $purchaseData = array_merge($sellData, [ 'type' => 'purchase_transfer', 'location_id' => $destinationId, 'transfer_parent_id' => $sellTransfer->id, 'status' => 'in_transit', ]); unset($purchaseData['id']); $purchaseTransfer = Transaction::create($purchaseData); $lineArr = [ 'product_id' => $ids['product_id'], 'variation_id' => $ids['variation_id'], 'quantity' => $quantity, 'item_tax' => 0, 'unit_price' => $unitPrice, 'unit_price_inc_tax' => $unitPrice, ]; $this->transactionUtil->createOrUpdateSellLines( $sellTransfer, [$lineArr], $sourceLocationId, false, null, [], false ); $purchaseTransfer->purchase_lines()->create([ 'product_id' => $ids['product_id'], 'variation_id' => $ids['variation_id'], 'quantity' => $quantity, 'purchase_price' => $unitPrice, 'purchase_price_inc_tax' => $unitPrice, 'item_tax' => 0, ]); $line->update(['status' => 'transfer_in_transit']); return MaterialProcurement::create([ 'material_line_id' => $line->id, 'transaction_id' => $sellTransfer->id, 'procurement_type' => 'stock_transfer', 'quantity' => $quantity, 'source_location_id' => $sourceLocationId, 'destination_location_id' => $destinationId, 'status' => 'in_transit', 'meta' => [ 'purchase_transfer_id' => $purchaseTransfer->id, 'ref_no' => $refNo, ], ]); }); } /** * @param array $lineSelections * @return Transaction */ public function createPurchaseRequisition( MaterialList $list, array $lineSelections, ?User $user = null ): Transaction { $destinationId = $list->destination_location_id ?: $this->stockService->resolveDestinationLocationId($list->business_id); if (! $destinationId) { throw new InvalidArgumentException('انبار مقصد مشخص نشده است.'); } $purchaseLines = []; $lineIds = []; foreach ($lineSelections as $sel) { $line = MaterialLine::where('material_list_id', $list->id)->findOrFail($sel['line_id']); $analysis = $this->stockService->analyzeLine($list->business_id, $line, $list->destination_location_id); $qty = $sel['quantity'] ?? $analysis['shortfall']; if ($qty <= 0 || ! $analysis['has_product_link']) { continue; } $supplierId = $sel['supplier_id'] ?? $line->suggested_supplier_id; if ($supplierId) { $line->update(['suggested_supplier_id' => $supplierId]); } $purchaseLines[] = [ 'product_id' => $line->product_id, 'variation_id' => $line->variation_id, 'quantity' => $qty, 'purchase_price_inc_tax' => 0, 'item_tax' => 0, ]; $lineIds[] = $line->id; $line->update(['status' => 'purchase_requested']); } if (empty($purchaseLines)) { throw new InvalidArgumentException('قطعه‌ای برای درخواست خرید وجود ندارد.'); } return DB::transaction(function () use ($list, $purchaseLines, $lineIds, $destinationId, $user) { $refCount = $this->commonUtil->setAndGetReferenceCount('purchase_requisition'); $refNo = $this->commonUtil->generateReferenceNumber('purchase_requisition', $refCount); $transaction = Transaction::create([ 'business_id' => $list->business_id, 'location_id' => $destinationId, 'type' => 'purchase_requisition', 'status' => 'ordered', 'ref_no' => $refNo, 'transaction_date' => now()->toDateTimeString(), 'created_by' => $user?->id ?? auth()->id(), 'additional_notes' => 'Maintenance material list #'.$list->id.' ('.$list->source_type.'#'.$list->source_id.')', ]); $transaction->purchase_lines()->createMany($purchaseLines); foreach ($lineIds as $lineId) { MaterialProcurement::create([ 'material_line_id' => $lineId, 'transaction_id' => $transaction->id, 'procurement_type' => 'purchase_requisition', 'quantity' => 0, 'destination_location_id' => $destinationId, 'status' => 'ordered', ]); } return $transaction; }); } /** * @param array $lineSelections * @return Collection */ public function createPurchaseOrders( MaterialList $list, array $lineSelections, ?User $user = null ): Collection { $destinationId = $list->destination_location_id ?: $this->stockService->resolveDestinationLocationId($list->business_id); if (! $destinationId) { throw new InvalidArgumentException('انبار مقصد مشخص نشده است.'); } $bySupplier = []; foreach ($lineSelections as $sel) { if (empty($sel['supplier_id'])) { throw new InvalidArgumentException('تامین‌کننده برای همه قطعات الزامی است.'); } $line = MaterialLine::where('material_list_id', $list->id)->findOrFail($sel['line_id']); $analysis = $this->stockService->analyzeLine($list->business_id, $line, $list->destination_location_id); $qty = $sel['quantity'] ?? $analysis['shortfall']; if ($qty <= 0 || ! $analysis['has_product_link']) { continue; } $supplierId = (int) $sel['supplier_id']; $unitPrice = $this->stockService->getUnitPrice((int) $line->variation_id, $destinationId, $supplierId); $bySupplier[$supplierId][] = [ 'line' => $line, 'quantity' => $qty, 'unit_price' => $unitPrice, ]; } if (empty($bySupplier)) { throw new InvalidArgumentException('قطعه‌ای برای سفارش خرید وجود ندارد.'); } return DB::transaction(function () use ($list, $bySupplier, $destinationId, $user) { $orders = collect(); $userId = $user?->id ?? auth()->id(); foreach ($bySupplier as $supplierId => $items) { $total = 0; $purchaseLines = []; $linkedLines = []; foreach ($items as $item) { $lineTotal = $item['unit_price'] * $item['quantity']; $total += $lineTotal; $purchaseLines[] = [ 'product_id' => $item['line']->product_id, 'variation_id' => $item['line']->variation_id, 'quantity' => $item['quantity'], 'pp_without_discount' => $item['unit_price'], 'purchase_price' => $item['unit_price'], 'purchase_price_inc_tax' => $item['unit_price'], 'item_tax' => 0, 'discount_percent' => 0, ]; $linkedLines[] = $item['line']; } $refCount = $this->commonUtil->setAndGetReferenceCount('purchase_order'); $refNo = $this->commonUtil->generateReferenceNumber('purchase_order', $refCount); $transaction = Transaction::create([ 'business_id' => $list->business_id, 'location_id' => $destinationId, 'contact_id' => $supplierId, 'type' => 'purchase_order', 'status' => 'ordered', 'ref_no' => $refNo, 'transaction_date' => now()->toDateTimeString(), 'created_by' => $userId, 'total_before_tax' => $total, 'final_total' => $total, 'tax_amount' => 0, 'discount_amount' => 0, 'shipping_charges' => 0, 'additional_notes' => 'Maintenance material list #'.$list->id, ]); $transaction->purchase_lines()->createMany($purchaseLines); foreach ($linkedLines as $line) { $line->update(['status' => 'purchase_ordered', 'suggested_supplier_id' => $supplierId]); MaterialProcurement::create([ 'material_line_id' => $line->id, 'transaction_id' => $transaction->id, 'procurement_type' => 'purchase_order', 'quantity' => $line->quantity_required, 'destination_location_id' => $destinationId, 'supplier_id' => $supplierId, 'status' => 'ordered', ]); } $orders->push($transaction); } return $orders; }); } public function deleteLine(MaterialLine $line): void { if (in_array($line->status, ['transfer_in_transit', 'purchase_ordered'], true)) { throw new InvalidArgumentException('این قطعه در جریان تأمین است و قابل حذف نیست.'); } $line->procurements()->delete(); $line->delete(); } public function resolveEquipmentId(string $sourceType, int $sourceId, int $businessId): ?int { return match ($sourceType) { 'overhaul' => Overhaul::where('business_id', $businessId)->find($sourceId)?->equipment_id, 'work_order' => WorkOrder::where('business_id', $businessId)->find($sourceId)?->equipment_id, 'rebuild' => null, default => null, }; } protected function assertSourceExists(int $businessId, string $sourceType, int $sourceId): void { if (! in_array($sourceType, self::SOURCE_TYPES, true)) { throw new InvalidArgumentException('نوع منبع نامعتبر است.'); } $exists = match ($sourceType) { 'overhaul' => Overhaul::where('business_id', $businessId)->where('id', $sourceId)->exists(), 'rebuild' => RebuildProject::where('business_id', $businessId)->where('id', $sourceId)->exists(), 'work_order' => WorkOrder::where('business_id', $businessId)->where('id', $sourceId)->exists(), default => false, }; if (! $exists) { throw new InvalidArgumentException('رکورد منبع یافت نشد.'); } } public function statusLabels(): array { return [ 'planned' => __('maintenance::lang.mat_status_planned'), 'repair_planned' => __('maintenance::lang.mat_status_repair_planned'), 'transfer_requested' => __('maintenance::lang.mat_status_transfer_requested'), 'transfer_in_transit' => __('maintenance::lang.mat_status_transfer_in_transit'), 'purchase_requested' => __('maintenance::lang.mat_status_purchase_requested'), 'purchase_ordered' => __('maintenance::lang.mat_status_purchase_ordered'), 'fulfilled' => __('maintenance::lang.mat_status_fulfilled'), 'cancelled' => __('maintenance::lang.mat_status_cancelled'), ]; } /** * @return array */ public function getPanelData(Business $business, string $sourceType, int $sourceId, ?User $user = null): array { $list = $this->getOrCreateList($business, $sourceType, $sourceId, $user); $list->load([ 'lines.sparePart', 'lines.suggestedSupplier', 'lines.procurements.transaction', 'lines.activeRepairDispatch.holdingEntity', 'lines.activeRepairDispatch.orgUnit', 'lines.activeRepairDispatch.serviceProvider', 'repairDispatches.materialLine', 'repairDispatches.holdingEntity', 'repairDispatches.orgUnit', 'repairDispatches.serviceProvider', 'destinationLocation', ]); $supplyList = clone $list; $supplyList->setRelation('lines', $list->lines->where('line_type', 'supply')->values()); $dispatchService = app(RepairDispatchService::class); $util = app(\Modules\Maintenance\Utils\MaintenanceUtil::class); return [ 'material_list' => $list, 'source_type' => $sourceType, 'source_id' => $sourceId, 'analysis' => $this->analyzeList($supplyList)->keyBy('line_id'), 'purchase_shortfall' => $this->buildPurchaseShortfall($supplyList), 'repair_lines' => $list->lines->where('line_type', 'repair')->values(), 'repair_dispatches' => $list->repairDispatches->sortByDesc('id')->values(), 'equipment_id' => $this->resolveEquipmentId($sourceType, $sourceId, $business->id), 'locations' => $this->stockService->locationsForBusiness($business->id), 'status_labels' => $this->statusLabels(), 'repair_status_labels' => $dispatchService->statusLabels(), 'route_type_labels' => $dispatchService->routeTypeLabels(), 'holding_entities' => $util->getHoldingEntitiesDropdown($business->id), 'external_providers' => $util->getExternalServiceProvidersDropdown($business->id), 'can_repair_dispatch' => auth()->user()?->can('maintenance.repair_dispatch.manage') ?? false, 'can_repair_view' => auth()->user()?->can('maintenance.repair_dispatch.view') ?? false, ]; } }