Skip to content

Commit afb4359

Browse files
committed
feat(phase-39): inventory reorder suggestions API with urgency levels
- ReorderController: suggestions endpoint with threshold filter and urgency - Urgency levels: critical (0 stock), high (<50% of reorder point), medium - Deficit and suggested_qty calculations per product - Summary endpoint: total products, low stock, out of stock counts - Routes: GET /reorder/suggestions, GET /reorder/summary - 7 feature tests covering filtering, urgency, deficit calculation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 7314404 commit afb4359

4 files changed

Lines changed: 236 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ beforeEach(function () {
170170
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171171
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172172
| 38 | API Token Management — named tokens with abilities and expiry ||
173+
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
173174

174175
## File Locations Reference
175176

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Inventory\Models\Product;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
9+
class ReorderController extends ApiController
10+
{
11+
public function suggestions(Request $request): JsonResponse
12+
{
13+
$tenantId = $this->tenantId($request);
14+
$threshold = (float) $request->get('threshold', 1.0);
15+
16+
$products = Product::where('tenant_id', $tenantId)
17+
->where('is_active', true)
18+
->where('reorder_point', '>', 0)
19+
->with(['preferredSupplier'])
20+
->get()
21+
->map(fn ($p) => (object) [
22+
'product' => $p,
23+
'stock' => (float) $p->stock_quantity,
24+
])
25+
->filter(fn ($r) => ($r->stock / max($r->product->reorder_point, 1)) <= $threshold)
26+
->map(fn ($r) => [
27+
'product_id' => $r->product->id,
28+
'sku' => $r->product->sku,
29+
'name' => $r->product->name,
30+
'current_stock' => $r->stock,
31+
'reorder_point' => (float) $r->product->reorder_point,
32+
'reorder_quantity' => (float) ($r->product->reorder_quantity ?? 0),
33+
'deficit' => max(0.0, (float) $r->product->reorder_point - $r->stock),
34+
'suggested_qty' => max((float) ($r->product->reorder_quantity ?? 0), (float) $r->product->reorder_point - $r->stock),
35+
'supplier' => $r->product->preferredSupplier?->only(['id', 'name']),
36+
'urgency' => $r->stock <= 0 ? 'critical' : ($r->stock < $r->product->reorder_point * 0.5 ? 'high' : 'medium'),
37+
])
38+
->sortByDesc('urgency')
39+
->values();
40+
41+
return $this->success([
42+
'total_items' => $products->count(),
43+
'critical_count' => $products->where('urgency', 'critical')->count(),
44+
'high_count' => $products->where('urgency', 'high')->count(),
45+
'suggestions' => $products,
46+
]);
47+
}
48+
49+
public function summary(Request $request): JsonResponse
50+
{
51+
$tenantId = $this->tenantId($request);
52+
53+
$total = Product::where('tenant_id', $tenantId)->where('is_active', true)->count();
54+
$lowStock = Product::where('tenant_id', $tenantId)
55+
->where('is_active', true)
56+
->where('reorder_point', '>', 0)
57+
->where('stock_quantity', '<=', \DB::raw('reorder_point'))
58+
->count();
59+
$outOfStock = Product::where('tenant_id', $tenantId)
60+
->where('is_active', true)
61+
->where('stock_quantity', '<=', 0)
62+
->count();
63+
64+
return $this->success([
65+
'total_products' => $total,
66+
'low_stock_count' => $lowStock,
67+
'out_of_stock' => $outOfStock,
68+
'healthy_stock' => $total - $lowStock,
69+
]);
70+
}
71+
72+
private function tenantId(Request $request): int
73+
{
74+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
75+
}
76+
}

erp/routes/api.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,12 @@
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450450

451+
// Inventory Reorder Suggestions
452+
Route::prefix('reorder')->group(function () {
453+
Route::get('/suggestions', [\App\Http\Controllers\Api\V1\ReorderController::class, 'suggestions']);
454+
Route::get('/summary', [\App\Http\Controllers\Api\V1\ReorderController::class, 'summary']);
455+
});
456+
451457
// API Token Management
452458
Route::prefix('tokens')->group(function () {
453459
Route::get('/', [\App\Http\Controllers\Api\V1\ApiTokenController::class, 'index']);
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Inventory\Models\Product;
6+
use Database\Seeders\RolePermissionSeeder;
7+
8+
beforeEach(function () {
9+
$this->seed(RolePermissionSeeder::class);
10+
$this->tenant = Tenant::create(['name' => 'Reorder Co', 'slug' => 'reorder-co-' . uniqid()]);
11+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
12+
$this->user->assignRole('super-admin');
13+
$this->token = $this->user->createToken('test')->plainTextToken;
14+
app()->instance('tenant', $this->tenant);
15+
});
16+
17+
test('suggestions returns products below reorder point', function () {
18+
Product::create([
19+
'tenant_id' => $this->tenant->id,
20+
'sku' => 'SKU-LOW-' . uniqid(),
21+
'name' => 'Low Stock Item',
22+
'cost_price' => 10,
23+
'sale_price' => 20,
24+
'stock_quantity' => 2,
25+
'reorder_point' => 10,
26+
'reorder_quantity' => 50,
27+
]);
28+
29+
$response = $this->withToken($this->token)
30+
->getJson('/api/v1/reorder/suggestions')
31+
->assertStatus(200);
32+
33+
$data = $response->json('data');
34+
expect($data['total_items'])->toBeGreaterThan(0);
35+
expect($data['suggestions'])->not->toBeEmpty();
36+
expect($data['suggestions'][0]['name'])->toBe('Low Stock Item');
37+
});
38+
39+
test('suggestion includes urgency level', function () {
40+
Product::create([
41+
'tenant_id' => $this->tenant->id,
42+
'sku' => 'SKU-OUT-' . uniqid(),
43+
'name' => 'Out Of Stock',
44+
'cost_price' => 5,
45+
'sale_price' => 15,
46+
'stock_quantity' => 0,
47+
'reorder_point' => 20,
48+
]);
49+
50+
$response = $this->withToken($this->token)
51+
->getJson('/api/v1/reorder/suggestions')
52+
->assertStatus(200);
53+
54+
$suggestion = collect($response->json('data.suggestions'))
55+
->firstWhere('name', 'Out Of Stock');
56+
57+
expect($suggestion['urgency'])->toBe('critical');
58+
expect($suggestion['current_stock'])->toBe(0);
59+
});
60+
61+
test('products above threshold are excluded', function () {
62+
Product::create([
63+
'tenant_id' => $this->tenant->id,
64+
'sku' => 'SKU-FULL-' . uniqid(),
65+
'name' => 'Well Stocked',
66+
'cost_price' => 10,
67+
'sale_price' => 20,
68+
'stock_quantity' => 100,
69+
'reorder_point' => 10,
70+
]);
71+
72+
$response = $this->withToken($this->token)
73+
->getJson('/api/v1/reorder/suggestions')
74+
->assertStatus(200);
75+
76+
$names = collect($response->json('data.suggestions'))->pluck('name');
77+
expect($names)->not->toContain('Well Stocked');
78+
});
79+
80+
test('suggestion calculates deficit correctly', function () {
81+
Product::create([
82+
'tenant_id' => $this->tenant->id,
83+
'sku' => 'SKU-DEF-' . uniqid(),
84+
'name' => 'Deficit Item',
85+
'cost_price' => 10,
86+
'sale_price' => 20,
87+
'stock_quantity' => 3,
88+
'reorder_point' => 15,
89+
'reorder_quantity' => 25,
90+
]);
91+
92+
$response = $this->withToken($this->token)
93+
->getJson('/api/v1/reorder/suggestions')
94+
->assertStatus(200);
95+
96+
$suggestion = collect($response->json('data.suggestions'))
97+
->firstWhere('name', 'Deficit Item');
98+
99+
expect($suggestion['deficit'])->toBe(12);
100+
expect($suggestion['suggested_qty'])->toBe(25);
101+
});
102+
103+
test('summary returns stock health metrics', function () {
104+
Product::create([
105+
'tenant_id' => $this->tenant->id,
106+
'sku' => 'SKU-SUM-1-' . uniqid(),
107+
'name' => 'Healthy Product',
108+
'cost_price' => 10,
109+
'sale_price' => 20,
110+
'stock_quantity' => 50,
111+
'reorder_point' => 10,
112+
]);
113+
114+
Product::create([
115+
'tenant_id' => $this->tenant->id,
116+
'sku' => 'SKU-SUM-2-' . uniqid(),
117+
'name' => 'Low Product',
118+
'cost_price' => 10,
119+
'sale_price' => 20,
120+
'stock_quantity' => 2,
121+
'reorder_point' => 10,
122+
]);
123+
124+
$response = $this->withToken($this->token)
125+
->getJson('/api/v1/reorder/summary')
126+
->assertStatus(200)
127+
->assertJsonStructure(['data' => ['total_products', 'low_stock_count', 'out_of_stock', 'healthy_stock']]);
128+
129+
expect($response->json('data.total_products'))->toBeGreaterThanOrEqual(2);
130+
});
131+
132+
test('products without reorder point not included', function () {
133+
Product::create([
134+
'tenant_id' => $this->tenant->id,
135+
'sku' => 'SKU-NRP-' . uniqid(),
136+
'name' => 'No Reorder Point',
137+
'cost_price' => 10,
138+
'sale_price' => 20,
139+
'stock_quantity' => 0,
140+
'reorder_point' => 0,
141+
]);
142+
143+
$response = $this->withToken($this->token)
144+
->getJson('/api/v1/reorder/suggestions')
145+
->assertStatus(200);
146+
147+
$names = collect($response->json('data.suggestions'))->pluck('name');
148+
expect($names)->not->toContain('No Reorder Point');
149+
});
150+
151+
test('requires authentication', function () {
152+
$this->getJson('/api/v1/reorder/suggestions')->assertStatus(401);
153+
});

0 commit comments

Comments
 (0)