feat: [bending] 절곡품 전용 테이블 분리 API
- 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 스키마 업데이트
This commit is contained in:
387
app/Console/Commands/BendingModelImport.php
Normal file
387
app/Console/Commands/BendingModelImport.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user