Skip to content

Commit 4620da2

Browse files
committed
feat(inventory): Phase 76 — Warehouse Bin & Location Tracking with zones
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 56154ca commit 4620da2

18 files changed

Lines changed: 1334 additions & 0 deletions
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\BinStockLocation;
7+
use App\Modules\Inventory\Models\Warehouse;
8+
use App\Modules\Inventory\Models\WarehouseBin;
9+
use App\Modules\Inventory\Models\WarehouseZone;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class WarehouseBinController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$this->authorize('viewAny', WarehouseBin::class);
20+
21+
$query = WarehouseBin::with(['zone', 'warehouse']);
22+
23+
if ($request->filled('warehouse_id')) {
24+
$query->where('warehouse_id', $request->input('warehouse_id'));
25+
}
26+
27+
$bins = $query->orderBy('code')->paginate(20)->withQueryString();
28+
29+
return Inertia::render('Inventory/WarehouseBins/Index', [
30+
'bins' => $bins,
31+
'filters' => $request->only('warehouse_id'),
32+
]);
33+
}
34+
35+
public function create(): Response
36+
{
37+
$this->authorize('create', WarehouseBin::class);
38+
39+
$warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']);
40+
$zones = WarehouseZone::where('is_active', true)->orderBy('name')->get(['id', 'warehouse_id', 'name', 'code']);
41+
42+
return Inertia::render('Inventory/WarehouseBins/Create', compact('warehouses', 'zones'));
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$this->authorize('create', WarehouseBin::class);
48+
49+
$data = $request->validate([
50+
'warehouse_id' => ['required', 'exists:warehouses,id'],
51+
'zone_id' => ['nullable', 'exists:warehouse_zones,id'],
52+
'code' => ['required', 'string', 'max:30'],
53+
'name' => ['nullable', 'string'],
54+
'bin_type' => ['required', 'in:standard,cold,hazmat,oversize'],
55+
'capacity' => ['nullable', 'numeric', 'min:0'],
56+
'is_active' => ['boolean'],
57+
]);
58+
59+
$data['tenant_id'] = app('tenant')->id;
60+
61+
$bin = WarehouseBin::create($data);
62+
63+
return redirect()->route('inventory.warehouse-bins.show', $bin);
64+
}
65+
66+
public function show(WarehouseBin $warehouseBin): Response
67+
{
68+
$this->authorize('view', $warehouseBin);
69+
70+
$warehouseBin->load(['stockLocations.product', 'zone', 'warehouse']);
71+
$warehouseBin->append(['used_capacity', 'available_capacity']);
72+
73+
return Inertia::render('Inventory/WarehouseBins/Show', [
74+
'bin' => $warehouseBin,
75+
]);
76+
}
77+
78+
public function destroy(WarehouseBin $warehouseBin): RedirectResponse
79+
{
80+
$this->authorize('delete', $warehouseBin);
81+
82+
$warehouseBin->delete();
83+
84+
return redirect()->route('inventory.warehouse-bins.index');
85+
}
86+
87+
public function addStock(Request $request, WarehouseBin $warehouseBin): RedirectResponse
88+
{
89+
$this->authorize('create', $warehouseBin);
90+
91+
$data = $request->validate([
92+
'product_id' => ['required', 'exists:products,id'],
93+
'quantity' => ['required', 'numeric', 'min:0.0001'],
94+
'lot_number' => ['nullable', 'string', 'max:50'],
95+
'expiry_date' => ['nullable', 'date'],
96+
]);
97+
98+
$location = BinStockLocation::where('bin_id', $warehouseBin->id)
99+
->where('product_id', $data['product_id'])
100+
->where('lot_number', $data['lot_number'] ?? null)
101+
->first();
102+
103+
if ($location) {
104+
$location->increment('quantity', $data['quantity']);
105+
} else {
106+
BinStockLocation::create([
107+
'tenant_id' => app('tenant')->id,
108+
'bin_id' => $warehouseBin->id,
109+
'product_id' => $data['product_id'],
110+
'quantity' => $data['quantity'],
111+
'lot_number' => $data['lot_number'] ?? null,
112+
'expiry_date' => $data['expiry_date'] ?? null,
113+
]);
114+
}
115+
116+
return back()->with('success', 'Stock added successfully.');
117+
}
118+
119+
public function removeStock(WarehouseBin $warehouseBin, BinStockLocation $location): RedirectResponse
120+
{
121+
$this->authorize('delete', $warehouseBin);
122+
123+
$location->delete();
124+
125+
return back();
126+
}
127+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\Warehouse;
7+
use App\Modules\Inventory\Models\WarehouseBin;
8+
use App\Modules\Inventory\Models\WarehouseZone;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class WarehouseZoneController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', WarehouseBin::class);
19+
20+
$query = WarehouseZone::with('warehouse')->withCount('bins');
21+
22+
if ($request->filled('warehouse_id')) {
23+
$query->where('warehouse_id', $request->input('warehouse_id'));
24+
}
25+
26+
$zones = $query->orderBy('name')->paginate(20)->withQueryString();
27+
$warehouses = Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']);
28+
29+
return Inertia::render('Inventory/WarehouseZones/Index', [
30+
'zones' => $zones,
31+
'filters' => $request->only('warehouse_id'),
32+
'warehouses' => $warehouses,
33+
]);
34+
}
35+
36+
public function store(Request $request): RedirectResponse
37+
{
38+
$this->authorize('create', WarehouseBin::class);
39+
40+
$data = $request->validate([
41+
'warehouse_id' => ['required', 'exists:warehouses,id'],
42+
'name' => ['required', 'string'],
43+
'code' => ['required', 'string', 'max:20'],
44+
]);
45+
46+
$data['tenant_id'] = app('tenant')->id;
47+
48+
WarehouseZone::create($data);
49+
50+
return back()->with('success', 'Zone created successfully.');
51+
}
52+
53+
public function destroy(WarehouseZone $warehouseZone): RedirectResponse
54+
{
55+
$this->authorize('delete', WarehouseBin::class);
56+
57+
$warehouseZone->delete();
58+
59+
return back();
60+
}
61+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 BinStockLocation extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'bin_stock_locations';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'bin_id',
18+
'product_id',
19+
'quantity',
20+
'lot_number',
21+
'expiry_date',
22+
];
23+
24+
protected $casts = [
25+
'quantity' => 'decimal:4',
26+
'expiry_date' => 'date',
27+
];
28+
29+
public function bin(): BelongsTo
30+
{
31+
return $this->belongsTo(WarehouseBin::class, 'bin_id');
32+
}
33+
34+
public function product(): BelongsTo
35+
{
36+
return $this->belongsTo(Product::class);
37+
}
38+
39+
public function getIsExpiredAttribute(): bool
40+
{
41+
return $this->expiry_date !== null && $this->expiry_date->isPast();
42+
}
43+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class WarehouseBin extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $table = 'warehouse_bins';
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'warehouse_id',
19+
'zone_id',
20+
'code',
21+
'name',
22+
'bin_type',
23+
'capacity',
24+
'is_active',
25+
];
26+
27+
protected $casts = [
28+
'capacity' => 'decimal:2',
29+
'is_active' => 'boolean',
30+
];
31+
32+
public function warehouse(): BelongsTo
33+
{
34+
return $this->belongsTo(Warehouse::class);
35+
}
36+
37+
public function zone(): BelongsTo
38+
{
39+
return $this->belongsTo(WarehouseZone::class, 'zone_id');
40+
}
41+
42+
public function stockLocations(): HasMany
43+
{
44+
return $this->hasMany(BinStockLocation::class, 'bin_id');
45+
}
46+
47+
public function getUsedCapacityAttribute(): float
48+
{
49+
return (float) $this->stockLocations->sum('quantity');
50+
}
51+
52+
public function getAvailableCapacityAttribute(): ?float
53+
{
54+
if ($this->capacity === null) {
55+
return null;
56+
}
57+
58+
return max(0, (float) $this->capacity - $this->used_capacity);
59+
}
60+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class WarehouseZone extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $table = 'warehouse_zones';
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'warehouse_id',
19+
'name',
20+
'code',
21+
'description',
22+
'is_active',
23+
];
24+
25+
protected $casts = [
26+
'is_active' => 'boolean',
27+
];
28+
29+
public function warehouse(): BelongsTo
30+
{
31+
return $this->belongsTo(Warehouse::class);
32+
}
33+
34+
public function bins(): HasMany
35+
{
36+
return $this->hasMany(WarehouseBin::class, 'zone_id');
37+
}
38+
}
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 WarehouseBinPolicy
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+
}

0 commit comments

Comments
 (0)