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

16 KiB

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   ← 신규
// 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      ← 신규
// 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← 신규
// 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     ← 신규
// 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'] ?? '') !== ''));
    }
}

라우트

// 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)

{
  "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})

{
  "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 재사용 (별도 이미지 컨트롤러 불필요):

// 이미 존재하는 엔드포인트 활용
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 설정 (이미 구성됨)

// 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,
],

이미지 조회

// 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 상수 정의

// 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 무변경