ultimatepos/Modules/IndustrialEngineering/Services/SourcingService.php

179 lines
6.8 KiB
PHP

<?php
namespace Modules\IndustrialEngineering\Services;
use App\Variation;
use App\VariationLocationDetails;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Modules\IndustrialEngineering\Models\ItemMaster;
use Modules\IndustrialEngineering\Models\PartEquivalent;
use Modules\IndustrialEngineering\Models\ProcurementTask;
use Modules\IndustrialEngineering\Models\SupplierPartNumber;
use Modules\IndustrialEngineering\Models\SupplierSourcingCase;
class SourcingService
{
/**
* Search holding-wide stock for an item (current business + linked businesses via suppliers).
*/
public function findStock(int $businessId, int $itemMasterId): Collection
{
$item = ItemMaster::findOrFail($itemMasterId);
$results = collect();
if ($item->variation_id) {
$stock = VariationLocationDetails::join('business_locations as bl', 'variation_location_details.location_id', '=', 'bl.id')
->where('variation_location_details.variation_id', $item->variation_id)
->where('bl.business_id', $businessId)
->select(
'bl.business_id',
'bl.id as location_id',
'bl.name as location_name',
'variation_location_details.qty_available'
)
->get();
foreach ($stock as $row) {
if ((float) $row->qty_available > 0) {
$results->push([
'source' => 'internal_stock',
'business_id' => $row->business_id,
'location_id' => $row->location_id,
'location_name' => $row->location_name,
'quantity' => (float) $row->qty_available,
]);
}
}
}
$equivalents = PartEquivalent::where('primary_item_id', $itemMasterId)
->where('is_approved', true)
->with('equivalentItem')
->orderBy('priority')
->get();
foreach ($equivalents as $equiv) {
$alt = $equiv->equivalentItem;
if ($alt?->variation_id) {
$altStock = VariationLocationDetails::join('business_locations as bl', 'variation_location_details.location_id', '=', 'bl.id')
->where('variation_location_details.variation_id', $alt->variation_id)
->where('bl.business_id', $businessId)
->where('variation_location_details.qty_available', '>', 0)
->select('bl.name as location_name', 'variation_location_details.qty_available')
->get();
foreach ($altStock as $s) {
$results->push([
'source' => 'equivalent_stock',
'item_code' => $alt->item_code,
'item_name' => $alt->name,
'location_name' => $s->location_name,
'quantity' => (float) $s->qty_available,
'equivalence_type' => $equiv->equivalence_type,
]);
}
}
}
return $results;
}
/**
* Find approved suppliers for an item.
*/
public function findSuppliers(int $itemMasterId): Collection
{
return SupplierPartNumber::with('supplier')
->where('item_master_id', $itemMasterId)
->where('is_approved', true)
->orderByDesc('is_preferred')
->get()
->map(fn ($spn) => [
'supplier_id' => $spn->supplier_id,
'supplier_name' => $spn->supplier?->name,
'supplier_part_number' => $spn->supplier_part_number,
'unit_price' => (float) $spn->unit_price,
'lead_time_days' => $spn->lead_time_days,
'is_preferred' => $spn->is_preferred,
]);
}
/**
* Create sourcing case and procurement tasks when part not available.
*/
public function createSourcingCase(
int $businessId,
int $itemMasterId,
float $quantity,
?int $installedAssetId = null,
?string $neededBy = null,
string $priority = 'medium'
): SupplierSourcingCase {
return DB::transaction(function () use ($businessId, $itemMasterId, $quantity, $installedAssetId, $neededBy, $priority) {
$caseNumber = 'SRC-'.now()->format('Ymd').'-'.str_pad((string) (SupplierSourcingCase::forBusiness($businessId)->count() + 1), 4, '0', STR_PAD_LEFT);
$case = SupplierSourcingCase::create([
'business_id' => $businessId,
'case_number' => $caseNumber,
'item_master_id' => $itemMasterId,
'installed_asset_id' => $installedAssetId,
'required_quantity' => $quantity,
'needed_by' => $neededBy,
'priority' => $priority,
'status' => 'open',
'assigned_to' => auth()->id(),
]);
$stock = $this->findStock($businessId, $itemMasterId);
if ($stock->isEmpty()) {
ProcurementTask::create([
'business_id' => $businessId,
'sourcing_case_id' => $case->id,
'item_master_id' => $itemMasterId,
'task_type' => 'find_supplier',
'title' => 'Find supplier for '.$case->itemMaster?->name,
'status' => 'pending',
'assigned_to' => auth()->id(),
'due_date' => $neededBy,
]);
} else {
ProcurementTask::create([
'business_id' => $businessId,
'sourcing_case_id' => $case->id,
'item_master_id' => $itemMasterId,
'task_type' => 'internal_transfer',
'title' => 'Internal transfer for '.$case->itemMaster?->name,
'status' => 'pending',
'assigned_to' => auth()->id(),
'due_date' => $neededBy,
]);
}
return $case->fresh(['itemMaster', 'procurementTasks']);
});
}
/**
* Resolve item cost from variation purchase price or standard cost.
*/
public function resolveItemCost(ItemMaster $item): float
{
if ((float) $item->standard_cost > 0) {
return (float) $item->standard_cost;
}
if ($item->variation_id) {
$variation = Variation::find($item->variation_id);
return (float) ($variation?->dpp_inc_tax ?? 0);
}
$spn = SupplierPartNumber::where('item_master_id', $item->id)
->where('is_preferred', true)
->first();
return (float) ($spn?->unit_price ?? 0);
}
}