Skip to content

Commit 6d30db1

Browse files
committed
feat(phase-36): product variants REST API with attribute matrix
- ProductVariantController: CRUD for product-attributes and product variants - variants() relationship on Product model; active() scope on ProductVariant - Routes: /product-attributes CRUD, /products/{product}/variants CRUD, /matrix - 11 API tests covering attributes, variants with values, matrix grid, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f844202 commit 6d30db1

6 files changed

Lines changed: 343 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ beforeEach(function () {
167167
| 33 | Smart Alert Rules — threshold monitoring + notifications ||
168168
| 34 | Budget Management REST API — CRUD + activate + variance ||
169169
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170+
| 36 | Product Variants REST API — attributes, variants, matrix view ||
170171

171172
## File Locations Reference
172173

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
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\JsonResponse;
10+
use Illuminate\Http\Request;
11+
12+
class ProductVariantController extends ApiController
13+
{
14+
// ── Attributes ────────────────────────────────────────────────
15+
16+
public function indexAttributes(Request $request): JsonResponse
17+
{
18+
$tenantId = $this->tenantId($request);
19+
$attributes = ProductAttribute::where('tenant_id', $tenantId)->get();
20+
return $this->success($attributes);
21+
}
22+
23+
public function storeAttribute(Request $request): JsonResponse
24+
{
25+
$tenantId = $this->tenantId($request);
26+
27+
$data = $request->validate([
28+
'name' => ['required', 'string', 'max:100'],
29+
'type' => ['required', 'in:text,select,color,size'],
30+
'options' => ['nullable', 'array'],
31+
]);
32+
33+
$attribute = ProductAttribute::create([...$data, 'tenant_id' => $tenantId]);
34+
return $this->success($attribute, 201);
35+
}
36+
37+
public function updateAttribute(Request $request, ProductAttribute $productAttribute): JsonResponse
38+
{
39+
$data = $request->validate([
40+
'name' => ['sometimes', 'string', 'max:100'],
41+
'type' => ['sometimes', 'in:text,select,color,size'],
42+
'options' => ['nullable', 'array'],
43+
]);
44+
45+
$productAttribute->update($data);
46+
return $this->success($productAttribute->fresh());
47+
}
48+
49+
public function destroyAttribute(ProductAttribute $productAttribute): JsonResponse
50+
{
51+
$productAttribute->delete();
52+
return $this->success(['message' => 'Attribute deleted.']);
53+
}
54+
55+
// ── Variants ─────────────────────────────────────────────────
56+
57+
public function index(Request $request, Product $product): JsonResponse
58+
{
59+
$variants = $product->variants()->with('values.attribute')->get();
60+
return $this->success($variants);
61+
}
62+
63+
public function store(Request $request, Product $product): JsonResponse
64+
{
65+
$tenantId = $this->tenantId($request);
66+
67+
$data = $request->validate([
68+
'name' => ['required', 'string', 'max:255'],
69+
'sku' => ['required', 'string', 'unique:product_variants,sku'],
70+
'price_adjustment' => ['nullable', 'numeric'],
71+
'stock_quantity' => ['nullable', 'integer', 'min:0'],
72+
'attributes' => ['nullable', 'array'],
73+
'attributes.*.attribute_id' => ['required', 'exists:product_attributes,id'],
74+
'attributes.*.value' => ['required', 'string'],
75+
]);
76+
77+
$variant = ProductVariant::create([
78+
'tenant_id' => $tenantId,
79+
'product_id' => $product->id,
80+
'name' => $data['name'],
81+
'sku' => $data['sku'],
82+
'price_adjustment' => $data['price_adjustment'] ?? 0,
83+
'stock_quantity' => $data['stock_quantity'] ?? 0,
84+
]);
85+
86+
foreach ($data['attributes'] ?? [] as $attr) {
87+
ProductVariantValue::create([
88+
'tenant_id' => $tenantId,
89+
'variant_id' => $variant->id,
90+
'attribute_id' => $attr['attribute_id'],
91+
'value' => $attr['value'],
92+
]);
93+
}
94+
95+
return $this->success($variant->load('values.attribute'), 201);
96+
}
97+
98+
public function update(Request $request, Product $product, ProductVariant $variant): JsonResponse
99+
{
100+
$data = $request->validate([
101+
'name' => ['sometimes', 'string', 'max:255'],
102+
'price_adjustment' => ['sometimes', 'numeric'],
103+
'stock_quantity' => ['sometimes', 'integer', 'min:0'],
104+
'is_active' => ['sometimes', 'boolean'],
105+
]);
106+
107+
$variant->update($data);
108+
return $this->success($variant->fresh()->load('values.attribute'));
109+
}
110+
111+
public function destroy(Product $product, ProductVariant $variant): JsonResponse
112+
{
113+
$variant->delete();
114+
return $this->success(['message' => 'Variant deleted.']);
115+
}
116+
117+
public function matrix(Request $request, Product $product): JsonResponse
118+
{
119+
$variants = $product->variants()->with('values.attribute')->active()->get();
120+
$attributes = $variants->flatMap(fn ($v) => $v->values)->pluck('attribute')->unique('id')->values();
121+
122+
return $this->success([
123+
'product' => $product->only(['id', 'name', 'sale_price']),
124+
'attributes' => $attributes,
125+
'variants' => $variants->map(fn ($v) => [
126+
'id' => $v->id,
127+
'name' => $v->name,
128+
'sku' => $v->sku,
129+
'price_adjustment' => $v->price_adjustment,
130+
'effective_price' => $v->effective_price,
131+
'stock_quantity' => $v->stock_quantity,
132+
'is_active' => $v->is_active,
133+
'attributes' => $v->values->mapWithKeys(fn ($val) => [$val->attribute?->name => $val->value]),
134+
]),
135+
]);
136+
}
137+
138+
private function tenantId(Request $request): int
139+
{
140+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
141+
}
142+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,9 @@ public function activeSubstitutes(): HasMany
152152
->orderBy('priority');
153153
}
154154

155+
public function variants(): HasMany
156+
{
157+
return $this->hasMany(ProductVariant::class);
158+
}
159+
155160
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,9 @@ public function adjustStock(int $delta): void
3939
$this->stock_quantity = max(0, $this->stock_quantity + $delta);
4040
$this->save();
4141
}
42+
43+
public function scopeActive($query)
44+
{
45+
return $query->where('is_active', true);
46+
}
4247
}

erp/routes/api.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,5 +432,20 @@
432432
Route::get('/contacts/{contact}/credit', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'show']);
433433
Route::put('/contacts/{contact}/credit', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'update']);
434434
Route::post('/contacts/{contact}/credit/check', [\App\Http\Controllers\Api\V1\CreditLimitController::class, 'check']);
435+
436+
// Product Variants & Attributes
437+
Route::prefix('product-attributes')->group(function () {
438+
Route::get('/', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'indexAttributes']);
439+
Route::post('/', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'storeAttribute']);
440+
Route::put('/{productAttribute}', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'updateAttribute']);
441+
Route::delete('/{productAttribute}', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'destroyAttribute']);
442+
});
443+
Route::prefix('products/{product}/variants')->group(function () {
444+
Route::get('/', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'index']);
445+
Route::post('/', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'store']);
446+
Route::put('/{variant}', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'update']);
447+
Route::delete('/{variant}',[\App\Http\Controllers\Api\V1\ProductVariantController::class, 'destroy']);
448+
});
449+
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
435450
});
436451
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Inventory\Models\Product;
6+
use App\Modules\Inventory\Models\ProductAttribute;
7+
use App\Modules\Inventory\Models\ProductVariant;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Variant API Co', 'slug' => 'variant-api-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
18+
$this->product = Product::create([
19+
'tenant_id' => $this->tenant->id,
20+
'sku' => 'PROD-VAPI-' . uniqid(),
21+
'name' => 'T-Shirt',
22+
'sale_price' => 25.00,
23+
'cost_price' => 10.00,
24+
]);
25+
});
26+
27+
test('can list product attributes via api', function () {
28+
ProductAttribute::create([
29+
'tenant_id' => $this->tenant->id,
30+
'name' => 'Color',
31+
'type' => 'select',
32+
'options' => ['Red', 'Blue', 'Green'],
33+
]);
34+
35+
$this->withToken($this->token)
36+
->getJson('/api/v1/product-attributes')
37+
->assertStatus(200)
38+
->assertJsonPath('data.0.name', 'Color');
39+
});
40+
41+
test('can create a product attribute via api', function () {
42+
$this->withToken($this->token)
43+
->postJson('/api/v1/product-attributes', [
44+
'name' => 'Size',
45+
'type' => 'select',
46+
'options' => ['S', 'M', 'L', 'XL'],
47+
])
48+
->assertStatus(201);
49+
50+
expect(ProductAttribute::where('name', 'Size')->exists())->toBeTrue();
51+
});
52+
53+
test('attribute type validation', function () {
54+
$this->withToken($this->token)
55+
->postJson('/api/v1/product-attributes', [
56+
'name' => 'Invalid',
57+
'type' => 'unknown',
58+
])
59+
->assertStatus(422)
60+
->assertJsonValidationErrors(['type']);
61+
});
62+
63+
test('can update a product attribute via api', function () {
64+
$attr = ProductAttribute::create([
65+
'tenant_id' => $this->tenant->id,
66+
'name' => 'Material',
67+
'type' => 'text',
68+
]);
69+
70+
$this->withToken($this->token)
71+
->putJson("/api/v1/product-attributes/{$attr->id}", ['name' => 'Fabric'])
72+
->assertStatus(200)
73+
->assertJsonPath('data.name', 'Fabric');
74+
});
75+
76+
test('can delete a product attribute via api', function () {
77+
$attr = ProductAttribute::create([
78+
'tenant_id' => $this->tenant->id,
79+
'name' => 'Delete Me',
80+
'type' => 'text',
81+
]);
82+
83+
$this->withToken($this->token)
84+
->deleteJson("/api/v1/product-attributes/{$attr->id}")
85+
->assertStatus(200);
86+
87+
expect(ProductAttribute::find($attr->id))->toBeNull();
88+
});
89+
90+
test('can list variants for a product via api', function () {
91+
ProductVariant::create([
92+
'tenant_id' => $this->tenant->id,
93+
'product_id' => $this->product->id,
94+
'sku' => 'TSH-RED-M-' . uniqid(),
95+
'name' => 'Red / M',
96+
]);
97+
98+
$this->withToken($this->token)
99+
->getJson("/api/v1/products/{$this->product->id}/variants")
100+
->assertStatus(200)
101+
->assertJsonPath('data.0.name', 'Red / M');
102+
});
103+
104+
test('can create a variant with attributes via api', function () {
105+
$attr = ProductAttribute::create([
106+
'tenant_id' => $this->tenant->id,
107+
'name' => 'Color',
108+
'type' => 'select',
109+
'options' => ['Red', 'Blue'],
110+
]);
111+
112+
$this->withToken($this->token)
113+
->postJson("/api/v1/products/{$this->product->id}/variants", [
114+
'name' => 'Blue / L',
115+
'sku' => 'TSH-BLUE-L-' . uniqid(),
116+
'price_adjustment' => 2.50,
117+
'stock_quantity' => 100,
118+
'attributes' => [
119+
['attribute_id' => $attr->id, 'value' => 'Blue'],
120+
],
121+
])
122+
->assertStatus(201);
123+
124+
expect(ProductVariant::where('name', 'Blue / L')->exists())->toBeTrue();
125+
});
126+
127+
test('can update a variant via api', function () {
128+
$variant = ProductVariant::create([
129+
'tenant_id' => $this->tenant->id,
130+
'product_id' => $this->product->id,
131+
'sku' => 'TSH-UPD-' . uniqid(),
132+
'name' => 'Old Name',
133+
]);
134+
135+
$this->withToken($this->token)
136+
->putJson("/api/v1/products/{$this->product->id}/variants/{$variant->id}", [
137+
'stock_quantity' => 50,
138+
])
139+
->assertStatus(200)
140+
->assertJsonPath('data.stock_quantity', 50);
141+
});
142+
143+
test('can delete a variant via api', function () {
144+
$variant = ProductVariant::create([
145+
'tenant_id' => $this->tenant->id,
146+
'product_id' => $this->product->id,
147+
'sku' => 'TSH-DEL-' . uniqid(),
148+
'name' => 'To Delete',
149+
]);
150+
151+
$this->withToken($this->token)
152+
->deleteJson("/api/v1/products/{$this->product->id}/variants/{$variant->id}")
153+
->assertStatus(200);
154+
155+
expect(ProductVariant::withTrashed()->find($variant->id)?->deleted_at)->not->toBeNull();
156+
});
157+
158+
test('matrix endpoint returns product variant grid', function () {
159+
ProductVariant::create([
160+
'tenant_id' => $this->tenant->id,
161+
'product_id' => $this->product->id,
162+
'sku' => 'TSH-MAT-' . uniqid(),
163+
'name' => 'Green / S',
164+
'is_active' => true,
165+
]);
166+
167+
$this->withToken($this->token)
168+
->getJson("/api/v1/products/{$this->product->id}/matrix")
169+
->assertStatus(200)
170+
->assertJsonStructure(['data' => ['product', 'attributes', 'variants']]);
171+
});
172+
173+
test('requires authentication for variants', function () {
174+
$this->getJson("/api/v1/products/{$this->product->id}/variants")->assertStatus(401);
175+
});

0 commit comments

Comments
 (0)