ultimatepos/Modules/Maintenance/Services/MaterialStockService.php

194 lines
6.6 KiB
PHP

<?php
namespace Modules\Maintenance\Services;
use App\BusinessLocation;
use App\HoldingEntity;
use App\PurchaseLine;
use App\Variation;
use App\VariationLocationDetails;
use Illuminate\Support\Collection;
use Modules\Maintenance\Models\MaterialLine;
use Modules\Maintenance\Models\SparePart;
use Modules\Maintenance\Utils\MaintenanceUtil;
class MaterialStockService
{
public function __construct(
protected MaintenanceUtil $maintenanceUtil,
protected SparePartInventoryService $inventoryService
) {}
public function resolveDestinationLocationId(int $businessId, ?int $override = null): ?int
{
if ($override) {
return $override;
}
$settings = $this->maintenanceUtil->getMaintenanceSettings($businessId);
return ! empty($settings['default_stock_location_id'])
? (int) $settings['default_stock_location_id']
: null;
}
/**
* @return array{product_id: ?int, variation_id: ?int}
*/
public function resolveProductIds(MaterialLine $line): array
{
if ($line->product_id && $line->variation_id) {
return [
'product_id' => (int) $line->product_id,
'variation_id' => (int) $line->variation_id,
];
}
if ($line->spare_part_id) {
$part = $line->sparePart ?? SparePart::find($line->spare_part_id);
if ($part && $part->product_id) {
$variationId = $this->inventoryService->resolveVariationId($part);
return [
'product_id' => (int) $part->product_id,
'variation_id' => $variationId,
];
}
}
return ['product_id' => null, 'variation_id' => null];
}
/**
* Stock per location for a material line across all business locations (holding warehouses).
*
* @return Collection<int, array{location_id: int, location_name: string, entity_name: ?string, qty: float}>
*/
public function stockByLocation(int $businessId, MaterialLine $line): Collection
{
$ids = $this->resolveProductIds($line);
if (! $ids['product_id'] || ! $ids['variation_id']) {
return collect();
}
$entityMap = $this->locationEntityMap($businessId);
return VariationLocationDetails::query()
->join('business_locations as bl', 'variation_location_details.location_id', '=', 'bl.id')
->where('variation_location_details.product_id', $ids['product_id'])
->where('variation_location_details.variation_id', $ids['variation_id'])
->where('bl.business_id', $businessId)
->where('variation_location_details.qty_available', '>', 0)
->select(
'variation_location_details.location_id',
'bl.name as location_name',
'variation_location_details.qty_available as qty'
)
->orderByDesc('variation_location_details.qty_available')
->get()
->map(function ($row) use ($entityMap) {
$locId = (int) $row->location_id;
return [
'location_id' => $locId,
'location_name' => $row->location_name,
'entity_name' => $entityMap[$locId] ?? null,
'qty' => (float) $row->qty,
];
});
}
/**
* @return array<string, mixed>
*/
public function analyzeLine(int $businessId, MaterialLine $line, ?int $destinationLocationId = null): array
{
$destinationLocationId = $this->resolveDestinationLocationId($businessId, $destinationLocationId);
$stockRows = $this->stockByLocation($businessId, $line);
$required = (float) $line->quantity_required;
$fulfilled = (float) $line->quantity_fulfilled;
$remaining = max(0, $required - $fulfilled);
$localQty = 0.0;
$otherLocations = [];
foreach ($stockRows as $row) {
if ($destinationLocationId && $row['location_id'] === $destinationLocationId) {
$localQty = $row['qty'];
} else {
$otherLocations[] = $row;
}
}
$totalAvailable = $stockRows->sum('qty');
$shortfall = max(0, $remaining - $totalAvailable);
return [
'line_id' => $line->id,
'display_name' => $line->displayName(),
'part_number' => $line->part_number ?? $line->sparePart?->part_number,
'quantity_required' => $required,
'quantity_fulfilled' => $fulfilled,
'remaining' => $remaining,
'local_qty' => $localQty,
'total_available' => $totalAvailable,
'shortfall' => $shortfall,
'destination_location_id' => $destinationLocationId,
'stock_by_location' => $stockRows->values()->all(),
'other_locations' => $otherLocations,
'has_product_link' => (bool) $this->resolveProductIds($line)['product_id'],
'status' => $line->status,
];
}
/**
* @return array<int, string>
*/
protected function locationEntityMap(int $businessId): array
{
$map = [];
$entities = HoldingEntity::where('business_id', $businessId)->where('is_active', true)->get();
foreach ($entities as $entity) {
$locId = data_get($entity->meta, 'business_location_id');
if ($locId) {
$map[(int) $locId] = $entity->name;
}
}
return $map;
}
public function getUnitPrice(int $variationId, ?int $locationId = null, ?int $supplierId = null): float
{
$variation = Variation::find($variationId);
if ($variation && $variation->default_purchase_price) {
return (float) $variation->default_purchase_price;
}
$query = PurchaseLine::join('transactions as t', 'purchase_lines.transaction_id', '=', 't.id')
->where('purchase_lines.variation_id', $variationId)
->where('t.type', 'purchase')
->where('t.status', 'received');
if ($locationId) {
$query->where('t.location_id', $locationId);
}
if ($supplierId) {
$query->where('t.contact_id', $supplierId);
}
$line = $query->orderByDesc('t.transaction_date')->select('purchase_lines.purchase_price')->first();
return $line ? (float) $line->purchase_price : 0.0;
}
/**
* @return Collection<int, BusinessLocation>
*/
public function locationsForBusiness(int $businessId): Collection
{
return BusinessLocation::where('business_id', $businessId)->orderBy('name')->get();
}
}