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:
2025-11-26 14:09:31 +09:00
parent 3fefb8ce26
commit bccfa19791
38 changed files with 5888 additions and 92 deletions

View File

@@ -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 인증 에러 처리 개선 및 요청 로그 강화
### 문제 상황

View File

@@ -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`

View File

@@ -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'));
}
}

View File

@@ -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)
{

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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',
];
}
}

View File

@@ -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',
];
}
}

View File

@@ -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',
];
}
}

View 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]),
];
}
}

View 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' => '정렬 항목']),
];
}
}

View 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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',

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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',
];
}

View 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;
}
}

View File

@@ -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 항목 생성
*/

View File

@@ -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),
];
}
/**
* 필드 생성
*/

View File

@@ -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();

View File

@@ -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),
];
}
/**
* 섹션 생성
*/

View File

@@ -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();
}
}
}

View 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() {}
}

View File

@@ -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")),

View File

@@ -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)

View File

@@ -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();
});
}

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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();
}
};

View File

@@ -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']);
});
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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`

View File

@@ -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 항목을 찾을 수 없습니다.',
];

View File

@@ -17,6 +17,8 @@
'toggled' => '상태 변경 성공',
'bulk_upsert' => '대량 저장 성공',
'reordered' => '정렬 변경 성공',
'linked' => '연결 성공',
'unlinked' => '연결 해제 성공',
'no_changes' => '변경 사항이 없습니다.',
// 인증/세션

View File

@@ -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');
});
});