diff --git a/app/Http/Controllers/Api/Admin/EquipmentController.php b/app/Http/Controllers/Api/Admin/EquipmentController.php index dcfbc8a6..80636a7b 100644 --- a/app/Http/Controllers/Api/Admin/EquipmentController.php +++ b/app/Http/Controllers/Api/Admin/EquipmentController.php @@ -245,9 +245,14 @@ public function importExecute(Request $request): JsonResponse ['duplicate_action' => $request->input('duplicate_action', 'skip')] ); + $photoMsg = ''; + if (! empty($result['photos_uploaded'])) { + $photoMsg = ", 사진 {$result['photos_uploaded']}장 업로드"; + } + return response()->json([ 'success' => true, - 'message' => "Import 완료: 성공 {$result['success']}건, 실패 {$result['failed']}건, 건너뜀 {$result['skipped']}건", + 'message' => "Import 완료: 성공 {$result['success']}건, 실패 {$result['failed']}건, 건너뜀 {$result['skipped']}건{$photoMsg}", 'data' => $result, ]); } catch (\Exception $e) { diff --git a/app/Services/EquipmentImportService.php b/app/Services/EquipmentImportService.php index 66c6288a..be713350 100644 --- a/app/Services/EquipmentImportService.php +++ b/app/Services/EquipmentImportService.php @@ -5,9 +5,16 @@ use App\Models\Equipment\Equipment; use Illuminate\Support\Facades\Log; use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing; +use PhpOffice\PhpSpreadsheet\Worksheet\Drawing; +use PhpOffice\PhpSpreadsheet\Worksheet\MemoryDrawing; class EquipmentImportService { + public function __construct( + private readonly EquipmentPhotoService $photoService + ) {} + private const HEADER_MAP = [ '설비코드' => 'equipment_code', '설비명' => 'name', @@ -111,16 +118,30 @@ public function import(string $filePath, array $options = []): array $spreadsheet = IOFactory::load($filePath); $sheet = $spreadsheet->getActiveSheet(); + + // Drawing을 행 번호별로 그룹핑 (toArray 호출 전에 추출) + $drawingsByRow = $this->extractDrawingsByRow($sheet); + $rows = $sheet->toArray(null, true, true, true); if (empty($rows)) { + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); throw new \RuntimeException('빈 파일입니다.'); } + // 첫 행(헤더) 제거 — 키는 2,3,4... 유지되어 Drawing 행 번호와 매칭 $headerRow = array_shift($rows); $headers = $this->mapHeaders($headerRow); - $result = ['success' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => []]; + $result = [ + 'success' => 0, + 'failed' => 0, + 'skipped' => 0, + 'photos_uploaded' => 0, + 'photos_skipped' => 0, + 'errors' => [], + ]; foreach ($rows as $rowNum => $row) { $mapped = $this->mapRow($row, $headers); @@ -138,6 +159,13 @@ public function import(string $filePath, array $options = []): array if ($duplicateAction === 'overwrite') { $this->updateEquipment($existing, $mapped); $result['success']++; + + // 기존 사진이 없을 때만 사진 업로드 + if ($existing->photos()->count() === 0 && isset($drawingsByRow[$rowNum])) { + $result = $this->processDrawingsForEquipment($existing, $drawingsByRow[$rowNum], $result); + } elseif (isset($drawingsByRow[$rowNum])) { + $result['photos_skipped'] += count($drawingsByRow[$rowNum]); + } } else { $result['skipped']++; } @@ -145,18 +173,151 @@ public function import(string $filePath, array $options = []): array continue; } - $this->createEquipment($mapped, $tenantId); + $equipment = $this->createEquipment($mapped, $tenantId); $result['success']++; + + // 새 설비에 사진 업로드 + if (isset($drawingsByRow[$rowNum])) { + $result = $this->processDrawingsForEquipment($equipment, $drawingsByRow[$rowNum], $result); + } } catch (\Exception $e) { $result['failed']++; - $result['errors'][] = "행 ".($rowNum + 2).": {$e->getMessage()}"; + $result['errors'][] = "행 {$rowNum}: {$e->getMessage()}"; Log::warning('EquipmentImport: 행 처리 실패', [ - 'row' => $rowNum + 2, + 'row' => $rowNum, 'error' => $e->getMessage(), ]); } } + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); + + return $result; + } + + /** + * Drawing을 행 번호별로 그룹핑 + * + * @return array + */ + private function extractDrawingsByRow(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): array + { + $drawingsByRow = []; + + foreach ($sheet->getDrawingCollection() as $drawing) { + $coordinate = $drawing->getCoordinates(); + // "F2" → row 2 + $row = (int) preg_replace('/[A-Z]+/', '', $coordinate); + + if ($row <= 1) { + continue; // 헤더 행 건너뜀 + } + + $drawingsByRow[$row][] = $drawing; + } + + return $drawingsByRow; + } + + /** + * Drawing을 임시 파일로 저장 + * + * @return array{0: string, 1: string, 2: string}|null [tempPath, extension, originalName] + */ + private function saveDrawingToTempFile(BaseDrawing $drawing): ?array + { + try { + if ($drawing instanceof MemoryDrawing) { + $resource = $drawing->getImageResource(); + if (! $resource) { + return null; + } + + $extension = match ($drawing->getMimeType()) { + MemoryDrawing::MIMETYPE_PNG => 'png', + MemoryDrawing::MIMETYPE_GIF => 'gif', + default => 'jpg', + }; + + $tempPath = tempnam(sys_get_temp_dir(), 'eq_import_'); + if ($extension === 'png') { + imagepng($resource, $tempPath); + } elseif ($extension === 'gif') { + imagegif($resource, $tempPath); + } else { + imagejpeg($resource, $tempPath, 90); + } + + $originalName = $drawing->getName() ?: 'equipment_photo.'.$extension; + + return [$tempPath, $extension, $originalName]; + } + + if ($drawing instanceof Drawing) { + $sourcePath = $drawing->getPath(); + if (! file_exists($sourcePath)) { + return null; + } + + $extension = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)) ?: 'jpg'; + $tempPath = tempnam(sys_get_temp_dir(), 'eq_import_'); + copy($sourcePath, $tempPath); + + $originalName = $drawing->getName() ?: basename($sourcePath); + + return [$tempPath, $extension, $originalName]; + } + } catch (\Throwable $e) { + Log::warning('EquipmentImport: Drawing 임시파일 저장 실패', [ + 'error' => $e->getMessage(), + ]); + } + + return null; + } + + /** + * Drawing 목록을 설비에 사진으로 업로드 + */ + private function processDrawingsForEquipment(Equipment $equipment, array $drawings, array $result): array + { + foreach ($drawings as $drawing) { + $saved = $this->saveDrawingToTempFile($drawing); + if (! $saved) { + $result['photos_skipped']++; + + continue; + } + + [$tempPath, $extension, $originalName] = $saved; + + try { + $uploaded = $this->photoService->uploadPhotoFromPath( + $equipment, + $tempPath, + $originalName, + $extension + ); + + if ($uploaded) { + $result['photos_uploaded']++; + } else { + $result['photos_skipped']++; + } + } catch (\Throwable $e) { + $result['photos_skipped']++; + Log::warning('EquipmentImport: 사진 업로드 실패', [ + 'equipment_id' => $equipment->id, + 'error' => $e->getMessage(), + ]); + } finally { + if (file_exists($tempPath)) { + @unlink($tempPath); + } + } + } + return $result; } diff --git a/app/Services/EquipmentPhotoService.php b/app/Services/EquipmentPhotoService.php index f437e103..712fe3cd 100644 --- a/app/Services/EquipmentPhotoService.php +++ b/app/Services/EquipmentPhotoService.php @@ -102,6 +102,79 @@ public function uploadPhotos(Equipment $equipment, array $files): array return ['uploaded' => $uploaded, 'errors' => $errors]; } + /** + * 로컬 파일 경로에서 설비 사진 업로드 (엑셀 Import용) + */ + public function uploadPhotoFromPath( + Equipment $equipment, + string $localPath, + string $originalName, + string $extension + ): bool { + $tenantId = session('selected_tenant_id'); + $currentCount = $equipment->photos()->count(); + + if ($currentCount >= self::MAX_PHOTOS) { + return false; + } + + $compressedPath = null; + + try { + [$compressedPath, $finalExtension] = $this->compressImage($localPath, $extension); + + $timestamp = now()->format('Ymd_His'); + $uniqueId = substr(md5(uniqid()), 0, 8); + $objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$uniqueId}.{$finalExtension}"; + + $result = $this->googleCloudService->uploadToStorage($compressedPath, $objectName); + + if (! $result) { + Log::error('EquipmentPhoto: GCS 업로드 실패 (Import)', [ + 'equipment_id' => $equipment->id, + 'file' => $originalName, + ]); + + return false; + } + + $mimeType = $finalExtension === 'png' ? 'image/png' : 'image/jpeg'; + + File::create([ + 'tenant_id' => $tenantId, + 'document_id' => $equipment->id, + 'document_type' => 'equipment', + 'file_path' => $objectName, + 'original_name' => $originalName, + 'display_name' => $originalName, + 'file_size' => $result['size'], + 'mime_type' => $mimeType, + 'file_type' => 'image', + 'gcs_object_name' => $objectName, + 'gcs_uri' => $result['uri'], + 'uploaded_by' => Auth::id(), + 'is_temp' => false, + ]); + + return true; + } catch (\Throwable $e) { + Log::error('EquipmentPhoto: Import 사진 처리 실패', [ + 'equipment_id' => $equipment->id, + 'file' => $originalName, + 'error' => $e->getMessage(), + ]); + + return false; + } finally { + if ($compressedPath && file_exists($compressedPath) && $compressedPath !== $localPath) { + @unlink($compressedPath); + } + if (file_exists($localPath)) { + @unlink($localPath); + } + } + } + /** * 설비 사진 삭제 */ @@ -168,7 +241,7 @@ public function deleteAllPhotos(Equipment $equipment): void * * @return array{0: string, 1: string} [압축 파일 경로, 최종 확장자] */ - private function compressImage(string $sourcePath, string $extension): array + public function compressImage(string $sourcePath, string $extension): array { $image = $this->createImageFromFile($sourcePath, $extension); if (! $image) { @@ -224,7 +297,7 @@ private function compressImage(string $sourcePath, string $extension): array /** * 확장자에 따라 GD 이미지 리소스 생성 */ - private function createImageFromFile(string $path, string $extension): ?\GdImage + public function createImageFromFile(string $path, string $extension): ?\GdImage { return match ($extension) { 'jpg', 'jpeg' => @imagecreatefromjpeg($path), @@ -238,7 +311,7 @@ private function createImageFromFile(string $path, string $extension): ?\GdImage /** * 이미지에 투명 픽셀이 있는지 확인 */ - private function hasTransparency(\GdImage $image): bool + public function hasTransparency(\GdImage $image): bool { $width = imagesx($image); $height = imagesy($image);