Skip to content

Commit dd658a5

Browse files
committed
feat(inventory): Phase 133 — Inventory Supplier Scorecards
Adds SupplierScorecard model, migration, policy, controller, routes, React stubs and feature tests for periodic supplier evaluation records with score calculation, rating bands, and publish workflow. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 322017b commit dd658a5

11 files changed

Lines changed: 403 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\SupplierScorecard;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class SupplierScorecardController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', SupplierScorecard::class);
17+
18+
$scorecards = SupplierScorecard::latest()
19+
->paginate(20)
20+
->withQueryString();
21+
22+
return Inertia::render('Inventory/SupplierScorecards/Index', [
23+
'scorecards' => $scorecards,
24+
'filters' => $request->only(['supplier_name', 'period', 'status']),
25+
]);
26+
}
27+
28+
public function create(): Response
29+
{
30+
$this->authorize('create', SupplierScorecard::class);
31+
32+
return Inertia::render('Inventory/SupplierScorecards/Create');
33+
}
34+
35+
public function store(Request $request): RedirectResponse
36+
{
37+
$this->authorize('create', SupplierScorecard::class);
38+
39+
$validated = $request->validate([
40+
'supplier_name' => 'required|string',
41+
'period' => 'required|string',
42+
'supplier_code' => 'nullable|string',
43+
'quality_score' => 'nullable|numeric|min:0|max:100',
44+
'delivery_score' => 'nullable|numeric|min:0|max:100',
45+
'pricing_score' => 'nullable|numeric|min:0|max:100',
46+
'service_score' => 'nullable|numeric|min:0|max:100',
47+
'notes' => 'nullable|string',
48+
]);
49+
50+
$validated['tenant_id'] = app('tenant')->id;
51+
52+
SupplierScorecard::create($validated);
53+
54+
return redirect()->route('inventory.supplier-scorecards.index')
55+
->with('success', 'Supplier scorecard created.');
56+
}
57+
58+
public function show(SupplierScorecard $supplierScorecard): Response
59+
{
60+
$this->authorize('view', $supplierScorecard);
61+
62+
return Inertia::render('Inventory/SupplierScorecards/Show', [
63+
'scorecard' => $supplierScorecard,
64+
]);
65+
}
66+
67+
public function edit(SupplierScorecard $supplierScorecard): Response
68+
{
69+
$this->authorize('update', $supplierScorecard);
70+
71+
return Inertia::render('Inventory/SupplierScorecards/Edit', [
72+
'scorecard' => $supplierScorecard,
73+
]);
74+
}
75+
76+
public function update(Request $request, SupplierScorecard $supplierScorecard): RedirectResponse
77+
{
78+
$this->authorize('update', $supplierScorecard);
79+
80+
$validated = $request->validate([
81+
'supplier_name' => 'required|string',
82+
'period' => 'required|string',
83+
'supplier_code' => 'nullable|string',
84+
'quality_score' => 'nullable|numeric|min:0|max:100',
85+
'delivery_score' => 'nullable|numeric|min:0|max:100',
86+
'pricing_score' => 'nullable|numeric|min:0|max:100',
87+
'service_score' => 'nullable|numeric|min:0|max:100',
88+
'notes' => 'nullable|string',
89+
]);
90+
91+
$supplierScorecard->update($validated);
92+
93+
return redirect()->route('inventory.supplier-scorecards.index')
94+
->with('success', 'Supplier scorecard updated.');
95+
}
96+
97+
public function publish(SupplierScorecard $supplierScorecard): RedirectResponse
98+
{
99+
$this->authorize('publish', $supplierScorecard);
100+
101+
$supplierScorecard->publish(auth()->id());
102+
103+
return redirect()->route('inventory.supplier-scorecards.index')
104+
->with('success', 'Supplier scorecard published.');
105+
}
106+
107+
public function destroy(SupplierScorecard $supplierScorecard): RedirectResponse
108+
{
109+
$this->authorize('delete', $supplierScorecard);
110+
111+
$supplierScorecard->delete();
112+
113+
return redirect()->route('inventory.supplier-scorecards.index')
114+
->with('success', 'Supplier scorecard deleted.');
115+
}
116+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
9+
class SupplierScorecard extends Model
10+
{
11+
use BelongsToTenant, SoftDeletes;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'supplier_name',
16+
'supplier_code',
17+
'scorecard_number',
18+
'period',
19+
'quality_score',
20+
'delivery_score',
21+
'pricing_score',
22+
'service_score',
23+
'overall_score',
24+
'rating',
25+
'status',
26+
'notes',
27+
'evaluated_by',
28+
'published_at',
29+
];
30+
31+
protected $attributes = [
32+
'status' => 'draft',
33+
'rating' => 'pending',
34+
'quality_score' => 0,
35+
'delivery_score' => 0,
36+
'pricing_score' => 0,
37+
'service_score' => 0,
38+
'overall_score' => 0,
39+
];
40+
41+
protected $casts = [
42+
'quality_score' => 'decimal:2',
43+
'delivery_score' => 'decimal:2',
44+
'pricing_score' => 'decimal:2',
45+
'service_score' => 'decimal:2',
46+
'overall_score' => 'decimal:2',
47+
'published_at' => 'datetime',
48+
];
49+
50+
public function calculateOverallScore(): void
51+
{
52+
$avg = ((float) $this->quality_score
53+
+ (float) $this->delivery_score
54+
+ (float) $this->pricing_score
55+
+ (float) $this->service_score) / 4.0;
56+
57+
$this->overall_score = round($avg, 2);
58+
59+
if ($avg < 40) {
60+
$this->rating = 'poor';
61+
} elseif ($avg < 60) {
62+
$this->rating = 'fair';
63+
} elseif ($avg < 80) {
64+
$this->rating = 'good';
65+
} else {
66+
$this->rating = 'excellent';
67+
}
68+
69+
$this->save();
70+
}
71+
72+
public function publish(int $userId): void
73+
{
74+
$this->calculateOverallScore();
75+
76+
if ($this->scorecard_number === null) {
77+
$this->scorecard_number = $this->generateScorecardNumber();
78+
}
79+
80+
$this->status = 'published';
81+
$this->evaluated_by = $userId;
82+
$this->published_at = now();
83+
84+
$this->save();
85+
}
86+
87+
public function generateScorecardNumber(): string
88+
{
89+
return 'SC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
90+
}
91+
92+
public function getIsPublishedAttribute(): bool
93+
{
94+
return $this->status === 'published';
95+
}
96+
97+
public function getIsDraftAttribute(): bool
98+
{
99+
return $this->status === 'draft';
100+
}
101+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
7+
class SupplierScorecardPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('inventory.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('inventory.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('inventory.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('inventory.create');
27+
}
28+
29+
public function publish(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('inventory.create');
32+
}
33+
34+
public function delete(User $user, $model): bool
35+
{
36+
return $user->hasPermissionTo('inventory.delete');
37+
}
38+
}

erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272
use App\Modules\Inventory\Policies\BackorderPolicy;
7373
use App\Modules\Inventory\Models\ReorderRule;
7474
use App\Modules\Inventory\Policies\ReorderRulePolicy;
75+
use App\Modules\Inventory\Models\SupplierScorecard;
76+
use App\Modules\Inventory\Policies\SupplierScorecardPolicy;
7577
use Illuminate\Support\Facades\Gate;
7678
use Illuminate\Support\ServiceProvider;
7779

@@ -128,5 +130,6 @@ public function boot(): void
128130
Gate::policy(ProductBundle::class, ProductBundlePolicy::class);
129131
Gate::policy(ProductBundleItem::class, ProductBundlePolicy::class);
130132
Gate::policy(ReorderRule::class, ReorderRulePolicy::class);
133+
Gate::policy(SupplierScorecard::class, SupplierScorecardPolicy::class);
131134
}
132135
}

erp/app/Modules/Inventory/routes/inventory.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,10 @@
264264
Route::post('reorder-rules/{reorder_rule}/resume', [ReorderRuleController::class, 'resume'])->name('reorder-rules.resume');
265265
Route::resource('reorder-rules', ReorderRuleController::class);
266266
});
267+
268+
// Supplier Scorecards
269+
use App\Modules\Inventory\Http\Controllers\SupplierScorecardController;
270+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
271+
Route::post('supplier-scorecards/{supplier_scorecard}/publish', [SupplierScorecardController::class, 'publish'])->name('supplier-scorecards.publish');
272+
Route::resource('supplier-scorecards', SupplierScorecardController::class);
273+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::dropIfExists('supplier_scorecards');
12+
Schema::create('supplier_scorecards', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->string('supplier_name');
16+
$table->string('supplier_code')->nullable();
17+
$table->string('scorecard_number')->nullable();
18+
$table->string('period');
19+
$table->decimal('quality_score', 5, 2)->default(0);
20+
$table->decimal('delivery_score', 5, 2)->default(0);
21+
$table->decimal('pricing_score', 5, 2)->default(0);
22+
$table->decimal('service_score', 5, 2)->default(0);
23+
$table->decimal('overall_score', 5, 2)->default(0);
24+
$table->string('rating')->default('pending');
25+
$table->string('status')->default('draft');
26+
$table->text('notes')->nullable();
27+
$table->foreignId('evaluated_by')->nullable()->constrained('users')->nullOnDelete();
28+
$table->timestamp('published_at')->nullable();
29+
$table->timestamps();
30+
$table->softDeletes();
31+
});
32+
}
33+
34+
public function down(): void
35+
{
36+
Schema::dropIfExists('supplier_scorecards');
37+
}
38+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Create() { return <div>Create</div>; }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Edit() { return <div>Edit</div>; }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Index() { return <div>Supplier Scorecards</div>; }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Show() { return <div>Show</div>; }

0 commit comments

Comments
 (0)