From 474976151913ff291660c53e994527ee8f9030cc Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 17 Nov 2025 13:40:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=EA=B5=AC=ED=98=84=20(?= =?UTF-8?q?=EC=A0=88=EA=B3=A1=EB=8F=84,=20=EC=8B=9C=EB=B0=A9=EC=84=9C,=20?= =?UTF-8?q?=EC=9D=B8=EC=A0=95=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- LOGICAL_RELATIONSHIPS.md | 7 +- .../Api/V1/ItemsFileController.php | 174 +++++++++++++++++ app/Http/Requests/ItemsFileUploadRequest.php | 109 +++++++++++ app/Models/Products/Product.php | 8 + app/Services/MenuBootstrapService.php | 1 + app/Swagger/v1/ItemsFileApi.php | 179 ++++++++++++++++++ ...add_performance_indexes_to_menus_table.php | 2 +- ...5437_add_file_fields_to_products_table.php | 124 ++++++++++++ database/seeders/GlobalMenuTemplateSeeder.php | 2 +- routes/api.php | 6 + 10 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ItemsFileController.php create mode 100644 app/Http/Requests/ItemsFileUploadRequest.php create mode 100644 app/Swagger/v1/ItemsFileApi.php create mode 100644 database/migrations/2025_11_17_125437_add_file_fields_to_products_table.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index f544ffa..983a250 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -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` diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php new file mode 100644 index 0000000..747ea01 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -0,0 +1,174 @@ +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, + }; + } +} diff --git a/app/Http/Requests/ItemsFileUploadRequest.php b/app/Http/Requests/ItemsFileUploadRequest.php new file mode 100644 index 0000000..50d983c --- /dev/null +++ b/app/Http/Requests/ItemsFileUploadRequest.php @@ -0,0 +1,109 @@ +|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 + */ + 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 + */ + public function messages(): array + { + return [ + 'type.in' => '파일 타입은 bending_diagram, specification, certification 중 하나여야 합니다.', + 'file.required' => '파일을 선택해주세요.', + 'file.mimes' => '허용되지 않는 파일 형식입니다.', + 'file.max' => '파일 크기가 너무 큽니다.', + 'certification_end_date.after_or_equal' => '인증 종료일은 시작일 이후여야 합니다.', + ]; + } +} diff --git a/app/Models/Products/Product.php b/app/Models/Products/Product.php index 9f8d74e..33076a9 100644 --- a/app/Models/Products/Product.php +++ b/app/Models/Products/Product.php @@ -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', diff --git a/app/Services/MenuBootstrapService.php b/app/Services/MenuBootstrapService.php index 5348439..2138a7f 100644 --- a/app/Services/MenuBootstrapService.php +++ b/app/Services/MenuBootstrapService.php @@ -60,6 +60,7 @@ public static function cloneGlobalMenusForTenant(int $tenantId): array * 테넌트를 위한 기본 메뉴 구조 생성 (구버전 - 하위 호환성 유지) * * @deprecated Use cloneGlobalMenusForTenant() instead + * * @param int $tenantId 테넌트 ID * @return array 생성된 메뉴 ID 목록 */ diff --git a/app/Swagger/v1/ItemsFileApi.php b/app/Swagger/v1/ItemsFileApi.php new file mode 100644 index 0000000..649e405 --- /dev/null +++ b/app/Swagger/v1/ItemsFileApi.php @@ -0,0 +1,179 @@ +dropIndex('menus_deleted_at_idx'); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_11_17_125437_add_file_fields_to_products_table.php b/database/migrations/2025_11_17_125437_add_file_fields_to_products_table.php new file mode 100644 index 0000000..d5296a4 --- /dev/null +++ b/database/migrations/2025_11_17_125437_add_file_fields_to_products_table.php @@ -0,0 +1,124 @@ +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); + } + } + }); + } +}; diff --git a/database/seeders/GlobalMenuTemplateSeeder.php b/database/seeders/GlobalMenuTemplateSeeder.php index fed8294..38ef304 100644 --- a/database/seeders/GlobalMenuTemplateSeeder.php +++ b/database/seeders/GlobalMenuTemplateSeeder.php @@ -268,4 +268,4 @@ public function run(): void $this->command->info('✅ 글로벌 메뉴 템플릿 생성 완료 (약 60개 메뉴 항목)'); } -} \ No newline at end of file +} diff --git a/routes/api.php b/routes/api.php index a0f8750..70e9066 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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');