Skip to content

Commit 06d55be

Browse files
committed
feat: Phase 29 — Reorder points with low-stock suggestions and one-click PO creation
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a041bc8 commit 06d55be

14 files changed

Lines changed: 635 additions & 16 deletions

File tree

erp/app/Modules/Inventory/Http/Controllers/ProductController.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Modules\Inventory\Http\Resources\ProductResource;
99
use App\Modules\Inventory\Models\Product;
1010
use App\Modules\Inventory\Models\ProductCategory;
11+
use App\Modules\Inventory\Models\Supplier;
1112
use App\Modules\Inventory\Models\UnitOfMeasure;
1213
use Inertia\Inertia;
1314
use Inertia\Response;
@@ -72,16 +73,19 @@ public function show(Product $product): Response
7273
{
7374
$this->authorize('view', $product);
7475

75-
$product->load(['category', 'uom', 'stockLevels.warehouse']);
76+
$product->load(['category', 'uom', 'stockLevels.warehouse', 'preferredSupplier']);
7677

7778
$movements = $product->stockMovements()
7879
->with('warehouse', 'creator')
7980
->latest('created_at')
8081
->paginate(20);
8182

83+
$tenantId = auth()->user()->tenant_id;
84+
8285
return Inertia::render('Inventory/Products/Show', [
8386
'product' => new ProductResource($product),
8487
'movements' => $movements,
88+
'suppliers' => Supplier::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']),
8589
'breadcrumbs' => [
8690
['label' => 'Inventory'],
8791
['label' => 'Products', 'href' => route('inventory.products.index')],
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\Product;
7+
use App\Modules\Inventory\Models\PurchaseOrder;
8+
use App\Modules\Inventory\Models\PurchaseOrderItem;
9+
use App\Modules\Inventory\Models\Supplier;
10+
use App\Modules\Inventory\Models\Warehouse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
14+
class ReorderController extends Controller
15+
{
16+
public function index(Request $request)
17+
{
18+
$this->authorize('viewAny', Product::class);
19+
$tenantId = $request->user()->tenant_id;
20+
21+
$suggestions = Product::where('tenant_id', $tenantId)
22+
->where('is_active', true)
23+
->where('reorder_point', '>', 0)
24+
->with(['stockLevels', 'preferredSupplier', 'category'])
25+
->get()
26+
->filter(fn ($p) => $p->needsReorder())
27+
->map(fn ($p) => [
28+
'id' => $p->id,
29+
'sku' => $p->sku,
30+
'name' => $p->name,
31+
'category' => $p->category?->name,
32+
'total_stock' => round($p->total_stock, 4),
33+
'reorder_point' => round((float) $p->reorder_point, 4),
34+
'reorder_quantity' => round((float) $p->reorder_quantity, 4),
35+
'preferred_supplier' => $p->preferredSupplier?->name,
36+
'preferred_supplier_id' => $p->preferred_supplier_id,
37+
'cost_price' => (float) $p->cost_price,
38+
])
39+
->values();
40+
41+
$suppliers = Supplier::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']);
42+
$warehouses = Warehouse::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']);
43+
44+
return Inertia::render('Inventory/Reorder/Index', [
45+
'suggestions' => $suggestions,
46+
'suppliers' => $suppliers,
47+
'warehouses' => $warehouses,
48+
]);
49+
}
50+
51+
public function createPurchaseOrder(Request $request)
52+
{
53+
$this->authorize('create', Product::class);
54+
55+
$data = $request->validate([
56+
'supplier_id' => 'required|exists:suppliers,id',
57+
'warehouse_id' => 'required|exists:warehouses,id',
58+
'items' => 'required|array|min:1',
59+
'items.*.product_id' => 'required|exists:products,id',
60+
'items.*.quantity' => 'required|numeric|min:0.001',
61+
'items.*.unit_cost' => 'required|numeric|min:0',
62+
]);
63+
64+
$tenantId = $request->user()->tenant_id;
65+
66+
$po = PurchaseOrder::create([
67+
'tenant_id' => $tenantId,
68+
'supplier_id' => $data['supplier_id'],
69+
'warehouse_id' => $data['warehouse_id'],
70+
'status' => 'draft',
71+
'created_by' => $request->user()->id,
72+
]);
73+
74+
foreach ($data['items'] as $item) {
75+
PurchaseOrderItem::create([
76+
'purchase_order_id' => $po->id,
77+
'product_id' => $item['product_id'],
78+
'quantity' => $item['quantity'],
79+
'unit_cost' => $item['unit_cost'],
80+
'received_quantity' => 0,
81+
]);
82+
}
83+
84+
return redirect("/inventory/purchase-orders/{$po->id}")
85+
->with('success', 'Purchase order created from reorder suggestions.');
86+
}
87+
}

erp/app/Modules/Inventory/Http/Requests/StoreProductRequest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ public function rules(): array
2323
'uom_id' => ['nullable', 'integer', 'exists:units_of_measure,id'],
2424
'cost_price' => ['required', 'numeric', 'min:0'],
2525
'sale_price' => ['required', 'numeric', 'min:0'],
26-
'reorder_point' => ['integer', 'min:0'],
27-
'is_active' => ['boolean'],
26+
'reorder_point' => ['nullable', 'numeric', 'min:0'],
27+
'reorder_quantity' => ['nullable', 'numeric', 'min:0'],
28+
'preferred_supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'],
29+
'is_active' => ['boolean'],
2830
];
2931
}
3032
}

erp/app/Modules/Inventory/Http/Requests/UpdateProductRequest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ public function rules(): array
2424
'uom_id' => ['nullable', 'integer', 'exists:units_of_measure,id'],
2525
'cost_price' => ['required', 'numeric', 'min:0'],
2626
'sale_price' => ['required', 'numeric', 'min:0'],
27-
'reorder_point' => ['integer', 'min:0'],
28-
'is_active' => ['boolean'],
27+
'reorder_point' => ['nullable', 'numeric', 'min:0'],
28+
'reorder_quantity' => ['nullable', 'numeric', 'min:0'],
29+
'preferred_supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'],
30+
'is_active' => ['boolean'],
2931
];
3032
}
3133
}

erp/app/Modules/Inventory/Http/Resources/ProductResource.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ public function toArray(Request $request): array
2727
]),
2828
'cost_price' => $this->cost_price,
2929
'sale_price' => $this->sale_price,
30-
'reorder_point' => $this->reorder_point,
30+
'reorder_point' => $this->reorder_point,
31+
'reorder_quantity' => $this->reorder_quantity,
32+
'preferred_supplier_id' => $this->preferred_supplier_id,
33+
'preferred_supplier' => $this->whenLoaded('preferredSupplier', fn () => $this->preferredSupplier ? [
34+
'id' => $this->preferredSupplier->id,
35+
'name' => $this->preferredSupplier->name,
36+
] : null),
3137
'is_active' => $this->is_active,
3238
'stock_levels' => $this->whenLoaded('stockLevels', fn () =>
3339
$this->stockLevels->map(fn ($sl) => [
@@ -42,6 +48,14 @@ public function toArray(Request $request): array
4248
$this->relationLoaded('stockLevels'),
4349
fn () => $this->total_quantity
4450
),
51+
'total_stock' => $this->when(
52+
$this->relationLoaded('stockLevels'),
53+
fn () => round($this->total_stock, 4)
54+
),
55+
'needs_reorder' => $this->when(
56+
$this->relationLoaded('stockLevels'),
57+
fn () => $this->needsReorder()
58+
),
4559
'created_at' => $this->created_at,
4660
];
4761
}

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ class Product extends Model
1818
protected $fillable = [
1919
'tenant_id', 'sku', 'name', 'description',
2020
'category_id', 'uom_id', 'cost_price',
21-
'sale_price', 'reorder_point', 'is_active',
21+
'sale_price', 'reorder_point', 'reorder_quantity',
22+
'preferred_supplier_id', 'is_active',
2223
];
2324

2425
protected $casts = [
25-
'cost_price' => 'decimal:2',
26-
'sale_price' => 'decimal:2',
27-
'reorder_point' => 'integer',
28-
'is_active' => 'boolean',
26+
'cost_price' => 'decimal:2',
27+
'sale_price' => 'decimal:2',
28+
'reorder_point' => 'float',
29+
'reorder_quantity' => 'float',
30+
'is_active' => 'boolean',
2931
];
3032

3133
public function category(): BelongsTo
@@ -48,6 +50,11 @@ public function stockMovements(): HasMany
4850
return $this->hasMany(StockMovement::class);
4951
}
5052

53+
public function preferredSupplier(): BelongsTo
54+
{
55+
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
56+
}
57+
5158
public function getTotalQuantityAttribute(): float
5259
{
5360
return (float) $this->stockLevels()->sum('quantity');
@@ -60,6 +67,16 @@ public function getTotalAvailableAttribute(): float
6067
->value('available') ?? 0.0;
6168
}
6269

70+
public function getTotalStockAttribute(): float
71+
{
72+
return (float) $this->stockLevels->sum('quantity');
73+
}
74+
75+
public function needsReorder(): bool
76+
{
77+
return $this->reorder_point > 0 && $this->total_stock <= $this->reorder_point;
78+
}
79+
6380
public function scopeSearch($query, string $term)
6481
{
6582
return $query->where(function ($q) use ($term) {
@@ -72,5 +89,4 @@ public function scopeActive($query)
7289
{
7390
return $query->where('is_active', true);
7491
}
75-
7692
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use App\Modules\Inventory\Http\Controllers\ProductCategoryController;
55
use App\Modules\Inventory\Http\Controllers\ProductController;
66
use App\Modules\Inventory\Http\Controllers\PurchaseOrderController;
7+
use App\Modules\Inventory\Http\Controllers\ReorderController;
78
use App\Modules\Inventory\Http\Controllers\StockMovementController;
89
use App\Modules\Inventory\Http\Controllers\SupplierController;
910
use App\Modules\Inventory\Http\Controllers\WarehouseController;
@@ -54,6 +55,10 @@
5455
Route::post('purchase-orders/{purchaseOrder}/receive', [PurchaseOrderController::class, 'receive'])->name('purchase-orders.receive');
5556
Route::post('purchase-orders/{purchaseOrder}/cancel', [PurchaseOrderController::class, 'cancel'])->name('purchase-orders.cancel');
5657

58+
// Reorder Suggestions
59+
Route::get('reorder', [ReorderController::class, 'index'])->name('reorder.index');
60+
Route::post('reorder/purchase-order', [ReorderController::class, 'createPurchaseOrder'])->name('reorder.create-po');
61+
5762
// Warehouse Transfers
5863
Route::resource('warehouse-transfers', WarehouseTransferController::class)->only(['index', 'create', 'store']);
5964
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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('products', function (Blueprint $table) {
12+
$table->decimal('reorder_quantity', 12, 4)->default(0)->after('reorder_point');
13+
$table->foreignId('preferred_supplier_id')->nullable()->after('reorder_quantity')
14+
->constrained('suppliers')->nullOnDelete();
15+
});
16+
}
17+
18+
public function down(): void
19+
{
20+
Schema::table('products', function (Blueprint $table) {
21+
$table->dropForeign(['preferred_supplier_id']);
22+
$table->dropColumn(['reorder_quantity', 'preferred_supplier_id']);
23+
});
24+
}
25+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const navItems: NavItem[] = [
5151
{ label: 'Stock Movements', href: '/inventory/stock-movements', icon: inventoryIcon },
5252
{ label: 'Purchase Orders', href: '/inventory/purchase-orders', icon: inventoryIcon },
5353
{ label: 'Transfers', href: '/inventory/warehouse-transfers', icon: inventoryIcon },
54+
{ label: 'Reorder', href: '/inventory/reorder', icon: inventoryIcon },
5455
],
5556
},
5657
{

erp/resources/js/Pages/Inventory/Products/Index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@ export default function ProductsIndex({ products, categories, filters }: Props)
9393
) : <span className="text-slate-400 text-xs"></span> },
9494
{ key: 'sale_price', header: 'Sale Price', render: (p) => `$${Number(p.sale_price).toFixed(2)}` },
9595
{ key: 'stock', header: 'Stock', render: (p) => (
96-
<StockLevelBadge quantity={p.total_quantity ?? 0} reorderPoint={p.reorder_point} />
96+
<div className="flex items-center gap-1.5">
97+
<StockLevelBadge quantity={p.total_quantity ?? 0} reorderPoint={p.reorder_point} />
98+
{p.needs_reorder && (
99+
<span className="inline-flex items-center rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700">
100+
&#9888; Reorder
101+
</span>
102+
)}
103+
</div>
97104
)},
98105
{ key: 'status', header: 'Status', render: (p) => (
99106
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${p.is_active ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'}`}>

0 commit comments

Comments
 (0)