Skip to content

Commit 0948e15

Browse files
committed
feat(inventory): Phase 118 — Product Substitutes & Alternatives
Implements product substitute/alternative linking for out-of-stock scenarios, including migration, model, policy, controller, routes, frontend stub, TS types, and 10 Pest tests (1220 total passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent cc3c57a commit 0948e15

10 files changed

Lines changed: 447 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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\ProductSubstitute;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class ProductSubstituteController extends Controller
15+
{
16+
public function index(Request $request, Product $product): Response
17+
{
18+
$this->authorize('viewAny', ProductSubstitute::class);
19+
20+
$substitutes = $product->substitutes()
21+
->with('substituteProduct')
22+
->get();
23+
24+
return Inertia::render('Inventory/ProductSubstitutes/Index', [
25+
'product' => $product,
26+
'substitutes' => $substitutes,
27+
]);
28+
}
29+
30+
public function store(Request $request, Product $product): RedirectResponse
31+
{
32+
$this->authorize('create', ProductSubstitute::class);
33+
34+
$validated = $request->validate([
35+
'substitute_product_id' => [
36+
'required',
37+
'exists:products,id',
38+
Rule::notIn([$product->id]),
39+
],
40+
'priority' => 'nullable|integer|min:1|max:10',
41+
'is_bidirectional' => 'boolean',
42+
'notes' => 'nullable|string',
43+
]);
44+
45+
$tenantId = auth()->user()->tenant_id;
46+
$isBidirectional = $validated['is_bidirectional'] ?? false;
47+
48+
ProductSubstitute::create([
49+
'tenant_id' => $tenantId,
50+
'product_id' => $product->id,
51+
'substitute_product_id' => $validated['substitute_product_id'],
52+
'priority' => $validated['priority'] ?? 1,
53+
'is_bidirectional' => $isBidirectional,
54+
'is_active' => true,
55+
'notes' => $validated['notes'] ?? null,
56+
]);
57+
58+
if ($isBidirectional) {
59+
ProductSubstitute::firstOrCreate(
60+
[
61+
'product_id' => $validated['substitute_product_id'],
62+
'substitute_product_id' => $product->id,
63+
],
64+
[
65+
'tenant_id' => $tenantId,
66+
'priority' => $validated['priority'] ?? 1,
67+
'is_bidirectional' => true,
68+
'is_active' => true,
69+
'notes' => $validated['notes'] ?? null,
70+
]
71+
);
72+
}
73+
74+
return back()->with('success', 'Product substitute added.');
75+
}
76+
77+
public function update(Request $request, Product $product, ProductSubstitute $productSubstitute): RedirectResponse
78+
{
79+
$this->authorize('update', $productSubstitute);
80+
81+
$validated = $request->validate([
82+
'priority' => 'nullable|integer|min:1|max:10',
83+
'is_active' => 'boolean',
84+
'notes' => 'nullable|string',
85+
]);
86+
87+
$productSubstitute->update($validated);
88+
89+
return back()->with('success', 'Product substitute updated.');
90+
}
91+
92+
public function destroy(Product $product, ProductSubstitute $productSubstitute): RedirectResponse
93+
{
94+
$this->authorize('delete', $productSubstitute);
95+
96+
$productSubstitute->delete();
97+
98+
return back()->with('success', 'Product substitute removed.');
99+
}
100+
}

erp/app/Modules/Inventory/Models/Product.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,16 @@ public function tags(): BelongsToMany
138138
->withTimestamps();
139139
}
140140

141+
public function substitutes(): HasMany
142+
{
143+
return $this->hasMany(ProductSubstitute::class, 'product_id')->orderBy('priority');
144+
}
145+
146+
public function activeSubstitutes(): HasMany
147+
{
148+
return $this->hasMany(ProductSubstitute::class, 'product_id')
149+
->where('is_active', true)
150+
->orderBy('priority');
151+
}
152+
141153
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
9+
class ProductSubstitute extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'product_id', 'substitute_product_id',
15+
'priority', 'is_bidirectional', 'is_active', 'notes',
16+
];
17+
18+
protected $casts = [
19+
'priority' => 'integer',
20+
'is_bidirectional' => 'boolean',
21+
'is_active' => 'boolean',
22+
];
23+
24+
public function product(): BelongsTo
25+
{
26+
return $this->belongsTo(Product::class, 'product_id');
27+
}
28+
29+
public function substituteProduct(): BelongsTo
30+
{
31+
return $this->belongsTo(Product::class, 'substitute_product_id');
32+
}
33+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\ProductSubstitute;
7+
8+
class ProductSubstitutePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, ProductSubstitute $productSubstitute): 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, ProductSubstitute $productSubstitute): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function delete(User $user, ProductSubstitute $productSubstitute): bool
31+
{
32+
return $user->can('inventory.delete');
33+
}
34+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
use App\Modules\Inventory\Policies\UnitOfMeasurePolicy;
6565
use App\Modules\Inventory\Models\ProductTag;
6666
use App\Modules\Inventory\Policies\ProductTagPolicy;
67+
use App\Modules\Inventory\Models\ProductSubstitute;
68+
use App\Modules\Inventory\Policies\ProductSubstitutePolicy;
6769
use Illuminate\Support\Facades\Gate;
6870
use Illuminate\Support\ServiceProvider;
6971

@@ -116,5 +118,6 @@ public function boot(): void
116118
Gate::policy(UnitOfMeasure::class, UnitOfMeasurePolicy::class);
117119
Gate::policy(CycleCount::class, CycleCountPolicy::class);
118120
Gate::policy(ProductTag::class, ProductTagPolicy::class);
121+
Gate::policy(ProductSubstitute::class, ProductSubstitutePolicy::class);
119122
}
120123
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,12 @@
240240
Route::post('products/{product}/tags', [ProductTagAssignmentController::class, 'attach'])->name('products.tags.attach');
241241
Route::delete('products/{product}/tags/{productTag}', [ProductTagAssignmentController::class, 'detach'])->name('products.tags.detach');
242242
});
243+
244+
// Product Substitutes (nested under products)
245+
use App\Modules\Inventory\Http\Controllers\ProductSubstituteController;
246+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
247+
Route::get( 'products/{product}/substitutes', [ProductSubstituteController::class, 'index'])->name('products.substitutes.index');
248+
Route::post( 'products/{product}/substitutes', [ProductSubstituteController::class, 'store'])->name('products.substitutes.store');
249+
Route::patch( 'products/{product}/substitutes/{productSubstitute}', [ProductSubstituteController::class, 'update'])->name('products.substitutes.update');
250+
Route::delete('products/{product}/substitutes/{productSubstitute}', [ProductSubstituteController::class, 'destroy'])->name('products.substitutes.destroy');
251+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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('product_substitutes', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('product_id');
15+
$table->unsignedBigInteger('substitute_product_id');
16+
$table->unsignedTinyInteger('priority')->default(1);
17+
$table->boolean('is_bidirectional')->default(false);
18+
$table->boolean('is_active')->default(true);
19+
$table->text('notes')->nullable();
20+
$table->timestamps();
21+
$table->unique(['product_id', 'substitute_product_id']);
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('product_substitutes');
28+
}
29+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Head } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import type { PageProps } from '@/types';
4+
import type { Product, ProductSubstitute } from '@/types/inventory';
5+
6+
interface Props extends PageProps {
7+
product: Product;
8+
substitutes: ProductSubstitute[];
9+
}
10+
11+
export default function ProductSubstitutesIndex({ product, substitutes }: Props) {
12+
return (
13+
<AppLayout>
14+
<Head title={`Substitutes for ${product.name}`} />
15+
<div className="space-y-6">
16+
<div className="flex items-center justify-between">
17+
<div>
18+
<h1 className="text-2xl font-semibold text-slate-900">Product Substitutes</h1>
19+
<p className="text-sm text-slate-500 mt-1">{product.name} &mdash; {substitutes.length} substitute(s)</p>
20+
</div>
21+
</div>
22+
23+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
24+
<table className="min-w-full divide-y divide-slate-200">
25+
<thead className="bg-slate-50">
26+
<tr>
27+
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Substitute Product</th>
28+
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Priority</th>
29+
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Bidirectional</th>
30+
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Active</th>
31+
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Notes</th>
32+
</tr>
33+
</thead>
34+
<tbody className="divide-y divide-slate-200 bg-white">
35+
{substitutes.map((sub) => (
36+
<tr key={sub.id}>
37+
<td className="px-4 py-3 text-sm text-slate-900">
38+
{sub.substitute_product?.name ?? sub.substitute_product_id}
39+
</td>
40+
<td className="px-4 py-3 text-sm text-slate-500">{sub.priority}</td>
41+
<td className="px-4 py-3 text-sm text-slate-500">{sub.is_bidirectional ? 'Yes' : 'No'}</td>
42+
<td className="px-4 py-3 text-sm text-slate-500">{sub.is_active ? 'Active' : 'Inactive'}</td>
43+
<td className="px-4 py-3 text-sm text-slate-500">{sub.notes ?? '-'}</td>
44+
</tr>
45+
))}
46+
{substitutes.length === 0 && (
47+
<tr>
48+
<td colSpan={5} className="px-4 py-8 text-center text-sm text-slate-500">
49+
No substitutes configured for this product.
50+
</td>
51+
</tr>
52+
)}
53+
</tbody>
54+
</table>
55+
</div>
56+
</div>
57+
</AppLayout>
58+
);
59+
}

erp/resources/js/types/inventory.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,14 @@ export interface ProductTag {
609609
is_active: boolean;
610610
product_count: number;
611611
}
612+
613+
export interface ProductSubstitute {
614+
id: number;
615+
product_id: number;
616+
substitute_product_id: number;
617+
priority: number;
618+
is_bidirectional: boolean;
619+
is_active: boolean;
620+
notes: string | null;
621+
substitute_product?: Product;
622+
}

0 commit comments

Comments
 (0)