- EquipmentPhotoService: uploadPhotoFromPath() 추가, 압축 메서드 public 전환 - EquipmentImportService: Drawing 추출/임시파일 저장/사진 업로드 통합 - EquipmentController: Import 응답 메시지에 사진 업로드 결과 포함
438 lines
14 KiB
PHP
438 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
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',
|
|
'설비유형' => '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<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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|