Skip to content

Commit 5d3cdb5

Browse files
committed
Phase 159: Inventory Reports + Multi-Warehouse
- InventoryReportController: stock-valuation, stock-movement, low-stock, abc-analysis - MultiWarehouseController: warehouse overview with stock summaries - warehouses table: address, city, country, phone, email, timezone, costing_method - React pages: Reports/StockValuation, StockMovement, LowStock, AbcAnalysis, MultiWarehouse/Index - Sidebar: Inventory Reports + Multi-Warehouse links added - 8 tests https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 536533b commit 5d3cdb5

12 files changed

Lines changed: 1130 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\Product;
6+
use App\Modules\Inventory\Models\StockMovement;
7+
use App\Modules\Inventory\Models\WarehouseStock;
8+
use App\Modules\Inventory\Models\Warehouse;
9+
use Carbon\Carbon;
10+
use Illuminate\Http\Request;
11+
use Illuminate\Support\Facades\DB;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class InventoryReportController
16+
{
17+
public function stockValuation(Request $request): Response
18+
{
19+
$tenantId = auth()->user()->tenant_id;
20+
$warehouseId = $request->get('warehouse_id');
21+
22+
$query = WarehouseStock::with(['product', 'warehouse'])
23+
->where('tenant_id', $tenantId)
24+
->where('quantity', '>', 0);
25+
26+
if ($warehouseId) {
27+
$query->where('warehouse_id', $warehouseId);
28+
}
29+
30+
$stocks = $query->get();
31+
32+
$rows = $stocks->map(function ($ws) {
33+
$unitCost = (float) ($ws->product->cost_price ?? 0);
34+
$qty = (float) $ws->quantity;
35+
$totalValue = $qty * $unitCost;
36+
return [
37+
'product_id' => $ws->product_id,
38+
'product_name' => $ws->product->name ?? '',
39+
'sku' => $ws->product->sku ?? '',
40+
'warehouse_id' => $ws->warehouse_id,
41+
'warehouse_name' => $ws->warehouse->name ?? '',
42+
'quantity' => $qty,
43+
'unit_cost' => $unitCost,
44+
'total_value' => $totalValue,
45+
];
46+
})->sortByDesc('total_value')->values();
47+
48+
$byWarehouse = $rows->groupBy('warehouse_name')->map(function ($group, $wName) {
49+
return [
50+
'warehouse_name' => $wName,
51+
'product_count' => $group->count(),
52+
'total_qty' => $group->sum('quantity'),
53+
'total_value' => $group->sum('total_value'),
54+
];
55+
})->values();
56+
57+
$summary = [
58+
'total_products' => $rows->pluck('product_id')->unique()->count(),
59+
'total_qty' => $rows->sum('quantity'),
60+
'total_value' => $rows->sum('total_value'),
61+
'by_warehouse' => $byWarehouse,
62+
];
63+
64+
$warehouses = Warehouse::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name']);
65+
66+
return Inertia::render('Inventory/Reports/StockValuation', [
67+
'rows' => $rows,
68+
'summary' => $summary,
69+
'warehouses' => $warehouses,
70+
'filters' => ['warehouse_id' => $warehouseId],
71+
]);
72+
}
73+
74+
public function stockMovement(Request $request): Response
75+
{
76+
$tenantId = auth()->user()->tenant_id;
77+
$dateFrom = $request->get('date_from', Carbon::now()->startOfMonth()->toDateString());
78+
$dateTo = $request->get('date_to', Carbon::today()->toDateString());
79+
$productId = $request->get('product_id');
80+
$warehouseId = $request->get('warehouse_id');
81+
82+
$query = StockMovement::with(['product', 'warehouse'])
83+
->where('tenant_id', $tenantId)
84+
->whereBetween(DB::raw('DATE(created_at)'), [$dateFrom, $dateTo]);
85+
86+
if ($productId) {
87+
$query->where('product_id', $productId);
88+
}
89+
if ($warehouseId) {
90+
$query->where('warehouse_id', $warehouseId);
91+
}
92+
93+
$movements = $query->orderBy('created_at', 'desc')->get();
94+
95+
$rows = $movements->map(fn($m) => [
96+
'id' => $m->id,
97+
'product_id' => $m->product_id,
98+
'product_name' => $m->product->name ?? '',
99+
'sku' => $m->product->sku ?? '',
100+
'warehouse_id' => $m->warehouse_id,
101+
'warehouse_name' => $m->warehouse->name ?? '',
102+
'type' => $m->type,
103+
'quantity' => (float) $m->quantity,
104+
'reference' => $m->reference ?? '',
105+
'notes' => $m->notes ?? '',
106+
'created_at' => $m->created_at?->toDateTimeString() ?? '',
107+
]);
108+
109+
$inTypes = ['in', 'purchase', 'return', 'adjustment_in', 'transfer_in', 'receipt'];
110+
$outTypes = ['out', 'sale', 'adjustment_out', 'transfer_out', 'issue'];
111+
112+
$totalIn = $rows->filter(fn($r) => in_array($r['type'], $inTypes))->sum('quantity');
113+
$totalOut = $rows->filter(fn($r) => in_array($r['type'], $outTypes))->sum('quantity');
114+
115+
$summary = [
116+
'total_movements' => $rows->count(),
117+
'total_in' => $totalIn,
118+
'total_out' => $totalOut,
119+
'net_change' => $totalIn - $totalOut,
120+
];
121+
122+
$products = Product::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']);
123+
$warehouses = Warehouse::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name']);
124+
125+
return Inertia::render('Inventory/Reports/StockMovement', [
126+
'rows' => $rows->values(),
127+
'summary' => $summary,
128+
'products' => $products,
129+
'warehouses' => $warehouses,
130+
'filters' => ['date_from' => $dateFrom, 'date_to' => $dateTo, 'product_id' => $productId, 'warehouse_id' => $warehouseId],
131+
]);
132+
}
133+
134+
public function lowStock(Request $request): Response
135+
{
136+
$tenantId = auth()->user()->tenant_id;
137+
138+
// Get products with their reorder_point and actual stock via WarehouseStock
139+
$stocks = WarehouseStock::with(['product'])
140+
->where('tenant_id', $tenantId)
141+
->whereNotNull('reorder_point')
142+
->get();
143+
144+
// Also check product-level reorder_point for products without warehouse-level setting
145+
$productReorderMap = Product::where('tenant_id', $tenantId)
146+
->where('is_active', true)
147+
->whereNotNull('reorder_point')
148+
->get(['id', 'name', 'sku', 'reorder_point'])
149+
->keyBy('id');
150+
151+
// Aggregate stock by product across warehouses
152+
$stockByProduct = $stocks->groupBy('product_id')->map(fn($group) => [
153+
'total_qty' => $group->sum('quantity'),
154+
'reorder_point' => $group->max('reorder_point'), // warehouse-level
155+
'product' => $group->first()->product,
156+
]);
157+
158+
$rows = collect();
159+
160+
foreach ($productReorderMap as $productId => $product) {
161+
$stockInfo = $stockByProduct->get($productId);
162+
$currentQty = $stockInfo ? (float) $stockInfo['total_qty'] : 0;
163+
$minLevel = $stockInfo ? (float) $stockInfo['reorder_point'] : (float) $product->reorder_point;
164+
165+
if ($minLevel <= 0) {
166+
$minLevel = (float) $product->reorder_point;
167+
}
168+
169+
if ($currentQty <= $minLevel) {
170+
$rows->push([
171+
'product_id' => $product->id,
172+
'product_name' => $product->name,
173+
'sku' => $product->sku,
174+
'current_stock' => $currentQty,
175+
'min_level' => $minLevel,
176+
'shortage' => max(0, $minLevel - $currentQty),
177+
]);
178+
}
179+
}
180+
181+
$rows = $rows->sortByDesc('shortage')->values();
182+
183+
return Inertia::render('Inventory/Reports/LowStock', [
184+
'rows' => $rows,
185+
'summary' => ['products_at_risk' => $rows->count()],
186+
]);
187+
}
188+
189+
public function abcAnalysis(Request $request): Response
190+
{
191+
$tenantId = auth()->user()->tenant_id;
192+
$since = Carbon::now()->subDays(90)->startOfDay();
193+
$outTypes = ['out', 'sale', 'adjustment_out', 'transfer_out', 'issue'];
194+
195+
$movements = StockMovement::with(['product'])
196+
->where('tenant_id', $tenantId)
197+
->where('created_at', '>=', $since)
198+
->whereIn('type', $outTypes)
199+
->get();
200+
201+
// Group by product, sum qty * cost
202+
$grouped = $movements->groupBy('product_id')->map(function ($group) {
203+
$product = $group->first()->product;
204+
$value = $group->sum(fn($m) => abs((float) $m->quantity) * (float) ($product->cost_price ?? 0));
205+
return [
206+
'product_id' => $group->first()->product_id,
207+
'product_name' => $product->name ?? '',
208+
'sku' => $product->sku ?? '',
209+
'movement_value' => $value,
210+
];
211+
})->sortByDesc('movement_value')->values();
212+
213+
$totalValue = $grouped->sum('movement_value');
214+
$cumulative = 0;
215+
$rows = $grouped->map(function ($row, $index) use ($totalValue, &$cumulative) {
216+
$pct = $totalValue > 0 ? ($row['movement_value'] / $totalValue * 100) : 0;
217+
$cumulative += $pct;
218+
$bucket = $cumulative <= 80 ? 'A' : ($cumulative <= 95 ? 'B' : 'C');
219+
return array_merge($row, [
220+
'rank' => $index + 1,
221+
'percentage' => round($pct, 2),
222+
'cumulative' => round($cumulative, 2),
223+
'bucket' => $bucket,
224+
]);
225+
});
226+
227+
$bucketSummary = [
228+
'A' => $rows->where('bucket', 'A')->count(),
229+
'B' => $rows->where('bucket', 'B')->count(),
230+
'C' => $rows->where('bucket', 'C')->count(),
231+
];
232+
233+
return Inertia::render('Inventory/Reports/AbcAnalysis', [
234+
'rows' => $rows->values(),
235+
'total_value' => $totalValue,
236+
'bucket_summary' => $bucketSummary,
237+
]);
238+
}
239+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\Warehouse;
6+
use App\Modules\Inventory\Models\WarehouseStock;
7+
use Inertia\Inertia;
8+
use Inertia\Response;
9+
10+
class MultiWarehouseController
11+
{
12+
public function index(): Response
13+
{
14+
$tenantId = auth()->user()->tenant_id;
15+
16+
$warehouses = Warehouse::where('tenant_id', $tenantId)
17+
->withCount([
18+
'warehouseStock as product_count' => fn($q) => $q->where('quantity', '>', 0),
19+
])
20+
->orderBy('name')
21+
->get()
22+
->map(fn($w) => [
23+
'id' => $w->id,
24+
'name' => $w->name,
25+
'address' => $w->address ?? null,
26+
'city' => $w->city ?? null,
27+
'country' => $w->country ?? null,
28+
'costing_method' => $w->costing_method ?? 'average',
29+
'is_active' => $w->is_active ?? true,
30+
'product_count' => $w->product_count,
31+
]);
32+
33+
$summary = [
34+
'total_warehouses' => $warehouses->count(),
35+
'active_warehouses' => $warehouses->where('is_active', true)->count(),
36+
'total_products' => WarehouseStock::where('tenant_id', $tenantId)->where('quantity', '>', 0)->distinct('product_id')->count('product_id'),
37+
];
38+
39+
return Inertia::render('Inventory/MultiWarehouse/Index', [
40+
'warehouses' => $warehouses->values(),
41+
'summary' => $summary,
42+
]);
43+
}
44+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,9 @@ public function purchaseOrders(): HasMany
3232
{
3333
return $this->hasMany(PurchaseOrder::class);
3434
}
35+
36+
public function warehouseStock(): HasMany
37+
{
38+
return $this->hasMany(WarehouseStock::class);
39+
}
3540
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,18 @@
386386
Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() {
387387
Route::get('traceability', [TraceabilityController::class,'index'])->name('traceability.index');
388388
});
389+
390+
// Inventory Reports
391+
use App\Modules\Inventory\Http\Controllers\InventoryReportController;
392+
Route::middleware(['web','auth','verified'])->prefix('inventory/reports')->name('inventory.reports.')->group(function() {
393+
Route::get('stock-valuation', [InventoryReportController::class, 'stockValuation'])->name('stock-valuation');
394+
Route::get('stock-movement', [InventoryReportController::class, 'stockMovement'])->name('stock-movement');
395+
Route::get('low-stock', [InventoryReportController::class, 'lowStock'])->name('low-stock');
396+
Route::get('abc-analysis', [InventoryReportController::class, 'abcAnalysis'])->name('abc-analysis');
397+
});
398+
399+
// Multi-Warehouse Overview
400+
use App\Modules\Inventory\Http\Controllers\MultiWarehouseController;
401+
Route::middleware(['web','auth','verified'])->prefix('inventory')->name('inventory.')->group(function() {
402+
Route::get('multi-warehouse', [MultiWarehouseController::class, 'index'])->name('multi-warehouse.index');
403+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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::table('warehouses', function (Blueprint $table) {
12+
if (! Schema::hasColumn('warehouses', 'address')) {
13+
$table->string('address')->nullable()->after('name');
14+
}
15+
if (! Schema::hasColumn('warehouses', 'city')) {
16+
$table->string('city')->nullable()->after('address');
17+
}
18+
if (! Schema::hasColumn('warehouses', 'country')) {
19+
$table->string('country')->nullable()->after('city');
20+
}
21+
if (! Schema::hasColumn('warehouses', 'phone')) {
22+
$table->string('phone')->nullable()->after('country');
23+
}
24+
if (! Schema::hasColumn('warehouses', 'email')) {
25+
$table->string('email')->nullable()->after('phone');
26+
}
27+
if (! Schema::hasColumn('warehouses', 'timezone')) {
28+
$table->string('timezone')->nullable()->after('email');
29+
}
30+
if (! Schema::hasColumn('warehouses', 'costing_method')) {
31+
$table->string('costing_method')->default('average')->after('timezone');
32+
}
33+
});
34+
}
35+
36+
public function down(): void
37+
{
38+
Schema::table('warehouses', function (Blueprint $table) {
39+
$table->dropColumn(['address', 'city', 'country', 'phone', 'email', 'timezone', 'costing_method']);
40+
});
41+
}
42+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ const navItems: NavItem[] = [
9898
{ label: 'Stock Pickings', href: '/inventory/stock-pickings', icon: <span /> },
9999
{ label: 'Replenishments', href: '/inventory/replenishments', icon: <span /> },
100100
{ label: 'Traceability', href: '/inventory/traceability', icon: <span /> },
101+
{ label: 'Multi-Warehouse', href: '/inventory/multi-warehouse', icon: <span /> },
102+
{ label: 'Reports: Stock Val', href: '/inventory/reports/stock-valuation', icon: <span /> },
103+
{ label: 'Reports: Movement', href: '/inventory/reports/stock-movement', icon: <span /> },
104+
{ label: 'Reports: Low Stock', href: '/inventory/reports/low-stock', icon: <span /> },
105+
{ label: 'Reports: ABC', href: '/inventory/reports/abc-analysis', icon: <span /> },
101106
],
102107
},
103108
{

0 commit comments

Comments
 (0)