Skip to content

Commit 444701a

Browse files
committed
feat(inventory): Phase 130 — Inventory Reorder Rules
Add full Reorder Rules module: migration (with warehouses FK), model with trigger/pause/resume methods and accessor properties, RBAC policy, resource controller with custom action routes, InventoryServiceProvider registration, React page stubs, and 9 feature tests (1321 passing total). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 5780308 commit 444701a

11 files changed

Lines changed: 432 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\ReorderRule;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class ReorderRuleController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', ReorderRule::class);
17+
18+
$rules = ReorderRule::with(['product'])
19+
->latest()
20+
->paginate(20)
21+
->withQueryString();
22+
23+
return Inertia::render('Inventory/ReorderRules/Index', [
24+
'rules' => $rules,
25+
]);
26+
}
27+
28+
public function create(): Response
29+
{
30+
$this->authorize('create', ReorderRule::class);
31+
32+
return Inertia::render('Inventory/ReorderRules/Create');
33+
}
34+
35+
public function store(Request $request): RedirectResponse
36+
{
37+
$this->authorize('create', ReorderRule::class);
38+
39+
$validated = $request->validate([
40+
'product_id' => 'required|exists:products,id',
41+
'reorder_point' => 'required|numeric|min:0',
42+
'reorder_quantity' => 'required|numeric|min:0',
43+
'warehouse_id' => 'nullable|exists:warehouses,id',
44+
'max_stock_level' => 'nullable|numeric|min:0',
45+
'rule_type' => 'nullable|string|in:fixed,dynamic',
46+
'is_active' => 'nullable|boolean',
47+
]);
48+
49+
ReorderRule::create([
50+
'tenant_id' => app('tenant')->id,
51+
'created_by' => auth()->id(),
52+
...$validated,
53+
]);
54+
55+
return redirect()->route('inventory.reorder-rules.index')
56+
->with('success', 'Reorder rule created.');
57+
}
58+
59+
public function show(ReorderRule $reorderRule): Response
60+
{
61+
$this->authorize('view', $reorderRule);
62+
63+
$reorderRule->load(['product']);
64+
65+
return Inertia::render('Inventory/ReorderRules/Show', [
66+
'rule' => $reorderRule,
67+
]);
68+
}
69+
70+
public function edit(ReorderRule $reorderRule): Response
71+
{
72+
$this->authorize('update', $reorderRule);
73+
74+
$reorderRule->load(['product']);
75+
76+
return Inertia::render('Inventory/ReorderRules/Edit', [
77+
'rule' => $reorderRule,
78+
]);
79+
}
80+
81+
public function update(Request $request, ReorderRule $reorderRule): RedirectResponse
82+
{
83+
$this->authorize('update', $reorderRule);
84+
85+
$validated = $request->validate([
86+
'product_id' => 'required|exists:products,id',
87+
'reorder_point' => 'required|numeric|min:0',
88+
'reorder_quantity' => 'required|numeric|min:0',
89+
'warehouse_id' => 'nullable|exists:warehouses,id',
90+
'max_stock_level' => 'nullable|numeric|min:0',
91+
'rule_type' => 'nullable|string|in:fixed,dynamic',
92+
'is_active' => 'nullable|boolean',
93+
]);
94+
95+
$reorderRule->update($validated);
96+
97+
return redirect()->route('inventory.reorder-rules.index')
98+
->with('success', 'Reorder rule updated.');
99+
}
100+
101+
public function destroy(ReorderRule $reorderRule): RedirectResponse
102+
{
103+
$this->authorize('delete', $reorderRule);
104+
105+
$reorderRule->delete();
106+
107+
return redirect()->route('inventory.reorder-rules.index')
108+
->with('success', 'Reorder rule deleted.');
109+
}
110+
111+
public function trigger(ReorderRule $reorderRule): RedirectResponse
112+
{
113+
$this->authorize('trigger', $reorderRule);
114+
115+
$reorderRule->trigger();
116+
117+
return back()->with('success', 'Reorder rule triggered.');
118+
}
119+
120+
public function pause(ReorderRule $reorderRule): RedirectResponse
121+
{
122+
$this->authorize('pause', $reorderRule);
123+
124+
$reorderRule->pause();
125+
126+
return back()->with('success', 'Reorder rule paused.');
127+
}
128+
129+
public function resume(ReorderRule $reorderRule): RedirectResponse
130+
{
131+
$this->authorize('resume', $reorderRule);
132+
133+
$reorderRule->resume();
134+
135+
return back()->with('success', 'Reorder rule resumed.');
136+
}
137+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 ReorderRule extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'product_id',
18+
'warehouse_id',
19+
'rule_number',
20+
'reorder_point',
21+
'reorder_quantity',
22+
'max_stock_level',
23+
'rule_type',
24+
'is_active',
25+
'status',
26+
'last_triggered_at',
27+
'created_by',
28+
];
29+
30+
protected $attributes = [
31+
'status' => 'active',
32+
'rule_type' => 'fixed',
33+
'is_active' => true,
34+
'reorder_point' => 0,
35+
'reorder_quantity' => 0,
36+
];
37+
38+
protected $casts = [
39+
'reorder_point' => 'decimal:2',
40+
'reorder_quantity' => 'decimal:2',
41+
'max_stock_level' => 'decimal:2',
42+
'is_active' => 'boolean',
43+
'last_triggered_at' => 'datetime',
44+
];
45+
46+
public function product(): BelongsTo
47+
{
48+
return $this->belongsTo(Product::class);
49+
}
50+
51+
public function trigger(): void
52+
{
53+
$this->status = 'triggered';
54+
$this->last_triggered_at = now();
55+
$this->save();
56+
}
57+
58+
public function pause(): void
59+
{
60+
$this->status = 'paused';
61+
$this->save();
62+
}
63+
64+
public function resume(): void
65+
{
66+
$this->status = 'active';
67+
$this->save();
68+
}
69+
70+
public function generateRuleNumber(): string
71+
{
72+
return 'RR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
73+
}
74+
75+
public function getIsTriggeredAttribute(): bool
76+
{
77+
return $this->status === 'triggered';
78+
}
79+
80+
public function getIsPausedAttribute(): bool
81+
{
82+
return $this->status === 'paused';
83+
}
84+
85+
public function getNeedsReorderAttribute(): bool
86+
{
87+
return $this->is_active && $this->status === 'active';
88+
}
89+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\ReorderRule;
7+
8+
class ReorderRulePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('inventory.view');
13+
}
14+
15+
public function view(User $user, ReorderRule $r): bool
16+
{
17+
return $user->hasPermissionTo('inventory.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('inventory.create');
23+
}
24+
25+
public function update(User $user, ReorderRule $r): bool
26+
{
27+
return $user->hasPermissionTo('inventory.create');
28+
}
29+
30+
public function delete(User $user, ReorderRule $r): bool
31+
{
32+
return $user->hasPermissionTo('inventory.delete');
33+
}
34+
35+
public function trigger(User $user, ReorderRule $r): bool
36+
{
37+
return $user->hasPermissionTo('inventory.create');
38+
}
39+
40+
public function pause(User $user, ReorderRule $r): bool
41+
{
42+
return $user->hasPermissionTo('inventory.create');
43+
}
44+
45+
public function resume(User $user, ReorderRule $r): bool
46+
{
47+
return $user->hasPermissionTo('inventory.create');
48+
}
49+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
use App\Modules\Inventory\Policies\ProductSubstitutePolicy;
7171
use App\Modules\Inventory\Models\Backorder;
7272
use App\Modules\Inventory\Policies\BackorderPolicy;
73+
use App\Modules\Inventory\Models\ReorderRule;
74+
use App\Modules\Inventory\Policies\ReorderRulePolicy;
7375
use Illuminate\Support\Facades\Gate;
7476
use Illuminate\Support\ServiceProvider;
7577

@@ -125,5 +127,6 @@ public function boot(): void
125127
Gate::policy(Backorder::class, BackorderPolicy::class);
126128
Gate::policy(ProductBundle::class, ProductBundlePolicy::class);
127129
Gate::policy(ProductBundleItem::class, ProductBundlePolicy::class);
130+
Gate::policy(ReorderRule::class, ReorderRulePolicy::class);
128131
}
129132
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,12 @@
255255
Route::post('backorders/{backorder}/cancel', [BackorderController::class, 'cancel'])->name('backorders.cancel');
256256
Route::resource('backorders', BackorderController::class)->only(['index', 'store', 'show', 'destroy']);
257257
});
258+
259+
// Reorder Rules
260+
use App\Modules\Inventory\Http\Controllers\ReorderRuleController;
261+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
262+
Route::post('reorder-rules/{reorder_rule}/trigger', [ReorderRuleController::class, 'trigger'])->name('reorder-rules.trigger');
263+
Route::post('reorder-rules/{reorder_rule}/pause', [ReorderRuleController::class, 'pause'])->name('reorder-rules.pause');
264+
Route::post('reorder-rules/{reorder_rule}/resume', [ReorderRuleController::class, 'resume'])->name('reorder-rules.resume');
265+
Route::resource('reorder-rules', ReorderRuleController::class);
266+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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('reorder_rules');
12+
Schema::create('reorder_rules', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
16+
$table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete();
17+
$table->string('rule_number')->nullable();
18+
$table->decimal('reorder_point', 15, 2)->default(0);
19+
$table->decimal('reorder_quantity', 15, 2)->default(0);
20+
$table->decimal('max_stock_level', 15, 2)->nullable();
21+
$table->string('rule_type')->default('fixed');
22+
$table->boolean('is_active')->default(true);
23+
$table->string('status')->default('active');
24+
$table->timestamp('last_triggered_at')->nullable();
25+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
26+
$table->timestamps();
27+
$table->softDeletes();
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('reorder_rules');
34+
}
35+
};
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)