# Step 2: API 엔드포인트 ✅ 완료

> **프로젝트**: API (`sam/api`)
> **선행 조건**: Step 1 완료
> **상태**: ✅ 구현 완료 (2026-03-16~17, 2026-03-21 code/lot_no 분리 반영)
> **참조**: `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 방향 (2026-03-21 최종):
  → 기존 ItemsController 무변경
  → 별도 BendingItemController → bending_items 전용 테이블 사용
  → bending_items.code = 'BD' (고정), lot_no = LOT 번호
  → BendingCodeService.resolveItem(): lot_no 기반 검색
  → 절곡품 전용 필터/검색/전개도 데이터 관리
```

---

## 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` | 모델 목록 | `?item_category=` 필수 |
| GET | `/api/v1/guiderail-models/filters` | 필터 옵션 | |
| 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) | |

**카테고리 구분** (필수 파라미터):

| item_category | 메뉴 | 건수 | 코드 패턴 |
|--------------|------|:---:|---------|
| `GUIDERAIL_MODEL` | 가이드레일 | 20 | `GR-KSS01-벽면형-SUS` |
| `SHUTTERBOX_MODEL` | 케이스 | 30 | `SB-500*350-밑면` |
| `BOTTOMBAR_MODEL` | 하단마감재 | 10 | `BB-KSS01-SUS` |

**필터 파라미터** (GET /api/v1/guiderail-models):
```
?item_category=GUIDERAIL_MODEL  # 필수: 카테고리 구분
&item_sep=스크린                 # 대분류
&model_UA=인정                  # 인정여부
&check_type=벽면형              # 형상 (가이드레일만)
&model_name=KSS01              # 모델명
&search=KSS01                  # 통합 검색
&page=1&size=50
```

---

## 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` 무변경
