# Step 2: API 엔드포인트 > **프로젝트**: API (`sam/api`) > **선행 조건**: Step 1 완료 > **참조**: `standards/api-rules.md`, `standards/options-column-policy.md`, `rules/item-policy.md` --- ## 1. 설계 방침 ### 기존 규칙 준수 사항 | 규칙 | 적용 | |------|------| | URL prefix | `/api/v1/` | | 응답 형식 | `ApiResponse::handle()` → `{success, message, data}` | | Controller | FormRequest 타입힌트 → Service 호출만 | | Service | `extends Service`, `tenantId()`, `apiUserId()` 사용 | | i18n 메시지 | `__('message.bending_item.created')` 패턴 | | 멀티테넌시 | `BelongsToTenant` 글로벌 스코프 | | Audit 로그 | `audit_logs` 테이블 자동 기록 | | SoftDeletes | 기본 적용 | | options | `'array'` 캐스트, `getOption()`/`setOption()` 헬퍼 | | Validation | FormRequest 클래스, 컨트롤러에서 직접 validate() 금지 | ### 기존 Item 구조와의 관계 ``` 기존 구조: ItemsController → ItemsService → items 테이블 item_type: FG(완제품), PT(부품), SM(부자재), RM(원자재), CS(소모품) item_category: 'BENDING' (절곡품 구분) 절곡품 API 방향: → 기존 ItemsController 무변경 → 별도 BendingItemController 생성 (items 테이블을 item_category='BENDING'으로 필터) → 절곡품 전용 필터/검색/전개도 데이터 관리 ``` --- ## 2. 엔드포인트 설계 ### 2-1. 절곡품 기초관리 (개별 부품) | Method | Path | 설명 | 비고 | |--------|------|------|------| | GET | `/api/v1/bending-items` | 목록 (필터/검색/페이지네이션) | | | GET | `/api/v1/bending-items/filters` | 필터 옵션 (분류/재질/모델 distinct) | 캐시 10분 | | GET | `/api/v1/bending-items/{id}` | 상세 (options 전체) | | | POST | `/api/v1/bending-items` | 등록 | | | PUT | `/api/v1/bending-items/{id}` | 수정 | | | DELETE | `/api/v1/bending-items/{id}` | 삭제 (soft delete) | | | ~~이미지~~ | 기존 `ItemsFileController` 사용 | `field_key: 'bending_diagram'` | 별도 엔드포인트 불필요 | **필터 파라미터** (GET /api/v1/bending-items): ``` ?item_sep=스크린 # 대분류 &item_bending=가이드레일 # 중분류 &material=SUS # 재질 (부분 매칭) &model_UA=인정 # 인정여부 &search=KSS01 # 통합 검색 (이름/검색어/규격) &page=1&size=50 # 페이지네이션 (size — api-rules 기준) ``` ### 2-2. 절곡품 모델 관리 (조합) | Method | Path | 설명 | 비고 | |--------|------|------|------| | GET | `/api/v1/guiderail-models` | 모델 목록 (타입별) | ?type=가이드레일 | | GET | `/api/v1/guiderail-models/{id}` | 모델 상세 (부품 조합 + 재질별 폭합) | | | POST | `/api/v1/guiderail-models` | 모델 등록 | | | PUT | `/api/v1/guiderail-models/{id}` | 모델 수정 | | | DELETE | `/api/v1/guiderail-models/{id}` | 모델 삭제 (soft delete) | | --- ## 3. 구현 파일 구조 ### Controller ``` app/Http/Controllers/Api/V1/ ├─ BendingItemController.php ← 신규 └─ GuiderailModelController.php ← 신규 ``` ```php // BendingItemController.php class BendingItemController extends Controller { public function __construct(private BendingItemService $service) {} public function index(BendingItemIndexRequest $request) { return ApiResponse::handle(fn() => $this->service->list($request->validated()) ); } public function store(BendingItemStoreRequest $request) { return ApiResponse::handle(fn() => $this->service->create($request->validated()), __('message.bending_item.created') ); } public function show(int $id) { return ApiResponse::handle(fn() => $this->service->find($id) ); } public function update(BendingItemUpdateRequest $request, int $id) { return ApiResponse::handle(fn() => $this->service->update($id, $request->validated()), __('message.bending_item.updated') ); } public function destroy(int $id) { return ApiResponse::handle(fn() => $this->service->delete($id), __('message.bending_item.deleted') ); } } ``` ### Service ``` app/Services/ ├─ BendingItemService.php ← 신규 └─ GuiderailModelService.php ← 신규 ``` ```php // BendingItemService.php class BendingItemService extends Service { public function list(array $params): LengthAwarePaginator { return Item::where('item_category', 'BENDING') ->when($params['item_sep'] ?? null, fn($q, $v) => $q->where('options->item_sep', $v)) ->when($params['item_bending'] ?? null, fn($q, $v) => $q->where('options->item_bending', $v)) ->when($params['material'] ?? null, fn($q, $v) => $q->where('options->material', 'like', "%{$v}%")) ->when($params['model_UA'] ?? null, fn($q, $v) => $q->where('options->model_UA', $v)) ->when($params['search'] ?? null, fn($q, $v) => $q->where(fn($q2) => $q2 ->where('name', 'like', "%{$v}%") ->orWhere('options->search_keyword', 'like', "%{$v}%") ->orWhere('options->item_spec', 'like', "%{$v}%"))) ->orderByDesc('id') ->paginate($params['size'] ?? 50); } public function create(array $data): Item { $options = $this->buildOptions($data); $item = Item::create([ 'tenant_id' => $this->tenantId(), 'item_type' => 'PT', 'item_category' => 'BENDING', 'code' => $data['code'], 'name' => $data['name'], 'options' => $options, 'created_by' => $this->apiUserId(), ]); // audit log 자동 기록 return $item; } public function update(int $id, array $data): Item { $item = Item::findOrFail($id); // setOption()으로 개별 키 업데이트 (기존 키 보존) foreach ($data as $key => $value) { if (in_array($key, ['code', 'name'])) { $item->$key = $value; } else { $item->setOption($key, $value); } } $item->updated_by = $this->apiUserId(); $item->save(); return $item; } private function buildOptions(array $data): array { $options = []; $optionKeys = [ 'item_name', 'item_sep', 'item_bending', 'item_spec', 'material', 'model_name', 'model_UA', 'search_keyword', 'rail_width', 'registration_date', 'author', 'memo', 'parent_num', 'exit_direction', 'front_bottom_width', 'box_width', 'box_height', 'bendingData', 'image_path', ]; foreach ($optionKeys as $key) { if (isset($data[$key])) { $options[$key] = $data[$key]; } } return $options ?: null; } } ``` ### FormRequest ``` app/Http/Requests/Api/V1/ ├─ BendingItemIndexRequest.php ← 신규 ├─ BendingItemStoreRequest.php ← 신규 ├─ BendingItemUpdateRequest.php ← 신규 ├─ GuiderailModelStoreRequest.php ← 신규 └─ GuiderailModelUpdateRequest.php← 신규 ``` ```php // BendingItemStoreRequest.php class BendingItemStoreRequest extends FormRequest { public function rules(): array { return [ 'code' => 'required|string|max:100|unique:items,code', 'name' => 'required|string|max:200', 'item_name' => 'required|string|max:50', 'item_sep' => 'required|in:스크린,철재', 'item_bending' => 'required|string', 'material' => 'required|string', 'model_UA' => 'nullable|in:인정,비인정', 'item_spec' => 'nullable|string', 'model_name' => 'nullable|string', 'search_keyword' => 'nullable|string', 'rail_width' => 'nullable|integer', 'memo' => 'nullable|string', // 케이스 전용 'exit_direction' => 'nullable|string', 'front_bottom_width' => 'nullable|integer', 'box_width' => 'nullable|integer', 'box_height' => 'nullable|integer', // 전개도 데이터 'bendingData' => 'nullable|array', 'bendingData.*.no' => 'required|integer', 'bendingData.*.input' => 'required|numeric', 'bendingData.*.rate' => 'nullable|string', 'bendingData.*.sum' => 'required|numeric', 'bendingData.*.color' => 'required|boolean', 'bendingData.*.aAngle' => 'required|boolean', ]; } } ``` ### Resource ``` app/Http/Resources/Api/V1/ ├─ BendingItemResource.php ← 신규 └─ GuiderailModelResource.php ← 신규 ``` ```php // BendingItemResource.php class BendingItemResource extends JsonResource { public function toArray($request): array { return [ 'id' => $this->id, 'code' => $this->code, 'name' => $this->name, // options → 최상위로 풀어서 노출 'item_name' => $this->getOption('item_name'), 'item_sep' => $this->getOption('item_sep'), 'item_bending' => $this->getOption('item_bending'), 'item_spec' => $this->getOption('item_spec'), 'material' => $this->getOption('material'), 'model_name' => $this->getOption('model_name'), 'model_UA' => $this->getOption('model_UA'), 'search_keyword' => $this->getOption('search_keyword'), 'rail_width' => $this->getOption('rail_width'), 'registration_date' => $this->getOption('registration_date'), 'author' => $this->getOption('author'), 'memo' => $this->getOption('memo'), // 케이스 전용 'exit_direction' => $this->getOption('exit_direction'), 'front_bottom_width' => $this->getOption('front_bottom_width'), 'box_width' => $this->getOption('box_width'), 'box_height' => $this->getOption('box_height'), // 전개도 'bendingData' => $this->getOption('bendingData'), 'image_path' => $this->getOption('image_path'), // 계산값 'width_sum' => $this->getWidthSum(), 'bend_count' => $this->getBendCount(), 'has_image' => !empty($this->getOption('image_path')), // 메타 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; } private function getWidthSum(): ?int { $data = $this->getOption('bendingData', []); if (empty($data)) return null; return (int) end($data)['sum'] ?? null; } private function getBendCount(): int { $data = $this->getOption('bendingData', []); return count(array_filter($data, fn($d) => ($d['rate'] ?? '') !== '')); } } ``` ### 라우트 ```php // routes/api.php (v1 그룹 내부에 추가) Route::prefix('v1')->middleware(['auth:sanctum'])->group(function () { // ... 기존 라우트 유지 ... // 절곡품 기초관리 Route::apiResource('bending-items', BendingItemController::class); Route::get('bending-items/filters', [BendingItemController::class, 'filters']); Route::post('bending-items/{id}/image', [BendingItemController::class, 'uploadImage']); Route::delete('bending-items/{id}/image', [BendingItemController::class, 'deleteImage']); // 절곡품 모델 (가이드레일 조합) Route::apiResource('guiderail-models', GuiderailModelController::class); }); ``` --- ## 4. 응답 형식 ### 목록 응답 (GET /api/v1/bending-items) ```json { "success": true, "message": null, "data": { "data": [ { "id": 123, "code": "BD-가이드레일-KSS01-SUS-120*70", "name": "가이드레일 KSS01 SUS 120*70", "item_name": "마감재", "item_sep": "스크린", "item_bending": "가이드레일", "item_spec": "120*70", "material": "SUS 1.2T", "model_name": "KSS01", "model_UA": "인정", "width_sum": 203, "bend_count": 3, "has_image": true } ], "current_page": 1, "total": 170, "per_page": 50 } } ``` ### 모델 상세 응답 (GET /api/v1/guiderail-models/{id}) ```json { "success": true, "message": null, "data": { "id": 1, "model_name": "KSS01", "check_type": "벽면형", "rail_width": 70, "rail_length": 120, "finishing_type": "SUS마감", "item_sep": "스크린", "model_UA": "인정", "components": [ { "order": 1, "name": "1번(마감재)", "material": "SUS 1.2T", "qty": 2, "bending_item_id": 100, "sum_total": 203, "bendingData": [...] } ], "material_summary": { "SUS 1.2T": 406, "EGI 1.55T": 398 } } } ``` --- ## 5. 이미지 처리 (Cloudflare R2) ### 기존 파일 시스템 구조 SAM API는 **Cloudflare R2** (S3 호환)로 파일을 관리한다. 절곡품 이미지도 동일한 구조를 따른다. ``` 기존 구조: FileStorageService.php → Storage::disk('r2')->put() FileStorageController → POST /api/v1/files/upload (임시) ItemsFileController → POST /api/v1/items/{id}/files (품목 전용) File 모델 → files 테이블 (메타데이터) 경로 패턴: 임시: {tenant_id}/temp/{year}/{month}/{stored_name} 확정: {tenant_id}/items/{year}/{month}/{stored_name} ``` ### 절곡품 이미지 업로드 방안 **기존 `ItemsFileController` 재사용** (별도 이미지 컨트롤러 불필요): ```php // 이미 존재하는 엔드포인트 활용 POST /api/v1/items/{id}/files ← 절곡품 이미지 업로드 GET /api/v1/items/{id}/files ← 이미지 목록 DELETE /api/v1/items/{id}/files/{fileId} ← 이미지 삭제 // field_key로 절곡품 이미지 구분 field_key: 'bending_diagram' ← 전개도 이미지 ``` ### R2 설정 (이미 구성됨) ```php // config/filesystems.php 'r2' => [ 'driver' => 's3', 'key' => env('R2_ACCESS_KEY_ID'), 'secret' => env('R2_SECRET_ACCESS_KEY'), 'region' => 'auto', 'bucket' => 'sam', 'endpoint' => env('R2_ENDPOINT'), 'use_path_style_endpoint' => true, ], ``` ### 이미지 조회 ```php // File 모델의 download() 메서드로 스트리밍 GET /api/v1/files/{id}/view ← 인라인 표시 (브라우저) GET /api/v1/files/{id}/download ← 다운로드 ``` ### 주의사항 - ❌ 별도 이미지 엔드포인트 생성 불필요 — `ItemsFileController` 재사용 - ❌ 로컬 `storage/app/public/bending/` 직접 저장 금지 — R2 사용 - ✅ `field_key: 'bending_diagram'`으로 전개도 이미지 식별 - ✅ `files` 테이블에 메타데이터 자동 관리 (tenant_id, file_path, mime_type 등) - ✅ options에는 `image_path` 대신 `file_id` 참조 또는 `field_key`로 조회 --- ## 6. options 상수 정의 ```php // Item 모델에 추가 (또는 별도 상수 클래스) class Item extends Model { // 절곡품 options 키 상수 const OPTION_ITEM_NAME = 'item_name'; const OPTION_ITEM_SEP = 'item_sep'; const OPTION_ITEM_BENDING = 'item_bending'; const OPTION_ITEM_SPEC = 'item_spec'; const OPTION_MATERIAL = 'material'; const OPTION_MODEL_NAME = 'model_name'; const OPTION_MODEL_UA = 'model_UA'; const OPTION_SEARCH_KEYWORD = 'search_keyword'; const OPTION_RAIL_WIDTH = 'rail_width'; const OPTION_BENDING_DATA = 'bendingData'; const OPTION_IMAGE_PATH = 'image_path'; const OPTION_EXIT_DIRECTION = 'exit_direction'; const OPTION_BOX_WIDTH = 'box_width'; const OPTION_BOX_HEIGHT = 'box_height'; const OPTION_FRONT_BOTTOM_WIDTH = 'front_bottom_width'; const OPTION_MEMO = 'memo'; const OPTION_AUTHOR = 'author'; const OPTION_REGISTRATION_DATE = 'registration_date'; const OPTION_PARENT_NUM = 'parent_num'; } ``` --- ## 7. 주의사항 - ✅ 기존 `ItemsController` / `ItemsService` 무변경 - ✅ items 테이블 스키마 무변경 — options JSON만 활용 - ✅ `item_category = 'BENDING'` 필터로 기존 items API 영향 없음 - ✅ `setOption()`으로 개별 키 업데이트 — 기존 키 보존 - ✅ `ApiResponse::handle()` 사용 — 직접 JSON 반환 금지 - ✅ FormRequest에서만 유효성 검증 — 컨트롤러 validate() 금지 - ✅ i18n 메시지 키 사용 — 직접 문자열 금지 - ✅ SoftDeletes 적용 - ⚠️ `BendingInfoBuilder` / `PrefixResolver` 무변경