'equipment_code', '설비명' => 'name', '설비유형' => 'equipment_type', '규격' => 'specification', '제조사' => 'manufacturer', '모델명' => 'model_name', '제조번호' => 'serial_no', '위치' => 'location', '생산라인' => 'production_line', '구입일' => 'purchase_date', '설치일' => 'install_date', '구입가격' => 'purchase_price', '내용연수' => 'useful_life', '상태' => 'status', '담당자' => 'manager_name', '비고' => 'memo', // 영문 매핑 (호환) 'equipment_code' => 'equipment_code', 'name' => 'name', 'equipment_type' => 'equipment_type', 'specification' => 'specification', 'manufacturer' => 'manufacturer', 'model_name' => 'model_name', 'serial_no' => 'serial_no', 'location' => 'location', 'production_line' => 'production_line', 'purchase_date' => 'purchase_date', 'install_date' => 'install_date', 'purchase_price' => 'purchase_price', 'useful_life' => 'useful_life', 'status' => 'status', 'manager_name' => 'manager_name', 'memo' => 'memo', ]; private const STATUS_MAP = [ '가동' => 'active', '사용' => 'active', '정상' => 'active', '유휴' => 'idle', '대기' => 'idle', '폐기' => 'disposed', '처분' => 'disposed', 'active' => 'active', 'idle' => 'idle', 'disposed' => 'disposed', ]; /** * 엑셀 파일 미리보기 (헤더 + 첫 10행) */ public function preview(string $filePath): array { $spreadsheet = IOFactory::load($filePath); $sheet = $spreadsheet->getActiveSheet(); $rows = $sheet->toArray(null, true, true, true); if (empty($rows)) { throw new \RuntimeException('빈 파일입니다.'); } // 첫 행 = 헤더 $headerRow = array_shift($rows); $headers = $this->mapHeaders($headerRow); if (empty($headers)) { throw new \RuntimeException('인식 가능한 헤더가 없습니다. 설비코드, 설비명 등의 헤더가 필요합니다.'); } // 미리보기 10행 $previewRows = []; $count = 0; foreach ($rows as $row) { if ($count >= 10) { break; } $mapped = $this->mapRow($row, $headers); if ($mapped) { $previewRows[] = $mapped; $count++; } } return [ 'headers' => array_values($headers), 'original_headers' => array_values(array_filter($headerRow)), 'preview' => $previewRows, 'total_rows' => count($rows), 'mapped_columns' => count($headers), ]; } /** * 엑셀 데이터 일괄 등록 */ public function import(string $filePath, array $options = []): array { $duplicateAction = $options['duplicate_action'] ?? 'skip'; $tenantId = session('selected_tenant_id', 1); $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, 'photos_uploaded' => 0, 'photos_skipped' => 0, 'errors' => [], ]; foreach ($rows as $rowNum => $row) { $mapped = $this->mapRow($row, $headers); if (! $mapped || empty($mapped['equipment_code'])) { continue; } try { $existing = Equipment::where('tenant_id', $tenantId) ->where('equipment_code', $mapped['equipment_code']) ->first(); if ($existing) { 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']++; } continue; } $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}: {$e->getMessage()}"; Log::warning('EquipmentImport: 행 처리 실패', [ '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; } private function mapHeaders(array $headerRow): array { $mapped = []; foreach ($headerRow as $col => $value) { $value = trim((string) $value); if (isset(self::HEADER_MAP[$value])) { $mapped[$col] = self::HEADER_MAP[$value]; } } return $mapped; } private function mapRow(array $row, array $headers): ?array { $mapped = []; $hasData = false; foreach ($headers as $col => $field) { $value = isset($row[$col]) ? trim((string) $row[$col]) : ''; if ($field === 'status') { $value = self::STATUS_MAP[mb_strtolower($value)] ?? 'active'; } if ($field === 'purchase_date' || $field === 'install_date') { $value = $this->parseDate($value); } if ($field === 'purchase_price') { $value = $value !== '' ? (float) preg_replace('/[^0-9.]/', '', $value) : null; } if ($field === 'useful_life') { $value = $value !== '' ? (int) preg_replace('/[^0-9]/', '', $value) : null; } if ($value !== '' && $value !== null) { $hasData = true; } $mapped[$field] = $value; } return $hasData ? $mapped : null; } private function parseDate(string $value): ?string { if (empty($value)) { return null; } // 엑셀 날짜 시리얼 번호 if (is_numeric($value)) { try { $date = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject((int) $value); return $date->format('Y-m-d'); } catch (\Exception) { return null; } } // 일반 날짜 문자열 foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Ymd'] as $format) { $date = \DateTime::createFromFormat($format, $value); if ($date) { return $date->format('Y-m-d'); } } return null; } private function createEquipment(array $data, int $tenantId): Equipment { $managerName = $data['manager_name'] ?? null; unset($data['manager_name']); $data['tenant_id'] = $tenantId; $data['created_by'] = auth()->id(); if (! isset($data['status'])) { $data['status'] = 'active'; } if ($managerName) { $user = \App\Models\User::where('name', $managerName)->first(); if ($user) { $data['manager_id'] = $user->id; } } return Equipment::create($data); } private function updateEquipment(Equipment $equipment, array $data): void { $managerName = $data['manager_name'] ?? null; unset($data['manager_name']); $data['updated_by'] = auth()->id(); if ($managerName) { $user = \App\Models\User::where('name', $managerName)->first(); if ($user) { $data['manager_id'] = $user->id; } } $equipment->update($data); } }