Files
sam-docs/dev/dev_plans/bending-management/step2-API.md
김보곤 828b452186 feat: [bending] 절곡품 관리 기능 개발 계획서 추가
- README.md: 전체 개요, 메뉴 구조, 작업 순서
- step1-데이터분석.md: 레거시 매핑 + options 확장 스키마
- step2-API.md: 엔드포인트 설계 (docs 규칙 준수)
- step3-MNG화면.md: Blade+HTMX 화면 구성 (3타입별 폼)
- step4-React연동.md: 견적 이미지 + 운영 화면 계획
2026-03-16 17:41:13 +09:00

526 lines
16 KiB
Markdown

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