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,199 @@
<?php
namespace App\Console\Commands;
use App\Models\BendingItem;
use App\Models\Items\Item;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* items(BENDING) + options JSON → bending_items + bending_data 이관
*
* 실행: php artisan bending:migrate-to-new-table
* 롤백: php artisan bending:migrate-to-new-table --rollback
*/
class MigrateBendingItemsToNewTable extends Command
{
protected $signature = 'bending:migrate-to-new-table
{--tenant_id=287 : 테넌트 ID}
{--dry-run : 실행하지 않고 미리보기만}
{--rollback : bending_items/bending_data 전체 삭제}';
protected $description = 'items(BENDING) → bending_items + bending_data 테이블 이관';
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$rollback = $this->option('rollback');
if ($rollback) {
return $this->rollback($tenantId);
}
// 이미 이관된 데이터 확인
$existingCount = BendingItem::where('tenant_id', $tenantId)->count();
if ($existingCount > 0) {
$this->warn("이미 bending_items에 {$existingCount}건 존재합니다.");
if (! $this->confirm('기존 데이터 삭제 후 재이관하시겠습니까?')) {
return 0;
}
$this->rollback($tenantId);
}
// items(BENDING) 조회
$items = Item::where('item_category', 'BENDING')
->where('tenant_id', $tenantId)
->get();
$this->info("이관 대상: {$items->count()}");
if ($dryRun) {
$this->previewItems($items);
return 0;
}
$success = 0;
$errors = 0;
$bdCount = 0;
DB::transaction(function () use ($items, $tenantId, &$success, &$errors, &$bdCount) {
foreach ($items as $item) {
try {
$bi = $this->migrateItem($item, $tenantId);
$bdRows = $this->migrateBendingData($bi, $item);
$bdCount += $bdRows;
$success++;
} catch (\Throwable $e) {
$this->error("{$item->code}: {$e->getMessage()}");
$errors++;
}
}
});
$this->newLine();
$this->info("완료: {$success}건 이관, {$bdCount}건 전개도 행, {$errors}건 오류");
return $errors > 0 ? 1 : 0;
}
private function migrateItem(Item $item, int $tenantId): BendingItem
{
$opts = $item->options ?? [];
// item_name: options.item_name → name 폴백
$itemName = $opts['item_name'] ?? null;
if (empty($itemName) || $itemName === 'null') {
$itemName = $item->name;
}
$bi = BendingItem::create([
'tenant_id' => $tenantId,
'code' => $item->code,
'legacy_code' => $item->code,
'legacy_bending_id' => $opts['legacy_bending_num'] ?? null,
// 정규 컬럼 (options에서 승격)
'item_name' => $itemName,
'item_sep' => $this->cleanNull($opts['item_sep'] ?? null),
'item_bending' => $this->cleanNull($opts['item_bending'] ?? null),
'material' => $this->cleanNull($opts['material'] ?? null),
'item_spec' => $this->cleanNull($opts['item_spec'] ?? null),
'model_name' => $this->cleanNull($opts['model_name'] ?? null),
'model_UA' => $this->cleanNull($opts['model_UA'] ?? null),
'rail_width' => $this->toDecimal($opts['rail_width'] ?? null),
'exit_direction' => $this->cleanNull($opts['exit_direction'] ?? null),
'box_width' => $this->toDecimal($opts['box_width'] ?? null),
'box_height' => $this->toDecimal($opts['box_height'] ?? null),
'front_bottom' => $this->toDecimal($opts['front_bottom_width'] ?? $opts['front_bottom'] ?? null),
'inspection_door' => $this->cleanNull($opts['inspection_door'] ?? null),
// 비정형 속성
'options' => $this->buildMetaOptions($opts),
'is_active' => $item->is_active,
'created_by' => $item->created_by,
'updated_by' => $item->updated_by,
]);
$this->line("{$item->code} → bending_items#{$bi->id} ({$itemName})");
return $bi;
}
private function migrateBendingData(BendingItem $bi, Item $item): int
{
$opts = $item->options ?? [];
$bendingData = $opts['bendingData'] ?? [];
if (empty($bendingData) || ! is_array($bendingData)) {
return 0;
}
// bending_items.bending_data JSON 컬럼에 저장
$bi->update(['bending_data' => $bendingData]);
return count($bendingData);
}
private function rollback(int $tenantId): int
{
$biCount = BendingItem::where('tenant_id', $tenantId)->count();
BendingItem::where('tenant_id', $tenantId)->forceDelete();
$this->info("롤백 완료: bending_items {$biCount}건 삭제");
return 0;
}
private function previewItems($items): void
{
$headers = ['code', 'name', 'item_name(opts)', 'item_sep', 'material', 'has_bd'];
$rows = $items->take(20)->map(function ($item) {
$opts = $item->options ?? [];
return [
$item->code,
mb_substr($item->name, 0, 20),
mb_substr($opts['item_name'] ?? '(NULL)', 0, 20),
$opts['item_sep'] ?? '-',
$opts['material'] ?? '-',
! empty($opts['bendingData']) ? '✅' : '❌',
];
});
$this->table($headers, $rows);
$nullNameCount = $items->filter(fn ($i) => empty(($i->options ?? [])['item_name']))->count();
$hasBdCount = $items->filter(fn ($i) => ! empty(($i->options ?? [])['bendingData']))->count();
$this->info("item_name NULL: {$nullNameCount}건 (name 필드로 폴백)");
$this->info("bendingData 있음: {$hasBdCount}");
}
private function cleanNull(?string $value): ?string
{
if ($value === null || $value === 'null' || $value === '') {
return null;
}
return $value;
}
private function toDecimal(mixed $value): ?float
{
if ($value === null || $value === 'null' || $value === '') {
return null;
}
return (float) $value;
}
/**
* options에 남길 비정형 속성만 추출
*/
private function buildMetaOptions(array $opts): ?array
{
$metaKeys = ['search_keyword', 'registration_date', 'author', 'memo', 'parent_num', 'modified_by'];
$meta = [];
foreach ($metaKeys as $key) {
$val = $opts[$key] ?? null;
if ($val !== null && $val !== 'null' && $val !== '') {
$meta[$key] = $val;
}
}
return empty($meta) ? null : $meta;
}
}