From 6c208cfb2cd4126afebe130f9af9461c316363fc 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 13:45:15 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20[equipment]=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20R2(FileStorageSystem)=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GCS 스텁 코드를 Cloudflare R2 기반 실제 파일 업로드로 교체 - File 모델 import를 Boards\File에서 Commons\File로 수정 - StoreEquipmentPhotoRequest FormRequest 추가 (파일 검증) - 다중 파일 업로드 지원 (최대 10장 제한) - softDeleteFile 패턴 적용 (삭제 시 soft delete) - ItemsFileController 패턴 준용 (R2 저장, 랜덤 파일명) --- .../V1/Equipment/EquipmentPhotoController.php | 38 +++++ .../Equipment/StoreEquipmentPhotoRequest.php | 53 ++++++ app/Models/Equipment/Equipment.php | 154 ++++++++++++++++++ .../Equipment/EquipmentPhotoService.php | 124 ++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php create mode 100644 app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php create mode 100644 app/Models/Equipment/Equipment.php create mode 100644 app/Services/Equipment/EquipmentPhotoService.php diff --git a/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php new file mode 100644 index 0000000..35de41c --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php @@ -0,0 +1,38 @@ + $this->service->index($id), + __('message.fetched') + ); + } + + public function store(StoreEquipmentPhotoRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($id, $request->file('files')), + __('message.equipment.photo_uploaded') + ); + } + + 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/Equipment/StoreEquipmentPhotoRequest.php b/app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php new file mode 100644 index 0000000..699859d --- /dev/null +++ b/app/Http/Requests/Equipment/StoreEquipmentPhotoRequest.php @@ -0,0 +1,53 @@ +route('id'); + $currentCount = File::where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->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.equipment.photo_limit_exceeded'), + 'files.*.mimes' => __('error.file.invalid_type'), + 'files.*.max' => __('error.file.size_exceeded'), + ]; + } +} diff --git a/app/Models/Equipment/Equipment.php b/app/Models/Equipment/Equipment.php new file mode 100644 index 0000000..dadf480 --- /dev/null +++ b/app/Models/Equipment/Equipment.php @@ -0,0 +1,154 @@ + 'date', + 'install_date' => 'date', + 'disposed_date' => 'date', + 'purchase_price' => 'decimal:2', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function manager(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'manager_id'); + } + + public function subManager(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'sub_manager_id'); + } + + public function canInspect(?int $userId = null): bool + { + if (! $userId) { + return false; + } + + return $this->manager_id === $userId || $this->sub_manager_id === $userId; + } + + public function inspectionTemplates(): HasMany + { + return $this->hasMany(EquipmentInspectionTemplate::class, 'equipment_id')->orderBy('sort_order'); + } + + public function inspections(): HasMany + { + return $this->hasMany(EquipmentInspection::class, 'equipment_id'); + } + + public function repairs(): HasMany + { + return $this->hasMany(EquipmentRepair::class, 'equipment_id'); + } + + public function photos(): HasMany + { + return $this->hasMany(File::class, 'document_id') + ->where('document_type', 'equipment') + ->orderBy('id'); + } + + public function processes(): BelongsToMany + { + return $this->belongsToMany(\App\Models\Process::class, 'equipment_process') + ->withPivot('is_primary') + ->withTimestamps(); + } + + public function scopeByLine($query, string $line) + { + return $query->where('production_line', $line); + } + + public function scopeByType($query, string $type) + { + return $query->where('equipment_type', $type); + } + + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + public static function getEquipmentTypes(): array + { + return ['포밍기', '미싱기', '샤링기', 'V컷팅기', '절곡기', '프레스', '드릴', '기타']; + } + + public static function getProductionLines(): array + { + return ['스라트', '스크린', '절곡', '기타']; + } + + public static function getStatuses(): array + { + return [ + 'active' => '가동', + 'idle' => '유휴', + 'disposed' => '폐기', + ]; + } +} diff --git a/app/Services/Equipment/EquipmentPhotoService.php b/app/Services/Equipment/EquipmentPhotoService.php new file mode 100644 index 0000000..1d6bbe1 --- /dev/null +++ b/app/Services/Equipment/EquipmentPhotoService.php @@ -0,0 +1,124 @@ +getEquipment($equipmentId); + + return $equipment->photos->map(fn ($file) => $this->formatFileResponse($file))->values()->toArray(); + } + + /** + * @param UploadedFile[] $files + */ + public function store(int $equipmentId, array $files): array + { + $equipment = $this->getEquipment($equipmentId); + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $currentCount = File::where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->whereNull('deleted_at') + ->count(); + + if ($currentCount + count($files) > self::MAX_PHOTOS) { + throw new \Exception(__('error.equipment.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/equipment/%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' => $equipmentId, + 'document_type' => 'equipment', + 'is_temp' => false, + 'uploaded_by' => $userId, + 'created_by' => $userId, + ]); + + $uploaded[] = $this->formatFileResponse($file); + } + + return $uploaded; + } + + public function destroy(int $equipmentId, int $fileId): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $file = File::where('tenant_id', $tenantId) + ->where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->where('id', $fileId) + ->first(); + + if (! $file) { + throw new NotFoundHttpException(__('error.file.not_found')); + } + + $file->softDeleteFile($userId); + + return [ + 'file_id' => $fileId, + 'deleted' => true, + ]; + } + + private function getEquipment(int $equipmentId): Equipment + { + $equipment = Equipment::find($equipmentId); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment; + } + + 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'), + ]; + } +}