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:
@@ -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)
|
||||
|
||||
### 작업 목표
|
||||
|
||||
@@ -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
198
app/Models/Items/Item.php
Normal 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');
|
||||
}
|
||||
}
|
||||
114
app/Models/Items/ItemDetail.php
Normal file
114
app/Models/Items/ItemDetail.php
Normal 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';
|
||||
}
|
||||
}
|
||||
440
app/Services/ItemService.php
Normal file
440
app/Services/ItemService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
71
database/migrations/2025_12_13_152507_create_items_table.php
Normal file
71
database/migrations/2025_12_13_152507_create_items_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user