250 lines
8.9 KiB
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';
|
|
}
|
|
}
|