Skip to content

Commit d7f9166

Browse files
committed
feat(inventory): Phase 142 — Inventory Purchase Requests
Adds PurchaseRequest model, migration, policy, controller, routes, React stubs, and feature tests for a multi-tenant purchase request workflow with draft/submitted/approved/rejected/ordered/cancelled states. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 48c9905 commit d7f9166

11 files changed

Lines changed: 806 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\PurchaseRequest;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class PurchaseRequestController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', PurchaseRequest::class);
17+
18+
$purchaseRequests = PurchaseRequest::orderByDesc('created_at')
19+
->paginate(25);
20+
21+
return Inertia::render('Inventory/PurchaseRequests/Index', compact('purchaseRequests'));
22+
}
23+
24+
public function create(): Response
25+
{
26+
$this->authorize('create', PurchaseRequest::class);
27+
28+
return Inertia::render('Inventory/PurchaseRequests/Create');
29+
}
30+
31+
public function store(Request $request): RedirectResponse
32+
{
33+
$this->authorize('create', PurchaseRequest::class);
34+
35+
$data = $request->validate([
36+
'title' => 'required|string',
37+
'estimated_cost' => 'nullable|numeric|min:0',
38+
'priority' => 'nullable|in:low,medium,high,urgent',
39+
'required_by' => 'nullable|date',
40+
]);
41+
42+
PurchaseRequest::create([
43+
'tenant_id' => app('tenant')->id,
44+
'created_by' => auth()->id(),
45+
'requested_by' => auth()->id(),
46+
...$data,
47+
]);
48+
49+
return redirect()->route('inventory.purchase-requests.index')
50+
->with('success', 'Purchase request created.');
51+
}
52+
53+
public function show(PurchaseRequest $purchaseRequest): Response
54+
{
55+
$this->authorize('view', $purchaseRequest);
56+
57+
return Inertia::render('Inventory/PurchaseRequests/Show', compact('purchaseRequest'));
58+
}
59+
60+
public function edit(PurchaseRequest $purchaseRequest): Response
61+
{
62+
$this->authorize('update', $purchaseRequest);
63+
64+
return Inertia::render('Inventory/PurchaseRequests/Edit', compact('purchaseRequest'));
65+
}
66+
67+
public function update(Request $request, PurchaseRequest $purchaseRequest): RedirectResponse
68+
{
69+
$this->authorize('update', $purchaseRequest);
70+
71+
$data = $request->validate([
72+
'title' => 'required|string',
73+
'estimated_cost' => 'nullable|numeric|min:0',
74+
'priority' => 'nullable|in:low,medium,high,urgent',
75+
'required_by' => 'nullable|date',
76+
]);
77+
78+
$purchaseRequest->update($data);
79+
80+
return redirect()->route('inventory.purchase-requests.index')
81+
->with('success', 'Purchase request updated.');
82+
}
83+
84+
public function destroy(PurchaseRequest $purchaseRequest): RedirectResponse
85+
{
86+
$this->authorize('delete', $purchaseRequest);
87+
88+
$purchaseRequest->delete();
89+
90+
return redirect()->route('inventory.purchase-requests.index')
91+
->with('success', 'Purchase request deleted.');
92+
}
93+
94+
public function submit(PurchaseRequest $purchaseRequest): RedirectResponse
95+
{
96+
$this->authorize('submit', $purchaseRequest);
97+
98+
$purchaseRequest->submit();
99+
100+
return redirect()->route('inventory.purchase-requests.index')
101+
->with('success', 'Purchase request submitted.');
102+
}
103+
104+
public function approve(PurchaseRequest $purchaseRequest): RedirectResponse
105+
{
106+
$this->authorize('approve', $purchaseRequest);
107+
108+
$purchaseRequest->approve(auth()->id());
109+
110+
return redirect()->route('inventory.purchase-requests.index')
111+
->with('success', 'Purchase request approved.');
112+
}
113+
114+
public function reject(PurchaseRequest $purchaseRequest): RedirectResponse
115+
{
116+
$this->authorize('reject', $purchaseRequest);
117+
118+
$purchaseRequest->reject();
119+
120+
return redirect()->route('inventory.purchase-requests.index')
121+
->with('success', 'Purchase request rejected.');
122+
}
123+
124+
public function markOrdered(PurchaseRequest $purchaseRequest): RedirectResponse
125+
{
126+
$this->authorize('markOrdered', $purchaseRequest);
127+
128+
$purchaseRequest->markOrdered();
129+
130+
return redirect()->route('inventory.purchase-requests.index')
131+
->with('success', 'Purchase request marked as ordered.');
132+
}
133+
134+
public function cancel(PurchaseRequest $purchaseRequest): RedirectResponse
135+
{
136+
$this->authorize('cancel', $purchaseRequest);
137+
138+
$purchaseRequest->cancel();
139+
140+
return redirect()->route('inventory.purchase-requests.index')
141+
->with('success', 'Purchase request cancelled.');
142+
}
143+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 PurchaseRequest extends Model
10+
{
11+
use BelongsToTenant, SoftDeletes;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'request_number',
16+
'title',
17+
'description',
18+
'department',
19+
'estimated_cost',
20+
'currency',
21+
'priority',
22+
'status',
23+
'required_by',
24+
'justification',
25+
'requested_by',
26+
'approved_by',
27+
'approved_at',
28+
'submitted_at',
29+
'created_by',
30+
];
31+
32+
protected $attributes = [
33+
'status' => 'draft',
34+
'priority' => 'medium',
35+
'currency' => 'USD',
36+
'estimated_cost' => 0,
37+
];
38+
39+
protected $casts = [
40+
'estimated_cost' => 'decimal:2',
41+
'required_by' => 'date',
42+
'approved_at' => 'datetime',
43+
'submitted_at' => 'datetime',
44+
];
45+
46+
// ── State transitions ──────────────────────────────────────────────────
47+
48+
public function submit(): void
49+
{
50+
$this->status = 'submitted';
51+
$this->submitted_at = now();
52+
53+
if ($this->request_number === null) {
54+
$this->request_number = $this->generateRequestNumber();
55+
}
56+
57+
$this->save();
58+
}
59+
60+
public function approve(int $userId): void
61+
{
62+
$this->status = 'approved';
63+
$this->approved_by = $userId;
64+
$this->approved_at = now();
65+
$this->save();
66+
}
67+
68+
public function reject(): void
69+
{
70+
$this->status = 'rejected';
71+
$this->save();
72+
}
73+
74+
public function markOrdered(): void
75+
{
76+
$this->status = 'ordered';
77+
$this->save();
78+
}
79+
80+
public function cancel(): void
81+
{
82+
$this->status = 'cancelled';
83+
$this->save();
84+
}
85+
86+
public function generateRequestNumber(): string
87+
{
88+
return 'PR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
89+
}
90+
91+
// ── Accessors ──────────────────────────────────────────────────────────
92+
93+
public function getIsDraftAttribute(): bool
94+
{
95+
return $this->status === 'draft';
96+
}
97+
98+
public function getIsSubmittedAttribute(): bool
99+
{
100+
return $this->status === 'submitted';
101+
}
102+
103+
public function getIsApprovedAttribute(): bool
104+
{
105+
return $this->status === 'approved';
106+
}
107+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\PurchaseRequest;
7+
8+
class PurchaseRequestPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, PurchaseRequest $purchaseRequest): bool
16+
{
17+
return $user->can('inventory.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('inventory.create');
23+
}
24+
25+
public function update(User $user, PurchaseRequest $purchaseRequest): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function submit(User $user, PurchaseRequest $purchaseRequest): bool
31+
{
32+
return $user->can('inventory.create');
33+
}
34+
35+
public function approve(User $user, PurchaseRequest $purchaseRequest): bool
36+
{
37+
return $user->can('inventory.create');
38+
}
39+
40+
public function markOrdered(User $user, PurchaseRequest $purchaseRequest): bool
41+
{
42+
return $user->can('inventory.create');
43+
}
44+
45+
public function reject(User $user, PurchaseRequest $purchaseRequest): bool
46+
{
47+
return $user->can('inventory.delete');
48+
}
49+
50+
public function cancel(User $user, PurchaseRequest $purchaseRequest): bool
51+
{
52+
return $user->can('inventory.delete');
53+
}
54+
55+
public function delete(User $user, PurchaseRequest $purchaseRequest): bool
56+
{
57+
return $user->can('inventory.delete');
58+
}
59+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
use App\Modules\Inventory\Policies\SupplierScorecardPolicy;
7777
use App\Modules\Inventory\Models\QualityAlert;
7878
use App\Modules\Inventory\Policies\QualityAlertPolicy;
79+
use App\Modules\Inventory\Models\PurchaseRequest;
80+
use App\Modules\Inventory\Policies\PurchaseRequestPolicy;
7981
use App\Modules\Inventory\Models\StockReservation;
8082
use App\Modules\Inventory\Policies\StockReservationPolicy;
8183
use Illuminate\Support\Facades\Gate;
@@ -137,5 +139,6 @@ public function boot(): void
137139
Gate::policy(SupplierScorecard::class, SupplierScorecardPolicy::class);
138140
Gate::policy(QualityAlert::class, QualityAlertPolicy::class);
139141
Gate::policy(StockReservation::class, StockReservationPolicy::class);
142+
Gate::policy(PurchaseRequest::class, PurchaseRequestPolicy::class);
140143
}
141144
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,14 @@
289289
Route::post('stock-reservations/{stock_reservation}/expire', [StockReservationController::class, 'expire'])->name('stock-reservations.expire');
290290
Route::resource('stock-reservations', StockReservationController::class);
291291
});
292+
293+
// Purchase Requests
294+
use App\Modules\Inventory\Http\Controllers\PurchaseRequestController;
295+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
296+
Route::post('purchase-requests/{purchase_request}/submit', [PurchaseRequestController::class, 'submit'])->name('purchase-requests.submit');
297+
Route::post('purchase-requests/{purchase_request}/approve', [PurchaseRequestController::class, 'approve'])->name('purchase-requests.approve');
298+
Route::post('purchase-requests/{purchase_request}/reject', [PurchaseRequestController::class, 'reject'])->name('purchase-requests.reject');
299+
Route::post('purchase-requests/{purchase_request}/mark-ordered', [PurchaseRequestController::class, 'markOrdered'])->name('purchase-requests.mark-ordered');
300+
Route::post('purchase-requests/{purchase_request}/cancel', [PurchaseRequestController::class, 'cancel'])->name('purchase-requests.cancel');
301+
Route::resource('purchase-requests', PurchaseRequestController::class);
302+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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('purchase_requests');
12+
Schema::create('purchase_requests', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->string('request_number')->nullable();
16+
$table->string('title');
17+
$table->text('description')->nullable();
18+
$table->string('department')->nullable();
19+
$table->decimal('estimated_cost', 15, 2)->default(0);
20+
$table->string('currency')->default('USD');
21+
$table->string('priority')->default('medium'); // low/medium/high/urgent
22+
$table->string('status')->default('draft'); // draft/submitted/approved/rejected/ordered/cancelled
23+
$table->date('required_by')->nullable();
24+
$table->text('justification')->nullable();
25+
$table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
26+
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
27+
$table->timestamp('approved_at')->nullable();
28+
$table->timestamp('submitted_at')->nullable();
29+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
30+
$table->timestamps();
31+
$table->softDeletes();
32+
});
33+
}
34+
35+
public function down(): void
36+
{
37+
Schema::dropIfExists('purchase_requests');
38+
}
39+
};

0 commit comments

Comments
 (0)