Skip to content

Commit b857ea1

Browse files
committed
feat(inventory): Phase 58 — Product Bundles with component tracking
Implements kit/bundle products composed of multiple component products, with stock sufficiency checking for bundle assembly. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 8647dd3 commit b857ea1

14 files changed

Lines changed: 1039 additions & 1 deletion

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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\ProductBundleItem;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\DB;
11+
use Illuminate\Validation\Rule;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class ProductBundleController extends Controller
16+
{
17+
public function index(): Response
18+
{
19+
$this->authorize('viewAny', Product::class);
20+
21+
$bundles = Product::with('bundleItems.componentProduct')
22+
->where('is_bundle', true)
23+
->paginate(15);
24+
25+
return Inertia::render('Inventory/ProductBundles/Index', [
26+
'bundles' => $bundles,
27+
]);
28+
}
29+
30+
public function create(): Response
31+
{
32+
$this->authorize('create', Product::class);
33+
34+
$products = Product::where('is_bundle', false)
35+
->orderBy('name')
36+
->get(['id', 'name', 'sku']);
37+
38+
return Inertia::render('Inventory/ProductBundles/Create', [
39+
'products' => $products,
40+
]);
41+
}
42+
43+
public function store(Request $request): RedirectResponse
44+
{
45+
$this->authorize('create', Product::class);
46+
47+
$validated = $request->validate([
48+
'name' => ['required', 'string', 'max:255'],
49+
'sku' => ['nullable', 'string', 'max:100'],
50+
'description' => ['nullable', 'string'],
51+
'selling_price' => ['nullable', 'numeric', 'min:0'],
52+
'items' => ['required', 'array', 'min:1'],
53+
'items.*.component_product_id' => ['required', Rule::exists('products', 'id')],
54+
'items.*.quantity' => ['required', 'numeric', 'min:0.0001'],
55+
]);
56+
57+
$bundleId = null;
58+
59+
DB::transaction(function () use ($validated, &$bundleId) {
60+
$bundle = Product::create([
61+
'name' => $validated['name'],
62+
'sku' => $validated['sku'] ?? null,
63+
'description' => $validated['description'] ?? null,
64+
'sale_price' => $validated['selling_price'] ?? 0,
65+
'is_bundle' => true,
66+
'is_active' => true,
67+
'cost_price' => 0,
68+
]);
69+
70+
foreach ($validated['items'] as $item) {
71+
ProductBundleItem::create([
72+
'bundle_product_id' => $bundle->id,
73+
'component_product_id' => $item['component_product_id'],
74+
'quantity' => $item['quantity'],
75+
]);
76+
}
77+
78+
$bundleId = $bundle->id;
79+
});
80+
81+
return redirect()->route('inventory.product-bundles.show', $bundleId)
82+
->with('success', 'Bundle created successfully.');
83+
}
84+
85+
public function show(Product $productBundle): Response
86+
{
87+
$this->authorize('view', $productBundle);
88+
89+
$productBundle->load('bundleItems.componentProduct');
90+
91+
$products = Product::where('is_bundle', false)
92+
->orderBy('name')
93+
->get(['id', 'name', 'sku']);
94+
95+
return Inertia::render('Inventory/ProductBundles/Show', [
96+
'bundle' => $productBundle,
97+
'products' => $products,
98+
]);
99+
}
100+
101+
public function destroy(Product $productBundle): RedirectResponse
102+
{
103+
$this->authorize('delete', $productBundle);
104+
105+
$productBundle->delete();
106+
107+
return redirect()->route('inventory.product-bundles.index')
108+
->with('success', 'Bundle deleted.');
109+
}
110+
111+
public function addItem(Request $request, Product $productBundle): RedirectResponse
112+
{
113+
$this->authorize('create', Product::class);
114+
115+
$validated = $request->validate([
116+
'component_product_id' => [
117+
'required',
118+
Rule::exists('products', 'id'),
119+
Rule::unique('product_bundle_items')
120+
->where(fn ($q) => $q->where('bundle_product_id', $productBundle->id)),
121+
],
122+
'quantity' => ['required', 'numeric', 'min:0.0001'],
123+
]);
124+
125+
ProductBundleItem::create([
126+
'bundle_product_id' => $productBundle->id,
127+
'component_product_id' => $validated['component_product_id'],
128+
'quantity' => $validated['quantity'],
129+
]);
130+
131+
return redirect()->route('inventory.product-bundles.show', $productBundle)
132+
->with('success', 'Component added.');
133+
}
134+
135+
public function removeItem(Product $productBundle, ProductBundleItem $item): RedirectResponse
136+
{
137+
$this->authorize('delete', $productBundle);
138+
139+
$item->delete();
140+
141+
return redirect()->route('inventory.product-bundles.show', $productBundle)
142+
->with('success', 'Component removed.');
143+
}
144+
}

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Product extends Model
1919
'tenant_id', 'sku', 'name', 'description',
2020
'category_id', 'uom_id', 'cost_price',
2121
'sale_price', 'reorder_point', 'reorder_quantity',
22-
'preferred_supplier_id', 'is_active',
22+
'preferred_supplier_id', 'is_active', 'is_bundle', 'stock_quantity',
2323
];
2424

2525
protected $casts = [
@@ -28,6 +28,8 @@ class Product extends Model
2828
'reorder_point' => 'float',
2929
'reorder_quantity' => 'float',
3030
'is_active' => 'boolean',
31+
'is_bundle' => 'boolean',
32+
'stock_quantity' => 'decimal:4',
3133
];
3234

3335
public function category(): BelongsTo
@@ -55,6 +57,35 @@ public function preferredSupplier(): BelongsTo
5557
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
5658
}
5759

60+
public function bundleItems(): HasMany
61+
{
62+
return $this->hasMany(ProductBundleItem::class, 'bundle_product_id');
63+
}
64+
65+
public function componentInBundles(): HasMany
66+
{
67+
return $this->hasMany(ProductBundleItem::class, 'component_product_id');
68+
}
69+
70+
public function getStockSufficientForBundleAttribute(): bool
71+
{
72+
if (! $this->is_bundle) {
73+
return true;
74+
}
75+
76+
foreach ($this->bundleItems as $item) {
77+
$component = $item->componentProduct;
78+
if ($component === null) {
79+
return false;
80+
}
81+
if ((float) $component->stock_quantity < (float) $item->quantity) {
82+
return false;
83+
}
84+
}
85+
86+
return true;
87+
}
88+
5889
public function getTotalQuantityAttribute(): float
5990
{
6091
return (float) $this->stockLevels()->sum('quantity');
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 ProductBundleItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'bundle_product_id',
16+
'component_product_id',
17+
'quantity',
18+
];
19+
20+
protected $casts = [
21+
'quantity' => 'decimal:4',
22+
];
23+
24+
public function bundleProduct(): BelongsTo
25+
{
26+
return $this->belongsTo(Product::class, 'bundle_product_id');
27+
}
28+
29+
public function componentProduct(): BelongsTo
30+
{
31+
return $this->belongsTo(Product::class, 'component_product_id');
32+
}
33+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Modules\Inventory\Models\Asset;
66
use App\Modules\Inventory\Models\AssetMaintenance;
77
use App\Modules\Inventory\Models\Product;
8+
use App\Modules\Inventory\Models\ProductBundleItem;
89
use App\Modules\Inventory\Models\ProductCategory;
910
use App\Modules\Inventory\Models\PurchaseRequisition;
1011
use App\Modules\Inventory\Models\StockAdjustment;
@@ -33,5 +34,6 @@ public function boot(): void
3334
Gate::policy(PurchaseRequisition::class, PurchaseRequisitionPolicy::class);
3435
Gate::policy(Asset::class, AssetPolicy::class);
3536
Gate::policy(AssetMaintenance::class, AssetPolicy::class);
37+
Gate::policy(ProductBundleItem::class, ProductPolicy::class);
3638
}
3739
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use App\Modules\Inventory\Http\Controllers\AssetController;
44
use App\Modules\Inventory\Http\Controllers\AssetMaintenanceController;
55
use App\Modules\Inventory\Http\Controllers\CategoryController;
6+
use App\Modules\Inventory\Http\Controllers\ProductBundleController;
67
use App\Modules\Inventory\Http\Controllers\ProductCategoryController;
78
use App\Modules\Inventory\Http\Controllers\ProductController;
89
use App\Modules\Inventory\Http\Controllers\PurchaseOrderController;
@@ -85,4 +86,11 @@
8586
// Asset Maintenances
8687
Route::patch('asset-maintenances/{assetMaintenance}/complete', [AssetMaintenanceController::class, 'complete'])->name('asset-maintenances.complete');
8788
Route::resource('asset-maintenances', AssetMaintenanceController::class)->except(['edit', 'update']);
89+
90+
// Product Bundles
91+
Route::post('product-bundles/{productBundle}/items', [ProductBundleController::class, 'addItem'])->name('product-bundles.items.add');
92+
Route::delete('product-bundles/{productBundle}/items/{item}', [ProductBundleController::class, 'removeItem'])->name('product-bundles.items.remove');
93+
Route::resource('product-bundles', ProductBundleController::class)
94+
->except(['edit', 'update'])
95+
->parameters(['product-bundles' => 'productBundle']);
8896
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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_bundle_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->foreignId('bundle_product_id')->constrained('products')->cascadeOnDelete();
15+
$table->foreignId('component_product_id')->constrained('products')->cascadeOnDelete();
16+
$table->decimal('quantity', 10, 4);
17+
$table->timestamps();
18+
19+
$table->unique(['bundle_product_id', 'component_product_id']);
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('product_bundle_items');
26+
}
27+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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::table('products', function (Blueprint $table) {
12+
$table->boolean('is_bundle')->default(false)->after('is_active');
13+
$table->decimal('stock_quantity', 12, 4)->default(0)->after('is_bundle');
14+
});
15+
}
16+
17+
public function down(): void
18+
{
19+
Schema::table('products', function (Blueprint $table) {
20+
$table->dropColumn(['is_bundle', 'stock_quantity']);
21+
});
22+
}
23+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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::table('products', function (Blueprint $table) {
12+
$table->string('sku')->nullable()->change();
13+
});
14+
}
15+
16+
public function down(): void
17+
{
18+
Schema::table('products', function (Blueprint $table) {
19+
$table->string('sku')->nullable(false)->change();
20+
});
21+
}
22+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const navItems: NavItem[] = [
6767
{ label: 'Requisitions', href: '/inventory/purchase-requisitions', icon: inventoryIcon },
6868
{ label: 'Assets', href: '/inventory/assets', icon: <span /> },
6969
{ label: 'Maintenance', href: '/inventory/asset-maintenances', icon: <span /> },
70+
{ label: 'Bundles', href: '/inventory/product-bundles', icon: <span /> },
7071
],
7172
},
7273
{

0 commit comments

Comments
 (0)