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:
강영보
2026-03-19 19:54:23 +09:00
parent 623298dd82
commit c29090a0b8
32 changed files with 3114 additions and 490 deletions

View 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;
}
}