feat: [vehicle] 법인차량 관리 API 추가

- 법인차량 CRUD (CorporateVehicle)
- 차량 운행일지 CRUD (VehicleLog)
- 차량 정비이력 CRUD (VehicleMaintenance)
- 모델, 서비스, 컨트롤러, 라우트 구성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-13 11:38:18 +09:00
parent e241c6a681
commit a36b7a2514
11 changed files with 678 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\CorporateVehicleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CorporateVehicleController extends Controller
{
public function __construct(private readonly CorporateVehicleService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'ownership_type', 'status', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
public function dropdown(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->dropdown(),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\VehicleLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleLogController extends Controller
{
public function __construct(private readonly VehicleLogService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'vehicle_id', 'year', 'month', 'trip_type', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
public function summary(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->summary($request->only([
'vehicle_id', 'year', 'month',
])),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\VehicleMaintenanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleMaintenanceController extends Controller
{
public function __construct(private readonly VehicleMaintenanceService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'vehicle_id', 'category', 'start_date', 'end_date', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class CorporateVehicle extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'plate_number',
'model',
'vehicle_type',
'ownership_type',
'year',
'driver',
'status',
'mileage',
'memo',
'purchase_date',
'purchase_price',
'contract_date',
'rent_company',
'rent_company_tel',
'rent_period',
'agreed_mileage',
'vehicle_price',
'residual_value',
'deposit',
'monthly_rent',
'monthly_rent_tax',
'insurance_company',
'insurance_company_tel',
];
protected $casts = [
'year' => 'integer',
'mileage' => 'integer',
'purchase_price' => 'integer',
'vehicle_price' => 'integer',
'residual_value' => 'integer',
'deposit' => 'integer',
'monthly_rent' => 'integer',
'monthly_rent_tax' => 'integer',
];
public function logs(): HasMany
{
return $this->hasMany(VehicleLog::class, 'vehicle_id');
}
public function maintenances(): HasMany
{
return $this->hasMany(VehicleMaintenance::class, 'vehicle_id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class VehicleLog extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'vehicle_id',
'log_date',
'department',
'driver_name',
'trip_type',
'departure_type',
'departure_name',
'departure_address',
'arrival_type',
'arrival_name',
'arrival_address',
'distance_km',
'note',
];
protected $casts = [
'vehicle_id' => 'integer',
'distance_km' => 'integer',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class VehicleMaintenance extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'vehicle_id',
'date',
'category',
'description',
'amount',
'mileage',
'vendor',
'memo',
];
protected $casts = [
'vehicle_id' => 'integer',
'amount' => 'integer',
'mileage' => 'integer',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Services\Vehicle;
use App\Models\Tenants\CorporateVehicle;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CorporateVehicleService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = CorporateVehicle::query();
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('plate_number', 'like', "%{$search}%")
->orWhere('model', 'like', "%{$search}%")
->orWhere('driver', 'like', "%{$search}%");
});
}
if (! empty($filters['ownership_type'])) {
$query->where('ownership_type', $filters['ownership_type']);
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
$query->orderByDesc('id');
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): CorporateVehicle
{
$vehicle = CorporateVehicle::find($id);
if (! $vehicle) {
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
}
return $vehicle;
}
public function store(array $data): CorporateVehicle
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return CorporateVehicle::create($data);
});
}
public function update(int $id, array $data): CorporateVehicle
{
return DB::transaction(function () use ($id, $data) {
$vehicle = CorporateVehicle::find($id);
if (! $vehicle) {
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
}
$vehicle->update($data);
return $vehicle->fresh();
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$vehicle = CorporateVehicle::find($id);
if (! $vehicle) {
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
}
return $vehicle->delete();
});
}
/**
* 드롭다운 목록 (차량일지, 정비이력에서 사용)
*/
public function dropdown(): array
{
return CorporateVehicle::where('status', '!=', 'disposed')
->orderBy('plate_number')
->get(['id', 'plate_number', 'model'])
->toArray();
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Services\Vehicle;
use App\Models\Tenants\VehicleLog;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class VehicleLogService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = VehicleLog::query()->with(['vehicle:id,plate_number,model']);
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('driver_name', 'like', "%{$search}%")
->orWhere('departure_name', 'like', "%{$search}%")
->orWhere('arrival_name', 'like', "%{$search}%");
});
}
if (! empty($filters['vehicle_id'])) {
$query->where('vehicle_id', $filters['vehicle_id']);
}
if (! empty($filters['year']) && ! empty($filters['month'])) {
$query->whereYear('log_date', $filters['year'])
->whereMonth('log_date', $filters['month']);
}
if (! empty($filters['trip_type'])) {
$query->where('trip_type', $filters['trip_type']);
}
$query->orderByDesc('log_date')->orderByDesc('id');
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): VehicleLog
{
$log = VehicleLog::with('vehicle:id,plate_number,model')->find($id);
if (! $log) {
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
}
return $log;
}
public function store(array $data): VehicleLog
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return VehicleLog::create($data);
});
}
public function update(int $id, array $data): VehicleLog
{
return DB::transaction(function () use ($id, $data) {
$log = VehicleLog::find($id);
if (! $log) {
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
}
$log->update($data);
return $log->fresh(['vehicle:id,plate_number,model']);
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$log = VehicleLog::find($id);
if (! $log) {
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
}
return $log->delete();
});
}
/**
* 월별 통계
*/
public function summary(array $filters = []): array
{
$query = VehicleLog::query();
if (! empty($filters['vehicle_id'])) {
$query->where('vehicle_id', $filters['vehicle_id']);
}
if (! empty($filters['year']) && ! empty($filters['month'])) {
$query->whereYear('log_date', $filters['year'])
->whereMonth('log_date', $filters['month']);
}
$totalDistance = (clone $query)->sum('distance_km');
$totalCount = (clone $query)->count();
$commuteToQuery = (clone $query)->whereIn('trip_type', ['commute_to', 'commute_round']);
$commuteFromQuery = (clone $query)->whereIn('trip_type', ['commute_from', 'commute_round']);
$businessQuery = (clone $query)->whereIn('trip_type', ['business', 'business_round']);
$personalQuery = (clone $query)->whereIn('trip_type', ['personal', 'personal_round']);
return [
'total_distance' => (int) $totalDistance,
'total_count' => $totalCount,
'commute_to_distance' => (int) $commuteToQuery->sum('distance_km'),
'commute_to_count' => $commuteToQuery->count(),
'commute_from_distance' => (int) $commuteFromQuery->sum('distance_km'),
'commute_from_count' => $commuteFromQuery->count(),
'business_distance' => (int) $businessQuery->sum('distance_km'),
'business_count' => $businessQuery->count(),
'personal_distance' => (int) $personalQuery->sum('distance_km'),
'personal_count' => $personalQuery->count(),
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Services\Vehicle;
use App\Models\Tenants\VehicleMaintenance;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class VehicleMaintenanceService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = VehicleMaintenance::query()->with(['vehicle:id,plate_number,model']);
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('vendor', 'like', "%{$search}%");
});
}
if (! empty($filters['vehicle_id'])) {
$query->where('vehicle_id', $filters['vehicle_id']);
}
if (! empty($filters['category'])) {
$query->where('category', $filters['category']);
}
if (! empty($filters['start_date'])) {
$query->where('date', '>=', $filters['start_date']);
}
if (! empty($filters['end_date'])) {
$query->where('date', '<=', $filters['end_date']);
}
$query->orderByDesc('date')->orderByDesc('id');
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): VehicleMaintenance
{
$item = VehicleMaintenance::with('vehicle:id,plate_number,model')->find($id);
if (! $item) {
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
}
return $item;
}
public function store(array $data): VehicleMaintenance
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return VehicleMaintenance::create($data);
});
}
public function update(int $id, array $data): VehicleMaintenance
{
return DB::transaction(function () use ($id, $data) {
$item = VehicleMaintenance::find($id);
if (! $item) {
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
}
$item->update($data);
return $item->fresh(['vehicle:id,plate_number,model']);
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$item = VehicleMaintenance::find($id);
if (! $item) {
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
}
return $item->delete();
});
}
}

View File

@@ -43,6 +43,7 @@
require __DIR__.'/api/v1/esign.php';
require __DIR__.'/api/v1/quality.php';
require __DIR__.'/api/v1/equipment.php';
require __DIR__.'/api/v1/vehicle.php';
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');

35
routes/api/v1/vehicle.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
use App\Http\Controllers\V1\Vehicle\CorporateVehicleController;
use App\Http\Controllers\V1\Vehicle\VehicleLogController;
use App\Http\Controllers\V1\Vehicle\VehicleMaintenanceController;
use Illuminate\Support\Facades\Route;
// 법인차량 관리
Route::prefix('corporate-vehicles')->group(function () {
Route::get('', [CorporateVehicleController::class, 'index'])->name('v1.corporate-vehicles.index');
Route::get('/dropdown', [CorporateVehicleController::class, 'dropdown'])->name('v1.corporate-vehicles.dropdown');
Route::post('', [CorporateVehicleController::class, 'store'])->name('v1.corporate-vehicles.store');
Route::get('/{id}', [CorporateVehicleController::class, 'show'])->whereNumber('id')->name('v1.corporate-vehicles.show');
Route::put('/{id}', [CorporateVehicleController::class, 'update'])->whereNumber('id')->name('v1.corporate-vehicles.update');
Route::delete('/{id}', [CorporateVehicleController::class, 'destroy'])->whereNumber('id')->name('v1.corporate-vehicles.destroy');
});
// 차량일지
Route::prefix('vehicle-logs')->group(function () {
Route::get('', [VehicleLogController::class, 'index'])->name('v1.vehicle-logs.index');
Route::get('/summary', [VehicleLogController::class, 'summary'])->name('v1.vehicle-logs.summary');
Route::post('', [VehicleLogController::class, 'store'])->name('v1.vehicle-logs.store');
Route::get('/{id}', [VehicleLogController::class, 'show'])->whereNumber('id')->name('v1.vehicle-logs.show');
Route::put('/{id}', [VehicleLogController::class, 'update'])->whereNumber('id')->name('v1.vehicle-logs.update');
Route::delete('/{id}', [VehicleLogController::class, 'destroy'])->whereNumber('id')->name('v1.vehicle-logs.destroy');
});
// 정비이력
Route::prefix('vehicle-maintenances')->group(function () {
Route::get('', [VehicleMaintenanceController::class, 'index'])->name('v1.vehicle-maintenances.index');
Route::post('', [VehicleMaintenanceController::class, 'store'])->name('v1.vehicle-maintenances.store');
Route::get('/{id}', [VehicleMaintenanceController::class, 'show'])->whereNumber('id')->name('v1.vehicle-maintenances.show');
Route::put('/{id}', [VehicleMaintenanceController::class, 'update'])->whereNumber('id')->name('v1.vehicle-maintenances.update');
Route::delete('/{id}', [VehicleMaintenanceController::class, 'destroy'])->whereNumber('id')->name('v1.vehicle-maintenances.destroy');
});