Skip to content

Commit ac0f504

Browse files
committed
feat(inventory): Phase 90 — Lot & Serial Number Tracking with traceability
Implements full lot number and serial number tracking for the Inventory module, covering receipt to consumption with quarantine, consume, sell and scrap workflows. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a309069 commit ac0f504

16 files changed

Lines changed: 1363 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\LotNumber;
7+
use App\Modules\Inventory\Models\Product;
8+
use App\Modules\Inventory\Models\Warehouse;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class LotNumberController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$lots = LotNumber::with(['product', 'warehouse'])
19+
->when($request->product_id, fn ($q) => $q->where('product_id', $request->product_id))
20+
->when($request->warehouse_id, fn ($q) => $q->where('warehouse_id', $request->warehouse_id))
21+
->when($request->status, fn ($q) => $q->where('status', $request->status))
22+
->latest()
23+
->paginate(20)
24+
->withQueryString();
25+
26+
return Inertia::render('Inventory/LotNumbers/Index', [
27+
'lots' => $lots,
28+
'products' => Product::orderBy('name')->get(['id', 'name', 'sku']),
29+
'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']),
30+
'filters' => $request->only(['product_id', 'warehouse_id', 'status']),
31+
]);
32+
}
33+
34+
public function store(Request $request): RedirectResponse
35+
{
36+
$this->authorize('create', LotNumber::class);
37+
38+
$validated = $request->validate([
39+
'product_id' => 'required|exists:products,id',
40+
'warehouse_id' => 'required|exists:warehouses,id',
41+
'lot_number' => 'required|string|max:255',
42+
'manufacture_date' => 'nullable|date',
43+
'expiry_date' => 'nullable|date|after:manufacture_date',
44+
'quantity_received' => 'required|integer|min:1',
45+
]);
46+
47+
$validated['tenant_id'] = auth()->user()->tenant_id;
48+
$validated['quantity_remaining'] = $validated['quantity_received'];
49+
50+
LotNumber::create($validated);
51+
52+
return back()->with('success', 'Lot number created successfully.');
53+
}
54+
55+
public function show(LotNumber $lotNumber): Response
56+
{
57+
$lotNumber->load(['product', 'warehouse', 'serialNumbers']);
58+
59+
return Inertia::render('Inventory/LotNumbers/Show', [
60+
'lot' => $lotNumber,
61+
]);
62+
}
63+
64+
public function quarantine(Request $request, LotNumber $lotNumber): RedirectResponse
65+
{
66+
$request->validate([
67+
'notes' => 'nullable|string',
68+
]);
69+
70+
$lotNumber->quarantine($request->notes);
71+
72+
return back()->with('success', 'Lot quarantined.');
73+
}
74+
75+
public function consume(Request $request, LotNumber $lotNumber): RedirectResponse
76+
{
77+
$request->validate([
78+
'qty' => 'required|integer|min:1',
79+
]);
80+
81+
$lotNumber->consume((int) $request->qty);
82+
83+
return back()->with('success', 'Lot consumption recorded.');
84+
}
85+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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\SerialNumber;
8+
use App\Modules\Inventory\Models\Warehouse;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class SerialNumberController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$serials = SerialNumber::with(['product', 'warehouse'])
19+
->when($request->product_id, fn ($q) => $q->where('product_id', $request->product_id))
20+
->when($request->status, fn ($q) => $q->where('status', $request->status))
21+
->latest()
22+
->paginate(20)
23+
->withQueryString();
24+
25+
return Inertia::render('Inventory/SerialNumbers/Index', [
26+
'serials' => $serials,
27+
'products' => Product::orderBy('name')->get(['id', 'name', 'sku']),
28+
'warehouses' => Warehouse::orderBy('name')->get(['id', 'name']),
29+
'filters' => $request->only(['product_id', 'status']),
30+
]);
31+
}
32+
33+
public function store(Request $request): RedirectResponse
34+
{
35+
$validated = $request->validate([
36+
'product_id' => 'required|exists:products,id',
37+
'warehouse_id' => 'required|exists:warehouses,id',
38+
'serial_number' => 'required|string|max:255|unique:serial_numbers,serial_number',
39+
'received_date' => 'nullable|date',
40+
'lot_number_id' => 'nullable|exists:lot_numbers,id',
41+
]);
42+
43+
$validated['tenant_id'] = auth()->user()->tenant_id;
44+
45+
SerialNumber::create($validated);
46+
47+
return back()->with('success', 'Serial number created successfully.');
48+
}
49+
50+
public function show(SerialNumber $serialNumber): Response
51+
{
52+
$serialNumber->load(['product', 'warehouse', 'lot']);
53+
54+
return Inertia::render('Inventory/SerialNumbers/Show', [
55+
'serial' => $serialNumber,
56+
]);
57+
}
58+
59+
public function sell(Request $request, SerialNumber $serialNumber): RedirectResponse
60+
{
61+
$serialNumber->sell($request->notes);
62+
63+
return back()->with('success', 'Serial number marked as sold.');
64+
}
65+
66+
public function scrap(Request $request, SerialNumber $serialNumber): RedirectResponse
67+
{
68+
$serialNumber->scrap($request->notes);
69+
70+
return back()->with('success', 'Serial number scrapped.');
71+
}
72+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 LotNumber extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'product_id',
17+
'warehouse_id',
18+
'lot_number',
19+
'manufacture_date',
20+
'expiry_date',
21+
'quantity_received',
22+
'quantity_remaining',
23+
'status',
24+
'notes',
25+
];
26+
27+
protected $casts = [
28+
'manufacture_date' => 'date',
29+
'expiry_date' => 'date',
30+
'quantity_received' => 'integer',
31+
'quantity_remaining' => 'integer',
32+
];
33+
34+
public function product(): BelongsTo
35+
{
36+
return $this->belongsTo(Product::class);
37+
}
38+
39+
public function warehouse(): BelongsTo
40+
{
41+
return $this->belongsTo(Warehouse::class);
42+
}
43+
44+
public function serialNumbers(): HasMany
45+
{
46+
return $this->hasMany(SerialNumber::class, 'lot_number_id');
47+
}
48+
49+
public function getIsExpiredAttribute(): bool
50+
{
51+
return $this->expiry_date !== null && $this->expiry_date->isPast();
52+
}
53+
54+
public function getIsExpiringAttribute(): bool
55+
{
56+
return $this->expiry_date !== null
57+
&& $this->expiry_date->isFuture()
58+
&& $this->expiry_date->diffInDays(now()) <= 30;
59+
}
60+
61+
public function quarantine(?string $reason = null): void
62+
{
63+
$this->status = 'quarantine';
64+
if ($reason !== null) {
65+
$this->notes = $reason;
66+
}
67+
$this->save();
68+
}
69+
70+
public function consume(int $qty): void
71+
{
72+
$this->quantity_remaining = max(0, $this->quantity_remaining - $qty);
73+
if ($this->quantity_remaining <= 0) {
74+
$this->status = 'consumed';
75+
}
76+
$this->save();
77+
}
78+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\Support\Carbon;
9+
10+
class SerialNumber extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'product_id',
17+
'warehouse_id',
18+
'serial_number',
19+
'status',
20+
'received_date',
21+
'sold_date',
22+
'lot_number_id',
23+
'notes',
24+
];
25+
26+
protected $casts = [
27+
'received_date' => 'date',
28+
'sold_date' => 'date',
29+
];
30+
31+
public function product(): BelongsTo
32+
{
33+
return $this->belongsTo(Product::class);
34+
}
35+
36+
public function warehouse(): BelongsTo
37+
{
38+
return $this->belongsTo(Warehouse::class);
39+
}
40+
41+
public function lot(): BelongsTo
42+
{
43+
return $this->belongsTo(LotNumber::class, 'lot_number_id');
44+
}
45+
46+
public function sell(?string $notes = null): void
47+
{
48+
$this->status = 'sold';
49+
$this->sold_date = Carbon::today();
50+
if ($notes !== null) {
51+
$this->notes = $notes;
52+
}
53+
$this->save();
54+
}
55+
56+
public function scrap(?string $notes = null): void
57+
{
58+
$this->status = 'scrapped';
59+
if ($notes !== null) {
60+
$this->notes = $notes;
61+
}
62+
$this->save();
63+
}
64+
65+
public function getIsAvailableAttribute(): bool
66+
{
67+
return $this->status === 'in_stock';
68+
}
69+
}
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 LotSerialPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('inventory.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('inventory.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('inventory.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('inventory.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('inventory.delete');
32+
}
33+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
use App\Modules\Inventory\Policies\CostingPolicy;
4545
use App\Modules\Inventory\Models\SupplierReview;
4646
use App\Modules\Inventory\Models\SupplierContract;
47+
use App\Modules\Inventory\Models\LotNumber;
48+
use App\Modules\Inventory\Models\SerialNumber;
49+
use App\Modules\Inventory\Policies\LotSerialPolicy;
4750
use App\Modules\Inventory\Policies\SupplierReviewPolicy;
4851
use Illuminate\Support\Facades\Gate;
4952
use Illuminate\Support\ServiceProvider;
@@ -85,5 +88,7 @@ public function boot(): void
8588
Gate::policy(ProductVariantValue::class, ProductVariantPolicy::class);
8689
Gate::policy(SupplierReview::class, SupplierReviewPolicy::class);
8790
Gate::policy(SupplierContract::class, SupplierReviewPolicy::class);
91+
Gate::policy(LotNumber::class, LotSerialPolicy::class);
92+
Gate::policy(SerialNumber::class, LotSerialPolicy::class);
8893
}
8994
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,18 @@
177177
// Supplier show
178178
Route::get('suppliers/{supplier}', [SupplierController::class, 'show'])->name('suppliers.show');
179179
});
180+
181+
// Lot & Serial Number Tracking - custom actions BEFORE resource
182+
use App\Modules\Inventory\Http\Controllers\LotNumberController;
183+
use App\Modules\Inventory\Http\Controllers\SerialNumberController;
184+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
185+
// Lot Numbers
186+
Route::post('lot-numbers/{lotNumber}/quarantine', [LotNumberController::class, 'quarantine'])->name('lot-numbers.quarantine');
187+
Route::post('lot-numbers/{lotNumber}/consume', [LotNumberController::class, 'consume'])->name('lot-numbers.consume');
188+
Route::resource('lot-numbers', LotNumberController::class)->only(['index', 'store', 'show']);
189+
190+
// Serial Numbers
191+
Route::post('serial-numbers/{serialNumber}/sell', [SerialNumberController::class, 'sell'])->name('serial-numbers.sell');
192+
Route::post('serial-numbers/{serialNumber}/scrap', [SerialNumberController::class, 'scrap'])->name('serial-numbers.scrap');
193+
Route::resource('serial-numbers', SerialNumberController::class)->only(['index', 'store', 'show']);
194+
});

0 commit comments

Comments
 (0)