feat: ItemMaster Phase 1 API 구현 (핵심 기능 13개 엔드포인트)

- Controller 4개 생성 (ItemMaster, ItemPage, ItemSection, ItemField)
- Service 4개 생성 (비즈니스 로직 및 Multi-tenant 지원)
- FormRequest 7개 생성 (Validation)
- Routes 등록 (/v1/item-master/*)

주요 API:
- GET /init - 전체 초기 데이터 로드
- 페이지 관리 (GET/POST/PUT/DELETE)
- 섹션 관리 (POST/PUT/DELETE/reorder)
- 필드 관리 (POST/PUT/DELETE/reorder)

기술적 특징:
- Service-First 패턴 (Controller는 DI + ApiResponse만)
- Multi-tenant 지원 (tenantId() 검증 + BelongsToTenant)
- Cascade Soft Delete (하위 엔티티 자동 처리)
- i18n 메시지 키 사용 (__('message.xxx'))
- order_no 자동 계산 및 reorder 지원

검증:
- 라우트 테스트: 13개 엔드포인트 정상 등록
- Pint 검사: 15 files PASS

SAM API Development Rules 준수
This commit is contained in:
2025-11-20 16:55:57 +09:00
parent 5cbfb42c0a
commit 4ccee253b6
17 changed files with 991 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1\ItemMaster;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemMaster\ItemFieldStoreRequest;
use App\Http\Requests\ItemMaster\ItemFieldUpdateRequest;
use App\Http\Requests\ItemMaster\ReorderRequest;
use App\Services\ItemMaster\ItemFieldService;
class ItemFieldController extends Controller
{
public function __construct(private ItemFieldService $service) {}
/**
* 필드 생성
*
* POST /api/v1/item-master/sections/{sectionId}/fields
*/
public function store(int $sectionId, ItemFieldStoreRequest $request)
{
return ApiResponse::handle(function () use ($sectionId, $request) {
return $this->service->store($sectionId, $request->validated());
}, __('message.created'));
}
/**
* 필드 수정
*
* PUT /api/v1/item-master/fields/{id}
*/
public function update(int $id, ItemFieldUpdateRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 필드 삭제 (Soft Delete)
*
* DELETE /api/v1/item-master/fields/{id}
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
/**
* 필드 순서 변경
*
* PUT /api/v1/item-master/sections/{sectionId}/fields/reorder
*/
public function reorder(int $sectionId, ReorderRequest $request)
{
return ApiResponse::handle(function () use ($sectionId, $request) {
$this->service->reorder($sectionId, $request->validated()['items']);
return 'success';
}, __('message.reordered'));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Api\V1\ItemMaster;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\ItemMaster\ItemMasterService;
class ItemMasterController extends Controller
{
public function __construct(private ItemMasterService $service) {}
/**
* 초기화 API - 전체 데이터 로드
*
* GET /api/v1/item-master/init
*/
public function init()
{
return ApiResponse::handle(function () {
return $this->service->init();
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1\ItemMaster;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemMaster\ItemPageStoreRequest;
use App\Http\Requests\ItemMaster\ItemPageUpdateRequest;
use App\Services\ItemMaster\ItemPageService;
use Illuminate\Http\Request;
class ItemPageController extends Controller
{
public function __construct(private ItemPageService $service) {}
/**
* 페이지 목록 조회 (섹션/필드 중첩 포함)
*
* GET /api/v1/item-master/pages?item_type=FG
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$itemType = $request->input('item_type');
return $this->service->index($itemType);
}, __('message.fetched'));
}
/**
* 페이지 생성
*
* POST /api/v1/item-master/pages
*/
public function store(ItemPageStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
/**
* 페이지 수정
*
* PUT /api/v1/item-master/pages/{id}
*/
public function update(int $id, ItemPageUpdateRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 페이지 삭제 (Soft Delete)
*
* DELETE /api/v1/item-master/pages/{id}
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1\ItemMaster;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemMaster\ItemSectionStoreRequest;
use App\Http\Requests\ItemMaster\ItemSectionUpdateRequest;
use App\Http\Requests\ItemMaster\ReorderRequest;
use App\Services\ItemMaster\ItemSectionService;
class ItemSectionController extends Controller
{
public function __construct(private ItemSectionService $service) {}
/**
* 섹션 생성
*
* POST /api/v1/item-master/pages/{pageId}/sections
*/
public function store(int $pageId, ItemSectionStoreRequest $request)
{
return ApiResponse::handle(function () use ($pageId, $request) {
return $this->service->store($pageId, $request->validated());
}, __('message.created'));
}
/**
* 섹션 수정
*
* PUT /api/v1/item-master/sections/{id}
*/
public function update(int $id, ItemSectionUpdateRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 섹션 삭제 (Soft Delete)
*
* DELETE /api/v1/item-master/sections/{id}
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
/**
* 섹션 순서 변경
*
* PUT /api/v1/item-master/pages/{pageId}/sections/reorder
*/
public function reorder(int $pageId, ReorderRequest $request)
{
return ApiResponse::handle(function () use ($pageId, $request) {
$this->service->reorder($pageId, $request->validated()['items']);
return 'success';
}, __('message.reordered'));
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ItemFieldStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'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,28 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ItemFieldUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'field_name' => 'sometimes|string|max:255',
'field_type' => 'sometimes|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,22 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ItemPageStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page_name' => 'required|string|max:255',
'item_type' => 'required|in:FG,PT,SM,RM,CS',
'absolute_path' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ItemPageUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page_name' => 'sometimes|string|max:255',
'absolute_path' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ItemSectionStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'type' => 'required|in:fields,bom',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ItemSectionUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'sometimes|string|max:255',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class ReorderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.id' => 'required|integer|exists:item_sections,id',
'items.*.order_no' => 'required|integer|min:0',
];
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Services\ItemMaster;
use App\Models\ItemMaster\ItemField;
use App\Services\Service;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ItemFieldService extends Service
{
/**
* 필드 생성
*/
public function store(int $sectionId, array $data): ItemField
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// order_no 자동 계산 (해당 섹션의 마지막 필드 + 1)
$maxOrder = ItemField::where('tenant_id', $tenantId)
->where('section_id', $sectionId)
->max('order_no');
$field = ItemField::create([
'tenant_id' => $tenantId,
'section_id' => $sectionId,
'field_name' => $data['field_name'],
'field_type' => $data['field_type'],
'order_no' => ($maxOrder ?? -1) + 1,
'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;
}
/**
* 필드 수정
*/
public function update(int $id, array $data): ItemField
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$field = ItemField::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $field) {
throw new NotFoundHttpException(__('error.not_found'));
}
$field->update([
'field_name' => $data['field_name'] ?? $field->field_name,
'field_type' => $data['field_type'] ?? $field->field_type,
'is_required' => $data['is_required'] ?? $field->is_required,
'default_value' => $data['default_value'] ?? $field->default_value,
'placeholder' => $data['placeholder'] ?? $field->placeholder,
'display_condition' => $data['display_condition'] ?? $field->display_condition,
'validation_rules' => $data['validation_rules'] ?? $field->validation_rules,
'options' => $data['options'] ?? $field->options,
'properties' => $data['properties'] ?? $field->properties,
'updated_by' => $userId,
]);
return $field;
}
/**
* 필드 삭제 (Soft Delete)
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$field = ItemField::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $field) {
throw new NotFoundHttpException(__('error.not_found'));
}
$field->update(['deleted_by' => $userId]);
$field->delete();
}
/**
* 필드 순서 변경
*
* @param array $items [['id' => 1, 'order_no' => 0], ['id' => 2, 'order_no' => 1], ...]
*/
public function reorder(int $sectionId, array $items): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
foreach ($items as $item) {
ItemField::where('tenant_id', $tenantId)
->where('section_id', $sectionId)
->where('id', $item['id'])
->update([
'order_no' => $item['order_no'],
'updated_by' => $userId,
]);
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Services\ItemMaster;
use App\Models\ItemMaster\CustomTab;
use App\Models\ItemMaster\ItemMasterField;
use App\Models\ItemMaster\ItemPage;
use App\Models\ItemMaster\SectionTemplate;
use App\Models\ItemMaster\UnitOption;
use App\Services\Service;
class ItemMasterService extends Service
{
/**
* 초기화 데이터 로드
*
* - pages (섹션/필드 중첩)
* - sectionTemplates
* - masterFields
* - customTabs (columnSetting 포함)
* - unitOptions
*/
public function init(): array
{
$tenantId = $this->tenantId();
// 1. 페이지 (섹션 → 필드 중첩)
$pages = ItemPage::with([
'sections' => function ($query) {
$query->orderBy('order_no');
},
'sections.fields' => function ($query) {
$query->orderBy('order_no');
},
'sections.bomItems',
])
->where('tenant_id', $tenantId)
->where('is_active', 1)
->get();
// 2. 섹션 템플릿
$sectionTemplates = SectionTemplate::where('tenant_id', $tenantId)->get();
// 3. 마스터 필드
$masterFields = ItemMasterField::where('tenant_id', $tenantId)->get();
// 4. 커스텀 탭 (컬럼 설정 포함)
$customTabs = CustomTab::with('columnSetting')
->where('tenant_id', $tenantId)
->orderBy('order_no')
->get();
// 5. 단위 옵션
$unitOptions = UnitOption::where('tenant_id', $tenantId)->get();
return [
'pages' => $pages,
'sectionTemplates' => $sectionTemplates,
'masterFields' => $masterFields,
'customTabs' => $customTabs,
'unitOptions' => $unitOptions,
];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Services\ItemMaster;
use App\Models\ItemMaster\ItemPage;
use App\Services\Service;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ItemPageService extends Service
{
/**
* 페이지 목록 조회 (섹션/필드 중첩)
*/
public function index(?string $itemType = null): Collection
{
$tenantId = $this->tenantId();
$query = ItemPage::with([
'sections' => function ($query) {
$query->orderBy('order_no');
},
'sections.fields' => function ($query) {
$query->orderBy('order_no');
},
'sections.bomItems',
])
->where('tenant_id', $tenantId)
->where('is_active', 1);
if ($itemType) {
$query->where('item_type', strtoupper($itemType));
}
return $query->get();
}
/**
* 페이지 생성
*/
public function store(array $data): ItemPage
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$page = ItemPage::create([
'tenant_id' => $tenantId,
'page_name' => $data['page_name'],
'item_type' => $data['item_type'],
'absolute_path' => $data['absolute_path'] ?? null,
'is_active' => true,
'created_by' => $userId,
]);
// 관계 로드
$page->load(['sections']);
return $page;
}
/**
* 페이지 수정
*/
public function update(int $id, array $data): ItemPage
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$page = ItemPage::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $page) {
throw new NotFoundHttpException(__('error.not_found'));
}
$page->update([
'page_name' => $data['page_name'] ?? $page->page_name,
'absolute_path' => $data['absolute_path'] ?? $page->absolute_path,
'updated_by' => $userId,
]);
$page->load(['sections.fields', 'sections.bomItems']);
return $page;
}
/**
* 페이지 삭제 (Soft Delete)
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$page = ItemPage::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $page) {
throw new NotFoundHttpException(__('error.not_found'));
}
$page->update(['deleted_by' => $userId]);
$page->delete();
// Cascade: 하위 섹션/필드도 Soft Delete
foreach ($page->sections as $section) {
$section->update(['deleted_by' => $userId]);
$section->delete();
foreach ($section->fields as $field) {
$field->update(['deleted_by' => $userId]);
$field->delete();
}
foreach ($section->bomItems as $bomItem) {
$bomItem->update(['deleted_by' => $userId]);
$bomItem->delete();
}
}
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Services\ItemMaster;
use App\Models\ItemMaster\ItemSection;
use App\Services\Service;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ItemSectionService extends Service
{
/**
* 섹션 생성
*/
public function store(int $pageId, array $data): ItemSection
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// order_no 자동 계산 (해당 페이지의 마지막 섹션 + 1)
$maxOrder = ItemSection::where('tenant_id', $tenantId)
->where('page_id', $pageId)
->max('order_no');
$section = ItemSection::create([
'tenant_id' => $tenantId,
'page_id' => $pageId,
'title' => $data['title'],
'type' => $data['type'],
'order_no' => ($maxOrder ?? -1) + 1,
'created_by' => $userId,
]);
$section->load(['fields', 'bomItems']);
return $section;
}
/**
* 섹션 수정
*/
public function update(int $id, array $data): ItemSection
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$section = ItemSection::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $section) {
throw new NotFoundHttpException(__('error.not_found'));
}
$section->update([
'title' => $data['title'] ?? $section->title,
'updated_by' => $userId,
]);
$section->load(['fields', 'bomItems']);
return $section;
}
/**
* 섹션 삭제 (Soft Delete)
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$section = ItemSection::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $section) {
throw new NotFoundHttpException(__('error.not_found'));
}
$section->update(['deleted_by' => $userId]);
$section->delete();
// Cascade: 하위 필드도 Soft Delete
foreach ($section->fields as $field) {
$field->update(['deleted_by' => $userId]);
$field->delete();
}
foreach ($section->bomItems as $bomItem) {
$bomItem->update(['deleted_by' => $userId]);
$bomItem->delete();
}
}
/**
* 섹션 순서 변경
*
* @param array $items [['id' => 1, 'order_no' => 0], ['id' => 2, 'order_no' => 1], ...]
*/
public function reorder(int $pageId, array $items): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
foreach ($items as $item) {
ItemSection::where('tenant_id', $tenantId)
->where('page_id', $pageId)
->where('id', $item['id'])
->update([
'order_no' => $item['order_no'],
'updated_by' => $userId,
]);
}
}
}