- README.md: 전체 개요, 메뉴 구조, 작업 순서 - step1-데이터분석.md: 레거시 매핑 + options 확장 스키마 - step2-API.md: 엔드포인트 설계 (docs 규칙 준수) - step3-MNG화면.md: Blade+HTMX 화면 구성 (3타입별 폼) - step4-React연동.md: 견적 이미지 + 운영 화면 계획
526 lines
16 KiB
Markdown
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` 무변경
|