feat: ItemMaster Phase 3 API 구현 (부가 기능 8개 엔드포인트)

- Controller 2개, Service 2개, FormRequest 3개 생성
- Routes 등록 (커스텀 탭, 단위 옵션)
- Service-First, Multi-tenant, Soft Delete
- 라우트 테스트 및 Pint 검사 통과
- ItemMaster API 전체 32개 엔드포인트 구현 완료

9 files changed, 467 insertions(+)
This commit is contained in:
2025-11-20 17:16:03 +09:00
parent ddfcaabfb2
commit a85cf0a144
9 changed files with 492 additions and 0 deletions

View File

@@ -1,3 +1,126 @@
## 2025-11-20 (수) - ItemMaster Phase 3 API 구현 (부가 기능)
### 주요 작업
- Phase 3 부가 기능 8개 API 엔드포인트 구현
- 커스텀 탭, 단위 옵션 관리 기능 추가
- SAM API Development Rules 준수 (Service-First, ApiResponse, i18n)
### 추가된 파일
#### Controllers (2개)
1. **app/Http/Controllers/Api/V1/ItemMaster/CustomTabController.php**
- index(), store(), update(), destroy(), reorder()
- 커스텀 탭 관리 및 순서 변경
2. **app/Http/Controllers/Api/V1/ItemMaster/UnitOptionController.php**
- index(), store(), destroy()
- 단위 옵션 관리 (Update 없음)
#### Services (2개)
1. **app/Services/ItemMaster/CustomTabService.php**
- CRUD + reorder
- columnSetting 관계 Eager Loading
- order_no 자동 계산
2. **app/Services/ItemMaster/UnitOptionService.php**
- index, store, destroy만 구현
- label 정렬
#### FormRequests (3개)
1. **app/Http/Requests/ItemMaster/CustomTabStoreRequest.php**
- label (required), icon, is_default
2. **app/Http/Requests/ItemMaster/CustomTabUpdateRequest.php**
- 모든 필드 sometimes
3. **app/Http/Requests/ItemMaster/UnitOptionStoreRequest.php**
- label (required), value (required)
### 수정된 파일
1. **routes/api.php**
- ItemMaster 관련 use 문 2개 추가
- 8개 엔드포인트 추가:
- GET/POST/PUT/DELETE /custom-tabs, PUT /custom-tabs/reorder
- GET/POST/DELETE /unit-options
### 작업 내용
#### API 엔드포인트 (8개)
1. ✅ GET `/custom-tabs` - 커스텀 탭 목록
2. ✅ POST `/custom-tabs` - 커스텀 탭 생성
3. ✅ PUT `/custom-tabs/{id}` - 커스텀 탭 수정
4. ✅ DELETE `/custom-tabs/{id}` - 커스텀 탭 삭제
5. ✅ PUT `/custom-tabs/reorder` - 커스텀 탭 순서 변경
6. ✅ GET `/unit-options` - 단위 옵션 목록
7. ✅ POST `/unit-options` - 단위 옵션 생성
8. ✅ DELETE `/unit-options/{id}` - 단위 옵션 삭제
#### 기술적 특징
**Service-First 패턴**:
- Controller는 DI + ApiResponse::handle()만 사용
- 모든 비즈니스 로직은 Service에 구현
- Service extends Service (tenantId(), apiUserId() 활용)
**Multi-tenant 지원**:
- 모든 Service 메서드에서 tenantId() 검증
- BelongsToTenant 스코프 자동 적용
- Soft Delete시 tenant_id 검증
**실시간 저장**:
- 모든 CUD 작업 즉시 처리
- order_no 자동 계산 (CustomTab)
- reorder는 배열로 한 번에 처리
**i18n 메시지**:
- __('message.fetched'), __('message.created')
- __('message.updated'), __('message.deleted')
- __('message.reordered'), __('error.not_found')
### 검증 결과
**라우트 테스트**:
```bash
php artisan route:list --path=item-master
# 결과: 32개 엔드포인트 정상 등록 (Phase 1: 13개 + Phase 2: 11개 + Phase 3: 8개)
```
**Pint 검사**:
```bash
./vendor/bin/pint --test [7개 파일]
# 결과: 7 files PASS
```
### 완료 상태
**ItemMaster API 전체 구현 완료**:
- Phase 1 (핵심): 13개 엔드포인트 ✅
- Phase 2 (확장): 11개 엔드포인트 ✅
- Phase 3 (부가): 8개 엔드포인트 ✅
- **총 32개 엔드포인트 구현 완료**
**다음 작업**:
- Swagger 문서화 (app/Swagger/v1/ItemMasterApi.php)
- API 테스트 케이스 작성
- 프론트엔드 연동
### Git 커밋
```bash
git commit [hash]
feat: ItemMaster Phase 3 API 구현 (부가 기능 8개 엔드포인트)
- Controller 2개, Service 2개, FormRequest 3개 생성
- Routes 등록 (커스텀 탭, 단위 옵션)
- Service-First, Multi-tenant, Soft Delete
- 라우트 테스트 및 Pint 검사 통과
- ItemMaster API 전체 32개 엔드포인트 구현 완료
8 files changed, 400+ insertions(+)
```
---
## 2025-11-20 (수) - ItemMaster Phase 2 API 구현 (확장 기능)
### 주요 작업

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api\V1\ItemMaster;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemMaster\CustomTabStoreRequest;
use App\Http\Requests\ItemMaster\CustomTabUpdateRequest;
use App\Http\Requests\ItemMaster\ReorderRequest;
use App\Http\Responses\ApiResponse;
use App\Services\ItemMaster\CustomTabService;
class CustomTabController extends Controller
{
public function __construct(
protected CustomTabService $service,
) {}
/**
* 커스텀 탭 목록
*/
public function index()
{
return ApiResponse::handle(function () {
return $this->service->index();
}, __('message.fetched'));
}
/**
* 커스텀 탭 생성
*/
public function store(CustomTabStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
/**
* 커스텀 탭 수정
*/
public function update(int $id, CustomTabUpdateRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 커스텀 탭 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
/**
* 커스텀 탭 순서 변경
*/
public function reorder(ReorderRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$this->service->reorder($request->validated()['items']);
return 'success';
}, __('message.reordered'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\V1\ItemMaster;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemMaster\UnitOptionStoreRequest;
use App\Http\Responses\ApiResponse;
use App\Services\ItemMaster\UnitOptionService;
class UnitOptionController extends Controller
{
public function __construct(
protected UnitOptionService $service,
) {}
/**
* 단위 옵션 목록
*/
public function index()
{
return ApiResponse::handle(function () {
return $this->service->index();
}, __('message.fetched'));
}
/**
* 단위 옵션 생성
*/
public function store(UnitOptionStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
/**
* 단위 옵션 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class CustomTabStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'label' => 'required|string|max:255',
'icon' => 'nullable|string|max:100',
'is_default' => 'nullable|boolean',
];
}
}

View File

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

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\ItemMaster;
use Illuminate\Foundation\Http\FormRequest;
class UnitOptionStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'label' => 'required|string|max:100',
'value' => 'required|string|max:50',
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Services\ItemMaster;
use App\Models\ItemMaster\CustomTab;
use App\Services\Service;
use Illuminate\Database\Eloquent\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CustomTabService extends Service
{
/**
* 커스텀 탭 목록
*/
public function index(): Collection
{
$tenantId = $this->tenantId();
return CustomTab::with('columnSetting')
->where('tenant_id', $tenantId)
->orderBy('order_no')
->get();
}
/**
* 커스텀 탭 생성
*/
public function store(array $data): CustomTab
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$maxOrder = CustomTab::where('tenant_id', $tenantId)
->max('order_no');
$tab = CustomTab::create([
'tenant_id' => $tenantId,
'label' => $data['label'],
'icon' => $data['icon'] ?? null,
'is_default' => $data['is_default'] ?? false,
'order_no' => ($maxOrder ?? -1) + 1,
'created_by' => $userId,
]);
return $tab->load('columnSetting');
}
/**
* 커스텀 탭 수정
*/
public function update(int $id, array $data): CustomTab
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$tab = CustomTab::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $tab) {
throw new NotFoundHttpException(__('error.not_found'));
}
$tab->update([
'label' => $data['label'] ?? $tab->label,
'icon' => $data['icon'] ?? $tab->icon,
'is_default' => $data['is_default'] ?? $tab->is_default,
'updated_by' => $userId,
]);
return $tab->load('columnSetting');
}
/**
* 커스텀 탭 삭제
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$tab = CustomTab::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $tab) {
throw new NotFoundHttpException(__('error.not_found'));
}
$tab->update(['deleted_by' => $userId]);
$tab->delete();
}
/**
* 커스텀 탭 순서 변경
*/
public function reorder(array $items): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
foreach ($items as $item) {
CustomTab::where('tenant_id', $tenantId)
->where('id', $item['id'])
->update([
'order_no' => $item['order_no'],
'updated_by' => $userId,
]);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services\ItemMaster;
use App\Models\ItemMaster\UnitOption;
use App\Services\Service;
use Illuminate\Database\Eloquent\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class UnitOptionService extends Service
{
/**
* 단위 옵션 목록
*/
public function index(): Collection
{
$tenantId = $this->tenantId();
return UnitOption::where('tenant_id', $tenantId)
->orderBy('label')
->get();
}
/**
* 단위 옵션 생성
*/
public function store(array $data): UnitOption
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$unit = UnitOption::create([
'tenant_id' => $tenantId,
'label' => $data['label'],
'value' => $data['value'],
'created_by' => $userId,
]);
return $unit;
}
/**
* 단위 옵션 삭제
*/
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$unit = UnitOption::where('tenant_id', $tenantId)
->where('id', $id)
->first();
if (! $unit) {
throw new NotFoundHttpException(__('error.not_found'));
}
$unit->update(['deleted_by' => $userId]);
$unit->delete();
}
}

View File

@@ -19,6 +19,7 @@
use App\Http\Controllers\Api\V1\EstimateController;
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\ItemBomItemController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemFieldController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterController;
@@ -26,6 +27,7 @@
use App\Http\Controllers\Api\V1\ItemMaster\ItemPageController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemSectionController;
use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController;
use App\Http\Controllers\Api\V1\ItemMaster\UnitOptionController;
use App\Http\Controllers\Api\V1\MaterialController;
use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\ModelSetController;
@@ -515,6 +517,18 @@
Route::post('/master-fields', [ItemMasterFieldController::class, 'store'])->name('v1.item-master.master-fields.store');
Route::put('/master-fields/{id}', [ItemMasterFieldController::class, 'update'])->name('v1.item-master.master-fields.update');
Route::delete('/master-fields/{id}', [ItemMasterFieldController::class, 'destroy'])->name('v1.item-master.master-fields.destroy');
// 커스텀 탭
Route::get('/custom-tabs', [CustomTabController::class, 'index'])->name('v1.item-master.custom-tabs.index');
Route::post('/custom-tabs', [CustomTabController::class, 'store'])->name('v1.item-master.custom-tabs.store');
Route::put('/custom-tabs/{id}', [CustomTabController::class, 'update'])->name('v1.item-master.custom-tabs.update');
Route::delete('/custom-tabs/{id}', [CustomTabController::class, 'destroy'])->name('v1.item-master.custom-tabs.destroy');
Route::put('/custom-tabs/reorder', [CustomTabController::class, 'reorder'])->name('v1.item-master.custom-tabs.reorder');
// 단위 옵션
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');
});
});