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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
53
app/Http/Requests/Item/ItemFileUploadRequest.php
Normal file
53
app/Http/Requests/Item/ItemFileUploadRequest.php
Normal 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를 초과할 수 없습니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user