Skip to content

Commit 33f5ba6

Browse files
committed
feat(inventory): Phase 126 — Inventory Product Bundles (revised)
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 4e3a969 commit 33f5ba6

8 files changed

Lines changed: 277 additions & 153 deletions

File tree

erp/app/Modules/Inventory/Http/Controllers/ProductBundleController.php

Lines changed: 46 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,107 +3,92 @@
33
namespace App\Modules\Inventory\Http\Controllers;
44

55
use App\Http\Controllers\Controller;
6-
use App\Modules\Inventory\Models\Product;
6+
use App\Modules\Inventory\Models\ProductBundle;
77
use App\Modules\Inventory\Models\ProductBundleItem;
88
use Illuminate\Http\RedirectResponse;
99
use Illuminate\Http\Request;
10-
use Illuminate\Validation\Rule;
1110
use Inertia\Inertia;
1211
use Inertia\Response;
1312

1413
class ProductBundleController extends Controller
1514
{
1615
public function index(): Response
1716
{
18-
$this->authorize('viewAny', Product::class);
19-
$bundles = Product::with('bundleItems.componentProduct')
20-
->where('tenant_id', app('tenant')->id)
21-
->where('is_bundle', true)
17+
$this->authorize('viewAny', ProductBundle::class);
18+
19+
$bundles = ProductBundle::where('tenant_id', app('tenant')->id)
20+
->latest()
2221
->paginate(20);
22+
2323
return Inertia::render('Inventory/ProductBundles/Index', compact('bundles'));
2424
}
2525

2626
public function store(Request $request): RedirectResponse
2727
{
28-
$this->authorize('create', Product::class);
28+
$this->authorize('create', ProductBundle::class);
29+
2930
$validated = $request->validate([
30-
'name' => 'required|string|max:255',
31-
'sku' => 'nullable|string|max:100',
32-
'description' => 'nullable|string',
33-
'selling_price' => 'nullable|numeric|min:0',
34-
'items' => 'required|array|min:1',
35-
'items.*.component_product_id' => ['required', Rule::exists('products', 'id')],
36-
'items.*.quantity' => 'required|numeric|min:0.0001',
31+
'name' => 'required|string|max:255',
32+
'sku' => 'nullable|string|max:100',
33+
'description' => 'nullable|string',
34+
'bundle_price' => 'nullable|numeric|min:0',
35+
'is_active' => 'nullable|boolean',
3736
]);
3837

39-
$bundle = Product::create([
40-
'tenant_id' => app('tenant')->id,
41-
'name' => $validated['name'],
42-
'sku' => $validated['sku'] ?? null,
43-
'description' => $validated['description'] ?? null,
44-
'sale_price' => $validated['selling_price'] ?? 0,
45-
'cost_price' => 0,
46-
'is_bundle' => true,
47-
'is_active' => true,
48-
]);
38+
$validated['tenant_id'] = app('tenant')->id;
39+
40+
ProductBundle::create($validated);
4941

50-
foreach ($validated['items'] as $item) {
51-
ProductBundleItem::create([
52-
'tenant_id' => app('tenant')->id,
53-
'bundle_product_id' => $bundle->id,
54-
'component_product_id' => $item['component_product_id'],
55-
'quantity' => $item['quantity'],
56-
]);
57-
}
58-
59-
return redirect()->route('inventory.product-bundles.show', $bundle)
60-
->with('success', 'Bundle created.');
42+
return back()->with('success', 'Product bundle created.');
6143
}
6244

63-
public function show(Product $productBundle): Response
45+
public function show(ProductBundle $productBundle): Response
6446
{
6547
$this->authorize('view', $productBundle);
66-
$productBundle->load('bundleItems.componentProduct');
67-
return Inertia::render('Inventory/ProductBundles/Show', ['bundle' => $productBundle]);
48+
49+
$productBundle->load('items.product');
50+
51+
return Inertia::render('Inventory/ProductBundles/Show', compact('productBundle'));
6852
}
6953

70-
public function addItem(Request $request, Product $productBundle): RedirectResponse
54+
public function addItem(Request $request, ProductBundle $productBundle): RedirectResponse
7155
{
7256
$this->authorize('update', $productBundle);
57+
7358
$validated = $request->validate([
74-
'component_product_id' => [
75-
'required',
76-
Rule::exists('products', 'id'),
77-
Rule::unique('product_bundle_items')
78-
->where(fn ($q) => $q->where('bundle_product_id', $productBundle->id)),
79-
],
80-
'quantity' => 'required|numeric|min:0.0001',
59+
'product_id' => 'required|exists:products,id',
60+
'quantity' => 'required|numeric|min:0.01',
8161
]);
8262

83-
ProductBundleItem::create([
84-
'tenant_id' => app('tenant')->id,
85-
'bundle_product_id' => $productBundle->id,
86-
'component_product_id' => $validated['component_product_id'],
87-
'quantity' => $validated['quantity'],
88-
]);
63+
ProductBundleItem::firstOrCreate(
64+
[
65+
'product_bundle_id' => $productBundle->id,
66+
'product_id' => $validated['product_id'],
67+
],
68+
[
69+
'tenant_id' => app('tenant')->id,
70+
'quantity' => $validated['quantity'],
71+
]
72+
);
8973

90-
return redirect()->route('inventory.product-bundles.show', $productBundle)
91-
->with('success', 'Component added.');
74+
return back()->with('success', 'Item added to bundle.');
9275
}
9376

94-
public function removeItem(Product $productBundle, ProductBundleItem $item): RedirectResponse
77+
public function removeItem(ProductBundle $productBundle, ProductBundleItem $item): RedirectResponse
9578
{
96-
$this->authorize('delete', $productBundle);
79+
$this->authorize('update', $productBundle);
80+
9781
$item->delete();
98-
return redirect()->route('inventory.product-bundles.show', $productBundle)
99-
->with('success', 'Component removed.');
82+
83+
return back()->with('success', 'Item removed from bundle.');
10084
}
10185

102-
public function destroy(Product $productBundle): RedirectResponse
86+
public function destroy(ProductBundle $productBundle): RedirectResponse
10387
{
10488
$this->authorize('delete', $productBundle);
89+
10590
$productBundle->delete();
106-
return redirect()->route('inventory.product-bundles.index')
107-
->with('success', 'Bundle deleted.');
91+
92+
return back()->with('success', 'Product bundle deleted.');
10893
}
10994
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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\BelongsToMany;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class ProductBundle extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $attributes = [
17+
'is_active' => true,
18+
];
19+
20+
protected $fillable = [
21+
'tenant_id',
22+
'name',
23+
'sku',
24+
'description',
25+
'bundle_price',
26+
'is_active',
27+
];
28+
29+
protected $casts = [
30+
'bundle_price' => 'float',
31+
'is_active' => 'boolean',
32+
];
33+
34+
public function items(): HasMany
35+
{
36+
return $this->hasMany(ProductBundleItem::class);
37+
}
38+
39+
public function products(): BelongsToMany
40+
{
41+
return $this->belongsToMany(Product::class, 'product_bundle_items')
42+
->withPivot('quantity')
43+
->withTimestamps();
44+
}
45+
46+
public function calculatePrice(): float
47+
{
48+
if (! is_null($this->bundle_price)) {
49+
return (float) $this->bundle_price;
50+
}
51+
52+
return (float) $this->items->sum(function ($item) {
53+
$product = $item->product;
54+
if ($product === null) {
55+
return 0;
56+
}
57+
$price = isset($product->sale_price) ? (float) $product->sale_price : 0.0;
58+
return $price * (float) $item->quantity;
59+
});
60+
}
61+
62+
public function activate(): void
63+
{
64+
$this->update(['is_active' => true]);
65+
}
66+
67+
public function deactivate(): void
68+
{
69+
$this->update(['is_active' => false]);
70+
}
71+
72+
public function getItemCountAttribute(): int
73+
{
74+
return $this->items()->count();
75+
}
76+
}

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,23 @@ class ProductBundleItem extends Model
1111
use BelongsToTenant;
1212

1313
protected $fillable = [
14-
'tenant_id', 'bundle_product_id', 'component_product_id', 'quantity',
14+
'tenant_id',
15+
'product_bundle_id',
16+
'product_id',
17+
'quantity',
1518
];
1619

1720
protected $casts = [
1821
'quantity' => 'float',
1922
];
2023

21-
public function bundleProduct(): BelongsTo
24+
public function bundle(): BelongsTo
2225
{
23-
return $this->belongsTo(Product::class, 'bundle_product_id');
26+
return $this->belongsTo(ProductBundle::class, 'product_bundle_id');
2427
}
2528

26-
public function componentProduct(): BelongsTo
29+
public function product(): BelongsTo
2730
{
28-
return $this->belongsTo(Product::class, 'component_product_id');
31+
return $this->belongsTo(Product::class);
2932
}
3033
}
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\ProductBundle;
7+
8+
class ProductBundlePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('inventory.view');
13+
}
14+
15+
public function view(User $user, ProductBundle $productBundle): 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, ProductBundle $productBundle): bool
26+
{
27+
return $user->hasPermissionTo('inventory.create');
28+
}
29+
30+
public function delete(User $user, ProductBundle $productBundle): bool
31+
{
32+
return $user->hasPermissionTo('inventory.delete');
33+
}
34+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
use App\Modules\Inventory\Policies\ProductVariantPolicy;
1313
use App\Modules\Inventory\Models\ForecastAlert;
1414
use App\Modules\Inventory\Models\Product;
15+
use App\Modules\Inventory\Models\ProductBundle;
1516
use App\Modules\Inventory\Models\ProductBundleItem;
17+
use App\Modules\Inventory\Policies\ProductBundlePolicy;
1618
use App\Modules\Inventory\Models\ProductCategory;
1719
use App\Modules\Inventory\Models\PurchaseRequisition;
1820
use App\Modules\Inventory\Models\QcChecklist;
@@ -86,7 +88,6 @@ public function boot(): void
8688
Gate::policy(PurchaseRequisition::class, PurchaseRequisitionPolicy::class);
8789
Gate::policy(Asset::class, AssetPolicy::class);
8890
Gate::policy(AssetMaintenance::class, AssetPolicy::class);
89-
Gate::policy(ProductBundleItem::class, ProductPolicy::class);
9091
Gate::policy(WarehouseStock::class, StockTransferPolicy::class);
9192
Gate::policy(StockTransfer::class, StockTransferPolicy::class);
9293
Gate::policy(StockTransferItem::class, StockTransferPolicy::class);
@@ -122,5 +123,7 @@ public function boot(): void
122123
Gate::policy(ProductTag::class, ProductTagPolicy::class);
123124
Gate::policy(ProductSubstitute::class, ProductSubstitutePolicy::class);
124125
Gate::policy(Backorder::class, BackorderPolicy::class);
126+
Gate::policy(ProductBundle::class, ProductBundlePolicy::class);
127+
Gate::policy(ProductBundleItem::class, ProductBundlePolicy::class);
125128
}
126129
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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_bundles', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->string('sku')->nullable();
16+
$table->text('description')->nullable();
17+
$table->decimal('bundle_price', 15, 2)->nullable(); // null means sum of components
18+
$table->boolean('is_active')->default(true);
19+
$table->timestamps();
20+
$table->softDeletes();
21+
});
22+
}
23+
24+
public function down(): void
25+
{
26+
Schema::dropIfExists('product_bundles');
27+
}
28+
};
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::dropIfExists('product_bundle_items');
12+
Schema::create('product_bundle_items', function (Blueprint $table) {
13+
$table->id();
14+
$table->unsignedBigInteger('tenant_id');
15+
$table->unsignedBigInteger('product_bundle_id');
16+
$table->unsignedBigInteger('product_id');
17+
$table->decimal('quantity', 10, 2)->default(1);
18+
$table->timestamps();
19+
$table->unique(['product_bundle_id', 'product_id']);
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('product_bundle_items');
26+
}
27+
};

0 commit comments

Comments
 (0)