Skip to content

Commit dc5dcb2

Browse files
committed
feat(inventory): Phase 111 — Inventory Cycle Counting
Implements scheduled physical stock counts to reconcile system vs. actual quantities, including migrations, CycleCount/CycleCountItem models, policy, controller, routes, frontend pages, TypeScript types, sidebar link, and 10 Pest feature tests (1150 total, all passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a6f3beb commit dc5dcb2

13 files changed

Lines changed: 780 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\CycleCount;
7+
use App\Modules\Inventory\Models\CycleCountItem;
8+
use App\Modules\Inventory\Models\Product;
9+
use App\Modules\Inventory\Models\StockLevel;
10+
use App\Modules\Inventory\Models\Warehouse;
11+
use Illuminate\Http\RedirectResponse;
12+
use Illuminate\Http\Request;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class CycleCountController extends Controller
17+
{
18+
public function index(Request $request): Response
19+
{
20+
$this->authorize('viewAny', CycleCount::class);
21+
22+
$query = CycleCount::with('warehouse')->orderByDesc('created_at');
23+
24+
if ($request->filled('status')) {
25+
$query->where('status', $request->status);
26+
}
27+
28+
$cycleCounts = $query->paginate(20);
29+
30+
return Inertia::render('Inventory/CycleCounts/Index', compact('cycleCounts'));
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', CycleCount::class);
36+
37+
$warehouses = Warehouse::orderBy('name')->get(['id', 'name']);
38+
$products = Product::orderBy('name')->get(['id', 'name', 'sku']);
39+
40+
return Inertia::render('Inventory/CycleCounts/Create', compact('warehouses', 'products'));
41+
}
42+
43+
public function store(Request $request): RedirectResponse
44+
{
45+
$this->authorize('create', CycleCount::class);
46+
47+
$validated = $request->validate([
48+
'warehouse_id' => 'required|exists:warehouses,id',
49+
'count_date' => 'required|date',
50+
'notes' => 'nullable|string',
51+
'products' => 'required|array|min:1',
52+
'products.*' => 'exists:products,id',
53+
]);
54+
55+
$cc = CycleCount::create([
56+
'tenant_id' => auth()->user()->tenant_id,
57+
'warehouse_id' => $validated['warehouse_id'],
58+
'count_number' => CycleCount::generateCountNumber(),
59+
'count_date' => $validated['count_date'],
60+
'notes' => $validated['notes'] ?? null,
61+
'created_by' => auth()->id(),
62+
]);
63+
64+
foreach ($validated['products'] as $productId) {
65+
$stockLevel = StockLevel::where('product_id', $productId)
66+
->where('warehouse_id', $validated['warehouse_id'])
67+
->first();
68+
69+
CycleCountItem::create([
70+
'tenant_id' => auth()->user()->tenant_id,
71+
'cycle_count_id' => $cc->id,
72+
'product_id' => $productId,
73+
'system_qty' => $stockLevel?->quantity ?? 0,
74+
]);
75+
}
76+
77+
return redirect()->route('inventory.cycle-counts.show', $cc)
78+
->with('success', 'Cycle count created.');
79+
}
80+
81+
public function show(CycleCount $cycleCount): Response
82+
{
83+
$this->authorize('view', $cycleCount);
84+
85+
$cycleCount->load(['warehouse', 'items.product']);
86+
87+
return Inertia::render('Inventory/CycleCounts/Show', compact('cycleCount'));
88+
}
89+
90+
public function updateCounts(Request $request, CycleCount $cycleCount): RedirectResponse
91+
{
92+
$this->authorize('update', $cycleCount);
93+
94+
$validated = $request->validate([
95+
'items' => 'required|array',
96+
'items.*.id' => 'required|exists:cycle_count_items,id',
97+
'items.*.counted_qty' => 'required|numeric|min:0',
98+
]);
99+
100+
foreach ($validated['items'] as $itemData) {
101+
$item = CycleCountItem::find($itemData['id']);
102+
if ($item && $item->cycle_count_id === $cycleCount->id) {
103+
$item->counted_qty = $itemData['counted_qty'];
104+
$item->save();
105+
}
106+
}
107+
108+
return back()->with('success', 'Counts updated.');
109+
}
110+
111+
public function start(CycleCount $cycleCount): RedirectResponse
112+
{
113+
$this->authorize('update', $cycleCount);
114+
115+
$cycleCount->start();
116+
117+
return back()->with('success', 'Cycle count started.');
118+
}
119+
120+
public function complete(CycleCount $cycleCount): RedirectResponse
121+
{
122+
$this->authorize('update', $cycleCount);
123+
124+
$cycleCount->complete();
125+
126+
return back()->with('success', 'Cycle count completed.');
127+
}
128+
129+
public function cancel(CycleCount $cycleCount): RedirectResponse
130+
{
131+
$this->authorize('update', $cycleCount);
132+
133+
$cycleCount->cancel();
134+
135+
return back()->with('success', 'Cycle count cancelled.');
136+
}
137+
138+
public function destroy(CycleCount $cycleCount): RedirectResponse
139+
{
140+
$this->authorize('delete', $cycleCount);
141+
142+
$cycleCount->delete();
143+
144+
return redirect()->route('inventory.cycle-counts.index')
145+
->with('success', 'Cycle count deleted.');
146+
}
147+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 CycleCount extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'warehouse_id', 'count_number', 'count_date',
18+
'status', 'notes', 'created_by', 'started_at', 'completed_at',
19+
];
20+
21+
protected $casts = [
22+
'count_date' => 'date',
23+
'started_at' => 'datetime',
24+
'completed_at' => 'datetime',
25+
];
26+
27+
public function warehouse(): BelongsTo
28+
{
29+
return $this->belongsTo(Warehouse::class);
30+
}
31+
32+
public function items(): HasMany
33+
{
34+
return $this->hasMany(CycleCountItem::class);
35+
}
36+
37+
public function createdBy(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class, 'created_by');
40+
}
41+
42+
public static function generateCountNumber(): string
43+
{
44+
return 'CC-' . strtoupper(uniqid());
45+
}
46+
47+
public function start(): void
48+
{
49+
$this->status = 'in_progress';
50+
$this->started_at = now();
51+
$this->save();
52+
}
53+
54+
public function complete(): void
55+
{
56+
$this->status = 'completed';
57+
$this->completed_at = now();
58+
$this->save();
59+
}
60+
61+
public function cancel(): void
62+
{
63+
$this->status = 'cancelled';
64+
$this->save();
65+
}
66+
67+
public function getTotalVarianceAttribute(): float
68+
{
69+
return (float) $this->items()
70+
->whereNotNull('counted_qty')
71+
->get()
72+
->sum(fn ($i) => abs($i->counted_qty - $i->system_qty));
73+
}
74+
75+
public function getItemsCountedAttribute(): int
76+
{
77+
return $this->items()->whereNotNull('counted_qty')->count();
78+
}
79+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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 CycleCountItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'cycle_count_id', 'product_id', 'system_qty', 'counted_qty', 'notes',
15+
];
16+
17+
protected $casts = [
18+
'system_qty' => 'float',
19+
'counted_qty' => 'float',
20+
];
21+
22+
public function cycleCount(): BelongsTo
23+
{
24+
return $this->belongsTo(CycleCount::class);
25+
}
26+
27+
public function product(): BelongsTo
28+
{
29+
return $this->belongsTo(Product::class);
30+
}
31+
32+
public function getVarianceAttribute(): float
33+
{
34+
return ($this->counted_qty ?? $this->system_qty) - $this->system_qty;
35+
}
36+
37+
public function getIsCountedAttribute(): bool
38+
{
39+
return $this->counted_qty !== null;
40+
}
41+
}
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\CycleCount;
7+
8+
class CycleCountPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, CycleCount $cycleCount): 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, CycleCount $cycleCount): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function delete(User $user, CycleCount $cycleCount): 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
@@ -59,6 +59,8 @@
5959
use App\Modules\Inventory\Models\CustomerDiscount;
6060
use App\Modules\Inventory\Policies\PriceListPolicy;
6161
use App\Modules\Inventory\Models\UnitOfMeasure;
62+
use App\Modules\Inventory\Models\CycleCount;
63+
use App\Modules\Inventory\Policies\CycleCountPolicy;
6264
use App\Modules\Inventory\Policies\UnitOfMeasurePolicy;
6365
use Illuminate\Support\Facades\Gate;
6466
use Illuminate\Support\ServiceProvider;
@@ -110,5 +112,6 @@ public function boot(): void
110112
Gate::policy(PriceListItem::class, PriceListPolicy::class);
111113
Gate::policy(CustomerDiscount::class, PriceListPolicy::class);
112114
Gate::policy(UnitOfMeasure::class, UnitOfMeasurePolicy::class);
115+
Gate::policy(CycleCount::class, CycleCountPolicy::class);
113116
}
114117
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,13 @@
221221
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
222222
Route::resource('units-of-measure', UnitOfMeasureController::class)->names('units-of-measure');
223223
});
224+
225+
// Cycle Counts — custom actions BEFORE resource
226+
use App\Modules\Inventory\Http\Controllers\CycleCountController;
227+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
228+
Route::post('cycle-counts/{cycleCount}/start', [CycleCountController::class, 'start'])->name('cycle-counts.start');
229+
Route::post('cycle-counts/{cycleCount}/complete', [CycleCountController::class, 'complete'])->name('cycle-counts.complete');
230+
Route::post('cycle-counts/{cycleCount}/cancel', [CycleCountController::class, 'cancel'])->name('cycle-counts.cancel');
231+
Route::post('cycle-counts/{cycleCount}/counts', [CycleCountController::class, 'updateCounts'])->name('cycle-counts.counts.update');
232+
Route::resource('cycle-counts', CycleCountController::class)->names('cycle-counts');
233+
});
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('cycle_counts', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('warehouse_id');
15+
$table->string('count_number')->unique();
16+
$table->date('count_date');
17+
$table->string('status')->default('draft'); // draft, in_progress, completed, cancelled
18+
$table->text('notes')->nullable();
19+
$table->unsignedBigInteger('created_by')->nullable();
20+
$table->timestamp('started_at')->nullable();
21+
$table->timestamp('completed_at')->nullable();
22+
$table->timestamps();
23+
$table->softDeletes();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('cycle_counts');
30+
}
31+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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('cycle_count_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('cycle_count_id');
15+
$table->unsignedBigInteger('product_id');
16+
$table->decimal('system_qty', 10, 2)->default(0); // qty from StockLevel at time of count
17+
$table->decimal('counted_qty', 10, 2)->nullable(); // actual counted qty (null = not yet counted)
18+
$table->text('notes')->nullable();
19+
$table->timestamps();
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('cycle_count_items');
26+
}
27+
};

0 commit comments

Comments
 (0)