Skip to content

Commit 8cd0ce5

Browse files
committed
feat: Phase 38 — Purchase Requisitions with approval workflow
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 7767ef9 commit 8cd0ce5

14 files changed

Lines changed: 1026 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\Product;
7+
use App\Modules\Inventory\Models\PurchaseRequisition;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class PurchaseRequisitionController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$this->authorize('viewAny', PurchaseRequisition::class);
18+
$requisitions = PurchaseRequisition::with(['requester', 'approver'])
19+
->orderByDesc('created_at')
20+
->paginate(25);
21+
return Inertia::render('Inventory/PurchaseRequisitions/Index', compact('requisitions'));
22+
}
23+
24+
public function create(): Response
25+
{
26+
$this->authorize('create', PurchaseRequisition::class);
27+
$products = Product::orderBy('name')->get(['id', 'name', 'sku', 'cost_price']);
28+
return Inertia::render('Inventory/PurchaseRequisitions/Create', compact('products'));
29+
}
30+
31+
public function store(Request $request): RedirectResponse
32+
{
33+
$this->authorize('create', PurchaseRequisition::class);
34+
$data = $request->validate([
35+
'reference' => 'required|string|max:100|unique:purchase_requisitions,reference',
36+
'needed_by' => 'nullable|date',
37+
'notes' => 'nullable|string',
38+
'items' => 'required|array|min:1',
39+
'items.*.description' => 'required|string',
40+
'items.*.product_id' => 'nullable|exists:products,id',
41+
'items.*.quantity' => 'required|numeric|min:0.01',
42+
'items.*.estimated_unit_cost' => 'required|numeric|min:0',
43+
]);
44+
45+
$pr = PurchaseRequisition::create([
46+
'tenant_id' => auth()->user()->tenant_id,
47+
'reference' => $data['reference'],
48+
'requested_by' => auth()->id(),
49+
'status' => 'draft',
50+
'needed_by' => $data['needed_by'] ?? null,
51+
'notes' => $data['notes'] ?? null,
52+
]);
53+
54+
foreach ($data['items'] as $item) {
55+
$pr->items()->create([
56+
'product_id' => $item['product_id'] ?? null,
57+
'description' => $item['description'],
58+
'quantity' => $item['quantity'],
59+
'estimated_unit_cost' => $item['estimated_unit_cost'],
60+
]);
61+
}
62+
63+
return redirect()->route('inventory.purchase-requisitions.show', $pr)
64+
->with('success', 'Purchase requisition created.');
65+
}
66+
67+
public function show(PurchaseRequisition $purchaseRequisition): Response
68+
{
69+
$this->authorize('view', $purchaseRequisition);
70+
$purchaseRequisition->load(['requester', 'approver', 'items.product']);
71+
return Inertia::render('Inventory/PurchaseRequisitions/Show', compact('purchaseRequisition'));
72+
}
73+
74+
public function submit(PurchaseRequisition $purchaseRequisition): RedirectResponse
75+
{
76+
$this->authorize('update', $purchaseRequisition);
77+
abort_unless($purchaseRequisition->status === 'draft', 422, 'Only drafts can be submitted.');
78+
$purchaseRequisition->update(['status' => 'submitted']);
79+
return back()->with('success', 'Requisition submitted for approval.');
80+
}
81+
82+
public function approve(PurchaseRequisition $purchaseRequisition): RedirectResponse
83+
{
84+
$this->authorize('approve', $purchaseRequisition);
85+
abort_unless($purchaseRequisition->status === 'submitted', 422, 'Only submitted requisitions can be approved.');
86+
$purchaseRequisition->update([
87+
'status' => 'approved',
88+
'approved_by' => auth()->id(),
89+
'approved_at' => now(),
90+
]);
91+
return back()->with('success', 'Requisition approved.');
92+
}
93+
94+
public function reject(PurchaseRequisition $purchaseRequisition, Request $request): RedirectResponse
95+
{
96+
$this->authorize('approve', $purchaseRequisition);
97+
abort_unless($purchaseRequisition->status === 'submitted', 422, 'Only submitted requisitions can be rejected.');
98+
$request->validate(['rejection_reason' => 'required|string']);
99+
$purchaseRequisition->update([
100+
'status' => 'rejected',
101+
'rejection_reason' => $request->rejection_reason,
102+
]);
103+
return back()->with('success', 'Requisition rejected.');
104+
}
105+
106+
public function destroy(PurchaseRequisition $purchaseRequisition): RedirectResponse
107+
{
108+
$this->authorize('delete', $purchaseRequisition);
109+
abort_unless($purchaseRequisition->status === 'draft', 422, 'Only drafts can be deleted.');
110+
$purchaseRequisition->delete();
111+
return redirect()->route('inventory.purchase-requisitions.index')->with('success', 'Requisition deleted.');
112+
}
113+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\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\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class PurchaseRequisition extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'reference', 'requested_by', 'approved_by',
18+
'status', 'needed_by', 'notes', 'rejection_reason', 'approved_at',
19+
];
20+
21+
protected $casts = ['needed_by' => 'date', 'approved_at' => 'datetime'];
22+
23+
public function requester(): BelongsTo
24+
{
25+
return $this->belongsTo(User::class, 'requested_by');
26+
}
27+
28+
public function approver(): BelongsTo
29+
{
30+
return $this->belongsTo(User::class, 'approved_by');
31+
}
32+
33+
public function items(): HasMany
34+
{
35+
return $this->hasMany(PurchaseRequisitionItem::class);
36+
}
37+
38+
public function getTotalEstimatedCostAttribute(): float
39+
{
40+
return round($this->items->sum(fn ($i) => $i->quantity * $i->estimated_unit_cost), 2);
41+
}
42+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class PurchaseRequisitionItem extends Model
9+
{
10+
protected $fillable = [
11+
'purchase_requisition_id', 'product_id', 'description', 'quantity', 'estimated_unit_cost',
12+
];
13+
14+
public function requisition(): BelongsTo
15+
{
16+
return $this->belongsTo(PurchaseRequisition::class, 'purchase_requisition_id');
17+
}
18+
19+
public function product(): BelongsTo
20+
{
21+
return $this->belongsTo(Product::class);
22+
}
23+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\PurchaseRequisition;
7+
8+
class PurchaseRequisitionPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, PurchaseRequisition $pr): 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, PurchaseRequisition $pr): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function approve(User $user, PurchaseRequisition $pr): bool
31+
{
32+
return $user->hasRole(['super-admin', 'admin', 'manager']);
33+
}
34+
35+
public function delete(User $user, PurchaseRequisition $pr): bool
36+
{
37+
return $user->can('inventory.delete');
38+
}
39+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
use App\Modules\Inventory\Models\Product;
66
use App\Modules\Inventory\Models\ProductCategory;
7+
use App\Modules\Inventory\Models\PurchaseRequisition;
78
use App\Modules\Inventory\Models\StockAdjustment;
89
use App\Modules\Inventory\Models\WarehouseTransfer;
910
use App\Modules\Inventory\Policies\ProductCategoryPolicy;
1011
use App\Modules\Inventory\Policies\ProductPolicy;
12+
use App\Modules\Inventory\Policies\PurchaseRequisitionPolicy;
1113
use App\Modules\Inventory\Policies\StockAdjustmentPolicy;
1214
use App\Modules\Inventory\Policies\WarehouseTransferPolicy;
1315
use Illuminate\Support\Facades\Gate;
@@ -25,5 +27,6 @@ public function boot(): void
2527
Gate::policy(ProductCategory::class, ProductCategoryPolicy::class);
2628
Gate::policy(WarehouseTransfer::class, WarehouseTransferPolicy::class);
2729
Gate::policy(StockAdjustment::class, StockAdjustmentPolicy::class);
30+
Gate::policy(PurchaseRequisition::class, PurchaseRequisitionPolicy::class);
2831
}
2932
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use App\Modules\Inventory\Http\Controllers\ProductCategoryController;
55
use App\Modules\Inventory\Http\Controllers\ProductController;
66
use App\Modules\Inventory\Http\Controllers\PurchaseOrderController;
7+
use App\Modules\Inventory\Http\Controllers\PurchaseRequisitionController;
78
use App\Modules\Inventory\Http\Controllers\ReorderController;
89
use App\Modules\Inventory\Http\Controllers\StockAdjustmentController;
910
use App\Modules\Inventory\Http\Controllers\StockMovementController;
@@ -67,4 +68,10 @@
6768
Route::resource('stock-adjustments', StockAdjustmentController::class)->except(['edit', 'update']);
6869
Route::post('stock-adjustments/{stockAdjustment}/confirm', [StockAdjustmentController::class, 'confirm'])->name('stock-adjustments.confirm');
6970
Route::post('stock-adjustments/{stockAdjustment}/cancel', [StockAdjustmentController::class, 'cancel'])->name('stock-adjustments.cancel');
71+
72+
// Purchase Requisitions
73+
Route::resource('purchase-requisitions', PurchaseRequisitionController::class)->except(['edit', 'update']);
74+
Route::post('purchase-requisitions/{purchaseRequisition}/submit', [PurchaseRequisitionController::class, 'submit'])->name('purchase-requisitions.submit');
75+
Route::post('purchase-requisitions/{purchaseRequisition}/approve', [PurchaseRequisitionController::class, 'approve'])->name('purchase-requisitions.approve');
76+
Route::post('purchase-requisitions/{purchaseRequisition}/reject', [PurchaseRequisitionController::class, 'reject'])->name('purchase-requisitions.reject');
7077
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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('purchase_requisitions', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
14+
$table->string('reference')->unique();
15+
$table->foreignId('requested_by')->nullable()->nullOnDelete()->constrained('users');
16+
$table->foreignId('approved_by')->nullable()->nullOnDelete()->constrained('users');
17+
$table->enum('status', ['draft', 'submitted', 'approved', 'rejected'])->default('draft');
18+
$table->date('needed_by')->nullable();
19+
$table->text('notes')->nullable();
20+
$table->text('rejection_reason')->nullable();
21+
$table->timestamp('approved_at')->nullable();
22+
$table->timestamps();
23+
$table->softDeletes();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('purchase_requisitions');
30+
}
31+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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('purchase_requisition_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('purchase_requisition_id')->constrained('purchase_requisitions')->cascadeOnDelete();
14+
$table->foreignId('product_id')->nullable()->nullOnDelete()->constrained('products');
15+
$table->string('description');
16+
$table->decimal('quantity', 10, 2)->default(1);
17+
$table->decimal('estimated_unit_cost', 15, 2)->default(0);
18+
$table->timestamps();
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists('purchase_requisition_items');
25+
}
26+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const navItems: NavItem[] = [
5353
{ label: 'Transfers', href: '/inventory/warehouse-transfers', icon: inventoryIcon },
5454
{ label: 'Reorder', href: '/inventory/reorder', icon: inventoryIcon },
5555
{ label: 'Stock Adjustments', href: '/inventory/stock-adjustments', icon: inventoryIcon },
56+
{ label: 'Requisitions', href: '/inventory/purchase-requisitions', icon: inventoryIcon },
5657
],
5758
},
5859
{

0 commit comments

Comments
 (0)