Skip to content

Commit f7bb222

Browse files
committed
feat(finance): Phase 51 — Vendor Management profiles and evaluations
Implements vendor-specific profiles with credit limits, payment terms, and banking details, plus a vendor evaluation/rating system with per-contact history and overall score computation. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 22e6598 commit f7bb222

12 files changed

Lines changed: 848 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\VendorEvaluation;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\Gate;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class VendorEvaluationController extends Controller
15+
{
16+
public function index(Contact $contact): Response
17+
{
18+
abort_unless(Gate::allows('finance.view'), 403);
19+
20+
$evaluations = VendorEvaluation::where('contact_id', $contact->id)
21+
->with('evaluator')
22+
->latest('evaluation_date')
23+
->paginate(25);
24+
25+
return Inertia::render('Finance/Vendors/Evaluations', [
26+
'contact' => $contact,
27+
'evaluations' => $evaluations,
28+
'breadcrumbs' => [
29+
['label' => 'Finance'],
30+
['label' => 'Contacts', 'href' => route('finance.contacts.index')],
31+
['label' => $contact->name],
32+
['label' => 'Evaluations'],
33+
],
34+
]);
35+
}
36+
37+
public function store(Request $request, Contact $contact): RedirectResponse
38+
{
39+
abort_unless(Gate::allows('finance.create'), 403);
40+
41+
$data = $request->validate([
42+
'evaluation_date' => ['required', 'date'],
43+
'quality_rating' => ['required', 'integer', 'min:1', 'max:5'],
44+
'delivery_rating' => ['required', 'integer', 'min:1', 'max:5'],
45+
'price_rating' => ['required', 'integer', 'min:1', 'max:5'],
46+
'communication_rating' => ['required', 'integer', 'min:1', 'max:5'],
47+
'comments' => ['nullable', 'string'],
48+
]);
49+
50+
$overall = round(
51+
($data['quality_rating'] + $data['delivery_rating'] + $data['price_rating'] + $data['communication_rating']) / 4,
52+
2
53+
);
54+
55+
VendorEvaluation::create(array_merge($data, [
56+
'tenant_id' => $contact->tenant_id,
57+
'contact_id' => $contact->id,
58+
'evaluated_by' => auth()->id(),
59+
'overall_rating' => $overall,
60+
]));
61+
62+
return redirect()->back()->with('success', 'Evaluation added.');
63+
}
64+
65+
public function destroy(Contact $contact, VendorEvaluation $evaluation): RedirectResponse
66+
{
67+
abort_unless(Gate::allows('finance.delete'), 403);
68+
69+
$evaluation->delete();
70+
71+
return redirect()->back()->with('success', 'Evaluation deleted.');
72+
}
73+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\VendorProfile;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\Gate;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class VendorProfileController extends Controller
15+
{
16+
public function show(Contact $contact): Response
17+
{
18+
abort_unless(Gate::allows('finance.view'), 403);
19+
20+
$profile = VendorProfile::firstOrCreate(
21+
['contact_id' => $contact->id],
22+
[
23+
'tenant_id' => $contact->tenant_id,
24+
'payment_terms_days' => 30,
25+
]
26+
);
27+
28+
$profile->load('contact');
29+
30+
return Inertia::render('Finance/Vendors/Profile', [
31+
'contact' => $contact,
32+
'profile' => array_merge($profile->toArray(), [
33+
'is_over_credit_limit' => $profile->is_over_credit_limit,
34+
]),
35+
'breadcrumbs' => [
36+
['label' => 'Finance'],
37+
['label' => 'Contacts', 'href' => route('finance.contacts.index')],
38+
['label' => $contact->name],
39+
['label' => 'Vendor Profile'],
40+
],
41+
]);
42+
}
43+
44+
public function update(Request $request, Contact $contact): RedirectResponse
45+
{
46+
abort_unless(Gate::allows('finance.create'), 403);
47+
48+
$data = $request->validate([
49+
'credit_limit' => ['nullable', 'numeric', 'min:0'],
50+
'payment_terms_days' => ['required', 'integer', 'min:0', 'max:365'],
51+
'preferred_currency' => ['nullable', 'string', 'size:3'],
52+
'bank_name' => ['nullable', 'string', 'max:255'],
53+
'bank_account_number' => ['nullable', 'string', 'max:100'],
54+
'bank_routing_number' => ['nullable', 'string', 'max:100'],
55+
'notes' => ['nullable', 'string'],
56+
]);
57+
58+
VendorProfile::updateOrCreate(
59+
['contact_id' => $contact->id],
60+
array_merge($data, ['tenant_id' => $contact->tenant_id])
61+
);
62+
63+
return redirect()->back()->with('success', 'Vendor profile updated.');
64+
}
65+
}

erp/app/Modules/Finance/Models/Contact.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Illuminate\Database\Eloquent\Model;
88
use Illuminate\Database\Eloquent\Relations\BelongsTo;
99
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\Relations\HasOne;
1011
use Illuminate\Database\Eloquent\SoftDeletes;
1112

1213
class Contact extends Model
@@ -27,11 +28,26 @@ public function invoices(): HasMany
2728
return $this->hasMany(Invoice::class);
2829
}
2930

31+
public function bills(): HasMany
32+
{
33+
return $this->hasMany(Bill::class);
34+
}
35+
3036
public function priceList(): BelongsTo
3137
{
3238
return $this->belongsTo(PriceList::class);
3339
}
3440

41+
public function vendorProfile(): HasOne
42+
{
43+
return $this->hasOne(VendorProfile::class);
44+
}
45+
46+
public function vendorEvaluations(): HasMany
47+
{
48+
return $this->hasMany(VendorEvaluation::class)->latest('evaluation_date');
49+
}
50+
3551
public function scopeCustomers($query)
3652
{
3753
return $query->whereIn('type', ['customer', 'both']);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class VendorEvaluation extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'contact_id',
19+
'evaluated_by',
20+
'evaluation_date',
21+
'quality_rating',
22+
'delivery_rating',
23+
'price_rating',
24+
'communication_rating',
25+
'overall_rating',
26+
'comments',
27+
];
28+
29+
protected $casts = [
30+
'evaluation_date' => 'date',
31+
'overall_rating' => 'decimal:2',
32+
];
33+
34+
public function contact(): BelongsTo
35+
{
36+
return $this->belongsTo(Contact::class);
37+
}
38+
39+
public function evaluator(): BelongsTo
40+
{
41+
return $this->belongsTo(User::class, 'evaluated_by');
42+
}
43+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class VendorProfile extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'contact_id',
16+
'credit_limit',
17+
'payment_terms_days',
18+
'preferred_currency',
19+
'bank_name',
20+
'bank_account_number',
21+
'bank_routing_number',
22+
'notes',
23+
];
24+
25+
protected $casts = [
26+
'credit_limit' => 'decimal:2',
27+
];
28+
29+
public function contact(): BelongsTo
30+
{
31+
return $this->belongsTo(Contact::class);
32+
}
33+
34+
public function getIsOverCreditLimitAttribute(): bool
35+
{
36+
if (is_null($this->credit_limit)) {
37+
return false;
38+
}
39+
40+
if (! $this->relationLoaded('contact')) {
41+
$this->load('contact');
42+
}
43+
44+
if (! $this->contact) {
45+
return false;
46+
}
47+
48+
$bills = $this->contact->bills()
49+
->whereIn('status', ['received', 'partial'])
50+
->with(['items', 'payments'])
51+
->get();
52+
53+
$outstanding = $bills->sum(fn ($bill) => $bill->amount_due);
54+
55+
return $outstanding > (float) $this->credit_limit;
56+
}
57+
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
use App\Modules\Finance\Http\Controllers\DeliveryNoteController;
2323
use App\Modules\Finance\Http\Controllers\ProjectController;
2424
use Illuminate\Support\Facades\Route;
25+
use App\Modules\Finance\Http\Controllers\VendorProfileController;
26+
use App\Modules\Finance\Http\Controllers\VendorEvaluationController;
2527

2628
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
2729

@@ -173,4 +175,14 @@
173175
Route::post('delivery-notes/{deliveryNote}/dispatch', [DeliveryNoteController::class, 'dispatch'])->name('delivery-notes.dispatch');
174176
Route::post('delivery-notes/{deliveryNote}/deliver', [DeliveryNoteController::class, 'deliver'])->name('delivery-notes.deliver');
175177

178+
// Vendor Profiles (nested under contacts)
179+
Route::get('vendors/{contact}/profile', [VendorProfileController::class, 'show'])->name('vendors.profile.show');
180+
Route::put('vendors/{contact}/profile', [VendorProfileController::class, 'update'])->name('vendors.profile.update');
181+
182+
// Vendor Evaluations (nested under contacts)
183+
Route::get('vendors/{contact}/evaluations', [VendorEvaluationController::class, 'index'])->name('vendors.evaluations.index');
184+
Route::post('vendors/{contact}/evaluations', [VendorEvaluationController::class, 'store'])->name('vendors.evaluations.store');
185+
Route::delete('vendors/{contact}/evaluations/{evaluation}', [VendorEvaluationController::class, 'destroy'])->name('vendors.evaluations.destroy');
186+
187+
176188
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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::create('vendor_profiles', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete()->unique();
15+
$table->decimal('credit_limit', 12, 2)->nullable();
16+
$table->unsignedInteger('payment_terms_days')->default(30);
17+
$table->string('preferred_currency', 3)->nullable();
18+
$table->string('bank_name')->nullable();
19+
$table->string('bank_account_number', 100)->nullable();
20+
$table->string('bank_routing_number', 100)->nullable();
21+
$table->text('notes')->nullable();
22+
$table->timestamps();
23+
});
24+
}
25+
26+
public function down(): void
27+
{
28+
Schema::dropIfExists('vendor_profiles');
29+
}
30+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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::create('vendor_evaluations', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete();
15+
$table->foreignId('evaluated_by')->constrained('users');
16+
$table->date('evaluation_date');
17+
$table->unsignedTinyInteger('quality_rating');
18+
$table->unsignedTinyInteger('delivery_rating');
19+
$table->unsignedTinyInteger('price_rating');
20+
$table->unsignedTinyInteger('communication_rating');
21+
$table->decimal('overall_rating', 3, 2);
22+
$table->text('comments')->nullable();
23+
$table->softDeletes();
24+
$table->timestamps();
25+
});
26+
}
27+
28+
public function down(): void
29+
{
30+
Schema::dropIfExists('vendor_evaluations');
31+
}
32+
};

0 commit comments

Comments
 (0)