Skip to content

Commit d7fff49

Browse files
committed
feat(inventory): Phase 86 — Fleet & Vehicle Management with trip logs
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 06e8036 commit d7fff49

14 files changed

Lines changed: 1201 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\Vehicle;
7+
use App\Modules\Inventory\Models\VehicleLog;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class VehicleController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', Vehicle::class);
19+
20+
$vehicles = Vehicle::with(['assignedEmployee'])
21+
->when($request->status, fn ($q) => $q->where('status', $request->status))
22+
->orderBy('registration')
23+
->paginate(20)
24+
->withQueryString();
25+
26+
return Inertia::render('Inventory/Vehicles/Index', [
27+
'vehicles' => $vehicles,
28+
'filters' => $request->only(['status']),
29+
'statusOptions' => ['available', 'in_use', 'maintenance', 'retired'],
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', Vehicle::class);
36+
37+
return Inertia::render('Inventory/Vehicles/Create');
38+
}
39+
40+
public function store(Request $request): RedirectResponse
41+
{
42+
$this->authorize('create', Vehicle::class);
43+
44+
$validated = $request->validate([
45+
'registration' => ['required', 'string', 'max:50', Rule::unique('vehicles')],
46+
'make' => ['required', 'string', 'max:100'],
47+
'model' => ['required', 'string', 'max:100'],
48+
'year' => ['nullable', 'integer', 'min:1900', 'max:2100'],
49+
'vin' => ['nullable', 'string', 'max:100'],
50+
'colour' => ['nullable', 'string', 'max:50'],
51+
'fuel_type' => ['nullable', Rule::in(['petrol', 'diesel', 'electric', 'hybrid'])],
52+
'odometer_km' => ['nullable', 'numeric', 'min:0'],
53+
'status' => ['nullable', Rule::in(['available', 'in_use', 'maintenance', 'retired'])],
54+
'insurance_expiry' => ['nullable', 'date'],
55+
'registration_expiry' => ['nullable', 'date'],
56+
'notes' => ['nullable', 'string'],
57+
]);
58+
59+
$vehicle = Vehicle::create([...$validated, 'tenant_id' => auth()->user()->tenant_id]);
60+
61+
return redirect()->route('inventory.vehicles.show', $vehicle)
62+
->with('success', 'Vehicle created successfully.');
63+
}
64+
65+
public function show(Vehicle $vehicle): Response
66+
{
67+
$this->authorize('view', $vehicle);
68+
69+
$vehicle->load(['logs', 'assignedEmployee']);
70+
71+
return Inertia::render('Inventory/Vehicles/Show', [
72+
'vehicle' => $vehicle->append(['is_insurance_expiring', 'is_registration_expiring', 'total_distance']),
73+
]);
74+
}
75+
76+
public function destroy(Vehicle $vehicle): RedirectResponse
77+
{
78+
$this->authorize('delete', $vehicle);
79+
80+
$vehicle->delete();
81+
82+
return redirect()->route('inventory.vehicles.index')
83+
->with('success', 'Vehicle deleted.');
84+
}
85+
86+
public function assign(Request $request, Vehicle $vehicle): RedirectResponse
87+
{
88+
$this->authorize('update', $vehicle);
89+
90+
$validated = $request->validate([
91+
'employee_id' => ['required', 'integer', Rule::exists('employees', 'id')],
92+
]);
93+
94+
$vehicle->assign($validated['employee_id']);
95+
96+
return redirect()->back()->with('success', 'Vehicle assigned.');
97+
}
98+
99+
public function unassign(Request $request, Vehicle $vehicle): RedirectResponse
100+
{
101+
$this->authorize('update', $vehicle);
102+
103+
$vehicle->unassign();
104+
105+
return redirect()->back()->with('success', 'Vehicle unassigned.');
106+
}
107+
108+
public function retire(Request $request, Vehicle $vehicle): RedirectResponse
109+
{
110+
$this->authorize('update', $vehicle);
111+
112+
$vehicle->retire();
113+
114+
return redirect()->back()->with('success', 'Vehicle retired.');
115+
}
116+
117+
public function addLog(Request $request, Vehicle $vehicle): RedirectResponse
118+
{
119+
$this->authorize('update', $vehicle);
120+
121+
$validated = $request->validate([
122+
'log_type' => ['required', Rule::in(['trip', 'refuel', 'maintenance', 'inspection'])],
123+
'log_date' => ['required', 'date'],
124+
'odometer_start' => ['nullable', 'numeric', 'min:0'],
125+
'odometer_end' => ['nullable', 'numeric', 'min:0'],
126+
'distance_km' => ['nullable', 'numeric', 'min:0'],
127+
'fuel_litres' => ['nullable', 'numeric', 'min:0'],
128+
'cost' => ['nullable', 'numeric', 'min:0'],
129+
'driver_name' => ['nullable', 'string', 'max:255'],
130+
'destination' => ['nullable', 'string', 'max:255'],
131+
'purpose' => ['nullable', 'string', 'max:255'],
132+
'notes' => ['nullable', 'string'],
133+
]);
134+
135+
if (isset($validated['odometer_end']) && $validated['odometer_end'] > $vehicle->odometer_km) {
136+
$vehicle->odometer_km = $validated['odometer_end'];
137+
$vehicle->save();
138+
}
139+
140+
VehicleLog::create([...$validated, 'tenant_id' => auth()->user()->tenant_id, 'vehicle_id' => $vehicle->id]);
141+
142+
return redirect()->back()->with('success', 'Log added.');
143+
}
144+
}
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 App\Modules\HR\Models\Employee;
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 Vehicle extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id', 'registration', 'make', 'model', 'year', 'vin', 'colour',
19+
'fuel_type', 'odometer_km', 'status', 'assigned_to_employee_id',
20+
'insurance_expiry', 'registration_expiry', 'notes',
21+
];
22+
23+
protected $casts = [
24+
'odometer_km' => 'float',
25+
'insurance_expiry' => 'date',
26+
'registration_expiry' => 'date',
27+
'year' => 'integer',
28+
];
29+
30+
public function logs(): HasMany
31+
{
32+
return $this->hasMany(VehicleLog::class);
33+
}
34+
35+
public function assignedEmployee(): BelongsTo
36+
{
37+
return $this->belongsTo(Employee::class, 'assigned_to_employee_id');
38+
}
39+
40+
public function assign(int $employeeId): void
41+
{
42+
$this->assigned_to_employee_id = $employeeId;
43+
$this->status = 'in_use';
44+
$this->save();
45+
}
46+
47+
public function unassign(): void
48+
{
49+
$this->assigned_to_employee_id = null;
50+
$this->status = 'available';
51+
$this->save();
52+
}
53+
54+
public function retire(): void
55+
{
56+
$this->status = 'retired';
57+
$this->save();
58+
}
59+
60+
public function getIsInsuranceExpiringAttribute(): bool
61+
{
62+
return $this->insurance_expiry !== null
63+
&& $this->insurance_expiry->isFuture()
64+
&& $this->insurance_expiry->diffInDays(now()) <= 30;
65+
}
66+
67+
public function getIsRegistrationExpiringAttribute(): bool
68+
{
69+
return $this->registration_expiry !== null
70+
&& $this->registration_expiry->isFuture()
71+
&& $this->registration_expiry->diffInDays(now()) <= 30;
72+
}
73+
74+
public function getTotalDistanceAttribute(): float
75+
{
76+
return (float) $this->logs()->sum('distance_km');
77+
}
78+
}
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 VehicleLog extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'vehicle_id', 'log_type', 'log_date',
15+
'odometer_start', 'odometer_end', 'distance_km',
16+
'fuel_litres', 'cost', 'driver_name', 'destination', 'purpose', 'notes',
17+
];
18+
19+
protected $casts = [
20+
'log_date' => 'date',
21+
'odometer_start' => 'float',
22+
'odometer_end' => 'float',
23+
'distance_km' => 'float',
24+
'fuel_litres' => 'float',
25+
'cost' => 'float',
26+
];
27+
28+
public function vehicle(): BelongsTo
29+
{
30+
return $this->belongsTo(Vehicle::class);
31+
}
32+
33+
public function getFuelEfficiencyAttribute(): ?float
34+
{
35+
if ($this->fuel_litres > 0 && $this->distance_km > 0) {
36+
return round($this->distance_km / $this->fuel_litres, 2);
37+
}
38+
39+
return null;
40+
}
41+
}
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 VehiclePolicy
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
@@ -37,6 +37,9 @@
3737
use App\Modules\Inventory\Policies\WarehouseBinPolicy;
3838
use App\Modules\Inventory\Policies\WarehouseTransferPolicy;
3939
use App\Modules\Inventory\Models\CostingLayer;
40+
use App\Modules\Inventory\Models\Vehicle;
41+
use App\Modules\Inventory\Models\VehicleLog;
42+
use App\Modules\Inventory\Policies\VehiclePolicy;
4043
use App\Modules\Inventory\Models\ProductCostSnapshot;
4144
use App\Modules\Inventory\Policies\CostingPolicy;
4245
use Illuminate\Support\Facades\Gate;
@@ -73,6 +76,8 @@ public function boot(): void
7376
Gate::policy(WarehouseZone::class, WarehouseBinPolicy::class);
7477
Gate::policy(BinStockLocation::class, WarehouseBinPolicy::class);
7578
Gate::policy(ProductAttribute::class, ProductVariantPolicy::class);
79+
Gate::policy(Vehicle::class, VehiclePolicy::class);
80+
Gate::policy(VehicleLog::class, VehiclePolicy::class);
7681
Gate::policy(ProductVariant::class, ProductVariantPolicy::class);
7782
Gate::policy(ProductVariantValue::class, ProductVariantPolicy::class);
7883
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,13 @@
152152
Route::post('demand-forecasts/alerts/{alert}/resolve', [DemandForecastController::class, 'resolveAlert'])->name('demand-forecasts.alerts.resolve');
153153
Route::resource('demand-forecasts', DemandForecastController::class)->except(['edit']);
154154
});
155+
156+
// Fleet/Vehicle Management - custom actions BEFORE resource
157+
use App\Modules\Inventory\Http\Controllers\VehicleController;
158+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
159+
Route::patch('vehicles/{vehicle}/assign', [VehicleController::class, 'assign'])->name('vehicles.assign');
160+
Route::patch('vehicles/{vehicle}/unassign', [VehicleController::class, 'unassign'])->name('vehicles.unassign');
161+
Route::post('vehicles/{vehicle}/retire', [VehicleController::class, 'retire'])->name('vehicles.retire');
162+
Route::post('vehicles/{vehicle}/logs', [VehicleController::class, 'addLog'])->name('vehicles.logs.add');
163+
Route::resource('vehicles', VehicleController::class)->except(['edit', 'update']);
164+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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('vehicles', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('registration');
15+
$table->string('make');
16+
$table->string('model');
17+
$table->integer('year')->nullable();
18+
$table->string('vin')->nullable();
19+
$table->string('colour')->nullable();
20+
$table->string('fuel_type')->default('petrol');
21+
$table->decimal('odometer_km', 10, 1)->default(0);
22+
$table->string('status')->default('available');
23+
$table->unsignedBigInteger('assigned_to_employee_id')->nullable();
24+
$table->date('insurance_expiry')->nullable();
25+
$table->date('registration_expiry')->nullable();
26+
$table->text('notes')->nullable();
27+
$table->timestamps();
28+
$table->softDeletes();
29+
});
30+
}
31+
32+
public function down(): void
33+
{
34+
Schema::dropIfExists('vehicles');
35+
}
36+
};

0 commit comments

Comments
 (0)