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

@@ -1,5 +1,94 @@
# SAM API 작업 현황
## 2025-12-13 (금) - Items 테이블 통합 마이그레이션 작성
### 작업 목표
- `docs/plans/items-table-unification-plan.md` 기반 작업
- products + materials 테이블을 items 단일 테이블로 통합
- BOM 관리 단순화 (child_item_type + child_item_id → child_item_id만)
### 생성된 마이그레이션 파일 (6개)
| 순서 | 파일명 | Phase | 설명 |
|------|--------|-------|------|
| 1 | `2025_12_13_152423_normalize_item_types_before_unification.php` | 0 | 비표준 item_type 삭제 (PRODUCT, SUBASSEMBLY, PART 등) |
| 2 | `2025_12_13_152507_create_items_table.php` | 1.1 | items 테이블 생성 |
| 3 | `2025_12_13_152553_create_item_details_table.php` | 1.2 | item_details 테이블 생성 (1:1 확장 필드) |
| 4 | `2025_12_13_152631_migrate_products_materials_to_items.php` | 1.3 | 데이터 이관 + item_id_mappings 매핑 테이블 |
| 5 | `2025_12_13_153116_update_item_pages_source_table_to_items.php` | 3 | item_pages.source_table 업데이트 |
| 6 | `2025_12_13_153544_update_reference_tables_to_items.php` | 5 | 참조 테이블 item_id 컬럼 추가 및 매핑 |
### 생성된 모델 (2개)
**app/Models/Items/Item.php:**
- 통합 품목 모델 (FG, PT, SM, RM, CS)
- BelongsToTenant, SoftDeletes
- 스코프: products(), materials(), type(), active()
- BOM 헬퍼: getBomChildIds(), loadBomChildren()
**app/Models/Items/ItemDetail.php:**
- 품목 상세 정보 (1:1 관계)
- Products 전용: is_sellable, is_purchasable, is_producible, 파일 필드
- Materials 전용: is_inspection, specification
### 생성된 서비스
**app/Services/ItemService.php:**
- items 단일 테이블 CRUD
- 동적 필드 → options JSON 병합
- 카테고리 트리 조회
- 활성/비활성 토글
### 수정된 파일
**app/Models/ItemMaster/ItemPage.php:**
- `getTargetModelClass()`: items 테이블 지원 추가
- `isItemPage()`, `isProductType()`, `isMaterialType()` 헬퍼 추가
### ID 매핑 전략
**item_id_mappings 테이블:**
- 기존 products/materials ID → 새 items ID 매핑
- Phase 5 참조 테이블 마이그레이션에서 활용
### 참조 테이블 업데이트 대상 (Phase 5)
| 테이블 | 기존 | 추가 컬럼 |
|--------|------|----------|
| product_components | ref_type + ref_id | item_id, parent_item_id |
| bom_template_items | ref_type + ref_id | item_id |
| orders | product_id | item_id |
| order_items | product_id | item_id |
| material_receipts | material_id | item_id |
| lots | material_id | item_id |
| price_histories | item_type + item_id | new_item_id |
| item_fields | source_table | → 'items' |
### 실행 명령어
```bash
# 마이그레이션 실행 (순서대로)
php artisan migrate
# 롤백 (전체)
php artisan migrate:rollback --step=6
```
### 다음 작업 (Phase 6 - 마이그레이션 후)
- [ ] 마이그레이션 실행 및 검증
- [ ] ItemsController → ItemService 교체
- [ ] CRUD 테스트 (전체 item_type)
- [ ] BOM 계산 테스트
- [ ] Item-Master 연동 테스트
- [ ] 기존 products/materials 테이블 삭제 (확인 후)
### 참조 문서
- `docs/plans/items-table-unification-plan.md`
- `docs/INDEX.md`
---
## 2025-12-09 (월) - HR API 개발 완료 (Employee, Attendance, Department Tree)
### 작업 목표

View File

@@ -103,15 +103,25 @@ public function allRelationships()
public function getTargetModelClass(): ?string
{
$mapping = [
'products' => \App\Models\Product::class,
'materials' => \App\Models\Material::class,
'items' => \App\Models\Items\Item::class,
// 하위 호환성 (마이그레이션 완료 전까지)
'products' => \App\Models\Products\Product::class,
'materials' => \App\Models\Materials\Material::class,
];
return $mapping[$this->source_table] ?? null;
}
/**
* 제품 페이지인지 확인
* 통합 품목 페이지인지 확인
*/
public function isItemPage(): bool
{
return $this->source_table === 'items';
}
/**
* 제품 페이지인지 확인 (하위 호환성)
*/
public function isProductPage(): bool
{
@@ -119,10 +129,26 @@ public function isProductPage(): bool
}
/**
* 자재 페이지인지 확인
* 자재 페이지인지 확인 (하위 호환성)
*/
public function isMaterialPage(): bool
{
return $this->source_table === 'materials';
}
/**
* Product 타입 품목인지 확인 (items 테이블 기준)
*/
public function isProductType(): bool
{
return in_array($this->item_type, ['FG', 'PT']);
}
/**
* Material 타입 품목인지 확인 (items 테이블 기준)
*/
public function isMaterialType(): bool
{
return in_array($this->item_type, ['SM', 'RM', 'CS']);
}
}

198
app/Models/Items/Item.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
namespace App\Models\Items;
use App\Models\Commons\Category;
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 통합 품목 모델
*
* products + materials를 통합한 단일 품목 테이블
* item_type: FG(완제품), PT(부품), SM(부자재), RM(원자재), CS(소모품)
*/
class Item extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',
'item_type',
'code',
'name',
'unit',
'category_id',
'bom',
'attributes',
'attributes_archive',
'options',
'description',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'bom' => 'array',
'attributes' => 'array',
'attributes_archive' => 'array',
'options' => 'array',
'is_active' => 'boolean',
];
protected $hidden = [
'deleted_at',
];
/**
* item_type 상수
*/
public const TYPE_FINISHED_GOODS = 'FG'; // 완제품
public const TYPE_PARTS = 'PT'; // 부품
public const TYPE_SUB_MATERIALS = 'SM'; // 부자재
public const TYPE_RAW_MATERIALS = 'RM'; // 원자재
public const TYPE_CONSUMABLES = 'CS'; // 소모품
/**
* Products 타입 (FG, PT)
*/
public const PRODUCT_TYPES = ['FG', 'PT'];
/**
* Materials 타입 (SM, RM, CS)
*/
public const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
/**
* 상세 정보 (1:1)
*/
public function details()
{
return $this->hasOne(ItemDetail::class);
}
/**
* 카테고리
*/
public function category()
{
return $this->belongsTo(Category::class, 'category_id');
}
/**
* BOM 자식 품목들 (JSON bom 필드 기반)
* 주의: 이 관계는 bom JSON에서 child_item_id를 추출하여 조회
*/
public function bomChildren()
{
$childIds = collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray();
return self::whereIn('id', $childIds);
}
/**
* 파일 (폴리모픽)
*/
public function files()
{
return $this->morphMany(File::class, 'fileable');
}
/**
* 태그 (폴리모픽)
*/
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────
/**
* 특정 타입 필터
*/
public function scopeType($query, string $type)
{
return $query->where('item_type', strtoupper($type));
}
/**
* Products 타입만 (FG, PT)
*/
public function scopeProducts($query)
{
return $query->whereIn('item_type', self::PRODUCT_TYPES);
}
/**
* Materials 타입만 (SM, RM, CS)
*/
public function scopeMaterials($query)
{
return $query->whereIn('item_type', self::MATERIAL_TYPES);
}
/**
* 활성 품목만
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
// ──────────────────────────────────────────────────────────────
// 헬퍼 메서드
// ──────────────────────────────────────────────────────────────
/**
* Product 타입인지 확인
*/
public function isProduct(): bool
{
return in_array($this->item_type, self::PRODUCT_TYPES);
}
/**
* Material 타입인지 확인
*/
public function isMaterial(): bool
{
return in_array($this->item_type, self::MATERIAL_TYPES);
}
/**
* BOM 자식 품목 ID 목록 추출
*/
public function getBomChildIds(): array
{
return collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray();
}
/**
* BOM 자식 품목들 조회 (Eager Loading 최적화)
*/
public function loadBomChildren()
{
$childIds = $this->getBomChildIds();
if (empty($childIds)) {
return collect();
}
return self::whereIn('id', $childIds)->get()->keyBy('id');
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Models\Items;
use Illuminate\Database\Eloquent\Model;
/**
* 품목 상세 정보 모델
*
* items 테이블의 확장 필드 (1:1 관계)
* - Products 전용 필드 (is_sellable, is_purchasable 등)
* - Materials 전용 필드 (is_inspection 등)
*/
class ItemDetail extends Model
{
protected $fillable = [
'item_id',
// Products 전용
'is_sellable',
'is_purchasable',
'is_producible',
'safety_stock',
'lead_time',
'is_variable_size',
'product_category',
'part_type',
// 파일 필드 (Products)
'bending_diagram',
'bending_details',
'specification_file',
'specification_file_name',
'certification_file',
'certification_file_name',
'certification_number',
'certification_start_date',
'certification_end_date',
// Materials 전용
'is_inspection',
'item_name',
'specification',
'search_tag',
'remarks',
];
protected $casts = [
'is_sellable' => 'boolean',
'is_purchasable' => 'boolean',
'is_producible' => 'boolean',
'is_variable_size' => 'boolean',
'bending_details' => 'array',
'certification_start_date' => 'date',
'certification_end_date' => 'date',
];
/**
* 품목 (부모)
*/
public function item()
{
return $this->belongsTo(Item::class);
}
// ──────────────────────────────────────────────────────────────
// Products 전용 헬퍼
// ──────────────────────────────────────────────────────────────
/**
* 판매 가능 여부
*/
public function isSellable(): bool
{
return $this->is_sellable ?? false;
}
/**
* 구매 가능 여부
*/
public function isPurchasable(): bool
{
return $this->is_purchasable ?? false;
}
/**
* 생산 가능 여부
*/
public function isProducible(): bool
{
return $this->is_producible ?? false;
}
/**
* 인증서 유효 여부
*/
public function isCertificationValid(): bool
{
if (! $this->certification_end_date) {
return false;
}
return $this->certification_end_date->isFuture();
}
// ──────────────────────────────────────────────────────────────
// Materials 전용 헬퍼
// ──────────────────────────────────────────────────────────────
/**
* 검사 필요 여부
*/
public function requiresInspection(): bool
{
return $this->is_inspection === 'Y';
}
}

View File

@@ -0,0 +1,440 @@
<?php
namespace App\Services;
use App\Constants\SystemFields;
use App\Models\Commons\Category;
use App\Models\ItemMaster\ItemField;
use App\Models\Items\Item;
use App\Models\Items\ItemDetail;
use App\Models\Products\CommonCode;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class ItemService extends Service
{
/**
* items 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
*/
private function getKnownFields(): array
{
$tenantId = $this->tenantId();
// 1. 기본 고정 필드
$baseFields = [
'id', 'tenant_id', 'item_type', 'code', 'name', 'unit', 'category_id',
'bom', 'attributes', 'attributes_archive', 'options', 'description',
'is_active', 'created_by', 'updated_by', 'deleted_by',
'created_at', 'updated_at', 'deleted_at',
];
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
$columnFields = ItemField::where('tenant_id', $tenantId)
->where('source_table', 'items')
->where('storage_type', 'column')
->whereNotNull('field_key')
->pluck('field_key')
->toArray();
// 3. API 전용 필드
$apiFields = ['type_code'];
return array_unique(array_merge($baseFields, $columnFields, $apiFields));
}
/**
* 정의된 필드 외의 동적 필드를 options로 추출
*/
private function extractDynamicOptions(array $params): array
{
$knownFields = $this->getKnownFields();
$dynamicOptions = [];
foreach ($params as $key => $value) {
if (! in_array($key, $knownFields) && $value !== null && $value !== '') {
$dynamicOptions[$key] = $value;
}
}
return $dynamicOptions;
}
/**
* 기존 options 배열과 동적 필드를 병합
*/
private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array
{
if (! is_array($existingOptions) || empty($existingOptions)) {
return $dynamicOptions;
}
$isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1);
if ($isAssoc) {
return array_merge($existingOptions, $dynamicOptions);
}
foreach ($dynamicOptions as $key => $value) {
$existingOptions[] = ['label' => $key, 'value' => $value];
}
return $existingOptions;
}
/**
* options 입력을 [{label, value, unit}] 형태로 정규화
*/
private function normalizeOptions(?array $in): ?array
{
if (! $in) {
return null;
}
$isAssoc = array_keys($in) !== range(0, count($in) - 1);
if ($isAssoc) {
$out = [];
foreach ($in as $k => $v) {
$label = trim((string) $k);
$value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE);
if ($label !== '' || $value !== '') {
$out[] = ['label' => $label, 'value' => $value, 'unit' => ''];
}
}
return $out ?: null;
}
$out = [];
foreach ($in as $a) {
if (! is_array($a)) {
continue;
}
$label = trim((string) ($a['label'] ?? ''));
$value = trim((string) ($a['value'] ?? ''));
$unit = trim((string) ($a['unit'] ?? ''));
if ($label === '' && $value === '') {
continue;
}
$out[] = ['label' => $label, 'value' => $value, 'unit' => $unit];
}
return $out ?: null;
}
/**
* 카테고리 트리 전체 조회
*/
public function getCategory($request)
{
$parentId = $request->parentId ?? null;
return $this->fetchCategoryTree($parentId);
}
/**
* 내부 재귀 함수 (하위 카테고리 트리 구조로 구성)
*/
protected function fetchCategoryTree(?int $parentId = null)
{
$tenantId = $this->tenantId();
$query = Category::query()
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when(
is_null($parentId),
fn ($q) => $q->whereNull('parent_id'),
fn ($q) => $q->where('parent_id', $parentId)
)
->where('is_active', 1)
->orderBy('sort_order');
$categories = $query->get();
foreach ($categories as $category) {
$children = $this->fetchCategoryTree($category->id);
$category->setRelation('children', $children);
}
return $categories;
}
/**
* 목록/검색
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$size = (int) ($params['size'] ?? $params['per_page'] ?? 20);
$q = trim((string) ($params['q'] ?? $params['search'] ?? ''));
$categoryId = $params['category_id'] ?? null;
$itemType = $params['item_type'] ?? null;
$active = $params['active'] ?? null;
$query = Item::query()
->with(['category:id,name', 'details'])
->where('tenant_id', $tenantId);
// 검색어
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
// 카테고리
if ($categoryId) {
$query->where('category_id', (int) $categoryId);
}
// item_type 필터
if ($itemType) {
$query->where('item_type', strtoupper($itemType));
}
// 활성 상태
if ($active !== null && $active !== '') {
$query->where('is_active', (bool) $active);
}
$paginator = $query->orderBy('id')->paginate($size);
// 날짜 형식 변환
$paginator->setCollection(
$paginator->getCollection()->transform(function ($item) {
$arr = $item->toArray();
$arr['created_at'] = $item->created_at
? $item->created_at->format('Y-m-d')
: null;
return $arr;
})
);
return $paginator;
}
/**
* 생성
*/
public function store(array $data): Item
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
// tenant별 code 유니크 체크
$dup = Item::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($dup) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
// items 테이블 데이터
$itemData = [
'tenant_id' => $tenantId,
'item_type' => strtoupper($data['item_type']),
'code' => $data['code'],
'name' => $data['name'],
'unit' => $data['unit'] ?? null,
'category_id' => $data['category_id'] ?? null,
'bom' => $data['bom'] ?? null,
'attributes' => $data['attributes'] ?? null,
'options' => $data['options'] ?? null,
'description' => $data['description'] ?? null,
'is_active' => $data['is_active'] ?? true,
'created_by' => $userId,
];
$item = Item::create($itemData);
// item_details 테이블 데이터
$detailData = $this->extractDetailData($data);
$detailData['item_id'] = $item->id;
ItemDetail::create($detailData);
return $item->load('details');
}
/**
* 단건 조회
*/
public function show(int $id): Item
{
$tenantId = $this->tenantId();
$item = Item::query()
->with(['category:id,name', 'details'])
->where('tenant_id', $tenantId)
->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
return $item;
}
/**
* 수정
*/
public function update(int $id, array $data): Item
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$item = Item::query()->where('tenant_id', $tenantId)->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
// code 변경 시 중복 체크
if (isset($data['code']) && $data['code'] !== $item->code) {
$dup = Item::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($dup) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
}
// items 테이블 업데이트
$itemData = array_intersect_key($data, array_flip([
'item_type', 'code', 'name', 'unit', 'category_id',
'bom', 'attributes', 'options', 'description', 'is_active',
]));
$itemData['updated_by'] = $userId;
if (isset($itemData['item_type'])) {
$itemData['item_type'] = strtoupper($itemData['item_type']);
}
$item->update($itemData);
// item_details 테이블 업데이트
$detailData = $this->extractDetailData($data);
if (! empty($detailData)) {
$item->details()->updateOrCreate(
['item_id' => $item->id],
$detailData
);
}
return $item->load('details')->refresh();
}
/**
* 삭제 (soft delete)
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$item = Item::query()->where('tenant_id', $tenantId)->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
$item->delete();
}
/**
* 간편 검색 (모달/드롭다운)
*/
public function search(array $params)
{
$tenantId = $this->tenantId();
$q = trim((string) ($params['q'] ?? ''));
$limit = (int) ($params['limit'] ?? 20);
$itemType = $params['item_type'] ?? null;
$query = Item::query()->where('tenant_id', $tenantId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%");
});
}
if ($itemType) {
$query->where('item_type', strtoupper($itemType));
}
return $query->orderBy('name')
->limit($limit)
->get(['id', 'code', 'name', 'item_type', 'category_id']);
}
/**
* 활성/비활성 토글
*/
public function toggle(int $id): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$item = Item::query()->where('tenant_id', $tenantId)->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
$item->is_active = ! $item->is_active;
$item->updated_by = $userId;
$item->save();
return ['id' => $item->id, 'is_active' => $item->is_active];
}
/**
* item_details 데이터 추출
*/
private function extractDetailData(array $data): array
{
$detailFields = [
// Products 전용
'is_sellable', 'is_purchasable', 'is_producible',
'safety_stock', 'lead_time', 'is_variable_size',
'product_category', 'part_type',
'bending_diagram', 'bending_details',
'specification_file', 'specification_file_name',
'certification_file', 'certification_file_name',
'certification_number', 'certification_start_date', 'certification_end_date',
// Materials 전용
'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
];
return array_intersect_key($data, array_flip($detailFields));
}
}

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');
}
};