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

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2025-11-14 11:26:35
> **자동 생성**: 2025-11-17 13:00:34
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -358,6 +358,11 @@ ### tenant_option_values
- **group()**: belongsTo → `tenant_option_groups`
### tenant_stat_fields
**모델**: `App\Models\Tenants\TenantStatField`
- **tenant()**: belongsTo → `tenants`
### tenant_user_profiles
**모델**: `App\Models\Tenants\TenantUserProfile`

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

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 품목 파일 업로드 요청 검증
*
* 지원 파일 타입:
* - bending_diagram: 절곡도 (이미지)
* - specification: 시방서 (문서)
* - certification: 인정서 (문서)
*/
class ItemsFileUploadRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$fileType = $this->route('type') ?? $this->input('type');
$rules = [
'type' => ['sometimes', 'required', 'string', 'in:bending_diagram,specification,certification'],
];
// 파일 타입별 검증 규칙
if ($fileType === 'bending_diagram') {
$rules['file'] = [
'required',
'file',
'mimes:jpg,jpeg,png,gif,bmp,svg,webp',
'max:10240', // 10MB
];
} else {
// specification, certification - 문서 파일
$rules['file'] = [
'required',
'file',
'mimes:pdf,doc,docx,xls,xlsx,hwp',
'max:20480', // 20MB
];
}
// 인증 정보 (certification 타입일 때만)
if ($fileType === 'certification') {
$rules['certification_number'] = ['sometimes', 'nullable', 'string', 'max:50'];
$rules['certification_start_date'] = ['sometimes', 'nullable', 'date'];
$rules['certification_end_date'] = ['sometimes', 'nullable', 'date', 'after_or_equal:certification_start_date'];
}
// 절곡 상세 정보 (bending_diagram 타입일 때만)
if ($fileType === 'bending_diagram') {
$rules['bending_details'] = ['sometimes', 'nullable', 'array'];
$rules['bending_details.*.angle'] = ['required_with:bending_details', 'numeric'];
$rules['bending_details.*.length'] = ['required_with:bending_details', 'numeric'];
$rules['bending_details.*.type'] = ['required_with:bending_details', 'string'];
}
return $rules;
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'type' => '파일 타입',
'file' => '파일',
'certification_number' => '인증번호',
'certification_start_date' => '인증 시작일',
'certification_end_date' => '인증 종료일',
'bending_details' => '절곡 상세 정보',
'bending_details.*.angle' => '절곡 각도',
'bending_details.*.length' => '절곡 길이',
'bending_details.*.type' => '절곡 타입',
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'type.in' => '파일 타입은 bending_diagram, specification, certification 중 하나여야 합니다.',
'file.required' => '파일을 선택해주세요.',
'file.mimes' => '허용되지 않는 파일 형식입니다.',
'file.max' => '파일 크기가 너무 큽니다.',
'certification_end_date.after_or_equal' => '인증 종료일은 시작일 이후여야 합니다.',
];
}
}

View File

@@ -23,12 +23,20 @@ class Product extends Model
'safety_stock', 'lead_time', 'is_variable_size',
'product_category', 'part_type',
'attributes_archive',
// 파일 필드
'bending_diagram', 'bending_details',
'specification_file', 'specification_file_name',
'certification_file', 'certification_file_name',
'certification_number', 'certification_start_date', 'certification_end_date',
'created_by', 'updated_by',
];
protected $casts = [
'attributes' => 'array',
'attributes_archive' => 'array',
'bending_details' => 'array',
'certification_start_date' => 'date',
'certification_end_date' => 'date',
'is_sellable' => 'boolean',
'is_purchasable' => 'boolean',
'is_producible' => 'boolean',

View File

@@ -60,6 +60,7 @@ public static function cloneGlobalMenusForTenant(int $tenantId): array
* 테넌트를 위한 기본 메뉴 구조 생성 (구버전 - 하위 호환성 유지)
*
* @deprecated Use cloneGlobalMenusForTenant() instead
*
* @param int $tenantId 테넌트 ID
* @return array 생성된 메뉴 ID 목록
*/

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Items Files",
* description="품목 파일 관리 API (절곡도, 시방서, 인정서)"
* )
*
* @OA\Schema(
* schema="ItemFileUploadResponse",
* type="object",
* required={"file_type", "file_url", "file_path", "file_name"},
*
* @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="http://api.sam.kr/storage/items/P-001/bending_diagram/abc123.jpg", description="파일 URL"),
* @OA\Property(property="file_path", type="string", example="items/P-001/bending_diagram/abc123.jpg", description="파일 경로"),
* @OA\Property(property="file_name", type="string", example="절곡도_V1.jpg", description="원본 파일명"),
* @OA\Property(property="product", ref="#/components/schemas/Product", description="업데이트된 품목 정보")
* )
*
* @OA\Schema(
* schema="ItemFileDeleteResponse",
* type="object",
* required={"file_type", "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="파일 삭제 여부"),
* @OA\Property(property="product", ref="#/components/schemas/Product", description="업데이트된 품목 정보")
* )
*/
class ItemsFileApi
{
/**
* 파일 업로드
*
* @OA\Post(
* path="/api/v1/items/{code}/files",
* summary="품목 파일 업로드",
* description="품목에 파일을 업로드합니다 (절곡도/시방서/인정서)",
* operationId="uploadItemFile",
* tags={"Items Files"},
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(
* name="code",
* in="path",
* required=true,
* description="품목 코드",
*
* @OA\Schema(type="string", example="P-001")
* ),
*
* @OA\RequestBody(
* required=true,
*
* @OA\MediaType(
* mediaType="multipart/form-data",
*
* @OA\Schema(
* required={"type", "file"},
*
* @OA\Property(
* property="type",
* type="string",
* enum={"bending_diagram", "specification", "certification"},
* description="파일 타입",
* example="bending_diagram"
* ),
* @OA\Property(
* property="file",
* type="string",
* format="binary",
* description="업로드할 파일 (절곡도: jpg,png,gif,svg / 문서: pdf,doc,docx,xls,xlsx,hwp)"
* ),
* @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="절곡 타입")
* )
* ),
*
* @OA\Property(
* property="certification_number",
* 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"
* )
* )
* )
* ),
*
* @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/ItemFileUploadResponse")
* )
* ),
*
* @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=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function upload() {}
/**
* 파일 삭제
*
* @OA\Delete(
* path="/api/v1/items/{code}/files/{type}",
* summary="품목 파일 삭제",
* description="품목의 파일을 삭제합니다",
* operationId="deleteItemFile",
* tags={"Items Files"},
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(
* name="code",
* in="path",
* required=true,
* description="품목 코드",
*
* @OA\Schema(type="string", example="P-001")
* ),
*
* @OA\Parameter(
* name="type",
* in="path",
* required=true,
* description="파일 타입",
*
* @OA\Schema(type="string", enum={"bending_diagram", "specification", "certification"}, example="bending_diagram")
* ),
*
* @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/ItemFileDeleteResponse")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="존재하지 않는 URI 또는 데이터", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function delete() {}
}

View File

@@ -31,4 +31,4 @@ public function down(): void
$table->dropIndex('menus_deleted_at_idx');
});
}
};
};

View File

@@ -0,0 +1,124 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 품목 파일 필드 추가
*
* 프론트엔드 요구사항 (BACKEND_DEVELOPMENT_ROADMAP_V2.md):
* - 절곡도 (bending_diagram)
* - 시방서 (specification_file, specification_file_name)
* - 인정서 (certification_file, certification_file_name)
* - 인증 정보 (certification_number, certification_start_date, certification_end_date)
*
* 파일 저장 방식:
* - Storage::putFile('items/{code}/{type}', $file)
* - URL 저장: Storage::url($path) 또는 relative path
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// ================================================
// 절곡도 (Bending Diagram)
// ================================================
if (! Schema::hasColumn('products', 'bending_diagram')) {
$table->string('bending_diagram', 255)
->nullable()
->after('part_type')
->comment('절곡도 파일 경로 (이미지 URL)');
}
if (! Schema::hasColumn('products', 'bending_details')) {
$table->json('bending_details')
->nullable()
->after('bending_diagram')
->comment('절곡 상세 정보 (BendingDetail[])');
}
// ================================================
// 시방서 (Specification File)
// ================================================
if (! Schema::hasColumn('products', 'specification_file')) {
$table->string('specification_file', 255)
->nullable()
->after('bending_details')
->comment('시방서 파일 경로');
}
if (! Schema::hasColumn('products', 'specification_file_name')) {
$table->string('specification_file_name', 255)
->nullable()
->after('specification_file')
->comment('시방서 원본 파일명');
}
// ================================================
// 인정서 (Certification File)
// ================================================
if (! Schema::hasColumn('products', 'certification_file')) {
$table->string('certification_file', 255)
->nullable()
->after('specification_file_name')
->comment('인정서 파일 경로');
}
if (! Schema::hasColumn('products', 'certification_file_name')) {
$table->string('certification_file_name', 255)
->nullable()
->after('certification_file')
->comment('인정서 원본 파일명');
}
// ================================================
// 인증 정보 (Certification Info)
// ================================================
if (! Schema::hasColumn('products', 'certification_number')) {
$table->string('certification_number', 50)
->nullable()
->after('certification_file_name')
->comment('인증번호');
}
if (! Schema::hasColumn('products', 'certification_start_date')) {
$table->date('certification_start_date')
->nullable()
->after('certification_number')
->comment('인증 시작일');
}
if (! Schema::hasColumn('products', 'certification_end_date')) {
$table->date('certification_end_date')
->nullable()
->after('certification_start_date')
->comment('인증 종료일');
}
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$columns = [
'certification_end_date',
'certification_start_date',
'certification_number',
'certification_file_name',
'certification_file',
'specification_file_name',
'specification_file',
'bending_details',
'bending_diagram',
];
foreach ($columns as $column) {
if (Schema::hasColumn('products', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -268,4 +268,4 @@ public function run(): void
$this->command->info('✅ 글로벌 메뉴 템플릿 생성 완료 (약 60개 메뉴 항목)');
}
}
}

View File

@@ -358,6 +358,12 @@
Route::get('/categories', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'listCategories'])->name('v1.items.bom.categories'); // 카테고리 목록
});
// Items Files (Code-based File Upload API)
Route::prefix('items/{code}/files')->group(function () {
Route::post('', [\App\Http\Controllers\Api\V1\ItemsFileController::class, 'upload'])->name('v1.items.files.upload'); // 파일 업로드
Route::delete('/{type}', [\App\Http\Controllers\Api\V1\ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (type: bending_diagram|specification|certification)
});
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
Route::prefix('products/{id}/bom')->group(function () {
Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace');