Skip to content

Commit 56154ca

Browse files
committed
feat(inventory): Phase 75 — Demand Forecasting with moving average and alerts
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 00df34d commit 56154ca

15 files changed

Lines changed: 1048 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\DemandForecast;
7+
use App\Modules\Inventory\Models\ForecastAlert;
8+
use App\Modules\Inventory\Models\Product;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class DemandForecastController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', DemandForecast::class);
19+
20+
$query = DemandForecast::with('product')
21+
->where('tenant_id', auth()->user()->tenant_id);
22+
23+
if ($request->filled('product_id')) {
24+
$query->where('product_id', $request->product_id);
25+
}
26+
27+
$forecasts = $query->orderByDesc('forecast_date')->paginate(20);
28+
$filters = $request->only('product_id');
29+
30+
return Inertia::render('Inventory/DemandForecasts/Index', compact('forecasts', 'filters'));
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', DemandForecast::class);
36+
37+
$products = Product::where('tenant_id', auth()->user()->tenant_id)
38+
->orderBy('name')
39+
->get(['id', 'name', 'sku']);
40+
41+
return Inertia::render('Inventory/DemandForecasts/Create', compact('products'));
42+
}
43+
44+
public function store(Request $request): RedirectResponse
45+
{
46+
$this->authorize('create', DemandForecast::class);
47+
48+
$data = $request->validate([
49+
'product_id' => ['required', 'exists:products,id'],
50+
'forecast_date' => ['required', 'date'],
51+
'forecasted_quantity' => ['required', 'numeric', 'min:0'],
52+
'method' => ['required', 'in:moving_avg,weighted_avg,manual'],
53+
'confidence_score' => ['nullable', 'numeric', 'min:0', 'max:100'],
54+
'notes' => ['nullable', 'string'],
55+
'warehouse_id' => ['nullable', 'exists:warehouses,id'],
56+
]);
57+
58+
DemandForecast::create([
59+
'tenant_id' => auth()->user()->tenant_id,
60+
...$data,
61+
]);
62+
63+
return redirect()->route('inventory.demand-forecasts.index')
64+
->with('success', 'Demand forecast created.');
65+
}
66+
67+
public function show(DemandForecast $demandForecast): Response
68+
{
69+
$this->authorize('view', $demandForecast);
70+
$demandForecast->load('product');
71+
72+
return Inertia::render('Inventory/DemandForecasts/Show', [
73+
'forecast' => $demandForecast->append('accuracy'),
74+
]);
75+
}
76+
77+
public function update(Request $request, DemandForecast $demandForecast): RedirectResponse
78+
{
79+
$this->authorize('create', DemandForecast::class);
80+
81+
$data = $request->validate([
82+
'actual_quantity' => ['required', 'numeric', 'min:0'],
83+
'notes' => ['nullable', 'string'],
84+
]);
85+
86+
$demandForecast->update($data);
87+
88+
return back()->with('success', 'Forecast updated.');
89+
}
90+
91+
public function destroy(DemandForecast $demandForecast): RedirectResponse
92+
{
93+
$this->authorize('delete', $demandForecast);
94+
$demandForecast->delete();
95+
96+
return redirect()->route('inventory.demand-forecasts.index')
97+
->with('success', 'Forecast deleted.');
98+
}
99+
100+
public function generateForecast(Request $request): RedirectResponse
101+
{
102+
$this->authorize('create', DemandForecast::class);
103+
104+
$data = $request->validate([
105+
'product_id' => ['required', 'exists:products,id'],
106+
'periods' => ['nullable', 'integer', 'min:1', 'max:12'],
107+
'forecast_date' => ['required', 'date'],
108+
]);
109+
110+
$tenantId = auth()->user()->tenant_id;
111+
$productId = (int) $data['product_id'];
112+
$periods = isset($data['periods']) ? (int) $data['periods'] : 3;
113+
114+
$quantity = DemandForecast::generateMovingAvg($tenantId, $productId, $periods);
115+
116+
DemandForecast::create([
117+
'tenant_id' => $tenantId,
118+
'product_id' => $productId,
119+
'forecast_date' => $data['forecast_date'],
120+
'forecasted_quantity' => $quantity,
121+
'method' => 'moving_avg',
122+
]);
123+
124+
return back()->with('success', 'Forecast generated using moving average.');
125+
}
126+
127+
public function alerts(Request $request): Response
128+
{
129+
$this->authorize('viewAny', DemandForecast::class);
130+
131+
$alerts = ForecastAlert::unresolved()
132+
->where('tenant_id', auth()->user()->tenant_id)
133+
->with('product')
134+
->orderByDesc('created_at')
135+
->paginate(20);
136+
137+
return Inertia::render('Inventory/DemandForecasts/Alerts', compact('alerts'));
138+
}
139+
140+
public function resolveAlert(Request $request, ForecastAlert $alert): RedirectResponse
141+
{
142+
$this->authorize('create', DemandForecast::class);
143+
$alert->resolve();
144+
145+
return back()->with('success', 'Alert resolved.');
146+
}
147+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 DemandForecast extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'demand_forecasts';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'product_id',
18+
'warehouse_id',
19+
'forecast_date',
20+
'forecasted_quantity',
21+
'actual_quantity',
22+
'method',
23+
'confidence_score',
24+
'notes',
25+
];
26+
27+
protected $casts = [
28+
'forecast_date' => 'date',
29+
'forecasted_quantity' => 'decimal:2',
30+
'actual_quantity' => 'decimal:2',
31+
'confidence_score' => 'decimal:2',
32+
];
33+
34+
public function product(): BelongsTo
35+
{
36+
return $this->belongsTo(Product::class);
37+
}
38+
39+
public function warehouse(): BelongsTo
40+
{
41+
return $this->belongsTo(Warehouse::class);
42+
}
43+
44+
public function getAccuracyAttribute(): ?float
45+
{
46+
if ($this->actual_quantity === null) {
47+
return null;
48+
}
49+
50+
if ((float) $this->forecasted_quantity <= 0) {
51+
return null;
52+
}
53+
54+
$accuracy = 100 - abs((float) $this->actual_quantity - (float) $this->forecasted_quantity) / (float) $this->forecasted_quantity * 100;
55+
56+
return (float) round(max(0, $accuracy), 1);
57+
}
58+
59+
public static function generateMovingAvg(int $tenantId, int $productId, int $periods = 3): float
60+
{
61+
$records = static::where('tenant_id', $tenantId)
62+
->where('product_id', $productId)
63+
->whereNotNull('actual_quantity')
64+
->orderByDesc('forecast_date')
65+
->take($periods)
66+
->get();
67+
68+
if ($records->isEmpty()) {
69+
return 0.0;
70+
}
71+
72+
return (float) $records->avg('actual_quantity');
73+
}
74+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 ForecastAlert extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'forecast_alerts';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'product_id',
18+
'alert_type',
19+
'severity',
20+
'message',
21+
'is_resolved',
22+
'resolved_at',
23+
];
24+
25+
protected $casts = [
26+
'is_resolved' => 'boolean',
27+
'resolved_at' => 'datetime',
28+
];
29+
30+
public function product(): BelongsTo
31+
{
32+
return $this->belongsTo(Product::class);
33+
}
34+
35+
public function resolve(): void
36+
{
37+
$this->is_resolved = true;
38+
$this->resolved_at = now();
39+
$this->save();
40+
}
41+
42+
public function scopeUnresolved($query)
43+
{
44+
return $query->where('is_resolved', false);
45+
}
46+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
7+
class ForecastPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->can('inventory.view');
12+
}
13+
14+
public function view(User $user): bool
15+
{
16+
return $user->can('inventory.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->can('inventory.create');
22+
}
23+
24+
public function update(User $user): bool
25+
{
26+
return $user->can('inventory.create');
27+
}
28+
29+
public function delete(User $user): bool
30+
{
31+
return $user->can('inventory.delete');
32+
}
33+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use App\Modules\Inventory\Models\Asset;
66
use App\Modules\Inventory\Models\AssetMaintenance;
7+
use App\Modules\Inventory\Models\DemandForecast;
8+
use App\Modules\Inventory\Models\ForecastAlert;
79
use App\Modules\Inventory\Models\Product;
810
use App\Modules\Inventory\Models\ProductBundleItem;
911
use App\Modules\Inventory\Models\ProductCategory;
@@ -18,6 +20,7 @@
1820
use App\Modules\Inventory\Models\WarehouseStock;
1921
use App\Modules\Inventory\Models\WarehouseTransfer;
2022
use App\Modules\Inventory\Policies\AssetPolicy;
23+
use App\Modules\Inventory\Policies\ForecastPolicy;
2124
use App\Modules\Inventory\Policies\ProductCategoryPolicy;
2225
use App\Modules\Inventory\Policies\ProductPolicy;
2326
use App\Modules\Inventory\Policies\PurchaseRequisitionPolicy;
@@ -56,5 +59,7 @@ public function boot(): void
5659
Gate::policy(QcInspectionResult::class, QcPolicy::class);
5760
Gate::policy(CostingLayer::class, CostingPolicy::class);
5861
Gate::policy(ProductCostSnapshot::class, CostingPolicy::class);
62+
Gate::policy(DemandForecast::class, ForecastPolicy::class);
63+
Gate::policy(ForecastAlert::class, ForecastPolicy::class);
5964
}
6065
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use App\Modules\Inventory\Http\Controllers\WarehouseController;
1919
use App\Modules\Inventory\Http\Controllers\WarehouseTransferController;
2020
use App\Modules\Inventory\Http\Controllers\WarehouseStockController;
21+
use App\Modules\Inventory\Http\Controllers\DemandForecastController;
2122
use App\Modules\Inventory\Http\Controllers\StockTransferController;
2223
use Illuminate\Support\Facades\Route;
2324

@@ -122,3 +123,11 @@
122123
Route::post('costing/add-layer', [CostingController::class, 'addLayer'])->name('costing.add-layer');
123124
Route::post('costing/snapshot', [CostingController::class, 'snapshot'])->name('costing.snapshot');
124125
});
126+
127+
// Demand Forecasting - custom actions BEFORE resource
128+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
129+
Route::get('demand-forecasts/alerts', [DemandForecastController::class, 'alerts'])->name('demand-forecasts.alerts');
130+
Route::post('demand-forecasts/generate', [DemandForecastController::class, 'generateForecast'])->name('demand-forecasts.generate');
131+
Route::post('demand-forecasts/alerts/{alert}/resolve', [DemandForecastController::class, 'resolveAlert'])->name('demand-forecasts.alerts.resolve');
132+
Route::resource('demand-forecasts', DemandForecastController::class)->except(['edit']);
133+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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::create('demand_forecasts', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
15+
$table->foreignId('warehouse_id')->nullable()->nullOnDelete()->constrained();
16+
$table->date('forecast_date');
17+
$table->decimal('forecasted_quantity', 14, 2)->default(0);
18+
$table->decimal('actual_quantity', 14, 2)->nullable();
19+
$table->string('method', 20)->default('manual');
20+
$table->decimal('confidence_score', 5, 2)->nullable();
21+
$table->text('notes')->nullable();
22+
$table->timestamps();
23+
$table->index(['tenant_id', 'product_id', 'forecast_date']);
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('demand_forecasts');
30+
}
31+
};

0 commit comments

Comments
 (0)