ultimatepos/Modules/Maintenance/Services/MaterialListService.php

584 lines
23 KiB
PHP

<?php
namespace Modules\Maintenance\Services;
use App\Business;
use App\Product;
use App\Transaction;
use App\User;
use App\Utils\ProductUtil;
use App\Utils\TransactionUtil;
use App\Utils\Util;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\Maintenance\Models\Equipment;
use Modules\Maintenance\Models\EquipmentPart;
use Modules\Maintenance\Models\MaterialLine;
use Modules\Maintenance\Models\MaterialList;
use Modules\Maintenance\Models\MaterialProcurement;
use Modules\Maintenance\Models\Overhaul;
use Modules\Maintenance\Models\RebuildProject;
use Modules\Maintenance\Models\SparePart;
use Modules\Maintenance\Models\WorkOrder;
class MaterialListService
{
public const SOURCE_TYPES = ['overhaul', 'rebuild', 'work_order'];
public function __construct(
protected MaterialStockService $stockService,
protected MaterialPurchaseSuggestionService $suggestionService,
protected ProductUtil $productUtil,
protected TransactionUtil $transactionUtil,
protected Util $commonUtil
) {}
public function getOrCreateList(Business $business, string $sourceType, int $sourceId, ?User $user = null): MaterialList
{
$this->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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array{line_id: int, supplier_id?: int, quantity?: float}> $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<int, array{line_id: int, supplier_id: int, quantity?: float}> $lineSelections
* @return Collection<int, Transaction>
*/
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<string, mixed>
*/
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,
];
}
}