From efa2a84d2c2818baf93d4e25a4cf55df862a73fb Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 27 Nov 2025 15:51:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(item-master):=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20FK=20?= =?UTF-8?q?=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 잠금 기능 (Lock Feature) - entity_relationships 테이블에 is_locked, locked_by, locked_at 컬럼 추가 - EntityRelationship 모델에 잠금 관련 헬퍼 메서드 추가 - LockCheckTrait 생성 (destroy 시 잠금 체크 공통 로직) - 각 Service의 destroy() 메서드에 잠금 체크 적용 - API 응답에 is_locked 필드 포함 - 한국어 에러 메시지 추가 ## FK 레거시 코드 정리 - ItemMasterSeeder: entity_relationships 기반으로 전환 - ItemPage 모델: FK 기반 sections() 관계 제거 - ItemSectionService: clone() 메서드 FK 제거 - SectionTemplateService: page_id 컬럼 참조 제거 - EntityRelationship::link() 파라미터 순서 통일 ## 기타 - Swagger 스키마에 is_locked 속성 추가 - 프론트엔드 가이드 문서 추가 --- LOGICAL_RELATIONSHIPS.md | 15 +- .../Api/V1/ItemMaster/CustomTabController.php | 2 +- .../ItemMaster/SectionTemplateController.php | 2 +- .../V1/ItemMaster/UnitOptionController.php | 2 +- app/Models/ItemMaster/EntityRelationship.php | 63 +++++++- app/Models/ItemMaster/ItemPage.php | 8 - app/Models/ItemMaster/UnitOption.php | 2 +- app/Services/Authz/AccessService.php | 37 +++-- .../ItemMaster/ItemBomItemService.php | 8 + app/Services/ItemMaster/ItemFieldService.php | 8 + app/Services/ItemMaster/ItemMasterService.php | 49 +++++- app/Services/ItemMaster/ItemPageService.php | 16 ++ .../ItemMaster/ItemSectionService.php | 57 +++++-- .../ItemMaster/SectionTemplateService.php | 3 +- app/Swagger/v1/ItemMasterApi.php | 5 +- app/Swagger/v1/ItemsFileApi.php | 3 +- app/Traits/LockCheckTrait.php | 45 ++++++ ...11_20_100000_create_unit_options_table.php | 2 +- ..._100001_create_section_templates_table.php | 2 +- ...100002_create_item_master_fields_table.php | 2 +- ...5_11_20_100003_create_item_pages_table.php | 2 +- ...1_20_100004_create_item_sections_table.php | 2 +- ..._11_20_100005_create_item_fields_table.php | 2 +- ..._20_100006_create_item_bom_items_table.php | 2 +- ..._11_20_100007_create_custom_tabs_table.php | 2 +- ..._11_20_100008_create_tab_columns_table.php | 2 +- ..._24_192518_add_audit_columns_to_tables.php | 10 +- ...8_add_deleted_by_to_soft_delete_tables.php | 8 +- ...age_id_nullable_in_item_sections_table.php | 2 +- ...s_locked_to_entity_relationships_table.php | 37 +++++ database/seeders/ItemMasterSeeder.php | 142 ++++++++++++------ ...API-2025-11-26] item-master-api-changes.md | 120 +++++++++++++++ lang/ko/error.php | 7 + routes/api.php | 7 - tests/Feature/Category/CategoryApiTest.php | 3 + 35 files changed, 537 insertions(+), 142 deletions(-) create mode 100644 app/Traits/LockCheckTrait.php create mode 100644 database/migrations/2025_11_27_151643_add_is_locked_to_entity_relationships_table.php create mode 100644 docs/front/[API-2025-11-26] item-master-api-changes.md diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index acc694f..d2458cb 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-11-26 14:00:30 +> **자동 생성**: 2025-11-27 15:34:54 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -134,16 +134,6 @@ ### entity_relationships - **parent()**: morphTo → `(Polymorphic)` - **child()**: morphTo → `(Polymorphic)` -### item_bom_items -**모델**: `App\Models\ItemMaster\ItemBomItem` - -- **section()**: belongsTo → `item_sections` - -### item_fields -**모델**: `App\Models\ItemMaster\ItemField` - -- **section()**: belongsTo → `item_sections` - ### item_pages **모델**: `App\Models\ItemMaster\ItemPage` @@ -155,9 +145,6 @@ ### item_pages ### item_sections **모델**: `App\Models\ItemMaster\ItemSection` -- **page()**: belongsTo → `item_pages` -- **fields()**: hasMany → `item_fields` -- **bomItems()**: hasMany → `item_bom_items` - **fieldRelationships()**: hasMany → `entity_relationships` - **bomRelationships()**: hasMany → `entity_relationships` - **allChildRelationships()**: hasMany → `entity_relationships` diff --git a/app/Http/Controllers/Api/V1/ItemMaster/CustomTabController.php b/app/Http/Controllers/Api/V1/ItemMaster/CustomTabController.php index 54258e3..c147b36 100644 --- a/app/Http/Controllers/Api/V1/ItemMaster/CustomTabController.php +++ b/app/Http/Controllers/Api/V1/ItemMaster/CustomTabController.php @@ -2,11 +2,11 @@ namespace App\Http\Controllers\Api\V1\ItemMaster; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\ItemMaster\CustomTabStoreRequest; use App\Http\Requests\ItemMaster\CustomTabUpdateRequest; use App\Http\Requests\ItemMaster\ReorderRequest; -use App\Helpers\ApiResponse; use App\Services\ItemMaster\CustomTabService; class CustomTabController extends Controller diff --git a/app/Http/Controllers/Api/V1/ItemMaster/SectionTemplateController.php b/app/Http/Controllers/Api/V1/ItemMaster/SectionTemplateController.php index 24cb199..3421b97 100644 --- a/app/Http/Controllers/Api/V1/ItemMaster/SectionTemplateController.php +++ b/app/Http/Controllers/Api/V1/ItemMaster/SectionTemplateController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1\ItemMaster; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\ItemMaster\SectionTemplateStoreRequest; use App\Http\Requests\ItemMaster\SectionTemplateUpdateRequest; -use App\Helpers\ApiResponse; use App\Services\ItemMaster\SectionTemplateService; class SectionTemplateController extends Controller diff --git a/app/Http/Controllers/Api/V1/ItemMaster/UnitOptionController.php b/app/Http/Controllers/Api/V1/ItemMaster/UnitOptionController.php index 90f8a31..1acd17f 100644 --- a/app/Http/Controllers/Api/V1/ItemMaster/UnitOptionController.php +++ b/app/Http/Controllers/Api/V1/ItemMaster/UnitOptionController.php @@ -2,9 +2,9 @@ namespace App\Http\Controllers\Api\V1\ItemMaster; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\ItemMaster\UnitOptionStoreRequest; -use App\Helpers\ApiResponse; use App\Services\ItemMaster\UnitOptionService; class UnitOptionController extends Controller diff --git a/app/Models/ItemMaster/EntityRelationship.php b/app/Models/ItemMaster/EntityRelationship.php index 0147aaf..25c4371 100644 --- a/app/Models/ItemMaster/EntityRelationship.php +++ b/app/Models/ItemMaster/EntityRelationship.php @@ -28,6 +28,9 @@ class EntityRelationship extends Model 'child_id', 'order_no', 'metadata', + 'is_locked', + 'locked_by', + 'locked_at', 'created_by', 'updated_by', ]; @@ -38,6 +41,8 @@ class EntityRelationship extends Model 'child_id' => 'integer', 'order_no' => 'integer', 'metadata' => 'array', + 'is_locked' => 'boolean', + 'locked_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; @@ -146,6 +151,8 @@ public static function link( /** * 관계 해제 + * + * @throws \App\Exceptions\BusinessException 잠금된 연결인 경우 */ public static function unlink( int $tenantId, @@ -155,18 +162,31 @@ public static function unlink( int $childId, int $groupId = self::GROUP_ITEM_MASTER ): bool { - return self::where([ + $relationship = self::where([ 'tenant_id' => $tenantId, 'group_id' => $groupId, 'parent_type' => $parentType, 'parent_id' => $parentId, 'child_type' => $childType, 'child_id' => $childId, - ])->delete() > 0; + ])->first(); + + if (! $relationship) { + return false; + } + + // 잠금 체크 + if ($relationship->is_locked) { + throw new \App\Exceptions\BusinessException(__('error.relationship_locked')); + } + + return $relationship->delete(); } /** - * 특정 부모의 모든 자식 관계 해제 + * 특정 부모의 모든 자식 관계 해제 (잠금되지 않은 것만) + * + * @throws \App\Exceptions\BusinessException 잠금된 연결이 있는 경우 */ public static function unlinkAllChildren( int $tenantId, @@ -186,6 +206,43 @@ public static function unlinkAllChildren( $query->where('child_type', $childType); } + // 잠금된 연결이 있는지 확인 + if ($query->clone()->where('is_locked', true)->exists()) { + throw new \App\Exceptions\BusinessException(__('error.has_locked_relationships')); + } + return $query->delete(); } + + /** + * 특정 자식 엔티티가 잠금된 연결을 가지고 있는지 확인 + */ + public static function hasLockedParentRelationship(string $childType, int $childId): bool + { + return self::where('child_type', $childType) + ->where('child_id', $childId) + ->where('is_locked', true) + ->exists(); + } + + /** + * 특정 자식 엔티티의 잠금 상태 조회 (computed) + * 연결이 잠겨있으면 엔티티도 잠금 상태로 간주 + */ + public static function getChildLockStatus(string $childType, int $childId): array + { + $lockedRelationships = self::where('child_type', $childType) + ->where('child_id', $childId) + ->where('is_locked', true) + ->get(['id', 'parent_type', 'parent_id']); + + return [ + 'is_locked' => $lockedRelationships->isNotEmpty(), + 'locked_by_relationships' => $lockedRelationships->map(fn ($rel) => [ + 'relationship_id' => $rel->id, + 'parent_type' => $rel->parent_type, + 'parent_id' => $rel->parent_id, + ])->toArray(), + ]; + } } diff --git a/app/Models/ItemMaster/ItemPage.php b/app/Models/ItemMaster/ItemPage.php index d903a68..08e4e99 100644 --- a/app/Models/ItemMaster/ItemPage.php +++ b/app/Models/ItemMaster/ItemPage.php @@ -36,14 +36,6 @@ class ItemPage extends Model 'deleted_at', ]; - /** - * 페이지의 섹션 목록 (기존 FK 기반 - 하위 호환성) - */ - public function sections() - { - return $this->hasMany(ItemSection::class, 'page_id')->orderBy('order_no'); - } - /** * 페이지와 연결된 섹션 관계 목록 (링크 테이블 기반) */ diff --git a/app/Models/ItemMaster/UnitOption.php b/app/Models/ItemMaster/UnitOption.php index a42ef58..bd73b73 100644 --- a/app/Models/ItemMaster/UnitOption.php +++ b/app/Models/ItemMaster/UnitOption.php @@ -30,4 +30,4 @@ class UnitOption extends Model 'deleted_by', 'deleted_at', ]; -} \ No newline at end of file +} diff --git a/app/Services/Authz/AccessService.php b/app/Services/Authz/AccessService.php index 9f813fb..6155e87 100644 --- a/app/Services/Authz/AccessService.php +++ b/app/Services/Authz/AccessService.php @@ -51,21 +51,22 @@ protected static function hasUserOverride( $now = now(); $guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★ - $q = DB::table('user_permission_overrides as uo') - ->join('permissions as p', 'p.id', '=', 'uo.permission_id') - ->whereNull('uo.deleted_at') - ->where('uo.user_id', $userId) - ->where('uo.tenant_id', $tenantId) + $q = DB::table('permission_overrides as po') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') + ->whereNull('po.deleted_at') + ->where('po.model_type', 'App\\Models\\Members\\User') + ->where('po.model_id', $userId) + ->where('po.tenant_id', $tenantId) ->where('p.name', $permissionName) ->where('p.tenant_id', $tenantId) // ★ 테넌트 일치 ->where('p.guard_name', $guard) // ★ 가드 일치 ->where(function ($w) use ($now) { - $w->whereNull('uo.effective_from')->orWhere('uo.effective_from', '<=', $now); + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); }) ->where(function ($w) use ($now) { - $w->whereNull('uo.effective_to')->orWhere('uo.effective_to', '>=', $now); + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); }) - ->where('uo.is_allowed', $allow ? 1 : 0); + ->where('po.effect', $allow ? 1 : 0); return $q->exists(); } @@ -76,19 +77,27 @@ protected static function departmentAllows( int $tenantId, ?string $guardName = null ): bool { + $now = now(); $guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★ $q = DB::table('department_user as du') - ->join('department_permissions as dp', function ($j) { - $j->on('dp.department_id', '=', 'du.department_id') - ->whereNull('dp.deleted_at') - ->where('dp.is_allowed', 1); + ->join('permission_overrides as po', function ($j) use ($now) { + $j->on('po.model_id', '=', 'du.department_id') + ->where('po.model_type', 'App\\Models\\Tenants\\Department') + ->whereNull('po.deleted_at') + ->where('po.effect', 1) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now); + }) + ->where(function ($w) use ($now) { + $w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now); + }); }) - ->join('permissions as p', 'p.id', '=', 'dp.permission_id') + ->join('permissions as p', 'p.id', '=', 'po.permission_id') ->whereNull('du.deleted_at') ->where('du.user_id', $userId) ->where('du.tenant_id', $tenantId) - ->where('dp.tenant_id', $tenantId) + ->where('po.tenant_id', $tenantId) ->where('p.tenant_id', $tenantId) // ★ 테넌트 일치 ->where('p.guard_name', $guard) // ★ 가드 일치 ->where('p.name', $permissionName); diff --git a/app/Services/ItemMaster/ItemBomItemService.php b/app/Services/ItemMaster/ItemBomItemService.php index 2a67d90..18e4602 100644 --- a/app/Services/ItemMaster/ItemBomItemService.php +++ b/app/Services/ItemMaster/ItemBomItemService.php @@ -6,11 +6,14 @@ use App\Models\ItemMaster\ItemBomItem; use App\Models\ItemMaster\ItemSection; use App\Services\Service; +use App\Traits\LockCheckTrait; use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemBomItemService extends Service { + use LockCheckTrait; + /** * 독립 BOM 목록 조회 * @@ -147,10 +150,15 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 잠금 체크: 이 BOM이 잠금된 연결로 보호되고 있는지 확인 + $this->checkCanDelete(EntityRelationship::TYPE_BOM, $id); + // 1. entity_relationships에서 이 BOM의 모든 부모 관계 해제 // (section→bom 관계에서 이 BOM 제거) + // 주의: 잠금된 연결이 있으면 위에서 예외 발생 EntityRelationship::where('child_type', EntityRelationship::TYPE_BOM) ->where('child_id', $id) + ->where('is_locked', false) ->delete(); // 2. BOM Soft Delete diff --git a/app/Services/ItemMaster/ItemFieldService.php b/app/Services/ItemMaster/ItemFieldService.php index b2052dc..318c4ec 100644 --- a/app/Services/ItemMaster/ItemFieldService.php +++ b/app/Services/ItemMaster/ItemFieldService.php @@ -5,11 +5,14 @@ use App\Models\ItemMaster\EntityRelationship; use App\Models\ItemMaster\ItemField; use App\Services\Service; +use App\Traits\LockCheckTrait; use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemFieldService extends Service { + use LockCheckTrait; + /** * 모든 필드 목록 조회 * @@ -223,10 +226,15 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 잠금 체크: 이 필드가 잠금된 연결로 보호되고 있는지 확인 + $this->checkCanDelete(EntityRelationship::TYPE_FIELD, $id); + // 1. entity_relationships에서 이 필드의 모든 부모 관계 해제 // (section→field, page→field 관계에서 이 필드 제거) + // 주의: 잠금된 연결이 있으면 위에서 예외 발생 EntityRelationship::where('child_type', EntityRelationship::TYPE_FIELD) ->where('child_id', $id) + ->where('is_locked', false) ->delete(); // 2. 필드 Soft Delete diff --git a/app/Services/ItemMaster/ItemMasterService.php b/app/Services/ItemMaster/ItemMasterService.php index a9d78cd..67f9b5b 100644 --- a/app/Services/ItemMaster/ItemMasterService.php +++ b/app/Services/ItemMaster/ItemMasterService.php @@ -4,8 +4,8 @@ use App\Models\ItemMaster\CustomTab; use App\Models\ItemMaster\EntityRelationship; +use App\Models\ItemMaster\ItemBomItem; use App\Models\ItemMaster\ItemField; -use App\Models\ItemMaster\ItemMasterField; use App\Models\ItemMaster\ItemPage; use App\Models\ItemMaster\ItemSection; use App\Models\ItemMaster\UnitOption; @@ -18,7 +18,6 @@ class ItemMasterService extends Service * * - pages (linkedSections 기반 중첩) * - sections (모든 독립 섹션) - * - masterFields * - customTabs (columnSetting 포함) * - unitOptions */ @@ -57,22 +56,18 @@ public function init(): array ->orderBy('created_at', 'desc') ->get(); - // 4. 마스터 필드 - $masterFields = ItemMasterField::where('tenant_id', $tenantId)->get(); - - // 5. 커스텀 탭 (컬럼 설정 포함) + // 4. 커스텀 탭 (컬럼 설정 포함) $customTabs = CustomTab::with('columnSetting') ->where('tenant_id', $tenantId) ->orderBy('order_no') ->get(); - // 6. 단위 옵션 + // 5. 단위 옵션 $unitOptions = UnitOption::where('tenant_id', $tenantId)->get(); return [ 'pages' => $pagesWithSections, 'sections' => $sections, - 'masterFields' => $masterFields, 'customTabs' => $customTabs, 'unitOptions' => $unitOptions, ]; @@ -99,15 +94,24 @@ private function getLinkedSections(int $tenantId, int $pageId): array if ($section) { // 섹션에 연결된 필드 (entity_relationships 기반) $linkedFields = $this->getLinkedFields($tenantId, $section->id); + // 섹션에 연결된 BOM 항목 (entity_relationships 기반) + $linkedBomItems = $this->getLinkedBomItems($tenantId, $section->id); $sectionData = $section->toArray(); $sectionData['order_no'] = $rel->order_no; + // 연결 잠금 상태 (이 페이지-섹션 관계의 잠금) + $sectionData['is_locked'] = (bool) $rel->is_locked; // FK 기반 필드 + 링크 기반 필드 병합 if (! empty($linkedFields)) { $sectionData['fields'] = $linkedFields; } + // FK 기반 BOM + 링크 기반 BOM 병합 + if (! empty($linkedBomItems)) { + $sectionData['bom_items'] = $linkedBomItems; + } + $sections[] = $sectionData; } } @@ -133,10 +137,39 @@ private function getLinkedFields(int $tenantId, int $sectionId): array if ($field) { $fieldData = $field->toArray(); $fieldData['order_no'] = $rel->order_no; + // 연결 잠금 상태 (이 섹션-필드 관계의 잠금) + $fieldData['is_locked'] = (bool) $rel->is_locked; $fields[] = $fieldData; } } return $fields; } + + /** + * 섹션에 연결된 BOM 항목 조회 (entity_relationships 기반) + */ + private function getLinkedBomItems(int $tenantId, int $sectionId): array + { + $relationships = EntityRelationship::where('tenant_id', $tenantId) + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $sectionId) + ->where('child_type', EntityRelationship::TYPE_BOM) + ->orderBy('order_no') + ->get(); + + $bomItems = []; + foreach ($relationships as $rel) { + $bomItem = ItemBomItem::find($rel->child_id); + if ($bomItem) { + $bomData = $bomItem->toArray(); + $bomData['order_no'] = $rel->order_no; + // 연결 잠금 상태 (이 섹션-BOM 관계의 잠금) + $bomData['is_locked'] = (bool) $rel->is_locked; + $bomItems[] = $bomData; + } + } + + return $bomItems; + } } diff --git a/app/Services/ItemMaster/ItemPageService.php b/app/Services/ItemMaster/ItemPageService.php index 14af2fe..8ab424e 100644 --- a/app/Services/ItemMaster/ItemPageService.php +++ b/app/Services/ItemMaster/ItemPageService.php @@ -2,14 +2,18 @@ namespace App\Services\ItemMaster; +use App\Exceptions\BusinessException; use App\Models\ItemMaster\EntityRelationship; use App\Models\ItemMaster\ItemPage; use App\Services\Service; +use App\Traits\LockCheckTrait; use Illuminate\Support\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemPageService extends Service { + use LockCheckTrait; + /** * 페이지 목록 조회 (섹션/필드 중첩) */ @@ -104,10 +108,22 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 잠금 체크: 이 페이지의 자식 관계 중 잠금된 것이 있는지 확인 + $hasLockedChildren = EntityRelationship::where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $id) + ->where('is_locked', true) + ->exists(); + + if ($hasLockedChildren) { + throw new BusinessException(__('error.page_has_locked_children')); + } + // 1. entity_relationships에서 이 페이지의 모든 자식 관계 해제 // (page→section, page→field 관계 삭제) + // 주의: 잠금된 연결이 없는 상태에서만 진입 EntityRelationship::where('parent_type', EntityRelationship::TYPE_PAGE) ->where('parent_id', $id) + ->where('is_locked', false) ->delete(); // 2. 페이지만 Soft Delete (섹션/필드는 독립 엔티티로 유지) diff --git a/app/Services/ItemMaster/ItemSectionService.php b/app/Services/ItemMaster/ItemSectionService.php index 9dd71a7..e0700cd 100644 --- a/app/Services/ItemMaster/ItemSectionService.php +++ b/app/Services/ItemMaster/ItemSectionService.php @@ -7,11 +7,14 @@ use App\Models\ItemMaster\ItemField; use App\Models\ItemMaster\ItemSection; use App\Services\Service; +use App\Traits\LockCheckTrait; use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemSectionService extends Service { + use LockCheckTrait; + /** * 모든 섹션 목록 조회 * @@ -89,12 +92,11 @@ public function clone(int $id): ItemSection 'created_by' => $userId, ]); - // 필드 복제 - foreach ($original->fields as $field) { - ItemField::create([ + // 필드 복제 (entity_relationships 기반) + foreach ($original->fields as $orderNo => $field) { + $clonedField = ItemField::create([ 'tenant_id' => $tenantId, 'group_id' => $field->group_id, - 'section_id' => $cloned->id, 'field_name' => $field->field_name, 'field_type' => $field->field_type, 'order_no' => $field->order_no, @@ -107,14 +109,25 @@ public function clone(int $id): ItemSection 'properties' => $field->properties, 'created_by' => $userId, ]); + + // 섹션-필드 관계 생성 + EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_SECTION, + $cloned->id, + EntityRelationship::TYPE_FIELD, + $clonedField->id, + $field->order_no, + null, + $field->group_id + ); } - // BOM 항목 복제 - foreach ($original->bomItems as $bom) { - ItemBomItem::create([ + // BOM 항목 복제 (entity_relationships 기반) + foreach ($original->bomItems as $orderNo => $bom) { + $clonedBom = ItemBomItem::create([ 'tenant_id' => $tenantId, 'group_id' => $bom->group_id, - 'section_id' => $cloned->id, 'item_code' => $bom->item_code, 'item_name' => $bom->item_name, 'quantity' => $bom->quantity, @@ -125,6 +138,18 @@ public function clone(int $id): ItemSection 'note' => $bom->note, 'created_by' => $userId, ]); + + // 섹션-BOM 관계 생성 + EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_SECTION, + $cloned->id, + EntityRelationship::TYPE_BOM, + $clonedBom->id, + $orderNo, + null, + $bom->group_id + ); } return $cloned->load(['fields', 'bomItems']); @@ -227,6 +252,8 @@ public function update(int $id, array $data): ItemSection * 섹션 삭제 (Soft Delete) * * 독립 엔티티 아키텍처: 섹션만 삭제하고 연결된 필드/BOM은 unlink만 수행 + * + * @throws \App\Exceptions\BusinessException 잠금된 연결이 있는 경우 */ public function destroy(int $id): void { @@ -241,17 +268,25 @@ public function destroy(int $id): void throw new NotFoundHttpException(__('error.not_found')); } + // 잠금 체크: 이 섹션이 잠금된 연결로 보호되고 있는지 확인 + $this->checkCanDelete(EntityRelationship::TYPE_SECTION, $id); + // 1. entity_relationships에서 이 섹션의 모든 부모 관계 해제 // (page→section 관계에서 이 섹션 제거) + // 주의: 잠금된 연결이 있으면 예외 발생 EntityRelationship::where('child_type', EntityRelationship::TYPE_SECTION) ->where('child_id', $id) + ->where('is_locked', false) ->delete(); // 2. entity_relationships에서 이 섹션의 모든 자식 관계 해제 // (section→field, section→bom 관계 삭제) - EntityRelationship::where('parent_type', EntityRelationship::TYPE_SECTION) - ->where('parent_id', $id) - ->delete(); + // 주의: 잠금된 연결이 있으면 예외 발생 (unlinkAllChildren에서 처리) + EntityRelationship::unlinkAllChildren( + $tenantId, + EntityRelationship::TYPE_SECTION, + $id + ); // 3. 섹션만 Soft Delete (필드/BOM은 독립 엔티티로 유지) $section->update(['deleted_by' => $userId]); diff --git a/app/Services/ItemMaster/SectionTemplateService.php b/app/Services/ItemMaster/SectionTemplateService.php index 4712ab5..585adf0 100644 --- a/app/Services/ItemMaster/SectionTemplateService.php +++ b/app/Services/ItemMaster/SectionTemplateService.php @@ -50,11 +50,10 @@ public function store(array $data): ItemSection } } - // 1. 독립 섹션 생성 (page_id=null) + // 1. 독립 섹션 생성 $section = ItemSection::create([ 'tenant_id' => $tenantId, 'group_id' => 1, - 'page_id' => null, 'title' => $data['title'], 'type' => $data['type'], 'order_no' => 0, diff --git a/app/Swagger/v1/ItemMasterApi.php b/app/Swagger/v1/ItemMasterApi.php index 172fb5d..3b7c71f 100644 --- a/app/Swagger/v1/ItemMasterApi.php +++ b/app/Swagger/v1/ItemMasterApi.php @@ -42,6 +42,7 @@ * @OA\Property(property="order_no", type="integer", example=0), * @OA\Property(property="is_template", type="boolean", example=false, description="템플릿 여부"), * @OA\Property(property="is_default", type="boolean", example=false, description="기본 템플릿 여부"), + * @OA\Property(property="is_locked", type="boolean", example=false, description="연결 잠금 여부 (init API 응답 시 포함)"), * @OA\Property(property="description", type="string", nullable=true, example="섹션 설명"), * @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"), @@ -53,7 +54,7 @@ * ), * * @OA\Property( - * property="bomItems", + * property="bom_items", * type="array", * * @OA\Items(ref="#/components/schemas/ItemBomItem") @@ -72,6 +73,7 @@ * @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), * @OA\Property(property="is_required", type="boolean", example=true), + * @OA\Property(property="is_locked", type="boolean", example=false, description="연결 잠금 여부 (init API 응답 시 포함)"), * @OA\Property(property="default_value", type="string", nullable=true, example=null), * @OA\Property(property="placeholder", type="string", nullable=true, example="제품명을 입력하세요"), * @OA\Property(property="display_condition", type="object", nullable=true, example=null), @@ -101,6 +103,7 @@ * @OA\Property(property="total_price", type="number", format="float", nullable=true, example=15000), * @OA\Property(property="spec", type="string", nullable=true, example="규격 정보"), * @OA\Property(property="note", type="string", nullable=true, example="비고"), + * @OA\Property(property="is_locked", type="boolean", example=false, description="연결 잠금 여부 (init API 응답 시 포함)"), * @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") * ) diff --git a/app/Swagger/v1/ItemsFileApi.php b/app/Swagger/v1/ItemsFileApi.php index 649e405..6be70ab 100644 --- a/app/Swagger/v1/ItemsFileApi.php +++ b/app/Swagger/v1/ItemsFileApi.php @@ -78,15 +78,16 @@ class ItemsFileApi * property="bending_details", * type="array", * description="절곡 상세 정보 (bending_diagram 타입일 때만)", + * * @OA\Items( * type="object", * required={"angle", "length", "type"}, + * * @OA\Property(property="angle", type="number", format="float", example=90, description="절곡 각도"), * @OA\Property(property="length", type="number", format="float", example=100.5, description="절곡 길이"), * @OA\Property(property="type", type="string", example="V형", description="절곡 타입") * ) * ), - * * @OA\Property( * property="certification_number", * type="string", diff --git a/app/Traits/LockCheckTrait.php b/app/Traits/LockCheckTrait.php new file mode 100644 index 0000000..fd451bd --- /dev/null +++ b/app/Traits/LockCheckTrait.php @@ -0,0 +1,45 @@ +foreignId('created_by') @@ -86,7 +86,7 @@ public function up(): void } // updated_by 추가 - if (!Schema::hasColumn($tableName, 'updated_by')) { + if (! Schema::hasColumn($tableName, 'updated_by')) { // created_by 다음에 추가 if (Schema::hasColumn($tableName, 'created_by')) { $table->foreignId('updated_by') @@ -159,7 +159,7 @@ public function down(): void ]; foreach ($tables as $tableName) { - if (!Schema::hasTable($tableName) || in_array($tableName, $excludeTables)) { + if (! Schema::hasTable($tableName) || in_array($tableName, $excludeTables)) { continue; } @@ -176,4 +176,4 @@ public function down(): void }); } } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_11_24_192518_add_deleted_by_to_soft_delete_tables.php b/database/migrations/2025_11_24_192518_add_deleted_by_to_soft_delete_tables.php index 5cd2bbf..a771185 100644 --- a/database/migrations/2025_11_24_192518_add_deleted_by_to_soft_delete_tables.php +++ b/database/migrations/2025_11_24_192518_add_deleted_by_to_soft_delete_tables.php @@ -47,12 +47,12 @@ public function up(): void ]; foreach ($tables as $tableName) { - if (!Schema::hasTable($tableName)) { + if (! Schema::hasTable($tableName)) { continue; } Schema::table($tableName, function (Blueprint $table) use ($tableName) { - if (!Schema::hasColumn($tableName, 'deleted_by')) { + if (! Schema::hasColumn($tableName, 'deleted_by')) { $table->foreignId('deleted_by') ->nullable() ->after('deleted_at') @@ -101,7 +101,7 @@ public function down(): void ]; foreach ($tables as $tableName) { - if (!Schema::hasTable($tableName)) { + if (! Schema::hasTable($tableName)) { continue; } @@ -112,4 +112,4 @@ public function down(): void }); } } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_11_26_170358_make_page_id_nullable_in_item_sections_table.php b/database/migrations/2025_11_26_170358_make_page_id_nullable_in_item_sections_table.php index 7ec7bdf..fedccaa 100644 --- a/database/migrations/2025_11_26_170358_make_page_id_nullable_in_item_sections_table.php +++ b/database/migrations/2025_11_26_170358_make_page_id_nullable_in_item_sections_table.php @@ -31,4 +31,4 @@ public function down(): void $table->unsignedBigInteger('page_id')->nullable(false)->comment('페이지 ID')->change(); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_11_27_151643_add_is_locked_to_entity_relationships_table.php b/database/migrations/2025_11_27_151643_add_is_locked_to_entity_relationships_table.php new file mode 100644 index 0000000..dbc0caf --- /dev/null +++ b/database/migrations/2025_11_27_151643_add_is_locked_to_entity_relationships_table.php @@ -0,0 +1,37 @@ +boolean('is_locked')->default(false)->after('metadata')->comment('잠금 여부 (잠금 시 연결 해제 및 자식 삭제 불가)'); + $table->unsignedBigInteger('locked_by')->nullable()->after('is_locked')->comment('잠금 설정자 ID'); + $table->timestamp('locked_at')->nullable()->after('locked_by')->comment('잠금 설정 일시'); + + $table->index('is_locked', 'idx_entity_rel_is_locked'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entity_relationships', function (Blueprint $table) { + $table->dropIndex('idx_entity_rel_is_locked'); + $table->dropColumn(['is_locked', 'locked_by', 'locked_at']); + }); + } +}; diff --git a/database/seeders/ItemMasterSeeder.php b/database/seeders/ItemMasterSeeder.php index 5296f1f..63a28cd 100644 --- a/database/seeders/ItemMasterSeeder.php +++ b/database/seeders/ItemMasterSeeder.php @@ -2,15 +2,15 @@ namespace Database\Seeders; -use App\Models\ItemMaster\UnitOption; -use App\Models\ItemMaster\SectionTemplate; -use App\Models\ItemMaster\ItemMasterField; +use App\Models\ItemMaster\CustomTab; +use App\Models\ItemMaster\EntityRelationship; +use App\Models\ItemMaster\ItemBomItem; +use App\Models\ItemMaster\ItemField; use App\Models\ItemMaster\ItemPage; use App\Models\ItemMaster\ItemSection; -use App\Models\ItemMaster\ItemField; -use App\Models\ItemMaster\ItemBomItem; -use App\Models\ItemMaster\CustomTab; +use App\Models\ItemMaster\SectionTemplate; use App\Models\ItemMaster\TabColumn; +use App\Models\ItemMaster\UnitOption; use Illuminate\Database\Seeder; class ItemMasterSeeder extends Seeder @@ -22,6 +22,7 @@ public function run(): void { $tenantId = 1; // 기본 테넌트 ID (실제 환경에 맞게 조정) $userId = 1; // 기본 사용자 ID (실제 환경에 맞게 조정) + $groupId = 1; // 기본 그룹 ID // 1. 단위 옵션 $units = [ @@ -60,7 +61,7 @@ public function run(): void ]); } - // 3. 마스터 필드 + // 3. 마스터 필드 (독립 엔티티로 생성) $masterFields = [ ['field_name' => '제품명', 'field_type' => 'textbox', 'category' => '기본정보', 'is_common' => true], ['field_name' => '제품코드', 'field_type' => 'textbox', 'category' => '기본정보', 'is_common' => true], @@ -77,10 +78,13 @@ public function run(): void ]; foreach ($masterFields as $field) { - ItemMasterField::create([ + ItemField::create([ 'tenant_id' => $tenantId, + 'group_id' => $groupId, 'field_name' => $field['field_name'], 'field_type' => $field['field_type'], + 'order_no' => 0, + 'is_required' => false, 'category' => $field['category'], 'is_common' => $field['is_common'], 'options' => $field['options'] ?? null, @@ -99,8 +103,10 @@ public function run(): void ]; foreach ($pages as $index => $page) { + // 페이지 생성 (독립 엔티티) $itemPage = ItemPage::create([ 'tenant_id' => $tenantId, + 'group_id' => $groupId, 'page_name' => $page['page_name'], 'item_type' => $page['item_type'], 'absolute_path' => $page['absolute_path'], @@ -108,65 +114,89 @@ public function run(): void 'created_by' => $userId, ]); - // 각 페이지에 기본 섹션 추가 + // 기본 정보 섹션 생성 (독립 엔티티) $section1 = ItemSection::create([ 'tenant_id' => $tenantId, - 'page_id' => $itemPage->id, + 'group_id' => $groupId, 'title' => '기본 정보', 'type' => 'fields', 'order_no' => 0, 'created_by' => $userId, ]); - // 섹션에 필드 추가 - ItemField::create([ - 'tenant_id' => $tenantId, - 'section_id' => $section1->id, - 'field_name' => '제품명', - 'field_type' => 'textbox', - 'order_no' => 0, - 'is_required' => true, - 'placeholder' => '제품명을 입력하세요', - 'created_by' => $userId, - ]); + // 페이지-섹션 관계 생성 (entity_relationships) + // link(tenantId, parentType, parentId, childType, childId, orderNo, metadata, groupId) + EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_PAGE, + $itemPage->id, + EntityRelationship::TYPE_SECTION, + $section1->id, + 0, // order_no + null, // metadata + $groupId + ); - ItemField::create([ - 'tenant_id' => $tenantId, - 'section_id' => $section1->id, - 'field_name' => '제품코드', - 'field_type' => 'textbox', - 'order_no' => 1, - 'is_required' => true, - 'placeholder' => '제품코드를 입력하세요', - 'created_by' => $userId, - ]); + // 필드 생성 및 섹션에 연결 + $fieldData = [ + ['field_name' => '제품명', 'is_required' => true, 'placeholder' => '제품명을 입력하세요'], + ['field_name' => '제품코드', 'is_required' => true, 'placeholder' => '제품코드를 입력하세요'], + ['field_name' => '규격', 'is_required' => false, 'placeholder' => '규격을 입력하세요'], + ]; - ItemField::create([ - 'tenant_id' => $tenantId, - 'section_id' => $section1->id, - 'field_name' => '규격', - 'field_type' => 'textbox', - 'order_no' => 2, - 'is_required' => false, - 'placeholder' => '규격을 입력하세요', - 'created_by' => $userId, - ]); + foreach ($fieldData as $orderNo => $fd) { + $field = ItemField::create([ + 'tenant_id' => $tenantId, + 'group_id' => $groupId, + 'field_name' => $fd['field_name'], + 'field_type' => 'textbox', + 'order_no' => $orderNo, + 'is_required' => $fd['is_required'], + 'placeholder' => $fd['placeholder'], + 'created_by' => $userId, + ]); + + // 섹션-필드 관계 생성 (entity_relationships) + EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_SECTION, + $section1->id, + EntityRelationship::TYPE_FIELD, + $field->id, + $orderNo, + null, + $groupId + ); + } // BOM 섹션 (완제품, 반제품만) if (in_array($page['item_type'], ['FG', 'PT'])) { + // BOM 섹션 생성 (독립 엔티티) $section2 = ItemSection::create([ 'tenant_id' => $tenantId, - 'page_id' => $itemPage->id, + 'group_id' => $groupId, 'title' => 'BOM 구성', 'type' => 'bom', 'order_no' => 1, 'created_by' => $userId, ]); - // BOM 항목 샘플 - ItemBomItem::create([ + // 페이지-섹션 관계 생성 + EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_PAGE, + $itemPage->id, + EntityRelationship::TYPE_SECTION, + $section2->id, + 1, // order_no + null, + $groupId + ); + + // BOM 항목 생성 (독립 엔티티) + $bomItem = ItemBomItem::create([ 'tenant_id' => $tenantId, - 'section_id' => $section2->id, + 'group_id' => $groupId, 'item_code' => 'MAT-001', 'item_name' => '철판', 'quantity' => 10.5, @@ -177,6 +207,18 @@ public function run(): void 'note' => '샘플 BOM 항목', 'created_by' => $userId, ]); + + // 섹션-BOM 관계 생성 + EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_SECTION, + $section2->id, + EntityRelationship::TYPE_BOM, + $bomItem->id, + 0, + null, + $groupId + ); } } @@ -212,10 +254,10 @@ public function run(): void } echo "✅ ItemMaster 시드 데이터 생성 완료!\n"; - echo " - 단위 옵션: ".count($units)."개\n"; - echo " - 섹션 템플릿: ".count($templates)."개\n"; - echo " - 마스터 필드: ".count($masterFields)."개\n"; - echo " - 품목 페이지: ".count($pages)."개\n"; - echo " - 커스텀 탭: ".count($tabs)."개\n"; + echo ' - 단위 옵션: '.count($units)."개\n"; + echo ' - 섹션 템플릿: '.count($templates)."개\n"; + echo ' - 마스터 필드: '.count($masterFields)."개\n"; + echo ' - 품목 페이지: '.count($pages)."개\n"; + echo ' - 커스텀 탭: '.count($tabs)."개\n"; } } diff --git a/docs/front/[API-2025-11-26] item-master-api-changes.md b/docs/front/[API-2025-11-26] item-master-api-changes.md new file mode 100644 index 0000000..d2eeb40 --- /dev/null +++ b/docs/front/[API-2025-11-26] item-master-api-changes.md @@ -0,0 +1,120 @@ +# Item Master API 변경사항 + +**작성일**: 2025-11-26 +**대상**: 프론트엔드 개발팀 +**관련 문서**: `[API-2025-11-25] item-master-data-management-api-request.md` + +--- + +## 구조 변경 + +**`section_templates` 테이블 삭제** → `item_sections`의 `is_template=true`로 통합 + +--- + +## 변경된 API + +### 섹션 템플릿 필드/BOM API + +| 요청서 | 실제 구현 | +|--------|----------| +| `POST /section-templates/{id}/fields` | `POST /sections/{id}/fields` | +| `POST /section-templates/{id}/bom-items` | `POST /sections/{id}/bom-items` | + +→ 템플릿도 섹션이므로 동일 API 사용 + +--- + +## 신규 API + +### 1. 독립 섹션 API + +| API | 설명 | +|-----|------| +| `GET /sections?is_template=true` | 템플릿 목록 조회 | +| `GET /sections?is_template=false` | 일반 섹션 목록 | +| `POST /sections` | 독립 섹션 생성 | +| `POST /sections/{id}/clone` | 섹션 복제 | +| `GET /sections/{id}/usage` | 사용처 조회 (어느 페이지에서 사용중인지) | + +**Request** (`POST /sections`): +```json +{ + "group_id": 1, + "title": "섹션명", + "type": "fields|bom", + "is_template": false, + "is_default": false, + "description": null +} +``` + +### 2. 독립 필드 API + +| API | 설명 | +|-----|------| +| `GET /fields` | 필드 목록 | +| `POST /fields` | 독립 필드 생성 | +| `POST /fields/{id}/clone` | 필드 복제 | +| `GET /fields/{id}/usage` | 사용처 조회 | + +**Request** (`POST /fields`): +```json +{ + "group_id": 1, + "field_name": "필드명", + "field_type": "textbox|number|dropdown|checkbox|date|textarea", + "is_required": false, + "default_value": null, + "placeholder": null, + "options": [], + "properties": [] +} +``` + +### 3. 독립 BOM API + +| API | 설명 | +|-----|------| +| `GET /bom-items` | BOM 목록 | +| `POST /bom-items` | 독립 BOM 생성 | + +**Request** (`POST /bom-items`): +```json +{ + "group_id": 1, + "item_code": null, + "item_name": "품목명", + "quantity": 0, + "unit": null, + "unit_price": 0, + "spec": null, + "note": null +} +``` + +### 4. 링크 관리 API + +| API | 설명 | +|-----|------| +| `POST /pages/{id}/link-section` | 페이지에 섹션 연결 | +| `DELETE /pages/{id}/unlink-section/{sectionId}` | 연결 해제 | +| `POST /sections/{id}/link-field` | 섹션에 필드 연결 | +| `DELETE /sections/{id}/unlink-field/{fieldId}` | 연결 해제 | +| `GET /pages/{id}/structure` | 페이지 전체 구조 조회 | + +**Request** (link 계열): +```json +{ + "target_id": 1, + "order_no": 0 +} +``` + +**Response** (usage 계열): +```json +{ + "used_in_pages": [{ "id": 1, "page_name": "기본정보" }], + "used_in_sections": [{ "id": 2, "title": "스펙정보" }] +} +``` diff --git a/lang/ko/error.php b/lang/ko/error.php index 9e0d283..a837851 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -110,4 +110,11 @@ 'section_not_found' => '섹션을 찾을 수 없습니다.', 'field_not_found' => '필드를 찾을 수 없습니다.', 'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.', + + // 잠금 관련 + 'relationship_locked' => '잠금된 연결은 해제할 수 없습니다.', + 'has_locked_relationships' => '잠금된 연결이 포함되어 있어 처리할 수 없습니다.', + 'entity_protected_by_locked_relationship' => '잠금된 연결로 보호된 항목은 삭제할 수 없습니다.', + 'page_has_locked_children' => '잠금된 자식 연결이 있어 페이지를 삭제할 수 없습니다.', + 'section_has_locked_children' => '잠금된 자식 연결이 있어 섹션을 삭제할 수 없습니다.', ]; diff --git a/routes/api.php b/routes/api.php index bd860cb..122090c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -24,7 +24,6 @@ use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController; use App\Http\Controllers\Api\V1\ItemMaster\ItemFieldController; use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterController; -use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterFieldController; use App\Http\Controllers\Api\V1\ItemMaster\ItemPageController; use App\Http\Controllers\Api\V1\ItemMaster\ItemSectionController; use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController; @@ -529,12 +528,6 @@ Route::put('/section-templates/{id}', [SectionTemplateController::class, 'update'])->name('v1.item-master.section-templates.update'); Route::delete('/section-templates/{id}', [SectionTemplateController::class, 'destroy'])->name('v1.item-master.section-templates.destroy'); - // 마스터 필드 - Route::get('/master-fields', [ItemMasterFieldController::class, 'index'])->name('v1.item-master.master-fields.index'); - Route::post('/master-fields', [ItemMasterFieldController::class, 'store'])->name('v1.item-master.master-fields.store'); - Route::put('/master-fields/{id}', [ItemMasterFieldController::class, 'update'])->name('v1.item-master.master-fields.update'); - Route::delete('/master-fields/{id}', [ItemMasterFieldController::class, 'destroy'])->name('v1.item-master.master-fields.destroy'); - // 커스텀 탭 Route::get('/custom-tabs', [CustomTabController::class, 'index'])->name('v1.item-master.custom-tabs.index'); Route::post('/custom-tabs', [CustomTabController::class, 'store'])->name('v1.item-master.custom-tabs.store'); diff --git a/tests/Feature/Category/CategoryApiTest.php b/tests/Feature/Category/CategoryApiTest.php index 0180b41..59a3ca8 100644 --- a/tests/Feature/Category/CategoryApiTest.php +++ b/tests/Feature/Category/CategoryApiTest.php @@ -14,8 +14,11 @@ class CategoryApiTest extends TestCase use DatabaseTransactions; private Tenant $tenant; + private User $user; + private string $apiKey; + private string $token; protected function setUp(): void