feat: Items 테이블 통합 마이그레이션 Phase 0-5 구현

## 주요 변경사항
- Phase 0: 비표준 item_type 데이터 정규화 마이그레이션
- Phase 1.1: items 테이블 생성 (products + materials 통합)
- Phase 1.2: item_details 테이블 생성 (1:1 확장 필드)
- Phase 1.3: 데이터 이관 + item_id_mappings 테이블 생성
- Phase 3: item_pages.source_table 업데이트
- Phase 5: 참조 테이블 마이그레이션 (product_components, orders 등)

## 신규 파일
- app/Models/Items/Item.php - 통합 아이템 모델
- app/Models/Items/ItemDetail.php - 1:1 확장 필드 모델
- app/Services/ItemService.php - 통합 서비스 클래스

## 수정 파일
- ItemPage.php - items 테이블 지원 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-13 15:41:30 +09:00
parent aa9746ae2f
commit 80281e65b7
11 changed files with 1660 additions and 4 deletions

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Phase 0: 데이터 정규화
*
* products 테이블에서 비표준 item_type 삭제
* - 표준: FG(완제품), PT(부품)
* - 비표준(삭제): PRODUCT, SUBASSEMBLY, PART, CS
*
* 개발 중이므로 비표준 데이터 삭제 처리
* 품목관리 완료 후 경동기업 데이터 전체 재세팅 예정
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 삭제 전 건수 확인
$beforeCount = DB::table('products')->count();
$nonStandardCount = DB::table('products')
->whereNotIn('product_type', ['FG', 'PT'])
->count();
Log::info("Phase 0: Normalizing item_types", [
'total_before' => $beforeCount,
'non_standard_count' => $nonStandardCount,
]);
// 2. 비표준 products와 관련된 product_components 삭제
$bomDeleted = DB::table('product_components')
->whereIn('parent_product_id', function ($query) {
$query->select('id')
->from('products')
->whereNotIn('product_type', ['FG', 'PT']);
})
->orWhere(function ($query) {
$query->where('ref_type', 'PRODUCT')
->whereIn('ref_id', function ($q) {
$q->select('id')
->from('products')
->whereNotIn('product_type', ['FG', 'PT']);
});
})
->delete();
// 3. 비표준 products 삭제 (Soft Delete 아닌 Hard Delete)
$deleted = DB::table('products')
->whereNotIn('product_type', ['FG', 'PT'])
->delete();
// 4. 삭제 후 건수 확인
$afterCount = DB::table('products')->count();
Log::info("Phase 0: Normalization complete", [
'bom_deleted' => $bomDeleted,
'products_deleted' => $deleted,
'total_after' => $afterCount,
]);
}
/**
* Reverse the migrations.
*
* 삭제된 데이터는 복구 불가 (개발 중이므로 허용)
*/
public function down(): void
{
Log::warning("Phase 0 rollback: Deleted data cannot be restored");
}
};

View File

@@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Phase 1.1: items 테이블 생성
*
* products + materials 통합 테이블
* item_type: FG(완제품), PT(부품), SM(부자재), RM(원자재), CS(소모품)
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('items', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('tenant_id')->comment('테넌트 ID');
// 기본 정보
$table->string('item_type', 15)->comment('FG, PT, SM, RM, CS');
$table->string('code', 100)->comment('품목코드');
$table->string('name', 255)->comment('품목명');
$table->string('unit', 20)->nullable()->comment('단위');
$table->foreignId('category_id')->nullable()->comment('카테고리 ID');
// BOM (JSON) - child_item_id, quantity
$table->json('bom')->nullable()->comment('[{child_item_id, quantity}, ...]');
// 동적 속성
$table->json('attributes')->nullable()->comment('동적 필드 값');
$table->json('attributes_archive')->nullable()->comment('속성 아카이브');
$table->json('options')->nullable()->comment('추가 옵션');
// 설명
$table->text('description')->nullable()->comment('설명');
// 상태
$table->boolean('is_active')->default(true)->comment('활성 여부');
// 감사 필드
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
$table->foreignId('deleted_by')->nullable()->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index(['tenant_id', 'item_type'], 'idx_items_tenant_type');
$table->index(['tenant_id', 'code'], 'idx_items_tenant_code');
$table->index(['tenant_id', 'category_id'], 'idx_items_tenant_category');
$table->unique(['tenant_id', 'code', 'deleted_at'], 'uq_items_tenant_code');
// 외래키
$table->foreign('tenant_id')->references('id')->on('tenants');
$table->foreign('category_id')->references('id')->on('categories')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('items');
}
};

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Phase 1.2: item_details 테이블 생성
*
* items 테이블의 확장 필드 (1:1 관계)
* - Products 전용 필드 (is_sellable, is_purchasable 등)
* - Materials 전용 필드 (is_inspection 등)
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('item_details', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('item_id')->comment('품목 ID');
// Products 전용 필드
$table->boolean('is_sellable')->default(true)->comment('판매 가능');
$table->boolean('is_purchasable')->default(false)->comment('구매 가능');
$table->boolean('is_producible')->default(false)->comment('생산 가능');
$table->integer('safety_stock')->nullable()->comment('안전 재고');
$table->integer('lead_time')->nullable()->comment('리드타임(일)');
$table->boolean('is_variable_size')->default(false)->comment('가변 크기 여부');
$table->string('product_category', 50)->nullable()->comment('제품 카테고리');
$table->string('part_type', 50)->nullable()->comment('부품 타입');
// 파일 필드 (Products)
$table->string('bending_diagram')->nullable()->comment('벤딩 도면');
$table->json('bending_details')->nullable()->comment('벤딩 상세');
$table->string('specification_file')->nullable()->comment('규격서 파일');
$table->string('specification_file_name')->nullable()->comment('규격서 파일명');
$table->string('certification_file')->nullable()->comment('인증서 파일');
$table->string('certification_file_name')->nullable()->comment('인증서 파일명');
$table->string('certification_number')->nullable()->comment('인증 번호');
$table->date('certification_start_date')->nullable()->comment('인증 시작일');
$table->date('certification_end_date')->nullable()->comment('인증 종료일');
// Materials 전용 필드
$table->string('is_inspection', 1)->default('N')->comment('검사 여부');
$table->string('item_name')->nullable()->comment('품명');
$table->string('specification')->nullable()->comment('규격');
$table->text('search_tag')->nullable()->comment('검색 태그');
$table->text('remarks')->nullable()->comment('비고');
$table->timestamps();
// 유니크 제약
$table->unique('item_id', 'uq_item_details_item_id');
// 외래키
$table->foreign('item_id')->references('id')->on('items')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('item_details');
}
};

View File

@@ -0,0 +1,177 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* Phase 1.3: 데이터 이관
*
* products (FG, PT) + materials (SM, RM, CS) → items + item_details
*
* ID 매핑 전략:
* - item_id_mappings 테이블로 기존 ID → 새 ID 매핑 유지
* - 참조 테이블 업데이트 시 사용 (Phase 5)
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// ID 매핑 테이블 생성
Schema::create('item_id_mappings', function (Blueprint $table) {
$table->id();
$table->string('source_table', 20)->comment('products or materials');
$table->unsignedBigInteger('source_id')->comment('원본 테이블 ID');
$table->unsignedBigInteger('item_id')->comment('items 테이블 ID');
$table->timestamps();
$table->unique(['source_table', 'source_id']);
$table->index('item_id');
});
$productCount = 0;
$materialCount = 0;
// 1. Products → Items + ItemDetails
$products = DB::table('products')
->whereIn('product_type', ['FG', 'PT'])
->get();
foreach ($products as $product) {
// items 테이블에 삽입
$itemId = DB::table('items')->insertGetId([
'tenant_id' => $product->tenant_id,
'item_type' => $product->product_type,
'code' => $product->code,
'name' => $product->name,
'unit' => $product->unit,
'category_id' => $product->category_id,
'bom' => $product->bom,
'attributes' => $product->attributes,
'attributes_archive' => $product->attributes_archive ?? null,
'options' => $product->options,
'description' => $product->description,
'is_active' => $product->is_active,
'created_by' => $product->created_by,
'updated_by' => $product->updated_by,
'deleted_by' => $product->deleted_by,
'created_at' => $product->created_at,
'updated_at' => $product->updated_at,
'deleted_at' => $product->deleted_at,
]);
// ID 매핑 저장
DB::table('item_id_mappings')->insert([
'source_table' => 'products',
'source_id' => $product->id,
'item_id' => $itemId,
'created_at' => now(),
'updated_at' => now(),
]);
// item_details 테이블에 삽입
DB::table('item_details')->insert([
'item_id' => $itemId,
'is_sellable' => $product->is_sellable ?? true,
'is_purchasable' => $product->is_purchasable ?? false,
'is_producible' => $product->is_producible ?? false,
'safety_stock' => $product->safety_stock,
'lead_time' => $product->lead_time,
'is_variable_size' => $product->is_variable_size ?? false,
'product_category' => $product->product_category,
'part_type' => $product->part_type,
'bending_diagram' => $product->bending_diagram,
'bending_details' => $product->bending_details,
'specification_file' => $product->specification_file,
'specification_file_name' => $product->specification_file_name,
'certification_file' => $product->certification_file,
'certification_file_name' => $product->certification_file_name,
'certification_number' => $product->certification_number,
'certification_start_date' => $product->certification_start_date,
'certification_end_date' => $product->certification_end_date,
'created_at' => now(),
'updated_at' => now(),
]);
$productCount++;
}
// 2. Materials → Items + ItemDetails
$materials = DB::table('materials')
->whereIn('material_type', ['SM', 'RM', 'CS'])
->get();
foreach ($materials as $material) {
// items 테이블에 삽입
$itemId = DB::table('items')->insertGetId([
'tenant_id' => $material->tenant_id,
'item_type' => $material->material_type,
'code' => $material->material_code,
'name' => $material->name,
'unit' => $material->unit,
'category_id' => $material->category_id,
'bom' => null,
'attributes' => $material->attributes,
'attributes_archive' => null,
'options' => $material->options,
'description' => null,
'is_active' => $material->is_active,
'created_by' => $material->created_by,
'updated_by' => $material->updated_by,
'deleted_by' => $material->deleted_by,
'created_at' => $material->created_at,
'updated_at' => $material->updated_at,
'deleted_at' => $material->deleted_at,
]);
// ID 매핑 저장
DB::table('item_id_mappings')->insert([
'source_table' => 'materials',
'source_id' => $material->id,
'item_id' => $itemId,
'created_at' => now(),
'updated_at' => now(),
]);
// item_details 테이블에 삽입
DB::table('item_details')->insert([
'item_id' => $itemId,
'is_inspection' => $material->is_inspection ?? 'N',
'item_name' => $material->item_name,
'specification' => $material->specification,
'search_tag' => $material->search_tag,
'remarks' => $material->remarks,
'created_at' => now(),
'updated_at' => now(),
]);
$materialCount++;
}
Log::info("Phase 1.3: Data migration complete", [
'products_migrated' => $productCount,
'materials_migrated' => $materialCount,
'total' => $productCount + $materialCount,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// items와 item_details 데이터 삭제
DB::table('item_details')->truncate();
DB::table('items')->truncate();
// ID 매핑 테이블 삭제
Schema::dropIfExists('item_id_mappings');
Log::info("Phase 1.3: Data migration rolled back");
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* item_pages.source_table을 'products'/'materials'에서 'items'로 통합
* - item_type 컬럼은 유지 (FG, PT, SM, RM, CS로 구분)
*/
public function up(): void
{
// products -> items 업데이트
$productsUpdated = DB::table('item_pages')
->where('source_table', 'products')
->update(['source_table' => 'items']);
// materials -> items 업데이트
$materialsUpdated = DB::table('item_pages')
->where('source_table', 'materials')
->update(['source_table' => 'items']);
// 로그 출력
if ($productsUpdated > 0 || $materialsUpdated > 0) {
echo "Updated item_pages.source_table: products({$productsUpdated}), materials({$materialsUpdated}) -> items\n";
}
}
/**
* Reverse the migrations.
*
* item_type 기준으로 source_table 복원
*/
public function down(): void
{
// Product 타입 (FG, PT) -> products
DB::table('item_pages')
->where('source_table', 'items')
->whereIn('item_type', ['FG', 'PT'])
->update(['source_table' => 'products']);
// Material 타입 (SM, RM, CS) -> materials
DB::table('item_pages')
->where('source_table', 'items')
->whereIn('item_type', ['SM', 'RM', 'CS'])
->update(['source_table' => 'materials']);
}
};

View File

@@ -0,0 +1,344 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* Phase 5: 참조 테이블 마이그레이션
*
* 기존 products/materials 참조를 items 참조로 변경
* item_id_mappings 테이블을 활용하여 ID 변환
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. product_components: ref_type + ref_id → item_id
$this->migrateProductComponents();
// 2. bom_template_items: ref_type + ref_id → item_id
$this->migrateBomTemplateItems();
// 3. orders: product_id → item_id
$this->migrateOrders();
// 4. order_items: product_id → item_id
$this->migrateOrderItems();
// 5. material_receipts: material_id → item_id
$this->migrateMaterialReceipts();
// 6. lots: material_id → item_id
$this->migrateLots();
// 7. price_histories: item_type + item_id → item_id
$this->migratePriceHistories();
// 8. item_fields: source_table 업데이트
$this->migrateItemFields();
Log::info('Phase 5: Reference tables migration complete');
}
/**
* product_components 마이그레이션
*/
private function migrateProductComponents(): void
{
if (! Schema::hasTable('product_components')) {
return;
}
// item_id 컬럼 추가
if (! Schema::hasColumn('product_components', 'item_id')) {
Schema::table('product_components', function (Blueprint $table) {
$table->unsignedBigInteger('item_id')->nullable()->after('ref_id')
->comment('items 테이블 참조 ID');
});
}
// parent_item_id 컬럼 추가
if (! Schema::hasColumn('product_components', 'parent_item_id')) {
Schema::table('product_components', function (Blueprint $table) {
$table->unsignedBigInteger('parent_item_id')->nullable()->after('parent_product_id')
->comment('상위 품목 ID (items 참조)');
});
}
// ref_type=PRODUCT → products 매핑으로 item_id 업데이트
DB::statement("
UPDATE product_components pc
JOIN item_id_mappings m ON m.source_table = 'products' AND m.source_id = pc.ref_id
SET pc.item_id = m.item_id
WHERE pc.ref_type = 'PRODUCT'
");
// ref_type=MATERIAL → materials 매핑으로 item_id 업데이트
DB::statement("
UPDATE product_components pc
JOIN item_id_mappings m ON m.source_table = 'materials' AND m.source_id = pc.ref_id
SET pc.item_id = m.item_id
WHERE pc.ref_type = 'MATERIAL'
");
// parent_product_id → parent_item_id 업데이트
DB::statement("
UPDATE product_components pc
JOIN item_id_mappings m ON m.source_table = 'products' AND m.source_id = pc.parent_product_id
SET pc.parent_item_id = m.item_id
WHERE pc.parent_product_id IS NOT NULL
");
Log::info('Phase 5: product_components migrated');
}
/**
* bom_template_items 마이그레이션
*/
private function migrateBomTemplateItems(): void
{
if (! Schema::hasTable('bom_template_items')) {
return;
}
// item_id 컬럼 추가
if (! Schema::hasColumn('bom_template_items', 'item_id')) {
Schema::table('bom_template_items', function (Blueprint $table) {
$table->unsignedBigInteger('item_id')->nullable()->after('ref_id')
->comment('items 테이블 참조 ID');
});
}
// ref_type=PRODUCT → products 매핑
DB::statement("
UPDATE bom_template_items bti
JOIN item_id_mappings m ON m.source_table = 'products' AND m.source_id = bti.ref_id
SET bti.item_id = m.item_id
WHERE bti.ref_type = 'PRODUCT'
");
// ref_type=MATERIAL → materials 매핑
DB::statement("
UPDATE bom_template_items bti
JOIN item_id_mappings m ON m.source_table = 'materials' AND m.source_id = bti.ref_id
SET bti.item_id = m.item_id
WHERE bti.ref_type = 'MATERIAL'
");
Log::info('Phase 5: bom_template_items migrated');
}
/**
* orders 마이그레이션
*/
private function migrateOrders(): void
{
if (! Schema::hasTable('orders')) {
return;
}
// item_id 컬럼 추가
if (! Schema::hasColumn('orders', 'item_id')) {
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('item_id')->nullable()->after('product_id')
->comment('items 테이블 참조 ID');
});
}
// product_id → item_id 매핑
DB::statement("
UPDATE orders o
JOIN item_id_mappings m ON m.source_table = 'products' AND m.source_id = o.product_id
SET o.item_id = m.item_id
WHERE o.product_id IS NOT NULL
");
Log::info('Phase 5: orders migrated');
}
/**
* order_items 마이그레이션
*/
private function migrateOrderItems(): void
{
if (! Schema::hasTable('order_items')) {
return;
}
// item_id 컬럼 추가
if (! Schema::hasColumn('order_items', 'item_id')) {
Schema::table('order_items', function (Blueprint $table) {
$table->unsignedBigInteger('item_id')->nullable()->after('product_id')
->comment('items 테이블 참조 ID');
});
}
// product_id → item_id 매핑
DB::statement("
UPDATE order_items oi
JOIN item_id_mappings m ON m.source_table = 'products' AND m.source_id = oi.product_id
SET oi.item_id = m.item_id
WHERE oi.product_id IS NOT NULL
");
Log::info('Phase 5: order_items migrated');
}
/**
* material_receipts 마이그레이션
*/
private function migrateMaterialReceipts(): void
{
if (! Schema::hasTable('material_receipts')) {
return;
}
// item_id 컬럼 추가
if (! Schema::hasColumn('material_receipts', 'item_id')) {
Schema::table('material_receipts', function (Blueprint $table) {
$table->unsignedBigInteger('item_id')->nullable()->after('material_id')
->comment('items 테이블 참조 ID');
});
}
// material_id → item_id 매핑
DB::statement("
UPDATE material_receipts mr
JOIN item_id_mappings m ON m.source_table = 'materials' AND m.source_id = mr.material_id
SET mr.item_id = m.item_id
WHERE mr.material_id IS NOT NULL
");
Log::info('Phase 5: material_receipts migrated');
}
/**
* lots 마이그레이션
*/
private function migrateLots(): void
{
if (! Schema::hasTable('lots')) {
return;
}
// item_id 컬럼 추가
if (! Schema::hasColumn('lots', 'item_id')) {
Schema::table('lots', function (Blueprint $table) {
$table->unsignedBigInteger('item_id')->nullable()->after('material_id')
->comment('items 테이블 참조 ID');
});
}
// material_id → item_id 매핑
DB::statement("
UPDATE lots l
JOIN item_id_mappings m ON m.source_table = 'materials' AND m.source_id = l.material_id
SET l.item_id = m.item_id
WHERE l.material_id IS NOT NULL
");
Log::info('Phase 5: lots migrated');
}
/**
* price_histories 마이그레이션
*/
private function migratePriceHistories(): void
{
if (! Schema::hasTable('price_histories')) {
return;
}
// new_item_id 컬럼 추가 (item_id가 이미 있을 수 있음)
if (! Schema::hasColumn('price_histories', 'new_item_id')) {
Schema::table('price_histories', function (Blueprint $table) {
$table->unsignedBigInteger('new_item_id')->nullable()->after('item_id')
->comment('items 테이블 참조 ID');
});
}
// item_type=PRODUCT → products 매핑
DB::statement("
UPDATE price_histories ph
JOIN item_id_mappings m ON m.source_table = 'products' AND m.source_id = ph.item_id
SET ph.new_item_id = m.item_id
WHERE ph.item_type = 'PRODUCT'
");
// item_type=MATERIAL → materials 매핑
DB::statement("
UPDATE price_histories ph
JOIN item_id_mappings m ON m.source_table = 'materials' AND m.source_id = ph.item_id
SET ph.new_item_id = m.item_id
WHERE ph.item_type = 'MATERIAL'
");
Log::info('Phase 5: price_histories migrated');
}
/**
* item_fields source_table 업데이트
*/
private function migrateItemFields(): void
{
if (! Schema::hasTable('item_fields')) {
return;
}
// source_table 컬럼 값 업데이트
DB::table('item_fields')
->whereIn('source_table', ['products', 'materials'])
->update(['source_table' => 'items']);
Log::info('Phase 5: item_fields migrated');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// item_fields 복원
// Note: 원본 source_table 값은 item_type으로 추론
DB::statement("
UPDATE item_fields if2
SET if2.source_table = CASE
WHEN if2.item_type IN ('FG', 'PT') THEN 'products'
WHEN if2.item_type IN ('SM', 'RM', 'CS') THEN 'materials'
ELSE if2.source_table
END
WHERE if2.source_table = 'items'
");
// 추가된 컬럼 제거
$columnsToRemove = [
'product_components' => ['item_id', 'parent_item_id'],
'bom_template_items' => ['item_id'],
'orders' => ['item_id'],
'order_items' => ['item_id'],
'material_receipts' => ['item_id'],
'lots' => ['item_id'],
'price_histories' => ['new_item_id'],
];
foreach ($columnsToRemove as $table => $columns) {
if (Schema::hasTable($table)) {
Schema::table($table, function (Blueprint $table) use ($columns) {
foreach ($columns as $column) {
if (Schema::hasColumn($table->getTable(), $column)) {
$table->dropColumn($column);
}
}
});
}
}
Log::info('Phase 5: Reference tables migration rolled back');
}
};