*/ public function calculateForBusiness(int $businessId, ?Carbon $periodStart = null, ?Carbon $periodEnd = null): array { $periodStart = ($periodStart ?? now()->subMonth()->startOfMonth())->copy()->startOfDay(); $periodEnd = ($periodEnd ?? now()->subMonth()->endOfMonth())->copy()->endOfDay(); $supplierIds = Contact::where('business_id', $businessId) ->whereIn('type', ['supplier', 'both']) ->pluck('id'); $results = []; foreach ($supplierIds as $contactId) { $results[] = $this->calculateForSupplier($businessId, (int) $contactId, $periodStart, $periodEnd); } return $results; } public function calculateForSupplier( int $businessId, int $contactId, ?Carbon $periodStart = null, ?Carbon $periodEnd = null ): SupplierScorecard { $periodStart = ($periodStart ?? now()->subMonth()->startOfMonth())->copy()->startOfDay(); $periodEnd = ($periodEnd ?? now()->subMonth()->endOfMonth())->copy()->endOfDay(); $otif = $this->calculateOtifScore($businessId, $contactId, $periodStart, $periodEnd); $quality = $this->calculateQualityScore($businessId, $contactId, $periodStart, $periodEnd); $price = $this->calculatePriceScore($businessId, $contactId, $periodStart, $periodEnd); $responsiveness = $this->calculateResponsivenessScore($businessId, $contactId, $periodStart, $periodEnd); $weights = config('supplychain.scorecard.weights', [ 'otif' => 0.35, 'quality' => 0.30, 'price' => 0.20, 'responsiveness' => 0.15, ]); $overall = round( ($otif * $weights['otif']) + ($quality * $weights['quality']) + ($price * $weights['price']) + ($responsiveness * $weights['responsiveness']), 2 ); return SupplierScorecard::updateOrCreate( [ 'business_id' => $businessId, 'contact_id' => $contactId, 'period_start' => $periodStart->toDateString(), 'period_end' => $periodEnd->toDateString(), ], [ 'otif_score' => $otif, 'quality_score' => $quality, 'price_score' => $price, 'responsiveness_score' => $responsiveness, 'overall_score' => $overall, 'risk_level' => $this->resolveRiskLevel($overall), 'meta' => [ 'purchase_count' => $this->purchaseCount($businessId, $contactId, $periodStart, $periodEnd), 'ncr_count' => $this->ncrCount($businessId, $contactId, $periodStart, $periodEnd), ], ] ); } protected function calculateOtifScore(int $businessId, int $contactId, Carbon $start, Carbon $end): float { $purchases = Transaction::where('business_id', $businessId) ->where('contact_id', $contactId) ->whereIn('type', ['purchase', 'purchase_order']) ->whereIn('status', ['received', 'final', 'ordered']) ->whereBetween('transaction_date', [$start, $end]) ->get(['id', 'delivery_date', 'transaction_date', 'shipping_status', 'status']); if ($purchases->isEmpty()) { return 75.0; } $onTime = 0; $inFull = 0; $total = $purchases->count(); foreach ($purchases as $purchase) { $expected = $purchase->delivery_date ? Carbon::parse($purchase->delivery_date) : Carbon::parse($purchase->transaction_date)->addDays(7); $actual = Carbon::parse($purchase->transaction_date); if ($actual->lte($expected->copy()->addDay())) { $onTime++; } if (in_array($purchase->shipping_status, ['delivered', 'shipped', null], true) || in_array($purchase->status ?? '', ['received', 'final'], true)) { $inFull++; } } $otifRate = (($onTime / $total) * 0.6) + (($inFull / $total) * 0.4); return round(min(100, max(0, $otifRate * 100)), 2); } protected function calculateQualityScore(int $businessId, int $contactId, Carbon $start, Carbon $end): float { $ncrCount = $this->ncrCount($businessId, $contactId, $start, $end); $purchaseCount = max(1, $this->purchaseCount($businessId, $contactId, $start, $end)); $defectRate = $ncrCount / $purchaseCount; $score = 100 - min(100, $defectRate * 50); return round(max(0, $score), 2); } protected function calculatePriceScore(int $businessId, int $contactId, Carbon $start, Carbon $end): float { $supplierAvg = Transaction::where('business_id', $businessId) ->where('contact_id', $contactId) ->where('type', 'purchase') ->whereBetween('transaction_date', [$start, $end]) ->avg('final_total'); if (! $supplierAvg) { return 70.0; } $marketAvg = Transaction::where('business_id', $businessId) ->where('type', 'purchase') ->whereBetween('transaction_date', [$start, $end]) ->avg('final_total'); if (! $marketAvg || (float) $marketAvg <= 0) { return 70.0; } $ratio = (float) $supplierAvg / (float) $marketAvg; if ($ratio <= 0.95) { return 95.0; } if ($ratio <= 1.0) { return 85.0; } if ($ratio <= 1.1) { return 70.0; } return max(30.0, round(100 - (($ratio - 1) * 100), 2)); } protected function calculateResponsivenessScore(int $businessId, int $contactId, Carbon $start, Carbon $end): float { $rfqResponses = 0; $rfqInvites = 0; if (Schema::hasTable('sc_rfq_responses') && Schema::hasTable('sc_rfq_requests')) { $rfqInvites = DB::table('sc_rfq_responses') ->join('sc_rfq_requests', 'sc_rfq_requests.id', '=', 'sc_rfq_responses.rfq_id') ->where('sc_rfq_requests.business_id', $businessId) ->where('sc_rfq_responses.contact_id', $contactId) ->whereBetween('sc_rfq_requests.created_at', [$start, $end]) ->count(); $rfqResponses = DB::table('sc_rfq_responses') ->join('sc_rfq_requests', 'sc_rfq_requests.id', '=', 'sc_rfq_responses.rfq_id') ->where('sc_rfq_requests.business_id', $businessId) ->where('sc_rfq_responses.contact_id', $contactId) ->whereIn('sc_rfq_responses.status', ['submitted', 'shortlisted', 'awarded']) ->whereBetween('sc_rfq_requests.created_at', [$start, $end]) ->count(); } if ($rfqInvites > 0) { return round(min(100, ($rfqResponses / $rfqInvites) * 100), 2); } $poCount = $this->purchaseCount($businessId, $contactId, $start, $end); return $poCount > 0 ? 80.0 : 65.0; } protected function purchaseCount(int $businessId, int $contactId, Carbon $start, Carbon $end): int { return Transaction::where('business_id', $businessId) ->where('contact_id', $contactId) ->whereIn('type', ['purchase', 'purchase_order']) ->whereBetween('transaction_date', [$start, $end]) ->count(); } protected function ncrCount(int $businessId, int $contactId, Carbon $start, Carbon $end): int { if (! $this->moduleUtil->isModuleInstalled('QualityManagement') || ! Schema::hasTable('qms_ncr_reports')) { return 0; } return DB::table('qms_ncr_reports') ->where('business_id', $businessId) ->where('contact_id', $contactId) ->where('source', 'supplier') ->whereBetween('detected_at', [$start->toDateString(), $end->toDateString()]) ->whereNull('deleted_at') ->count(); } protected function resolveRiskLevel(float $overallScore): string { $thresholds = config('supplychain.scorecard.risk_thresholds', ['low' => 80, 'medium' => 60]); if ($overallScore >= $thresholds['low']) { return 'low'; } if ($overallScore >= $thresholds['medium']) { return 'medium'; } if ($overallScore >= 40) { return 'high'; } return 'critical'; } }