From 85d5b989666de936e10c0c4e69a60e8233d709a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 22:26:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[vehicle]=20=EB=B2=95=EC=9D=B8=EC=B0=A8?= =?UTF-8?q?=EB=9F=89=20=EC=82=AC=EC=A7=84=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CorporateVehicle 모델 (photos 관계 포함) - VehiclePhotoService (R2 저장, 최대 10장 제한) - VehiclePhotoController (index/store/destroy) - StoreVehiclePhotoRequest (동적 max 검증) - finance.php 라우트 등록 --- .../Api/V1/VehiclePhotoController.php | 38 ++++++ .../Finance/StoreVehiclePhotoRequest.php | 53 ++++++++ app/Models/Finance/CorporateVehicle.php | 40 ++++++ app/Services/Finance/VehiclePhotoService.php | 124 ++++++++++++++++++ routes/api/v1/finance.php | 8 ++ 5 files changed, 263 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/VehiclePhotoController.php create mode 100644 app/Http/Requests/Finance/StoreVehiclePhotoRequest.php create mode 100644 app/Models/Finance/CorporateVehicle.php create mode 100644 app/Services/Finance/VehiclePhotoService.php diff --git a/app/Http/Controllers/Api/V1/VehiclePhotoController.php b/app/Http/Controllers/Api/V1/VehiclePhotoController.php new file mode 100644 index 0000000..4c208b6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/VehiclePhotoController.php @@ -0,0 +1,38 @@ + $this->service->index($id), + __('message.fetched') + ); + } + + public function store(StoreVehiclePhotoRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($id, $request->file('files')), + __('message.created') + ); + } + + public function destroy(int $id, int $fileId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id, $fileId), + __('message.deleted') + ); + } +} diff --git a/app/Http/Requests/Finance/StoreVehiclePhotoRequest.php b/app/Http/Requests/Finance/StoreVehiclePhotoRequest.php new file mode 100644 index 0000000..755519b --- /dev/null +++ b/app/Http/Requests/Finance/StoreVehiclePhotoRequest.php @@ -0,0 +1,53 @@ +route('id'); + $currentCount = File::where('document_id', $vehicleId) + ->where('document_type', 'corporate_vehicle') + ->whereNull('deleted_at') + ->count(); + + $maxFiles = 10 - $currentCount; + + return [ + 'files' => ['required', 'array', 'min:1', "max:{$maxFiles}"], + 'files.*' => [ + 'required', + 'file', + 'mimes:jpg,jpeg,png,gif,bmp,webp', + 'max:10240', // 10MB + ], + ]; + } + + public function attributes(): array + { + return [ + 'files' => '사진 파일', + 'files.*' => '사진 파일', + ]; + } + + public function messages(): array + { + return [ + 'files.required' => __('error.file.required'), + 'files.max' => __('error.vehicle.photo_limit_exceeded'), + 'files.*.mimes' => __('error.file.invalid_type'), + 'files.*.max' => __('error.file.size_exceeded'), + ]; + } +} diff --git a/app/Models/Finance/CorporateVehicle.php b/app/Models/Finance/CorporateVehicle.php new file mode 100644 index 0000000..298d59f --- /dev/null +++ b/app/Models/Finance/CorporateVehicle.php @@ -0,0 +1,40 @@ + 'integer', + 'options' => 'array', + 'is_active' => 'boolean', + ]; + + public function photos(): HasMany + { + return $this->hasMany(File::class, 'document_id') + ->where('document_type', 'corporate_vehicle') + ->orderBy('id'); + } +} diff --git a/app/Services/Finance/VehiclePhotoService.php b/app/Services/Finance/VehiclePhotoService.php new file mode 100644 index 0000000..be9e059 --- /dev/null +++ b/app/Services/Finance/VehiclePhotoService.php @@ -0,0 +1,124 @@ +getVehicle($vehicleId); + + return $vehicle->photos->map(fn ($file) => $this->formatFileResponse($file))->values()->toArray(); + } + + /** + * @param UploadedFile[] $files + */ + public function store(int $vehicleId, array $files): array + { + $vehicle = $this->getVehicle($vehicleId); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $currentCount = File::where('document_id', $vehicleId) + ->where('document_type', 'corporate_vehicle') + ->whereNull('deleted_at') + ->count(); + + if ($currentCount + count($files) > self::MAX_PHOTOS) { + throw new \Exception(__('error.vehicle.photo_limit_exceeded')); + } + + $uploaded = []; + + foreach ($files as $uploadedFile) { + $extension = $uploadedFile->getClientOriginalExtension(); + $storedName = bin2hex(random_bytes(8)).'.'.$extension; + $displayName = $uploadedFile->getClientOriginalName(); + + $year = date('Y'); + $month = date('m'); + $directory = sprintf('%d/corporate-vehicles/%s/%s', $tenantId, $year, $month); + $filePath = $directory.'/'.$storedName; + + Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName); + + $mimeType = $uploadedFile->getMimeType(); + + $file = File::create([ + 'tenant_id' => $tenantId, + 'display_name' => $displayName, + 'stored_name' => $storedName, + 'file_path' => $filePath, + 'file_size' => $uploadedFile->getSize(), + 'mime_type' => $mimeType, + 'file_type' => 'image', + 'document_id' => $vehicleId, + 'document_type' => 'corporate_vehicle', + 'is_temp' => false, + 'uploaded_by' => $userId, + 'created_by' => $userId, + ]); + + $uploaded[] = $this->formatFileResponse($file); + } + + return $uploaded; + } + + public function destroy(int $vehicleId, int $fileId): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $file = File::where('tenant_id', $tenantId) + ->where('document_id', $vehicleId) + ->where('document_type', 'corporate_vehicle') + ->where('id', $fileId) + ->first(); + + if (! $file) { + throw new NotFoundHttpException(__('error.file.not_found')); + } + + $file->softDeleteFile($userId); + + return [ + 'file_id' => $fileId, + 'deleted' => true, + ]; + } + + private function getVehicle(int $vehicleId): CorporateVehicle + { + $vehicle = CorporateVehicle::find($vehicleId); + + if (! $vehicle) { + throw new NotFoundHttpException(__('error.vehicle.not_found')); + } + + return $vehicle; + } + + private function formatFileResponse(File $file): array + { + return [ + 'id' => $file->id, + 'file_name' => $file->display_name, + 'file_path' => $file->file_path, + 'file_url' => url("/api/v1/files/{$file->id}/download"), + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'created_at' => $file->created_at?->format('Y-m-d H:i:s'), + ]; + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 0a18dc4..b7b8295 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -42,6 +42,7 @@ use App\Http\Controllers\Api\V1\TaxInvoiceController; use App\Http\Controllers\Api\V1\TodayIssueController; use App\Http\Controllers\Api\V1\VatController; +use App\Http\Controllers\Api\V1\VehiclePhotoController; use App\Http\Controllers\Api\V1\VendorLedgerController; use App\Http\Controllers\Api\V1\WelfareController; use App\Http\Controllers\Api\V1\WithdrawalController; @@ -396,6 +397,13 @@ Route::delete('/{id}', [AccountSubjectController::class, 'destroy'])->whereNumber('id')->name('v1.account-subjects.destroy'); }); +// Corporate Vehicle Photo API (법인차량 사진) +Route::prefix('corporate-vehicles')->group(function () { + Route::get('/{id}/photos', [VehiclePhotoController::class, 'index'])->whereNumber('id')->name('v1.corporate-vehicles.photos.index'); + Route::post('/{id}/photos', [VehiclePhotoController::class, 'store'])->whereNumber('id')->name('v1.corporate-vehicles.photos.store'); + Route::delete('/{id}/photos/{fileId}', [VehiclePhotoController::class, 'destroy'])->whereNumber(['id', 'fileId'])->name('v1.corporate-vehicles.photos.destroy'); +}); + // Bill API (어음관리) Route::prefix('bills')->group(function () { Route::get('', [BillController::class, 'index'])->name('v1.bills.index');