Skip to content

Commit 1e5311a

Browse files
committed
feat(inventory): Phase 139 — Inventory Stock Reservations
Add stock reservation lifecycle: active → fulfilled/cancelled/expired, with partial fulfillment tracking and policy-gated CRUD + action routes. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 6154a74 commit 1e5311a

11 files changed

Lines changed: 447 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\StockReservation;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class StockReservationController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', StockReservation::class);
17+
18+
$reservations = StockReservation::latest()
19+
->paginate(20)
20+
->withQueryString();
21+
22+
return Inertia::render('Inventory/StockReservations/Index', [
23+
'reservations' => $reservations,
24+
]);
25+
}
26+
27+
public function create(): Response
28+
{
29+
$this->authorize('create', StockReservation::class);
30+
31+
return Inertia::render('Inventory/StockReservations/Create');
32+
}
33+
34+
public function store(Request $request): RedirectResponse
35+
{
36+
$this->authorize('create', StockReservation::class);
37+
38+
$validated = $request->validate([
39+
'product_id' => 'required|exists:products,id',
40+
'quantity' => 'required|numeric|min:0.01',
41+
'reserved_until' => 'nullable|date',
42+
'reference_type' => 'nullable|string',
43+
'reference_id' => 'nullable|string',
44+
'notes' => 'nullable|string',
45+
]);
46+
47+
$reservation = StockReservation::create([
48+
'tenant_id' => app('tenant')->id,
49+
'reserved_by' => auth()->id(),
50+
...$validated,
51+
]);
52+
53+
$reservation->reservation_number = $reservation->generateReservationNumber();
54+
$reservation->save();
55+
56+
return redirect()->route('inventory.stock-reservations.index');
57+
}
58+
59+
public function show(StockReservation $stockReservation): Response
60+
{
61+
$this->authorize('view', $stockReservation);
62+
63+
return Inertia::render('Inventory/StockReservations/Show', [
64+
'reservation' => $stockReservation,
65+
]);
66+
}
67+
68+
public function edit(StockReservation $stockReservation): Response
69+
{
70+
$this->authorize('update', $stockReservation);
71+
72+
return Inertia::render('Inventory/StockReservations/Edit', [
73+
'reservation' => $stockReservation,
74+
]);
75+
}
76+
77+
public function update(Request $request, StockReservation $stockReservation): RedirectResponse
78+
{
79+
$this->authorize('update', $stockReservation);
80+
81+
$validated = $request->validate([
82+
'product_id' => 'sometimes|exists:products,id',
83+
'quantity' => 'sometimes|numeric|min:0.01',
84+
'reserved_until' => 'nullable|date',
85+
'reference_type' => 'nullable|string',
86+
'reference_id' => 'nullable|string',
87+
'notes' => 'nullable|string',
88+
]);
89+
90+
$stockReservation->update($validated);
91+
92+
return redirect()->route('inventory.stock-reservations.index');
93+
}
94+
95+
public function destroy(StockReservation $stockReservation): RedirectResponse
96+
{
97+
$this->authorize('delete', $stockReservation);
98+
99+
$stockReservation->delete();
100+
101+
return redirect()->route('inventory.stock-reservations.index');
102+
}
103+
104+
public function fulfill(Request $request, StockReservation $stockReservation): RedirectResponse
105+
{
106+
$this->authorize('fulfill', $stockReservation);
107+
108+
$validated = $request->validate([
109+
'quantity' => 'required|numeric|min:0.01',
110+
]);
111+
112+
$stockReservation->fulfill((float) $validated['quantity']);
113+
114+
return redirect()->route('inventory.stock-reservations.index');
115+
}
116+
117+
public function cancel(StockReservation $stockReservation): RedirectResponse
118+
{
119+
$this->authorize('cancel', $stockReservation);
120+
121+
$stockReservation->cancel();
122+
123+
return redirect()->route('inventory.stock-reservations.index');
124+
}
125+
126+
public function expire(StockReservation $stockReservation): RedirectResponse
127+
{
128+
$this->authorize('expire', $stockReservation);
129+
130+
$stockReservation->expire();
131+
132+
return redirect()->route('inventory.stock-reservations.index');
133+
}
134+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class StockReservation extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'product_id',
18+
'reservation_number',
19+
'reference_type',
20+
'reference_id',
21+
'quantity',
22+
'quantity_fulfilled',
23+
'reserved_until',
24+
'status',
25+
'notes',
26+
'reserved_by',
27+
];
28+
29+
protected $attributes = [
30+
'status' => 'active',
31+
'quantity_fulfilled' => 0,
32+
];
33+
34+
protected $casts = [
35+
'quantity' => 'decimal:2',
36+
'quantity_fulfilled' => 'decimal:2',
37+
'reserved_until' => 'date',
38+
];
39+
40+
// Relations
41+
42+
public function product(): BelongsTo
43+
{
44+
return $this->belongsTo(Product::class);
45+
}
46+
47+
// Mutating methods
48+
49+
public function fulfill(float $quantity): void
50+
{
51+
$this->quantity_fulfilled = (float) $this->quantity_fulfilled + $quantity;
52+
if ((float) $this->quantity_fulfilled >= (float) $this->quantity) {
53+
$this->status = 'fulfilled';
54+
}
55+
$this->save();
56+
}
57+
58+
public function cancel(): void
59+
{
60+
$this->status = 'cancelled';
61+
$this->save();
62+
}
63+
64+
public function expire(): void
65+
{
66+
$this->status = 'expired';
67+
$this->save();
68+
}
69+
70+
public function generateReservationNumber(): string
71+
{
72+
return 'SR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
73+
}
74+
75+
// Accessors
76+
77+
public function getQuantityRemainingAttribute(): float
78+
{
79+
return max(0, (float) $this->quantity - (float) $this->quantity_fulfilled);
80+
}
81+
82+
public function getIsActiveAttribute(): bool
83+
{
84+
return $this->status === 'active';
85+
}
86+
87+
public function getIsFulfilledAttribute(): bool
88+
{
89+
return $this->status === 'fulfilled';
90+
}
91+
92+
public function getIsExpiredAttribute(): bool
93+
{
94+
if ($this->status === 'expired') {
95+
return true;
96+
}
97+
98+
if ($this->status === 'active' && $this->reserved_until !== null) {
99+
return $this->reserved_until->lt(now()->startOfDay());
100+
}
101+
102+
return false;
103+
}
104+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
7+
class StockReservationPolicy
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 fulfill(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('inventory.create');
32+
}
33+
34+
public function cancel(User $user, $model): bool
35+
{
36+
return $user->hasPermissionTo('inventory.create');
37+
}
38+
39+
public function expire(User $user, $model): bool
40+
{
41+
return $user->hasPermissionTo('inventory.create');
42+
}
43+
44+
public function delete(User $user, $model): bool
45+
{
46+
return $user->hasPermissionTo('inventory.delete');
47+
}
48+
}

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\StockReservation;
80+
use App\Modules\Inventory\Policies\StockReservationPolicy;
7981
use Illuminate\Support\Facades\Gate;
8082
use Illuminate\Support\ServiceProvider;
8183

@@ -134,5 +136,6 @@ public function boot(): void
134136
Gate::policy(ReorderRule::class, ReorderRulePolicy::class);
135137
Gate::policy(SupplierScorecard::class, SupplierScorecardPolicy::class);
136138
Gate::policy(QualityAlert::class, QualityAlertPolicy::class);
139+
Gate::policy(StockReservation::class, StockReservationPolicy::class);
137140
}
138141
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,12 @@
280280
Route::post('quality-alerts/{quality_alert}/close', [QualityAlertController::class, 'close'])->name('quality-alerts.close');
281281
Route::resource('quality-alerts', QualityAlertController::class);
282282
});
283+
284+
// Stock Reservations
285+
use App\Modules\Inventory\Http\Controllers\StockReservationController;
286+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
287+
Route::post('stock-reservations/{stock_reservation}/fulfill', [StockReservationController::class, 'fulfill'])->name('stock-reservations.fulfill');
288+
Route::post('stock-reservations/{stock_reservation}/cancel', [StockReservationController::class, 'cancel'])->name('stock-reservations.cancel');
289+
Route::post('stock-reservations/{stock_reservation}/expire', [StockReservationController::class, 'expire'])->name('stock-reservations.expire');
290+
Route::resource('stock-reservations', StockReservationController::class);
291+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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('stock_reservations');
12+
Schema::create('stock_reservations', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
16+
$table->string('reservation_number')->nullable();
17+
$table->string('reference_type')->nullable();
18+
$table->string('reference_id')->nullable();
19+
$table->decimal('quantity', 15, 2);
20+
$table->decimal('quantity_fulfilled', 15, 2)->default(0);
21+
$table->date('reserved_until')->nullable();
22+
$table->string('status')->default('active');
23+
$table->text('notes')->nullable();
24+
$table->foreignId('reserved_by')->nullable()->constrained('users')->nullOnDelete();
25+
$table->timestamps();
26+
$table->softDeletes();
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::dropIfExists('stock_reservations');
33+
}
34+
};
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>Index</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)