From 2e4d4d3be3fc6e51de12f16b63d7ddbe33f8a72a Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 12 Dec 2025 17:38:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Items=20Files=20API=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20group=5Fid=20=EA=B8=B0=EB=B0=98,=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20field=5Fkey=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /items/{id}/files 엔드포인트 추가 (파일 목록 조회) - document_type을 group_id('1')로 변경 (테이블명 대신 코드 기반) - field_key 제한 해제 (자유롭게 지정 가능) - 품목 상세 조회 시 files 필드 포함 (field_key별 그룹핑) - ItemFileUploadRequest FormRequest 추가 - DELETE 라우트 파라미터를 {fileId}로 변경 --- .../Api/V1/ItemsFileController.php | 276 +++++++++--------- .../Requests/Item/ItemFileUploadRequest.php | 53 ++++ app/Services/ItemsService.php | 89 ++++-- app/Swagger/v1/ItemsApi.php | 14 +- app/Swagger/v1/ItemsFileApi.php | 193 ++++++++---- routes/api.php | 7 +- 6 files changed, 397 insertions(+), 235 deletions(-) create mode 100644 app/Http/Requests/Item/ItemFileUploadRequest.php diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index 3de3521..d16d9ca 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -3,55 +3,114 @@ namespace App\Http\Controllers\Api\V1; use App\Helpers\ApiResponse; +use App\Helpers\ItemTypeHelper; use App\Http\Controllers\Controller; -use App\Http\Requests\ItemsFileUploadRequest; +use App\Http\Requests\Item\ItemFileUploadRequest; use App\Models\Commons\File; +use App\Models\Materials\Material; use App\Models\Products\Product; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * 품목 파일 관리 컨트롤러 * - * ID-based 파일 업로드/삭제 API (files 테이블 기반) - * - 절곡도 (bending_diagram) - * - 시방서 (specification) - * - 인정서 (certification) + * group_id 기반 파일 관리, field_key 동적 지원 + * - document_type: group_id (품목 그룹) + * - document_id: item_id (품목 ID) + * - file_type: field_key (동적) */ class ItemsFileController extends Controller { + /** + * 품목 그룹 ID (고정값, 추후 설정으로 변경 가능) + */ + private const ITEM_GROUP_ID = '1'; + + /** + * 파일 목록 조회 + * + * GET /api/v1/items/{id}/files + */ + public function index(int $id, Request $request) + { + return ApiResponse::handle(function () use ($id, $request) { + $tenantId = app('tenant_id'); + $itemType = strtoupper($request->input('item_type', 'FG')); + $fieldKey = $request->input('field_key'); + + // 품목 존재 확인 + $this->getItemById($id, $itemType, $tenantId); + + // 파일 조회 + $query = File::query() + ->where('tenant_id', $tenantId) + ->where('document_type', self::ITEM_GROUP_ID) + ->where('document_id', $id); + + // 특정 field_key만 조회 + if ($fieldKey) { + $query->where('file_type', $fieldKey); + } + + $files = $query->orderBy('created_at', 'desc')->get(); + + // field_key별 그룹핑 + $grouped = $files->groupBy('file_type')->map(function ($group) { + return $group->map(fn ($file) => $this->formatFileResponse($file))->values(); + }); + + return $grouped; + }, __('message.fetched')); + } + /** * 파일 업로드 * * POST /api/v1/items/{id}/files - * - * 파일 저장 구조 (file-storage-guide.md 참고): - * - 물리 경로: storage/app/tenants/{tenant_id}/items/{year}/{month}/{stored_name} - * - DB: files 테이블에 저장 - * - Product: file_id 참조 */ - public function upload(int $id, ItemsFileUploadRequest $request) + public function upload(int $id, ItemFileUploadRequest $request) { return ApiResponse::handle(function () use ($id, $request) { $tenantId = app('tenant_id'); $userId = auth()->id(); - $product = $this->getProductById($id); $validated = $request->validated(); - $fileType = $validated['type']; + $itemType = strtoupper($validated['item_type'] ?? 'FG'); + $fieldKey = $validated['field_key']; $uploadedFile = $validated['file']; + $existingFileId = $validated['file_id'] ?? null; + + // 품목 존재 확인 + $this->getItemById($id, $itemType, $tenantId); + + $replaced = false; + + // 기존 파일 교체 (file_id가 있는 경우) + if ($existingFileId) { + $existingFile = File::query() + ->where('tenant_id', $tenantId) + ->where('document_type', self::ITEM_GROUP_ID) + ->where('document_id', $id) + ->where('id', $existingFileId) + ->first(); + + if ($existingFile) { + $existingFile->softDeleteFile($userId); + $replaced = true; + } + } // 파일명 생성 (64bit 난수) $extension = $uploadedFile->getClientOriginalExtension(); - $storedName = bin2hex(random_bytes(8)) . '.' . $extension; + $storedName = bin2hex(random_bytes(8)).'.'.$extension; $displayName = $uploadedFile->getClientOriginalName(); // 경로 생성: tenants/{tenant_id}/items/{year}/{month}/{stored_name} $year = date('Y'); $month = date('m'); $directory = sprintf('%d/items/%s/%s', $tenantId, $year, $month); - $filePath = $directory . '/' . $storedName; + $filePath = $directory.'/'.$storedName; // 파일 저장 (tenant 디스크) Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName); @@ -64,30 +123,23 @@ public function upload(int $id, ItemsFileUploadRequest $request) 'file_path' => $filePath, 'file_size' => $uploadedFile->getSize(), 'mime_type' => $uploadedFile->getMimeType(), - 'file_type' => $this->getFileTypeCategory($uploadedFile->getMimeType()), - 'document_id' => $product->id, - 'document_type' => 'product_' . $fileType, // product_bending_diagram, product_specification, product_certification + 'file_type' => $fieldKey, // field_key + 'document_id' => $id, + 'document_type' => self::ITEM_GROUP_ID, // group_id 'is_temp' => false, 'uploaded_by' => $userId, 'created_by' => $userId, ]); - // Product 모델 업데이트 (file_id 참조 방식) - $updateData = $this->buildUpdateData($fileType, $file->id, $validated); - $product->update($updateData); - - // 파일 URL 생성 - $fileUrl = $this->getFileUrl($filePath); - return [ 'file_id' => $file->id, - 'file_type' => $fileType, - 'file_url' => $fileUrl, + 'field_key' => $fieldKey, + 'file_url' => $this->getFileUrl($filePath), 'file_path' => $filePath, 'file_name' => $displayName, 'file_size' => $file->file_size, 'mime_type' => $file->mime_type, - 'product' => $product->fresh(), + 'replaced' => $replaced, ]; }, __('message.file.uploaded')); } @@ -95,150 +147,84 @@ public function upload(int $id, ItemsFileUploadRequest $request) /** * 파일 삭제 * - * DELETE /api/v1/items/{id}/files/{type} + * DELETE /api/v1/items/{id}/files/{fileId} */ - public function delete(int $id, string $type, Request $request) + public function delete(int $id, int $fileId, Request $request) { - return ApiResponse::handle(function () use ($id, $type) { + return ApiResponse::handle(function () use ($id, $fileId, $request) { + $tenantId = app('tenant_id'); $userId = auth()->id(); - $product = $this->getProductById($id); + $itemType = strtoupper($request->input('item_type', 'FG')); - // 파일 타입 검증 - if (! in_array($type, ['bending_diagram', 'specification', 'certification'])) { - throw new \InvalidArgumentException(__('error.file.invalid_file_type')); + // 품목 존재 확인 + $this->getItemById($id, $itemType, $tenantId); + + // 파일 조회 + $file = File::query() + ->where('tenant_id', $tenantId) + ->where('document_type', self::ITEM_GROUP_ID) + ->where('document_id', $id) + ->where('id', $fileId) + ->first(); + + if (! $file) { + throw new NotFoundHttpException(__('error.file.not_found')); } - // 파일 ID 가져오기 - $fileId = $this->getFileId($product, $type); - $deleted = false; - - if ($fileId) { - $file = File::find($fileId); - if ($file) { - // Soft delete (files 테이블) - $file->softDeleteFile($userId); - $deleted = true; - } - } - - // DB 필드 null 처리 (Product) - $updateData = $this->buildDeleteData($type); - $product->update($updateData); + // Soft delete + $file->softDeleteFile($userId); return [ - 'file_type' => $type, - 'deleted' => $deleted, - 'product' => $product->fresh(), + 'file_id' => $fileId, + 'deleted' => true, ]; }, __('message.file.deleted')); } /** - * ID로 Product 조회 + * ID로 품목 조회 (Product 또는 Material) */ - private function getProductById(int $id): Product + private function getItemById(int $id, string $itemType, int $tenantId): Product|Material { - $tenantId = app('tenant_id'); - $product = Product::query() - ->where('tenant_id', $tenantId) - ->find($id); + if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { + $item = Material::query() + ->where('tenant_id', $tenantId) + ->find($id); + } else { + $item = Product::query() + ->where('tenant_id', $tenantId) + ->find($id); + } - if (! $product) { + if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } - return $product; + return $item; } /** - * MIME 타입에서 파일 타입 카테고리 추출 + * 파일 응답 포맷 */ - private function getFileTypeCategory(string $mimeType): string + private function formatFileResponse(File $file): array { - if (str_starts_with($mimeType, 'image/')) { - return 'image'; - } - if (in_array($mimeType, [ - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-excel', - 'text/csv', - ])) { - return 'excel'; - } - if (in_array($mimeType, ['application/zip', 'application/x-rar-compressed'])) { - return 'archive'; - } - - return 'document'; + return [ + 'id' => $file->id, + 'file_name' => $file->display_name, + 'file_path' => $file->file_path, + 'file_url' => $this->getFileUrl($file->file_path), + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'field_key' => $file->file_type, + 'created_at' => $file->created_at?->format('Y-m-d H:i:s'), + ]; } /** - * 파일 URL 생성 (tenant 디스크는 private이므로 API 경유) + * 파일 URL 생성 */ private function getFileUrl(string $filePath): string { - // Public URL이 아닌 API 다운로드 경로 반환 - return url('/api/v1/files/download/' . base64_encode($filePath)); + return url('/api/v1/files/download/'.base64_encode($filePath)); } - - /** - * 파일 타입별 업데이트 데이터 구성 (file_id 참조) - */ - private function buildUpdateData(string $fileType, int $fileId, array $validated): array - { - return match ($fileType) { - 'bending_diagram' => [ - 'bending_diagram' => $fileId, // file_id 저장 - 'bending_details' => $validated['bending_details'] ?? null, - ], - 'specification' => [ - 'specification_file' => $fileId, // file_id 저장 - ], - 'certification' => [ - 'certification_file' => $fileId, // file_id 저장 - 'certification_number' => $validated['certification_number'] ?? null, - 'certification_start_date' => $validated['certification_start_date'] ?? null, - 'certification_end_date' => $validated['certification_end_date'] ?? null, - ], - default => throw new \InvalidArgumentException(__('error.file.invalid_file_type')), - }; - } - - /** - * 파일 타입별 삭제 데이터 구성 - */ - private function buildDeleteData(string $fileType): array - { - return match ($fileType) { - 'bending_diagram' => [ - 'bending_diagram' => null, - 'bending_details' => null, - ], - 'specification' => [ - 'specification_file' => null, - ], - 'certification' => [ - 'certification_file' => null, - 'certification_number' => null, - 'certification_start_date' => null, - 'certification_end_date' => null, - ], - default => throw new \InvalidArgumentException(__('error.file.invalid_file_type')), - }; - } - - /** - * Product에서 파일 ID 가져오기 - */ - private function getFileId(Product $product, string $fileType): ?int - { - $value = match ($fileType) { - 'bending_diagram' => $product->bending_diagram, - 'specification' => $product->specification_file, - 'certification' => $product->certification_file, - default => null, - }; - - return $value ? (int) $value : null; - } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Item/ItemFileUploadRequest.php b/app/Http/Requests/Item/ItemFileUploadRequest.php new file mode 100644 index 0000000..3685d1f --- /dev/null +++ b/app/Http/Requests/Item/ItemFileUploadRequest.php @@ -0,0 +1,53 @@ + ['required', 'string', 'max:100'], + 'file' => [ + 'required', + 'file', + 'mimes:jpg,jpeg,png,gif,bmp,svg,webp,pdf,doc,docx,xls,xlsx,hwp,dwg,dxf,zip,rar', + 'max:20480', // 20MB + ], + 'file_id' => ['sometimes', 'nullable', 'integer'], + 'item_type' => ['sometimes', 'nullable', 'string', 'in:FG,PT,SM,RM,CS,fg,pt,sm,rm,cs'], + ]; + } + + public function attributes(): array + { + return [ + 'field_key' => '필드 키', + 'file' => '파일', + 'file_id' => '파일 ID', + 'item_type' => '품목 유형', + ]; + } + + public function messages(): array + { + return [ + 'field_key.required' => '필드 키는 필수입니다.', + 'file.required' => '파일을 선택해주세요.', + 'file.mimes' => '허용되지 않는 파일 형식입니다.', + 'file.max' => '파일 크기는 20MB를 초과할 수 없습니다.', + ]; + } +} diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index 7c856c2..db55dc2 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -5,6 +5,7 @@ use App\Constants\SystemFields; use App\Exceptions\DuplicateCodeException; use App\Helpers\ItemTypeHelper; +use App\Models\Commons\File; use App\Models\ItemMaster\ItemField; use App\Models\Materials\Material; use App\Models\Products\Product; @@ -318,19 +319,19 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe } $productsQuery->select([ - 'id', - 'product_type as item_type', - 'code', - 'name', - DB::raw('NULL as specification'), - 'unit', - 'category_id', - 'product_type as type_code', - 'attributes', - 'is_active', - 'created_at', - 'deleted_at', - ]); + 'id', + 'product_type as item_type', + 'code', + 'name', + DB::raw('NULL as specification'), + 'unit', + 'category_id', + 'product_type as type_code', + 'attributes', + 'is_active', + 'created_at', + 'deleted_at', + ]); // soft delete 포함 if ($includeDeleted) { @@ -364,19 +365,19 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe } $materialsQuery->select([ - 'id', - 'material_type as item_type', - 'material_code as code', - 'name', - 'specification', - 'unit', - 'category_id', - 'material_type as type_code', - 'attributes', - 'is_active', - 'created_at', - 'deleted_at', - ]); + 'id', + 'material_type as item_type', + 'material_code as code', + 'name', + 'specification', + 'unit', + 'category_id', + 'material_type as type_code', + 'attributes', + 'is_active', + 'created_at', + 'deleted_at', + ]); // soft delete 포함 if ($includeDeleted) { @@ -484,6 +485,9 @@ public function getItem( $data['prices'] = $this->fetchPrices('MATERIAL', $id, $clientId, $priceDate); } + // 파일 정보 추가 + $data['files'] = $this->getItemFiles($id, $tenantId); + return $data; } @@ -511,6 +515,9 @@ public function getItem( $data['prices'] = $this->fetchPrices('PRODUCT', $id, $clientId, $priceDate); } + // 파일 정보 추가 + $data['files'] = $this->getItemFiles($id, $tenantId); + return $data; } @@ -1119,6 +1126,36 @@ private function batchDeleteMaterials(array $ids): void } } + /** + * 품목의 파일 목록 조회 (field_key별 그룹핑) + * + * @param int $itemId 품목 ID + * @param int $tenantId 테넌트 ID + * @return array field_key별로 그룹핑된 파일 목록 + */ + private function getItemFiles(int $itemId, int $tenantId): array + { + $files = File::query() + ->where('tenant_id', $tenantId) + ->where('document_type', '1') // ITEM_GROUP_ID + ->where('document_id', $itemId) + ->whereNull('deleted_at') + ->orderBy('created_at', 'desc') + ->get(); + + if ($files->isEmpty()) { + return []; + } + + return $files->groupBy('file_type')->map(function ($group) { + return $group->map(fn ($file) => [ + 'id' => $file->id, + 'file_name' => $file->display_name ?? $file->file_name, + 'file_path' => $file->file_path, + ])->values()->toArray(); + })->toArray(); + } + /** * 품목 상세 조회 (code 기반, BOM 포함 옵션) * diff --git a/app/Swagger/v1/ItemsApi.php b/app/Swagger/v1/ItemsApi.php index 2879d4c..6f55549 100644 --- a/app/Swagger/v1/ItemsApi.php +++ b/app/Swagger/v1/ItemsApi.php @@ -33,7 +33,19 @@ * @OA\Property(property="remarks", type="string", nullable=true, example="비고", description="비고 (Material 전용)"), * @OA\Property(property="created_at", type="string", example="2025-11-14 10:00:00"), * @OA\Property(property="updated_at", type="string", example="2025-11-14 10:10:00"), - * @OA\Property(property="deleted_at", type="string", nullable=true, example=null, description="삭제일시 (soft delete)") + * @OA\Property(property="deleted_at", type="string", nullable=true, example=null, description="삭제일시 (soft delete)"), + * @OA\Property( + * property="files", + * type="object", + * nullable=true, + * description="첨부 파일 (field_key별 그룹핑)", + * example={"bending_diagram": {{"id": 1, "file_name": "벤딩도.pdf", "file_path": "/uploads/items/1/bending_diagram.pdf"}}, "specification": {{"id": 2, "file_name": "규격서.pdf", "file_path": "/uploads/items/1/specification.pdf"}}}, + * additionalProperties=@OA\Property( + * type="array", + * + * @OA\Items(ref="#/components/schemas/ItemFile") + * ) + * ) * ) * * @OA\Schema( diff --git a/app/Swagger/v1/ItemsFileApi.php b/app/Swagger/v1/ItemsFileApi.php index ab4f890..53b403c 100644 --- a/app/Swagger/v1/ItemsFileApi.php +++ b/app/Swagger/v1/ItemsFileApi.php @@ -5,49 +5,135 @@ /** * @OA\Tag( * name="Items Files", - * description="품목 파일 관리 API (절곡도, 시방서, 인정서) - files 테이블 기반" + * description="품목 파일 관리 API - group_id 기반, field_key 동적 지원" + * ) + * + * @OA\Schema( + * schema="ItemFile", + * type="object", + * required={"id", "file_name", "file_path"}, + * + * @OA\Property(property="id", type="integer", example=123, description="파일 ID"), + * @OA\Property(property="file_name", type="string", example="도면_v1.pdf", description="파일명"), + * @OA\Property(property="file_path", type="string", example="1/items/2025/12/a1b2c3d4.pdf", description="파일 경로"), + * @OA\Property(property="file_url", type="string", format="uri", example="https://api.sam.kr/api/v1/files/download/...", description="다운로드 URL"), + * @OA\Property(property="file_size", type="integer", example=102400, description="파일 크기 (bytes)"), + * @OA\Property(property="mime_type", type="string", example="application/pdf", description="MIME 타입"), + * @OA\Property(property="field_key", type="string", example="drawing", description="필드 키"), + * @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-12 10:00:00", description="생성일시") + * ) + * + * @OA\Schema( + * schema="ItemFilesGrouped", + * type="object", + * description="field_key별 그룹핑된 파일 목록", + * example={"drawing": {{"id": 10, "file_name": "도면1.pdf", "file_path": "..."}, {"id": 11, "file_name": "도면2.pdf", "file_path": "..."}}, "certificate": {{"id": 12, "file_name": "인증서.pdf", "file_path": "..."}}} + * ) + * @OA\Schema( + * schema="ItemFileUploadRequest", + * type="object", + * required={"field_key", "file"}, + * + * @OA\Property(property="field_key", type="string", example="drawing", description="필드 키 (자유롭게 지정)"), + * @OA\Property(property="file", type="string", format="binary", description="업로드할 파일 (최대 20MB)"), + * @OA\Property(property="file_id", type="integer", nullable=true, example=123, description="기존 파일 ID (있으면 교체, 없으면 추가)") * ) * * @OA\Schema( * schema="ItemFileUploadResponse", * type="object", - * required={"file_id", "file_type", "file_url", "file_path", "file_name"}, + * required={"file_id", "field_key", "file_url", "file_path", "file_name"}, * - * @OA\Property(property="file_id", type="integer", example=123, description="files 테이블 ID"), - * @OA\Property(property="file_type", type="string", enum={"bending_diagram", "specification", "certification"}, example="bending_diagram", description="파일 타입"), - * @OA\Property(property="file_url", type="string", format="uri", example="https://api.sam.kr/api/v1/files/download/MjE3L2l0ZW1zLzIwMjUvMTIvYTFiMmMzZDRlNWY2ZzdoOC5wbmc=", description="파일 다운로드 URL"), - * @OA\Property(property="file_path", type="string", example="287/items/2025/12/a1b2c3d4e5f6g7h8.png", description="파일 저장 경로"), - * @OA\Property(property="file_name", type="string", example="절곡도_V1.jpg", description="원본 파일명"), + * @OA\Property(property="file_id", type="integer", example=123, description="파일 ID"), + * @OA\Property(property="field_key", type="string", example="drawing", description="필드 키"), + * @OA\Property(property="file_url", type="string", format="uri", example="https://api.sam.kr/api/v1/files/download/...", description="다운로드 URL"), + * @OA\Property(property="file_path", type="string", example="1/items/2025/12/a1b2c3d4.pdf", description="파일 경로"), + * @OA\Property(property="file_name", type="string", example="도면_v1.pdf", description="원본 파일명"), * @OA\Property(property="file_size", type="integer", example=102400, description="파일 크기 (bytes)"), - * @OA\Property(property="mime_type", type="string", example="image/png", description="MIME 타입"), - * @OA\Property(property="product", ref="#/components/schemas/Product", description="업데이트된 품목 정보") + * @OA\Property(property="mime_type", type="string", example="application/pdf", description="MIME 타입"), + * @OA\Property(property="replaced", type="boolean", example=false, description="기존 파일 교체 여부") * ) * * @OA\Schema( * schema="ItemFileDeleteResponse", * type="object", - * required={"file_type", "deleted"}, + * required={"file_id", "deleted"}, * - * @OA\Property(property="file_type", type="string", enum={"bending_diagram", "specification", "certification"}, example="bending_diagram", description="파일 타입"), - * @OA\Property(property="deleted", type="boolean", example=true, description="파일 삭제 여부 (soft delete)"), - * @OA\Property(property="product", ref="#/components/schemas/Product", description="업데이트된 품목 정보") + * @OA\Property(property="file_id", type="integer", example=123, description="삭제된 파일 ID"), + * @OA\Property(property="deleted", type="boolean", example=true, description="삭제 성공 여부") * ) */ class ItemsFileApi { + /** + * 파일 목록 조회 + * + * @OA\Get( + * path="/api/v1/items/{id}/files", + * summary="품목 파일 목록 조회", + * description="품목에 등록된 모든 파일을 field_key별로 그룹핑하여 조회합니다. 응답은 field_key를 키로 하고 파일 배열을 값으로 하는 객체입니다.", + * operationId="getItemFiles", + * tags={"Items Files"}, + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * description="품목 ID", + * + * @OA\Schema(type="integer", example=795) + * ), + * + * @OA\Parameter( + * name="item_type", + * in="query", + * required=false, + * description="품목 유형 (FG|PT|SM|RM|CS)", + * + * @OA\Schema(type="string", enum={"FG", "PT", "SM", "RM", "CS"}, default="FG") + * ), + * + * @OA\Parameter( + * name="field_key", + * in="query", + * required=false, + * description="특정 field_key만 조회 (미지정 시 전체)", + * + * @OA\Schema(type="string", example="drawing") + * ), + * + * @OA\Response( + * response=200, + * description="파일 목록 조회 성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="조회되었습니다."), + * @OA\Property(property="data", ref="#/components/schemas/ItemFilesGrouped") + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function index() {} + /** * 파일 업로드 * * @OA\Post( * path="/api/v1/items/{id}/files", * summary="품목 파일 업로드", - * description="품목에 파일을 업로드합니다 (절곡도/시방서/인정서). 파일은 files 테이블에 저장되고, Product에는 file_id가 참조됩니다. + * description="품목에 파일을 업로드합니다. * - * **저장 경로**: `storage/app/tenants/{tenant_id}/items/{year}/{month}/{stored_name}` + * **동작 방식**: + * - `file_id` 없음 → 새 파일 추가 + * - `file_id` 있음 → 기존 파일 soft delete 후 새 파일 추가 (교체) * - * **저장 구조**: - * - files 테이블: 파일 메타데이터 저장 (display_name, stored_name, file_path, file_size, mime_type 등) - * - products 테이블: file_id 참조 (bending_diagram, specification_file, certification_file 컬럼)", + * **저장 경로**: `storage/app/tenants/{tenant_id}/items/{year}/{month}/{stored_name}`", * operationId="uploadItemFile", * tags={"Items Files"}, * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, @@ -68,14 +154,13 @@ class ItemsFileApi * mediaType="multipart/form-data", * * @OA\Schema( - * required={"type", "file"}, + * required={"field_key", "file"}, * * @OA\Property( - * property="type", + * property="field_key", * type="string", - * enum={"bending_diagram", "specification", "certification"}, - * description="파일 타입", - * example="bending_diagram" + * description="필드 키 (자유롭게 지정)", + * example="drawing" * ), * @OA\Property( * property="file", @@ -84,38 +169,17 @@ class ItemsFileApi * description="업로드할 파일 (이미지: jpg,png,gif,svg / 문서: pdf,doc,docx,xls,xlsx,hwp). 최대 20MB" * ), * @OA\Property( - * property="bending_details", - * type="array", - * description="절곡 상세 정보 (bending_diagram 타입일 때만)", - * - * @OA\Items( - * type="object", - * required={"angle", "length", "type"}, - * - * @OA\Property(property="angle", type="number", format="float", example=90, description="절곡 각도"), - * @OA\Property(property="length", type="number", format="float", example=100.5, description="절곡 길이"), - * @OA\Property(property="type", type="string", example="V형", description="절곡 타입") - * ) + * property="file_id", + * type="integer", + * nullable=true, + * description="기존 파일 ID (있으면 교체, 없으면 추가)", + * example=123 * ), * @OA\Property( - * property="certification_number", + * property="item_type", * type="string", - * description="인증번호 (certification 타입일 때만)", - * example="CERT-2025-001" - * ), - * @OA\Property( - * property="certification_start_date", - * type="string", - * format="date", - * description="인증 시작일 (certification 타입일 때만)", - * example="2025-01-01" - * ), - * @OA\Property( - * property="certification_end_date", - * type="string", - * format="date", - * description="인증 종료일 (certification 타입일 때만)", - * example="2026-12-31" + * description="품목 유형 (FG|PT|SM|RM|CS)", + * example="FG" * ) * ) * ) @@ -134,7 +198,7 @@ class ItemsFileApi * ), * * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=404, description="존재하지 않는 URI 또는 데이터", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ @@ -144,9 +208,9 @@ public function upload() {} * 파일 삭제 * * @OA\Delete( - * path="/api/v1/items/{id}/files/{type}", + * path="/api/v1/items/{id}/files/{fileId}", * summary="품목 파일 삭제", - * description="품목의 파일을 삭제합니다 (Soft Delete). files 테이블의 deleted_at이 설정되고, products 테이블의 file_id 참조가 null로 변경됩니다.", + * description="품목의 특정 파일을 삭제합니다 (Soft Delete).", * operationId="deleteItemFile", * tags={"Items Files"}, * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, @@ -161,12 +225,21 @@ public function upload() {} * ), * * @OA\Parameter( - * name="type", + * name="fileId", * in="path", * required=true, - * description="파일 타입", + * description="파일 ID", * - * @OA\Schema(type="string", enum={"bending_diagram", "specification", "certification"}, example="bending_diagram") + * @OA\Schema(type="integer", example=123) + * ), + * + * @OA\Parameter( + * name="item_type", + * in="query", + * required=false, + * description="품목 유형 (FG|PT|SM|RM|CS)", + * + * @OA\Schema(type="string", enum={"FG", "PT", "SM", "RM", "CS"}, default="FG") * ), * * @OA\Response( @@ -182,8 +255,8 @@ public function upload() {} * ), * * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=404, description="존재하지 않는 URI 또는 데이터", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * @OA\Response(response=404, description="품목 또는 파일 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function delete() {} -} \ No newline at end of file +} diff --git a/routes/api.php b/routes/api.php index b246de4..20e7f0a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -462,10 +462,11 @@ Route::get('/categories', [ItemsBomController::class, 'listCategories'])->name('v1.items.bom.categories'); // 카테고리 목록 }); - // Items Files (ID-based File Upload API) + // Items Files (group_id 기반 파일 관리, 동적 field_key 지원) Route::prefix('items/{id}/files')->group(function () { - Route::post('', [ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드 - Route::delete('/{type}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (type: bending_diagram|specification|certification) + Route::get('', [ItemsFileController::class, 'index'])->name('v1.items.files.index'); // 파일 조회 (field_key별 그룹핑) + Route::post('', [ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드 (field_key 동적) + Route::delete('/{fileId}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (file_id) }); // BOM (product_components: ref_type=PRODUCT|MATERIAL)