feat: [equipment] 엑셀 Import 시 설비 사진 추출/업로드 기능 추가
- EquipmentPhotoService: uploadPhotoFromPath() 추가, 압축 메서드 public 전환 - EquipmentImportService: Drawing 추출/임시파일 저장/사진 업로드 통합 - EquipmentController: Import 응답 메시지에 사진 업로드 결과 포함
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<int, BaseDrawing[]>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user