Skip to content

Commit 413393f

Browse files
committed
feat: Phase 22 — Warehouse transfers with stock movement tracking
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent ec86ca7 commit 413393f

11 files changed

Lines changed: 600 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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\Warehouse;
8+
use App\Modules\Inventory\Models\WarehouseTransfer;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
12+
class WarehouseTransferController extends Controller
13+
{
14+
public function index(Request $request)
15+
{
16+
$this->authorize('viewAny', WarehouseTransfer::class);
17+
$tenantId = $request->user()->tenant_id;
18+
$transfers = WarehouseTransfer::where('tenant_id', $tenantId)
19+
->with(['product', 'fromWarehouse', 'toWarehouse'])
20+
->latest()
21+
->paginate(50);
22+
return Inertia::render('Inventory/WarehouseTransfers/Index', ['transfers' => $transfers]);
23+
}
24+
25+
public function create(Request $request)
26+
{
27+
$this->authorize('create', WarehouseTransfer::class);
28+
$tenantId = $request->user()->tenant_id;
29+
$products = Product::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']);
30+
$warehouses = Warehouse::where('tenant_id', $tenantId)->orderBy('name')->get(['id', 'name']);
31+
return Inertia::render('Inventory/WarehouseTransfers/Create', compact('products', 'warehouses'));
32+
}
33+
34+
public function store(Request $request)
35+
{
36+
$this->authorize('create', WarehouseTransfer::class);
37+
38+
$data = $request->validate([
39+
'product_id' => 'required|exists:products,id',
40+
'from_warehouse_id' => 'required|exists:warehouses,id',
41+
'to_warehouse_id' => 'required|exists:warehouses,id|different:from_warehouse_id',
42+
'quantity' => 'required|numeric|min:0.0001',
43+
'reference' => 'nullable|string|max:100',
44+
'notes' => 'nullable|string|max:500',
45+
]);
46+
47+
$data['tenant_id'] = $request->user()->tenant_id;
48+
49+
// Bind tenant so StockMovement::record() can resolve tenant_id for stock levels
50+
if (!app()->has('tenant')) {
51+
$tenant = \App\Modules\Core\Models\Tenant::find($data['tenant_id']);
52+
if ($tenant) {
53+
app()->instance('tenant', $tenant);
54+
}
55+
}
56+
57+
try {
58+
WarehouseTransfer::execute($data);
59+
} catch (\DomainException $e) {
60+
return back()->withErrors(['quantity' => $e->getMessage()]);
61+
}
62+
63+
return redirect('/inventory/warehouse-transfers')->with('success', 'Transfer completed.');
64+
}
65+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Support\Facades\Auth;
10+
use Illuminate\Support\Facades\DB;
11+
12+
class WarehouseTransfer extends Model
13+
{
14+
use BelongsToTenant;
15+
16+
protected $fillable = [
17+
'tenant_id', 'product_id', 'from_warehouse_id', 'to_warehouse_id',
18+
'quantity', 'reference', 'notes', 'status', 'created_by',
19+
];
20+
21+
protected $casts = [
22+
'quantity' => 'float',
23+
];
24+
25+
public function product(): BelongsTo
26+
{
27+
return $this->belongsTo(Product::class);
28+
}
29+
30+
public function fromWarehouse(): BelongsTo
31+
{
32+
return $this->belongsTo(Warehouse::class, 'from_warehouse_id');
33+
}
34+
35+
public function toWarehouse(): BelongsTo
36+
{
37+
return $this->belongsTo(Warehouse::class, 'to_warehouse_id');
38+
}
39+
40+
public function creator(): BelongsTo
41+
{
42+
return $this->belongsTo(User::class, 'created_by');
43+
}
44+
45+
public static function execute(array $data): static
46+
{
47+
return DB::transaction(function () use ($data) {
48+
// Record OUT from source warehouse
49+
StockMovement::record([
50+
'product_id' => $data['product_id'],
51+
'warehouse_id' => $data['from_warehouse_id'],
52+
'type' => 'out',
53+
'quantity' => $data['quantity'],
54+
'reference' => $data['reference'] ?? null,
55+
'notes' => $data['notes'] ?? null,
56+
]);
57+
58+
// Record IN to destination warehouse
59+
StockMovement::record([
60+
'product_id' => $data['product_id'],
61+
'warehouse_id' => $data['to_warehouse_id'],
62+
'type' => 'in',
63+
'quantity' => $data['quantity'],
64+
'reference' => $data['reference'] ?? null,
65+
'notes' => $data['notes'] ?? null,
66+
]);
67+
68+
// Create the transfer record
69+
return static::create([
70+
'tenant_id' => $data['tenant_id'],
71+
'product_id' => $data['product_id'],
72+
'from_warehouse_id' => $data['from_warehouse_id'],
73+
'to_warehouse_id' => $data['to_warehouse_id'],
74+
'quantity' => $data['quantity'],
75+
'reference' => $data['reference'] ?? null,
76+
'notes' => $data['notes'] ?? null,
77+
'status' => 'completed',
78+
'created_by' => Auth::id(),
79+
]);
80+
});
81+
}
82+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\WarehouseTransfer;
7+
8+
class WarehouseTransferPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, WarehouseTransfer $transfer): bool
16+
{
17+
return $user->can('inventory.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('inventory.create');
23+
}
24+
25+
public function delete(User $user, WarehouseTransfer $transfer): bool
26+
{
27+
return $user->can('inventory.delete');
28+
}
29+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Modules\Inventory\Providers;
44

55
use App\Modules\Inventory\Models\Product;
6+
use App\Modules\Inventory\Models\WarehouseTransfer;
67
use App\Modules\Inventory\Policies\ProductPolicy;
8+
use App\Modules\Inventory\Policies\WarehouseTransferPolicy;
79
use Illuminate\Support\Facades\Gate;
810
use Illuminate\Support\ServiceProvider;
911

@@ -16,5 +18,6 @@ public function boot(): void
1618
$this->loadRoutesFrom(__DIR__ . '/../routes/inventory.php');
1719

1820
Gate::policy(Product::class, ProductPolicy::class);
21+
Gate::policy(WarehouseTransfer::class, WarehouseTransferPolicy::class);
1922
}
2023
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Modules\Inventory\Http\Controllers\StockMovementController;
77
use App\Modules\Inventory\Http\Controllers\SupplierController;
88
use App\Modules\Inventory\Http\Controllers\WarehouseController;
9+
use App\Modules\Inventory\Http\Controllers\WarehouseTransferController;
910
use Illuminate\Support\Facades\Route;
1011

1112
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
@@ -46,4 +47,7 @@
4647
Route::post('purchase-orders/{purchaseOrder}/approve', [PurchaseOrderController::class, 'approve'])->name('purchase-orders.approve');
4748
Route::post('purchase-orders/{purchaseOrder}/receive', [PurchaseOrderController::class, 'receive'])->name('purchase-orders.receive');
4849
Route::post('purchase-orders/{purchaseOrder}/cancel', [PurchaseOrderController::class, 'cancel'])->name('purchase-orders.cancel');
50+
51+
// Warehouse Transfers
52+
Route::resource('warehouse-transfers', WarehouseTransferController::class)->only(['index', 'create', 'store']);
4953
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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('warehouse_transfers', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
15+
$table->foreignId('from_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
16+
$table->foreignId('to_warehouse_id')->constrained('warehouses')->cascadeOnDelete();
17+
$table->decimal('quantity', 12, 4);
18+
$table->string('reference')->nullable();
19+
$table->text('notes')->nullable();
20+
$table->enum('status', ['pending', 'completed', 'cancelled'])->default('completed');
21+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
22+
$table->timestamps();
23+
});
24+
}
25+
26+
public function down(): void
27+
{
28+
Schema::dropIfExists('warehouse_transfers');
29+
}
30+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const navItems: NavItem[] = [
4949
{ label: 'Suppliers', href: '/inventory/suppliers', icon: inventoryIcon },
5050
{ label: 'Stock Movements', href: '/inventory/stock-movements', icon: inventoryIcon },
5151
{ label: 'Purchase Orders', href: '/inventory/purchase-orders', icon: inventoryIcon },
52+
{ label: 'Transfers', href: '/inventory/warehouse-transfers', icon: inventoryIcon },
5253
],
5354
},
5455
{

0 commit comments

Comments
 (0)