ultimatepos/Modules/SupplyChain/Services/DemandForecastService.php

200 lines
7.2 KiB
PHP

<?php
namespace Modules\SupplyChain\Services;
use App\Utils\ModuleUtil;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\SupplyChain\Models\DemandForecast;
class DemandForecastService
{
public function __construct(
protected ModuleUtil $moduleUtil
) {
}
/**
* Generate forecasts for all products with sales history.
*
* @return Collection<int, DemandForecast>
*/
public function generateForBusiness(int $businessId, int $monthsAhead = 3): Collection
{
$months = (int) config('supplychain.forecast.moving_average_months', 3);
$products = $this->getProductsWithSalesHistory($businessId, $months);
$forecasts = collect();
foreach ($products as $row) {
for ($i = 1; $i <= $monthsAhead; $i++) {
$forecastMonth = now()->addMonths($i)->startOfMonth();
$forecasts->push($this->forecastProduct(
$businessId,
(int) $row->product_id,
$row->variation_id ? (int) $row->variation_id : null,
$forecastMonth,
$months
));
}
}
return $forecasts;
}
public function forecastProduct(
int $businessId,
int $productId,
?int $variationId,
Carbon $forecastMonth,
?int $movingAverageMonths = null
): DemandForecast {
$months = $movingAverageMonths ?? (int) config('supplychain.forecast.moving_average_months', 3);
$historyStart = $forecastMonth->copy()->subMonths($months)->startOfMonth();
$historyEnd = $forecastMonth->copy()->subMonth()->endOfMonth();
$salesQty = $this->getHistoricalSalesQty($businessId, $productId, $variationId, $historyStart, $historyEnd);
$movingAvg = $months > 0 ? $salesQty / $months : 0;
$crmQty = $this->getCrmPipelineQty($businessId, $productId, $forecastMonth);
$crmWeight = (float) config('supplychain.forecast.crm_pipeline_weight', 0.25);
$forecastQty = round($movingAvg + ($crmQty * $crmWeight), 4);
$confidence = $this->calculateConfidence($salesQty, $crmQty, $months);
return DemandForecast::updateOrCreate(
[
'business_id' => $businessId,
'product_id' => $productId,
'variation_id' => $variationId,
'forecast_month' => $forecastMonth->toDateString(),
'method' => 'moving_average',
],
[
'forecast_qty' => max(0, $forecastQty),
'confidence' => $confidence,
'source' => $crmQty > 0 ? 'sales_crm' : 'sales',
'meta' => [
'moving_average_months' => $months,
'historical_sales_qty' => $salesQty,
'crm_pipeline_qty' => $crmQty,
'moving_average' => round($movingAvg, 4),
],
]
);
}
/**
* @return Collection<int, object>
*/
protected function getProductsWithSalesHistory(int $businessId, int $months): Collection
{
$since = now()->subMonths($months)->startOfMonth();
return DB::table('transaction_sell_lines')
->join('transactions', 'transactions.id', '=', 'transaction_sell_lines.transaction_id')
->where('transactions.business_id', $businessId)
->where('transactions.type', 'sell')
->where('transactions.status', 'final')
->where('transactions.transaction_date', '>=', $since)
->select('transaction_sell_lines.product_id', 'transaction_sell_lines.variation_id')
->groupBy('transaction_sell_lines.product_id', 'transaction_sell_lines.variation_id')
->get();
}
protected function getHistoricalSalesQty(
int $businessId,
int $productId,
?int $variationId,
Carbon $start,
Carbon $end
): float {
$query = DB::table('transaction_sell_lines')
->join('transactions', 'transactions.id', '=', 'transaction_sell_lines.transaction_id')
->where('transactions.business_id', $businessId)
->where('transactions.type', 'sell')
->where('transactions.status', 'final')
->where('transaction_sell_lines.product_id', $productId)
->whereBetween('transactions.transaction_date', [$start, $end]);
if ($variationId) {
$query->where('transaction_sell_lines.variation_id', $variationId);
}
return (float) $query->sum('transaction_sell_lines.quantity');
}
protected function getCrmPipelineQty(int $businessId, int $productId, Carbon $forecastMonth): float
{
if (! $this->moduleUtil->isModuleInstalled('Crm')) {
return 0.0;
}
$total = 0.0;
if (Schema::hasTable('crm_company_requirements')) {
$query = DB::table('crm_company_requirements')
->join('crm_companies', 'crm_companies.id', '=', 'crm_company_requirements.company_id')
->where('crm_companies.business_id', $businessId)
->where('crm_company_requirements.product_id', $productId);
if (Schema::hasColumn('crm_company_requirements', 'status')) {
$query->whereIn('crm_company_requirements.status', ['open', 'qualified', 'proposal']);
}
$total += (float) $query->sum(DB::raw('CAST(crm_company_requirements.quantity_estimate AS DECIMAL(22,4))'));
}
if (Schema::hasTable('crm_lead_products') && Schema::hasTable('crm_leads')) {
$total += (float) DB::table('crm_lead_products')
->join('crm_leads', 'crm_leads.id', '=', 'crm_lead_products.lead_id')
->where('crm_leads.business_id', $businessId)
->where('crm_lead_products.product_id', $productId)
->whereNotIn('crm_leads.lead_status', ['converted', 'lost'])
->sum('crm_lead_products.quantity');
}
return $total;
}
protected function calculateConfidence(float $salesQty, float $crmQty, int $months): float
{
if ($salesQty <= 0 && $crmQty <= 0) {
return 25.0;
}
$base = min(90.0, 40.0 + ($months * 10));
if ($crmQty > 0) {
$base = min(95.0, $base + 10);
}
if ($salesQty > 0) {
$base = min(98.0, $base + 5);
}
return round($base, 2);
}
/**
* Aggregate forecast qty by variation for MRP consumption.
*
* @return array<int, float> variation_id => qty
*/
public function getForecastByVariation(int $businessId, ?Carbon $month = null): array
{
$month = ($month ?? now()->addMonth()->startOfMonth())->copy()->startOfMonth();
$rows = DemandForecast::forBusiness($businessId)
->whereDate('forecast_month', $month->toDateString())
->whereNotNull('variation_id')
->get(['variation_id', 'forecast_qty']);
$map = [];
foreach ($rows as $row) {
$map[(int) $row->variation_id] = ($map[(int) $row->variation_id] ?? 0) + (float) $row->forecast_qty;
}
return $map;
}
}