diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 36bafc3..c124a57 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,5 +1,198 @@ # SAM API 작업 현황 +## 2025-11-26 (화) - Item Master 독립 엔티티 API 추가 ✅ 완료 + +### 작업 목표 +- 독립 엔티티(섹션, 필드, BOM) CRUD API 10개 추가 +- `SectionTemplate` 모델 삭제 → `ItemSection.is_template` 플래그로 통합 +- Swagger 문서 업데이트 + +### 변경 내용 + +**1. SectionTemplate → ItemSection 통합** +- `section_templates` 테이블 삭제 +- `item_sections` 테이블에 `is_template` 컬럼 추가 +- 기존 `/section-templates` API는 유지 (내부적으로 `is_template=true` 사용) + +**2. 10개 독립 API 추가** + +| API | 메서드 | 설명 | +|-----|--------|------| +| `/sections` | GET | 섹션 목록 (is_template 필터) | +| `/sections` | POST | 독립 섹션 생성 | +| `/sections/{id}/clone` | POST | 섹션 복제 | +| `/sections/{id}/usage` | GET | 섹션 사용처 조회 | +| `/fields` | GET | 필드 목록 | +| `/fields` | POST | 독립 필드 생성 | +| `/fields/{id}/clone` | POST | 필드 복제 | +| `/fields/{id}/usage` | GET | 필드 사용처 조회 | +| `/bom-items` | GET | BOM 항목 목록 | +| `/bom-items` | POST | 독립 BOM 생성 | + +### 추가된 파일 +- `app/Http/Requests/ItemMaster/IndependentSectionStoreRequest.php` +- `app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php` +- `app/Http/Requests/ItemMaster/IndependentBomItemStoreRequest.php` + +### 삭제된 파일 +- `app/Models/ItemMaster/SectionTemplate.php` + +### 수정된 파일 +- `app/Http/Controllers/Api/V1/ItemMaster/ItemSectionController.php` +- `app/Http/Controllers/Api/V1/ItemMaster/ItemFieldController.php` +- `app/Http/Controllers/Api/V1/ItemMaster/ItemBomItemController.php` +- `app/Services/ItemMaster/ItemSectionService.php` +- `app/Services/ItemMaster/ItemFieldService.php` +- `app/Services/ItemMaster/ItemBomItemService.php` +- `app/Models/ItemMaster/ItemSection.php` (is_template, scopeTemplates 추가) +- `routes/api.php` +- `app/Swagger/v1/ItemMasterApi.php` + +### 마이그레이션 +```bash +# 실행된 마이그레이션 +2025_11_26_120000_add_is_template_to_item_sections_and_drop_section_templates.php +``` + +### 검증 결과 +- PHP 문법 검사: ✅ 통과 +- Pint 코드 포맷팅: ✅ 통과 +- Swagger 문서 생성: ✅ 완료 + +--- + +## 2025-11-26 (화) - Item Master 하이브리드 구조 전환 (독립 엔티티 + 링크 테이블) ✅ 완료 + +### 작업 목표 +기존 CASCADE FK 기반 계층 구조를 **독립 엔티티 + 링크 테이블** 구조로 전환 + +### 배경 +- **문제점**: 현재 구조에서 섹션 삭제 시 항목(필드)도 함께 삭제됨 (CASCADE) +- **요구사항**: + - 페이지, 섹션, 항목은 독립적으로 존재 + - 관계는 링크 테이블로 관리 (Many-to-Many) + - 페이지에서 섹션/항목 모두 직접 연결 가능 + - 섹션에서 항목 연결 가능 + - 엔티티 삭제 시 링크만 제거, 다른 엔티티는 유지 + - `group_id`로 카테고리 격리 (품목관리=1, 향후 확장) + +### 변경 구조 + +**Before (CASCADE FK)**: +``` +item_pages + ↓ page_id FK (CASCADE) +item_sections + ↓ section_id FK (CASCADE) +item_fields / item_bom_items +``` + +**After (독립 + 링크)**: +``` +item_pages (독립) +item_sections (독립) +item_fields (독립) +item_bom_items (독립) + ⇄ entity_relationships (링크 테이블) +``` + +### Phase 계획 + +| Phase | 작업 내용 | 상태 | +|-------|----------|------| +| 1 | 마이그레이션: FK 제거 + group_id 추가 | ✅ 완료 | +| 2 | 마이그레이션: entity_relationships 테이블 생성 | ✅ 완료 | +| 3 | 마이그레이션: 기존 데이터 이관 | ✅ 완료 | +| 4 | 모델 및 Service 수정 | ✅ 완료 | +| 5 | 새로운 API 엔드포인트 추가 | ✅ 완료 | +| 6 | Swagger 문서 업데이트 | ✅ 완료 | +| 7 | 테스트 및 검증 | ✅ 완료 | + +### 추가된 파일 + +**마이그레이션** (Batch 26으로 실행): +- `database/migrations/2025_11_26_100001_convert_item_tables_to_independent_entities.php` +- `database/migrations/2025_11_26_100002_create_entity_relationships_table.php` +- `database/migrations/2025_11_26_100003_migrate_existing_relationships_to_entity_relationships.php` + +**모델**: +- `app/Models/ItemMaster/EntityRelationship.php` (신규) + +**서비스**: +- `app/Services/ItemMaster/EntityRelationshipService.php` (신규) + +**컨트롤러**: +- `app/Http/Controllers/Api/V1/ItemMaster/EntityRelationshipController.php` (신규) + +**Request**: +- `app/Http/Requests/ItemMaster/LinkEntityRequest.php` (신규) +- `app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php` (신규) + +**Swagger**: +- `app/Swagger/v1/EntityRelationshipApi.php` (신규) + +### 수정된 파일 + +**모델 (group_id 추가 + relationship 메서드)**: +- `app/Models/ItemMaster/ItemPage.php` +- `app/Models/ItemMaster/ItemSection.php` +- `app/Models/ItemMaster/ItemField.php` +- `app/Models/ItemMaster/ItemBomItem.php` +- `app/Models/ItemMaster/SectionTemplate.php` +- `app/Models/ItemMaster/ItemMasterField.php` + +**라우트**: +- `routes/api.php` (새로운 엔드포인트 추가) + +**언어 파일**: +- `lang/ko/message.php` (linked, unlinked 추가) +- `lang/ko/error.php` (page_not_found, section_not_found, field_not_found, bom_not_found 추가) + +### 새로운 API 엔드포인트 (14개) + +**페이지-섹션 연결**: +- `POST /api/v1/item-master/pages/{pageId}/link-section` +- `DELETE /api/v1/item-master/pages/{pageId}/unlink-section/{sectionId}` + +**페이지-필드 직접 연결**: +- `POST /api/v1/item-master/pages/{pageId}/link-field` +- `DELETE /api/v1/item-master/pages/{pageId}/unlink-field/{fieldId}` + +**페이지 관계 조회**: +- `GET /api/v1/item-master/pages/{pageId}/relationships` +- `GET /api/v1/item-master/pages/{pageId}/structure` + +**섹션-필드 연결**: +- `POST /api/v1/item-master/sections/{sectionId}/link-field` +- `DELETE /api/v1/item-master/sections/{sectionId}/unlink-field/{fieldId}` + +**섹션-BOM 연결**: +- `POST /api/v1/item-master/sections/{sectionId}/link-bom` +- `DELETE /api/v1/item-master/sections/{sectionId}/unlink-bom/{bomId}` + +**섹션 관계 조회**: +- `GET /api/v1/item-master/sections/{sectionId}/relationships` + +**관계 순서 변경**: +- `POST /api/v1/item-master/relationships/reorder` + +### 검증 결과 +- PHP 문법 검사: ✅ 통과 +- Pint 코드 포맷팅: ✅ 통과 (9개 신규 파일) +- Swagger 문서 생성: ✅ 완료 +- 라우트 등록: ✅ 44개 item-master 라우트 확인 + +### 롤백 방법 +```bash +php artisan migrate:rollback --step=3 +``` + +### 다음 작업 (옵션) +- 기존 API (POST /pages/{pageId}/sections 등) 내부적으로 entity_relationships 사용하도록 수정 +- 독립 엔티티 CRUD API 추가 (POST /sections, POST /fields 등) + +--- + ## 2025-11-25 (월) - API 인증 에러 처리 개선 및 요청 로그 강화 ### 문제 상황 diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 8dd1f0c..acc694f 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-11-24 19:27:59 +> **자동 생성**: 2025-11-26 14:00:30 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -128,6 +128,12 @@ ### custom_tabs - **columnSetting()**: hasOne → `tab_columns` +### entity_relationships +**모델**: `App\Models\ItemMaster\EntityRelationship` + +- **parent()**: morphTo → `(Polymorphic)` +- **child()**: morphTo → `(Polymorphic)` + ### item_bom_items **모델**: `App\Models\ItemMaster\ItemBomItem` @@ -142,6 +148,9 @@ ### item_pages **모델**: `App\Models\ItemMaster\ItemPage` - **sections()**: hasMany → `item_sections` +- **sectionRelationships()**: hasMany → `entity_relationships` +- **fieldRelationships()**: hasMany → `entity_relationships` +- **allRelationships()**: hasMany → `entity_relationships` ### item_sections **모델**: `App\Models\ItemMaster\ItemSection` @@ -149,6 +158,9 @@ ### item_sections - **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` ### tab_columns **모델**: `App\Models\ItemMaster\TabColumn` diff --git a/app/Http/Controllers/Api/V1/ItemMaster/EntityRelationshipController.php b/app/Http/Controllers/Api/V1/ItemMaster/EntityRelationshipController.php new file mode 100644 index 0000000..fe9e77c --- /dev/null +++ b/app/Http/Controllers/Api/V1/ItemMaster/EntityRelationshipController.php @@ -0,0 +1,200 @@ +validated(); + + return $this->service->linkSectionToPage( + $pageId, + $data['child_id'], + $data['order_no'] ?? 0 + ); + }, __('message.linked')); + } + + /** + * 페이지에서 섹션 연결 해제 + * + * DELETE /api/v1/item-master/pages/{pageId}/unlink-section/{sectionId} + */ + public function unlinkSectionFromPage(int $pageId, int $sectionId) + { + return ApiResponse::handle(function () use ($pageId, $sectionId) { + $this->service->unlinkSectionFromPage($pageId, $sectionId); + + return 'success'; + }, __('message.unlinked')); + } + + /** + * 페이지에 필드 직접 연결 + * + * POST /api/v1/item-master/pages/{pageId}/link-field + */ + public function linkFieldToPage(int $pageId, LinkEntityRequest $request) + { + return ApiResponse::handle(function () use ($pageId, $request) { + $data = $request->validated(); + + return $this->service->linkFieldToPage( + $pageId, + $data['child_id'], + $data['order_no'] ?? 0 + ); + }, __('message.linked')); + } + + /** + * 페이지에서 필드 연결 해제 + * + * DELETE /api/v1/item-master/pages/{pageId}/unlink-field/{fieldId} + */ + public function unlinkFieldFromPage(int $pageId, int $fieldId) + { + return ApiResponse::handle(function () use ($pageId, $fieldId) { + $this->service->unlinkFieldFromPage($pageId, $fieldId); + + return 'success'; + }, __('message.unlinked')); + } + + /** + * 섹션에 필드 연결 + * + * POST /api/v1/item-master/sections/{sectionId}/link-field + */ + public function linkFieldToSection(int $sectionId, LinkEntityRequest $request) + { + return ApiResponse::handle(function () use ($sectionId, $request) { + $data = $request->validated(); + + return $this->service->linkFieldToSection( + $sectionId, + $data['child_id'], + $data['order_no'] ?? 0 + ); + }, __('message.linked')); + } + + /** + * 섹션에서 필드 연결 해제 + * + * DELETE /api/v1/item-master/sections/{sectionId}/unlink-field/{fieldId} + */ + public function unlinkFieldFromSection(int $sectionId, int $fieldId) + { + return ApiResponse::handle(function () use ($sectionId, $fieldId) { + $this->service->unlinkFieldFromSection($sectionId, $fieldId); + + return 'success'; + }, __('message.unlinked')); + } + + /** + * 섹션에 BOM 항목 연결 + * + * POST /api/v1/item-master/sections/{sectionId}/link-bom + */ + public function linkBomToSection(int $sectionId, LinkEntityRequest $request) + { + return ApiResponse::handle(function () use ($sectionId, $request) { + $data = $request->validated(); + + return $this->service->linkBomToSection( + $sectionId, + $data['child_id'], + $data['order_no'] ?? 0 + ); + }, __('message.linked')); + } + + /** + * 섹션에서 BOM 항목 연결 해제 + * + * DELETE /api/v1/item-master/sections/{sectionId}/unlink-bom/{bomId} + */ + public function unlinkBomFromSection(int $sectionId, int $bomId) + { + return ApiResponse::handle(function () use ($sectionId, $bomId) { + $this->service->unlinkBomFromSection($sectionId, $bomId); + + return 'success'; + }, __('message.unlinked')); + } + + /** + * 페이지의 모든 관계 조회 + * + * GET /api/v1/item-master/pages/{pageId}/relationships + */ + public function getPageRelationships(int $pageId) + { + return ApiResponse::handle(function () use ($pageId) { + return $this->service->getPageRelationships($pageId); + }, __('message.fetched')); + } + + /** + * 페이지 구조 조회 (섹션 + 직접 연결된 필드 + 중첩 구조) + * + * GET /api/v1/item-master/pages/{pageId}/structure + */ + public function getPageStructure(int $pageId) + { + return ApiResponse::handle(function () use ($pageId) { + return $this->service->getPageStructure($pageId); + }, __('message.fetched')); + } + + /** + * 섹션의 자식 관계 조회 + * + * GET /api/v1/item-master/sections/{sectionId}/relationships + */ + public function getSectionRelationships(int $sectionId) + { + return ApiResponse::handle(function () use ($sectionId) { + return $this->service->getSectionChildRelationships($sectionId); + }, __('message.fetched')); + } + + /** + * 관계 순서 변경 + * + * POST /api/v1/item-master/relationships/reorder + */ + public function reorderRelationships(ReorderRelationshipsRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $request->validated(); + $this->service->reorderRelationships( + $data['parent_type'], + $data['parent_id'], + $data['ordered_items'] + ); + + return 'success'; + }, __('message.reordered')); + } +} diff --git a/app/Http/Controllers/Api/V1/ItemMaster/ItemBomItemController.php b/app/Http/Controllers/Api/V1/ItemMaster/ItemBomItemController.php index a3e54b2..4a870ed 100644 --- a/app/Http/Controllers/Api/V1/ItemMaster/ItemBomItemController.php +++ b/app/Http/Controllers/Api/V1/ItemMaster/ItemBomItemController.php @@ -2,10 +2,11 @@ namespace App\Http\Controllers\Api\V1\ItemMaster; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\ItemMaster\IndependentBomItemStoreRequest; use App\Http\Requests\ItemMaster\ItemBomItemStoreRequest; use App\Http\Requests\ItemMaster\ItemBomItemUpdateRequest; -use App\Helpers\ApiResponse; use App\Services\ItemMaster\ItemBomItemService; class ItemBomItemController extends Controller @@ -15,7 +16,33 @@ public function __construct( ) {} /** - * BOM 항목 생성 + * 독립 BOM 목록 조회 + * + * GET /api/v1/item-master/bom-items + */ + public function index() + { + return ApiResponse::handle(function () { + return $this->service->index(); + }, __('message.fetched')); + } + + /** + * 독립 BOM 생성 (섹션 연결 없음) + * + * POST /api/v1/item-master/bom-items + */ + public function storeIndependent(IndependentBomItemStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->storeIndependent($request->validated()); + }, __('message.created')); + } + + /** + * BOM 항목 생성 (섹션에 연결) + * + * POST /api/v1/item-master/sections/{sectionId}/bom-items */ public function store(int $sectionId, ItemBomItemStoreRequest $request) { diff --git a/app/Http/Controllers/Api/V1/ItemMaster/ItemFieldController.php b/app/Http/Controllers/Api/V1/ItemMaster/ItemFieldController.php index 446a9f4..efbcf28 100644 --- a/app/Http/Controllers/Api/V1/ItemMaster/ItemFieldController.php +++ b/app/Http/Controllers/Api/V1/ItemMaster/ItemFieldController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\ItemMaster\IndependentFieldStoreRequest; use App\Http\Requests\ItemMaster\ItemFieldStoreRequest; use App\Http\Requests\ItemMaster\ItemFieldUpdateRequest; use App\Http\Requests\ItemMaster\ReorderRequest; @@ -14,7 +15,55 @@ class ItemFieldController extends Controller public function __construct(private ItemFieldService $service) {} /** - * 필드 생성 + * 독립 필드 목록 조회 + * + * GET /api/v1/item-master/fields + */ + public function index() + { + return ApiResponse::handle(function () { + return $this->service->index(); + }, __('message.fetched')); + } + + /** + * 독립 필드 생성 (섹션 연결 없음) + * + * POST /api/v1/item-master/fields + */ + public function storeIndependent(IndependentFieldStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->storeIndependent($request->validated()); + }, __('message.created')); + } + + /** + * 필드 복제 + * + * POST /api/v1/item-master/fields/{id}/clone + */ + public function clone(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->clone($id); + }, __('message.created')); + } + + /** + * 필드 사용처 조회 + * + * GET /api/v1/item-master/fields/{id}/usage + */ + public function getUsage(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getUsage($id); + }, __('message.fetched')); + } + + /** + * 필드 생성 (섹션에 연결) * * POST /api/v1/item-master/sections/{sectionId}/fields */ diff --git a/app/Http/Controllers/Api/V1/ItemMaster/ItemSectionController.php b/app/Http/Controllers/Api/V1/ItemMaster/ItemSectionController.php index 96717f1..78fdf3f 100644 --- a/app/Http/Controllers/Api/V1/ItemMaster/ItemSectionController.php +++ b/app/Http/Controllers/Api/V1/ItemMaster/ItemSectionController.php @@ -4,17 +4,71 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\ItemMaster\IndependentSectionStoreRequest; use App\Http\Requests\ItemMaster\ItemSectionStoreRequest; use App\Http\Requests\ItemMaster\ItemSectionUpdateRequest; use App\Http\Requests\ItemMaster\ReorderRequest; use App\Services\ItemMaster\ItemSectionService; +use Illuminate\Http\Request; class ItemSectionController extends Controller { public function __construct(private ItemSectionService $service) {} /** - * 섹션 생성 + * 독립 섹션 목록 조회 + * + * GET /api/v1/item-master/sections + */ + public function index(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $isTemplate = $request->has('is_template') + ? filter_var($request->query('is_template'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) + : null; + + return $this->service->index($isTemplate); + }, __('message.fetched')); + } + + /** + * 독립 섹션 생성 (페이지 연결 없음) + * + * POST /api/v1/item-master/sections + */ + public function storeIndependent(IndependentSectionStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->service->storeIndependent($request->validated()); + }, __('message.created')); + } + + /** + * 섹션 복제 + * + * POST /api/v1/item-master/sections/{id}/clone + */ + public function clone(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->clone($id); + }, __('message.created')); + } + + /** + * 섹션 사용처 조회 + * + * GET /api/v1/item-master/sections/{id}/usage + */ + public function getUsage(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getUsage($id); + }, __('message.fetched')); + } + + /** + * 섹션 생성 (페이지에 연결) * * POST /api/v1/item-master/pages/{pageId}/sections */ diff --git a/app/Http/Requests/ItemMaster/IndependentBomItemStoreRequest.php b/app/Http/Requests/ItemMaster/IndependentBomItemStoreRequest.php new file mode 100644 index 0000000..c4692dd --- /dev/null +++ b/app/Http/Requests/ItemMaster/IndependentBomItemStoreRequest.php @@ -0,0 +1,28 @@ + 'nullable|integer', + 'item_code' => 'nullable|string|max:100', + 'item_name' => 'required|string|max:255', + 'quantity' => 'nullable|numeric|min:0', + 'unit' => 'nullable|string|max:50', + 'unit_price' => 'nullable|numeric|min:0', + 'total_price' => 'nullable|numeric|min:0', + 'spec' => 'nullable|string', + 'note' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php b/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php new file mode 100644 index 0000000..05f1959 --- /dev/null +++ b/app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php @@ -0,0 +1,29 @@ + 'nullable|integer', + 'field_name' => 'required|string|max:255', + 'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea', + 'is_required' => 'nullable|boolean', + 'default_value' => 'nullable|string', + 'placeholder' => 'nullable|string|max:255', + 'display_condition' => 'nullable|array', + 'validation_rules' => 'nullable|array', + 'options' => 'nullable|array', + 'properties' => 'nullable|array', + ]; + } +} diff --git a/app/Http/Requests/ItemMaster/IndependentSectionStoreRequest.php b/app/Http/Requests/ItemMaster/IndependentSectionStoreRequest.php new file mode 100644 index 0000000..8bc3525 --- /dev/null +++ b/app/Http/Requests/ItemMaster/IndependentSectionStoreRequest.php @@ -0,0 +1,25 @@ + 'nullable|integer', + 'title' => 'required|string|max:255', + 'type' => 'required|in:fields,bom', + 'is_template' => 'nullable|boolean', + 'is_default' => 'nullable|boolean', + 'description' => 'nullable|string', + ]; + } +} diff --git a/app/Http/Requests/ItemMaster/LinkEntityRequest.php b/app/Http/Requests/ItemMaster/LinkEntityRequest.php new file mode 100644 index 0000000..7f482cb --- /dev/null +++ b/app/Http/Requests/ItemMaster/LinkEntityRequest.php @@ -0,0 +1,30 @@ + 'required|integer|min:1', + 'order_no' => 'nullable|integer|min:0', + ]; + } + + public function messages(): array + { + return [ + 'child_id.required' => __('validation.required', ['attribute' => '연결 대상 ID']), + 'child_id.integer' => __('validation.integer', ['attribute' => '연결 대상 ID']), + 'child_id.min' => __('validation.min.numeric', ['attribute' => '연결 대상 ID', 'min' => 1]), + ]; + } +} diff --git a/app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php b/app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php new file mode 100644 index 0000000..09567f4 --- /dev/null +++ b/app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php @@ -0,0 +1,35 @@ + 'required|string|in:page,section', + 'parent_id' => 'required|integer|min:1', + 'ordered_items' => 'required|array|min:1', + 'ordered_items.*.child_type' => 'required|string|in:section,field,bom', + 'ordered_items.*.child_id' => 'required|integer|min:1', + ]; + } + + public function messages(): array + { + return [ + 'parent_type.required' => __('validation.required', ['attribute' => '부모 타입']), + 'parent_type.in' => __('validation.in', ['attribute' => '부모 타입']), + 'parent_id.required' => __('validation.required', ['attribute' => '부모 ID']), + 'ordered_items.required' => __('validation.required', ['attribute' => '정렬 항목']), + 'ordered_items.array' => __('validation.array', ['attribute' => '정렬 항목']), + ]; + } +} diff --git a/app/Models/ItemMaster/EntityRelationship.php b/app/Models/ItemMaster/EntityRelationship.php new file mode 100644 index 0000000..0147aaf --- /dev/null +++ b/app/Models/ItemMaster/EntityRelationship.php @@ -0,0 +1,191 @@ + 'integer', + 'parent_id' => 'integer', + 'child_id' => 'integer', + 'order_no' => 'integer', + 'metadata' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // 엔티티 타입 상수 + public const TYPE_PAGE = 'page'; + + public const TYPE_SECTION = 'section'; + + public const TYPE_FIELD = 'field'; + + public const TYPE_BOM = 'bom'; + + // 그룹 ID 상수 + public const GROUP_ITEM_MASTER = 1; + + /** + * 부모 엔티티 조회 (Polymorphic) + */ + public function parent() + { + return $this->morphTo('parent', 'parent_type', 'parent_id'); + } + + /** + * 자식 엔티티 조회 (Polymorphic) + */ + public function child() + { + return $this->morphTo('child', 'child_type', 'child_id'); + } + + /** + * 부모 타입에 따른 모델 클래스 반환 + */ + public static function getModelClass(string $type): ?string + { + return match ($type) { + self::TYPE_PAGE => ItemPage::class, + self::TYPE_SECTION => ItemSection::class, + self::TYPE_FIELD => ItemField::class, + self::TYPE_BOM => ItemBomItem::class, + default => null, + }; + } + + /** + * 특정 부모의 자식 관계 조회 + */ + public static function getChildren(string $parentType, int $parentId, ?string $childType = null) + { + $query = self::where('parent_type', $parentType) + ->where('parent_id', $parentId) + ->orderBy('order_no'); + + if ($childType) { + $query->where('child_type', $childType); + } + + return $query; + } + + /** + * 특정 자식의 부모 관계 조회 + */ + public static function getParents(string $childType, int $childId, ?string $parentType = null) + { + $query = self::where('child_type', $childType) + ->where('child_id', $childId); + + if ($parentType) { + $query->where('parent_type', $parentType); + } + + return $query; + } + + /** + * 관계 생성 또는 업데이트 + */ + public static function link( + int $tenantId, + string $parentType, + int $parentId, + string $childType, + int $childId, + int $orderNo = 0, + ?array $metadata = null, + int $groupId = self::GROUP_ITEM_MASTER + ): self { + return self::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'group_id' => $groupId, + 'parent_type' => $parentType, + 'parent_id' => $parentId, + 'child_type' => $childType, + 'child_id' => $childId, + ], + [ + 'order_no' => $orderNo, + 'metadata' => $metadata, + ] + ); + } + + /** + * 관계 해제 + */ + public static function unlink( + int $tenantId, + string $parentType, + int $parentId, + string $childType, + int $childId, + int $groupId = self::GROUP_ITEM_MASTER + ): bool { + return self::where([ + 'tenant_id' => $tenantId, + 'group_id' => $groupId, + 'parent_type' => $parentType, + 'parent_id' => $parentId, + 'child_type' => $childType, + 'child_id' => $childId, + ])->delete() > 0; + } + + /** + * 특정 부모의 모든 자식 관계 해제 + */ + public static function unlinkAllChildren( + int $tenantId, + string $parentType, + int $parentId, + ?string $childType = null, + int $groupId = self::GROUP_ITEM_MASTER + ): int { + $query = self::where([ + 'tenant_id' => $tenantId, + 'group_id' => $groupId, + 'parent_type' => $parentType, + 'parent_id' => $parentId, + ]); + + if ($childType) { + $query->where('child_type', $childType); + } + + return $query->delete(); + } +} diff --git a/app/Models/ItemMaster/ItemBomItem.php b/app/Models/ItemMaster/ItemBomItem.php index 271c404..a1c9850 100644 --- a/app/Models/ItemMaster/ItemBomItem.php +++ b/app/Models/ItemMaster/ItemBomItem.php @@ -13,6 +13,7 @@ class ItemBomItem extends Model protected $fillable = [ 'tenant_id', + 'group_id', 'section_id', 'item_code', 'item_name', @@ -28,6 +29,7 @@ class ItemBomItem extends Model ]; protected $casts = [ + 'group_id' => 'integer', 'quantity' => 'decimal:4', 'unit_price' => 'decimal:2', 'total_price' => 'decimal:2', @@ -42,10 +44,33 @@ class ItemBomItem extends Model ]; /** - * 소속 섹션 + * 소속 섹션 (기존 FK 기반 - 하위 호환성) */ public function section() { return $this->belongsTo(ItemSection::class, 'section_id'); } + + /** + * 이 BOM 항목이 연결된 섹션들 조회 (링크 테이블 기반) + */ + public function linkedSections() + { + return ItemSection::whereIn('id', function ($query) { + $query->select('parent_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('child_type', EntityRelationship::TYPE_BOM) + ->where('child_id', $this->id); + }); + } + + /** + * 이 BOM 항목의 모든 부모 관계 목록 조회 + */ + public function allParentRelationships() + { + return EntityRelationship::where('child_type', EntityRelationship::TYPE_BOM) + ->where('child_id', $this->id); + } } diff --git a/app/Models/ItemMaster/ItemField.php b/app/Models/ItemMaster/ItemField.php index 90c4791..c6bb740 100644 --- a/app/Models/ItemMaster/ItemField.php +++ b/app/Models/ItemMaster/ItemField.php @@ -13,6 +13,7 @@ class ItemField extends Model protected $fillable = [ 'tenant_id', + 'group_id', 'section_id', 'field_name', 'field_type', @@ -30,6 +31,7 @@ class ItemField extends Model ]; protected $casts = [ + 'group_id' => 'integer', 'order_no' => 'integer', 'is_required' => 'boolean', 'display_condition' => 'array', @@ -47,10 +49,47 @@ class ItemField extends Model ]; /** - * 소속 섹션 + * 소속 섹션 (기존 FK 기반 - 하위 호환성) */ public function section() { return $this->belongsTo(ItemSection::class, 'section_id'); } + + /** + * 이 필드가 연결된 섹션들 조회 (링크 테이블 기반) + */ + public function linkedSections() + { + return ItemSection::whereIn('id', function ($query) { + $query->select('parent_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('child_type', EntityRelationship::TYPE_FIELD) + ->where('child_id', $this->id); + }); + } + + /** + * 이 필드가 직접 연결된 페이지들 조회 (링크 테이블 기반) + */ + public function linkedPages() + { + return ItemPage::whereIn('id', function ($query) { + $query->select('parent_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('child_type', EntityRelationship::TYPE_FIELD) + ->where('child_id', $this->id); + }); + } + + /** + * 이 필드의 모든 부모 관계 목록 조회 + */ + public function allParentRelationships() + { + return EntityRelationship::where('child_type', EntityRelationship::TYPE_FIELD) + ->where('child_id', $this->id); + } } diff --git a/app/Models/ItemMaster/ItemMasterField.php b/app/Models/ItemMaster/ItemMasterField.php index b2b1c55..ca5e7f9 100644 --- a/app/Models/ItemMaster/ItemMasterField.php +++ b/app/Models/ItemMaster/ItemMasterField.php @@ -13,6 +13,7 @@ class ItemMasterField extends Model protected $fillable = [ 'tenant_id', + 'group_id', 'field_name', 'field_type', 'category', @@ -28,6 +29,7 @@ class ItemMasterField extends Model ]; protected $casts = [ + 'group_id' => 'integer', 'is_common' => 'boolean', 'options' => 'array', 'validation_rules' => 'array', @@ -41,4 +43,4 @@ class ItemMasterField extends Model 'deleted_by', 'deleted_at', ]; -} \ No newline at end of file +} diff --git a/app/Models/ItemMaster/ItemPage.php b/app/Models/ItemMaster/ItemPage.php index edc11be..d903a68 100644 --- a/app/Models/ItemMaster/ItemPage.php +++ b/app/Models/ItemMaster/ItemPage.php @@ -13,6 +13,7 @@ class ItemPage extends Model protected $fillable = [ 'tenant_id', + 'group_id', 'page_name', 'item_type', 'absolute_path', @@ -23,6 +24,7 @@ class ItemPage extends Model ]; protected $casts = [ + 'group_id' => 'integer', 'is_active' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -35,10 +37,70 @@ class ItemPage extends Model ]; /** - * 페이지의 섹션 목록 + * 페이지의 섹션 목록 (기존 FK 기반 - 하위 호환성) */ public function sections() { return $this->hasMany(ItemSection::class, 'page_id')->orderBy('order_no'); } -} \ No newline at end of file + + /** + * 페이지와 연결된 섹션 관계 목록 (링크 테이블 기반) + */ + public function sectionRelationships() + { + return $this->hasMany(EntityRelationship::class, 'parent_id') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('child_type', EntityRelationship::TYPE_SECTION) + ->orderBy('order_no'); + } + + /** + * 페이지와 직접 연결된 필드 관계 목록 (링크 테이블 기반) + */ + public function fieldRelationships() + { + return $this->hasMany(EntityRelationship::class, 'parent_id') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('child_type', EntityRelationship::TYPE_FIELD) + ->orderBy('order_no'); + } + + /** + * 페이지에 연결된 섹션들 조회 (링크 테이블 기반) + */ + public function linkedSections() + { + return ItemSection::whereIn('id', function ($query) { + $query->select('child_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $this->id) + ->where('child_type', EntityRelationship::TYPE_SECTION); + }); + } + + /** + * 페이지에 직접 연결된 필드들 조회 (링크 테이블 기반) + */ + public function linkedFields() + { + return ItemField::whereIn('id', function ($query) { + $query->select('child_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $this->id) + ->where('child_type', EntityRelationship::TYPE_FIELD); + }); + } + + /** + * 페이지의 모든 관계 목록 조회 (섹션 + 직접 연결된 필드) + */ + public function allRelationships() + { + return $this->hasMany(EntityRelationship::class, 'parent_id') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->orderBy('order_no'); + } +} diff --git a/app/Models/ItemMaster/ItemSection.php b/app/Models/ItemMaster/ItemSection.php index 269327d..6be472e 100644 --- a/app/Models/ItemMaster/ItemSection.php +++ b/app/Models/ItemMaster/ItemSection.php @@ -13,29 +13,52 @@ class ItemSection extends Model protected $fillable = [ 'tenant_id', + 'group_id', 'page_id', 'title', 'type', 'order_no', + 'is_template', + 'is_default', + 'description', 'created_by', 'updated_by', 'deleted_by', ]; protected $casts = [ + 'group_id' => 'integer', 'order_no' => 'integer', + 'is_template' => 'boolean', + 'is_default' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', ]; + /** + * 템플릿만 조회하는 스코프 + */ + public function scopeTemplates($query) + { + return $query->where('is_template', true); + } + + /** + * 일반 섹션만 조회하는 스코프 (템플릿 제외) + */ + public function scopeNonTemplates($query) + { + return $query->where('is_template', false); + } + protected $hidden = [ 'deleted_by', 'deleted_at', ]; /** - * 소속 페이지 + * 소속 페이지 (기존 FK 기반 - 하위 호환성) */ public function page() { @@ -43,7 +66,7 @@ public function page() } /** - * 섹션의 필드 목록 + * 섹션의 필드 목록 (기존 FK 기반 - 하위 호환성) */ public function fields() { @@ -51,10 +74,93 @@ public function fields() } /** - * 섹션의 BOM 항목 목록 + * 섹션의 BOM 항목 목록 (기존 FK 기반 - 하위 호환성) */ public function bomItems() { return $this->hasMany(ItemBomItem::class, 'section_id'); } + + /** + * 섹션과 연결된 필드 관계 목록 (링크 테이블 기반) + */ + public function fieldRelationships() + { + return $this->hasMany(EntityRelationship::class, 'parent_id') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('child_type', EntityRelationship::TYPE_FIELD) + ->orderBy('order_no'); + } + + /** + * 섹션과 연결된 BOM 관계 목록 (링크 테이블 기반) + */ + public function bomRelationships() + { + return $this->hasMany(EntityRelationship::class, 'parent_id') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('child_type', EntityRelationship::TYPE_BOM) + ->orderBy('order_no'); + } + + /** + * 섹션에 연결된 필드들 조회 (링크 테이블 기반) + */ + public function linkedFields() + { + return ItemField::whereIn('id', function ($query) { + $query->select('child_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $this->id) + ->where('child_type', EntityRelationship::TYPE_FIELD); + }); + } + + /** + * 섹션에 연결된 BOM 항목들 조회 (링크 테이블 기반) + */ + public function linkedBomItems() + { + return ItemBomItem::whereIn('id', function ($query) { + $query->select('child_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $this->id) + ->where('child_type', EntityRelationship::TYPE_BOM); + }); + } + + /** + * 이 섹션이 연결된 페이지들 조회 (링크 테이블 기반) + */ + public function linkedPages() + { + return ItemPage::whereIn('id', function ($query) { + $query->select('parent_id') + ->from('entity_relationships') + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('child_type', EntityRelationship::TYPE_SECTION) + ->where('child_id', $this->id); + }); + } + + /** + * 섹션의 모든 자식 관계 목록 조회 (필드 + BOM) + */ + public function allChildRelationships() + { + return $this->hasMany(EntityRelationship::class, 'parent_id') + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->orderBy('order_no'); + } + + /** + * 섹션의 모든 부모 관계 목록 조회 + */ + public function allParentRelationships() + { + return EntityRelationship::where('child_type', EntityRelationship::TYPE_SECTION) + ->where('child_id', $this->id); + } } diff --git a/app/Models/ItemMaster/SectionTemplate.php b/app/Models/ItemMaster/SectionTemplate.php deleted file mode 100644 index 4ddd4c7..0000000 --- a/app/Models/ItemMaster/SectionTemplate.php +++ /dev/null @@ -1,36 +0,0 @@ - 'boolean', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'deleted_at' => 'datetime', - ]; - - protected $hidden = [ - 'deleted_by', - 'deleted_at', - ]; -} \ No newline at end of file diff --git a/app/Services/ItemMaster/EntityRelationshipService.php b/app/Services/ItemMaster/EntityRelationshipService.php new file mode 100644 index 0000000..5cdd497 --- /dev/null +++ b/app/Services/ItemMaster/EntityRelationshipService.php @@ -0,0 +1,379 @@ +tenantId(); + $userId = $this->apiUserId(); + + // 페이지 존재 확인 + $page = ItemPage::where('tenant_id', $tenantId)->find($pageId); + if (! $page) { + throw new NotFoundHttpException(__('error.page_not_found')); + } + + // 섹션 존재 확인 + $section = ItemSection::where('tenant_id', $tenantId)->find($sectionId); + if (! $section) { + throw new NotFoundHttpException(__('error.section_not_found')); + } + + return EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_PAGE, + $pageId, + EntityRelationship::TYPE_SECTION, + $sectionId, + $orderNo + ); + } + + /** + * 페이지에서 섹션 연결 해제 + */ + public function unlinkSectionFromPage(int $pageId, int $sectionId): bool + { + $tenantId = $this->tenantId(); + + return EntityRelationship::unlink( + $tenantId, + EntityRelationship::TYPE_PAGE, + $pageId, + EntityRelationship::TYPE_SECTION, + $sectionId + ); + } + + /** + * 페이지에 필드 직접 연결 + */ + public function linkFieldToPage(int $pageId, int $fieldId, int $orderNo = 0): EntityRelationship + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 페이지 존재 확인 + $page = ItemPage::where('tenant_id', $tenantId)->find($pageId); + if (! $page) { + throw new NotFoundHttpException(__('error.page_not_found')); + } + + // 필드 존재 확인 + $field = ItemField::where('tenant_id', $tenantId)->find($fieldId); + if (! $field) { + throw new NotFoundHttpException(__('error.field_not_found')); + } + + return EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_PAGE, + $pageId, + EntityRelationship::TYPE_FIELD, + $fieldId, + $orderNo + ); + } + + /** + * 페이지에서 필드 연결 해제 + */ + public function unlinkFieldFromPage(int $pageId, int $fieldId): bool + { + $tenantId = $this->tenantId(); + + return EntityRelationship::unlink( + $tenantId, + EntityRelationship::TYPE_PAGE, + $pageId, + EntityRelationship::TYPE_FIELD, + $fieldId + ); + } + + /** + * 섹션에 필드 연결 + */ + public function linkFieldToSection(int $sectionId, int $fieldId, int $orderNo = 0): EntityRelationship + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 섹션 존재 확인 + $section = ItemSection::where('tenant_id', $tenantId)->find($sectionId); + if (! $section) { + throw new NotFoundHttpException(__('error.section_not_found')); + } + + // 필드 존재 확인 + $field = ItemField::where('tenant_id', $tenantId)->find($fieldId); + if (! $field) { + throw new NotFoundHttpException(__('error.field_not_found')); + } + + return EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_SECTION, + $sectionId, + EntityRelationship::TYPE_FIELD, + $fieldId, + $orderNo + ); + } + + /** + * 섹션에서 필드 연결 해제 + */ + public function unlinkFieldFromSection(int $sectionId, int $fieldId): bool + { + $tenantId = $this->tenantId(); + + return EntityRelationship::unlink( + $tenantId, + EntityRelationship::TYPE_SECTION, + $sectionId, + EntityRelationship::TYPE_FIELD, + $fieldId + ); + } + + /** + * 섹션에 BOM 항목 연결 + */ + public function linkBomToSection(int $sectionId, int $bomId, int $orderNo = 0): EntityRelationship + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 섹션 존재 확인 + $section = ItemSection::where('tenant_id', $tenantId)->find($sectionId); + if (! $section) { + throw new NotFoundHttpException(__('error.section_not_found')); + } + + // BOM 항목 존재 확인 + $bom = ItemBomItem::where('tenant_id', $tenantId)->find($bomId); + if (! $bom) { + throw new NotFoundHttpException(__('error.bom_not_found')); + } + + return EntityRelationship::link( + $tenantId, + EntityRelationship::TYPE_SECTION, + $sectionId, + EntityRelationship::TYPE_BOM, + $bomId, + $orderNo + ); + } + + /** + * 섹션에서 BOM 항목 연결 해제 + */ + public function unlinkBomFromSection(int $sectionId, int $bomId): bool + { + $tenantId = $this->tenantId(); + + return EntityRelationship::unlink( + $tenantId, + EntityRelationship::TYPE_SECTION, + $sectionId, + EntityRelationship::TYPE_BOM, + $bomId + ); + } + + /** + * 페이지의 모든 관계 조회 + */ + public function getPageRelationships(int $pageId): Collection + { + $tenantId = $this->tenantId(); + + $page = ItemPage::where('tenant_id', $tenantId)->find($pageId); + if (! $page) { + throw new NotFoundHttpException(__('error.page_not_found')); + } + + return EntityRelationship::where('tenant_id', $tenantId) + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $pageId) + ->orderBy('order_no') + ->get(); + } + + /** + * 섹션의 모든 자식 관계 조회 + */ + public function getSectionChildRelationships(int $sectionId): Collection + { + $tenantId = $this->tenantId(); + + $section = ItemSection::where('tenant_id', $tenantId)->find($sectionId); + if (! $section) { + throw new NotFoundHttpException(__('error.section_not_found')); + } + + return EntityRelationship::where('tenant_id', $tenantId) + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $sectionId) + ->orderBy('order_no') + ->get(); + } + + /** + * 특정 엔티티가 연결된 부모 목록 조회 + */ + public function getParentRelationships(string $childType, int $childId): Collection + { + $tenantId = $this->tenantId(); + + return EntityRelationship::where('tenant_id', $tenantId) + ->where('child_type', $childType) + ->where('child_id', $childId) + ->get(); + } + + /** + * 관계 순서 변경 + */ + public function reorderRelationships(string $parentType, int $parentId, array $orderedChildIds): void + { + $tenantId = $this->tenantId(); + + foreach ($orderedChildIds as $index => $item) { + EntityRelationship::where([ + 'tenant_id' => $tenantId, + 'parent_type' => $parentType, + 'parent_id' => $parentId, + 'child_type' => $item['child_type'], + 'child_id' => $item['child_id'], + ])->update(['order_no' => $index]); + } + } + + /** + * 부모의 모든 자식 관계 일괄 삭제 + */ + public function unlinkAllChildren(string $parentType, int $parentId, ?string $childType = null): int + { + $tenantId = $this->tenantId(); + + return EntityRelationship::unlinkAllChildren( + $tenantId, + $parentType, + $parentId, + $childType + ); + } + + /** + * 페이지 구조 조회 (섹션 + 직접 연결된 필드 포함) + */ + public function getPageStructure(int $pageId): array + { + $tenantId = $this->tenantId(); + + $page = ItemPage::where('tenant_id', $tenantId)->find($pageId); + if (! $page) { + throw new NotFoundHttpException(__('error.page_not_found')); + } + + // 페이지의 모든 관계 조회 + $relationships = EntityRelationship::where('tenant_id', $tenantId) + ->where('parent_type', EntityRelationship::TYPE_PAGE) + ->where('parent_id', $pageId) + ->orderBy('order_no') + ->get(); + + $structure = [ + 'page' => $page, + 'sections' => [], + 'direct_fields' => [], + ]; + + foreach ($relationships as $rel) { + if ($rel->child_type === EntityRelationship::TYPE_SECTION) { + $section = ItemSection::find($rel->child_id); + if ($section) { + // 섹션의 자식(필드/BOM) 조회 + $sectionChildren = $this->getSectionChildren($section->id); + $structure['sections'][] = [ + 'section' => $section, + 'order_no' => $rel->order_no, + 'fields' => $sectionChildren['fields'], + 'bom_items' => $sectionChildren['bom_items'], + ]; + } + } elseif ($rel->child_type === EntityRelationship::TYPE_FIELD) { + $field = ItemField::find($rel->child_id); + if ($field) { + $structure['direct_fields'][] = [ + 'field' => $field, + 'order_no' => $rel->order_no, + ]; + } + } + } + + return $structure; + } + + /** + * 섹션의 자식 엔티티 조회 + */ + private function getSectionChildren(int $sectionId): array + { + $tenantId = $this->tenantId(); + + $relationships = EntityRelationship::where('tenant_id', $tenantId) + ->where('parent_type', EntityRelationship::TYPE_SECTION) + ->where('parent_id', $sectionId) + ->orderBy('order_no') + ->get(); + + $result = [ + 'fields' => [], + 'bom_items' => [], + ]; + + foreach ($relationships as $rel) { + if ($rel->child_type === EntityRelationship::TYPE_FIELD) { + $field = ItemField::find($rel->child_id); + if ($field) { + $result['fields'][] = [ + 'field' => $field, + 'order_no' => $rel->order_no, + ]; + } + } elseif ($rel->child_type === EntityRelationship::TYPE_BOM) { + $bom = ItemBomItem::find($rel->child_id); + if ($bom) { + $result['bom_items'][] = [ + 'bom_item' => $bom, + 'order_no' => $rel->order_no, + ]; + } + } + } + + return $result; + } +} diff --git a/app/Services/ItemMaster/ItemBomItemService.php b/app/Services/ItemMaster/ItemBomItemService.php index 570e403..076c854 100644 --- a/app/Services/ItemMaster/ItemBomItemService.php +++ b/app/Services/ItemMaster/ItemBomItemService.php @@ -5,10 +5,53 @@ use App\Models\ItemMaster\ItemBomItem; use App\Models\ItemMaster\ItemSection; use App\Services\Service; +use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemBomItemService extends Service { + /** + * 독립 BOM 목록 조회 + * + * GET /api/v1/item-master/bom-items + */ + public function index(): Collection + { + $tenantId = $this->tenantId(); + + return ItemBomItem::where('tenant_id', $tenantId) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * 독립 BOM 생성 (섹션 연결 없음) + * + * POST /api/v1/item-master/bom-items + */ + public function storeIndependent(array $data): ItemBomItem + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $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, + 'unit' => $data['unit'] ?? null, + 'unit_price' => $data['unit_price'] ?? null, + 'total_price' => $data['total_price'] ?? null, + 'spec' => $data['spec'] ?? null, + 'note' => $data['note'] ?? null, + 'created_by' => $userId, + ]); + + return $bomItem; + } + /** * BOM 항목 생성 */ diff --git a/app/Services/ItemMaster/ItemFieldService.php b/app/Services/ItemMaster/ItemFieldService.php index 6d18397..30c0f9c 100644 --- a/app/Services/ItemMaster/ItemFieldService.php +++ b/app/Services/ItemMaster/ItemFieldService.php @@ -2,12 +2,139 @@ namespace App\Services\ItemMaster; +use App\Models\ItemMaster\EntityRelationship; use App\Models\ItemMaster\ItemField; use App\Services\Service; +use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemFieldService extends Service { + /** + * 독립 필드 목록 조회 + * + * GET /api/v1/item-master/fields + */ + public function index(): Collection + { + $tenantId = $this->tenantId(); + + return ItemField::where('tenant_id', $tenantId) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * 독립 필드 생성 (섹션 연결 없음) + * + * POST /api/v1/item-master/fields + */ + public function storeIndependent(array $data): ItemField + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $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, + 'is_required' => $data['is_required'] ?? false, + 'default_value' => $data['default_value'] ?? null, + 'placeholder' => $data['placeholder'] ?? null, + 'display_condition' => $data['display_condition'] ?? null, + 'validation_rules' => $data['validation_rules'] ?? null, + 'options' => $data['options'] ?? null, + 'properties' => $data['properties'] ?? null, + 'created_by' => $userId, + ]); + + return $field; + } + + /** + * 필드 복제 + * + * POST /api/v1/item-master/fields/{id}/clone + */ + public function clone(int $id): ItemField + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $original = ItemField::where('tenant_id', $tenantId) + ->where('id', $id) + ->first(); + + if (! $original) { + throw new NotFoundHttpException(__('error.field_not_found')); + } + + $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, + 'is_required' => $original->is_required, + 'default_value' => $original->default_value, + 'placeholder' => $original->placeholder, + 'display_condition' => $original->display_condition, + 'validation_rules' => $original->validation_rules, + 'options' => $original->options, + 'properties' => $original->properties, + 'created_by' => $userId, + ]); + + return $cloned; + } + + /** + * 필드 사용처 조회 (어떤 섹션/페이지에 연결되어 있는지) + * + * GET /api/v1/item-master/fields/{id}/usage + */ + public function getUsage(int $id): array + { + $tenantId = $this->tenantId(); + + $field = ItemField::where('tenant_id', $tenantId) + ->where('id', $id) + ->first(); + + if (! $field) { + 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(); + + 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), + ]; + } + /** * 필드 생성 */ diff --git a/app/Services/ItemMaster/ItemMasterService.php b/app/Services/ItemMaster/ItemMasterService.php index 2a3f24f..10c2aac 100644 --- a/app/Services/ItemMaster/ItemMasterService.php +++ b/app/Services/ItemMaster/ItemMasterService.php @@ -5,7 +5,7 @@ use App\Models\ItemMaster\CustomTab; use App\Models\ItemMaster\ItemMasterField; use App\Models\ItemMaster\ItemPage; -use App\Models\ItemMaster\SectionTemplate; +use App\Models\ItemMaster\ItemSection; use App\Models\ItemMaster\UnitOption; use App\Services\Service; @@ -15,7 +15,7 @@ class ItemMasterService extends Service * 초기화 데이터 로드 * * - pages (섹션/필드 중첩) - * - sectionTemplates + * - sectionTemplates (is_template=true인 섹션) * - masterFields * - customTabs (columnSetting 포함) * - unitOptions @@ -24,10 +24,10 @@ public function init(): array { $tenantId = $this->tenantId(); - // 1. 페이지 (섹션 → 필드 중첩) + // 1. 페이지 (섹션 → 필드 중첩) - 템플릿 제외 $pages = ItemPage::with([ 'sections' => function ($query) { - $query->orderBy('order_no'); + $query->nonTemplates()->orderBy('order_no'); }, 'sections.fields' => function ($query) { $query->orderBy('order_no'); @@ -38,8 +38,11 @@ public function init(): array ->where('is_active', 1) ->get(); - // 2. 섹션 템플릿 - $sectionTemplates = SectionTemplate::where('tenant_id', $tenantId)->get(); + // 2. 섹션 템플릿 (is_template=true인 섹션) + $sectionTemplates = ItemSection::templates() + ->where('tenant_id', $tenantId) + ->with(['fields', 'bomItems']) + ->get(); // 3. 마스터 필드 $masterFields = ItemMasterField::where('tenant_id', $tenantId)->get(); diff --git a/app/Services/ItemMaster/ItemSectionService.php b/app/Services/ItemMaster/ItemSectionService.php index 6a66280..d8c2277 100644 --- a/app/Services/ItemMaster/ItemSectionService.php +++ b/app/Services/ItemMaster/ItemSectionService.php @@ -2,12 +2,172 @@ namespace App\Services\ItemMaster; +use App\Models\ItemMaster\EntityRelationship; +use App\Models\ItemMaster\ItemBomItem; +use App\Models\ItemMaster\ItemField; use App\Models\ItemMaster\ItemSection; use App\Services\Service; +use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ItemSectionService extends Service { + /** + * 독립 섹션 목록 조회 + * + * GET /api/v1/item-master/sections + */ + public function index(?bool $isTemplate = null): Collection + { + $tenantId = $this->tenantId(); + + $query = ItemSection::where('tenant_id', $tenantId) + ->with(['fields', 'bomItems']); + + if ($isTemplate === true) { + $query->templates(); + } elseif ($isTemplate === false) { + $query->nonTemplates(); + } + + return $query->orderBy('created_at', 'desc')->get(); + } + + /** + * 독립 섹션 생성 (페이지 연결 없음) + * + * POST /api/v1/item-master/sections + */ + public function storeIndependent(array $data): ItemSection + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $section = ItemSection::create([ + 'tenant_id' => $tenantId, + 'group_id' => $data['group_id'] ?? 1, + 'page_id' => null, + 'title' => $data['title'], + 'type' => $data['type'], + 'order_no' => 0, + 'is_template' => $data['is_template'] ?? false, + 'is_default' => $data['is_default'] ?? false, + 'description' => $data['description'] ?? null, + 'created_by' => $userId, + ]); + + return $section->load(['fields', 'bomItems']); + } + + /** + * 섹션 복제 + * + * POST /api/v1/item-master/sections/{id}/clone + */ + public function clone(int $id): ItemSection + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $original = ItemSection::where('tenant_id', $tenantId) + ->where('id', $id) + ->with(['fields', 'bomItems']) + ->first(); + + if (! $original) { + throw new NotFoundHttpException(__('error.section_not_found')); + } + + // 섹션 복제 + $cloned = ItemSection::create([ + 'tenant_id' => $tenantId, + 'group_id' => $original->group_id, + 'page_id' => null, + 'title' => $original->title.' (복사본)', + 'type' => $original->type, + 'order_no' => 0, + 'is_template' => $original->is_template, + 'is_default' => false, + 'description' => $original->description, + 'created_by' => $userId, + ]); + + // 필드 복제 + foreach ($original->fields as $field) { + 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, + 'is_required' => $field->is_required, + 'default_value' => $field->default_value, + 'placeholder' => $field->placeholder, + 'display_condition' => $field->display_condition, + 'validation_rules' => $field->validation_rules, + 'options' => $field->options, + 'properties' => $field->properties, + 'created_by' => $userId, + ]); + } + + // BOM 항목 복제 + foreach ($original->bomItems as $bom) { + 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, + 'unit' => $bom->unit, + 'unit_price' => $bom->unit_price, + 'total_price' => $bom->total_price, + 'spec' => $bom->spec, + 'note' => $bom->note, + 'created_by' => $userId, + ]); + } + + return $cloned->load(['fields', 'bomItems']); + } + + /** + * 섹션 사용처 조회 (어떤 페이지에 연결되어 있는지) + * + * GET /api/v1/item-master/sections/{id}/usage + */ + public function getUsage(int $id): array + { + $tenantId = $this->tenantId(); + + $section = ItemSection::where('tenant_id', $tenantId) + ->where('id', $id) + ->first(); + + if (! $section) { + 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(); + + return [ + 'section_id' => $id, + 'direct_page' => $directPage, + 'linked_pages' => $section->linkedPages()->get(), + 'total_usage_count' => ($directPage ? 1 : 0) + count($linkedPageIds), + ]; + } + /** * 섹션 생성 */ diff --git a/app/Services/ItemMaster/SectionTemplateService.php b/app/Services/ItemMaster/SectionTemplateService.php index f041fed..f879282 100644 --- a/app/Services/ItemMaster/SectionTemplateService.php +++ b/app/Services/ItemMaster/SectionTemplateService.php @@ -2,11 +2,17 @@ namespace App\Services\ItemMaster; -use App\Models\ItemMaster\SectionTemplate; +use App\Models\ItemMaster\ItemSection; use App\Services\Service; use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +/** + * SectionTemplateService + * + * 섹션 템플릿 관리 서비스 + * 내부적으로 ItemSection (is_template=true) 사용 + */ class SectionTemplateService extends Service { /** @@ -16,7 +22,9 @@ public function index(): Collection { $tenantId = $this->tenantId(); - return SectionTemplate::where('tenant_id', $tenantId) + return ItemSection::templates() + ->where('tenant_id', $tenantId) + ->with(['fields', 'bomItems']) ->orderBy('created_at', 'desc') ->get(); } @@ -24,37 +32,42 @@ public function index(): Collection /** * 섹션 템플릿 생성 */ - public function store(array $data): SectionTemplate + public function store(array $data): ItemSection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $template = SectionTemplate::create([ + $template = ItemSection::create([ 'tenant_id' => $tenantId, + 'group_id' => 1, + 'page_id' => null, 'title' => $data['title'], 'type' => $data['type'], - 'description' => $data['description'] ?? null, + 'order_no' => 0, + 'is_template' => true, 'is_default' => $data['is_default'] ?? false, + 'description' => $data['description'] ?? null, 'created_by' => $userId, ]); - return $template; + return $template->load(['fields', 'bomItems']); } /** * 섹션 템플릿 수정 */ - public function update(int $id, array $data): SectionTemplate + public function update(int $id, array $data): ItemSection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $template = SectionTemplate::where('tenant_id', $tenantId) + $template = ItemSection::templates() + ->where('tenant_id', $tenantId) ->where('id', $id) ->first(); if (! $template) { - throw new NotFoundHttpException(__('error.not_found')); + throw new NotFoundHttpException(__('error.section_not_found')); } $template->update([ @@ -65,7 +78,7 @@ public function update(int $id, array $data): SectionTemplate 'updated_by' => $userId, ]); - return $template->fresh(); + return $template->fresh()->load(['fields', 'bomItems']); } /** @@ -76,15 +89,27 @@ public function destroy(int $id): void $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $template = SectionTemplate::where('tenant_id', $tenantId) + $template = ItemSection::templates() + ->where('tenant_id', $tenantId) ->where('id', $id) ->first(); if (! $template) { - throw new NotFoundHttpException(__('error.not_found')); + throw new NotFoundHttpException(__('error.section_not_found')); } $template->update(['deleted_by' => $userId]); $template->delete(); + + // 하위 필드/BOM도 Soft Delete + foreach ($template->fields as $field) { + $field->update(['deleted_by' => $userId]); + $field->delete(); + } + + foreach ($template->bomItems as $bomItem) { + $bomItem->update(['deleted_by' => $userId]); + $bomItem->delete(); + } } } diff --git a/app/Swagger/v1/EntityRelationshipApi.php b/app/Swagger/v1/EntityRelationshipApi.php new file mode 100644 index 0000000..05e8bba --- /dev/null +++ b/app/Swagger/v1/EntityRelationshipApi.php @@ -0,0 +1,474 @@ +string('code_group', 30) + ->default('category') + ->after('code') + ->comment('코드 그룹'); + } + }); + + // A-4. 유니크 (tenant_id, code) → (tenant_id, code_group, code) $this->dropUniqueIfColumns('categories', ['tenant_id', 'code']); $idx = collect(DB::select('SHOW INDEX FROM `categories`'))->groupBy('Key_name'); $hasTarget = false; @@ -80,7 +90,7 @@ public function up(): void ADD UNIQUE KEY `uq_tenant_codegroup_code` (`tenant_id`,`code_group`,`code`)'); } - // A-4. profile_code 추가 (common_codes.code, code_group='capability_profile') + // A-5. profile_code 추가 (common_codes.code, code_group='capability_profile') Schema::table('categories', function (Blueprint $table) { if (! Schema::hasColumn('categories', 'profile_code')) { $table->string('profile_code', 30) diff --git a/database/migrations/2025_11_11_112258_add_material_type_to_materials_table.php b/database/migrations/2025_11_11_112258_add_material_type_to_materials_table.php index 6e8dc1a..307c0d4 100644 --- a/database/migrations/2025_11_11_112258_add_material_type_to_materials_table.php +++ b/database/migrations/2025_11_11_112258_add_material_type_to_materials_table.php @@ -12,32 +12,39 @@ */ public function up(): void { - Schema::table('materials', function (Blueprint $table) { - // material_type 컬럼 추가 (SM=부자재, RM=원자재, CS=소모품) - $table->string('material_type', 10) - ->nullable() - ->after('category_id') - ->comment('자재 타입: SM=부자재, RM=원자재, CS=소모품'); + // material_type 컬럼이 없을 때만 추가 + if (! Schema::hasColumn('materials', 'material_type')) { + Schema::table('materials', function (Blueprint $table) { + // material_type 컬럼 추가 (SM=부자재, RM=원자재, CS=소모품) + $table->string('material_type', 10) + ->nullable() + ->after('category_id') + ->comment('자재 타입: SM=부자재, RM=원자재, CS=소모품'); + }); + } - // 조회 성능을 위한 인덱스 - $table->index('material_type'); - }); + // 인덱스가 없을 때만 추가 + $indexes = collect(DB::select('SHOW INDEX FROM materials')) + ->pluck('Key_name') + ->toArray(); - // 기존 데이터 업데이트 + if (! in_array('materials_material_type_index', $indexes)) { + Schema::table('materials', function (Blueprint $table) { + $table->index('material_type'); + }); + } + + // 기존 데이터 업데이트 (options 컬럼이 없으므로 기본값 'SM' 설정) DB::statement(" UPDATE materials - SET material_type = CASE - WHEN JSON_EXTRACT(options, '$.categories.item_type.code') = 'RAW' THEN 'RM' - WHEN JSON_EXTRACT(options, '$.categories.item_type.code') = 'SUB' THEN 'SM' - ELSE 'SM' - END - WHERE tenant_id = 1 - AND deleted_at IS NULL + SET material_type = 'SM' + WHERE deleted_at IS NULL + AND material_type IS NULL "); // 모든 자재에 타입이 설정되었으므로 NOT NULL로 변경 Schema::table('materials', function (Blueprint $table) { - $table->string('material_type', 10)->nullable(false)->change(); + $table->string('material_type', 10)->default('SM')->nullable(false)->change(); }); } diff --git a/database/migrations/2025_11_26_100001_convert_item_tables_to_independent_entities.php b/database/migrations/2025_11_26_100001_convert_item_tables_to_independent_entities.php new file mode 100644 index 0000000..6af987d --- /dev/null +++ b/database/migrations/2025_11_26_100001_convert_item_tables_to_independent_entities.php @@ -0,0 +1,127 @@ +dropForeign('fk_item_sections_page'); + }); + + // 2. item_fields: section_id FK 제거 (컬럼 유지, FK만 제거) + Schema::table('item_fields', function (Blueprint $table) { + $table->dropForeign('fk_item_fields_section'); + }); + + // 3. item_bom_items: section_id FK 제거 (컬럼 유지, FK만 제거) + Schema::table('item_bom_items', function (Blueprint $table) { + $table->dropForeign('fk_item_bom_items_section'); + }); + + // 4. 모든 테이블에 group_id 추가 (기본값 1 = 품목관리) + Schema::table('item_pages', function (Blueprint $table) { + $table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)'); + $table->index(['tenant_id', 'group_id'], 'idx_item_pages_tenant_group'); + }); + + Schema::table('item_sections', function (Blueprint $table) { + $table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)'); + $table->index(['tenant_id', 'group_id'], 'idx_item_sections_tenant_group'); + }); + + Schema::table('item_fields', function (Blueprint $table) { + $table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)'); + $table->index(['tenant_id', 'group_id'], 'idx_item_fields_tenant_group'); + }); + + Schema::table('item_bom_items', function (Blueprint $table) { + $table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)'); + $table->index(['tenant_id', 'group_id'], 'idx_item_bom_items_tenant_group'); + }); + + Schema::table('section_templates', function (Blueprint $table) { + $table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)'); + $table->index(['tenant_id', 'group_id'], 'idx_section_templates_tenant_group'); + }); + + Schema::table('item_master_fields', function (Blueprint $table) { + $table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)'); + $table->index(['tenant_id', 'group_id'], 'idx_item_master_fields_tenant_group'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // group_id 인덱스 및 컬럼 제거 + Schema::table('item_master_fields', function (Blueprint $table) { + $table->dropIndex('idx_item_master_fields_tenant_group'); + $table->dropColumn('group_id'); + }); + + Schema::table('section_templates', function (Blueprint $table) { + $table->dropIndex('idx_section_templates_tenant_group'); + $table->dropColumn('group_id'); + }); + + Schema::table('item_bom_items', function (Blueprint $table) { + $table->dropIndex('idx_item_bom_items_tenant_group'); + $table->dropColumn('group_id'); + }); + + Schema::table('item_fields', function (Blueprint $table) { + $table->dropIndex('idx_item_fields_tenant_group'); + $table->dropColumn('group_id'); + }); + + Schema::table('item_sections', function (Blueprint $table) { + $table->dropIndex('idx_item_sections_tenant_group'); + $table->dropColumn('group_id'); + }); + + Schema::table('item_pages', function (Blueprint $table) { + $table->dropIndex('idx_item_pages_tenant_group'); + $table->dropColumn('group_id'); + }); + + // FK 복원 + Schema::table('item_bom_items', function (Blueprint $table) { + $table->foreign('section_id', 'fk_item_bom_items_section') + ->references('id')->on('item_sections') + ->onDelete('cascade'); + }); + + Schema::table('item_fields', function (Blueprint $table) { + $table->foreign('section_id', 'fk_item_fields_section') + ->references('id')->on('item_sections') + ->onDelete('cascade'); + }); + + Schema::table('item_sections', function (Blueprint $table) { + $table->foreign('page_id', 'fk_item_sections_page') + ->references('id')->on('item_pages') + ->onDelete('cascade'); + }); + } +}; diff --git a/database/migrations/2025_11_26_100002_create_entity_relationships_table.php b/database/migrations/2025_11_26_100002_create_entity_relationships_table.php new file mode 100644 index 0000000..4f82384 --- /dev/null +++ b/database/migrations/2025_11_26_100002_create_entity_relationships_table.php @@ -0,0 +1,79 @@ +id()->comment('관계 ID'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedInteger('group_id')->default(1)->comment('그룹 ID (1: 품목관리)'); + + // 부모 엔티티 정보 + $table->string('parent_type', 50)->comment('부모 엔티티 타입 (page, section)'); + $table->unsignedBigInteger('parent_id')->comment('부모 엔티티 ID'); + + // 자식 엔티티 정보 + $table->string('child_type', 50)->comment('자식 엔티티 타입 (section, field, bom)'); + $table->unsignedBigInteger('child_id')->comment('자식 엔티티 ID'); + + // 관계 메타데이터 + $table->integer('order_no')->default(0)->comment('정렬 순서'); + $table->json('metadata')->nullable()->comment('관계 메타데이터 (추가 설정)'); + + // 감사 컬럼 + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID'); + $table->timestamps(); + + // 인덱스 + $table->index(['tenant_id', 'group_id'], 'idx_entity_rel_tenant_group'); + $table->index(['parent_type', 'parent_id'], 'idx_entity_rel_parent'); + $table->index(['child_type', 'child_id'], 'idx_entity_rel_child'); + $table->index(['parent_type', 'parent_id', 'order_no'], 'idx_entity_rel_parent_order'); + + // 유니크 제약 (동일 부모-자식 관계 중복 방지) + $table->unique( + ['tenant_id', 'group_id', 'parent_type', 'parent_id', 'child_type', 'child_id'], + 'uq_entity_rel_parent_child' + ); + + // 외래키 + $table->foreign('tenant_id', 'fk_entity_rel_tenant') + ->references('id')->on('tenants') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entity_relationships'); + } +}; diff --git a/database/migrations/2025_11_26_100003_migrate_existing_relationships_to_entity_relationships.php b/database/migrations/2025_11_26_100003_migrate_existing_relationships_to_entity_relationships.php new file mode 100644 index 0000000..c15fa05 --- /dev/null +++ b/database/migrations/2025_11_26_100003_migrate_existing_relationships_to_entity_relationships.php @@ -0,0 +1,99 @@ +whereIn('parent_type', ['page', 'section']) + ->whereIn('child_type', ['section', 'field', 'bom']) + ->delete(); + } +}; diff --git a/database/migrations/2025_11_26_200001_add_is_template_to_item_sections_and_migrate_data.php b/database/migrations/2025_11_26_200001_add_is_template_to_item_sections_and_migrate_data.php new file mode 100644 index 0000000..a0f594d --- /dev/null +++ b/database/migrations/2025_11_26_200001_add_is_template_to_item_sections_and_migrate_data.php @@ -0,0 +1,107 @@ +boolean('is_template')->default(false)->after('order_no')->comment('템플릿 여부'); + $table->boolean('is_default')->default(false)->after('is_template')->comment('기본 템플릿 여부'); + $table->text('description')->nullable()->after('is_default')->comment('설명'); + }); + + // 2. section_templates 데이터를 item_sections로 이관 + if (Schema::hasTable('section_templates')) { + $templates = DB::table('section_templates')->whereNull('deleted_at')->get(); + + foreach ($templates as $template) { + DB::table('item_sections')->insert([ + 'tenant_id' => $template->tenant_id, + 'group_id' => $template->group_id ?? 1, + 'title' => $template->title, + 'type' => $template->type, + 'order_no' => 0, + 'is_template' => true, + 'is_default' => $template->is_default, + 'description' => $template->description, + 'created_by' => $template->created_by, + 'updated_by' => $template->updated_by, + 'created_at' => $template->created_at, + 'updated_at' => $template->updated_at, + ]); + } + + // 3. section_templates 테이블 제거 + Schema::dropIfExists('section_templates'); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 1. section_templates 테이블 복원 + Schema::create('section_templates', function (Blueprint $table) { + $table->id()->comment('섹션 템플릿 ID'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedInteger('group_id')->default(1)->comment('그룹 ID'); + $table->string('title')->comment('템플릿명'); + $table->enum('type', ['fields', 'bom'])->default('fields')->comment('섹션 타입'); + $table->text('description')->nullable()->comment('설명'); + $table->boolean('is_default')->default(false)->comment('기본 템플릿 여부'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes()->comment('소프트 삭제'); + + $table->index('tenant_id', 'idx_section_templates_tenant_id'); + $table->foreign('tenant_id', 'fk_section_templates_tenant') + ->references('id')->on('tenants') + ->onDelete('cascade'); + }); + + // 2. item_sections의 is_template=true 데이터를 section_templates로 이관 + $templates = DB::table('item_sections') + ->where('is_template', true) + ->whereNull('deleted_at') + ->get(); + + foreach ($templates as $template) { + DB::table('section_templates')->insert([ + 'tenant_id' => $template->tenant_id, + 'group_id' => $template->group_id, + 'title' => $template->title, + 'type' => $template->type, + 'description' => $template->description, + 'is_default' => $template->is_default, + 'created_by' => $template->created_by, + 'updated_by' => $template->updated_by, + 'created_at' => $template->created_at, + 'updated_at' => $template->updated_at, + ]); + } + + // 3. item_sections에서 is_template=true 데이터 삭제 + DB::table('item_sections')->where('is_template', true)->delete(); + + // 4. item_sections에서 컬럼 제거 + Schema::table('item_sections', function (Blueprint $table) { + $table->dropColumn(['is_template', 'is_default', 'description']); + }); + } +}; diff --git a/docs/front/[API-2025-11-20] item-master-specification.md b/docs/front/[API-2025-11-20] item-master-specification.md new file mode 100644 index 0000000..243a782 --- /dev/null +++ b/docs/front/[API-2025-11-20] item-master-specification.md @@ -0,0 +1,1297 @@ + # 품목기준관리 API 명세서 + +**작성일**: 2025-11-20 +**버전**: v1.0 +**작성자**: 프론트엔드 개발팀 +**수신**: 백엔드 개발팀 + +--- + +## 📋 목차 + +1. [개요](#1-개요) +2. [인증 및 공통 사항](#2-인증-및-공통-사항) +3. [데이터베이스 테이블 정의](#3-데이터베이스-테이블-정의) +4. [API 엔드포인트](#4-api-엔드포인트) +5. [요청/응답 예시](#5-요청응답-예시) +6. [에러 처리](#6-에러-처리) +7. [구현 우선순위](#7-구현-우선순위) + +--- + +## 1. 개요 + +### 1.1 목적 +품목기준관리 화면에서 사용할 API 개발 요청 + +### 1.2 주요 기능 +- 품목 유형별 페이지 관리 (FG, PT, SM, RM, CS) +- 계층구조 기반 섹션 및 필드 관리 +- BOM(Bill of Materials) 항목 관리 +- 섹션 템플릿 및 마스터 필드 관리 +- 커스텀 탭 및 단위 관리 + +### 1.3 기술 요구사항 +- ✅ **Service-First 패턴** 적용 +- ✅ **Multi-tenant**: `tenant_id` 기반 격리, `BelongsToTenant` 스코프 +- ✅ **Soft Delete**: 모든 테이블 적용 +- ✅ **감사 로그**: 생성/수정/삭제 시 `audit_logs` 기록 +- ✅ **i18n**: 메시지는 `__('message.xxx')` 키만 사용 +- ✅ **실시간 저장**: 모든 CUD 작업 즉시 처리 (일괄 저장 없음) + +### 1.4 저장 전략 +**중요**: 프론트엔드에서 **실시간 저장** 방식 사용 +- 페이지/섹션/필드 생성 즉시 API 호출 +- 수정/삭제/순서변경 즉시 API 호출 +- 일괄 저장(Batch Save) API 불필요 + +--- + +## 2. 인증 및 공통 사항 + +### 2.1 인증 +``` +Headers: + X-API-KEY: {api_key} + Authorization: Bearer {sanctum_token} +``` + +### 2.2 Base URL +``` +http://api.sam.kr/api/v1/item-master +``` + +### 2.3 공통 응답 형식 + +**성공 응답**: +```json +{ + "success": true, + "message": "message.created", + "data": { ... } +} +``` + +**에러 응답**: +```json +{ + "success": false, + "message": "error.validation_failed", + "errors": { + "page_name": ["페이지명은 필수입니다."] + } +} +``` + +### 2.4 공통 컬럼 +모든 테이블에 다음 컬럼 포함: +- `tenant_id` (BIGINT, NOT NULL, INDEX) +- `created_by` (BIGINT, NULL) +- `updated_by` (BIGINT, NULL) +- `deleted_by` (BIGINT, NULL) +- `created_at` (TIMESTAMP) +- `updated_at` (TIMESTAMP) +- `deleted_at` (TIMESTAMP, NULL) - Soft Delete + +--- + +## 3. 데이터베이스 테이블 정의 + +### 3.1 item_pages (품목 페이지) + +```sql +CREATE TABLE item_pages ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + page_name VARCHAR(255) NOT NULL COMMENT '페이지명', + item_type ENUM('FG', 'PT', 'SM', 'RM', 'CS') NOT NULL COMMENT '품목 유형 (완제품/반제품/부자재/원자재/소모품)', + absolute_path VARCHAR(500) NULL COMMENT '절대 경로', + is_active TINYINT(1) DEFAULT 1 COMMENT '활성 여부', + created_by BIGINT UNSIGNED NULL COMMENT '생성자 ID', + updated_by BIGINT UNSIGNED NULL COMMENT '수정자 ID', + deleted_by BIGINT UNSIGNED NULL COMMENT '삭제자 ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_id (tenant_id), + INDEX idx_item_type (item_type), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='품목 페이지'; +``` + +### 3.2 item_sections (섹션 인스턴스) + +```sql +CREATE TABLE item_sections ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + page_id BIGINT UNSIGNED NOT NULL COMMENT '페이지 ID', + title VARCHAR(255) NOT NULL COMMENT '섹션 제목', + type ENUM('fields', 'bom') NOT NULL DEFAULT 'fields' COMMENT '섹션 타입 (필드형/BOM형)', + order_no INT NOT NULL DEFAULT 0 COMMENT '정렬 순서', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_page (tenant_id, page_id), + INDEX idx_order (page_id, order_no), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (page_id) REFERENCES item_pages(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='품목 섹션 인스턴스'; +``` + +### 3.3 item_fields (필드) + +```sql +CREATE TABLE item_fields ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + section_id BIGINT UNSIGNED NOT NULL COMMENT '섹션 ID', + field_name VARCHAR(255) NOT NULL COMMENT '필드명', + field_type ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea') NOT NULL COMMENT '필드 타입', + order_no INT NOT NULL DEFAULT 0 COMMENT '정렬 순서', + is_required TINYINT(1) DEFAULT 0 COMMENT '필수 여부', + default_value TEXT NULL COMMENT '기본값', + placeholder VARCHAR(255) NULL COMMENT '플레이스홀더', + display_condition JSON NULL COMMENT '표시 조건 {"field_id": "1", "operator": "equals", "value": "true"}', + validation_rules JSON NULL COMMENT '검증 규칙 {"min": 0, "max": 100, "pattern": "regex"}', + options JSON NULL COMMENT '드롭다운 옵션 [{"label": "옵션1", "value": "val1"}]', + properties JSON NULL COMMENT '필드 속성 {"unit": "mm", "precision": 2, "format": "YYYY-MM-DD"}', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_section (tenant_id, section_id), + INDEX idx_order (section_id, order_no), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (section_id) REFERENCES item_sections(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='품목 필드'; +``` + +### 3.4 item_bom_items (BOM 항목) + +```sql +CREATE TABLE item_bom_items ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + section_id BIGINT UNSIGNED NOT NULL COMMENT '섹션 ID', + item_code VARCHAR(100) NULL COMMENT '품목 코드', + item_name VARCHAR(255) NOT NULL COMMENT '품목명', + quantity DECIMAL(15, 4) NOT NULL DEFAULT 0 COMMENT '수량', + unit VARCHAR(50) NULL COMMENT '단위', + unit_price DECIMAL(15, 2) NULL COMMENT '단가', + total_price DECIMAL(15, 2) NULL COMMENT '총액', + spec TEXT NULL COMMENT '사양', + note TEXT NULL COMMENT '비고', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_section (tenant_id, section_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (section_id) REFERENCES item_sections(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BOM 항목'; +``` + +### 3.5 section_templates (섹션 템플릿) + +```sql +CREATE TABLE section_templates ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + title VARCHAR(255) NOT NULL COMMENT '템플릿명', + type ENUM('fields', 'bom') NOT NULL DEFAULT 'fields' COMMENT '섹션 타입', + description TEXT NULL COMMENT '설명', + is_default TINYINT(1) DEFAULT 0 COMMENT '기본 템플릿 여부', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='섹션 템플릿'; +``` + +### 3.6 item_master_fields (마스터 필드) + +```sql +CREATE TABLE item_master_fields ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + field_name VARCHAR(255) NOT NULL COMMENT '필드명', + field_type ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea') NOT NULL COMMENT '필드 타입', + category VARCHAR(100) NULL COMMENT '카테고리', + description TEXT NULL COMMENT '설명', + is_common TINYINT(1) DEFAULT 0 COMMENT '공통 필드 여부', + default_value TEXT NULL COMMENT '기본값', + options JSON NULL COMMENT '옵션', + validation_rules JSON NULL COMMENT '검증 규칙', + properties JSON NULL COMMENT '속성', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + INDEX idx_category (category), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='마스터 필드'; +``` + +### 3.7 custom_tabs (커스텀 탭) + +```sql +CREATE TABLE custom_tabs ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + label VARCHAR(255) NOT NULL COMMENT '탭 라벨', + icon VARCHAR(100) NULL COMMENT '아이콘', + is_default TINYINT(1) DEFAULT 0 COMMENT '기본 탭 여부', + order_no INT NOT NULL DEFAULT 0 COMMENT '정렬 순서', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + INDEX idx_order (tenant_id, order_no), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='커스텀 탭'; +``` + +### 3.8 tab_columns (탭별 컬럼 설정) + +```sql +CREATE TABLE tab_columns ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + tab_id BIGINT UNSIGNED NOT NULL COMMENT '탭 ID', + columns JSON NOT NULL COMMENT '컬럼 설정 [{"key": "name", "label": "품목명", "visible": true, "order": 0}]', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_tenant_tab (tenant_id, tab_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (tab_id) REFERENCES custom_tabs(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='탭별 컬럼 설정'; +``` + +### 3.9 unit_options (단위 옵션) + +```sql +CREATE TABLE unit_options ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + label VARCHAR(100) NOT NULL COMMENT '단위 라벨', + value VARCHAR(50) NOT NULL COMMENT '단위 값', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='단위 옵션'; +``` + +--- + +## 4. API 엔드포인트 + +### 4.1 초기화 API + +#### `GET /v1/item-master/init` +**목적**: 화면 진입 시 전체 초기 데이터 로드 + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.fetched", + "data": { + "pages": [ + { + "id": 1, + "page_name": "완제품 A", + "item_type": "FG", + "absolute_path": "/FG/완제품 A", + "is_active": true, + "sections": [ + { + "id": 1, + "title": "기본 정보", + "type": "fields", + "order_no": 0, + "fields": [...] + } + ] + } + ], + "sectionTemplates": [...], + "masterFields": [...], + "customTabs": [...], + "tabColumns": {...}, + "unitOptions": [...] + } +} +``` + +**참고**: +- `pages`는 `sections`, `fields`, `bomItems`를 중첩(nested) 포함 +- 한 번의 API 호출로 모든 데이터 로드 + +--- + +### 4.2 페이지 관리 + +#### `GET /v1/item-master/pages` +**목적**: 페이지 목록 조회 (섹션/필드 포함) + +**Query Parameters**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| `item_type` | string | 선택 | 품목 유형 필터 (FG, PT, SM, RM, CS) | + +**Response**: 초기화 API의 `data.pages`와 동일 + +--- + +#### `POST /v1/item-master/pages` +**목적**: 페이지 생성 + +**Request Body**: +```json +{ + "page_name": "완제품 A", + "item_type": "FG", + "absolute_path": "/FG/완제품 A" +} +``` + +**Validation**: +- `page_name`: required, string, max:255 +- `item_type`: required, in:FG,PT,SM,RM,CS +- `absolute_path`: nullable, string, max:500 + +**Response**: +```json +{ + "success": true, + "message": "message.created", + "data": { + "id": 1, + "page_name": "완제품 A", + "item_type": "FG", + "absolute_path": "/FG/완제품 A", + "is_active": true, + "sections": [], + "created_at": "2025-11-20T10:00:00.000000Z", + "updated_at": "2025-11-20T10:00:00.000000Z" + } +} +``` + +--- + +#### `PUT /v1/item-master/pages/{id}` +**목적**: 페이지 수정 + +**Request Body**: +```json +{ + "page_name": "완제품 A (수정)", + "absolute_path": "/FG/완제품 A (수정)" +} +``` + +**Validation**: +- `page_name`: string, max:255 +- `absolute_path`: nullable, string, max:500 + +**Response**: 수정된 페이지 정보 반환 + +--- + +#### `DELETE /v1/item-master/pages/{id}` +**목적**: 페이지 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +**참고**: +- Soft Delete 처리 (`deleted_at` 업데이트) +- 하위 섹션/필드도 함께 Soft Delete 처리 (Cascade) + +--- + +### 4.3 섹션 관리 + +#### `POST /v1/item-master/pages/{pageId}/sections` +**목적**: 섹션 생성 + +**Request Body**: +```json +{ + "title": "기본 정보", + "type": "fields" +} +``` + +**Validation**: +- `title`: required, string, max:255 +- `type`: required, in:fields,bom + +**Response**: +```json +{ + "success": true, + "message": "message.created", + "data": { + "id": 1, + "page_id": 1, + "title": "기본 정보", + "type": "fields", + "order_no": 0, + "fields": [] + } +} +``` + +**참고**: +- `order_no`는 자동 계산 (해당 페이지의 마지막 섹션 order + 1) + +--- + +#### `PUT /v1/item-master/sections/{id}` +**목적**: 섹션 수정 + +**Request Body**: +```json +{ + "title": "기본 정보 (수정)" +} +``` + +**Validation**: +- `title`: string, max:255 + +**Response**: 수정된 섹션 정보 반환 + +--- + +#### `DELETE /v1/item-master/sections/{id}` +**목적**: 섹션 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +**참고**: 하위 필드도 함께 Soft Delete 처리 + +--- + +#### `PUT /v1/item-master/pages/{pageId}/sections/reorder` +**목적**: 섹션 순서 변경 (드래그 앤 드롭) + +**Request Body**: +```json +{ + "section_orders": [ + {"id": 2, "order_no": 0}, + {"id": 1, "order_no": 1}, + {"id": 3, "order_no": 2} + ] +} +``` + +**Validation**: +- `section_orders`: required, array +- `section_orders.*.id`: required, exists:item_sections,id +- `section_orders.*.order_no`: required, integer, min:0 + +**Response**: +```json +{ + "success": true, + "message": "message.reordered" +} +``` + +--- + +### 4.4 필드 관리 + +#### `POST /v1/item-master/sections/{sectionId}/fields` +**목적**: 필드 생성 + +**Request Body**: +```json +{ + "field_name": "제품명", + "field_type": "textbox", + "is_required": true, + "placeholder": "제품명을 입력하세요", + "validation_rules": { + "min": 1, + "max": 100 + }, + "properties": { + "unit": "mm", + "precision": 2 + } +} +``` + +**Validation**: +- `field_name`: required, string, max:255 +- `field_type`: required, in:textbox,number,dropdown,checkbox,date,textarea +- `is_required`: boolean +- `placeholder`: nullable, string, max:255 +- `validation_rules`: nullable, json +- `properties`: nullable, json +- `options`: nullable, json (field_type=dropdown일 때 필수) + +**Response**: +```json +{ + "success": true, + "message": "message.created", + "data": { + "id": 1, + "section_id": 1, + "field_name": "제품명", + "field_type": "textbox", + "order_no": 0, + "is_required": true, + "placeholder": "제품명을 입력하세요", + "validation_rules": {...}, + "properties": {...} + } +} +``` + +--- + +#### `PUT /v1/item-master/fields/{id}` +**목적**: 필드 수정 + +**Request Body**: 생성과 동일 (모든 필드 선택적) + +**Response**: 수정된 필드 정보 반환 + +--- + +#### `DELETE /v1/item-master/fields/{id}` +**목적**: 필드 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +#### `PUT /v1/item-master/sections/{sectionId}/fields/reorder` +**목적**: 필드 순서 변경 + +**Request Body**: +```json +{ + "field_orders": [ + {"id": 3, "order_no": 0}, + {"id": 1, "order_no": 1}, + {"id": 2, "order_no": 2} + ] +} +``` + +**Validation**: 섹션 순서 변경과 동일 + +**Response**: +```json +{ + "success": true, + "message": "message.reordered" +} +``` + +--- + +### 4.5 BOM 관리 + +#### `POST /v1/item-master/sections/{sectionId}/bom-items` +**목적**: BOM 항목 생성 + +**Request Body**: +```json +{ + "item_code": "MAT-001", + "item_name": "철판", + "quantity": 10.5, + "unit": "kg", + "unit_price": 5000.00, + "total_price": 52500.00, + "spec": "두께 2mm, 스테인리스", + "note": "비고 사항" +} +``` + +**Validation**: +- `item_code`: nullable, string, max:100 +- `item_name`: required, string, max:255 +- `quantity`: required, numeric, min:0 +- `unit`: nullable, string, max:50 +- `unit_price`: nullable, numeric, min:0 +- `total_price`: nullable, numeric, min:0 +- `spec`: nullable, string +- `note`: nullable, string + +**Response**: +```json +{ + "success": true, + "message": "message.created", + "data": { + "id": 1, + "section_id": 1, + "item_code": "MAT-001", + "item_name": "철판", + "quantity": 10.5, + "unit": "kg", + "unit_price": 5000.00, + "total_price": 52500.00, + "spec": "두께 2mm, 스테인리스", + "note": "비고 사항" + } +} +``` + +--- + +#### `PUT /v1/item-master/bom-items/{id}` +**목적**: BOM 항목 수정 + +**Request Body**: 생성과 동일 (모든 필드 선택적) + +**Response**: 수정된 BOM 항목 반환 + +--- + +#### `DELETE /v1/item-master/bom-items/{id}` +**목적**: BOM 항목 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +### 4.6 섹션 템플릿 + +#### `GET /v1/item-master/section-templates` +**목적**: 섹션 템플릿 목록 조회 + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.fetched", + "data": [ + { + "id": 1, + "title": "기본 정보 템플릿", + "type": "fields", + "description": "제품 기본 정보 입력용 템플릿", + "is_default": true + } + ] +} +``` + +--- + +#### `POST /v1/item-master/section-templates` +**목적**: 섹션 템플릿 생성 + +**Request Body**: +```json +{ + "title": "기본 정보 템플릿", + "type": "fields", + "description": "제품 기본 정보 입력용 템플릿", + "is_default": false +} +``` + +**Validation**: +- `title`: required, string, max:255 +- `type`: required, in:fields,bom +- `description`: nullable, string +- `is_default`: boolean + +**Response**: 생성된 템플릿 정보 반환 + +--- + +#### `PUT /v1/item-master/section-templates/{id}` +**목적**: 섹션 템플릿 수정 + +**Request Body**: 생성과 동일 (모든 필드 선택적) + +**Response**: 수정된 템플릿 정보 반환 + +--- + +#### `DELETE /v1/item-master/section-templates/{id}` +**목적**: 섹션 템플릿 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +### 4.7 마스터 필드 + +#### `GET /v1/item-master/master-fields` +**목적**: 마스터 필드 목록 조회 + +**Query Parameters**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| `category` | string | 선택 | 카테고리 필터 | + +**Response**: +```json +{ + "success": true, + "message": "message.fetched", + "data": [ + { + "id": 1, + "field_name": "제품명", + "field_type": "textbox", + "category": "기본정보", + "description": "제품 이름", + "is_common": true, + "options": null, + "validation_rules": {"max": 100}, + "properties": null + } + ] +} +``` + +--- + +#### `POST /v1/item-master/master-fields` +**목적**: 마스터 필드 생성 + +**Request Body**: +```json +{ + "field_name": "제품명", + "field_type": "textbox", + "category": "기본정보", + "description": "제품 이름", + "is_common": true, + "validation_rules": {"max": 100} +} +``` + +**Validation**: +- `field_name`: required, string, max:255 +- `field_type`: required, in:textbox,number,dropdown,checkbox,date,textarea +- `category`: nullable, string, max:100 +- `description`: nullable, string +- `is_common`: boolean +- `options`: nullable, json +- `validation_rules`: nullable, json +- `properties`: nullable, json + +**Response**: 생성된 마스터 필드 정보 반환 + +--- + +#### `PUT /v1/item-master/master-fields/{id}` +**목적**: 마스터 필드 수정 + +**Request Body**: 생성과 동일 (모든 필드 선택적) + +**Response**: 수정된 마스터 필드 정보 반환 + +--- + +#### `DELETE /v1/item-master/master-fields/{id}` +**목적**: 마스터 필드 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +### 4.8 커스텀 탭 + +#### `GET /v1/item-master/custom-tabs` +**목적**: 커스텀 탭 목록 조회 + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.fetched", + "data": [ + { + "id": 1, + "label": "품질", + "icon": "quality-icon", + "is_default": false, + "order_no": 0 + } + ] +} +``` + +--- + +#### `POST /v1/item-master/custom-tabs` +**목적**: 커스텀 탭 생성 + +**Request Body**: +```json +{ + "label": "품질", + "icon": "quality-icon", + "is_default": false +} +``` + +**Validation**: +- `label`: required, string, max:255 +- `icon`: nullable, string, max:100 +- `is_default`: boolean + +**Response**: 생성된 탭 정보 반환 (order_no 자동 계산) + +--- + +#### `PUT /v1/item-master/custom-tabs/{id}` +**목적**: 커스텀 탭 수정 + +**Request Body**: 생성과 동일 (모든 필드 선택적) + +**Response**: 수정된 탭 정보 반환 + +--- + +#### `DELETE /v1/item-master/custom-tabs/{id}` +**목적**: 커스텀 탭 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +#### `PUT /v1/item-master/custom-tabs/reorder` +**목적**: 탭 순서 변경 + +**Request Body**: +```json +{ + "tab_orders": [ + {"id": 2, "order_no": 0}, + {"id": 1, "order_no": 1} + ] +} +``` + +**Validation**: 섹션 순서 변경과 동일 + +**Response**: +```json +{ + "success": true, + "message": "message.reordered" +} +``` + +--- + +#### `PUT /v1/item-master/custom-tabs/{id}/columns` +**목적**: 탭별 컬럼 설정 + +**Request Body**: +```json +{ + "columns": [ + {"key": "name", "label": "품목명", "visible": true, "order": 0}, + {"key": "code", "label": "품목코드", "visible": true, "order": 1}, + {"key": "price", "label": "가격", "visible": false, "order": 2} + ] +} +``` + +**Validation**: +- `columns`: required, array +- `columns.*.key`: required, string +- `columns.*.label`: required, string +- `columns.*.visible`: required, boolean +- `columns.*.order`: required, integer + +**Response**: +```json +{ + "success": true, + "message": "message.updated", + "data": { + "tab_id": 1, + "columns": [...] + } +} +``` + +--- + +### 4.9 단위 관리 + +#### `GET /v1/item-master/units` +**목적**: 단위 목록 조회 + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.fetched", + "data": [ + {"id": 1, "label": "킬로그램", "value": "kg"}, + {"id": 2, "label": "미터", "value": "m"} + ] +} +``` + +--- + +#### `POST /v1/item-master/units` +**목적**: 단위 생성 + +**Request Body**: +```json +{ + "label": "킬로그램", + "value": "kg" +} +``` + +**Validation**: +- `label`: required, string, max:100 +- `value`: required, string, max:50 + +**Response**: 생성된 단위 정보 반환 + +--- + +#### `DELETE /v1/item-master/units/{id}` +**목적**: 단위 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +## 5. 요청/응답 예시 + +### 5.1 페이지 생성 → 섹션 추가 → 필드 추가 흐름 + +**1단계: 페이지 생성** +```bash +POST /v1/item-master/pages +{ + "page_name": "완제품 A", + "item_type": "FG", + "absolute_path": "/FG/완제품 A" +} + +→ Response: {"data": {"id": 1, ...}} +``` + +**2단계: 섹션 추가** +```bash +POST /v1/item-master/pages/1/sections +{ + "title": "기본 정보", + "type": "fields" +} + +→ Response: {"data": {"id": 1, "page_id": 1, ...}} +``` + +**3단계: 필드 추가** +```bash +POST /v1/item-master/sections/1/fields +{ + "field_name": "제품명", + "field_type": "textbox", + "is_required": true +} + +→ Response: {"data": {"id": 1, "section_id": 1, ...}} +``` + +--- + +### 5.2 BOM 섹션 생성 → BOM 항목 추가 + +**1단계: BOM 섹션 생성** +```bash +POST /v1/item-master/pages/1/sections +{ + "title": "자재 목록", + "type": "bom" +} + +→ Response: {"data": {"id": 2, "type": "bom", ...}} +``` + +**2단계: BOM 항목 추가** +```bash +POST /v1/item-master/sections/2/bom-items +{ + "item_name": "철판", + "quantity": 10.5, + "unit": "kg", + "unit_price": 5000 +} + +→ Response: {"data": {"id": 1, "section_id": 2, ...}} +``` + +--- + +## 6. 에러 처리 + +### 6.1 에러 응답 형식 + +```json +{ + "success": false, + "message": "error.validation_failed", + "errors": { + "page_name": ["페이지명은 필수입니다."], + "item_type": ["올바른 품목 유형을 선택하세요."] + } +} +``` + +### 6.2 주요 에러 코드 + +| HTTP 상태 | message 키 | 설명 | +|----------|-----------|------| +| 400 | error.validation_failed | 유효성 검증 실패 | +| 401 | error.unauthorized | 인증 실패 | +| 403 | error.forbidden | 권한 없음 | +| 404 | error.not_found | 리소스를 찾을 수 없음 | +| 422 | error.unprocessable | 처리할 수 없는 요청 | +| 500 | error.internal_server | 서버 내부 오류 | + +### 6.3 Tenant 격리 에러 + +```json +{ + "success": false, + "message": "error.forbidden", + "errors": { + "tenant_id": ["다른 테넌트의 리소스에 접근할 수 없습니다."] + } +} +``` + +**참고**: `BelongsToTenant` 스코프가 자동으로 처리하므로 404 반환 + +--- + +## 7. 구현 우선순위 + +### 🔴 우선순위 1 (필수 - 화면 기본 동작) + +1. **초기화 API**: `GET /v1/item-master/init` +2. **페이지 CRUD**: + - `GET /v1/item-master/pages` + - `POST /v1/item-master/pages` + - `PUT /v1/item-master/pages/{id}` + - `DELETE /v1/item-master/pages/{id}` +3. **섹션 CRUD**: + - `POST /v1/item-master/pages/{pageId}/sections` + - `PUT /v1/item-master/sections/{id}` + - `DELETE /v1/item-master/sections/{id}` +4. **필드 CRUD**: + - `POST /v1/item-master/sections/{sectionId}/fields` + - `PUT /v1/item-master/fields/{id}` + - `DELETE /v1/item-master/fields/{id}` + +### 🟡 우선순위 2 (중요 - 핵심 기능) + +5. **BOM 관리**: + - `POST /v1/item-master/sections/{sectionId}/bom-items` + - `PUT /v1/item-master/bom-items/{id}` + - `DELETE /v1/item-master/bom-items/{id}` +6. **순서 변경**: + - `PUT /v1/item-master/pages/{pageId}/sections/reorder` + - `PUT /v1/item-master/sections/{sectionId}/fields/reorder` +7. **단위 관리**: + - `GET /v1/item-master/units` + - `POST /v1/item-master/units` + - `DELETE /v1/item-master/units/{id}` + +### 🟢 우선순위 3 (부가 기능) + +8. **섹션 템플릿**: 전체 CRUD +9. **마스터 필드**: 전체 CRUD +10. **커스텀 탭**: 전체 CRUD + 컬럼 설정 + +--- + +## 📌 참고 사항 + +### 1. Cascade 삭제 정책 +- 페이지 삭제 시 → 하위 섹션/필드 모두 Soft Delete +- 섹션 삭제 시 → 하위 필드 모두 Soft Delete +- 모두 `deleted_at` 업데이트로 처리 + +### 2. order_no 자동 계산 +- 새로운 섹션/필드 생성 시 자동으로 마지막 순서 + 1 +- 프론트엔드에서 order_no 전달 불필요 + +### 3. Nested 조회 최적화 +- `GET /v1/item-master/pages`: with(['sections.fields', 'sections.bomItems']) +- Eager Loading으로 N+1 문제 방지 + +### 4. 감사 로그 +- 모든 생성/수정/삭제 시 `audit_logs` 기록 +- `action`: created, updated, deleted +- `before`, `after` 필드에 변경 전후 데이터 JSON 저장 + +### 5. i18n 메시지 키 +```php +// lang/ko/message.php +return [ + 'fetched' => '조회되었습니다.', + 'created' => '생성되었습니다.', + 'updated' => '수정되었습니다.', + 'deleted' => '삭제되었습니다.', + 'reordered' => '순서가 변경되었습니다.', +]; +``` + +--- + +## ✅ 체크리스트 + +백엔드 개발 완료 전 확인사항: + +``` +□ Service-First 패턴 적용 (Controller는 DI + Service 호출만) +□ BelongsToTenant scope 모든 모델에 적용 +□ SoftDeletes 모든 모델에 적용 +□ 공통 컬럼 (tenant_id, created_by, updated_by, deleted_by) 포함 +□ 감사 로그 생성/수정/삭제 시 기록 +□ i18n 메시지 키 사용 (__('message.xxx')) +□ FormRequest 검증 +□ Swagger 문서화 (app/Swagger/v1/ItemMasterApi.php) +□ Cascade 삭제 정책 적용 +□ Nested 조회 최적화 (Eager Loading) +□ order_no 자동 계산 로직 +□ 실시간 저장 지원 (일괄 저장 없음) +``` + +--- + +## 📞 문의 + +**프론트엔드 개발팀**: [연락처] +**백엔드 개발팀**: [연락처] + +--- + +**문서 버전**: v1.0 +**작성일**: 2025-11-20 +**다음 리뷰 예정일**: 백엔드 구현 완료 후 diff --git a/docs/front/[API-2025-11-25] item-master-data-management-api-request.md b/docs/front/[API-2025-11-25] item-master-data-management-api-request.md new file mode 100644 index 0000000..ccc09db --- /dev/null +++ b/docs/front/[API-2025-11-25] item-master-data-management-api-request.md @@ -0,0 +1,841 @@ +# 품목기준관리 API 요청서 + +**작성일**: 2025-11-25 +**요청자**: 프론트엔드 개발팀 +**대상**: 백엔드 개발팀 +**프로젝트**: SAM MES System - 품목기준관리 (Item Master Data Management) + +--- + +## 1. 개요 + +### 1.1 목적 +품목기준관리 화면에서 품목의 메타데이터(페이지, 섹션, 필드)를 동적으로 정의하기 위한 백엔드 API 개발 요청 + +### 1.2 프론트엔드 구현 현황 +- 프론트엔드 UI 구현 완료 +- API 클라이언트 코드 작성 완료 (`src/lib/api/item-master.ts`) +- 타입 정의 완료 (`src/types/item-master-api.ts`) +- Next.js API 프록시 구조 적용 (HttpOnly 쿠키 인증) + +### 1.3 API 기본 정보 +| 항목 | 값 | +|------|-----| +| Base URL | `/api/v1/item-master` | +| 인증 방식 | `auth.apikey + auth:sanctum` (HttpOnly Cookie) | +| Content-Type | `application/json` | +| 응답 형식 | 표준 API 응답 래퍼 사용 | + +### 1.4 표준 응답 형식 +```json +{ + "success": true, + "message": "message.fetched", + "data": { ... } +} +``` + +--- + +## 2. 필수 API 엔드포인트 + +### 2.1 초기화 API (최우선) + +#### `GET /api/v1/item-master/init` + +**목적**: 화면 진입 시 전체 데이터를 한 번에 로드 + +**Request**: 없음 (JWT에서 tenant_id 자동 추출) + +**Response**: +```typescript +interface InitResponse { + pages: ItemPageResponse[]; // 페이지 목록 (섹션, 필드 포함) + sectionTemplates: SectionTemplateResponse[]; // 섹션 템플릿 목록 + masterFields: MasterFieldResponse[]; // 마스터 필드 목록 + customTabs: CustomTabResponse[]; // 커스텀 탭 목록 + tabColumns: Record; // 탭별 컬럼 설정 + unitOptions: UnitOptionResponse[]; // 단위 옵션 목록 +} +``` + +**중요**: `pages` 응답 시 `sections`와 `fields`를 Nested로 포함해야 함 + +**예시 응답**: +```json +{ + "success": true, + "message": "message.fetched", + "data": { + "pages": [ + { + "id": 1, + "page_name": "기본정보", + "item_type": "FG", + "is_active": true, + "sections": [ + { + "id": 1, + "title": "품목코드 정보", + "type": "fields", + "order_no": 1, + "fields": [ + { + "id": 1, + "field_name": "품목코드", + "field_type": "textbox", + "is_required": true, + "master_field_id": null, + "order_no": 1 + } + ] + } + ] + } + ], + "sectionTemplates": [...], + "masterFields": [...], + "customTabs": [...], + "tabColumns": {...}, + "unitOptions": [...] + } +} +``` + +--- + +### 2.2 페이지 관리 API + +#### `POST /api/v1/item-master/pages` +**목적**: 새 페이지 생성 + +**Request Body**: +```typescript +interface ItemPageRequest { + page_name: string; // 페이지명 (필수) + item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형 (필수) + absolute_path?: string; // 절대경로 (선택) + is_active?: boolean; // 활성화 여부 (기본: true) +} +``` + +**Response**: `ItemPageResponse` + +--- + +#### `PUT /api/v1/item-master/pages/{id}` +**목적**: 페이지 수정 + +**Path Parameter**: `id` - 페이지 ID + +**Request Body**: `Partial` + +**Response**: `ItemPageResponse` + +--- + +#### `DELETE /api/v1/item-master/pages/{id}` +**목적**: 페이지 삭제 (Soft Delete) + +**Path Parameter**: `id` - 페이지 ID + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +### 2.3 섹션 관리 API + +#### `POST /api/v1/item-master/pages/{pageId}/sections` +**목적**: 페이지에 새 섹션 추가 + +**Path Parameter**: `pageId` - 페이지 ID + +**Request Body**: +```typescript +interface ItemSectionRequest { + title: string; // 섹션명 (필수) + type: 'fields' | 'bom'; // 섹션 타입 (필수) + template_id?: number; // 템플릿 ID (선택) - 템플릿에서 생성 시 +} +``` + +**중요 - 템플릿 적용 로직**: +- `template_id`가 전달되면 해당 템플릿의 필드들을 복사하여 새 섹션에 추가 +- 템플릿의 필드들은 `master_field_id` 연결 관계도 복사 + +**Response**: `ItemSectionResponse` (생성된 섹션 + 필드 포함) + +--- + +#### `PUT /api/v1/item-master/sections/{id}` +**목적**: 섹션 수정 (제목 변경 등) + +**Path Parameter**: `id` - 섹션 ID + +**Request Body**: `Partial` + +**Response**: `ItemSectionResponse` + +--- + +#### `DELETE /api/v1/item-master/sections/{id}` +**목적**: 섹션 삭제 + +**Path Parameter**: `id` - 섹션 ID + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +#### `PUT /api/v1/item-master/pages/{pageId}/sections/reorder` +**목적**: 섹션 순서 변경 (드래그앤드롭) + +**Path Parameter**: `pageId` - 페이지 ID + +**Request Body**: +```typescript +interface SectionReorderRequest { + section_orders: Array<{ + id: number; // 섹션 ID + order_no: number; // 새 순서 + }>; +} +``` + +**Response**: `ItemSectionResponse[]` + +--- + +### 2.4 필드 관리 API + +#### `POST /api/v1/item-master/sections/{sectionId}/fields` +**목적**: 섹션에 새 필드 추가 + +**Path Parameter**: `sectionId` - 섹션 ID + +**Request Body**: +```typescript +interface ItemFieldRequest { + field_name: string; // 필드명 (필수) + field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입 (필수) + + // 마스터 필드 연결 (핵심 기능) + master_field_id?: number; // 마스터 필드 ID (마스터에서 선택한 경우) + + // 선택 속성 + is_required?: boolean; + placeholder?: string; + default_value?: string; + options?: Array<{ label: string; value: string }>; // dropdown 옵션 + validation_rules?: Record; + properties?: Record; + + // 조건부 표시 설정 (신규 기능) + display_condition?: { + field_key: string; // 조건 필드 키 + expected_value: string; // 예상 값 + target_field_ids?: string[]; // 표시할 필드 ID 목록 + target_section_ids?: string[]; // 표시할 섹션 ID 목록 + }[]; +} +``` + +**중요 - master_field_id 처리**: +- 프론트엔드에서 "마스터 항목 선택" 모드로 필드 추가 시 `master_field_id` 전달 +- 백엔드에서 해당 마스터 필드의 속성을 참조하여 기본값 설정 +- 마스터 필드가 수정되면 연결된 필드도 동기화 필요 (옵션) + +**Response**: `ItemFieldResponse` + +--- + +#### `PUT /api/v1/item-master/fields/{id}` +**목적**: 필드 수정 + +**Path Parameter**: `id` - 필드 ID + +**Request Body**: `Partial` + +**Response**: `ItemFieldResponse` + +--- + +#### `DELETE /api/v1/item-master/fields/{id}` +**목적**: 필드 삭제 + +**Path Parameter**: `id` - 필드 ID + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +#### `PUT /api/v1/item-master/sections/{sectionId}/fields/reorder` +**목적**: 필드 순서 변경 (드래그앤드롭) + +**Path Parameter**: `sectionId` - 섹션 ID + +**Request Body**: +```typescript +interface FieldReorderRequest { + field_orders: Array<{ + id: number; // 필드 ID + order_no: number; // 새 순서 + }>; +} +``` + +**Response**: `ItemFieldResponse[]` + +--- + +### 2.5 섹션 템플릿 API + +#### `GET /api/v1/item-master/section-templates` +**목적**: 섹션 템플릿 목록 조회 + +**Response**: `SectionTemplateResponse[]` + +--- + +#### `POST /api/v1/item-master/section-templates` +**목적**: 새 섹션 템플릿 생성 + +**Request Body**: +```typescript +interface SectionTemplateRequest { + title: string; // 템플릿명 (필수) + type: 'fields' | 'bom'; // 타입 (필수) + description?: string; // 설명 (선택) + is_default?: boolean; // 기본 템플릿 여부 (선택) + + // 템플릿에 포함될 필드들 + fields?: Array<{ + field_name: string; + field_type: string; + master_field_id?: number; + is_required?: boolean; + options?: Array<{ label: string; value: string }>; + properties?: Record; + }>; +} +``` + +**Response**: `SectionTemplateResponse` + +--- + +#### `PUT /api/v1/item-master/section-templates/{id}` +**목적**: 섹션 템플릿 수정 + +**Response**: `SectionTemplateResponse` + +--- + +#### `DELETE /api/v1/item-master/section-templates/{id}` +**목적**: 섹션 템플릿 삭제 + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +### 2.6 마스터 필드 API + +#### `GET /api/v1/item-master/master-fields` +**목적**: 마스터 필드 목록 조회 + +**Response**: `MasterFieldResponse[]` + +--- + +#### `POST /api/v1/item-master/master-fields` +**목적**: 새 마스터 필드 생성 + +**Request Body**: +```typescript +interface MasterFieldRequest { + field_name: string; // 필드명 (필수) + field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입 (필수) + category?: string; // 카테고리 (선택) - 예: "기본정보", "스펙정보" + description?: string; // 설명 (선택) + is_common?: boolean; // 공통 항목 여부 (선택) + default_value?: string; + options?: Array<{ label: string; value: string }>; + validation_rules?: Record; + properties?: Record; +} +``` + +**Response**: `MasterFieldResponse` + +--- + +#### `PUT /api/v1/item-master/master-fields/{id}` +**목적**: 마스터 필드 수정 + +**Response**: `MasterFieldResponse` + +--- + +#### `DELETE /api/v1/item-master/master-fields/{id}` +**목적**: 마스터 필드 삭제 + +**주의**: 해당 마스터 필드를 참조하는 필드(`master_field_id`)가 있을 경우 처리 방안 필요 +- 옵션 1: 삭제 불가 (참조 무결성) +- 옵션 2: 참조 해제 후 삭제 + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +### 2.7 BOM 관리 API + +#### `POST /api/v1/item-master/sections/{sectionId}/bom-items` +**목적**: BOM 항목 추가 + +**Request Body**: +```typescript +interface BomItemRequest { + item_code?: string; + item_name: string; // 필수 + quantity: number; // 필수 + unit?: string; + unit_price?: number; + total_price?: number; + spec?: string; + note?: string; +} +``` + +**Response**: `BomItemResponse` + +--- + +#### `PUT /api/v1/item-master/bom-items/{id}` +**목적**: BOM 항목 수정 + +**Response**: `BomItemResponse` + +--- + +#### `DELETE /api/v1/item-master/bom-items/{id}` +**목적**: BOM 항목 삭제 + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +### 2.8 커스텀 탭 API + +#### `GET /api/v1/item-master/custom-tabs` +**목적**: 커스텀 탭 목록 조회 + +**Response**: `CustomTabResponse[]` + +--- + +#### `POST /api/v1/item-master/custom-tabs` +**목적**: 새 커스텀 탭 생성 + +**Request Body**: +```typescript +interface CustomTabRequest { + label: string; // 탭 레이블 (필수) + icon?: string; // 아이콘 (선택) + is_default?: boolean; // 기본 탭 여부 (선택) +} +``` + +**Response**: `CustomTabResponse` + +--- + +#### `PUT /api/v1/item-master/custom-tabs/{id}` +**목적**: 커스텀 탭 수정 + +**Response**: `CustomTabResponse` + +--- + +#### `DELETE /api/v1/item-master/custom-tabs/{id}` +**목적**: 커스텀 탭 삭제 + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +#### `PUT /api/v1/item-master/custom-tabs/reorder` +**목적**: 탭 순서 변경 + +**Request Body**: +```typescript +interface TabReorderRequest { + tab_orders: Array<{ + id: number; + order_no: number; + }>; +} +``` + +**Response**: `{ success: true }` + +--- + +#### `PUT /api/v1/item-master/custom-tabs/{id}/columns` +**목적**: 탭별 컬럼 설정 업데이트 + +**Request Body**: +```typescript +interface TabColumnUpdateRequest { + columns: Array<{ + key: string; + label: string; + visible: boolean; + order: number; + }>; +} +``` + +**Response**: `TabColumnResponse[]` + +--- + +### 2.9 단위 옵션 API + +#### `GET /api/v1/item-master/unit-options` +**목적**: 단위 옵션 목록 조회 + +**Response**: `UnitOptionResponse[]` + +--- + +#### `POST /api/v1/item-master/unit-options` +**목적**: 새 단위 옵션 추가 + +**Request Body**: +```typescript +interface UnitOptionRequest { + label: string; // 표시명 (예: "개") + value: string; // 값 (예: "EA") +} +``` + +**Response**: `UnitOptionResponse` + +--- + +#### `DELETE /api/v1/item-master/unit-options/{id}` +**목적**: 단위 옵션 삭제 + +**Response**: `{ success: true, message: "message.deleted" }` + +--- + +## 3. 데이터베이스 스키마 제안 + +### 3.1 item_master_pages +```sql +CREATE TABLE item_master_pages ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + page_name VARCHAR(100) NOT NULL, + item_type ENUM('FG', 'PT', 'SM', 'RM', 'CS') NOT NULL, + absolute_path VARCHAR(500) NULL, + order_no INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) +); +``` + +### 3.2 item_master_sections +```sql +CREATE TABLE item_master_sections ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + page_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(100) NOT NULL, + type ENUM('fields', 'bom') NOT NULL DEFAULT 'fields', + order_no INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_page (tenant_id, page_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id), + FOREIGN KEY (page_id) REFERENCES item_master_pages(id) ON DELETE CASCADE +); +``` + +### 3.3 item_master_fields +```sql +CREATE TABLE item_master_fields ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + section_id BIGINT UNSIGNED NOT NULL, + master_field_id BIGINT UNSIGNED NULL, -- 마스터 필드 참조 + field_name VARCHAR(100) NOT NULL, + field_type ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea') NOT NULL, + order_no INT NOT NULL DEFAULT 0, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + placeholder VARCHAR(200) NULL, + default_value VARCHAR(500) NULL, + display_condition JSON NULL, -- 조건부 표시 설정 + validation_rules JSON NULL, + options JSON NULL, -- dropdown 옵션 + properties JSON NULL, -- 추가 속성 (컬럼 설정 등) + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_section (tenant_id, section_id), + INDEX idx_master_field (master_field_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id), + FOREIGN KEY (section_id) REFERENCES item_master_sections(id) ON DELETE CASCADE, + FOREIGN KEY (master_field_id) REFERENCES item_master_master_fields(id) ON DELETE SET NULL +); +``` + +### 3.4 item_master_master_fields (마스터 필드) +```sql +CREATE TABLE item_master_master_fields ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + field_name VARCHAR(100) NOT NULL, + field_type ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea') NOT NULL, + category VARCHAR(50) NULL, + description TEXT NULL, + is_common BOOLEAN NOT NULL DEFAULT FALSE, + default_value VARCHAR(500) NULL, + options JSON NULL, + validation_rules JSON NULL, + properties JSON NULL, + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + INDEX idx_category (tenant_id, category), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) +); +``` + +### 3.5 item_master_section_templates (섹션 템플릿) +```sql +CREATE TABLE item_master_section_templates ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(100) NOT NULL, + type ENUM('fields', 'bom') NOT NULL DEFAULT 'fields', + description TEXT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant (tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) +); +``` + +### 3.6 item_master_template_fields (템플릿 필드) +```sql +CREATE TABLE item_master_template_fields ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + master_field_id BIGINT UNSIGNED NULL, + field_name VARCHAR(100) NOT NULL, + field_type ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea') NOT NULL, + order_no INT NOT NULL DEFAULT 0, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + placeholder VARCHAR(200) NULL, + default_value VARCHAR(500) NULL, + options JSON NULL, + properties JSON NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + INDEX idx_template (template_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id), + FOREIGN KEY (template_id) REFERENCES item_master_section_templates(id) ON DELETE CASCADE, + FOREIGN KEY (master_field_id) REFERENCES item_master_master_fields(id) ON DELETE SET NULL +); +``` + +--- + +## 4. 핵심 비즈니스 로직 + +### 4.1 마스터 필드 연결 (`master_field_id`) + +**시나리오**: 사용자가 필드 추가 시 "마스터 항목 선택" 모드로 추가 + +**프론트엔드 동작**: +1. 마스터 필드 목록에서 선택 +2. 선택된 마스터 필드의 속성을 폼에 자동 채움 +3. 저장 시 `master_field_id` 포함하여 전송 + +**백엔드 처리**: +```php +// ItemFieldService.php +public function create(int $sectionId, array $data): ItemField +{ + // master_field_id가 있으면 마스터 필드에서 기본값 가져오기 + if (!empty($data['master_field_id'])) { + $masterField = MasterField::findOrFail($data['master_field_id']); + + // 마스터 필드의 속성을 기본값으로 사용 (명시적 값이 없는 경우) + $data = array_merge([ + 'field_type' => $masterField->field_type, + 'options' => $masterField->options, + 'validation_rules' => $masterField->validation_rules, + 'properties' => $masterField->properties, + ], $data); + } + + return ItemField::create($data); +} +``` + +### 4.2 섹션 템플릿 적용 + +**시나리오**: 사용자가 섹션 추가 시 "템플릿에서 선택" 모드로 추가 + +**프론트엔드 동작**: +1. 템플릿 목록에서 선택 +2. 선택된 템플릿 정보로 섹션 생성 요청 +3. `template_id` 포함하여 전송 + +**백엔드 처리**: +```php +// ItemSectionService.php +public function create(int $pageId, array $data): ItemSection +{ + $section = ItemSection::create([ + 'page_id' => $pageId, + 'title' => $data['title'], + 'type' => $data['type'], + ]); + + // template_id가 있으면 템플릿의 필드들을 복사 + if (!empty($data['template_id'])) { + $templateFields = TemplateField::where('template_id', $data['template_id']) + ->orderBy('order_no') + ->get(); + + foreach ($templateFields as $index => $tf) { + ItemField::create([ + 'section_id' => $section->id, + 'master_field_id' => $tf->master_field_id, // 마스터 연결 유지 + 'field_name' => $tf->field_name, + 'field_type' => $tf->field_type, + 'order_no' => $index, + 'is_required' => $tf->is_required, + 'options' => $tf->options, + 'properties' => $tf->properties, + ]); + } + } + + return $section->load('fields'); +} +``` + +### 4.3 조건부 표시 설정 + +**JSON 구조**: +```json +{ + "display_condition": [ + { + "field_key": "item_type", + "expected_value": "FG", + "target_field_ids": ["5", "6", "7"] + }, + { + "field_key": "item_type", + "expected_value": "PT", + "target_section_ids": ["3"] + } + ] +} +``` + +**활용**: 프론트엔드에서 품목 데이터 입력 시 해당 조건에 따라 필드/섹션을 동적으로 표시/숨김 + +--- + +## 5. 우선순위 + +### Phase 1 (필수 - 즉시) +1. `GET /api/v1/item-master/init` - 초기화 API +2. 페이지 CRUD API +3. 섹션 CRUD API (순서변경 포함) +4. 필드 CRUD API (순서변경 포함, `master_field_id` 지원) + +### Phase 2 (중요 - 1주 내) +5. 마스터 필드 CRUD API +6. 섹션 템플릿 CRUD API +7. 템플릿 필드 관리 + +### Phase 3 (선택 - 2주 내) +8. BOM 항목 관리 API +9. 커스텀 탭 API +10. 단위 옵션 API + +--- + +## 6. 참고 사항 + +### 6.1 프론트엔드 코드 위치 +- API 클라이언트: `src/lib/api/item-master.ts` +- 타입 정의: `src/types/item-master-api.ts` +- 메인 컴포넌트: `src/components/items/ItemMasterDataManagement.tsx` + +### 6.2 기존 API 문서 +- `claudedocs/[API-2025-11-24] item-management-dynamic-api-spec.md` - 품목관리 동적 화면 API + +### 6.3 Multi-Tenancy +- 모든 테이블에 `tenant_id` 컬럼 필수 +- JWT에서 tenant_id 자동 추출 +- `BelongsToTenant` Trait 적용 필요 + +### 6.4 에러 응답 형식 +```json +{ + "success": false, + "message": "error.validation_failed", + "errors": { + "field_name": ["필드명은 필수입니다."], + "field_type": ["유효하지 않은 필드 타입입니다."] + } +} +``` + +--- + +## 7. 연락처 + +질문이나 협의 사항이 있으면 언제든 연락 바랍니다. + +**프론트엔드 담당**: [담당자명] +**작성일**: 2025-11-25 \ No newline at end of file diff --git a/docs/front/[API-REQUEST-2025-11-25] section-template-fields-api.md b/docs/front/[API-REQUEST-2025-11-25] section-template-fields-api.md new file mode 100644 index 0000000..493dd79 --- /dev/null +++ b/docs/front/[API-REQUEST-2025-11-25] section-template-fields-api.md @@ -0,0 +1,588 @@ +# 품목기준관리 API 추가 요청 - 섹션 템플릿 하위 데이터 + +**요청일**: 2025-11-25 +**버전**: v1.1 +**작성자**: 프론트엔드 개발팀 +**수신**: 백엔드 개발팀 +**긴급도**: 🔴 높음 + +--- + +## 📋 목차 + +1. [요청 배경](#1-요청-배경) +2. [데이터베이스 테이블 추가](#2-데이터베이스-테이블-추가) +3. [API 엔드포인트 추가](#3-api-엔드포인트-추가) +4. [init API 응답 수정](#4-init-api-응답-수정) +5. [구현 우선순위](#5-구현-우선순위) + +--- + +## 1. 요청 배경 + +### 1.1 문제 상황 +- 섹션탭 > 일반 섹션에 항목(필드) 추가 후 **새로고침 시 데이터 사라짐** +- 섹션탭 > 모듈 섹션(BOM)에 BOM 품목 추가 후 **새로고침 시 데이터 사라짐** +- 원인: 섹션 템플릿 하위 데이터를 저장/조회하는 API 없음 + +### 1.2 현재 상태 비교 + +| 구분 | 계층구조 (정상) | 섹션 템플릿 (문제) | +|------|----------------|-------------------| +| 섹션/템플릿 CRUD | ✅ 있음 | ✅ 있음 | +| 필드 CRUD | ✅ `/sections/{id}/fields` | ❌ **없음** | +| BOM 품목 CRUD | ✅ `/sections/{id}/bom-items` | ❌ **없음** | +| init 응답에 중첩 포함 | ✅ `fields`, `bomItems` 포함 | ❌ **미포함** | + +### 1.3 요청 내용 +1. 섹션 템플릿 필드 테이블 및 CRUD API 추가 +2. 섹션 템플릿 BOM 품목 테이블 및 CRUD API 추가 +3. init API 응답에 섹션 템플릿 하위 데이터 중첩 포함 +4. **🔴 [추가] 계층구조 섹션 ↔ 섹션 템플릿 데이터 동기화** + +--- + +## 2. 데이터베이스 테이블 추가 + +### 2.0 section_templates 테이블 수정 (데이터 동기화용) + +**요구사항**: 계층구조에서 생성한 섹션과 섹션탭의 템플릿이 **동일한 데이터**로 연동되어야 함 + +**현재 문제**: +``` +계층구조 섹션 생성 시: +├── item_sections 테이블에 저장 (id: 1) +└── section_templates 테이블에 저장 (id: 1) + → 두 개의 별도 데이터! 연결 없음! +``` + +**해결 방안**: `section_templates`에 `section_id` 컬럼 추가 + +```sql +ALTER TABLE section_templates +ADD COLUMN section_id BIGINT UNSIGNED NULL COMMENT '연결된 계층구조 섹션 ID (동기화용)' AFTER tenant_id, +ADD INDEX idx_section_id (section_id), +ADD FOREIGN KEY (section_id) REFERENCES item_sections(id) ON DELETE SET NULL; +``` + +**동기화 동작**: +| 액션 | 동작 | +|------|------| +| 계층구조에서 섹션 생성 | `item_sections` + `section_templates` 생성, `section_id`로 연결 | +| 계층구조에서 섹션 수정 | `item_sections` 수정 → 연결된 `section_templates`도 수정 | +| 계층구조에서 섹션 삭제 | `item_sections` 삭제 → 연결된 `section_templates`의 `section_id` = NULL | +| 섹션탭에서 템플릿 수정 | `section_templates` 수정 → 연결된 `item_sections`도 수정 | +| 섹션탭에서 템플릿 삭제 | `section_templates` 삭제 → 연결된 `item_sections`는 유지 | + +**init API 응답 수정** (section_id 포함): +```json +{ + "sectionTemplates": [ + { + "id": 1, + "section_id": 5, // 연결된 계층구조 섹션 ID (없으면 null) + "title": "일반 섹션", + "type": "fields", + ... + } + ] +} +``` + +--- + +### 2.1 section_template_fields (섹션 템플릿 필드) + +**참고**: 기존 `item_fields` 테이블 구조와 유사하게 설계 + +```sql +CREATE TABLE section_template_fields ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + template_id BIGINT UNSIGNED NOT NULL COMMENT '섹션 템플릿 ID', + field_name VARCHAR(255) NOT NULL COMMENT '필드명', + field_key VARCHAR(100) NOT NULL COMMENT '필드 키 (영문)', + field_type ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea') NOT NULL COMMENT '필드 타입', + order_no INT NOT NULL DEFAULT 0 COMMENT '정렬 순서', + is_required TINYINT(1) DEFAULT 0 COMMENT '필수 여부', + options JSON NULL COMMENT '드롭다운 옵션 ["옵션1", "옵션2"]', + multi_column TINYINT(1) DEFAULT 0 COMMENT '다중 컬럼 여부', + column_count INT NULL COMMENT '컬럼 수', + column_names JSON NULL COMMENT '컬럼명 목록 ["컬럼1", "컬럼2"]', + description TEXT NULL COMMENT '설명', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_template (tenant_id, template_id), + INDEX idx_order (template_id, order_no), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES section_templates(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='섹션 템플릿 필드'; +``` + +### 2.2 section_template_bom_items (섹션 템플릿 BOM 품목) + +**참고**: 기존 `item_bom_items` 테이블 구조와 유사하게 설계 + +```sql +CREATE TABLE section_template_bom_items ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '테넌트 ID', + template_id BIGINT UNSIGNED NOT NULL COMMENT '섹션 템플릿 ID', + item_code VARCHAR(100) NULL COMMENT '품목 코드', + item_name VARCHAR(255) NOT NULL COMMENT '품목명', + quantity DECIMAL(15, 4) NOT NULL DEFAULT 0 COMMENT '수량', + unit VARCHAR(50) NULL COMMENT '단위', + unit_price DECIMAL(15, 2) NULL COMMENT '단가', + total_price DECIMAL(15, 2) NULL COMMENT '총액', + spec TEXT NULL COMMENT '규격/사양', + note TEXT NULL COMMENT '비고', + order_no INT NOT NULL DEFAULT 0 COMMENT '정렬 순서', + created_by BIGINT UNSIGNED NULL, + updated_by BIGINT UNSIGNED NULL, + deleted_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + INDEX idx_tenant_template (tenant_id, template_id), + INDEX idx_order (template_id, order_no), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES section_templates(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='섹션 템플릿 BOM 품목'; +``` + +--- + +## 3. API 엔드포인트 추가 + +### 3.1 섹션 템플릿 필드 관리 (우선순위 1) + +#### `POST /v1/item-master/section-templates/{templateId}/fields` +**목적**: 템플릿 필드 생성 + +**Request Body**: +```json +{ + "field_name": "품목코드", + "field_key": "item_code", + "field_type": "textbox", + "is_required": true, + "options": null, + "multi_column": false, + "column_count": null, + "column_names": null, + "description": "품목 고유 코드" +} +``` + +**Validation**: +- `field_name`: required, string, max:255 +- `field_key`: required, string, max:100, alpha_dash +- `field_type`: required, in:textbox,number,dropdown,checkbox,date,textarea +- `is_required`: boolean +- `options`: nullable, array (dropdown 타입일 경우) +- `multi_column`: boolean +- `column_count`: nullable, integer, min:2, max:10 +- `column_names`: nullable, array +- `description`: nullable, string + +**Response**: +```json +{ + "success": true, + "message": "message.created", + "data": { + "id": 1, + "template_id": 1, + "field_name": "품목코드", + "field_key": "item_code", + "field_type": "textbox", + "order_no": 0, + "is_required": true, + "options": null, + "multi_column": false, + "column_count": null, + "column_names": null, + "description": "품목 고유 코드", + "created_at": "2025-11-25T10:00:00.000000Z", + "updated_at": "2025-11-25T10:00:00.000000Z" + } +} +``` + +**참고**: +- `order_no`는 자동 계산 (해당 템플릿의 마지막 필드 order + 1) + +--- + +#### `PUT /v1/item-master/section-templates/{templateId}/fields/{fieldId}` +**목적**: 템플릿 필드 수정 + +**Request Body**: +```json +{ + "field_name": "품목코드 (수정)", + "field_type": "dropdown", + "options": ["옵션1", "옵션2"], + "is_required": false +} +``` + +**Validation**: POST와 동일 (모든 필드 optional) + +**Response**: 수정된 필드 정보 반환 + +--- + +#### `DELETE /v1/item-master/section-templates/{templateId}/fields/{fieldId}` +**목적**: 템플릿 필드 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +#### `PUT /v1/item-master/section-templates/{templateId}/fields/reorder` +**목적**: 템플릿 필드 순서 변경 + +**Request Body**: +```json +{ + "field_orders": [ + { "id": 3, "order_no": 0 }, + { "id": 1, "order_no": 1 }, + { "id": 2, "order_no": 2 } + ] +} +``` + +**Validation**: +- `field_orders`: required, array +- `field_orders.*.id`: required, exists:section_template_fields,id +- `field_orders.*.order_no`: required, integer, min:0 + +**Response**: +```json +{ + "success": true, + "message": "message.updated", + "data": [ + { "id": 3, "order_no": 0 }, + { "id": 1, "order_no": 1 }, + { "id": 2, "order_no": 2 } + ] +} +``` + +--- + +### 3.2 섹션 템플릿 BOM 품목 관리 (우선순위 2) + +#### `POST /v1/item-master/section-templates/{templateId}/bom-items` +**목적**: 템플릿 BOM 품목 생성 + +**Request Body**: +```json +{ + "item_code": "PART-001", + "item_name": "부품 A", + "quantity": 2, + "unit": "EA", + "unit_price": 15000, + "spec": "100x50x20", + "note": "필수 부품" +} +``` + +**Validation**: +- `item_code`: nullable, string, max:100 +- `item_name`: required, string, max:255 +- `quantity`: required, numeric, min:0 +- `unit`: nullable, string, max:50 +- `unit_price`: nullable, numeric, min:0 +- `spec`: nullable, string +- `note`: nullable, string + +**Response**: +```json +{ + "success": true, + "message": "message.created", + "data": { + "id": 1, + "template_id": 2, + "item_code": "PART-001", + "item_name": "부품 A", + "quantity": 2, + "unit": "EA", + "unit_price": 15000, + "total_price": 30000, + "spec": "100x50x20", + "note": "필수 부품", + "order_no": 0, + "created_at": "2025-11-25T10:00:00.000000Z", + "updated_at": "2025-11-25T10:00:00.000000Z" + } +} +``` + +**참고**: +- `total_price`는 서버에서 자동 계산 (`quantity * unit_price`) +- `order_no`는 자동 계산 (해당 템플릿의 마지막 BOM 품목 order + 1) + +--- + +#### `PUT /v1/item-master/section-templates/{templateId}/bom-items/{itemId}` +**목적**: 템플릿 BOM 품목 수정 + +**Request Body**: +```json +{ + "item_name": "부품 A (수정)", + "quantity": 3, + "unit_price": 12000 +} +``` + +**Validation**: POST와 동일 (모든 필드 optional) + +**Response**: 수정된 BOM 품목 정보 반환 + +--- + +#### `DELETE /v1/item-master/section-templates/{templateId}/bom-items/{itemId}` +**목적**: 템플릿 BOM 품목 삭제 (Soft Delete) + +**Request**: 없음 + +**Response**: +```json +{ + "success": true, + "message": "message.deleted" +} +``` + +--- + +#### `PUT /v1/item-master/section-templates/{templateId}/bom-items/reorder` +**목적**: 템플릿 BOM 품목 순서 변경 + +**Request Body**: +```json +{ + "item_orders": [ + { "id": 3, "order_no": 0 }, + { "id": 1, "order_no": 1 }, + { "id": 2, "order_no": 2 } + ] +} +``` + +**Validation**: +- `item_orders`: required, array +- `item_orders.*.id`: required, exists:section_template_bom_items,id +- `item_orders.*.order_no`: required, integer, min:0 + +**Response**: +```json +{ + "success": true, + "message": "message.updated", + "data": [...] +} +``` + +--- + +## 4. init API 응답 수정 + +### 4.1 현재 응답 (문제) + +```json +{ + "success": true, + "data": { + "sectionTemplates": [ + { + "id": 1, + "title": "일반 섹션", + "type": "fields", + "description": null, + "is_default": false + }, + { + "id": 2, + "title": "BOM 섹션", + "type": "bom", + "description": null, + "is_default": false + } + ] + } +} +``` + +### 4.2 수정 요청 + +`sectionTemplates`에 하위 데이터 중첩 포함: + +```json +{ + "success": true, + "data": { + "sectionTemplates": [ + { + "id": 1, + "title": "일반 섹션", + "type": "fields", + "description": null, + "is_default": false, + "fields": [ + { + "id": 1, + "field_name": "품목코드", + "field_key": "item_code", + "field_type": "textbox", + "order_no": 0, + "is_required": true, + "options": null, + "multi_column": false, + "column_count": null, + "column_names": null, + "description": "품목 고유 코드" + } + ] + }, + { + "id": 2, + "title": "BOM 섹션", + "type": "bom", + "description": null, + "is_default": false, + "bomItems": [ + { + "id": 1, + "item_code": "PART-001", + "item_name": "부품 A", + "quantity": 2, + "unit": "EA", + "unit_price": 15000, + "total_price": 30000, + "spec": "100x50x20", + "note": "필수 부품", + "order_no": 0 + } + ] + } + ] + } +} +``` + +**참고**: +- `type: "fields"` 템플릿: `fields` 배열 포함 +- `type: "bom"` 템플릿: `bomItems` 배열 포함 +- 기존 `pages` 응답의 중첩 구조와 동일한 패턴 + +--- + +## 5. 구현 우선순위 + +| 우선순위 | 작업 내용 | 예상 공수 | +|---------|----------|----------| +| 🔴 0 | `section_templates`에 `section_id` 컬럼 추가 (동기화용) | 0.5일 | +| 🔴 0 | 계층구조 섹션 생성 시 `section_templates` 자동 생성 로직 | 0.5일 | +| 🔴 1 | `section_template_fields` 테이블 생성 | 0.5일 | +| 🔴 1 | 섹션 템플릿 필드 CRUD API (5개) | 1일 | +| 🔴 1 | init API 응답에 `fields` 중첩 포함 | 0.5일 | +| 🟡 2 | `section_template_bom_items` 테이블 생성 | 0.5일 | +| 🟡 2 | 섹션 템플릿 BOM 품목 CRUD API (5개) | 1일 | +| 🟡 2 | init API 응답에 `bomItems` 중첩 포함 | 0.5일 | +| 🟢 3 | 양방향 동기화 로직 (섹션↔템플릿 수정 시 상호 반영) | 1일 | +| 🟢 3 | Swagger 문서 업데이트 | 0.5일 | + +**총 예상 공수**: 백엔드 6.5일 + +--- + +## 6. 프론트엔드 연동 계획 + +### 6.1 API 완료 후 프론트엔드 작업 + +| 작업 | 설명 | 의존성 | +|------|------|--------| +| 타입 정의 수정 | `SectionTemplateResponse`에 `fields`, `bomItems`, `section_id` 추가 | init API 수정 후 | +| Context 수정 | 섹션 템플릿 필드/BOM API 호출 로직 추가 | CRUD API 완료 후 | +| 로컬 상태 제거 | `default_fields` 로컬 관리 로직 → API 연동으로 교체 | CRUD API 완료 후 | +| 동기화 UI | 계층구조↔섹션탭 간 데이터 자동 반영 | section_id 추가 후 | + +### 6.2 타입 수정 예시 + +**현재** (`src/types/item-master-api.ts`): +```typescript +export interface SectionTemplateResponse { + id: number; + title: string; + type: 'fields' | 'bom'; + description?: string; + is_default: boolean; +} +``` + +**수정 후**: +```typescript +export interface SectionTemplateResponse { + id: number; + section_id?: number | null; // 연결된 계층구조 섹션 ID + title: string; + type: 'fields' | 'bom'; + description?: string; + is_default: boolean; + fields?: SectionTemplateFieldResponse[]; // type='fields'일 때 + bomItems?: SectionTemplateBomItemResponse[]; // type='bom'일 때 +} +``` + +### 6.3 동기화 시나리오 정리 + +``` +[시나리오 1] 계층구조에서 섹션 생성 + └─ 백엔드: item_sections + section_templates 동시 생성 (section_id로 연결) + └─ 프론트: init 재조회 → 양쪽 탭에 데이터 표시 + +[시나리오 2] 계층구조에서 필드 추가/수정 + └─ 백엔드: item_fields 저장 → 연결된 section_template_fields도 동기화 + └─ 프론트: init 재조회 → 섹션탭에 필드 반영 + +[시나리오 3] 섹션탭에서 필드 추가/수정 + └─ 백엔드: section_template_fields 저장 → 연결된 item_fields도 동기화 + └─ 프론트: init 재조회 → 계층구조탭에 필드 반영 + +[시나리오 4] 섹션탭에서 독립 템플릿 생성 (section_id = null) + └─ 백엔드: section_templates만 생성 (계층구조와 무관) + └─ 프론트: 섹션탭에서만 사용 가능한 템플릿 +``` + +--- + +## 📞 문의 + +질문 있으시면 프론트엔드 팀으로 연락 주세요. + +--- + +**작성일**: 2025-11-25 +**기준 문서**: `[API-2025-11-20] item-master-specification.md` diff --git a/lang/ko/error.php b/lang/ko/error.php index d831563..9e0d283 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -104,4 +104,10 @@ 'duplicate_code' => '중복된 그룹 코드입니다.', 'has_clients' => '해당 고객 그룹에 속한 고객이 있어 삭제할 수 없습니다.', 'code_exists_in_deleted' => '삭제된 데이터에 동일한 코드가 존재합니다. 먼저 해당 코드를 완전히 삭제하거나 다른 코드를 사용하세요.', + + // 품목 기준 관리 관련 + 'page_not_found' => '페이지를 찾을 수 없습니다.', + 'section_not_found' => '섹션을 찾을 수 없습니다.', + 'field_not_found' => '필드를 찾을 수 없습니다.', + 'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.', ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index a16fa2c..0789266 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -17,6 +17,8 @@ 'toggled' => '상태 변경 성공', 'bulk_upsert' => '대량 저장 성공', 'reordered' => '정렬 변경 성공', + 'linked' => '연결 성공', + 'unlinked' => '연결 해제 성공', 'no_changes' => '변경 사항이 없습니다.', // 인증/세션 diff --git a/routes/api.php b/routes/api.php index bc78cf5..bd860cb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,6 +20,7 @@ use App\Http\Controllers\Api\V1\FileStorageController; use App\Http\Controllers\Api\V1\FolderController; use App\Http\Controllers\Api\V1\ItemMaster\CustomTabController; +use App\Http\Controllers\Api\V1\ItemMaster\EntityRelationshipController; use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController; use App\Http\Controllers\Api\V1\ItemMaster\ItemFieldController; use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterController; @@ -489,19 +490,35 @@ Route::put('/pages/{id}', [ItemPageController::class, 'update'])->name('v1.item-master.pages.update'); Route::delete('/pages/{id}', [ItemPageController::class, 'destroy'])->name('v1.item-master.pages.destroy'); - // 섹션 관리 + // 독립 섹션 관리 + Route::get('/sections', [ItemSectionController::class, 'index'])->name('v1.item-master.sections.index'); + Route::post('/sections', [ItemSectionController::class, 'storeIndependent'])->name('v1.item-master.sections.store-independent'); + Route::post('/sections/{id}/clone', [ItemSectionController::class, 'clone'])->name('v1.item-master.sections.clone'); + Route::get('/sections/{id}/usage', [ItemSectionController::class, 'getUsage'])->name('v1.item-master.sections.usage'); + + // 섹션 관리 (페이지 연결) Route::post('/pages/{pageId}/sections', [ItemSectionController::class, 'store'])->name('v1.item-master.sections.store'); Route::put('/sections/{id}', [ItemSectionController::class, 'update'])->name('v1.item-master.sections.update'); Route::delete('/sections/{id}', [ItemSectionController::class, 'destroy'])->name('v1.item-master.sections.destroy'); Route::put('/pages/{pageId}/sections/reorder', [ItemSectionController::class, 'reorder'])->name('v1.item-master.sections.reorder'); - // 필드 관리 + // 독립 필드 관리 + Route::get('/fields', [ItemFieldController::class, 'index'])->name('v1.item-master.fields.index'); + Route::post('/fields', [ItemFieldController::class, 'storeIndependent'])->name('v1.item-master.fields.store-independent'); + Route::post('/fields/{id}/clone', [ItemFieldController::class, 'clone'])->name('v1.item-master.fields.clone'); + Route::get('/fields/{id}/usage', [ItemFieldController::class, 'getUsage'])->name('v1.item-master.fields.usage'); + + // 필드 관리 (섹션 연결) Route::post('/sections/{sectionId}/fields', [ItemFieldController::class, 'store'])->name('v1.item-master.fields.store'); Route::put('/fields/{id}', [ItemFieldController::class, 'update'])->name('v1.item-master.fields.update'); Route::delete('/fields/{id}', [ItemFieldController::class, 'destroy'])->name('v1.item-master.fields.destroy'); Route::put('/sections/{sectionId}/fields/reorder', [ItemFieldController::class, 'reorder'])->name('v1.item-master.fields.reorder'); - // BOM 항목 관리 + // 독립 BOM 항목 관리 + Route::get('/bom-items', [ItemBomItemController::class, 'index'])->name('v1.item-master.bom-items.index'); + Route::post('/bom-items', [ItemBomItemController::class, 'storeIndependent'])->name('v1.item-master.bom-items.store-independent'); + + // BOM 항목 관리 (섹션 연결) Route::post('/sections/{sectionId}/bom-items', [ItemBomItemController::class, 'store'])->name('v1.item-master.bom-items.store'); Route::put('/bom-items/{id}', [ItemBomItemController::class, 'update'])->name('v1.item-master.bom-items.update'); Route::delete('/bom-items/{id}', [ItemBomItemController::class, 'destroy'])->name('v1.item-master.bom-items.destroy'); @@ -529,6 +546,33 @@ Route::get('/unit-options', [UnitOptionController::class, 'index'])->name('v1.item-master.unit-options.index'); Route::post('/unit-options', [UnitOptionController::class, 'store'])->name('v1.item-master.unit-options.store'); Route::delete('/unit-options/{id}', [UnitOptionController::class, 'destroy'])->name('v1.item-master.unit-options.destroy'); + + // 엔티티 관계 관리 (독립 엔티티 + 링크 테이블) + // 페이지-섹션 연결 + Route::post('/pages/{pageId}/link-section', [EntityRelationshipController::class, 'linkSectionToPage'])->name('v1.item-master.pages.link-section'); + Route::delete('/pages/{pageId}/unlink-section/{sectionId}', [EntityRelationshipController::class, 'unlinkSectionFromPage'])->name('v1.item-master.pages.unlink-section'); + + // 페이지-필드 직접 연결 + Route::post('/pages/{pageId}/link-field', [EntityRelationshipController::class, 'linkFieldToPage'])->name('v1.item-master.pages.link-field'); + Route::delete('/pages/{pageId}/unlink-field/{fieldId}', [EntityRelationshipController::class, 'unlinkFieldFromPage'])->name('v1.item-master.pages.unlink-field'); + + // 페이지 관계 조회 + Route::get('/pages/{pageId}/relationships', [EntityRelationshipController::class, 'getPageRelationships'])->name('v1.item-master.pages.relationships'); + Route::get('/pages/{pageId}/structure', [EntityRelationshipController::class, 'getPageStructure'])->name('v1.item-master.pages.structure'); + + // 섹션-필드 연결 + Route::post('/sections/{sectionId}/link-field', [EntityRelationshipController::class, 'linkFieldToSection'])->name('v1.item-master.sections.link-field'); + Route::delete('/sections/{sectionId}/unlink-field/{fieldId}', [EntityRelationshipController::class, 'unlinkFieldFromSection'])->name('v1.item-master.sections.unlink-field'); + + // 섹션-BOM 연결 + Route::post('/sections/{sectionId}/link-bom', [EntityRelationshipController::class, 'linkBomToSection'])->name('v1.item-master.sections.link-bom'); + Route::delete('/sections/{sectionId}/unlink-bom/{bomId}', [EntityRelationshipController::class, 'unlinkBomFromSection'])->name('v1.item-master.sections.unlink-bom'); + + // 섹션 관계 조회 + Route::get('/sections/{sectionId}/relationships', [EntityRelationshipController::class, 'getSectionRelationships'])->name('v1.item-master.sections.relationships'); + + // 관계 순서 변경 + Route::post('/relationships/reorder', [EntityRelationshipController::class, 'reorderRelationships'])->name('v1.item-master.relationships.reorder'); }); });