179 lines
6.8 KiB
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);
|
|
}
|
|
}
|