ultimatepos/Modules/SupplyChain/Services/SupplierScorecardService.php

250 lines
8.9 KiB
PHP

<?php
namespace Modules\SupplyChain\Services;
use App\Contact;
use App\Transaction;
use App\Utils\ModuleUtil;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\SupplyChain\Models\SupplierScorecard;
class SupplierScorecardService
{
public function __construct(
protected ModuleUtil $moduleUtil
) {
}
/**
* Calculate and persist scorecards for all suppliers in a period.
*
* @return array<int, SupplierScorecard>
*/
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';
}
}