- bending_items 전용 테이블 생성 (items.options → 정규 컬럼 승격) - bending_models 전용 테이블 생성 (가이드레일/케이스/하단마감재 통합) - bending_data JSON 통합 (별도 테이블 → bending_items.bending_data 컬럼) - bending_item_mappings 테이블 DROP (bending_items.code에 흡수) - BendingItemService/BendingCodeService → BendingItem 모델 전환 - GuiderailModelService component 이미지 자동 복사 - ItemsFileController bending_items/bending_models 폴백 지원 - Swagger 스키마 업데이트
388 lines
16 KiB
PHP
388 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\BendingItem;
|
|
use App\Models\BendingModel;
|
|
use App\Models\Commons\File;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* chandj guiderail/bottombar/shutterbox → bending_models 직접 이관
|
|
* + 기존 assembly_image 파일 매핑 보존
|
|
* + component별 이미지 복사 (기초관리 원본 → 독립 복사본)
|
|
*
|
|
* 실행: php artisan bending:model-import [--dry-run] [--tenant_id=287]
|
|
*/
|
|
class BendingModelImport extends Command
|
|
{
|
|
protected $signature = 'bending:model-import
|
|
{--tenant_id=287 : 테넌트 ID}
|
|
{--dry-run : 미리보기}
|
|
{--legacy-path=/tmp/legacy_5130 : 레거시 5130 경로}';
|
|
|
|
protected $description = 'chandj 절곡품 모델 3종 → bending_models 이관 (이미지 포함)';
|
|
|
|
private int $tenantId;
|
|
private string $legacyPath;
|
|
private array $itemImageMap = [];
|
|
private array $itemIdMap = [];
|
|
private array $modelImageMap = []; // "type:model_name:finishing_type" → image path
|
|
|
|
public function handle(): int
|
|
{
|
|
$this->tenantId = (int) $this->option('tenant_id');
|
|
$dryRun = $this->option('dry-run');
|
|
$this->legacyPath = $this->option('legacy-path');
|
|
|
|
// 기초관리 이미지 매핑 + 모델 JSON 이미지 로드
|
|
$this->buildItemImageMap();
|
|
$this->loadModelImageJsons();
|
|
|
|
// 기존 데이터 삭제 (assembly_image 파일 매핑 보존)
|
|
$existing = BendingModel::where('tenant_id', $this->tenantId)->count();
|
|
$oldFileMap = [];
|
|
if ($existing > 0 && ! $dryRun) {
|
|
$oldFileMap = $this->buildOldFileMap();
|
|
|
|
// component_image 삭제 (재생성할 거니까)
|
|
File::where('document_type', 'bending_model')
|
|
->where('field_key', 'component_image')
|
|
->whereNull('deleted_at')
|
|
->forceDelete();
|
|
|
|
BendingModel::where('tenant_id', $this->tenantId)->forceDelete();
|
|
$this->info("기존 bending_models {$existing}건 삭제 (component_image 재생성)");
|
|
}
|
|
|
|
$total = 0;
|
|
|
|
// 1. guiderail
|
|
$guiderails = DB::connection('chandj')->table('guiderail')
|
|
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
|
|
->orderBy('num')->get();
|
|
$this->info("\n=== 가이드레일: {$guiderails->count()}건 ===");
|
|
foreach ($guiderails as $row) {
|
|
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; }
|
|
$this->importModel($row, BendingModel::TYPE_GUIDERAIL, "GR-{$row->num}", $this->buildGuiderailData($row));
|
|
$total++;
|
|
}
|
|
|
|
// 2. shutterbox
|
|
$shutterboxes = DB::connection('chandj')->table('shutterbox')
|
|
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
|
|
->orderBy('num')->get();
|
|
$this->info("\n=== 케이스: {$shutterboxes->count()}건 ===");
|
|
foreach ($shutterboxes as $row) {
|
|
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->exit_direction}"); continue; }
|
|
$this->importModel($row, BendingModel::TYPE_SHUTTERBOX, "SB-{$row->num}", $this->buildShutterboxData($row));
|
|
$total++;
|
|
}
|
|
|
|
// 3. bottombar
|
|
$bottombars = DB::connection('chandj')->table('bottombar')
|
|
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
|
|
->orderBy('num')->get();
|
|
$this->info("\n=== 하단마감재: {$bottombars->count()}건 ===");
|
|
foreach ($bottombars as $row) {
|
|
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; }
|
|
$this->importModel($row, BendingModel::TYPE_BOTTOMBAR, "BB-{$row->num}", $this->buildBottombarData($row));
|
|
$total++;
|
|
}
|
|
|
|
// assembly_image 파일 매핑 업데이트
|
|
if (! $dryRun && ! empty($oldFileMap)) {
|
|
$this->remapAssemblyImages($oldFileMap);
|
|
}
|
|
|
|
// 최종 결과
|
|
$this->newLine();
|
|
$final = BendingModel::where('tenant_id', $this->tenantId)->count();
|
|
$assemblyFiles = File::where('document_type', 'bending_model')->where('field_key', 'assembly_image')->whereNull('deleted_at')->count();
|
|
$compFiles = File::where('document_type', 'bending_model')->where('field_key', 'component_image')->whereNull('deleted_at')->count();
|
|
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
$this->info("모델: {$final}건 / 조립도: {$assemblyFiles}건 / 부품이미지: {$compFiles}건");
|
|
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
|
|
|
return 0;
|
|
}
|
|
|
|
private function importModel(object $row, string $type, string $code, array $data): void
|
|
{
|
|
$components = json_decode($row->bending_components ?? '[]', true) ?: [];
|
|
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
|
|
|
|
// component별 이미지 복사
|
|
$components = $this->copyComponentImages($components);
|
|
|
|
$bm = BendingModel::create(array_merge($data, [
|
|
'tenant_id' => $this->tenantId,
|
|
'model_type' => $type,
|
|
'code' => $code,
|
|
'legacy_num' => $row->num,
|
|
'components' => $components,
|
|
'material_summary' => $materialSummary,
|
|
'registration_date' => $row->registration_date ?? null,
|
|
'author' => $this->clean($row->author ?? null),
|
|
'remark' => $this->clean($row->remark ?? null),
|
|
'search_keyword' => $this->clean($row->search_keyword ?? null),
|
|
'is_active' => true,
|
|
'created_by' => 1,
|
|
]));
|
|
|
|
// assembly_image 업로드 (JSON 파일에서)
|
|
$this->uploadAssemblyImage($bm, $type, $data);
|
|
|
|
$compCount = count($components);
|
|
$imgCount = collect($components)->whereNotNull('image_file_id')->count();
|
|
$hasAssembly = File::where('document_type', 'bending_model')->where('document_id', $bm->id)->where('field_key', 'assembly_image')->exists();
|
|
$this->line(" ✅ #{$row->num} → {$bm->id} ({$data['name']}) [부품:{$compCount} 이미지:{$imgCount} 조립도:" . ($hasAssembly ? 'Y' : 'N') . ']');
|
|
}
|
|
|
|
private function copyComponentImages(array $components): array
|
|
{
|
|
foreach ($components as &$comp) {
|
|
$sourceNum = $comp['num'] ?? $comp['source_num'] ?? null;
|
|
if (! $sourceNum) {
|
|
continue;
|
|
}
|
|
|
|
// sam_item_id 매핑 (원본수정 링크용)
|
|
$samItemId = $this->itemIdMap[(int) $sourceNum] ?? null;
|
|
if ($samItemId) {
|
|
$comp['sam_item_id'] = $samItemId;
|
|
}
|
|
|
|
$sourceFile = $this->itemImageMap[(int) $sourceNum] ?? null;
|
|
if (! $sourceFile || ! $sourceFile->file_path) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$extension = pathinfo($sourceFile->stored_name, PATHINFO_EXTENSION);
|
|
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
|
$directory = sprintf('%d/bending/model-parts/%s/%s', $this->tenantId, date('Y'), date('m'));
|
|
$newPath = $directory . '/' . $storedName;
|
|
|
|
$content = Storage::disk('r2')->get($sourceFile->file_path);
|
|
Storage::disk('r2')->put($newPath, $content);
|
|
|
|
$newFile = File::create([
|
|
'tenant_id' => $this->tenantId,
|
|
'display_name' => $sourceFile->display_name,
|
|
'stored_name' => $storedName,
|
|
'file_path' => $newPath,
|
|
'file_size' => $sourceFile->file_size,
|
|
'mime_type' => $sourceFile->mime_type,
|
|
'file_type' => 'image',
|
|
'field_key' => 'component_image',
|
|
'document_id' => 0, // 모델 생성 전이므로 임시, 나중에 update 불필요 (component JSON에 ID 저장)
|
|
'document_type' => 'bending_model',
|
|
'is_temp' => false,
|
|
'uploaded_by' => 1,
|
|
'created_by' => 1,
|
|
]);
|
|
|
|
$comp['image_file_id'] = $newFile->id;
|
|
} catch (\Throwable $e) {
|
|
// 복사 실패 시 무시
|
|
}
|
|
}
|
|
unset($comp);
|
|
|
|
return $components;
|
|
}
|
|
|
|
private function buildItemImageMap(): void
|
|
{
|
|
$items = BendingItem::where('tenant_id', $this->tenantId)
|
|
->whereNotNull('legacy_bending_id')
|
|
->get();
|
|
|
|
foreach ($items as $bi) {
|
|
$file = File::where('document_type', 'bending_item')
|
|
->where('document_id', $bi->id)
|
|
->where('field_key', 'bending_diagram')
|
|
->whereNull('deleted_at')
|
|
->first();
|
|
|
|
$this->itemIdMap[$bi->legacy_bending_id] = $bi->id;
|
|
|
|
if ($file) {
|
|
$this->itemImageMap[$bi->legacy_bending_id] = $file;
|
|
}
|
|
}
|
|
|
|
$this->info("기초관리 매핑: " . count($this->itemIdMap) . "건 (이미지: " . count($this->itemImageMap) . "건)");
|
|
}
|
|
|
|
private function buildOldFileMap(): array
|
|
{
|
|
return File::where('document_type', 'bending_model')
|
|
->where('field_key', 'assembly_image')
|
|
->whereNull('deleted_at')
|
|
->get()
|
|
->mapWithKeys(function ($file) {
|
|
$bm = BendingModel::find($file->document_id);
|
|
return $bm ? [$bm->legacy_num => $file->document_id] : [];
|
|
})->toArray();
|
|
}
|
|
|
|
private function remapAssemblyImages(array $oldFileMap): void
|
|
{
|
|
$remapped = 0;
|
|
$newModels = BendingModel::where('tenant_id', $this->tenantId)->get()->keyBy('legacy_num');
|
|
|
|
foreach ($oldFileMap as $legacyNum => $oldDocId) {
|
|
$newBm = $newModels[$legacyNum] ?? null;
|
|
if ($newBm && $oldDocId !== $newBm->id) {
|
|
File::where('document_type', 'bending_model')
|
|
->where('field_key', 'assembly_image')
|
|
->where('document_id', $oldDocId)
|
|
->whereNull('deleted_at')
|
|
->update(['document_id' => $newBm->id]);
|
|
$remapped++;
|
|
}
|
|
}
|
|
|
|
$this->info("조립도 매핑 업데이트: {$remapped}건");
|
|
}
|
|
|
|
private function loadModelImageJsons(): void
|
|
{
|
|
$jsonFiles = [
|
|
'guiderail' => $this->legacyPath . '/guiderail/guiderail.json',
|
|
'shutterbox' => $this->legacyPath . '/shutterbox/shutterbox.json',
|
|
'bottombar' => $this->legacyPath . '/bottombar/bottombar.json',
|
|
];
|
|
|
|
foreach ($jsonFiles as $type => $path) {
|
|
if (! file_exists($path)) {
|
|
continue;
|
|
}
|
|
$items = json_decode(file_get_contents($path), true) ?: [];
|
|
foreach ($items as $item) {
|
|
$key = $this->makeImageKey($type, $item);
|
|
if ($key && ! empty($item['image'])) {
|
|
$this->modelImageMap[$key] = $item['image'];
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->info("모델 이미지 매핑: " . count($this->modelImageMap) . "건");
|
|
}
|
|
|
|
private function makeImageKey(string $type, array $item): ?string
|
|
{
|
|
if ($type === 'guiderail') {
|
|
return "GR:{$item['model_name']}:{$item['check_type']}:{$item['finishing_type']}";
|
|
}
|
|
if ($type === 'shutterbox') {
|
|
return "SB:{$item['exit_direction']}:{$item['box_width']}x{$item['box_height']}";
|
|
}
|
|
if ($type === 'bottombar') {
|
|
return "BB:{$item['model_name']}:{$item['finishing_type']}";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function uploadAssemblyImage(BendingModel $bm, string $type, array $data): void
|
|
{
|
|
$key = match ($type) {
|
|
BendingModel::TYPE_GUIDERAIL => "GR:{$data['model_name']}:{$data['check_type']}:{$data['finishing_type']}",
|
|
BendingModel::TYPE_SHUTTERBOX => "SB:{$data['exit_direction']}:" . intval($data['box_width'] ?? 0) . 'x' . intval($data['box_height'] ?? 0),
|
|
BendingModel::TYPE_BOTTOMBAR => "BB:{$data['model_name']}:{$data['finishing_type']}",
|
|
default => null,
|
|
};
|
|
|
|
if (! $key) return;
|
|
|
|
$imagePath = $this->modelImageMap[$key] ?? null;
|
|
if (! $imagePath) return;
|
|
|
|
// /bottombar/images/xxx.png → legacy-path/bottombar/images/xxx.png
|
|
$localPath = $this->legacyPath . $imagePath;
|
|
if (! file_exists($localPath)) return;
|
|
|
|
try {
|
|
$extension = pathinfo($localPath, PATHINFO_EXTENSION);
|
|
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
|
$directory = sprintf('%d/bending/models/%s/%s', $this->tenantId, date('Y'), date('m'));
|
|
$r2Path = $directory . '/' . $storedName;
|
|
|
|
Storage::disk('r2')->put($r2Path, file_get_contents($localPath));
|
|
|
|
File::create([
|
|
'tenant_id' => $this->tenantId,
|
|
'display_name' => basename($imagePath),
|
|
'stored_name' => $storedName,
|
|
'file_path' => $r2Path,
|
|
'file_size' => filesize($localPath),
|
|
'mime_type' => mime_content_type($localPath),
|
|
'file_type' => 'image',
|
|
'field_key' => 'assembly_image',
|
|
'document_id' => $bm->id,
|
|
'document_type' => 'bending_model',
|
|
'is_temp' => false,
|
|
'uploaded_by' => 1,
|
|
'created_by' => 1,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
$this->warn(" ⚠️ 조립도 업로드 실패: {$bm->name} — {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
// ── 모델별 데이터 빌드 ──
|
|
|
|
private function buildGuiderailData(object $row): array
|
|
{
|
|
return [
|
|
'name' => trim("{$row->model_name} {$row->firstitem} {$row->check_type} {$row->finishing_type}"),
|
|
'model_name' => $this->clean($row->model_name),
|
|
'model_UA' => $this->clean($row->model_UA),
|
|
'item_sep' => $this->clean($row->firstitem),
|
|
'finishing_type' => $this->clean($row->finishing_type),
|
|
'check_type' => $this->clean($row->check_type),
|
|
'rail_width' => $this->toNum($row->rail_width),
|
|
'rail_length' => $this->toNum($row->rail_length),
|
|
];
|
|
}
|
|
|
|
private function buildShutterboxData(object $row): array
|
|
{
|
|
return [
|
|
'name' => trim("케이스 {$row->exit_direction} {$row->box_width}x{$row->box_height}"),
|
|
'exit_direction' => $this->clean($row->exit_direction),
|
|
'front_bottom_width' => $this->toNum($row->front_bottom_width ?? null),
|
|
'rail_width' => $this->toNum($row->rail_width ?? null),
|
|
'box_width' => $this->toNum($row->box_width),
|
|
'box_height' => $this->toNum($row->box_height),
|
|
];
|
|
}
|
|
|
|
private function buildBottombarData(object $row): array
|
|
{
|
|
return [
|
|
'name' => trim("{$row->model_name} {$row->firstitem} {$row->finishing_type} {$row->bar_width}x{$row->bar_height}"),
|
|
'model_name' => $this->clean($row->model_name),
|
|
'model_UA' => $this->clean($row->model_UA),
|
|
'item_sep' => $this->clean($row->firstitem),
|
|
'finishing_type' => $this->clean($row->finishing_type),
|
|
'bar_width' => $this->toNum($row->bar_width),
|
|
'bar_height' => $this->toNum($row->bar_height),
|
|
];
|
|
}
|
|
|
|
private function clean(?string $v): ?string
|
|
{
|
|
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
|
|
}
|
|
|
|
private function toNum(mixed $v): ?float
|
|
{
|
|
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
|
|
}
|
|
}
|