From 80281e65b787ae2d01a8125e8fd1bb53afc9067d Mon Sep 17 00:00:00 2001 From: kent Date: Sat, 13 Dec 2025 15:41:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Items=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20Phase=200-5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 주요 변경사항 - 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 --- CURRENT_WORKS.md | 89 ++++ app/Models/ItemMaster/ItemPage.php | 34 +- app/Models/Items/Item.php | 198 ++++++++ app/Models/Items/ItemDetail.php | 114 +++++ app/Services/ItemService.php | 440 ++++++++++++++++++ ...ormalize_item_types_before_unification.php | 76 +++ .../2025_12_13_152507_create_items_table.php | 71 +++ ...12_13_152553_create_item_details_table.php | 70 +++ ...31_migrate_products_materials_to_items.php | 177 +++++++ ...pdate_item_pages_source_table_to_items.php | 51 ++ ...53544_update_reference_tables_to_items.php | 344 ++++++++++++++ 11 files changed, 1660 insertions(+), 4 deletions(-) create mode 100644 app/Models/Items/Item.php create mode 100644 app/Models/Items/ItemDetail.php create mode 100644 app/Services/ItemService.php create mode 100644 database/migrations/2025_12_13_152423_normalize_item_types_before_unification.php create mode 100644 database/migrations/2025_12_13_152507_create_items_table.php create mode 100644 database/migrations/2025_12_13_152553_create_item_details_table.php create mode 100644 database/migrations/2025_12_13_152631_migrate_products_materials_to_items.php create mode 100644 database/migrations/2025_12_13_153116_update_item_pages_source_table_to_items.php create mode 100644 database/migrations/2025_12_13_153544_update_reference_tables_to_items.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 349cc09..5e57cc9 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -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) ### 작업 목표 diff --git a/app/Models/ItemMaster/ItemPage.php b/app/Models/ItemMaster/ItemPage.php index a96f336..746f43a 100644 --- a/app/Models/ItemMaster/ItemPage.php +++ b/app/Models/ItemMaster/ItemPage.php @@ -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']); + } } diff --git a/app/Models/Items/Item.php b/app/Models/Items/Item.php new file mode 100644 index 0000000..d342526 --- /dev/null +++ b/app/Models/Items/Item.php @@ -0,0 +1,198 @@ + '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'); + } +} \ No newline at end of file diff --git a/app/Models/Items/ItemDetail.php b/app/Models/Items/ItemDetail.php new file mode 100644 index 0000000..03f6497 --- /dev/null +++ b/app/Models/Items/ItemDetail.php @@ -0,0 +1,114 @@ + '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'; + } +} \ No newline at end of file diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php new file mode 100644 index 0000000..29a8038 --- /dev/null +++ b/app/Services/ItemService.php @@ -0,0 +1,440 @@ +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)); + } +} \ No newline at end of file diff --git a/database/migrations/2025_12_13_152423_normalize_item_types_before_unification.php b/database/migrations/2025_12_13_152423_normalize_item_types_before_unification.php new file mode 100644 index 0000000..ef5f767 --- /dev/null +++ b/database/migrations/2025_12_13_152423_normalize_item_types_before_unification.php @@ -0,0 +1,76 @@ +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"); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_13_152507_create_items_table.php b/database/migrations/2025_12_13_152507_create_items_table.php new file mode 100644 index 0000000..b4ac5c4 --- /dev/null +++ b/database/migrations/2025_12_13_152507_create_items_table.php @@ -0,0 +1,71 @@ +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'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_13_152553_create_item_details_table.php b/database/migrations/2025_12_13_152553_create_item_details_table.php new file mode 100644 index 0000000..49bf04f --- /dev/null +++ b/database/migrations/2025_12_13_152553_create_item_details_table.php @@ -0,0 +1,70 @@ +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'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_13_152631_migrate_products_materials_to_items.php b/database/migrations/2025_12_13_152631_migrate_products_materials_to_items.php new file mode 100644 index 0000000..3f70e92 --- /dev/null +++ b/database/migrations/2025_12_13_152631_migrate_products_materials_to_items.php @@ -0,0 +1,177 @@ +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"); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_13_153116_update_item_pages_source_table_to_items.php b/database/migrations/2025_12_13_153116_update_item_pages_source_table_to_items.php new file mode 100644 index 0000000..987ca90 --- /dev/null +++ b/database/migrations/2025_12_13_153116_update_item_pages_source_table_to_items.php @@ -0,0 +1,51 @@ + 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']); + } +}; diff --git a/database/migrations/2025_12_13_153544_update_reference_tables_to_items.php b/database/migrations/2025_12_13_153544_update_reference_tables_to_items.php new file mode 100644 index 0000000..d4ba5ae --- /dev/null +++ b/database/migrations/2025_12_13_153544_update_reference_tables_to_items.php @@ -0,0 +1,344 @@ +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'); + } +};