feat: Item Master 하이브리드 구조 전환 및 독립 API 추가
- CASCADE FK → 독립 엔티티 + entity_relationships 링크 테이블 - 독립 API 10개 추가 (섹션/필드/BOM CRUD, clone, usage) - SectionTemplate 모델 제거 → ItemSection.is_template 통합 - 페이지-섹션, 섹션-필드, 섹션-BOM 링크/언링크 API 14개 추가 - Swagger 문서 업데이트
This commit is contained in:
193
CURRENT_WORKS.md
193
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 인증 에러 처리 개선 및 요청 로그 강화
|
||||
|
||||
### 문제 상황
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\ItemMaster;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ItemMaster\LinkEntityRequest;
|
||||
use App\Http\Requests\ItemMaster\ReorderRelationshipsRequest;
|
||||
use App\Services\ItemMaster\EntityRelationshipService;
|
||||
|
||||
/**
|
||||
* EntityRelationshipController - 엔티티 간 관계(링크) 관리 API
|
||||
*/
|
||||
class EntityRelationshipController extends Controller
|
||||
{
|
||||
public function __construct(private EntityRelationshipService $service) {}
|
||||
|
||||
/**
|
||||
* 페이지에 섹션 연결
|
||||
*
|
||||
* POST /api/v1/item-master/pages/{pageId}/link-section
|
||||
*/
|
||||
public function linkSectionToPage(int $pageId, LinkEntityRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($pageId, $request) {
|
||||
$data = $request->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'));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ItemMaster;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndependentBomItemStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'group_id' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ItemMaster;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndependentFieldStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'group_id' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ItemMaster;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndependentSectionStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'group_id' => 'nullable|integer',
|
||||
'title' => 'required|string|max:255',
|
||||
'type' => 'required|in:fields,bom',
|
||||
'is_template' => 'nullable|boolean',
|
||||
'is_default' => 'nullable|boolean',
|
||||
'description' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/ItemMaster/LinkEntityRequest.php
Normal file
30
app/Http/Requests/ItemMaster/LinkEntityRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ItemMaster;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LinkEntityRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'child_id' => '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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php
Normal file
35
app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ItemMaster;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReorderRelationshipsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'parent_type' => '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' => '정렬 항목']),
|
||||
];
|
||||
}
|
||||
}
|
||||
191
app/Models/ItemMaster/EntityRelationship.php
Normal file
191
app/Models/ItemMaster/EntityRelationship.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ItemMaster;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* EntityRelationship - 엔티티 간 관계를 관리하는 링크 테이블 모델
|
||||
*
|
||||
* 지원하는 관계 유형:
|
||||
* - page-section: 페이지와 섹션 연결
|
||||
* - page-field: 페이지와 필드 직접 연결
|
||||
* - section-field: 섹션과 필드 연결
|
||||
* - section-bom: 섹션과 BOM 항목 연결
|
||||
*/
|
||||
class EntityRelationship extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'group_id',
|
||||
'parent_type',
|
||||
'parent_id',
|
||||
'child_type',
|
||||
'child_id',
|
||||
'order_no',
|
||||
'metadata',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'group_id' => '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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지와 연결된 섹션 관계 목록 (링크 테이블 기반)
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ItemMaster;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class SectionTemplate extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'title',
|
||||
'type',
|
||||
'description',
|
||||
'is_default',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'deleted_by',
|
||||
'deleted_at',
|
||||
];
|
||||
}
|
||||
379
app/Services/ItemMaster/EntityRelationshipService.php
Normal file
379
app/Services/ItemMaster/EntityRelationshipService.php
Normal file
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ItemMaster;
|
||||
|
||||
use App\Models\ItemMaster\EntityRelationship;
|
||||
use App\Models\ItemMaster\ItemBomItem;
|
||||
use App\Models\ItemMaster\ItemField;
|
||||
use App\Models\ItemMaster\ItemPage;
|
||||
use App\Models\ItemMaster\ItemSection;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* EntityRelationshipService - 엔티티 간 관계(링크) 관리 서비스
|
||||
*/
|
||||
class EntityRelationshipService extends Service
|
||||
{
|
||||
/**
|
||||
* 페이지에 섹션 연결
|
||||
*/
|
||||
public function linkSectionToPage(int $pageId, int $sectionId, 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'));
|
||||
}
|
||||
|
||||
// 섹션 존재 확인
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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 항목 생성
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 생성
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션 생성
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
474
app/Swagger/v1/EntityRelationshipApi.php
Normal file
474
app/Swagger/v1/EntityRelationshipApi.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="ItemMaster-Relationships", description="품목기준관리 - 엔티티 관계 API")
|
||||
*
|
||||
* ========================================
|
||||
* 모델 스키마
|
||||
* ========================================
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="EntityRelationship",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="group_id", type="integer", example=1, description="그룹 ID (1: 품목관리)"),
|
||||
* @OA\Property(property="parent_type", type="string", enum={"page","section"}, example="page"),
|
||||
* @OA\Property(property="parent_id", type="integer", example=1),
|
||||
* @OA\Property(property="child_type", type="string", enum={"section","field","bom"}, example="section"),
|
||||
* @OA\Property(property="child_id", type="integer", example=1),
|
||||
* @OA\Property(property="order_no", type="integer", example=0),
|
||||
* @OA\Property(property="metadata", type="object", nullable=true, example=null),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-11-26 10:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-11-26 10:00:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="LinkEntityRequest",
|
||||
* type="object",
|
||||
* required={"child_id"},
|
||||
*
|
||||
* @OA\Property(property="child_id", type="integer", example=1, description="연결할 엔티티 ID"),
|
||||
* @OA\Property(property="order_no", type="integer", example=0, description="정렬 순서")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ReorderRelationshipsRequest",
|
||||
* type="object",
|
||||
* required={"parent_type","parent_id","ordered_items"},
|
||||
*
|
||||
* @OA\Property(property="parent_type", type="string", enum={"page","section"}, example="page"),
|
||||
* @OA\Property(property="parent_id", type="integer", example=1),
|
||||
* @OA\Property(
|
||||
* property="ordered_items",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="child_type", type="string", example="section"),
|
||||
* @OA\Property(property="child_id", type="integer", example=1)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="PageStructure",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="page", ref="#/components/schemas/ItemPage"),
|
||||
* @OA\Property(
|
||||
* property="sections",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="section", ref="#/components/schemas/ItemSection"),
|
||||
* @OA\Property(property="order_no", type="integer", example=0),
|
||||
* @OA\Property(property="fields", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="bom_items", type="array", @OA\Items(type="object"))
|
||||
* )
|
||||
* ),
|
||||
* @OA\Property(
|
||||
* property="direct_fields",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="field", ref="#/components/schemas/ItemField"),
|
||||
* @OA\Property(property="order_no", type="integer", example=0)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class EntityRelationshipApi
|
||||
{
|
||||
// ========================================
|
||||
// 페이지-섹션 연결
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/pages/{pageId}/link-section",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="페이지에 섹션 연결",
|
||||
* description="페이지와 섹션을 연결합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/LinkEntityRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 성공"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/EntityRelationship")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="페이지 또는 섹션을 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function linkSectionToPage() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/item-master/pages/{pageId}/unlink-section/{sectionId}",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="페이지에서 섹션 연결 해제",
|
||||
* description="페이지와 섹션의 연결을 해제합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 해제 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 해제 성공"),
|
||||
* @OA\Property(property="data", type="string", example="success")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function unlinkSectionFromPage() {}
|
||||
|
||||
// ========================================
|
||||
// 페이지-필드 직접 연결
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/pages/{pageId}/link-field",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="페이지에 필드 직접 연결",
|
||||
* description="페이지와 필드를 직접 연결합니다 (섹션 없이).",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/LinkEntityRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 성공"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/EntityRelationship")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="페이지 또는 필드를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function linkFieldToPage() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/item-master/pages/{pageId}/unlink-field/{fieldId}",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="페이지에서 필드 연결 해제",
|
||||
* description="페이지와 필드의 연결을 해제합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="fieldId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 해제 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 해제 성공"),
|
||||
* @OA\Property(property="data", type="string", example="success")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function unlinkFieldFromPage() {}
|
||||
|
||||
// ========================================
|
||||
// 페이지 관계 조회
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/pages/{pageId}/relationships",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="페이지의 모든 관계 조회",
|
||||
* description="페이지에 연결된 모든 관계(섹션, 필드)를 조회합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="조회 성공"),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(ref="#/components/schemas/EntityRelationship")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="페이지를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function getPageRelationships() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/pages/{pageId}/structure",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="페이지 구조 조회",
|
||||
* description="페이지의 전체 구조를 조회합니다 (섹션, 직접 연결된 필드, 중첩 구조).",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="조회 성공"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/PageStructure")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="페이지를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function getPageStructure() {}
|
||||
|
||||
// ========================================
|
||||
// 섹션-필드 연결
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/sections/{sectionId}/link-field",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="섹션에 필드 연결",
|
||||
* description="섹션과 필드를 연결합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/LinkEntityRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 성공"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/EntityRelationship")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="섹션 또는 필드를 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function linkFieldToSection() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/item-master/sections/{sectionId}/unlink-field/{fieldId}",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="섹션에서 필드 연결 해제",
|
||||
* description="섹션과 필드의 연결을 해제합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="fieldId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 해제 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 해제 성공"),
|
||||
* @OA\Property(property="data", type="string", example="success")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function unlinkFieldFromSection() {}
|
||||
|
||||
// ========================================
|
||||
// 섹션-BOM 연결
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/sections/{sectionId}/link-bom",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="섹션에 BOM 항목 연결",
|
||||
* description="섹션과 BOM 항목을 연결합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/LinkEntityRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 성공"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/EntityRelationship")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="섹션 또는 BOM 항목을 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function linkBomToSection() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/item-master/sections/{sectionId}/unlink-bom/{bomId}",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="섹션에서 BOM 항목 연결 해제",
|
||||
* description="섹션과 BOM 항목의 연결을 해제합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="bomId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="연결 해제 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="연결 해제 성공"),
|
||||
* @OA\Property(property="data", type="string", example="success")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function unlinkBomFromSection() {}
|
||||
|
||||
// ========================================
|
||||
// 섹션 관계 조회
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/sections/{sectionId}/relationships",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="섹션의 자식 관계 조회",
|
||||
* description="섹션에 연결된 모든 자식 관계(필드, BOM)를 조회합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="sectionId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="조회 성공"),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
*
|
||||
* @OA\Items(ref="#/components/schemas/EntityRelationship")
|
||||
* )
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="섹션을 찾을 수 없음")
|
||||
* )
|
||||
*/
|
||||
public function getSectionRelationships() {}
|
||||
|
||||
// ========================================
|
||||
// 관계 순서 변경
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/relationships/reorder",
|
||||
* tags={"ItemMaster-Relationships"},
|
||||
* summary="관계 순서 변경",
|
||||
* description="특정 부모 아래의 자식 관계 순서를 변경합니다.",
|
||||
* security={{"ApiKeyAuth":{}}, {"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(ref="#/components/schemas/ReorderRelationshipsRequest")
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="순서 변경 성공",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="정렬 변경 성공"),
|
||||
* @OA\Property(property="data", type="string", example="success")
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function reorderRelationships() {}
|
||||
}
|
||||
@@ -35,10 +35,14 @@
|
||||
*
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="page_id", type="integer", example=1),
|
||||
* @OA\Property(property="group_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="page_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="title", type="string", example="제품 상세"),
|
||||
* @OA\Property(property="type", type="string", enum={"fields","bom"}, example="fields"),
|
||||
* @OA\Property(property="order_no", type="integer", example=0),
|
||||
* @OA\Property(property="is_template", type="boolean", example=false, description="템플릿 여부"),
|
||||
* @OA\Property(property="is_default", type="boolean", example=false, description="기본 템플릿 여부"),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example="섹션 설명"),
|
||||
* @OA\Property(property="created_at", type="string", example="2025-11-20 10:00:00"),
|
||||
* @OA\Property(property="updated_at", type="string", example="2025-11-20 10:00:00"),
|
||||
* @OA\Property(
|
||||
@@ -188,6 +192,73 @@
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="IndependentSectionStoreRequest",
|
||||
* type="object",
|
||||
* required={"title","type"},
|
||||
*
|
||||
* @OA\Property(property="group_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="title", type="string", maxLength=255, example="독립 섹션"),
|
||||
* @OA\Property(property="type", type="string", enum={"fields","bom"}, example="fields"),
|
||||
* @OA\Property(property="is_template", type="boolean", example=false),
|
||||
* @OA\Property(property="is_default", type="boolean", example=false),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example="섹션 설명")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="IndependentFieldStoreRequest",
|
||||
* type="object",
|
||||
* required={"field_name","field_type"},
|
||||
*
|
||||
* @OA\Property(property="group_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="field_name", type="string", maxLength=255, example="제품명"),
|
||||
* @OA\Property(property="field_type", type="string", enum={"textbox","number","dropdown","checkbox","date","textarea"}, example="textbox"),
|
||||
* @OA\Property(property="is_required", type="boolean", example=false),
|
||||
* @OA\Property(property="default_value", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="placeholder", type="string", nullable=true, maxLength=255, example="입력하세요"),
|
||||
* @OA\Property(property="display_condition", type="object", nullable=true, example=null),
|
||||
* @OA\Property(property="validation_rules", type="object", nullable=true, example=null),
|
||||
* @OA\Property(property="options", type="object", nullable=true, example=null),
|
||||
* @OA\Property(property="properties", type="object", nullable=true, example=null)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="IndependentBomItemStoreRequest",
|
||||
* type="object",
|
||||
* required={"item_name"},
|
||||
*
|
||||
* @OA\Property(property="group_id", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="item_code", type="string", nullable=true, maxLength=100, example="ITEM001"),
|
||||
* @OA\Property(property="item_name", type="string", maxLength=255, example="부품 A"),
|
||||
* @OA\Property(property="quantity", type="number", format="float", example=1),
|
||||
* @OA\Property(property="unit", type="string", nullable=true, maxLength=50, example="EA"),
|
||||
* @OA\Property(property="unit_price", type="number", format="float", nullable=true, example=10000),
|
||||
* @OA\Property(property="total_price", type="number", format="float", nullable=true, example=10000),
|
||||
* @OA\Property(property="spec", type="string", nullable=true, example="규격"),
|
||||
* @OA\Property(property="note", type="string", nullable=true, example="비고")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="SectionUsageResponse",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="section_id", type="integer", example=1),
|
||||
* @OA\Property(property="direct_page", type="object", nullable=true),
|
||||
* @OA\Property(property="linked_pages", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="total_usage_count", type="integer", example=2)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="FieldUsageResponse",
|
||||
* type="object",
|
||||
*
|
||||
* @OA\Property(property="field_id", type="integer", example=1),
|
||||
* @OA\Property(property="direct_section", type="object", nullable=true),
|
||||
* @OA\Property(property="linked_sections", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="linked_pages", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="total_usage_count", type="integer", example=3)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="ItemSectionUpdateRequest",
|
||||
* type="object",
|
||||
*
|
||||
@@ -507,11 +578,247 @@ public function updatePages() {}
|
||||
*/
|
||||
public function destroyPages() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/sections",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="독립 섹션 목록 조회",
|
||||
* description="페이지와 연결되지 않은 독립 섹션 목록을 조회합니다. is_template 파라미터로 템플릿 필터링이 가능합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="is_template", in="query", description="템플릿 여부 필터", @OA\Schema(type="boolean")),
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemSection")))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function indexSections() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/sections",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="독립 섹션 생성",
|
||||
* description="페이지와 연결되지 않은 독립 섹션을 생성합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentSectionStoreRequest")),
|
||||
*
|
||||
* @OA\Response(response=200, description="생성 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemSection"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function storeIndependentSection() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/sections/{id}/clone",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="섹션 복제",
|
||||
* description="기존 섹션을 복제하여 새 독립 섹션을 생성합니다. 하위 필드와 BOM 항목도 함께 복제됩니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="복제할 섹션 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="복제 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemSection"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cloneSection() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/sections/{id}/usage",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="섹션 사용처 조회",
|
||||
* description="섹션이 어떤 페이지에 연결되어 있는지 조회합니다. FK 기반 연결과 entity_relationships 기반 연결 모두 조회됩니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="섹션 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/SectionUsageResponse"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getSectionUsage() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/fields",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="독립 필드 목록 조회",
|
||||
* description="섹션과 연결되지 않은 독립 필드 목록을 조회합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemField")))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function indexFields() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/fields",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="독립 필드 생성",
|
||||
* description="섹션과 연결되지 않은 독립 필드를 생성합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentFieldStoreRequest")),
|
||||
*
|
||||
* @OA\Response(response=200, description="생성 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function storeIndependentField() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/fields/{id}/clone",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="필드 복제",
|
||||
* description="기존 필드를 복제하여 새 독립 필드를 생성합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="복제할 필드 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="복제 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemField"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function cloneField() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/fields/{id}/usage",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="필드 사용처 조회",
|
||||
* description="필드가 어떤 섹션/페이지에 연결되어 있는지 조회합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="필드 ID", @OA\Schema(type="integer")),
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/FieldUsageResponse"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function getFieldUsage() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/item-master/bom-items",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="독립 BOM 목록 조회",
|
||||
* description="섹션과 연결되지 않은 독립 BOM 항목 목록을 조회합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Response(response=200, description="조회 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ItemBomItem")))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function indexBomItems() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/bom-items",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="독립 BOM 생성",
|
||||
* description="섹션과 연결되지 않은 독립 BOM 항목을 생성합니다.",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/IndependentBomItemStoreRequest")),
|
||||
*
|
||||
* @OA\Response(response=200, description="생성 성공",
|
||||
*
|
||||
* @OA\JsonContent(allOf={
|
||||
*
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ItemBomItem"))
|
||||
* })
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function storeIndependentBomItem() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/item-master/pages/{pageId}/sections",
|
||||
* tags={"ItemMaster"},
|
||||
* summary="섹션 생성",
|
||||
* summary="섹션 생성 (페이지 연결)",
|
||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||
*
|
||||
* @OA\Parameter(name="pageId", in="path", required=true, @OA\Schema(type="integer")),
|
||||
|
||||
@@ -63,7 +63,17 @@ public function up(): void
|
||||
DB::statement("ALTER TABLE categories
|
||||
MODIFY COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성여부(1=활성,0=비활성)'");
|
||||
|
||||
// A-3. 유니크 (tenant_id, code) → (tenant_id, code_group, code)
|
||||
// A-3. code_group 컬럼 추가 (유니크 인덱스 추가 전에 필요)
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('categories', 'code_group')) {
|
||||
$table->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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Item Master 하이브리드 구조 전환 - Phase 1
|
||||
*
|
||||
* 목적: CASCADE FK 기반 계층 구조를 독립 엔티티 + 링크 테이블 구조로 전환
|
||||
*
|
||||
* 변경 내용:
|
||||
* 1. item_sections에서 page_id FK 제거 (컬럼은 유지, FK 제약만 제거)
|
||||
* 2. item_fields에서 section_id FK 제거
|
||||
* 3. item_bom_items에서 section_id FK 제거
|
||||
* 4. 모든 item 관련 테이블에 group_id 추가 (카테고리 격리용, 기본값 1 = 품목관리)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. item_sections: page_id FK 제거 (컬럼 유지, FK만 제거)
|
||||
Schema::table('item_sections', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Item Master 하이브리드 구조 전환 - Phase 2
|
||||
*
|
||||
* 목적: 엔티티 간 관계를 관리하는 범용 링크 테이블 생성
|
||||
*
|
||||
* 지원하는 관계 유형:
|
||||
* - page-section: 페이지와 섹션 연결
|
||||
* - page-field: 페이지와 필드 직접 연결
|
||||
* - section-field: 섹션과 필드 연결
|
||||
* - section-bom: 섹션과 BOM 항목 연결
|
||||
*
|
||||
* 특징:
|
||||
* - 다대다(Many-to-Many) 관계 지원
|
||||
* - 동일 엔티티를 여러 부모에 연결 가능
|
||||
* - 부모 삭제 시 링크만 제거, 자식 엔티티는 유지
|
||||
* - group_id로 카테고리 격리
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entity_relationships', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Item Master 하이브리드 구조 전환 - Phase 3
|
||||
*
|
||||
* 목적: 기존 FK 기반 관계 데이터를 entity_relationships 테이블로 이관
|
||||
*
|
||||
* 이관 대상:
|
||||
* 1. item_sections.page_id → entity_relationships (page-section)
|
||||
* 2. item_fields.section_id → entity_relationships (section-field)
|
||||
* 3. item_bom_items.section_id → entity_relationships (section-bom)
|
||||
*
|
||||
* 주의사항:
|
||||
* - 기존 컬럼(page_id, section_id)의 값은 유지 (하위 호환성)
|
||||
* - soft delete된 레코드도 이관 대상에 포함
|
||||
* - order_no 값을 그대로 복사
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. item_sections → entity_relationships (page-section)
|
||||
DB::statement("
|
||||
INSERT INTO entity_relationships (tenant_id, group_id, parent_type, parent_id, child_type, child_id, order_no, created_by, created_at, updated_at)
|
||||
SELECT
|
||||
s.tenant_id,
|
||||
COALESCE(s.group_id, 1) as group_id,
|
||||
'page' as parent_type,
|
||||
s.page_id as parent_id,
|
||||
'section' as child_type,
|
||||
s.id as child_id,
|
||||
s.order_no,
|
||||
s.created_by,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM item_sections s
|
||||
WHERE s.page_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE updated_at = NOW()
|
||||
");
|
||||
|
||||
// 2. item_fields → entity_relationships (section-field)
|
||||
DB::statement("
|
||||
INSERT INTO entity_relationships (tenant_id, group_id, parent_type, parent_id, child_type, child_id, order_no, created_by, created_at, updated_at)
|
||||
SELECT
|
||||
f.tenant_id,
|
||||
COALESCE(f.group_id, 1) as group_id,
|
||||
'section' as parent_type,
|
||||
f.section_id as parent_id,
|
||||
'field' as child_type,
|
||||
f.id as child_id,
|
||||
f.order_no,
|
||||
f.created_by,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM item_fields f
|
||||
WHERE f.section_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE updated_at = NOW()
|
||||
");
|
||||
|
||||
// 3. item_bom_items → entity_relationships (section-bom)
|
||||
// BOM 항목은 order_no가 없으므로 id 기준으로 순서 부여
|
||||
DB::statement("
|
||||
INSERT INTO entity_relationships (tenant_id, group_id, parent_type, parent_id, child_type, child_id, order_no, created_by, created_at, updated_at)
|
||||
SELECT
|
||||
b.tenant_id,
|
||||
COALESCE(b.group_id, 1) as group_id,
|
||||
'section' as parent_type,
|
||||
b.section_id as parent_id,
|
||||
'bom' as child_type,
|
||||
b.id as child_id,
|
||||
@rownum := @rownum + 1 as order_no,
|
||||
b.created_by,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM item_bom_items b, (SELECT @rownum := 0) r
|
||||
WHERE b.section_id IS NOT NULL
|
||||
ORDER BY b.section_id, b.id
|
||||
ON DUPLICATE KEY UPDATE updated_at = NOW()
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 이관된 데이터 삭제 (page-section, section-field, section-bom 관계만)
|
||||
DB::table('entity_relationships')
|
||||
->whereIn('parent_type', ['page', 'section'])
|
||||
->whereIn('child_type', ['section', 'field', 'bom'])
|
||||
->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 1. item_sections에 is_template, description 컬럼 추가
|
||||
* 2. section_templates 데이터를 item_sections로 이관
|
||||
* 3. section_templates 테이블 제거
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. item_sections에 is_template, description 컬럼 추가
|
||||
Schema::table('item_sections', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
1297
docs/front/[API-2025-11-20] item-master-specification.md
Normal file
1297
docs/front/[API-2025-11-20] item-master-specification.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<number, TabColumnResponse[]>; // 탭별 컬럼 설정
|
||||
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<ItemPageRequest>`
|
||||
|
||||
**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<ItemSectionRequest>`
|
||||
|
||||
**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<string, any>;
|
||||
properties?: Record<string, any>;
|
||||
|
||||
// 조건부 표시 설정 (신규 기능)
|
||||
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<ItemFieldRequest>`
|
||||
|
||||
**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<string, any>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
**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<string, any>;
|
||||
properties?: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
@@ -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`
|
||||
@@ -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 항목을 찾을 수 없습니다.',
|
||||
];
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
'toggled' => '상태 변경 성공',
|
||||
'bulk_upsert' => '대량 저장 성공',
|
||||
'reordered' => '정렬 변경 성공',
|
||||
'linked' => '연결 성공',
|
||||
'unlinked' => '연결 해제 성공',
|
||||
'no_changes' => '변경 사항이 없습니다.',
|
||||
|
||||
// 인증/세션
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user