Skip to content

Commit 44b1fbf

Browse files
committed
feat: Phase 36 — Inventory Adjustments for stock count corrections
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent e191fa0 commit 44b1fbf

14 files changed

Lines changed: 1062 additions & 2 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Inventory\Models\Product;
8+
use App\Modules\Inventory\Models\StockAdjustment;
9+
use App\Modules\Inventory\Models\Warehouse;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class StockAdjustmentController extends Controller
16+
{
17+
public function index(): Response
18+
{
19+
$this->authorize('viewAny', StockAdjustment::class);
20+
$adjustments = StockAdjustment::with(['warehouse', 'adjuster'])
21+
->orderByDesc('created_at')
22+
->paginate(25);
23+
return Inertia::render('Inventory/StockAdjustments/Index', compact('adjustments'));
24+
}
25+
26+
public function create(): Response
27+
{
28+
$this->authorize('create', StockAdjustment::class);
29+
$warehouses = Warehouse::orderBy('name')->get(['id', 'name']);
30+
$products = Product::orderBy('name')->get(['id', 'name', 'sku']);
31+
return Inertia::render('Inventory/StockAdjustments/Create', compact('warehouses', 'products'));
32+
}
33+
34+
public function store(Request $request): RedirectResponse
35+
{
36+
$this->authorize('create', StockAdjustment::class);
37+
$data = $request->validate([
38+
'warehouse_id' => 'required|exists:warehouses,id',
39+
'reference' => 'required|string|max:100|unique:stock_adjustments,reference',
40+
'reason' => 'required|in:count,damage,theft,expiry,correction,other',
41+
'notes' => 'nullable|string',
42+
'items' => 'required|array|min:1',
43+
'items.*.product_id' => 'required|exists:products,id',
44+
'items.*.expected_quantity' => 'required|numeric|min:0',
45+
'items.*.actual_quantity' => 'required|numeric|min:0',
46+
]);
47+
48+
$adj = StockAdjustment::create([
49+
'tenant_id' => auth()->user()->tenant_id,
50+
'warehouse_id' => $data['warehouse_id'],
51+
'reference' => $data['reference'],
52+
'reason' => $data['reason'],
53+
'status' => 'draft',
54+
'notes' => $data['notes'] ?? null,
55+
]);
56+
57+
foreach ($data['items'] as $item) {
58+
$adj->items()->create([
59+
'product_id' => $item['product_id'],
60+
'expected_quantity' => $item['expected_quantity'],
61+
'actual_quantity' => $item['actual_quantity'],
62+
'difference' => $item['actual_quantity'] - $item['expected_quantity'],
63+
]);
64+
}
65+
66+
return redirect()->route('inventory.stock-adjustments.show', $adj)
67+
->with('success', 'Stock adjustment created.');
68+
}
69+
70+
public function show(StockAdjustment $stockAdjustment): Response
71+
{
72+
$this->authorize('view', $stockAdjustment);
73+
$stockAdjustment->load(['warehouse', 'adjuster', 'items.product']);
74+
return Inertia::render('Inventory/StockAdjustments/Show', compact('stockAdjustment'));
75+
}
76+
77+
public function confirm(StockAdjustment $stockAdjustment): RedirectResponse
78+
{
79+
$this->authorize('update', $stockAdjustment);
80+
/** @var User $user */
81+
$user = auth()->user();
82+
$stockAdjustment->confirm($user);
83+
return back()->with('success', 'Adjustment confirmed and stock updated.');
84+
}
85+
86+
public function cancel(StockAdjustment $stockAdjustment): RedirectResponse
87+
{
88+
$this->authorize('update', $stockAdjustment);
89+
abort_unless($stockAdjustment->status === 'draft', 422, 'Only draft adjustments can be cancelled.');
90+
$stockAdjustment->update(['status' => 'cancelled']);
91+
return back()->with('success', 'Adjustment cancelled.');
92+
}
93+
94+
public function destroy(StockAdjustment $stockAdjustment): RedirectResponse
95+
{
96+
$this->authorize('delete', $stockAdjustment);
97+
abort_unless($stockAdjustment->status === 'draft', 422, 'Only draft adjustments can be deleted.');
98+
$stockAdjustment->delete();
99+
return redirect()->route('inventory.stock-adjustments.index')->with('success', 'Adjustment deleted.');
100+
}
101+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class StockAdjustment extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'warehouse_id', 'reference', 'reason', 'status',
18+
'adjusted_by', 'confirmed_at', 'notes',
19+
];
20+
21+
protected $casts = ['confirmed_at' => 'datetime'];
22+
23+
public function warehouse(): BelongsTo
24+
{
25+
return $this->belongsTo(Warehouse::class);
26+
}
27+
28+
public function adjuster(): BelongsTo
29+
{
30+
return $this->belongsTo(User::class, 'adjusted_by');
31+
}
32+
33+
public function items(): HasMany
34+
{
35+
return $this->hasMany(StockAdjustmentItem::class);
36+
}
37+
38+
/**
39+
* Confirm the adjustment: create stock movements for each item with a difference.
40+
*/
41+
public function confirm(User $user): void
42+
{
43+
abort_unless($this->status === 'draft', 422, 'Only draft adjustments can be confirmed.');
44+
$this->load('items.product', 'warehouse');
45+
46+
foreach ($this->items as $item) {
47+
if ($item->difference == 0) {
48+
continue;
49+
}
50+
51+
$type = $item->difference > 0 ? 'in' : 'out';
52+
53+
StockMovement::record([
54+
'product_id' => $item->product_id,
55+
'warehouse_id' => $this->warehouse_id,
56+
'type' => $type,
57+
'quantity' => abs((float) $item->difference),
58+
'reference' => $this->reference,
59+
]);
60+
}
61+
62+
$this->update([
63+
'status' => 'confirmed',
64+
'adjusted_by' => $user->id,
65+
'confirmed_at' => now(),
66+
]);
67+
}
68+
}
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\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class StockAdjustmentItem extends Model
9+
{
10+
protected $fillable = [
11+
'stock_adjustment_id', 'product_id', 'expected_quantity', 'actual_quantity', 'difference',
12+
];
13+
14+
protected $casts = [
15+
'expected_quantity' => 'decimal:2',
16+
'actual_quantity' => 'decimal:2',
17+
'difference' => 'decimal:2',
18+
];
19+
20+
public function stockAdjustment(): BelongsTo
21+
{
22+
return $this->belongsTo(StockAdjustment::class);
23+
}
24+
25+
public function product(): BelongsTo
26+
{
27+
return $this->belongsTo(Product::class);
28+
}
29+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\StockAdjustment;
7+
8+
class StockAdjustmentPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, StockAdjustment $adj): 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 update(User $user, StockAdjustment $adj): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function delete(User $user, StockAdjustment $adj): bool
31+
{
32+
return $user->can('inventory.delete');
33+
}
34+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use App\Modules\Inventory\Models\Product;
66
use App\Modules\Inventory\Models\ProductCategory;
7+
use App\Modules\Inventory\Models\StockAdjustment;
78
use App\Modules\Inventory\Models\WarehouseTransfer;
89
use App\Modules\Inventory\Policies\ProductCategoryPolicy;
910
use App\Modules\Inventory\Policies\ProductPolicy;
11+
use App\Modules\Inventory\Policies\StockAdjustmentPolicy;
1012
use App\Modules\Inventory\Policies\WarehouseTransferPolicy;
1113
use Illuminate\Support\Facades\Gate;
1214
use Illuminate\Support\ServiceProvider;
@@ -22,5 +24,6 @@ public function boot(): void
2224
Gate::policy(Product::class, ProductPolicy::class);
2325
Gate::policy(ProductCategory::class, ProductCategoryPolicy::class);
2426
Gate::policy(WarehouseTransfer::class, WarehouseTransferPolicy::class);
27+
Gate::policy(StockAdjustment::class, StockAdjustmentPolicy::class);
2528
}
2629
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Modules\Inventory\Http\Controllers\ProductController;
66
use App\Modules\Inventory\Http\Controllers\PurchaseOrderController;
77
use App\Modules\Inventory\Http\Controllers\ReorderController;
8+
use App\Modules\Inventory\Http\Controllers\StockAdjustmentController;
89
use App\Modules\Inventory\Http\Controllers\StockMovementController;
910
use App\Modules\Inventory\Http\Controllers\SupplierController;
1011
use App\Modules\Inventory\Http\Controllers\WarehouseController;
@@ -61,4 +62,9 @@
6162

6263
// Warehouse Transfers
6364
Route::resource('warehouse-transfers', WarehouseTransferController::class)->only(['index', 'create', 'store']);
65+
66+
// Stock Adjustments
67+
Route::resource('stock-adjustments', StockAdjustmentController::class)->except(['edit', 'update']);
68+
Route::post('stock-adjustments/{stockAdjustment}/confirm', [StockAdjustmentController::class, 'confirm'])->name('stock-adjustments.confirm');
69+
Route::post('stock-adjustments/{stockAdjustment}/cancel', [StockAdjustmentController::class, 'cancel'])->name('stock-adjustments.cancel');
6470
});
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('stock_adjustments', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
14+
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
15+
$table->string('reference')->unique();
16+
$table->enum('reason', ['count', 'damage', 'theft', 'expiry', 'correction', 'other'])->default('count');
17+
$table->enum('status', ['draft', 'confirmed', 'cancelled'])->default('draft');
18+
$table->foreignId('adjusted_by')->nullable()->nullOnDelete()->constrained('users');
19+
$table->timestamp('confirmed_at')->nullable();
20+
$table->text('notes')->nullable();
21+
$table->timestamps();
22+
$table->softDeletes();
23+
});
24+
}
25+
26+
public function down(): void
27+
{
28+
Schema::dropIfExists('stock_adjustments');
29+
}
30+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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('stock_adjustment_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('stock_adjustment_id')->constrained('stock_adjustments')->cascadeOnDelete();
14+
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
15+
$table->decimal('expected_quantity', 10, 2)->default(0);
16+
$table->decimal('actual_quantity', 10, 2)->default(0);
17+
$table->decimal('difference', 10, 2)->default(0);
18+
$table->timestamps();
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists('stock_adjustment_items');
25+
}
26+
};

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ const navItems: NavItem[] = [
5050
{ label: 'Suppliers', href: '/inventory/suppliers', icon: inventoryIcon },
5151
{ label: 'Stock Movements', href: '/inventory/stock-movements', icon: inventoryIcon },
5252
{ label: 'Purchase Orders', href: '/inventory/purchase-orders', icon: inventoryIcon },
53-
{ label: 'Transfers', href: '/inventory/warehouse-transfers', icon: inventoryIcon },
54-
{ label: 'Reorder', href: '/inventory/reorder', icon: inventoryIcon },
53+
{ label: 'Transfers', href: '/inventory/warehouse-transfers', icon: inventoryIcon },
54+
{ label: 'Reorder', href: '/inventory/reorder', icon: inventoryIcon },
55+
{ label: 'Stock Adjustments', href: '/inventory/stock-adjustments', icon: inventoryIcon },
5556
],
5657
},
5758
{

0 commit comments

Comments
 (0)