feat: Items Files API 개선 - group_id 기반, 동적 field_key 지원

- GET /items/{id}/files 엔드포인트 추가 (파일 목록 조회)
- document_type을 group_id('1')로 변경 (테이블명 대신 코드 기반)
- field_key 제한 해제 (자유롭게 지정 가능)
- 품목 상세 조회 시 files 필드 포함 (field_key별 그룹핑)
- ItemFileUploadRequest FormRequest 추가
- DELETE 라우트 파라미터를 {fileId}로 변경
This commit is contained in:
2025-12-12 17:38:22 +09:00
parent b6bea99cd9
commit 2e4d4d3be3
6 changed files with 397 additions and 235 deletions

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Item;
use Illuminate\Foundation\Http\FormRequest;
/**
* 품목 파일 업로드 요청 검증
*
* field_key 동적 지원, 다중 파일 가능
*/
class ItemFileUploadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'field_key' => ['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를 초과할 수 없습니다.',
];
}
}

View File

@@ -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 포함 옵션)
*

View File

@@ -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(

View File

@@ -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() {}
}
}

View File

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