Skip to content

Commit c6748fb

Browse files
committed
feat(inventory): Phase 79 — Product Variants & Attributes with SKU matrix
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 47e2b86 commit c6748fb

18 files changed

Lines changed: 1016 additions & 1 deletion
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\ProductAttribute;
6+
use Illuminate\Http\Request;
7+
use Inertia\Inertia;
8+
9+
class ProductAttributeController
10+
{
11+
public function index()
12+
{
13+
$this->authorize('viewAny', ProductAttribute::class);
14+
$attributes = ProductAttribute::paginate(20);
15+
return Inertia::render('Inventory/ProductAttributes/Index', ['attributes' => $attributes]);
16+
}
17+
18+
public function store(Request $request)
19+
{
20+
$this->authorize('create', ProductAttribute::class);
21+
$data = $request->validate([
22+
'name' => 'required|string|max:100',
23+
'type' => 'required|in:text,select,color,number',
24+
'options' => 'nullable|array',
25+
'options.*' => 'string',
26+
]);
27+
$data['tenant_id'] = app('tenant')->id;
28+
ProductAttribute::create($data);
29+
return back()->with('success', 'Attribute created.');
30+
}
31+
32+
public function update(Request $request, ProductAttribute $productAttribute)
33+
{
34+
$this->authorize('update', $productAttribute);
35+
$data = $request->validate([
36+
'name' => 'required|string|max:100',
37+
'type' => 'required|in:text,select,color,number',
38+
'options' => 'nullable|array',
39+
'options.*' => 'string',
40+
]);
41+
$productAttribute->update($data);
42+
return back()->with('success', 'Attribute updated.');
43+
}
44+
45+
public function destroy(ProductAttribute $productAttribute)
46+
{
47+
$this->authorize('delete', $productAttribute);
48+
$productAttribute->delete();
49+
return back()->with('success', 'Attribute deleted.');
50+
}
51+
52+
private function authorize(string $ability, $model): void
53+
{
54+
if (auth()->user()->cannot($ability, $model)) {
55+
abort(403);
56+
}
57+
}
58+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\Product;
6+
use App\Modules\Inventory\Models\ProductAttribute;
7+
use App\Modules\Inventory\Models\ProductVariant;
8+
use App\Modules\Inventory\Models\ProductVariantValue;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
12+
class ProductVariantController
13+
{
14+
public function index(Request $request)
15+
{
16+
$this->authorize('viewAny', ProductVariant::class);
17+
$query = ProductVariant::with(['product', 'values.attribute']);
18+
if ($request->product_id) {
19+
$query->where('product_id', $request->product_id);
20+
}
21+
$variants = $query->paginate(20)->withQueryString();
22+
$products = Product::select('id', 'name', 'sku')->orderBy('name')->get();
23+
return Inertia::render('Inventory/ProductVariants/Index', [
24+
'variants' => $variants,
25+
'products' => $products,
26+
'filters' => $request->only('product_id'),
27+
]);
28+
}
29+
30+
public function create()
31+
{
32+
$this->authorize('create', ProductVariant::class);
33+
$products = Product::select('id', 'name', 'sku', 'sale_price')->orderBy('name')->get();
34+
$attributes = ProductAttribute::orderBy('name')->get();
35+
return Inertia::render('Inventory/ProductVariants/Create', [
36+
'products' => $products,
37+
'attributes' => $attributes,
38+
]);
39+
}
40+
41+
public function store(Request $request)
42+
{
43+
$this->authorize('create', ProductVariant::class);
44+
$data = $request->validate([
45+
'product_id' => 'required|exists:products,id',
46+
'sku' => 'required|string|unique:product_variants,sku',
47+
'name' => 'required|string|max:200',
48+
'price_adjustment' => 'nullable|numeric',
49+
'stock_quantity' => 'nullable|integer|min:0',
50+
'values' => 'nullable|array',
51+
'values.*.attribute_id' => 'required|exists:product_attributes,id',
52+
'values.*.value' => 'required|string',
53+
]);
54+
55+
$tenantId = app('tenant')->id;
56+
$variant = ProductVariant::create([
57+
'tenant_id' => $tenantId,
58+
'product_id' => $data['product_id'],
59+
'sku' => $data['sku'],
60+
'name' => $data['name'],
61+
'price_adjustment' => $data['price_adjustment'] ?? 0,
62+
'stock_quantity' => $data['stock_quantity'] ?? 0,
63+
]);
64+
65+
foreach ($data['values'] ?? [] as $v) {
66+
ProductVariantValue::create([
67+
'tenant_id' => $tenantId,
68+
'variant_id' => $variant->id,
69+
'attribute_id' => $v['attribute_id'],
70+
'value' => $v['value'],
71+
]);
72+
}
73+
74+
return redirect()->route('inventory.product-variants.show', $variant)
75+
->with('success', 'Variant created.');
76+
}
77+
78+
public function show(ProductVariant $productVariant)
79+
{
80+
$this->authorize('view', $productVariant);
81+
$productVariant->load(['product', 'values.attribute']);
82+
return Inertia::render('Inventory/ProductVariants/Show', [
83+
'variant' => $productVariant,
84+
]);
85+
}
86+
87+
public function destroy(ProductVariant $productVariant)
88+
{
89+
$this->authorize('delete', $productVariant);
90+
$productVariant->delete();
91+
return redirect()->route('inventory.product-variants.index')
92+
->with('success', 'Variant deleted.');
93+
}
94+
95+
public function adjustStock(Request $request, ProductVariant $productVariant)
96+
{
97+
$this->authorize('update', $productVariant);
98+
$data = $request->validate(['delta' => 'required|integer']);
99+
$productVariant->adjustStock($data['delta']);
100+
return back()->with('success', 'Stock adjusted.');
101+
}
102+
103+
private function authorize(string $ability, $model): void
104+
{
105+
if (auth()->user()->cannot($ability, $model)) {
106+
abort(403);
107+
}
108+
}
109+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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\HasMany;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class ProductAttribute extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = ['tenant_id', 'name', 'type', 'options'];
15+
16+
protected $casts = ['options' => 'array'];
17+
18+
public function values(): HasMany
19+
{
20+
return $this->hasMany(ProductVariantValue::class, 'attribute_id');
21+
}
22+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class ProductVariant extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'product_id', 'sku', 'name',
17+
'price_adjustment', 'stock_quantity', 'is_active',
18+
];
19+
20+
protected $casts = ['is_active' => 'boolean', 'price_adjustment' => 'float', 'stock_quantity' => 'integer'];
21+
22+
public function product(): BelongsTo
23+
{
24+
return $this->belongsTo(Product::class);
25+
}
26+
27+
public function values(): HasMany
28+
{
29+
return $this->hasMany(ProductVariantValue::class, 'variant_id');
30+
}
31+
32+
public function getEffectivePriceAttribute(): float
33+
{
34+
return (float) ($this->product?->sale_price ?? 0) + (float) $this->price_adjustment;
35+
}
36+
37+
public function adjustStock(int $delta): void
38+
{
39+
$this->stock_quantity = max(0, $this->stock_quantity + $delta);
40+
$this->save();
41+
}
42+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 ProductVariantValue extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = ['tenant_id', 'variant_id', 'attribute_id', 'value'];
14+
15+
public function variant(): BelongsTo
16+
{
17+
return $this->belongsTo(ProductVariant::class, 'variant_id');
18+
}
19+
20+
public function attribute(): BelongsTo
21+
{
22+
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
23+
}
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use Illuminate\Auth\Access\HandlesAuthorization;
7+
8+
class ProductVariantPolicy
9+
{
10+
use HandlesAuthorization;
11+
12+
public function viewAny(User $user): bool { return $user->hasPermissionTo('inventory.view'); }
13+
public function view(User $user, $model): bool { return $user->hasPermissionTo('inventory.view'); }
14+
public function create(User $user): bool { return $user->hasPermissionTo('inventory.create'); }
15+
public function update(User $user, $model): bool { return $user->hasPermissionTo('inventory.create'); }
16+
public function delete(User $user, $model): bool { return $user->hasPermissionTo('inventory.delete'); }
17+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
use App\Modules\Inventory\Models\AssetMaintenance;
77
use App\Modules\Inventory\Models\BinStockLocation;
88
use App\Modules\Inventory\Models\DemandForecast;
9+
use App\Modules\Inventory\Models\ProductAttribute;
10+
use App\Modules\Inventory\Models\ProductVariant;
11+
use App\Modules\Inventory\Models\ProductVariantValue;
12+
use App\Modules\Inventory\Policies\ProductVariantPolicy;
913
use App\Modules\Inventory\Models\ForecastAlert;
1014
use App\Modules\Inventory\Models\Product;
1115
use App\Modules\Inventory\Models\ProductBundleItem;
@@ -68,5 +72,8 @@ public function boot(): void
6872
Gate::policy(WarehouseBin::class, WarehouseBinPolicy::class);
6973
Gate::policy(WarehouseZone::class, WarehouseBinPolicy::class);
7074
Gate::policy(BinStockLocation::class, WarehouseBinPolicy::class);
75+
Gate::policy(ProductAttribute::class, ProductVariantPolicy::class);
76+
Gate::policy(ProductVariant::class, ProductVariantPolicy::class);
77+
Gate::policy(ProductVariantValue::class, ProductVariantPolicy::class);
7178
}
7279
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
use App\Modules\Inventory\Http\Controllers\WarehouseStockController;
2222
use App\Modules\Inventory\Http\Controllers\WarehouseZoneController;
2323
use App\Modules\Inventory\Http\Controllers\DemandForecastController;
24+
use App\Modules\Inventory\Http\Controllers\ProductAttributeController;
25+
use App\Modules\Inventory\Http\Controllers\ProductVariantController;
2426
use App\Modules\Inventory\Http\Controllers\StockTransferController;
2527
use Illuminate\Support\Facades\Route;
2628

@@ -134,6 +136,13 @@
134136
Route::post('warehouse-bins/{warehouseBin}/stock', [WarehouseBinController::class, 'addStock'])->name('warehouse-bins.stock.add');
135137
Route::delete('warehouse-bins/{warehouseBin}/stock/{location}', [WarehouseBinController::class, 'removeStock'])->name('warehouse-bins.stock.remove');
136138
Route::resource('warehouse-bins', WarehouseBinController::class)->except(['edit', 'update']);
139+
140+
// Product Attributes
141+
Route::resource('product-attributes', ProductAttributeController::class)->except(['show', 'create', 'edit']);
142+
143+
// Product Variants
144+
Route::patch('product-variants/{productVariant}/adjust-stock', [ProductVariantController::class, 'adjustStock'])->name('product-variants.adjust-stock');
145+
Route::resource('product-variants', ProductVariantController::class)->except(['edit', 'update']);
137146
});
138147

139148
// Demand Forecasting - custom actions BEFORE resource
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
public function up(): void
9+
{
10+
Schema::create('product_attributes', function (Blueprint $table) {
11+
$table->id();
12+
$table->unsignedBigInteger('tenant_id');
13+
$table->string('name');
14+
$table->string('type')->default('text');
15+
$table->json('options')->nullable();
16+
$table->timestamps();
17+
$table->softDeletes();
18+
});
19+
}
20+
21+
public function down(): void
22+
{
23+
Schema::dropIfExists('product_attributes');
24+
}
25+
};
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+
public function up(): void
9+
{
10+
Schema::create('product_variants', function (Blueprint $table) {
11+
$table->id();
12+
$table->unsignedBigInteger('tenant_id');
13+
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
14+
$table->string('sku')->unique();
15+
$table->string('name');
16+
$table->decimal('price_adjustment', 10, 2)->default(0);
17+
$table->integer('stock_quantity')->default(0);
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_variants');
27+
}
28+
};

0 commit comments

Comments
 (0)