feat: 품목 파일 업로드 API 구현 (절곡도, 시방서, 인정서)

- Products 테이블에 9개 파일 관련 필드 추가
  - bending_diagram, bending_details (JSON)
  - specification_file, specification_file_name
  - certification_file, certification_file_name
  - certification_number, certification_start_date, certification_end_date

- ItemsFileController 구현 (Code-based API)
  - POST /items/{code}/files - 파일 업로드
  - DELETE /items/{code}/files/{type} - 파일 삭제
  - 파일 타입: bending_diagram, specification, certification

- ItemsFileUploadRequest 검증
  - 파일 타입별 MIME 검증 (이미지/문서)
  - 파일 크기 제한 (10MB/20MB)
  - 인증 정보 및 절곡 상세 정보 검증

- Swagger 문서 작성 (ItemsFileApi.php)
  - 업로드/삭제 API 스펙
  - 스키마: ItemFileUploadResponse, ItemFileDeleteResponse
This commit is contained in:
2025-11-17 13:40:07 +09:00
parent 2f2fffb6f0
commit 4749761519
10 changed files with 609 additions and 3 deletions

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemsFileUploadRequest;
use App\Http\Responses\ApiResponse;
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;
/**
* 품목 파일 관리 컨트롤러
*
* Code-based 파일 업로드/삭제 API
* - 절곡도 (bending_diagram)
* - 시방서 (specification)
* - 인정서 (certification)
*/
class ItemsFileController extends Controller
{
/**
* 파일 업로드
*
* POST /api/v1/items/{code}/files
*/
public function upload(string $code, ItemsFileUploadRequest $request)
{
return ApiResponse::handle(function () use ($code, $request) {
$product = $this->getProductByCode($code);
$validated = $request->validated();
$fileType = $request->route('type') ?? $validated['type'];
$file = $validated['file'];
// 파일 저장 경로: items/{code}/{type}/{filename}
$directory = sprintf('items/%s/%s', $code, $fileType);
$filePath = Storage::disk('public')->putFile($directory, $file);
$fileUrl = Storage::disk('public')->url($filePath);
$originalName = $file->getClientOriginalName();
// Product 모델 업데이트
$updateData = $this->buildUpdateData($fileType, $filePath, $originalName, $validated);
$product->update($updateData);
return [
'file_type' => $fileType,
'file_url' => $fileUrl,
'file_path' => $filePath,
'file_name' => $originalName,
'product' => $product->fresh(),
];
}, __('message.file.uploaded'));
}
/**
* 파일 삭제
*
* DELETE /api/v1/items/{code}/files/{type}
*/
public function delete(string $code, string $type, Request $request)
{
return ApiResponse::handle(function () use ($code, $type) {
$product = $this->getProductByCode($code);
// 파일 타입 검증
if (! in_array($type, ['bending_diagram', 'specification', 'certification'])) {
throw new \InvalidArgumentException(__('error.file.invalid_file_type'));
}
// 파일 경로 가져오기
$filePath = $this->getFilePath($product, $type);
if ($filePath) {
// 물리적 파일 삭제
Storage::disk('public')->delete($filePath);
}
// DB 필드 null 처리
$updateData = $this->buildDeleteData($type);
$product->update($updateData);
return [
'file_type' => $type,
'deleted' => (bool) $filePath,
'product' => $product->fresh(),
];
}, __('message.file.deleted'));
}
/**
* 코드로 Product 조회
*/
private function getProductByCode(string $code): Product
{
$tenantId = app('tenant_id');
$product = Product::query()
->where('tenant_id', $tenantId)
->where('code', $code)
->first();
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $product;
}
/**
* 파일 타입별 업데이트 데이터 구성
*/
private function buildUpdateData(string $fileType, string $filePath, string $originalName, array $validated): array
{
$updateData = match ($fileType) {
'bending_diagram' => [
'bending_diagram' => $filePath,
'bending_details' => $validated['bending_details'] ?? null,
],
'specification' => [
'specification_file' => $filePath,
'specification_file_name' => $originalName,
],
'certification' => [
'certification_file' => $filePath,
'certification_file_name' => $originalName,
'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')),
};
return $updateData;
}
/**
* 파일 타입별 삭제 데이터 구성
*/
private function buildDeleteData(string $fileType): array
{
return match ($fileType) {
'bending_diagram' => [
'bending_diagram' => null,
'bending_details' => null,
],
'specification' => [
'specification_file' => null,
'specification_file_name' => null,
],
'certification' => [
'certification_file' => null,
'certification_file_name' => null,
'certification_number' => null,
'certification_start_date' => null,
'certification_end_date' => null,
],
default => throw new \InvalidArgumentException(__('error.file.invalid_file_type')),
};
}
/**
* Product에서 파일 경로 가져오기
*/
private function getFilePath(Product $product, string $fileType): ?string
{
return match ($fileType) {
'bending_diagram' => $product->bending_diagram,
'specification' => $product->specification_file,
'certification' => $product->certification_file,
default => null,
};
}
}