Skip to content

Commit d02d3ac

Browse files
committed
Phase 50: Inventory Valuation API
Adds stock value calculation endpoints: summary (total cost value by tenant), breakdown (per-product with margin), movement history, and low-value-stock alert. Uses sale_price/cost_price × stock_quantity; excludes zero-stock items. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a925307 commit d02d3ac

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
use Illuminate\Support\Facades\DB;
9+
10+
class InventoryValuationController extends ApiController
11+
{
12+
public function summary(Request $request): JsonResponse
13+
{
14+
$tenantId = $this->tenantId($request);
15+
16+
$products = Product::where('tenant_id', $tenantId)
17+
->where('is_active', true)
18+
->where('stock_quantity', '>', 0)
19+
->get(['id', 'name', 'sku', 'stock_quantity', 'cost_price']);
20+
21+
$totalValue = $products->sum(fn ($p) => (float) $p->stock_quantity * (float) $p->cost_price);
22+
23+
return $this->success([
24+
'total_value' => round($totalValue, 2),
25+
'product_count' => $products->count(),
26+
'valuation_method' => 'average_cost',
27+
'as_of' => now()->toDateString(),
28+
]);
29+
}
30+
31+
public function breakdown(Request $request): JsonResponse
32+
{
33+
$tenantId = $this->tenantId($request);
34+
$minValue = (float) $request->get('min_value', 0);
35+
36+
$products = Product::where('tenant_id', $tenantId)
37+
->where('is_active', true)
38+
->where('stock_quantity', '>', 0)
39+
->get(['id', 'name', 'sku', 'stock_quantity', 'cost_price', 'sale_price']);
40+
41+
$rows = $products->map(fn ($p) => [
42+
'product_id' => $p->id,
43+
'name' => $p->name,
44+
'sku' => $p->sku,
45+
'stock_quantity' => (float) $p->stock_quantity,
46+
'cost_price' => (float) $p->cost_price,
47+
'stock_value' => round((float) $p->stock_quantity * (float) $p->cost_price, 2),
48+
'retail_value' => round((float) $p->stock_quantity * (float) $p->sale_price, 2),
49+
'potential_margin' => (float) $p->sale_price > 0
50+
? round((((float) $p->sale_price - (float) $p->cost_price) / (float) $p->sale_price) * 100, 1)
51+
: null,
52+
])->filter(fn ($row) => $row['stock_value'] >= $minValue)
53+
->sortByDesc('stock_value')
54+
->values();
55+
56+
$totalCostValue = $rows->sum('stock_value');
57+
$totalRetailValue = $rows->sum('retail_value');
58+
59+
return $this->success([
60+
'products' => $rows,
61+
'total_cost_value' => round($totalCostValue, 2),
62+
'total_retail_value' => round($totalRetailValue, 2),
63+
'total_potential_margin' => $totalRetailValue > 0
64+
? round((($totalRetailValue - $totalCostValue) / $totalRetailValue) * 100, 1)
65+
: null,
66+
'count' => $rows->count(),
67+
]);
68+
}
69+
70+
public function movement(Request $request): JsonResponse
71+
{
72+
$tenantId = $this->tenantId($request);
73+
$from = $request->get('from', now()->subMonths(3)->toDateString());
74+
$to = $request->get('to', now()->toDateString());
75+
76+
$movements = DB::table('stock_movements')
77+
->where('tenant_id', $tenantId)
78+
->whereDate('created_at', '>=', $from)
79+
->whereDate('created_at', '<=', $to)
80+
->select([
81+
'type as movement_type',
82+
DB::raw('COUNT(*) as transaction_count'),
83+
DB::raw('SUM(quantity) as total_quantity'),
84+
])
85+
->groupBy('type')
86+
->get();
87+
88+
return $this->success([
89+
'period' => ['from' => $from, 'to' => $to],
90+
'movements' => $movements,
91+
]);
92+
}
93+
94+
public function lowValueStock(Request $request): JsonResponse
95+
{
96+
$tenantId = $this->tenantId($request);
97+
$threshold = (float) $request->get('threshold', 100);
98+
99+
$products = Product::where('tenant_id', $tenantId)
100+
->where('is_active', true)
101+
->where('stock_quantity', '>', 0)
102+
->get(['id', 'name', 'sku', 'stock_quantity', 'cost_price'])
103+
->map(fn ($p) => [
104+
'product_id' => $p->id,
105+
'name' => $p->name,
106+
'sku' => $p->sku,
107+
'stock_quantity' => (float) $p->stock_quantity,
108+
'cost_price' => (float) $p->cost_price,
109+
'stock_value' => round((float) $p->stock_quantity * (float) $p->cost_price, 2),
110+
])
111+
->filter(fn ($r) => $r['stock_value'] <= $threshold)
112+
->sortBy('stock_value')
113+
->values();
114+
115+
return $this->success([
116+
'threshold' => $threshold,
117+
'products' => $products,
118+
'count' => $products->count(),
119+
]);
120+
}
121+
122+
private function tenantId(Request $request): int
123+
{
124+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
125+
}
126+
}

erp/routes/api.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,14 @@
456456
Route::post('/{contact}/evaluate', [\App\Http\Controllers\Api\V1\VendorPerformanceController::class, 'evaluate']);
457457
});
458458

459+
// Inventory Valuation
460+
Route::prefix('inventory-valuation')->group(function () {
461+
Route::get('/summary', [\App\Http\Controllers\Api\V1\InventoryValuationController::class, 'summary']);
462+
Route::get('/breakdown', [\App\Http\Controllers\Api\V1\InventoryValuationController::class, 'breakdown']);
463+
Route::get('/movement', [\App\Http\Controllers\Api\V1\InventoryValuationController::class, 'movement']);
464+
Route::get('/low-value', [\App\Http\Controllers\Api\V1\InventoryValuationController::class, 'lowValueStock']);
465+
});
466+
459467
// Contract Management
460468
Route::prefix('contracts')->group(function () {
461469
Route::get('/expiring-soon', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'expiringSoon']);
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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' => 'Valuation Co', 'slug' => 'val-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+
function createInventoryProduct(array $attrs = []): Product
18+
{
19+
return Product::create([
20+
'tenant_id' => test()->tenant->id,
21+
'name' => 'Product ' . uniqid(),
22+
'sku' => 'SKU-' . uniqid(),
23+
'sale_price' => 20.00,
24+
'cost_price' => 10.00,
25+
'stock_quantity' => 100,
26+
'is_active' => true,
27+
...$attrs,
28+
]);
29+
}
30+
31+
test('valuation summary returns total value', function () {
32+
createInventoryProduct(['cost_price' => 10.00, 'stock_quantity' => 50]);
33+
createInventoryProduct(['cost_price' => 20.00, 'stock_quantity' => 25]);
34+
35+
$response = $this->withToken($this->token)
36+
->getJson('/api/v1/inventory-valuation/summary')
37+
->assertStatus(200)
38+
->assertJsonStructure(['data' => ['total_value', 'product_count', 'valuation_method', 'as_of']]);
39+
40+
expect((float) $response->json('data.total_value'))->toBe(1000.0);
41+
expect($response->json('data.product_count'))->toBe(2);
42+
});
43+
44+
test('valuation breakdown lists products with margin', function () {
45+
createInventoryProduct(['cost_price' => 5.00, 'sale_price' => 10.00, 'stock_quantity' => 100]);
46+
47+
$response = $this->withToken($this->token)
48+
->getJson('/api/v1/inventory-valuation/breakdown')
49+
->assertStatus(200)
50+
->assertJsonStructure(['data' => ['products', 'total_cost_value', 'total_retail_value', 'count']]);
51+
52+
expect($response->json('data.count'))->toBe(1);
53+
expect((float) $response->json('data.products.0.stock_value'))->toBe(500.0);
54+
expect((float) $response->json('data.products.0.potential_margin'))->toBe(50.0);
55+
});
56+
57+
test('breakdown min_value filter excludes low value products', function () {
58+
createInventoryProduct(['cost_price' => 1.00, 'stock_quantity' => 5]); // value = 5
59+
createInventoryProduct(['cost_price' => 10.00, 'stock_quantity' => 50]); // value = 500
60+
61+
$data = $this->withToken($this->token)
62+
->getJson('/api/v1/inventory-valuation/breakdown?min_value=100')
63+
->assertStatus(200)
64+
->json('data');
65+
66+
expect($data['count'])->toBe(1);
67+
expect((float) $data['products'][0]['stock_value'])->toBeGreaterThanOrEqual(100);
68+
});
69+
70+
test('breakdown returns products sorted by descending stock value', function () {
71+
createInventoryProduct(['cost_price' => 5.00, 'stock_quantity' => 10]); // value = 50
72+
createInventoryProduct(['cost_price' => 50.00, 'stock_quantity' => 10]); // value = 500
73+
74+
$data = $this->withToken($this->token)
75+
->getJson('/api/v1/inventory-valuation/breakdown')
76+
->assertStatus(200)
77+
->json('data');
78+
79+
expect((float) $data['products'][0]['stock_value'])->toBeGreaterThan((float) $data['products'][1]['stock_value']);
80+
});
81+
82+
test('movement summary returns movement type breakdown', function () {
83+
$response = $this->withToken($this->token)
84+
->getJson('/api/v1/inventory-valuation/movement')
85+
->assertStatus(200)
86+
->assertJsonStructure(['data' => ['period', 'movements']]);
87+
88+
expect($response->json('data.period'))->toHaveKey('from');
89+
expect($response->json('data.period'))->toHaveKey('to');
90+
});
91+
92+
test('low value stock endpoint filters by threshold', function () {
93+
createInventoryProduct(['cost_price' => 1.00, 'stock_quantity' => 5]); // value = 5
94+
createInventoryProduct(['cost_price' => 50.00, 'stock_quantity' => 100]); // value = 5000
95+
96+
$response = $this->withToken($this->token)
97+
->getJson('/api/v1/inventory-valuation/low-value?threshold=50')
98+
->assertStatus(200)
99+
->assertJsonStructure(['data' => ['threshold', 'products', 'count']]);
100+
101+
expect($response->json('data.count'))->toBe(1);
102+
expect((float) $response->json('data.products.0.stock_value'))->toBeLessThanOrEqual(50);
103+
});
104+
105+
test('no stock products are excluded from valuation', function () {
106+
createInventoryProduct(['stock_quantity' => 0]);
107+
createInventoryProduct(['stock_quantity' => 10, 'cost_price' => 5.00]);
108+
109+
$response = $this->withToken($this->token)
110+
->getJson('/api/v1/inventory-valuation/summary')
111+
->assertStatus(200);
112+
113+
expect($response->json('data.product_count'))->toBe(1);
114+
});
115+
116+
test('total retail value is calculated from sale_price', function () {
117+
createInventoryProduct(['cost_price' => 5.00, 'sale_price' => 15.00, 'stock_quantity' => 10]);
118+
119+
$data = $this->withToken($this->token)
120+
->getJson('/api/v1/inventory-valuation/breakdown')
121+
->assertStatus(200)
122+
->json('data');
123+
124+
expect((float) $data['total_retail_value'])->toBe(150.0);
125+
expect((float) $data['total_cost_value'])->toBe(50.0);
126+
});
127+
128+
test('requires authentication', function () {
129+
$this->getJson('/api/v1/inventory-valuation/summary')->assertStatus(401);
130+
});

0 commit comments

Comments
 (0)