diff --git a/app/Http/Controllers/Api/V1/ItemMaster/ItemMasterFieldController.php b/app/Http/Controllers/Api/V1/ItemMaster/ItemMasterFieldController.php deleted file mode 100644 index ff5e357..0000000 --- a/app/Http/Controllers/Api/V1/ItemMaster/ItemMasterFieldController.php +++ /dev/null @@ -1,58 +0,0 @@ -service->index(); - }, __('message.fetched')); - } - - /** - * 마스터 필드 생성 - */ - public function store(ItemMasterFieldStoreRequest $request) - { - return ApiResponse::handle(function () use ($request) { - return $this->service->store($request->validated()); - }, __('message.created')); - } - - /** - * 마스터 필드 수정 - */ - public function update(int $id, ItemMasterFieldUpdateRequest $request) - { - return ApiResponse::handle(function () use ($id, $request) { - return $this->service->update($id, $request->validated()); - }, __('message.updated')); - } - - /** - * 마스터 필드 삭제 - */ - public function destroy(int $id) - { - return ApiResponse::handle(function () use ($id) { - $this->service->destroy($id); - - return 'success'; - }, __('message.deleted')); - } -} diff --git a/app/Http/Requests/ItemMaster/ItemBomItemStoreRequest.php b/app/Http/Requests/ItemMaster/ItemBomItemStoreRequest.php index e1d10fe..9ab5557 100644 --- a/app/Http/Requests/ItemMaster/ItemBomItemStoreRequest.php +++ b/app/Http/Requests/ItemMaster/ItemBomItemStoreRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + 'group_id' => 'nullable|integer|min:1', // 계층번호 'item_code' => 'nullable|string|max:100', 'item_name' => 'required|string|max:255', 'quantity' => 'nullable|numeric|min:0', diff --git a/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php b/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php index e46f436..f728e8e 100644 --- a/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php +++ b/app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + 'group_id' => 'nullable|integer|min:1', // 계층번호 'field_name' => 'required|string|max:255', 'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea', 'is_required' => 'nullable|boolean', diff --git a/app/Http/Requests/ItemMaster/ItemMasterFieldStoreRequest.php b/app/Http/Requests/ItemMaster/ItemMasterFieldStoreRequest.php deleted file mode 100644 index c59b55f..0000000 --- a/app/Http/Requests/ItemMaster/ItemMasterFieldStoreRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - 'required|string|max:255', - 'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea', - 'category' => 'nullable|string|max:100', - 'description' => 'nullable|string', - 'is_common' => 'nullable|boolean', - 'default_value' => 'nullable|string', - 'options' => 'nullable|array', - 'validation_rules' => 'nullable|array', - 'properties' => 'nullable|array', - ]; - } -} diff --git a/app/Http/Requests/ItemMaster/ItemMasterFieldUpdateRequest.php b/app/Http/Requests/ItemMaster/ItemMasterFieldUpdateRequest.php deleted file mode 100644 index bfbfce3..0000000 --- a/app/Http/Requests/ItemMaster/ItemMasterFieldUpdateRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - 'sometimes|string|max:255', - 'field_type' => 'sometimes|in:textbox,number,dropdown,checkbox,date,textarea', - 'category' => 'sometimes|nullable|string|max:100', - 'description' => 'sometimes|nullable|string', - 'is_common' => 'sometimes|nullable|boolean', - 'default_value' => 'sometimes|nullable|string', - 'options' => 'sometimes|nullable|array', - 'validation_rules' => 'sometimes|nullable|array', - 'properties' => 'sometimes|nullable|array', - ]; - } -} diff --git a/app/Http/Requests/ItemMaster/ItemSectionStoreRequest.php b/app/Http/Requests/ItemMaster/ItemSectionStoreRequest.php index 968aa08..6a3fb6d 100644 --- a/app/Http/Requests/ItemMaster/ItemSectionStoreRequest.php +++ b/app/Http/Requests/ItemMaster/ItemSectionStoreRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + 'group_id' => 'nullable|integer|min:1', // 계층번호 'title' => 'required|string|max:255', 'type' => 'required|in:fields,bom', ]; diff --git a/app/Models/ItemMaster/ItemBomItem.php b/app/Models/ItemMaster/ItemBomItem.php index a1c9850..789c003 100644 --- a/app/Models/ItemMaster/ItemBomItem.php +++ b/app/Models/ItemMaster/ItemBomItem.php @@ -14,7 +14,6 @@ class ItemBomItem extends Model protected $fillable = [ 'tenant_id', 'group_id', - 'section_id', 'item_code', 'item_name', 'quantity', @@ -44,15 +43,7 @@ class ItemBomItem extends Model ]; /** - * 소속 섹션 (기존 FK 기반 - 하위 호환성) - */ - public function section() - { - return $this->belongsTo(ItemSection::class, 'section_id'); - } - - /** - * 이 BOM 항목이 연결된 섹션들 조회 (링크 테이블 기반) + * 이 BOM 항목이 연결된 섹션들 조회 (entity_relationships 기반) */ public function linkedSections() { diff --git a/app/Models/ItemMaster/ItemField.php b/app/Models/ItemMaster/ItemField.php index c6bb740..682076d 100644 --- a/app/Models/ItemMaster/ItemField.php +++ b/app/Models/ItemMaster/ItemField.php @@ -14,7 +14,6 @@ class ItemField extends Model protected $fillable = [ 'tenant_id', 'group_id', - 'section_id', 'field_name', 'field_type', 'order_no', @@ -25,6 +24,9 @@ class ItemField extends Model 'validation_rules', 'options', 'properties', + 'category', + 'description', + 'is_common', 'created_by', 'updated_by', 'deleted_by', @@ -34,6 +36,7 @@ class ItemField extends Model 'group_id' => 'integer', 'order_no' => 'integer', 'is_required' => 'boolean', + 'is_common' => 'boolean', 'display_condition' => 'array', 'validation_rules' => 'array', 'options' => 'array', @@ -49,15 +52,7 @@ class ItemField extends Model ]; /** - * 소속 섹션 (기존 FK 기반 - 하위 호환성) - */ - public function section() - { - return $this->belongsTo(ItemSection::class, 'section_id'); - } - - /** - * 이 필드가 연결된 섹션들 조회 (링크 테이블 기반) + * 이 필드가 연결된 섹션들 조회 (entity_relationships 기반) */ public function linkedSections() { diff --git a/app/Models/ItemMaster/ItemMasterField.php b/app/Models/ItemMaster/ItemMasterField.php deleted file mode 100644 index ca5e7f9..0000000 --- a/app/Models/ItemMaster/ItemMasterField.php +++ /dev/null @@ -1,46 +0,0 @@ - 'integer', - 'is_common' => 'boolean', - 'options' => 'array', - 'validation_rules' => 'array', - 'properties' => 'array', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'deleted_at' => 'datetime', - ]; - - protected $hidden = [ - 'deleted_by', - 'deleted_at', - ]; -} diff --git a/app/Models/ItemMaster/ItemSection.php b/app/Models/ItemMaster/ItemSection.php index 6be472e..09b7984 100644 --- a/app/Models/ItemMaster/ItemSection.php +++ b/app/Models/ItemMaster/ItemSection.php @@ -14,7 +14,6 @@ class ItemSection extends Model protected $fillable = [ 'tenant_id', 'group_id', - 'page_id', 'title', 'type', 'order_no', @@ -58,27 +57,19 @@ public function scopeNonTemplates($query) ]; /** - * 소속 페이지 (기존 FK 기반 - 하위 호환성) - */ - public function page() - { - return $this->belongsTo(ItemPage::class, 'page_id'); - } - - /** - * 섹션의 필드 목록 (기존 FK 기반 - 하위 호환성) + * 섹션에 연결된 필드 목록 (entity_relationships 기반) */ public function fields() { - return $this->hasMany(ItemField::class, 'section_id')->orderBy('order_no'); + return $this->linkedFields(); } /** - * 섹션의 BOM 항목 목록 (기존 FK 기반 - 하위 호환성) + * 섹션에 연결된 BOM 항목 목록 (entity_relationships 기반) */ public function bomItems() { - return $this->hasMany(ItemBomItem::class, 'section_id'); + return $this->linkedBomItems(); } /** diff --git a/app/Services/ItemMaster/ItemBomItemService.php b/app/Services/ItemMaster/ItemBomItemService.php index 076c854..2a67d90 100644 --- a/app/Services/ItemMaster/ItemBomItemService.php +++ b/app/Services/ItemMaster/ItemBomItemService.php @@ -2,6 +2,7 @@ namespace App\Services\ItemMaster; +use App\Models\ItemMaster\EntityRelationship; use App\Models\ItemMaster\ItemBomItem; use App\Models\ItemMaster\ItemSection; use App\Services\Service; @@ -37,7 +38,6 @@ public function storeIndependent(array $data): ItemBomItem $bomItem = ItemBomItem::create([ 'tenant_id' => $tenantId, 'group_id' => $data['group_id'] ?? 1, - 'section_id' => null, 'item_code' => $data['item_code'] ?? null, 'item_name' => $data['item_name'], 'quantity' => $data['quantity'] ?? 1, @@ -53,7 +53,7 @@ public function storeIndependent(array $data): ItemBomItem } /** - * BOM 항목 생성 + * BOM 항목 생성 및 섹션에 연결 */ public function store(int $sectionId, array $data): ItemBomItem { @@ -69,9 +69,10 @@ public function store(int $sectionId, array $data): ItemBomItem throw new NotFoundHttpException(__('error.not_found')); } + // BOM 항목 생성 $bomItem = ItemBomItem::create([ 'tenant_id' => $tenantId, - 'section_id' => $sectionId, + 'group_id' => $data['group_id'] ?? 1, 'item_code' => $data['item_code'] ?? null, 'item_name' => $data['item_name'], 'quantity' => $data['quantity'] ?? 1, @@ -83,6 +84,17 @@ public function store(int $sectionId, array $data): ItemBomItem 'created_by' => $userId, ]); + // 섹션-BOM 관계 생성 + EntityRelationship::link( + EntityRelationship::TYPE_SECTION, + $sectionId, + EntityRelationship::TYPE_BOM, + $bomItem->id, + $tenantId, + $data['group_id'] ?? 1, + 0 + ); + return $bomItem; } @@ -118,7 +130,9 @@ public function update(int $id, array $data): ItemBomItem } /** - * BOM 항목 삭제 + * BOM 항목 삭제 (Soft Delete) + * + * 독립 엔티티 아키텍처: BOM 삭제 시 모든 부모 관계도 해제 */ public function destroy(int $id): void { @@ -133,6 +147,13 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 1. entity_relationships에서 이 BOM의 모든 부모 관계 해제 + // (section→bom 관계에서 이 BOM 제거) + EntityRelationship::where('child_type', EntityRelationship::TYPE_BOM) + ->where('child_id', $id) + ->delete(); + + // 2. BOM Soft Delete $bomItem->update(['deleted_by' => $userId]); $bomItem->delete(); } diff --git a/app/Services/ItemMaster/ItemFieldService.php b/app/Services/ItemMaster/ItemFieldService.php index 30c0f9c..b2052dc 100644 --- a/app/Services/ItemMaster/ItemFieldService.php +++ b/app/Services/ItemMaster/ItemFieldService.php @@ -11,7 +11,7 @@ class ItemFieldService extends Service { /** - * 독립 필드 목록 조회 + * 모든 필드 목록 조회 * * GET /api/v1/item-master/fields */ @@ -37,7 +37,6 @@ public function storeIndependent(array $data): ItemField $field = ItemField::create([ 'tenant_id' => $tenantId, 'group_id' => $data['group_id'] ?? 1, - 'section_id' => null, 'field_name' => $data['field_name'], 'field_type' => $data['field_type'], 'order_no' => 0, @@ -48,6 +47,9 @@ public function storeIndependent(array $data): ItemField 'validation_rules' => $data['validation_rules'] ?? null, 'options' => $data['options'] ?? null, 'properties' => $data['properties'] ?? null, + 'category' => $data['category'] ?? null, + 'description' => $data['description'] ?? null, + 'is_common' => $data['is_common'] ?? false, 'created_by' => $userId, ]); @@ -75,7 +77,6 @@ public function clone(int $id): ItemField $cloned = ItemField::create([ 'tenant_id' => $tenantId, 'group_id' => $original->group_id, - 'section_id' => null, 'field_name' => $original->field_name.' (복사본)', 'field_type' => $original->field_type, 'order_no' => 0, @@ -86,6 +87,9 @@ public function clone(int $id): ItemField 'validation_rules' => $original->validation_rules, 'options' => $original->options, 'properties' => $original->properties, + 'category' => $original->category, + 'description' => $original->description, + 'is_common' => $original->is_common, 'created_by' => $userId, ]); @@ -109,48 +113,36 @@ public function getUsage(int $id): array throw new NotFoundHttpException(__('error.field_not_found')); } - // 1. 기존 FK 기반 연결 (section_id) - $directSection = $field->section; - - // 2. entity_relationships 기반 연결 - 섹션 - $linkedSectionIds = EntityRelationship::where('child_type', EntityRelationship::TYPE_FIELD) - ->where('child_id', $id) - ->where('parent_type', EntityRelationship::TYPE_SECTION) - ->pluck('parent_id') - ->toArray(); - - // 3. entity_relationships 기반 연결 - 페이지 (직접 연결) - $linkedPageIds = EntityRelationship::where('child_type', EntityRelationship::TYPE_FIELD) - ->where('child_id', $id) - ->where('parent_type', EntityRelationship::TYPE_PAGE) - ->pluck('parent_id') - ->toArray(); + // entity_relationships 기반 연결 + $linkedSections = $field->linkedSections()->get(); + $linkedPages = $field->linkedPages()->get(); return [ 'field_id' => $id, - 'direct_section' => $directSection, - 'linked_sections' => $field->linkedSections()->get(), - 'linked_pages' => $field->linkedPages()->get(), - 'total_usage_count' => ($directSection ? 1 : 0) + count($linkedSectionIds) + count($linkedPageIds), + 'linked_sections' => $linkedSections, + 'linked_pages' => $linkedPages, + 'total_usage_count' => $linkedSections->count() + $linkedPages->count(), ]; } /** - * 필드 생성 + * 필드 생성 및 섹션에 연결 */ public function store(int $sectionId, array $data): ItemField { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - // order_no 자동 계산 (해당 섹션의 마지막 필드 + 1) - $maxOrder = ItemField::where('tenant_id', $tenantId) - ->where('section_id', $sectionId) + // order_no 자동 계산 (해당 섹션에 연결된 마지막 필드 + 1) + $maxOrder = EntityRelationship::where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $sectionId) + ->where('child_type', EntityRelationship::TYPE_FIELD) ->max('order_no'); + // 필드 생성 $field = ItemField::create([ 'tenant_id' => $tenantId, - 'section_id' => $sectionId, + 'group_id' => $data['group_id'] ?? 1, 'field_name' => $data['field_name'], 'field_type' => $data['field_type'], 'order_no' => ($maxOrder ?? -1) + 1, @@ -164,6 +156,17 @@ public function store(int $sectionId, array $data): ItemField 'created_by' => $userId, ]); + // 섹션-필드 관계 생성 + EntityRelationship::link( + EntityRelationship::TYPE_SECTION, + $sectionId, + EntityRelationship::TYPE_FIELD, + $field->id, + $tenantId, + $data['group_id'] ?? 1, + ($maxOrder ?? -1) + 1 + ); + return $field; } @@ -193,6 +196,9 @@ public function update(int $id, array $data): ItemField 'validation_rules' => $data['validation_rules'] ?? $field->validation_rules, 'options' => $data['options'] ?? $field->options, 'properties' => $data['properties'] ?? $field->properties, + 'category' => $data['category'] ?? $field->category, + 'description' => $data['description'] ?? $field->description, + 'is_common' => $data['is_common'] ?? $field->is_common, 'updated_by' => $userId, ]); @@ -201,6 +207,8 @@ public function update(int $id, array $data): ItemField /** * 필드 삭제 (Soft Delete) + * + * 독립 엔티티 아키텍처: 필드 삭제 시 모든 부모 관계도 해제 */ public function destroy(int $id): void { @@ -215,28 +223,35 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 1. entity_relationships에서 이 필드의 모든 부모 관계 해제 + // (section→field, page→field 관계에서 이 필드 제거) + EntityRelationship::where('child_type', EntityRelationship::TYPE_FIELD) + ->where('child_id', $id) + ->delete(); + + // 2. 필드 Soft Delete $field->update(['deleted_by' => $userId]); $field->delete(); } /** - * 필드 순서 변경 + * 필드 순서 변경 (entity_relationships 기반) * * @param array $items [['id' => 1, 'order_no' => 0], ['id' => 2, 'order_no' => 1], ...] */ public function reorder(int $sectionId, array $items): void { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - foreach ($items as $item) { - ItemField::where('tenant_id', $tenantId) - ->where('section_id', $sectionId) - ->where('id', $item['id']) - ->update([ - 'order_no' => $item['order_no'], - 'updated_by' => $userId, - ]); + // entity_relationships에서 순서 업데이트 + EntityRelationship::where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $sectionId) + ->where('child_type', EntityRelationship::TYPE_FIELD) + ->where('child_id', $item['id']) + ->update(['order_no' => $item['order_no']]); + + // 필드 자체의 order_no도 동기화 + ItemField::where('id', $item['id']) + ->update(['order_no' => $item['order_no']]); } } } diff --git a/app/Services/ItemMaster/ItemMasterFieldService.php b/app/Services/ItemMaster/ItemMasterFieldService.php deleted file mode 100644 index 490446b..0000000 --- a/app/Services/ItemMaster/ItemMasterFieldService.php +++ /dev/null @@ -1,101 +0,0 @@ -tenantId(); - - return ItemMasterField::where('tenant_id', $tenantId) - ->orderBy('category') - ->orderBy('field_name') - ->get(); - } - - /** - * 마스터 필드 생성 - */ - public function store(array $data): ItemMasterField - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - $field = ItemMasterField::create([ - 'tenant_id' => $tenantId, - 'field_name' => $data['field_name'], - 'field_type' => $data['field_type'], - 'category' => $data['category'] ?? null, - 'description' => $data['description'] ?? null, - 'is_common' => $data['is_common'] ?? false, - 'default_value' => $data['default_value'] ?? null, - 'options' => $data['options'] ?? null, - 'validation_rules' => $data['validation_rules'] ?? null, - 'properties' => $data['properties'] ?? null, - 'created_by' => $userId, - ]); - - return $field; - } - - /** - * 마스터 필드 수정 - */ - public function update(int $id, array $data): ItemMasterField - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - $field = ItemMasterField::where('tenant_id', $tenantId) - ->where('id', $id) - ->first(); - - if (! $field) { - throw new NotFoundHttpException(__('error.not_found')); - } - - $field->update([ - 'field_name' => $data['field_name'] ?? $field->field_name, - 'field_type' => $data['field_type'] ?? $field->field_type, - 'category' => $data['category'] ?? $field->category, - 'description' => $data['description'] ?? $field->description, - 'is_common' => $data['is_common'] ?? $field->is_common, - 'default_value' => $data['default_value'] ?? $field->default_value, - 'options' => $data['options'] ?? $field->options, - 'validation_rules' => $data['validation_rules'] ?? $field->validation_rules, - 'properties' => $data['properties'] ?? $field->properties, - 'updated_by' => $userId, - ]); - - return $field->fresh(); - } - - /** - * 마스터 필드 삭제 - */ - public function destroy(int $id): void - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - $field = ItemMasterField::where('tenant_id', $tenantId) - ->where('id', $id) - ->first(); - - if (! $field) { - throw new NotFoundHttpException(__('error.not_found')); - } - - $field->update(['deleted_by' => $userId]); - $field->delete(); - } -} diff --git a/app/Services/ItemMaster/ItemPageService.php b/app/Services/ItemMaster/ItemPageService.php index 4ab5223..14af2fe 100644 --- a/app/Services/ItemMaster/ItemPageService.php +++ b/app/Services/ItemMaster/ItemPageService.php @@ -2,6 +2,7 @@ namespace App\Services\ItemMaster; +use App\Models\ItemMaster\EntityRelationship; use App\Models\ItemMaster\ItemPage; use App\Services\Service; use Illuminate\Support\Collection; @@ -87,6 +88,8 @@ public function update(int $id, array $data): ItemPage /** * 페이지 삭제 (Soft Delete) + * + * 독립 엔티티 아키텍처: 페이지만 삭제하고 연결된 섹션/필드는 unlink만 수행 */ public function destroy(int $id): void { @@ -101,23 +104,14 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 1. entity_relationships에서 이 페이지의 모든 자식 관계 해제 + // (page→section, page→field 관계 삭제) + EntityRelationship::where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $id) + ->delete(); + + // 2. 페이지만 Soft Delete (섹션/필드는 독립 엔티티로 유지) $page->update(['deleted_by' => $userId]); $page->delete(); - - // Cascade: 하위 섹션/필드도 Soft Delete - foreach ($page->sections as $section) { - $section->update(['deleted_by' => $userId]); - $section->delete(); - - foreach ($section->fields as $field) { - $field->update(['deleted_by' => $userId]); - $field->delete(); - } - - foreach ($section->bomItems as $bomItem) { - $bomItem->update(['deleted_by' => $userId]); - $bomItem->delete(); - } - } } } diff --git a/app/Services/ItemMaster/ItemSectionService.php b/app/Services/ItemMaster/ItemSectionService.php index d8c2277..9dd71a7 100644 --- a/app/Services/ItemMaster/ItemSectionService.php +++ b/app/Services/ItemMaster/ItemSectionService.php @@ -13,7 +13,7 @@ class ItemSectionService extends Service { /** - * 독립 섹션 목록 조회 + * 모든 섹션 목록 조회 * * GET /api/v1/item-master/sections */ @@ -21,8 +21,7 @@ public function index(?bool $isTemplate = null): Collection { $tenantId = $this->tenantId(); - $query = ItemSection::where('tenant_id', $tenantId) - ->with(['fields', 'bomItems']); + $query = ItemSection::where('tenant_id', $tenantId); if ($isTemplate === true) { $query->templates(); @@ -46,7 +45,6 @@ public function storeIndependent(array $data): ItemSection $section = ItemSection::create([ 'tenant_id' => $tenantId, 'group_id' => $data['group_id'] ?? 1, - 'page_id' => null, 'title' => $data['title'], 'type' => $data['type'], 'order_no' => 0, @@ -82,7 +80,6 @@ public function clone(int $id): ItemSection $cloned = ItemSection::create([ 'tenant_id' => $tenantId, 'group_id' => $original->group_id, - 'page_id' => null, 'title' => $original->title.' (복사본)', 'type' => $original->type, 'order_no' => 0, @@ -150,46 +147,51 @@ public function getUsage(int $id): array throw new NotFoundHttpException(__('error.section_not_found')); } - // 1. 기존 FK 기반 연결 (page_id) - $directPage = $section->page; - - // 2. entity_relationships 기반 연결 - $linkedPageIds = EntityRelationship::where('child_type', EntityRelationship::TYPE_SECTION) - ->where('child_id', $id) - ->where('parent_type', EntityRelationship::TYPE_PAGE) - ->pluck('parent_id') - ->toArray(); + // entity_relationships 기반 연결 + $linkedPages = $section->linkedPages()->get(); return [ 'section_id' => $id, - 'direct_page' => $directPage, - 'linked_pages' => $section->linkedPages()->get(), - 'total_usage_count' => ($directPage ? 1 : 0) + count($linkedPageIds), + 'linked_pages' => $linkedPages, + 'total_usage_count' => $linkedPages->count(), ]; } /** - * 섹션 생성 + * 섹션 생성 및 페이지에 연결 */ public function store(int $pageId, array $data): ItemSection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - // order_no 자동 계산 (해당 페이지의 마지막 섹션 + 1) - $maxOrder = ItemSection::where('tenant_id', $tenantId) - ->where('page_id', $pageId) + // order_no 자동 계산 (해당 페이지에 연결된 마지막 섹션 + 1) + $maxOrder = EntityRelationship::where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $pageId) + ->where('child_type', EntityRelationship::TYPE_SECTION) ->max('order_no'); + // 섹션 생성 $section = ItemSection::create([ 'tenant_id' => $tenantId, - 'page_id' => $pageId, + 'group_id' => $data['group_id'] ?? 1, 'title' => $data['title'], 'type' => $data['type'], 'order_no' => ($maxOrder ?? -1) + 1, 'created_by' => $userId, ]); + // 페이지-섹션 관계 생성 + EntityRelationship::link( + EntityRelationship::TYPE_PAGE, + $pageId, + EntityRelationship::TYPE_SECTION, + $section->id, + $tenantId, + $data['group_id'] ?? 1, + ($maxOrder ?? -1) + 1 + ); + $section->load(['fields', 'bomItems']); return $section; @@ -223,6 +225,8 @@ public function update(int $id, array $data): ItemSection /** * 섹션 삭제 (Soft Delete) + * + * 독립 엔티티 아키텍처: 섹션만 삭제하고 연결된 필드/BOM은 unlink만 수행 */ public function destroy(int $id): void { @@ -237,39 +241,41 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 1. entity_relationships에서 이 섹션의 모든 부모 관계 해제 + // (page→section 관계에서 이 섹션 제거) + EntityRelationship::where('child_type', EntityRelationship::TYPE_SECTION) + ->where('child_id', $id) + ->delete(); + + // 2. entity_relationships에서 이 섹션의 모든 자식 관계 해제 + // (section→field, section→bom 관계 삭제) + EntityRelationship::where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $id) + ->delete(); + + // 3. 섹션만 Soft Delete (필드/BOM은 독립 엔티티로 유지) $section->update(['deleted_by' => $userId]); $section->delete(); - - // Cascade: 하위 필드도 Soft Delete - foreach ($section->fields as $field) { - $field->update(['deleted_by' => $userId]); - $field->delete(); - } - - foreach ($section->bomItems as $bomItem) { - $bomItem->update(['deleted_by' => $userId]); - $bomItem->delete(); - } } /** - * 섹션 순서 변경 + * 섹션 순서 변경 (entity_relationships 기반) * * @param array $items [['id' => 1, 'order_no' => 0], ['id' => 2, 'order_no' => 1], ...] */ public function reorder(int $pageId, array $items): void { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - foreach ($items as $item) { - ItemSection::where('tenant_id', $tenantId) - ->where('page_id', $pageId) - ->where('id', $item['id']) - ->update([ - 'order_no' => $item['order_no'], - 'updated_by' => $userId, - ]); + // entity_relationships에서 순서 업데이트 + EntityRelationship::where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $pageId) + ->where('child_type', EntityRelationship::TYPE_SECTION) + ->where('child_id', $item['id']) + ->update(['order_no' => $item['order_no']]); + + // 섹션 자체의 order_no도 동기화 + ItemSection::where('id', $item['id']) + ->update(['order_no' => $item['order_no']]); } } } diff --git a/app/Swagger/v1/ItemMasterApi.php b/app/Swagger/v1/ItemMasterApi.php index 7567196..172fb5d 100644 --- a/app/Swagger/v1/ItemMasterApi.php +++ b/app/Swagger/v1/ItemMasterApi.php @@ -32,11 +32,11 @@ * @OA\Schema( * schema="ItemSection", * type="object", + * description="독립 엔티티 - 관계는 entity_relationships로 관리", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), - * @OA\Property(property="group_id", type="integer", nullable=true, example=1), - * @OA\Property(property="page_id", type="integer", nullable=true, example=1), + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="title", type="string", example="제품 상세"), * @OA\Property(property="type", type="string", enum={"fields","bom"}, example="fields"), * @OA\Property(property="order_no", type="integer", example=0), @@ -63,10 +63,11 @@ * @OA\Schema( * schema="ItemField", * type="object", + * description="독립 엔티티 - 관계는 entity_relationships로 관리", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), - * @OA\Property(property="section_id", type="integer", example=1), + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="field_name", type="string", example="제품명"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="order_no", type="integer", example=0), @@ -77,6 +78,9 @@ * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), * @OA\Property(property="options", type="object", nullable=true, example=null), * @OA\Property(property="properties", type="object", nullable=true, example=null), + * @OA\Property(property="category", type="string", nullable=true, example="basic", description="필드 카테고리"), + * @OA\Property(property="description", type="string", nullable=true, example="필드 설명"), + * @OA\Property(property="is_common", type="boolean", example=false, description="공통 필드 여부"), * @OA\Property(property="created_at", type="string", example="2025-11-20 10:00:00"), * @OA\Property(property="updated_at", type="string", example="2025-11-20 10:00:00") * ) @@ -84,10 +88,11 @@ * @OA\Schema( * schema="ItemBomItem", * type="object", + * description="독립 엔티티 - 관계는 entity_relationships로 관리", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), - * @OA\Property(property="section_id", type="integer", example=1), + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="item_code", type="string", nullable=true, example="ITEM001"), * @OA\Property(property="item_name", type="string", example="부품 A"), * @OA\Property(property="quantity", type="number", format="float", example=1.5), @@ -115,25 +120,6 @@ * ) * * @OA\Schema( - * schema="ItemMasterField", - * type="object", - * - * @OA\Property(property="id", type="integer", example=1), - * @OA\Property(property="tenant_id", type="integer", example=1), - * @OA\Property(property="field_name", type="string", example="제품명"), - * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), - * @OA\Property(property="category", type="string", nullable=true, example="basic"), - * @OA\Property(property="description", type="string", nullable=true, example="설명"), - * @OA\Property(property="is_common", type="boolean", example=true), - * @OA\Property(property="default_value", type="string", nullable=true, example=null), - * @OA\Property(property="options", type="object", nullable=true, example=null), - * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), - * @OA\Property(property="properties", type="object", nullable=true, example=null), - * @OA\Property(property="created_at", type="string", example="2025-11-20 10:00:00"), - * @OA\Property(property="updated_at", type="string", example="2025-11-20 10:00:00") - * ) - * - * @OA\Schema( * schema="CustomTab", * type="object", * @@ -187,6 +173,7 @@ * type="object", * required={"title","type"}, * + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="title", type="string", maxLength=255, example="제품 상세"), * @OA\Property(property="type", type="string", enum={"fields","bom"}, example="fields") * ) @@ -196,7 +183,7 @@ * type="object", * required={"title","type"}, * - * @OA\Property(property="group_id", type="integer", nullable=true, example=1), + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="title", type="string", maxLength=255, example="독립 섹션"), * @OA\Property(property="type", type="string", enum={"fields","bom"}, example="fields"), * @OA\Property(property="is_template", type="boolean", example=false), @@ -209,7 +196,7 @@ * type="object", * required={"field_name","field_type"}, * - * @OA\Property(property="group_id", type="integer", nullable=true, example=1), + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="is_required", type="boolean", example=false), @@ -226,7 +213,7 @@ * type="object", * required={"item_name"}, * - * @OA\Property(property="group_id", type="integer", nullable=true, example=1), + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="item_code", type="string", nullable=true, maxLength=100, example="ITEM001"), * @OA\Property(property="item_name", type="string", maxLength=255, example="부품 A"), * @OA\Property(property="quantity", type="number", format="float", example=1), @@ -240,9 +227,9 @@ * @OA\Schema( * schema="SectionUsageResponse", * type="object", + * description="섹션 사용처 응답 (entity_relationships 기반)", * * @OA\Property(property="section_id", type="integer", example=1), - * @OA\Property(property="direct_page", type="object", nullable=true), * @OA\Property(property="linked_pages", type="array", @OA\Items(type="object")), * @OA\Property(property="total_usage_count", type="integer", example=2) * ) @@ -250,9 +237,9 @@ * @OA\Schema( * schema="FieldUsageResponse", * type="object", + * description="필드 사용처 응답 (entity_relationships 기반)", * * @OA\Property(property="field_id", type="integer", example=1), - * @OA\Property(property="direct_section", type="object", nullable=true), * @OA\Property(property="linked_sections", type="array", @OA\Items(type="object")), * @OA\Property(property="linked_pages", type="array", @OA\Items(type="object")), * @OA\Property(property="total_usage_count", type="integer", example=3) @@ -270,6 +257,7 @@ * type="object", * required={"field_name","field_type"}, * + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), * @OA\Property(property="is_required", type="boolean", example=true), @@ -301,6 +289,7 @@ * type="object", * required={"item_name"}, * + * @OA\Property(property="group_id", type="integer", nullable=true, example=1, description="계층번호"), * @OA\Property(property="item_code", type="string", nullable=true, maxLength=100, example="ITEM001"), * @OA\Property(property="item_name", type="string", maxLength=255, example="부품 A"), * @OA\Property(property="quantity", type="number", format="float", example=1.5), @@ -348,37 +337,6 @@ * ) * * @OA\Schema( - * schema="ItemMasterFieldStoreRequest", - * type="object", - * required={"field_name","field_type"}, - * - * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), - * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), - * @OA\Property(property="category", type="string", nullable=true, maxLength=100, example="basic"), - * @OA\Property(property="description", type="string", nullable=true, example="설명"), - * @OA\Property(property="is_common", type="boolean", example=true), - * @OA\Property(property="default_value", type="string", nullable=true, example=null), - * @OA\Property(property="options", type="object", nullable=true, example=null), - * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), - * @OA\Property(property="properties", type="object", nullable=true, example=null) - * ) - * - * @OA\Schema( - * schema="ItemMasterFieldUpdateRequest", - * type="object", - * - * @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"), - * @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"), - * @OA\Property(property="category", type="string", nullable=true, maxLength=100, example="basic"), - * @OA\Property(property="description", type="string", nullable=true, example="설명"), - * @OA\Property(property="is_common", type="boolean", example=true), - * @OA\Property(property="default_value", type="string", nullable=true, example=null), - * @OA\Property(property="options", type="object", nullable=true, example=null), - * @OA\Property(property="validation_rules", type="object", nullable=true, example=null), - * @OA\Property(property="properties", type="object", nullable=true, example=null) - * ) - * - * @OA\Schema( * schema="CustomTabStoreRequest", * type="object", * required={"label"}, @@ -451,13 +409,6 @@ * ), * * @OA\Property( - * property="masterFields", - * type="array", - * - * @OA\Items(ref="#/components/schemas/ItemMasterField") - * ), - * - * @OA\Property( * property="customTabs", * type="array", * @@ -570,7 +521,8 @@ public function updatePages() {} * @OA\Delete( * path="/api/v1/item-master/pages/{id}", * tags={"ItemMaster"}, - * summary="페이지 삭제 (Cascade)", + * summary="페이지 삭제", + * description="페이지를 삭제합니다. 연결된 섹션/필드는 삭제되지 않고 관계만 해제됩니다.", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), @@ -658,7 +610,7 @@ public function cloneSection() {} * path="/api/v1/item-master/sections/{id}/usage", * tags={"ItemMaster"}, * summary="섹션 사용처 조회", - * description="섹션이 어떤 페이지에 연결되어 있는지 조회합니다. FK 기반 연결과 entity_relationships 기반 연결 모두 조회됩니다.", + * description="섹션이 어떤 페이지에 연결되어 있는지 조회합니다 (entity_relationships 기반).", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\Parameter(name="id", in="path", required=true, description="섹션 ID", @OA\Schema(type="integer")), @@ -871,7 +823,8 @@ public function updateSections() {} * @OA\Delete( * path="/api/v1/item-master/sections/{id}", * tags={"ItemMaster"}, - * summary="섹션 삭제 (Cascade)", + * summary="섹션 삭제", + * description="섹션을 삭제합니다. 연결된 필드/BOM은 삭제되지 않고 관계만 해제됩니다.", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), @@ -1130,90 +1083,6 @@ public function updateSectionTemplates() {} */ public function destroySectionTemplates() {} - /** - * @OA\Get( - * path="/api/v1/item-master/master-fields", - * tags={"ItemMaster"}, - * summary="마스터 필드 목록", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Response(response=200, description="조회 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemMasterField"))) - * }) - * ), - * - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function indexMasterFields() {} - - /** - * @OA\Post( - * path="/api/v1/item-master/master-fields", - * tags={"ItemMaster"}, - * summary="마스터 필드 생성", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemMasterFieldStoreRequest")), - * - * @OA\Response(response=200, description="생성 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemMasterField")) - * }) - * ), - * - * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function storeMasterFields() {} - - /** - * @OA\Put( - * path="/api/v1/item-master/master-fields/{id}", - * tags={"ItemMaster"}, - * summary="마스터 필드 수정", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ItemMasterFieldUpdateRequest")), - * - * @OA\Response(response=200, description="수정 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemMasterField")) - * }) - * ), - * - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function updateMasterFields() {} - - /** - * @OA\Delete( - * path="/api/v1/item-master/master-fields/{id}", - * tags={"ItemMaster"}, - * summary="마스터 필드 삭제", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * - * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), - * @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) - * ) - */ - public function destroyMasterFields() {} - /** * @OA\Get( * path="/api/v1/item-master/custom-tabs", diff --git a/database/migrations/2025_11_27_090523_drop_item_master_fields_table.php b/database/migrations/2025_11_27_090523_drop_item_master_fields_table.php new file mode 100644 index 0000000..19e2204 --- /dev/null +++ b/database/migrations/2025_11_27_090523_drop_item_master_fields_table.php @@ -0,0 +1,31 @@ +whereNotNull('page_id') + ->whereNull('deleted_at') + ->get(); + + foreach ($sections as $section) { + DB::table('entity_relationships')->insertOrIgnore([ + 'tenant_id' => $section->tenant_id, + 'group_id' => $section->group_id ?? 1, + 'parent_type' => 'page', + 'parent_id' => $section->page_id, + 'child_type' => 'section', + 'child_id' => $section->id, + 'order_no' => $section->order_no ?? 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // 2. item_fields.section_id → entity_relationships 이관 + $fields = DB::table('item_fields') + ->whereNotNull('section_id') + ->whereNull('deleted_at') + ->get(); + + foreach ($fields as $field) { + DB::table('entity_relationships')->insertOrIgnore([ + 'tenant_id' => $field->tenant_id, + 'group_id' => $field->group_id ?? 1, + 'parent_type' => 'section', + 'parent_id' => $field->section_id, + 'child_type' => 'field', + 'child_id' => $field->id, + 'order_no' => $field->order_no ?? 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // 3. item_bom_items.section_id → entity_relationships 이관 + $bomItems = DB::table('item_bom_items') + ->whereNotNull('section_id') + ->whereNull('deleted_at') + ->get(); + + foreach ($bomItems as $bom) { + DB::table('entity_relationships')->insertOrIgnore([ + 'tenant_id' => $bom->tenant_id, + 'group_id' => $bom->group_id ?? 1, + 'parent_type' => 'section', + 'parent_id' => $bom->section_id, + 'child_type' => 'bom', + 'child_id' => $bom->id, + 'order_no' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // 4. FK 컬럼 삭제 + Schema::table('item_sections', function (Blueprint $table) { + $table->dropColumn('page_id'); + }); + + Schema::table('item_fields', function (Blueprint $table) { + $table->dropColumn('section_id'); + }); + + Schema::table('item_bom_items', function (Blueprint $table) { + $table->dropColumn('section_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 1. FK 컬럼 복원 + Schema::table('item_sections', function (Blueprint $table) { + $table->unsignedBigInteger('page_id')->nullable()->after('group_id')->comment('페이지 ID'); + }); + + Schema::table('item_fields', function (Blueprint $table) { + $table->unsignedBigInteger('section_id')->nullable()->after('group_id')->comment('섹션 ID'); + }); + + Schema::table('item_bom_items', function (Blueprint $table) { + $table->unsignedBigInteger('section_id')->nullable()->after('group_id')->comment('섹션 ID'); + }); + + // 2. entity_relationships → FK 복원 (page-section) + $pageRelations = DB::table('entity_relationships') + ->where('parent_type', 'page') + ->where('child_type', 'section') + ->get(); + + foreach ($pageRelations as $rel) { + DB::table('item_sections') + ->where('id', $rel->child_id) + ->update(['page_id' => $rel->parent_id]); + } + + // 3. entity_relationships → FK 복원 (section-field) + $fieldRelations = DB::table('entity_relationships') + ->where('parent_type', 'section') + ->where('child_type', 'field') + ->get(); + + foreach ($fieldRelations as $rel) { + DB::table('item_fields') + ->where('id', $rel->child_id) + ->update(['section_id' => $rel->parent_id]); + } + + // 4. entity_relationships → FK 복원 (section-bom) + $bomRelations = DB::table('entity_relationships') + ->where('parent_type', 'section') + ->where('child_type', 'bom') + ->get(); + + foreach ($bomRelations as $rel) { + DB::table('item_bom_items') + ->where('id', $rel->child_id) + ->update(['section_id' => $rel->parent_id]); + } + } +};